controller v0.269.0: whole restore from the second drive; crash loops stopped; exact image digests; steps judged by their own .felhom.yml (decisions 26-28, R-661 R-666 R-667 R-668 R-664 R-665 R-662, 09 6.4 part 6)
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 12:18:39 +02:00
parent 7c3b3a9694
commit 3c6b49b31c
141 changed files with 3401 additions and 237 deletions
+51 -4
View File
@@ -295,6 +295,9 @@ func fillHoldReason(g UpdateGuards, st *Stack) {
if nw, ok := g.(holdWholeCopy); ok {
st.HoldNoWholeCopy = nw.HoldNoWholeCopy(st.Name)
}
if hk, ok := g.(holdKinder); ok {
st.HoldKind = hk.HoldKind(st.Name)
}
}
}
// R-480: an update that ended HELD carries the hold's sentence as its UpdateError. Once that hold
@@ -307,6 +310,11 @@ func fillHoldReason(g UpdateGuards, st *Stack) {
}
}
// holdKinder is the OPTIONAL half of UpdateGuards that names the hold's kind (v0.269.0).
type holdKinder interface {
HoldKind(name string) string
}
// holdWholeCopy is the OPTIONAL half of UpdateGuards that says a hold names no copy (R-659).
type holdWholeCopy interface {
HoldNoWholeCopy(name string) bool
@@ -445,6 +453,14 @@ func (m *Manager) updateMemoryRefusal(name string, st *Stack) *UpdateRefusal {
return nil
}
newMeta := LoadMetadata(filepath.Dir(catPath))
// R-664 (v0.269.0): on a ladder the NEXT STEP's own memory request is what this press installs.
if st.AppConfig != nil && len(st.AppConfig.PinnedImages) > 0 {
if step, err := nextLadderStep(filepath.Dir(catPath), st.AppConfig.PinnedImages); err == nil && step.Meta != catPath {
if mm, merr := loadMetadataFile(step.Meta); merr == nil {
newMeta = mm
}
}
}
newReq, newLim := ParseMemoryMB(newMeta.Resources.MemRequest), ParseMemoryMB(newMeta.Resources.MemLimit)
if newReq == 0 {
m.logger.Printf("[WARN] [stacks] update %s: the new template declares no memory request — proceeding without the memory check", name)
@@ -657,9 +673,32 @@ func (m *Manager) updateCompose(dir string, env []string, args ...string) (strin
}
func (m *Manager) updateHealth(ctx context.Context, name string, timeout time.Duration) (bool, string) {
if m.updateHealthFn != nil {
return m.updateHealthFor(ctx, name, timeout, "")
}
// updateHealthFor is the verify with the NEW version's own .felhom.yml (R-665/R-664): metaFile is the
// catalog's (or the step's) file journaled at the start of the job; "" = the stack dir's (an update
// journaled by an older controller).
func (m *Manager) updateHealthFor(ctx context.Context, name string, timeout time.Duration, metaFile string) (bool, string) {
if m.updateHealthFn != nil && m.updateHealthMetaFn == nil {
return m.updateHealthFn(ctx, name, timeout)
}
if metaFile != "" {
if _, err := os.Stat(metaFile); err == nil {
meta := LoadMetadata(filepath.Dir(metaFile))
if filepath.Base(metaFile) != ".felhom.yml" {
if mm, err := loadMetadataFile(metaFile); err == nil {
meta = mm
}
}
m.lastVerifyMeta = metaFile
if m.updateHealthMetaFn != nil {
return m.updateHealthMetaFn(ctx, name, timeout, &meta)
}
return m.waitUpdateHealthyMeta(ctx, name, timeout, &meta)
}
m.logger.Printf("[WARN] [stacks] update %s: the new version's .felhom.yml %s is gone — judging with the stack dir's", name, metaFile)
}
return m.waitUpdateHealthy(ctx, name, timeout)
}
@@ -712,15 +751,20 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
// 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")
stepMeta := m.CatalogTemplatePath(name, ".felhom.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
stepSrc, stepMeta = step.Source, step.Meta
m.logger.Printf("[INFO] [stacks] update %s: ladder — %s", name, step.Why)
}
// R-665 (v0.269.0): the new version is judged by ITS OWN .felhom.yml, journaled so a resumed verify
// uses it too — never by the stack dir's, which a restore rewrites with the unit's older file until
// the next catalog sync (measured on 9202 2026-09-24: a failing edge passed on the restored probe).
entry.NewMeta = stepMeta
// 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.
@@ -813,7 +857,7 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
fail("update.error.journal_failed", "journal write failed")
return
}
if err := m.advancePinTo(name, dir, stepSrc); err != nil {
if err := m.advancePinTo(name, dir, stepSrc, stepMeta); err != nil {
m.pinBack(name, dir, entry)
fail("update.error.pin_failed", "advancing the pin: "+err.Error())
return
@@ -876,7 +920,7 @@ func (m *Manager) verifyAndConclude(ctx context.Context, name, dir string, env [
}
timeout := m.healthTimeout()
waitStart := m.now()
healthy, detail := m.updateHealth(ctx, name, timeout)
healthy, detail := m.updateHealthFor(ctx, name, timeout, entry.NewMeta)
if !healthy {
m.failAndHold(ctx, name, dir, env, rp, "not healthy: "+detail, entry)
return
@@ -1131,6 +1175,9 @@ type updateJournalEntry struct {
Copied bool `json:"copied,omitempty"`
PrevMeta string `json:"prev_meta,omitempty"`
NewPin map[string]string `json:"new_pin,omitempty"`
// NewMeta (v0.269.0, R-665/R-664) is the NEW version's own .felhom.yml — the catalog's, or the
// ladder step's — used for the verify, so a resumed verify judges by the same file.
NewMeta string `json:"new_meta,omitempty"`
}
type updateJournal struct {