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>
336 lines
12 KiB
Rust
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);
|
|
}
|