F17: per-app restore now replays the captured .sql DB dump (.sql wins)

The restore paths (RestoreFromRecoveryUnit + the RestoreApp fallback) repopulated
Docker volume tars but NEVER replayed the captured <stack>-<dbtype>.sql dump, so
DB-resident data (e.g. rows in a DB whose data dir is a bind mount) did not come
back — the romm marker round-trip in the audit lost the row.

New appbackup.ImportDump (read-side counterpart to DumpOne) replays a .sql/.sql.gz
into the running DB using the live container's OWN discovered credentials (no env
threading; reuses DiscoveredDB + getMariaDBPassword). backup.reimportDBDumps
orchestrates it AFTER volume restore + stack bring-up, so the logical dump WINS
over any volume-tar copy of the DB (operator-chosen precedence). pg_dump
--clean --if-exists and mariadb-dump (default --add-drop-table) make replay
idempotent; psql ON_ERROR_STOP=1 surfaces real import errors.

Also: volume-restore per-volume failures and DB-import failures now SURFACE (the
restore returns an error) instead of a swallowed WARN, so a failed data restore
cannot read as success.

Tests (restore_db_test.go, injectable discover/import seams): imports when dump+DB
present, failure surfaces, no-dump skips discovery, dump-but-no-matching-DB is a
non-fatal skip. Live DB round-trip to be validated post-deploy.
This commit is contained in:
2026-06-14 10:02:58 +02:00
parent 803ce50578
commit 0b9450e356
7 changed files with 376 additions and 4 deletions
+108
View File
@@ -2,6 +2,7 @@ package appbackup
import (
"bufio"
"compress/gzip"
"context"
"fmt"
"io"
@@ -513,6 +514,113 @@ func populateDBEnv(ctx context.Context, db *DiscoveredDB) error {
return nil
}
// ImportDump replays a (possibly gzipped) SQL dump into a RUNNING database container — the read-side
// counterpart to DumpOne (F17). It reuses the per-engine clients (psql / mariadb) and the DiscoveredDB's
// OWN credentials (discovered from the live container env), so the caller needs no external env map. The
// container must already be running (the restore flow brings the stack up first); ImportDump briefly
// waits for the engine to accept connections, then pipes the dump in. The backup dumps are produced with
// DROP/CREATE (pg_dump --clean --if-exists; mariadb-dump's default --add-drop-table), so a replay fully
// reconstructs the captured logical state.
func ImportDump(ctx context.Context, db DiscoveredDB, dumpPath string, logger *log.Logger, debug bool) error {
if err := waitDBReady(ctx, db, 30*time.Second); err != nil {
return fmt.Errorf("waiting for %s (%s) readiness: %w", db.ContainerName, db.DBType, err)
}
f, err := os.Open(dumpPath)
if err != nil {
return fmt.Errorf("opening dump %s: %w", dumpPath, err)
}
defer f.Close()
var reader io.Reader = f
if strings.HasSuffix(dumpPath, ".gz") {
gr, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("opening gzip %s: %w", dumpPath, err)
}
defer gr.Close()
reader = gr
}
impCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
var cmd *exec.Cmd
switch db.DBType {
case DBTypePostgres:
user := db.DBUser
if user == "" {
user = "postgres"
}
dbName := db.DBName
if dbName == "" {
dbName = user
}
// ON_ERROR_STOP=1: a real import error must FAIL (and surface), not silently half-apply.
cmd = exec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
"psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", dbName)
case DBTypeMariaDB:
password := getMariaDBPassword(impCtx, db.ContainerID)
if password == "" {
return fmt.Errorf("could not determine MariaDB root password for %s", db.ContainerName)
}
cmd = exec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
"mariadb", "-u", "root", "-p"+password, db.DBName)
default:
return fmt.Errorf("unsupported DB type: %s", db.DBType)
}
cmd.Stdin = reader
var stderr strings.Builder
cmd.Stderr = &stderr
if debug && logger != nil {
logger.Printf("[DEBUG] [backup] ImportDump: importing %s into %s (%s)", dumpPath, db.ContainerName, db.DBType)
}
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if len(msg) > 300 {
msg = msg[:300]
}
return fmt.Errorf("%s import into %s failed: %s — %w", db.DBType, db.ContainerName, msg, err)
}
if logger != nil {
logger.Printf("[INFO] [backup] Imported DB dump %s into %s (%s)", filepath.Base(dumpPath), db.ContainerName, db.DBType)
}
return nil
}
// waitDBReady polls until the database accepts connections (pg_isready / mariadb-admin ping).
func waitDBReady(ctx context.Context, db DiscoveredDB, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
c, cancel := context.WithTimeout(ctx, 5*time.Second)
var cmd *exec.Cmd
switch db.DBType {
case DBTypePostgres:
user := db.DBUser
if user == "" {
user = "postgres"
}
cmd = exec.CommandContext(c, "docker", "exec", db.ContainerID, "pg_isready", "-U", user)
case DBTypeMariaDB:
pw := getMariaDBPassword(c, db.ContainerID)
cmd = exec.CommandContext(c, "docker", "exec", db.ContainerID, "mariadb-admin", "ping", "-u", "root", "-p"+pw)
default:
cancel()
return fmt.Errorf("unsupported DB type: %s", db.DBType)
}
err := cmd.Run()
cancel()
if err == nil {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("timeout after %s", timeout)
}
time.Sleep(2 * time.Second)
}
}
func getMariaDBPassword(ctx context.Context, containerID string) string {
cmd := exec.CommandContext(ctx, "docker", "inspect", containerID,
"--format", "{{range .Config.Env}}{{println .}}{{end}}")