diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index b284dd2..f4fcbca 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -83,6 +83,11 @@ pub struct Bundle { /// 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, } #[derive(Debug, Clone, Deserialize)] @@ -315,6 +320,8 @@ pub struct Plan { pub secrets: Vec, #[serde(default)] pub vars: Vec, + #[serde(default)] + pub extension_points: Vec, } impl Plan { @@ -327,6 +334,7 @@ impl Plan { .chain(&self.triggers) .chain(&self.secrets) .chain(&self.vars) + .chain(&self.extension_points) .all(|c| c.op == Op::NoOp) } } @@ -403,6 +411,8 @@ pub struct CurrentState { /// 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, } // ---------------------------------------------------------------------------- @@ -773,6 +783,21 @@ impl ApplyService { } } + // 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; + } + } + // 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): @@ -848,6 +873,20 @@ impl ApplyService { 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; + } + } } Ok(name_to_id) } @@ -1603,6 +1642,7 @@ impl ApplyService { /// 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, @@ -1722,6 +1762,24 @@ impl ApplyService { 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 = 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)" + ))); + } + } Ok(()) } @@ -1769,12 +1827,18 @@ impl ApplyService { .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()))?; Ok(CurrentState { scripts, routes, triggers, secret_names, vars, + extension_point_names, }) } @@ -1838,6 +1902,7 @@ fn compute_diff_with_names( 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), } } @@ -2002,6 +2067,14 @@ impl ApplyOwner { } } + /// 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), + } + } + /// The app id, when this is an app node. Routes/triggers/email-secrets only /// exist on app nodes, so their reconcile paths call this with an /// `expect` — guarded by validation that rejects them on a group bundle. @@ -2308,6 +2381,43 @@ fn diff_secrets(current: &CurrentState, bundle: &Bundle) -> Vec 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 { + 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 +} + /// 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). @@ -2594,6 +2704,10 @@ pub struct ApplyReport { 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(skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, } @@ -2763,7 +2877,8 @@ fn state_token_with_names( + current.routes.len() + current.triggers.len() + current.secret_names.len() - + current.vars.len(), + + current.vars.len() + + current.extension_point_names.len(), ); for s in ¤t.scripts { parts.push(format!( @@ -2818,6 +2933,10 @@ fn state_token_with_names( 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}")); + } // Order-independent: sort the per-resource tokens before hashing. parts.sort_unstable(); let mut h: u64 = 0xcbf2_9ce4_8422_2325; @@ -2898,6 +3017,7 @@ mod tests { triggers: vec![], secrets: vec!["S".into()], vars: std::collections::BTreeMap::new(), + extension_points: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.scripts.len(), 1); @@ -2920,6 +3040,7 @@ mod tests { triggers: vec![], secrets: vec!["S".into()], vars: std::collections::BTreeMap::new(), + extension_points: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert!(plan.is_noop(), "expected all no-op, got {plan:?}"); @@ -2937,6 +3058,7 @@ mod tests { triggers: vec![], secrets: vec![], vars: std::collections::BTreeMap::new(), + extension_points: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.scripts[0].op, Op::Update); @@ -2955,6 +3077,7 @@ mod tests { triggers: vec![], secrets: vec![], vars: std::collections::BTreeMap::new(), + extension_points: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.scripts[0].op, Op::Delete); @@ -3001,6 +3124,7 @@ mod tests { triggers: vec![], secrets: vec![], vars: std::collections::BTreeMap::new(), + extension_points: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.routes[0].op, Op::Update); @@ -3013,6 +3137,7 @@ mod tests { triggers: vec![], secrets: vec![], vars: std::collections::BTreeMap::new(), + extension_points: Vec::new(), } } diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index 8d8aee1..95ae06b 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -1320,6 +1320,8 @@ pub struct PlanDto { pub secrets: Vec, #[serde(default)] pub vars: Vec, + #[serde(default)] + pub extension_points: 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)] @@ -1358,6 +1360,8 @@ pub struct NodePlanDto { pub secrets: Vec, #[serde(default)] pub vars: Vec, + #[serde(default)] + pub extension_points: Vec, } /// Response of `POST .../apply`: counts of what changed. @@ -1386,6 +1390,10 @@ pub struct ApplyReportDto { #[serde(default)] pub vars_deleted: u32, #[serde(default)] + pub extension_points_created: u32, + #[serde(default)] + pub extension_points_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 b830170..69c57da 100644 --- a/crates/picloud-cli/src/cmds/apply.rs +++ b/crates/picloud-cli/src/cmds/apply.rs @@ -84,6 +84,13 @@ pub async fn run( "+{} ~{} -{}", report.vars_created, report.vars_updated, report.vars_deleted ), + ) + .field( + "extension_points", + format!( + "+{} -{}", + report.extension_points_created, report.extension_points_deleted + ), ); for w in &report.warnings { block.field("warning", w.clone()); @@ -151,6 +158,13 @@ pub async fn run_tree( "+{} ~{} -{}", report.vars_created, report.vars_updated, report.vars_deleted ), + ) + .field( + "extension_points", + format!( + "+{} -{}", + report.extension_points_created, report.extension_points_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 dec46ca..dad5a20 100644 --- a/crates/picloud-cli/src/cmds/init.rs +++ b/crates/picloud-cli/src/cmds/init.rs @@ -139,6 +139,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest { triggers: crate::manifest::ManifestTriggers::default(), secrets: crate::manifest::ManifestSecrets::default(), vars: std::collections::BTreeMap::new(), + extension_points: Vec::new(), } } diff --git a/crates/picloud-cli/src/cmds/plan.rs b/crates/picloud-cli/src/cmds/plan.rs index 2c51e68..1c52647 100644 --- a/crates/picloud-cli/src/cmds/plan.rs +++ b/crates/picloud-cli/src/cmds/plan.rs @@ -64,12 +64,13 @@ 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); 5] = [ + let groups: [(&str, &Vec); 6] = [ ("script", &n.scripts), ("route", &n.routes), ("trigger", &n.triggers), ("secret", &n.secrets), ("var", &n.vars), + ("extension_point", &n.extension_points), ]; for (rk, changes) in groups { for c in changes { @@ -160,6 +161,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result { "triggers": triggers, "secrets": manifest.secrets.names, "vars": vars, + "extension_points": manifest.extension_points, })) } @@ -174,12 +176,13 @@ 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); 5] = [ + let groups: [(&str, &Vec); 6] = [ ("script", &plan.scripts), ("route", &plan.routes), ("trigger", &plan.triggers), ("secret", &plan.secrets), ("var", &plan.vars), + ("extension_point", &plan.extension_points), ]; 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 d8a7320..fc19e3d 100644 --- a/crates/picloud-cli/src/cmds/pull.rs +++ b/crates/picloud-cli/src/cmds/pull.rs @@ -224,6 +224,8 @@ 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, + // Wired to the read endpoint in C4 so pull→plan round-trips EPs. + extension_points: Vec::new(), }; 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 84cd2a9..e46cb36 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -49,6 +49,12 @@ pub struct Manifest { /// The overlay merges per-env, last-write-wins by key. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub vars: BTreeMap, + /// `extension_points = ["theme", …]` (§5.5) — module names this node marks + /// as provided/overridable by descendants. Name-only, like `[secrets]`; the + /// optional default body is a co-located `[[scripts]]` module of the same + /// name. Allowed on app and group nodes. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub extension_points: Vec, } impl Manifest { @@ -488,6 +494,7 @@ mod tests { ("region".to_string(), toml::Value::String("eu".into())), ("max-retries".to_string(), toml::Value::Integer(3)), ]), + extension_points: vec!["theme".into()], } } @@ -591,12 +598,14 @@ mod tests { triggers: ManifestTriggers::default(), secrets: ManifestSecrets::default(), vars: BTreeMap::new(), + extension_points: vec![], }; let text = m.to_toml().unwrap(); assert!(!text.contains("[[scripts]]"), "got:\n{text}"); assert!(!text.contains("triggers"), "got:\n{text}"); assert!(!text.contains("secrets"), "got:\n{text}"); assert!(!text.contains("vars"), "got:\n{text}"); + assert!(!text.contains("extension_points"), "got:\n{text}"); // Still round-trips. assert_eq!(m, Manifest::parse(&text).unwrap()); }