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}");

View File

@@ -41,6 +41,7 @@ mod routes;
mod scripts;
mod secrets;
mod staleness;
mod templates;
mod tree;
mod tree_shape;
mod triggers;

View File

@@ -0,0 +1,218 @@
//! M4a route templates (§4.5) via `pic apply --dir`:
//! * a group declares ONE route template (`/t/{app_slug}`) bound to its
//! inherited script; the apply fans it out into a concrete route on every
//! descendant app, with `{app_slug}` resolved per app,
//! * re-apply is idempotent (no duplicate expansions — provenance),
//! * removing the template + `--prune` reaps the expansions,
//! * an expansion colliding with a hand-declared route is a hard error,
//! * `pic plan --dir` reports the blast radius.
use std::fs;
use postgres::{Client as PgClient, NoTls};
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
/// Template-expanded routes (`from_template IS NOT NULL`) for the two app slugs
/// under test, as `(path, id)` — read straight from Postgres since the route
/// admin endpoint only lists app-owned-script routes (a group script isn't
/// addressable there). Scoped to `/t/<a>` and `/t/<b>` so parallel tests don't
/// interfere.
fn expansion_rows(a: &str, b: &str) -> Vec<(String, String)> {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
let want = [format!("/t/{a}"), format!("/t/{b}")];
let rows = pg
.query(
"SELECT path, id::text FROM routes \
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
&[&&want[..]],
)
.expect("query expansions");
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
}
/// A tree: a root group declaring a `shared` endpoint + a `greet` route template
/// `/t/{app_slug}`, and `extra_app_toml` appended to app `a`'s manifest. Apps
/// must pre-exist under the group (created in the test before applying).
fn tree_dir(group: &str, a: &str, b: &str, template: &str, extra_app_a: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(
dir.path().join("scripts/shared.rhai"),
r#""hi from template""#,
)
.unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"Tmpl Group\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n{template}"
),
)
.unwrap();
for (slug, extra) in [(a, extra_app_a), (b, "")] {
fs::create_dir_all(dir.path().join(slug)).unwrap();
fs::write(
dir.path().join(slug).join("picloud.toml"),
format!("[app]\nslug = \"{slug}\"\nname = \"App\"\n{extra}"),
)
.unwrap();
}
dir
}
const TEMPLATE: &str = "[[route_templates]]\nname = \"greet\"\nscript = \"shared\"\n\
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/t/{app_slug}\"\n";
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn route_template_fans_out_per_app_idempotently_then_prunes() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("tpl-g");
let a = common::unique_slug("tpl-a");
let b = common::unique_slug("tpl-b");
// group ← (app a, app b). Guards drop apps before the group (RESTRICT).
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _aa = AppGuard::new(&env.url, &env.token, &a);
let _ab = AppGuard::new(&env.url, &env.token, &b);
for slug in [&a, &b] {
common::pic_as(&env)
.args(["apps", "create", slug, "--group", &group])
.assert()
.success();
}
// --- Apply: the template fans out to both apps. ---
let dir = tree_dir(&group, &a, &b, TEMPLATE, "");
// Plan reports the blast radius (both descendant apps).
let plan = common::pic_as(&env)
.args(["plan", "--dir"])
.arg(dir.path())
.output()
.expect("plan --dir");
let plan_err = String::from_utf8_lossy(&plan.stderr);
assert!(
plan_err.contains("blast radius") && plan_err.contains("2 app(s)"),
"plan should report the 2-app blast radius:\n{plan_err}"
);
// --- Apply: the template fans out to both apps, one route each. ---
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
// The group script must drop before its group at teardown (RESTRICT).
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
let first = expansion_rows(&a, &b);
let paths: Vec<&str> = first.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(
paths,
vec![format!("/t/{a}").as_str(), format!("/t/{b}").as_str()],
"each app gets its own `{{app_slug}}`-resolved route"
);
// --- Idempotent re-apply: same rows, same ids (no churn — provenance). ---
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
let second = expansion_rows(&a, &b);
assert_eq!(
first, second,
"re-apply must not churn expansions (stable ids)"
);
// --- Remove the template + --prune: expansions are reaped. Rewrite the
// SAME repo's group manifest (a fresh dir would be a different project and
// conflict on the owned group, §7). ---
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"Tmpl Group\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n"
),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.args(["--prune", "--yes"])
.assert()
.success();
assert!(
expansion_rows(&a, &b).is_empty(),
"removing the template must reap its expansions"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn expansion_colliding_with_hand_declared_route_is_rejected() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("tplc-g");
let a = common::unique_slug("tplc-a");
let b = common::unique_slug("tplc-b");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _aa = AppGuard::new(&env.url, &env.token, &a);
let _ab = AppGuard::new(&env.url, &env.token, &b);
for slug in [&a, &b] {
common::pic_as(&env)
.args(["apps", "create", slug, "--group", &group])
.assert()
.success();
}
// App `a` hand-declares the EXACT route the template would expand to (the
// template path resolves to `/t/<a>` for app a). That collision is a hard
// error — neither side silently wins.
let collide = format!(
"\n[[routes]]\nscript = \"shared\"\nhost_kind = \"any\"\n\
path_kind = \"exact\"\npath = \"/t/{a}\"\n"
);
let dir = tree_dir(&group, &a, &b, TEMPLATE, &collide);
let out = common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.output()
.expect("apply --dir");
assert!(
!out.status.success(),
"an expansion colliding with a hand-declared route must be rejected"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("template") && (err.contains("hand") || err.contains("declare")),
"error should name the template/hand-declared collision:\n{err}"
);
}
fn group_script_id(env: &common::TestEnv, group: &str) -> String {
let ls = common::pic_as(env)
.args(["scripts", "ls", "--group", group])
.output()
.expect("scripts ls --group");
common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
.expect("group should have one script")
}