A group declares a declaratively-authored [[triggers.dead_letter]] shared=true handler; when a message in the group's SHARED queue is exhausted, the dispatcher's q_terminal group branch (after persisting to group_dead_letters) fans out to it via list_matching_shared_dead_letter(owning_group, "queue", …), each outbox row stamped the WRITER app_id (the consuming app — the M2 shared -write model), so the handler runs under the consumer. The per-app list_matching_dead_letter gained AND t.shared = FALSE (the shared flag is the namespace boundary); the owning-group filter is the isolation boundary. Adds BundleTrigger::DeadLetter + a DeadLetterTriggerSpec manifest kind (group +shared only — validate_bundle_for rejects app-owned or non-shared, and exempts it from the shared-requires-a-collection rule); insert_trigger_tx now accepts dead_letter and writes dead_letter_trigger_details; current_trigger_identity matches a group-shared dead_letter so re-apply is a NoOp (app-owned ones stay diff-invisible). Pinned by tests/shared_dead_letter.rs (owning group matches, per-app query does not, foreign group does not). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7106 lines
282 KiB
Rust
7106 lines
282 KiB
Rust
//! Declarative reconcile engine for a single app.
|
||
//!
|
||
//! Takes a desired-state [`Bundle`] (an app's scripts, routes, triggers,
|
||
//! and the names of its secrets) and either diffs it against the live
|
||
//! database ([`ApplyService::plan`], read-only) or reconciles it in one
|
||
//! transaction ([`ApplyService::apply`]).
|
||
//!
|
||
//! Identity keys match the DB UNIQUE constraints so the diff is stable:
|
||
//! scripts by `lower(name)`, routes by the `(method, host, path)` tuple,
|
||
//! triggers by their per-kind semantic tuple, secrets by name.
|
||
//!
|
||
//! **Trigger-identity limitation:** a trigger's identity *is* its semantic
|
||
//! definition, so the diff only ever Creates or Deletes (a change is
|
||
//! delete-old + create-new). For queue the identity is `queue|{queue_name}`
|
||
//! (script-independent — it encodes the one-consumer-per-queue invariant)
|
||
//! and for email it is `email|{script}`. Consequence: rebinding a queue to
|
||
//! a different script, changing its visibility timeout, or rotating an email
|
||
//! trigger's secret/reference diffs as a **NoOp and is not applied**. To
|
||
//! change those, recreate the trigger (drop it from the manifest, apply
|
||
//! `--prune`, then re-add it).
|
||
//!
|
||
//! Other known limitations vs. the interactive API: a script **rename**
|
||
//! diffs as delete+create, so under `--prune` it loses the old script's id,
|
||
//! version counter, and execution logs; an `endpoint`→`module` kind flip is
|
||
//! not guarded against *live* (un-pruned) routes/triggers still bound to the
|
||
//! script; and cross-pattern route conflict-vs-live is not checked (only
|
||
//! identical tuples collide). See `validate_bundle`.
|
||
|
||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||
use std::sync::Arc;
|
||
|
||
use picloud_orchestrator_core::routing::{pattern, RouteTable};
|
||
use picloud_shared::{
|
||
AdminUserId, AppId, DispatchMode, DocsEventOp, FilesEventOp, GroupId, HostKind, KvEventOp,
|
||
MasterKey, PathKind, Principal, ProjectId, Route, Script, ScriptId, ScriptKind, ScriptOwner,
|
||
ScriptSandbox, ScriptValidator, TriggerId,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
use sqlx::PgPool;
|
||
|
||
use crate::app_domain_repo::AppDomainRepository;
|
||
use crate::app_repo::AppRepository;
|
||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||
use crate::config_resolver::CHAIN_LEVELS_CTE;
|
||
use crate::repo::{
|
||
delete_script_tx, insert_script_tx, update_script_tx, NewScript, ScriptPatch, ScriptRepository,
|
||
ScriptRepositoryError,
|
||
};
|
||
use crate::route_repo::{delete_route_tx, insert_route_tx, NewRoute, RouteRepository};
|
||
use crate::sandbox::SandboxCeiling;
|
||
use crate::secrets_repo::SecretsRepo;
|
||
use crate::trigger_config::TriggerConfig;
|
||
use crate::trigger_repo::{
|
||
delete_trigger_tx, insert_email_trigger_tx, insert_trigger_tx, Trigger, TriggerDetails,
|
||
TriggerDispatchMode, TriggerRepo, TriggerRepoError,
|
||
};
|
||
use crate::vars_repo::{VarOwner, VarsRepo};
|
||
|
||
/// Reserved path prefixes that user routes may not claim (mirrors the
|
||
/// route-create rejection — see CLAUDE.md "Reserved path prefixes").
|
||
const RESERVED_PATH_PREFIXES: &[&str] = &["/api/", "/admin/"];
|
||
const RESERVED_PATH_EXACT: &[&str] = &["/healthz", "/version"];
|
||
|
||
// ----------------------------------------------------------------------------
|
||
// Wire types — desired state (request) and the computed plan (response)
|
||
// ----------------------------------------------------------------------------
|
||
|
||
/// Desired state for one app, sent by `pic plan` / `pic apply`.
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
pub struct Bundle {
|
||
#[serde(default)]
|
||
pub scripts: Vec<BundleScript>,
|
||
#[serde(default)]
|
||
pub routes: Vec<BundleRoute>,
|
||
#[serde(default)]
|
||
pub triggers: Vec<BundleTrigger>,
|
||
/// Declared secret *names* only; values are pushed out-of-band.
|
||
#[serde(default)]
|
||
pub secrets: Vec<String>,
|
||
/// Declared app-owned config `vars` — key → JSON value. Unlike secrets,
|
||
/// var values are non-secret and live inline in the manifest. Written at
|
||
/// app scope `*` (an app *is* an environment, §2 — per-env scoping is a
|
||
/// group concern, deferred to the nested-manifest model). Tombstones are
|
||
/// not expressible here; use `pic vars set --tombstone`.
|
||
#[serde(default)]
|
||
pub vars: std::collections::BTreeMap<String, serde_json::Value>,
|
||
/// Declared extension-point *names* (§5.5) — module names this node offers
|
||
/// for descendants to provide/override. Name-only, like `secrets`; the
|
||
/// optional default body is a co-located `kind = module` script.
|
||
#[serde(default)]
|
||
pub extension_points: Vec<String>,
|
||
/// Declared shared group collections (§11.6) — `(name, kind)` markers this
|
||
/// node offers as cross-app-shared (`kind` ∈ `kv`/`docs`). The data lives in
|
||
/// the per-kind store (`group_kv_entries` / `group_docs`). Authored on
|
||
/// `[group]` nodes only (the CLI rejects it on `[app]`).
|
||
#[serde(default)]
|
||
pub collections: Vec<CollectionSpec>,
|
||
/// §11 tail per-app opt-out: handler SCRIPT names whose inherited group
|
||
/// triggers this app declines. App-only (rejected on a group node).
|
||
#[serde(default)]
|
||
pub suppress_triggers: Vec<String>,
|
||
/// §11 tail per-app opt-out: PATHS whose inherited group route this app
|
||
/// declines (the binding 404s instead of serving). App-only.
|
||
#[serde(default)]
|
||
pub suppress_routes: Vec<String>,
|
||
/// v1.2 Workflows: declared DAG workflow definitions. App-owned (M1); a
|
||
/// `[group]` carrying workflows is rejected in `validate_bundle_for`.
|
||
#[serde(default)]
|
||
pub workflows: Vec<BundleWorkflow>,
|
||
/// §9.4 service interceptors: before-op allow/deny hooks. One entry per
|
||
/// `(service, op)` guarded, naming the interceptor script. App- OR
|
||
/// group-owned; resolved nearest-owner-wins on a descendant app's chain.
|
||
#[serde(default)]
|
||
pub interceptors: Vec<BundleInterceptor>,
|
||
}
|
||
|
||
/// One §9.4 interceptor marker on the wire: which `(service, op)` it guards and
|
||
/// the interceptor script name. The CLI expands a manifest `[[interceptors]]`
|
||
/// entry's `ops = [...]` into one of these per op.
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
pub struct BundleInterceptor {
|
||
pub service: String,
|
||
pub op: String,
|
||
pub script: String,
|
||
}
|
||
|
||
/// One declared workflow on the wire: a name + its (already-parsed) DAG.
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
pub struct BundleWorkflow {
|
||
pub name: String,
|
||
pub definition: picloud_shared::workflow::WorkflowDefinition,
|
||
/// Three-state lifecycle (§4.3); omitted ⇒ active.
|
||
#[serde(default = "picloud_shared::default_true")]
|
||
pub enabled: bool,
|
||
}
|
||
|
||
/// One declared shared-collection marker on the wire: a name + its store kind.
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
pub struct CollectionSpec {
|
||
pub name: String,
|
||
/// Storage kind; defaults to `kv` for back-compat with the name-only form.
|
||
#[serde(default = "default_collection_kind")]
|
||
pub kind: String,
|
||
}
|
||
|
||
fn default_collection_kind() -> String {
|
||
"kv".to_string()
|
||
}
|
||
|
||
/// The shared-collection kinds the apply engine accepts.
|
||
const COLLECTION_KINDS: &[&str] = &["kv", "docs", "files", "topic", "queue"];
|
||
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
pub struct BundleScript {
|
||
pub name: String,
|
||
pub source: String,
|
||
#[serde(default)]
|
||
pub kind: ScriptKind,
|
||
#[serde(default)]
|
||
pub description: Option<String>,
|
||
#[serde(default)]
|
||
pub timeout_seconds: Option<i32>,
|
||
#[serde(default)]
|
||
pub memory_limit_mb: Option<i32>,
|
||
#[serde(default)]
|
||
pub sandbox: Option<ScriptSandbox>,
|
||
/// Three-state lifecycle (§4.3); omitted ⇒ active. Declarative (not
|
||
/// leave-as-is): a script absent the key is `enabled = true`.
|
||
#[serde(default = "picloud_shared::default_true")]
|
||
pub enabled: bool,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
pub struct BundleRoute {
|
||
/// Name of the script this route binds to.
|
||
pub script: String,
|
||
#[serde(default)]
|
||
pub method: Option<String>,
|
||
pub host_kind: HostKind,
|
||
#[serde(default)]
|
||
pub host: String,
|
||
#[serde(default)]
|
||
pub host_param_name: Option<String>,
|
||
pub path_kind: PathKind,
|
||
pub path: String,
|
||
#[serde(default)]
|
||
pub dispatch_mode: DispatchMode,
|
||
/// Three-state lifecycle (§4.3); omitted ⇒ active.
|
||
#[serde(default = "picloud_shared::default_true")]
|
||
pub enabled: bool,
|
||
/// §11 tail: `sealed = true` on a group route template makes it
|
||
/// non-suppressible — a descendant's `[suppress]` cannot decline it.
|
||
/// Rejected (in `validate_bundle_for`) on an app owner. Omitted ⇒ unsealed.
|
||
#[serde(default)]
|
||
pub sealed: bool,
|
||
}
|
||
|
||
/// Desired trigger, tagged by kind on the wire (`{ "kind": "cron", … }`).
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||
pub enum BundleTrigger {
|
||
Kv {
|
||
script: String,
|
||
collection_glob: String,
|
||
#[serde(default)]
|
||
ops: Vec<KvEventOp>,
|
||
#[serde(default)]
|
||
dispatch_mode: Option<TriggerDispatchMode>,
|
||
#[serde(default)]
|
||
retry_max_attempts: Option<u32>,
|
||
/// §11 tail: sealed group template (event kinds only). See
|
||
/// [`BundleTrigger::sealed`].
|
||
#[serde(default)]
|
||
sealed: bool,
|
||
/// §11.6: shared-collection group trigger. See [`BundleTrigger::shared`].
|
||
#[serde(default)]
|
||
shared: bool,
|
||
},
|
||
Docs {
|
||
script: String,
|
||
collection_glob: String,
|
||
#[serde(default)]
|
||
ops: Vec<DocsEventOp>,
|
||
#[serde(default)]
|
||
dispatch_mode: Option<TriggerDispatchMode>,
|
||
#[serde(default)]
|
||
retry_max_attempts: Option<u32>,
|
||
#[serde(default)]
|
||
sealed: bool,
|
||
#[serde(default)]
|
||
shared: bool,
|
||
},
|
||
Files {
|
||
script: String,
|
||
collection_glob: String,
|
||
#[serde(default)]
|
||
ops: Vec<FilesEventOp>,
|
||
#[serde(default)]
|
||
dispatch_mode: Option<TriggerDispatchMode>,
|
||
#[serde(default)]
|
||
retry_max_attempts: Option<u32>,
|
||
#[serde(default)]
|
||
sealed: bool,
|
||
#[serde(default)]
|
||
shared: bool,
|
||
},
|
||
Cron {
|
||
script: String,
|
||
schedule: String,
|
||
#[serde(default = "default_timezone")]
|
||
timezone: String,
|
||
#[serde(default)]
|
||
dispatch_mode: Option<TriggerDispatchMode>,
|
||
#[serde(default)]
|
||
retry_max_attempts: Option<u32>,
|
||
},
|
||
Pubsub {
|
||
script: String,
|
||
topic_pattern: String,
|
||
#[serde(default)]
|
||
dispatch_mode: Option<TriggerDispatchMode>,
|
||
#[serde(default)]
|
||
retry_max_attempts: Option<u32>,
|
||
#[serde(default)]
|
||
sealed: bool,
|
||
/// §11.6 D2: `true` for a shared-TOPIC group trigger — fires when a
|
||
/// subtree app publishes to the group's shared topic namespace, not on
|
||
/// per-app publishes.
|
||
#[serde(default)]
|
||
shared: bool,
|
||
},
|
||
Email {
|
||
script: String,
|
||
/// Name of the secret (set via `pic secret set`) holding the
|
||
/// inbound HMAC value; resolved + sealed at apply time. Never the
|
||
/// value itself.
|
||
inbound_secret_ref: String,
|
||
#[serde(default)]
|
||
dispatch_mode: Option<TriggerDispatchMode>,
|
||
#[serde(default)]
|
||
retry_max_attempts: Option<u32>,
|
||
},
|
||
Queue {
|
||
script: String,
|
||
queue_name: String,
|
||
#[serde(default)]
|
||
visibility_timeout_secs: Option<u32>,
|
||
#[serde(default)]
|
||
dispatch_mode: Option<TriggerDispatchMode>,
|
||
#[serde(default)]
|
||
retry_max_attempts: Option<u32>,
|
||
/// §11.6 D3: `true` for a shared-QUEUE group consumer — materializes a
|
||
/// competing consumer per descendant app, all claiming the group store.
|
||
#[serde(default)]
|
||
shared: bool,
|
||
},
|
||
/// §11.6 B2: a dead-letter handler. Declarative authoring is restricted to
|
||
/// GROUP-owned + `shared = true` — it fires when a message in the group's
|
||
/// SHARED queue is exhausted (dead-lettered). An app-owned or non-shared
|
||
/// `dead_letter` bundle trigger is rejected in `validate_bundle_for`.
|
||
DeadLetter {
|
||
script: String,
|
||
/// Match only dead-letters filed under this source (`"queue"` for a
|
||
/// shared-queue exhaustion). `None` matches any source.
|
||
#[serde(default)]
|
||
source_filter: Option<String>,
|
||
#[serde(default)]
|
||
dispatch_mode: Option<TriggerDispatchMode>,
|
||
#[serde(default)]
|
||
retry_max_attempts: Option<u32>,
|
||
#[serde(default)]
|
||
shared: bool,
|
||
},
|
||
}
|
||
|
||
fn default_timezone() -> String {
|
||
"UTC".to_string()
|
||
}
|
||
|
||
impl BundleTrigger {
|
||
/// The script name this trigger fires.
|
||
#[must_use]
|
||
pub fn script(&self) -> &str {
|
||
match self {
|
||
Self::Kv { script, .. }
|
||
| Self::Docs { script, .. }
|
||
| Self::Files { script, .. }
|
||
| Self::Cron { script, .. }
|
||
| Self::Pubsub { script, .. }
|
||
| Self::Email { script, .. }
|
||
| Self::Queue { script, .. }
|
||
| Self::DeadLetter { script, .. } => script,
|
||
}
|
||
}
|
||
|
||
/// Whether this trigger is an email trigger, which resolves and decrypts
|
||
/// a stored secret by reference at apply time (gating an extra capability).
|
||
#[must_use]
|
||
pub fn is_email(&self) -> bool {
|
||
matches!(self, Self::Email { .. })
|
||
}
|
||
|
||
/// §11 tail: whether this template is `sealed` (non-suppressible). Only the
|
||
/// event kinds (which can be group templates) carry the flag; the app-only
|
||
/// kinds (cron/email/queue) are never sealed.
|
||
#[must_use]
|
||
pub fn sealed(&self) -> bool {
|
||
match self {
|
||
Self::Kv { sealed, .. }
|
||
| Self::Docs { sealed, .. }
|
||
| Self::Files { sealed, .. }
|
||
| Self::Pubsub { sealed, .. } => *sealed,
|
||
Self::Cron { .. }
|
||
| Self::Email { .. }
|
||
| Self::Queue { .. }
|
||
| Self::DeadLetter { .. } => false,
|
||
}
|
||
}
|
||
|
||
/// §11.6: whether this template watches a SHARED collection. The collection-
|
||
/// scoped event kinds (kv/docs/files) and pubsub (D2: a shared TOPIC) can be
|
||
/// shared; cron/email/queue are never shared here (a shared queue consumer
|
||
/// is authored as a `[[triggers.queue]]` and handled via materialization).
|
||
#[must_use]
|
||
pub fn shared(&self) -> bool {
|
||
match self {
|
||
Self::Kv { shared, .. }
|
||
| Self::Docs { shared, .. }
|
||
| Self::Files { shared, .. }
|
||
| Self::Pubsub { shared, .. }
|
||
| Self::Queue { shared, .. }
|
||
| Self::DeadLetter { shared, .. } => *shared,
|
||
Self::Cron { .. } | Self::Email { .. } => false,
|
||
}
|
||
}
|
||
|
||
/// The watched collection glob for the collection-scoped kinds (kv/docs/
|
||
/// files); `None` for the others.
|
||
#[must_use]
|
||
pub fn collection_glob(&self) -> Option<&str> {
|
||
match self {
|
||
Self::Kv {
|
||
collection_glob, ..
|
||
}
|
||
| Self::Docs {
|
||
collection_glob, ..
|
||
}
|
||
| Self::Files {
|
||
collection_glob, ..
|
||
} => Some(collection_glob),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Stable semantic identity (the per-kind tuple), used for the diff.
|
||
#[must_use]
|
||
pub fn identity(&self) -> String {
|
||
match self {
|
||
// §11 tail: `sealed` is part of the identity for the event kinds, so
|
||
// toggling it re-diffs (a Create + a Delete-on-prune of the stale
|
||
// row) like any other definitional change on a template.
|
||
Self::Kv {
|
||
script,
|
||
collection_glob,
|
||
ops,
|
||
sealed,
|
||
shared,
|
||
..
|
||
} => format!(
|
||
"kv|{script}|{collection_glob}|{}|{sealed}|{shared}",
|
||
sorted_csv(ops.iter().copied().map(KvEventOp::as_str))
|
||
),
|
||
Self::Docs {
|
||
script,
|
||
collection_glob,
|
||
ops,
|
||
sealed,
|
||
shared,
|
||
..
|
||
} => format!(
|
||
"docs|{script}|{collection_glob}|{}|{sealed}|{shared}",
|
||
sorted_csv(ops.iter().copied().map(DocsEventOp::as_str))
|
||
),
|
||
Self::Files {
|
||
script,
|
||
collection_glob,
|
||
ops,
|
||
sealed,
|
||
shared,
|
||
..
|
||
} => format!(
|
||
"files|{script}|{collection_glob}|{}|{sealed}|{shared}",
|
||
sorted_csv(ops.iter().copied().map(FilesEventOp::as_str))
|
||
),
|
||
Self::Cron {
|
||
script,
|
||
schedule,
|
||
timezone,
|
||
..
|
||
} => format!("cron|{script}|{schedule}|{timezone}"),
|
||
Self::Pubsub {
|
||
script,
|
||
topic_pattern,
|
||
sealed,
|
||
shared,
|
||
..
|
||
} => format!("pubsub|{script}|{topic_pattern}|{sealed}|{shared}"),
|
||
Self::Email { script, .. } => format!("email|{script}"),
|
||
Self::Queue {
|
||
queue_name, shared, ..
|
||
} => format!("queue|{queue_name}|{shared}"),
|
||
// §11.6 B2: mirrored by `current_trigger_identity` for a group-owned
|
||
// shared dead_letter, so a re-apply diffs as NoOp.
|
||
Self::DeadLetter {
|
||
script,
|
||
source_filter,
|
||
shared,
|
||
..
|
||
} => format!(
|
||
"dead_letter|{script}|{}|{shared}",
|
||
source_filter.as_deref().unwrap_or("")
|
||
),
|
||
}
|
||
}
|
||
|
||
#[must_use]
|
||
pub fn kind_str(&self) -> &'static str {
|
||
match self {
|
||
Self::Kv { .. } => "kv",
|
||
Self::Docs { .. } => "docs",
|
||
Self::Files { .. } => "files",
|
||
Self::Cron { .. } => "cron",
|
||
Self::Pubsub { .. } => "pubsub",
|
||
Self::Email { .. } => "email",
|
||
Self::Queue { .. } => "queue",
|
||
Self::DeadLetter { .. } => "dead_letter",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One change in the plan. `key` is the human-renderable identity.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||
pub struct ResourceChange {
|
||
pub op: Op,
|
||
pub key: String,
|
||
/// Optional one-line note (e.g. why an update, or "value must be set").
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub detail: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum Op {
|
||
Create,
|
||
Update,
|
||
NoOp,
|
||
Delete,
|
||
}
|
||
|
||
/// The computed diff, grouped by resource kind.
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||
pub struct Plan {
|
||
pub scripts: Vec<ResourceChange>,
|
||
pub routes: Vec<ResourceChange>,
|
||
pub triggers: Vec<ResourceChange>,
|
||
pub secrets: Vec<ResourceChange>,
|
||
#[serde(default)]
|
||
pub vars: Vec<ResourceChange>,
|
||
#[serde(default)]
|
||
pub extension_points: Vec<ResourceChange>,
|
||
#[serde(default)]
|
||
pub collections: Vec<ResourceChange>,
|
||
/// §11 tail per-app suppression markers, keyed `"{target_kind}:{reference}"`.
|
||
#[serde(default)]
|
||
pub suppressions: Vec<ResourceChange>,
|
||
/// v1.2 Workflows: workflow definitions, keyed by name.
|
||
#[serde(default)]
|
||
pub workflows: Vec<ResourceChange>,
|
||
/// §9.4 interceptor markers, keyed `"{service}/{op}"` with the script as the
|
||
/// value (create/update/delete like `vars`).
|
||
#[serde(default)]
|
||
pub interceptors: Vec<ResourceChange>,
|
||
}
|
||
|
||
impl Plan {
|
||
/// True when every resource is a no-op (nothing to apply).
|
||
#[must_use]
|
||
pub fn is_noop(&self) -> bool {
|
||
self.scripts
|
||
.iter()
|
||
.chain(&self.routes)
|
||
.chain(&self.triggers)
|
||
.chain(&self.secrets)
|
||
.chain(&self.vars)
|
||
.chain(&self.extension_points)
|
||
.chain(&self.collections)
|
||
.chain(&self.suppressions)
|
||
.chain(&self.workflows)
|
||
.chain(&self.interceptors)
|
||
.all(|c| c.op == Op::NoOp)
|
||
}
|
||
}
|
||
|
||
/// What `plan` returns: the diff plus a fingerprint of the live state it was
|
||
/// computed against. The token is flattened onto the plan JSON, so the wire
|
||
/// shape stays `{ scripts, routes, triggers, secrets, state_token }`.
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct PlanResult {
|
||
#[serde(flatten)]
|
||
pub plan: Plan,
|
||
pub state_token: String,
|
||
/// §7 M3: the ownership outcome this apply would produce (claim / owned /
|
||
/// conflict / unclaimed). Present only when a `[project]` was declared.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub ownership: Option<OwnershipPreview>,
|
||
/// §3 M3: environments the governing project marks confirm-required — applying
|
||
/// to one needs `pic apply --approve <env>`. Surfaced so CI sees the gate at
|
||
/// `plan` time, before an `apply` refuses.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub approvals_required: Vec<String>,
|
||
/// The desired-state warnings `apply` would emit, computed at plan so `pic
|
||
/// plan` is a faithful preview for CI: an enabled binding on a disabled
|
||
/// script (deployed-but-unreachable), an endpoint with no route/trigger, a
|
||
/// `[suppress]` that matches no inherited template. Post-commit side-effect
|
||
/// warnings (route-table refresh, materialization skips) are apply-only.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub warnings: Vec<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, 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,
|
||
/// §6: for a GROUP node, the declared parent group slug inferred from the
|
||
/// repo's directory nesting (`None` = the repo's attach point / instance
|
||
/// root). Absent for an app node, and absent on the wire from a pre-§6 CLI
|
||
/// (`#[serde(default)]`) — in which case a group is resolved as before and
|
||
/// must pre-exist. Used to CREATE a missing group and (M2) to detect a
|
||
/// server/manifest structural divergence.
|
||
#[serde(default)]
|
||
pub parent: Option<String>,
|
||
/// §6: a GROUP node's display name / description, used only when the group
|
||
/// is CREATED (a missing group defaults its name to the slug). Content
|
||
/// reconcile never touches them.
|
||
#[serde(default)]
|
||
pub name: Option<String>,
|
||
#[serde(default)]
|
||
pub description: Option<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<TreeNode>,
|
||
}
|
||
|
||
/// §7 multi-repo ownership: the `[project]` identity an apply declares. The
|
||
/// slug is committed in the repo's root manifest (stable across clones); the
|
||
/// first apply with a new slug registers the project (server-assigned UUID).
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
pub struct ProjectDecl {
|
||
pub slug: String,
|
||
#[serde(default)]
|
||
pub name: Option<String>,
|
||
/// §7/§6 attach point: the pre-existing group this repo binds under. Every
|
||
/// applied node must be strictly within its subtree; `None` = instance root
|
||
/// (no ceiling).
|
||
#[serde(default)]
|
||
pub parent_group: Option<String>,
|
||
/// §3 M3 per-env approval policy (the root manifest's `[project.environments]`).
|
||
/// A `confirm = true` env makes applying to it a gated, admin-gated + audited
|
||
/// step (`--approve <env>` required; a blanket `--yes` does NOT cover it). The
|
||
/// server re-derives the gate from THIS field — the CLI's client-side check is
|
||
/// convenience, this is the authoritative source. `#[serde(default)]` keeps a
|
||
/// pre-M3 client (which omits it) backward-compatible (no gate).
|
||
#[serde(default)]
|
||
pub environments: std::collections::BTreeMap<String, EnvPolicyDecl>,
|
||
}
|
||
|
||
/// §3 M3: one environment's server-side apply policy (mirrors the manifest's
|
||
/// `EnvPolicy`). `confirm = true` gates applying to this env.
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
pub struct EnvPolicyDecl {
|
||
#[serde(default)]
|
||
pub confirm: bool,
|
||
}
|
||
|
||
impl ProjectDecl {
|
||
/// Does environment `env` require explicit approval to apply (§3 M3)?
|
||
#[must_use]
|
||
pub fn confirm_required(&self, env: &str) -> bool {
|
||
self.environments.get(env).is_some_and(|p| p.confirm)
|
||
}
|
||
|
||
/// Names of every confirm-required environment, sorted (for `plan` to surface
|
||
/// so CI sees the gate before an `apply` refuses).
|
||
#[must_use]
|
||
pub fn gated_envs(&self) -> Vec<String> {
|
||
self.environments
|
||
.iter()
|
||
.filter(|(_, p)| p.confirm)
|
||
.map(|(name, _)| name.clone())
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
/// The ownership context threaded through an apply: the declared project (if
|
||
/// any), whether the caller passed `--takeover`, and the principal (its
|
||
/// `user_id` is the actor; a takeover additionally requires `GroupAdmin`).
|
||
pub struct OwnershipClaim<'a> {
|
||
pub project: Option<ProjectDecl>,
|
||
pub takeover: bool,
|
||
pub principal: &'a Principal,
|
||
}
|
||
|
||
/// §7 M3 plan preview: what applying this node WOULD do about ownership, so
|
||
/// `pic plan` surfaces a conflict before `pic apply`. `action` ∈ `claim`
|
||
/// (unclaimed → this project claims it), `owned` (already this project),
|
||
/// `conflict` (owned by `owner`), `unclaimed` (no project, or an app in open
|
||
/// territory). `owner` is the current owner's slug for `owned`/`conflict`.
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct OwnershipPreview {
|
||
pub action: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub owner: Option<String>,
|
||
/// §7 M3.2 cross-repo blast radius: for a GROUP node, the descendant apps
|
||
/// owned by OTHER projects that a config change here fans out to, grouped by
|
||
/// project. Empty for an app node or when nothing else is affected.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub blast_radius: Vec<ProjectImpact>,
|
||
}
|
||
|
||
/// One entry of a group plan's blast radius: another project and how many of
|
||
/// its apps inherit from the group being changed.
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct ProjectImpact {
|
||
pub project: String,
|
||
pub apps: u32,
|
||
}
|
||
|
||
/// Pure preview policy (read-only, slug-only — no registration): compares the
|
||
/// node's current owner slug against this apply's declared project slug. The
|
||
/// blast radius (a DB fan-out) is filled in by the caller for group nodes.
|
||
fn preview_ownership(
|
||
is_group: bool,
|
||
current: Option<&str>,
|
||
declared: Option<&str>,
|
||
) -> OwnershipPreview {
|
||
// Project the SAME pure policy the apply path runs onto a display label,
|
||
// keyed on slugs (plan has no assigned project id yet) and takeover-agnostic
|
||
// (`takeover = false`; plan never mutates ownership). Deriving the preview
|
||
// here — rather than re-encoding the arms — keeps plan and apply from
|
||
// drifting: one decision, two projections.
|
||
let cur = current.map(|s| (s.to_string(), s.to_string()));
|
||
let decl = declared.map(str::to_string);
|
||
let action = if is_group {
|
||
match decide_group_claim(cur, decl, false) {
|
||
GroupClaimAction::Claim(_) => "claim",
|
||
// Noop covers both "already ours" and "unclaimed + no project"; the
|
||
// current owner disambiguates.
|
||
GroupClaimAction::Noop if current.is_none() => "unclaimed",
|
||
GroupClaimAction::Noop => "owned",
|
||
// Takeover can't arise with `takeover = false`; treat defensively.
|
||
GroupClaimAction::Conflict(_) | GroupClaimAction::Takeover(_) => "conflict",
|
||
}
|
||
} else {
|
||
match decide_app_owner(cur, decl) {
|
||
Ok(()) if current.is_none() => "unclaimed",
|
||
Ok(()) => "owned",
|
||
Err(_) => "conflict",
|
||
}
|
||
};
|
||
OwnershipPreview {
|
||
action: action.to_string(),
|
||
owner: current.map(str::to_string),
|
||
blast_radius: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// §6 M2: how a tree apply resolves a group whose SERVER parent differs from the
|
||
/// manifest's declared (directory-nesting) parent — Terraform-style
|
||
/// detect-and-refuse. Request-only; `Refuse` is the default (a pre-M2 CLI omits
|
||
/// it), so a divergence never silently reshapes the tree.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
|
||
#[serde(rename_all = "kebab-case")]
|
||
pub enum StructureMode {
|
||
/// Refuse the apply on any structural divergence (422).
|
||
#[default]
|
||
Refuse,
|
||
/// Reparent each diverged group to the manifest's declared parent.
|
||
ForceLocal,
|
||
/// Accept the server's shape; reconcile content in place, no reparent.
|
||
AdoptServer,
|
||
}
|
||
|
||
/// §6 M2: a group node's structural state under the manifest — `in-sync` when
|
||
/// the server parent matches the declared (directory-nesting) parent, else
|
||
/// `diverged` (naming both parents). Previewed by `pic plan`.
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct StructurePreview {
|
||
pub status: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub server_parent: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub manifest_parent: Option<String>,
|
||
}
|
||
|
||
/// 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,
|
||
/// §7 M3: this node's ownership outcome under the tree's `[project]`.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub ownership: Option<OwnershipPreview>,
|
||
/// §6 M2: for a GROUP node, its structural state (in-sync / diverged).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub structure: Option<StructurePreview>,
|
||
/// The desired-state warnings this node's apply would emit (same set as
|
||
/// [`PlanResult::warnings`]), so `pic plan --dir` previews them per node.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub warnings: Vec<String>,
|
||
}
|
||
|
||
/// 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<NodePlan>,
|
||
pub state_token: String,
|
||
/// §3 M3: environments the project marks confirm-required (see
|
||
/// [`PlanResult::approvals_required`]). Surfaced so CI sees the gate at plan.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub approvals_required: Vec<String>,
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------
|
||
// Current state snapshot
|
||
// ----------------------------------------------------------------------------
|
||
|
||
/// The app's live state, loaded once for a diff.
|
||
#[derive(Debug, Default)]
|
||
pub struct CurrentState {
|
||
pub scripts: Vec<Script>,
|
||
pub routes: Vec<Route>,
|
||
pub triggers: Vec<Trigger>,
|
||
pub secret_names: Vec<String>,
|
||
/// App's OWN `vars` at scope `*` (key, value), non-tombstone — the set
|
||
/// the manifest reconciles. Inherited group vars and tombstones are
|
||
/// excluded (the manifest manages only the app's own real values).
|
||
pub vars: Vec<(String, serde_json::Value)>,
|
||
/// Extension-point marker names declared directly at this node (§5.5).
|
||
pub extension_point_names: Vec<String>,
|
||
/// Shared group collections declared directly at this node (§11.6), as
|
||
/// `(name, kind)` pairs.
|
||
pub collections: Vec<(String, String)>,
|
||
/// §11 tail per-app suppression markers at this node (app-only), as
|
||
/// `(target_kind, reference)` pairs.
|
||
pub suppressions: Vec<(String, String)>,
|
||
/// v1.2 Workflows owned directly by this node (app-owned; empty for a group).
|
||
pub workflows: Vec<crate::workflow_repo::Workflow>,
|
||
/// §9.4 interceptor markers declared directly at this node, as
|
||
/// `(service, op, script)` triples.
|
||
pub interceptors: Vec<(String, String, String)>,
|
||
}
|
||
|
||
/// One row of the read-only extension-point report (§5.5).
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ExtensionPointInfo {
|
||
pub name: String,
|
||
/// True when this node owns the marker (vs inheriting it from an ancestor).
|
||
pub declared_here: bool,
|
||
/// Resolved provider for an app (`app override` / `inherited default`), or
|
||
/// `None` when unset (no provider — a plan error) or for a group node.
|
||
pub provider: Option<String>,
|
||
}
|
||
|
||
/// One row of the read-only §11.6 shared-collection report.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct CollectionInfo {
|
||
pub name: String,
|
||
pub kind: String,
|
||
}
|
||
|
||
/// One row of the read-only §11 tail suppression report (`pic suppress ls
|
||
/// --app`). `target_kind` is `trigger` | `route`; `reference` is the handler
|
||
/// script name (trigger) or path (route) the app declines.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct SuppressionInfo {
|
||
pub target_kind: String,
|
||
pub reference: String,
|
||
}
|
||
|
||
/// One row of the read-only §11 tail trigger-template report (`pic triggers ls
|
||
/// --group`). `target` is the kind-specific identity bit (collection glob or
|
||
/// topic pattern); `script` is the handler script's name.
|
||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||
pub struct TriggerTemplateInfo {
|
||
pub kind: String,
|
||
pub target: String,
|
||
pub script: String,
|
||
pub enabled: bool,
|
||
/// §11 tail: `true` for a sealed (non-suppressible) template.
|
||
pub sealed: bool,
|
||
/// §11.6: `true` for a shared-collection template.
|
||
pub shared: bool,
|
||
/// Full `TriggerDetails` as internally-tagged JSON (`{ "kind": …, … }`),
|
||
/// so `pic pull --group` can round-trip a template's ops / schedule /
|
||
/// timezone / queue name / visibility timeout, not just the display target.
|
||
/// `#[serde(default)]` on the read side so an older server (which omits it)
|
||
/// still deserializes; the display command reads `target`, not this.
|
||
#[serde(default)]
|
||
pub details: serde_json::Value,
|
||
/// Wire dispatch mode (`sync`/`async`) — round-trip only.
|
||
#[serde(default)]
|
||
pub dispatch_mode: String,
|
||
/// Per-trigger retry ceiling — round-trip only.
|
||
#[serde(default)]
|
||
pub retry_max_attempts: u32,
|
||
}
|
||
|
||
/// One row of the read-only §11 tail route-template report (`pic routes ls
|
||
/// --group`). The binding tuple a descendant app inherits, plus the handler
|
||
/// script's name. The stored route `name` is a UUID for reconcile-created
|
||
/// rows, so the semantic bits are shown instead.
|
||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||
pub struct RouteTemplateInfo {
|
||
/// `None` = any method. Carried raw (not the "ANY" display munge) so
|
||
/// `pic pull --group` round-trips it; the display command applies the munge.
|
||
pub method: Option<String>,
|
||
pub host_kind: HostKind,
|
||
/// Raw host (empty for `Any`, suffix for `Wildcard`), not the display munge.
|
||
pub host: String,
|
||
pub host_param_name: Option<String>,
|
||
pub path_kind: PathKind,
|
||
pub path: String,
|
||
pub script: String,
|
||
pub dispatch_mode: DispatchMode,
|
||
pub enabled: bool,
|
||
/// §11 tail: `true` for a sealed (non-suppressible) template.
|
||
pub sealed: bool,
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------
|
||
// Errors
|
||
// ----------------------------------------------------------------------------
|
||
|
||
#[derive(Debug, thiserror::Error)]
|
||
pub enum ApplyError {
|
||
#[error("app not found: {0}")]
|
||
AppNotFound(String),
|
||
#[error("invalid manifest: {0}")]
|
||
Invalid(String),
|
||
#[error(
|
||
"live state changed since `pic plan` (someone edited this app's scripts, \
|
||
routes, triggers, or secrets); re-run `pic plan` to review, then apply — \
|
||
or `pic apply --force` to skip the check"
|
||
)]
|
||
StateMoved,
|
||
#[error("forbidden")]
|
||
Forbidden,
|
||
/// §3 M3: applying to a confirm-required environment without an explicit
|
||
/// per-env approval. Maps to 409 — actionable (`--approve <env>`).
|
||
#[error(
|
||
"environment `{0}` requires explicit approval to apply; \
|
||
re-run with `--approve {0}` (a blanket `--yes` does not cover it)"
|
||
)]
|
||
ApprovalRequired(String),
|
||
/// §7 multi-repo ownership: this apply's `[project]` does not own a node it
|
||
/// touches (or the node is unclaimed and the apply declared no project).
|
||
/// Maps to 409 — actionable (`use --takeover`, `declare [project]`).
|
||
#[error("ownership conflict: {0}")]
|
||
OwnershipConflict(String),
|
||
/// §6/§7: a node is above/outside this project's declared `parent_group`
|
||
/// attach point — you can only apply within its subtree. Maps to 422.
|
||
#[error("outside attach point: {0}")]
|
||
OutsideAttachPoint(String),
|
||
/// §6 M2: a group's SERVER parent differs from the manifest's declared
|
||
/// (directory-nesting) parent, and no `--force-local-structure` /
|
||
/// `--adopt-server-structure` was given. Maps to 422 — actionable.
|
||
#[error("structural divergence: {0}")]
|
||
StructuralDivergence(String),
|
||
#[error("authorization repo error: {0}")]
|
||
AuthzRepo(String),
|
||
#[error("backend: {0}")]
|
||
Backend(String),
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------
|
||
// Service
|
||
// ----------------------------------------------------------------------------
|
||
|
||
/// Reconcile engine. Holds trait-object repos for the read/diff path; the
|
||
/// transactional write path (`apply`) reuses the same handles plus `pool`.
|
||
#[derive(Clone)]
|
||
pub struct ApplyService {
|
||
/// Pool for the transactional write path (`apply`).
|
||
pub pool: PgPool,
|
||
pub scripts: Arc<dyn ScriptRepository>,
|
||
pub routes: Arc<dyn RouteRepository>,
|
||
pub triggers: Arc<dyn TriggerRepo>,
|
||
pub secrets: Arc<dyn SecretsRepo>,
|
||
/// App-owned `vars` reconciliation (read/diff path; the transactional
|
||
/// write goes through `set_app_var_tx` / `delete_app_var_tx`).
|
||
pub vars: Arc<dyn VarsRepo>,
|
||
pub apps: Arc<dyn AppRepository>,
|
||
/// Group lookups (Phase 5): resolve a group slug→id for group/tree apply.
|
||
pub groups: Arc<dyn crate::group_repo::GroupRepository>,
|
||
/// Project registry (§7 multi-repo ownership): the READ side. The claim
|
||
/// write path registers projects via `upsert_project_tx` inside the apply
|
||
/// transaction, not through this repo.
|
||
pub projects: Arc<dyn crate::project_repo::ProjectRepository>,
|
||
/// Module resolver (Phase 4b): lexical, origin-aware module lookup used by
|
||
/// the single-node dangling-import plan check (§5.5).
|
||
pub modules: Arc<dyn picloud_shared::ModuleSource>,
|
||
/// App domain claims — validates a route's host belongs to the app.
|
||
pub domains: Arc<dyn AppDomainRepository>,
|
||
pub authz: Arc<dyn AuthzRepo>,
|
||
pub validator: Arc<dyn ScriptValidator>,
|
||
pub sandbox_ceiling: SandboxCeiling,
|
||
/// Default retry/dispatch settings filled when a bundle trigger omits them.
|
||
pub trigger_config: TriggerConfig,
|
||
/// Shared route snapshot, rebuilt once after a successful apply.
|
||
pub route_table: Arc<RouteTable>,
|
||
/// Master key — seals email triggers' resolved inbound secrets.
|
||
pub master_key: MasterKey,
|
||
}
|
||
|
||
impl ApplyService {
|
||
/// Compute the diff between `bundle` and app `app_id`'s live state.
|
||
/// Read-only: validates the bundle, loads current state, diffs.
|
||
///
|
||
/// # Errors
|
||
/// `Invalid` for a malformed bundle; `Backend` for repo failures.
|
||
pub async fn plan(
|
||
&self,
|
||
app_id: AppId,
|
||
bundle: &Bundle,
|
||
project: Option<&ProjectDecl>,
|
||
) -> Result<PlanResult, ApplyError> {
|
||
self.plan_owner(ApplyOwner::App(app_id), bundle, project)
|
||
.await
|
||
}
|
||
|
||
/// The sorted names of every gated env in a computed policy (`confirm`
|
||
/// entries), surfaced as `plan.approvals_required`. Callers pass the
|
||
/// governing policy so plan's preview matches what apply gates on.
|
||
fn gated_envs_from(policy: &std::collections::BTreeMap<String, bool>) -> Vec<String> {
|
||
let mut gated: Vec<String> = policy
|
||
.iter()
|
||
.filter(|&(_, confirm)| *confirm)
|
||
.map(|(env, _)| env.clone())
|
||
.collect();
|
||
gated.sort_unstable();
|
||
gated
|
||
}
|
||
|
||
/// Owner-generic plan (Phase 5): diff `bundle` against an app OR group node.
|
||
/// A group node has only scripts + vars — routes/triggers are rejected, and
|
||
/// the app-only inherited-target + route-host validation is skipped.
|
||
///
|
||
/// # Errors
|
||
/// `Invalid` for a malformed bundle; `Backend` for repo failures.
|
||
pub async fn plan_owner(
|
||
&self,
|
||
owner: ApplyOwner,
|
||
bundle: &Bundle,
|
||
project: Option<&ProjectDecl>,
|
||
) -> Result<PlanResult, ApplyError> {
|
||
let inherited = self.resolve_inherited_targets_for(owner, bundle).await?;
|
||
self.validate_bundle_for(
|
||
matches!(owner, ApplyOwner::Group(_)),
|
||
bundle,
|
||
&keys_set(&inherited),
|
||
)?;
|
||
self.check_imports_resolve(owner, bundle).await?;
|
||
self.check_extension_points_provided(owner, bundle).await?;
|
||
if let ApplyOwner::App(app_id) = owner {
|
||
self.validate_route_hosts(app_id, bundle).await?;
|
||
}
|
||
// §6/§7 M3: preview the attach-point ceiling (same rule as apply) so a
|
||
// `pic plan` outside the subtree fails early, consistent with apply.
|
||
if let Some(pg_slug) = project.and_then(|p| p.parent_group.as_deref()) {
|
||
let pg = self.resolve_group(pg_slug).await?;
|
||
self.check_within_attach(owner, pg, pg_slug).await?;
|
||
}
|
||
let current = self.load_current(owner).await?;
|
||
let current_names = self.resolve_current_target_names(¤t).await?;
|
||
validate_email_secrets_present(bundle, ¤t.secret_names)?;
|
||
let ownership = self
|
||
.ownership_preview(owner, project.map(|p| p.slug.as_str()))
|
||
.await?;
|
||
Ok(PlanResult {
|
||
plan: compute_diff_with_names(¤t, bundle, ¤t_names),
|
||
// Fingerprint of the live state this plan was computed against, so
|
||
// `apply` can refuse if the node changed underneath it (§4.2).
|
||
state_token: state_token_with_names(¤t, ¤t_names),
|
||
ownership,
|
||
approvals_required: Self::gated_envs_from(
|
||
&self.governing_env_policy(owner, project).await?,
|
||
),
|
||
warnings: self.plan_warnings(owner, bundle).await?,
|
||
})
|
||
}
|
||
|
||
/// Inherited group-script targets for an app node; a group node has no
|
||
/// routes/triggers to bind, so there is nothing to inherit (empty map).
|
||
async fn resolve_inherited_targets_for(
|
||
&self,
|
||
owner: ApplyOwner,
|
||
bundle: &Bundle,
|
||
) -> Result<HashMap<String, ScriptId>, ApplyError> {
|
||
match owner {
|
||
ApplyOwner::App(app_id) => self.resolve_inherited_targets(app_id, bundle).await,
|
||
// §11 tail: a group trigger or ROUTE TEMPLATE binds the group's OWN
|
||
// endpoint scripts (declared here or pre-existing). Surface the ones
|
||
// a template references but the bundle doesn't declare, so
|
||
// `validate_bundle` accepts them (the reconcile's name_to_id already
|
||
// includes the group's current scripts).
|
||
ApplyOwner::Group(group_id) => {
|
||
let declared: HashSet<String> = bundle
|
||
.scripts
|
||
.iter()
|
||
.map(|s| s.name.to_lowercase())
|
||
.collect();
|
||
let referenced: HashSet<String> = bundle
|
||
.triggers
|
||
.iter()
|
||
.map(|t| t.script().to_lowercase())
|
||
.chain(bundle.routes.iter().map(|r| r.script.to_lowercase()))
|
||
.collect();
|
||
let by_name: HashMap<String, ScriptId> = self
|
||
.scripts
|
||
.list_for_group(group_id)
|
||
.await
|
||
.map_err(map_repo)?
|
||
.into_iter()
|
||
.filter(|s| s.kind == ScriptKind::Endpoint)
|
||
.map(|s| (s.name.to_lowercase(), s.id))
|
||
.collect();
|
||
let mut out = HashMap::new();
|
||
for name in referenced {
|
||
if declared.contains(&name) {
|
||
continue;
|
||
}
|
||
if let Some(id) = by_name.get(&name) {
|
||
out.insert(name, *id);
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `validate_bundle`, plus a group-node guard: a group owns only scripts +
|
||
/// vars (+ declared secret names), so a `[group]` manifest carrying routes
|
||
/// or triggers is a 422 — those are app concerns.
|
||
#[allow(clippy::too_many_lines)]
|
||
fn validate_bundle_for(
|
||
&self,
|
||
is_group: bool,
|
||
bundle: &Bundle,
|
||
inherited_endpoints: &HashSet<String>,
|
||
) -> Result<(), ApplyError> {
|
||
if is_group {
|
||
// §11 tail: a group MAY declare ROUTE TEMPLATES (validated as binding
|
||
// a group-owned endpoint by `validate_bundle` via the inherited
|
||
// targets) and TRIGGER TEMPLATES. The stateless EVENT kinds
|
||
// (kv/docs/files/pubsub) resolve live via the chain union at
|
||
// dispatch; §4.5 M5 the stateful CRON + QUEUE + EMAIL kinds are
|
||
// allowed and MATERIALIZED into a per-descendant-app row. An email
|
||
// template resolves its inbound secret against the GROUP's own secret
|
||
// store once at apply (shared-group-secret model); materialization
|
||
// copies the sealed bytes verbatim.
|
||
for t in &bundle.triggers {
|
||
// §11.6: a `shared` trigger watches a SHARED collection/namespace.
|
||
// kv/docs/files watch a shared collection (name = the glob); D2
|
||
// pubsub watches a shared TOPIC namespace (name = the topic
|
||
// pattern's ROOT segment, e.g. `events.*` → `events`). The watched
|
||
// name must be declared shared of the matching kind on the SAME
|
||
// group; a wildcard can't be statically resolved, so it's allowed
|
||
// (runtime match).
|
||
if t.shared() {
|
||
let (watched, coll_kind) = match t {
|
||
BundleTrigger::Kv { .. }
|
||
| BundleTrigger::Docs { .. }
|
||
| BundleTrigger::Files { .. } => {
|
||
(t.collection_glob().unwrap_or_default(), t.kind_str())
|
||
}
|
||
BundleTrigger::Pubsub { topic_pattern, .. } => {
|
||
(topic_pattern.split('.').next().unwrap_or(""), "topic")
|
||
}
|
||
BundleTrigger::Queue { queue_name, .. } => (queue_name.as_str(), "queue"),
|
||
// §11.6 B2: a shared dead_letter watches the group's
|
||
// shared queue exhaustion, not a named collection store —
|
||
// no declared-collection requirement. The group/shared
|
||
// gate for it lives below.
|
||
BundleTrigger::DeadLetter { .. } => continue,
|
||
_ => {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"a `shared` trigger must be a kv/docs/files/pubsub/queue kind; \
|
||
`{}` has no shared collection store",
|
||
t.kind_str()
|
||
)));
|
||
}
|
||
};
|
||
let is_wildcard = watched.contains('*');
|
||
let declared = bundle
|
||
.collections
|
||
.iter()
|
||
.any(|c| c.kind == coll_kind && c.name.eq_ignore_ascii_case(watched));
|
||
if !is_wildcard && !declared {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"a `shared` trigger watches `{watched}`, which this group \
|
||
does not declare as a shared {coll_kind} collection (add \
|
||
it to `collections`)"
|
||
)));
|
||
}
|
||
}
|
||
// §11.6 B2: a group `dead_letter` template is only meaningful as
|
||
// `shared = true` — it fires on the group's SHARED queue
|
||
// exhaustion. A non-shared group dead_letter would never fire (no
|
||
// per-app queue to watch at the group level), so reject it.
|
||
if matches!(t, BundleTrigger::DeadLetter { .. }) && !t.shared() {
|
||
return Err(ApplyError::Invalid(
|
||
"a group `dead_letter` trigger must be `shared = true` — it \
|
||
fires on the group's shared-queue exhaustion"
|
||
.into(),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
// §11.6: shared collections are owned by GROUPS. Reject them on an app
|
||
// node — the inverse of the group route/trigger guard above. The CLI
|
||
// already prevents this (`ManifestApp` has no `collections` field), so
|
||
// this is defense-in-depth against a hand-rolled wire `Bundle`; without
|
||
// it the reconcile would insert an inert `app_id`-owned marker row that
|
||
// no resolver ever reads.
|
||
if !is_group && !bundle.collections.is_empty() {
|
||
return Err(app_only_reject(
|
||
"manifest cannot declare shared collections",
|
||
"they are owned by a group",
|
||
));
|
||
}
|
||
// §11 tail: `sealed` is a GROUP-template property — it makes an
|
||
// inherited template non-suppressible. On an app-owned route/trigger it
|
||
// is meaningless (nothing inherits it), so reject it rather than persist
|
||
// a misleading `sealed` app row the filters never consult. Defense in
|
||
// depth over the CLI (which authors `sealed` freely but only a group
|
||
// node inherits down).
|
||
if !is_group {
|
||
if bundle.routes.iter().any(|r| r.sealed) {
|
||
return Err(app_only_reject(
|
||
"route cannot be `sealed`",
|
||
"sealing marks a group template as non-suppressible; an app \
|
||
route is never inherited",
|
||
));
|
||
}
|
||
if bundle.triggers.iter().any(BundleTrigger::sealed) {
|
||
return Err(app_only_reject(
|
||
"trigger cannot be `sealed`",
|
||
"sealing marks a group template as non-suppressible; an app \
|
||
trigger is never inherited",
|
||
));
|
||
}
|
||
// §11.6: `shared` watches a group's SHARED collection — meaningless
|
||
// for an app (apps don't own shared collections).
|
||
if bundle.triggers.iter().any(BundleTrigger::shared) {
|
||
return Err(app_only_reject(
|
||
"trigger cannot be `shared`",
|
||
"a shared-collection trigger is owned by the group that \
|
||
declares the collection",
|
||
));
|
||
}
|
||
// §11.6 B2: a `dead_letter` trigger is authored only as a group-owned
|
||
// shared template (it watches the group's shared-queue exhaustion).
|
||
// An app-owned dead_letter handler is created via `pic triggers`, not
|
||
// the declarative manifest.
|
||
if bundle
|
||
.triggers
|
||
.iter()
|
||
.any(|t| matches!(t, BundleTrigger::DeadLetter { .. }))
|
||
{
|
||
return Err(app_only_reject(
|
||
"trigger cannot be a `dead_letter` kind",
|
||
"a declarative dead_letter is a group-owned shared template; \
|
||
create an app dead-letter handler with `pic triggers`",
|
||
));
|
||
}
|
||
}
|
||
// §11 tail M1: both an app and a group may declare suppressions — an
|
||
// app declines an inherited template for itself, a group for its whole
|
||
// subtree. Both are inheritance-only (the dispatch filters gate to
|
||
// group-owned templates on the suppressing owner's chain), so no
|
||
// owner-kind guard here.
|
||
//
|
||
// v1.2 Workflows: app-owned only (M1). Reject on a group node (the
|
||
// `group_id` column ships unused). Then structurally validate each
|
||
// definition (unique/acyclic steps, deps exist, when/template parse).
|
||
if is_group && !bundle.workflows.is_empty() {
|
||
return Err(ApplyError::Invalid(
|
||
"a group cannot declare workflows — they are app-owned (group-owned \
|
||
workflow templates are a future addition)"
|
||
.into(),
|
||
));
|
||
}
|
||
// Duplicate workflow names collide in the reconcile diff (keyed by
|
||
// `lower(name)`), silently dropping all but one. Reject them up front —
|
||
// case-insensitively, matching the diff key and the DB's partial-unique
|
||
// index on `(app_id, lower(name))`.
|
||
let mut seen_workflows: HashSet<String> = HashSet::new();
|
||
for w in &bundle.workflows {
|
||
if !seen_workflows.insert(w.name.to_lowercase()) {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"duplicate workflow name `{}` (names are case-insensitive)",
|
||
w.name
|
||
)));
|
||
}
|
||
}
|
||
for w in &bundle.workflows {
|
||
validate_workflow_definition(&w.name, &w.definition).map_err(ApplyError::Invalid)?;
|
||
}
|
||
// §9.4 interceptors (MVP): `service = "kv"`, `op ∈ {set, delete}`,
|
||
// authored on app OR group. One marker per `(service, op)` — a duplicate
|
||
// would collide on the reconcile key and the DB's partial-unique index.
|
||
let mut seen_hooks: HashSet<(String, String)> = HashSet::new();
|
||
for i in &bundle.interceptors {
|
||
if i.service != "kv" {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"interceptor service `{}` is not supported — only `kv` (MVP)",
|
||
i.service
|
||
)));
|
||
}
|
||
if i.op != "set" && i.op != "delete" {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"interceptor op `{}` is not supported for kv — only `set` / `delete`",
|
||
i.op
|
||
)));
|
||
}
|
||
if i.script.trim().is_empty() {
|
||
return Err(ApplyError::Invalid(
|
||
"an interceptor must name a `script`".into(),
|
||
));
|
||
}
|
||
if !seen_hooks.insert((i.service.clone(), i.op.clone())) {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"duplicate interceptor for `{}/{}`",
|
||
i.service, i.op
|
||
)));
|
||
}
|
||
}
|
||
self.validate_bundle(bundle, inherited_endpoints)
|
||
}
|
||
|
||
/// Reconcile app `app_id` to `bundle` in a single transaction. Creates
|
||
/// and updates are always applied; deletions of resources absent from the
|
||
/// bundle are executed only when `prune` is set (secrets are never pruned).
|
||
///
|
||
/// # 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<ScriptId, String>,
|
||
inherited: &HashMap<String, ScriptId>,
|
||
prune: bool,
|
||
actor: AdminUserId,
|
||
report: &mut ApplyReport,
|
||
) -> Result<HashMap<String, ScriptId>, ApplyError> {
|
||
let bundle_scripts: HashMap<String, &BundleScript> = bundle
|
||
.scripts
|
||
.iter()
|
||
.map(|s| (s.name.to_lowercase(), s))
|
||
.collect();
|
||
let current_scripts: HashMap<String, &Script> = 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<String, ScriptId> = 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<String, &Route> = 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<String, &BundleRoute> = 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)?;
|
||
// §11 tail: an app node writes an app-owned route; a group node
|
||
// writes a route TEMPLATE (group_id owner) that expands into every
|
||
// descendant app's slice at rebuild.
|
||
insert_bundle_route(&mut *tx, owner.as_script_owner(), 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<String, &BundleTrigger> =
|
||
bundle.triggers.iter().map(|t| (t.identity(), t)).collect();
|
||
for ch in &plan.triggers {
|
||
if ch.op != Op::Create {
|
||
continue;
|
||
}
|
||
// §11 tail: an app node owns triggers; a group node owns trigger
|
||
// TEMPLATES (event kinds only — validated in `validate_bundle_for`).
|
||
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 against the OWNER's store,
|
||
// decrypt it, and re-seal it into the email trigger's detail
|
||
// row — the value never travels in the manifest. §4.5 M5: an
|
||
// app trigger resolves the app's secret; a group EMAIL TEMPLATE
|
||
// resolves the group's own secret once here, and materialization
|
||
// copies the sealed bytes verbatim to each descendant.
|
||
let (ct, nonce, version) = self
|
||
.resolve_and_seal(owner.secret_owner(), inbound_secret_ref)
|
||
.await?;
|
||
insert_email_trigger_tx(
|
||
&mut *tx,
|
||
owner.as_script_owner(),
|
||
sid,
|
||
actor,
|
||
&ct,
|
||
&nonce,
|
||
version,
|
||
)
|
||
.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,
|
||
owner.as_script_owner(),
|
||
sid,
|
||
actor,
|
||
dispatch,
|
||
retry_max,
|
||
backoff,
|
||
base,
|
||
// §11 tail: a sealed group template is non-suppressible
|
||
// (rejected on an app owner in `validate_bundle_for`).
|
||
bt.sealed(),
|
||
// §11.6: a shared-collection group trigger.
|
||
bt.shared(),
|
||
&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 => {}
|
||
}
|
||
}
|
||
|
||
// 3c. Extension-point markers (§5.5) — insert each Create (idempotent).
|
||
// Name-only identity, so there is no Update; deletes happen in prune.
|
||
for ch in &plan.extension_points {
|
||
if ch.op == Op::Create {
|
||
crate::extension_point_repo::insert_extension_point_tx(
|
||
&mut *tx,
|
||
owner.as_script_owner(),
|
||
&ch.key,
|
||
)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
report.extension_points_created += 1;
|
||
}
|
||
}
|
||
|
||
// 3c.5. §9.4 interceptor markers — upsert each Create/Update by
|
||
// `(service, op)`. A changed script is an in-place Update; deletes happen
|
||
// in prune.
|
||
for ch in &plan.interceptors {
|
||
if ch.op == Op::Create || ch.op == Op::Update {
|
||
let bi = bundle
|
||
.interceptors
|
||
.iter()
|
||
.find(|i| format!("{}/{}", i.service, i.op) == ch.key)
|
||
.ok_or_else(|| {
|
||
ApplyError::Backend("internal: interceptor plan/bundle mismatch".into())
|
||
})?;
|
||
crate::interceptor_repo::insert_interceptor_tx(
|
||
&mut *tx,
|
||
owner.as_script_owner(),
|
||
&bi.service,
|
||
&bi.op,
|
||
&bi.script,
|
||
)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
if ch.op == Op::Create {
|
||
report.interceptors_created += 1;
|
||
} else {
|
||
report.interceptors_updated += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3d. Shared group-collection markers (§11.6) — insert each Create
|
||
// (idempotent). Name-only identity; deletes happen in prune. Pruning a
|
||
// marker hides the store but does NOT drop its `group_kv_entries` data
|
||
// (that dies only with the owning group).
|
||
for ch in &plan.collections {
|
||
if ch.op == Op::Create {
|
||
let kind = ch.detail.as_deref().unwrap_or("kv");
|
||
crate::group_collection_repo::insert_collection_tx(
|
||
&mut *tx,
|
||
owner.as_script_owner(),
|
||
&ch.key,
|
||
kind,
|
||
)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
report.collections_created += 1;
|
||
}
|
||
}
|
||
|
||
// 3e. Per-app suppression markers (§11 tail) — insert each Create
|
||
// (idempotent). Owner-polymorphic (§11 tail M1): an app declines for
|
||
// itself, a group for its whole subtree. Deletes happen in prune → the
|
||
// template re-inherits. Key is `"{target_kind}:{reference}"`; split on
|
||
// the FIRST `:` so a route reference containing `:` (a param path
|
||
// `/users/:id`) survives.
|
||
for ch in &plan.suppressions {
|
||
if ch.op == Op::Create {
|
||
let (kind, reference) = ch.key.split_once(':').expect("suppression key has ':'");
|
||
crate::suppression_repo::insert_suppression_tx(
|
||
&mut *tx,
|
||
owner.as_script_owner(),
|
||
kind,
|
||
reference,
|
||
)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
report.suppressions_created += 1;
|
||
}
|
||
}
|
||
|
||
// 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<ScriptId, String> = 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<String, TriggerId> = 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<ScriptId> = current
|
||
.triggers
|
||
.iter()
|
||
.filter(|t| {
|
||
matches!(
|
||
t.details,
|
||
TriggerDetails::Email { .. } | TriggerDetails::DeadLetter { .. }
|
||
)
|
||
})
|
||
.map(|t| t.script_id)
|
||
.collect();
|
||
let script_id_by_name: HashMap<String, ScriptId> = 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;
|
||
}
|
||
}
|
||
|
||
// Extension-point markers are prunable config too (§5.5).
|
||
for ch in &plan.extension_points {
|
||
if ch.op == Op::Delete {
|
||
crate::extension_point_repo::delete_extension_point_tx(
|
||
&mut *tx,
|
||
owner.as_script_owner(),
|
||
&ch.key,
|
||
)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
report.extension_points_deleted += 1;
|
||
}
|
||
}
|
||
|
||
// §9.4 interceptor markers are prunable config too. The delete keys
|
||
// by `(service, op)` (the marker's identity), so a script-change
|
||
// Update above is never clobbered here.
|
||
for ch in &plan.interceptors {
|
||
if ch.op == Op::Delete {
|
||
let (service, op) = ch.key.split_once('/').unwrap_or((ch.key.as_str(), ""));
|
||
crate::interceptor_repo::delete_interceptor_tx(
|
||
&mut *tx,
|
||
owner.as_script_owner(),
|
||
service,
|
||
op,
|
||
)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
report.interceptors_deleted += 1;
|
||
}
|
||
}
|
||
|
||
// Shared group-collection markers are prunable config too (§11.6).
|
||
// Only the marker is removed here — the data survives until the
|
||
// owning group is deleted.
|
||
for ch in &plan.collections {
|
||
if ch.op == Op::Delete {
|
||
let kind = ch.detail.as_deref().unwrap_or("kv");
|
||
crate::group_collection_repo::delete_collection_tx(
|
||
&mut *tx,
|
||
owner.as_script_owner(),
|
||
&ch.key,
|
||
kind,
|
||
)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
report.collections_deleted += 1;
|
||
}
|
||
}
|
||
|
||
// Suppression markers are prunable config too (§11 tail).
|
||
// Removing a marker re-inherits the template.
|
||
for ch in &plan.suppressions {
|
||
if ch.op == Op::Delete {
|
||
let (kind, reference) =
|
||
ch.key.split_once(':').expect("suppression key has ':'");
|
||
crate::suppression_repo::delete_suppression_tx(
|
||
&mut *tx,
|
||
owner.as_script_owner(),
|
||
kind,
|
||
reference,
|
||
)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
report.suppressions_deleted += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// v1.2 Workflows (app-owned in M1). Create/Update always; Delete only
|
||
// under --prune (mirrors scripts). Function / sub-workflow references
|
||
// are resolved at RUN time, so there is no in-tx ordering dependency on
|
||
// the scripts applied above.
|
||
if let ApplyOwner::App(app_id) = owner {
|
||
let cur_by_name: HashMap<String, &crate::workflow_repo::Workflow> = current
|
||
.workflows
|
||
.iter()
|
||
.map(|w| (w.name.to_lowercase(), w))
|
||
.collect();
|
||
let desired_by_name: HashMap<String, &BundleWorkflow> = bundle
|
||
.workflows
|
||
.iter()
|
||
.map(|w| (w.name.to_lowercase(), w))
|
||
.collect();
|
||
for ch in &plan.workflows {
|
||
let key = ch.key.to_lowercase();
|
||
match ch.op {
|
||
Op::Create => {
|
||
let w = desired_by_name[&key];
|
||
crate::workflow_repo::insert_workflow_tx(
|
||
&mut *tx,
|
||
app_id,
|
||
&w.name,
|
||
&w.definition,
|
||
w.enabled,
|
||
)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
report.workflows_created += 1;
|
||
}
|
||
Op::Update => {
|
||
let w = desired_by_name[&key];
|
||
let cur = cur_by_name[&key];
|
||
crate::workflow_repo::update_workflow_tx(
|
||
&mut *tx,
|
||
cur.id,
|
||
&w.definition,
|
||
w.enabled,
|
||
)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
report.workflows_updated += 1;
|
||
}
|
||
Op::Delete if prune => {
|
||
let cur = cur_by_name[&key];
|
||
crate::workflow_repo::delete_workflow_tx(&mut *tx, cur.id)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
report.workflows_deleted += 1;
|
||
}
|
||
Op::NoOp | Op::Delete => {}
|
||
}
|
||
}
|
||
}
|
||
Ok(name_to_id)
|
||
}
|
||
|
||
pub async fn apply(
|
||
&self,
|
||
app_id: AppId,
|
||
bundle: &Bundle,
|
||
prune: bool,
|
||
claim: &OwnershipClaim<'_>,
|
||
expected_token: Option<&str>,
|
||
) -> Result<ApplyReport, ApplyError> {
|
||
self.apply_owner(
|
||
ApplyOwner::App(app_id),
|
||
bundle,
|
||
prune,
|
||
claim,
|
||
expected_token,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Owner-generic apply (Phase 5): reconcile an app OR group node to `bundle`
|
||
/// in a single transaction. A group node reconciles only its scripts + vars.
|
||
///
|
||
/// # Errors
|
||
/// `Invalid` for a bad bundle; `Backend` for repo/transaction failures.
|
||
#[allow(clippy::too_many_lines)]
|
||
pub async fn apply_owner(
|
||
&self,
|
||
owner: ApplyOwner,
|
||
bundle: &Bundle,
|
||
prune: bool,
|
||
claim: &OwnershipClaim<'_>,
|
||
expected_token: Option<&str>,
|
||
) -> Result<ApplyReport, ApplyError> {
|
||
let actor = claim.principal.user_id;
|
||
let inherited = self.resolve_inherited_targets_for(owner, bundle).await?;
|
||
self.validate_bundle_for(
|
||
matches!(owner, ApplyOwner::Group(_)),
|
||
bundle,
|
||
&keys_set(&inherited),
|
||
)?;
|
||
self.check_imports_resolve(owner, bundle).await?;
|
||
self.check_extension_points_provided(owner, bundle).await?;
|
||
if let ApplyOwner::App(app_id) = owner {
|
||
self.validate_route_hosts(app_id, bundle).await?;
|
||
}
|
||
// §6/§7 attach point: refuse a node outside the project's declared
|
||
// `parent_group` subtree (read-only, before the tx).
|
||
if let Some(pg_slug) = claim
|
||
.project
|
||
.as_ref()
|
||
.and_then(|d| d.parent_group.as_deref())
|
||
{
|
||
let pg = self.resolve_group(pg_slug).await?;
|
||
self.check_within_attach(owner, pg, pg_slug).await?;
|
||
}
|
||
|
||
let mut tx = self
|
||
.pool
|
||
.begin()
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
// Serialize concurrent applies to the same app. NOTE: this lock
|
||
// serializes apply-vs-apply only — `load_current` below reads on the
|
||
// pool (a separate connection), so an *interactive* admin write
|
||
// committing between the read and our writes can surface as a
|
||
// unique-constraint conflict (clean tx rollback, no corruption) or a
|
||
// stale no-op delete. Reading current state through `&mut *tx` is the
|
||
// proper fix and is left as a follow-up. (The queue one-consumer
|
||
// invariant is closed independently in `insert_trigger_tx`.)
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(apply_lock_key(owner))
|
||
.execute(&mut *tx)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
|
||
// §7 ownership: register the declared project (first apply claims), then
|
||
// gate this node. A group node claims/verifies itself; an app node must
|
||
// match its nearest claimed ancestor. Runs under the lock above so two
|
||
// repos racing to claim the same group serialize. Short-circuits before
|
||
// any diff work on a conflict (409).
|
||
let declared_project = match &claim.project {
|
||
Some(decl) => Some(upsert_project_tx(&mut tx, decl, actor).await?),
|
||
None => None,
|
||
};
|
||
match owner {
|
||
ApplyOwner::Group(gid) => {
|
||
self.claim_group_owner(
|
||
&mut tx,
|
||
gid,
|
||
declared_project,
|
||
claim.takeover,
|
||
claim.principal,
|
||
)
|
||
.await?;
|
||
}
|
||
ApplyOwner::App(app_id) => {
|
||
self.check_app_owner_single(&mut tx, app_id, declared_project)
|
||
.await?;
|
||
}
|
||
}
|
||
|
||
let current = self.load_current(owner).await?;
|
||
// Phase 4: names of group scripts the current routes/triggers bind to,
|
||
// so the token / diff / prune resolve inherited bindings (see method).
|
||
let current_names = self.resolve_current_target_names(¤t).await?;
|
||
// Bound-plan check (§4.2): if the caller passed the token from a prior
|
||
// `pic plan`, refuse when the live state changed since. Computed from
|
||
// the same `load_current` snapshot the diff uses, before any mutation
|
||
// (so a stale apply rolls back nothing). NOTE: like the diff, this read
|
||
// is on the pool, not `&mut *tx` (see the lock comment above), so it
|
||
// catches drift between plan and apply but shares that same narrow
|
||
// apply-vs-interactive-write window — it is not a substitute for the
|
||
// tx-scoped read the follow-up will add.
|
||
if let Some(expected) = expected_token {
|
||
if state_token_with_names(¤t, ¤t_names) != expected {
|
||
return Err(ApplyError::StateMoved);
|
||
}
|
||
}
|
||
// Surface a missing email-secret reference here so `plan` and `apply`
|
||
// agree, rather than only failing deep in `resolve_and_seal` below.
|
||
validate_email_secrets_present(bundle, ¤t.secret_names)?;
|
||
let plan = compute_diff_with_names(¤t, bundle, ¤t_names);
|
||
let mut report = ApplyReport::default();
|
||
// The desired-state warnings — identical to what `pic plan` previews, so
|
||
// plan and apply agree (see `plan_warnings`).
|
||
report
|
||
.warnings
|
||
.extend(self.plan_warnings(owner, bundle).await?);
|
||
|
||
self.reconcile_node_tx(
|
||
&mut tx,
|
||
owner,
|
||
bundle,
|
||
&plan,
|
||
¤t,
|
||
¤t_names,
|
||
&inherited,
|
||
prune,
|
||
actor,
|
||
&mut report,
|
||
)
|
||
.await?;
|
||
|
||
tx.commit()
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
|
||
// Post-commit: rebuild the orchestrator's route snapshot once. A
|
||
// failure here doesn't undo the committed DB write — the table
|
||
// self-heals on the next route write or restart. §11 tail: a GROUP node
|
||
// may now declare route TEMPLATES, so refresh on either owner (the
|
||
// rebuild expands templates into every descendant app's slice).
|
||
{
|
||
if let Err(e) = self.refresh_route_table().await {
|
||
tracing::warn!(error = %e, "apply: route table refresh failed");
|
||
report
|
||
.warnings
|
||
.push("route table refresh failed; it will self-heal".into());
|
||
}
|
||
}
|
||
// §4.5 M5: re-materialize stateful (cron/queue) group templates into
|
||
// per-descendant app rows. Post-commit + best-effort like the route
|
||
// rebuild; per-app skips (e.g. a queue slot already taken) surface as
|
||
// warnings.
|
||
report.warnings.extend(self.rematerialize().await);
|
||
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,
|
||
project: Option<&ProjectDecl>,
|
||
) -> Result<TreePlanResult, ApplyError> {
|
||
let existing = self.load_existing_groups(bundle).await?;
|
||
let resolved: HashMap<String, GroupId> =
|
||
existing.iter().map(|(k, g)| (k.clone(), g.id)).collect();
|
||
let (prepared, to_create, token) = self.prepare_tree(bundle, &resolved, project).await?;
|
||
// §6/§7 M3: attach-point ceiling preview (consistent with apply_tree).
|
||
if let Some(pg_slug) = project.and_then(|p| p.parent_group.as_deref()) {
|
||
let pg = self.resolve_group(pg_slug).await?;
|
||
for p in &prepared {
|
||
self.check_within_attach(p.owner, pg, pg_slug).await?;
|
||
}
|
||
}
|
||
let declared = project.map(|p| p.slug.as_str());
|
||
let mut nodes = Vec::with_capacity(prepared.len() + to_create.len());
|
||
for p in prepared {
|
||
let ownership = self.ownership_preview(p.owner, declared).await?;
|
||
// §6 M2: for an existing group node, preview a structural divergence
|
||
// (server parent ≠ manifest parent) before apply.
|
||
let structure = self.divergence_preview(p.node, &existing).await?;
|
||
let warnings = self.plan_warnings(p.owner, &p.node.bundle).await?;
|
||
nodes.push(NodePlan {
|
||
kind: p.node.kind,
|
||
slug: p.node.slug.clone(),
|
||
plan: p.plan,
|
||
ownership,
|
||
structure,
|
||
warnings,
|
||
});
|
||
}
|
||
// §6: a to-create group is previewed with a full-create plan; if the
|
||
// repo declares a `[project]`, the create will also CLAIM it. It has no
|
||
// node id yet (no chain to walk), so only the pure desired-state warnings
|
||
// apply — a disabled binding; unreachable/suppress need an existing owner.
|
||
for c in to_create {
|
||
let ownership = declared.map(|d| preview_ownership(true, None, Some(d)));
|
||
nodes.push(NodePlan {
|
||
kind: c.node.kind,
|
||
slug: c.node.slug.clone(),
|
||
plan: c.plan,
|
||
ownership,
|
||
structure: None,
|
||
warnings: disabled_target_warnings(&c.node.bundle),
|
||
});
|
||
}
|
||
Ok(TreePlanResult {
|
||
nodes,
|
||
state_token: token,
|
||
approvals_required: Self::gated_envs_from(
|
||
&self.governing_env_policy_tree(bundle, project).await?,
|
||
),
|
||
})
|
||
}
|
||
|
||
/// 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;
|
||
/// `OwnershipConflict` (§7) if a node is owned by another project;
|
||
/// `Backend` for repo/transaction failures.
|
||
#[allow(clippy::too_many_lines)]
|
||
pub async fn apply_tree(
|
||
&self,
|
||
bundle: &TreeBundle,
|
||
prune: bool,
|
||
claim: &OwnershipClaim<'_>,
|
||
expected_token: Option<&str>,
|
||
structure_mode: StructureMode,
|
||
) -> Result<ApplyReport, ApplyError> {
|
||
let actor = claim.principal.user_id;
|
||
let mut tx = self
|
||
.pool
|
||
.begin()
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
// §6 Phase 0: reconcile the group tree SHAPE — create every group the
|
||
// manifest declares (by directory nesting) but the server lacks, and
|
||
// (M2) reparent a diverged group per `structure_mode` — IN this tx
|
||
// (parents-first), returning the complete slug → id map and the set of
|
||
// structurally-changed groups. Enforces the attach ceiling + RBAC per
|
||
// mutation and takes the coarse structural lock only when it mutates.
|
||
// With all groups now resolvable, `prepare_tree` sees the whole tree
|
||
// (created groups load an empty current → a full-create plan), so its
|
||
// to-create list is empty.
|
||
let (resolved, structurally_changed) = self
|
||
.reconcile_group_structure_tx(
|
||
&mut tx,
|
||
bundle,
|
||
claim.project.as_ref(),
|
||
claim.principal,
|
||
structure_mode,
|
||
)
|
||
.await?;
|
||
let (prepared, to_create, token) = self
|
||
.prepare_tree(bundle, &resolved, claim.project.as_ref())
|
||
.await?;
|
||
if !to_create.is_empty() {
|
||
return Err(ApplyError::Backend(
|
||
"internal: a group remained unresolved after create".into(),
|
||
));
|
||
}
|
||
if let Some(expected) = expected_token {
|
||
if token != expected {
|
||
return Err(ApplyError::StateMoved);
|
||
}
|
||
}
|
||
// §6/§7 attach point: every EXISTING node in the tree must be within the
|
||
// project's declared `parent_group` subtree. A group CREATED in Phase 0
|
||
// was already checked there (and its uncommitted row isn't visible to
|
||
// `check_within_attach`'s pool-based ancestor walk), so skip it.
|
||
if let Some(pg_slug) = claim
|
||
.project
|
||
.as_ref()
|
||
.and_then(|d| d.parent_group.as_deref())
|
||
{
|
||
let pg = self.resolve_group(pg_slug).await?;
|
||
for p in &prepared {
|
||
if let ApplyOwner::Group(gid) = p.owner {
|
||
if structurally_changed.contains(&gid) {
|
||
continue;
|
||
}
|
||
}
|
||
self.check_within_attach(p.owner, pg, pg_slug).await?;
|
||
}
|
||
}
|
||
|
||
// §6 content-write authz, in-tx defense-in-depth. `authz_tree` (API
|
||
// layer) SKIPS a group node absent at that time (it is to-create),
|
||
// deferring to the create-gate in `reconcile_group_structure_tx`. That
|
||
// gate only fires when the group is STILL absent at reconcile — so a
|
||
// group created by a racer in the window would otherwise have its
|
||
// content written with no authz. Re-check content-write caps here for
|
||
// every group NOT structurally created/reparented by this apply: a group
|
||
// WE created is covered by create-RBAC (`GroupAdmin(parent)` cascades)
|
||
// and its uncommitted row is invisible to the pool-based authz cascade
|
||
// anyway, so it must not be re-checked; a pre-existing (incl.
|
||
// racer-created) group IS committed, so its ancestry resolves and a
|
||
// missing cap denies. Runs before the write loops; a denial rolls back.
|
||
for p in &prepared {
|
||
if let ApplyOwner::Group(gid) = p.owner {
|
||
if !structurally_changed.contains(&gid) {
|
||
self.require_group_node_writes(claim.principal, gid, &p.node.bundle, prune)
|
||
.await?;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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<i64> = 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<GroupId, HashMap<String, ScriptId>> = HashMap::new();
|
||
|
||
// §7 ownership: one project for the whole tree; claim/verify every group
|
||
// node in Phase A (recording it in `claimed_in_tree`), then check every
|
||
// app in Phase B against its nearest claimed ancestor. A conflict aborts
|
||
// the whole transaction.
|
||
let declared_project = match &claim.project {
|
||
Some(decl) => Some(upsert_project_tx(&mut tx, decl, actor).await?),
|
||
None => None,
|
||
};
|
||
let declared_slug = claim.project.as_ref().map(|d| d.slug.clone());
|
||
let mut claimed_in_tree: HashMap<GroupId, (ProjectId, String)> = HashMap::new();
|
||
|
||
// 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 {
|
||
self.claim_group_owner(
|
||
&mut tx,
|
||
gid,
|
||
declared_project,
|
||
claim.takeover,
|
||
claim.principal,
|
||
)
|
||
.await?;
|
||
if let (Some(pid), Some(slug)) = (declared_project, &declared_slug) {
|
||
claimed_in_tree.insert(gid, (pid, slug.clone()));
|
||
}
|
||
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 {
|
||
// §7: an app must match its nearest claimed ancestor — an in-tree
|
||
// group (just claimed in Phase A) or a committed ancestor.
|
||
let nearest =
|
||
nearest_claimed_in_tx(&mut tx, &p.app_chain, &claimed_in_tree).await?;
|
||
decide_app_owner(nearest, declared_project)
|
||
.map_err(ApplyError::OwnershipConflict)?;
|
||
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 the whole tree. §11 tail: a
|
||
// group node may declare route TEMPLATES affecting descendant apps even
|
||
// when no app node is in this apply, so refresh unconditionally (a full
|
||
// rebuild; cheap relative to a tree apply).
|
||
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());
|
||
}
|
||
// §4.5 M5: one post-commit materialization pass covers the whole tree.
|
||
report.warnings.extend(self.rematerialize().await);
|
||
Ok(report)
|
||
}
|
||
|
||
/// §6: load every EXISTING group node (lowercased slug → its full row) on
|
||
/// the pool, skipping ones that don't exist yet (to-create, previewed by the
|
||
/// plan path). Returns the whole `Group` so the divergence preview reads
|
||
/// `parent_id` (and resolves an in-tree parent's slug) without re-fetching.
|
||
async fn load_existing_groups(
|
||
&self,
|
||
bundle: &TreeBundle,
|
||
) -> Result<HashMap<String, picloud_shared::Group>, ApplyError> {
|
||
let mut map = HashMap::new();
|
||
for n in &bundle.nodes {
|
||
if n.kind == NodeKind::Group {
|
||
if let Some(g) = self
|
||
.groups
|
||
.get_by_slug(&n.slug)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
{
|
||
map.insert(n.slug.to_lowercase(), g);
|
||
}
|
||
}
|
||
}
|
||
Ok(map)
|
||
}
|
||
|
||
/// §6 M2: preview an EXISTING group node's structural divergence — `Some`
|
||
/// only when the server's parent differs from the manifest's declared
|
||
/// (directory-nesting) parent. `None` for an app node, a to-create group, or
|
||
/// an in-sync group. Reads the node's own row from the pre-loaded `existing`
|
||
/// map (no per-node `get_by_slug`), resolves the server parent's slug from
|
||
/// that map when the parent is another tree node (else one `get_by_id`), and
|
||
/// compares parent slugs **case-insensitively** so this preview agrees with
|
||
/// the apply path's id-based check (which lowercases) on a mixed-case slug.
|
||
async fn divergence_preview(
|
||
&self,
|
||
node: &TreeNode,
|
||
existing: &HashMap<String, picloud_shared::Group>,
|
||
) -> Result<Option<StructurePreview>, ApplyError> {
|
||
if node.kind != NodeKind::Group {
|
||
return Ok(None);
|
||
}
|
||
let Some(group) = existing.get(&node.slug.to_lowercase()) else {
|
||
return Ok(None); // to-create — not a divergence
|
||
};
|
||
let server_parent = match group.parent_id {
|
||
Some(pid) => match existing.values().find(|g| g.id == pid) {
|
||
Some(g) => Some(g.slug.clone()),
|
||
None => self
|
||
.groups
|
||
.get_by_id(pid)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
.map(|g| g.slug),
|
||
},
|
||
None => None,
|
||
};
|
||
let norm = |s: Option<&str>| s.map(str::to_lowercase);
|
||
if norm(server_parent.as_deref()) == norm(node.parent.as_deref()) {
|
||
return Ok(None); // in-sync
|
||
}
|
||
Ok(Some(StructurePreview {
|
||
status: "diverged".to_string(),
|
||
server_parent,
|
||
manifest_parent: node.parent.clone(),
|
||
}))
|
||
}
|
||
|
||
/// §6: resolve a group node's DECLARED (directory-nesting) parent slug to a
|
||
/// `GroupId` — an in-tree group resolved earlier this tree wins, else a
|
||
/// committed server group; `None` = instance root. Errors if a declared
|
||
/// parent slug resolves to nothing.
|
||
async fn resolve_declared_parent(
|
||
&self,
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
map: &HashMap<String, GroupId>,
|
||
n: &TreeNode,
|
||
) -> Result<Option<GroupId>, ApplyError> {
|
||
let Some(pslug) = &n.parent else {
|
||
return Ok(None);
|
||
};
|
||
if let Some(&g) = map.get(&pslug.to_lowercase()) {
|
||
return Ok(Some(g));
|
||
}
|
||
crate::group_repo::read_group_id_by_slug_tx(tx, pslug)
|
||
.await
|
||
.map_err(map_group_repo_err)?
|
||
.map(Some)
|
||
.ok_or_else(|| {
|
||
ApplyError::Invalid(format!(
|
||
"group `{}` declares parent `{pslug}`, which does not exist — a parent \
|
||
group must be declared before its children in the tree (the CLI orders \
|
||
nodes parents-first by directory nesting)",
|
||
n.slug
|
||
))
|
||
})
|
||
}
|
||
|
||
/// §6: is `parent` within the attach-point subtree? `attach = None` (no
|
||
/// ceiling) → always true. A just-structurally-changed group in `ok` (its
|
||
/// own placement already validated within) short-circuits the ancestor walk.
|
||
async fn parent_within_attach(
|
||
&self,
|
||
parent: Option<GroupId>,
|
||
attach: Option<GroupId>,
|
||
ok: &HashSet<GroupId>,
|
||
) -> Result<bool, ApplyError> {
|
||
let Some(attach) = attach else {
|
||
return Ok(true);
|
||
};
|
||
Ok(match parent {
|
||
Some(p) if p == attach || ok.contains(&p) => true,
|
||
Some(p) => self
|
||
.groups
|
||
.ancestors(p)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
.iter()
|
||
.any(|g| g.id == attach),
|
||
None => false,
|
||
})
|
||
}
|
||
|
||
/// §6 M1+M2: reconcile the group tree SHAPE inside the apply tx (parents-
|
||
/// first). A group the server lacks is CREATED (`create_group_node_tx`); a
|
||
/// group whose server parent differs from the manifest's declared parent is
|
||
/// a structural divergence, resolved per `mode` (`reparent_diverged_group_tx`
|
||
/// — refuse / reparent / adopt-server). Returns the complete slug → id map
|
||
/// (so `prepare_tree` resolves the whole tree) and the set of groups
|
||
/// STRUCTURALLY changed here (created or reparented) — the caller skips them
|
||
/// in the pool-based attach re-check (their uncommitted row isn't visible).
|
||
/// Enforces the attach ceiling + RBAC per mutation (in the helpers); the
|
||
/// coarse structural lock is taken only once, on the first mutation.
|
||
async fn reconcile_group_structure_tx(
|
||
&self,
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
bundle: &TreeBundle,
|
||
project: Option<&ProjectDecl>,
|
||
principal: &Principal,
|
||
mode: StructureMode,
|
||
) -> Result<(HashMap<String, GroupId>, HashSet<GroupId>), ApplyError> {
|
||
use crate::group_repo::{
|
||
read_group_id_by_slug_tx, read_group_node_tx, GROUP_STRUCTURAL_LOCK_KEY,
|
||
};
|
||
|
||
let attach_slug = project.and_then(|p| p.parent_group.as_deref());
|
||
let attach_gid = match attach_slug {
|
||
Some(slug) => Some(
|
||
read_group_id_by_slug_tx(tx, slug)
|
||
.await
|
||
.map_err(map_group_repo_err)?
|
||
.ok_or_else(|| {
|
||
ApplyError::Invalid(format!("attach point group `{slug}` does not exist"))
|
||
})?,
|
||
),
|
||
None => None,
|
||
};
|
||
let ctx = ReconcileCtx {
|
||
attach_gid,
|
||
attach_slug,
|
||
principal,
|
||
mode,
|
||
};
|
||
|
||
let mut map: HashMap<String, GroupId> = HashMap::new();
|
||
let mut changed: HashSet<GroupId> = HashSet::new();
|
||
let mut within_attach: HashSet<GroupId> = HashSet::new();
|
||
let mut locked = false;
|
||
for n in &bundle.nodes {
|
||
if n.kind != NodeKind::Group {
|
||
continue;
|
||
}
|
||
let key = n.slug.to_lowercase();
|
||
let mut existing = read_group_node_tx(tx, &n.slug)
|
||
.await
|
||
.map_err(map_group_repo_err)?;
|
||
let declared_parent = self.resolve_declared_parent(tx, &map, n).await?;
|
||
|
||
// A structural change (create or reparent) is needed if the group is
|
||
// missing, or exists with a divergent parent under ForceLocal.
|
||
let diverged = matches!(&existing, Some((_, sp)) if *sp != declared_parent);
|
||
let needs_mutation =
|
||
existing.is_none() || (diverged && mode == StructureMode::ForceLocal);
|
||
if needs_mutation && !locked {
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(GROUP_STRUCTURAL_LOCK_KEY)
|
||
.execute(&mut **tx)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
locked = true;
|
||
// Re-read under the lock — a racer may have created/moved it.
|
||
existing = read_group_node_tx(tx, &n.slug)
|
||
.await
|
||
.map_err(map_group_repo_err)?;
|
||
}
|
||
|
||
match existing {
|
||
None => {
|
||
let gid = self
|
||
.create_group_node_tx(tx, n, declared_parent, &ctx, &within_attach)
|
||
.await?;
|
||
changed.insert(gid);
|
||
if attach_gid.is_some() {
|
||
within_attach.insert(gid);
|
||
}
|
||
map.insert(key, gid);
|
||
}
|
||
Some((gid, server_parent)) => {
|
||
map.insert(key, gid);
|
||
if server_parent == declared_parent {
|
||
continue; // in-sync
|
||
}
|
||
if self
|
||
.reparent_diverged_group_tx(
|
||
tx,
|
||
n,
|
||
(gid, server_parent),
|
||
declared_parent,
|
||
&ctx,
|
||
&within_attach,
|
||
)
|
||
.await?
|
||
{
|
||
changed.insert(gid);
|
||
if attach_gid.is_some() {
|
||
within_attach.insert(gid);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Ok((map, changed))
|
||
}
|
||
|
||
/// §6: create one missing group node under its declared parent, attach-
|
||
/// ceiling + RBAC gated (a root group needs `InstanceCreateGroup`, a subgroup
|
||
/// `GroupAdmin(parent)`). Returns the new id; the caller records it in the
|
||
/// changed / within-attach / slug→id sets.
|
||
async fn create_group_node_tx(
|
||
&self,
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
n: &TreeNode,
|
||
declared_parent: Option<GroupId>,
|
||
ctx: &ReconcileCtx<'_>,
|
||
within_attach: &HashSet<GroupId>,
|
||
) -> Result<GroupId, ApplyError> {
|
||
crate::groups_api::validate_slug_format(&n.slug).map_err(ApplyError::Invalid)?;
|
||
if !self
|
||
.parent_within_attach(declared_parent, ctx.attach_gid, within_attach)
|
||
.await?
|
||
{
|
||
return Err(ApplyError::OutsideAttachPoint(format!(
|
||
"group `{}` would be created outside this project's attach point `{}`",
|
||
n.slug,
|
||
ctx.attach_slug.unwrap_or_default()
|
||
)));
|
||
}
|
||
let cap = match declared_parent {
|
||
Some(p) => Capability::GroupAdmin(p),
|
||
None => Capability::InstanceCreateGroup,
|
||
};
|
||
require(self.authz.as_ref(), ctx.principal, cap)
|
||
.await
|
||
.map_err(map_authz)?;
|
||
let name = n.name.clone().unwrap_or_else(|| n.slug.clone());
|
||
crate::group_repo::create_group_tx(
|
||
tx,
|
||
&n.slug,
|
||
&name,
|
||
n.description.as_deref(),
|
||
declared_parent,
|
||
)
|
||
.await
|
||
.map_err(map_group_repo_err)
|
||
}
|
||
|
||
/// §6 M2: resolve an EXISTING group node whose server parent diverges from
|
||
/// the manifest, per `mode`. `Refuse` → `StructuralDivergence` (422);
|
||
/// `AdoptServer` → keep the server shape (`Ok(false)`); `ForceLocal` →
|
||
/// §5.6-gated (`GroupAdmin` on node + source + destination) + attach-
|
||
/// ceilinged reparent to the declared parent (`Ok(true)`). `Ok(false)` = no
|
||
/// structural change (the caller records the change only on `Ok(true)`).
|
||
async fn reparent_diverged_group_tx(
|
||
&self,
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
n: &TreeNode,
|
||
existing: (GroupId, Option<GroupId>),
|
||
declared_parent: Option<GroupId>,
|
||
ctx: &ReconcileCtx<'_>,
|
||
within_attach: &HashSet<GroupId>,
|
||
) -> Result<bool, ApplyError> {
|
||
let (gid, server_parent) = existing;
|
||
match ctx.mode {
|
||
StructureMode::AdoptServer => Ok(false), // keep the server shape
|
||
StructureMode::Refuse => Err(ApplyError::StructuralDivergence(format!(
|
||
"group `{}` is under a different parent on the server than the manifest \
|
||
declares — pass --force-local-structure to reparent it, or \
|
||
--adopt-server-structure to keep the server's placement",
|
||
n.slug
|
||
))),
|
||
StructureMode::ForceLocal => {
|
||
// §5.6 RBAC: admin on the node + source + destination.
|
||
require(
|
||
self.authz.as_ref(),
|
||
ctx.principal,
|
||
Capability::GroupAdmin(gid),
|
||
)
|
||
.await
|
||
.map_err(map_authz)?;
|
||
for side in [server_parent, declared_parent].into_iter().flatten() {
|
||
require(
|
||
self.authz.as_ref(),
|
||
ctx.principal,
|
||
Capability::GroupAdmin(side),
|
||
)
|
||
.await
|
||
.map_err(map_authz)?;
|
||
}
|
||
if !self
|
||
.parent_within_attach(declared_parent, ctx.attach_gid, within_attach)
|
||
.await?
|
||
{
|
||
return Err(ApplyError::OutsideAttachPoint(format!(
|
||
"reparenting group `{}` would move it outside this project's attach \
|
||
point `{}`",
|
||
n.slug,
|
||
ctx.attach_slug.unwrap_or_default()
|
||
)));
|
||
}
|
||
crate::group_repo::reparent_group_tx(tx, gid, declared_parent)
|
||
.await
|
||
.map_err(map_group_repo_err)?;
|
||
Ok(true)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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`.
|
||
///
|
||
/// `resolved_ids` maps every group node's lowercased slug → its `GroupId`.
|
||
/// The caller pre-populates it: the plan path with the groups that already
|
||
/// exist (a missing one becomes a `ToCreateGroup` preview), the apply path
|
||
/// with existing groups PLUS the ones it just created in-tx (so the returned
|
||
/// to-create list is empty). A to-create group's token part is identical to
|
||
/// what it will be once created (empty current), so the plan and apply
|
||
/// tokens match across a create.
|
||
#[allow(clippy::too_many_lines)]
|
||
async fn prepare_tree<'a>(
|
||
&self,
|
||
bundle: &'a TreeBundle,
|
||
resolved_ids: &HashMap<String, GroupId>,
|
||
project: Option<&ProjectDecl>,
|
||
) -> Result<(Vec<PreparedNode<'a>>, Vec<ToCreateGroup<'a>>, 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.
|
||
// A group node whose slug is not in `resolved_ids` is to-create.
|
||
let mut group_id_by_slug: HashMap<String, GroupId> = HashMap::new();
|
||
let mut group_script_names: HashMap<GroupId, HashSet<String>> = HashMap::new();
|
||
let mut seen_slugs: HashSet<String> = 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 {
|
||
if let Some(&gid) = resolved_ids.get(&n.slug.to_lowercase()) {
|
||
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 to_create: Vec<ToCreateGroup<'a>> = Vec::new();
|
||
let mut token_parts: Vec<String> = Vec::new();
|
||
let mut versioned_groups: BTreeSet<GroupId> = BTreeSet::new();
|
||
|
||
for n in &bundle.nodes {
|
||
// §6: a group node not in `resolved_ids` is to-create — validate its
|
||
// bundle, diff against an empty current (a full create), and preview
|
||
// it. The apply path creates it before this content reconcile.
|
||
if n.kind == NodeKind::Group && !group_id_by_slug.contains_key(&n.slug.to_lowercase()) {
|
||
self.validate_bundle_for(true, &n.bundle, &HashSet::new())?;
|
||
let current = CurrentState::default();
|
||
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)
|
||
));
|
||
to_create.push(ToCreateGroup { node: n, plan });
|
||
continue;
|
||
}
|
||
|
||
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::<Vec<_>>(),
|
||
)
|
||
}
|
||
};
|
||
|
||
// Inherited endpoint NAMES the bindings may reference: pre-existing
|
||
// (pool resolver) plus in-tree ancestor group scripts.
|
||
let inherited_names: HashSet<String> = 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(
|
||
matches!(owner, ApplyOwner::Group(_)),
|
||
&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,
|
||
});
|
||
}
|
||
|
||
// §5.5 / Phase 4b: the single-node path runs `check_imports_resolve` and
|
||
// `check_extension_points_provided`, but the tree path cannot — a node
|
||
// may legitimately import a module another in-tree (uncommitted) node
|
||
// declares, so a pool-based provider check would false-positive. Both
|
||
// lean on the runtime backstop here. Log it so the gap isn't silent.
|
||
if prepared
|
||
.iter()
|
||
.any(|p| matches!(p.owner, ApplyOwner::App(_)))
|
||
{
|
||
tracing::debug!(
|
||
nodes = prepared.len(),
|
||
"tree apply: per-node import / extension-point provider checks \
|
||
deferred to the runtime backstop (single-node-only at plan time)"
|
||
);
|
||
}
|
||
|
||
// 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));
|
||
}
|
||
}
|
||
// §3 M3: fold the env approval policy (desired state) into the token, so a
|
||
// policy edit between plan and apply trips StateMoved. Sorted key=confirm
|
||
// pairs for order-independence. Both plan_tree and apply_tree pass the
|
||
// same (root) project here, so the fold is symmetric.
|
||
if let Some(p) = project {
|
||
let mut envs: Vec<String> = p
|
||
.environments
|
||
.iter()
|
||
.map(|(name, pol)| format!("{name}={}", pol.confirm))
|
||
.collect();
|
||
envs.sort_unstable();
|
||
token_parts.push(format!("proj|{}", envs.join(",")));
|
||
}
|
||
token_parts.sort_unstable();
|
||
Ok((prepared, to_create, 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<GroupId, HashMap<String, ScriptId>>,
|
||
) -> Result<HashMap<String, ScriptId>, ApplyError> {
|
||
let own: HashSet<String> = bundle
|
||
.scripts
|
||
.iter()
|
||
.map(|s| s.name.to_lowercase())
|
||
.collect();
|
||
let mut referenced: HashSet<String> = 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<HashMap<String, ScriptId>> = 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)
|
||
}
|
||
|
||
/// Group-node write caps (editor-tier): scripts → `GroupScriptsWrite`, vars
|
||
/// → `GroupVarsWrite`, both required on prune. The single-group and tree
|
||
/// apply authorization (`apply_api::authz_tree`) call this at the API layer;
|
||
/// the tree apply ALSO re-checks it in-tx (see `apply_tree`) to close the
|
||
/// window where a group absent at authz time appears before reconcile.
|
||
pub(crate) async fn require_group_node_writes(
|
||
&self,
|
||
principal: &Principal,
|
||
group_id: GroupId,
|
||
bundle: &Bundle,
|
||
prune: bool,
|
||
) -> Result<(), ApplyError> {
|
||
if prune || !bundle.scripts.is_empty() {
|
||
require(
|
||
self.authz.as_ref(),
|
||
principal,
|
||
Capability::GroupScriptsWrite(group_id),
|
||
)
|
||
.await
|
||
.map_err(map_authz)?;
|
||
}
|
||
if prune || !bundle.vars.is_empty() {
|
||
require(
|
||
self.authz.as_ref(),
|
||
principal,
|
||
Capability::GroupVarsWrite(group_id),
|
||
)
|
||
.await
|
||
.map_err(map_authz)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ------------------------------------------------------------------------
|
||
// §7 ownership claim — the ApplyService side (executes the pure verdict).
|
||
// ------------------------------------------------------------------------
|
||
|
||
/// Claim / verify ownership of a GROUP node inside the apply tx. Reads the
|
||
/// live owner under the caller's advisory lock, runs the pure policy, and
|
||
/// applies the verdict: first-apply claims, a re-apply by the owner is a
|
||
/// no-op, a conflict is a 409, and a `--takeover` reassign additionally
|
||
/// requires `GroupAdmin` on the target (ownership ⟂ RBAC, §7.4).
|
||
async fn claim_group_owner(
|
||
&self,
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
gid: GroupId,
|
||
declared: Option<ProjectId>,
|
||
takeover: bool,
|
||
principal: &Principal,
|
||
) -> Result<(), ApplyError> {
|
||
let current = read_group_owner_tx(tx, gid).await?;
|
||
match decide_group_claim(current, declared, takeover) {
|
||
GroupClaimAction::Noop => Ok(()),
|
||
GroupClaimAction::Claim(pid) => write_group_owner_tx(tx, gid, pid).await,
|
||
GroupClaimAction::Takeover(pid) => {
|
||
require(self.authz.as_ref(), principal, Capability::GroupAdmin(gid))
|
||
.await
|
||
.map_err(map_authz)?;
|
||
write_group_owner_tx(tx, gid, pid).await
|
||
}
|
||
GroupClaimAction::Conflict(msg) => Err(ApplyError::OwnershipConflict(msg)),
|
||
}
|
||
}
|
||
|
||
/// Verify an APP node's ownership (single-node path). The app claims
|
||
/// nothing — it must match its nearest claimed ancestor group. The ancestor
|
||
/// STRUCTURE is read on the pool (`ancestors()`), but the ownership VALUE is
|
||
/// then read WITHIN the tx via `nearest_claimed_in_tx` — the same mechanism
|
||
/// the tree path uses — so the check and this apply's writes see one
|
||
/// consistent snapshot instead of a separate pool connection (`in_tree` is
|
||
/// empty; a single app node claims no groups itself). An unclaimed subtree
|
||
/// stays open. (Fully serializing against a concurrent claim on an
|
||
/// out-of-tree ancestor is part of the tracked pool→tx follow-up — see the
|
||
/// `apply_owner` advisory-lock note.)
|
||
async fn check_app_owner_single(
|
||
&self,
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
app_id: AppId,
|
||
declared: Option<ProjectId>,
|
||
) -> Result<(), ApplyError> {
|
||
let Some(app) = self
|
||
.apps
|
||
.get_by_id(app_id)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
else {
|
||
return Ok(()); // resolved earlier in the handler; defensive.
|
||
};
|
||
let chain = self
|
||
.groups
|
||
.ancestors(app.group_id)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
let chain_ids: Vec<GroupId> = chain.iter().map(|g| g.id).collect();
|
||
let nearest = nearest_claimed_in_tx(tx, &chain_ids, &HashMap::new()).await?;
|
||
decide_app_owner(nearest, declared).map_err(ApplyError::OwnershipConflict)
|
||
}
|
||
|
||
/// The nearest ancestor (nearest-first `chain`) with an owner, as
|
||
/// `(id, slug)`. Resolves the owning project's slug lazily — only when a
|
||
/// claim exists — for the conflict message.
|
||
async fn nearest_claimed_from_chain(
|
||
&self,
|
||
chain: &[picloud_shared::Group],
|
||
) -> Result<Option<(ProjectId, String)>, ApplyError> {
|
||
let Some(pid) = chain.iter().find_map(|g| g.owner_project) else {
|
||
return Ok(None);
|
||
};
|
||
let slug = self
|
||
.projects
|
||
.get_by_id(pid)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
.map(|p| p.slug)
|
||
.unwrap_or_default();
|
||
Ok(Some((pid, slug)))
|
||
}
|
||
|
||
/// §3 M3 (hermetic gate, server-authoritative): the id of the project that
|
||
/// GOVERNS `owner` — its nearest-claimed ancestor (inclusive: a group node
|
||
/// uses its own claim first, then walks up; an app node walks its group
|
||
/// chain). This is the same ownership walk `ownership_preview` uses, and it
|
||
/// is deliberately independent of any client-supplied `[project]` — the
|
||
/// server decides which project's approval policy applies.
|
||
async fn governing_project_id(
|
||
&self,
|
||
owner: ApplyOwner,
|
||
) -> Result<Option<ProjectId>, ApplyError> {
|
||
let anchor = match owner {
|
||
ApplyOwner::Group(g) => g,
|
||
ApplyOwner::App(a) => {
|
||
let Some(app) = self
|
||
.apps
|
||
.get_by_id(a)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
else {
|
||
return Ok(None);
|
||
};
|
||
app.group_id
|
||
}
|
||
};
|
||
let chain = self
|
||
.groups
|
||
.ancestors(anchor)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
Ok(chain.iter().find_map(|g| g.owner_project))
|
||
}
|
||
|
||
/// §3 M3 (hermetic gate): the EFFECTIVE per-env approval policy for an apply
|
||
/// to `owner` — `env -> confirm` — computed as `governing ∪ declared`. The
|
||
/// GOVERNING side is loaded from the project the server RESOLVED as owning
|
||
/// the target node (`governing_project_id`), NOT from the client's declared
|
||
/// slug — so a request that omits or spoofs `[project]` still trips a gate
|
||
/// the owning project established. The declared side only STRENGTHENS (union
|
||
/// on confirm), never weakens. A persisted-read error surfaces as `Backend`
|
||
/// (fail-closed — a gate is never silently dropped).
|
||
pub async fn governing_env_policy(
|
||
&self,
|
||
owner: ApplyOwner,
|
||
declared: Option<&ProjectDecl>,
|
||
) -> Result<std::collections::BTreeMap<String, bool>, ApplyError> {
|
||
let mut effective = match self.governing_project_id(owner).await? {
|
||
Some(pid) => self
|
||
.projects
|
||
.get_environments_by_id(pid)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||
None => std::collections::BTreeMap::new(),
|
||
};
|
||
if let Some(decl) = declared {
|
||
for (env, pol) in &decl.environments {
|
||
let entry = effective.entry(env.clone()).or_insert(false);
|
||
*entry = *entry || pol.confirm;
|
||
}
|
||
}
|
||
Ok(effective)
|
||
}
|
||
|
||
/// §3 M3 (hermetic gate) for a TREE apply: the union of every declared
|
||
/// node's governing policy with the declared side. Each existing node is
|
||
/// resolved to its owning project server-side; a to-create group node has
|
||
/// nothing persisted to gate (its claim is established by this same apply)
|
||
/// so it contributes only the declared policy. Un-bypassable for the real
|
||
/// case — applying to existing nodes under a claimed, gated subtree.
|
||
pub async fn governing_env_policy_tree(
|
||
&self,
|
||
bundle: &TreeBundle,
|
||
declared: Option<&ProjectDecl>,
|
||
) -> Result<std::collections::BTreeMap<String, bool>, ApplyError> {
|
||
let mut effective: std::collections::BTreeMap<String, bool> =
|
||
std::collections::BTreeMap::new();
|
||
for node in &bundle.nodes {
|
||
// The project that governs this node. An existing node uses its own
|
||
// ownership walk. A to-create GROUP has no id yet, so resolve the
|
||
// governing project from its DECLARED parent's chain — a gated,
|
||
// claimed pre-existing ancestor still governs a node created beneath
|
||
// it, closing the whole-block-omission gap for a fresh subtree node.
|
||
let governing_pid = match node.kind {
|
||
NodeKind::App => {
|
||
// Apps are never to-create in the tree path (resolve errors),
|
||
// so a miss here means the app truly doesn't exist yet.
|
||
match crate::app_repo::resolve_app(self.apps.as_ref(), &node.slug)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
{
|
||
Some(l) => self.governing_project_id(ApplyOwner::App(l.app.id)).await?,
|
||
None => continue,
|
||
}
|
||
}
|
||
NodeKind::Group => match self
|
||
.groups
|
||
.get_by_slug(&node.slug)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
{
|
||
Some(g) => self.governing_project_id(ApplyOwner::Group(g.id)).await?,
|
||
None => match node.parent.as_deref() {
|
||
Some(parent_slug) => match self
|
||
.groups
|
||
.get_by_slug(parent_slug)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
{
|
||
Some(parent) => {
|
||
self.governing_project_id(ApplyOwner::Group(parent.id))
|
||
.await?
|
||
}
|
||
// Parent is itself to-create (no persisted claim yet,
|
||
// so no persisted gate from it) or absent → nothing to
|
||
// govern this node with.
|
||
None => continue,
|
||
},
|
||
None => continue,
|
||
},
|
||
},
|
||
};
|
||
if let Some(pid) = governing_pid {
|
||
for (env, confirm) in self
|
||
.projects
|
||
.get_environments_by_id(pid)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
{
|
||
let entry = effective.entry(env).or_insert(false);
|
||
*entry = *entry || confirm;
|
||
}
|
||
}
|
||
}
|
||
if let Some(decl) = declared {
|
||
for (env, pol) in &decl.environments {
|
||
let entry = effective.entry(env.clone()).or_insert(false);
|
||
*entry = *entry || pol.confirm;
|
||
}
|
||
}
|
||
Ok(effective)
|
||
}
|
||
|
||
// ------------------------------------------------------------------------
|
||
// §6/§7 attach point — a project's ceiling. Every applied node must live
|
||
// strictly within the subtree rooted at the declared `parent_group`.
|
||
// ------------------------------------------------------------------------
|
||
|
||
/// Enforce the ceiling for one node: `pg` must be a PROPER ancestor of a
|
||
/// group node (you can't apply the attach point itself), or an ancestor
|
||
/// (inclusive) of an app node's group. Reject otherwise.
|
||
async fn check_within_attach(
|
||
&self,
|
||
owner: ApplyOwner,
|
||
pg: GroupId,
|
||
pg_slug: &str,
|
||
) -> Result<(), ApplyError> {
|
||
let (anchor, is_group) = match owner {
|
||
ApplyOwner::Group(g) => (g, true),
|
||
ApplyOwner::App(a) => {
|
||
let app = self
|
||
.apps
|
||
.get_by_id(a)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
.ok_or_else(|| ApplyError::AppNotFound(a.to_string()))?;
|
||
(app.group_id, false)
|
||
}
|
||
};
|
||
let chain = self
|
||
.groups
|
||
.ancestors(anchor)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
let in_subtree = chain.iter().any(|g| g.id == pg);
|
||
// A group node equal to the attach point is the ceiling itself → above.
|
||
let is_attach_itself = is_group && anchor == pg;
|
||
if !in_subtree || is_attach_itself {
|
||
return Err(ApplyError::OutsideAttachPoint(format!(
|
||
"node is not within this project's attach point '{pg_slug}' — apply only \
|
||
to '{pg_slug}' and its descendants"
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// A group's OWN owning-project slug (not inherited), read on the pool.
|
||
async fn read_group_owner_pool(&self, gid: GroupId) -> Result<Option<String>, ApplyError> {
|
||
let g = self
|
||
.groups
|
||
.get_by_id(gid)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
match g.and_then(|g| g.owner_project) {
|
||
None => Ok(None),
|
||
Some(pid) => Ok(self
|
||
.projects
|
||
.get_by_id(pid)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
.map(|p| p.slug)),
|
||
}
|
||
}
|
||
|
||
/// §7 M3 preview: the ownership outcome a node's apply would produce, or
|
||
/// `None` when no `[project]` is declared (nothing to preview). Read-only.
|
||
/// A group node previews against its OWN owner; an app node against its
|
||
/// nearest claimed ancestor.
|
||
async fn ownership_preview(
|
||
&self,
|
||
owner: ApplyOwner,
|
||
declared: Option<&str>,
|
||
) -> Result<Option<OwnershipPreview>, ApplyError> {
|
||
if declared.is_none() {
|
||
return Ok(None);
|
||
}
|
||
let (is_group, current) = match owner {
|
||
ApplyOwner::Group(g) => (true, self.read_group_owner_pool(g).await?),
|
||
ApplyOwner::App(a) => {
|
||
let current = match self
|
||
.apps
|
||
.get_by_id(a)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
{
|
||
Some(app) => {
|
||
let chain = self
|
||
.groups
|
||
.ancestors(app.group_id)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
self.nearest_claimed_from_chain(&chain)
|
||
.await?
|
||
.map(|(_, s)| s)
|
||
}
|
||
None => None,
|
||
};
|
||
(false, current)
|
||
}
|
||
};
|
||
let mut preview = preview_ownership(is_group, current.as_deref(), declared);
|
||
// §7 M3.2: for a group node, surface the cross-repo blast radius — the
|
||
// descendant apps owned by OTHER projects a config change here reaches.
|
||
if let ApplyOwner::Group(g) = owner {
|
||
preview.blast_radius = self.group_blast_radius(g, declared).await?;
|
||
}
|
||
Ok(Some(preview))
|
||
}
|
||
|
||
/// §7 M3.2: descendant apps of `gid` grouped by their nearest-claimed-
|
||
/// ancestor project, EXCLUDING `exclude` (the planning project) — the apps
|
||
/// a change to `gid`'s inherited config fans out to across other repos.
|
||
/// Resolved in one set-based recursive query (see below).
|
||
async fn group_blast_radius(
|
||
&self,
|
||
gid: GroupId,
|
||
exclude: Option<&str>,
|
||
) -> Result<Vec<ProjectImpact>, ApplyError> {
|
||
// One set-based query: every app in gid's subtree, resolved to its
|
||
// nearest claimed ancestor project, grouped and counted — replacing the
|
||
// per-descendant-group `ancestors()` round-trips (N+1) with a single
|
||
// recursive walk. `subtree` = gid + descendant groups; `app_chain` =
|
||
// (app, ancestor group, depth) for every subtree app walked to the root;
|
||
// `nearest` picks the shallowest claimed ancestor per app. Apps owned by
|
||
// `exclude` (the planning project) or by nothing are dropped.
|
||
let rows: Vec<(String, i64)> = sqlx::query_as(
|
||
"WITH RECURSIVE subtree AS (
|
||
SELECT id, 0 AS depth FROM groups WHERE id = $1
|
||
UNION ALL
|
||
SELECT g.id, s.depth + 1 FROM groups g JOIN subtree s ON g.parent_id = s.id
|
||
WHERE s.depth < 64
|
||
),
|
||
app_chain AS (
|
||
SELECT a.id AS app_id, a.group_id AS gid, 0 AS depth
|
||
FROM apps a WHERE a.group_id IN (SELECT id FROM subtree)
|
||
UNION ALL
|
||
SELECT ac.app_id, g.parent_id, ac.depth + 1
|
||
FROM app_chain ac JOIN groups g ON g.id = ac.gid
|
||
WHERE g.parent_id IS NOT NULL AND ac.depth < 64
|
||
),
|
||
nearest AS (
|
||
SELECT DISTINCT ON (ac.app_id) ac.app_id, g.owner_project
|
||
FROM app_chain ac JOIN groups g ON g.id = ac.gid
|
||
WHERE g.owner_project IS NOT NULL
|
||
ORDER BY ac.app_id, ac.depth ASC
|
||
)
|
||
SELECT p.slug, COUNT(*) AS n
|
||
FROM nearest n JOIN projects p ON p.id = n.owner_project
|
||
WHERE p.slug IS DISTINCT FROM $2
|
||
GROUP BY p.slug
|
||
ORDER BY p.slug",
|
||
)
|
||
.bind(gid.into_inner())
|
||
.bind(exclude)
|
||
.fetch_all(&self.pool)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
|
||
Ok(rows
|
||
.into_iter()
|
||
.map(|(project, n)| ProjectImpact {
|
||
project,
|
||
apps: u32::try_from(n).unwrap_or(u32::MAX),
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
async fn resolve_app(&self, ident: &str) -> Result<picloud_shared::App, ApplyError> {
|
||
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<GroupId, ApplyError> {
|
||
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||
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<Vec<String>, ApplyError> {
|
||
let res = if bs.kind == ScriptKind::Module {
|
||
self.validator.validate_module(&bs.source)
|
||
} else {
|
||
self.validator.validate(&bs.source)
|
||
};
|
||
res.map(|v| v.imports)
|
||
.map_err(|e| ApplyError::Invalid(format!("script `{}`: {e}", bs.name)))
|
||
}
|
||
|
||
fn trigger_settings(
|
||
&self,
|
||
bt: &BundleTrigger,
|
||
) -> (
|
||
TriggerDispatchMode,
|
||
u32,
|
||
crate::trigger_config::BackoffShape,
|
||
u32,
|
||
) {
|
||
let (dispatch, retry_max) = match bt {
|
||
BundleTrigger::Kv {
|
||
dispatch_mode,
|
||
retry_max_attempts,
|
||
..
|
||
}
|
||
| BundleTrigger::Docs {
|
||
dispatch_mode,
|
||
retry_max_attempts,
|
||
..
|
||
}
|
||
| BundleTrigger::Files {
|
||
dispatch_mode,
|
||
retry_max_attempts,
|
||
..
|
||
}
|
||
| BundleTrigger::Cron {
|
||
dispatch_mode,
|
||
retry_max_attempts,
|
||
..
|
||
}
|
||
| BundleTrigger::Pubsub {
|
||
dispatch_mode,
|
||
retry_max_attempts,
|
||
..
|
||
}
|
||
| BundleTrigger::Email {
|
||
dispatch_mode,
|
||
retry_max_attempts,
|
||
..
|
||
}
|
||
| BundleTrigger::Queue {
|
||
dispatch_mode,
|
||
retry_max_attempts,
|
||
..
|
||
}
|
||
| BundleTrigger::DeadLetter {
|
||
dispatch_mode,
|
||
retry_max_attempts,
|
||
..
|
||
} => (
|
||
dispatch_mode.unwrap_or(TriggerDispatchMode::Async),
|
||
retry_max_attempts.unwrap_or(self.trigger_config.retry_max_attempts),
|
||
),
|
||
};
|
||
(
|
||
dispatch,
|
||
retry_max,
|
||
self.trigger_config.retry_backoff,
|
||
self.trigger_config.retry_base_ms,
|
||
)
|
||
}
|
||
|
||
async fn refresh_route_table(&self) -> Result<(), ApplyError> {
|
||
// §11 tail: rebuild via the live expansion so a group route TEMPLATE
|
||
// created in this apply lands in every descendant app's slice.
|
||
crate::route_admin::rebuild_route_table(self.routes.as_ref(), &self.route_table)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))
|
||
}
|
||
|
||
/// §4.5 M5: reconcile materialized stateful-template copies. Best-effort —
|
||
/// a failure is logged (the copies self-heal on the next mutation) and any
|
||
/// per-app skip warnings are returned for the apply report.
|
||
async fn rematerialize(&self) -> Vec<String> {
|
||
match crate::materialize::rematerialize_stateful_templates(&self.pool).await {
|
||
Ok(warnings) => warnings,
|
||
Err(e) => {
|
||
tracing::warn!(error = %e, "apply: stateful-template materialization failed");
|
||
vec!["stateful-template materialization failed; it will self-heal".into()]
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Resolve a referenced secret to plaintext, then re-seal it for an
|
||
/// email trigger's inbound-secret column. The value never appears in
|
||
/// the manifest — only the secret's name does. §4.5 M5: `owner` is the
|
||
/// app for an app-owned trigger, or the GROUP for a group email template
|
||
/// (which resolves against the group's own secret store once at apply;
|
||
/// materialization then copies the sealed bytes to each descendant).
|
||
async fn resolve_and_seal(
|
||
&self,
|
||
owner: crate::secrets_service::SecretOwner,
|
||
name: &str,
|
||
) -> Result<(Vec<u8>, Vec<u8>, i16), ApplyError> {
|
||
let stored = self
|
||
.secrets
|
||
.get(owner, "*", name)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
.ok_or_else(|| {
|
||
ApplyError::Invalid(format!(
|
||
"email trigger references secret `{name}`, which is not set \
|
||
(push it with `pic secret set {name}`)"
|
||
))
|
||
})?;
|
||
let plaintext = crate::secrets_service::open(&self.master_key, owner, name, &stored)
|
||
.map_err(|e| ApplyError::Invalid(format!("could not read secret `{name}`: {e}")))?;
|
||
// The inbound HMAC must be a non-empty string — an empty/whitespace
|
||
// key is forgeable (preserves the spirit of audit 2026-06-11 H-B2).
|
||
match plaintext.as_str() {
|
||
Some(s) if !s.trim().is_empty() => {}
|
||
_ => {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"secret `{name}` must be a non-empty string to sign an email trigger"
|
||
)))
|
||
}
|
||
}
|
||
// §M5.5 / audit H-D1: seal v1 with AAD bound to the SEALING owner (the
|
||
// app for a standalone trigger, the group for a template — stable across
|
||
// materialized copies). The email inbound path recovers the same owner to
|
||
// open it.
|
||
let (ct, nonce, version) = crate::secrets_service::seal_email(
|
||
&self.master_key,
|
||
owner,
|
||
&plaintext,
|
||
crate::secrets_service::DEFAULT_SECRET_MAX_VALUE_BYTES,
|
||
)
|
||
.map_err(|e| ApplyError::Invalid(format!("could not seal secret `{name}`: {e}")))?;
|
||
Ok((ct, nonce.to_vec(), version))
|
||
}
|
||
|
||
/// Validate each bundle route's host against the app's domain claims —
|
||
/// the cross-state check the interactive route API enforces. Async, so
|
||
/// it runs alongside `plan`/`apply`, not in the sync `validate_bundle`.
|
||
async fn validate_route_hosts(&self, app_id: AppId, bundle: &Bundle) -> Result<(), ApplyError> {
|
||
for r in &bundle.routes {
|
||
crate::route_admin::validate_route_host_against_app(
|
||
self.domains.as_ref(),
|
||
app_id,
|
||
r.host_kind,
|
||
&r.host,
|
||
)
|
||
.await
|
||
.map_err(|e| ApplyError::Invalid(format!("route host claim: {e}")))?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Bundle-local validation: catches the errors visible without the
|
||
/// live state (syntax, ceilings, dangling/wrong-kind targets, intra-
|
||
/// bundle duplicates, reserved paths, host/path structural validity).
|
||
/// Host-claim validation runs separately in `validate_route_hosts`.
|
||
///
|
||
/// NOTE (known gap vs. the interactive route API): cross-pattern route
|
||
/// conflict-vs-live (e.g. a param route overlapping a live exact route)
|
||
/// is NOT checked — the DB unique index catches only identical tuples,
|
||
/// so overlapping patterns are accepted.
|
||
#[allow(clippy::too_many_lines)]
|
||
/// Phase 4: resolve route/trigger target names that the manifest does NOT
|
||
/// declare as app-own scripts to inherited group-owned endpoint scripts on
|
||
/// the app's chain. Returns `name(lowercased) → script_id` for the ones
|
||
/// that resolve to a group endpoint. App-own names (in `bundle.scripts`)
|
||
/// are skipped — they bind through the normal `name_to_id` path and take
|
||
/// precedence (CoW). Resolved before the apply transaction; the group
|
||
/// scripts pre-exist (committed), so a pool read is correct and stable.
|
||
async fn resolve_inherited_targets(
|
||
&self,
|
||
app_id: AppId,
|
||
bundle: &Bundle,
|
||
) -> Result<HashMap<String, ScriptId>, ApplyError> {
|
||
let own: HashSet<String> = bundle
|
||
.scripts
|
||
.iter()
|
||
.map(|s| s.name.to_lowercase())
|
||
.collect();
|
||
let mut referenced: HashSet<String> = HashSet::new();
|
||
for r in &bundle.routes {
|
||
referenced.insert(r.script.to_lowercase());
|
||
}
|
||
for t in &bundle.triggers {
|
||
referenced.insert(t.script().to_lowercase());
|
||
}
|
||
let mut out = HashMap::new();
|
||
for name in referenced {
|
||
if own.contains(&name) {
|
||
continue;
|
||
}
|
||
if let Some(s) = self
|
||
.scripts
|
||
.get_by_name_inherited(app_id, &name)
|
||
.await
|
||
.map_err(map_repo)?
|
||
{
|
||
// Only genuinely-inherited group endpoints qualify as
|
||
// route/trigger targets. A depth-0 app-own match is already
|
||
// covered by `name_to_id`; skip it so the manifest keeps
|
||
// precedence. Group modules (Phase 4b) exist but are never
|
||
// bindable targets — the kind guard excludes them.
|
||
if s.group_id.is_some() && s.kind == ScriptKind::Endpoint {
|
||
out.insert(name, s.id);
|
||
}
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Phase 4: resolve the names of group-owned scripts the app's CURRENT
|
||
/// routes/triggers are bound to, `script_id → name`. These ids aren't in
|
||
/// `current.scripts` (app-own only), so this is what lets the diff /
|
||
/// state-token / prune resolve an existing inherited binding back to its
|
||
/// name — independent of whether the bundle still references it (so a
|
||
/// dropped inherited binding still diffs to a Delete, and an inherited-bound
|
||
/// trigger still moves the state token).
|
||
async fn resolve_current_target_names(
|
||
&self,
|
||
current: &CurrentState,
|
||
) -> Result<HashMap<ScriptId, String>, ApplyError> {
|
||
let own: HashSet<ScriptId> = current.scripts.iter().map(|s| s.id).collect();
|
||
let mut ids: HashSet<ScriptId> = HashSet::new();
|
||
for r in ¤t.routes {
|
||
if !own.contains(&r.script_id) {
|
||
ids.insert(r.script_id);
|
||
}
|
||
}
|
||
for t in ¤t.triggers {
|
||
if !own.contains(&t.script_id) {
|
||
ids.insert(t.script_id);
|
||
}
|
||
}
|
||
let mut out = HashMap::new();
|
||
for id in ids {
|
||
if let Some(s) = self.scripts.get(id).await.map_err(map_repo)? {
|
||
out.insert(id, s.name);
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// §5.5 dangling-import plan check (Phase 4b, single-node path). Every
|
||
/// `import "<m>"` in a bundle script must resolve to a module declared in
|
||
/// the same bundle OR reachable lexically up the node's chain — otherwise
|
||
/// the apply would write a script that 404s its import at runtime. Lexical:
|
||
/// the node's owner IS the defining node for its own scripts, so resolution
|
||
/// roots at `owner` (an app node reaches ancestor group modules; a group
|
||
/// node walks its own ancestry, sealed from apps below).
|
||
///
|
||
/// Not run on the tree path: a tree node may import a module declared by
|
||
/// another node created in the same transaction (not yet committed), so the
|
||
/// in-tree set would have to be threaded through; the runtime check is the
|
||
/// backstop there.
|
||
async fn check_imports_resolve(
|
||
&self,
|
||
owner: ApplyOwner,
|
||
bundle: &Bundle,
|
||
) -> Result<(), ApplyError> {
|
||
// Modules this bundle declares (lowercased) satisfy their own imports.
|
||
let local: HashSet<String> = bundle
|
||
.scripts
|
||
.iter()
|
||
.filter(|s| s.kind == ScriptKind::Module)
|
||
.map(|s| s.name.to_lowercase())
|
||
.collect();
|
||
let origin = match owner {
|
||
ApplyOwner::App(a) => picloud_shared::ScriptOwner::App(a),
|
||
ApplyOwner::Group(g) => picloud_shared::ScriptOwner::Group(g),
|
||
};
|
||
// Resolve each distinct imported name once.
|
||
let mut checked: HashSet<String> = HashSet::new();
|
||
for s in &bundle.scripts {
|
||
for imp in self.script_imports(s)? {
|
||
let key = imp.to_lowercase();
|
||
if local.contains(&key) || !checked.insert(key) {
|
||
continue;
|
||
}
|
||
let found = self
|
||
.modules
|
||
.resolve(origin, &imp)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
if found.is_none() {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"script `{}` imports unknown module `{imp}` \
|
||
(not declared here and not inherited from an ancestor)",
|
||
s.name
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Read-only extension-point report for a node (§5.5). For an **app**:
|
||
/// every EP visible on its chain, each flagged `declared_here` (owned by
|
||
/// the app, vs inherited) with its resolved `provider` (`app override` /
|
||
/// `inherited default` / `null` = unset). For a **group**: its own declared
|
||
/// names (no single app to resolve a provider against). Backs the read-only
|
||
/// `extension-points ls` and the `pull` round-trip.
|
||
pub async fn extension_point_report(
|
||
&self,
|
||
owner: ApplyOwner,
|
||
) -> Result<Vec<ExtensionPointInfo>, ApplyError> {
|
||
let pool = &self.pool;
|
||
match owner {
|
||
ApplyOwner::Group(g) => {
|
||
let names =
|
||
crate::extension_point_repo::list_for_owner(pool, ScriptOwner::Group(g))
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
Ok(names
|
||
.into_iter()
|
||
.map(|name| ExtensionPointInfo {
|
||
name,
|
||
declared_here: true,
|
||
provider: None,
|
||
})
|
||
.collect())
|
||
}
|
||
ApplyOwner::App(a) => {
|
||
let own: HashSet<String> =
|
||
crate::extension_point_repo::list_for_owner(pool, ScriptOwner::App(a))
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
.into_iter()
|
||
.map(|n| n.to_lowercase())
|
||
.collect();
|
||
let visible = crate::extension_point_repo::list_on_app_chain(pool, a)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
let mut out = Vec::with_capacity(visible.len());
|
||
for name in visible {
|
||
let provider = match self
|
||
.modules
|
||
.resolve(ScriptOwner::App(a), &name)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
.and_then(|m| m.owner())
|
||
{
|
||
Some(ScriptOwner::App(_)) => Some("app override".to_string()),
|
||
Some(ScriptOwner::Group(_)) => Some("inherited default".to_string()),
|
||
None => None,
|
||
};
|
||
out.push(ExtensionPointInfo {
|
||
declared_here: own.contains(&name.to_lowercase()),
|
||
name,
|
||
provider,
|
||
});
|
||
}
|
||
Ok(out)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Read-only §11.6 shared-collection report for a node: the shared
|
||
/// collections (`name` + `kind`) declared directly at it. Group-only in
|
||
/// practice (the CLI rejects app-declared collections). Backs
|
||
/// `pic collections ls`.
|
||
/// Read-only §11 tail report: a group's own trigger TEMPLATES, surfaced as
|
||
/// (kind, target, handler script name, enabled). The stored `name` is a UUID
|
||
/// default for reconcile-created rows, so the semantic bits are shown
|
||
/// instead. Backs `pic triggers ls --group`.
|
||
pub async fn trigger_report(
|
||
&self,
|
||
owner: ApplyOwner,
|
||
) -> Result<Vec<TriggerTemplateInfo>, ApplyError> {
|
||
let ApplyOwner::Group(group_id) = owner else {
|
||
return Ok(Vec::new());
|
||
};
|
||
let triggers = self
|
||
.triggers
|
||
.list_for_group(group_id)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
let scripts = self
|
||
.scripts
|
||
.list_for_group(group_id)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
let name_by_id: HashMap<ScriptId, String> =
|
||
scripts.iter().map(|s| (s.id, s.name.clone())).collect();
|
||
Ok(triggers
|
||
.into_iter()
|
||
.map(|t| {
|
||
let (kind, target) = match &t.details {
|
||
TriggerDetails::Kv {
|
||
collection_glob, ..
|
||
} => ("kv", collection_glob.clone()),
|
||
TriggerDetails::Docs {
|
||
collection_glob, ..
|
||
} => ("docs", collection_glob.clone()),
|
||
TriggerDetails::Files {
|
||
collection_glob, ..
|
||
} => ("files", collection_glob.clone()),
|
||
TriggerDetails::Pubsub { topic_pattern } => ("pubsub", topic_pattern.clone()),
|
||
TriggerDetails::Cron { .. } => ("cron", String::new()),
|
||
TriggerDetails::Queue { queue_name, .. } => ("queue", queue_name.clone()),
|
||
TriggerDetails::Email { .. } => ("email", String::new()),
|
||
TriggerDetails::DeadLetter { .. } => ("dead_letter", String::new()),
|
||
};
|
||
TriggerTemplateInfo {
|
||
kind: kind.to_string(),
|
||
target,
|
||
script: name_by_id.get(&t.script_id).cloned().unwrap_or_default(),
|
||
enabled: t.enabled,
|
||
sealed: t.sealed,
|
||
shared: t.shared,
|
||
details: serde_json::to_value(&t.details).unwrap_or(serde_json::Value::Null),
|
||
dispatch_mode: t.dispatch_mode.as_str().to_string(),
|
||
retry_max_attempts: t.retry_max_attempts,
|
||
}
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
/// Read-only §11 tail report: a group's own route TEMPLATES, surfaced as
|
||
/// the inherited binding tuple + handler script name. Backs
|
||
/// `pic routes ls --group`.
|
||
pub async fn route_report(
|
||
&self,
|
||
owner: ApplyOwner,
|
||
) -> Result<Vec<RouteTemplateInfo>, ApplyError> {
|
||
let ApplyOwner::Group(group_id) = owner else {
|
||
return Ok(Vec::new());
|
||
};
|
||
let routes = self
|
||
.routes
|
||
.list_for_group(group_id)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
let scripts = self
|
||
.scripts
|
||
.list_for_group(group_id)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
let name_by_id: HashMap<ScriptId, String> =
|
||
scripts.iter().map(|s| (s.id, s.name.clone())).collect();
|
||
Ok(routes
|
||
.into_iter()
|
||
.map(|r| RouteTemplateInfo {
|
||
method: r.method.clone(),
|
||
host_kind: r.host_kind,
|
||
host: r.host.clone(),
|
||
host_param_name: r.host_param_name.clone(),
|
||
path_kind: r.path_kind,
|
||
path: r.path.clone(),
|
||
script: name_by_id.get(&r.script_id).cloned().unwrap_or_default(),
|
||
dispatch_mode: r.dispatch_mode,
|
||
enabled: r.enabled,
|
||
sealed: r.sealed,
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
pub async fn collection_report(
|
||
&self,
|
||
owner: ApplyOwner,
|
||
) -> Result<Vec<CollectionInfo>, ApplyError> {
|
||
let rows =
|
||
crate::group_collection_repo::list_all_for_owner(&self.pool, owner.as_script_owner())
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
Ok(rows
|
||
.into_iter()
|
||
.map(|(name, kind)| CollectionInfo { name, kind })
|
||
.collect())
|
||
}
|
||
|
||
/// The desired-state warnings a plan/apply of `bundle` onto `owner` would
|
||
/// emit — the ones knowable BEFORE committing, so `pic plan` previews exactly
|
||
/// what `apply` reports (post-commit side-effect warnings like route-table
|
||
/// refresh or materialization skips are apply-only). Covers: an enabled
|
||
/// binding on a disabled script (deployed-but-unreachable), an endpoint with
|
||
/// no route/trigger (apps only — a group endpoint is reached by inheritance),
|
||
/// and a `[suppress]` that matches no inherited template.
|
||
async fn plan_warnings(
|
||
&self,
|
||
owner: ApplyOwner,
|
||
bundle: &Bundle,
|
||
) -> Result<Vec<String>, ApplyError> {
|
||
let mut warnings = Vec::new();
|
||
warnings.extend(disabled_target_warnings(bundle));
|
||
if let ApplyOwner::App(_) = owner {
|
||
warnings.extend(unreachable_endpoint_warnings(bundle));
|
||
}
|
||
warnings.extend(self.dangling_suppress_warnings(owner, bundle).await?);
|
||
warnings.extend(workflow_nesting_warnings(bundle));
|
||
Ok(warnings)
|
||
}
|
||
|
||
/// §11 tail typo guard: a suppress reference that matches **no** inherited
|
||
/// template on the app's chain does nothing — return a warning for each such
|
||
/// dangling reference (soft; never fails the apply). Trigger refs match an
|
||
/// ancestor-group trigger's handler script name (case-insensitive); route
|
||
/// refs match an ancestor-group route's path.
|
||
async fn dangling_suppress_warnings(
|
||
&self,
|
||
owner: ApplyOwner,
|
||
bundle: &Bundle,
|
||
) -> Result<Vec<String>, ApplyError> {
|
||
if bundle.suppress_triggers.is_empty() && bundle.suppress_routes.is_empty() {
|
||
return Ok(Vec::new());
|
||
}
|
||
// §11 tail M1: the set of templates a suppression can decline lives on
|
||
// the OWNER's chain — an app walks its ancestor groups (`CHAIN_LEVELS_CTE`
|
||
// bound to the app), a group walks itself + its ancestors
|
||
// (`GROUP_CHAIN_LEVELS_CTE` bound to the group). Both CTEs expose a
|
||
// `chain` view with a `group_owner` column, so the queries below are
|
||
// identical modulo which CTE + bind.
|
||
let (chain_cte, owner_uuid) = match owner {
|
||
ApplyOwner::App(a) => (CHAIN_LEVELS_CTE, a.into_inner()),
|
||
ApplyOwner::Group(g) => (
|
||
crate::config_resolver::GROUP_CHAIN_LEVELS_CTE,
|
||
g.into_inner(),
|
||
),
|
||
};
|
||
// Inherited trigger handler names + route paths, each tagged with whether
|
||
// ANY matching template is un-sealed (i.e. actually suppressible).
|
||
// `bool_or(NOT sealed)` = true iff at least one match can be declined;
|
||
// false = every match is `sealed` (§11 tail), so the suppression is
|
||
// inert; absent = no inherited template at all (a typo).
|
||
let trig_names: Vec<(String, bool)> = sqlx::query_as(&format!(
|
||
"{chain_cte} \
|
||
SELECT LOWER(s.name), bool_or(NOT t.sealed) FROM triggers t \
|
||
JOIN scripts s ON s.id = t.script_id \
|
||
JOIN chain c ON t.group_id = c.group_owner \
|
||
WHERE t.group_id IS NOT NULL \
|
||
GROUP BY LOWER(s.name)",
|
||
))
|
||
.bind(owner_uuid)
|
||
.fetch_all(&self.pool)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
let inherited_handlers: HashMap<String, bool> = trig_names.into_iter().collect();
|
||
|
||
let route_paths: Vec<(String, bool)> = sqlx::query_as(&format!(
|
||
"{chain_cte} \
|
||
SELECT r.path, bool_or(NOT r.sealed) FROM routes r \
|
||
JOIN chain c ON r.group_id = c.group_owner \
|
||
WHERE r.group_id IS NOT NULL \
|
||
GROUP BY r.path",
|
||
))
|
||
.bind(owner_uuid)
|
||
.fetch_all(&self.pool)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
let inherited_paths: HashMap<String, bool> = route_paths.into_iter().collect();
|
||
|
||
let mut out = Vec::new();
|
||
for r in &bundle.suppress_triggers {
|
||
match inherited_handlers.get(&r.to_lowercase()) {
|
||
None => out.push(format!(
|
||
"suppress: no inherited trigger bound to script `{r}` — the \
|
||
suppression has no effect"
|
||
)),
|
||
Some(false) => out.push(format!(
|
||
"suppress: the inherited trigger bound to script `{r}` is \
|
||
sealed — the suppression has no effect"
|
||
)),
|
||
Some(true) => {}
|
||
}
|
||
}
|
||
for r in &bundle.suppress_routes {
|
||
match inherited_paths.get(r) {
|
||
None => out.push(format!(
|
||
"suppress: no inherited route at path `{r}` — the \
|
||
suppression has no effect"
|
||
)),
|
||
Some(false) => out.push(format!(
|
||
"suppress: the inherited route at path `{r}` is sealed — the \
|
||
suppression has no effect"
|
||
)),
|
||
Some(true) => {}
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Read-only §11 tail report: an owner's own suppression markers (the
|
||
/// inherited templates it declines). An app declines for itself, a group
|
||
/// for its subtree. Backs `pic suppress ls --app` / `--group`.
|
||
pub async fn suppression_report(
|
||
&self,
|
||
owner: ApplyOwner,
|
||
) -> Result<Vec<SuppressionInfo>, ApplyError> {
|
||
let rows = crate::suppression_repo::list_for_owner(&self.pool, owner.as_script_owner())
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
Ok(rows
|
||
.into_iter()
|
||
.map(|(target_kind, reference)| SuppressionInfo {
|
||
target_kind,
|
||
reference,
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
/// §5.5 no-provider plan check (app nodes only). Every extension point
|
||
/// visible to the app — declared in this bundle or inherited from an
|
||
/// ancestor — must have a provider: a module of that name the app provides
|
||
/// (in this bundle, or resolvable up its chain as an override or a default
|
||
/// body). Otherwise the import would fail at runtime, so refuse at plan.
|
||
/// Group nodes have no single inheriting app, so the check is per-app.
|
||
/// Single-node only (the tree path leans on the runtime backstop).
|
||
async fn check_extension_points_provided(
|
||
&self,
|
||
owner: ApplyOwner,
|
||
bundle: &Bundle,
|
||
) -> Result<(), ApplyError> {
|
||
let ApplyOwner::App(app_id) = owner else {
|
||
return Ok(());
|
||
};
|
||
// A same-apply app module provides its EP without being committed yet.
|
||
let local: HashSet<String> = bundle
|
||
.scripts
|
||
.iter()
|
||
.filter(|s| s.kind == ScriptKind::Module)
|
||
.map(|s| s.name.to_lowercase())
|
||
.collect();
|
||
// EP names visible to the app: its own (this bundle) ∪ inherited.
|
||
let mut names: HashSet<String> = bundle
|
||
.extension_points
|
||
.iter()
|
||
.map(|n| n.to_lowercase())
|
||
.collect();
|
||
for n in crate::extension_point_repo::list_on_app_chain(&self.pool, app_id)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
{
|
||
names.insert(n.to_lowercase());
|
||
}
|
||
for name in names {
|
||
if local.contains(&name) {
|
||
continue;
|
||
}
|
||
let found = self
|
||
.modules
|
||
.resolve(picloud_shared::ScriptOwner::App(app_id), &name)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
if found.is_none() {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"extension point `{name}` has no provider for this app — \
|
||
declare a module named `{name}` here or ensure a default \
|
||
body exists up-chain"
|
||
)));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// `inherited_endpoints` (lowercased names) are group-owned endpoint
|
||
/// scripts reachable from the app's chain that a route/trigger may bind to
|
||
/// even though the manifest doesn't declare them (Phase 4). They count as
|
||
/// known endpoints for the binding checks below.
|
||
#[allow(clippy::too_many_lines)]
|
||
fn validate_bundle(
|
||
&self,
|
||
bundle: &Bundle,
|
||
inherited_endpoints: &HashSet<String>,
|
||
) -> Result<(), ApplyError> {
|
||
// Scripts: unique names; each source compiles for its kind.
|
||
let mut names: HashSet<String> = HashSet::new();
|
||
for s in &bundle.scripts {
|
||
if !names.insert(s.name.to_lowercase()) {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"duplicate script name `{}`",
|
||
s.name
|
||
)));
|
||
}
|
||
let res = if s.kind == ScriptKind::Module {
|
||
// Parity with the interactive create path (`api.rs`): a module
|
||
// may not shadow a built-in SDK namespace, or `import "kv"`
|
||
// could resolve to a user module instead of the real bridge.
|
||
if crate::api::RESERVED_MODULE_NAMES.contains(&s.name.as_str()) {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"script `{}`: reserved module name (shadows a built-in SDK namespace)",
|
||
s.name
|
||
)));
|
||
}
|
||
self.validator.validate_module(&s.source)
|
||
} else {
|
||
self.validator.validate(&s.source)
|
||
};
|
||
res.map_err(|e| ApplyError::Invalid(format!("script `{}`: {e}", s.name)))?;
|
||
if let Some(sb) = &s.sandbox {
|
||
self.sandbox_ceiling
|
||
.check(sb)
|
||
.map_err(|e| ApplyError::Invalid(format!("script `{}`: {e}", s.name)))?;
|
||
}
|
||
}
|
||
let endpoints: HashSet<&str> = bundle
|
||
.scripts
|
||
.iter()
|
||
.filter(|s| s.kind == ScriptKind::Endpoint)
|
||
.map(|s| s.name.as_str())
|
||
.collect();
|
||
let all_names: HashSet<&str> = bundle.scripts.iter().map(|s| s.name.as_str()).collect();
|
||
|
||
// Routes: reserved paths; bind to an endpoint script; unique tuples.
|
||
let mut route_keys: HashSet<String> = HashSet::new();
|
||
for r in &bundle.routes {
|
||
reject_reserved_path(&r.path)?;
|
||
// Structural validity (parity with the interactive route API):
|
||
// a route whose path/host doesn't parse would otherwise be written
|
||
// and then silently dropped by the route-table compile.
|
||
pattern::parse_path(r.path_kind, &r.path)
|
||
.map_err(|e| ApplyError::Invalid(format!("route path `{}`: {e}", r.path)))?;
|
||
pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())
|
||
.map_err(|e| ApplyError::Invalid(format!("route host `{}`: {e}", r.host)))?;
|
||
let inherited = inherited_endpoints.contains(&r.script.to_lowercase());
|
||
if !all_names.contains(r.script.as_str()) && !inherited {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"route {} binds to unknown script `{}`",
|
||
route_key(
|
||
r.method.as_deref(),
|
||
r.host_kind,
|
||
&r.host,
|
||
r.path_kind,
|
||
&r.path
|
||
),
|
||
r.script
|
||
)));
|
||
}
|
||
// Inherited targets are resolved as endpoints by construction
|
||
// (the inherited-name resolver excludes group modules); only
|
||
// check kind for app-own names.
|
||
if !inherited && !endpoints.contains(r.script.as_str()) {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"route binds to `{}` which is a module, not an endpoint",
|
||
r.script
|
||
)));
|
||
}
|
||
let key = route_key(
|
||
r.method.as_deref(),
|
||
r.host_kind,
|
||
&r.host,
|
||
r.path_kind,
|
||
&r.path,
|
||
);
|
||
if !route_keys.insert(key.clone()) {
|
||
return Err(ApplyError::Invalid(format!("duplicate route `{key}`")));
|
||
}
|
||
}
|
||
|
||
// Triggers: bind to an endpoint script; unique semantic identity.
|
||
let mut trig_keys: HashSet<String> = HashSet::new();
|
||
for t in &bundle.triggers {
|
||
let inherited = inherited_endpoints.contains(&t.script().to_lowercase());
|
||
if !all_names.contains(t.script()) && !inherited {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"{} trigger binds to unknown script `{}`",
|
||
t.kind_str(),
|
||
t.script()
|
||
)));
|
||
}
|
||
if !inherited && !endpoints.contains(t.script()) {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"{} trigger binds to `{}` which is a module, not an endpoint",
|
||
t.kind_str(),
|
||
t.script()
|
||
)));
|
||
}
|
||
validate_trigger_shape(t)?;
|
||
let id = t.identity();
|
||
if !trig_keys.insert(id.clone()) {
|
||
return Err(ApplyError::Invalid(format!("duplicate trigger `{id}`")));
|
||
}
|
||
}
|
||
|
||
// Vars: kebab keys (same rule the vars admin API enforces), so a
|
||
// manifest can't write a key the interactive surface would reject.
|
||
for key in bundle.vars.keys() {
|
||
validate_var_key(key)?;
|
||
}
|
||
|
||
// Extension points (§5.5): unique names; none may shadow a built-in
|
||
// SDK namespace (same guard as module names — an `import "kv"` must
|
||
// never resolve to a user-supplied provider).
|
||
let mut ep_names: HashSet<String> = HashSet::new();
|
||
for name in &bundle.extension_points {
|
||
if !ep_names.insert(name.to_lowercase()) {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"duplicate extension point `{name}`"
|
||
)));
|
||
}
|
||
if crate::api::RESERVED_MODULE_NAMES.contains(&name.as_str()) {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"extension point `{name}`: reserved module name \
|
||
(shadows a built-in SDK namespace)"
|
||
)));
|
||
}
|
||
}
|
||
|
||
// Shared group collections (§11.6): a known kind + unique (name, kind).
|
||
// No reserved-name guard — a collection name is a data namespace, not an
|
||
// importable module, so it can't shadow an SDK namespace.
|
||
let mut seen_collections: HashSet<(String, &str)> = HashSet::new();
|
||
for c in &bundle.collections {
|
||
if c.name.is_empty() {
|
||
return Err(ApplyError::Invalid(
|
||
"shared collection name must not be empty".into(),
|
||
));
|
||
}
|
||
if !COLLECTION_KINDS.contains(&c.kind.as_str()) {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"shared collection `{}`: unknown kind `{}` (want one of {COLLECTION_KINDS:?})",
|
||
c.name, c.kind
|
||
)));
|
||
}
|
||
if !seen_collections.insert((c.name.to_lowercase(), c.kind.as_str())) {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"duplicate shared collection `{}` (kind `{}`)",
|
||
c.name, c.kind
|
||
)));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn load_current(&self, owner: ApplyOwner) -> Result<CurrentState, ApplyError> {
|
||
// A group node owns scripts + vars + route/trigger TEMPLATES (§11 tail);
|
||
// secrets stay empty (app-only — the bundle for a group declares none).
|
||
let (scripts, routes, triggers, secret_names, var_owner) = match owner {
|
||
ApplyOwner::App(app_id) => (
|
||
self.scripts
|
||
.list_for_app(app_id)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||
self.routes
|
||
.list_for_app(app_id)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||
self.triggers
|
||
.list_for_app(app_id)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||
self.all_secret_names(crate::secrets_service::SecretOwner::App(app_id))
|
||
.await?,
|
||
VarOwner::App(app_id),
|
||
),
|
||
ApplyOwner::Group(group_id) => (
|
||
self.scripts
|
||
.list_for_group(group_id)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||
// §11 tail: a group owns route + trigger TEMPLATES; load them so
|
||
// the diff sees existing markers (NoOp on re-apply, prune-able).
|
||
self.routes
|
||
.list_for_group(group_id)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||
self.triggers
|
||
.list_for_group(group_id)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||
// §4.5 M5.5: a group's own set secrets — the plan check for an
|
||
// email TEMPLATE resolves `inbound_secret_ref` against the
|
||
// GROUP store, and the state token folds them in. Informational
|
||
// in the secrets diff only (apply never writes secrets).
|
||
self.all_secret_names(crate::secrets_service::SecretOwner::Group(group_id))
|
||
.await?,
|
||
VarOwner::Group(group_id),
|
||
),
|
||
};
|
||
// The owner's OWN vars, narrowed to scope `*` and real values: the
|
||
// manifest reconciles env-agnostic config, not inherited vars (not in
|
||
// this owner's rows) nor tombstones (an imperative suppress the manifest
|
||
// can't express).
|
||
let vars = self
|
||
.vars
|
||
.list_for_owner(var_owner)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
.into_iter()
|
||
.filter(|r| r.environment_scope == "*" && !r.is_tombstone)
|
||
.map(|r| (r.key, r.value))
|
||
.collect();
|
||
// Extension-point markers declared directly at this node (§5.5).
|
||
let extension_point_names =
|
||
crate::extension_point_repo::list_for_owner(&self.pool, owner.as_script_owner())
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
// Shared group-collection markers declared directly at this node
|
||
// (§11.6), all kinds.
|
||
let collections =
|
||
crate::group_collection_repo::list_all_for_owner(&self.pool, owner.as_script_owner())
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
// §11 tail suppression markers declared directly at this node
|
||
// (§11 tail M1: owner-polymorphic — an app declines for itself, a group
|
||
// for its subtree).
|
||
let suppressions =
|
||
crate::suppression_repo::list_for_owner(&self.pool, owner.as_script_owner())
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
// v1.2 Workflows are app-owned (M1); a group node owns none.
|
||
let workflows = match owner {
|
||
ApplyOwner::App(app_id) => {
|
||
crate::workflow_repo::list_workflows_for_app(&self.pool, app_id)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
}
|
||
ApplyOwner::Group(_) => Vec::new(),
|
||
};
|
||
// §9.4 interceptor markers declared directly at this node.
|
||
let interceptors =
|
||
crate::interceptor_repo::list_for_owner(&self.pool, owner.as_script_owner())
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||
.into_iter()
|
||
.map(|m| (m.service, m.op, m.script))
|
||
.collect();
|
||
Ok(CurrentState {
|
||
scripts,
|
||
routes,
|
||
triggers,
|
||
secret_names,
|
||
vars,
|
||
extension_point_names,
|
||
collections,
|
||
suppressions,
|
||
workflows,
|
||
interceptors,
|
||
})
|
||
}
|
||
|
||
async fn all_secret_names(
|
||
&self,
|
||
owner: crate::secrets_service::SecretOwner,
|
||
) -> Result<Vec<String>, ApplyError> {
|
||
let mut names = Vec::new();
|
||
let mut cursor: Option<String> = None;
|
||
loop {
|
||
let page = self
|
||
.secrets
|
||
.list_names(owner, cursor.as_deref(), 200)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
names.extend(page.names);
|
||
match page.next_cursor {
|
||
Some(c) => cursor = Some(c),
|
||
None => break,
|
||
}
|
||
}
|
||
Ok(names)
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------
|
||
// Diff (pure)
|
||
// ----------------------------------------------------------------------------
|
||
|
||
/// Diff desired `bundle` against `current`. Pure — unit-tested directly.
|
||
#[must_use]
|
||
pub fn compute_diff(current: &CurrentState, bundle: &Bundle) -> Plan {
|
||
compute_diff_with_names(current, bundle, &HashMap::new())
|
||
}
|
||
|
||
/// `compute_diff` with `current_names` — the names of group-owned (inherited)
|
||
/// scripts the app's CURRENT routes/triggers are bound to, `script_id → name`
|
||
/// (Phase 4). Those ids aren't in `current.scripts` (app-own only), so without
|
||
/// them a current binding to a group script resolves to no name: it reads as a
|
||
/// perpetual rebind, and a binding dropped from the manifest never diffs to a
|
||
/// Delete (so `--prune` can't remove it). The public `compute_diff` passes an
|
||
/// empty map (no inheritance).
|
||
fn compute_diff_with_names(
|
||
current: &CurrentState,
|
||
bundle: &Bundle,
|
||
current_names: &HashMap<ScriptId, String>,
|
||
) -> Plan {
|
||
let script_name_by_id: HashMap<ScriptId, String> = current
|
||
.scripts
|
||
.iter()
|
||
.map(|s| (s.id, s.name.clone()))
|
||
// Inherited group bindings' ids → names. Disjoint from app-own ids by
|
||
// construction, so order doesn't matter.
|
||
.chain(current_names.iter().map(|(id, n)| (*id, n.clone())))
|
||
.collect();
|
||
|
||
Plan {
|
||
scripts: diff_scripts(current, bundle),
|
||
routes: diff_routes(current, bundle, &script_name_by_id),
|
||
triggers: diff_triggers(current, bundle, &script_name_by_id),
|
||
secrets: diff_secrets(current, bundle),
|
||
vars: diff_vars(current, bundle),
|
||
extension_points: diff_extension_points(current, bundle),
|
||
collections: diff_collections(current, bundle),
|
||
suppressions: diff_suppressions(current, bundle),
|
||
workflows: diff_workflows(current, bundle),
|
||
interceptors: diff_interceptors(current, bundle),
|
||
}
|
||
}
|
||
|
||
/// Diff §9.4 interceptor markers by `"{service}/{op}"` key with the script name
|
||
/// as the value — create/update/noop/delete like `vars`. A changed script for
|
||
/// the same `(service, op)` is an `Update`; a live marker the manifest stops
|
||
/// declaring is a `Delete` (applied only under `--prune`).
|
||
fn diff_interceptors(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||
let key = |service: &str, op: &str| format!("{service}/{op}");
|
||
let live: HashMap<String, &str> = current
|
||
.interceptors
|
||
.iter()
|
||
.map(|(s, o, script)| (key(s, o), script.as_str()))
|
||
.collect();
|
||
let mut out = Vec::new();
|
||
for bi in &bundle.interceptors {
|
||
let k = key(&bi.service, &bi.op);
|
||
match live.get(&k) {
|
||
Some(cur) if *cur == bi.script => out.push(ResourceChange {
|
||
op: Op::NoOp,
|
||
key: k,
|
||
detail: None,
|
||
}),
|
||
Some(_) => out.push(ResourceChange {
|
||
op: Op::Update,
|
||
key: k,
|
||
detail: Some("interceptor script changed".into()),
|
||
}),
|
||
None => out.push(ResourceChange {
|
||
op: Op::Create,
|
||
key: k,
|
||
detail: Some(bi.script.clone()),
|
||
}),
|
||
}
|
||
}
|
||
for (s, o, _) in ¤t.interceptors {
|
||
let present = bundle
|
||
.interceptors
|
||
.iter()
|
||
.any(|bi| bi.service == *s && bi.op == *o);
|
||
if !present {
|
||
out.push(ResourceChange {
|
||
op: Op::Delete,
|
||
key: key(s, o),
|
||
detail: Some("on server, not declared".into()),
|
||
});
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Diff workflows by `lower(name)`. Like scripts, a workflow has a stable name
|
||
/// identity with a mutable body, so a changed definition (or `enabled`) is an
|
||
/// `Update`, not a delete+create. Live workflows absent from the manifest are
|
||
/// `Delete` (applied only under `--prune`).
|
||
fn diff_workflows(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||
let by_name: HashMap<String, &crate::workflow_repo::Workflow> = current
|
||
.workflows
|
||
.iter()
|
||
.map(|w| (w.name.to_lowercase(), w))
|
||
.collect();
|
||
let desired: HashSet<String> = bundle
|
||
.workflows
|
||
.iter()
|
||
.map(|w| w.name.to_lowercase())
|
||
.collect();
|
||
|
||
let mut out = Vec::new();
|
||
for w in &bundle.workflows {
|
||
match by_name.get(&w.name.to_lowercase()) {
|
||
None => out.push(ResourceChange {
|
||
op: Op::Create,
|
||
key: w.name.clone(),
|
||
detail: None,
|
||
}),
|
||
Some(cur) => {
|
||
let reason = if cur.definition != w.definition {
|
||
Some("definition changed".to_string())
|
||
} else if cur.enabled != w.enabled {
|
||
Some(if w.enabled { "enabled" } else { "disabled" }.to_string())
|
||
} else {
|
||
None
|
||
};
|
||
out.push(ResourceChange {
|
||
op: if reason.is_some() {
|
||
Op::Update
|
||
} else {
|
||
Op::NoOp
|
||
},
|
||
key: w.name.clone(),
|
||
detail: reason,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
for w in ¤t.workflows {
|
||
if !desired.contains(&w.name.to_lowercase()) {
|
||
out.push(ResourceChange {
|
||
op: Op::Delete,
|
||
key: w.name.clone(),
|
||
detail: None,
|
||
});
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Pure structural validation of a workflow definition (mirrors
|
||
/// `validate_trigger_shape`): non-empty; unique step names; each step targets
|
||
/// exactly one of function/workflow; `depends_on` references exist and aren't
|
||
/// self-loops; the DAG is acyclic; every `when` + input template parses and
|
||
/// only references declared steps. Returns a human error string on failure.
|
||
fn validate_workflow_definition(
|
||
wf_name: &str,
|
||
def: &picloud_shared::workflow::WorkflowDefinition,
|
||
) -> Result<(), String> {
|
||
use std::collections::{BTreeSet, HashMap as Map};
|
||
if def.steps.is_empty() {
|
||
return Err(format!("workflow `{wf_name}` has no steps"));
|
||
}
|
||
let mut names: BTreeSet<String> = BTreeSet::new();
|
||
for s in &def.steps {
|
||
if s.name.trim().is_empty() {
|
||
return Err(format!(
|
||
"workflow `{wf_name}` has a step with an empty name"
|
||
));
|
||
}
|
||
if !names.insert(s.name.clone()) {
|
||
return Err(format!(
|
||
"workflow `{wf_name}` has a duplicate step name `{}`",
|
||
s.name
|
||
));
|
||
}
|
||
}
|
||
for s in &def.steps {
|
||
if s.target().is_none() {
|
||
return Err(format!(
|
||
"workflow `{wf_name}` step `{}` must set exactly one of `function` or `workflow`",
|
||
s.name
|
||
));
|
||
}
|
||
for dep in &s.depends_on {
|
||
if dep == &s.name {
|
||
return Err(format!(
|
||
"workflow `{wf_name}` step `{}` depends on itself",
|
||
s.name
|
||
));
|
||
}
|
||
if !names.contains(dep) {
|
||
return Err(format!(
|
||
"workflow `{wf_name}` step `{}` depends on undeclared step `{dep}`",
|
||
s.name
|
||
));
|
||
}
|
||
}
|
||
if let Some(w) = &s.when {
|
||
crate::workflow_expr::validate(w, &names).map_err(|e| {
|
||
format!(
|
||
"workflow `{wf_name}` step `{}` has a bad `when`: {e}",
|
||
s.name
|
||
)
|
||
})?;
|
||
}
|
||
crate::workflow_template::validate(&s.input, &names).map_err(|e| {
|
||
format!(
|
||
"workflow `{wf_name}` step `{}` has a bad input template: {e}",
|
||
s.name
|
||
)
|
||
})?;
|
||
}
|
||
// DAG acyclicity via Kahn topological sort. Count DISTINCT deps: a step that
|
||
// lists the same dependency twice inflates its in-degree past what the
|
||
// single-decrement in the drain loop below can undo, which would otherwise
|
||
// report a spurious cycle for a valid DAG.
|
||
let mut indeg: Map<&str, usize> = def.steps.iter().map(|s| (s.name.as_str(), 0)).collect();
|
||
for s in &def.steps {
|
||
let distinct: BTreeSet<&str> = s.depends_on.iter().map(String::as_str).collect();
|
||
*indeg.get_mut(s.name.as_str()).unwrap() += distinct.len();
|
||
}
|
||
let mut queue: Vec<&str> = indeg
|
||
.iter()
|
||
.filter(|(_, d)| **d == 0)
|
||
.map(|(n, _)| *n)
|
||
.collect();
|
||
let mut seen = 0usize;
|
||
while let Some(n) = queue.pop() {
|
||
seen += 1;
|
||
for s in &def.steps {
|
||
if s.depends_on.iter().any(|d| d == n) {
|
||
let e = indeg.get_mut(s.name.as_str()).unwrap();
|
||
*e -= 1;
|
||
if *e == 0 {
|
||
queue.push(s.name.as_str());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if seen != def.steps.len() {
|
||
return Err(format!("workflow `{wf_name}` has a dependency cycle"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Diff app-owned vars by key. Unlike secrets (whose values live out-of-band),
|
||
/// a var's value IS in the manifest, so equality is value-sensitive: a changed
|
||
/// value is an `Update`. Live vars absent from the manifest are `Delete`
|
||
/// (applied only under `--prune`).
|
||
fn diff_vars(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||
let live: HashMap<&str, &serde_json::Value> =
|
||
current.vars.iter().map(|(k, v)| (k.as_str(), v)).collect();
|
||
|
||
let mut out = Vec::new();
|
||
for (key, value) in &bundle.vars {
|
||
match live.get(key.as_str()) {
|
||
Some(cur) if *cur == value => out.push(ResourceChange {
|
||
op: Op::NoOp,
|
||
key: key.clone(),
|
||
detail: None,
|
||
}),
|
||
Some(_) => out.push(ResourceChange {
|
||
op: Op::Update,
|
||
key: key.clone(),
|
||
detail: Some("value changed".into()),
|
||
}),
|
||
None => out.push(ResourceChange {
|
||
op: Op::Create,
|
||
key: key.clone(),
|
||
detail: None,
|
||
}),
|
||
}
|
||
}
|
||
for (key, _) in ¤t.vars {
|
||
if !bundle.vars.contains_key(key) {
|
||
out.push(ResourceChange {
|
||
op: Op::Delete,
|
||
key: key.clone(),
|
||
detail: Some("on server, not declared".into()),
|
||
});
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Kebab var key (`^[a-z0-9][a-z0-9-]{0,127}$`) — mirrors `vars_api`'s
|
||
/// `validate_key` so the manifest and the interactive surface agree.
|
||
fn validate_var_key(key: &str) -> Result<(), ApplyError> {
|
||
let ok = !key.is_empty()
|
||
&& key.len() <= 128
|
||
&& key
|
||
.chars()
|
||
.next()
|
||
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||
&& key
|
||
.chars()
|
||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
|
||
if ok {
|
||
Ok(())
|
||
} else {
|
||
Err(ApplyError::Invalid(format!(
|
||
"var key `{key}` must be 1–128 chars of lowercase letters, digits, and hyphens, \
|
||
starting with a letter or digit"
|
||
)))
|
||
}
|
||
}
|
||
|
||
/// Upsert one app-owned var at scope `*`, in the apply transaction. Mirrors
|
||
/// `PostgresVarsRepo::set` for `VarOwner::App` (the partial-index predicate is
|
||
/// restated in the conflict target) but runs against `&mut tx` so it commits
|
||
/// atomically with the rest of the apply. Clears any tombstone.
|
||
async fn set_app_var_tx(
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
app_id: AppId,
|
||
key: &str,
|
||
value: &serde_json::Value,
|
||
) -> Result<(), ApplyError> {
|
||
sqlx::query(
|
||
"INSERT INTO vars (app_id, environment_scope, key, value, is_tombstone) \
|
||
VALUES ($1, '*', $2, $3, FALSE) \
|
||
ON CONFLICT (app_id, environment_scope, key) WHERE app_id IS NOT NULL DO UPDATE \
|
||
SET value = EXCLUDED.value, is_tombstone = FALSE, updated_at = NOW()",
|
||
)
|
||
.bind(app_id.into_inner())
|
||
.bind(key)
|
||
.bind(value)
|
||
.execute(&mut **tx)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Delete one app-owned var at scope `*`, in the apply transaction.
|
||
async fn delete_app_var_tx(
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
app_id: AppId,
|
||
key: &str,
|
||
) -> Result<(), ApplyError> {
|
||
sqlx::query("DELETE FROM vars WHERE app_id = $1 AND environment_scope = '*' AND key = $2")
|
||
.bind(app_id.into_inner())
|
||
.bind(key)
|
||
.execute(&mut **tx)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Upsert one group-owned var at scope `*`, in the apply transaction (Phase 5).
|
||
/// Mirrors `set_app_var_tx` for `VarOwner::Group`. Env-scoped group vars
|
||
/// (`@staging`) stay imperative for now — a group manifest declares only
|
||
/// env-agnostic (`*`) group config.
|
||
async fn set_group_var_tx(
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
group_id: GroupId,
|
||
key: &str,
|
||
value: &serde_json::Value,
|
||
) -> Result<(), ApplyError> {
|
||
sqlx::query(
|
||
"INSERT INTO vars (group_id, environment_scope, key, value, is_tombstone) \
|
||
VALUES ($1, '*', $2, $3, FALSE) \
|
||
ON CONFLICT (group_id, environment_scope, key) WHERE group_id IS NOT NULL DO UPDATE \
|
||
SET value = EXCLUDED.value, is_tombstone = FALSE, updated_at = NOW()",
|
||
)
|
||
.bind(group_id.into_inner())
|
||
.bind(key)
|
||
.bind(value)
|
||
.execute(&mut **tx)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Delete one group-owned var at scope `*`, in the apply transaction (Phase 5).
|
||
async fn delete_group_var_tx(
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
group_id: GroupId,
|
||
key: &str,
|
||
) -> Result<(), ApplyError> {
|
||
sqlx::query("DELETE FROM vars WHERE group_id = $1 AND environment_scope = '*' AND key = $2")
|
||
.bind(group_id.into_inner())
|
||
.bind(key)
|
||
.execute(&mut **tx)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------
|
||
// §7 multi-repo ownership — the claim state machine + its tx helpers.
|
||
//
|
||
// A project is single-owner-per-NODE: each group node is owned by exactly one
|
||
// project (`groups.owner_project`); apps inherit ownership from their nearest
|
||
// claimed ancestor group. The pure `decide_*` fns are the whole policy; the
|
||
// apply path executes their verdict (the UPDATE / the takeover authz gate)
|
||
// under the per-node advisory lock. See docs/design/groups-and-project-tool.md §7.
|
||
// ----------------------------------------------------------------------------
|
||
|
||
/// What a group-node apply should do about ownership, given the current owner,
|
||
/// the declared project, and whether `--takeover` was passed. Generic over the
|
||
/// identity key `K` so the same policy serves both the apply path (`K =
|
||
/// ProjectId`, comparing the assigned ids) and the plan preview (`K = String`,
|
||
/// comparing slugs before any id is assigned).
|
||
#[derive(Debug, PartialEq, Eq)]
|
||
enum GroupClaimAction<K> {
|
||
/// Nothing to write (unclaimed + no project, or already owned by us).
|
||
Noop,
|
||
/// First-apply-claims: set `owner_project`.
|
||
Claim(K),
|
||
/// Reassign to us — capability-gated (`GroupAdmin`) at the call site.
|
||
Takeover(K),
|
||
/// Refuse (409); the string is the actionable message.
|
||
Conflict(String),
|
||
}
|
||
|
||
/// Pure ownership policy for a GROUP node. `current` is the live owner as
|
||
/// `(key, slug)`; `declared` is this apply's project key. See §7.3.
|
||
fn decide_group_claim<K: Eq>(
|
||
current: Option<(K, String)>,
|
||
declared: Option<K>,
|
||
takeover: bool,
|
||
) -> GroupClaimAction<K> {
|
||
match (current, declared) {
|
||
// Unclaimed: a declared project claims it; no project is a no-op (the
|
||
// node stays UI/API-owned — the backward-compatible path).
|
||
(None, Some(p)) => GroupClaimAction::Claim(p),
|
||
(None, None) => GroupClaimAction::Noop,
|
||
// Already ours: re-apply by the owning repo.
|
||
(Some((c, _)), Some(p)) if c == p => GroupClaimAction::Noop,
|
||
// Owned by someone else: takeover (gated) or refuse.
|
||
(Some((_, cslug)), Some(p)) => {
|
||
if takeover {
|
||
GroupClaimAction::Takeover(p)
|
||
} else {
|
||
GroupClaimAction::Conflict(format!(
|
||
"group is owned by project '{cslug}'; use --takeover (requires group-admin) \
|
||
to reassign it"
|
||
))
|
||
}
|
||
}
|
||
// A no-project apply must not silently write into a claimed subtree.
|
||
(Some((_, cslug)), None) => GroupClaimAction::Conflict(format!(
|
||
"group is owned by project '{cslug}'; declare a matching [project] (or --takeover)"
|
||
)),
|
||
}
|
||
}
|
||
|
||
/// Pure ownership policy for an APP node. Apps never claim — they must match
|
||
/// their nearest claimed ancestor group (`nearest`, as `(key, slug)`); an
|
||
/// unclaimed subtree is open (backward-compatible). Generic over the identity
|
||
/// key `K` for the same reason as [`decide_group_claim`]. See §7.
|
||
fn decide_app_owner<K: Eq>(
|
||
nearest: Option<(K, String)>,
|
||
declared: Option<K>,
|
||
) -> Result<(), String> {
|
||
match (nearest, declared) {
|
||
(None, _) => Ok(()),
|
||
(Some((c, _)), Some(p)) if c == p => Ok(()),
|
||
(Some((_, cslug)), Some(_)) => Err(format!(
|
||
"the nearest owning group belongs to project '{cslug}', not this apply's project; \
|
||
apply from '{cslug}', or --takeover its group from a [group] node"
|
||
)),
|
||
(Some((_, cslug)), None) => Err(format!(
|
||
"this app is under a subtree owned by project '{cslug}'; declare [project] '{cslug}'"
|
||
)),
|
||
}
|
||
}
|
||
|
||
/// §7: validate a `[project]` slug — the same FORMAT rule as app/group slugs
|
||
/// (`^[a-z0-9][a-z0-9-]{0,62}$`), reusing the shared validator. A project slug
|
||
/// is intentionally NOT reserved-word-checked: unlike a group/app slug it is
|
||
/// never used in a route or host, so no path can collide with it.
|
||
fn validate_project_slug(slug: &str) -> Result<(), ApplyError> {
|
||
crate::groups_api::validate_slug_format(slug)
|
||
.map_err(|why| ApplyError::Invalid(format!("project slug {slug:?}: {why}")))
|
||
}
|
||
|
||
/// Register (or fetch) a project by slug inside the apply tx, returning its id.
|
||
/// First apply with a new slug inserts, falling back to the slug when the
|
||
/// manifest omits `name` (the column is NOT NULL). A re-apply updates the
|
||
/// display name ONLY when the manifest actually declares one — a name-less
|
||
/// `[project]` block (e.g. a clone scaffolded without a name) preserves the
|
||
/// previously-set name and `created_by` rather than clobbering the name back
|
||
/// to the slug. `$2` (the optional declared name) is bound once and referenced
|
||
/// in both the INSERT fallback and the ON CONFLICT guard.
|
||
async fn upsert_project_tx(
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
decl: &ProjectDecl,
|
||
actor: AdminUserId,
|
||
) -> Result<ProjectId, ApplyError> {
|
||
validate_project_slug(&decl.slug)?;
|
||
let (id,): (uuid::Uuid,) = sqlx::query_as(
|
||
"INSERT INTO projects (slug, name, created_by) VALUES ($1, COALESCE($2, $1), $3) \
|
||
ON CONFLICT (slug) DO UPDATE SET name = COALESCE($2, projects.name) \
|
||
RETURNING id",
|
||
)
|
||
.bind(&decl.slug)
|
||
.bind(decl.name.clone())
|
||
.bind(actor.into_inner())
|
||
.fetch_one(&mut **tx)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
// §3 M3 (hermetic gate): persist the declared per-env approval policy as the
|
||
// authoritative source. Declarative replace (delete-then-insert) so a
|
||
// removed env drops out — the gate reads `persisted ∪ declared`, so an
|
||
// ungating still has to pass the prior gate at apply time (see
|
||
// `apply_api::env_gate_check`). Same tx as the project row + node claim.
|
||
sqlx::query("DELETE FROM project_environments WHERE project_id = $1")
|
||
.bind(id)
|
||
.execute(&mut **tx)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
for (env_name, pol) in &decl.environments {
|
||
sqlx::query(
|
||
"INSERT INTO project_environments (project_id, env_name, confirm) \
|
||
VALUES ($1, $2, $3)",
|
||
)
|
||
.bind(id)
|
||
.bind(env_name)
|
||
.bind(pol.confirm)
|
||
.execute(&mut **tx)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
}
|
||
Ok(id.into())
|
||
}
|
||
|
||
/// Read a group's current owner as `(project id, project slug)` inside the tx,
|
||
/// or `None` when unclaimed. Serialized with the write via the caller's
|
||
/// per-node advisory lock.
|
||
async fn read_group_owner_tx(
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
gid: GroupId,
|
||
) -> Result<Option<(ProjectId, String)>, ApplyError> {
|
||
let row: Option<(Option<uuid::Uuid>, Option<String>)> = sqlx::query_as(
|
||
"SELECT g.owner_project, p.slug FROM groups g \
|
||
LEFT JOIN projects p ON p.id = g.owner_project WHERE g.id = $1",
|
||
)
|
||
.bind(gid.into_inner())
|
||
.fetch_optional(&mut **tx)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
Ok(row.and_then(|(pid, slug)| pid.map(|p| (p.into(), slug.unwrap_or_default()))))
|
||
}
|
||
|
||
/// Assign `owner_project` for a group inside the tx. Deliberately does NOT bump
|
||
/// `structure_version` — a claim is not a diff/inheritance change, so it must
|
||
/// not invalidate a pending bound plan (hazard b in the design).
|
||
async fn write_group_owner_tx(
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
gid: GroupId,
|
||
pid: ProjectId,
|
||
) -> Result<(), ApplyError> {
|
||
sqlx::query("UPDATE groups SET owner_project = $1 WHERE id = $2")
|
||
.bind(pid.into_inner())
|
||
.bind(gid.into_inner())
|
||
.execute(&mut **tx)
|
||
.await
|
||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Nearest claimed ancestor for an app, resolved in-tx: an in-tree group
|
||
/// (Phase-A overlay) wins over a committed ancestor. `app_chain` is nearest-
|
||
/// first. Used by the tree path (the single-node path folds `ancestors()`).
|
||
async fn nearest_claimed_in_tx(
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
app_chain: &[GroupId],
|
||
in_tree: &HashMap<GroupId, (ProjectId, String)>,
|
||
) -> Result<Option<(ProjectId, String)>, ApplyError> {
|
||
for gid in app_chain {
|
||
if let Some(hit) = in_tree.get(gid) {
|
||
return Ok(Some(hit.clone()));
|
||
}
|
||
if let Some(hit) = read_group_owner_tx(tx, *gid).await? {
|
||
return Ok(Some(hit));
|
||
}
|
||
}
|
||
Ok(None)
|
||
}
|
||
|
||
/// Map an authz denial to the apply surface (`Denied` → 403, repo error → 500).
|
||
/// Used by the takeover capability gate, keeping "lacks group-admin" (403)
|
||
/// distinct from a 409 ownership conflict.
|
||
fn map_authz(d: AuthzDenied) -> ApplyError {
|
||
match d {
|
||
AuthzDenied::Denied => ApplyError::Forbidden,
|
||
AuthzDenied::Repo(e) => ApplyError::AuthzRepo(e.to_string()),
|
||
}
|
||
}
|
||
|
||
/// §6: map a group-repo failure from the declarative create path — a real DB
|
||
/// error is a 500, everything else (slug/parent conflict) is a 422 with the
|
||
/// actionable message.
|
||
fn map_group_repo_err(e: crate::group_repo::GroupRepositoryError) -> ApplyError {
|
||
match e {
|
||
crate::group_repo::GroupRepositoryError::Db(e) => ApplyError::Backend(e.to_string()),
|
||
other => ApplyError::Invalid(other.to_string()),
|
||
}
|
||
}
|
||
|
||
/// Owner of an apply node (Phase 5): an app or a group. The apply engine is
|
||
/// owner-generic — only script-create ownership, var writes, the current-state
|
||
/// load, and the advisory lock differ; the diff and all other CRUD are shared.
|
||
/// A group node has only scripts + vars (no routes / triggers / secrets).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum ApplyOwner {
|
||
App(AppId),
|
||
Group(GroupId),
|
||
}
|
||
|
||
impl ApplyOwner {
|
||
/// `(app_id, group_id)` for a `NewScript` — exactly one is `Some`.
|
||
fn script_owner(self) -> (Option<AppId>, Option<GroupId>) {
|
||
match self {
|
||
Self::App(a) => (Some(a), None),
|
||
Self::Group(g) => (None, Some(g)),
|
||
}
|
||
}
|
||
|
||
/// As the shared [`ScriptOwner`] — for the module/extension-point repos.
|
||
fn as_script_owner(self) -> picloud_shared::ScriptOwner {
|
||
match self {
|
||
Self::App(a) => picloud_shared::ScriptOwner::App(a),
|
||
Self::Group(g) => picloud_shared::ScriptOwner::Group(g),
|
||
}
|
||
}
|
||
|
||
/// As a [`SecretOwner`] — for resolving an email trigger's inbound secret
|
||
/// against the owner's store (§4.5 M5: an app trigger vs a group template).
|
||
fn secret_owner(self) -> crate::secrets_service::SecretOwner {
|
||
match self {
|
||
Self::App(a) => crate::secrets_service::SecretOwner::App(a),
|
||
Self::Group(g) => crate::secrets_service::SecretOwner::Group(g),
|
||
}
|
||
}
|
||
|
||
async fn set_var_tx(
|
||
self,
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
key: &str,
|
||
value: &serde_json::Value,
|
||
) -> Result<(), ApplyError> {
|
||
match self {
|
||
Self::App(a) => set_app_var_tx(tx, a, key, value).await,
|
||
Self::Group(g) => set_group_var_tx(tx, g, key, value).await,
|
||
}
|
||
}
|
||
|
||
async fn delete_var_tx(
|
||
self,
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
key: &str,
|
||
) -> Result<(), ApplyError> {
|
||
match self {
|
||
Self::App(a) => delete_app_var_tx(tx, a, key).await,
|
||
Self::Group(g) => delete_group_var_tx(tx, g, key).await,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn diff_scripts(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||
let by_name: HashMap<String, &Script> = current
|
||
.scripts
|
||
.iter()
|
||
.map(|s| (s.name.to_lowercase(), s))
|
||
.collect();
|
||
let desired: HashSet<String> = bundle
|
||
.scripts
|
||
.iter()
|
||
.map(|s| s.name.to_lowercase())
|
||
.collect();
|
||
|
||
let mut out = Vec::new();
|
||
for s in &bundle.scripts {
|
||
match by_name.get(&s.name.to_lowercase()) {
|
||
None => out.push(ResourceChange {
|
||
op: Op::Create,
|
||
key: s.name.clone(),
|
||
detail: None,
|
||
}),
|
||
Some(cur) => {
|
||
if let Some(reason) = script_update_reason(cur, s) {
|
||
out.push(ResourceChange {
|
||
op: Op::Update,
|
||
key: s.name.clone(),
|
||
detail: Some(reason),
|
||
});
|
||
} else {
|
||
out.push(ResourceChange {
|
||
op: Op::NoOp,
|
||
key: s.name.clone(),
|
||
detail: None,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for s in ¤t.scripts {
|
||
if !desired.contains(&s.name.to_lowercase()) {
|
||
out.push(ResourceChange {
|
||
op: Op::Delete,
|
||
key: s.name.clone(),
|
||
detail: None,
|
||
});
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Returns `Some(reason)` if the desired script differs from current. A
|
||
/// `None` optional field means "unspecified — leave as-is", so it never
|
||
/// forces an update.
|
||
fn script_update_reason(cur: &Script, desired: &BundleScript) -> Option<String> {
|
||
if cur.source != desired.source {
|
||
return Some("source changed".into());
|
||
}
|
||
if cur.kind != desired.kind {
|
||
return Some("kind changed".into());
|
||
}
|
||
// Declarative (default true): always reconcile to the manifest's value.
|
||
if cur.enabled != desired.enabled {
|
||
return Some(if desired.enabled {
|
||
"enabled".into()
|
||
} else {
|
||
"disabled".into()
|
||
});
|
||
}
|
||
// Sparse like the other optional fields: an omitted (`None`) description
|
||
// means "leave as-is", so only an explicitly-set value that differs
|
||
// forces an update. (Clearing a description is done in the dashboard.)
|
||
if let Some(d) = &desired.description {
|
||
if cur.description.as_ref() != Some(d) {
|
||
return Some("description changed".into());
|
||
}
|
||
}
|
||
if let Some(t) = desired.timeout_seconds {
|
||
if i64::from(cur.timeout_seconds) != i64::from(t) {
|
||
return Some("timeout changed".into());
|
||
}
|
||
}
|
||
if let Some(m) = desired.memory_limit_mb {
|
||
if i64::from(cur.memory_limit_mb) != i64::from(m) {
|
||
return Some("memory changed".into());
|
||
}
|
||
}
|
||
if let Some(sb) = desired.sandbox {
|
||
if cur.sandbox != sb {
|
||
return Some("sandbox changed".into());
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn diff_routes(
|
||
current: &CurrentState,
|
||
bundle: &Bundle,
|
||
name_by_id: &HashMap<ScriptId, String>,
|
||
) -> Vec<ResourceChange> {
|
||
let by_key: HashMap<String, &Route> = current
|
||
.routes
|
||
.iter()
|
||
.map(|r| {
|
||
(
|
||
route_key(
|
||
r.method.as_deref(),
|
||
r.host_kind,
|
||
&r.host,
|
||
r.path_kind,
|
||
&r.path,
|
||
),
|
||
r,
|
||
)
|
||
})
|
||
.collect();
|
||
let desired: HashSet<String> = bundle
|
||
.routes
|
||
.iter()
|
||
.map(|r| {
|
||
route_key(
|
||
r.method.as_deref(),
|
||
r.host_kind,
|
||
&r.host,
|
||
r.path_kind,
|
||
&r.path,
|
||
)
|
||
})
|
||
.collect();
|
||
|
||
let mut out = Vec::new();
|
||
for r in &bundle.routes {
|
||
let key = route_key(
|
||
r.method.as_deref(),
|
||
r.host_kind,
|
||
&r.host,
|
||
r.path_kind,
|
||
&r.path,
|
||
);
|
||
match by_key.get(&key) {
|
||
None => out.push(ResourceChange {
|
||
op: Op::Create,
|
||
key,
|
||
detail: Some(format!("→ {}", r.script)),
|
||
}),
|
||
Some(cur) => {
|
||
let cur_script = name_by_id.get(&cur.script_id).map(String::as_str);
|
||
if cur_script != Some(r.script.as_str())
|
||
|| cur.dispatch_mode != r.dispatch_mode
|
||
|| cur.host_param_name != r.host_param_name
|
||
|| cur.enabled != r.enabled
|
||
// §11 tail: a sealed toggle on a group route template must
|
||
// re-insert the row so the rebuild sees the new flag.
|
||
|| cur.sealed != r.sealed
|
||
{
|
||
out.push(ResourceChange {
|
||
op: Op::Update,
|
||
key,
|
||
detail: Some(format!("→ {}", r.script)),
|
||
});
|
||
} else {
|
||
out.push(ResourceChange {
|
||
op: Op::NoOp,
|
||
key,
|
||
detail: None,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for r in ¤t.routes {
|
||
let key = route_key(
|
||
r.method.as_deref(),
|
||
r.host_kind,
|
||
&r.host,
|
||
r.path_kind,
|
||
&r.path,
|
||
);
|
||
if !desired.contains(&key) {
|
||
out.push(ResourceChange {
|
||
op: Op::Delete,
|
||
key,
|
||
detail: None,
|
||
});
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn diff_triggers(
|
||
current: &CurrentState,
|
||
bundle: &Bundle,
|
||
name_by_id: &HashMap<ScriptId, String>,
|
||
) -> Vec<ResourceChange> {
|
||
let by_id: HashMap<String, &Trigger> = current
|
||
.triggers
|
||
.iter()
|
||
.filter_map(|t| current_trigger_identity(t, name_by_id).map(|id| (id, t)))
|
||
.collect();
|
||
let desired: HashSet<String> = bundle
|
||
.triggers
|
||
.iter()
|
||
.map(BundleTrigger::identity)
|
||
.collect();
|
||
|
||
let mut out = Vec::new();
|
||
for t in &bundle.triggers {
|
||
let id = t.identity();
|
||
// Semantic identity captures the whole definition for these kinds,
|
||
// so a match is a no-op (changing a field changes the identity).
|
||
if by_id.contains_key(&id) {
|
||
out.push(ResourceChange {
|
||
op: Op::NoOp,
|
||
key: id,
|
||
detail: None,
|
||
});
|
||
} else {
|
||
out.push(ResourceChange {
|
||
op: Op::Create,
|
||
key: id,
|
||
detail: None,
|
||
});
|
||
}
|
||
}
|
||
for t in ¤t.triggers {
|
||
// Never prune email triggers: `pull` can't represent them (the
|
||
// server stores the sealed secret, not the `inbound_secret_ref`), so
|
||
// their absence from the manifest is ambiguous, not a delete intent.
|
||
// Manage email triggers with `pic triggers rm`.
|
||
if matches!(t.details, TriggerDetails::Email { .. }) {
|
||
continue;
|
||
}
|
||
if let Some(id) = current_trigger_identity(t, name_by_id) {
|
||
if !desired.contains(&id) {
|
||
out.push(ResourceChange {
|
||
op: Op::Delete,
|
||
key: id,
|
||
detail: None,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn diff_secrets(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||
let live: HashSet<&str> = current.secret_names.iter().map(String::as_str).collect();
|
||
let declared: HashSet<&str> = bundle.secrets.iter().map(String::as_str).collect();
|
||
|
||
let mut out = Vec::new();
|
||
for name in &bundle.secrets {
|
||
if live.contains(name.as_str()) {
|
||
out.push(ResourceChange {
|
||
op: Op::NoOp,
|
||
key: name.clone(),
|
||
detail: None,
|
||
});
|
||
} else {
|
||
out.push(ResourceChange {
|
||
op: Op::Create,
|
||
key: name.clone(),
|
||
detail: Some("declared but unset — push with `pic secret set`".into()),
|
||
});
|
||
}
|
||
}
|
||
for name in ¤t.secret_names {
|
||
if !declared.contains(name.as_str()) {
|
||
out.push(ResourceChange {
|
||
op: Op::Delete,
|
||
key: name.clone(),
|
||
detail: Some("on server, not declared".into()),
|
||
});
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Diff extension-point markers by name (§5.5). Name-only identity like
|
||
/// secrets, but — unlike secrets — the markers are real declarations the
|
||
/// manifest owns: a declared-but-absent name is a Create (the apply inserts
|
||
/// it), and a live-but-undeclared name is a Delete (pruned under `--prune`),
|
||
/// mirroring `vars`.
|
||
fn diff_extension_points(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||
let live: HashSet<&str> = current
|
||
.extension_point_names
|
||
.iter()
|
||
.map(String::as_str)
|
||
.collect();
|
||
let declared: HashSet<&str> = bundle.extension_points.iter().map(String::as_str).collect();
|
||
|
||
let mut out = Vec::new();
|
||
for name in &bundle.extension_points {
|
||
out.push(ResourceChange {
|
||
op: if live.contains(name.as_str()) {
|
||
Op::NoOp
|
||
} else {
|
||
Op::Create
|
||
},
|
||
key: name.clone(),
|
||
detail: None,
|
||
});
|
||
}
|
||
for name in ¤t.extension_point_names {
|
||
if !declared.contains(name.as_str()) {
|
||
out.push(ResourceChange {
|
||
op: Op::Delete,
|
||
key: name.clone(),
|
||
detail: Some("on server, not declared".into()),
|
||
});
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Diff shared group-collection markers by `(name, kind)` (§11.6). A
|
||
/// declared-but-absent marker is a Create, a live-but-undeclared one is a
|
||
/// Delete (pruned under `--prune`). Each change carries `key = name`,
|
||
/// `detail = Some(kind)` so the reconcile knows which store to touch and
|
||
/// `pic plan` renders the kind. Identity is `(LOWER(name), kind)`, so the same
|
||
/// name as both a `kv` and a `docs` collection are distinct markers.
|
||
fn diff_collections(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||
let live: HashSet<(String, &str)> = current
|
||
.collections
|
||
.iter()
|
||
.map(|(n, k)| (n.to_lowercase(), k.as_str()))
|
||
.collect();
|
||
let declared: HashSet<(String, &str)> = bundle
|
||
.collections
|
||
.iter()
|
||
.map(|c| (c.name.to_lowercase(), c.kind.as_str()))
|
||
.collect();
|
||
|
||
let mut out = Vec::new();
|
||
for c in &bundle.collections {
|
||
out.push(ResourceChange {
|
||
op: if live.contains(&(c.name.to_lowercase(), c.kind.as_str())) {
|
||
Op::NoOp
|
||
} else {
|
||
Op::Create
|
||
},
|
||
key: c.name.clone(),
|
||
detail: Some(c.kind.clone()),
|
||
});
|
||
}
|
||
for (name, kind) in ¤t.collections {
|
||
if !declared.contains(&(name.to_lowercase(), kind.as_str())) {
|
||
out.push(ResourceChange {
|
||
op: Op::Delete,
|
||
key: name.clone(),
|
||
detail: Some(kind.clone()),
|
||
});
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// §11 tail per-app suppression diff. Reconciles the app's declared
|
||
/// `suppress_triggers` (handler script names) + `suppress_routes` (paths) into
|
||
/// `template_suppressions` markers. Keyed `"{target_kind}:{reference}"` so a
|
||
/// trigger ref and a route ref of the same string are distinct markers; the
|
||
/// reconcile splits the key back on the first `:`.
|
||
fn diff_suppressions(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||
// Desired (kind, reference) pairs from the two manifest lists.
|
||
let desired: Vec<(&str, &str)> = bundle
|
||
.suppress_triggers
|
||
.iter()
|
||
.map(|r| (crate::suppression_repo::TARGET_TRIGGER, r.as_str()))
|
||
.chain(
|
||
bundle
|
||
.suppress_routes
|
||
.iter()
|
||
.map(|r| (crate::suppression_repo::TARGET_ROUTE, r.as_str())),
|
||
)
|
||
.collect();
|
||
let live: HashSet<(&str, &str)> = current
|
||
.suppressions
|
||
.iter()
|
||
.map(|(k, r)| (k.as_str(), r.as_str()))
|
||
.collect();
|
||
let declared: HashSet<(&str, &str)> = desired.iter().copied().collect();
|
||
|
||
let mut out = Vec::new();
|
||
for (kind, reference) in &desired {
|
||
out.push(ResourceChange {
|
||
op: if live.contains(&(*kind, *reference)) {
|
||
Op::NoOp
|
||
} else {
|
||
Op::Create
|
||
},
|
||
key: format!("{kind}:{reference}"),
|
||
detail: Some((*kind).to_string()),
|
||
});
|
||
}
|
||
for (kind, reference) in ¤t.suppressions {
|
||
if !declared.contains(&(kind.as_str(), reference.as_str())) {
|
||
out.push(ResourceChange {
|
||
op: Op::Delete,
|
||
key: format!("{kind}:{reference}"),
|
||
detail: Some(kind.clone()),
|
||
});
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Reject any email trigger whose referenced secret isn't set, so `plan` and
|
||
/// `apply` give the same answer (apply also fails in `resolve_and_seal`, but
|
||
/// only after taking the lock — surfacing it here keeps plan honest).
|
||
///
|
||
/// This checks the secret *reference exists*. `resolve_and_seal` additionally
|
||
/// requires the resolved value to be a non-empty string; `plan` deliberately
|
||
/// does not decrypt secrets, so a reference to a set-but-empty secret passes
|
||
/// `plan` and is caught at `apply` — the one residual plan/apply divergence,
|
||
/// and a deliberate one (plan stays read-only and crypto-free).
|
||
fn validate_email_secrets_present(
|
||
bundle: &Bundle,
|
||
secret_names: &[String],
|
||
) -> Result<(), ApplyError> {
|
||
for t in &bundle.triggers {
|
||
if let BundleTrigger::Email {
|
||
script,
|
||
inbound_secret_ref,
|
||
..
|
||
} = t
|
||
{
|
||
if !secret_names.iter().any(|n| n == inbound_secret_ref) {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"email trigger on `{script}` references secret \
|
||
`{inbound_secret_ref}`, which is not set; push it with \
|
||
`pic secret set` first"
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Semantic identity for a live trigger. Returns `None` only for
|
||
/// `dead_letter` (not representable in the manifest), so it's ignored by the
|
||
/// diff entirely. `email` returns `Some` so a declared email trigger matches
|
||
/// as NoOp, but `diff_triggers` separately refuses to emit a Delete for it.
|
||
fn current_trigger_identity(t: &Trigger, name_by_id: &HashMap<ScriptId, String>) -> Option<String> {
|
||
let script = name_by_id.get(&t.script_id)?;
|
||
// §11 tail: `sealed` (and §11.6 `shared`) are part of the identity for the
|
||
// event kinds (mirroring `BundleTrigger::identity`), so toggling either on a
|
||
// group template re-diffs.
|
||
let sealed = t.sealed;
|
||
let shared = t.shared;
|
||
match &t.details {
|
||
TriggerDetails::Kv {
|
||
collection_glob,
|
||
ops,
|
||
} => Some(format!(
|
||
"kv|{script}|{collection_glob}|{}|{sealed}|{shared}",
|
||
sorted_csv(ops.iter().copied().map(KvEventOp::as_str))
|
||
)),
|
||
TriggerDetails::Docs {
|
||
collection_glob,
|
||
ops,
|
||
} => Some(format!(
|
||
"docs|{script}|{collection_glob}|{}|{sealed}|{shared}",
|
||
sorted_csv(ops.iter().copied().map(DocsEventOp::as_str))
|
||
)),
|
||
TriggerDetails::Files {
|
||
collection_glob,
|
||
ops,
|
||
} => Some(format!(
|
||
"files|{script}|{collection_glob}|{}|{sealed}|{shared}",
|
||
sorted_csv(ops.iter().copied().map(FilesEventOp::as_str))
|
||
)),
|
||
TriggerDetails::Cron {
|
||
schedule, timezone, ..
|
||
} => Some(format!("cron|{script}|{schedule}|{timezone}")),
|
||
TriggerDetails::Pubsub { topic_pattern } => {
|
||
Some(format!("pubsub|{script}|{topic_pattern}|{sealed}|{shared}"))
|
||
}
|
||
TriggerDetails::Email { .. } => Some(format!("email|{script}")),
|
||
TriggerDetails::Queue { queue_name, .. } => Some(format!("queue|{queue_name}|{shared}")),
|
||
// §11.6 B2: a GROUP-owned shared dead_letter is declarative (a `[group]`
|
||
// template), so it participates in the diff like the other group
|
||
// templates — its identity mirrors `BundleTrigger::identity`. An
|
||
// APP-owned dead_letter (interactive API, not manifest-representable)
|
||
// stays `None`, so the diff neither matches nor prunes it (same as
|
||
// email).
|
||
TriggerDetails::DeadLetter { source_filter, .. } => {
|
||
if t.group_id.is_some() && shared {
|
||
Some(format!(
|
||
"dead_letter|{script}|{}|{shared}",
|
||
source_filter.as_deref().unwrap_or("")
|
||
))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn route_key(
|
||
method: Option<&str>,
|
||
host_kind: HostKind,
|
||
host: &str,
|
||
path_kind: PathKind,
|
||
path: &str,
|
||
) -> String {
|
||
let m = method.map_or_else(|| "ANY".to_string(), str::to_uppercase);
|
||
format!(
|
||
"{m} {} {} {} {path}",
|
||
host_kind_str(host_kind),
|
||
host,
|
||
path_kind_str(path_kind)
|
||
)
|
||
}
|
||
|
||
fn host_kind_str(k: HostKind) -> &'static str {
|
||
match k {
|
||
HostKind::Any => "any",
|
||
HostKind::Strict => "strict",
|
||
HostKind::Wildcard => "wildcard",
|
||
}
|
||
}
|
||
|
||
fn path_kind_str(k: PathKind) -> &'static str {
|
||
match k {
|
||
PathKind::Exact => "exact",
|
||
PathKind::Prefix => "prefix",
|
||
PathKind::Param => "param",
|
||
}
|
||
}
|
||
|
||
/// Sort + comma-join op strings so order is insignificant for identity.
|
||
fn sorted_csv<'a>(ops: impl Iterator<Item = &'a str>) -> String {
|
||
let set: BTreeSet<&str> = ops.collect();
|
||
set.into_iter().collect::<Vec<_>>().join(",")
|
||
}
|
||
|
||
fn reject_reserved_path(path: &str) -> Result<(), ApplyError> {
|
||
if RESERVED_PATH_EXACT.contains(&path)
|
||
|| RESERVED_PATH_PREFIXES.iter().any(|p| path.starts_with(p))
|
||
{
|
||
return Err(ApplyError::Invalid(format!("path `{path}` is reserved")));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// §4.7 reachability warning: an *enabled* endpoint script with no route and
|
||
/// no trigger has no event surface — it's only reachable via the
|
||
/// execute-by-id bypass / `invoke()`. Modules are exempt (never invoked
|
||
/// directly); disabled endpoints are intentionally inert, so skip them.
|
||
fn unreachable_endpoint_warnings(bundle: &Bundle) -> Vec<String> {
|
||
let bound: HashSet<&str> = bundle
|
||
.routes
|
||
.iter()
|
||
.map(|r| r.script.as_str())
|
||
.chain(bundle.triggers.iter().map(BundleTrigger::script))
|
||
.collect();
|
||
bundle
|
||
.scripts
|
||
.iter()
|
||
.filter(|s| s.kind == ScriptKind::Endpoint && s.enabled && !bound.contains(s.name.as_str()))
|
||
.map(|s| {
|
||
format!(
|
||
"endpoint `{}` has no route or trigger — only reachable via the \
|
||
execute-by-id bypass / invoke()",
|
||
s.name
|
||
)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// §4.7 reachability warnings: an *enabled* route or trigger bound to a
|
||
/// script the manifest marks *disabled* is deployed but unreachable (the
|
||
/// route 404s, the trigger won't fire). Valid desired state, so a warning —
|
||
/// not an error — keyed on the bundle alone.
|
||
/// Consistent phrasing for a GROUP-only feature declared on an APP node — the
|
||
/// several such guards in `validate_bundle_for` (§11.6 shared collections, §11
|
||
/// tail `sealed`, shared triggers). Renders `an app <what> — <why>` so the
|
||
/// app/group boundary reads the same everywhere.
|
||
fn app_only_reject(what: &str, why: &str) -> ApplyError {
|
||
ApplyError::Invalid(format!("an app {what} — {why}"))
|
||
}
|
||
|
||
fn disabled_target_warnings(bundle: &Bundle) -> Vec<String> {
|
||
let disabled: HashSet<&str> = bundle
|
||
.scripts
|
||
.iter()
|
||
.filter(|s| !s.enabled)
|
||
.map(|s| s.name.as_str())
|
||
.collect();
|
||
if disabled.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let mut out = Vec::new();
|
||
for r in &bundle.routes {
|
||
if r.enabled && disabled.contains(r.script.as_str()) {
|
||
out.push(format!(
|
||
"route `{}` is enabled but its script `{}` is disabled — it will 404",
|
||
route_key(
|
||
r.method.as_deref(),
|
||
r.host_kind,
|
||
&r.host,
|
||
r.path_kind,
|
||
&r.path
|
||
),
|
||
r.script
|
||
));
|
||
}
|
||
}
|
||
for t in &bundle.triggers {
|
||
if disabled.contains(t.script()) {
|
||
out.push(format!(
|
||
"{} trigger targets disabled script `{}` — it will not fire",
|
||
t.kind_str(),
|
||
t.script()
|
||
));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// M4 soft-warn: a `workflow`-kind step that (directly or transitively, within
|
||
/// this bundle) nests back into its own workflow forms a cycle. This is not an
|
||
/// error — the runtime depth ceiling (`PICLOUD_WORKFLOW_MAX_DEPTH`) bounds it —
|
||
/// but it's almost always a mistake, so we surface it at plan time. Only edges
|
||
/// to workflows present in the same bundle are considered (a reference to an
|
||
/// external workflow can't be resolved here).
|
||
fn workflow_nesting_warnings(bundle: &Bundle) -> Vec<String> {
|
||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||
if bundle.workflows.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let names: HashSet<String> = bundle
|
||
.workflows
|
||
.iter()
|
||
.map(|w| w.name.to_lowercase())
|
||
.collect();
|
||
// from → the set of in-bundle workflows it nests into.
|
||
let mut edges: HashMap<String, BTreeSet<String>> = HashMap::new();
|
||
for w in &bundle.workflows {
|
||
let from = w.name.to_lowercase();
|
||
for s in &w.definition.steps {
|
||
if let Some(t) = s.workflow.as_deref() {
|
||
let t = t.to_lowercase();
|
||
if names.contains(&t) {
|
||
edges.entry(from.clone()).or_default().insert(t);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let mut out = Vec::new();
|
||
for w in &bundle.workflows {
|
||
let start = w.name.to_lowercase();
|
||
// Reachability from `start`: if we can get back to `start`, it's cyclic.
|
||
let mut seen: HashSet<String> = HashSet::new();
|
||
let mut stack: Vec<String> = edges.get(&start).into_iter().flatten().cloned().collect();
|
||
let mut cyclic = false;
|
||
while let Some(n) = stack.pop() {
|
||
if n == start {
|
||
cyclic = true;
|
||
break;
|
||
}
|
||
if seen.insert(n.clone()) {
|
||
if let Some(next) = edges.get(&n) {
|
||
stack.extend(next.iter().cloned());
|
||
}
|
||
}
|
||
}
|
||
if cyclic {
|
||
out.push(format!(
|
||
"workflow `{}` can nest into itself (a sub-workflow cycle) — runs are \
|
||
bounded by PICLOUD_WORKFLOW_MAX_DEPTH, not infinite, but this is likely a bug",
|
||
w.name
|
||
));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Per-kind structural validation for one desired trigger — kept at parity
|
||
/// with the interactive trigger API so `apply` can't write a trigger the
|
||
/// dashboard would have rejected. Pure (no live state), so it's unit-tested
|
||
/// directly. The script-binding checks live in `validate_bundle`.
|
||
fn validate_trigger_shape(t: &BundleTrigger) -> Result<(), ApplyError> {
|
||
match t {
|
||
BundleTrigger::Email {
|
||
inbound_secret_ref, ..
|
||
} if inbound_secret_ref.trim().is_empty() => {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"email trigger on `{}` needs inbound_secret_ref (a secret name)",
|
||
t.script()
|
||
)));
|
||
}
|
||
BundleTrigger::Queue {
|
||
queue_name,
|
||
visibility_timeout_secs,
|
||
..
|
||
} => {
|
||
if queue_name.trim().is_empty() {
|
||
return Err(ApplyError::Invalid("queue trigger needs queue_name".into()));
|
||
}
|
||
if let Some(v) = visibility_timeout_secs {
|
||
// Parity with the interactive API: the floor is the dispatcher
|
||
// tick/reclaim-cadence minimum (`triggers_api`), and the ceiling
|
||
// matches `trigger_repo`'s queue check. Apply previously allowed
|
||
// a [5, 29] floor the dashboard rejects.
|
||
let min = crate::triggers_api::MIN_QUEUE_VISIBILITY_TIMEOUT_SECS;
|
||
if !(min..=3600).contains(v) {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"queue `{queue_name}`: visibility_timeout_secs must be in [{min}, 3600]"
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
BundleTrigger::Cron {
|
||
schedule, timezone, ..
|
||
} => {
|
||
crate::cron_scheduler::validate_schedule(schedule).map_err(|e| {
|
||
ApplyError::Invalid(format!(
|
||
"cron trigger on `{}`: invalid schedule: {e}",
|
||
t.script()
|
||
))
|
||
})?;
|
||
crate::cron_scheduler::validate_timezone(timezone).map_err(|e| {
|
||
ApplyError::Invalid(format!(
|
||
"cron trigger on `{}`: invalid timezone: {e}",
|
||
t.script()
|
||
))
|
||
})?;
|
||
}
|
||
// Parity with the interactive trigger API, which rejects an empty
|
||
// collection_glob (`create_{kv,docs,files}_trigger`).
|
||
BundleTrigger::Kv {
|
||
collection_glob, ..
|
||
}
|
||
| BundleTrigger::Docs {
|
||
collection_glob, ..
|
||
}
|
||
| BundleTrigger::Files {
|
||
collection_glob, ..
|
||
} if collection_glob.trim().is_empty() => {
|
||
return Err(ApplyError::Invalid(format!(
|
||
"{} trigger on `{}` needs a non-empty collection_glob",
|
||
t.kind_str(),
|
||
t.script()
|
||
)));
|
||
}
|
||
// Parity with `create_pubsub_trigger`'s topic-pattern check — otherwise
|
||
// a malformed pattern is written and silently never matches at dispatch.
|
||
BundleTrigger::Pubsub { topic_pattern, .. } => {
|
||
picloud_shared::validate_topic_pattern(topic_pattern).map_err(|e| {
|
||
ApplyError::Invalid(format!(
|
||
"pubsub trigger on `{}`: invalid topic_pattern: {e}",
|
||
t.script()
|
||
))
|
||
})?;
|
||
}
|
||
_ => {}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Summary of what an `apply` changed.
|
||
#[derive(Debug, Default, Serialize)]
|
||
pub struct ApplyReport {
|
||
pub scripts_created: u32,
|
||
pub scripts_updated: u32,
|
||
pub scripts_deleted: u32,
|
||
pub routes_created: u32,
|
||
pub routes_updated: u32,
|
||
pub routes_deleted: u32,
|
||
pub triggers_created: u32,
|
||
pub triggers_deleted: u32,
|
||
#[serde(default)]
|
||
pub vars_created: u32,
|
||
#[serde(default)]
|
||
pub vars_updated: u32,
|
||
#[serde(default)]
|
||
pub vars_deleted: u32,
|
||
#[serde(default)]
|
||
pub extension_points_created: u32,
|
||
#[serde(default)]
|
||
pub extension_points_deleted: u32,
|
||
#[serde(default)]
|
||
pub collections_created: u32,
|
||
#[serde(default)]
|
||
pub collections_deleted: u32,
|
||
#[serde(default)]
|
||
pub suppressions_created: u32,
|
||
#[serde(default)]
|
||
pub suppressions_deleted: u32,
|
||
#[serde(default)]
|
||
pub workflows_created: u32,
|
||
#[serde(default)]
|
||
pub workflows_updated: u32,
|
||
#[serde(default)]
|
||
pub workflows_deleted: u32,
|
||
#[serde(default)]
|
||
pub interceptors_created: u32,
|
||
#[serde(default)]
|
||
pub interceptors_updated: u32,
|
||
#[serde(default)]
|
||
pub interceptors_deleted: u32,
|
||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||
pub warnings: Vec<String>,
|
||
}
|
||
|
||
/// The key set of a name→id map, for passing the inherited target *names*
|
||
/// into `validate_bundle` without leaking the ids.
|
||
fn keys_set(m: &HashMap<String, ScriptId>) -> HashSet<String> {
|
||
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<ScriptId, String>,
|
||
plan: Plan,
|
||
app_chain: Vec<GroupId>,
|
||
}
|
||
|
||
/// §6: a GROUP node whose slug does NOT yet exist on the server — it will be
|
||
/// CREATED (its declared parent inferred from directory nesting). Its `current`
|
||
/// is empty and `plan` is a full create; the plan path previews it, and the
|
||
/// apply path creates it in-tx before reconciling this content. Kept separate
|
||
/// from `PreparedNode` because it has no `GroupId` until created.
|
||
struct ToCreateGroup<'a> {
|
||
node: &'a TreeNode,
|
||
plan: Plan,
|
||
}
|
||
|
||
/// §6: the invariant context threaded through one `reconcile_group_structure_tx`
|
||
/// pass — the attach ceiling (id for the subtree check, slug for messages) and
|
||
/// the acting principal (for per-mutation RBAC). Groups the args the create /
|
||
/// reparent helpers share.
|
||
struct ReconcileCtx<'a> {
|
||
attach_gid: Option<GroupId>,
|
||
attach_slug: Option<&'a str>,
|
||
principal: &'a Principal,
|
||
mode: StructureMode,
|
||
}
|
||
|
||
/// 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<String, ScriptId>,
|
||
name: &str,
|
||
) -> Result<ScriptId, ApplyError> {
|
||
name_to_id
|
||
.get(&name.to_lowercase())
|
||
.copied()
|
||
.ok_or_else(|| ApplyError::Invalid(format!("binding references unknown script `{name}`")))
|
||
}
|
||
|
||
async fn insert_bundle_route(
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
owner: ScriptOwner,
|
||
script_id: ScriptId,
|
||
br: &BundleRoute,
|
||
) -> Result<(), ApplyError> {
|
||
let new = NewRoute {
|
||
owner,
|
||
script_id,
|
||
host_kind: br.host_kind,
|
||
host: br.host.clone(),
|
||
host_param_name: br.host_param_name.clone(),
|
||
path_kind: br.path_kind,
|
||
path: br.path.clone(),
|
||
method: br.method.clone(),
|
||
dispatch_mode: br.dispatch_mode,
|
||
enabled: br.enabled,
|
||
// §11 tail: a sealed group route template is non-suppressible. Rejected
|
||
// on an app owner earlier in `validate_bundle_for`.
|
||
sealed: br.sealed,
|
||
};
|
||
insert_route_tx(tx, &new).await.map_err(map_repo)?;
|
||
Ok(())
|
||
}
|
||
|
||
fn bundle_trigger_details(bt: &BundleTrigger, default_visibility: u32) -> TriggerDetails {
|
||
match bt {
|
||
BundleTrigger::Kv {
|
||
collection_glob,
|
||
ops,
|
||
..
|
||
} => TriggerDetails::Kv {
|
||
collection_glob: collection_glob.clone(),
|
||
ops: ops.clone(),
|
||
},
|
||
BundleTrigger::Docs {
|
||
collection_glob,
|
||
ops,
|
||
..
|
||
} => TriggerDetails::Docs {
|
||
collection_glob: collection_glob.clone(),
|
||
ops: ops.clone(),
|
||
},
|
||
BundleTrigger::Files {
|
||
collection_glob,
|
||
ops,
|
||
..
|
||
} => TriggerDetails::Files {
|
||
collection_glob: collection_glob.clone(),
|
||
ops: ops.clone(),
|
||
},
|
||
BundleTrigger::Cron {
|
||
schedule, timezone, ..
|
||
} => TriggerDetails::Cron {
|
||
schedule: schedule.clone(),
|
||
timezone: timezone.clone(),
|
||
last_fired_at: None,
|
||
},
|
||
BundleTrigger::Pubsub { topic_pattern, .. } => TriggerDetails::Pubsub {
|
||
topic_pattern: topic_pattern.clone(),
|
||
},
|
||
BundleTrigger::Queue {
|
||
queue_name,
|
||
visibility_timeout_secs,
|
||
..
|
||
} => TriggerDetails::Queue {
|
||
queue_name: queue_name.clone(),
|
||
visibility_timeout_secs: visibility_timeout_secs.unwrap_or(default_visibility),
|
||
last_fired_at: None,
|
||
},
|
||
BundleTrigger::DeadLetter { source_filter, .. } => TriggerDetails::DeadLetter {
|
||
source_filter: source_filter.clone(),
|
||
trigger_id_filter: None,
|
||
script_id_filter: None,
|
||
},
|
||
BundleTrigger::Email { .. } => unreachable!("email handled separately"),
|
||
}
|
||
}
|
||
|
||
fn map_repo(e: ScriptRepositoryError) -> ApplyError {
|
||
match e {
|
||
ScriptRepositoryError::Conflict(m) => ApplyError::Invalid(m),
|
||
ScriptRepositoryError::NotFound(_) => {
|
||
ApplyError::Invalid("a referenced resource was not found".into())
|
||
}
|
||
ScriptRepositoryError::Db(e) => ApplyError::Backend(e.to_string()),
|
||
}
|
||
}
|
||
|
||
fn map_trig(e: TriggerRepoError) -> ApplyError {
|
||
match e {
|
||
TriggerRepoError::Invalid(m) => ApplyError::Invalid(m),
|
||
TriggerRepoError::NotFound(_) => ApplyError::Invalid("trigger not found".into()),
|
||
TriggerRepoError::Db(e) => ApplyError::Backend(e.to_string()),
|
||
}
|
||
}
|
||
|
||
/// A stable fingerprint of an app's live state, covering exactly what the
|
||
/// reconcile diff keys on: script identity + write-counter (`version` bumps on
|
||
/// any script edit), route identity + binding/attrs, trigger semantic identity
|
||
/// (via `current_trigger_identity`, so dead-letter triggers — which the diff
|
||
/// ignores — are excluded), and secret names. Any out-of-band change to those
|
||
/// flips the token, so `plan`-then-`apply` can detect the app moving underneath.
|
||
///
|
||
/// Deliberately mirrors the diff's inputs so a mismatch means the *reviewed
|
||
/// plan* would now differ — not merely that some unrelated row changed.
|
||
///
|
||
/// Uses FNV-1a (deterministic across process restarts, unlike `DefaultHasher`'s
|
||
/// per-process seed) so a token stored by `pic plan` still matches on a later
|
||
/// `pic apply`. A hash collision can only ever yield a false "unchanged", which
|
||
/// is the same risk class as not checking at all — never a false refusal.
|
||
#[must_use]
|
||
pub fn state_token(current: &CurrentState) -> String {
|
||
state_token_with_names(current, &HashMap::new())
|
||
}
|
||
|
||
/// `state_token` with `current_names` (group-bound binding ids → names, Phase
|
||
/// 4) so a trigger bound to an inherited group script contributes to the
|
||
/// fingerprint. Without it such a trigger is silently excluded and the
|
||
/// bound-plan (StateMoved) check has a false-negative for that binding class.
|
||
fn state_token_with_names(
|
||
current: &CurrentState,
|
||
current_names: &HashMap<ScriptId, String>,
|
||
) -> String {
|
||
let mut parts: Vec<String> = Vec::with_capacity(
|
||
current.scripts.len()
|
||
+ current.routes.len()
|
||
+ current.triggers.len()
|
||
+ current.secret_names.len()
|
||
+ current.vars.len()
|
||
+ current.extension_point_names.len()
|
||
+ current.collections.len(),
|
||
);
|
||
for s in ¤t.scripts {
|
||
parts.push(format!(
|
||
"s|{}|{}|{}",
|
||
s.name.to_lowercase(),
|
||
s.version,
|
||
s.enabled
|
||
));
|
||
}
|
||
for r in ¤t.routes {
|
||
parts.push(format!(
|
||
"r|{}|{:?}|{:?}|{}|{}",
|
||
route_key(
|
||
r.method.as_deref(),
|
||
r.host_kind,
|
||
&r.host,
|
||
r.path_kind,
|
||
&r.path
|
||
),
|
||
r.script_id,
|
||
r.dispatch_mode,
|
||
r.host_param_name.as_deref().unwrap_or(""),
|
||
r.enabled,
|
||
));
|
||
}
|
||
// Mirror exactly what the trigger diff keys on: `current_trigger_identity`
|
||
// excludes dead-letter triggers (the diff ignores them) and keys on the
|
||
// semantic identity — NOT raw id/enabled. Hashing dead-letter rows or the
|
||
// (currently inert) `enabled` flag would force a false refusal over a
|
||
// change the manifest can't represent.
|
||
let script_name_by_id: HashMap<ScriptId, String> = current
|
||
.scripts
|
||
.iter()
|
||
.map(|s| (s.id, s.name.clone()))
|
||
.chain(current_names.iter().map(|(id, n)| (*id, n.clone())))
|
||
.collect();
|
||
for t in ¤t.triggers {
|
||
if let Some(id) = current_trigger_identity(t, &script_name_by_id) {
|
||
parts.push(format!("t|{id}"));
|
||
}
|
||
}
|
||
for n in ¤t.secret_names {
|
||
parts.push(format!("k|{n}"));
|
||
}
|
||
// Vars are value-sensitive (the value lives in the manifest), so the token
|
||
// moves when a value changes — the bound-plan check then catches an edit
|
||
// between plan and apply. `to_string` on a serde_json::Value is stable
|
||
// (serde_json sorts object keys by default).
|
||
for (key, value) in ¤t.vars {
|
||
parts.push(format!(
|
||
"v|{key}|{}",
|
||
serde_json::to_string(value).unwrap_or_default()
|
||
));
|
||
}
|
||
// Extension-point markers are name-only, like secret names.
|
||
for n in ¤t.extension_point_names {
|
||
parts.push(format!("ep|{n}"));
|
||
}
|
||
// Shared group-collection markers carry a kind (§11.6), so a kind change
|
||
// moves the token.
|
||
for (name, kind) in ¤t.collections {
|
||
parts.push(format!("coll|{kind}|{name}"));
|
||
}
|
||
// Order-independent: sort the per-resource tokens before hashing.
|
||
parts.sort_unstable();
|
||
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}")
|
||
}
|
||
|
||
/// Per-node advisory lock key, namespaced so it can't collide with the
|
||
/// queue-trigger lock space. App and group ids live in disjoint UUID spaces and
|
||
/// are tagged by owner kind, so an app and a group never share a key.
|
||
fn apply_lock_key(owner: ApplyOwner) -> i64 {
|
||
use std::hash::{Hash, Hasher};
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
"picloud-apply".hash(&mut h);
|
||
match owner {
|
||
ApplyOwner::App(a) => {
|
||
"app".hash(&mut h);
|
||
a.into_inner().hash(&mut h);
|
||
}
|
||
ApplyOwner::Group(g) => {
|
||
"group".hash(&mut h);
|
||
g.into_inner().hash(&mut h);
|
||
}
|
||
}
|
||
i64::from_ne_bytes(h.finish().to_ne_bytes())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use chrono::Utc;
|
||
|
||
fn script(name: &str, source: &str) -> Script {
|
||
Script {
|
||
id: ScriptId::from(uuid::Uuid::new_v4()),
|
||
app_id: Some(AppId::from(uuid::Uuid::nil())),
|
||
group_id: None,
|
||
name: name.to_string(),
|
||
description: None,
|
||
version: 1,
|
||
source: source.to_string(),
|
||
kind: ScriptKind::Endpoint,
|
||
timeout_seconds: 30,
|
||
sandbox: ScriptSandbox::empty(),
|
||
memory_limit_mb: 256,
|
||
enabled: true,
|
||
created_at: Utc::now(),
|
||
updated_at: Utc::now(),
|
||
}
|
||
}
|
||
|
||
fn bundle_script(name: &str, source: &str) -> BundleScript {
|
||
BundleScript {
|
||
name: name.to_string(),
|
||
source: source.to_string(),
|
||
kind: ScriptKind::Endpoint,
|
||
description: None,
|
||
timeout_seconds: None,
|
||
memory_limit_mb: None,
|
||
sandbox: None,
|
||
enabled: true,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn empty_app_is_all_create() {
|
||
let current = CurrentState::default();
|
||
let bundle = Bundle {
|
||
scripts: vec![bundle_script("a", "1")],
|
||
routes: vec![],
|
||
triggers: vec![],
|
||
secrets: vec!["S".into()],
|
||
vars: std::collections::BTreeMap::new(),
|
||
extension_points: Vec::new(),
|
||
collections: Vec::new(),
|
||
suppress_triggers: Vec::new(),
|
||
suppress_routes: Vec::new(),
|
||
workflows: Vec::new(),
|
||
interceptors: Vec::new(),
|
||
};
|
||
let plan = compute_diff(¤t, &bundle);
|
||
assert_eq!(plan.scripts.len(), 1);
|
||
assert_eq!(plan.scripts[0].op, Op::Create);
|
||
assert_eq!(plan.secrets[0].op, Op::Create); // declared-but-unset
|
||
assert!(!plan.is_noop());
|
||
}
|
||
|
||
#[test]
|
||
fn identical_is_all_noop() {
|
||
let s = script("a", "let x = 1;");
|
||
let current = CurrentState {
|
||
scripts: vec![s.clone()],
|
||
secret_names: vec!["S".into()],
|
||
..CurrentState::default()
|
||
};
|
||
let bundle = Bundle {
|
||
scripts: vec![bundle_script("a", "let x = 1;")],
|
||
routes: vec![],
|
||
triggers: vec![],
|
||
secrets: vec!["S".into()],
|
||
vars: std::collections::BTreeMap::new(),
|
||
extension_points: Vec::new(),
|
||
collections: Vec::new(),
|
||
suppress_triggers: Vec::new(),
|
||
suppress_routes: Vec::new(),
|
||
workflows: Vec::new(),
|
||
interceptors: Vec::new(),
|
||
};
|
||
let plan = compute_diff(¤t, &bundle);
|
||
assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn changed_source_is_update() {
|
||
let current = CurrentState {
|
||
scripts: vec![script("a", "old")],
|
||
..CurrentState::default()
|
||
};
|
||
let bundle = Bundle {
|
||
scripts: vec![bundle_script("a", "new")],
|
||
routes: vec![],
|
||
triggers: vec![],
|
||
secrets: vec![],
|
||
vars: std::collections::BTreeMap::new(),
|
||
extension_points: Vec::new(),
|
||
collections: Vec::new(),
|
||
suppress_triggers: Vec::new(),
|
||
suppress_routes: Vec::new(),
|
||
workflows: Vec::new(),
|
||
interceptors: Vec::new(),
|
||
};
|
||
let plan = compute_diff(¤t, &bundle);
|
||
assert_eq!(plan.scripts[0].op, Op::Update);
|
||
}
|
||
|
||
#[test]
|
||
fn missing_from_bundle_is_delete() {
|
||
let current = CurrentState {
|
||
scripts: vec![script("gone", "x")],
|
||
secret_names: vec!["OLD".into()],
|
||
..CurrentState::default()
|
||
};
|
||
let bundle = Bundle {
|
||
scripts: vec![],
|
||
routes: vec![],
|
||
triggers: vec![],
|
||
secrets: vec![],
|
||
vars: std::collections::BTreeMap::new(),
|
||
extension_points: Vec::new(),
|
||
collections: Vec::new(),
|
||
suppress_triggers: Vec::new(),
|
||
suppress_routes: Vec::new(),
|
||
workflows: Vec::new(),
|
||
interceptors: Vec::new(),
|
||
};
|
||
let plan = compute_diff(¤t, &bundle);
|
||
assert_eq!(plan.scripts[0].op, Op::Delete);
|
||
assert_eq!(plan.secrets[0].op, Op::Delete);
|
||
}
|
||
|
||
#[test]
|
||
fn route_rebind_is_update() {
|
||
let s = script("a", "x");
|
||
let sid = s.id;
|
||
let route = Route {
|
||
id: uuid::Uuid::new_v4(),
|
||
app_id: Some(AppId::from(uuid::Uuid::nil())),
|
||
group_id: None,
|
||
script_id: sid,
|
||
host_kind: HostKind::Any,
|
||
host: String::new(),
|
||
host_param_name: None,
|
||
path_kind: PathKind::Exact,
|
||
path: "/p".into(),
|
||
method: Some("POST".into()),
|
||
dispatch_mode: DispatchMode::Sync,
|
||
enabled: true,
|
||
sealed: false,
|
||
created_at: Utc::now(),
|
||
};
|
||
let current = CurrentState {
|
||
scripts: vec![s, script("b", "y")],
|
||
routes: vec![route],
|
||
..CurrentState::default()
|
||
};
|
||
// Same tuple, bound to a different script → update.
|
||
let bundle = Bundle {
|
||
scripts: vec![],
|
||
routes: vec![BundleRoute {
|
||
script: "b".into(),
|
||
method: Some("POST".into()),
|
||
host_kind: HostKind::Any,
|
||
host: String::new(),
|
||
host_param_name: None,
|
||
path_kind: PathKind::Exact,
|
||
path: "/p".into(),
|
||
dispatch_mode: DispatchMode::Sync,
|
||
enabled: true,
|
||
sealed: false,
|
||
}],
|
||
triggers: vec![],
|
||
secrets: vec![],
|
||
vars: std::collections::BTreeMap::new(),
|
||
extension_points: Vec::new(),
|
||
collections: Vec::new(),
|
||
suppress_triggers: Vec::new(),
|
||
suppress_routes: Vec::new(),
|
||
workflows: Vec::new(),
|
||
interceptors: Vec::new(),
|
||
};
|
||
let plan = compute_diff(¤t, &bundle);
|
||
assert_eq!(plan.routes[0].op, Op::Update);
|
||
}
|
||
|
||
fn empty_bundle() -> Bundle {
|
||
Bundle {
|
||
scripts: vec![],
|
||
routes: vec![],
|
||
triggers: vec![],
|
||
secrets: vec![],
|
||
vars: std::collections::BTreeMap::new(),
|
||
extension_points: Vec::new(),
|
||
collections: Vec::new(),
|
||
suppress_triggers: Vec::new(),
|
||
suppress_routes: Vec::new(),
|
||
workflows: Vec::new(),
|
||
interceptors: Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn trig(script_id: ScriptId, details: TriggerDetails) -> Trigger {
|
||
use crate::trigger_config::BackoffShape;
|
||
use crate::trigger_repo::TriggerKind;
|
||
let kind = match &details {
|
||
TriggerDetails::Cron { .. } => TriggerKind::Cron,
|
||
TriggerDetails::DeadLetter { .. } => TriggerKind::DeadLetter,
|
||
TriggerDetails::Queue { .. } => TriggerKind::Queue,
|
||
// Other kinds aren't distinguished by these tests (the diff keys
|
||
// on `details`, not `kind`).
|
||
_ => TriggerKind::Kv,
|
||
};
|
||
Trigger {
|
||
id: TriggerId::from(uuid::Uuid::new_v4()),
|
||
app_id: Some(AppId::from(uuid::Uuid::nil())),
|
||
group_id: None,
|
||
script_id,
|
||
name: "t".into(),
|
||
kind,
|
||
enabled: true,
|
||
sealed: false,
|
||
shared: false,
|
||
materialized: false,
|
||
dispatch_mode: TriggerDispatchMode::Async,
|
||
retry_max_attempts: 3,
|
||
retry_backoff: BackoffShape::Exponential,
|
||
retry_base_ms: 1000,
|
||
registered_by_principal: AdminUserId::from(uuid::Uuid::nil()),
|
||
created_at: Utc::now(),
|
||
updated_at: Utc::now(),
|
||
details,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn script_update_only_on_real_change() {
|
||
// `script("a", ..)` defaults to timeout 30, mem 256, empty sandbox.
|
||
let current = CurrentState {
|
||
scripts: vec![script("a", "let x = 1;")],
|
||
..CurrentState::default()
|
||
};
|
||
// Same source, optional fields unset → NoOp ("None = leave as-is").
|
||
let mut b = empty_bundle();
|
||
b.scripts = vec![bundle_script("a", "let x = 1;")];
|
||
assert_eq!(compute_diff(¤t, &b).scripts[0].op, Op::NoOp);
|
||
// Explicit timeout that MATCHES current → still NoOp.
|
||
let mut same = empty_bundle();
|
||
let mut s = bundle_script("a", "let x = 1;");
|
||
s.timeout_seconds = Some(30);
|
||
same.scripts = vec![s];
|
||
assert_eq!(compute_diff(¤t, &same).scripts[0].op, Op::NoOp);
|
||
// Explicit timeout that DIFFERS → Update.
|
||
let mut chg = empty_bundle();
|
||
let mut s2 = bundle_script("a", "let x = 1;");
|
||
s2.timeout_seconds = Some(99);
|
||
chg.scripts = vec![s2];
|
||
assert_eq!(compute_diff(¤t, &chg).scripts[0].op, Op::Update);
|
||
// kind change → Update.
|
||
let mut chg2 = empty_bundle();
|
||
let mut s3 = bundle_script("a", "let x = 1;");
|
||
s3.kind = ScriptKind::Module;
|
||
chg2.scripts = vec![s3];
|
||
assert_eq!(compute_diff(¤t, &chg2).scripts[0].op, Op::Update);
|
||
}
|
||
|
||
#[test]
|
||
fn description_is_sparse() {
|
||
// An omitted (`None`) description must mean "leave as-is", matching
|
||
// the other optional fields — not "clear it".
|
||
let mut cur = script("a", "let x = 1;");
|
||
cur.description = Some("kept".into());
|
||
let current = CurrentState {
|
||
scripts: vec![cur],
|
||
..CurrentState::default()
|
||
};
|
||
// Omitted → NoOp (leave the stored description alone).
|
||
let mut omit = empty_bundle();
|
||
omit.scripts = vec![bundle_script("a", "let x = 1;")];
|
||
assert_eq!(compute_diff(¤t, &omit).scripts[0].op, Op::NoOp);
|
||
// Same explicit value → NoOp.
|
||
let mut same = empty_bundle();
|
||
let mut s = bundle_script("a", "let x = 1;");
|
||
s.description = Some("kept".into());
|
||
same.scripts = vec![s];
|
||
assert_eq!(compute_diff(¤t, &same).scripts[0].op, Op::NoOp);
|
||
// Different explicit value → Update.
|
||
let mut chg = empty_bundle();
|
||
let mut s2 = bundle_script("a", "let x = 1;");
|
||
s2.description = Some("changed".into());
|
||
chg.scripts = vec![s2];
|
||
assert_eq!(compute_diff(¤t, &chg).scripts[0].op, Op::Update);
|
||
}
|
||
|
||
#[test]
|
||
fn trigger_shape_validation_matches_interactive_api() {
|
||
// Empty collection_glob is rejected (kv/docs/files).
|
||
assert!(validate_trigger_shape(&BundleTrigger::Kv {
|
||
script: "h".into(),
|
||
collection_glob: " ".into(),
|
||
ops: vec![],
|
||
dispatch_mode: None,
|
||
retry_max_attempts: None,
|
||
sealed: false,
|
||
shared: false,
|
||
})
|
||
.is_err());
|
||
// A non-empty glob passes.
|
||
assert!(validate_trigger_shape(&BundleTrigger::Docs {
|
||
script: "h".into(),
|
||
collection_glob: "users".into(),
|
||
ops: vec![],
|
||
dispatch_mode: None,
|
||
retry_max_attempts: None,
|
||
sealed: false,
|
||
shared: false,
|
||
})
|
||
.is_ok());
|
||
// A malformed pubsub topic_pattern (mid-pattern wildcard) is rejected;
|
||
// a valid one passes.
|
||
assert!(validate_trigger_shape(&BundleTrigger::Pubsub {
|
||
script: "h".into(),
|
||
topic_pattern: "user.*.created".into(),
|
||
dispatch_mode: None,
|
||
retry_max_attempts: None,
|
||
sealed: false,
|
||
shared: false,
|
||
})
|
||
.is_err());
|
||
assert!(validate_trigger_shape(&BundleTrigger::Pubsub {
|
||
script: "h".into(),
|
||
topic_pattern: "orders.created".into(),
|
||
dispatch_mode: None,
|
||
retry_max_attempts: None,
|
||
sealed: false,
|
||
shared: false,
|
||
})
|
||
.is_ok());
|
||
// Queue visibility floor matches the interactive API (>= 30): a value
|
||
// below the floor is rejected; the floor itself and `None` are ok.
|
||
let queue = |vis| BundleTrigger::Queue {
|
||
script: "h".into(),
|
||
queue_name: "jobs".into(),
|
||
visibility_timeout_secs: vis,
|
||
dispatch_mode: None,
|
||
retry_max_attempts: None,
|
||
shared: false,
|
||
};
|
||
assert!(validate_trigger_shape(&queue(Some(10))).is_err());
|
||
assert!(validate_trigger_shape(&queue(Some(
|
||
crate::triggers_api::MIN_QUEUE_VISIBILITY_TIMEOUT_SECS
|
||
)))
|
||
.is_ok());
|
||
assert!(validate_trigger_shape(&queue(None)).is_ok());
|
||
// Empty email inbound_secret_ref is rejected.
|
||
assert!(validate_trigger_shape(&BundleTrigger::Email {
|
||
script: "h".into(),
|
||
inbound_secret_ref: " ".into(),
|
||
dispatch_mode: None,
|
||
retry_max_attempts: None,
|
||
})
|
||
.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn state_token_is_stable_order_independent_and_sensitive() {
|
||
let a = script("a", "x"); // version 1
|
||
let b = script("b", "y");
|
||
let base = CurrentState {
|
||
scripts: vec![a.clone(), b.clone()],
|
||
secret_names: vec!["S".into()],
|
||
..CurrentState::default()
|
||
};
|
||
// Deterministic + order-independent.
|
||
let reordered = CurrentState {
|
||
scripts: vec![b.clone(), a.clone()],
|
||
secret_names: vec!["S".into()],
|
||
..CurrentState::default()
|
||
};
|
||
assert_eq!(state_token(&base), state_token(&reordered));
|
||
// A script edit bumps `version`, which must flip the token even if the
|
||
// source-by-name set is otherwise unchanged (out-of-band redeploy).
|
||
let mut a2 = a.clone();
|
||
a2.version += 1;
|
||
let bumped = CurrentState {
|
||
scripts: vec![a2, b.clone()],
|
||
secret_names: vec!["S".into()],
|
||
..CurrentState::default()
|
||
};
|
||
assert_ne!(state_token(&base), state_token(&bumped));
|
||
// Adding a secret name flips it too.
|
||
let with_secret = CurrentState {
|
||
scripts: vec![a, b],
|
||
secret_names: vec!["S".into(), "T".into()],
|
||
..CurrentState::default()
|
||
};
|
||
assert_ne!(state_token(&base), state_token(&with_secret));
|
||
}
|
||
|
||
#[test]
|
||
fn diff_vars_create_update_noop_delete() {
|
||
let current = CurrentState {
|
||
vars: vec![
|
||
("region".into(), serde_json::json!("eu")),
|
||
("keep".into(), serde_json::json!(1)),
|
||
("stale".into(), serde_json::json!(true)),
|
||
],
|
||
..CurrentState::default()
|
||
};
|
||
let mut vars = std::collections::BTreeMap::new();
|
||
vars.insert("region".to_string(), serde_json::json!("us")); // value changed
|
||
vars.insert("keep".to_string(), serde_json::json!(1)); // identical
|
||
vars.insert("new".to_string(), serde_json::json!("x")); // not live
|
||
// "stale" is live but undeclared → Delete.
|
||
let bundle = Bundle {
|
||
vars,
|
||
..empty_bundle()
|
||
};
|
||
let plan = compute_diff(¤t, &bundle);
|
||
let op = |k: &str| plan.vars.iter().find(|c| c.key == k).unwrap().op;
|
||
assert_eq!(op("region"), Op::Update);
|
||
assert_eq!(op("keep"), Op::NoOp);
|
||
assert_eq!(op("new"), Op::Create);
|
||
assert_eq!(op("stale"), Op::Delete);
|
||
assert!(!plan.is_noop());
|
||
}
|
||
|
||
#[test]
|
||
fn state_token_is_sensitive_to_var_values_and_order_independent() {
|
||
let base = CurrentState {
|
||
vars: vec![("region".into(), serde_json::json!("eu"))],
|
||
..CurrentState::default()
|
||
};
|
||
// A value change must flip the token (covers the bound-plan check for
|
||
// an out-of-band `pic vars set` between plan and apply).
|
||
let changed = CurrentState {
|
||
vars: vec![("region".into(), serde_json::json!("us"))],
|
||
..CurrentState::default()
|
||
};
|
||
assert_ne!(state_token(&base), state_token(&changed));
|
||
// Order-independent across multiple vars.
|
||
let ab = CurrentState {
|
||
vars: vec![
|
||
("a".into(), serde_json::json!(1)),
|
||
("b".into(), serde_json::json!(2)),
|
||
],
|
||
..CurrentState::default()
|
||
};
|
||
let ba = CurrentState {
|
||
vars: vec![
|
||
("b".into(), serde_json::json!(2)),
|
||
("a".into(), serde_json::json!(1)),
|
||
],
|
||
..CurrentState::default()
|
||
};
|
||
assert_eq!(state_token(&ab), state_token(&ba));
|
||
}
|
||
|
||
#[test]
|
||
fn state_token_ignores_dead_letter_triggers() {
|
||
// The diff ignores dead-letter triggers (not manifest-representable),
|
||
// so the token must too — otherwise an out-of-band dead-letter change
|
||
// would force a spurious `--force`.
|
||
let s = script("h", "x");
|
||
let sid = s.id;
|
||
let base = CurrentState {
|
||
scripts: vec![s.clone()],
|
||
..CurrentState::default()
|
||
};
|
||
let with_dl = CurrentState {
|
||
scripts: vec![s],
|
||
triggers: vec![trig(
|
||
sid,
|
||
TriggerDetails::DeadLetter {
|
||
source_filter: None,
|
||
trigger_id_filter: None,
|
||
script_id_filter: None,
|
||
},
|
||
)],
|
||
..CurrentState::default()
|
||
};
|
||
assert_eq!(
|
||
state_token(&base),
|
||
state_token(&with_dl),
|
||
"dead-letter triggers must not affect the token"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn enabled_is_declarative_and_diffed() {
|
||
// A live-active script the manifest marks disabled → Update; the token
|
||
// is sensitive to the flag so an out-of-band toggle is detectable.
|
||
let cur = script("a", "let x = 1;"); // enabled: true
|
||
let base = CurrentState {
|
||
scripts: vec![cur.clone()],
|
||
..CurrentState::default()
|
||
};
|
||
let mut disable = empty_bundle();
|
||
let mut bs = bundle_script("a", "let x = 1;");
|
||
bs.enabled = false;
|
||
disable.scripts = vec![bs];
|
||
assert_eq!(compute_diff(&base, &disable).scripts[0].op, Op::Update);
|
||
|
||
let mut flipped = cur;
|
||
flipped.enabled = false;
|
||
let toggled = CurrentState {
|
||
scripts: vec![flipped],
|
||
..CurrentState::default()
|
||
};
|
||
assert_ne!(
|
||
state_token(&base),
|
||
state_token(&toggled),
|
||
"token must flip on an enabled change"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn warns_on_enabled_binding_to_disabled_script() {
|
||
let mut b = empty_bundle();
|
||
let mut s = bundle_script("h", "x");
|
||
s.enabled = false;
|
||
b.scripts = vec![s];
|
||
b.routes = vec![BundleRoute {
|
||
script: "h".into(),
|
||
method: None,
|
||
host_kind: HostKind::Any,
|
||
host: String::new(),
|
||
host_param_name: None,
|
||
path_kind: PathKind::Exact,
|
||
path: "/h".into(),
|
||
dispatch_mode: DispatchMode::Sync,
|
||
enabled: true,
|
||
sealed: false,
|
||
}];
|
||
let w = disabled_target_warnings(&b);
|
||
assert!(
|
||
w.iter().any(|m| m.contains("will 404")),
|
||
"expected a 404 reachability warning: {w:?}"
|
||
);
|
||
// No warning when the script is active.
|
||
b.scripts[0].enabled = true;
|
||
assert!(disabled_target_warnings(&b).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn warns_on_unreachable_endpoint() {
|
||
// §4.7: an enabled endpoint with no route and no trigger is flagged.
|
||
let mut b = empty_bundle();
|
||
b.scripts = vec![bundle_script("orphan", "x")];
|
||
let w = unreachable_endpoint_warnings(&b);
|
||
assert!(
|
||
w.iter().any(|m| m.contains("no route or trigger")),
|
||
"expected an unreachable-endpoint warning: {w:?}"
|
||
);
|
||
// Bound by a route → no warning.
|
||
b.routes = vec![BundleRoute {
|
||
script: "orphan".into(),
|
||
method: None,
|
||
host_kind: HostKind::Any,
|
||
host: String::new(),
|
||
host_param_name: None,
|
||
path_kind: PathKind::Exact,
|
||
path: "/o".into(),
|
||
dispatch_mode: DispatchMode::Sync,
|
||
enabled: true,
|
||
sealed: false,
|
||
}];
|
||
assert!(unreachable_endpoint_warnings(&b).is_empty());
|
||
// A module is exempt even with no binding.
|
||
let mut m = empty_bundle();
|
||
let mut lib = bundle_script("lib", "x");
|
||
lib.kind = ScriptKind::Module;
|
||
m.scripts = vec![lib];
|
||
assert!(unreachable_endpoint_warnings(&m).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn trigger_diff_create_noop_delete() {
|
||
let s = script("h", "x");
|
||
let sid = s.id;
|
||
let cron = |sched: &str| TriggerDetails::Cron {
|
||
schedule: sched.into(),
|
||
timezone: "UTC".into(),
|
||
last_fired_at: None,
|
||
};
|
||
let current = CurrentState {
|
||
scripts: vec![s],
|
||
triggers: vec![trig(sid, cron("0 0 * * * *"))],
|
||
..CurrentState::default()
|
||
};
|
||
let bundle_cron = |sched: &str| BundleTrigger::Cron {
|
||
script: "h".into(),
|
||
schedule: sched.into(),
|
||
timezone: "UTC".into(),
|
||
dispatch_mode: None,
|
||
retry_max_attempts: None,
|
||
};
|
||
// Same schedule → NoOp.
|
||
let mut same = empty_bundle();
|
||
same.triggers = vec![bundle_cron("0 0 * * * *")];
|
||
let p = compute_diff(¤t, &same);
|
||
assert!(p.triggers.iter().all(|c| c.op == Op::NoOp), "{p:?}");
|
||
// Removed → Delete.
|
||
let p = compute_diff(¤t, &empty_bundle());
|
||
assert_eq!(p.triggers.len(), 1);
|
||
assert_eq!(p.triggers[0].op, Op::Delete);
|
||
// Different schedule → Create (new) + Delete (old).
|
||
let mut chg = empty_bundle();
|
||
chg.triggers = vec![bundle_cron("0 5 * * * *")];
|
||
let p = compute_diff(¤t, &chg);
|
||
assert!(p.triggers.iter().any(|c| c.op == Op::Create), "{p:?}");
|
||
assert!(p.triggers.iter().any(|c| c.op == Op::Delete), "{p:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn kv_ops_order_insensitive() {
|
||
let s = script("h", "x");
|
||
let sid = s.id;
|
||
let current = CurrentState {
|
||
scripts: vec![s],
|
||
triggers: vec![trig(
|
||
sid,
|
||
TriggerDetails::Kv {
|
||
collection_glob: "users".into(),
|
||
ops: vec![KvEventOp::Update, KvEventOp::Insert],
|
||
},
|
||
)],
|
||
..CurrentState::default()
|
||
};
|
||
let mut b = empty_bundle();
|
||
b.triggers = vec![BundleTrigger::Kv {
|
||
script: "h".into(),
|
||
collection_glob: "users".into(),
|
||
ops: vec![KvEventOp::Insert, KvEventOp::Update], // reversed
|
||
dispatch_mode: None,
|
||
retry_max_attempts: None,
|
||
sealed: false,
|
||
shared: false,
|
||
}];
|
||
let p = compute_diff(¤t, &b);
|
||
assert!(
|
||
p.triggers.iter().all(|c| c.op == Op::NoOp),
|
||
"ops order must not matter: {p:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sealed_is_part_of_trigger_identity() {
|
||
// §11 tail: toggling `sealed` on a trigger template is a definitional
|
||
// change — it must re-diff (Create the sealed row + Delete the old),
|
||
// not silently NoOp. A current UNSEALED kv trigger vs a desired SEALED
|
||
// one at the same script/glob/ops.
|
||
let s = script("h", "x");
|
||
let sid = s.id;
|
||
let mut cur = trig(
|
||
sid,
|
||
TriggerDetails::Kv {
|
||
collection_glob: "users".into(),
|
||
ops: vec![KvEventOp::Insert],
|
||
},
|
||
);
|
||
cur.sealed = false;
|
||
let current = CurrentState {
|
||
scripts: vec![s],
|
||
triggers: vec![cur],
|
||
..CurrentState::default()
|
||
};
|
||
let mut b = empty_bundle();
|
||
b.triggers = vec![BundleTrigger::Kv {
|
||
script: "h".into(),
|
||
collection_glob: "users".into(),
|
||
ops: vec![KvEventOp::Insert],
|
||
dispatch_mode: None,
|
||
retry_max_attempts: None,
|
||
sealed: true,
|
||
shared: false,
|
||
}];
|
||
let p = compute_diff(¤t, &b);
|
||
assert!(
|
||
p.triggers.iter().any(|c| c.op == Op::Create),
|
||
"sealing must Create the sealed row: {p:?}"
|
||
);
|
||
assert!(
|
||
p.triggers.iter().any(|c| c.op == Op::Delete),
|
||
"sealing must Delete the stale unsealed row: {p:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sealed_toggle_updates_route() {
|
||
// §11 tail: a route template carries `sealed`; toggling it is an Update
|
||
// (routes have an Update op, unlike triggers). Same binding tuple, only
|
||
// `sealed` differs.
|
||
let s = script("h", "x");
|
||
let sid = s.id;
|
||
let mut route = Route {
|
||
id: uuid::Uuid::new_v4(),
|
||
app_id: None,
|
||
group_id: Some(GroupId::from(uuid::Uuid::nil())),
|
||
script_id: sid,
|
||
host_kind: HostKind::Any,
|
||
host: String::new(),
|
||
host_param_name: None,
|
||
path_kind: PathKind::Exact,
|
||
path: "/h".into(),
|
||
method: None,
|
||
dispatch_mode: DispatchMode::Sync,
|
||
enabled: true,
|
||
sealed: false,
|
||
created_at: Utc::now(),
|
||
};
|
||
route.sealed = false;
|
||
let current = CurrentState {
|
||
scripts: vec![s],
|
||
routes: vec![route],
|
||
..CurrentState::default()
|
||
};
|
||
let mut b = empty_bundle();
|
||
b.routes = vec![BundleRoute {
|
||
script: "h".into(),
|
||
method: None,
|
||
host_kind: HostKind::Any,
|
||
host: String::new(),
|
||
host_param_name: None,
|
||
path_kind: PathKind::Exact,
|
||
path: "/h".into(),
|
||
dispatch_mode: DispatchMode::Sync,
|
||
enabled: true,
|
||
sealed: true,
|
||
}];
|
||
let p = compute_diff(¤t, &b);
|
||
assert!(
|
||
p.routes.iter().any(|c| c.op == Op::Update),
|
||
"toggling sealed on a route must Update it: {p:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dead_letter_trigger_is_ignored_by_diff() {
|
||
// Dead-letter triggers can't be expressed in the manifest, so the
|
||
// diff must neither match nor prune them.
|
||
let s = script("h", "x");
|
||
let sid = s.id;
|
||
let current = CurrentState {
|
||
scripts: vec![s],
|
||
triggers: vec![trig(
|
||
sid,
|
||
TriggerDetails::DeadLetter {
|
||
source_filter: None,
|
||
trigger_id_filter: None,
|
||
script_id_filter: None,
|
||
},
|
||
)],
|
||
..CurrentState::default()
|
||
};
|
||
let p = compute_diff(¤t, &empty_bundle());
|
||
assert!(p.triggers.is_empty(), "dead-letter must be ignored: {p:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn email_trigger_is_never_pruned() {
|
||
// `pull` can't represent email triggers (the server keeps the sealed
|
||
// secret, not the `inbound_secret_ref`), so their absence from a
|
||
// bundle is ambiguous — the diff must not emit a Delete for them.
|
||
let s = script("h", "x");
|
||
let sid = s.id;
|
||
let current = CurrentState {
|
||
scripts: vec![s],
|
||
triggers: vec![trig(
|
||
sid,
|
||
TriggerDetails::Email {
|
||
has_inbound_secret: true,
|
||
},
|
||
)],
|
||
..CurrentState::default()
|
||
};
|
||
let p = compute_diff(¤t, &empty_bundle());
|
||
assert!(
|
||
!p.triggers.iter().any(|c| c.op == Op::Delete),
|
||
"email trigger must not be pruned: {p:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn queue_rebind_is_noop_documented_limitation() {
|
||
// Queue identity is `queue|{queue_name}` (script-independent), so a
|
||
// rebind to a different script diffs as NoOp — a known limitation.
|
||
let a = script("a", "x");
|
||
let aid = a.id;
|
||
let current = CurrentState {
|
||
scripts: vec![a, script("b", "y")],
|
||
triggers: vec![trig(
|
||
aid,
|
||
TriggerDetails::Queue {
|
||
queue_name: "jobs".into(),
|
||
visibility_timeout_secs: 30,
|
||
last_fired_at: None,
|
||
},
|
||
)],
|
||
..CurrentState::default()
|
||
};
|
||
let mut b = empty_bundle();
|
||
b.triggers = vec![BundleTrigger::Queue {
|
||
script: "b".into(), // rebind a→b
|
||
queue_name: "jobs".into(),
|
||
visibility_timeout_secs: None,
|
||
dispatch_mode: None,
|
||
retry_max_attempts: None,
|
||
shared: false,
|
||
}];
|
||
let p = compute_diff(¤t, &b);
|
||
assert!(
|
||
p.triggers.iter().all(|c| c.op == Op::NoOp),
|
||
"queue identity is queue_name-only: {p:?}"
|
||
);
|
||
}
|
||
|
||
// ------------------------------------------------------------------------
|
||
// §7 ownership — the pure claim policy (the whole decision, DB-free).
|
||
// ------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn group_claim_unclaimed_claims_only_with_a_project() {
|
||
let p = ProjectId::new();
|
||
// First apply with a project claims; without a project it stays UI-owned.
|
||
assert_eq!(
|
||
decide_group_claim(None, Some(p), false),
|
||
GroupClaimAction::Claim(p)
|
||
);
|
||
assert_eq!(
|
||
decide_group_claim::<ProjectId>(None, None, false),
|
||
GroupClaimAction::Noop
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn group_claim_by_owner_is_a_noop() {
|
||
let p = ProjectId::new();
|
||
assert_eq!(
|
||
decide_group_claim(Some((p, "acme".into())), Some(p), false),
|
||
GroupClaimAction::Noop
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn group_claim_foreign_owner_conflicts_unless_takeover() {
|
||
let owner = ProjectId::new();
|
||
let me = ProjectId::new();
|
||
// A different project is refused without --takeover…
|
||
assert!(matches!(
|
||
decide_group_claim(Some((owner, "platform".into())), Some(me), false),
|
||
GroupClaimAction::Conflict(_)
|
||
));
|
||
// …and reassigns with it (the authz gate is applied at the call site).
|
||
assert_eq!(
|
||
decide_group_claim(Some((owner, "platform".into())), Some(me), true),
|
||
GroupClaimAction::Takeover(me)
|
||
);
|
||
// A no-project apply must not silently write into a claimed node.
|
||
assert!(matches!(
|
||
decide_group_claim(Some((owner, "platform".into())), None, false),
|
||
GroupClaimAction::Conflict(_)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn app_owner_matches_nearest_or_conflicts() {
|
||
let owner = ProjectId::new();
|
||
let me = ProjectId::new();
|
||
// An unclaimed subtree is open (backward-compatible).
|
||
assert!(decide_app_owner(None, Some(me)).is_ok());
|
||
assert!(decide_app_owner::<ProjectId>(None, None).is_ok());
|
||
// Same project as the nearest claimed ancestor → ok.
|
||
assert!(decide_app_owner(Some((owner, "x".into())), Some(owner)).is_ok());
|
||
// A different project, or none, under a claimed subtree → conflict.
|
||
assert!(decide_app_owner(Some((owner, "platform".into())), Some(me)).is_err());
|
||
assert!(decide_app_owner(Some((owner, "platform".into())), None).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn ownership_preview_reflects_claim_owned_conflict() {
|
||
// Group node: unclaimed + project → claim; already ours → owned; foreign
|
||
// → conflict (owner named); no project → unclaimed.
|
||
assert_eq!(preview_ownership(true, None, Some("p")).action, "claim");
|
||
assert_eq!(preview_ownership(true, None, None).action, "unclaimed");
|
||
let owned = preview_ownership(true, Some("p"), Some("p"));
|
||
assert_eq!(owned.action, "owned");
|
||
assert_eq!(owned.owner.as_deref(), Some("p"));
|
||
let conflict = preview_ownership(true, Some("plat"), Some("teamb"));
|
||
assert_eq!(conflict.action, "conflict");
|
||
assert_eq!(conflict.owner.as_deref(), Some("plat"));
|
||
// App node never claims — unclaimed subtree stays unclaimed.
|
||
assert_eq!(
|
||
preview_ownership(false, None, Some("p")).action,
|
||
"unclaimed"
|
||
);
|
||
assert_eq!(
|
||
preview_ownership(false, Some("plat"), Some("teamb")).action,
|
||
"conflict"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn project_decl_gates_only_confirm_required_envs() {
|
||
// §3 M3: the server re-derives the gate from ProjectDecl.environments.
|
||
let mut environments = std::collections::BTreeMap::new();
|
||
environments.insert("production".to_string(), EnvPolicyDecl { confirm: true });
|
||
environments.insert("staging".to_string(), EnvPolicyDecl { confirm: false });
|
||
let decl = ProjectDecl {
|
||
slug: "p".into(),
|
||
name: None,
|
||
parent_group: None,
|
||
environments,
|
||
};
|
||
assert!(decl.confirm_required("production"));
|
||
assert!(!decl.confirm_required("staging"));
|
||
assert!(!decl.confirm_required("unknown"));
|
||
assert_eq!(decl.gated_envs(), vec!["production".to_string()]);
|
||
// A project with no policy gates nothing (backward-compatible default).
|
||
let bare = ProjectDecl {
|
||
slug: "p".into(),
|
||
name: None,
|
||
parent_group: None,
|
||
environments: std::collections::BTreeMap::new(),
|
||
};
|
||
assert!(!bare.confirm_required("production"));
|
||
assert!(bare.gated_envs().is_empty());
|
||
}
|
||
|
||
// ---- v1.2 Workflows: definition validation ----------------------------
|
||
|
||
fn wf_def(steps: serde_json::Value) -> picloud_shared::workflow::WorkflowDefinition {
|
||
serde_json::from_value(serde_json::json!({ "steps": steps })).expect("valid def json")
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_validation_accepts_a_valid_dag() {
|
||
let def = wf_def(serde_json::json!([
|
||
{ "name": "a", "function": "fa" },
|
||
{ "name": "b", "function": "fb", "depends_on": ["a"],
|
||
"when": "steps.a.output.ok == true",
|
||
"input": { "x": "{{ steps.a.output.value }}" } },
|
||
{ "name": "c", "workflow": "sub", "depends_on": ["a", "b"] },
|
||
]));
|
||
assert!(validate_workflow_definition("w", &def).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_validation_rejects_empty_and_dupes() {
|
||
assert!(validate_workflow_definition("w", &wf_def(serde_json::json!([]))).is_err());
|
||
let dupe = wf_def(serde_json::json!([
|
||
{ "name": "a", "function": "fa" },
|
||
{ "name": "a", "function": "fb" },
|
||
]));
|
||
assert!(validate_workflow_definition("w", &dupe)
|
||
.unwrap_err()
|
||
.contains("duplicate step name"));
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_validation_rejects_bad_target_and_deps() {
|
||
// neither function nor workflow
|
||
let no_target = wf_def(serde_json::json!([{ "name": "a" }]));
|
||
assert!(validate_workflow_definition("w", &no_target)
|
||
.unwrap_err()
|
||
.contains("exactly one"));
|
||
// both function and workflow
|
||
let both = wf_def(serde_json::json!([{ "name": "a", "function": "f", "workflow": "w" }]));
|
||
assert!(validate_workflow_definition("w", &both).is_err());
|
||
// dependency on an undeclared step
|
||
let bad_dep = wf_def(serde_json::json!([
|
||
{ "name": "a", "function": "fa", "depends_on": ["ghost"] },
|
||
]));
|
||
assert!(validate_workflow_definition("w", &bad_dep)
|
||
.unwrap_err()
|
||
.contains("undeclared step"));
|
||
// self-dependency
|
||
let self_dep = wf_def(serde_json::json!([
|
||
{ "name": "a", "function": "fa", "depends_on": ["a"] },
|
||
]));
|
||
assert!(validate_workflow_definition("w", &self_dep)
|
||
.unwrap_err()
|
||
.contains("depends on itself"));
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_validation_tolerates_duplicate_depends_on() {
|
||
// A step listing the same dep twice is a valid DAG — the topo sort must
|
||
// not report a spurious cycle from the double-counted in-degree.
|
||
let dup_dep = wf_def(serde_json::json!([
|
||
{ "name": "a", "function": "fa" },
|
||
{ "name": "b", "function": "fb", "depends_on": ["a", "a"] },
|
||
]));
|
||
assert!(validate_workflow_definition("w", &dup_dep).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_validation_detects_cycles() {
|
||
let cyclic = wf_def(serde_json::json!([
|
||
{ "name": "a", "function": "fa", "depends_on": ["c"] },
|
||
{ "name": "b", "function": "fb", "depends_on": ["a"] },
|
||
{ "name": "c", "function": "fc", "depends_on": ["b"] },
|
||
]));
|
||
assert!(validate_workflow_definition("w", &cyclic)
|
||
.unwrap_err()
|
||
.contains("cycle"));
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_validation_rejects_bad_when_and_template() {
|
||
let bad_when = wf_def(serde_json::json!([
|
||
{ "name": "a", "function": "fa" },
|
||
{ "name": "b", "function": "fb", "depends_on": ["a"], "when": "steps.ghost.output.x" },
|
||
]));
|
||
assert!(validate_workflow_definition("w", &bad_when).is_err());
|
||
let bad_tmpl = wf_def(serde_json::json!([
|
||
{ "name": "a", "function": "fa", "input": { "x": "{{ steps.ghost.output }}" } },
|
||
]));
|
||
assert!(validate_workflow_definition("w", &bad_tmpl).is_err());
|
||
}
|
||
|
||
fn bundle_workflow(name: &str, steps: serde_json::Value) -> BundleWorkflow {
|
||
BundleWorkflow {
|
||
name: name.into(),
|
||
definition: wf_def(steps),
|
||
enabled: true,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_nesting_warning_flags_self_and_mutual_cycles() {
|
||
// Direct self-nest.
|
||
let mut b = empty_bundle();
|
||
b.workflows = vec![bundle_workflow(
|
||
"loop",
|
||
serde_json::json!([{ "name": "s", "workflow": "loop" }]),
|
||
)];
|
||
let w = workflow_nesting_warnings(&b);
|
||
assert_eq!(w.len(), 1);
|
||
assert!(w[0].contains("loop") && w[0].contains("cycle"));
|
||
|
||
// Mutual cycle w1 -> w2 -> w1.
|
||
let mut b = empty_bundle();
|
||
b.workflows = vec![
|
||
bundle_workflow("w1", serde_json::json!([{ "name": "s", "workflow": "w2" }])),
|
||
bundle_workflow("w2", serde_json::json!([{ "name": "s", "workflow": "w1" }])),
|
||
];
|
||
assert_eq!(workflow_nesting_warnings(&b).len(), 2);
|
||
|
||
// Acyclic nesting (parent -> child, child is a leaf) → no warning.
|
||
let mut b = empty_bundle();
|
||
b.workflows = vec![
|
||
bundle_workflow(
|
||
"parent",
|
||
serde_json::json!([{ "name": "s", "workflow": "child" }]),
|
||
),
|
||
bundle_workflow(
|
||
"child",
|
||
serde_json::json!([{ "name": "s", "function": "f" }]),
|
||
),
|
||
];
|
||
assert!(workflow_nesting_warnings(&b).is_empty());
|
||
}
|
||
}
|