Idunn: rollback drift after freezing, cross-binding refusal, and docs
Tests: a previous release that drifts after the rollback was frozen ends
FailedBeforeFencing at the Starting-time verify with current untouched (fails if
that verify is removed); rollback_source refuses a previous release deployed
under another release root or current link. Docs: CommandKind::Rollback needs no
schema change but an Idunn at 9f15cee or earlier cannot decode it (forward-only);
flipped_at ordering and the observed-link check; a rollback cannot be cancelled.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
01b6016193
commit
7ba27c362e
2 changed files with 59 additions and 0 deletions
|
|
@ -254,6 +254,14 @@ as it is at rollback time, and its sha256 is recorded in the rollback's own
|
|||
flip evidence (it may differ from the original release's; the copy inside the
|
||||
rolled-back-to release is replaced). Nothing prunes release directories.
|
||||
|
||||
`CommandKind::Rollback` is stored in the existing v5 command and transaction
|
||||
records with no schema change; an Idunn at 9f15cee or earlier cannot decode
|
||||
them, so this is forward-only, like v5 itself. The newest flip is ordered by
|
||||
`flipped_at` wall clock, and the observed-link check makes a clock step refuse
|
||||
the rollback rather than mis-flip. A rollback cannot be cancelled, like a static
|
||||
deploy. A rollback is also refused when the previous release was deployed under
|
||||
a different release root or current link than the newest one.
|
||||
|
||||
## Typed deployment state
|
||||
|
||||
Mutable control-plane state will be CultCache. Runtime integration must use
|
||||
|
|
|
|||
|
|
@ -7099,6 +7099,15 @@ impl Engine {
|
|||
.rev()
|
||||
.find(|transaction| transaction.flip.as_ref().is_some_and(|f| f.release_id == previous))
|
||||
.with_context(|| format!("no record of a transaction that flipped the previous release {previous}"))?;
|
||||
// The prior plan is reused verbatim, but the link was observed through
|
||||
// the newest plan's binding: they must be the same release root and link.
|
||||
let prior_binding = required(&prior.plan, "previous release plan")?.parsed_inputs()?.1;
|
||||
let prior_workload = prior_binding.workload.static_release()?;
|
||||
ensure!(
|
||||
prior_workload.release_root == workload.release_root
|
||||
&& prior_workload.current_link == workload.current_link,
|
||||
"the previous release was deployed under a different release root or current link than the newest; rollback does not cross bindings"
|
||||
);
|
||||
self.static_release.verify_installed(
|
||||
workload,
|
||||
required(&prior.sealed_release, "previous sealed release")?,
|
||||
|
|
@ -26742,5 +26751,47 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide
|
|||
assert!(command("rollback-1", CommandKind::Deploy).validate().is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn static_rollback_drift_after_freezing_ends_before_fencing_with_current_untouched() -> Result<()> {
|
||||
let w = static_world()?;
|
||||
let (first, second) = w.two_deploys()?;
|
||||
let id = w.submit_rollback("rollback-1")?;
|
||||
let waiting = w.run_to_brake(&id)?;
|
||||
// Frozen and intact; now the previous release drifts before Starting.
|
||||
std::fs::write(w.release_dir(&first).join("dist/index.html"), b"tampered")?;
|
||||
w.release_brake(&waiting)?;
|
||||
w.tick_until(&id, "the rollback end", |t| t.is_terminal())?;
|
||||
let ended = latest_id(&w.world, &id)?;
|
||||
let Some(TransactionCompletion::FailedBeforeFencing { error }) = &ended.completion else {
|
||||
bail!("the drifted rollback did not fail before fencing: {ended:?}");
|
||||
};
|
||||
assert!(error.contains("differs from its sealed receipt"), "{error}");
|
||||
assert!(ended.flip.is_none());
|
||||
assert_eq!(w.current_release().as_deref(), Some(second.as_str()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn static_rollback_refuses_a_previous_release_deployed_under_another_binding() -> Result<()> {
|
||||
let w = static_world()?;
|
||||
w.two_deploys()?;
|
||||
let snapshot = ControlSnapshot::read(&w.world.state_store)?;
|
||||
let mut history = read_history_transactions(&w.world.state_store);
|
||||
// The older transaction's plan names another release root.
|
||||
let older = history
|
||||
.iter_mut()
|
||||
.min_by_key(|t| t.created_at_unix_millis)
|
||||
.context("no history")?;
|
||||
let plan = older.plan.as_mut().context("no plan")?;
|
||||
let text = String::from_utf8(plan.binding_blob.clone())?;
|
||||
assert!(text.contains("/releases\""));
|
||||
plan.binding_blob = text.replace("/releases\"", "/releases-old\"").into_bytes();
|
||||
let error = w.world.engine.rollback_source(&snapshot, &history, "service").unwrap_err();
|
||||
assert!(format!("{error:#}").contains("does not cross bindings"), "{error:#}");
|
||||
Ok(())
|
||||
}
|
||||
// END static-release tests
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue