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
@@ -0,0 +1,125 @@
package backup
import (
"context"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
)
func newReimportTestManager() *Manager {
return &Manager{logger: log.New(io.Discard, "", 0)}
}
func writeDump(t *testing.T, nsRoot, stack string, dbType DBType) string {
t.Helper()
dir := AppDBDumpPath(nsRoot, stack)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, fmt.Sprintf("%s-%s.sql", stack, dbType))
if err := os.WriteFile(p, []byte("-- dump\nDROP TABLE IF EXISTS t;\n"), 0o644); err != nil {
t.Fatal(err)
}
return p
}
// TestReimportDBDumps_ImportsWhenDumpAndDBPresent asserts F17: when a captured .sql dump exists and a
// matching running DB is discovered, reimportDBDumps replays it. The PRE-FIX restore never called any
// import — this orchestration is the fix.
func TestReimportDBDumps_ImportsWhenDumpAndDBPresent(t *testing.T) {
nsRoot := t.TempDir()
wantPath := writeDump(t, nsRoot, "app", DBTypeMariaDB)
m := newReimportTestManager()
m.discoverDBs = func(ctx context.Context) ([]DiscoveredDB, error) {
return []DiscoveredDB{
{StackName: "other", DBType: DBTypePostgres, ContainerName: "other-db"},
{StackName: "app", DBType: DBTypeMariaDB, ContainerName: "app-db", ContainerID: "cid"},
}, nil
}
var gotPath string
var gotDB DiscoveredDB
m.importDBDump = func(ctx context.Context, db DiscoveredDB, dumpPath string) error {
gotPath, gotDB = dumpPath, db
return nil
}
n, err := m.reimportDBDumps(context.Background(), "app", nsRoot)
if err != nil {
t.Fatalf("reimportDBDumps: %v", err)
}
if n != 1 {
t.Fatalf("imported = %d, want 1", n)
}
if gotPath != wantPath {
t.Fatalf("imported path = %q, want %q", gotPath, wantPath)
}
if gotDB.ContainerName != "app-db" {
t.Fatalf("imported into %q, want app-db (must match the stack's own DB)", gotDB.ContainerName)
}
}
// TestReimportDBDumps_FailureSurfaces asserts an import failure is RETURNED, not swallowed (a failed
// data restore must not read as success).
func TestReimportDBDumps_FailureSurfaces(t *testing.T) {
nsRoot := t.TempDir()
writeDump(t, nsRoot, "app", DBTypeMariaDB)
m := newReimportTestManager()
m.discoverDBs = func(ctx context.Context) ([]DiscoveredDB, error) {
return []DiscoveredDB{{StackName: "app", DBType: DBTypeMariaDB, ContainerName: "app-db"}}, nil
}
m.importDBDump = func(ctx context.Context, db DiscoveredDB, dumpPath string) error {
return fmt.Errorf("boom")
}
if _, err := m.reimportDBDumps(context.Background(), "app", nsRoot); err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected the import failure to surface, got %v", err)
}
}
// TestReimportDBDumps_NoDumpNoImport asserts apps with no .sql dump never trigger discovery/import.
func TestReimportDBDumps_NoDumpNoImport(t *testing.T) {
nsRoot := t.TempDir()
m := newReimportTestManager()
discoverCalled := false
m.discoverDBs = func(ctx context.Context) ([]DiscoveredDB, error) {
discoverCalled = true
return nil, nil
}
m.importDBDump = func(ctx context.Context, db DiscoveredDB, dumpPath string) error {
t.Fatal("importDBDump must not be called when there is no dump")
return nil
}
n, err := m.reimportDBDumps(context.Background(), "app", nsRoot)
if err != nil || n != 0 {
t.Fatalf("reimportDBDumps with no dump = (%d, %v), want (0, nil)", n, err)
}
if discoverCalled {
t.Fatalf("discovery should be skipped when there is no .sql dump")
}
}
// TestReimportDBDumps_DumpButNoMatchingDB asserts that a dump with no matching running DB container is a
// non-fatal skip (logged), returning 0 imported and no error — the app is up, just no DB matched.
func TestReimportDBDumps_DumpButNoMatchingDB(t *testing.T) {
nsRoot := t.TempDir()
writeDump(t, nsRoot, "app", DBTypeMariaDB)
m := newReimportTestManager()
m.discoverDBs = func(ctx context.Context) ([]DiscoveredDB, error) {
return []DiscoveredDB{{StackName: "different", DBType: DBTypeMariaDB}}, nil
}
m.importDBDump = func(ctx context.Context, db DiscoveredDB, dumpPath string) error {
t.Fatal("must not import when no DB matches the stack")
return nil
}
n, err := m.reimportDBDumps(context.Background(), "app", nsRoot)
if err != nil || n != 0 {
t.Fatalf("= (%d, %v), want (0, nil)", n, err)
}
}