package backup import ( "context" "fmt" "os" "path/filepath" "time" ) // reimportDBDumps replays the captured per-app .sql dumps back into the app's now-running database // container(s) — the F17 fix. The per-app backup captures a logical SQL dump (DumpOne → // -.sql) but the legacy restore only repopulated Docker volume tars and NEVER replayed // the dump, so DB-resident data (e.g. rows in a DB whose data dir is a bind mount, not a named volume) // did not come back. This runs AFTER volume restore + stack bring-up, so the dump WINS over any // volume-tar copy of the DB (the operator-chosen precedence: the consistent logical dump is authoritative). // // It uses the live container's OWN discovered credentials (DiscoveredDB), so no env threading is needed. // A dump whose DB container is not found is logged and skipped; an actual import FAILURE is returned // (surfaced, not swallowed) so a failed data restore cannot read as success. func (m *Manager) reimportDBDumps(ctx context.Context, stackName, nsRoot string) (int, error) { return m.reimportDBDumpsFrom(ctx, stackName, AppDBDumpPath(nsRoot, stackName)) } // reimportDBDumpsFrom is reimportDBDumps with an EXPLICIT dump directory. The offsite // reconstitution path (R-43) replays out of the restored SCRATCH unit rather than the live one: // the local unit is deliberately never overwritten by a placement, so the dump that belongs to the // chosen snapshot exists only under the scratch. Same discovery/import seams, same failure // semantics — only the source directory differs. func (m *Manager) reimportDBDumpsFrom(ctx context.Context, stackName, dumpDir string) (int, error) { entries, err := os.ReadDir(dumpDir) if err != nil { if os.IsNotExist(err) { return 0, nil // no DB dumps for this app } return 0, fmt.Errorf("reading db-dump dir: %w", err) } hasDump := false for _, e := range entries { if !e.IsDir() && filepath.Ext(e.Name()) == ".sql" { hasDump = true break } } if !hasDump { return 0, nil } discover := m.discoverDBs if discover == nil { discover = func(ctx context.Context) ([]DiscoveredDB, error) { return DiscoverDatabases(ctx, m.logger, m.isDebug(), m.knownStackNames()) } } imp := m.importDBDump if imp == nil { imp = func(ctx context.Context, db DiscoveredDB, dumpPath string) error { return ImportDump(ctx, db, dumpPath, m.logger, m.isDebug()) } } dbs, err := discover(ctx) if err != nil { return 0, fmt.Errorf("discovering DB containers for %s: %w", stackName, err) } var imported int for _, db := range dbs { if db.StackName != stackName { continue } // The dump for this DB is named "-.sql" (see DumpOne). dumpPath := filepath.Join(dumpDir, fmt.Sprintf("%s-%s.sql", stackName, db.DBType)) if _, statErr := os.Stat(dumpPath); statErr != nil { continue // no dump for this particular DB engine } m.logger.Printf("[INFO] [backup] Restore %s: replaying DB dump into %s (%s)", stackName, db.ContainerName, db.DBType) if err := imp(ctx, db, dumpPath); err != nil { return imported, fmt.Errorf("importing %s dump for %s: %w", db.DBType, stackName, err) } imported++ } if imported == 0 { m.logger.Printf("[WARN] [backup] Restore %s: a .sql dump exists but no matching running DB container was found — DB content NOT restored", stackName) } else { m.logger.Printf("[INFO] [backup] Restore %s: replayed %d DB dump(s)", stackName, imported) } return imported, nil } // reimportDBDumpsCtx is a small helper that runs reimportDBDumps with a bounded context so a stuck DB // import cannot hang the restore indefinitely. func (m *Manager) reimportDBDumpsCtx(stackName, nsRoot string) (int, error) { ctx, cancel := context.WithTimeout(context.Background(), 35*time.Minute) defer cancel() return m.reimportDBDumps(ctx, stackName, nsRoot) }