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:
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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()?)
|
||||
|
||||
@@ -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}");
|
||||
|
||||
Reference in New Issue
Block a user