feat(apply): declarative extension_points reconcile (§5.5 C3)
Thread extension-point markers through the manifest and the apply engine — a name-only resource modeled on `secrets` (shape) with `vars`-style create/prune write behavior. - manifest: top-level `extension_points = ["theme", …]` (allowed on app and group; overlay stays base-only via deny_unknown_fields); `build_bundle` emits the names. - apply_service: `Bundle.extension_points`, `CurrentState.extension_point_names`, `Plan.extension_points` (+ is_noop), `diff_extension_points` (declared→Create / live-undeclared→Delete / else NoOp), `load_current` loads them, `reconcile_node_tx` inserts on Create + deletes on prune, `state_token_with_names` folds in `ep|<name>`, `validate_bundle` rejects duplicates + reserved names, `ApplyReport` gains created/deleted counts. - CLI client + plan/apply rendering: `extension_points` in PlanDto/NodePlanDto/ ApplyReportDto, an `extension_point` row group in `pic plan`, a count in the apply summary. pull sets it empty for now (wired to the read endpoint in C4). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,11 @@ pub struct Bundle {
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -315,6 +320,8 @@ pub struct Plan {
|
||||
pub secrets: Vec<ResourceChange>,
|
||||
#[serde(default)]
|
||||
pub vars: Vec<ResourceChange>,
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<ResourceChange>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -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<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)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
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<ResourceChange>
|
||||
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
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1320,6 +1320,8 @@ pub struct PlanDto {
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub vars: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<ChangeDto>,
|
||||
/// 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<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub vars: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<ChangeDto>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ChangeDto>); 5] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 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<Value> {
|
||||
"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<Value> {
|
||||
|
||||
fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 5] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 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 {
|
||||
|
||||
@@ -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()?)
|
||||
|
||||
@@ -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<String, toml::Value>,
|
||||
/// `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<String>,
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user