bdcbd50b42
gates / gates (push) Successful in 13s
R-486 (P1): removing an app with its backups KEPT keeps its Tier-2 record, so the second-drive restore is no longer refused over an intact mirror. R-484: postgis/pgvector/timescaledb images are Postgres (logical dumps). R-485: the backup card sizes the recovery unit and the mirror(s). R-480: a held update's sentence leaves the card once the hold is lifted. R-477: the update's off-site lookup is one snapshots call, no stats. R-478: a copy older than this install's deploy does not count. R-474: "delete backups" deletes the unit, the mirror(s) and the prefs. Tests and red-proofs per row; evidence in felhom.eu documentation/audits/v0240-2026-09-13/ and nightly-2026-09-13-adventurelog/.
200 lines
7.6 KiB
Go
200 lines
7.6 KiB
Go
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},
|
|
// R-484: adventurelog's real pin — no "postgres" substring anywhere in the reference.
|
|
{"postgis/postgis:16-3.5-alpine", DBTypePostgres, true},
|
|
{"pgvector/pgvector:pg16", DBTypePostgres, true},
|
|
{"timescale/timescaledb:2.17.0-pg16", 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)
|
|
}
|
|
}
|