feat(cli): env-scoped overlays — --env merges picloud.<env>.toml
The §4.1 base+overlay model for the single-app world: a base `picloud.toml`
holds the shared scripts/routes/triggers; a sparse `picloud.<env>.toml`
carries the per-environment slug (+ extra secret names). `pic plan/apply
--env <E>` merges the overlay onto the base before diffing/applying, so an
environment deploys to its own app ("an environment is an app", §2)
without duplicating the shared definitions.
Merge is sparse: overlay `app.slug`/`name` replace the base's; secret
names union. Scripts/routes/triggers always come from the base. Rich
per-key `vars` resolution stays Phase 3.
Tested: unit tests for the merge + overlay-path derivation; a journey
proving `apply --env staging` deploys to the staging app and leaves the
base app untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ use crate::output::{KvBlock, OutputMode};
|
|||||||
|
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
manifest_path: &Path,
|
manifest_path: &Path,
|
||||||
|
env: Option<&str>,
|
||||||
prune: bool,
|
prune: bool,
|
||||||
yes: bool,
|
yes: bool,
|
||||||
force: bool,
|
force: bool,
|
||||||
@@ -24,7 +25,7 @@ pub async fn run(
|
|||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
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 base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
let bundle = build_bundle(&manifest, base_dir)?;
|
let bundle = build_bundle(&manifest, base_dir)?;
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ use crate::config;
|
|||||||
use crate::manifest::Manifest;
|
use crate::manifest::Manifest;
|
||||||
use crate::output::{OutputMode, Table};
|
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 creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
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 base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
let bundle = build_bundle(&manifest, base_dir)?;
|
let bundle = build_bundle(&manifest, base_dir)?;
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,10 @@ struct ApplyArgs {
|
|||||||
/// since the last `pic plan`).
|
/// since the last `pic plan`).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
force: bool,
|
force: bool,
|
||||||
|
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
|
||||||
|
/// top of the base manifest before applying.
|
||||||
|
#[arg(long)]
|
||||||
|
env: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
@@ -207,6 +211,10 @@ struct PlanArgs {
|
|||||||
/// Path to the manifest.
|
/// Path to the manifest.
|
||||||
#[arg(long, default_value = "picloud.toml")]
|
#[arg(long, default_value = "picloud.toml")]
|
||||||
file: PathBuf,
|
file: PathBuf,
|
||||||
|
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
|
||||||
|
/// top of the base manifest before diffing.
|
||||||
|
#[arg(long)]
|
||||||
|
env: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
@@ -1132,9 +1140,17 @@ async fn main() -> ExitCode {
|
|||||||
Cmd::Logout => cmds::logout::run().await,
|
Cmd::Logout => cmds::logout::run().await,
|
||||||
Cmd::Whoami => cmds::whoami::run(mode).await,
|
Cmd::Whoami => cmds::whoami::run(mode).await,
|
||||||
Cmd::Apply(args) => {
|
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::Pull(args) => cmds::pull::run(&args.app, &args.dir, mode).await,
|
||||||
Cmd::Config(args) => cmds::config::run(&args.file, args.effective, mode).await,
|
Cmd::Config(args) => cmds::config::run(&args.file, args.effective, mode).await,
|
||||||
Cmd::Init(args) => cmds::init::run(
|
Cmd::Init(args) => cmds::init::run(
|
||||||
|
|||||||
@@ -58,6 +58,74 @@ impl Manifest {
|
|||||||
pub fn to_toml(&self) -> Result<String> {
|
pub fn to_toml(&self) -> Result<String> {
|
||||||
toml::to_string_pretty(self).context("serializing manifest TOML")
|
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.
|
||||||
|
#[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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[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]
|
#[test]
|
||||||
fn empty_optional_sections_omitted() {
|
fn empty_optional_sections_omitted() {
|
||||||
let m = Manifest {
|
let m = Manifest {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ mod config;
|
|||||||
mod dead_letters;
|
mod dead_letters;
|
||||||
mod email_queue;
|
mod email_queue;
|
||||||
mod enabled;
|
mod enabled;
|
||||||
|
mod env_overlay;
|
||||||
mod init;
|
mod init;
|
||||||
mod invoke;
|
mod invoke;
|
||||||
mod logs;
|
mod logs;
|
||||||
|
|||||||
76
crates/picloud-cli/tests/env_overlay.rs
Normal file
76
crates/picloud-cli/tests/env_overlay.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
//! Env-scoped overlays (§4.1): `pic apply --env <E>` merges the sparse
|
||||||
|
//! `picloud.<env>.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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user