package appbackup import ( "fmt" "os" "regexp" "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 { // R-484 (v0.240.0): derived Postgres images whose name does not carry "postgres". Measured on // demo-hp 2026-09-13: adventurelog's `postgis/postgis:16-3.5-alpine` was "not a database", so // the app got no logical dump anywhere. Same pg_dump, same PG* credentials. case strings.Contains(img, "postgres"), strings.Contains(img, "postgis"), strings.Contains(img, "pgvector"), strings.Contains(img, "timescaledb"): 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 } // dbCredentialKeyRe matches a database service's credential variable (POSTGRES_PASSWORD, MARIADB_ROOT_PASSWORD, // MYSQL_PASSWORD, …): the engine images read these ONLY when they initialise an empty data directory, so once a // restore puts a data directory back, the value that matches it is the one it was created with (R-1106). var dbCredentialKeyRe = regexp.MustCompile(`^(POSTGRES|PG|MARIADB|MYSQL)_[A-Z_]*PASSWORD$`) // interpRefRe pulls the variable names out of a compose value: ${NAME}, ${NAME:-x}, ${NAME-x}, $NAME. var interpRefRe = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?:[:]?[-?+][^}]*)?\}|\$([A-Za-z_][A-Za-z0-9_]*)`) type composeDBEnvDoc struct { Services map[string]struct { Image string `yaml:"image"` Environment yaml.Node `yaml:"environment"` } `yaml:"services"` } // DBCredentialVars (R-1106, `07` §7.4 — operator decision 1A, 2026-10-16) returns the sorted, unique app.yaml // variable names that feed a database service's credential variables in composePath — e.g. rallly's // `POSTGRES_PASSWORD=${DB_PASSWORD}` → DB_PASSWORD. These are the values a restore must put back WITH the data it puts // back: a fresh install's generated value does not open a restored data directory. A literal credential (no // variable) yields nothing — there is nothing to carry. Same parse and same DB predicate as DBServiceNames, so the // two cannot disagree about which services hold a database. Pinned by TestR1106_DBCredentialVars*. func DBCredentialVars(composePath string) ([]string, error) { data, err := os.ReadFile(composePath) if err != nil { return nil, fmt.Errorf("reading compose file: %w", err) } var doc composeDBEnvDoc if err := yaml.Unmarshal(data, &doc); err != nil { return nil, fmt.Errorf("parsing compose file %s: %w", composePath, err) } seen := map[string]bool{} add := func(key, val string) { if !dbCredentialKeyRe.MatchString(key) { return } for _, m := range interpRefRe.FindAllStringSubmatch(val, -1) { n := m[1] if n == "" { n = m[2] } seen[n] = true } } for _, svc := range doc.Services { if _, ok := dbTypeForImage(svc.Image); !ok { continue } env := svc.Environment switch env.Kind { case yaml.SequenceNode: // - KEY=value for _, it := range env.Content { if k, v, ok := strings.Cut(it.Value, "="); ok { add(strings.TrimSpace(k), v) } } case yaml.MappingNode: // KEY: value for i := 0; i+1 < len(env.Content); i += 2 { add(env.Content[i].Value, env.Content[i+1].Value) } } } out := make([]string, 0, len(seen)) for n := range seen { out = append(out, n) } sort.Strings(out) return out, nil } type composeDBVolDoc struct { Services map[string]struct { Image string `yaml:"image"` Volumes []interface{} `yaml:"volumes"` } `yaml:"services"` } // DBDataVolumes (R-1106) returns the sorted NAMED volumes (not binds) that a database service in composePath mounts — // its data directory. A restore that replays one of these puts back a data directory that only its own password // opens; a restore that replays only a logical dump does not, and the current password stays right. func DBDataVolumes(composePath string) ([]string, error) { data, err := os.ReadFile(composePath) if err != nil { return nil, fmt.Errorf("reading compose file: %w", err) } var doc composeDBVolDoc if err := yaml.Unmarshal(data, &doc); err != nil { return nil, fmt.Errorf("parsing compose file %s: %w", composePath, err) } seen := map[string]bool{} for _, svc := range doc.Services { if _, ok := dbTypeForImage(svc.Image); !ok { continue } for _, v := range svc.Volumes { var src string switch t := v.(type) { case string: src, _, _ = strings.Cut(t, ":") case map[string]interface{}: if typ, _ := t["type"].(string); typ != "" && typ != "volume" { continue } src, _ = t["source"].(string) } if src == "" || strings.ContainsAny(src, "/.$~") { continue // a bind or an interpolated path — not a named volume } seen[src] = true } } out := make([]string, 0, len(seen)) for n := range seen { out = append(out, n) } sort.Strings(out) return out, nil }