v0.324.0: the database password comes back with the data (R-1106, R-127b, R-926); restic 0.19.1 (R-1107); R-1108, R-1065, R-1017; dashboard hardening (R-983, R-976)
gates / gates (push) Successful in 44s
gates / gates (push) Successful in 44s
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user