feat(cli): declare app vars in the manifest + plan/apply them

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>
This commit is contained in:
MechaCat02
2026-06-25 07:40:36 +02:00
parent e517f6dc8c
commit 34dcae98a2
6 changed files with 72 additions and 8 deletions

View File

@@ -14,6 +14,7 @@
//! exposed declaratively). `email` triggers carry an `inbound_secret_ref`
//! (a secret name) resolved server-side at apply.
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
@@ -38,6 +39,11 @@ pub struct Manifest {
pub triggers: ManifestTriggers,
#[serde(default, skip_serializing_if = "ManifestSecrets::is_empty")]
pub secrets: ManifestSecrets,
/// `[vars]` — app config key → value, reconciled at app scope `*`. Values
/// are non-secret and live inline (unlike `[secrets]`, which is name-only).
/// The overlay merges per-env, last-write-wins by key.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub vars: BTreeMap<String, toml::Value>,
}
impl Manifest {
@@ -61,9 +67,9 @@ impl Manifest {
/// Load the base manifest, then (if `env` is set) merge the sparse
/// `picloud.<env>.toml` overlay on top — the §4.1 base+overlay model
/// where "an environment is an app". The overlay carries per-env slug +
/// secrets; scripts/routes/triggers stay in the shared base. (Rich
/// per-key `vars` resolution is Phase 3.)
/// where "an environment is an app". The overlay carries per-env slug,
/// secrets, and vars (overlay vars win per key); scripts/routes/triggers
/// stay in the shared base.
pub fn load_with_env(base_path: &Path, env: Option<&str>) -> Result<Self> {
let mut base = Self::load(base_path)?;
if let Some(env) = env {
@@ -95,6 +101,11 @@ impl Manifest {
self.secrets.names.push(n);
}
}
// Overlay vars override base vars per key (the env-specific value of
// an env-agnostic default); keys only in the base are kept.
for (k, v) in overlay.vars {
self.vars.insert(k, v);
}
}
}
@@ -113,10 +124,10 @@ fn overlay_path(base_path: &Path, env: &str) -> std::path::PathBuf {
/// A sparse per-environment overlay (`picloud.<env>.toml`). Only the fields
/// that vary per environment today — slug/name and secret names.
///
/// `deny_unknown_fields`: an overlay can carry *only* `[app]` and `[secrets]`.
/// Scripts/routes/triggers belong in the shared base manifest, so a
/// `[[scripts]]`/`[[routes]]`/`[[triggers]]` table (or a typo'd key) in an
/// overlay is a mistake — error loudly rather than silently dropping it.
/// `deny_unknown_fields`: an overlay can carry *only* `[app]`, `[secrets]`,
/// and `[vars]`. Scripts/routes/triggers belong in the shared base manifest,
/// so a `[[scripts]]`/`[[routes]]`/`[[triggers]]` table (or a typo'd key) in
/// an overlay is a mistake — error loudly rather than silently dropping it.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestOverlay {
@@ -124,6 +135,8 @@ pub struct ManifestOverlay {
pub app: OverlayApp,
#[serde(default)]
pub secrets: ManifestSecrets,
#[serde(default)]
pub vars: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Clone, Default, Deserialize)]
@@ -409,6 +422,10 @@ mod tests {
secrets: ManifestSecrets {
names: vec!["STRIPE_KEY".into()],
},
vars: BTreeMap::from([
("region".to_string(), toml::Value::String("eu".into())),
("max-retries".to_string(), toml::Value::Integer(3)),
]),
}
}
@@ -512,12 +529,30 @@ mod tests {
routes: vec![],
triggers: ManifestTriggers::default(),
secrets: ManifestSecrets::default(),
vars: BTreeMap::new(),
};
let text = m.to_toml().unwrap();
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
assert!(!text.contains("triggers"), "got:\n{text}");
assert!(!text.contains("secrets"), "got:\n{text}");
assert!(!text.contains("vars"), "got:\n{text}");
// Still round-trips.
assert_eq!(m, Manifest::parse(&text).unwrap());
}
#[test]
fn overlay_vars_override_base_per_key() {
let mut base = sample();
base.vars
.insert("region".into(), toml::Value::String("eu".into()));
base.vars
.insert("tier".into(), toml::Value::String("base".into()));
let overlay: ManifestOverlay =
toml::from_str("[vars]\nregion = \"us\"\nextra = true\n").unwrap();
base.apply_overlay(overlay);
// overlay wins for `region`, base-only `tier` survives, overlay adds `extra`.
assert_eq!(base.vars["region"], toml::Value::String("us".into()));
assert_eq!(base.vars["tier"], toml::Value::String("base".into()));
assert_eq!(base.vars["extra"], toml::Value::Boolean(true));
}
}