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
@@ -0,0 +1,507 @@
package stacks
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// v0.273.0 — the PostgreSQL major conversion (`09` §6.4 part 10, pgconvert.go). Every test runs the REAL
// guarded update job with the process boundaries faked (compose, the volume copier, the PostgreSQL
// converter, the health waits) and reads the EFFECT back: the bytes in the fake database volume, the pin,
// the phase, the hold, the kept copy. Nothing here reaches Docker (R-650).
const (
convOld = "services:\n web:\n image: docmost/docmost:0.96.0\n db:\n image: postgres:16-alpine\n volumes:\n - pgdata:/var/lib/postgresql/data\nvolumes:\n pgdata:\n"
convNew = "services:\n web:\n image: docmost/docmost:0.96.0\n db:\n image: postgres:18-alpine\n volumes:\n - pgdata:/var/lib/postgresql\nvolumes:\n pgdata:\n"
convVol = "nextcloud_pgdata" // project = the stack dir's name (newPinManager names it nextcloud)
convPin = "deployed: true\nenv: {}\npinned_images:\n web: docmost/docmost:0.96.0\n db: postgres:16-alpine\n"
convOldData = "PG16-DATADIR"
convNewData = "PG18-DATADIR"
)
func convLadder(mark string) string {
e := ` - {"from": {"web": "docmost/docmost:0.96.0", "db": "postgres:16-alpine"}, "to": {"web": "docmost/docmost:0.96.0", "db": "postgres:18-alpine"}, "digest": {}, "verdict": "proven"`
if mark != "" {
e += `, "engine_conversion": ` + mark
}
return "update_ladder:\n" + e + "}\n"
}
const goodMark = `{"service": "db", "engine": "postgres", "from": 16, "to": 18}`
// fakePG is the PostgreSQL boundary. It works on the fake copier's volume content, so "the data came
// back" is a string compare.
type fakePG struct {
mu sync.Mutex
fc *fakeCopier
calls []string
before []string
after []string // nil = equal to before
loadErr error
dumpCut bool
version string
startErr error
onConvert func(step string) // a hook to act between steps (the restart test)
}
func (p *fakePG) note(s string) {
p.mu.Lock()
p.calls = append(p.calls, s)
p.mu.Unlock()
if p.onConvert != nil {
p.onConvert(s)
}
}
func (p *fakePG) ServiceContainer(project, service string) (string, error) {
return project + "-" + service, nil
}
func (p *fakePG) Start(c string) error { p.note("start " + c); return p.startErr }
func (p *fakePG) Stop(c string) error { p.note("stop " + c); return nil }
func (p *fakePG) Env(string, string) string { return "docmost" }
func (p *fakePG) WaitReady(context.Context, string, string, time.Duration) error { return nil }
func (p *fakePG) Snapshot(c, user string) ([]string, error) {
if p.fc.vol(convVol) == convOldData {
p.note("snapshot old")
return p.before, nil
}
p.note("snapshot new")
if p.after != nil {
return p.after, nil
}
return p.before, nil
}
func (p *fakePG) DumpAll(c, user, path string) error {
p.note("dumpall")
body := "CREATE ROLE docmost;\nALTER ROLE docmost WITH SUPERUSER;\n-- data\n"
if !p.dumpCut {
body += "--\n-- " + dumpAllCompleteLine + "\n--\n"
}
return os.WriteFile(path, []byte(body), 0o600)
}
func (p *fakePG) Empty(copyVol, vol string) error {
p.note("empty " + vol)
if !p.fc.Complete(copyVol) {
return fmt.Errorf("no marker in %s", copyVol)
}
p.fc.setVol(vol, "")
return nil
}
func (p *fakePG) DropEmptyDatabases(string, string) ([]string, error) {
p.note("drop-empty")
return []string{"docmost"}, nil
}
func (p *fakePG) Roles(string, string) ([]string, error) { return []string{"docmost"}, nil }
func (p *fakePG) Load(c, user, path string, skip map[string]bool) error {
p.note("load")
if !skip["CREATE ROLE docmost;"] {
return fmt.Errorf("the existing role's CREATE line was not skipped")
}
if p.loadErr != nil {
return p.loadErr
}
p.fc.setVol(convVol, convNewData)
return nil
}
func (p *fakePG) DataVersion(string) (string, error) {
if p.version != "" {
return p.version, nil
}
return "18\n", nil
}
func (p *fakePG) called(prefix string) bool {
p.mu.Lock()
defer p.mu.Unlock()
for _, c := range p.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
// convManager: slice 4's shape with docmost on postgres:16-alpine and the catalog offering 18 through a
// one-entry ladder carrying `mark` ("" = no mark). The new version is healthy unless newHealthy is false;
// the old one always answers its old probe.
func convManager(t *testing.T, mark string) (*Manager, string, *fakeGuards, *composeRec, *fakeCopier, *fakePG) {
t.Helper()
m, dir := newPinManager(t, convOld, convNew, convPin)
mustWrite(t, AppliedComposePath(dir), convOld)
catDir := filepath.Dir(m.CatalogTemplatePath("nextcloud", "docker-compose.yml"))
mustWrite(t, filepath.Join(catDir, ".felhom.yml"), "display_name: Docmost\n"+convLadder(mark))
mustWrite(t, filepath.Join(dir, ".felhom.yml"), "display_name: Docmost\n")
m.stacks["nextcloud"].Meta = Metadata{DisplayName: "Docmost"}
g := &fakeGuards{points: []UpdateRestorePoint{{Tier: UpdateTierSecondDrive, ProvenAt: slice4T0.Add(-1 * time.Hour)}}, stackDir: dir}
c := &composeRec{fail: map[string]error{}}
m.updateGuards = g
m.updateComposeFn = c.fn
m.updateHealthFn = func(context.Context, string, time.Duration) (bool, string) { return true, "fake healthy" }
m.updateUndoHealthFn = func(context.Context, string, time.Duration, *Metadata) (bool, string) { return true, "old answered" }
m.updateMemoryFn = func(int, int, int, int) (error, string) { return nil, "" }
m.updateDiskFreeFn = func() (float64, bool) { return 50, true }
m.convertFreeFn = func(string) (int64, bool) { return 50 << 30, true }
m.updateNowFn = func() time.Time { return slice4T0 }
m.execFn = func(string, ...string) (string, error) { return "", nil }
fc := newFakeCopier(map[string]string{convVol: convOldData})
m.undoCopier = fc
pg := &fakePG{fc: fc, before: []string{"db:docmost owner=docmost", "role:docmost super=true", "ext:docmost:pg_trgm", "rows:docmost:public.users=1"}}
m.pgConv = pg
return m, dir, g, c, fc, pg
}
func dbPin(t *testing.T, dir string) string {
t.Helper()
return LoadAppConfig(dir).PinnedImages["db"]
}
// TestConvert_HappyPath — one press converts 16 → 18: the volume holds the new datadir, the pin names 18,
// the phase passed through `converting`, the OLD datadir's copy is KEPT and recorded, the dump is gone.
func TestConvert_HappyPath(t *testing.T) {
m, dir, _, c, fc, pg := convManager(t, goodMark)
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
t.Fatal(err)
}
st := waitUpdateDone(t, m, "nextcloud")
if st.UpdatePhase != UpdatePhaseDone {
t.Fatalf("ended %q (%s)", st.UpdatePhase, st.UpdateError)
}
if got := fc.vol(convVol); got != convNewData {
t.Fatalf("the database volume holds %q, want the converted datadir", got)
}
if got := dbPin(t, dir); got != "postgres:18-alpine" {
t.Fatalf("pin %q, want postgres:18-alpine", got)
}
order := strings.Join(pg.calls, ",")
for _, want := range []string{"start nextcloud-db", "snapshot old", "dumpall", "stop nextcloud-db", "empty " + convVol, "drop-empty", "load", "snapshot new"} {
if !strings.Contains(order, want) {
t.Fatalf("the conversion never did %q: %s", want, order)
}
}
if strings.Index(order, "dumpall") > strings.Index(order, "empty") {
t.Fatalf("the volume was emptied before the dump was taken: %s", order)
}
if !strings.Contains(strings.Join(c.list(), ","), "up -d --no-deps db") {
t.Fatalf("the new engine was never started alone: %v", c.list())
}
cc := LoadAppConfig(dir).ConversionCopy
if cc == nil || cc.Volume != convVol || cc.From != 16 || cc.To != 18 {
t.Fatalf("the kept copy is not recorded: %+v", cc)
}
if fc.nCopies() != 1 || fc.copies[cc.Copy] != convOldData {
t.Fatalf("the OLD datadir's copy must be kept after success (B5); copies=%v", fc.copies)
}
if _, err := os.Stat(filepath.Join(dir, preUpdateConvertDir)); !os.IsNotExist(err) {
t.Fatal("the conversion's dump must be removed after success")
}
}
// TestConvert_MajorWithoutMarkIsRefused — B1's defence in depth: a step moving PostgreSQL across a major
// with no engine_conversion mark is refused BEFORE anything moves, with the household sentence.
//
// COMPANION RED-PROOF (REPORT.md): at v0.272.0 there is no such check — the update pins 18 and runs;
// reproduced by making planEngineConversion return nil, nil: this test fails at "the preflight let it through".
func TestConvert_MajorWithoutMarkIsRefused(t *testing.T) {
m, dir, _, c, fc, pg := convManager(t, "")
ref := m.UpdatePreflight("nextcloud")
if ref == nil || ref.Reason != "engine_no_test" {
t.Fatalf("the preflight let it through: %+v", ref)
}
if want := "Ez a lépés a(z) Docmost adatbázisának fő verzióját váltaná, de nincs róla próba. Nem változott semmi."; ref.Error() != want {
t.Fatalf("sentence %q, want %q", ref.Error(), want)
}
if err := m.StartGuardedUpdate("nextcloud"); err == nil {
t.Fatal("StartGuardedUpdate accepted a PostgreSQL major move with no test")
}
if dbPin(t, dir) != "postgres:16-alpine" || fc.vol(convVol) != convOldData || len(c.list()) != 0 || len(pg.calls) != 0 {
t.Fatalf("something moved: pin=%s vol=%s compose=%v pg=%v", dbPin(t, dir), fc.vol(convVol), c.list(), pg.calls)
}
}
// TestConvert_JobRefusesAMarkThatDoesNotMatch — the job itself (not only the preflight) refuses a mark
// naming a different major; nothing is pulled.
func TestConvert_JobRefusesAMarkThatDoesNotMatch(t *testing.T) {
m, dir, _, c, _, _ := convManager(t, `{"service": "db", "engine": "postgres", "from": 16, "to": 17}`)
m.runGuardedUpdate(context.Background(), "nextcloud")
st, _ := m.GetStack("nextcloud")
if st.UpdatePhase != UpdatePhaseFailed || st.UpdateErrorKey != "err.stacks.update_engine_no_test" {
t.Fatalf("phase %q key %q, want failed with the no-test sentence", st.UpdatePhase, st.UpdateErrorKey)
}
if dbPin(t, dir) != "postgres:16-alpine" {
t.Fatal("the pin moved")
}
for _, call := range c.list() {
if strings.HasPrefix(call, "pull") {
t.Fatal("pulled before refusing")
}
}
}
func assertUndoneOnOld(t *testing.T, m *Manager, dir string, fc *fakeCopier) {
t.Helper()
st := waitUpdateDone(t, m, "nextcloud")
if st.UpdatePhase != UpdatePhaseUndone {
t.Fatalf("ended %q (%s), want undone", st.UpdatePhase, st.UpdateError)
}
if got := fc.vol(convVol); got != convOldData {
t.Fatalf("the database volume holds %q after the undo, want the OLD datadir back", got)
}
if got := dbPin(t, dir); got != "postgres:16-alpine" {
t.Fatalf("pin %q after the undo, want 16", got)
}
if LoadAppConfig(dir).ConversionCopy != nil {
t.Fatal("an undone conversion must not record a kept copy")
}
}
// TestConvert_CountsDifferAreUndone — the check after the load differs → undo, the old datadir back.
func TestConvert_CountsDifferAreUndone(t *testing.T) {
m, dir, _, _, fc, pg := convManager(t, goodMark)
pg.after = []string{"db:docmost owner=docmost", "role:docmost super=true", "ext:docmost:pg_trgm", "rows:docmost:public.users=0"}
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
t.Fatal(err)
}
assertUndoneOnOld(t, m, dir, fc)
}
// TestConvert_LoadFailureIsUndone — case (a) of Part D: the load errors → undo.
func TestConvert_LoadFailureIsUndone(t *testing.T) {
m, dir, _, _, fc, pg := convManager(t, goodMark)
pg.loadErr = errors.New("ERROR: relation already exists")
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
t.Fatal(err)
}
assertUndoneOnOld(t, m, dir, fc)
}
// TestConvert_HealthFailureIsUndone — case (b): converted fine, the app unhealthy on the new engine → undo.
func TestConvert_HealthFailureIsUndone(t *testing.T) {
m, dir, _, _, fc, pg := convManager(t, goodMark)
m.updateHealthFn = func(context.Context, string, time.Duration) (bool, string) { return false, "new version unhealthy" }
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
t.Fatal(err)
}
assertUndoneOnOld(t, m, dir, fc)
if !pg.called("load") {
t.Fatal("the fixture never reached the load — this test must fail AFTER the conversion")
}
}
// TestConvert_WrongVersionIsUndone — the new datadir's PG_VERSION is not the mark's `to` → undo.
func TestConvert_WrongVersionIsUndone(t *testing.T) {
m, dir, _, _, fc, pg := convManager(t, goodMark)
pg.version = "17"
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
t.Fatal(err)
}
assertUndoneOnOld(t, m, dir, fc)
}
// TestConvert_CutOffDumpNeverEmptiesTheVolume — a dump without its completion line stops the conversion
// BEFORE the volume is touched.
func TestConvert_CutOffDumpNeverEmptiesTheVolume(t *testing.T) {
m, dir, _, _, fc, pg := convManager(t, goodMark)
pg.dumpCut = true
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
t.Fatal(err)
}
assertUndoneOnOld(t, m, dir, fc)
if pg.called("empty") {
t.Fatal("the volume was emptied after a cut-off dump")
}
}
// TestConvert_NeverEmptiedWithoutMarker — rule 3 of the brief: the DB volume is never emptied before the
// undo copy's finished-marker is validated. The copy is made WITHOUT its marker; the volume must keep the
// old datadir and the app must be held (the undo cannot use the copy either).
//
// COMPANION RED-PROOF (REPORT.md): drop the Complete() check before Empty in convertEngine AND the marker
// test inside Empty — this test fails at "the volume was emptied".
func TestConvert_NeverEmptiedWithoutMarker(t *testing.T) {
m, _, g, _, fc, pg := convManager(t, goodMark)
fc.cutOff = true
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
t.Fatal(err)
}
st := waitUpdateDone(t, m, "nextcloud")
if fc.vol(convVol) != convOldData {
t.Fatalf("the volume was emptied (holds %q) without a validated copy", fc.vol(convVol))
}
if pg.called("empty") {
t.Fatal("Empty was called although the copy had no marker")
}
if held, _ := g.HoldFor("nextcloud"); !held || st.UpdatePhase != UpdatePhaseFailed {
t.Fatalf("held=%v phase=%q — with no usable copy the app must be HELD (data untouched)", held, st.UpdatePhase)
}
}
// TestConvert_NoSpaceIsRefusedWithNothingMoved — B6/A5: too little room beside the stack dir → refused
// before the pin, the pull or the copy, with the household sentence naming both sizes.
func TestConvert_NoSpaceIsRefusedWithNothingMoved(t *testing.T) {
m, dir, _, c, fc, pg := convManager(t, goodMark)
m.convertFreeFn = func(string) (int64, bool) { return 1 << 30, true }
m.runGuardedUpdate(context.Background(), "nextcloud")
st, _ := m.GetStack("nextcloud")
if st.UpdatePhase != UpdatePhaseFailed || st.UpdateErrorKey != "err.stacks.update_convert_space" {
t.Fatalf("phase %q key %q", st.UpdatePhase, st.UpdateErrorKey)
}
if !strings.HasPrefix(st.UpdateError, "A(z) Docmost adatbázisának átalakításához ") || !strings.HasSuffix(st.UpdateError, "Nem változott semmi.") {
t.Fatalf("sentence %q", st.UpdateError)
}
if dbPin(t, dir) != "postgres:16-alpine" || fc.nCopies() != 0 || len(pg.calls) != 0 {
t.Fatalf("something moved: pin=%s copies=%d pg=%v", dbPin(t, dir), fc.nCopies(), pg.calls)
}
for _, call := range c.list() {
if strings.HasPrefix(call, "pull") || strings.HasPrefix(call, "stop") {
t.Fatalf("compose %q ran before the refusal", call)
}
}
}
// TestConvert_RestartDuringConvertingIsUndone — B4: the controller dies after the volume was emptied; the
// journal says `converting`; the next start UNDOES it — the old datadir back, the old pin, `undone`.
func TestConvert_RestartDuringConvertingIsUndone(t *testing.T) {
m, dir, _, _, fc, pg := convManager(t, goodMark)
killed := make(chan struct{})
var once sync.Once
pg.onConvert = func(step string) {
if strings.HasPrefix(step, "empty") {
once.Do(func() { close(killed) })
select {} // the process "dies" here: this goroutine never returns
}
}
go m.runGuardedUpdate(context.Background(), "nextcloud")
select {
case <-killed:
case <-time.After(5 * time.Second):
t.Fatal("never reached the empty step")
}
time.Sleep(50 * time.Millisecond) // let Empty's own write land
fc.setVol(convVol, "") // what a real kill leaves: the volume emptied
// a NEW manager process reading the same disk
m2, _, _, _, _, _ := convManager(t, goodMark)
m2.cfg, m2.stacks, m2.undoCopier = m.cfg, map[string]*Stack{"nextcloud": {Name: "nextcloud", Deployed: true, ComposePath: filepath.Join(dir, "docker-compose.yml"), AppConfig: LoadAppConfig(dir)}}, fc
m2.pgConv = &fakePG{fc: fc}
m2.updateGuards = m.updateGuards
j := m2.readUpdateJournal().Updates["nextcloud"]
if j.Phase != UpdatePhaseConverting || !j.ConvertTouched || !j.Copied {
t.Fatalf("journal phase=%q touched=%v copied=%v — the restart must find `converting` with the copies", j.Phase, j.ConvertTouched, j.Copied)
}
if got := m2.RecoverUpdates(); len(got) != 1 {
t.Fatalf("RecoverUpdates resumed %v", got)
}
m2.ResumeInterruptedUpdates(context.Background())
assertUndoneOnOld(t, m2, dir, fc)
}
// TestConvert_ReleaseAfterAProvenBackup — B5: the kept copy stays while no backup is proven after the
// conversion, and goes (with its record) once one is.
func TestConvert_ReleaseAfterAProvenBackup(t *testing.T) {
m, dir, g, _, fc, _ := convManager(t, goodMark)
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
t.Fatal(err)
}
if st := waitUpdateDone(t, m, "nextcloud"); st.UpdatePhase != UpdatePhaseDone {
t.Fatalf("setup: %q", st.UpdatePhase)
}
if err := m.ScanStacks(); err == nil {
_ = err
}
m.mu.Lock()
m.stacks["nextcloud"].AppConfig = LoadAppConfig(dir)
m.mu.Unlock()
if got := m.ReleaseConversionCopies(context.Background()); len(got) != 0 || fc.nCopies() != 1 {
t.Fatalf("released %v with only a pre-conversion backup; copies=%d", got, fc.nCopies())
}
g.mu.Lock()
g.points = []UpdateRestorePoint{{Tier: UpdateTierLocal, ProvenAt: slice4T0.Add(time.Hour)}}
g.mu.Unlock()
if got := m.ReleaseConversionCopies(context.Background()); len(got) != 1 || fc.nCopies() != 0 {
t.Fatalf("released %v after a proven backup; copies=%d", got, fc.nCopies())
}
if LoadAppConfig(dir).ConversionCopy != nil {
t.Fatal("the record must go with the copy")
}
}
func TestConvert_PostgresMajorFromTags(t *testing.T) {
for ref, want := range map[string]int{
"postgres:16-alpine": 16, "postgres:17.2": 17, "postgres:18-alpine@sha256:ab": 18,
"postgis/postgis:16-3.5-alpine": 16, "pgvector/pgvector:pg16": 16,
"ghcr.io/immich-app/postgres:16-vectorchord0.4.3-pgvectors0.2.0": 16,
} {
if got, ok := postgresMajor(ref); !ok || got != want {
t.Errorf("%s → %d %v, want %d", ref, got, ok, want)
}
}
if _, ok := postgresMajor("postgres"); ok {
t.Error("an untagged ref has no major")
}
// within a major is not a conversion; a non-postgres move is not either
if c, err := planEngineConversion(map[string]string{"db": "postgres:16-alpine"}, map[string]string{"db": "postgres:16.4-alpine"}, nil); c != nil || err != nil {
t.Errorf("16 → 16.4 must be neither a conversion nor a refusal: %v %v", c, err)
}
if c, err := planEngineConversion(map[string]string{"r": "redis:7"}, map[string]string{"r": "redis:8"}, nil); c != nil || err != nil {
t.Errorf("redis is not postgres: %v %v", c, err)
}
}
// TestConvert_PostgresFamilyMatchesTheBackupSide — isPostgresImage must know every family the backup
// side dumps as Postgres (appbackup.dbTypeForImage, R-484); read from its source, the only way across the
// package boundary without exporting it.
func TestConvert_PostgresFamilyMatchesTheBackupSide(t *testing.T) {
src, err := os.ReadFile("../appbackup/dbservices.go")
if err != nil {
t.Fatal(err)
}
for _, fam := range []string{"postgres", "postgis", "pgvector", "timescaledb"} {
if !strings.Contains(string(src), `strings.Contains(img, "`+fam+`")`) {
t.Fatalf("the backup side no longer names %q — re-read it and update isPostgresImage", fam)
}
if !isPostgresImage("x/" + fam + ":1") {
t.Errorf("isPostgresImage misses %q", fam)
}
}
}
func TestConvert_FilterSkipsOnlyExactLines(t *testing.T) {
in := "CREATE ROLE docmost;\nCREATE ROLE docmost_ro;\nALTER ROLE docmost WITH PASSWORD 'x';\nCREATE ROLE docmost; \n"
var out bytes.Buffer
if err := filterLines(strings.NewReader(in), &out, map[string]bool{"CREATE ROLE docmost;": true}); err != nil {
t.Fatal(err)
}
want := "CREATE ROLE docmost_ro;\nALTER ROLE docmost WITH PASSWORD 'x';\nCREATE ROLE docmost; \n"
if out.String() != want {
t.Fatalf("got %q", out.String())
}
}
func TestConvert_ValidateDumpAll(t *testing.T) {
d := t.TempDir()
good, cut := filepath.Join(d, "g"), filepath.Join(d, "c")
mustWrite(t, good, strings.Repeat("x", 9000)+"\n-- "+dumpAllCompleteLine+"\n")
mustWrite(t, cut, strings.Repeat("x", 9000)+"\n-- PostgreSQL database dump complete\n")
if err := validateDumpAll(good); err != nil {
t.Fatal(err)
}
if validateDumpAll(cut) == nil {
t.Fatal("a dump ending with a PER-DATABASE completion line is not a whole cluster dump")
}
}
// TestConvert_MarkDoesNotChangeOldLadderPrints — the new field is omitempty, so R-680's failed-step
// records (tied to LadderPrint) survive the upgrade to v0.273.0.
func TestConvert_MarkDoesNotChangeOldLadderPrints(t *testing.T) {
b, _ := json.Marshal([]LadderEntry{{From: map[string]string{"a": "x:1"}, To: map[string]string{"a": "x:2"}, Verdict: "proven"}})
if strings.Contains(string(b), "engine_conversion") {
t.Fatalf("an entry without the mark prints it: %s", b)
}
}