Files
felhom-controller/controller/internal/backup/restore_pull_test.go
T

139 lines
5.7 KiB
Go

package backup
import (
"context"
"errors"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// D48 (operator ruling 2026-10-25; `09` §3 decision 257) — a restore downloads first. Before (`e1613ae`): the first pull
// was inside the database-only start, AFTER the stop; a version that could not be downloaded ended in the D22 put-back.
// COMPANION RED-PROOF (REPORT.md): with prepareRestoreImages not called, TestD48_AnUnpullablePinIsRefusedBeforeTheStop
// and TestD48_TheOffsiteRestoreToo fail — the app is stopped (the calls start with "stop").
func d48Unpullable(m *Manager, answer string) *[]string {
var pulled []string
m.imagePresentFn = func(string) bool { return false }
m.imagePullFn = func(_ context.Context, ref string) (string, error) {
pulled = append(pulled, ref)
return answer, errors.New("exit status 1")
}
return &pulled
}
// An unpullable pin: refused, the stop never called, nothing poured, no copy made, no definition written.
func TestD48_AnUnpullablePinIsRefusedBeforeTheStop(t *testing.T) {
m, p, rig, imported := r1177Fixture(t)
pulled := d48Unpullable(m, "Error response from daemon: manifest for example/app:9.9.9 not found: manifest unknown")
var notified []string
m.SetRestorePullRefusedNotify(func(stack, ref string, _ error) { notified = append(notified, stack+" "+ref) })
_, err := m.RestoreFromRecoveryUnit("app")
if !errors.Is(err, ErrRestoreImageUnavailable) || len(*pulled) == 0 {
t.Fatalf("an unpullable pin must refuse, got %v (pulled %v)", err, *pulled)
}
if want := util.MsgError("err.backup.restore_image_gone", PinsVersion([]string{(*pulled)[0]})).Error(); err.Error() != want {
t.Fatalf("a deleted version reads its own sentence, got %q", err.Error())
}
if p.stopped || len(p.calls) != 0 {
t.Fatalf("the app must not be stopped or touched at all, calls %v", p.calls)
}
if len(rig.poured) != 0 || len(rig.defsWrote) != 0 || len(*imported) != 0 || len(undoCopies(t, rig.undoRoot)) != 0 {
t.Fatalf("nothing may move: poured %v, defs %v, imported %v", rig.poured, rig.defsWrote, *imported)
}
if len(notified) != 1 {
t.Fatalf("the operator gets one notice, got %v", notified)
}
}
// A registry that is busy or unreachable reads differently from a deleted version.
func TestD48_AnUnreachableRegistryReadsDifferently(t *testing.T) {
m, p, _, _ := r1177Fixture(t)
pulled := d48Unpullable(m, "Error response from daemon: Get \"https://registry-1.docker.io/v2/\": dial tcp: lookup registry-1.docker.io: i/o timeout")
_, err := m.RestoreFromRecoveryUnit("app")
if len(*pulled) == 0 {
t.Fatalf("the restore never asked for the image (err %v)", err)
}
if want := util.MsgError("err.backup.restore_image_unreachable", PinsVersion([]string{(*pulled)[0]})).Error(); err == nil || err.Error() != want {
t.Fatalf("an unreachable registry reads „try again later\", got %v", err)
}
if p.stopped {
t.Fatal("the app must not be stopped")
}
}
// A pullable pin: pulled BEFORE the stop, and the restore runs as before.
func TestD48_APullablePinIsPulledFirstAndTheRestoreRuns(t *testing.T) {
m, p, _, imported := r1177Fixture(t)
m.imagePresentFn = func(string) bool { return false }
m.imagePullFn = func(_ context.Context, ref string) (string, error) {
p.calls = append(p.calls, "pull:"+ref)
return "Status: Downloaded newer image", nil
}
if _, err := m.RestoreFromRecoveryUnit("app"); err != nil {
t.Fatalf("a pullable restore runs: %v", err)
}
if len(p.calls) == 0 || !strings.HasPrefix(p.calls[0], "pull:") {
t.Fatalf("the pull must come before the stop, calls %v", p.calls)
}
stop := -1
for i, c := range p.calls {
if c == "stop" {
stop = i
break
}
}
for i, c := range p.calls {
if strings.HasPrefix(c, "pull:") && i > stop {
t.Fatalf("every pull before the stop, calls %v", p.calls)
}
}
if len(*imported) != 1 {
t.Fatalf("the restore ran as before (one database replayed), got %v", *imported)
}
}
// The second drive's whole restore asks the unit half BEFORE its file half (R-1178): the pull is in that check.
func TestD48_TheCheckOnlyPassCarriesThePull(t *testing.T) {
m, p, _, _ := r1177Fixture(t)
d48Unpullable(m, "manifest unknown")
unit := m.primaryUnitDirFor("app")
if _, err := m.RestoreFromRecoveryUnitAtWith("app", unit, UnitRestoreOptions{CheckOnly: true}); !errors.Is(err, ErrRestoreImageUnavailable) {
t.Fatalf("the check must refuse an unpullable pin, got %v", err)
}
if p.stopped {
t.Fatal("the check never stops the app")
}
}
// The off-site restore: refused before the stop too.
func TestD48_TheOffsiteRestoreToo(t *testing.T) {
m, prov, imported := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1))
d48Unpullable(m, "pull access denied for example/immich, repository does not exist")
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if !errors.Is(err, ErrRestoreImageUnavailable) {
t.Fatalf("an off-site restore with an unpullable image must refuse, got %v", err)
}
if len(prov.calls) != 0 || len(*imported) != 0 {
t.Fatalf("the app must not be stopped and nothing replayed: calls %v, imported %v", prov.calls, *imported)
}
}
// Docker's answers, as the two sentences read them.
func TestD48_PullGone(t *testing.T) {
for out, want := range map[string]bool{
"manifest for x:1 not found: manifest unknown": true,
"pull access denied for x, repository does not exist or may require": true,
"toomanyrequests: You have reached your pull rate limit": false,
"dial tcp: lookup registry-1.docker.io: i/o timeout": false,
"net/http: TLS handshake timeout": false,
} {
if got := pullGone(out); got != want {
t.Errorf("pullGone(%q) = %v, want %v", out, got, want)
}
}
}