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::client::{Client, NodeKind};
|
||||||
use crate::cmds::plan::build_bundle;
|
use crate::cmds::plan::build_bundle;
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::manifest::Manifest;
|
use crate::manifest::{Manifest, ManifestProject};
|
||||||
use crate::output::{KvBlock, OutputMode};
|
use crate::output::{KvBlock, OutputMode};
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
manifest_path: &Path,
|
manifest_path: &Path,
|
||||||
env: Option<&str>,
|
env: Option<&str>,
|
||||||
@@ -21,12 +22,14 @@ pub async fn run(
|
|||||||
yes: bool,
|
yes: bool,
|
||||||
force: bool,
|
force: bool,
|
||||||
takeover: bool,
|
takeover: bool,
|
||||||
|
approve: &[String],
|
||||||
mode: OutputMode,
|
mode: OutputMode,
|
||||||
) -> Result<()> {
|
) -> 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_with_env(manifest_path, env)?;
|
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 base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
let bundle = build_bundle(&manifest, base_dir)?;
|
let bundle = build_bundle(&manifest, base_dir)?;
|
||||||
let kind = if manifest.is_group() {
|
let kind = if manifest.is_group() {
|
||||||
@@ -132,12 +135,14 @@ pub async fn run_tree(
|
|||||||
force: bool,
|
force: bool,
|
||||||
takeover: bool,
|
takeover: bool,
|
||||||
structure_mode: &str,
|
structure_mode: &str,
|
||||||
|
approve: &[String],
|
||||||
env: Option<&str>,
|
env: Option<&str>,
|
||||||
mode: OutputMode,
|
mode: OutputMode,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
let (bundle, node_count, project) = crate::discover::build_tree(dir, env)?;
|
let (bundle, node_count, project) = crate::discover::build_tree(dir, env)?;
|
||||||
|
require_env_approval(project.as_ref(), env, approve)?;
|
||||||
|
|
||||||
if prune && !yes {
|
if prune && !yes {
|
||||||
confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?;
|
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`
|
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
|
||||||
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
|
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
|
||||||
/// rather than silently deleting (review the deletions first with `pic plan`).
|
/// 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<()> {
|
fn confirm_prune(slug: &str) -> Result<()> {
|
||||||
if !std::io::stdin().is_terminal() {
|
if !std::io::stdin().is_terminal() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ struct ApplyArgs {
|
|||||||
/// divergence and reconcile content in place (no reparent).
|
/// divergence and reconcile content in place (no reparent).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
adopt_server_structure: bool,
|
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)]
|
#[derive(Args)]
|
||||||
@@ -1455,6 +1459,7 @@ async fn main() -> ExitCode {
|
|||||||
args.force,
|
args.force,
|
||||||
args.takeover,
|
args.takeover,
|
||||||
structure_mode,
|
structure_mode,
|
||||||
|
&args.approve,
|
||||||
args.env.as_deref(),
|
args.env.as_deref(),
|
||||||
mode,
|
mode,
|
||||||
)
|
)
|
||||||
@@ -1468,6 +1473,7 @@ async fn main() -> ExitCode {
|
|||||||
args.yes,
|
args.yes,
|
||||||
args.force,
|
args.force,
|
||||||
args.takeover,
|
args.takeover,
|
||||||
|
&args.approve,
|
||||||
mode,
|
mode,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -283,6 +283,22 @@ pub struct ManifestProject {
|
|||||||
/// that subtree. Absent = instance root = no ceiling (the default).
|
/// that subtree. Absent = instance root = no ceiling (the default).
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub parent_group: Option<String>,
|
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).
|
/// The store kind of a shared collection (§11.6).
|
||||||
|
|||||||
Reference in New Issue
Block a user