302 lines
11 KiB
Go
302 lines
11 KiB
Go
package backup
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// R-1106 (`07` §7.4, operator decision 1A): a restored database comes back with the password it was saved with.
|
|
|
|
const r1106Compose = `services:
|
|
app:
|
|
image: ghcr.io/x/app:1
|
|
environment:
|
|
- DATABASE_URL=postgresql://app:${DB_PASSWORD}@app-db:5432/app
|
|
app-db:
|
|
image: postgres:18-alpine
|
|
environment:
|
|
- POSTGRES_USER=app
|
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
|
volumes:
|
|
- app_db_data:/var/lib/postgresql
|
|
- ./init:/docker-entrypoint-initdb.d
|
|
maria:
|
|
image: mariadb:11.4
|
|
environment:
|
|
MARIADB_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-x}"
|
|
MARIADB_PASSWORD: literal-not-a-variable
|
|
MARIADB_DATABASE: app
|
|
volumes:
|
|
- type: volume
|
|
source: maria_data
|
|
target: /var/lib/mysql
|
|
cache:
|
|
image: redis:7-alpine
|
|
environment:
|
|
- REDIS_PASSWORD=${CACHE_PASSWORD}
|
|
`
|
|
|
|
// r1106OneDB is one Postgres fed by DB_PASSWORD — the Rallly shape.
|
|
const r1106OneDB = `services:
|
|
app:
|
|
image: ghcr.io/x/app:1
|
|
app-db:
|
|
image: postgres:18-alpine
|
|
environment:
|
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
|
volumes:
|
|
- app_db_data:/var/lib/postgresql
|
|
`
|
|
|
|
func TestR1106_DBCredentialVarsAndVolumes(t *testing.T) {
|
|
p := filepath.Join(t.TempDir(), "docker-compose.yml")
|
|
mustWrite(t, p, r1106Compose)
|
|
vars, err := DBCredentialVars(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// list form, map form with a default; the literal yields nothing; redis is not a database.
|
|
if want := []string{"DB_PASSWORD", "DB_ROOT_PASSWORD"}; !reflect.DeepEqual(vars, want) {
|
|
t.Errorf("DBCredentialVars = %v, want %v", vars, want)
|
|
}
|
|
vols, err := DBDataVolumesFor(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if want := []string{"app_db_data", "maria_data"}; !reflect.DeepEqual(vols, want) {
|
|
t.Errorf("DBDataVolumes = %v, want %v (binds are not volumes)", vols, want)
|
|
}
|
|
}
|
|
|
|
func TestR1106_PlanDBCredentials(t *testing.T) {
|
|
data := time.Date(2026, 10, 16, 18, 0, 0, 0, time.UTC)
|
|
before, after := data.Add(-24*time.Hour), data.Add(time.Hour)
|
|
names := []string{"DB_PASSWORD"}
|
|
cases := []struct {
|
|
name string
|
|
unit map[string]string
|
|
live map[string]string
|
|
installed time.Time
|
|
dataAt time.Time
|
|
wantErr bool
|
|
wantUnit string
|
|
}{
|
|
{"the unit's value wins over a reinstall's", map[string]string{"DB_PASSWORD": "old"}, map[string]string{"DB_PASSWORD": "new"}, after, data, false, "old"},
|
|
{"unit lacks it, install OLDER than the data → kept", nil, map[string]string{"DB_PASSWORD": "same"}, before, data, false, ""},
|
|
{"unit lacks it, install NEWER than the data → refused", nil, map[string]string{"DB_PASSWORD": "new"}, after, data, true, ""},
|
|
{"unit lacks it, install time unknown → refused", nil, map[string]string{"DB_PASSWORD": "new"}, time.Time{}, data, true, ""},
|
|
{"unit lacks it, data time unknown → refused", nil, map[string]string{"DB_PASSWORD": "new"}, before, time.Time{}, true, ""},
|
|
{"nowhere at all → refused", nil, nil, before, data, true, ""},
|
|
}
|
|
for _, c := range cases {
|
|
p, err := planDBCredentials(names, c.unit, c.live, c.installed, c.dataAt)
|
|
if (err != nil) != c.wantErr {
|
|
t.Errorf("%s: err = %v, wantErr %v", c.name, err, c.wantErr)
|
|
continue
|
|
}
|
|
if p.FromUnit["DB_PASSWORD"] != c.wantUnit {
|
|
t.Errorf("%s: FromUnit = %q, want %q", c.name, p.FromUnit["DB_PASSWORD"], c.wantUnit)
|
|
}
|
|
}
|
|
}
|
|
|
|
// r1106UnitRestoreFixture: a unit whose compose has a database fed by DB_PASSWORD, a live install deployed AFTER the
|
|
// unit's data. unitPW "" = the unit carries no DB_PASSWORD.
|
|
func r1106UnitRestoreFixture(t *testing.T, unitPW string, guest map[string]string) (*Manager, *fakeRecoveryProvider, *[]string) {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
drive := filepath.Join(tmp, "drive")
|
|
composeDir := RecoveryUnitComposePath(drive, "app")
|
|
mustWrite(t, filepath.Join(composeDir, "docker-compose.yml"), r1106OneDB)
|
|
env := "deployed: true\nenv:\n SUBDOMAIN: trips\n"
|
|
portable := []string{}
|
|
if unitPW != "" {
|
|
env += " DB_PASSWORD: " + unitPW + "\n"
|
|
portable = []string{"DB_PASSWORD"}
|
|
}
|
|
mustWrite(t, filepath.Join(composeDir, "app.yaml"), env)
|
|
man := &RecoveryManifest{SchemaVersion: 2, AppName: "app", ControllerVer: "v",
|
|
SecretEnvVars: []string{"DB_PASSWORD"}, PortableSecretEnvVars: portable,
|
|
CreatedAt: "2026-10-16T18:00:00Z", DumpsAt: "2026-10-16T18:00:00Z"}
|
|
if err := writeManifest(RecoveryUnitManifestPath(drive, "app"), man); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
live := filepath.Join(tmp, "live")
|
|
mustWrite(t, filepath.Join(live, "docker-compose.yml"), r1106OneDB)
|
|
mustWrite(t, filepath.Join(live, "app.yaml"), "deployed: true\ndeployed_at: \"2026-10-16T19:00:00Z\"\nenv: {}\n")
|
|
fake := &fakeRecoveryProvider{hdd: drive, running: true, secrets: guest, info: RecoveryInfo{StackDir: live}}
|
|
m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake}
|
|
var gen []string
|
|
m.generateSecret = func(_, envVar string) (string, bool) { gen = append(gen, envVar); return "GENERATED", true }
|
|
return m, fake, &gen
|
|
}
|
|
|
|
// The on-box restore after a reinstall: the backup's password is the one the app gets.
|
|
func TestR1106_UnitRestoreUsesTheBackupsDBPassword(t *testing.T) {
|
|
m, fake, gen := r1106UnitRestoreFixture(t, "old-pw", map[string]string{"DB_PASSWORD": "new-pw-from-reinstall"})
|
|
if _, err := m.RestoreFromRecoveryUnit("app"); err != nil {
|
|
t.Fatalf("restore: %v", err)
|
|
}
|
|
if fake.gotEnv["DB_PASSWORD"] != "old-pw" {
|
|
t.Errorf("DB_PASSWORD = %q, want the backup's value", fake.gotEnv["DB_PASSWORD"])
|
|
}
|
|
if len(*gen) != 0 {
|
|
t.Errorf("a database password was generated: %v", *gen)
|
|
}
|
|
}
|
|
|
|
// A unit without the database password, restored over a NEWER install: refused before anything changes, and never
|
|
// a generated password (O4 used to make one — the R-127 (b) trap).
|
|
func TestR1106_UnitRestoreNeverGeneratesADBPassword(t *testing.T) {
|
|
for name, guest := range map[string]map[string]string{
|
|
"no value anywhere": nil,
|
|
"only the reinstall's new value": {"DB_PASSWORD": "new-pw-from-reinstall"},
|
|
} {
|
|
m, fake, gen := r1106UnitRestoreFixture(t, "", guest)
|
|
_, err := m.RestoreFromRecoveryUnit("app")
|
|
if err == nil || !strings.Contains(err.Error(), "adatbázis jelszavát") {
|
|
t.Errorf("%s: want the refusal sentence, got %v", name, err)
|
|
}
|
|
if fake.gotEnv != nil || fake.stopped {
|
|
t.Errorf("%s: the current install was touched (recreate %v, stopped %v)", name, fake.gotEnv != nil, fake.stopped)
|
|
}
|
|
if len(*gen) != 0 {
|
|
t.Errorf("%s: a database password was generated: %v", name, *gen)
|
|
}
|
|
}
|
|
}
|
|
|
|
// credProvider wraps the reconstitute fixture's provider with the live install's secrets, its app.yaml (for the
|
|
// install time) and the narrow writer, recording WHEN the writer ran relative to the other calls.
|
|
type credProvider struct {
|
|
*recordingProvider
|
|
live map[string]string
|
|
stackDir string
|
|
written map[string]string
|
|
}
|
|
|
|
func (p *credProvider) RecoverStackSecrets(_ string, names []string) map[string]string {
|
|
out := map[string]string{}
|
|
for _, n := range names {
|
|
if v, ok := p.live[n]; ok {
|
|
out[n] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
func (p *credProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
|
|
return RecoveryInfo{StackDir: p.stackDir}, true
|
|
}
|
|
func (p *credProvider) SetStackSecretValues(_ string, values map[string]string) error {
|
|
p.written = values
|
|
p.calls = append(p.calls, "setsecrets")
|
|
return nil
|
|
}
|
|
|
|
// r1106ReconFixture: the reconstitute fixture with a database fed by DB_PASSWORD, a reinstall NEWER than the
|
|
// snapshot, the snapshot's unit carrying unitPW (or not), and the DB volume archive in the snapshot when withVolume.
|
|
func r1106ReconFixture(t *testing.T, unitPW string, withVolume bool) (*Manager, *credProvider) {
|
|
t.Helper()
|
|
m, prov, _ := reconFixture(t, "20261016T180000Z", "2026-10-16T18:00:00Z", pgDump(1))
|
|
compose := `services:
|
|
immich-server:
|
|
image: ghcr.io/immich-app/immich-server:v3.0.3
|
|
immich-postgres:
|
|
image: ghcr.io/immich-app/postgres:16-vectorchord0.4.3-pgvectors0.2.0
|
|
environment:
|
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
|
volumes:
|
|
- immich_postgres_data:/var/lib/postgresql/data
|
|
`
|
|
mustWrite(t, prov.composePath, compose)
|
|
stackDir := filepath.Dir(prov.composePath)
|
|
mustWrite(t, filepath.Join(stackDir, "app.yaml"), "deployed: true\ndeployed_at: \"2026-10-16T19:00:00Z\"\nenv: {}\n")
|
|
cp := &credProvider{recordingProvider: prov, live: map[string]string{"DB_PASSWORD": "new-pw-from-reinstall"}, stackDir: stackDir}
|
|
m.SetStackProvider(cp)
|
|
|
|
scratch, _, err := m.offboxRestoreScratchDir("immich")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
unit := findScratchUnitDir(scratch, "immich")
|
|
env := "deployed: true\nenv:\n SUBDOMAIN: photos\n"
|
|
portable := []string{}
|
|
if unitPW != "" {
|
|
env += " DB_PASSWORD: " + unitPW + "\n"
|
|
portable = []string{"DB_PASSWORD"}
|
|
}
|
|
mustWrite(t, filepath.Join(UnitComposeDir(unit), "app.yaml"), env)
|
|
man := &RecoveryManifest{SchemaVersion: 2, AppName: "immich", OffsiteRunID: "20261016T180000Z", DumpsAt: "2026-10-16T18:00:00Z",
|
|
SecretEnvVars: []string{"DB_PASSWORD"}, PortableSecretEnvVars: portable}
|
|
if err := writeManifest(filepath.Join(unit, "manifest.json"), man); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if withVolume {
|
|
seedScratchVolumes(t, m, "immich", "immich_immich_postgres_data.tar")
|
|
}
|
|
m.volumeReplayFrom = func(_, dir string) (int, error) {
|
|
cp.calls = append(cp.calls, "volumes")
|
|
entries, _ := os.ReadDir(dir)
|
|
return len(entries), nil
|
|
}
|
|
return m, cp
|
|
}
|
|
|
|
// The drill's shape (Rallly, Grimmory, 2026-10-16): reinstall, then an off-site full restore that replays the DB
|
|
// volume. The backup's password must be in the app's settings BEFORE the database starts.
|
|
func TestR1106_OffsiteRestoreWritesTheBackupsDBPasswordBeforeTheDBStarts(t *testing.T) {
|
|
m, cp := r1106ReconFixture(t, "old-pw", true)
|
|
if _, err := m.ReconstituteFromOffsite(context.Background(), "immich", false); err != nil {
|
|
t.Fatalf("ReconstituteFromOffsite: %v", err)
|
|
}
|
|
if cp.written["DB_PASSWORD"] != "old-pw" {
|
|
t.Fatalf("the backup's password was not written (got %v)", cp.written)
|
|
}
|
|
set, db := -1, -1
|
|
for i, c := range cp.calls {
|
|
if c == "setsecrets" && set < 0 {
|
|
set = i
|
|
}
|
|
if strings.HasPrefix(c, "startsvc:") && db < 0 {
|
|
db = i
|
|
}
|
|
}
|
|
if set < 0 || db < 0 || set > db {
|
|
t.Errorf("the password must be written before the database service starts; calls = %v", cp.calls)
|
|
}
|
|
}
|
|
|
|
// The unit carries no password and the install is newer than the snapshot: refused, nothing stopped or written.
|
|
func TestR1106_OffsiteRestoreRefusesWithoutTheDBPassword(t *testing.T) {
|
|
m, cp := r1106ReconFixture(t, "", true)
|
|
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
|
|
if err == nil || !strings.Contains(err.Error(), "adatbázis jelszavát") {
|
|
t.Fatalf("want the refusal sentence, got %v", err)
|
|
}
|
|
for _, c := range cp.calls {
|
|
if c == "stop" || c == "setsecrets" || c == "volumes" {
|
|
t.Errorf("the current install was touched: calls = %v", cp.calls)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// A snapshot that replays only a logical dump (no DB volume) must leave the current password alone: the dump goes
|
|
// into the current data directory, which only the current password opens.
|
|
func TestR1106_DumpOnlySnapshotKeepsTheCurrentPassword(t *testing.T) {
|
|
m, cp := r1106ReconFixture(t, "old-pw", false)
|
|
if _, err := m.ReconstituteFromOffsite(context.Background(), "immich", false); err != nil {
|
|
t.Fatalf("ReconstituteFromOffsite: %v", err)
|
|
}
|
|
if cp.written != nil {
|
|
t.Errorf("a dump-only restore rewrote the password: %v", cp.written)
|
|
}
|
|
}
|