package appbackup import ( "fmt" "os" "sort" "strings" "gopkg.in/yaml.v3" ) // R-47 — naming the database SERVICE, not just the running container. // // A dump replay must never race the application's own schema management. Proven live on 2026-07-19 // (DIAG-immich-restore-round2-2026-07-19, H4): the reconstitution started the whole stack before // replaying, immich-server rebuilt `clip_index` two seconds before the dump's own CREATE INDEX, and // the replay aborted `already exists` under ON_ERROR_STOP=1 — leaving a half-applied schema that the // app itself then reported as drift. The fix is to bring up ONLY the database service(s) for the // replay, which requires knowing their compose SERVICE names (docker `up -d ` takes service // names, not container names). // // The symmetry that makes this safe: a `.sql` dump can only exist because DiscoverDatabases matched // the running container's image string, and the compose `image:` value IS that image string. So the // same predicate — dbTypeForImage — decides both "is there a dump" and "which service holds it". // dbTypeForImage maps a container/compose image reference to the database engine the backup code // supports, or ok=false for anything else (redis/valkey/app images — never started in the DB-only // phase). Extracted from DiscoverDatabases so the discovery heuristic and the compose heuristic can // never drift apart; behaviour is byte-equivalent to the inline form it replaced. func dbTypeForImage(image string) (DBType, bool) { img := strings.ToLower(image) switch { case strings.Contains(img, "postgres"): return DBTypePostgres, true case strings.Contains(img, "mariadb"), strings.Contains(img, "mysql"): return DBTypeMariaDB, true } return DBType(""), false } // composeServicesDoc is the minimal view of a compose file needed here: the `services:` MAP and each // service's `image:`. Deliberately a real YAML parse and not a line scan — a top-level `volumes:` // block (immich's `immich_ml_cache:`) has exactly the shape a naive scan misreads as a service, and // starting a phantom service, or missing the real one, both land in the wrong branch. type composeServicesDoc struct { Services map[string]struct { Image string `yaml:"image"` } `yaml:"services"` } // DBServiceNames returns the sorted compose SERVICE names in composePath whose `image:` identifies a // supported database engine — the exact argument list for `docker compose up -d ...`. // // A file with no (or an empty) `services:` key returns (nil, nil): an app with no identifiable DB // service is a legitimate, common case and the caller decides what it means. An unreadable or // unparseable file returns an error, because "cannot tell" must never silently read as "no database" // — the callers turn that into a refusal when a dump exists. // // Image values are matched literally. Catalog templates pin their images literally (enforced since // Campaign 7), so an interpolated `${...}` image simply does not match and lands in the caller's // fail-closed branch by design, rather than being guessed at. func DBServiceNames(composePath string) ([]string, error) { data, err := os.ReadFile(composePath) if err != nil { return nil, fmt.Errorf("reading compose file: %w", err) } var doc composeServicesDoc if err := yaml.Unmarshal(data, &doc); err != nil { return nil, fmt.Errorf("parsing compose file %s: %w", composePath, err) } var names []string for name, svc := range doc.Services { if _, ok := dbTypeForImage(svc.Image); ok { names = append(names, name) } } sort.Strings(names) return names, nil }