diff --git a/crates/picloud-cli/src/cmds/apply.rs b/crates/picloud-cli/src/cmds/apply.rs index 88e08e9..51febd3 100644 --- a/crates/picloud-cli/src/cmds/apply.rs +++ b/crates/picloud-cli/src/cmds/apply.rs @@ -16,6 +16,7 @@ use crate::output::{KvBlock, OutputMode}; pub async fn run( manifest_path: &Path, + env: Option<&str>, prune: bool, yes: bool, force: bool, @@ -24,7 +25,7 @@ pub async fn run( let creds = config::resolve()?; let client = Client::from_creds(&creds)?; - let manifest = Manifest::load(manifest_path)?; + 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)?; diff --git a/crates/picloud-cli/src/cmds/plan.rs b/crates/picloud-cli/src/cmds/plan.rs index c0c5290..ff3b3b7 100644 --- a/crates/picloud-cli/src/cmds/plan.rs +++ b/crates/picloud-cli/src/cmds/plan.rs @@ -14,11 +14,11 @@ use crate::config; use crate::manifest::Manifest; use crate::output::{OutputMode, Table}; -pub async fn run(manifest_path: &Path, mode: OutputMode) -> Result<()> { +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(manifest_path)?; + 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)?; diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index dda3854..1fa6699 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -200,6 +200,10 @@ struct ApplyArgs { /// since the last `pic plan`). #[arg(long)] force: bool, + /// Merge the `picloud..toml` overlay (per-env slug + secrets) on + /// top of the base manifest before applying. + #[arg(long)] + env: Option, } #[derive(Args)] @@ -207,6 +211,10 @@ struct PlanArgs { /// Path to the manifest. #[arg(long, default_value = "picloud.toml")] file: PathBuf, + /// Merge the `picloud..toml` overlay (per-env slug + secrets) on + /// top of the base manifest before diffing. + #[arg(long)] + env: Option, } #[derive(Args)] @@ -1132,9 +1140,17 @@ async fn main() -> ExitCode { Cmd::Logout => cmds::logout::run().await, Cmd::Whoami => cmds::whoami::run(mode).await, Cmd::Apply(args) => { - cmds::apply::run(&args.file, args.prune, args.yes, args.force, mode).await + cmds::apply::run( + &args.file, + args.env.as_deref(), + args.prune, + args.yes, + args.force, + mode, + ) + .await } - Cmd::Plan(args) => cmds::plan::run(&args.file, mode).await, + Cmd::Plan(args) => cmds::plan::run(&args.file, args.env.as_deref(), mode).await, Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, mode).await, Cmd::Config(args) => cmds::config::run(&args.file, args.effective, mode).await, Cmd::Init(args) => cmds::init::run( diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index d45f7aa..bdfe7f5 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -58,6 +58,74 @@ impl Manifest { pub fn to_toml(&self) -> Result { toml::to_string_pretty(self).context("serializing manifest TOML") } + + /// Load the base manifest, then (if `env` is set) merge the sparse + /// `picloud..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 { + 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..toml`, alongside the base (works for a +/// custom `--file` too: `custom.toml` → `custom..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..toml`). Only the fields +/// that vary per environment today — slug/name and secret names. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ManifestOverlay { + #[serde(default)] + pub app: OverlayApp, + #[serde(default)] + pub secrets: ManifestSecrets, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct OverlayApp { + #[serde(default)] + pub slug: Option, + #[serde(default)] + pub name: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -371,6 +439,40 @@ mod tests { ); } + #[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_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 { diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index 417fbf8..f1c8420 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -22,6 +22,7 @@ mod config; mod dead_letters; mod email_queue; mod enabled; +mod env_overlay; mod init; mod invoke; mod logs; diff --git a/crates/picloud-cli/tests/env_overlay.rs b/crates/picloud-cli/tests/env_overlay.rs new file mode 100644 index 0000000..9e5b94e --- /dev/null +++ b/crates/picloud-cli/tests/env_overlay.rs @@ -0,0 +1,76 @@ +//! Env-scoped overlays (§4.1): `pic apply --env ` merges the sparse +//! `picloud..toml` (per-env slug) onto the base and deploys to that +//! environment's app — leaving the base app untouched. + +use std::fs; + +use tempfile::TempDir; + +use crate::common; +use crate::common::cleanup::AppGuard; + +fn scripts_ls(env: &common::TestEnv, slug: &str) -> String { + String::from_utf8( + common::pic_as(env) + .args(["scripts", "ls", "--app", slug]) + .output() + .unwrap() + .stdout, + ) + .unwrap() +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn apply_env_overlay_targets_the_env_app() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let base_slug = common::unique_slug("ovl"); + let staging_slug = format!("{base_slug}-staging"); + for s in [&base_slug, &staging_slug] { + common::pic_as(&env) + .args(["apps", "create", s]) + .assert() + .success(); + } + let _g1 = AppGuard::new(&env.url, &env.token, &base_slug); + let _g2 = AppGuard::new(&env.url, &env.token, &staging_slug); + + let dir = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join("scripts")).unwrap(); + fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap(); + let base = dir.path().join("picloud.toml"); + fs::write( + &base, + format!( + "[app]\nslug = \"{base_slug}\"\nname = \"Ovl\"\n\n\ + [[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n" + ), + ) + .unwrap(); + // Overlay redirects to the staging app. + fs::write( + dir.path().join("picloud.staging.toml"), + format!("[app]\nslug = \"{staging_slug}\"\n"), + ) + .unwrap(); + + // Apply to staging only. + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(&base) + .args(["--env", "staging"]) + .assert() + .success(); + + assert!( + scripts_ls(&env, &staging_slug).contains("hello"), + "overlay apply must deploy to the staging app" + ); + assert!( + !scripts_ls(&env, &base_slug).contains("hello"), + "the base app must be untouched by an --env apply" + ); +}