Files
PiCloud/crates/picloud-cli/src/cmds/plan.rs
MechaCat02 2fc9476f9e feat(interceptors): §9.4 service interceptors — thin KV allow/deny slice
Smallest honest vertical slice of §9.4: a `[[interceptors]]` block (app OR
group) binds a script to run BEFORE `kv::set`/`delete`; it reads the operation
context (`ctx.request.body`: service, action, collection, key, value, caller
ids) and returns `#{ allowed, reason }` — `allowed == false` denies the op (the
write never runs, the caller gets a runtime error).

Reuses two existing mechanisms rather than inventing new ones:
- Registration mirrors extension points (§5.5): a marker table
  `0073_interceptors.sql` (owner-polymorphic app_id/group_id XOR, keyed
  (service, op) → script), `interceptor_repo` (insert/delete/list + the
  nearest-owner-wins `resolve_before` chain walk), reconciled through the
  declarative apply exactly like `vars` (create/update/delete, prunable).
- Execution reuses the `invoke()` re-entry path: the new `InterceptorService`
  (shared trait + Postgres-backed impl) only RESOLVES the script name (keeping
  executor-core Postgres-free); the executor's `sdk::interceptor::run_before`
  resolves that name and runs it via `run_resolved_blocking` (extracted from
  `invoke_blocking` — shared depth bound + AST cache). An un-hooked write pays
  one indexed `Ok(None)` resolve; no interceptor ⇒ zero overhead.

Nearest-owner-wins so an app overrides a group's interceptor, and a group
interceptor is inherited by every descendant app — the chain walk is the
isolation boundary (a sibling subtree never matches). `validate_bundle_for`
restricts the MVP to `service = "kv"`, `op ∈ {set, delete}`, one marker per
(service, op).

Deferred (documented in §9.4): the `data` transform return, services other
than kv, `after_*` hooks, chaining + circular-dependency guard, the timeout
policy, and a `pic interceptors ls` read surface (needs a server route).

Pinned by `tests/interceptors.rs` (deny blocks the write; allow passes;
group→app inheritance), schema snapshot re-blessed. 154/154 journeys pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:44:50 +02:00

336 lines
12 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
};
// §3 M3: preview the gate against the GOVERNING project (own block, else the
// nearest ancestor's), symmetric with `apply::run` — so `pic plan --file` on
// a leaf still surfaces `approvals_required` for the gate the apply enforces.
let governing = manifest
.project
.clone()
.or_else(|| crate::cmds::apply::find_governing_project(manifest_path));
let plan = client
.plan_node(kind, manifest.slug(), &bundle, governing.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(s) = &n.structure {
table.row([
node.clone(),
"structure".to_string(),
s.status.clone(),
format!(
"server={}",
s.server_parent.clone().unwrap_or_else(|| "<root>".into())
),
format!(
"manifest={}",
s.manifest_parent.clone().unwrap_or_else(|| "<root>".into())
),
]);
}
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>); 10] = [
("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),
("workflow", &n.workflows),
("interceptor", &n.interceptors),
];
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(),
]);
}
}
for w in &n.warnings {
table.row([
node.clone(),
"warning".to_string(),
String::new(),
w.clone(),
String::new(),
]);
}
}
table.print(mode);
render_approvals(&plan.approvals_required, mode);
}
/// §3 M3: surface the confirm-required environments so CI sees the gate at plan
/// time, before an `apply --approve <env>` is needed.
fn render_approvals(approvals_required: &[String], mode: OutputMode) {
if mode != OutputMode::Json && !approvals_required.is_empty() {
eprintln!("\nenvironments requiring explicit approval (apply with --approve <env>):");
for e in approvals_required {
eprintln!(" - {e}");
}
}
}
/// Surface the desired-state warnings `apply` would emit, so `pic plan` is a
/// faithful preview for CI (disabled binding, unreachable endpoint, dangling
/// suppress). JSON callers read them off the `warnings` field.
fn render_warnings(warnings: &[String], mode: OutputMode) {
if mode != OutputMode::Json && !warnings.is_empty() {
eprintln!("\nwarnings:");
for w in warnings {
eprintln!(" - {w}");
}
}
}
/// 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,
"workflows": manifest
.workflows
.iter()
.map(workflow_to_wire)
.collect::<Result<Vec<_>>>()?,
// §9.4: expand each `[[interceptors]]` entry's `ops` into one wire
// marker per (service, op).
"interceptors": manifest
.interceptors
.iter()
.flat_map(|i| {
i.ops.iter().map(move |op| {
json!({ "service": i.service, "op": op, "script": i.script })
})
})
.collect::<Vec<_>>(),
}))
}
/// Convert a `[[workflows]]` manifest block to the server `BundleWorkflow`
/// shape `{ name, enabled, definition: { steps } }`. The manifest authors steps
/// at the workflow's top level; the wire nests them under `definition`.
fn workflow_to_wire(w: &crate::manifest::ManifestWorkflow) -> Result<Value> {
let steps = serde_json::to_value(&w.steps)
.with_context(|| format!("encoding workflow `{}`", w.name))?;
Ok(json!({
"name": w.name,
"enabled": w.enabled,
"definition": { "steps": steps },
}))
}
/// 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>); 10] = [
("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),
("workflow", &plan.workflows),
("interceptor", &plan.interceptors),
];
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);
render_approvals(&plan.approvals_required, mode);
render_warnings(&plan.warnings, mode);
}