diff --git a/crates/manager-core/migrations/0045_scripts_routes_enabled.sql b/crates/manager-core/migrations/0045_scripts_routes_enabled.sql new file mode 100644 index 0000000..6025ee2 --- /dev/null +++ b/crates/manager-core/migrations/0045_scripts_routes_enabled.sql @@ -0,0 +1,8 @@ +-- Three-state `enabled` lifecycle (blueprint §4.3), scripts + routes half. +-- Triggers already carry `enabled` (0008) honored at match/schedule time; +-- this adds the same toggle to scripts and routes. A disabled script is not +-- invocable; a disabled route 404s (indistinguishable from absent). Default +-- TRUE so every existing row stays active — no behavior change on migrate. + +ALTER TABLE scripts ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE routes ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/crates/manager-core/migrations/0046_trigger_name.sql b/crates/manager-core/migrations/0046_trigger_name.sql new file mode 100644 index 0000000..a941aa8 --- /dev/null +++ b/crates/manager-core/migrations/0046_trigger_name.sql @@ -0,0 +1,29 @@ +-- Trigger `name` (§4.5): an explicit per-app identifier that becomes the +-- manifest merge/upsert key (Step B switches the apply diff to key on it). +-- Until now triggers were identified only by their per-kind semantic tuple, +-- so the declarative tool could only Create/Delete them, never Update. +-- +-- Backfill existing rows with `{kind}-{n}` (n = per-(app,kind) sequence by +-- creation order) — the spec-sanctioned fallback when there's no cleaner +-- entity token. Then enforce NOT NULL + UNIQUE(app_id, name). + +-- A `gen_random_uuid()` default keeps existing INSERTs (which don't yet +-- supply a name) valid and unique — the manager-core write paths start +-- providing meaningful names in the follow-up; new rows until then get a +-- harmless unique placeholder. +ALTER TABLE triggers + ADD COLUMN name TEXT NOT NULL DEFAULT gen_random_uuid()::text; + +-- Rewrite the just-defaulted existing rows to the readable `{kind}-{n}` form. +UPDATE triggers t +SET name = sub.nm +FROM ( + SELECT id, + kind || '-' || row_number() OVER ( + PARTITION BY app_id, kind ORDER BY created_at, id + ) AS nm + FROM triggers +) sub +WHERE t.id = sub.id; + +CREATE UNIQUE INDEX triggers_app_name_uniq ON triggers (app_id, name); diff --git a/crates/manager-core/src/api.rs b/crates/manager-core/src/api.rs index ff15f33..275e63a 100644 --- a/crates/manager-core/src/api.rs +++ b/crates/manager-core/src/api.rs @@ -246,6 +246,8 @@ async fn create_script( } else { Some(input.sandbox) }, + // Scripts are created active; toggling is a dedicated path. + enabled: true, imports: validated.imports, }) .await?; @@ -258,7 +260,7 @@ async fn create_script( /// real KV bridge — defense against author confusion, not a security /// boundary (stdlib namespaces and module imports already live in /// disjoint Rhai scopes). -const RESERVED_MODULE_NAMES: &[&str] = &[ +pub(crate) const RESERVED_MODULE_NAMES: &[&str] = &[ "log", "regex", "random", @@ -345,6 +347,7 @@ async fn update_script( memory_limit_mb: input.memory_limit_mb, sandbox: input.sandbox, kind: input.kind, + enabled: None, imports: imports_for_patch, }, ) @@ -476,10 +479,9 @@ impl IntoResponse for ApiError { fn into_response(self) -> Response { let (status, message) = match &self { Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), - Self::AppNotFound(_) - | Self::BadRequest(_) - | Self::Invalid(_) - | Self::Ceiling(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()), + Self::AppNotFound(_) | Self::BadRequest(_) | Self::Invalid(_) | Self::Ceiling(_) => { + (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()) + } Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()), Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()), Self::AuthzRepo(e) => { diff --git a/crates/manager-core/src/app_bootstrap.rs b/crates/manager-core/src/app_bootstrap.rs index 187c912..77f2dd5 100644 --- a/crates/manager-core/src/app_bootstrap.rs +++ b/crates/manager-core/src/app_bootstrap.rs @@ -68,6 +68,7 @@ async fn seed_into( timeout_seconds: Some(5), memory_limit_mb: None, sandbox: None, + enabled: true, imports: Vec::new(), }) .await?; @@ -85,6 +86,7 @@ async fn seed_into( // `curl -d '{"name":"X"}' /hello` work out of the box. method: None, dispatch_mode: picloud_shared::DispatchMode::Sync, + enabled: true, }) .await?; diff --git a/crates/manager-core/src/apply_api.rs b/crates/manager-core/src/apply_api.rs new file mode 100644 index 0000000..82753e2 --- /dev/null +++ b/crates/manager-core/src/apply_api.rs @@ -0,0 +1,173 @@ +//! Admin HTTP surface for the declarative reconcile engine. +//! +//! `POST /api/v1/admin/apps/{id}/plan` — diff a desired-state bundle +//! against the app's live state and return the plan. Read-only; requires +//! `AppRead`. The `apply` route (write path) lands in the next milestone. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::post, + Extension, Json, Router, +}; +use picloud_shared::{AppId, Principal}; +use serde::Deserialize; +use serde_json::json; + +use crate::app_repo::AppRepository; +use crate::apply_service::{ + ApplyError, ApplyReport, ApplyService, Bundle, BundleTrigger, PlanResult, +}; +use crate::authz::{require, AuthzDenied, Capability}; + +/// Build the apply/plan router. Mounted under `/api/v1/admin`. +pub fn apply_router(service: ApplyService) -> Router { + Router::new() + .route("/apps/{id}/plan", post(plan_handler)) + .route("/apps/{id}/apply", post(apply_handler)) + .with_state(service) +} + +#[derive(Deserialize)] +pub struct ApplyRequest { + pub bundle: Bundle, + #[serde(default)] + pub prune: bool, + /// Optional bound-plan token from a prior `plan`. When present, apply + /// refuses (409) if the app's live state has changed since. + #[serde(default)] + pub expected_token: Option, +} + +async fn apply_handler( + State(svc): State, + Extension(principal): Extension, + Path(id_or_slug): Path, + Json(req): Json, +) -> Result, ApplyError> { + let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?; + // Read is always needed; write caps are required for the resource kinds + // the bundle touches — and for ALL kinds when `prune` is set, since + // pruning deletes resources whose bundle section is empty (and a script + // delete cascades its routes/triggers). + require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id)) + .await + .map_err(map_authz)?; + if req.prune || !req.bundle.scripts.is_empty() { + require( + svc.authz.as_ref(), + &principal, + Capability::AppWriteScript(app_id), + ) + .await + .map_err(map_authz)?; + } + if req.prune || !req.bundle.routes.is_empty() { + require( + svc.authz.as_ref(), + &principal, + Capability::AppWriteRoute(app_id), + ) + .await + .map_err(map_authz)?; + } + if req.prune || !req.bundle.triggers.is_empty() { + require( + svc.authz.as_ref(), + &principal, + Capability::AppManageTriggers(app_id), + ) + .await + .map_err(map_authz)?; + } + // Email triggers resolve and decrypt a stored secret by name server-side, + // which the secrets API guards with `AppSecretsRead`. Require it here too + // so apply can't bind a secret a principal couldn't otherwise read — the + // caps aren't strictly nested on the API-key scope path. + if req.bundle.triggers.iter().any(BundleTrigger::is_email) { + require( + svc.authz.as_ref(), + &principal, + Capability::AppSecretsRead(app_id), + ) + .await + .map_err(map_authz)?; + } + let report = svc + .apply( + app_id, + &req.bundle, + req.prune, + principal.user_id, + req.expected_token.as_deref(), + ) + .await?; + Ok(Json(report)) +} + +async fn plan_handler( + State(svc): State, + Extension(principal): Extension, + Path(id_or_slug): Path, + Json(bundle): Json, +) -> Result, ApplyError> { + let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?; + // NOTE: the returned `Plan` discloses live secret NAMES (not values). That + // is safe today only because `AppRead` and `AppSecretsRead` are co-granted + // at every tier (same `script:read` scope, both in the viewer role). If a + // future authz split puts `AppSecretsRead` on its own tier, this handler + // must additionally require it — otherwise it leaks names a principal + // couldn't enumerate via the secrets API. + require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id)) + .await + .map_err(map_authz)?; + let plan = svc.plan(app_id, &bundle).await?; + Ok(Json(plan)) +} + +/// Resolve a slug-or-id path param to an `AppId`, mapping miss → 404. +/// Mirrors the `triggers_api` helper of the same shape. +async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result { + crate::app_repo::resolve_app(apps, ident) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))? + .map(|l| l.app.id) + .ok_or_else(|| ApplyError::AppNotFound(ident.to_string())) +} + +fn map_authz(denied: AuthzDenied) -> ApplyError { + match denied { + AuthzDenied::Denied => ApplyError::Forbidden, + AuthzDenied::Repo(e) => ApplyError::AuthzRepo(e.to_string()), + } +} + +impl IntoResponse for ApplyError { + fn into_response(self) -> Response { + let (status, body) = match &self { + Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })), + Self::Invalid(_) => ( + StatusCode::UNPROCESSABLE_ENTITY, + json!({ "error": self.to_string() }), + ), + Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })), + Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })), + Self::AuthzRepo(e) => { + tracing::error!(error = %e, "apply authz repo error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "internal error" }), + ) + } + Self::Backend(e) => { + tracing::error!(error = %e, "apply backend error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "internal error" }), + ) + } + }; + (status, Json(body)).into_response() + } +} diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs new file mode 100644 index 0000000..38adcf0 --- /dev/null +++ b/crates/manager-core/src/apply_service.rs @@ -0,0 +1,2396 @@ +//! 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, HostKind, KvEventOp, MasterKey, + PathKind, Route, Script, ScriptId, ScriptKind, 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, +}; + +/// 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, +} + +#[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, +} + +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) + .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, +} + +// ---------------------------------------------------------------------------- +// Current state snapshot +// ---------------------------------------------------------------------------- + +/// The app's live state, loaded once for a diff. +#[derive(Debug, Default)] +pub struct CurrentState { + pub scripts: Vec