//! 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, 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::AuthzRepo; 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, #[serde(default)] pub routes: Vec, #[serde(default)] pub triggers: Vec, /// Declared secret *names* only; values are pushed out-of-band. #[serde(default)] pub secrets: Vec, /// 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, /// 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, /// 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, } /// 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"]; #[derive(Debug, Clone, Deserialize)] pub struct BundleScript { pub name: String, pub source: String, #[serde(default)] pub kind: ScriptKind, #[serde(default)] pub description: Option, #[serde(default)] pub timeout_seconds: Option, #[serde(default)] pub memory_limit_mb: Option, #[serde(default)] pub sandbox: Option, /// 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, pub host_kind: HostKind, #[serde(default)] pub host: String, #[serde(default)] pub host_param_name: Option, 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, } /// 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, #[serde(default)] dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, }, Docs { script: String, collection_glob: String, #[serde(default)] ops: Vec, #[serde(default)] dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, }, Files { script: String, collection_glob: String, #[serde(default)] ops: Vec, #[serde(default)] dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, }, Cron { script: String, schedule: String, #[serde(default = "default_timezone")] timezone: String, #[serde(default)] dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, }, Pubsub { script: String, topic_pattern: String, #[serde(default)] dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, }, 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, #[serde(default)] retry_max_attempts: Option, }, Queue { script: String, queue_name: String, #[serde(default)] visibility_timeout_secs: Option, #[serde(default)] dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, }, } 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, .. } => 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 { .. }) } /// Stable semantic identity (the per-kind tuple), used for the diff. #[must_use] pub fn identity(&self) -> String { match self { Self::Kv { script, collection_glob, ops, .. } => format!( "kv|{script}|{collection_glob}|{}", sorted_csv(ops.iter().copied().map(KvEventOp::as_str)) ), Self::Docs { script, collection_glob, ops, .. } => format!( "docs|{script}|{collection_glob}|{}", sorted_csv(ops.iter().copied().map(DocsEventOp::as_str)) ), Self::Files { script, collection_glob, ops, .. } => format!( "files|{script}|{collection_glob}|{}", sorted_csv(ops.iter().copied().map(FilesEventOp::as_str)) ), Self::Cron { script, schedule, timezone, .. } => format!("cron|{script}|{schedule}|{timezone}"), Self::Pubsub { script, topic_pattern, .. } => format!("pubsub|{script}|{topic_pattern}"), Self::Email { script, .. } => format!("email|{script}"), Self::Queue { queue_name, .. } => format!("queue|{queue_name}"), } } #[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", } } } /// 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, } #[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, pub routes: Vec, pub triggers: Vec, pub secrets: Vec, #[serde(default)] pub vars: Vec, #[serde(default)] pub extension_points: Vec, #[serde(default)] pub collections: Vec, } 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) .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, } // ---------------------------------------------------------------------------- // Tree apply (Phase 5): a whole project subtree applied in one transaction. // ---------------------------------------------------------------------------- /// Whether a tree node is an app or a group. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum NodeKind { App, Group, } /// One node of a project tree: its kind, slug (must pre-exist on the server), /// and its desired-state bundle (a group's bundle has no routes/triggers). /// Request-only (deserialized from the CLI's tree bundle). #[derive(Debug, Clone, Deserialize)] pub struct TreeNode { pub kind: NodeKind, pub slug: String, pub bundle: Bundle, } /// A whole project subtree submitted to `apply_tree`. #[derive(Debug, Clone, Default, Deserialize)] pub struct TreeBundle { #[serde(default)] pub nodes: Vec, } /// One node's diff within a tree plan. #[derive(Debug, Clone, Serialize)] pub struct NodePlan { pub kind: NodeKind, pub slug: String, #[serde(flatten)] pub plan: Plan, } /// What `plan_tree` returns: a per-node diff plus ONE combined bound-plan token /// (fingerprint of every node's content + every in-scope group's structure /// version, so a reparent or content edit between plan and apply is caught). #[derive(Debug, Clone, Serialize)] pub struct TreePlanResult { pub nodes: Vec, pub state_token: String, } // ---------------------------------------------------------------------------- // Current state snapshot // ---------------------------------------------------------------------------- /// The app's live state, loaded once for a diff. #[derive(Debug, Default)] pub struct CurrentState { pub scripts: Vec