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

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)
}
}