controller v0.267.0: tests off DooPlex's Docker, cut-off copies refused, two pages true
gates / gates (push) Successful in 26s

R-650: internal/dockerexec — every docker exec routed through it; under
go test a real docker is refused (opt-in FELHOM_TEST_REAL_DOCKER=1; a stub
under the temp dir is allowed). api/stacks/web tests run under a silent
stub (TestMain). TestR650_NoBareDockerExec pins it repo-wide.
R-640: a dump without its engine's completion marker is refused before
the first mutation (unit + off-site restore) and again before any load.
R-499: the Tier-2 page's system-disk sentence has four true branches.
R-518: the backup button states the measured ~8 min stop.
R-626: measured on 9202, not reproduced.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-23 20:25:28 +02:00
parent 15e630aa02
commit 80e6ad8c47
55 changed files with 2510 additions and 125 deletions
+79 -11
View File
@@ -4,7 +4,9 @@ import (
"bufio"
"compress/gzip"
"context"
"errors"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"io"
"log"
"os"
@@ -136,7 +138,7 @@ func DiscoverDatabases(ctx context.Context, logger *log.Logger, debug bool, know
// whole `docker ps` output, so a trailing empty field on the LAST line would be eaten and that
// row would arrive one column short. Image is never empty, so ending on it keeps every row the
// same width.
cmd := exec.CommandContext(ctx, "docker", "ps", "--format",
cmd := dockerexec.CommandContext(ctx, "docker", "ps", "--format",
"{{.ID}}\t{{.Names}}\t{{.Label \"com.docker.compose.project\"}}\t{{.Image}}", "--filter", "status=running")
out, err := cmd.Output()
if err != nil {
@@ -279,7 +281,7 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
defer cancel()
// Verify container is still running
checkCmd := exec.CommandContext(dumpCtx, "docker", "inspect", "--format", "{{.State.Running}}", db.ContainerID)
checkCmd := dockerexec.CommandContext(dumpCtx, "docker", "inspect", "--format", "{{.State.Running}}", db.ContainerID)
checkOut, err := checkCmd.Output()
if err != nil || strings.TrimSpace(string(checkOut)) != "true" {
result.Error = fmt.Errorf("container %s no longer running", db.ContainerName)
@@ -294,7 +296,7 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
var cmd *exec.Cmd
switch db.DBType {
case DBTypePostgres:
cmd = exec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
cmd = dockerexec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
"pg_dump", "-U", db.DBUser, "-d", db.DBName,
"--clean", "--if-exists", "--no-owner", "--no-privileges")
if debug {
@@ -312,7 +314,7 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
}
return result
}
cmd = exec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
cmd = dockerexec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
"mariadb-dump", "-u", "root", "-p***",
"--single-transaction", "--routines", "--triggers", db.DBName)
if debug {
@@ -320,7 +322,7 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
db.ContainerID[:12], db.DBName)
}
// Actual command with real password (not logged)
cmd = exec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
cmd = dockerexec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
"mariadb-dump", "-u", "root", "-p"+password,
"--single-transaction", "--routines", "--triggers", db.DBName)
default:
@@ -671,7 +673,7 @@ func ListDumpFiles(dumpDir string, cached func(name string, size int64, mod time
}
func populateDBEnv(ctx context.Context, db *DiscoveredDB) error {
cmd := exec.CommandContext(ctx, "docker", "inspect", db.ContainerID,
cmd := dockerexec.CommandContext(ctx, "docker", "inspect", db.ContainerID,
"--format", "{{range .Config.Env}}{{println .}}{{end}}")
out, err := cmd.Output()
if err != nil {
@@ -759,14 +761,14 @@ func ImportDump(ctx context.Context, db DiscoveredDB, dumpPath string, logger *l
// MariaDB's DDL is not transactional, so a partial apply there is unavoidable at the engine
// and is why the rollback in offbox_reconstitute.go exists and is the actual fix. Do not
// read this flag as making the rollback optional.
cmd = exec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
cmd = dockerexec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
"psql", "-v", "ON_ERROR_STOP=1", "--single-transaction", "-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,
cmd = dockerexec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
"mariadb", "-u", "root", "-p"+password, db.DBName)
default:
return fmt.Errorf("unsupported DB type: %s", db.DBType)
@@ -813,10 +815,10 @@ func waitDBReady(ctx context.Context, db DiscoveredDB, timeout time.Duration) er
if user == "" {
user = "postgres"
}
cmd = exec.CommandContext(c, "docker", "exec", db.ContainerID, "pg_isready", "-U", user)
cmd = dockerexec.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)
cmd = dockerexec.CommandContext(c, "docker", "exec", db.ContainerID, "mariadb-admin", "ping", "-u", "root", "-p"+pw)
default:
cancel()
return fmt.Errorf("unsupported DB type: %s", db.DBType)
@@ -834,7 +836,7 @@ func waitDBReady(ctx context.Context, db DiscoveredDB, timeout time.Duration) er
}
func getMariaDBPassword(ctx context.Context, containerID string) string {
cmd := exec.CommandContext(ctx, "docker", "inspect", containerID,
cmd := dockerexec.CommandContext(ctx, "docker", "inspect", containerID,
"--format", "{{range .Config.Env}}{{println .}}{{end}}")
out, err := cmd.Output()
if err != nil {
@@ -976,3 +978,69 @@ func cleanupTmpFiles(dumpDir string, logger *log.Logger) {
}
// M1: formatBytes removed — use humanizeBytes() from appdata.go (same package, no duplication).
// Completion markers — the last comment each engine's dump tool writes, and only when it finished.
// R-640 (measured 2026-09-23 on 9202): the first half of a real pg_dump, loaded with
// `psql -v ON_ERROR_STOP=1 --single-transaction`, returned rc 0 and left 42 tables with 0 users —
// psql treats end-of-file inside a COPY as end of data and commits. A truncated MariaDB dump fails
// its load, but only after it has already replaced half the tables. The header and CREATE TABLE
// checks in ValidateDump cannot see either: only the END of the file can.
const (
pgCompleteMarker = "-- PostgreSQL database dump complete"
mariadbCompleteMarker = "-- Dump completed"
)
// ErrDumpIncomplete is returned (wrapped) by CheckDumpComplete when the marker is absent.
var ErrDumpIncomplete = errors.New("database copy is incomplete: the engine's completion marker is missing")
// CheckDumpComplete reports whether a (possibly .gz) dump ends with its engine's completion marker.
// It reads the whole stream but keeps only the last 4 KiB — pg_dump may print a `\unrestrict` line
// after its marker, so the marker is searched in the tail rather than required on the last line.
// An engine this function does not know is an error: "cannot tell" must never load.
func CheckDumpComplete(path string, dbType DBType) error {
var marker string
switch dbType {
case DBTypePostgres:
marker = pgCompleteMarker
case DBTypeMariaDB:
marker = mariadbCompleteMarker
default:
return fmt.Errorf("%s: unknown database type %q, completeness cannot be checked", filepath.Base(path), dbType)
}
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("opening %s: %w", filepath.Base(path), err)
}
defer f.Close()
var r io.Reader = f
if strings.HasSuffix(path, ".gz") {
gr, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("opening gzip %s: %w", filepath.Base(path), err)
}
defer gr.Close()
r = gr
}
const tailSize = 4096
tail := make([]byte, 0, 2*tailSize)
buf := make([]byte, 64*1024)
for {
n, rerr := r.Read(buf)
if n > 0 {
tail = append(tail, buf[:n]...)
if len(tail) > tailSize {
tail = append(tail[:0], tail[len(tail)-tailSize:]...)
}
}
if rerr == io.EOF {
break
}
if rerr != nil {
return fmt.Errorf("reading %s: %w", filepath.Base(path), rerr)
}
}
if !strings.Contains(string(tail), marker) {
return fmt.Errorf("%s (%s): %w", filepath.Base(path), dbType, ErrDumpIncomplete)
}
return nil
}