From 62710b5d81313e08949736e6f6bb8a031b31a665 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 7 Jul 2026 20:28:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli):=20=C2=A73=20M3=20=E2=80=94=20per-env?= =?UTF-8?q?=20approval=20gating=20([project.environments])?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `[project.environments]` block maps an env name to `{ confirm = bool }` (`ManifestProject.environments`, client-side only — `skip_serializing`). `pic apply --env ` is refused before any request when `` is confirm-required unless it is explicitly `--approve `d; a blanket `--yes` does NOT cover a gated env (§4.2 "CI must opt in per environment"); an unlisted or `confirm = false` env applies freely. `require_env_approval` gates both the single (`run`) and tree (`run_tree`) apply paths; `--approve ` is a repeatable flag. Per-env value overlays (`picloud..toml`) already merge client-side, so this is the confirm-policy gate only. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/picloud-cli/src/cmds/apply.rs | 32 +++++++++++++++++++++++++++- crates/picloud-cli/src/main.rs | 6 ++++++ crates/picloud-cli/src/manifest.rs | 16 ++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/picloud-cli/src/cmds/apply.rs b/crates/picloud-cli/src/cmds/apply.rs index 385a15e..6ff216f 100644 --- a/crates/picloud-cli/src/cmds/apply.rs +++ b/crates/picloud-cli/src/cmds/apply.rs @@ -11,9 +11,10 @@ use anyhow::{Context, Result}; use crate::client::{Client, NodeKind}; use crate::cmds::plan::build_bundle; use crate::config; -use crate::manifest::Manifest; +use crate::manifest::{Manifest, ManifestProject}; use crate::output::{KvBlock, OutputMode}; +#[allow(clippy::too_many_arguments)] pub async fn run( manifest_path: &Path, env: Option<&str>, @@ -21,12 +22,14 @@ pub async fn run( yes: bool, force: bool, takeover: bool, + approve: &[String], mode: OutputMode, ) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let manifest = Manifest::load_with_env(manifest_path, env)?; + require_env_approval(manifest.project.as_ref(), env, approve)?; let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new(".")); let bundle = build_bundle(&manifest, base_dir)?; let kind = if manifest.is_group() { @@ -132,12 +135,14 @@ pub async fn run_tree( force: bool, takeover: bool, structure_mode: &str, + approve: &[String], env: Option<&str>, mode: OutputMode, ) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let (bundle, node_count, project) = crate::discover::build_tree(dir, env)?; + require_env_approval(project.as_ref(), env, approve)?; if prune && !yes { confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?; @@ -224,6 +229,31 @@ pub async fn run_tree( /// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes` /// for non-interactive/CI use. Refuse a non-interactive prune without `--yes` /// rather than silently deleting (review the deletions first with `pic plan`). +/// §3 M3: refuse `pic apply --env ` to a confirm-required env +/// (`[project.environments]` with `confirm = true`) unless it was explicitly +/// `--approve `d. A blanket `--yes` does NOT cover a gated env (§4.2 "CI must +/// opt in per environment"). An unlisted or `confirm = false` env applies freely. +fn require_env_approval( + project: Option<&ManifestProject>, + env: Option<&str>, + approve: &[String], +) -> Result<()> { + let (Some(env), Some(project)) = (env, project) else { + return Ok(()); + }; + let Some(policy) = project.environments.get(env) else { + return Ok(()); + }; + if policy.confirm && !approve.iter().any(|a| a == env) { + anyhow::bail!( + "environment `{env}` is confirm-required (`[project.environments]`); \ + re-run with `--approve {env}` to apply to it — a blanket `--yes` does \ + not cover a gated environment" + ); + } + Ok(()) +} + fn confirm_prune(slug: &str) -> Result<()> { if !std::io::stdin().is_terminal() { anyhow::bail!( diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 31474ae..2f50aa0 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -269,6 +269,10 @@ struct ApplyArgs { /// divergence and reconcile content in place (no reparent). #[arg(long)] adopt_server_structure: bool, + /// §3 M3: approve applying to a confirm-required environment + /// (`[project.environments]`). Repeatable; each must match the `--env`. + #[arg(long = "approve")] + approve: Vec, } #[derive(Args)] @@ -1455,6 +1459,7 @@ async fn main() -> ExitCode { args.force, args.takeover, structure_mode, + &args.approve, args.env.as_deref(), mode, ) @@ -1468,6 +1473,7 @@ async fn main() -> ExitCode { args.yes, args.force, args.takeover, + &args.approve, mode, ) .await diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index f10c5dd..eb00353 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -283,6 +283,22 @@ pub struct ManifestProject { /// that subtree. Absent = instance root = no ceiling (the default). #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_group: Option, + /// §3 M3 per-env approval policy: `[project.environments]` maps an env name + /// to its policy. A `confirm = true` env requires an explicit `--approve + /// ` on `pic apply --env ` (a blanket `--yes` does NOT cover it — + /// §4.2 "CI must opt in per environment"). Client-side gating only; never + /// sent to the server. + #[serde(default, skip_serializing)] + pub environments: std::collections::BTreeMap, +} + +/// §3 M3: the apply-gating policy for one environment. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EnvPolicy { + /// Require an explicit `--approve ` to apply to this env. + #[serde(default)] + pub confirm: bool, } /// The store kind of a shared collection (§11.6).