feat(hierarchies): trigger templates — per-app fan-out (M4b)
Completes §4.5 templates (M4a shipped routes). A group declares a trigger once;
the tree apply fans it out into one concrete per-app_id trigger on each
descendant app, reusing the M4a expansion engine over the trigger insert path.
- Migration 0054: `trigger_templates` table (group-owned; the whole
BundleTrigger wire object stored as `spec` JSONB, placeholders unresolved) +
`triggers.from_template` provenance column + index.
- `template_repo.rs`: trigger-template CRUD + chain-load.
- Manifest: `[group]` `[[trigger_templates]]` (flat: name, kind, script, + kind
params); build_bundle emits them; rejected on an app node.
- Apply: group nodes reconcile trigger-template rows; tree Phase B expands each
chain trigger template into a concrete trigger per descendant app —
placeholders resolved in every spec string leaf, then the typed trigger is
rebuilt and inserted through the shared `insert_bundle_trigger_tx` (email
secrets resolved + re-sealed per recipient app). Idempotent via from_template
(one trigger per template per app; semantic-identity compare → no-op /
delete+recreate / reap). Collision with a hand-declared trigger or between
templates is a hard error. Each resolved trigger is re-validated with the
per-kind shape check (cron/queue/etc.) so a {var:}-injected bad
schedule/timeout can't slip in.
- Authz: declaring → GroupScriptsWrite; receiving → AppManageTriggers, plus
AppSecretsRead for email-trigger recipients (parity with hand-declared email).
- Plan/token/report extended for trigger templates; blast radius covers both.
- Tests: tests/templates.rs trigger fan-out (kv + {app_slug}, stable-ids,
prune-reap); resolve_placeholders_in_json unit test; schema golden reblessed.
Reviewed (no CRITICAL; isolation + per-app email-secret sealing verified
sound). Closed a HIGH validation-parity gap (re-validate resolved triggers) and
a MEDIUM authz gap (AppSecretsRead for email expansions). v1.2 Hierarchies
template work (M4) complete.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1426,6 +1426,8 @@ pub struct NodePlanDto {
|
||||
pub extension_points: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub route_templates: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub trigger_templates: Vec<ChangeDto>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../apply`: counts of what changed.
|
||||
@@ -1476,6 +1478,12 @@ pub struct ApplyReportDto {
|
||||
#[serde(default)]
|
||||
pub route_templates_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub trigger_templates_created: u32,
|
||||
#[serde(default)]
|
||||
pub trigger_templates_updated: u32,
|
||||
#[serde(default)]
|
||||
pub trigger_templates_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,20 @@ pub async fn run_tree(
|
||||
),
|
||||
);
|
||||
}
|
||||
if report.trigger_templates_created > 0
|
||||
|| report.trigger_templates_updated > 0
|
||||
|| report.trigger_templates_deleted > 0
|
||||
{
|
||||
block.field(
|
||||
"trigger-templates",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.trigger_templates_created,
|
||||
report.trigger_templates_updated,
|
||||
report.trigger_templates_deleted
|
||||
),
|
||||
);
|
||||
}
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,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<ChangeDto>); 7] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 8] = [
|
||||
("script", &n.scripts),
|
||||
("route", &n.routes),
|
||||
("trigger", &n.triggers),
|
||||
@@ -75,6 +75,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
("var", &n.vars),
|
||||
("extension-point", &n.extension_points),
|
||||
("route-template", &n.route_templates),
|
||||
("trigger-template", &n.trigger_templates),
|
||||
];
|
||||
for (rk, changes) in groups {
|
||||
for c in changes {
|
||||
@@ -206,6 +207,15 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
.map(serde_json::to_value)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
// Trigger templates (§4.5, M4b): name + the flattened trigger spec
|
||||
// (kind/script/params) — the server's `TriggerTemplateSpec` deserializes
|
||||
// `{ name, <BundleTrigger> }`. Group manifests only.
|
||||
let trigger_templates = manifest
|
||||
.trigger_templates
|
||||
.iter()
|
||||
.map(serde_json::to_value)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(json!({
|
||||
"scripts": scripts,
|
||||
"routes": routes,
|
||||
@@ -214,6 +224,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
"vars": vars,
|
||||
"extension_points": extension_points,
|
||||
"route_templates": route_templates,
|
||||
"trigger_templates": trigger_templates,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -237,8 +237,9 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
},
|
||||
vars: manifest_vars,
|
||||
extension_points,
|
||||
// `pull` is app-scoped; route templates are group-owned (§4.5, M4a).
|
||||
// `pull` is app-scoped; templates are group-owned (§4.5).
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
};
|
||||
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
|
||||
@@ -58,6 +58,12 @@ pub struct Manifest {
|
||||
/// fields may carry `{app_slug}`/`{env}`/`{var:NAME}` placeholders.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub route_templates: Vec<ManifestRouteTemplate>,
|
||||
/// `[[trigger_templates]]` — GROUP-only trigger declarations that fan out per
|
||||
/// descendant app (§4.5, M4b). Each entry is `name = …`, `kind = "cron"|…`,
|
||||
/// `script = …`, plus the kind's params (string fields may carry
|
||||
/// placeholders). Kept loosely-typed so one block covers all seven kinds.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub trigger_templates: Vec<ManifestTriggerTemplate>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
@@ -306,6 +312,17 @@ pub struct ManifestRouteTemplate {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// `[[trigger_templates]]` — a trigger declared once on a group, fanned out per
|
||||
/// descendant app (§4.5, M4b). `name` is the per-group identity; the remaining
|
||||
/// keys (`kind`, `script`, kind params) are kept as a flattened table so one
|
||||
/// block covers every kind, matching the server's `{ name, <BundleTrigger> }`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestTriggerTemplate {
|
||||
pub name: String,
|
||||
#[serde(flatten)]
|
||||
pub spec: toml::Value,
|
||||
}
|
||||
|
||||
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestTriggers {
|
||||
@@ -543,6 +560,7 @@ mod tests {
|
||||
default: Some("default-theme".into()),
|
||||
}],
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,6 +666,7 @@ mod tests {
|
||||
vars: BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
};
|
||||
let text = m.to_toml().unwrap();
|
||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||
|
||||
Reference in New Issue
Block a user