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
+2 -2
View File
@@ -3,9 +3,9 @@ package backup
import (
"context"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
@@ -856,7 +856,7 @@ func (m *Manager) tarVolumeOrDefault(volName, dumpDir string) ([]byte, error) {
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
cmd := dockerexec.CommandContext(ctx, "docker", "run", "--rm",
"-v", volName+":/vol:ro",
"-v", dumpDir+":/out",
"alpine", "tar", "cf", "/out/"+volName+".tar.tmp", "-C", "/vol", ".")
@@ -678,6 +678,11 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
// dialog says so and the safety dump makes it reversible.
res.Skewed = res.OffsiteRunID == ""
res.LooksEmpty = m.sniffScratchDump(scratchDumpDir, stack)
// R-640: the snapshot's database copy must be whole before anything is stopped or overwritten.
if bad := incompleteDumps(scratchDumpDir); len(bad) > 0 {
m.logger.Printf("[ERROR] [offbox] Restore REFUSED for %s: incomplete database copy %v (no completion marker)", stack, bad)
return res, util.MsgError("err.backup.adatbazis_masolat_csonka_nem_indult", stack)
}
// --- WHICH SERVICE HOLDS THE DATABASE (R-47) ------------------------------------------------
// Read from the LIVE compose, not the scratch one: reconstitution never overwrites the stack dir,
@@ -82,6 +82,8 @@ COPY public."user" (id, email) FROM stdin;
b.WriteString("id-x\tuser@example.invalid\n")
}
b.WriteString("\\.\n") // the COPY-block terminator
// R-640: a real pg_dump ends with its completion marker (and, since 17.6, a \unrestrict line).
b.WriteString("\n--\n-- PostgreSQL database dump complete\n--\n\n")
return b.String()
}
@@ -303,7 +305,7 @@ func TestReconstituteFlagsCustomerEmptyDump(t *testing.T) {
// only be honest if this reports the pair's age and warnings before anything is started.
func TestOffsiteScratchPairReportsWhatTheConfirmNeeds(t *testing.T) {
m, _, _ := reconFixture(t, "run1", "2026-07-19T06:00:00Z",
"-- PostgreSQL database dump\nCREATE TABLE a();\nCOPY public.\"user\" (id) FROM stdin;\n7\n\\.\n")
"-- PostgreSQL database dump\nCREATE TABLE a();\nCOPY public.\"user\" (id) FROM stdin;\n7\n\\.\n"+"-- PostgreSQL database dump complete\n")
info := m.OffsiteScratchPair("immich")
if !info.Ready || !info.HasDump {
@@ -0,0 +1,114 @@
package backup
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// R-640 — a cut-off database copy must never be loaded. Measured 2026-09-23 on 9202: the first half
// of a real pg_dump loaded with rc 0 into an EMPTY database (42 tables, 0 users). Every test here
// asserts the CONSEQUENCE — nothing was loaded, nothing was stopped — not only that an error came back.
// truncatedPG is pgDump cut off inside its COPY block: header and CREATE TABLEs present, so
// ValidateDump's header + table checks pass it; only the missing end marker betrays it.
func truncatedPG() string {
full := pgDump(50)
return full[:strings.Index(full, "\\.\n")]
}
// COMPANION RED-PROOF: deleting the CheckDumpComplete call in reimportDBDumpsFrom makes this fail on
// `a cut-off copy was LOADED`.
func TestR640_ReplayRefusesACutOffCopyAndLoadsNothing(t *testing.T) {
m := newReimportTestManager()
ns := t.TempDir()
p := writeDump(t, ns, "docmost", DBType("postgres"))
if err := os.WriteFile(p, []byte(truncatedPG()), 0o644); err != nil {
t.Fatal(err)
}
if v := ValidateDump(p, DBType("postgres")); !v.Valid {
t.Fatalf("precondition: the truncated copy must PASS the old structural check (that is the bug), got %+v", v)
}
m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
return []DiscoveredDB{{StackName: "docmost", ContainerName: "docmost-postgres", DBType: DBType("postgres")}}, nil
}
var loaded []string
m.importDBDump = func(_ context.Context, _ DiscoveredDB, path string) error { loaded = append(loaded, path); return nil }
n, err := m.reimportDBDumps(context.Background(), "docmost", ns)
if len(loaded) != 0 {
t.Fatalf("a cut-off copy was LOADED: %v", loaded)
}
if err == nil || n != 0 || !strings.Contains(err.Error(), "csonka") {
t.Fatalf("want the Hungarian cut-off refusal and 0 replayed, got n=%d err=%v", n, err)
}
}
// The control: the same path with the complete copy loads it — the check refuses nothing whole.
func TestR640_ReplayLoadsACompleteCopy(t *testing.T) {
m := newReimportTestManager()
ns := t.TempDir()
p := writeDump(t, ns, "docmost", DBType("postgres"))
if err := os.WriteFile(p, []byte(pgDump(50)), 0o644); err != nil {
t.Fatal(err)
}
m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
return []DiscoveredDB{{StackName: "docmost", ContainerName: "docmost-postgres", DBType: DBType("postgres")}}, nil
}
var loaded []string
m.importDBDump = func(_ context.Context, _ DiscoveredDB, path string) error { loaded = append(loaded, path); return nil }
if n, err := m.reimportDBDumps(context.Background(), "docmost", ns); err != nil || n != 1 || len(loaded) != 1 {
t.Fatalf("a complete copy must load: n=%d err=%v loaded=%v", n, err, loaded)
}
}
// The local unit restore refuses BEFORE the first mutation: no stop, no volume replay, no definition.
// COMPANION RED-PROOF: deleting the incompleteDumps gate in RestoreFromRecoveryUnit makes this fail
// on `the app was stopped`.
func TestR640_UnitRestoreRefusesACutOffCopyBeforeAnyMutation(t *testing.T) {
m, prov, imported := r47UnitFixture(t, immichLikeCompose, true)
drive := prov.hdd
mustWrite(t, filepath.Join(AppDBDumpPath(drive, "app"), "app-postgres.sql"), truncatedPG())
_, err := m.RestoreFromRecoveryUnit("app")
if err == nil || !strings.Contains(err.Error(), "csonka") {
t.Fatalf("want the cut-off refusal, got %v", err)
}
if prov.stopped || len(prov.calls) != 0 || prov.gotEnv != nil {
t.Fatalf("ZERO mutations required: stopped=%v calls=%v definition=%v", prov.stopped, prov.calls, prov.gotEnv != nil)
}
if len(*imported) != 0 {
t.Fatalf("a replay happened: %v", *imported)
}
}
// The off-site restore refuses before the safety dump, the stop and the file copy.
func TestR640_OffsiteRestoreRefusesACutOffCopyBeforeAnyMutation(t *testing.T) {
m, prov, imported := reconFixture(t, "run1", "2026-07-19T06:00:00Z", truncatedPG())
var copied bool
m.SetOffboxFullPlaceCopier(func(_, _ string) (int, error) { copied = true; return 1, nil })
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil || !strings.Contains(err.Error(), "csonka") {
t.Fatalf("want the cut-off refusal, got %v", err)
}
if len(prov.calls) != 0 || copied || len(*imported) != 0 {
t.Fatalf("ZERO mutations required: calls=%v copied=%v imported=%v", prov.calls, copied, *imported)
}
}
// A MariaDB copy is judged by its own marker, not PostgreSQL's.
func TestR640_MariaDBCopyNeedsItsOwnMarker(t *testing.T) {
dir := t.TempDir()
good := filepath.Join(dir, "romm-mariadb.sql")
mustWrite(t, good, "-- MariaDB dump 10.19\nCREATE TABLE `users` (id int);\nINSERT INTO `users` VALUES (1);\n-- Dump completed on 2026-09-23 21:00:00\n")
if bad := incompleteDumps(dir); len(bad) != 0 {
t.Fatalf("a complete MariaDB copy was flagged: %v", bad)
}
mustWrite(t, good, "-- MariaDB dump 10.19\nCREATE TABLE `users` (id int);\nINSERT INTO `users` VALUES (1);\n-- PostgreSQL database dump complete\n")
if bad := incompleteDumps(dir); len(bad) != 1 {
t.Fatalf("a MariaDB copy carrying the WRONG engine's marker must be flagged, got %v", bad)
}
}
+4 -4
View File
@@ -3,8 +3,8 @@ package backup
import (
"context"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"os"
"os/exec"
"strings"
"time"
)
@@ -148,10 +148,10 @@ func (m *Manager) restoreDockerVolumesFrom(stackName, dumpDir string) (int, erro
m.logger.Printf("[INFO] [backup] Restoring Docker volume %s for %s", volName, stackName)
// Remove existing volume (ignore errors — may not exist)
exec.Command("docker", "volume", "rm", "-f", volName).Run()
dockerexec.Command("docker", "volume", "rm", "-f", volName).Run()
// Create fresh volume
if out, err := exec.Command("docker", "volume", "create", volName).CombinedOutput(); err != nil {
if out, err := dockerexec.Command("docker", "volume", "create", volName).CombinedOutput(); err != nil {
m.logger.Printf("[ERROR] [backup] Failed to create volume %s: %s — %v", volName, strings.TrimSpace(string(out)), err)
failed = append(failed, volName)
continue
@@ -159,7 +159,7 @@ func (m *Manager) restoreDockerVolumesFrom(stackName, dumpDir string) (int, erro
// Populate from tar
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
cmd := dockerexec.CommandContext(ctx, "docker", "run", "--rm",
"-v", volName+":/vol",
"-v", dumpDir+":/in:ro",
"alpine", "tar", "xf", "/in/"+entry.Name(), "-C", "/vol")
+41
View File
@@ -5,7 +5,11 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// reimportDBDumps replays the captured per-app .sql dumps back into the app's now-running database
@@ -74,6 +78,12 @@ func (m *Manager) reimportDBDumpsFrom(ctx context.Context, stackName, dumpDir st
if _, statErr := os.Stat(dumpPath); statErr != nil {
continue // no dump for this particular DB engine
}
// R-640: a cut-off copy loads as SUCCESS into an empty PostgreSQL database. Checked here, on
// the one path every replay takes, whatever `imp` is — the seam must not be able to skip it.
if err := appbackup.CheckDumpComplete(dumpPath, db.DBType); err != nil {
m.logger.Printf("[ERROR] [backup] Restore %s: NOT replaying %s — %v", stackName, filepath.Base(dumpPath), err)
return imported, util.MsgError("err.backup.adatbazis_masolat_csonka_nem_toltve", stackName)
}
m.logger.Printf("[INFO] [backup] Restore %s: replaying DB dump into %s (%s)", stackName, db.ContainerName, db.DBType)
if err := imp(ctx, db, dumpPath); err != nil {
return imported, fmt.Errorf("importing %s dump for %s: %w", db.DBType, stackName, err)
@@ -111,3 +121,34 @@ func (m *Manager) reimportDBDumpsAtCtx(stackName, dumpDir string) (int, error) {
defer cancel()
return m.reimportDBDumpsFrom(ctx, stackName, dumpDir)
}
// incompleteDumps names the replayable dumps in dumpDir that do not end with their engine's
// completion marker (R-640). Only the files a replay would load are checked: `<stack>-<engine>.sql`,
// never the pre-restore safety copies. A directory that cannot be read yields nothing here — the
// replay's own ReadDir reports that.
func incompleteDumps(dumpDir string) []string {
entries, err := os.ReadDir(dumpDir)
if err != nil {
return nil
}
var bad []string
for _, e := range entries {
name := e.Name()
if e.IsDir() || filepath.Ext(name) != ".sql" || strings.HasPrefix(name, preRestoreDumpPrefix) {
continue
}
var t DBType
switch {
case strings.HasSuffix(name, "-"+string(appbackup.DBTypePostgres)+".sql"):
t = appbackup.DBTypePostgres
case strings.HasSuffix(name, "-"+string(appbackup.DBTypeMariaDB)+".sql"):
t = appbackup.DBTypeMariaDB
default:
continue // not a file the replay loads
}
if err := appbackup.CheckDumpComplete(filepath.Join(dumpDir, name), t); err != nil {
bad = append(bad, name)
}
}
return bad
}
@@ -9,6 +9,8 @@ import (
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
)
func newReimportTestManager() *Manager {
@@ -22,7 +24,12 @@ func writeDump(t *testing.T, nsRoot, stack string, dbType DBType) string {
t.Fatal(err)
}
p := filepath.Join(dir, fmt.Sprintf("%s-%s.sql", stack, dbType))
if err := os.WriteFile(p, []byte("-- dump\nDROP TABLE IF EXISTS t;\n"), 0o644); err != nil {
// R-640: a complete dump ends with its engine's marker, as the real tools write it.
end := "-- PostgreSQL database dump complete\n"
if dbType == appbackup.DBTypeMariaDB {
end = "-- Dump completed on 2026-09-23 21:00:00\n"
}
if err := os.WriteFile(p, []byte("-- dump\nDROP TABLE IF EXISTS t;\n"+end), 0o644); err != nil {
t.Fatal(err)
}
return p
@@ -393,6 +393,13 @@ func (m *Manager) RestoreFromRecoveryUnitAtWith(stackName, unitDir string, opt U
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: a .sql dump exists but no database service is identifiable in the unit's compose", stackName)
return res, util.MsgError("err.backup.az_adatbazis_szolgaltatas_nem_azonosithato_a", stackName)
}
// R-640: a cut-off database copy is refused HERE, before the first mutation, so the live app is
// untouched — loading it would report success over an empty (PostgreSQL) or half-replaced
// (MariaDB) database.
if bad := incompleteDumps(dbDumpDir); len(bad) > 0 {
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: incomplete database copy %v (no completion marker)", stackName, bad)
return res, util.MsgError("err.backup.adatbazis_masolat_csonka_nem_indult", stackName)
}
// Stop, restore named-volume data, recreate the definition, replay the DB with ONLY the database
// service running, and only then start the whole stack.
+2 -2
View File
@@ -3,8 +3,8 @@ package backup
import (
"encoding/json"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
@@ -89,7 +89,7 @@ func (m *Manager) sharesPassdbCapturer() func() ([]byte, error) {
// manifest-only (re-setting the SMB password is a cheap UX step; the definitions are the load-bearing
// part). Uses the same `docker exec` shape as stacks.extractInitialCreds.
func defaultSharesPassdbCapture() ([]byte, error) {
cmd := exec.Command("docker", "exec", infra.SambaContainerName,
cmd := dockerexec.Command("docker", "exec", infra.SambaContainerName,
"tar", "cf", "-", "-C", infra.SambaPassdbMount, sharesPassdbMember)
out, err := cmd.Output()
if err != nil {
+2 -2
View File
@@ -5,9 +5,9 @@ import (
"context"
"encoding/json"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -45,7 +45,7 @@ func (m *Manager) sharesPassdbRestorer() func([]byte) error {
// writes ONLY into infra.SambaPassdbMount inside the samba container — never onto the host — so a
// malformed archive cannot reach anything outside the volume it came from.
func defaultSharesPassdbRestore(tar []byte) error {
cmd := exec.Command("docker", "exec", "-i", infra.SambaContainerName,
cmd := dockerexec.Command("docker", "exec", "-i", infra.SambaContainerName,
"tar", "xf", "-", "-C", infra.SambaPassdbMount)
cmd.Stdin = bytes.NewReader(tar)
out, err := cmd.CombinedOutput()