Files
PiCloud/crates/picloud-cli/src/manifest.rs
MechaCat02 345f265062 fix(cli): env-aware config, pull clobber guard, strict overlays, 409 hint
Four review fixes on the `pic` surface:

- `config --effective` gains `--env`: it now resolves through the same
  overlay as `plan`/`apply`, so the masked report targets the app those
  commands act on instead of silently reading the base slug's secrets.
- `pull` gains `--force` and refuses to overwrite an existing
  `picloud.toml` (and colliding `scripts/*.rhai`) without it — fail-fast
  before any network call or write, mirroring `init`.
- Overlays (`ManifestOverlay`/`OverlayApp`) get `deny_unknown_fields`: a
  `[[scripts]]`/`[[routes]]`/`[[triggers]]` table or a typo'd key in an
  overlay now errors loudly instead of being silently dropped. +unit test.
- `apply` 409 (StateMoved — the only 409 the endpoint emits) surfaces an
  actionable hint (re-run `pic plan`, or `--force`) instead of a bare
  `HTTP 409`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:06:45 +02:00

524 lines
19 KiB
Rust

//! Declarative project manifest (`picloud.toml`).
//!
//! One manifest describes the desired state of a **single app** — its
//! scripts, routes, triggers, and the *names* of the secrets it expects
//! (values are pushed out-of-band via `pic secret set`, never committed).
//!
//! This is the foundation of the declarative project tool (`pic pull` /
//! `pic plan` / `pic apply`). The types deliberately reuse `picloud_shared`
//! enums (`HostKind`, `PathKind`, `DispatchMode`, `ScriptKind`,
//! `ScriptSandbox`, the event-op enums) so the manifest's wire shape stays
//! identical to the admin API — the CLI never depends on `manager-core`.
//!
//! All eight trigger kinds are representable except `dead_letter` (not
//! exposed declaratively). `email` triggers carry an `inbound_secret_ref`
//! (a secret name) resolved server-side at apply.
use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
use picloud_shared::{
DispatchMode, DocsEventOp, FilesEventOp, HostKind, KvEventOp, PathKind, ScriptKind,
ScriptSandbox,
};
use serde::{Deserialize, Serialize};
/// Conventional manifest filename at a project root.
pub const MANIFEST_FILE: &str = "picloud.toml";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Manifest {
pub app: ManifestApp,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scripts: Vec<ManifestScript>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub routes: Vec<ManifestRoute>,
#[serde(default, skip_serializing_if = "ManifestTriggers::is_empty")]
pub triggers: ManifestTriggers,
#[serde(default, skip_serializing_if = "ManifestSecrets::is_empty")]
pub secrets: ManifestSecrets,
}
impl Manifest {
/// Parse a manifest from TOML text.
pub fn parse(text: &str) -> Result<Self> {
toml::from_str(text).context("parsing manifest TOML")
}
/// Load and parse the manifest at `path`.
pub fn load(path: &Path) -> Result<Self> {
let body =
fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
Self::parse(&body)
}
/// Render to TOML text. Tables are emitted after scalars (the struct
/// field order already satisfies TOML's "values before tables" rule).
pub fn to_toml(&self) -> Result<String> {
toml::to_string_pretty(self).context("serializing manifest TOML")
}
/// 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.)
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 {
let path = overlay_path(base_path, env);
let body = fs::read_to_string(&path).with_context(|| {
format!(
"reading overlay {} for env `{env}` (expected next to the base manifest)",
path.display()
)
})?;
let overlay: ManifestOverlay = toml::from_str(&body)
.with_context(|| format!("parsing overlay {}", path.display()))?;
base.apply_overlay(overlay);
}
Ok(base)
}
/// Merge a sparse overlay onto this manifest: overlay `app.slug`/`name`
/// replace the base's; overlay secret names union into the base set.
fn apply_overlay(&mut self, overlay: ManifestOverlay) {
if let Some(slug) = overlay.app.slug {
self.app.slug = slug;
}
if let Some(name) = overlay.app.name {
self.app.name = name;
}
for n in overlay.secrets.names {
if !self.secrets.names.contains(&n) {
self.secrets.names.push(n);
}
}
}
}
/// `picloud.toml` → `picloud.<env>.toml`, alongside the base (works for a
/// custom `--file` too: `custom.toml` → `custom.<env>.toml`).
fn overlay_path(base_path: &Path, env: &str) -> std::path::PathBuf {
let parent = base_path.parent().unwrap_or_else(|| Path::new("."));
let name = base_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| MANIFEST_FILE.to_string());
let stem = name.strip_suffix(".toml").unwrap_or(&name);
parent.join(format!("{stem}.{env}.toml"))
}
/// 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.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestOverlay {
#[serde(default)]
pub app: OverlayApp,
#[serde(default)]
pub secrets: ManifestSecrets,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OverlayApp {
#[serde(default)]
pub slug: Option<String>,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestApp {
pub slug: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestScript {
pub name: String,
/// Path to the `.rhai` source, relative to the manifest's directory.
pub file: String,
#[serde(default, skip_serializing_if = "is_endpoint")]
pub kind: ScriptKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_limit_mb: Option<i32>,
/// Per-script sandbox overrides; omitted entirely when no knob is set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<ScriptSandbox>,
/// Three-state lifecycle (§4.3): `false` deploys the script inert (not
/// invocable). Omitted ⇒ active; only serialized when disabled.
#[serde(
default = "picloud_shared::default_true",
skip_serializing_if = "is_true"
)]
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestRoute {
/// Name of the script this route binds to.
pub script: String,
/// HTTP method; omit for ANY.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
pub host_kind: HostKind,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub host: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_param_name: Option<String>,
pub path_kind: PathKind,
pub path: String,
#[serde(default, skip_serializing_if = "is_sync")]
pub dispatch_mode: DispatchMode,
/// Three-state lifecycle (§4.3): `false` deploys the route inert (404).
/// Omitted ⇒ active; only serialized when disabled.
#[serde(
default = "picloud_shared::default_true",
skip_serializing_if = "is_true"
)]
pub enabled: bool,
}
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ManifestTriggers {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub kv: Vec<KvTriggerSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub docs: Vec<DocsTriggerSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files: Vec<FilesTriggerSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cron: Vec<CronTriggerSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pubsub: Vec<PubsubTriggerSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub email: Vec<EmailTriggerSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub queue: Vec<QueueTriggerSpec>,
}
impl ManifestTriggers {
#[must_use]
pub fn is_empty(&self) -> bool {
self.kv.is_empty()
&& self.docs.is_empty()
&& self.files.is_empty()
&& self.cron.is_empty()
&& self.pubsub.is_empty()
&& self.email.is_empty()
&& self.queue.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KvTriggerSpec {
pub script: String,
pub collection_glob: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ops: Vec<KvEventOp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch_mode: Option<DispatchMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_max_attempts: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DocsTriggerSpec {
pub script: String,
pub collection_glob: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ops: Vec<DocsEventOp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch_mode: Option<DispatchMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_max_attempts: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FilesTriggerSpec {
pub script: String,
pub collection_glob: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ops: Vec<FilesEventOp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch_mode: Option<DispatchMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_max_attempts: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CronTriggerSpec {
pub script: String,
/// 6-field cron expression (with seconds).
pub schedule: String,
#[serde(default = "default_timezone")]
pub timezone: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch_mode: Option<DispatchMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_max_attempts: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PubsubTriggerSpec {
pub script: String,
pub topic_pattern: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch_mode: Option<DispatchMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_max_attempts: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EmailTriggerSpec {
pub script: String,
/// Name of the secret (set via `pic secret set`) holding the inbound
/// HMAC value — resolved + sealed server-side at apply. Never the value.
pub inbound_secret_ref: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch_mode: Option<DispatchMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_max_attempts: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QueueTriggerSpec {
pub script: String,
pub queue_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility_timeout_secs: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch_mode: Option<DispatchMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_max_attempts: Option<u32>,
}
/// `[secrets] names = [...]` — declares which secrets the app expects.
/// Values are never in the manifest; `pic secret set` pushes them.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ManifestSecrets {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub names: Vec<String>,
}
impl ManifestSecrets {
#[must_use]
pub fn is_empty(&self) -> bool {
self.names.is_empty()
}
}
// ---- serde skip/default helpers ----
fn is_endpoint(kind: &ScriptKind) -> bool {
*kind == ScriptKind::Endpoint
}
fn is_sync(mode: &DispatchMode) -> bool {
*mode == DispatchMode::Sync
}
/// Skip-serialize helper: `enabled` defaults true, so only emit it when false.
fn is_true(b: &bool) -> bool {
*b
}
fn default_timezone() -> String {
"UTC".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Manifest {
Manifest {
app: ManifestApp {
slug: "blog".into(),
name: "My Blog".into(),
description: Some("demo".into()),
},
scripts: vec![
ManifestScript {
name: "create-post".into(),
file: "scripts/create-post.rhai".into(),
kind: ScriptKind::Endpoint,
description: None,
timeout_seconds: Some(10),
memory_limit_mb: Some(256),
sandbox: None,
enabled: true,
},
ManifestScript {
name: "lib".into(),
file: "scripts/lib.rhai".into(),
kind: ScriptKind::Module,
description: None,
timeout_seconds: None,
memory_limit_mb: None,
sandbox: Some(ScriptSandbox {
max_operations: Some(5_000_000),
..ScriptSandbox::empty()
}),
enabled: false,
},
],
routes: vec![ManifestRoute {
script: "create-post".into(),
method: Some("POST".into()),
host_kind: HostKind::Any,
host: String::new(),
host_param_name: None,
path_kind: PathKind::Exact,
path: "/posts".into(),
dispatch_mode: DispatchMode::Sync,
enabled: true,
}],
triggers: ManifestTriggers {
cron: vec![CronTriggerSpec {
script: "create-post".into(),
schedule: "0 6 * * * *".into(),
timezone: "UTC".into(),
dispatch_mode: None,
retry_max_attempts: None,
}],
kv: vec![KvTriggerSpec {
script: "create-post".into(),
collection_glob: "users".into(),
ops: vec![KvEventOp::Insert, KvEventOp::Update],
dispatch_mode: Some(DispatchMode::Async),
retry_max_attempts: Some(5),
}],
..ManifestTriggers::default()
},
secrets: ManifestSecrets {
names: vec!["STRIPE_KEY".into()],
},
}
}
#[test]
fn round_trips_through_toml() {
let m = sample();
let text = m.to_toml().expect("serialize");
let back = Manifest::parse(&text).expect("parse");
assert_eq!(m, back, "manifest must survive a TOML round-trip");
}
#[test]
fn omits_defaulted_fields() {
let text = sample().to_toml().unwrap();
// Endpoint kind + sync dispatch are defaults → not emitted.
assert!(
!text.contains("kind = \"endpoint\""),
"default kind should be omitted:\n{text}"
);
assert!(
!text.contains("dispatch_mode = \"sync\""),
"default route dispatch should be omitted:\n{text}"
);
// Module kind IS non-default → emitted.
assert!(text.contains("kind = \"module\""), "got:\n{text}");
// `enabled` defaults true → omitted for the active script/route, but
// the disabled `lib` script emits `enabled = false`.
assert!(
text.contains("enabled = false"),
"a disabled entity must emit enabled:\n{text}"
);
assert!(
!text.contains("enabled = true"),
"active entities must omit the default:\n{text}"
);
}
#[test]
fn overlay_merges_slug_and_unions_secrets() {
let mut m = sample(); // slug "blog", secrets ["STRIPE_KEY"]
let overlay: ManifestOverlay = toml::from_str(
"[app]\nslug = \"blog-staging\"\n\n[secrets]\nnames = [\"STRIPE_KEY\", \"STAGING_ONLY\"]\n",
)
.unwrap();
m.apply_overlay(overlay);
assert_eq!(m.app.slug, "blog-staging", "overlay slug wins");
assert_eq!(
m.app.name, "My Blog",
"base name kept when overlay omits it"
);
assert_eq!(
m.secrets.names,
vec!["STRIPE_KEY".to_string(), "STAGING_ONLY".to_string()],
"secrets union, no dupes"
);
// Scripts/routes come from the base, untouched by the overlay.
assert_eq!(m.scripts.len(), 2);
}
#[test]
fn overlay_rejects_non_overlay_tables() {
// An overlay carries only [app]/[secrets]. Scripts/routes/triggers
// belong in the shared base, so a `[[scripts]]` table (or a typo'd
// key) in an overlay must error loudly, not be silently dropped.
let err = toml::from_str::<ManifestOverlay>(
"[app]\nslug = \"blog-staging\"\n\n\
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n",
)
.expect_err("overlay with [[scripts]] must be rejected");
assert!(
err.to_string().contains("scripts") || err.to_string().contains("unknown"),
"error should point at the offending table: {err}"
);
// Typo'd key inside [app] is likewise rejected.
toml::from_str::<ManifestOverlay>("[app]\nslag = \"oops\"\n")
.expect_err("overlay with a typo'd [app] key must be rejected");
}
#[test]
fn overlay_path_derivation() {
assert_eq!(
overlay_path(Path::new("proj/picloud.toml"), "staging"),
Path::new("proj/picloud.staging.toml")
);
assert_eq!(
overlay_path(Path::new("custom.toml"), "prod"),
Path::new("custom.prod.toml")
);
}
#[test]
fn empty_optional_sections_omitted() {
let m = Manifest {
app: ManifestApp {
slug: "x".into(),
name: "X".into(),
description: None,
},
scripts: vec![],
routes: vec![],
triggers: ManifestTriggers::default(),
secrets: ManifestSecrets::default(),
};
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}");
// Still round-trips.
assert_eq!(m, Manifest::parse(&text).unwrap());
}
}