controller v0.268.0: the undo finds volumes by definition; a held app names only a whole copy; one press = one tested step (R-658, R-659, R-660, R-651; 09 §6.4 part 5)
gates / gates (push) Successful in 27s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-24 08:15:27 +02:00
parent 80e6ad8c47
commit 206b0357d1
36 changed files with 2201 additions and 40 deletions
+23 -2
View File
@@ -292,6 +292,9 @@ func fillHoldReason(g UpdateGuards, st *Stack) {
if g != nil && st.Deployed {
if h, why := g.HoldFor(st.Name); h {
st.HoldReason, held = why, true
if nw, ok := g.(holdWholeCopy); ok {
st.HoldNoWholeCopy = nw.HoldNoWholeCopy(st.Name)
}
}
}
// R-480: an update that ended HELD carries the hold's sentence as its UpdateError. Once that hold
@@ -304,6 +307,11 @@ func fillHoldReason(g UpdateGuards, st *Stack) {
}
}
// holdWholeCopy is the OPTIONAL half of UpdateGuards that says a hold names no copy (R-659).
type holdWholeCopy interface {
HoldNoWholeCopy(name string) bool
}
// UpdateRefusal is a refusal taken before anything moved. Reason is a stable key for logs and tests;
// Message is the customer sentence.
type UpdateRefusal struct {
@@ -700,6 +708,19 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
fail("update.error.no_guards", "no UpdateGuards wired")
return
}
// v0.268.0 — THE LADDER (`09` §3 decision 14): which definition this ONE press pins. Decided
// first, before anything moves, so a step the catalog promises and does not carry refuses here
// rather than jumping past it. An unpinned app is left to today's behaviour (advancePinTo no-ops).
stepSrc := m.CatalogTemplatePath(name, "docker-compose.yml")
if cfg := LoadAppConfig(dir); cfg != nil && len(cfg.PinnedImages) > 0 {
step, serr := nextLadderStep(filepath.Dir(stepSrc), cfg.PinnedImages)
if serr != nil {
fail("update.error.pin_failed", "update ladder: "+serr.Error())
return
}
stepSrc = step.Source
m.logger.Printf("[INFO] [stacks] update %s: ladder — %s", name, step.Why)
}
// R-475: the precondition is a copy on ANY tier, chosen in the order 2, 1, 3, and the age rule
// applies to whichever tier is chosen. The first FRESH copy wins — not merely the first copy — so a
// stale second-drive mirror never forces a backup while the app's own unit is minutes old.
@@ -743,7 +764,7 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
// v0.263.0 — the undo's copy is PLANNED here, before anything moves: its size against the disk
// floor (decision 19's limit). The copy itself is taken after the pull, where the app stops anyway.
undoVols, perr := m.planUndoCopies(name)
undoVols, perr := m.planUndoCopies(name, dir)
if perr != nil {
if se, ok := perr.(*undoSpaceError); ok {
fail("err.stacks.update_undo_space", "undo copy: "+perr.Error(), se.need, se.free, updateDiskFloorGiB)
@@ -792,7 +813,7 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
fail("update.error.journal_failed", "journal write failed")
return
}
if err := m.advancePinToCatalog(name, dir); err != nil {
if err := m.advancePinTo(name, dir, stepSrc); err != nil {
m.pinBack(name, dir, entry)
fail("update.error.pin_failed", "advancing the pin: "+err.Error())
return