diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 1f46374..24c8f30 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -94,6 +94,14 @@ pub struct Bundle { /// `[group]` nodes only (the CLI rejects it on `[app]`). #[serde(default)] pub collections: Vec, + /// §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, + /// §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, } /// One declared shared-collection marker on the wire: a name + its store kind. @@ -346,6 +354,9 @@ pub struct Plan { pub extension_points: Vec, #[serde(default)] pub collections: Vec, + /// §11 tail per-app suppression markers, keyed `"{target_kind}:{reference}"`. + #[serde(default)] + pub suppressions: Vec, } impl Plan { @@ -441,6 +452,9 @@ pub struct CurrentState { /// 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)>, } /// One row of the read-only extension-point report (§5.5). @@ -681,6 +695,20 @@ impl ApplyService { .into(), )); } + // §11 tail: only an APP opts out of inherited templates. A group that + // doesn't want a template simply doesn't declare it (the CLI already + // rejects `[suppress]` on a `[group]`; this guards a hand-rolled wire + // Bundle — the reconcile's `owner.app_id().expect(...)` would otherwise + // panic on a group). + if matches!(owner, ApplyOwner::Group(_)) + && (!bundle.suppress_triggers.is_empty() || !bundle.suppress_routes.is_empty()) + { + return Err(ApplyError::Invalid( + "a group cannot declare suppressions — only an app opts out of \ + inherited templates" + .into(), + )); + } self.validate_bundle(bundle, inherited_endpoints) } @@ -961,6 +989,22 @@ impl ApplyService { } } + // 3e. Per-app suppression markers (§11 tail) — insert each Create + // (idempotent). App-only; 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 app_id = owner.app_id().expect("suppressions are app-only"); + let (kind, reference) = ch.key.split_once(':').expect("suppression key has ':'"); + crate::suppression_repo::insert_suppression_tx(&mut *tx, app_id, 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): @@ -1068,6 +1112,22 @@ impl ApplyService { report.collections_deleted += 1; } } + + // Per-app 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 app_id = owner.app_id().expect("suppressions are app-only"); + let (kind, reference) = + ch.key.split_once(':').expect("suppression key has ':'"); + crate::suppression_repo::delete_suppression_tx( + &mut *tx, app_id, kind, reference, + ) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + report.suppressions_deleted += 1; + } + } } Ok(name_to_id) } @@ -2290,6 +2350,14 @@ impl ApplyService { 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 per-app suppression markers — app-only (a group never + // suppresses; it just wouldn't declare the template). + let suppressions = match owner { + ApplyOwner::App(app_id) => crate::suppression_repo::list_for_app(&self.pool, app_id) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?, + ApplyOwner::Group(_) => Vec::new(), + }; Ok(CurrentState { scripts, routes, @@ -2298,6 +2366,7 @@ impl ApplyService { vars, extension_point_names, collections, + suppressions, }) } @@ -2363,6 +2432,7 @@ fn compute_diff_with_names( vars: diff_vars(current, bundle), extension_points: diff_extension_points(current, bundle), collections: diff_collections(current, bundle), + suppressions: diff_suppressions(current, bundle), } } @@ -2920,6 +2990,55 @@ fn diff_collections(current: &CurrentState, bundle: &Bundle) -> Vec Vec { + // 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). @@ -3214,6 +3333,10 @@ pub struct ApplyReport { pub collections_created: u32, #[serde(default)] pub collections_deleted: u32, + #[serde(default)] + pub suppressions_created: u32, + #[serde(default)] + pub suppressions_deleted: u32, #[serde(skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, } @@ -3531,6 +3654,8 @@ mod tests { vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), collections: Vec::new(), + suppress_triggers: Vec::new(), + suppress_routes: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.scripts.len(), 1); @@ -3555,6 +3680,8 @@ mod tests { vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), collections: Vec::new(), + suppress_triggers: Vec::new(), + suppress_routes: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert!(plan.is_noop(), "expected all no-op, got {plan:?}"); @@ -3574,6 +3701,8 @@ mod tests { vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), collections: Vec::new(), + suppress_triggers: Vec::new(), + suppress_routes: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.scripts[0].op, Op::Update); @@ -3594,6 +3723,8 @@ mod tests { vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), collections: Vec::new(), + suppress_triggers: Vec::new(), + suppress_routes: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.scripts[0].op, Op::Delete); @@ -3643,6 +3774,8 @@ mod tests { vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), collections: Vec::new(), + suppress_triggers: Vec::new(), + suppress_routes: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.routes[0].op, Op::Update); @@ -3657,6 +3790,8 @@ mod tests { vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), collections: Vec::new(), + suppress_triggers: Vec::new(), + suppress_routes: Vec::new(), } } diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index a2e9c97..fb2d39b 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -88,6 +88,7 @@ pub mod secrets_api; pub mod secrets_repo; pub mod secrets_service; pub mod ssrf; +pub mod suppression_repo; pub mod topic_repo; pub mod topics_api; pub mod trigger_config; diff --git a/crates/manager-core/src/suppression_repo.rs b/crates/manager-core/src/suppression_repo.rs new file mode 100644 index 0000000..a1b68af --- /dev/null +++ b/crates/manager-core/src/suppression_repo.rs @@ -0,0 +1,75 @@ +//! Template-suppression markers (§11 tail) — the `template_suppressions` table +//! (0058). A marker `(app_id, target_kind, reference)` records that an app opts +//! OUT of an inherited group template: a handler script name it declines +//! (`target_kind='trigger'`) or a path it declines (`target_kind='route'`). +//! +//! App-only (a group would just not declare the template), so — unlike the +//! owner-polymorphic `extension_points` — these free functions take a plain +//! `AppId`. Same tx-function style: read over `&PgPool`, write over a +//! `&mut Transaction`. + +use picloud_shared::AppId; +use sqlx::{PgPool, Postgres, Transaction}; + +/// The two suppressible template kinds, as stored in `target_kind`. +pub const TARGET_TRIGGER: &str = "trigger"; +pub const TARGET_ROUTE: &str = "route"; + +/// List the suppression markers declared at `app_id`, as `(target_kind, +/// reference)` pairs, stably ordered. Backs `load_current` (the apply diff) and +/// the read-only `suppress ls`. +pub async fn list_for_app( + pool: &PgPool, + app_id: AppId, +) -> Result, sqlx::Error> { + let rows: Vec<(String, String)> = sqlx::query_as( + "SELECT target_kind, reference FROM template_suppressions \ + WHERE app_id = $1 ORDER BY target_kind, LOWER(reference)", + ) + .bind(app_id.into_inner()) + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// Insert a suppression marker in the apply transaction. Idempotent: re-apply +/// of an already-declared `(kind, reference)` is a no-op (`ON CONFLICT DO +/// NOTHING`), so the marker survives without a spurious version bump. +pub async fn insert_suppression_tx( + tx: &mut Transaction<'_, Postgres>, + app_id: AppId, + target_kind: &str, + reference: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO template_suppressions (app_id, target_kind, reference) \ + VALUES ($1, $2, $3) \ + ON CONFLICT (app_id, target_kind, reference) DO NOTHING", + ) + .bind(app_id.into_inner()) + .bind(target_kind) + .bind(reference) + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// Delete a suppression marker in the apply transaction. Used by `--prune` when +/// the manifest stops declaring the reference → the template re-inherits. +pub async fn delete_suppression_tx( + tx: &mut Transaction<'_, Postgres>, + app_id: AppId, + target_kind: &str, + reference: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + "DELETE FROM template_suppressions \ + WHERE app_id = $1 AND target_kind = $2 AND reference = $3", + ) + .bind(app_id.into_inner()) + .bind(target_kind) + .bind(reference) + .execute(&mut **tx) + .await?; + Ok(()) +} diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index 8a8b913..adb03c6 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -1429,6 +1429,8 @@ pub struct PlanDto { pub extension_points: Vec, #[serde(default)] pub collections: Vec, + #[serde(default)] + pub suppressions: Vec, /// Fingerprint of the live state this plan was computed against; carried /// in `.picloud/` and replayed to `apply` for the bound-plan check. #[serde(default)] @@ -1471,6 +1473,8 @@ pub struct NodePlanDto { pub extension_points: Vec, #[serde(default)] pub collections: Vec, + #[serde(default)] + pub suppressions: Vec, } /// Response of `POST .../apply`: counts of what changed. @@ -1507,6 +1511,10 @@ pub struct ApplyReportDto { #[serde(default)] pub collections_deleted: u32, #[serde(default)] + pub suppressions_created: u32, + #[serde(default)] + pub suppressions_deleted: u32, + #[serde(default)] pub warnings: Vec, } diff --git a/crates/picloud-cli/src/cmds/apply.rs b/crates/picloud-cli/src/cmds/apply.rs index 64cfc5d..aa27aa8 100644 --- a/crates/picloud-cli/src/cmds/apply.rs +++ b/crates/picloud-cli/src/cmds/apply.rs @@ -98,6 +98,13 @@ pub async fn run( "+{} -{}", report.collections_created, report.collections_deleted ), + ) + .field( + "suppressions", + format!( + "+{} -{}", + report.suppressions_created, report.suppressions_deleted + ), ); for w in &report.warnings { block.field("warning", w.clone()); @@ -179,6 +186,13 @@ pub async fn run_tree( "+{} -{}", report.collections_created, report.collections_deleted ), + ) + .field( + "suppressions", + format!( + "+{} -{}", + report.suppressions_created, report.suppressions_deleted + ), ); for w in &report.warnings { block.field("warning", w.clone()); diff --git a/crates/picloud-cli/src/cmds/init.rs b/crates/picloud-cli/src/cmds/init.rs index 8751356..3ed0905 100644 --- a/crates/picloud-cli/src/cmds/init.rs +++ b/crates/picloud-cli/src/cmds/init.rs @@ -140,6 +140,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest { triggers: crate::manifest::ManifestTriggers::default(), secrets: crate::manifest::ManifestSecrets::default(), vars: std::collections::BTreeMap::new(), + suppress: crate::manifest::ManifestSuppress::default(), } } diff --git a/crates/picloud-cli/src/cmds/plan.rs b/crates/picloud-cli/src/cmds/plan.rs index d71de6b..55da10f 100644 --- a/crates/picloud-cli/src/cmds/plan.rs +++ b/crates/picloud-cli/src/cmds/plan.rs @@ -64,7 +64,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) { let mut table = Table::new(["node", "kind", "op", "resource", "detail"]); for n in &plan.nodes { let node = format!("{}:{}", n.kind, n.slug); - let groups: [(&str, &Vec); 7] = [ + let groups: [(&str, &Vec); 8] = [ ("script", &n.scripts), ("route", &n.routes), ("trigger", &n.triggers), @@ -72,6 +72,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) { ("var", &n.vars), ("extension_point", &n.extension_points), ("collection", &n.collections), + ("suppression", &n.suppressions), ]; for (rk, changes) in groups { for c in changes { @@ -168,6 +169,8 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result { .into_iter() .map(|(name, kind)| json!({ "name": name, "kind": kind })) .collect::>(), + "suppress_triggers": manifest.suppress.triggers, + "suppress_routes": manifest.suppress.routes, })) } @@ -182,7 +185,7 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result { fn render(plan: &PlanDto, mode: OutputMode) { let mut table = Table::new(["kind", "op", "resource", "detail"]); - let groups: [(&str, &Vec); 7] = [ + let groups: [(&str, &Vec); 8] = [ ("script", &plan.scripts), ("route", &plan.routes), ("trigger", &plan.triggers), @@ -190,6 +193,7 @@ fn render(plan: &PlanDto, mode: OutputMode) { ("var", &plan.vars), ("extension_point", &plan.extension_points), ("collection", &plan.collections), + ("suppression", &plan.suppressions), ]; for (kind, changes) in groups { for c in changes { diff --git a/crates/picloud-cli/src/cmds/pull.rs b/crates/picloud-cli/src/cmds/pull.rs index a1b3c6c..5d187de 100644 --- a/crates/picloud-cli/src/cmds/pull.rs +++ b/crates/picloud-cli/src/cmds/pull.rs @@ -234,6 +234,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> names: secrets.iter().map(|s| s.name.clone()).collect(), }, vars: manifest_vars, + suppress: crate::manifest::ManifestSuppress::default(), }; std::fs::write(&manifest_path, manifest.to_toml()?) diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index 49c835e..25e82a7 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -50,6 +50,10 @@ pub struct Manifest { /// The overlay merges per-env, last-write-wins by key. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub vars: BTreeMap, + /// `[suppress]` (§11 tail) — per-app opt-out of inherited group templates. + /// App-only; a `[group]` carrying it is rejected in [`Manifest::parse`]. + #[serde(default, skip_serializing_if = "ManifestSuppress::is_empty")] + pub suppress: ManifestSuppress, } impl Manifest { @@ -73,6 +77,12 @@ impl Manifest { // (cron/queue/email) stay app-only — they need per-app state/secrets → // materialization. if m.group.is_some() { + if !m.suppress.is_empty() { + anyhow::bail!( + "a [group] cannot declare [suppress] — only an app opts out of \ + inherited templates; a group simply doesn't declare the template" + ); + } let t = &m.triggers; if !t.cron.is_empty() || !t.queue.is_empty() || !t.email.is_empty() { anyhow::bail!( @@ -492,6 +502,27 @@ impl ManifestSecrets { } } +/// `[suppress]` (§11 tail) — per-app opt-out of inherited group templates. +/// `triggers = [...]` names handler SCRIPTS whose inherited triggers this app +/// declines; `routes = [...]` names PATHS whose inherited route this app +/// declines (404s instead of serving). App-only — a `[group]` carrying +/// `[suppress]` is a hard parse error. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManifestSuppress { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub triggers: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub routes: Vec, +} + +impl ManifestSuppress { + #[must_use] + pub fn is_empty(&self) -> bool { + self.triggers.is_empty() && self.routes.is_empty() + } +} + // ---- serde skip/default helpers ---- fn is_endpoint(kind: &ScriptKind) -> bool { @@ -584,6 +615,7 @@ mod tests { ("region".to_string(), toml::Value::String("eu".into())), ("max-retries".to_string(), toml::Value::Integer(3)), ]), + suppress: ManifestSuppress::default(), } } @@ -688,6 +720,7 @@ mod tests { triggers: ManifestTriggers::default(), secrets: ManifestSecrets::default(), vars: BTreeMap::new(), + suppress: ManifestSuppress::default(), }; let text = m.to_toml().unwrap(); assert!(!text.contains("[[scripts]]"), "got:\n{text}"); @@ -749,6 +782,34 @@ mod tests { ); } + #[test] + fn app_suppress_parses_group_suppress_rejected() { + // §11 tail: an [app] declares [suppress] to opt out of inherited + // templates — script names (triggers) + paths (routes). + let m = Manifest::parse( + "[app]\nslug = \"blog\"\nname = \"Blog\"\n\n\ + [suppress]\ntriggers = [\"audit\"]\nroutes = [\"/hello\"]\n", + ) + .expect("app [suppress] must parse"); + assert_eq!(m.suppress.triggers, ["audit"]); + assert_eq!(m.suppress.routes, ["/hello"]); + + // A [group] cannot suppress — it simply wouldn't declare the template. + let err = Manifest::parse( + "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\ + [suppress]\ntriggers = [\"audit\"]\n", + ) + .expect_err("group [suppress] is rejected"); + assert!(err.to_string().contains("suppress"), "got: {err}"); + + // An unknown key inside [suppress] is a hard error (deny_unknown_fields). + let typo = "[app]\nslug = \"x\"\nname = \"X\"\n\n[suppress]\ntrigger = [\"a\"]\n"; + assert!( + Manifest::parse(typo).is_err(), + "an unknown key in [suppress] must be rejected" + ); + } + #[test] fn group_manifest_parses_and_rejects_app_only_blocks() { // A [group] node: scripts + vars, no [app].