fix(R-1187): the .fab import loads the database through the backup side (mariadb-admin/mariadb, the container's own credentials)
gates / gates (push) Successful in 48s

Found live on 9202: every MariaDB 11+ import timed out (mysqladmin/mysql absent). Red-proved.
This commit is contained in:
2026-10-26 21:58:11 +01:00
parent 5d07c4e923
commit 90a2530689
4 changed files with 96 additions and 130 deletions
@@ -185,10 +185,8 @@ func TestR1187g_PostgisIsPostgresAndPsqlStopsOnError(t *testing.T) {
t.Errorf("%s is not classified as PostgreSQL: %+v", img, info)
}
}
args := strings.Join(psqlImportArgs("c", "u", "d"), " ")
if !strings.Contains(args, "--set=ON_ERROR_STOP=1") {
t.Fatalf("psql does not stop on error: %s", args)
}
// The load itself goes through appbackup.ImportDump (psql -v ON_ERROR_STOP=1 --single-transaction) since the
// import's own client calls were removed — TestR1187_TheImportLoadsTheDumpThroughTheBackupSide.
}
// (b), the security review's case: a link already in the destination folder (left by the old app, or made by an earlier
@@ -0,0 +1,47 @@
package appexport
import (
"context"
"io"
"log"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
)
// R-1187, found live on 9202 2026-10-26 night: the `.fab` import waited with `mysqladmin` and loaded with `mysql` — names
// the MariaDB 11+ images do not ship (only mariadb-admin / mariadb; measured in mariadb:11.4) — and read the password
// from the app's env under two fixed names (grimmory's is DB_ROOT_PASSWORD). Every import of a MariaDB 11+ app timed
// out and left the app down. The import now uses the BACKUP side's discovery (the live container's own credentials)
// and appbackup.ImportDump (mariadb / mariadb-admin, psql ON_ERROR_STOP + --single-transaction). The seams keep this
// test off the real Docker (R-650). RED-PROOF: the old body (its own waitForDB + importDBDump) never reaches the seams.
func TestR1187_TheImportLoadsTheDumpThroughTheBackupSide(t *testing.T) {
oldD, oldI := discoverDBsFn, importDumpFn
defer func() { discoverDBsFn, importDumpFn = oldD, oldI }()
discoverDBsFn = func(ctx context.Context, l *log.Logger, debug bool, known []string) ([]appbackup.DiscoveredDB, error) {
return []appbackup.DiscoveredDB{
{ContainerName: "paperless-postgres", ContainerID: "1b47ee5f58d2", DBType: appbackup.DBTypePostgres, StackName: "paperless-ngx"},
{ContainerName: "grimmory-db", ContainerID: "2310c379bd74", DBType: appbackup.DBTypeMariaDB, DBName: "grimmory", StackName: "grimmory"},
}, nil
}
var got appbackup.DiscoveredDB
var gotPath string
importDumpFn = func(ctx context.Context, db appbackup.DiscoveredDB, dumpPath string, l *log.Logger, debug bool) error {
got, gotPath = db, dumpPath
return nil
}
e := NewExporter(&rtProvider{}, log.New(io.Discard, "", 0), "test")
err := e.importIntoContainer("grimmory", "2310c379bd742a9bbb3a78ceb60d1872edb2dfe0ddc15b134edb9142b048e558", "mariadb", "/tmp/x/grimmory-mariadb.sql.gz", nil)
if err != nil {
t.Fatalf("import: %v", err)
}
if got.ContainerName != "grimmory-db" || gotPath != "/tmp/x/grimmory-mariadb.sql.gz" {
t.Fatalf("the dump did not go through the backup side's ImportDump for the started container: %+v %q", got, gotPath)
}
// A container the backup side cannot see is a clear refusal, never a guess.
err = e.importIntoContainer("grimmory", "ffffffffffff", "mariadb", "/tmp/x/d.sql.gz", nil)
if err == nil || !strings.Contains(err.Error(), "ffffffffffff") {
t.Fatalf("an undiscovered container must be refused naming it, got %v", err)
}
}
+40 -126
View File
@@ -10,7 +10,6 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
@@ -884,29 +883,53 @@ func (e *Exporter) restoreDatabase(manifest *Manifest, tmpDir, composePath, stac
}
e.debugf("restoreDatabase: container ID=%s", containerID)
// Wait for DB readiness
e.logger.Printf("[INFO] Import: waiting for DB readiness (container %s)", containerID[:12])
e.debugf("restoreDatabase: waiting for DB readiness...")
waitStart := time.Now()
if err := waitForDB(containerID, dbSvc.DBType, env); err != nil {
e.debugf("restoreDatabase: DB not ready after %v: %v", time.Since(waitStart), err)
return fmt.Errorf("waiting for DB: %w", err)
}
e.debugf("restoreDatabase: DB ready in %v", time.Since(waitStart))
// Import the dump
e.logger.Printf("[INFO] Import: importing database dump")
e.debugf("restoreDatabase: importing dump into container %s", containerID[:12])
// Wait for readiness and import the dump.
importStart := time.Now()
if err := importDBDump(containerID, dumpGzPath, dbSvc.DBType, env); err != nil {
if err := e.importIntoContainer(manifest.AppName, containerID, dbSvc.DBType, dumpGzPath, env); err != nil {
e.debugf("restoreDatabase: import failed after %v: %v", time.Since(importStart), err)
return fmt.Errorf("importing dump: %w", err)
return err
}
e.debugf("restoreDatabase: dump imported in %v", time.Since(importStart))
return nil
}
// Seams for importIntoContainer (a test replaces them; production is the backup side's own code).
var (
discoverDBsFn = appbackup.DiscoverDatabases
importDumpFn = appbackup.ImportDump
)
// importIntoContainer waits for the database and loads the dump — through the BACKUP side (R-1187, found live on 9202):
// the container's own credentials from appbackup's discovery, and appbackup.ImportDump (mariadb / mariadb-admin, which
// the MariaDB 11+ images ship; psql ON_ERROR_STOP + --single-transaction). The import's own waitForDB/importDBDump used
// mysqladmin/mysql (absent from mariadb:11.4) and two fixed env names for the password, so every MariaDB 11+ import
// timed out. Pinned by TestR1187_TheImportLoadsTheDumpThroughTheBackupSide.
func (e *Exporter) importIntoContainer(app, containerID, dbType, dumpPath string, env map[string]string) error {
ctx := context.Background()
dbs, err := discoverDBsFn(ctx, e.logger, e.debug, []string{app})
if err != nil {
return fmt.Errorf("finding the database container: %w", err)
}
for _, db := range dbs {
if db.ContainerID == "" || !(strings.HasPrefix(containerID, db.ContainerID) || strings.HasPrefix(db.ContainerID, containerID)) {
continue
}
e.logger.Printf("[INFO] Import: loading the database dump into %s (%s)", db.ContainerName, db.DBType)
if err := importDumpFn(ctx, db, dumpPath, e.logger, e.debug); err != nil {
return fmt.Errorf("importing dump: %w", err)
}
return nil
}
return fmt.Errorf("the started database container %s is not among the databases the box can see", shortID(containerID))
}
func shortID(id string) string {
if len(id) > 12 {
return id[:12]
}
return id
}
// findDumpFile finds the gzipped SQL dump in the database directory.
func findDumpFile(dbDir string) (string, error) {
entries, err := os.ReadDir(dbDir)
@@ -1070,115 +1093,6 @@ func withImportPathVars(env map[string]string, nsRoot, importRoot string) map[st
return out
}
// waitForDB polls until the database is ready to accept connections.
func waitForDB(containerID, dbType string, env map[string]string) error {
timeout := 60 * time.Second
start := time.Now()
for {
if time.Since(start) > timeout {
return fmt.Errorf("timeout waiting for %s to become ready", dbType)
}
var cmd *exec.Cmd
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
switch dbType {
case "postgres":
user := env["POSTGRES_USER"]
if user == "" {
user = "postgres"
}
cmd = dockerexec.CommandContext(ctx, "docker", "exec", containerID,
"pg_isready", "-U", user)
case "mariadb":
password := env["MYSQL_ROOT_PASSWORD"]
if password == "" {
password = env["MARIADB_ROOT_PASSWORD"]
}
cmd = dockerexec.CommandContext(ctx, "docker", "exec", containerID,
"mysqladmin", "ping", "-u", "root", "-p"+password)
default:
cancel()
return fmt.Errorf("unknown DB type: %s", dbType)
}
err := cmd.Run()
cancel()
if err == nil {
return nil
}
time.Sleep(2 * time.Second)
}
}
// psqlImportArgs is the `docker` argv that loads a Postgres dump.
func psqlImportArgs(containerID, user, dbName string) []string {
// R-1187 (g): stop on the first error — the dump is --clean --if-exists, so a failing statement is a real failure,
// and without ON_ERROR_STOP psql went on and the import reported success over a half-loaded database.
return []string{"exec", "-i", containerID, "psql", "--set=ON_ERROR_STOP=1", "-U", user, "-d", dbName}
}
// importDBDump imports a (possibly gzipped) SQL dump into the running database container.
func importDBDump(containerID, dumpPath, dbType string, env map[string]string) error {
// Open the dump file
f, err := os.Open(dumpPath)
if err != nil {
return err
}
defer f.Close()
// If gzipped, wrap in gzip reader
var reader io.Reader = f
if strings.HasSuffix(dumpPath, ".gz") {
gr, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("opening gzip: %w", err)
}
defer gr.Close()
reader = gr
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
var cmd *exec.Cmd
switch dbType {
case "postgres":
user := env["POSTGRES_USER"]
if user == "" {
user = "postgres"
}
dbName := env["POSTGRES_DB"]
if dbName == "" {
dbName = user
}
cmd = dockerexec.CommandContext(ctx, "docker", psqlImportArgs(containerID, user, dbName)...)
case "mariadb":
password := env["MYSQL_ROOT_PASSWORD"]
if password == "" {
password = env["MARIADB_ROOT_PASSWORD"]
}
dbName := env["MYSQL_DATABASE"]
if dbName == "" {
dbName = env["MARIADB_DATABASE"]
}
cmd = dockerexec.CommandContext(ctx, "docker", "exec", "-i", containerID,
"mysql", "-u", "root", "-p"+password, dbName)
default:
return fmt.Errorf("unknown DB type: %s", dbType)
}
cmd.Stdin = reader
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s import failed: %s — %w", dbType, strings.TrimSpace(string(out)), err)
}
return nil
}
// extractTarGz extracts a tar.gz archive to a directory.
func extractTarGz(tgzPath, destDir string) error {
f, err := os.Open(tgzPath)