From 8a559ea17894459b21044c238213917252d61af0 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 25 Jun 2026 22:18:07 +0200 Subject: [PATCH] feat(apply): atomic project-tree apply with cross-node inherited binding (Phase 5 C3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 C3 — the headline. A whole project subtree (group + app nodes) is reconciled in ONE Postgres transaction, all-or-nothing. `POST /api/v1/admin/tree/{plan,apply}` take a `TreeBundle { nodes: [{kind, slug, bundle}] }`. The actor must hold the relevant read/write caps for EVERY node (per-kind, widened on prune) — `authz_tree` + reusable `require_{app,group}_node_writes` (now shared with the single-node handlers). Engine: * The in-tx reconcile body of `apply_owner` is extracted to `reconcile_node_tx` (returns the node's post-create `name → script_id`), so single-node and tree apply share one implementation — behaviour-preserving (391 unit tests + app journeys green). * `prepare_tree` resolves each slug→owner, validates (an app's route/trigger target may bind to an in-tree ancestor group's script), computes each node's plan, and folds a combined bound-plan token = every node's `state_token` + every in-scope group's `structure_version` (so a reparent or content edit between plan and apply trips StateMoved). * `apply_tree`: lock every node key (sorted, deadlock-free); reconcile GROUPS first, recording each group's `name → id` in an in-memory index; then APPS, resolving inherited route/trigger targets nearest-ancestor-wins across the in-tree index (incl. scripts created earlier in THIS tx, invisible to the pool resolver) and pre-existing out-of-tree ancestors. One commit, one post-commit route refresh. Live-validated against the dev DB: * Headline: a group node creates `shared`; an app node in the SAME apply binds `GET /greet` to it — the route ends up bound to the group-owned script, and re-plan is all-noop (idempotent). * Atomicity: one invalid node → 422 and ZERO rows written (the valid group node's script is not created). CLI tree discovery + `pic plan/apply` tree mode is C4; journeys + docs are C5. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/manager-core/src/apply_api.rs | 265 ++++-- crates/manager-core/src/apply_service.rs | 1016 ++++++++++++++++------ 2 files changed, 920 insertions(+), 361 deletions(-) diff --git a/crates/manager-core/src/apply_api.rs b/crates/manager-core/src/apply_api.rs index f366036..6033f43 100644 --- a/crates/manager-core/src/apply_api.rs +++ b/crates/manager-core/src/apply_api.rs @@ -17,7 +17,8 @@ use serde_json::json; use crate::app_repo::AppRepository; use crate::apply_service::{ - ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, PlanResult, + ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, NodeKind, PlanResult, + TreeBundle, TreePlanResult, }; use crate::authz::{require, AuthzDenied, Capability}; use crate::group_repo::GroupRepository; @@ -29,6 +30,8 @@ pub fn apply_router(service: ApplyService) -> Router { .route("/apps/{id}/apply", post(apply_handler)) .route("/groups/{id}/plan", post(group_plan_handler)) .route("/groups/{id}/apply", post(group_apply_handler)) + .route("/tree/plan", post(tree_plan_handler)) + .route("/tree/apply", post(tree_apply_handler)) .with_state(service) } @@ -50,62 +53,7 @@ async fn apply_handler( Json(req): Json, ) -> Result, 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)?; - } - if req.prune || !req.bundle.vars.is_empty() { - require( - svc.authz.as_ref(), - &principal, - Capability::AppVarsWrite(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)?; - } + require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?; let report = svc .apply( app_id, @@ -170,26 +118,7 @@ async fn group_apply_handler( Json(req): Json, ) -> Result, ApplyError> { let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?; - // Write caps per touched kind (+ all kinds on prune). Group scripts are - // editor-tier (`GroupScriptsWrite`), group vars likewise (`GroupVarsWrite`). - if req.prune || !req.bundle.scripts.is_empty() { - require( - svc.authz.as_ref(), - &principal, - Capability::GroupScriptsWrite(group_id), - ) - .await - .map_err(map_authz)?; - } - if req.prune || !req.bundle.vars.is_empty() { - require( - svc.authz.as_ref(), - &principal, - Capability::GroupVarsWrite(group_id), - ) - .await - .map_err(map_authz)?; - } + require_group_node_writes(&svc, &principal, group_id, &req.bundle, req.prune).await?; let report = svc .apply_owner( ApplyOwner::Group(group_id), @@ -202,6 +131,188 @@ async fn group_apply_handler( Ok(Json(report)) } +// ---------------------------------------------------------------------------- +// Tree apply (Phase 5): a whole project subtree in one transaction. The actor +// must hold the relevant read/write caps for EVERY node touched. +// ---------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct TreeApplyRequest { + bundle: TreeBundle, + #[serde(default)] + prune: bool, + #[serde(default)] + expected_token: Option, +} + +async fn tree_plan_handler( + State(svc): State, + Extension(principal): Extension, + Json(bundle): Json, +) -> Result, ApplyError> { + authz_tree(&svc, &principal, &bundle, None).await?; + Ok(Json(svc.plan_tree(&bundle).await?)) +} + +async fn tree_apply_handler( + State(svc): State, + Extension(principal): Extension, + Json(req): Json, +) -> Result, ApplyError> { + authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?; + let report = svc + .apply_tree( + &req.bundle, + req.prune, + principal.user_id, + req.expected_token.as_deref(), + ) + .await?; + Ok(Json(report)) +} + +/// Per-node capability check for a tree plan/apply. `prune` widens an apply's +/// write requirement to every kind. A plan (`prune` ignored, no writes) needs +/// only the per-node read cap. +async fn authz_tree( + svc: &ApplyService, + principal: &Principal, + bundle: &TreeBundle, + apply_prune: Option, +) -> Result<(), ApplyError> { + for node in &bundle.nodes { + match node.kind { + NodeKind::App => { + let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?; + match apply_prune { + Some(prune) => { + require_app_node_writes(svc, principal, app_id, &node.bundle, prune) + .await?; + } + None => { + require(svc.authz.as_ref(), principal, Capability::AppRead(app_id)) + .await + .map_err(map_authz)?; + } + } + } + NodeKind::Group => { + let group_id = resolve_group_id(svc.groups.as_ref(), &node.slug).await?; + match apply_prune { + Some(prune) => { + require_group_node_writes(svc, principal, group_id, &node.bundle, prune) + .await?; + } + None => { + require( + svc.authz.as_ref(), + principal, + Capability::GroupScriptsRead(group_id), + ) + .await + .map_err(map_authz)?; + } + } + } + } + } + Ok(()) +} + +/// App-node write caps: per resource kind the bundle touches, widened to all +/// kinds when `prune` (which deletes empty-section resources + cascades). Read +/// is always needed. Shared by the single-app and tree apply paths. +async fn require_app_node_writes( + svc: &ApplyService, + principal: &Principal, + app_id: picloud_shared::AppId, + bundle: &Bundle, + prune: bool, +) -> Result<(), ApplyError> { + require(svc.authz.as_ref(), principal, Capability::AppRead(app_id)) + .await + .map_err(map_authz)?; + if prune || !bundle.scripts.is_empty() { + require( + svc.authz.as_ref(), + principal, + Capability::AppWriteScript(app_id), + ) + .await + .map_err(map_authz)?; + } + if prune || !bundle.routes.is_empty() { + require( + svc.authz.as_ref(), + principal, + Capability::AppWriteRoute(app_id), + ) + .await + .map_err(map_authz)?; + } + if prune || !bundle.triggers.is_empty() { + require( + svc.authz.as_ref(), + principal, + Capability::AppManageTriggers(app_id), + ) + .await + .map_err(map_authz)?; + } + if prune || !bundle.vars.is_empty() { + require( + svc.authz.as_ref(), + principal, + Capability::AppVarsWrite(app_id), + ) + .await + .map_err(map_authz)?; + } + // Email triggers resolve + decrypt a stored secret by name server-side + // (`AppSecretsRead`), so require it so apply can't bind a secret the + // principal couldn't otherwise read. + if bundle.triggers.iter().any(BundleTrigger::is_email) { + require( + svc.authz.as_ref(), + principal, + Capability::AppSecretsRead(app_id), + ) + .await + .map_err(map_authz)?; + } + Ok(()) +} + +/// Group-node write caps (editor-tier): scripts → `GroupScriptsWrite`, vars → +/// `GroupVarsWrite`, both on prune. +async fn require_group_node_writes( + svc: &ApplyService, + principal: &Principal, + group_id: GroupId, + bundle: &Bundle, + prune: bool, +) -> Result<(), ApplyError> { + if prune || !bundle.scripts.is_empty() { + require( + svc.authz.as_ref(), + principal, + Capability::GroupScriptsWrite(group_id), + ) + .await + .map_err(map_authz)?; + } + if prune || !bundle.vars.is_empty() { + require( + svc.authz.as_ref(), + principal, + Capability::GroupVarsWrite(group_id), + ) + .await + .map_err(map_authz)?; + } + Ok(()) +} + /// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404. async fn resolve_group_id( groups: &dyn GroupRepository, diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 2a6381f..2415a96 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -341,6 +341,53 @@ pub struct PlanResult { pub state_token: String, } +// ---------------------------------------------------------------------------- +// Tree apply (Phase 5): a whole project subtree applied in one transaction. +// ---------------------------------------------------------------------------- + +/// Whether a tree node is an app or a group. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NodeKind { + App, + Group, +} + +/// One node of a project tree: its kind, slug (must pre-exist on the server), +/// and its desired-state bundle (a group's bundle has no routes/triggers). +/// Request-only (deserialized from the CLI's tree bundle). +#[derive(Debug, Clone, Deserialize)] +pub struct TreeNode { + pub kind: NodeKind, + pub slug: String, + pub bundle: Bundle, +} + +/// A whole project subtree submitted to `apply_tree`. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct TreeBundle { + #[serde(default)] + pub nodes: Vec, +} + +/// One node's diff within a tree plan. +#[derive(Debug, Clone, Serialize)] +pub struct NodePlan { + pub kind: NodeKind, + pub slug: String, + #[serde(flatten)] + pub plan: Plan, +} + +/// What `plan_tree` returns: a per-node diff plus ONE combined bound-plan token +/// (fingerprint of every node's content + every in-scope group's structure +/// version, so a reparent or content edit between plan and apply is caught). +#[derive(Debug, Clone, Serialize)] +pub struct TreePlanResult { + pub nodes: Vec, + pub state_token: String, +} + // ---------------------------------------------------------------------------- // Current state snapshot // ---------------------------------------------------------------------------- @@ -495,6 +542,312 @@ impl ApplyService { /// /// # Errors /// `Invalid` for a bad bundle; `Backend` for repo/transaction failures. + /// Reconcile ONE node's scripts/routes/triggers/vars inside an existing + /// transaction (Phase 5). Returns the node's final `name -> script_id` map + /// (current + created), which the tree apply records so a later app node + /// can bind a route to a group script created earlier in the SAME tx. The + /// single-node `apply_owner` and the multi-node tree apply both call this. + #[allow(clippy::too_many_arguments, clippy::too_many_lines)] + async fn reconcile_node_tx( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + owner: ApplyOwner, + bundle: &Bundle, + plan: &Plan, + current: &CurrentState, + current_names: &HashMap, + inherited: &HashMap, + prune: bool, + actor: AdminUserId, + report: &mut ApplyReport, + ) -> Result, ApplyError> { + let bundle_scripts: HashMap = bundle + .scripts + .iter() + .map(|s| (s.name.to_lowercase(), s)) + .collect(); + let current_scripts: HashMap = current + .scripts + .iter() + .map(|s| (s.name.to_lowercase(), s)) + .collect(); + // name -> id for resolving route/trigger targets (existing + created). + let mut name_to_id: HashMap = current + .scripts + .iter() + .map(|s| (s.name.to_lowercase(), s.id)) + .collect(); + + // 1. Scripts — create + update first so routes/triggers resolve. + for ch in &plan.scripts { + let key = ch.key.to_lowercase(); + match ch.op { + Op::Create => { + let bs = bundle_scripts[&key]; + let (script_app_id, script_group_id) = owner.script_owner(); + let new = NewScript { + app_id: script_app_id, + group_id: script_group_id, + name: bs.name.clone(), + description: bs.description.clone(), + source: bs.source.clone(), + kind: bs.kind, + timeout_seconds: bs.timeout_seconds, + memory_limit_mb: bs.memory_limit_mb, + sandbox: bs.sandbox, + enabled: bs.enabled, + imports: self.script_imports(bs)?, + }; + let created = insert_script_tx(&mut *tx, &new).await.map_err(map_repo)?; + name_to_id.insert(created.name.to_lowercase(), created.id); + report.scripts_created += 1; + } + Op::Update => { + let bs = bundle_scripts[&key]; + let cur = current_scripts[&key]; + let patch = ScriptPatch { + name: None, + // Sparse: `None` (omitted in the manifest) leaves the + // stored description untouched; `Some(text)` sets it. + // Mirrors `script_update_reason`'s leave-as-is rule. + description: bs.description.clone().map(Some), + source: Some(bs.source.clone()), + timeout_seconds: bs.timeout_seconds, + memory_limit_mb: bs.memory_limit_mb, + sandbox: bs.sandbox, + kind: Some(bs.kind), + // Declarative: always reconcile to the manifest's value. + enabled: Some(bs.enabled), + imports: Some(self.script_imports(bs)?), + }; + update_script_tx(&mut *tx, cur.id, &patch) + .await + .map_err(map_repo)?; + report.scripts_updated += 1; + } + Op::NoOp | Op::Delete => {} + } + } + + // Phase 4: route/trigger targets the manifest doesn't define as app-own + // scripts may bind to inherited group endpoints. `or_insert` keeps + // app-own precedence (those names aren't in `inherited` by + // construction, but be defensive about CoW). + for (name, id) in inherited { + name_to_id.entry(name.clone()).or_insert(*id); + } + + // 2. Routes — identity is the (method, host, path) tuple; an + // "update" (rebind / dispatch change) is delete + insert. + let current_routes: HashMap = current + .routes + .iter() + .map(|r| { + ( + route_key( + r.method.as_deref(), + r.host_kind, + &r.host, + r.path_kind, + &r.path, + ), + r, + ) + }) + .collect(); + let bundle_routes: HashMap = bundle + .routes + .iter() + .map(|r| { + ( + route_key( + r.method.as_deref(), + r.host_kind, + &r.host, + r.path_kind, + &r.path, + ), + r, + ) + }) + .collect(); + // 2. Routes — delete before insert so a binding freed by an update's + // delete-half or a prune removal can be reused by a create in the + // SAME apply without tripping the per-app UNIQUE binding index. + // Phase A — deletes: update replacements always; removals when pruning. + for ch in &plan.routes { + match ch.op { + Op::Update => { + let cur = current_routes[&ch.key]; + delete_route_tx(&mut *tx, cur.id).await.map_err(map_repo)?; + } + Op::Delete if prune => { + if let Some(cur) = current_routes.get(&ch.key) { + delete_route_tx(&mut *tx, cur.id).await.map_err(map_repo)?; + report.routes_deleted += 1; + } + } + _ => {} + } + } + // Phase B — inserts: creates and update replacements. + for ch in &plan.routes { + let is_update = match ch.op { + Op::Create => false, + Op::Update => true, + _ => continue, + }; + let br = bundle_routes[&ch.key]; + let sid = resolve_script(&name_to_id, &br.script)?; + // Routes only exist on app nodes (a group bundle has none, so this + // loop is empty for a group — validated above). + let app_id = owner.app_id().expect("routes only exist on app nodes"); + insert_bundle_route(&mut *tx, app_id, sid, br).await?; + if is_update { + report.routes_updated += 1; + } else { + report.routes_created += 1; + } + } + + // 3. Triggers — semantic identity captures the whole definition, + // so the only op is Create (a change is delete-old + create-new; + // the stale one is removed by prune). Additive: create only. + let bundle_triggers: HashMap = + bundle.triggers.iter().map(|t| (t.identity(), t)).collect(); + for ch in &plan.triggers { + if ch.op != Op::Create { + continue; + } + // Triggers only exist on app nodes (a group bundle has none). + let app_id = owner.app_id().expect("triggers only exist on app nodes"); + let bt = bundle_triggers[&ch.key]; + let sid = resolve_script(&name_to_id, bt.script())?; + if let BundleTrigger::Email { + inbound_secret_ref, .. + } = bt + { + // Resolve the referenced secret, decrypt it, and re-seal it + // into the email trigger's detail row — the value never + // travels in the manifest. + let (ct, nonce) = self.resolve_and_seal(app_id, inbound_secret_ref).await?; + insert_email_trigger_tx(&mut *tx, app_id, sid, actor, &ct, &nonce) + .await + .map_err(map_trig)?; + } else { + let (dispatch, retry_max, backoff, base) = self.trigger_settings(bt); + let details = bundle_trigger_details( + bt, + self.trigger_config.queue_default_visibility_timeout_secs, + ); + insert_trigger_tx( + &mut *tx, app_id, sid, actor, dispatch, retry_max, backoff, base, &details, + ) + .await + .map_err(map_trig)?; + } + report.triggers_created += 1; + } + + // 3b. Vars — upsert every Create/Update at app scope `*`, in-tx so a + // later failure rolls them back with everything else. + for ch in &plan.vars { + match ch.op { + Op::Create => { + owner + .set_var_tx(&mut *tx, &ch.key, &bundle.vars[&ch.key]) + .await?; + report.vars_created += 1; + } + Op::Update => { + owner + .set_var_tx(&mut *tx, &ch.key, &bundle.vars[&ch.key]) + .await?; + report.vars_updated += 1; + } + Op::NoOp | Op::Delete => {} + } + } + + // 4. Prune (only with --prune): delete stale triggers, then scripts + // (route removals already happened in the route delete-pass above). + // Secret pruning is deliberately deferred (destructive + irreversible): + // stale secrets are surfaced in the plan but never deleted here. + if prune { + // Include inherited group-script bindings (Phase 4) so a trigger + // bound to a group script resolves its identity and is pruned when + // dropped from the manifest — not silently orphaned. + let id_to_name: HashMap = current + .scripts + .iter() + .map(|s| (s.id, s.name.clone())) + .chain(current_names.iter().map(|(id, n)| (*id, n.clone()))) + .collect(); + let trigger_id_by_identity: HashMap = current + .triggers + .iter() + .filter_map(|t| current_trigger_identity(t, &id_to_name).map(|i| (i, t.id))) + .collect(); + for ch in &plan.triggers { + if ch.op == Op::Delete { + if let Some(id) = trigger_id_by_identity.get(&ch.key) { + delete_trigger_tx(&mut *tx, *id).await.map_err(map_trig)?; + report.triggers_deleted += 1; + } + } + } + + // Scripts owning a trigger the manifest can't represent (email, + // dead_letter) must not be silently pruned: deleting the script + // would cascade-destroy that trigger (and its sealed secret) via + // the `triggers.script_id ON DELETE CASCADE` FK, defeating the + // "never prune email triggers" guarantee. Refuse and point the + // operator at the explicit removal path. + let protected: HashSet = current + .triggers + .iter() + .filter(|t| { + matches!( + t.details, + TriggerDetails::Email { .. } | TriggerDetails::DeadLetter { .. } + ) + }) + .map(|t| t.script_id) + .collect(); + let script_id_by_name: HashMap = current + .scripts + .iter() + .map(|s| (s.name.to_lowercase(), s.id)) + .collect(); + for ch in &plan.scripts { + if ch.op == Op::Delete { + if let Some(id) = script_id_by_name.get(&ch.key.to_lowercase()) { + if protected.contains(id) { + return Err(ApplyError::Invalid(format!( + "cannot prune script `{}`: it still has an email or \ + dead-letter trigger that the manifest can't represent; \ + remove the trigger with `pic triggers rm` first", + ch.key + ))); + } + delete_script_tx(&mut *tx, *id).await.map_err(map_repo)?; + report.scripts_deleted += 1; + } + } + } + + // Vars are prunable config (unlike secrets, which are never + // pruned): a live app var the manifest dropped is deleted. + for ch in &plan.vars { + if ch.op == Op::Delete { + owner.delete_var_tx(&mut *tx, &ch.key).await?; + report.vars_deleted += 1; + } + } + } + Ok(name_to_id) + } + pub async fn apply( &self, app_id: AppId, @@ -587,290 +940,19 @@ impl ApplyService { .extend(unreachable_endpoint_warnings(bundle)); } - let bundle_scripts: HashMap = bundle - .scripts - .iter() - .map(|s| (s.name.to_lowercase(), s)) - .collect(); - let current_scripts: HashMap = current - .scripts - .iter() - .map(|s| (s.name.to_lowercase(), s)) - .collect(); - // name -> id for resolving route/trigger targets (existing + created). - let mut name_to_id: HashMap = current - .scripts - .iter() - .map(|s| (s.name.to_lowercase(), s.id)) - .collect(); - - // 1. Scripts — create + update first so routes/triggers resolve. - for ch in &plan.scripts { - let key = ch.key.to_lowercase(); - match ch.op { - Op::Create => { - let bs = bundle_scripts[&key]; - let (script_app_id, script_group_id) = owner.script_owner(); - let new = NewScript { - app_id: script_app_id, - group_id: script_group_id, - name: bs.name.clone(), - description: bs.description.clone(), - source: bs.source.clone(), - kind: bs.kind, - timeout_seconds: bs.timeout_seconds, - memory_limit_mb: bs.memory_limit_mb, - sandbox: bs.sandbox, - enabled: bs.enabled, - imports: self.script_imports(bs)?, - }; - let created = insert_script_tx(&mut tx, &new).await.map_err(map_repo)?; - name_to_id.insert(created.name.to_lowercase(), created.id); - report.scripts_created += 1; - } - Op::Update => { - let bs = bundle_scripts[&key]; - let cur = current_scripts[&key]; - let patch = ScriptPatch { - name: None, - // Sparse: `None` (omitted in the manifest) leaves the - // stored description untouched; `Some(text)` sets it. - // Mirrors `script_update_reason`'s leave-as-is rule. - description: bs.description.clone().map(Some), - source: Some(bs.source.clone()), - timeout_seconds: bs.timeout_seconds, - memory_limit_mb: bs.memory_limit_mb, - sandbox: bs.sandbox, - kind: Some(bs.kind), - // Declarative: always reconcile to the manifest's value. - enabled: Some(bs.enabled), - imports: Some(self.script_imports(bs)?), - }; - update_script_tx(&mut tx, cur.id, &patch) - .await - .map_err(map_repo)?; - report.scripts_updated += 1; - } - Op::NoOp | Op::Delete => {} - } - } - - // Phase 4: route/trigger targets the manifest doesn't define as app-own - // scripts may bind to inherited group endpoints. `or_insert` keeps - // app-own precedence (those names aren't in `inherited` by - // construction, but be defensive about CoW). - for (name, id) in &inherited { - name_to_id.entry(name.clone()).or_insert(*id); - } - - // 2. Routes — identity is the (method, host, path) tuple; an - // "update" (rebind / dispatch change) is delete + insert. - let current_routes: HashMap = current - .routes - .iter() - .map(|r| { - ( - route_key( - r.method.as_deref(), - r.host_kind, - &r.host, - r.path_kind, - &r.path, - ), - r, - ) - }) - .collect(); - let bundle_routes: HashMap = bundle - .routes - .iter() - .map(|r| { - ( - route_key( - r.method.as_deref(), - r.host_kind, - &r.host, - r.path_kind, - &r.path, - ), - r, - ) - }) - .collect(); - // 2. Routes — delete before insert so a binding freed by an update's - // delete-half or a prune removal can be reused by a create in the - // SAME apply without tripping the per-app UNIQUE binding index. - // Phase A — deletes: update replacements always; removals when pruning. - for ch in &plan.routes { - match ch.op { - Op::Update => { - let cur = current_routes[&ch.key]; - delete_route_tx(&mut tx, cur.id).await.map_err(map_repo)?; - } - Op::Delete if prune => { - if let Some(cur) = current_routes.get(&ch.key) { - delete_route_tx(&mut tx, cur.id).await.map_err(map_repo)?; - report.routes_deleted += 1; - } - } - _ => {} - } - } - // Phase B — inserts: creates and update replacements. - for ch in &plan.routes { - let is_update = match ch.op { - Op::Create => false, - Op::Update => true, - _ => continue, - }; - let br = bundle_routes[&ch.key]; - let sid = resolve_script(&name_to_id, &br.script)?; - // Routes only exist on app nodes (a group bundle has none, so this - // loop is empty for a group — validated above). - let app_id = owner.app_id().expect("routes only exist on app nodes"); - insert_bundle_route(&mut tx, app_id, sid, br).await?; - if is_update { - report.routes_updated += 1; - } else { - report.routes_created += 1; - } - } - - // 3. Triggers — semantic identity captures the whole definition, - // so the only op is Create (a change is delete-old + create-new; - // the stale one is removed by prune). Additive: create only. - let bundle_triggers: HashMap = - bundle.triggers.iter().map(|t| (t.identity(), t)).collect(); - for ch in &plan.triggers { - if ch.op != Op::Create { - continue; - } - // Triggers only exist on app nodes (a group bundle has none). - let app_id = owner.app_id().expect("triggers only exist on app nodes"); - let bt = bundle_triggers[&ch.key]; - let sid = resolve_script(&name_to_id, bt.script())?; - if let BundleTrigger::Email { - inbound_secret_ref, .. - } = bt - { - // Resolve the referenced secret, decrypt it, and re-seal it - // into the email trigger's detail row — the value never - // travels in the manifest. - let (ct, nonce) = self.resolve_and_seal(app_id, inbound_secret_ref).await?; - insert_email_trigger_tx(&mut tx, app_id, sid, actor, &ct, &nonce) - .await - .map_err(map_trig)?; - } else { - let (dispatch, retry_max, backoff, base) = self.trigger_settings(bt); - let details = bundle_trigger_details( - bt, - self.trigger_config.queue_default_visibility_timeout_secs, - ); - insert_trigger_tx( - &mut tx, app_id, sid, actor, dispatch, retry_max, backoff, base, &details, - ) - .await - .map_err(map_trig)?; - } - report.triggers_created += 1; - } - - // 3b. Vars — upsert every Create/Update at app scope `*`, in-tx so a - // later failure rolls them back with everything else. - for ch in &plan.vars { - match ch.op { - Op::Create => { - owner - .set_var_tx(&mut tx, &ch.key, &bundle.vars[&ch.key]) - .await?; - report.vars_created += 1; - } - Op::Update => { - owner - .set_var_tx(&mut tx, &ch.key, &bundle.vars[&ch.key]) - .await?; - report.vars_updated += 1; - } - Op::NoOp | Op::Delete => {} - } - } - - // 4. Prune (only with --prune): delete stale triggers, then scripts - // (route removals already happened in the route delete-pass above). - // Secret pruning is deliberately deferred (destructive + irreversible): - // stale secrets are surfaced in the plan but never deleted here. - if prune { - // Include inherited group-script bindings (Phase 4) so a trigger - // bound to a group script resolves its identity and is pruned when - // dropped from the manifest — not silently orphaned. - let id_to_name: HashMap = current - .scripts - .iter() - .map(|s| (s.id, s.name.clone())) - .chain(current_names.iter().map(|(id, n)| (*id, n.clone()))) - .collect(); - let trigger_id_by_identity: HashMap = current - .triggers - .iter() - .filter_map(|t| current_trigger_identity(t, &id_to_name).map(|i| (i, t.id))) - .collect(); - for ch in &plan.triggers { - if ch.op == Op::Delete { - if let Some(id) = trigger_id_by_identity.get(&ch.key) { - delete_trigger_tx(&mut tx, *id).await.map_err(map_trig)?; - report.triggers_deleted += 1; - } - } - } - - // Scripts owning a trigger the manifest can't represent (email, - // dead_letter) must not be silently pruned: deleting the script - // would cascade-destroy that trigger (and its sealed secret) via - // the `triggers.script_id ON DELETE CASCADE` FK, defeating the - // "never prune email triggers" guarantee. Refuse and point the - // operator at the explicit removal path. - let protected: HashSet = current - .triggers - .iter() - .filter(|t| { - matches!( - t.details, - TriggerDetails::Email { .. } | TriggerDetails::DeadLetter { .. } - ) - }) - .map(|t| t.script_id) - .collect(); - let script_id_by_name: HashMap = current - .scripts - .iter() - .map(|s| (s.name.to_lowercase(), s.id)) - .collect(); - for ch in &plan.scripts { - if ch.op == Op::Delete { - if let Some(id) = script_id_by_name.get(&ch.key.to_lowercase()) { - if protected.contains(id) { - return Err(ApplyError::Invalid(format!( - "cannot prune script `{}`: it still has an email or \ - dead-letter trigger that the manifest can't represent; \ - remove the trigger with `pic triggers rm` first", - ch.key - ))); - } - delete_script_tx(&mut tx, *id).await.map_err(map_repo)?; - report.scripts_deleted += 1; - } - } - } - - // Vars are prunable config (unlike secrets, which are never - // pruned): a live app var the manifest dropped is deleted. - for ch in &plan.vars { - if ch.op == Op::Delete { - owner.delete_var_tx(&mut tx, &ch.key).await?; - report.vars_deleted += 1; - } - } - } + self.reconcile_node_tx( + &mut tx, + owner, + bundle, + &plan, + ¤t, + ¤t_names, + &inherited, + prune, + actor, + &mut report, + ) + .await?; tx.commit() .await @@ -891,6 +973,344 @@ impl ApplyService { Ok(report) } + // ------------------------------------------------------------------------ + // Tree apply (Phase 5): a project subtree reconciled in ONE transaction. + // ------------------------------------------------------------------------ + + /// Diff a whole project subtree, read-only. Returns a per-node plan + one + /// combined bound-plan token. + /// + /// # Errors + /// `Invalid` for a malformed/unknown bundle; `Backend` for repo failures. + pub async fn plan_tree(&self, bundle: &TreeBundle) -> Result { + let (prepared, token) = self.prepare_tree(bundle).await?; + let nodes = prepared + .into_iter() + .map(|p| NodePlan { + kind: p.node.kind, + slug: p.node.slug.clone(), + plan: p.plan, + }) + .collect(); + Ok(TreePlanResult { + nodes, + state_token: token, + }) + } + + /// Reconcile a whole project subtree in a single transaction (all-or- + /// nothing). Groups reconcile before apps, so an app route/trigger can bind + /// a group script created earlier in the SAME transaction (resolved via the + /// in-memory `group_script_index`, since uncommitted rows aren't visible to + /// the pool-based inherited resolver). + /// + /// # Errors + /// `Invalid` for a bad bundle; `StateMoved` if the bound token is stale; + /// `Backend` for repo/transaction failures. + pub async fn apply_tree( + &self, + bundle: &TreeBundle, + prune: bool, + actor: AdminUserId, + expected_token: Option<&str>, + ) -> Result { + let (prepared, token) = self.prepare_tree(bundle).await?; + if let Some(expected) = expected_token { + if token != expected { + return Err(ApplyError::StateMoved); + } + } + + let mut tx = self + .pool + .begin() + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + // Lock every node's apply key, in sorted order, so concurrent applies + // touching any shared node serialize and the ordering can't deadlock. + let mut lock_keys: Vec = prepared.iter().map(|p| apply_lock_key(p.owner)).collect(); + lock_keys.sort_unstable(); + lock_keys.dedup(); + for k in lock_keys { + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(k) + .execute(&mut *tx) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + } + + let mut report = ApplyReport::default(); + let mut group_script_index: HashMap> = HashMap::new(); + let mut any_app = false; + + // Phase A — groups first: reconcile each and record its post-create + // `name -> id` so descendant app nodes can resolve inherited bindings. + for p in &prepared { + if let ApplyOwner::Group(gid) = p.owner { + report + .warnings + .extend(disabled_target_warnings(&p.node.bundle)); + let name_to_id = self + .reconcile_node_tx( + &mut tx, + p.owner, + &p.node.bundle, + &p.plan, + &p.current, + &p.current_names, + &HashMap::new(), + prune, + actor, + &mut report, + ) + .await?; + group_script_index.insert(gid, name_to_id); + } + } + + // Phase B — apps: resolve inherited targets against in-tree groups (the + // ids just created) and pre-existing ancestors, then reconcile. + for p in &prepared { + if let ApplyOwner::App(_) = p.owner { + any_app = true; + report + .warnings + .extend(disabled_target_warnings(&p.node.bundle)); + report + .warnings + .extend(unreachable_endpoint_warnings(&p.node.bundle)); + let inherited = self + .tree_inherited_for_app(&p.app_chain, &p.node.bundle, &group_script_index) + .await?; + self.reconcile_node_tx( + &mut tx, + p.owner, + &p.node.bundle, + &p.plan, + &p.current, + &p.current_names, + &inherited, + prune, + actor, + &mut report, + ) + .await?; + } + } + + tx.commit() + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + // One post-commit route refresh covers every app node touched. + if any_app { + if let Err(e) = self.refresh_route_table().await { + tracing::warn!(error = %e, "apply_tree: route table refresh failed"); + report + .warnings + .push("route table refresh failed; it will self-heal".into()); + } + } + Ok(report) + } + + /// Resolve nodes to owners, validate each (apps' route/trigger targets may + /// bind to in-tree ancestor group scripts), compute each node's plan, and + /// fold a combined bound-plan token. Shared by `plan_tree` / `apply_tree`. + #[allow(clippy::too_many_lines)] + async fn prepare_tree<'a>( + &self, + bundle: &'a TreeBundle, + ) -> Result<(Vec>, String), ApplyError> { + if bundle.nodes.is_empty() { + return Err(ApplyError::Invalid("project tree has no nodes".into())); + } + // Register in-tree group nodes first: their (resolved) ids and endpoint + // script names, which an app node's binding validation may reference. + let mut group_id_by_slug: HashMap = HashMap::new(); + let mut group_script_names: HashMap> = HashMap::new(); + let mut seen_slugs: HashSet = HashSet::new(); + for n in &bundle.nodes { + let key = format!("{:?}:{}", n.kind, n.slug.to_lowercase()); + if !seen_slugs.insert(key) { + return Err(ApplyError::Invalid(format!( + "project tree lists node `{}` more than once", + n.slug + ))); + } + if n.kind == NodeKind::Group { + let gid = self.resolve_group(&n.slug).await?; + group_id_by_slug.insert(n.slug.to_lowercase(), gid); + group_script_names.insert( + gid, + n.bundle + .scripts + .iter() + .filter(|s| s.kind == ScriptKind::Endpoint) + .map(|s| s.name.to_lowercase()) + .collect(), + ); + } + } + + let mut prepared = Vec::with_capacity(bundle.nodes.len()); + let mut token_parts: Vec = Vec::new(); + let mut versioned_groups: BTreeSet = BTreeSet::new(); + + for n in &bundle.nodes { + let (owner, app_chain) = match n.kind { + NodeKind::Group => { + let gid = group_id_by_slug[&n.slug.to_lowercase()]; + versioned_groups.insert(gid); + (ApplyOwner::Group(gid), Vec::new()) + } + NodeKind::App => { + let app = self.resolve_app(&n.slug).await?; + let chain = self + .groups + .ancestors(app.group_id) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + for g in &chain { + versioned_groups.insert(g.id); + } + ( + ApplyOwner::App(app.id), + chain.iter().map(|g| g.id).collect::>(), + ) + } + }; + + // Inherited endpoint NAMES the bindings may reference: pre-existing + // (pool resolver) plus in-tree ancestor group scripts. + let inherited_names: HashSet = match owner { + ApplyOwner::App(app_id) => { + let mut names = + keys_set(&self.resolve_inherited_targets(app_id, &n.bundle).await?); + for gid in &app_chain { + if let Some(s) = group_script_names.get(gid) { + names.extend(s.iter().cloned()); + } + } + names + } + ApplyOwner::Group(_) => HashSet::new(), + }; + self.validate_bundle_for(owner, &n.bundle, &inherited_names)?; + if let ApplyOwner::App(app_id) = owner { + self.validate_route_hosts(app_id, &n.bundle).await?; + } + + let current = self.load_current(owner).await?; + let current_names = self.resolve_current_target_names(¤t).await?; + validate_email_secrets_present(&n.bundle, ¤t.secret_names)?; + let plan = compute_diff_with_names(¤t, &n.bundle, ¤t_names); + token_parts.push(format!( + "n|{}|{}", + n.slug.to_lowercase(), + state_token_with_names(¤t, ¤t_names) + )); + prepared.push(PreparedNode { + owner, + node: n, + current, + current_names, + plan, + app_chain, + }); + } + + // Fold in every in-scope group's structure version, so a reparent or a + // new app under the subtree between plan and apply trips StateMoved. + for gid in &versioned_groups { + if let Some(g) = self + .groups + .get_by_id(*gid) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))? + { + token_parts.push(format!("gs|{gid}|{}", g.structure_version)); + } + } + token_parts.sort_unstable(); + Ok((prepared, combine_tokens(&token_parts))) + } + + /// Resolve an app node's inherited route/trigger targets to script ids, + /// nearest-ancestor-wins, considering BOTH in-tree group nodes (their + /// in-tx `name -> id`, incl. just-created scripts) and pre-existing + /// out-of-tree ancestor groups (their committed rows). + async fn tree_inherited_for_app( + &self, + app_chain: &[GroupId], + bundle: &Bundle, + index: &HashMap>, + ) -> Result, ApplyError> { + let own: HashSet = bundle + .scripts + .iter() + .map(|s| s.name.to_lowercase()) + .collect(); + let mut referenced: HashSet = HashSet::new(); + for r in &bundle.routes { + referenced.insert(r.script.to_lowercase()); + } + for t in &bundle.triggers { + referenced.insert(t.script().to_lowercase()); + } + referenced.retain(|n| !own.contains(n)); + if referenced.is_empty() { + return Ok(HashMap::new()); + } + // Per-ancestor name→id maps, nearest-first. + let mut chain_maps: Vec> = Vec::with_capacity(app_chain.len()); + for gid in app_chain { + if let Some(idx) = index.get(gid) { + chain_maps.push(idx.clone()); + } else { + let m = self + .scripts + .list_for_group(*gid) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))? + .into_iter() + .filter(|s| s.kind == ScriptKind::Endpoint) + .map(|s| (s.name.to_lowercase(), s.id)) + .collect(); + chain_maps.push(m); + } + } + let mut out = HashMap::new(); + for name in referenced { + for m in &chain_maps { + if let Some(id) = m.get(&name) { + out.insert(name.clone(), *id); + break; + } + } + } + Ok(out) + } + + async fn resolve_app(&self, ident: &str) -> Result { + crate::app_repo::resolve_app(self.apps.as_ref(), ident) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))? + .map(|l| l.app) + .ok_or_else(|| ApplyError::AppNotFound(ident.to_string())) + } + + async fn resolve_group(&self, ident: &str) -> Result { + let found = if let Ok(uuid) = ident.parse::() { + self.groups.get_by_id(uuid.into()).await + } else { + self.groups.get_by_slug(ident).await + }; + found + .map_err(|e| ApplyError::Backend(e.to_string()))? + .map(|g| g.id) + .ok_or_else(|| ApplyError::AppNotFound(format!("group {ident}"))) + } + fn script_imports(&self, bs: &BundleScript) -> Result, ApplyError> { let res = if bs.kind == ScriptKind::Module { self.validator.validate_module(&bs.source) @@ -2124,6 +2544,34 @@ fn keys_set(m: &HashMap) -> HashSet { m.keys().cloned().collect() } +/// A tree node resolved to its owner + loaded current state + computed plan, +/// ready to reconcile. `app_chain` is the ordered (nearest-first) ancestor +/// group ids for an app node (empty for a group node). +struct PreparedNode<'a> { + owner: ApplyOwner, + node: &'a TreeNode, + current: CurrentState, + current_names: HashMap, + plan: Plan, + app_chain: Vec, +} + +/// FNV-1a over a set of already-sorted per-resource token parts — the same +/// hashing `state_token` uses, lifted to combine many nodes into one tree +/// token. Order-independent because the caller sorts `parts`. +fn combine_tokens(parts: &[String]) -> String { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for p in parts { + for b in p.as_bytes() { + h ^= u64::from(*b); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h ^= u64::from(b'\n'); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{h:016x}") +} + fn resolve_script( name_to_id: &HashMap, name: &str,