stacks: the box converts a PostgreSQL major as a guarded-update step (09 6.4 part 10, decisions 35/37/38)
gates / gates (push) Successful in 25s

A step whose ladder entry carries engine_conversion {service, engine, from, to}
converts the database: the old engine alone, the check (owners, roles,
extensions, per-table row counts), pg_dumpall validated by its completion line,
the volume emptied only after the undo copy's marker is validated again, the new
engine alone, the load with ON_ERROR_STOP, the check again + PG_VERSION. Any
failure goes to the existing undo; a restart during converting is undone.
A PostgreSQL major move without the mark is refused before anything moves.
The old datadir's copy is kept until a backup is proven after the conversion.
17 tests, 9 red-proofs (audits/night-2026-09-26/B/).

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-25 12:57:03 +02:00
parent 3d49df1e5a
commit 2caae38a71
13 changed files with 1523 additions and 12 deletions
+91 -5
View File
@@ -79,6 +79,8 @@ var updatePhaseLabels = map[string]string{
UpdatePhaseCopying: "Az adatok másolása a frissítés előtt…",
UpdatePhaseUndoing: "Visszaállítás az előző változatra…",
UpdatePhaseUndone: "Visszaállítva az előző változatra",
// v0.273.0 — born as a bundle key (update.phase.converting).
UpdatePhaseConverting: "Adatbázis átalakítása",
}
// UpdatePhaseLabel returns the customer label for a phase, "" for an unknown one.
@@ -444,6 +446,14 @@ func (m *Manager) UpdatePreflight(name string) *UpdateRefusal {
}
m.logger.Printf("[WARN] [stacks] update %s: no backup can be taken now (%s) — an existing copy must carry the update", name, why)
}
// v0.273.0 (B1) — a PostgreSQL major move without the test's mark is refused before anything moves.
if st.AppConfig != nil && len(st.AppConfig.PinnedImages) > 0 {
if step, err := nextLadderStep(filepath.Dir(m.CatalogTemplatePath(name, "docker-compose.yml")), st.AppConfig.PinnedImages); err == nil {
if _, _, key, cerr := m.conversionFor(st, st.AppConfig.PinnedImages, step); cerr != nil && key == "err.stacks.update_engine_no_test" {
return m.refuseUpdateErr(name, "engine_no_test", util.MsgError(key, appLabel(st)), cerr.Error())
}
}
}
if ref := m.updateMemoryRefusal(name, st); ref != nil {
return ref
}
@@ -774,6 +784,21 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
}
stepSrc, stepMeta = step.Source, step.Meta
m.logger.Printf("[INFO] [stacks] update %s: ladder — %s", name, step.Why)
// v0.273.0 — A POSTGRESQL MAJOR MOVES ONLY WITH THE TEST'S MARK (`09` §6.4 part 10, B1). Decided
// here, before anything moves; the preflight asked the same question (conversionRefusal).
conv, vol, key, cerr := m.conversionFor(st, cfg.PinnedImages, step)
if cerr != nil {
if key == "" {
fail("update.error.pin_failed", "engine conversion: "+cerr.Error())
} else {
fail(key, "engine conversion: "+cerr.Error(), appLabel(st))
}
return
}
if conv != nil {
entry.Convert, entry.ConvertVolume = conv, vol
m.logger.Printf("[INFO] [stacks] update %s: this step CONVERTS %s PostgreSQL %d → %d (the ladder entry's mark); database volume %s", name, conv.Service, conv.From, conv.To, vol)
}
}
// 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
@@ -831,6 +856,24 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
}
return
}
if entry.Convert != nil {
covered := false
for _, v := range undoVols {
covered = covered || v == entry.ConvertVolume
}
if !covered {
fail("update.error.pin_failed", fmt.Sprintf("engine conversion: the database volume %s is not in the undo copy %v — refusing before anything moves", entry.ConvertVolume, undoVols))
return
}
if err := m.planConversionSpace(name, dir, entry.ConvertVolume); err != nil {
if se, ok := err.(*convertSpaceError); ok {
fail("err.stacks.update_convert_space", "engine conversion: "+err.Error(), appLabel(st), humanBytes(se.need), humanBytes(se.free))
} else {
fail("err.stacks.update_undo_copy_failed", "engine conversion space: "+err.Error())
}
return
}
}
// PINNING — the previous definition is copied aside and journaled BEFORE the pin moves, so a crash
// at any later instant can put it back (Scenario G).
@@ -914,6 +957,18 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
return
}
// CONVERTING (v0.273.0) — only on a step the ladder marks. Any failure is undone like a failed health
// check: every volume back from its copy (the old datadir included), old pin, old definition.
if entry.Convert != nil {
if !m.enterUpdatePhase(name, &entry, UpdatePhaseConverting) {
m.failAndHold(ctx, name, dir, env, rp, "journal write failed before converting", &entry)
return
}
if err := m.convertEngine(ctx, name, dir, env, &entry); err != nil {
m.failAndHold(ctx, name, dir, env, rp, "conversion failed: "+err.Error(), &entry)
return
}
}
if !m.enterUpdatePhase(name, &entry, UpdatePhaseStarting) {
m.failAndHold(ctx, name, dir, env, rp, "journal write failed before up", &entry)
return
@@ -942,7 +997,27 @@ func (m *Manager) verifyAndConclude(ctx context.Context, name, dir string, env [
m.logger.Printf("[INFO] [stacks] update %s: healthy after %s (%s)", name, m.now().Sub(waitStart).Round(time.Second), detail)
m.recordInstalledImages(name, dir, env)
_ = m.RefreshStatus()
m.removeUndoCopies(name, entry.UndoCopies)
if entry.Convert != nil {
// B5 (v0.273.0): the OLD datadir's copy stays until a backup of the converted app is proven
// (ReleaseConversionCopies); every other copy goes as usual.
var keep *undoCopy
var rest []undoCopy
for i, c := range entry.UndoCopies {
if c.Volume == entry.ConvertVolume {
keep = &entry.UndoCopies[i]
continue
}
rest = append(rest, c)
}
m.removeUndoCopies(name, rest)
if keep != nil {
m.recordConversionCopy(name, dir, &ConversionCopy{Volume: keep.Volume, Copy: keep.Copy, At: m.now().UTC().Format(time.RFC3339), From: entry.Convert.From, To: entry.Convert.To})
m.logger.Printf("[INFO] [stacks] update %s: KEEPING the pre-conversion datadir copy %s (PostgreSQL %d) until a backup of the converted app is proven", name, keep.Copy, entry.Convert.From)
}
_ = os.RemoveAll(filepath.Join(dir, preUpdateConvertDir))
} else {
m.removeUndoCopies(name, entry.UndoCopies)
}
m.recordUpdateUndone(name, dir, nil) // a successful update ends the "undone" note
m.clearFailedStep(name, dir) // R-680: and the failed-step record
m.clearJournal(name)
@@ -1203,6 +1278,11 @@ type updateJournalEntry struct {
// 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"`
// v0.273.0 (pgconvert.go): the step's engine conversion, the DB volume it rebuilds, and whether that
// volume has been touched (emptied) — journaled so a restart during `converting` runs the undo.
Convert *EngineConversion `json:"convert,omitempty"`
ConvertVolume string `json:"convert_volume,omitempty"`
ConvertTouched bool `json:"convert_touched,omitempty"`
}
type updateJournal struct {
@@ -1343,7 +1423,9 @@ func (m *Manager) RecoverUpdates() []string {
}
m.clearJournal(name)
m.finishUpdateKey(name, UpdatePhaseFailed, "update.error.interrupted", "")
case UpdatePhaseUndoing:
case UpdatePhaseUndoing, UpdatePhaseConverting:
// v0.273.0: a restart during `converting` is UNDONE the same way — the database volume may be
// emptied or half-loaded, and only the copy is known-good (B4). Never "done".
// v0.263.0: a power cut DURING the undo. Resumed like `starting` — the undo runs again from
// the copies (still there: they are removed only after the undo succeeded) and then probes.
// Never "done": what ran last was a failed new version.
@@ -1399,9 +1481,13 @@ func (m *Manager) ResumeInterruptedUpdates(ctx context.Context) int {
dir := filepath.Dir(st.ComposePath)
go func(name, dir string, e updateJournalEntry, rp UpdateRestorePoint) {
env := m.stackEnv(dir)
if e.Phase == UpdatePhaseUndoing {
m.logger.Printf("[INFO] [stacks] update %s: resuming the UNDO after a controller restart", name)
m.failAndHold(ctx, name, dir, env, rp, "resumed after a restart during the undo", &e)
if e.Phase == UpdatePhaseUndoing || e.Phase == UpdatePhaseConverting {
why := "resumed after a restart during the undo"
if e.Phase == UpdatePhaseConverting {
why = "the controller restarted during the database conversion — undoing it"
}
m.logger.Printf("[INFO] [stacks] update %s: resuming the UNDO after a controller restart (was %s)", name, e.Phase)
m.failAndHold(ctx, name, dir, env, rp, why, &e)
return
}
m.logger.Printf("[INFO] [stacks] update %s: resuming after a controller restart — `up -d` then the health wait", name)