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
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user