§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>
246 lines
8.3 KiB
Rust
246 lines
8.3 KiB
Rust
//! `pic plan [--file picloud.toml]` — diff the manifest's desired state
|
|
//! against the live app and print the per-resource changes. Read-only:
|
|
//! builds a bundle (manifest + script sources) and POSTs it to the
|
|
//! server's plan endpoint, which computes the diff.
|
|
|
|
use std::path::Path;
|
|
|
|
use anyhow::{Context, Result};
|
|
use serde::Serialize;
|
|
use serde_json::{json, Map, Value};
|
|
|
|
use crate::client::{ChangeDto, Client, NodeKind, PlanDto, TreePlanDto};
|
|
use crate::config;
|
|
use crate::manifest::Manifest;
|
|
use crate::output::{OutputMode, Table};
|
|
|
|
/// Linkstate key for a whole-tree bound plan (Phase 5) — a tree has no single
|
|
/// slug, so its token is stored under this reserved marker.
|
|
pub const TREE_TOKEN_KEY: &str = "<tree>";
|
|
|
|
pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
|
|
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
|
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
|
let bundle = build_bundle(&manifest, base_dir)?;
|
|
let kind = if manifest.is_group() {
|
|
NodeKind::Group
|
|
} else {
|
|
NodeKind::App
|
|
};
|
|
|
|
let plan = client
|
|
.plan_node(kind, manifest.slug(), &bundle, manifest.project.as_ref())
|
|
.await?;
|
|
// Record the bound-plan token so a subsequent `pic apply` can detect the
|
|
// node changing underneath the reviewed plan (best-effort — a read-only
|
|
// plan still succeeds if the project dir isn't writable).
|
|
if !plan.state_token.is_empty() {
|
|
if let Err(e) = crate::linkstate::write_plan(base_dir, manifest.slug(), &plan.state_token) {
|
|
eprintln!("warning: could not record plan state for `pic apply`: {e}");
|
|
}
|
|
}
|
|
render(&plan, mode);
|
|
Ok(())
|
|
}
|
|
|
|
/// `pic plan --dir <root>` (Phase 5): diff a whole project tree — every
|
|
/// `picloud.toml` under `root` — against the live group/app subtree.
|
|
pub async fn run_tree(dir: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let (bundle, _count, project) = crate::discover::build_tree(dir, env)?;
|
|
let plan = client.plan_tree(&bundle, project.as_ref()).await?;
|
|
if !plan.state_token.is_empty() {
|
|
if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) {
|
|
eprintln!("warning: could not record plan state for `pic apply`: {e}");
|
|
}
|
|
}
|
|
render_tree(&plan, mode);
|
|
Ok(())
|
|
}
|
|
|
|
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);
|
|
if let Some(o) = &n.ownership {
|
|
table.row([
|
|
node.clone(),
|
|
"ownership".to_string(),
|
|
o.action.clone(),
|
|
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),
|
|
("route", &n.routes),
|
|
("trigger", &n.triggers),
|
|
("secret", &n.secrets),
|
|
("var", &n.vars),
|
|
("extension_point", &n.extension_points),
|
|
("collection", &n.collections),
|
|
("suppression", &n.suppressions),
|
|
];
|
|
for (rk, changes) in groups {
|
|
for c in changes {
|
|
table.row([
|
|
node.clone(),
|
|
rk.to_string(),
|
|
c.op.clone(),
|
|
c.key.clone(),
|
|
c.detail.clone().unwrap_or_default(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
table.print(mode);
|
|
}
|
|
|
|
/// Assemble the wire bundle: scripts carry inlined source (read from
|
|
/// their `file`), routes pass through, triggers flatten into a tagged
|
|
/// array, secrets are names only.
|
|
pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
|
let mut scripts = Vec::with_capacity(manifest.scripts.len());
|
|
for s in &manifest.scripts {
|
|
let source = std::fs::read_to_string(base_dir.join(&s.file))
|
|
.with_context(|| format!("reading script source {}", s.file))?;
|
|
let mut obj = Map::new();
|
|
obj.insert("name".into(), json!(s.name));
|
|
obj.insert("source".into(), json!(source));
|
|
obj.insert("kind".into(), serde_json::to_value(s.kind)?);
|
|
if let Some(d) = &s.description {
|
|
obj.insert("description".into(), json!(d));
|
|
}
|
|
if let Some(t) = s.timeout_seconds {
|
|
obj.insert("timeout_seconds".into(), json!(t));
|
|
}
|
|
if let Some(m) = s.memory_limit_mb {
|
|
obj.insert("memory_limit_mb".into(), json!(m));
|
|
}
|
|
if let Some(sb) = &s.sandbox {
|
|
obj.insert("sandbox".into(), serde_json::to_value(sb)?);
|
|
}
|
|
obj.insert("enabled".into(), json!(s.enabled));
|
|
scripts.push(Value::Object(obj));
|
|
}
|
|
|
|
let routes = manifest
|
|
.routes
|
|
.iter()
|
|
.map(serde_json::to_value)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
let t = &manifest.triggers;
|
|
let mut triggers = Vec::new();
|
|
for s in &t.kv {
|
|
triggers.push(tagged("kv", s)?);
|
|
}
|
|
for s in &t.docs {
|
|
triggers.push(tagged("docs", s)?);
|
|
}
|
|
for s in &t.files {
|
|
triggers.push(tagged("files", s)?);
|
|
}
|
|
for s in &t.cron {
|
|
triggers.push(tagged("cron", s)?);
|
|
}
|
|
for s in &t.pubsub {
|
|
triggers.push(tagged("pubsub", s)?);
|
|
}
|
|
for s in &t.email {
|
|
triggers.push(tagged("email", s)?);
|
|
}
|
|
for s in &t.queue {
|
|
triggers.push(tagged("queue", s)?);
|
|
}
|
|
|
|
// Vars: key → JSON value. TOML values round-trip to JSON via serde so the
|
|
// wire shape matches the server's `serde_json::Value`.
|
|
let mut vars = Map::new();
|
|
for (k, v) in &manifest.vars {
|
|
vars.insert(
|
|
k.clone(),
|
|
serde_json::to_value(v).with_context(|| format!("encoding var `{k}`"))?,
|
|
);
|
|
}
|
|
|
|
Ok(json!({
|
|
"scripts": scripts,
|
|
"routes": routes,
|
|
"triggers": triggers,
|
|
"secrets": manifest.secrets.names,
|
|
"vars": vars,
|
|
"extension_points": manifest.extension_points(),
|
|
"collections": manifest
|
|
.collections()
|
|
.into_iter()
|
|
.map(|(name, kind)| json!({ "name": name, "kind": kind }))
|
|
.collect::<Vec<_>>(),
|
|
"suppress_triggers": manifest.suppress.triggers,
|
|
"suppress_routes": manifest.suppress.routes,
|
|
}))
|
|
}
|
|
|
|
/// Serialize a trigger spec and stamp its `kind` discriminator.
|
|
fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
|
|
let mut v = serde_json::to_value(spec)?;
|
|
if let Value::Object(map) = &mut v {
|
|
map.insert("kind".into(), Value::String(kind.to_string()));
|
|
}
|
|
Ok(v)
|
|
}
|
|
|
|
fn render(plan: &PlanDto, mode: OutputMode) {
|
|
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
|
if let Some(o) = &plan.ownership {
|
|
table.row([
|
|
"ownership".to_string(),
|
|
o.action.clone(),
|
|
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),
|
|
("route", &plan.routes),
|
|
("trigger", &plan.triggers),
|
|
("secret", &plan.secrets),
|
|
("var", &plan.vars),
|
|
("extension_point", &plan.extension_points),
|
|
("collection", &plan.collections),
|
|
("suppression", &plan.suppressions),
|
|
];
|
|
for (kind, changes) in groups {
|
|
for c in changes {
|
|
table.row([
|
|
kind.to_string(),
|
|
c.op.clone(),
|
|
c.key.clone(),
|
|
c.detail.clone().unwrap_or_default(),
|
|
]);
|
|
}
|
|
}
|
|
table.print(mode);
|
|
}
|