feat(hierarchies): route templates — group-declared, per-app fan-out (M4a)

§4.5 of the groups/project-tool design. A group declares a route ONCE; the
tree apply fans it out into one concrete per-app_id route on every descendant
app — instantiation, not inheritance (the cross-app isolation boundary is
unchanged; expanded rows stay app-owned).

- Migration 0053: `route_templates` table (group-owned) + `routes.from_template`
  provenance column + index.
- `template_repo.rs`: tx-aware CRUD + chain-load (mirrors group_repo/route_repo).
- Manifest: `[group]` `[[route_templates]]`; build_bundle emits them; rejected
  on an app node (422).
- Apply: group nodes reconcile template rows (create/update/delete via the
  plan); tree Phase B expands each chain template into a concrete route per
  descendant app node — placeholders {app_slug}/{env}/{var:NAME} resolved per
  app (unknown var/placeholder = hard error), script bound nearest-owner-wins.
  Idempotent via from_template (diffed create/update-as-replace/delete; re-apply
  is no-churn, --prune reaps expansions). Collision with a hand-declared route,
  or between two templates, is a hard error. Each RESOLVED expansion is
  validated like a hand-declared route (reserved-path, path/host parse,
  host-claim) so a {var:}-injected path or unclaimed strict host can't slip in.
- Plan: route-template diff at the group node + blast-radius (descendant app
  count in this apply); state_token folds each template (edit trips StateMoved).
- Authz: declaring → GroupScriptsWrite; an app receiving expansions →
  AppWriteRoute (gated even with no hand-declared routes).
- Tests: tests/templates.rs (fan-out + per-slug + idempotent-stable-ids +
  prune-reap + collision-rejected); placeholder/validation unit tests; schema
  golden reblessed.

Reviewed (no CRITICAL/HIGH; isolation core verified sound). Closed the two
validation-parity MEDIUMs (host-claim + reserved-path/pattern on expansions)
and added an out-of-apply-descendant prune warning. Scope: expansion targets
app nodes in the tree apply (not yet cross-repo descendants); {var:NAME} uses
committed vars. M4b (trigger templates) reuses this engine next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-28 15:58:41 +02:00
parent 193336a8a6
commit 0396866698
18 changed files with 1504 additions and 37 deletions

View File

@@ -1272,6 +1272,7 @@ impl Client {
expected_token: Option<&str>,
project_key: Option<&str>,
allow_takeover: bool,
env: Option<&str>,
) -> Result<ApplyReportDto> {
let body = serde_json::json!({
"bundle": bundle,
@@ -1279,6 +1280,7 @@ impl Client {
"expected_token": expected_token,
"project_key": project_key,
"allow_takeover": allow_takeover,
"env": env,
});
let resp = self
.request(Method::POST, "/api/v1/admin/tree/apply")
@@ -1387,6 +1389,9 @@ pub struct TreePlanDto {
/// Slugs of owned groups absent from the manifest — what `--prune` deletes.
#[serde(default)]
pub structural_prunes: Vec<String>,
/// Route-template fan-out per declaring group (§4.5, M4a).
#[serde(default)]
pub template_blast_radius: Vec<TemplateBlastRadiusDto>,
}
/// A single ownership conflict (`pic plan --dir`).
@@ -1396,6 +1401,13 @@ pub struct OwnershipConflictDto {
pub owner_key: String,
}
/// One group's route-template blast radius (`pic plan --dir`).
#[derive(Debug, Deserialize)]
pub struct TemplateBlastRadiusDto {
pub group: String,
pub affected_apps: usize,
}
#[derive(Debug, Deserialize)]
pub struct NodePlanDto {
pub kind: String,
@@ -1412,6 +1424,8 @@ pub struct NodePlanDto {
pub vars: Vec<ChangeDto>,
#[serde(default)]
pub extension_points: Vec<ChangeDto>,
#[serde(default)]
pub route_templates: Vec<ChangeDto>,
}
/// Response of `POST .../apply`: counts of what changed.
@@ -1456,6 +1470,12 @@ pub struct ApplyReportDto {
#[serde(default)]
pub groups_pruned: u32,
#[serde(default)]
pub route_templates_created: u32,
#[serde(default)]
pub route_templates_updated: u32,
#[serde(default)]
pub route_templates_deleted: u32,
#[serde(default)]
pub warnings: Vec<String>,
}

View File

@@ -151,6 +151,7 @@ pub async fn run_tree(
expected_token.as_deref(),
Some(&project_key),
takeover,
env,
)
.await?;
crate::linkstate::clear_plan(dir, token_key);
@@ -216,6 +217,22 @@ pub async fn run_tree(
);
}
}
// Route templates (§4.5, M4a) — the template rows; their per-app route
// expansions are folded into the `routes` line above.
if report.route_templates_created > 0
|| report.route_templates_updated > 0
|| report.route_templates_deleted > 0
{
block.field(
"route-templates",
format!(
"+{} ~{} -{}",
report.route_templates_created,
report.route_templates_updated,
report.route_templates_deleted
),
);
}
for w in &report.warnings {
block.field("warning", w.clone());
}

View File

@@ -143,6 +143,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
secrets: crate::manifest::ManifestSecrets::default(),
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
route_templates: Vec::new(),
}
}

View File

@@ -67,13 +67,14 @@ 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>); 6] = [
let groups: [(&str, &Vec<ChangeDto>); 7] = [
("script", &n.scripts),
("route", &n.routes),
("trigger", &n.triggers),
("secret", &n.secrets),
("var", &n.vars),
("extension-point", &n.extension_points),
("route-template", &n.route_templates),
];
for (rk, changes) in groups {
for c in changes {
@@ -104,6 +105,12 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
eprintln!(" - {slug}");
}
}
if !plan.template_blast_radius.is_empty() {
eprintln!("\nroute-template blast radius (apps IN THIS apply that get expansions):");
for b in &plan.template_blast_radius {
eprintln!(" - {}{} app(s)", b.group, b.affected_apps);
}
}
}
}
@@ -190,6 +197,15 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
})
.collect();
// Route templates (§4.5, M4a): name + the flattened route fields the
// server's `RouteTemplateSpec` (name + `#[serde(flatten)] BundleRoute`)
// deserializes. Group manifests only; the server rejects them on an app.
let route_templates = manifest
.route_templates
.iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()?;
Ok(json!({
"scripts": scripts,
"routes": routes,
@@ -197,6 +213,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
"secrets": manifest.secrets.names,
"vars": vars,
"extension_points": extension_points,
"route_templates": route_templates,
}))
}

View File

@@ -237,6 +237,8 @@ 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).
route_templates: Vec::new(),
};
std::fs::write(&manifest_path, manifest.to_toml()?)

View File

@@ -53,6 +53,11 @@ pub struct Manifest {
/// resolution (§5.5). Allowed on both app and group manifests.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extension_points: Vec<ManifestExtensionPoint>,
/// `[[route_templates]]` — GROUP-only route declarations that fan out into a
/// concrete route on every descendant app at apply time (§4.5, M4a). String
/// fields may carry `{app_slug}`/`{env}`/`{var:NAME}` placeholders.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub route_templates: Vec<ManifestRouteTemplate>,
}
impl Manifest {
@@ -275,6 +280,32 @@ pub struct ManifestRoute {
pub enabled: bool,
}
/// `[[route_templates]]` — a route declared once on a group, fanned out per
/// descendant app (§4.5, M4a). Same shape as [`ManifestRoute`] plus a `name`
/// (the per-group identity/upsert key).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestRouteTemplate {
/// Per-group template name (identity/upsert key).
pub name: String,
pub script: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
pub host_kind: HostKind,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub host: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_param_name: Option<String>,
pub path_kind: PathKind,
pub path: String,
#[serde(default, skip_serializing_if = "is_sync")]
pub dispatch_mode: DispatchMode,
#[serde(
default = "picloud_shared::default_true",
skip_serializing_if = "is_true"
)]
pub enabled: bool,
}
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ManifestTriggers {
@@ -511,6 +542,7 @@ mod tests {
name: "theme".into(),
default: Some("default-theme".into()),
}],
route_templates: Vec::new(),
}
}
@@ -615,6 +647,7 @@ mod tests {
secrets: ManifestSecrets::default(),
vars: BTreeMap::new(),
extension_points: Vec::new(),
route_templates: Vec::new(),
};
let text = m.to_toml().unwrap();
assert!(!text.contains("[[scripts]]"), "got:\n{text}");