feat(project-tool): bound-plan staleness check (content fingerprint)
`pic plan` now records a fingerprint of the live state it diffed against; `pic apply` replays it and the server refuses (HTTP 409) if the app changed underneath the reviewed plan — the §4.2 "apply exactly what you reviewed" guarantee, in its content-addressed form (no migration, no changes to interactive write paths). Server (manager-core): - `state_token(CurrentState)`: deterministic FNV-1a fingerprint over what the diff keys on — script name+version (version bumps on any edit), route identity+binding/attrs, trigger membership+enabled, secret names. Order-independent; a collision can only yield a false "unchanged", never a false refusal. - `plan` returns it (flattened onto the plan JSON, so the wire stays additive); `apply` takes an optional `expected_token` and, under the apply lock before any write, returns `StateMoved` (409) on mismatch. CLI: - `.picloud/` link state (`linkstate`): `pic plan` writes the token scoped to the app slug; `pic apply` replays it, then clears it on success (the token is single-use — the next apply re-plans). `--force` skips the check; apply with no recorded plan still works standalone (today's behavior). `.picloud/` is already gitignored by `pic init`. The tree-structure version (the other half of §4.2's counter) stays a deliberate no-op until groups exist — it only guards reparent/structural moves, which don't exist single-app. Tested: state_token unit test (stable/order-independent/sensitive) + a staleness journey (plan → out-of-band deploy → apply refuses → --force applies); manager-core lib 363 + cli bins 31 + 13 journeys green; clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,9 @@ use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::apply_service::{ApplyError, ApplyReport, ApplyService, Bundle, BundleTrigger, Plan};
|
||||
use crate::apply_service::{
|
||||
ApplyError, ApplyReport, ApplyService, Bundle, BundleTrigger, PlanResult,
|
||||
};
|
||||
use crate::authz::{require, AuthzDenied, Capability};
|
||||
|
||||
/// Build the apply/plan router. Mounted under `/api/v1/admin`.
|
||||
@@ -32,6 +34,10 @@ pub struct ApplyRequest {
|
||||
pub bundle: Bundle,
|
||||
#[serde(default)]
|
||||
pub prune: bool,
|
||||
/// Optional bound-plan token from a prior `plan`. When present, apply
|
||||
/// refuses (409) if the app's live state has changed since.
|
||||
#[serde(default)]
|
||||
pub expected_token: Option<String>,
|
||||
}
|
||||
|
||||
async fn apply_handler(
|
||||
@@ -89,7 +95,13 @@ async fn apply_handler(
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
let report = svc
|
||||
.apply(app_id, &req.bundle, req.prune, principal.user_id)
|
||||
.apply(
|
||||
app_id,
|
||||
&req.bundle,
|
||||
req.prune,
|
||||
principal.user_id,
|
||||
req.expected_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
@@ -99,7 +111,7 @@ async fn plan_handler(
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(bundle): Json<Bundle>,
|
||||
) -> Result<Json<Plan>, ApplyError> {
|
||||
) -> Result<Json<PlanResult>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
// NOTE: the returned `Plan` discloses live secret NAMES (not values). That
|
||||
// is safe today only because `AppRead` and `AppSecretsRead` are co-granted
|
||||
@@ -139,6 +151,7 @@ impl IntoResponse for ApplyError {
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "apply authz repo error");
|
||||
|
||||
@@ -312,6 +312,16 @@ impl Plan {
|
||||
}
|
||||
}
|
||||
|
||||
/// What `plan` returns: the diff plus a fingerprint of the live state it was
|
||||
/// computed against. The token is flattened onto the plan JSON, so the wire
|
||||
/// shape stays `{ scripts, routes, triggers, secrets, state_token }`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PlanResult {
|
||||
#[serde(flatten)]
|
||||
pub plan: Plan,
|
||||
pub state_token: String,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Current state snapshot
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -335,6 +345,12 @@ pub enum ApplyError {
|
||||
AppNotFound(String),
|
||||
#[error("invalid manifest: {0}")]
|
||||
Invalid(String),
|
||||
#[error(
|
||||
"live state changed since `pic plan` (someone edited this app's scripts, \
|
||||
routes, triggers, or secrets); re-run `pic plan` to review, then apply — \
|
||||
or `pic apply --force` to skip the check"
|
||||
)]
|
||||
StateMoved,
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
@@ -377,12 +393,17 @@ impl ApplyService {
|
||||
///
|
||||
/// # Errors
|
||||
/// `Invalid` for a malformed bundle; `Backend` for repo failures.
|
||||
pub async fn plan(&self, app_id: AppId, bundle: &Bundle) -> Result<Plan, ApplyError> {
|
||||
pub async fn plan(&self, app_id: AppId, bundle: &Bundle) -> Result<PlanResult, ApplyError> {
|
||||
self.validate_bundle(bundle)?;
|
||||
self.validate_route_hosts(app_id, bundle).await?;
|
||||
let current = self.load_current(app_id).await?;
|
||||
validate_email_secrets_present(bundle, ¤t.secret_names)?;
|
||||
Ok(compute_diff(¤t, bundle))
|
||||
Ok(PlanResult {
|
||||
plan: compute_diff(¤t, bundle),
|
||||
// Fingerprint of the live state this plan was computed against, so
|
||||
// `apply` can refuse if the app changed underneath it (§4.2).
|
||||
state_token: state_token(¤t),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reconcile app `app_id` to `bundle` in a single transaction. Creates
|
||||
@@ -398,6 +419,7 @@ impl ApplyService {
|
||||
bundle: &Bundle,
|
||||
prune: bool,
|
||||
actor: AdminUserId,
|
||||
expected_token: Option<&str>,
|
||||
) -> Result<ApplyReport, ApplyError> {
|
||||
self.validate_bundle(bundle)?;
|
||||
self.validate_route_hosts(app_id, bundle).await?;
|
||||
@@ -422,6 +444,15 @@ impl ApplyService {
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
|
||||
let current = self.load_current(app_id).await?;
|
||||
// Bound-plan check (§4.2): if the caller passed the token from a prior
|
||||
// `pic plan`, refuse when the live state has changed since — computed
|
||||
// under the apply lock so it reflects what we're about to write. Done
|
||||
// before any mutation so a stale apply rolls back nothing.
|
||||
if let Some(expected) = expected_token {
|
||||
if state_token(¤t) != expected {
|
||||
return Err(ApplyError::StateMoved);
|
||||
}
|
||||
}
|
||||
// Surface a missing email-secret reference here so `plan` and `apply`
|
||||
// agree, rather than only failing deep in `resolve_and_seal` below.
|
||||
validate_email_secrets_present(bundle, ¤t.secret_names)?;
|
||||
@@ -1564,6 +1595,62 @@ fn map_trig(e: TriggerRepoError) -> ApplyError {
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable fingerprint of an app's live state, covering exactly what the
|
||||
/// reconcile diff keys on: script identity + write-counter (`version` bumps on
|
||||
/// any script edit), route identity + binding/attrs, trigger membership +
|
||||
/// `enabled`, and secret names. Any out-of-band change to those flips the
|
||||
/// token, so a `plan`-then-`apply` can detect that the app moved underneath it.
|
||||
///
|
||||
/// Uses FNV-1a (deterministic across process restarts, unlike `DefaultHasher`'s
|
||||
/// per-process seed) so a token stored by `pic plan` still matches on a later
|
||||
/// `pic apply`. A hash collision can only ever yield a false "unchanged", which
|
||||
/// is the same risk class as not checking at all — never a false refusal.
|
||||
#[must_use]
|
||||
pub fn state_token(current: &CurrentState) -> String {
|
||||
let mut parts: Vec<String> = Vec::with_capacity(
|
||||
current.scripts.len()
|
||||
+ current.routes.len()
|
||||
+ current.triggers.len()
|
||||
+ current.secret_names.len(),
|
||||
);
|
||||
for s in ¤t.scripts {
|
||||
parts.push(format!("s|{}|{}", s.name.to_lowercase(), s.version));
|
||||
}
|
||||
for r in ¤t.routes {
|
||||
parts.push(format!(
|
||||
"r|{}|{:?}|{:?}|{}",
|
||||
route_key(
|
||||
r.method.as_deref(),
|
||||
r.host_kind,
|
||||
&r.host,
|
||||
r.path_kind,
|
||||
&r.path
|
||||
),
|
||||
r.script_id,
|
||||
r.dispatch_mode,
|
||||
r.host_param_name.as_deref().unwrap_or(""),
|
||||
));
|
||||
}
|
||||
for t in ¤t.triggers {
|
||||
parts.push(format!("t|{:?}|{}", t.id, t.enabled));
|
||||
}
|
||||
for n in ¤t.secret_names {
|
||||
parts.push(format!("k|{n}"));
|
||||
}
|
||||
// Order-independent: sort the per-resource tokens before hashing.
|
||||
parts.sort_unstable();
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for p in &parts {
|
||||
for b in p.as_bytes() {
|
||||
h ^= u64::from(*b);
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
h ^= u64::from(b'\n');
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
format!("{h:016x}")
|
||||
}
|
||||
|
||||
/// Per-app advisory lock key, namespaced so it can't collide with the
|
||||
/// queue-trigger lock space.
|
||||
fn apply_lock_key(app_id: AppId) -> i64 {
|
||||
@@ -1875,6 +1962,41 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_token_is_stable_order_independent_and_sensitive() {
|
||||
let a = script("a", "x"); // version 1
|
||||
let b = script("b", "y");
|
||||
let base = CurrentState {
|
||||
scripts: vec![a.clone(), b.clone()],
|
||||
secret_names: vec!["S".into()],
|
||||
..CurrentState::default()
|
||||
};
|
||||
// Deterministic + order-independent.
|
||||
let reordered = CurrentState {
|
||||
scripts: vec![b.clone(), a.clone()],
|
||||
secret_names: vec!["S".into()],
|
||||
..CurrentState::default()
|
||||
};
|
||||
assert_eq!(state_token(&base), state_token(&reordered));
|
||||
// A script edit bumps `version`, which must flip the token even if the
|
||||
// source-by-name set is otherwise unchanged (out-of-band redeploy).
|
||||
let mut a2 = a.clone();
|
||||
a2.version += 1;
|
||||
let bumped = CurrentState {
|
||||
scripts: vec![a2, b.clone()],
|
||||
secret_names: vec!["S".into()],
|
||||
..CurrentState::default()
|
||||
};
|
||||
assert_ne!(state_token(&base), state_token(&bumped));
|
||||
// Adding a secret name flips it too.
|
||||
let with_secret = CurrentState {
|
||||
scripts: vec![a, b],
|
||||
secret_names: vec!["S".into(), "T".into()],
|
||||
..CurrentState::default()
|
||||
};
|
||||
assert_ne!(state_token(&base), state_token(&with_secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_diff_create_noop_delete() {
|
||||
let s = script("h", "x");
|
||||
|
||||
@@ -920,9 +920,14 @@ impl Client {
|
||||
app: &str,
|
||||
bundle: &serde_json::Value,
|
||||
prune: bool,
|
||||
expected_token: Option<&str>,
|
||||
) -> Result<ApplyReportDto> {
|
||||
let app = seg(app);
|
||||
let body = serde_json::json!({ "bundle": bundle, "prune": prune });
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
"prune": prune,
|
||||
"expected_token": expected_token,
|
||||
});
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/apply"))
|
||||
.json(&body)
|
||||
@@ -965,6 +970,10 @@ pub struct PlanDto {
|
||||
pub triggers: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
/// Fingerprint of the live state this plan was computed against; carried
|
||||
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
||||
#[serde(default)]
|
||||
pub state_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -14,7 +14,13 @@ use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
pub async fn run(manifest_path: &Path, prune: bool, yes: bool, mode: OutputMode) -> Result<()> {
|
||||
pub async fn run(
|
||||
manifest_path: &Path,
|
||||
prune: bool,
|
||||
yes: bool,
|
||||
force: bool,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
@@ -26,7 +32,27 @@ pub async fn run(manifest_path: &Path, prune: bool, yes: bool, mode: OutputMode)
|
||||
confirm_prune(&manifest.app.slug)?;
|
||||
}
|
||||
|
||||
let report = client.apply(&manifest.app.slug, &bundle, prune).await?;
|
||||
// Bound-plan check: replay the token from the last `pic plan` (for this
|
||||
// app) so the server refuses if the app changed since it was reviewed.
|
||||
// `--force` skips it; no recorded plan means no check (apply still works
|
||||
// standalone). The token is single-use — cleared after a successful apply.
|
||||
let expected_token = if force {
|
||||
None
|
||||
} else {
|
||||
crate::linkstate::read_plan(base_dir)
|
||||
.filter(|l| l.app == manifest.app.slug)
|
||||
.map(|l| l.state_token)
|
||||
};
|
||||
|
||||
let report = client
|
||||
.apply(
|
||||
&manifest.app.slug,
|
||||
&bundle,
|
||||
prune,
|
||||
expected_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
crate::linkstate::clear_plan(base_dir);
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
|
||||
@@ -23,6 +23,16 @@ pub async fn run(manifest_path: &Path, mode: OutputMode) -> Result<()> {
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
|
||||
let plan = client.plan(&manifest.app.slug, &bundle).await?;
|
||||
// Record the bound-plan token so a subsequent `pic apply` can detect the
|
||||
// app changing underneath the reviewed plan (best-effort — a read-only
|
||||
// plan still succeeds if the project dir isn't writable).
|
||||
if !plan.state_token.is_empty() {
|
||||
if let Err(e) =
|
||||
crate::linkstate::write_plan(base_dir, &manifest.app.slug, &plan.state_token)
|
||||
{
|
||||
eprintln!("warning: could not record plan state for `pic apply`: {e}");
|
||||
}
|
||||
}
|
||||
render(&plan, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
55
crates/picloud-cli/src/linkstate.rs
Normal file
55
crates/picloud-cli/src/linkstate.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
//! `.picloud/` link state — gitignored, per-project metadata the project tool
|
||||
//! carries between CLI invocations. Today it holds just the bound-plan token:
|
||||
//! `pic plan` records the fingerprint of the live state it diffed against, and
|
||||
//! `pic apply` replays it so the server can refuse if the app moved underneath.
|
||||
//!
|
||||
//! All paths are relative to the manifest's directory (the project root).
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DIR: &str = ".picloud";
|
||||
const PLAN_FILE: &str = "plan.json";
|
||||
|
||||
/// The recorded result of the last `pic plan`, scoped to the app it was for.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanLink {
|
||||
/// App slug the token belongs to — guards against replaying a token from a
|
||||
/// different app if the manifest's `slug` changed.
|
||||
pub app: String,
|
||||
pub state_token: String,
|
||||
}
|
||||
|
||||
fn plan_path(base: &Path) -> PathBuf {
|
||||
base.join(DIR).join(PLAN_FILE)
|
||||
}
|
||||
|
||||
/// Record the bound-plan token for `app` under `base/.picloud/`.
|
||||
pub fn write_plan(base: &Path, app: &str, state_token: &str) -> Result<()> {
|
||||
let dir = base.join(DIR);
|
||||
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
let link = PlanLink {
|
||||
app: app.to_string(),
|
||||
state_token: state_token.to_string(),
|
||||
};
|
||||
let body = serde_json::to_vec_pretty(&link).context("encoding .picloud/plan.json")?;
|
||||
fs::write(plan_path(base), body).context("writing .picloud/plan.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the recorded plan token, if any. Returns `None` when absent or
|
||||
/// unreadable (treated as "no prior plan" — never an error).
|
||||
#[must_use]
|
||||
pub fn read_plan(base: &Path) -> Option<PlanLink> {
|
||||
let body = fs::read(plan_path(base)).ok()?;
|
||||
serde_json::from_slice(&body).ok()
|
||||
}
|
||||
|
||||
/// Remove the recorded plan token (best-effort). Called after a successful
|
||||
/// apply consumes it, so the next apply requires a fresh plan.
|
||||
pub fn clear_plan(base: &Path) {
|
||||
let _ = fs::remove_file(plan_path(base));
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
mod client;
|
||||
mod cmds;
|
||||
mod config;
|
||||
mod linkstate;
|
||||
mod manifest;
|
||||
mod output;
|
||||
|
||||
@@ -191,6 +192,10 @@ struct ApplyArgs {
|
||||
/// non-interactively (CI).
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
/// Skip the bound-plan staleness check (apply even if the app changed
|
||||
/// since the last `pic plan`).
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -1112,7 +1117,9 @@ async fn main() -> ExitCode {
|
||||
}
|
||||
Cmd::Logout => cmds::logout::run().await,
|
||||
Cmd::Whoami => cmds::whoami::run(mode).await,
|
||||
Cmd::Apply(args) => cmds::apply::run(&args.file, args.prune, args.yes, mode).await,
|
||||
Cmd::Apply(args) => {
|
||||
cmds::apply::run(&args.file, args.prune, args.yes, args.force, mode).await
|
||||
}
|
||||
Cmd::Plan(args) => cmds::plan::run(&args.file, mode).await,
|
||||
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, mode).await,
|
||||
Cmd::Init(args) => cmds::init::run(
|
||||
|
||||
@@ -31,4 +31,5 @@ mod roles;
|
||||
mod routes;
|
||||
mod scripts;
|
||||
mod secrets;
|
||||
mod staleness;
|
||||
mod triggers;
|
||||
|
||||
@@ -68,7 +68,10 @@ fn init_appends_to_an_existing_gitignore_once() {
|
||||
.assert()
|
||||
.success();
|
||||
let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
|
||||
assert!(gitignore.contains("target/"), "must preserve existing rules");
|
||||
assert!(
|
||||
gitignore.contains("target/"),
|
||||
"must preserve existing rules"
|
||||
);
|
||||
assert_eq!(
|
||||
gitignore.matches(".picloud/").count(),
|
||||
1,
|
||||
|
||||
82
crates/picloud-cli/tests/staleness.rs
Normal file
82
crates/picloud-cli/tests/staleness.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
//! Bound-plan staleness: `pic plan` records a state token under `.picloud/`,
|
||||
//! and a later `pic apply` refuses (without `--force`) if the app changed
|
||||
//! out-of-band since the plan was reviewed.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_refuses_when_state_moved_since_plan() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("stale");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), "let x = 1; x").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Stale\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
// Establish hello, then plan — the plan records the current state token.
|
||||
apply(&env, &manifest_path).assert().success();
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
assert!(
|
||||
dir.path().join(".picloud/plan.json").exists(),
|
||||
"plan must record the bound-plan token under .picloud/"
|
||||
);
|
||||
|
||||
// Out-of-band change: deploy an extra script the manifest doesn't mention.
|
||||
fs::write(dir.path().join("scripts/sneaky.rhai"), "let y = 2; y").unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/sneaky.rhai"))
|
||||
.args(["--app", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Apply must now refuse — the live state no longer matches the plan.
|
||||
let refused = apply(&env, &manifest_path).output().expect("apply");
|
||||
assert!(
|
||||
!refused.status.success(),
|
||||
"apply must refuse after out-of-band change"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&refused.stderr);
|
||||
assert!(
|
||||
err.contains("changed since") || err.contains("pic plan"),
|
||||
"refusal should explain the staleness:\n{err}"
|
||||
);
|
||||
|
||||
// `--force` bypasses the check and applies anyway.
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.arg("--force")
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
fn apply(env: &common::TestEnv, manifest_path: &std::path::Path) -> assert_cmd::Command {
|
||||
let mut cmd = common::pic_as(env);
|
||||
cmd.args(["apply", "--file"]).arg(manifest_path);
|
||||
cmd
|
||||
}
|
||||
Reference in New Issue
Block a user