feat(hierarchies): cross-repo template expansion (M7)
Route/trigger templates declared at a group now fan out to EVERY descendant
app in the DB subtree, not just the app nodes present in the current
`pic apply --dir`. A template change at group N reaches subtree(N) across
repos, and a removed/disabled template reaps its expansions on those
descendants too.
- group_repo: recursive `descendant_app_ids` (+ `_tx`) — inverse of the
ancestor chain CTE, app_id-ordered, depth-bounded.
- apply_service: Phase B2 expands templates into descendants of every in-tree
group not already handled in Phase B. All descendant locks are taken up
front in the single sorted batch (their union is invariant under reparenting
in-tree groups), so there is no out-of-order mid-tx locking / deadlock.
Per-recipient write caps (AppWriteRoute / AppManageTriggers /
AppSecretsRead-for-email) are required AUTHORITATIVELY IN-TX against the
post-reparent, already-locked app — checked set == written set, no TOCTOU.
expand_*_templates_tx are now self-contained (load hand-declared identities
from the tx), so collisions are caught on descendants too. Blast radius now
counts the true DB subtree. Descendant expansion errors are tagged with the
app slug (e.g. an {env} template on a NULL-environment descendant names it).
- apply_api: the pre-tx descendant authz pass is removed in favour of the
in-tx gate (sound via the hierarchy-aware effective_app_role).
- templates journey: out-of-tree descendant at depth 1 AND 2 receives the
expansion and is reaped on template removal.
- doc §4.5: strike the in-apply-only scope limitation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -231,6 +231,134 @@ fn route_template_var_resolves_in_same_apply() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Template-expanded routes (`from_template IS NOT NULL`) whose path is one of
|
||||
/// `paths`, as `path` strings — for asserting which descendant apps received an
|
||||
/// expansion regardless of which apply declared them.
|
||||
fn expansion_paths(paths: &[String]) -> Vec<String> {
|
||||
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
||||
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
|
||||
let rows = pg
|
||||
.query(
|
||||
"SELECT path FROM routes \
|
||||
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
|
||||
&[&paths],
|
||||
)
|
||||
.expect("query expansion paths");
|
||||
rows.iter().map(|r| r.get(0)).collect()
|
||||
}
|
||||
|
||||
/// M7 (§4.5): a route template fans out to EVERY descendant app in the DB
|
||||
/// subtree, not only the app nodes present in the apply. An app created under
|
||||
/// the group out-of-band (absent from the manifest) still receives the
|
||||
/// expansion on the next apply, and removing the template reaps it there too.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn route_template_reaches_out_of_tree_descendant() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("xrepo-g");
|
||||
let sg = common::unique_slug("xrepo-sg"); // subgroup (depth-2)
|
||||
let a = common::unique_slug("xrepo-a");
|
||||
let c = common::unique_slug("xrepo-c"); // out-of-tree descendant (depth-1)
|
||||
let d = common::unique_slug("xrepo-d"); // out-of-tree descendant (depth-2)
|
||||
|
||||
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);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &a, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Apply a tree of just (group ← app a) with an `/x/{app_slug}` template.
|
||||
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""#).unwrap();
|
||||
let group_toml = |with_template: bool| {
|
||||
let tmpl = if with_template {
|
||||
"\n[[route_templates]]\nname = \"x\"\nscript = \"shared\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/x/{app_slug}\"\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"G\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n{tmpl}"
|
||||
)
|
||||
};
|
||||
fs::write(dir.path().join("picloud.toml"), group_toml(true)).unwrap();
|
||||
fs::create_dir_all(dir.path().join(&a)).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(&a).join("picloud.toml"),
|
||||
format!("[app]\nslug = \"{a}\"\nname = \"App\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
|
||||
assert_eq!(
|
||||
expansion_paths(&[format!("/x/{a}")]).len(),
|
||||
1,
|
||||
"in-tree app a gets its expansion"
|
||||
);
|
||||
|
||||
// Create app c (direct child) and a nested subgroup ← app d (depth-2),
|
||||
// all OUTSIDE the manifest tree, to exercise the recursive descendant CTE.
|
||||
let _ac = AppGuard::new(&env.url, &env.token, &c);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &c, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _sg = GroupGuard::new(&env.url, &env.token, &sg);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &sg, "--parent", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _ad = AppGuard::new(&env.url, &env.token, &d);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &d, "--group", &sg])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Re-apply the same tree (still only group + a). M7: c (depth-1) AND d
|
||||
// (depth-2, under the subgroup) both receive the expansion. `--force`
|
||||
// waives the bound token (the out-of-band creates bumped structure version).
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.arg("--force")
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
expansion_paths(&[format!("/x/{c}"), format!("/x/{d}")]),
|
||||
vec![format!("/x/{c}"), format!("/x/{d}")],
|
||||
"out-of-tree descendants at depth 1 AND 2 receive expansions"
|
||||
);
|
||||
|
||||
// Remove the template + prune: every descendant's expansion is reaped,
|
||||
// in-tree or not, at any depth.
|
||||
fs::write(dir.path().join("picloud.toml"), group_toml(false)).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.args(["--prune", "--yes", "--force"])
|
||||
.assert()
|
||||
.success();
|
||||
assert!(
|
||||
expansion_paths(&[format!("/x/{a}"), format!("/x/{c}"), format!("/x/{d}")]).is_empty(),
|
||||
"removing the template reaps expansions on ALL descendants, any depth"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn expansion_colliding_with_hand_declared_route_is_rejected() {
|
||||
|
||||
Reference in New Issue
Block a user