feat(apply): cross-repo blast-radius in the plan preview
§7 M3 (part 2): planning a change to a GROUP node now reports the descendant apps owned by OTHER projects that the change fans out to — the cross-repo signal an operator needs before touching shared config. - OwnershipPreview gains `blast_radius: Vec<ProjectImpact>`; `group_blast_radius` walks the group's subtree (recursive CTE) and groups descendant apps by their nearest-claimed-ancestor project, excluding the planning project. Ancestor resolution is memoized per group (one walk per distinct descendant group, not per app). - `pic plan` renders `blast_radius` rows (mode-agnostic). - the apply_ownership journey gains a plan-preview case pinning both the ownership annotation (claim / conflict) and the blast radius (two other projects' apps surfaced) end to end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -545,10 +545,24 @@ pub struct OwnershipPreview {
|
||||
pub action: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owner: Option<String>,
|
||||
/// §7 M3.2 cross-repo blast radius: for a GROUP node, the descendant apps
|
||||
/// owned by OTHER projects that a config change here fans out to, grouped by
|
||||
/// project. Empty for an app node or when nothing else is affected.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub blast_radius: Vec<ProjectImpact>,
|
||||
}
|
||||
|
||||
/// One entry of a group plan's blast radius: another project and how many of
|
||||
/// its apps inherit from the group being changed.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProjectImpact {
|
||||
pub project: String,
|
||||
pub apps: u32,
|
||||
}
|
||||
|
||||
/// Pure preview policy (read-only, slug-only — no registration): compares the
|
||||
/// node's current owner slug against this apply's declared project slug.
|
||||
/// node's current owner slug against this apply's declared project slug. The
|
||||
/// blast radius (a DB fan-out) is filled in by the caller for group nodes.
|
||||
fn preview_ownership(
|
||||
is_group: bool,
|
||||
current: Option<&str>,
|
||||
@@ -557,6 +571,7 @@ fn preview_ownership(
|
||||
let mk = |action: &str, owner: Option<&str>| OwnershipPreview {
|
||||
action: action.to_string(),
|
||||
owner: owner.map(str::to_string),
|
||||
blast_radius: Vec::new(),
|
||||
};
|
||||
match (current, declared) {
|
||||
// A group node claims when unclaimed + a project is declared.
|
||||
@@ -2142,11 +2157,68 @@ impl ApplyService {
|
||||
(false, current)
|
||||
}
|
||||
};
|
||||
Ok(Some(preview_ownership(
|
||||
is_group,
|
||||
current.as_deref(),
|
||||
declared,
|
||||
)))
|
||||
let mut preview = preview_ownership(is_group, current.as_deref(), declared);
|
||||
// §7 M3.2: for a group node, surface the cross-repo blast radius — the
|
||||
// descendant apps owned by OTHER projects a config change here reaches.
|
||||
if let ApplyOwner::Group(g) = owner {
|
||||
preview.blast_radius = self.group_blast_radius(g, declared).await?;
|
||||
}
|
||||
Ok(Some(preview))
|
||||
}
|
||||
|
||||
/// §7 M3.2: descendant apps of `gid` grouped by their nearest-claimed-
|
||||
/// ancestor project, EXCLUDING `exclude` (the planning project) — the apps
|
||||
/// a change to `gid`'s inherited config fans out to across other repos.
|
||||
/// Ancestor resolution is memoized per group, so the cost is one walk per
|
||||
/// distinct descendant group, not per app.
|
||||
async fn group_blast_radius(
|
||||
&self,
|
||||
gid: GroupId,
|
||||
exclude: Option<&str>,
|
||||
) -> Result<Vec<ProjectImpact>, ApplyError> {
|
||||
// All apps in gid's subtree (gid + descendant groups).
|
||||
let rows: Vec<(uuid::Uuid, uuid::Uuid)> = sqlx::query_as(
|
||||
"WITH RECURSIVE subtree AS (
|
||||
SELECT id FROM groups WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT g.id FROM groups g JOIN subtree s ON g.parent_id = s.id
|
||||
)
|
||||
SELECT a.id, a.group_id FROM apps a WHERE a.group_id IN (SELECT id FROM subtree)",
|
||||
)
|
||||
.bind(gid.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
|
||||
let mut owner_by_group: HashMap<GroupId, Option<String>> = HashMap::new();
|
||||
let mut counts: std::collections::BTreeMap<String, u32> = std::collections::BTreeMap::new();
|
||||
for (_app, group_id) in rows {
|
||||
let ag = GroupId::from(group_id);
|
||||
let owner = if let Some(o) = owner_by_group.get(&ag) {
|
||||
o.clone()
|
||||
} else {
|
||||
let chain = self
|
||||
.groups
|
||||
.ancestors(ag)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
let o = self
|
||||
.nearest_claimed_from_chain(&chain)
|
||||
.await?
|
||||
.map(|(_, s)| s);
|
||||
owner_by_group.insert(ag, o.clone());
|
||||
o
|
||||
};
|
||||
if let Some(slug) = owner {
|
||||
if Some(slug.as_str()) != exclude {
|
||||
*counts.entry(slug).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(counts
|
||||
.into_iter()
|
||||
.map(|(project, apps)| ProjectImpact { project, apps })
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn resolve_app(&self, ident: &str) -> Result<picloud_shared::App, ApplyError> {
|
||||
|
||||
@@ -1548,12 +1548,21 @@ pub struct ChangeDto {
|
||||
}
|
||||
|
||||
/// §7 M3 plan preview: `action` ∈ claim/owned/conflict/unclaimed; `owner` is
|
||||
/// the current owner's slug (for owned/conflict).
|
||||
/// the current owner's slug (for owned/conflict); `blast_radius` (M3.2) lists
|
||||
/// other projects' apps a group change fans out to.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OwnershipPreviewDto {
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub owner: Option<String>,
|
||||
#[serde(default)]
|
||||
pub blast_radius: Vec<ProjectImpactDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ProjectImpactDto {
|
||||
pub project: String,
|
||||
pub apps: u32,
|
||||
}
|
||||
|
||||
/// Response of `POST .../tree/plan` (Phase 5): a per-node diff + one combined
|
||||
|
||||
@@ -74,6 +74,15 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
o.owner.clone().unwrap_or_default(),
|
||||
String::new(),
|
||||
]);
|
||||
for b in &o.blast_radius {
|
||||
table.row([
|
||||
node.clone(),
|
||||
"blast_radius".to_string(),
|
||||
b.project.clone(),
|
||||
format!("{} app(s)", b.apps),
|
||||
String::new(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
let groups: [(&str, &Vec<ChangeDto>); 8] = [
|
||||
("script", &n.scripts),
|
||||
@@ -203,6 +212,14 @@ fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
o.owner.clone().unwrap_or_default(),
|
||||
String::new(),
|
||||
]);
|
||||
for b in &o.blast_radius {
|
||||
table.row([
|
||||
"blast_radius".to_string(),
|
||||
b.project.clone(),
|
||||
format!("{} app(s)", b.apps),
|
||||
String::new(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
let groups: [(&str, &Vec<ChangeDto>); 8] = [
|
||||
("script", &plan.scripts),
|
||||
|
||||
@@ -291,3 +291,107 @@ fn attach_point_ceiling_bounds_the_subtree() {
|
||||
"a sibling subtree is outside the attach point"
|
||||
);
|
||||
}
|
||||
|
||||
/// §7 M3 — `pic plan` previews the ownership outcome (claim / conflict) and the
|
||||
/// cross-repo blast radius (descendant apps owned by other projects) BEFORE any
|
||||
/// apply.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn plan_previews_ownership_and_blast_radius() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
// acme (root) → team_a, team_b; an app under each, each team claimed by a
|
||||
// DIFFERENT project.
|
||||
let acme = common::unique_slug("br-acme");
|
||||
let team_a = common::unique_slug("br-a");
|
||||
let team_b = common::unique_slug("br-b");
|
||||
let app_a = common::unique_slug("br-app-a");
|
||||
let app_b = common::unique_slug("br-app-b");
|
||||
let plat = common::unique_slug("plat");
|
||||
let teamb = common::unique_slug("teamb");
|
||||
let platform = common::unique_slug("platform");
|
||||
let _g0 = GroupGuard::new(&env.url, &env.token, &acme);
|
||||
let _g1 = GroupGuard::new(&env.url, &env.token, &team_a);
|
||||
let _g2 = GroupGuard::new(&env.url, &env.token, &team_b);
|
||||
let _a1 = AppGuard::new(&env.url, &env.token, &app_a);
|
||||
let _a2 = AppGuard::new(&env.url, &env.token, &app_b);
|
||||
for (slug, parent) in [
|
||||
(&acme, None),
|
||||
(&team_a, Some(&acme)),
|
||||
(&team_b, Some(&acme)),
|
||||
] {
|
||||
let mut c = common::pic_as(&env);
|
||||
c.args(["groups", "create", slug]);
|
||||
if let Some(p) = parent {
|
||||
c.args(["--parent", p]);
|
||||
}
|
||||
c.assert().success();
|
||||
}
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app_a, "--group", &team_a])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app_b, "--group", &team_b])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Claim team_a by `plat`, team_b by `teamb` (empty group applies).
|
||||
let dir = manifest_dir();
|
||||
let claim = |project: &str, group: &str| {
|
||||
let m = format!(
|
||||
"[project]\nslug = \"{project}\"\n\n[group]\nslug = \"{group}\"\nname = \"G\"\n"
|
||||
);
|
||||
fs::write(dir.path().join("picloud.toml"), m).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(dir.path().join("picloud.toml"))
|
||||
.assert()
|
||||
.success();
|
||||
};
|
||||
claim(&plat, &team_a);
|
||||
claim(&teamb, &team_b);
|
||||
|
||||
// Plan acme with project `platform`: acme is unclaimed → action `claim`; the
|
||||
// blast radius lists the OTHER projects' descendant apps (plat + teamb).
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[project]\nslug = \"{platform}\"\n\n[group]\nslug = \"{acme}\"\nname = \"Acme\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(dir.path().join("picloud.toml"))
|
||||
.output()
|
||||
.expect("plan acme");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
stdout.contains("claim"),
|
||||
"acme is unclaimed → the preview must show `claim`:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("blast_radius") && stdout.contains(&plat) && stdout.contains(&teamb),
|
||||
"the blast radius must list the other projects' apps (plat + teamb):\n{stdout}"
|
||||
);
|
||||
|
||||
// Plan team_a with `platform`: it is owned by `plat` → action `conflict`.
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!("[project]\nslug = \"{platform}\"\n\n[group]\nslug = \"{team_a}\"\nname = \"A\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(dir.path().join("picloud.toml"))
|
||||
.output()
|
||||
.expect("plan team_a");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
stdout.contains("conflict") && stdout.contains(&plat),
|
||||
"planning a foreign-owned group must preview a conflict naming the owner:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user