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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user