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:
@@ -1221,6 +1221,8 @@ pub struct PlanDto {
|
||||
pub triggers: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub vars: Vec<ChangeDto>,
|
||||
/// Fingerprint of the live state this plan was computed against; carried
|
||||
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
||||
#[serde(default)]
|
||||
@@ -1255,6 +1257,12 @@ pub struct ApplyReportDto {
|
||||
#[serde(default)]
|
||||
pub triggers_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub vars_created: u32,
|
||||
#[serde(default)]
|
||||
pub vars_updated: u32,
|
||||
#[serde(default)]
|
||||
pub vars_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,13 @@ pub async fn run(
|
||||
.field(
|
||||
"triggers",
|
||||
format!("+{} -{}", report.triggers_created, report.triggers_deleted),
|
||||
)
|
||||
.field(
|
||||
"vars",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.vars_created, report.vars_updated, report.vars_deleted
|
||||
),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
|
||||
@@ -137,6 +137,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
}],
|
||||
triggers: crate::manifest::ManifestTriggers::default(),
|
||||
secrets: crate::manifest::ManifestSecrets::default(),
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,11 +95,22 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -114,11 +125,12 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
|
||||
|
||||
fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 4] = [
|
||||
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 {
|
||||
|
||||
@@ -204,6 +204,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
secrets: ManifestSecrets {
|
||||
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
||||
},
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
};
|
||||
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user