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:
@@ -41,6 +41,7 @@ mod routes;
|
||||
mod scripts;
|
||||
mod secrets;
|
||||
mod staleness;
|
||||
mod templates;
|
||||
mod tree;
|
||||
mod tree_shape;
|
||||
mod triggers;
|
||||
|
||||
218
crates/picloud-cli/tests/templates.rs
Normal file
218
crates/picloud-cli/tests/templates.rs
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user