b6810f14ff
gates / gates (push) Successful in 23s
The unit's data files are stamped with the versions that wrote them; the capture keeps the definition the data belongs to; a restore never starts data under another version's definition (unit restores refuse a mismatch; the off-site restore writes the snapshot's definition); every tier's time is its data's; the conversion-copy release needs a dump on the new engine. File-browser sync single-flight + no empty kept folder (R-695); the kept view joins the folder's owning group, language switch resyncs (R-691); a restore-generated login is not shown as the password (R-694). Red-proofs in felhom.eu/documentation/audits/version-travel-2026-09-26/. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
577 lines
24 KiB
Go
577 lines
24 KiB
Go
package backup
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// Part A of the version-travel brief (controller v0.275.0, R-696, `07` §6.6): a backup's data and its
|
|
// version travel together. Measured before the fix on 9202 (`audits/version-travel-2026-09-26/A1/`): the
|
|
// periodic refresh re-captured the unit's DEFINITION two minutes after an update, over the previous
|
|
// version's DATA; Tier 1's time moved to the refresh; a restore in that window started a PostgreSQL 16
|
|
// datadir under the 18 definition and left the app down.
|
|
//
|
|
// Every test asserts a CONSEQUENCE a household or the update would see — which definition the restore
|
|
// starts, which time the precondition reads, which copy the release trusts — not the mechanism.
|
|
|
|
// vtStack is one app's stack dir + a provider whose ImagePins are what production derives them from:
|
|
// ParseComposeImages of the stack's compose (the adapter's GetStackRecoveryInfo does exactly that).
|
|
type vtStack struct {
|
|
t *testing.T
|
|
tmp string
|
|
drive string
|
|
stackDir string
|
|
fake *fakeRecoveryProvider
|
|
m *Manager
|
|
}
|
|
|
|
func vtCompose(pgMajor string) string {
|
|
return "services:\n app:\n image: example/app:0.96.0\n app-db:\n image: postgres:" + pgMajor + "-alpine\n"
|
|
}
|
|
|
|
func newVTStack(t *testing.T, pgMajor string) *vtStack {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
v := &vtStack{t: t, tmp: tmp, drive: filepath.Join(tmp, "drive"), stackDir: filepath.Join(tmp, "stack")}
|
|
if err := os.MkdirAll(v.stackDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
v.fake = &fakeRecoveryProvider{hdd: v.drive, running: true}
|
|
v.m = &Manager{
|
|
logger: log.New(io.Discard, "", 0),
|
|
systemDataPath: filepath.Join(tmp, "system"),
|
|
stackProvider: v.fake,
|
|
version: "vtest",
|
|
}
|
|
v.setVersion(pgMajor)
|
|
return v
|
|
}
|
|
|
|
// setVersion is what an update does to the stack dir: the definition moves, and what runs moves with it.
|
|
func (v *vtStack) setVersion(pgMajor string) {
|
|
v.t.Helper()
|
|
mustWrite(v.t, filepath.Join(v.stackDir, "docker-compose.yml"), vtCompose(pgMajor))
|
|
mustWrite(v.t, filepath.Join(v.stackDir, ".felhom.yml"), "display_name: App "+pgMajor+"\n")
|
|
mustWrite(v.t, filepath.Join(v.stackDir, "app.yaml"), "deployed: true\nenv:\n SUBDOMAIN: vt\n")
|
|
v.fake.info = RecoveryInfo{
|
|
StackDir: v.stackDir,
|
|
DisplayName: "App",
|
|
ImagePins: ParseComposeImages(filepath.Join(v.stackDir, "docker-compose.yml")),
|
|
NonSecretEnv: map[string]string{"SUBDOMAIN": "vt"},
|
|
InstalledImages: map[string]string{
|
|
"app": "example/app:0.96.0@sha256:aaa",
|
|
"app-db": "postgres:" + pgMajor + "-alpine@sha256:" + pgMajor + pgMajor,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (v *vtStack) unitDir() string { return RecoveryUnitPath(v.drive, "app") }
|
|
|
|
// backupLegs is what a data run writes, through the SAME stamp call the legs make: a database dump and
|
|
// a volume tar, both dated `at` (so "the refresh ran later" is a fact of the clock, not of the test).
|
|
func (v *vtStack) backupLegs(at time.Time, marker string) {
|
|
v.t.Helper()
|
|
sql := filepath.Join(UnitDBDumpDir(v.unitDir()), "app-postgres.sql")
|
|
tar := filepath.Join(UnitVolumeDumpDir(v.unitDir()), "app_db.tar")
|
|
mustWrite(v.t, sql, pgDump(1)+"-- "+marker+"\n")
|
|
mustWrite(v.t, tar, "tar:"+marker)
|
|
for _, p := range []string{sql, tar} {
|
|
if err := os.Chtimes(p, at, at); err != nil {
|
|
v.t.Fatal(err)
|
|
}
|
|
}
|
|
v.m.stampDataFile("app", v.unitDir(), "db-dumps/app-postgres.sql")
|
|
v.m.stampDataFile("app", v.unitDir(), "volume-dumps/app_db.tar")
|
|
}
|
|
|
|
func (v *vtStack) manifest() *RecoveryManifest {
|
|
v.t.Helper()
|
|
man := readManifest(UnitManifestFile(v.unitDir()))
|
|
if man == nil {
|
|
v.t.Fatal("no readable manifest")
|
|
}
|
|
return man
|
|
}
|
|
|
|
func (v *vtStack) unitComposeImages() []string {
|
|
return ParseComposeImages(filepath.Join(UnitComposeDir(v.unitDir()), "docker-compose.yml"))
|
|
}
|
|
|
|
// theWindow builds the measured A1 state: a data run at PostgreSQL 16 two hours ago, then the update to
|
|
// 18, then the periodic refresh NOW.
|
|
func theWindow(t *testing.T) (*vtStack, time.Time) {
|
|
t.Helper()
|
|
v := newVTStack(t, "16")
|
|
dataAt := time.Now().Add(-2 * time.Hour).UTC().Truncate(time.Second)
|
|
v.backupLegs(dataAt, "written-by-16")
|
|
if err := v.m.CaptureRecoveryUnit("app"); err != nil { // the data run's capture
|
|
t.Fatal(err)
|
|
}
|
|
v.setVersion("18") // the guarded update moved the pin
|
|
if err := v.m.captureRecoveryUnit("app", false); err != nil { // the 5-minute refresh
|
|
t.Fatal(err)
|
|
}
|
|
return v, dataAt
|
|
}
|
|
|
|
// A5 red-proof 1 — the manifest refresh after a pin change no longer moves Tier 1's time.
|
|
// Pre-fix (v0.274.0): ListRestorePoints = newest of the manifest's and the dumps' mtimes → "now".
|
|
func TestA5_RefreshAfterAPinChangeDoesNotMoveTier1sTime(t *testing.T) {
|
|
v, dataAt := theWindow(t)
|
|
pts, found := v.m.ListRestorePoints("app")
|
|
if !found || len(pts) != 1 {
|
|
t.Fatalf("restore points = %v found=%v, want one", pts, found)
|
|
}
|
|
got, err := time.Parse(time.RFC3339, pts[0].Time)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !got.Equal(dataAt) {
|
|
t.Fatalf("Tier 1's time = %s, want the DATA's time %s — the refresh %s made a two-hour-old dump read as new",
|
|
got.Format(time.RFC3339), dataAt.Format(time.RFC3339), time.Since(got).Round(time.Second))
|
|
}
|
|
}
|
|
|
|
// The definition stays with its data: after the refresh, compose/ still names the 16 definition, the
|
|
// manifest says both (image_pins = what runs, data.image_pins = what wrote the data).
|
|
// Pre-fix: the refresh rewrote compose/ to 18 (A1 S3b).
|
|
func TestA2_TheUnitKeepsTheDefinitionItsDataBelongsTo(t *testing.T) {
|
|
v, _ := theWindow(t)
|
|
if got := v.unitComposeImages(); !samePins(got, ParseComposeImages(writeTmpCompose(t, vtCompose("16")))) {
|
|
t.Fatalf("the unit's compose/ names %v — the refresh paired the 18 definition with the 16 data", got)
|
|
}
|
|
man := v.manifest()
|
|
if man.Data == nil || !strings.Contains(strings.Join(man.Data.ImagePins, " "), "postgres:16-alpine") {
|
|
t.Fatalf("manifest data = %+v, want the 16 pins", man.Data)
|
|
}
|
|
if !strings.Contains(strings.Join(man.ImagePins, " "), "postgres:18-alpine") {
|
|
t.Fatalf("manifest image_pins = %v, want the app's CURRENT (18) pins", man.ImagePins)
|
|
}
|
|
if got := man.Data.Files["db-dumps/app-postgres.sql"].Images["app-db"]; !strings.HasPrefix(got, "postgres:16-alpine@sha256:") {
|
|
t.Fatalf("the dump's recorded engine = %q, want postgres:16 with its digest", got)
|
|
}
|
|
// And a SECOND refresh writes nothing: the frozen checksums describe what compose/ holds.
|
|
before, _ := os.Stat(UnitManifestFile(v.unitDir()))
|
|
time.Sleep(20 * time.Millisecond)
|
|
if err := v.m.captureRecoveryUnit("app", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after, _ := os.Stat(UnitManifestFile(v.unitDir()))
|
|
if !after.ModTime().Equal(before.ModTime()) {
|
|
t.Fatal("a second refresh rewrote the manifest — the frozen unit thrashes the drive every five minutes")
|
|
}
|
|
}
|
|
|
|
func writeTmpCompose(t *testing.T, body string) string {
|
|
t.Helper()
|
|
p := filepath.Join(t.TempDir(), "docker-compose.yml")
|
|
mustWrite(t, p, body)
|
|
return p
|
|
}
|
|
|
|
// The next data run replaces the data AND the definition: both are 18 afterwards.
|
|
func TestA2_TheNextDataRunMovesDataAndDefinitionTogether(t *testing.T) {
|
|
v, _ := theWindow(t)
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
v.backupLegs(now, "written-by-18")
|
|
if err := v.m.CaptureRecoveryUnit("app"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := strings.Join(v.unitComposeImages(), " "); !strings.Contains(got, "postgres:18-alpine") {
|
|
t.Fatalf("after a data run on 18 the unit's compose/ names %s, want 18", got)
|
|
}
|
|
if man := v.manifest(); man.Data == nil || man.Data.At != now.Format(time.RFC3339) || man.Data.Mixed {
|
|
t.Fatalf("data = %+v, want at %s, not mixed", man.Data, now.Format(time.RFC3339))
|
|
}
|
|
}
|
|
|
|
// vtRestorer records the definition the restore STARTS the data with.
|
|
type vtRestorer struct {
|
|
*fakeRecoveryProvider
|
|
startedWith []string
|
|
}
|
|
|
|
func (f *vtRestorer) RecreateStackDefinitionFromUnit(name, composeDir string, env map[string]string) error {
|
|
f.startedWith = ParseComposeImages(filepath.Join(composeDir, "docker-compose.yml"))
|
|
return f.fakeRecoveryProvider.RecreateStackDefinitionFromUnit(name, composeDir, env)
|
|
}
|
|
|
|
func vtRestoreSeams(m *Manager) (vols *[]string) {
|
|
var vd []string
|
|
m.volumeReplayFrom = func(_, dir string) (int, error) { vd = append(vd, dir); return 1, nil }
|
|
m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
|
|
return []DiscoveredDB{{StackName: "app", ContainerName: "app-db", DBType: DBTypePostgres}}, nil
|
|
}
|
|
m.importDBDump = func(context.Context, DiscoveredDB, string) error { return nil }
|
|
return &vd
|
|
}
|
|
|
|
// A5 red-proof 3 — a restore in the window starts the OLD version with the OLD data, and says so.
|
|
// Pre-fix: the unit's definition was 18 (the refresh), so the 16 datadir was started under 18 (A1 S4).
|
|
func TestA5_ARestoreInTheWindowStartsTheOldVersionWithTheOldData(t *testing.T) {
|
|
v, dataAt := theWindow(t)
|
|
rec := &vtRestorer{fakeRecoveryProvider: v.fake}
|
|
v.m.stackProvider = rec
|
|
vtRestoreSeams(v.m)
|
|
|
|
res, err := v.m.RestoreFromRecoveryUnit("app")
|
|
if err != nil {
|
|
t.Fatalf("restore: %v", err)
|
|
}
|
|
if got := strings.Join(rec.startedWith, " "); !strings.Contains(got, "postgres:16-alpine") || strings.Contains(got, "postgres:18") {
|
|
t.Fatalf("the restore started the 16 data with the definition %s — a restore must never mix versions", got)
|
|
}
|
|
if !res.VersionChanged || !samePins(res.DataPins, ParseComposeImages(writeTmpCompose(t, vtCompose("16")))) || !res.DataAt.Equal(dataAt) {
|
|
t.Fatalf("result = changed %v pins %v at %s, want changed, the 16 pins, %s", res.VersionChanged, res.DataPins, res.DataAt, dataAt)
|
|
}
|
|
}
|
|
|
|
// A restore never starts data with a definition it does not belong to: a unit whose compose/ names other
|
|
// pins than its data, and a unit whose files were written by different versions, are refused BEFORE
|
|
// anything is touched (no stop, no volume, no recreate).
|
|
func TestA3_AMismatchedOrMixedUnitIsRefusedBeforeAnythingMoves(t *testing.T) {
|
|
t.Run("definition-not-the-data's", func(t *testing.T) {
|
|
v, _ := theWindow(t)
|
|
// What v0.274.0's refresh left behind: the NEW definition in compose/ over the old data.
|
|
mustWrite(t, filepath.Join(UnitComposeDir(v.unitDir()), "docker-compose.yml"), vtCompose("18"))
|
|
vols := vtRestoreSeams(v.m)
|
|
_, err := v.m.RestoreFromRecoveryUnit("app")
|
|
if !errors.Is(err, ErrUnitVersionMismatch) {
|
|
t.Fatalf("err = %v, want ErrUnitVersionMismatch", err)
|
|
}
|
|
if len(v.fake.calls) != 0 || len(*vols) != 0 {
|
|
t.Fatalf("the refusal touched the app: calls=%v volumes=%v", v.fake.calls, *vols)
|
|
}
|
|
})
|
|
t.Run("mixed", func(t *testing.T) {
|
|
v, _ := theWindow(t)
|
|
// The DB leg ran under 18, the volume leg failed and kept its 16 tar.
|
|
sql := filepath.Join(UnitDBDumpDir(v.unitDir()), "app-postgres.sql")
|
|
mustWrite(t, sql, pgDump(1))
|
|
v.m.stampDataFile("app", v.unitDir(), "db-dumps/app-postgres.sql")
|
|
if err := v.m.CaptureRecoveryUnit("app"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if man := v.manifest(); man.Data == nil || !man.Data.Mixed {
|
|
t.Fatalf("data = %+v, want Mixed", man.Data)
|
|
}
|
|
vols := vtRestoreSeams(v.m)
|
|
_, err := v.m.RestoreFromRecoveryUnit("app")
|
|
if !errors.Is(err, ErrUnitVersionMismatch) {
|
|
t.Fatalf("err = %v, want ErrUnitVersionMismatch", err)
|
|
}
|
|
if len(v.fake.calls) != 0 || len(*vols) != 0 {
|
|
t.Fatalf("the refusal touched the app: calls=%v volumes=%v", v.fake.calls, *vols)
|
|
}
|
|
})
|
|
}
|
|
|
|
// An older unit (no stamps) restores as before: its definition, VersionsUnknown, no refusal.
|
|
func TestA3_AnUnstampedUnitRestoresAsBefore(t *testing.T) {
|
|
v := newVTStack(t, "16")
|
|
mustWrite(t, filepath.Join(UnitDBDumpDir(v.unitDir()), "app-postgres.sql"), pgDump(1))
|
|
if err := v.m.CaptureRecoveryUnit("app"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if man := v.manifest(); man.Data != nil {
|
|
t.Fatalf("an unstamped dump produced data %+v — unknown must stay unknown", man.Data)
|
|
}
|
|
rec := &vtRestorer{fakeRecoveryProvider: v.fake}
|
|
v.m.stackProvider = rec
|
|
vtRestoreSeams(v.m)
|
|
res, err := v.m.RestoreFromRecoveryUnit("app")
|
|
if err != nil {
|
|
t.Fatalf("restore: %v", err)
|
|
}
|
|
if !res.VersionsUnknown || res.VersionChanged || len(rec.startedWith) == 0 {
|
|
t.Fatalf("result = %+v started=%v, want VersionsUnknown and the unit's definition started", res, rec.startedWith)
|
|
}
|
|
}
|
|
|
|
// A5 red-proof 4 — the update's precondition refuses a stale dump that a refresh made look new.
|
|
// Pre-fix: Tier 1 = the refresh's manifest time → "0m old" → accepted, and the update leaned on a copy
|
|
// of the previous version's data from two hours before.
|
|
func TestA5_ThePreconditionRefusesAStaleDumpARefreshMadeLookNew(t *testing.T) {
|
|
v, dataAt := theWindow(t)
|
|
now := time.Now()
|
|
fresh := func(p UpdateTierPoint) bool { return now.Sub(p.At) <= time.Hour }
|
|
p, ok, seen := v.m.UpdateRestorePoints(context.Background(), "app", fresh)
|
|
if ok {
|
|
t.Fatalf("the precondition ACCEPTED tier %d at %s (%s old) — the data is from %s",
|
|
p.Tier, p.At.Format(time.RFC3339), now.Sub(p.At).Round(time.Second), dataAt.Format(time.RFC3339))
|
|
}
|
|
if len(seen) != 1 || seen[0].Tier != UpdateTierLocal || !seen[0].At.Equal(dataAt) {
|
|
t.Fatalf("seen = %+v, want Tier 1 at the data's time %s", seen, dataAt.Format(time.RFC3339))
|
|
}
|
|
}
|
|
|
|
// A unit with no data file at all is dated by the data run that confirmed it, and a refresh keeps it.
|
|
func TestA4_AUnitWithoutDataFilesIsDatedByItsDataRun(t *testing.T) {
|
|
v := newVTStack(t, "16")
|
|
if err := v.m.CaptureRecoveryUnit("app"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first := v.manifest().Data
|
|
if first == nil || first.At == "" {
|
|
t.Fatalf("data = %+v, want the data run's time", first)
|
|
}
|
|
v.setVersion("18")
|
|
if err := v.m.captureRecoveryUnit("app", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := v.manifest().Data; got == nil || got.At != first.At {
|
|
t.Fatalf("a refresh moved the data time: %+v → %+v", first, got)
|
|
}
|
|
}
|
|
|
|
// The undo copies are not data: an update's safety dump written after the backup must not date Tier 1.
|
|
func TestA4_AnUndoCopyNeverDatesTheUnit(t *testing.T) {
|
|
v := newVTStack(t, "16")
|
|
old := time.Now().Add(-3 * time.Hour).UTC().Truncate(time.Second)
|
|
mustWrite(t, filepath.Join(UnitDBDumpDir(v.unitDir()), "app-postgres.sql"), pgDump(1))
|
|
_ = os.Chtimes(filepath.Join(UnitDBDumpDir(v.unitDir()), "app-postgres.sql"), old, old)
|
|
if err := v.m.captureRecoveryUnit("app", false); err != nil { // unstamped: the legacy rule
|
|
t.Fatal(err)
|
|
}
|
|
mustWrite(t, filepath.Join(UnitDBDumpDir(v.unitDir()), preRestoreDumpPrefix+"20260926T000000Z-app-postgres.sql"), pgDump(1))
|
|
pts, _ := v.m.ListRestorePoints("app")
|
|
if len(pts) != 1 || pts[0].Time != old.Format(time.RFC3339) {
|
|
t.Fatalf("Tier 1 = %+v, want the dump's %s (not the undo copy's, not the manifest's)", pts, old.Format(time.RFC3339))
|
|
}
|
|
}
|
|
|
|
// The stamps are written by the PRODUCTION leg (seam discipline): RunAppBackupNow → the dump seam → the
|
|
// stamp → the capture's `data`. A stamp helper nobody calls is the "seam built but never wired" shape.
|
|
func TestA2_TheUpdatesOwnBackupStampsItsDataThroughTheRealLeg(t *testing.T) {
|
|
v := newVTStack(t, "16")
|
|
v.m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
|
|
return []DiscoveredDB{{StackName: "app", ContainerName: "app-db", DBType: DBTypePostgres}}, nil
|
|
}
|
|
v.m.dumpOne = func(_ context.Context, db DiscoveredDB, dir string, _ *log.Logger, _ bool) DumpResult {
|
|
p := filepath.Join(dir, "app-postgres.sql")
|
|
mustWrite(t, p, pgDump(1))
|
|
return DumpResult{DB: db, FilePath: p}
|
|
}
|
|
v.m.perAppTier2 = func(string) error { return nil }
|
|
if err := v.m.RunAppBackupNow(context.Background(), "app"); err != nil {
|
|
t.Fatalf("RunAppBackupNow: %v", err)
|
|
}
|
|
man := v.manifest()
|
|
if man.Data == nil || man.Data.Mixed || len(man.Data.Files) != 1 {
|
|
t.Fatalf("data = %+v, want one stamped dump", man.Data)
|
|
}
|
|
st := man.Data.Files["db-dumps/app-postgres.sql"]
|
|
if st.Images["app-db"] != "postgres:16-alpine@sha256:1616" || !samePins(st.Pins, v.fake.info.ImagePins) {
|
|
t.Fatalf("stamp = %+v, want the running images and the definition's pins", st)
|
|
}
|
|
var raw map[string]interface{}
|
|
b, _ := os.ReadFile(UnitManifestFile(v.unitDir()))
|
|
_ = json.Unmarshal(b, &raw)
|
|
if _, ok := raw["data"]; !ok {
|
|
t.Fatal("manifest.json carries no `data` key")
|
|
}
|
|
}
|
|
|
|
// Tier 2: a mirror taken after the update of a unit whose data is older is as old as that data.
|
|
func TestA4_ATier2CopyIsAsOldAsItsData(t *testing.T) {
|
|
copyAt := time.Now().UTC().Truncate(time.Second)
|
|
dataAt := copyAt.Add(-2 * time.Hour)
|
|
p := Tier2RestorePoint{Restorable: true, CopyDateProven: true, CopyLastSuccess: copyAt.Format(time.RFC3339), DataDate: dataAt.Format(time.RFC3339)}
|
|
got, ok := p.ProvenCopyTime()
|
|
if !ok || !got.Equal(dataAt) {
|
|
t.Fatalf("Tier 2 proven at %s ok=%v, want the data's %s", got, ok, dataAt)
|
|
}
|
|
p.DataDate = ""
|
|
if got, _ := p.ProvenCopyTime(); !got.Equal(copyAt) {
|
|
t.Fatalf("with no data date, Tier 2 = %s, want the copy's %s (as before)", got, copyAt)
|
|
}
|
|
}
|
|
|
|
// The household's version label names every image — a PostgreSQL step is visible in it.
|
|
func TestA3_PinsVersionNamesEveryImage(t *testing.T) {
|
|
got := PinsVersion([]string{"docmost/docmost:0.96.0@sha256:b5", "postgres:16-alpine@sha256:72", "gitea.dooplex.hu/x/redis:7-alpine"})
|
|
if got != "docmost:0.96.0, postgres:16-alpine, redis:7-alpine" {
|
|
t.Fatalf("PinsVersion = %q", got)
|
|
}
|
|
}
|
|
|
|
// vtReconProvider is the reconstitution fixture's provider with a LIVE version (18) and a recorder for
|
|
// the definition the restore writes.
|
|
type vtReconProvider struct {
|
|
*recordingProvider
|
|
livePins []string
|
|
wroteDef []string
|
|
wroteAtCall int
|
|
}
|
|
|
|
func (p *vtReconProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
|
|
return RecoveryInfo{DisplayName: "Immich", ImagePins: p.livePins}, true
|
|
}
|
|
func (p *vtReconProvider) RecreateStackDefinitionFromUnit(_, composeDir string, _ map[string]string) error {
|
|
p.wroteDef = ParseComposeImages(filepath.Join(composeDir, "docker-compose.yml"))
|
|
p.wroteAtCall = len(p.calls)
|
|
p.calls = append(p.calls, "recreate")
|
|
return nil
|
|
}
|
|
|
|
// vtSnapshotUnit gives the fixture's scratch unit a definition and a `data` block (a snapshot taken by
|
|
// v0.275.0), and returns the unit dir.
|
|
func vtSnapshotUnit(t *testing.T, m *Manager, pgMajor string, withData bool) string {
|
|
t.Helper()
|
|
scratch, _, err := m.offboxRestoreScratchDir("immich")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var unit string
|
|
_ = filepath.Walk(scratch, func(p string, fi os.FileInfo, _ error) error {
|
|
if fi != nil && !fi.IsDir() && fi.Name() == "manifest.json" {
|
|
unit = filepath.Dir(p)
|
|
}
|
|
return nil
|
|
})
|
|
if unit == "" {
|
|
t.Fatal("no scratch unit")
|
|
}
|
|
mustWrite(t, filepath.Join(UnitComposeDir(unit), "docker-compose.yml"), vtCompose(pgMajor))
|
|
mustWrite(t, filepath.Join(UnitComposeDir(unit), "app.yaml"), "deployed: true\nenv:\n SUBDOMAIN: vt\n")
|
|
man := readManifest(UnitManifestFile(unit))
|
|
if withData {
|
|
man.Data = &UnitData{At: "2026-09-26T02:15:01Z", ImagePins: ParseComposeImages(filepath.Join(UnitComposeDir(unit), "docker-compose.yml"))}
|
|
}
|
|
if err := writeManifest(UnitManifestFile(unit), man); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return unit
|
|
}
|
|
|
|
// Off-site (Tier 3): v0.274.0 never wrote the definition — last night's snapshot data went under the
|
|
// app's NEW definition (A1, read from source). Now the snapshot's own definition is written BEFORE any
|
|
// file, volume or database is touched, and the database service is resolved from it.
|
|
func TestA3_TheOffsiteRestoreBringsTheSnapshotsVersionBack(t *testing.T) {
|
|
m, prov, imported := reconFixture(t, "20260926T021500Z", "2026-09-26T02:15:01Z", pgDump(1))
|
|
vp := &vtReconProvider{recordingProvider: prov, livePins: ParseComposeImages(writeTmpCompose(t, vtCompose("18")))}
|
|
m.SetStackProvider(vp)
|
|
vtSnapshotUnit(t, m, "16", true)
|
|
|
|
res, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
|
|
if err != nil {
|
|
t.Fatalf("reconstitute: %v", err)
|
|
}
|
|
if got := strings.Join(vp.wroteDef, " "); !strings.Contains(got, "postgres:16-alpine") {
|
|
t.Fatalf("the restore wrote the definition %q — want the snapshot's 16", got)
|
|
}
|
|
if vp.wroteAtCall != 1 || vp.calls[0] != "stop" {
|
|
t.Fatalf("calls = %v — the definition must be written right after the stop, before any data", vp.calls)
|
|
}
|
|
if !res.VersionChanged || res.DataAt.Format(time.RFC3339) != "2026-09-26T02:15:01Z" || len(*imported) != 1 {
|
|
t.Fatalf("result changed=%v at=%s imported=%v", res.VersionChanged, res.DataAt, *imported)
|
|
}
|
|
if got := strings.Join(vp.gotServices, ","); got != "app-db" {
|
|
t.Fatalf("the DB-only start was %q — the database service must come from the definition that RUNS (the snapshot's)", got)
|
|
}
|
|
}
|
|
|
|
// Same version, or a snapshot from before v0.275.0: nothing is written — the path of every earlier release.
|
|
func TestA3_TheOffsiteRestoreLeavesTheDefinitionWhenNothingDiffers(t *testing.T) {
|
|
for _, c := range []struct {
|
|
name string
|
|
live string
|
|
withData bool
|
|
unknown bool
|
|
}{{"same-version", "16", true, false}, {"pre-v0.275.0-snapshot", "18", false, true}} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
m, prov, _ := reconFixture(t, "20260926T021500Z", "2026-09-26T02:15:01Z", pgDump(1))
|
|
vp := &vtReconProvider{recordingProvider: prov, livePins: ParseComposeImages(writeTmpCompose(t, vtCompose(c.live)))}
|
|
m.SetStackProvider(vp)
|
|
vtSnapshotUnit(t, m, "16", c.withData)
|
|
res, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
|
|
if err != nil {
|
|
t.Fatalf("reconstitute: %v", err)
|
|
}
|
|
if vp.wroteDef != nil || res.VersionChanged || res.VersionsUnknown != c.unknown {
|
|
t.Fatalf("wrote %v changed=%v unknown=%v", vp.wroteDef, res.VersionChanged, res.VersionsUnknown)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Tier 3: a snapshot pushed after a failed dump leg carries older data than its own time — the recorded
|
|
// push caps it; a snapshot newer than the last recorded push (another box) keeps its own time.
|
|
func TestA4_AnOffsiteCopyIsAsOldAsTheDataItWasPushedWith(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
snap := time.Date(2026, 9, 26, 2, 15, 0, 0, time.UTC)
|
|
if got := m.offsiteDataTime("app", snap); !got.Equal(snap) {
|
|
t.Fatalf("no record: %s, want the snapshot's own time", got)
|
|
}
|
|
data := snap.Add(-24 * time.Hour)
|
|
if err := sett.SetOffsiteDataAt("app", settings.OffsiteDataRecord{PushedAt: snap.Add(time.Minute).Format(time.RFC3339), DataAt: data.Format(time.RFC3339)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := m.offsiteDataTime("app", snap); !got.Equal(data) {
|
|
t.Fatalf("recorded push: %s, want the data's %s", got, data)
|
|
}
|
|
later := snap.Add(2 * time.Hour)
|
|
if got := m.offsiteDataTime("app", later); !got.Equal(later) {
|
|
t.Fatalf("a snapshot newer than the recorded push: %s, want its own %s", got, later)
|
|
}
|
|
}
|
|
|
|
// The push records the pushed unit's DATA time (the production call in runOffboxInternal).
|
|
func TestA4_ThePushRecordsTheUnitsDataTime(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
v := newVTStack(t, "16")
|
|
dataAt := time.Now().Add(-26 * time.Hour).UTC().Truncate(time.Second)
|
|
v.backupLegs(dataAt, "x")
|
|
if err := v.m.CaptureRecoveryUnit("app"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.recordOffsiteDataAt("app", v.unitDir())
|
|
rec, ok := sett.GetOffsiteDataAt("app")
|
|
if !ok || rec.DataAt != dataAt.Format(time.RFC3339) || rec.PushedAt == "" {
|
|
t.Fatalf("record = %+v ok=%v, want data at %s", rec, ok, dataAt.Format(time.RFC3339))
|
|
}
|
|
}
|
|
|
|
// The production push path writes the record (seam discipline: recordOffsiteDataAt is CALLED by
|
|
// runOffboxInternal after a successful snapshot, not only testable on its own).
|
|
func TestA4_TheOffsiteRunRecordsThePushedDataTime(t *testing.T) {
|
|
drive := t.TempDir()
|
|
m, sett, prov := classifiedOffboxManager(t, drive)
|
|
u := mkUnit(t, drive, "immich")
|
|
if err := writeManifest(UnitManifestFile(u), &RecoveryManifest{AppName: "immich", Data: &UnitData{At: "2026-09-25T02:15:01Z"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
prov.hdd["immich"] = drive
|
|
prov.has["immich"] = true
|
|
_ = sett.SetAppOffbox("immich", true)
|
|
m.SetOffsitePreDumpFn(func(context.Context) error { return nil })
|
|
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
|
switch {
|
|
case contains(args, "cat") && contains(args, "config"):
|
|
return []byte(`{"version":2}`), nil
|
|
case contains(args, "snapshots"):
|
|
return []byte(`[]`), nil
|
|
case contains(args, "stats"):
|
|
return []byte(`{"total_size":123}`), nil
|
|
}
|
|
return nil, nil
|
|
})
|
|
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
rec, ok := sett.GetOffsiteDataAt("immich")
|
|
if !ok || rec.DataAt != "2026-09-25T02:15:01Z" || rec.PushedAt == "" {
|
|
t.Fatalf("after a successful push the data-time record is %+v ok=%v, want the unit's data time", rec, ok)
|
|
}
|
|
}
|