From 90a2530689440260eb3d79cde2fa8529a19a5bb7 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 26 Oct 2026 21:58:11 +0100 Subject: [PATCH] fix(R-1187): the .fab import loads the database through the backup side (mariadb-admin/mariadb, the container's own credentials) Found live on 9202: every MariaDB 11+ import timed out (mysqladmin/mysql absent). Red-proved. --- CHANGELOG.md | 7 + .../internal/appexport/r1187_abdfg_test.go | 6 +- .../appexport/r1187_db_import_test.go | 47 +++++ controller/internal/appexport/restore.go | 166 +++++------------- 4 files changed, 96 insertions(+), 130 deletions(-) create mode 100644 controller/internal/appexport/r1187_db_import_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 238fadec..adf0f9d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,13 @@ hub answers 400 to both and nothing else changes. app removed and not started. Now `withImportPathVars` gives the import's compose runs the same derivation (never a bundle's value) and `containerIDFrom` takes only a line that IS an id. Tests `TestR1187_TheContainerIDIsNeverAComposeWarning`, `TestR1187_TheImportEnvCarriesTheControllersPathVars`, red-proved. + **And the second cause, found by the same live run:** the import waited with `mysqladmin` and loaded with `mysql` — + names the MariaDB 11+ images do not ship (only `mariadb-admin` / `mariadb`; checked in `mariadb:11.4`) — and read the + password from two fixed env names, so EVERY `.fab` import of a MariaDB 11+ app timed out and left the app down. The + import now loads through the backup side: `appbackup.DiscoverDatabases` (the live container's own credentials) and + `appbackup.ImportDump` (mariadb / mariadb-admin; psql `ON_ERROR_STOP` + `--single-transaction`); the import's own + `waitForDB` / `importDBDump` are removed. `appbackup.DBTypeForImage` stays the classifier (g). Test + `TestR1187_TheImportLoadsTheDumpThroughTheBackupSide` (seams, no Docker), red-proved with the old body. - **R-1298:** the hold report skips an install's hold (`unhealthy_stop`, `update_failed`, `putback_pending`) of an app that is not installed (`holdreport.Reporter.SetInstalled`: the app's `app.yaml` exists — an unreadable one still counts, R-1220) and reports it cleared if it was reported; a restore hold is always reported (R-379). 9202's four orphan holds diff --git a/controller/internal/appexport/r1187_abdfg_test.go b/controller/internal/appexport/r1187_abdfg_test.go index 06464644..af5c5720 100644 --- a/controller/internal/appexport/r1187_abdfg_test.go +++ b/controller/internal/appexport/r1187_abdfg_test.go @@ -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 diff --git a/controller/internal/appexport/r1187_db_import_test.go b/controller/internal/appexport/r1187_db_import_test.go new file mode 100644 index 00000000..1cfa63fc --- /dev/null +++ b/controller/internal/appexport/r1187_db_import_test.go @@ -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) + } +} diff --git a/controller/internal/appexport/restore.go b/controller/internal/appexport/restore.go index 682c626c..5562afb1 100644 --- a/controller/internal/appexport/restore.go +++ b/controller/internal/appexport/restore.go @@ -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)