feat(cli): §3 M3 — per-env approval gating ([project.environments])

A `[project.environments]` block maps an env name to `{ confirm = bool }`
(`ManifestProject.environments`, client-side only — `skip_serializing`).
`pic apply --env <e>` is refused before any request when `<e>` is
confirm-required unless it is explicitly `--approve <e>`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 <env>` is a
repeatable flag. Per-env value overlays (`picloud.<env>.toml`) already merge
client-side, so this is the confirm-policy gate only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-07 20:28:28 +02:00
parent b8eced9d91
commit 62710b5d81
3 changed files with 53 additions and 1 deletions

View File

@@ -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 <e>` to a confirm-required env
/// (`[project.environments]` with `confirm = true`) unless it was explicitly
/// `--approve <e>`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!(