controller v0.267.0: tests off DooPlex's Docker, cut-off copies refused, two pages true
gates / gates (push) Successful in 26s
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user