package appbackup import ( "os" "path/filepath" "reflect" "strings" "testing" ) // R-47 (v0.153.0) — the DB-service resolver. // // These exist because a dump replay that starts the WHOLE stack races the application's own schema // management: proven live on 2026-07-19 (DIAG-immich-restore-round2-2026-07-19, H4) when // immich-server rebuilt `clip_index` two seconds before the dump's CREATE INDEX and the replay // aborted `already exists`. Closing that window means bringing up ONLY the database service, which // means naming it correctly — every case below is a way of naming it wrongly. // writeCompose drops a compose file in a temp dir and returns its path. func writeCompose(t *testing.T, body string) string { t.Helper() p := filepath.Join(t.TempDir(), "docker-compose.yml") if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } return p } // TestDBTypeForImage pins the shared heuristic. It is the SAME predicate DiscoverDatabases applies to // a running container's image, which is what makes "a dump exists ⇒ a service can be named" hold: // the compose `image:` value IS the container's image string. The table reproduces the inline form // this function replaced, byte for byte, including the redis/valkey negatives that must never be // started in the DB-only window. func TestDBTypeForImage(t *testing.T) { cases := []struct { image string want DBType ok bool }{ {"docker.io/library/postgres:16-alpine", DBTypePostgres, true}, // immich's real pin — a vector-extended postgres whose REPO segment carries the substring. {"ghcr.io/immich-app/postgres:16-vectorchord0.4.3-pgvectors0.2.0", DBTypePostgres, true}, {"postgres", DBTypePostgres, true}, {"POSTGRES:16", DBTypePostgres, true}, // the discovery path lowercases; so does this {"mariadb:11", DBTypeMariaDB, true}, {"mysql:8.4", DBTypeMariaDB, true}, {"docker.io/library/MySQL:8", DBTypeMariaDB, true}, {"redis:7-alpine", "", false}, {"valkey/valkey:8", "", false}, {"ghcr.io/immich-app/immich-server:v1.119.0", "", false}, {"", "", false}, } for _, c := range cases { got, ok := dbTypeForImage(c.image) if ok != c.ok || (ok && got != c.want) { t.Errorf("dbTypeForImage(%q) = (%q, %v), want (%q, %v)", c.image, got, ok, c.want, c.ok) } } } func TestDBServiceNames(t *testing.T) { cases := []struct { name string body string want []string }{ { name: "postgres service is named", body: "services:\n app:\n image: ghcr.io/x/app:1\n database:\n image: postgres:16\n", want: []string{"database"}, }, { name: "mariadb service is named", body: "services:\n db:\n image: mariadb:11\n web:\n image: nextcloud:30\n", want: []string{"db"}, }, { name: "mysql service is named", body: "services:\n mysql:\n image: mysql:8.4\n", want: []string{"mysql"}, }, { name: "redis-only app has no database service", body: "services:\n app:\n image: ghcr.io/x/app:1\n redis:\n image: redis:7-alpine\n", want: nil, }, { name: "multiple databases are returned SORTED (one up -d carries them all)", body: "services:\n zdb:\n image: postgres:16\n adb:\n image: mariadb:11\n app:\n image: x:1\n", want: []string{"adb", "zdb"}, }, { name: "no services key at all", body: "volumes:\n data:\n", want: nil, }, { name: "empty services map", body: "services:\n", want: nil, }, { name: "an interpolated image is not guessed at", body: "services:\n db:\n image: ${DB_IMAGE}\n", want: nil, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, err := DBServiceNames(writeCompose(t, c.body)) if err != nil { t.Fatalf("unexpected error: %v", err) } if !reflect.DeepEqual(got, c.want) { t.Errorf("DBServiceNames = %v, want %v", got, c.want) } }) } } // TestDBServiceNames_TopLevelKeysAreNotServices is the decoy test, and the reason this is a YAML // parse rather than a line scan. immich's real compose carries a top-level `volumes:` block whose // entry (`immich_ml_cache:`) sits at exactly the indentation a service name does, and a top-level // `networks:` block does the same. A scanner that collected "indented keys followed by image-ish // lines" would either invent a service that `docker compose up -d` cannot start, or — worse — match // the wrong one and leave the real database down while the app came up around the replay. func TestDBServiceNames_TopLevelKeysAreNotServices(t *testing.T) { // The service/volume/network names and the image pins are the catalog's real immich template. // `immich_postgres_data` is the trap made concrete: a top-level VOLUME key whose name contains // "postgres" and which no `up -d` could ever start. body := `services: immich-server: image: ghcr.io/immich-app/immich-server:v3.0.3 immich-machine-learning: image: ghcr.io/immich-app/immich-machine-learning:v3.0.3 immich-postgres: image: ghcr.io/immich-app/postgres:16-vectorchord0.4.3-pgvectors0.2.0 immich-redis: image: redis:7-alpine volumes: immich_ml_cache: immich_postgres_data: immich_redis_data: networks: traefik-public: external: true immich-internal: ` got, err := DBServiceNames(writeCompose(t, body)) if err != nil { t.Fatalf("unexpected error: %v", err) } if !reflect.DeepEqual(got, []string{"immich-postgres"}) { t.Fatalf("DBServiceNames = %v, want [immich-postgres] — a top-level volume/network key was mistaken for a service", got) } } // TestDBServiceNames_UnreadableAndUnparseableError proves the fail-closed direction: "cannot tell" // must surface as an ERROR, never as the empty (= "this app has no database") answer. The callers // turn an empty result into a refusal only when a dump exists; if a read failure silently produced // the same empty slice for an app with no dump, a genuinely broken compose would flow on unnoticed. func TestDBServiceNames_UnreadableAndUnparseableError(t *testing.T) { if _, err := DBServiceNames(filepath.Join(t.TempDir(), "nope.yml")); err == nil { t.Fatal("a missing compose file must be an error, not an empty service list") } // Valid YAML scalar where a map is required, plus outright broken YAML. if _, err := DBServiceNames(writeCompose(t, "services: [1, 2, 3\n broken")); err == nil { t.Fatal("an unparseable compose file must be an error, not an empty service list") } } // TestDiscoverAndComposeAgreeOnTheSameImages is the SYMMETRY guard: whatever image string makes // DiscoverDatabases produce a dump must also make DBServiceNames name a service. They now share one // predicate; this asserts the property that sharing is FOR, so a future edit to either side that // breaks it fails here rather than in a customer's restore. func TestDiscoverAndComposeAgreeOnTheSameImages(t *testing.T) { images := []string{"postgres:16", "mariadb:11", "mysql:8.4", "redis:7", "ghcr.io/x/app:1"} var body strings.Builder body.WriteString("services:\n") var wantDB []string for i, img := range images { svc := string(rune('a' + i)) body.WriteString(" " + svc + ":\n image: " + img + "\n") if _, ok := dbTypeForImage(img); ok { wantDB = append(wantDB, svc) } } got, err := DBServiceNames(writeCompose(t, body.String())) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(got, wantDB) { t.Fatalf("compose resolver named %v but the discovery predicate says %v — the two sides have drifted", got, wantDB) } }