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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user