`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>
83 lines
2.7 KiB
Rust
83 lines
2.7 KiB
Rust
//! 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
|
|
}
|