Adds a `[vars]` block to `picloud.toml` (key → TOML value), reconciled by `pic plan`/`pic apply` alongside scripts/routes/triggers/secrets. Unlike `[secrets]` (name-only), var values live inline since they aren't secret. The overlay (`picloud.<env>.toml`) may carry `[vars]`; overlay keys win per key (the env-specific value of an env-agnostic default). `build_bundle` serializes the vars to JSON for the wire (TOML round-trips via serde); `pic plan` shows a `var` row group and `pic apply` reports `vars +c ~u -d`. The `PlanDto`/`ApplyReportDto` gain the matching fields. `pic pull` carries an empty `[vars]` for now (export lands next). Adds a round-trip + overlay-precedence unit test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
147 lines
4.8 KiB
Rust
147 lines
4.8 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, PlanDto};
|
|
use crate::config;
|
|
use crate::manifest::Manifest;
|
|
use crate::output::{OutputMode, Table};
|
|
|
|
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 plan = client.plan(&manifest.app.slug, &bundle).await?;
|
|
// Record the bound-plan token so a subsequent `pic apply` can detect the
|
|
// app 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.app.slug, &plan.state_token)
|
|
{
|
|
eprintln!("warning: could not record plan state for `pic apply`: {e}");
|
|
}
|
|
}
|
|
render(&plan, mode);
|
|
Ok(())
|
|
}
|
|
|
|
/// 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,
|
|
}))
|
|
}
|
|
|
|
/// 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"]);
|
|
let groups: [(&str, &Vec<ChangeDto>); 5] = [
|
|
("script", &plan.scripts),
|
|
("route", &plan.routes),
|
|
("trigger", &plan.triggers),
|
|
("secret", &plan.secrets),
|
|
("var", &plan.vars),
|
|
];
|
|
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);
|
|
}
|