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:
@@ -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