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:
@@ -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!(
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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
|
||||
|
||||
@@ -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<String>,
|
||||
/// §3 M3 per-env approval policy: `[project.environments]` maps an env name
|
||||
/// to its policy. A `confirm = true` env requires an explicit `--approve
|
||||
/// <env>` on `pic apply --env <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<String, EnvPolicy>,
|
||||
}
|
||||
|
||||
/// §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 <env>` to apply to this env.
|
||||
#[serde(default)]
|
||||
pub confirm: bool,
|
||||
}
|
||||
|
||||
/// The store kind of a shared collection (§11.6).
|
||||
|
||||
Reference in New Issue
Block a user