feat: declarative project-tool foundation (pull/plan/apply/prune)
Add a server-side, atomic, declarative reconcile loop for a single app —
the foundation of the project-tool design. Developers describe an app's
scripts, routes, triggers, and secret-names in `picloud.toml`, then
`pic pull / plan / apply [--prune]` to converge live state to the manifest.
Server (manager-core):
- apply_service: a pure diff engine (compute_diff) shared by plan and
apply, plus an ApplyService that composes the existing per-repo writes
into ONE Postgres transaction. Identity keys mirror the DB UNIQUE
constraints (script=lower(name); route=(method,host_kind,host,
path_kind,path); trigger=per-kind semantic tuple; secret=name).
Apply takes a per-app advisory lock, recomputes the diff in-tx, applies
scripts -> routes -> triggers, prunes dependents-first, commits, then
refreshes the route table once post-commit.
- apply_api: POST /apps/{id}/plan (AppRead) and /apps/{id}/apply.
Apply requires the per-kind write caps the bundle exercises (all three
when --prune), plus AppSecretsRead when it binds an email trigger.
- tx-accepting repo siblings (insert/update/delete *_tx) so the existing
create/update/delete delegate to one SQL definition each.
- email triggers reference an inbound secret by NAME; the value is
resolved, decrypted (AAD-bound), and re-sealed server-side at apply —
it never travels in the manifest.
CLI (picloud-cli):
- manifest.rs (picloud.toml model), client plan/apply, and the pull/plan/
apply commands. pull rejects filesystem-unsafe script names up front.
Safety properties enforced and tested:
- idempotent: a freshly-pulled manifest re-applies as all-NoOp.
- atomic: a mid-bundle failure rolls back with nothing written.
- routes delete-before-insert so a freed binding is reusable in one apply.
- queue one-consumer invariant held inside the shared tx.
- email triggers are never pruned, and a script that still owns an
email/dead-letter trigger can't be pruned (the FK cascade would destroy
the sealed secret) — refused with a pointer to `pic triggers rm`.
- plan and apply agree on unset email-secret references.
No migration: the existing schema's UNIQUE constraints serve as identity
keys. Groups, env-scoping, and the `enabled` toggle are later milestones.
Tested: manager-core lib (360) + CLI bins (27) + 8 project-tool journeys
(pull/plan/apply/prune/email+queue), all green; clippy -D warnings clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
160
crates/manager-core/src/apply_api.rs
Normal file
160
crates/manager-core/src/apply_api.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
//! Admin HTTP surface for the declarative reconcile engine.
|
||||
//!
|
||||
//! `POST /api/v1/admin/apps/{id}/plan` — diff a desired-state bundle
|
||||
//! against the app's live state and return the plan. Read-only; requires
|
||||
//! `AppRead`. The `apply` route (write path) lands in the next milestone.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{AppId, Principal};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::apply_service::{ApplyError, ApplyReport, ApplyService, Bundle, BundleTrigger, Plan};
|
||||
use crate::authz::{require, AuthzDenied, Capability};
|
||||
|
||||
/// Build the apply/plan router. Mounted under `/api/v1/admin`.
|
||||
pub fn apply_router(service: ApplyService) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{id}/plan", post(plan_handler))
|
||||
.route("/apps/{id}/apply", post(apply_handler))
|
||||
.with_state(service)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ApplyRequest {
|
||||
pub bundle: Bundle,
|
||||
#[serde(default)]
|
||||
pub prune: bool,
|
||||
}
|
||||
|
||||
async fn apply_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(req): Json<ApplyRequest>,
|
||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
// Read is always needed; write caps are required for the resource kinds
|
||||
// the bundle touches — and for ALL kinds when `prune` is set, since
|
||||
// pruning deletes resources whose bundle section is empty (and a script
|
||||
// delete cascades its routes/triggers).
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
if req.prune || !req.bundle.scripts.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteScript(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if req.prune || !req.bundle.routes.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteRoute(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if req.prune || !req.bundle.triggers.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
// Email triggers resolve and decrypt a stored secret by name server-side,
|
||||
// which the secrets API guards with `AppSecretsRead`. Require it here too
|
||||
// so apply can't bind a secret a principal couldn't otherwise read — the
|
||||
// caps aren't strictly nested on the API-key scope path.
|
||||
if req.bundle.triggers.iter().any(BundleTrigger::is_email) {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppSecretsRead(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
let report = svc
|
||||
.apply(app_id, &req.bundle, req.prune, principal.user_id)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
async fn plan_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(bundle): Json<Bundle>,
|
||||
) -> Result<Json<Plan>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
// NOTE: the returned `Plan` discloses live secret NAMES (not values). That
|
||||
// is safe today only because `AppRead` and `AppSecretsRead` are co-granted
|
||||
// at every tier (same `script:read` scope, both in the viewer role). If a
|
||||
// future authz split puts `AppSecretsRead` on its own tier, this handler
|
||||
// must additionally require it — otherwise it leaks names a principal
|
||||
// couldn't enumerate via the secrets API.
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let plan = svc.plan(app_id, &bundle).await?;
|
||||
Ok(Json(plan))
|
||||
}
|
||||
|
||||
/// Resolve a slug-or-id path param to an `AppId`, mapping miss → 404.
|
||||
/// Mirrors the `triggers_api` helper of the same shape.
|
||||
async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApplyError> {
|
||||
crate::app_repo::resolve_app(apps, ident)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.map(|l| l.app.id)
|
||||
.ok_or_else(|| ApplyError::AppNotFound(ident.to_string()))
|
||||
}
|
||||
|
||||
fn map_authz(denied: AuthzDenied) -> ApplyError {
|
||||
match denied {
|
||||
AuthzDenied::Denied => ApplyError::Forbidden,
|
||||
AuthzDenied::Repo(e) => ApplyError::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApplyError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
|
||||
Self::Invalid(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "apply authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "apply backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
1880
crates/manager-core/src/apply_service.rs
Normal file
1880
crates/manager-core/src/apply_service.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ pub mod app_user_repo;
|
||||
pub mod app_user_role_repo;
|
||||
pub mod app_user_session_repo;
|
||||
pub mod app_user_verification_repo;
|
||||
pub mod apply_api;
|
||||
pub mod apply_service;
|
||||
pub mod apps_api;
|
||||
pub mod auth;
|
||||
pub mod auth_api;
|
||||
@@ -128,6 +130,8 @@ pub use app_user_session_repo::{
|
||||
pub use app_user_verification_repo::{
|
||||
AppUserVerificationRepo, AppUserVerificationRepoError, PostgresAppUserVerificationRepo,
|
||||
};
|
||||
pub use apply_api::apply_router;
|
||||
pub use apply_service::{ApplyError, ApplyService, Bundle, Plan};
|
||||
pub use apps_api::{apps_router, AppsState};
|
||||
pub use auth_api::auth_router;
|
||||
pub use auth_bootstrap::{
|
||||
|
||||
@@ -273,42 +273,8 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
}
|
||||
|
||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
||||
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"INSERT INTO scripts ( \
|
||||
app_id, name, description, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox \
|
||||
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8) \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(&input.name)
|
||||
.bind(input.description.as_deref())
|
||||
.bind(&input.source)
|
||||
.bind(input.kind.as_str())
|
||||
.bind(input.timeout_seconds)
|
||||
.bind(input.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
|
||||
let script: Script = match res {
|
||||
Ok(row) => row.into(),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"a script named {:?} already exists in this app",
|
||||
input.name
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Dep-graph: write any literal-path imports declared in the
|
||||
// source. Unresolved names (the referenced module doesn't
|
||||
// exist yet) are silently skipped — best-effort.
|
||||
replace_imports_tx(&mut tx, script.id, script.app_id, &input.imports).await?;
|
||||
let script = insert_script_tx(&mut tx, &input).await?;
|
||||
tx.commit().await?;
|
||||
Ok(script)
|
||||
}
|
||||
@@ -318,62 +284,8 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
id: ScriptId,
|
||||
patch: ScriptPatch,
|
||||
) -> Result<Script, ScriptRepositoryError> {
|
||||
// COALESCE-based partial update: `NULL` parameters leave columns
|
||||
// untouched. Description is double-Optioned so callers can
|
||||
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
||||
// Sandbox is replaced wholesale when present; per-field merging
|
||||
// happens in the API layer (clearer semantics for a "PUT a new
|
||||
// sandbox config" call). app_id is immutable — moving a script
|
||||
// to another app is a copy-and-delete, not an in-place edit.
|
||||
let sandbox_json = patch
|
||||
.sandbox
|
||||
.as_ref()
|
||||
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"UPDATE scripts SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
source = COALESCE($5, source), \
|
||||
timeout_seconds = COALESCE($6, timeout_seconds), \
|
||||
memory_limit_mb = COALESCE($7, memory_limit_mb), \
|
||||
sandbox = COALESCE($8, sandbox), \
|
||||
kind = COALESCE($9, kind), \
|
||||
version = version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(patch.name.as_deref())
|
||||
.bind(patch.description.is_some())
|
||||
.bind(patch.description.as_ref().and_then(|d| d.as_deref()))
|
||||
.bind(patch.source.as_deref())
|
||||
.bind(patch.timeout_seconds)
|
||||
.bind(patch.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.bind(patch.kind.map(ScriptKind::as_str))
|
||||
.fetch_optional(&mut *tx)
|
||||
.await;
|
||||
|
||||
let script: Script = match res {
|
||||
Ok(Some(row)) => row.into(),
|
||||
Ok(None) => return Err(ScriptRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(
|
||||
"a script with that name already exists in this app".into(),
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Replace imports only when the caller has a fresh list (i.e.
|
||||
// the source actually changed and the validator re-extracted
|
||||
// imports). A name-only or description-only edit leaves the
|
||||
// dep graph alone.
|
||||
if let Some(imports) = patch.imports.as_deref() {
|
||||
replace_imports_tx(&mut tx, script.id, script.app_id, imports).await?;
|
||||
}
|
||||
let script = update_script_tx(&mut tx, id, &patch).await?;
|
||||
tx.commit().await?;
|
||||
Ok(script)
|
||||
}
|
||||
@@ -469,6 +381,114 @@ async fn replace_imports_tx(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a script within an existing transaction — the declarative
|
||||
/// `apply` engine composes scripts + routes + triggers into one tx.
|
||||
/// Mirrors `create` minus the `begin`/`commit`.
|
||||
pub(crate) async fn insert_script_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
input: &NewScript,
|
||||
) -> Result<Script, ScriptRepositoryError> {
|
||||
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"INSERT INTO scripts ( \
|
||||
app_id, name, description, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox \
|
||||
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8) \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(&input.name)
|
||||
.bind(input.description.as_deref())
|
||||
.bind(&input.source)
|
||||
.bind(input.kind.as_str())
|
||||
.bind(input.timeout_seconds)
|
||||
.bind(input.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
let script: Script = match res {
|
||||
Ok(row) => row.into(),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"a script named {:?} already exists in this app",
|
||||
input.name
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
replace_imports_tx(tx, script.id, script.app_id, &input.imports).await?;
|
||||
Ok(script)
|
||||
}
|
||||
|
||||
/// Update a script within an existing transaction. Mirrors `update`
|
||||
/// minus the `begin`/`commit`.
|
||||
pub(crate) async fn update_script_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: ScriptId,
|
||||
patch: &ScriptPatch,
|
||||
) -> Result<Script, ScriptRepositoryError> {
|
||||
let sandbox_json = patch
|
||||
.sandbox
|
||||
.as_ref()
|
||||
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"UPDATE scripts SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
source = COALESCE($5, source), \
|
||||
timeout_seconds = COALESCE($6, timeout_seconds), \
|
||||
memory_limit_mb = COALESCE($7, memory_limit_mb), \
|
||||
sandbox = COALESCE($8, sandbox), \
|
||||
kind = COALESCE($9, kind), \
|
||||
version = version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(patch.name.as_deref())
|
||||
.bind(patch.description.is_some())
|
||||
.bind(patch.description.as_ref().and_then(|d| d.as_deref()))
|
||||
.bind(patch.source.as_deref())
|
||||
.bind(patch.timeout_seconds)
|
||||
.bind(patch.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.bind(patch.kind.map(ScriptKind::as_str))
|
||||
.fetch_optional(&mut **tx)
|
||||
.await;
|
||||
let script: Script = match res {
|
||||
Ok(Some(row)) => row.into(),
|
||||
Ok(None) => return Err(ScriptRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(
|
||||
"a script with that name already exists in this app".into(),
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if let Some(imports) = patch.imports.as_deref() {
|
||||
replace_imports_tx(tx, script.id, script.app_id, imports).await?;
|
||||
}
|
||||
Ok(script)
|
||||
}
|
||||
|
||||
/// Delete a script within an existing transaction (its routes/triggers
|
||||
/// cascade via their FKs). Mirrors `delete` minus the pool.
|
||||
pub(crate) async fn delete_script_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: ScriptId,
|
||||
) -> Result<(), ScriptRepositoryError> {
|
||||
let res = sqlx::query("DELETE FROM scripts WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(ScriptRepositoryError::NotFound(id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Row shape mirroring the `scripts` table for sqlx FromRow.
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ScriptRow {
|
||||
|
||||
@@ -426,7 +426,7 @@ fn compile_route(r: &Route) -> Result<CompiledRoute, pattern::ParseError> {
|
||||
/// Validate that a new route's (host_kind, host) is consistent with at
|
||||
/// least one of the parent app's domain claims. `HostKind::Any` is
|
||||
/// always permitted — it catches every host the app already owns.
|
||||
async fn validate_route_host_against_app(
|
||||
pub(crate) async fn validate_route_host_against_app(
|
||||
domains: &dyn AppDomainRepository,
|
||||
app_id: AppId,
|
||||
host_kind: HostKind,
|
||||
|
||||
@@ -111,36 +111,10 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
}
|
||||
|
||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, RouteRow>(
|
||||
"INSERT INTO routes ( \
|
||||
app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
|
||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(input.script_id.into_inner())
|
||||
.bind(host_kind_str(input.host_kind))
|
||||
.bind(&input.host)
|
||||
.bind(input.host_param_name.as_deref())
|
||||
.bind(path_kind_str(input.path_kind))
|
||||
.bind(&input.path)
|
||||
.bind(input.method.as_deref())
|
||||
.bind(input.dispatch_mode.as_str())
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
ScriptRepositoryError::Conflict("a route with this binding already exists".into()),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
Err(ScriptRepositoryError::NotFound(input.script_id))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let route = insert_route_tx(&mut tx, &input).await?;
|
||||
tx.commit().await?;
|
||||
Ok(route)
|
||||
}
|
||||
|
||||
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError> {
|
||||
@@ -189,6 +163,56 @@ const fn path_kind_str(k: PathKind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a route within an existing transaction (declarative apply
|
||||
/// composes scripts + routes + triggers into one tx). Mirrors `create`
|
||||
/// minus the `begin`/`commit`.
|
||||
pub(crate) async fn insert_route_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
input: &NewRoute,
|
||||
) -> Result<Route, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, RouteRow>(
|
||||
"INSERT INTO routes ( \
|
||||
app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
|
||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(input.script_id.into_inner())
|
||||
.bind(host_kind_str(input.host_kind))
|
||||
.bind(&input.host)
|
||||
.bind(input.host_param_name.as_deref())
|
||||
.bind(path_kind_str(input.path_kind))
|
||||
.bind(&input.path)
|
||||
.bind(input.method.as_deref())
|
||||
.bind(input.dispatch_mode.as_str())
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
ScriptRepositoryError::Conflict("a route with this binding already exists".into()),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
Err(ScriptRepositoryError::NotFound(input.script_id))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a route by id within an existing transaction.
|
||||
pub(crate) async fn delete_route_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
route_id: Uuid,
|
||||
) -> Result<(), ScriptRepositoryError> {
|
||||
sqlx::query("DELETE FROM routes WHERE id = $1")
|
||||
.bind(route_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RouteRow {
|
||||
id: Uuid,
|
||||
|
||||
@@ -502,6 +502,220 @@ impl PostgresTriggerRepo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a trigger (parent row + per-kind detail) within an existing
|
||||
/// transaction — used by the declarative `apply` engine. Supports the
|
||||
/// five settled kinds; `email`/`queue`/`dead_letter` have their own
|
||||
/// create paths and are rejected here.
|
||||
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||
pub(crate) async fn insert_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
app_id: AppId,
|
||||
script_id: ScriptId,
|
||||
registered_by: AdminUserId,
|
||||
dispatch_mode: TriggerDispatchMode,
|
||||
retry_max_attempts: u32,
|
||||
retry_backoff: BackoffShape,
|
||||
retry_base_ms: u32,
|
||||
details: &TriggerDetails,
|
||||
) -> Result<TriggerId, TriggerRepoError> {
|
||||
let kind = match details {
|
||||
TriggerDetails::Kv { .. } => "kv",
|
||||
TriggerDetails::Docs { .. } => "docs",
|
||||
TriggerDetails::Files { .. } => "files",
|
||||
TriggerDetails::Cron { .. } => "cron",
|
||||
TriggerDetails::Pubsub { .. } => "pubsub",
|
||||
TriggerDetails::Queue { .. } => "queue",
|
||||
TriggerDetails::DeadLetter { .. } | TriggerDetails::Email { .. } => {
|
||||
return Err(TriggerRepoError::Invalid(
|
||||
"trigger kind not supported by declarative apply".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
// Queue: enforce the one-consumer-per-(app_id, queue_name) invariant —
|
||||
// the same advisory-lock + existence guard the interactive
|
||||
// `create_queue_trigger` uses. Without this, a concurrent apply +
|
||||
// interactive create on disjoint locks could double-register a queue
|
||||
// consumer (there is no DB unique constraint backing the invariant).
|
||||
if let TriggerDetails::Queue { queue_name, .. } = details {
|
||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||
.bind(advisory_lock_key(app_id, queue_name))
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
let existing: Option<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT t.id FROM triggers t \
|
||||
JOIN queue_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.app_id = $1 AND t.kind = 'queue' AND d.queue_name = $2",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(queue_name)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
if existing.is_some() {
|
||||
return Err(TriggerRepoError::Invalid(format!(
|
||||
"queue '{queue_name}' already has a consumer trigger; remove the existing one first"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8) RETURNING id",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
.bind(kind)
|
||||
.bind(dispatch_mode.as_str())
|
||||
.bind(i32::try_from(retry_max_attempts).unwrap_or(3))
|
||||
.bind(retry_backoff.as_str())
|
||||
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
|
||||
.bind(registered_by.into_inner())
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
let tid = row.0;
|
||||
|
||||
match details {
|
||||
TriggerDetails::Kv {
|
||||
collection_glob,
|
||||
ops,
|
||||
} => {
|
||||
let ops_str: Vec<String> = ops.iter().map(|o| o.as_str().to_string()).collect();
|
||||
sqlx::query(
|
||||
"INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(collection_glob)
|
||||
.bind(&ops_str)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::Docs {
|
||||
collection_glob,
|
||||
ops,
|
||||
} => {
|
||||
let ops_str: Vec<String> = ops.iter().map(|o| o.as_str().to_string()).collect();
|
||||
sqlx::query(
|
||||
"INSERT INTO docs_trigger_details (trigger_id, collection_glob, ops) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(collection_glob)
|
||||
.bind(&ops_str)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::Files {
|
||||
collection_glob,
|
||||
ops,
|
||||
} => {
|
||||
let ops_str: Vec<String> = ops.iter().map(|o| o.as_str().to_string()).collect();
|
||||
sqlx::query(
|
||||
"INSERT INTO files_trigger_details (trigger_id, collection_glob, ops) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(collection_glob)
|
||||
.bind(&ops_str)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::Cron {
|
||||
schedule, timezone, ..
|
||||
} => {
|
||||
sqlx::query(
|
||||
"INSERT INTO cron_trigger_details (trigger_id, schedule, timezone) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(schedule)
|
||||
.bind(timezone)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::Pubsub { topic_pattern } => {
|
||||
sqlx::query(
|
||||
"INSERT INTO pubsub_trigger_details (trigger_id, topic_pattern) VALUES ($1, $2)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(topic_pattern)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::Queue {
|
||||
queue_name,
|
||||
visibility_timeout_secs,
|
||||
..
|
||||
} => {
|
||||
sqlx::query(
|
||||
"INSERT INTO queue_trigger_details \
|
||||
(trigger_id, queue_name, visibility_timeout_secs) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(queue_name)
|
||||
.bind(i32::try_from(*visibility_timeout_secs).unwrap_or(30))
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::DeadLetter { .. } | TriggerDetails::Email { .. } => {
|
||||
unreachable!("guarded above")
|
||||
}
|
||||
}
|
||||
Ok(tid.into())
|
||||
}
|
||||
|
||||
/// Insert an email trigger within a transaction. The inbound HMAC secret
|
||||
/// is sealed by the apply engine (resolved from the app's secret store);
|
||||
/// this writes the ciphertext. Parent retry settings match the
|
||||
/// interactive `create_email_trigger` path (async, 3, exponential, 1000).
|
||||
pub(crate) async fn insert_email_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
app_id: AppId,
|
||||
script_id: ScriptId,
|
||||
registered_by: AdminUserId,
|
||||
inbound_secret_encrypted: &[u8],
|
||||
inbound_secret_nonce: &[u8],
|
||||
) -> Result<TriggerId, TriggerRepoError> {
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) RETURNING id",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
.bind(registered_by.into_inner())
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO email_trigger_details \
|
||||
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(row.0)
|
||||
.bind(inbound_secret_encrypted)
|
||||
.bind(inbound_secret_nonce)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(row.0.into())
|
||||
}
|
||||
|
||||
/// Delete a trigger by id within an existing transaction (its detail row
|
||||
/// cascades via the FK). Used by `apply --prune`.
|
||||
pub(crate) async fn delete_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: TriggerId,
|
||||
) -> Result<(), TriggerRepoError> {
|
||||
sqlx::query("DELETE FROM triggers WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TriggerRepo for PostgresTriggerRepo {
|
||||
async fn create_kv_trigger(
|
||||
|
||||
@@ -900,6 +900,36 @@ impl Client {
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id_or_slug}/plan` — diff a desired-state
|
||||
/// bundle against the app's live state. Read-only.
|
||||
pub async fn plan(&self, app: &str, bundle: &serde_json::Value) -> Result<PlanDto> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/plan"))
|
||||
.json(bundle)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id_or_slug}/apply` — reconcile the live
|
||||
/// app to the bundle in one transaction.
|
||||
pub async fn apply(
|
||||
&self,
|
||||
app: &str,
|
||||
bundle: &serde_json::Value,
|
||||
prune: bool,
|
||||
) -> Result<ApplyReportDto> {
|
||||
let app = seg(app);
|
||||
let body = serde_json::json!({ "bundle": bundle, "prune": prune });
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/apply"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
|
||||
@@ -924,6 +954,50 @@ pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<Log
|
||||
|
||||
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||
|
||||
/// Response of `POST .../plan`: per-resource diffs grouped by kind.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PlanDto {
|
||||
#[serde(default)]
|
||||
pub scripts: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub routes: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub triggers: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChangeDto {
|
||||
pub op: String,
|
||||
pub key: String,
|
||||
#[serde(default)]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../apply`: counts of what changed.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct ApplyReportDto {
|
||||
#[serde(default)]
|
||||
pub scripts_created: u32,
|
||||
#[serde(default)]
|
||||
pub scripts_updated: u32,
|
||||
#[serde(default)]
|
||||
pub scripts_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub routes_created: u32,
|
||||
#[serde(default)]
|
||||
pub routes_updated: u32,
|
||||
#[serde(default)]
|
||||
pub routes_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub triggers_created: u32,
|
||||
#[serde(default)]
|
||||
pub triggers_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthMeDto {
|
||||
|
||||
52
crates/picloud-cli/src/cmds/apply.rs
Normal file
52
crates/picloud-cli/src/cmds/apply.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! `pic apply [--file picloud.toml]` — reconcile the live app to the
|
||||
//! manifest's desired state in one server-side transaction. Additive in
|
||||
//! this milestone (creates + updates); pruning of stale resources lands
|
||||
//! next.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::cmds::plan::build_bundle;
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
pub async fn run(manifest_path: &Path, prune: bool, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let manifest = Manifest::load(manifest_path)?;
|
||||
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
|
||||
let report = client.apply(&manifest.app.slug, &bundle, prune).await?;
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("app", manifest.app.slug.clone())
|
||||
.field(
|
||||
"scripts",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.scripts_created, report.scripts_updated, report.scripts_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"routes",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.routes_created, report.routes_updated, report.routes_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"triggers",
|
||||
format!("+{} -{}", report.triggers_created, report.triggers_deleted),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
}
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod admins;
|
||||
pub mod api_keys;
|
||||
pub mod apply;
|
||||
pub mod apps;
|
||||
pub mod apps_domains;
|
||||
pub mod dead_letters;
|
||||
@@ -9,6 +10,8 @@ pub mod login;
|
||||
pub mod logout;
|
||||
pub mod logs;
|
||||
pub mod members;
|
||||
pub mod plan;
|
||||
pub mod pull;
|
||||
pub mod queues;
|
||||
pub mod routes;
|
||||
pub mod scripts;
|
||||
|
||||
123
crates/picloud-cli/src/cmds/plan.rs
Normal file
123
crates/picloud-cli/src/cmds/plan.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
//! `pic plan [--file picloud.toml]` — diff the manifest's desired state
|
||||
//! against the live app and print the per-resource changes. Read-only:
|
||||
//! builds a bundle (manifest + script sources) and POSTs it to the
|
||||
//! server's plan endpoint, which computes the diff.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::client::{ChangeDto, Client, PlanDto};
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn run(manifest_path: &Path, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let manifest = Manifest::load(manifest_path)?;
|
||||
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
|
||||
let plan = client.plan(&manifest.app.slug, &bundle).await?;
|
||||
render(&plan, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Assemble the wire bundle: scripts carry inlined source (read from
|
||||
/// their `file`), routes pass through, triggers flatten into a tagged
|
||||
/// array, secrets are names only.
|
||||
pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
let mut scripts = Vec::with_capacity(manifest.scripts.len());
|
||||
for s in &manifest.scripts {
|
||||
let source = std::fs::read_to_string(base_dir.join(&s.file))
|
||||
.with_context(|| format!("reading script source {}", s.file))?;
|
||||
let mut obj = Map::new();
|
||||
obj.insert("name".into(), json!(s.name));
|
||||
obj.insert("source".into(), json!(source));
|
||||
obj.insert("kind".into(), serde_json::to_value(s.kind)?);
|
||||
if let Some(d) = &s.description {
|
||||
obj.insert("description".into(), json!(d));
|
||||
}
|
||||
if let Some(t) = s.timeout_seconds {
|
||||
obj.insert("timeout_seconds".into(), json!(t));
|
||||
}
|
||||
if let Some(m) = s.memory_limit_mb {
|
||||
obj.insert("memory_limit_mb".into(), json!(m));
|
||||
}
|
||||
if let Some(sb) = &s.sandbox {
|
||||
obj.insert("sandbox".into(), serde_json::to_value(sb)?);
|
||||
}
|
||||
scripts.push(Value::Object(obj));
|
||||
}
|
||||
|
||||
let routes = manifest
|
||||
.routes
|
||||
.iter()
|
||||
.map(serde_json::to_value)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let t = &manifest.triggers;
|
||||
let mut triggers = Vec::new();
|
||||
for s in &t.kv {
|
||||
triggers.push(tagged("kv", s)?);
|
||||
}
|
||||
for s in &t.docs {
|
||||
triggers.push(tagged("docs", s)?);
|
||||
}
|
||||
for s in &t.files {
|
||||
triggers.push(tagged("files", s)?);
|
||||
}
|
||||
for s in &t.cron {
|
||||
triggers.push(tagged("cron", s)?);
|
||||
}
|
||||
for s in &t.pubsub {
|
||||
triggers.push(tagged("pubsub", s)?);
|
||||
}
|
||||
for s in &t.email {
|
||||
triggers.push(tagged("email", s)?);
|
||||
}
|
||||
for s in &t.queue {
|
||||
triggers.push(tagged("queue", s)?);
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"scripts": scripts,
|
||||
"routes": routes,
|
||||
"triggers": triggers,
|
||||
"secrets": manifest.secrets.names,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Serialize a trigger spec and stamp its `kind` discriminator.
|
||||
fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
|
||||
let mut v = serde_json::to_value(spec)?;
|
||||
if let Value::Object(map) = &mut v {
|
||||
map.insert("kind".into(), Value::String(kind.to_string()));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 4] = [
|
||||
("script", &plan.scripts),
|
||||
("route", &plan.routes),
|
||||
("trigger", &plan.triggers),
|
||||
("secret", &plan.secrets),
|
||||
];
|
||||
for (kind, changes) in groups {
|
||||
for c in changes {
|
||||
table.row([
|
||||
kind.to_string(),
|
||||
c.op.clone(),
|
||||
c.key.clone(),
|
||||
c.detail.clone().unwrap_or_default(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
table.print(mode);
|
||||
}
|
||||
285
crates/picloud-cli/src/cmds/pull.rs
Normal file
285
crates/picloud-cli/src/cmds/pull.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
//! `pic pull <app> [--dir .]` — export an app's current server state into
|
||||
//! a `picloud.toml` manifest plus `scripts/<name>.rhai` source files, for
|
||||
//! declarative management with `pic plan` / `pic apply`.
|
||||
//!
|
||||
//! Read-only: issues `GET`s only and writes local files. Every trigger
|
||||
//! kind is exported except `email` — the server stores the *sealed secret
|
||||
//! value*, not the secret name, so the manifest's `inbound_secret_ref`
|
||||
//! can't be reconstructed (email triggers must be set up by hand).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::manifest::{
|
||||
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
|
||||
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, PubsubTriggerSpec,
|
||||
QueueTriggerSpec, MANIFEST_FILE,
|
||||
};
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
// One GET per resource kind (routes are per-script, below).
|
||||
let app = client.apps_get(app_ident).await?;
|
||||
let scripts = client.scripts_list_by_app(app_ident).await?;
|
||||
let triggers = client.triggers_list(app_ident).await?.triggers;
|
||||
let secrets = client.secrets_list(app_ident).await?.secrets;
|
||||
|
||||
let name_by_id: HashMap<ScriptId, String> =
|
||||
scripts.iter().map(|s| (s.id, s.name.clone())).collect();
|
||||
|
||||
// Routes: the admin surface lists them per script.
|
||||
let mut routes = Vec::new();
|
||||
for s in &scripts {
|
||||
for r in client.routes_list_for_script(&s.id.to_string()).await? {
|
||||
routes.push(ManifestRoute {
|
||||
script: s.name.clone(),
|
||||
method: r.method,
|
||||
host_kind: r.host_kind,
|
||||
host: r.host,
|
||||
host_param_name: r.host_param_name,
|
||||
path_kind: r.path_kind,
|
||||
path: r.path,
|
||||
dispatch_mode: r.dispatch_mode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The server does not constrain script names to a filesystem-safe
|
||||
// charset, so a name containing a path separator or `..` would let `pull`
|
||||
// write outside the project dir. Validate ALL names up front, before any
|
||||
// file is written, so a single bad name can't leave a half-written dir.
|
||||
// Reject rather than sanitize: a silent rename would desync the manifest
|
||||
// `name` from its `file`.
|
||||
for s in &scripts {
|
||||
if !is_safe_filename(&s.name) {
|
||||
anyhow::bail!(
|
||||
"script name {:?} is not filesystem-safe (contains a path \
|
||||
separator, `..`, or a leading dot); cannot pull",
|
||||
s.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scripts: write each source next to the manifest and record a path ref.
|
||||
let scripts_dir = dir.join("scripts");
|
||||
std::fs::create_dir_all(&scripts_dir)
|
||||
.with_context(|| format!("creating {}", scripts_dir.display()))?;
|
||||
let mut manifest_scripts = Vec::with_capacity(scripts.len());
|
||||
for s in &scripts {
|
||||
let rel = format!("scripts/{}.rhai", s.name);
|
||||
std::fs::write(dir.join(&rel), &s.source).with_context(|| format!("writing {rel}"))?;
|
||||
manifest_scripts.push(ManifestScript {
|
||||
name: s.name.clone(),
|
||||
file: rel,
|
||||
kind: s.kind,
|
||||
description: s.description.clone(),
|
||||
timeout_seconds: i32::try_from(s.timeout_seconds).ok(),
|
||||
memory_limit_mb: i32::try_from(s.memory_limit_mb).ok(),
|
||||
sandbox: if s.sandbox.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s.sandbox)
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Triggers: map the five settled kinds; warn + skip the rest.
|
||||
let mut manifest_triggers = ManifestTriggers::default();
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
for t in &triggers {
|
||||
let script = name_by_id
|
||||
.get(&t.script_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| t.script_id.to_string());
|
||||
let dispatch_mode = DispatchMode::from_wire(&t.dispatch_mode);
|
||||
let retry_max_attempts = Some(t.retry_max_attempts);
|
||||
match t.kind.as_str() {
|
||||
"kv" => {
|
||||
let d: CollectionDetails<KvEventOp> = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.kv.push(KvTriggerSpec {
|
||||
script,
|
||||
collection_glob: d.collection_glob,
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"docs" => {
|
||||
let d: CollectionDetails<DocsEventOp> = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.docs.push(DocsTriggerSpec {
|
||||
script,
|
||||
collection_glob: d.collection_glob,
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"files" => {
|
||||
let d: CollectionDetails<FilesEventOp> = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.files.push(FilesTriggerSpec {
|
||||
script,
|
||||
collection_glob: d.collection_glob,
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"cron" => {
|
||||
let d: CronDetails = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.cron.push(CronTriggerSpec {
|
||||
script,
|
||||
schedule: d.schedule,
|
||||
timezone: d.timezone,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"pubsub" => {
|
||||
let d: PubsubDetails = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.pubsub.push(PubsubTriggerSpec {
|
||||
script,
|
||||
topic_pattern: d.topic_pattern,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"queue" => {
|
||||
let d: QueueDetails = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.queue.push(QueueTriggerSpec {
|
||||
script,
|
||||
queue_name: d.queue_name,
|
||||
visibility_timeout_secs: Some(d.visibility_timeout_secs),
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
// `email` is skipped: the server stores the sealed secret value,
|
||||
// not the secret *name*, so the manifest's `inbound_secret_ref`
|
||||
// can't be reconstructed — set it up by hand.
|
||||
other => skipped.push(format!("{other} ({})", t.id)),
|
||||
}
|
||||
}
|
||||
for s in &skipped {
|
||||
eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
|
||||
}
|
||||
|
||||
let manifest = Manifest {
|
||||
app: ManifestApp {
|
||||
slug: app.app.slug.clone(),
|
||||
name: app.app.name.clone(),
|
||||
description: app.app.description.clone(),
|
||||
},
|
||||
scripts: manifest_scripts,
|
||||
routes,
|
||||
triggers: manifest_triggers,
|
||||
secrets: ManifestSecrets {
|
||||
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
||||
},
|
||||
};
|
||||
|
||||
let manifest_path = dir.join(MANIFEST_FILE);
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
.with_context(|| format!("writing {}", manifest_path.display()))?;
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("manifest", manifest_path.display().to_string())
|
||||
.field("app", manifest.app.slug.clone())
|
||||
.field("scripts", manifest.scripts.len().to_string())
|
||||
.field("routes", manifest.routes.len().to_string())
|
||||
.field("triggers", trigger_count(&manifest.triggers).to_string())
|
||||
.field("secrets", manifest.secrets.names.len().to_string());
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trigger_count(t: &ManifestTriggers) -> usize {
|
||||
t.kv.len() + t.docs.len() + t.files.len() + t.cron.len() + t.pubsub.len() + t.queue.len()
|
||||
}
|
||||
|
||||
/// True if `name` is safe to use as a single path component in `scripts/`.
|
||||
/// Rejects empty names, path separators, `.`/`..`, and leading dots.
|
||||
fn is_safe_filename(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& !name.starts_with('.')
|
||||
&& !name.contains('/')
|
||||
&& !name.contains('\\')
|
||||
&& name != ".."
|
||||
&& !name.contains('\0')
|
||||
}
|
||||
|
||||
/// Deserialize a trigger's `details` JSON, attributing failures to the kind.
|
||||
/// The server tags details with a `kind` field which these structs ignore.
|
||||
fn decode_details<T: for<'de> Deserialize<'de>>(
|
||||
details: &serde_json::Value,
|
||||
kind: &str,
|
||||
) -> Result<T> {
|
||||
serde_json::from_value(details.clone())
|
||||
.with_context(|| format!("decoding {kind} trigger details"))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CollectionDetails<Op> {
|
||||
collection_glob: String,
|
||||
#[serde(default = "Vec::new")]
|
||||
ops: Vec<Op>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CronDetails {
|
||||
schedule: String,
|
||||
#[serde(default = "default_timezone")]
|
||||
timezone: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PubsubDetails {
|
||||
topic_pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QueueDetails {
|
||||
queue_name: String,
|
||||
visibility_timeout_secs: u32,
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
"UTC".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_safe_filename;
|
||||
|
||||
#[test]
|
||||
fn rejects_traversal_and_separators() {
|
||||
for bad in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"../etc/passwd",
|
||||
"a/b",
|
||||
"a\\b",
|
||||
".hidden",
|
||||
"with\0nul",
|
||||
] {
|
||||
assert!(!is_safe_filename(bad), "expected {bad:?} to be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_normal_names() {
|
||||
for ok in ["create-post", "nightly_digest", "Greet", "x", "a.b"] {
|
||||
assert!(is_safe_filename(ok), "expected {ok:?} to be accepted");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
mod client;
|
||||
mod cmds;
|
||||
mod config;
|
||||
mod manifest;
|
||||
mod output;
|
||||
|
||||
use crate::output::OutputMode;
|
||||
@@ -156,6 +157,46 @@ enum Cmd {
|
||||
#[command(subcommand)]
|
||||
cmd: KvCmd,
|
||||
},
|
||||
|
||||
/// Reconcile the live app to a `picloud.toml` manifest in one
|
||||
/// transaction (additive: creates + updates).
|
||||
Apply(ApplyArgs),
|
||||
|
||||
/// Diff a `picloud.toml` manifest against the live app and print the
|
||||
/// changes (create / update / no-op / delete). Read-only.
|
||||
Plan(PlanArgs),
|
||||
|
||||
/// Export an app's current server state into a `picloud.toml` manifest
|
||||
/// (+ `scripts/<name>.rhai` sources) for declarative management with
|
||||
/// `pic plan` / `pic apply`.
|
||||
Pull(PullArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct ApplyArgs {
|
||||
/// Path to the manifest.
|
||||
#[arg(long, default_value = "picloud.toml")]
|
||||
file: PathBuf,
|
||||
/// Delete live scripts/routes/triggers absent from the manifest.
|
||||
/// Secrets are never pruned.
|
||||
#[arg(long)]
|
||||
prune: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct PlanArgs {
|
||||
/// Path to the manifest.
|
||||
#[arg(long, default_value = "picloud.toml")]
|
||||
file: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct PullArgs {
|
||||
/// App slug or id to export.
|
||||
app: String,
|
||||
/// Directory to write `picloud.toml` + `scripts/` into.
|
||||
#[arg(long, default_value = ".")]
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -1045,6 +1086,9 @@ async fn main() -> ExitCode {
|
||||
}
|
||||
Cmd::Logout => cmds::logout::run().await,
|
||||
Cmd::Whoami => cmds::whoami::run(mode).await,
|
||||
Cmd::Apply(args) => cmds::apply::run(&args.file, args.prune, mode).await,
|
||||
Cmd::Plan(args) => cmds::plan::run(&args.file, mode).await,
|
||||
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, mode).await,
|
||||
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
|
||||
Cmd::Apps {
|
||||
cmd:
|
||||
|
||||
362
crates/picloud-cli/src/manifest.rs
Normal file
362
crates/picloud-cli/src/manifest.rs
Normal file
@@ -0,0 +1,362 @@
|
||||
//! Declarative project manifest (`picloud.toml`).
|
||||
//!
|
||||
//! One manifest describes the desired state of a **single app** — its
|
||||
//! scripts, routes, triggers, and the *names* of the secrets it expects
|
||||
//! (values are pushed out-of-band via `pic secret set`, never committed).
|
||||
//!
|
||||
//! This is the foundation of the declarative project tool (`pic pull` /
|
||||
//! `pic plan` / `pic apply`). The types deliberately reuse `picloud_shared`
|
||||
//! enums (`HostKind`, `PathKind`, `DispatchMode`, `ScriptKind`,
|
||||
//! `ScriptSandbox`, the event-op enums) so the manifest's wire shape stays
|
||||
//! identical to the admin API — the CLI never depends on `manager-core`.
|
||||
//!
|
||||
//! All eight trigger kinds are representable except `dead_letter` (not
|
||||
//! exposed declaratively). `email` triggers carry an `inbound_secret_ref`
|
||||
//! (a secret name) resolved server-side at apply.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use picloud_shared::{
|
||||
DispatchMode, DocsEventOp, FilesEventOp, HostKind, KvEventOp, PathKind, ScriptKind,
|
||||
ScriptSandbox,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Conventional manifest filename at a project root.
|
||||
pub const MANIFEST_FILE: &str = "picloud.toml";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub app: ManifestApp,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub scripts: Vec<ManifestScript>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub routes: Vec<ManifestRoute>,
|
||||
#[serde(default, skip_serializing_if = "ManifestTriggers::is_empty")]
|
||||
pub triggers: ManifestTriggers,
|
||||
#[serde(default, skip_serializing_if = "ManifestSecrets::is_empty")]
|
||||
pub secrets: ManifestSecrets,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// Parse a manifest from TOML text.
|
||||
pub fn parse(text: &str) -> Result<Self> {
|
||||
toml::from_str(text).context("parsing manifest TOML")
|
||||
}
|
||||
|
||||
/// Load and parse the manifest at `path`.
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let body =
|
||||
fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
Self::parse(&body)
|
||||
}
|
||||
|
||||
/// Render to TOML text. Tables are emitted after scalars (the struct
|
||||
/// field order already satisfies TOML's "values before tables" rule).
|
||||
pub fn to_toml(&self) -> Result<String> {
|
||||
toml::to_string_pretty(self).context("serializing manifest TOML")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestApp {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestScript {
|
||||
pub name: String,
|
||||
/// Path to the `.rhai` source, relative to the manifest's directory.
|
||||
pub file: String,
|
||||
#[serde(default, skip_serializing_if = "is_endpoint")]
|
||||
pub kind: ScriptKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_seconds: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
/// Per-script sandbox overrides; omitted entirely when no knob is set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestRoute {
|
||||
/// Name of the script this route binds to.
|
||||
pub script: String,
|
||||
/// HTTP method; omit for ANY.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<String>,
|
||||
pub host_kind: HostKind,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub host: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub host_param_name: Option<String>,
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "is_sync")]
|
||||
pub dispatch_mode: DispatchMode,
|
||||
}
|
||||
|
||||
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestTriggers {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub kv: Vec<KvTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub docs: Vec<DocsTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub files: Vec<FilesTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub cron: Vec<CronTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub pubsub: Vec<PubsubTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub email: Vec<EmailTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub queue: Vec<QueueTriggerSpec>,
|
||||
}
|
||||
|
||||
impl ManifestTriggers {
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.kv.is_empty()
|
||||
&& self.docs.is_empty()
|
||||
&& self.files.is_empty()
|
||||
&& self.cron.is_empty()
|
||||
&& self.pubsub.is_empty()
|
||||
&& self.email.is_empty()
|
||||
&& self.queue.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KvTriggerSpec {
|
||||
pub script: String,
|
||||
pub collection_glob: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ops: Vec<KvEventOp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DocsTriggerSpec {
|
||||
pub script: String,
|
||||
pub collection_glob: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ops: Vec<DocsEventOp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FilesTriggerSpec {
|
||||
pub script: String,
|
||||
pub collection_glob: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ops: Vec<FilesEventOp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CronTriggerSpec {
|
||||
pub script: String,
|
||||
/// 6-field cron expression (with seconds).
|
||||
pub schedule: String,
|
||||
#[serde(default = "default_timezone")]
|
||||
pub timezone: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PubsubTriggerSpec {
|
||||
pub script: String,
|
||||
pub topic_pattern: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmailTriggerSpec {
|
||||
pub script: String,
|
||||
/// Name of the secret (set via `pic secret set`) holding the inbound
|
||||
/// HMAC value — resolved + sealed server-side at apply. Never the value.
|
||||
pub inbound_secret_ref: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct QueueTriggerSpec {
|
||||
pub script: String,
|
||||
pub queue_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub visibility_timeout_secs: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
/// `[secrets] names = [...]` — declares which secrets the app expects.
|
||||
/// Values are never in the manifest; `pic secret set` pushes them.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestSecrets {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
impl ManifestSecrets {
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.names.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- serde skip/default helpers ----
|
||||
|
||||
fn is_endpoint(kind: &ScriptKind) -> bool {
|
||||
*kind == ScriptKind::Endpoint
|
||||
}
|
||||
|
||||
fn is_sync(mode: &DispatchMode) -> bool {
|
||||
*mode == DispatchMode::Sync
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
"UTC".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> Manifest {
|
||||
Manifest {
|
||||
app: ManifestApp {
|
||||
slug: "blog".into(),
|
||||
name: "My Blog".into(),
|
||||
description: Some("demo".into()),
|
||||
},
|
||||
scripts: vec![
|
||||
ManifestScript {
|
||||
name: "create-post".into(),
|
||||
file: "scripts/create-post.rhai".into(),
|
||||
kind: ScriptKind::Endpoint,
|
||||
description: None,
|
||||
timeout_seconds: Some(10),
|
||||
memory_limit_mb: Some(256),
|
||||
sandbox: None,
|
||||
},
|
||||
ManifestScript {
|
||||
name: "lib".into(),
|
||||
file: "scripts/lib.rhai".into(),
|
||||
kind: ScriptKind::Module,
|
||||
description: None,
|
||||
timeout_seconds: None,
|
||||
memory_limit_mb: None,
|
||||
sandbox: Some(ScriptSandbox {
|
||||
max_operations: Some(5_000_000),
|
||||
..ScriptSandbox::empty()
|
||||
}),
|
||||
},
|
||||
],
|
||||
routes: vec![ManifestRoute {
|
||||
script: "create-post".into(),
|
||||
method: Some("POST".into()),
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
host_param_name: None,
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/posts".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
}],
|
||||
triggers: ManifestTriggers {
|
||||
cron: vec![CronTriggerSpec {
|
||||
script: "create-post".into(),
|
||||
schedule: "0 6 * * * *".into(),
|
||||
timezone: "UTC".into(),
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
}],
|
||||
kv: vec![KvTriggerSpec {
|
||||
script: "create-post".into(),
|
||||
collection_glob: "users".into(),
|
||||
ops: vec![KvEventOp::Insert, KvEventOp::Update],
|
||||
dispatch_mode: Some(DispatchMode::Async),
|
||||
retry_max_attempts: Some(5),
|
||||
}],
|
||||
..ManifestTriggers::default()
|
||||
},
|
||||
secrets: ManifestSecrets {
|
||||
names: vec!["STRIPE_KEY".into()],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_toml() {
|
||||
let m = sample();
|
||||
let text = m.to_toml().expect("serialize");
|
||||
let back = Manifest::parse(&text).expect("parse");
|
||||
assert_eq!(m, back, "manifest must survive a TOML round-trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_defaulted_fields() {
|
||||
let text = sample().to_toml().unwrap();
|
||||
// Endpoint kind + sync dispatch are defaults → not emitted.
|
||||
assert!(
|
||||
!text.contains("kind = \"endpoint\""),
|
||||
"default kind should be omitted:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("dispatch_mode = \"sync\""),
|
||||
"default route dispatch should be omitted:\n{text}"
|
||||
);
|
||||
// Module kind IS non-default → emitted.
|
||||
assert!(text.contains("kind = \"module\""), "got:\n{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_optional_sections_omitted() {
|
||||
let m = Manifest {
|
||||
app: ManifestApp {
|
||||
slug: "x".into(),
|
||||
name: "X".into(),
|
||||
description: None,
|
||||
},
|
||||
scripts: vec![],
|
||||
routes: vec![],
|
||||
triggers: ManifestTriggers::default(),
|
||||
secrets: ManifestSecrets::default(),
|
||||
};
|
||||
let text = m.to_toml().unwrap();
|
||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||
assert!(!text.contains("triggers"), "got:\n{text}");
|
||||
assert!(!text.contains("secrets"), "got:\n{text}");
|
||||
// Still round-trips.
|
||||
assert_eq!(m, Manifest::parse(&text).unwrap());
|
||||
}
|
||||
}
|
||||
153
crates/picloud-cli/tests/apply.rs
Normal file
153
crates/picloud-cli/tests/apply.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
//! `pic apply` journey: apply a manifest to an empty app (atomic create),
|
||||
//! re-apply is an idempotent no-op, and a bundle containing any invalid
|
||||
//! resource applies nothing (all-or-nothing).
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
fn manifest_dir() -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_creates_then_noop() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("apply");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = manifest_dir();
|
||||
fs::write(
|
||||
dir.path().join("scripts/greet.rhai"),
|
||||
"let body = #{ ok: true }; body",
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Apply Test\"\n\n\
|
||||
[[scripts]]\nname = \"greet\"\nfile = \"scripts/greet.rhai\"\n\n\
|
||||
[[routes]]\nscript = \"greet\"\nmethod = \"POST\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/greet\"\n\n\
|
||||
[[triggers.cron]]\nscript = \"greet\"\nschedule = \"0 0 * * * *\"\ntimezone = \"UTC\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
// First apply: creates script + route + trigger.
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.expect("apply");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"apply failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(
|
||||
stdout.contains("+1"),
|
||||
"expected creations in report:\n{stdout}"
|
||||
);
|
||||
|
||||
// The resources now exist.
|
||||
let s = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.contains("greet"), "script not created:\n{s}");
|
||||
|
||||
// Plan is now clean (apply reached desired state).
|
||||
let p = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!p.contains("create") && !p.contains("update"),
|
||||
"expected clean plan after apply:\n{p}"
|
||||
);
|
||||
|
||||
// Re-apply: idempotent — nothing created/updated.
|
||||
let r = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!r.contains("+1"), "re-apply should be a no-op:\n{r}");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_rejects_bad_bundle_atomically() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("apply-atomic");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = manifest_dir();
|
||||
fs::write(dir.path().join("scripts/good.rhai"), "let x = 1; x").unwrap();
|
||||
// Invalid Rhai — fails validation, so the whole apply must abort.
|
||||
fs::write(dir.path().join("scripts/bad.rhai"), "let x = ;").unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Atomic Test\"\n\n\
|
||||
[[scripts]]\nname = \"good\"\nfile = \"scripts/good.rhai\"\n\n\
|
||||
[[scripts]]\nname = \"bad\"\nfile = \"scripts/bad.rhai\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.expect("apply");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"apply with an invalid script should fail"
|
||||
);
|
||||
|
||||
// Atomic: the valid script must NOT have been created.
|
||||
let s = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!s.contains("good"),
|
||||
"a failed apply must leave nothing behind:\n{s}"
|
||||
);
|
||||
}
|
||||
@@ -15,12 +15,17 @@ mod common;
|
||||
|
||||
mod admins;
|
||||
mod api_keys;
|
||||
mod apply;
|
||||
mod apps;
|
||||
mod auth;
|
||||
mod dead_letters;
|
||||
mod email_queue;
|
||||
mod invoke;
|
||||
mod logs;
|
||||
mod output;
|
||||
mod plan;
|
||||
mod prune;
|
||||
mod pull;
|
||||
mod roles;
|
||||
mod routes;
|
||||
mod scripts;
|
||||
|
||||
222
crates/picloud-cli/tests/email_queue.rs
Normal file
222
crates/picloud-cli/tests/email_queue.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
//! M5: `pic apply` creates email + queue triggers. The email trigger's
|
||||
//! inbound secret is referenced by name (pushed via `pic secret set`) and
|
||||
//! resolved + re-sealed server-side — never written into the manifest.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_email_and_queue_triggers() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("m5");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
// The email trigger references this secret by name; push its value
|
||||
// out-of-band first.
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--app", &slug, "email-hmac"])
|
||||
.write_stdin("super-secret-hmac")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\
|
||||
[secrets]\nnames = [\"email-hmac\"]\n\n\
|
||||
[[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\
|
||||
[[triggers.queue]]\nscript = \"handler\"\nqueue_name = \"jobs\"\n\n\
|
||||
[[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"email-hmac\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.expect("apply");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"apply failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
// Both triggers exist.
|
||||
let s = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["triggers", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
s.lines().any(|l| l.contains("queue")),
|
||||
"queue trigger missing:\n{s}"
|
||||
);
|
||||
assert!(
|
||||
s.lines().any(|l| l.contains("email")),
|
||||
"email trigger missing:\n{s}"
|
||||
);
|
||||
|
||||
// Re-apply is a no-op (both triggers match by identity).
|
||||
let r = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!r.contains("+1"), "re-apply should be a no-op:\n{r}");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn prune_refuses_to_orphan_email_trigger() {
|
||||
// `pull` can't represent email triggers, so a manifest that omits the
|
||||
// script owning one would, under `--prune`, cascade-delete the trigger
|
||||
// (and its sealed secret) when the script is dropped. Apply must refuse.
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("m5-orphan");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--app", &slug, "email-hmac"])
|
||||
.write_stdin("super-secret-hmac")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
|
||||
// v1: a script with an email trigger.
|
||||
let v1 = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\
|
||||
[secrets]\nnames = [\"email-hmac\"]\n\n\
|
||||
[[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\
|
||||
[[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"email-hmac\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &v1).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// v2: drop the script (and, implicitly, its un-representable email
|
||||
// trigger). A prune apply must REFUSE rather than cascade-destroy it.
|
||||
let v2 = format!("[app]\nslug = \"{slug}\"\nname = \"M5\"\n");
|
||||
fs::write(&manifest_path, &v2).unwrap();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.arg("--prune")
|
||||
.output()
|
||||
.expect("apply --prune");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"prune must refuse to orphan an email trigger"
|
||||
);
|
||||
|
||||
// The script and its email trigger both survive the refused apply.
|
||||
let scripts = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
scripts.contains("handler"),
|
||||
"script must survive:\n{scripts}"
|
||||
);
|
||||
let triggers = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["triggers", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
triggers.lines().any(|l| l.contains("email")),
|
||||
"email trigger must survive:\n{triggers}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_email_unset_secret_fails() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("m5-nosecret");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\
|
||||
[[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\
|
||||
[[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"never-set\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
// The referenced secret was never set → apply must fail atomically.
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.expect("apply");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"apply must fail when an email secret is unset"
|
||||
);
|
||||
|
||||
// Atomic: neither the script nor the email trigger was created.
|
||||
let s = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!s.contains("handler"),
|
||||
"failed apply must leave nothing behind:\n{s}"
|
||||
);
|
||||
}
|
||||
81
crates/picloud-cli/tests/plan.rs
Normal file
81
crates/picloud-cli/tests/plan.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
//! `pic plan` journey: a freshly-pulled manifest must diff to all-no-op
|
||||
//! (pull→plan is idempotent), and editing a script source must surface
|
||||
//! as an update.
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn plan_roundtrips_then_detects_change() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let (script_id, guard) = common::deploy_fixture(&env, "plan", "hello.rhai");
|
||||
let app = guard.slug().to_string();
|
||||
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"routes", "create", "--script", &script_id, "--path", "/p", "--method", "GET",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Pull the live state, then plan it back — must be a clean no-op.
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
common::pic_as(&env)
|
||||
.args(["pull", &app, "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
let manifest = dir.path().join("picloud.toml");
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest)
|
||||
.output()
|
||||
.expect("plan");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"plan failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let hello = stdout
|
||||
.lines()
|
||||
.find(|l| l.contains("hello"))
|
||||
.unwrap_or_else(|| panic!("no hello row in plan:\n{stdout}"));
|
||||
assert!(
|
||||
hello.contains("noop"),
|
||||
"expected hello no-op, got:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
!stdout.contains("create") && !stdout.contains("delete"),
|
||||
"fresh pull should diff clean, got:\n{stdout}"
|
||||
);
|
||||
|
||||
// Edit the script source on disk → plan must report an update.
|
||||
std::fs::write(
|
||||
dir.path().join("scripts/hello.rhai"),
|
||||
"let body = #{ ok: false }; body",
|
||||
)
|
||||
.expect("rewrite source");
|
||||
let out = common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest)
|
||||
.output()
|
||||
.expect("plan after edit");
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let hello = stdout
|
||||
.lines()
|
||||
.find(|l| l.contains("hello"))
|
||||
.unwrap_or_else(|| panic!("no hello row in plan:\n{stdout}"));
|
||||
assert!(
|
||||
hello.contains("update"),
|
||||
"expected hello update after source edit, got:\n{stdout}"
|
||||
);
|
||||
|
||||
drop(guard);
|
||||
}
|
||||
111
crates/picloud-cli/tests/prune.rs
Normal file
111
crates/picloud-cli/tests/prune.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
//! `pic apply --prune` journey: a resource dropped from the manifest
|
||||
//! survives a plain (additive) apply but is deleted with `--prune`.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
fn scripts_ls(env: &common::TestEnv, slug: &str) -> String {
|
||||
String::from_utf8(
|
||||
common::pic_as(env)
|
||||
.args(["scripts", "ls", "--app", slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn prune_deletes_stale_resources() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("prune");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/keep.rhai"), "let x = 1; x").unwrap();
|
||||
fs::write(dir.path().join("scripts/drop.rhai"), "let y = 2; y").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
|
||||
// v1: two scripts + a route on `drop`.
|
||||
let v1 = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Prune Test\"\n\n\
|
||||
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n\n\
|
||||
[[scripts]]\nname = \"drop\"\nfile = \"scripts/drop.rhai\"\n\n\
|
||||
[[routes]]\nscript = \"drop\"\nmethod = \"GET\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/drop\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &v1).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// v2: drop `drop` and its route.
|
||||
let v2 = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Prune Test\"\n\n\
|
||||
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &v2).unwrap();
|
||||
|
||||
// Plain apply is additive — `drop` survives.
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
assert!(
|
||||
scripts_ls(&env, &slug).contains("drop"),
|
||||
"additive apply must not delete"
|
||||
);
|
||||
|
||||
// Prune apply removes `drop` and its route.
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.arg("--prune")
|
||||
.output()
|
||||
.expect("apply --prune");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"prune failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let report = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(
|
||||
report.contains("-1"),
|
||||
"expected deletions in report:\n{report}"
|
||||
);
|
||||
|
||||
let s = scripts_ls(&env, &slug);
|
||||
assert!(!s.contains("drop"), "prune should delete `drop`:\n{s}");
|
||||
assert!(s.contains("keep"), "prune must keep `keep`:\n{s}");
|
||||
|
||||
// Plan is clean after prune.
|
||||
let p = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!p.contains("delete"),
|
||||
"plan should be clean after prune:\n{p}"
|
||||
);
|
||||
}
|
||||
91
crates/picloud-cli/tests/pull.rs
Normal file
91
crates/picloud-cli/tests/pull.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
//! `pic pull` journey: stand up an app with a script, route, cron trigger,
|
||||
//! and a secret, then export it and assert the manifest + script file.
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn pull_exports_manifest_and_sources() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
|
||||
// App + script "hello" (deploy derives the name from the file stem).
|
||||
let (script_id, guard) = common::deploy_fixture(&env, "pull", "hello.rhai");
|
||||
let app = guard.slug().to_string();
|
||||
|
||||
// Route → script.
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"routes", "create", "--script", &script_id, "--path", "/hook", "--method", "POST",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Cron trigger → script.
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"triggers",
|
||||
"create-cron",
|
||||
"--app",
|
||||
&app,
|
||||
"--script",
|
||||
&script_id,
|
||||
"--schedule",
|
||||
"0 0 * * * *",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Secret (name only ends up in the manifest; value stays server-side).
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--app", &app, "api_key"])
|
||||
.write_stdin("xyzzy")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Pull into a scratch dir.
|
||||
let out_dir = TempDir::new().expect("pull tempdir");
|
||||
common::pic_as(&env)
|
||||
.args(["pull", &app, "--dir"])
|
||||
.arg(out_dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Manifest exists and captures every resource.
|
||||
let manifest = std::fs::read_to_string(out_dir.path().join("picloud.toml"))
|
||||
.expect("picloud.toml should be written");
|
||||
assert!(
|
||||
manifest.contains(&format!("slug = \"{app}\"")),
|
||||
"manifest missing app slug:\n{manifest}"
|
||||
);
|
||||
assert!(
|
||||
manifest.contains("name = \"hello\"") && manifest.contains("scripts/hello.rhai"),
|
||||
"manifest missing script entry:\n{manifest}"
|
||||
);
|
||||
assert!(
|
||||
manifest.contains("[[routes]]") && manifest.contains("path = \"/hook\""),
|
||||
"manifest missing route:\n{manifest}"
|
||||
);
|
||||
assert!(
|
||||
manifest.contains("[[triggers.cron]]") && manifest.contains("schedule = \"0 0 * * * *\""),
|
||||
"manifest missing cron trigger:\n{manifest}"
|
||||
);
|
||||
assert!(
|
||||
manifest.contains("api_key"),
|
||||
"manifest missing secret name:\n{manifest}"
|
||||
);
|
||||
|
||||
// Script source was written out faithfully.
|
||||
let src = std::fs::read_to_string(out_dir.path().join("scripts/hello.rhai"))
|
||||
.expect("scripts/hello.rhai should be written");
|
||||
assert!(
|
||||
src.contains("hello from pic"),
|
||||
"exported source mismatch:\n{src}"
|
||||
);
|
||||
|
||||
drop(guard);
|
||||
}
|
||||
@@ -10,13 +10,13 @@ use axum::middleware::from_fn_with_state;
|
||||
use axum::{routing::get, Json, Router};
|
||||
use picloud_executor_core::{Engine, Limits};
|
||||
use picloud_manager_core::{
|
||||
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
|
||||
attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
|
||||
admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api,
|
||||
apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
|
||||
dev_emails_router, email_inbound_router, files_admin_router, kv_admin_router, migrations,
|
||||
require_authenticated, route_admin_router, secrets_router, topics_router, triggers_router,
|
||||
AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository,
|
||||
AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository,
|
||||
AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo, DeadLetterRepo,
|
||||
AppMembersState, AppRepository, ApplyService, AppsState, AuthState, AuthzRepo, DeadLetterRepo,
|
||||
DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState,
|
||||
EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig,
|
||||
HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter,
|
||||
@@ -42,8 +42,8 @@ use picloud_orchestrator_core::{
|
||||
use picloud_shared::{
|
||||
DeadLetterService, DocsService, EmailService, ExecutionLogSink, FilesService, HttpService,
|
||||
InboxResolver, KvService, MasterKey, OutboxWriter, PubsubService, RealtimeAuthority,
|
||||
RealtimeBroadcaster, ScriptValidator, SecretsService, ServiceEventEmitter, Services,
|
||||
UsersService, API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION,
|
||||
RealtimeBroadcaster, SecretsService, ServiceEventEmitter, Services, UsersService, API_VERSION,
|
||||
PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION,
|
||||
};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
@@ -399,7 +399,7 @@ pub async fn build_app(
|
||||
logs: log_repo,
|
||||
apps: apps_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
validator: engine as Arc<dyn ScriptValidator>,
|
||||
validator: engine.clone(),
|
||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||
};
|
||||
let route_admin = RouteAdminState {
|
||||
@@ -414,7 +414,7 @@ pub async fn build_app(
|
||||
resolver,
|
||||
log_sink,
|
||||
app_domains: app_domain_table.clone(),
|
||||
routes: route_table,
|
||||
routes: route_table.clone(),
|
||||
inbox: inbox_registry,
|
||||
outbox: outbox_writer,
|
||||
};
|
||||
@@ -440,7 +440,7 @@ pub async fn build_app(
|
||||
// v1.1.4: cron scheduler. Polls cron_trigger_details on a tick and
|
||||
// enqueues due triggers into the outbox; the dispatcher above
|
||||
// delivers them like any other async trigger.
|
||||
picloud_manager_core::spawn_cron_scheduler(pool, trigger_config.cron_tick_interval_ms);
|
||||
picloud_manager_core::spawn_cron_scheduler(pool.clone(), trigger_config.cron_tick_interval_ms);
|
||||
// v1.1.6: GC empty realtime broadcast channels (one-shot subscribers)
|
||||
// and sweep orphaned `*.tmp.*` blobs left by crashed file writes.
|
||||
spawn_realtime_gc(broadcaster_concrete, DEFAULT_GC_INTERVAL_SECS);
|
||||
@@ -453,6 +453,23 @@ pub async fn build_app(
|
||||
config: trigger_config,
|
||||
master_key: master_key.clone(),
|
||||
};
|
||||
// Declarative reconcile engine (pic plan / apply). Trait-object repos
|
||||
// for the read/diff path; shares the same handles as the CRUD routers.
|
||||
let apply_service = ApplyService {
|
||||
pool: pool.clone(),
|
||||
scripts: script_repo.clone(),
|
||||
routes: route_repo.clone(),
|
||||
triggers: trigger_repo.clone(),
|
||||
secrets: secrets_repo.clone(),
|
||||
apps: apps_repo.clone(),
|
||||
domains: domains_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
validator: engine.clone(),
|
||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||
trigger_config,
|
||||
route_table: route_table.clone(),
|
||||
master_key: master_key.clone(),
|
||||
};
|
||||
// v1.1.9: keep a clone for the queues-api state (built later).
|
||||
let trigger_repo_for_queues = trigger_repo.clone();
|
||||
// v1.1.7 public inbound-email receiver. Outside the admin auth layer
|
||||
@@ -562,6 +579,7 @@ pub async fn build_app(
|
||||
))
|
||||
.merge(api_keys_router(api_keys_state))
|
||||
.merge(triggers_router(triggers_state))
|
||||
.merge(apply_router(apply_service))
|
||||
.merge(picloud_manager_core::queues_api::queues_router(
|
||||
picloud_manager_core::queues_api::QueuesState {
|
||||
queues: queue_repo.clone(),
|
||||
|
||||
Reference in New Issue
Block a user