Idunn: pin static redeploy and stuck-flip behavior; document both

Release ids are per transaction, so redeploying an unchanged tree with a changed
site file yields a new release (the test asserts distinct ids, the first release
keeping its carry, current carrying the new file); previous_release_id can never
equal release_id and no refusal is needed. A flip whose current link was replaced
by a directory after the intent retries visibly: last_error is shown by idunn
status as 'waiting on', cancel is refused, and the docs name the by-hand fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Eureka 2026-11-13 16:48:16 +00:00
commit be98521120
2 changed files with 45 additions and 1 deletions

View file

@ -210,7 +210,15 @@ What `current_link` names is observed from the symlink and confirmed by the
latest Admitted static transaction's `flip.release_id`; it is stored nowhere
else. A rollback is a flip back to `flip.previous_release_id`: release
directories stay, and Idunn prunes none. The brake is read exactly as for a
service, naming the binding's runtime id and the sealed release id. A new
service, naming the binding's runtime id and the sealed release id. Release ids are per transaction, so
redeploying an unchanged tarball with a changed site file is a new release that
carries the new file, never an in-place re-carry of an existing one: the carry
is part of a release. If `current_link` is replaced by a directory or by a link
that names nothing under the release root after the flip was recorded, the
transaction keeps retrying with backoff: `idunn status --command <id>` shows the
error as "waiting on ...", operator cancel is refused (Starting, not held), and
the fix is by hand (restore the link or remove the directory); it then
completes. A bounded retry is follow-up work. A new
upstream tarball enters as a recipe `external_input` with a pinned url and
sha256, so each release is a recipe commit whose pin is its provenance.

View file

@ -26102,6 +26102,11 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide
let first_release = first_done.flip.context("no first flip")?.release_id;
assert_eq!(w.current_release().as_deref(), Some(first_release.as_str()));
// The same tree is deployed again with a changed site config. Release
// ids are per transaction (the plan names its own incarnation), so the
// redeploy is a new release that carries the new file; the first keeps
// the bytes it was flipped with. No redeploy can name its own release.
std::fs::write(w.site_config(), SITE_CONFIG_TWO)?;
enqueue_deploy(&w.world, "up-service-2")?;
let second_id = w.transaction_of("up-service-2")?;
let second = w.run_to_brake(&second_id)?;
@ -26115,6 +26120,11 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide
assert_eq!(flip.previous_release_id.as_deref(), Some(first_release.as_str()));
assert_ne!(flip.release_id, first_release);
assert_eq!(w.current_release().as_deref(), Some(flip.release_id.as_str()));
assert_eq!(std::fs::read(w.current_link().join("config.json"))?, SITE_CONFIG_TWO);
assert_eq!(
std::fs::read(w.site.join("releases").join(&first_release).join("config.json"))?,
SITE_CONFIG_ONE
);
// The first release is still on disk: rollback is a flip back to it.
assert!(w.site.join("releases").join(&first_release).join("dist/index.html").is_file());
Ok(())
@ -26315,5 +26325,31 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide
assert_eq!(lifted, expected, "some field changed in the lift");
Ok(())
}
#[cfg(unix)]
#[test]
fn static_release_with_a_foreign_current_retries_visibly_and_cannot_be_cancelled() -> Result<()> {
let w = static_world()?;
let id = w.transaction_of("up-service")?;
let waiting = w.run_to_brake(&id)?;
w.release_brake(&waiting)?;
w.tick_until(&id, "the flip intent", |t| t.flip.is_some())?;
// The operator replaces the link's place with a directory.
std::fs::create_dir(w.current_link())?;
for _ in 0..4 {
w.world.engine.resume_backoff.lock().unwrap().clear();
let _ = w.world.engine.run_scheduler_tick();
}
let stuck = latest_id(&w.world, &id)?;
assert!(!stuck.is_terminal() && stuck.phase == DeploymentPhase::Starting, "{stuck:?}");
assert!(stuck.pre_fencing_abort.is_none(), "an error after the intent ended the flip");
let reason = stuck.last_error.clone().context("the retry kept no error")?;
assert!(reason.contains("not a symlink"), "{reason}");
let printed = printed_status(&w.world)?;
assert!(printed.contains(&format!("waiting on {reason}")), "{printed}");
let refused = cancel(&w.world.state_store, "up-service", "operator").unwrap_err();
assert!(format!("{refused:#}").contains("can only be cancelled"), "{refused:#}");
Ok(())
}
// END static-release tests
}