diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1dd06cf..81eb641 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,26 @@
+## v0.267.0 — tests never touch DooPlex's Docker, a cut-off copy is never loaded, two pages tell the truth (2026-09-23, R-650, R-640, R-499, R-518; R-626 measured)
+
+**MinAgent: 0.131.0** (unchanged). No hub change. New strings: yes (hu + en).
+
+- **R-650 — a unit test can no longer act on production Docker.** New `internal/dockerexec`: every docker
+ exec in the controller goes through it, and under `go test` a real docker is refused with an error
+ naming the command (opt-in `FELHOM_TEST_REAL_DOCKER=1`; a stub under the temp dir is allowed). The sweep
+ found **8 `api` tests** and the `stacks`/`web` fixtures running real `docker ps` / `docker compose
+ version` on DooPlex, and one `stacks` test reaching a real `docker-compose down`; those packages now run
+ under a silent stub (`RunWithStub`). `backup`, `appexport`, `system` tests read `docker ps`/`info` and
+ pass on the refusal. `TestR650_NoBareDockerExec` pins the invariant repo-wide.
+- **R-640 — a cut-off database copy is refused before anything is touched.** `appbackup.CheckDumpComplete`
+ reads the end of the copy for the engine's completion marker. The unit restore and the off-site
+ restore refuse before the first mutation („…adatbázis-másolata csonka… nem indult el”); every replay
+ checks again right before the load, whatever the import seam is.
+- **R-499 — the system-disk sentence says where THIS box's whole-system backup goes.** Four branches
+ (own drive / same disk / drive gone / cannot ask); „(PBS)” and „nincs külön teendő” only where true.
+- **R-518 — the backup button states the measured stop** (about 8 minutes on a 12-app box). The brief's
+ „csak néhány másodpercre” was already gone since v0.243.0; the vague „általában néhány perc” is replaced.
+- **R-626 measured, not reproduced:** remove → 390 s of `docker events` (the remove's destroy seen, no
+ create) + a controller restart + a guest reboot → no container, no volume.
+- Red-proofs: eight, each seen failing (REPORT).
+
## v0.266.0 — a failed install removes what it started (2026-09-23, R-649)
**MinAgent: 0.131.0** (unchanged). No new strings. No hub change.
diff --git a/CONTEXT.md b/CONTEXT.md
index dbecf1c..8f574be 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -7,7 +7,14 @@
>
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
-Last updated: 2026-09-23 (v0.265.0 — R-634's cause fixed, held badge, OOM storm)
+Last updated: 2026-09-23 (v0.267.0 — tests off DooPlex's Docker, cut-off copies refused)
+
+> **2026-09-23 (night) — v0.267.0 (R-650, R-640, R-499, R-518).** Every docker exec goes through
+> `internal/dockerexec`; under `go test` a real docker is refused (opt-in `FELHOM_TEST_REAL_DOCKER=1`; a
+> stub under `os.TempDir()` passes). `api`/`stacks`/`web` tests run under `RunWithStub` (TestMain). Restore:
+> `appbackup.CheckDumpComplete` — a dump without its engine's end marker is refused before the first
+> mutation (unit + off-site) and again before any load. Tier-2 page: `systemBackupFact` → four sentences.
+> Backup button: the measured ~8 min. R-626 not reproduced (closed by measurement).
> **2026-09-23 (late evening) — v0.265.0 (R-634, R-625, R-636, R-647).** A whole-box backup no longer
> stops/restarts a DEPLOYING app (`ListDeployedStacks` skips `Deploying`; the volume leg re-asks via the
diff --git a/REPORT.md b/REPORT.md
index d866ac1..77b3cb0 100644
--- a/REPORT.md
+++ b/REPORT.md
@@ -1,37 +1,43 @@
-# ADDENDUM — v0.266.0: a failed install removes what it started (R-649, operator ruling, 2026-09-23)
+# REPORT — controller v0.267.0: tests off DooPlex's Docker, cut-off copies refused, two pages true (2026-09-23)
-`964ae75`. MinAgent 0.131.0. `compose down` (volumes kept) on the deploy's failure branch. Red-proof seen
-failing; live on 9202: 0 containers left, volumes kept. Floor 0.266.0. Evidence:
-`felhom.eu/documentation/audits/r649-2026-09-23/`.
-
----
-
-# REPORT — controller v0.265.0: R-634's cause fixed, held apps say so, the OOM storm (2026-09-23)
-
-R-634, R-625, R-636, R-647. Commit `0054d4b`. MinAgent 0.131.0 (unchanged). Needs hub v0.121.0 (deployed
-first). **Floor raised to 0.265.0.** Full report, the brief's wrong claims, evidence:
-`felhom.eu/REPORT.md`, `felhom.eu/documentation/audits/cleanup-2026-09-23/`.
+Night shift 2026-09-23, Part A. R-650, R-640, R-499, R-518; R-626 measured. MinAgent 0.131.0 (unchanged).
+No hub change. Full night record: `felhom.eu/documentation/audits/DRILL-night-2026-09-23.md`; evidence
+`felhom.eu/documentation/audits/night-2026-09-23/A*`.
## Not done, or changed
-- R-634: the backup race is fixed; the deploy's OWN failure leaving containers is an operator question → R-649.
-- R-625: the Update button was already hidden on the list; the fix is the badge (both hold kinds).
-- R-636: the flag cannot count (sticky) — the kernel `oom_kill` counter is read instead.
-- My first test draft ran real docker on DooPlex (an empty volume) — removed; tests use seams; R-650.
+- **R-518:** the brief said the page promises „csak néhány másodpercre". It does not — v0.243.0 removed
+ that. The remaining vague „általában néhány perc" is replaced by the measured ~8 minutes.
+- **R-626:** not reproduced, so not "fixed" — closed by measurement (below).
+- R-640's "narrowed" scope held: the undo copies folders; only the three restore paths load dumps.
## What shipped
-- **R-634:** deploying apps leave `ListDeployedStacks`; the volume leg re-asks (`deployingReporter`) and
- SKIPs; `StopStack`/`StartStack` → `ErrStackDeploying`.
-- **R-625:** badge `badge.update.held` „Megállítva — visszaállítás szükséges" / "Stopped — restore needed".
-- **R-636:** `ScanOOMKilled` reads `oom_kill`/`memory.max`/`memory.peak`; ≥20 kills in 30 min per container
- run → one `app_oom_storm` (error, operator-only).
-- **R-647:** `readerFuncs` (every language) + `UpdateErrorKeyHeld`; `copy_holds` as a key; two log wordings.
+- **R-650:** `internal/dockerexec` (`Command`/`CommandContext`/`RunWithStub`). 77 docker exec sites routed
+ through it. The sweep (trace of the guard's refusals, `A1-r650-sweep.txt`): `web` 67× `docker compose
+ version`, 19× `docker ps`, 19× `docker info`, 10× `docker inspect felhom-samba`, 2× `docker exec
+ felhom-samba`; `backup` 46× `docker ps`; `stacks` 11× `compose ps`, 4× `docker ps`, **1× `docker-compose
+ down`**; `appexport` 7× `docker ps`; `system` 1× `docker info`; 8 `api` tests FAILED on the refusal
+ (they built a real Manager). `api`/`stacks`/`web` → `TestMain` + `RunWithStub`; the rest pass on the refusal.
+- **R-640:** `appbackup.CheckDumpComplete`; `incompleteDumps` gate in `RestoreFromRecoveryUnit` and
+ `ReconstituteFromOffsite` before the first mutation; a second check in `reimportDBDumpsFrom` before any
+ load. Test fixtures' fake dumps now end with the real markers (they did not — 38 tests red first).
+- **R-499:** `systemBackupFact(resolveBackupTargetState)` → four branches on the Tier-2 page.
+- **R-518:** two bundle strings, both languages, state the measured stop.
-## Red-proofs (8 here, each seen failing)
-Backup skip; StopStack guard; held badge (v0.264.0 shape); reader funcs; copy_holds phrase; health
-severity; storm escalation; counter read. Messages in `felhom.eu/REPORT.md` §3.
+## Red-proofs (8, each seen failing, then restored; `git diff` clean on the reverted file)
+1. R-650 guard disabled → `TestR650_RealDockerIsRefusedUnderGoTest`: `got `.
+2. R-650 one bare `exec.CommandContext(ctx, "docker"…)` put back in dbdump.go → the sweep names `dbdump.go:140`.
+3. R-640 replay check removed → `a cut-off copy was LOADED`.
+4. R-640 unit gate removed → `stopped=true calls=[stop recreate startsvc:immich-postgres start]`.
+5. R-640 off-site gate removed → the replay check still caught it, then a rollback — not the clean refusal.
+6. R-499 handler not passing the fact → three branches missing on the page.
+7. R-499 old sentence in the same-disk branch → `promises the backup protects this app`.
+8. R-518 bundles back to v0.243.0 → measured downtime appears 0 times, old wording present.
-## Live (9202)
-R-634 race run across a live deploy — the backup stopped three other apps and never the deploying one;
-held badge + no button + 409 in all four box/reader language pairs; RomM storm at 21 kills, one line at 49.
+## R-626, measured on 9202 (v0.266.0)
+navidrome deployed and removed through the product; `docker events` for its compose project 390 s:
+kill/stop/die/destroy seen (the positive observable), **no create**. Controller restarted at +150 s;
+guest rebooted after. 0 containers, 0 volumes at every check. Leftover: `applied-compose.yml` +
+`applied-meta/` stay in the stack dir after a remove (row filed).
-`go test ./...` rc=0; `controller_gates.py` rc=0.
+## Gates
+`go build/vet` rc 0; `go test -count=1 ./...` rc 0; `controller_gates.py` all OK (golden-notice advisory).
diff --git a/controller/README.md b/controller/README.md
index 53bdb95..a32ff70 100644
--- a/controller/README.md
+++ b/controller/README.md
@@ -1438,6 +1438,7 @@ Three guards added on the off-site restore surface, all server-side:
| **Free space, R-357** | `ReconstituteFromOffsite`, before `mapOffsiteRestorePaths` / `writeSafetyDump` / `StopStack` | the live namespace has less free than the scratch's size. Same wording as the two non-destructive gates (`offsiteNoSpaceMsgFmt`). No headroom multiplier — this copies a measured tree, not a predicted download. **Fail-closed** when either probe reads ≤ 0. The app is never stopped for a refused restore. |
| **Incomplete scratch, R-358** | `offboxPlaceHandler` AND `offboxReconstituteHandler` | `OffboxFullScratchReady` is false — no `.felhom-restore-complete.json`, unreadable, wrong schema, or `full:false`. „A visszaállítási másolat nem teljes…". The wizard flags control a button; these control the operation. |
| **Restore in flight, R-360** | `offboxVerifyCopyDeleteHandler` | any backup **or restore** op is running (`restoreOpBlocked()`, not `IsRunning()`). Previously it refused only during a backup, so the copy a restore was writing into could be deleted from the UI. |
+| **Cut-off database copy, R-640 (v0.267.0)** | `RestoreFromRecoveryUnit` and `ReconstituteFromOffsite`, before the first mutation; and `reimportDBDumpsFrom`, before any load, whatever the import seam is | a `-postgres.sql` / `-mariadb.sql` that does not END with its engine's completion marker (`-- PostgreSQL database dump complete` / `-- Dump completed`; `appbackup.CheckDumpComplete`, gzip-aware, last 4 KiB). A cut-off PostgreSQL copy loads with rc 0 into an empty database; a cut-off MariaDB copy fails after replacing half the tables. „…adatbázis-másolata csonka… a visszaállítás biztonsági okból nem indult el”. |
**The scratch completion marker** (`.felhom-restore-complete.json`, 0600, atomic) is written by
`RestoreOffboxScratch` only after restic returns nil, and any stale one is cleared before restic starts.
@@ -4328,6 +4329,16 @@ See `docker-compose.yml` for the full volume configuration.
---
+## Unit tests never reach the real Docker (v0.267.0, R-650)
+
+Every `docker` / `docker compose` / `docker-compose` process the controller builds goes through
+`internal/dockerexec` (`Command` / `CommandContext`). Under `go test` it is **refused** — the command's
+Start returns an error naming it — unless `FELHOM_TEST_REAL_DOCKER=1` is set, or the executable resolves
+under `os.TempDir()` (a test's own stub on PATH). The build host is DooPlex, whose Docker is production.
+`TestR650_NoBareDockerExec` fails on any new bare `exec.Command("docker", …)` in non-test code. Packages
+whose fixtures build a real `stacks.Manager` (`api`, `stacks`, `web`) run under `dockerexec.RunWithStub`
+from their `TestMain` (a silent stub on PATH).
+
## Test Environments
| Node | Hardware | Domain | Status |
diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go
index f44c4af..b4ce05c 100644
--- a/controller/cmd/controller/main.go
+++ b/controller/cmd/controller/main.go
@@ -6,11 +6,11 @@ import (
"errors"
"flag"
"fmt"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"io"
"log"
"net/http"
"os"
- "os/exec"
"os/signal"
"path/filepath"
"sort"
@@ -3019,7 +3019,7 @@ func (a *exportAdapter) RemoveStackVolumes(name string) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
- cmd := exec.CommandContext(ctx, "docker", "compose", "down", "--volumes")
+ cmd := dockerexec.CommandContext(ctx, "docker", "compose", "down", "--volumes")
cmd.Dir = stackDir
cmd.Env = cmdEnv
out, err := cmd.CombinedOutput()
diff --git a/controller/internal/api/r650_main_test.go b/controller/internal/api/r650_main_test.go
new file mode 100644
index 0000000..edec5ad
--- /dev/null
+++ b/controller/internal/api/r650_main_test.go
@@ -0,0 +1,12 @@
+package api
+
+import (
+ "os"
+ "testing"
+
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
+)
+
+// TestMain puts a silent docker stub on PATH for every test in this package: R-650, the fixtures
+// here build a real stacks.Manager, and on the build host (DooPlex) that reached production Docker.
+func TestMain(m *testing.M) { os.Exit(dockerexec.RunWithStub(m)) }
diff --git a/controller/internal/appbackup/dbdump.go b/controller/internal/appbackup/dbdump.go
index 55c0e54..9e43b2c 100644
--- a/controller/internal/appbackup/dbdump.go
+++ b/controller/internal/appbackup/dbdump.go
@@ -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
+}
diff --git a/controller/internal/appbackup/r640_complete_test.go b/controller/internal/appbackup/r640_complete_test.go
new file mode 100644
index 0000000..dc93b2d
--- /dev/null
+++ b/controller/internal/appbackup/r640_complete_test.go
@@ -0,0 +1,53 @@
+package appbackup
+
+import (
+ "compress/gzip"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// R-640: CheckDumpComplete reads the END of the copy. pg_dump 17.6+ prints `\unrestrict ` after
+// its marker, so the marker need not be the last line.
+func TestR640_CheckDumpComplete(t *testing.T) {
+ dir := t.TempDir()
+ big := make([]byte, 200*1024) // longer than the kept tail: the marker must be found at the END
+ for i := range big {
+ big[i] = 'x'
+ }
+ cases := []struct {
+ name, body string
+ t DBType
+ gz, ok bool
+ }{
+ {"pg complete", "-- PostgreSQL database dump\nCOPY t FROM stdin;\n1\n\\.\n\n--\n-- PostgreSQL database dump complete\n--\n\n\\unrestrict abc\n", DBTypePostgres, false, true},
+ {"pg cut inside COPY", "-- PostgreSQL database dump\nCOPY t FROM stdin;\n1\n", DBTypePostgres, false, false},
+ {"pg marker only at the start is not an end", "-- PostgreSQL database dump complete\n" + string(big) + "\n", DBTypePostgres, false, false},
+ {"pg complete, gzipped", "-- PostgreSQL database dump\n" + string(big) + "\n-- PostgreSQL database dump complete\n", DBTypePostgres, true, true},
+ {"pg cut, gzipped", "-- PostgreSQL database dump\n" + string(big), DBTypePostgres, true, false},
+ {"mariadb complete", "-- MariaDB dump 10.19\nINSERT INTO `t` VALUES (1);\n-- Dump completed on 2026-09-23\n", DBTypeMariaDB, false, true},
+ {"mariadb cut", "-- MariaDB dump 10.19\nINSERT INTO `t` VALUES (1),(2", DBTypeMariaDB, false, false},
+ {"unknown engine never passes", "anything\n-- Dump completed\n", DBType("sqlite"), false, false},
+ }
+ for i, c := range cases {
+ p := filepath.Join(dir, "d"+string(rune('a'+i))+".sql")
+ if c.gz {
+ p += ".gz"
+ f, _ := os.Create(p)
+ w := gzip.NewWriter(f)
+ w.Write([]byte(c.body))
+ w.Close()
+ f.Close()
+ } else if err := os.WriteFile(p, []byte(c.body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ err := CheckDumpComplete(p, c.t)
+ if (err == nil) != c.ok {
+ t.Errorf("%s: ok=%v, got err=%v", c.name, c.ok, err)
+ }
+ if !c.ok && c.t != "sqlite" && !errors.Is(err, ErrDumpIncomplete) {
+ t.Errorf("%s: want ErrDumpIncomplete, got %v", c.name, err)
+ }
+ }
+}
diff --git a/controller/internal/appexport/export.go b/controller/internal/appexport/export.go
index cc85c9d..852c167 100644
--- a/controller/internal/appexport/export.go
+++ b/controller/internal/appexport/export.go
@@ -6,11 +6,11 @@ import (
"compress/gzip"
"context"
"fmt"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"io"
"log"
"os"
- "os/exec"
"path/filepath"
"strings"
"sync"
@@ -731,7 +731,7 @@ func (e *Exporter) exportHDDData(req ExportRequest, dataDir string, manifest *Ma
// controller itself runs containerized (the v0.124.0 HIGH finding). Package var so unit tests
// inject a recorder (no docker on test boxes).
var dockerExec = func(ctx context.Context, stdin io.Reader, stdout io.Writer, args ...string) (string, error) {
- cmd := exec.CommandContext(ctx, "docker", args...)
+ cmd := dockerexec.CommandContext(ctx, "docker", args...)
cmd.Stdin = stdin
cmd.Stdout = stdout
var errBuf bytes.Buffer
diff --git a/controller/internal/appexport/restore.go b/controller/internal/appexport/restore.go
index ef0ae2c..2047d42 100644
--- a/controller/internal/appexport/restore.go
+++ b/controller/internal/appexport/restore.go
@@ -5,6 +5,7 @@ import (
"compress/gzip"
"context"
"fmt"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"io"
"os"
@@ -941,7 +942,7 @@ func composeExecEnv(stackDir string, env map[string]string, args ...string) ([]b
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
- cmd := exec.CommandContext(ctx, "docker", cmdArgs...)
+ cmd := dockerexec.CommandContext(ctx, "docker", cmdArgs...)
cmd.Dir = stackDir
cmd.Env = os.Environ()
for k, v := range env {
@@ -989,14 +990,14 @@ func waitForDB(containerID, dbType string, env map[string]string) error {
if user == "" {
user = "postgres"
}
- cmd = exec.CommandContext(ctx, "docker", "exec", containerID,
+ 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 = exec.CommandContext(ctx, "docker", "exec", containerID,
+ cmd = dockerexec.CommandContext(ctx, "docker", "exec", containerID,
"mysqladmin", "ping", "-u", "root", "-p"+password)
default:
cancel()
@@ -1047,7 +1048,7 @@ func importDBDump(containerID, dumpPath, dbType string, env map[string]string) e
if dbName == "" {
dbName = user
}
- cmd = exec.CommandContext(ctx, "docker", "exec", "-i", containerID,
+ cmd = dockerexec.CommandContext(ctx, "docker", "exec", "-i", containerID,
"psql", "-U", user, "-d", dbName)
case "mariadb":
password := env["MYSQL_ROOT_PASSWORD"]
@@ -1058,7 +1059,7 @@ func importDBDump(containerID, dumpPath, dbType string, env map[string]string) e
if dbName == "" {
dbName = env["MARIADB_DATABASE"]
}
- cmd = exec.CommandContext(ctx, "docker", "exec", "-i", containerID,
+ cmd = dockerexec.CommandContext(ctx, "docker", "exec", "-i", containerID,
"mysql", "-u", "root", "-p"+password, dbName)
default:
return fmt.Errorf("unknown DB type: %s", dbType)
diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go
index e51e4f3..8b42aae 100644
--- a/controller/internal/backup/backup.go
+++ b/controller/internal/backup/backup.go
@@ -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", ".")
diff --git a/controller/internal/backup/offbox_reconstitute.go b/controller/internal/backup/offbox_reconstitute.go
index 7baf937..c706813 100644
--- a/controller/internal/backup/offbox_reconstitute.go
+++ b/controller/internal/backup/offbox_reconstitute.go
@@ -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,
diff --git a/controller/internal/backup/offbox_reconstitute_test.go b/controller/internal/backup/offbox_reconstitute_test.go
index 026a7dd..69fe0f4 100644
--- a/controller/internal/backup/offbox_reconstitute_test.go
+++ b/controller/internal/backup/offbox_reconstitute_test.go
@@ -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 {
diff --git a/controller/internal/backup/r640_incomplete_dump_test.go b/controller/internal/backup/r640_incomplete_dump_test.go
new file mode 100644
index 0000000..5897cbc
--- /dev/null
+++ b/controller/internal/backup/r640_incomplete_dump_test.go
@@ -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)
+ }
+}
diff --git a/controller/internal/backup/restore.go b/controller/internal/backup/restore.go
index 0454791..1aa3603 100644
--- a/controller/internal/backup/restore.go
+++ b/controller/internal/backup/restore.go
@@ -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")
diff --git a/controller/internal/backup/restore_db.go b/controller/internal/backup/restore_db.go
index 0ed576b..cb21a1c 100644
--- a/controller/internal/backup/restore_db.go
+++ b/controller/internal/backup/restore_db.go
@@ -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: `-.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
+}
diff --git a/controller/internal/backup/restore_db_test.go b/controller/internal/backup/restore_db_test.go
index 569a4ec..6a651a7 100644
--- a/controller/internal/backup/restore_db_test.go
+++ b/controller/internal/backup/restore_db_test.go
@@ -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
diff --git a/controller/internal/backup/restore_unit.go b/controller/internal/backup/restore_unit.go
index 477020d..d97062f 100644
--- a/controller/internal/backup/restore_unit.go
+++ b/controller/internal/backup/restore_unit.go
@@ -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.
diff --git a/controller/internal/backup/shares_payload.go b/controller/internal/backup/shares_payload.go
index 4a0cff5..07ebfba 100644
--- a/controller/internal/backup/shares_payload.go
+++ b/controller/internal/backup/shares_payload.go
@@ -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 {
diff --git a/controller/internal/backup/shares_restore.go b/controller/internal/backup/shares_restore.go
index f8c55b5..8e9e298 100644
--- a/controller/internal/backup/shares_restore.go
+++ b/controller/internal/backup/shares_restore.go
@@ -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()
diff --git a/controller/internal/dockerexec/dockerexec.go b/controller/internal/dockerexec/dockerexec.go
new file mode 100644
index 0000000..6330e58
--- /dev/null
+++ b/controller/internal/dockerexec/dockerexec.go
@@ -0,0 +1,99 @@
+// Package dockerexec is the ONE way the controller builds a `docker` / `docker compose` /
+// `docker-compose` process. It exists for R-650: the controller's unit tests run on DooPlex, the
+// build host, which is production Docker (Gitea, the registry, k3s). A test that fell through to a
+// real `docker run … tar` created a volume there, and one ran a real `docker compose down`.
+//
+// Under `go test` a docker command is REFUSED — its Start/Run/Output returns an error naming the
+// command — unless one of two things holds:
+//
+// - FELHOM_TEST_REAL_DOCKER=1 is set (a deliberate, per-run opt-in); or
+// - the executable resolves under os.TempDir() — a stub a test wrote into t.TempDir() and put on
+// PATH, which is a seam, not Docker.
+//
+// Outside `go test` (the real controller) nothing changes: Command is exec.Command.
+// Pinned by TestR650_* in this package and by the repo-wide TestR650_NoBareDockerExec sweep.
+package dockerexec
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// OptInEnv is the environment variable that lets a test reach the real docker on purpose.
+const OptInEnv = "FELHOM_TEST_REAL_DOCKER"
+
+// underTest is a variable so this package's own tests can model the production binary.
+var underTest = testing.Testing
+
+// IsDocker reports whether name is a docker binary (by base name).
+func IsDocker(name string) bool {
+ b := filepath.Base(name)
+ return b == "docker" || b == "docker-compose"
+}
+
+// refusal returns the error a refused command carries, or nil when the command may run.
+func refusal(name string, args []string) error {
+ if !IsDocker(name) || !underTest() || os.Getenv(OptInEnv) == "1" {
+ return nil
+ }
+ if p, err := exec.LookPath(name); err == nil {
+ if abs, aerr := filepath.Abs(p); aerr == nil {
+ tmp := filepath.Clean(os.TempDir()) + string(os.PathSeparator)
+ if strings.HasPrefix(abs, tmp) {
+ return nil // a test's own stub on PATH
+ }
+ }
+ }
+ return fmt.Errorf("R-650: refused to run the real %q under go test (this host may be production Docker); "+
+ "use a seam or a stub on PATH, or set %s=1 deliberately",
+ strings.TrimSpace(name+" "+strings.Join(args, " ")), OptInEnv)
+}
+
+// Command is exec.Command for a docker binary, refused under go test (see package doc).
+// A non-docker name passes through unchanged, so a generic runner may call it for any command.
+func Command(name string, args ...string) *exec.Cmd {
+ cmd := exec.Command(name, args...)
+ if err := refusal(name, args); err != nil {
+ cmd.Err = err
+ }
+ return cmd
+}
+
+// CommandContext is exec.CommandContext with the same guard.
+func CommandContext(ctx context.Context, name string, args ...string) *exec.Cmd {
+ cmd := exec.CommandContext(ctx, name, args...)
+ if err := refusal(name, args); err != nil {
+ cmd.Err = err
+ }
+ return cmd
+}
+
+// Runner is what *testing.M offers; named so this file does not need a test-only type.
+type Runner interface{ Run() int }
+
+// RunWithStub is for a package's TestMain: it puts a `docker` and a `docker-compose` that print
+// nothing and exit 0 at the front of PATH for the whole test binary, then runs the tests. It is the
+// package-wide seam for fixtures that build a real stacks.Manager (whose NewManager/ScanStacks run
+// `docker compose version` and `docker ps`). A test that needs a specific answer still writes its
+// own stub; a test that needs the real docker sets OptInEnv. (R-650)
+func RunWithStub(m Runner) int {
+ dir, err := os.MkdirTemp("", "r650-docker-stub-")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "R-650 stub:", err)
+ return 1
+ }
+ defer os.RemoveAll(dir)
+ for _, n := range []string{"docker", "docker-compose"} {
+ if err := os.WriteFile(filepath.Join(dir, n), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+ fmt.Fprintln(os.Stderr, "R-650 stub:", err)
+ return 1
+ }
+ }
+ os.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
+ return m.Run()
+}
diff --git a/controller/internal/dockerexec/dockerexec_test.go b/controller/internal/dockerexec/dockerexec_test.go
new file mode 100644
index 0000000..95f30f4
--- /dev/null
+++ b/controller/internal/dockerexec/dockerexec_test.go
@@ -0,0 +1,104 @@
+package dockerexec
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// TestR650_RealDockerIsRefusedUnderGoTest is the decoy: a harmless-looking `docker ps` with the
+// real PATH must NOT run. The consequence asserted is that Run returns the refusal, naming the
+// command — and that nothing was started (ProcessState stays nil).
+func TestR650_RealDockerIsRefusedUnderGoTest(t *testing.T) {
+ t.Setenv(OptInEnv, "")
+ for _, name := range []string{"docker", "docker-compose", "/usr/bin/docker"} {
+ cmd := Command(name, "ps", "-a")
+ err := cmd.Run()
+ if err == nil || !strings.Contains(err.Error(), "R-650") || !strings.Contains(err.Error(), "ps -a") {
+ t.Fatalf("%s: want an R-650 refusal naming the command, got %v", name, err)
+ }
+ if cmd.ProcessState != nil {
+ t.Fatalf("%s: a process was started", name)
+ }
+ c2 := CommandContext(context.Background(), name, "volume", "create", "r650-decoy")
+ if _, err := c2.CombinedOutput(); err == nil || !strings.Contains(err.Error(), "volume create r650-decoy") {
+ t.Fatalf("%s: CommandContext not refused: %v", name, err)
+ }
+ }
+}
+
+// TestR650_StubOnPathIsAllowed — a test's own fake in t.TempDir() is a seam, not Docker.
+func TestR650_StubOnPathIsAllowed(t *testing.T) {
+ bin := t.TempDir()
+ if err := os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\necho stub:$*\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", bin)
+ out, err := Command("docker", "ps").CombinedOutput()
+ if err != nil || strings.TrimSpace(string(out)) != "stub:ps" {
+ t.Fatalf("stub should run: out=%q err=%v", out, err)
+ }
+}
+
+// TestR650_OptInAndProductionAndNonDockerPassThrough — the guard refuses nothing else.
+func TestR650_OptInAndProductionAndNonDockerPassThrough(t *testing.T) {
+ t.Setenv(OptInEnv, "1")
+ if err := refusal("docker", []string{"ps"}); err != nil {
+ t.Fatalf("opt-in must allow: %v", err)
+ }
+ t.Setenv(OptInEnv, "")
+ if err := refusal("du", []string{"-sb", "/"}); err != nil {
+ t.Fatalf("a non-docker command must pass: %v", err)
+ }
+ old := underTest
+ underTest = func() bool { return false }
+ defer func() { underTest = old }()
+ if err := refusal("docker", []string{"ps"}); err != nil {
+ t.Fatalf("the production binary must never refuse: %v", err)
+ }
+ if Command("docker", "ps").Err != nil {
+ t.Fatal("production Command carried an error")
+ }
+}
+
+// TestR650_NoBareDockerExec pins the invariant the package doc states: every production path that
+// builds a docker process goes through this package. A new `exec.Command("docker", …)` in
+// non-test code fails here, naming the file and line.
+func TestR650_NoBareDockerExec(t *testing.T) {
+ root := filepath.Join("..", "..")
+ bare := regexp.MustCompile(`\bexec\.Command(Context)?\(([^,()]+, )?"docker`)
+ var hits []string
+ n := 0
+ err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
+ return nil
+ }
+ b, err := os.ReadFile(p)
+ if err != nil {
+ return err
+ }
+ n++
+ for i, line := range strings.Split(string(b), "\n") {
+ if bare.MatchString(line) {
+ hits = append(hits, p+":"+strconv.Itoa(i+1)+": "+strings.TrimSpace(line))
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n < 100 {
+ t.Fatalf("scope: only %d Go files walked — the sweep is not looking at the tree", n)
+ }
+ if len(hits) > 0 {
+ t.Fatalf("bare docker exec outside dockerexec (R-650):\n%s", strings.Join(hits, "\n"))
+ }
+}
diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json
index 461f8a4..2fe3216 100644
--- a/controller/internal/i18n/locales/en.json
+++ b/controller/internal/i18n/locales/en.json
@@ -187,7 +187,7 @@
"backups.a_kijeloles_nem_sikerult": "The drive could not be assigned:",
"backups.a_meghajto_kijelolve": "The drive is assigned.",
"backups.a_meghajto_kijelolve_a_beallitas": "The drive is assigned. The setting takes effect after the host agent next restarts.",
- "backups.a_mentes_alatt_az_alkalmazasok": "Your apps stop during the backup — usually for a few minutes, longer with more data.",
+ "backups.a_mentes_alatt_az_alkalmazasok": "Every app stops for as long as the full system backup takes — that can be several minutes (about 8 minutes, measured on a box with 12 apps), longer with more data.",
"backups.a_mentes_sikertelen": "The backup failed.",
"backups.a_mentesek_egymas_utan_futnak": "The backups run one after the other: database backup, local copy, remote backup, then the full system backup.",
"backups.a_rendszermentes_elkeszult": "The system backup is done.",
@@ -201,7 +201,7 @@
"backups.beagyazott_db_k_a_kotetmentesben": "embedded databases, inside the volume backup",
"backups.biztonsagi_mentes": "Backup",
"backups.db_mentesek": "Database backups",
- "backups.elinditod_a_teljes_rendszermentest_most": "Start the full system backup now? Your apps stop during the backup — usually for a few minutes, longer with more data.",
+ "backups.elinditod_a_teljes_rendszermentest_most": "Start the full system backup now? Every app stops for as long as the full system backup takes — that can be several minutes (about 8 minutes, measured on a box with 12 apps), longer with more data.",
"backups.esedekes": "Due",
"backups.fajl": "{{$n := len .Backup.DumpFiles}}{{$n}} {{if eq $n 1}}file{{else}}files{{end}}",
"backups.helyi_masolat": "Local copy: {{.BackupLegTier2}}",
@@ -1199,6 +1199,8 @@
"err.backup.a_z_ujrainditasa_sikertelen_a_fajlok": "%s could not be restarted after its files were put back: %s",
"err.backup.adatathelyezes_folyamatban_a_mentes_most_nem": "data is being moved — the backup cannot start right now",
"err.backup.adatbazis_felderites_sikertelen": "finding the databases failed: %s",
+ "err.backup.adatbazis_masolat_csonka_nem_indult": "The database copy of %s is cut off: it does not end the way a complete copy does. The restore did not start, for safety — the app and its data are untouched.",
+ "err.backup.adatbazis_masolat_csonka_nem_toltve": "The database copy of %s is cut off: it does not end the way a complete copy does. That copy was not loaded — the database was not restored from it.",
"err.backup.adatbazis_mentes_sikertelen": "the database backup failed (%s): %s",
"err.backup.az_adatbazis_szolgaltatas_nem_azonosithato_a": "The database service cannot be identified in %s — the restore did not start, for safety.",
"err.backup.az_alkalmazas_meghajtoja_le_van_szerelve": "the app’s drive is decommissioned",
@@ -2345,7 +2347,6 @@
"tier2_config.cel": "Target",
"tier2_config.cel_meghajto": "Target drive",
"tier2_config.csak_db_konfiguracio": "— database/configuration only",
- "tier2_config.ennek_az_alkalmazasnak_az_adatai": "This app’s data is on the internal system disk, which is\n already part of the full system backup (PBS). The 2nd (off-drive) copy\n is extra, and is made mainly for apps stored on the external data drive — nothing to do for this\n app.",
"tier2_config.jelenleg": "(now: {{.EffectiveLabel}})",
"tier2_config.jelenleg_csak_a_belso_ssd": "Only the internal SSD is available as a 2nd target right now, so only the database and the configuration\n are copied. The internal system disk is small, so an off-drive backup of large files needs a\n 2nd data drive (so the system disk does not fill up).",
"tier2_config.jelenlegi_allapot": "Current state",
@@ -2356,6 +2357,10 @@
"tier2_config.mentes": "Save",
"tier2_config.nincs_elerheto_off_drive_cel": "No off-drive target available",
"tier2_config.nincs_masik_adatmeghajto_automatikus_cel": "No other data drive — the automatic target is the internal SSD (database/configuration only). Add a 2nd\n data drive to back up all data off-drive too.",
+ "tier2_config.rendszerlemez.hianyzik": "This app’s data is on the internal system disk. The full system backup’s drive\n is not reachable right now — until you reconnect it, no fresh system backup is made of this app either.",
+ "tier2_config.rendszerlemez.ismeretlen": "This app’s data is on the internal system disk and is part of the full system backup.\n Where that backup is being made right now could not be checked — the Backups page shows it.",
+ "tier2_config.rendszerlemez.ugyanaz": "This app’s data is on the internal system disk. The full system backup\n is currently made to that same disk — so it protects against damaged files, not against a failed disk.\n Connect a second drive and choose it as the system backup location on the Backups page, and this app is protected too.",
+ "tier2_config.rendszerlemez.vedett": "This app’s data is on the internal system disk, which is\n part of the full system backup, and that backup is made to a separate drive. The 2nd (off-drive) copy\n is extra, and is made mainly for apps stored on the external data drive — nothing to do for this\n app.",
"tier2_config.vissza_a_mentesekhez": "← Back to backups",
"update.error.backup_failed": "The update did not start, because the backup before the update did not succeed: %v. The app keeps running unchanged.",
"update.error.backup_no_unit": "The update did not start: the backup before the update ran, but no fresh copy that can be restored was made. The app keeps running unchanged.",
diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json
index 8e3cd62..c94ea3e 100644
--- a/controller/internal/i18n/locales/hu.json
+++ b/controller/internal/i18n/locales/hu.json
@@ -183,7 +183,7 @@
"backups.a_kijeloles_nem_sikerult": "A kijelölés nem sikerült:",
"backups.a_meghajto_kijelolve": "A meghajtó kijelölve.",
"backups.a_meghajto_kijelolve_a_beallitas": "A meghajtó kijelölve. A beállítás a host-ügynök következő újraindulása után lép életbe.",
- "backups.a_mentes_alatt_az_alkalmazasok": "A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.",
+ "backups.a_mentes_alatt_az_alkalmazasok": "A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.",
"backups.a_mentes_sikertelen": "A mentés sikertelen.",
"backups.a_mentesek_egymas_utan_futnak": "A mentések egymás után futnak: adatbázis-mentés, helyi másolat, távoli mentés, majd a teljes rendszermentés.",
"backups.a_rendszermentes_elkeszult": "A rendszermentés elkészült.",
@@ -197,7 +197,7 @@
"backups.beagyazott_db_k_a_kotetmentesben": "beágyazott DB-k a kötetmentésben",
"backups.biztonsagi_mentes": "Biztonsági mentés",
"backups.db_mentesek": "DB mentések",
- "backups.elinditod_a_teljes_rendszermentest_most": "Elindítod a teljes rendszermentést most? A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.",
+ "backups.elinditod_a_teljes_rendszermentest_most": "Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.",
"backups.esedekes": "Esedékes",
"backups.fajl": "{{len .Backup.DumpFiles}} fájl",
"backups.helyi_masolat": "Helyi másolat: {{.BackupLegTier2}}",
@@ -1194,6 +1194,8 @@
"err.backup.a_z_ujrainditasa_sikertelen_a_fajlok": "a(z) %s újraindítása sikertelen a fájlok visszaállítása után: %s",
"err.backup.adatathelyezes_folyamatban_a_mentes_most_nem": "adatáthelyezés folyamatban — a mentés most nem indítható",
"err.backup.adatbazis_felderites_sikertelen": "adatbázis-felderítés sikertelen: %s",
+ "err.backup.adatbazis_masolat_csonka_nem_indult": "A(z) %s adatbázis-másolata csonka: nem ér véget, ahogy egy teljes másolat. A visszaállítás biztonsági okból nem indult el — az alkalmazás és az adatai érintetlenek.",
+ "err.backup.adatbazis_masolat_csonka_nem_toltve": "A(z) %s adatbázis-másolata csonka: nem ér véget, ahogy egy teljes másolat. Ezt a másolatot nem töltöttük vissza — az adatbázis nem ebből a másolatból állt vissza.",
"err.backup.adatbazis_mentes_sikertelen": "adatbázis-mentés sikertelen (%s): %s",
"err.backup.az_adatbazis_szolgaltatas_nem_azonosithato_a": "Az adatbázis-szolgáltatás nem azonosítható a(z) %s alkalmazásban — a visszaállítás biztonsági okból nem indult el.",
"err.backup.az_alkalmazas_meghajtoja_le_van_szerelve": "az alkalmazás meghajtója le van szerelve",
@@ -2333,7 +2335,6 @@
"tier2_config.cel": "Cél",
"tier2_config.cel_meghajto": "Cél meghajtó",
"tier2_config.csak_db_konfiguracio": "— csak DB/konfiguráció",
- "tier2_config.ennek_az_alkalmazasnak_az_adatai": "Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, amelyek\n már szerepelnek a teljes rendszermentésben (PBS). A 2. (off-drive) másolat\n kiegészítő, és elsősorban a külső adatmeghajtón tárolt alkalmazásokhoz készül — ehhez az\n alkalmazáshoz nincs külön teendő.",
"tier2_config.jelenleg": "(jelenleg: {{.EffectiveLabel}})",
"tier2_config.jelenleg_csak_a_belso_ssd": "Jelenleg csak a belső SSD érhető el 2. célként, ezért csak az adatbázis és a konfiguráció\n másolódik. A belső rendszerlemez kicsi, ezért a nagy fájlok off-drive mentéséhez egy\n 2. adatmeghajtó szükséges (hogy a rendszerlemez ne teljen meg).",
"tier2_config.jelenlegi_allapot": "Jelenlegi állapot",
@@ -2344,6 +2345,10 @@
"tier2_config.mentes": "Mentés",
"tier2_config.nincs_elerheto_off_drive_cel": "Nincs elérhető off-drive cél",
"tier2_config.nincs_masik_adatmeghajto_automatikus_cel": "Nincs másik adatmeghajtó — automatikus cél a belső SSD (csak DB/konfiguráció). Egy 2.\n adatmeghajtó hozzáadásával a teljes adat is off-drive menthető.",
+ "tier2_config.rendszerlemez.hianyzik": "Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak. A teljes rendszermentés meghajtója\n most nem érhető el — amíg vissza nem csatlakoztatod, erről az alkalmazásról sem készül friss rendszermentés.",
+ "tier2_config.rendszerlemez.ismeretlen": "Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, és a teljes rendszermentés része.\n Hogy a rendszermentés most hová készül, azt nem tudtuk lekérdezni — a Mentések oldalon láthatod.",
+ "tier2_config.rendszerlemez.ugyanaz": "Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak. A teljes rendszermentés\n jelenleg ugyanerre a lemezre készül — így hibás fájlok ellen véd, lemezhiba ellen nem.\n Ha csatlakoztatsz egy második meghajtót, és a Mentések oldalon kijelölöd a rendszermentés helyéül, ez az alkalmazás is védett lesz.",
+ "tier2_config.rendszerlemez.vedett": "Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, amelyek\n szerepelnek a teljes rendszermentésben, és az egy külön meghajtóra készül. A 2. (off-drive) másolat\n kiegészítő, és elsősorban a külső adatmeghajtón tárolt alkalmazásokhoz készül — ehhez az\n alkalmazáshoz nincs külön teendő.",
"tier2_config.vissza_a_mentesekhez": "← Vissza a mentésekhez",
"update.error.backup_failed": "A frissítés nem indult el, mert a frissítés előtti biztonsági mentés nem sikerült: %v. Az alkalmazás változatlanul fut tovább.",
"update.error.backup_no_unit": "A frissítés nem indult el: a frissítés előtti mentés lefutott, de nem jött létre friss, visszaállítható másolat. Az alkalmazás változatlanul fut tovább.",
diff --git a/controller/internal/integrations/onlyoffice_nextcloud.go b/controller/internal/integrations/onlyoffice_nextcloud.go
index fde8807..821766c 100644
--- a/controller/internal/integrations/onlyoffice_nextcloud.go
+++ b/controller/internal/integrations/onlyoffice_nextcloud.go
@@ -3,8 +3,8 @@ package integrations
import (
"context"
"fmt"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
- "os/exec"
"strings"
"time"
)
@@ -67,7 +67,7 @@ func (h *OnlyOfficeNextcloudHandler) Apply(ac *ApplyContext) error {
for _, cmd := range commands {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
- c := exec.CommandContext(ctx, cmd.args[0], cmd.args[1:]...)
+ c := dockerexec.CommandContext(ctx, cmd.args[0], cmd.args[1:]...)
out, err := c.CombinedOutput()
cancel()
if err != nil {
@@ -89,7 +89,7 @@ func (h *OnlyOfficeNextcloudHandler) Revoke(ac *ApplyContext) error {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
- cmd := exec.CommandContext(ctx, "docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:disable", "onlyoffice")
+ cmd := dockerexec.CommandContext(ctx, "docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:disable", "onlyoffice")
out, err := cmd.CombinedOutput()
if err != nil {
outStr := string(out)
diff --git a/controller/internal/metrics/collector.go b/controller/internal/metrics/collector.go
index c388327..da8bf2d 100644
--- a/controller/internal/metrics/collector.go
+++ b/controller/internal/metrics/collector.go
@@ -3,8 +3,8 @@ package metrics
import (
"context"
"fmt"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"log"
- "os/exec"
"strconv"
"strings"
"sync"
@@ -100,7 +100,7 @@ func (c *MetricsCollector) sampleContainers(parentCtx context.Context) []Contain
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Second)
defer cancel()
- cmd := exec.CommandContext(ctx, "docker", "stats", "--no-stream",
+ cmd := dockerexec.CommandContext(ctx, "docker", "stats", "--no-stream",
"--format", "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}")
out, err := cmd.Output()
if err != nil {
diff --git a/controller/internal/metrics/logscanner.go b/controller/internal/metrics/logscanner.go
index be713e7..1471c51 100644
--- a/controller/internal/metrics/logscanner.go
+++ b/controller/internal/metrics/logscanner.go
@@ -3,8 +3,8 @@ package metrics
import (
"context"
"fmt"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"log"
- "os/exec"
"regexp"
"sort"
"strings"
@@ -98,7 +98,7 @@ func scanOneContainer(name string, since time.Duration, logger *log.Logger) Cont
defer cancel()
sinceStr := formatSinceDuration(since)
- cmd := exec.CommandContext(ctx, "docker", "logs", "--since="+sinceStr, "--tail=1000", name)
+ cmd := dockerexec.CommandContext(ctx, "docker", "logs", "--since="+sinceStr, "--tail=1000", name)
output, err := cmd.CombinedOutput()
if err != nil {
if logger != nil {
@@ -238,7 +238,7 @@ func FetchContainerLogTail(name string, tailLines int) (string, error) {
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
- cmd := exec.CommandContext(ctx, "docker", "logs", fmt.Sprintf("--tail=%d", tailLines), name)
+ cmd := dockerexec.CommandContext(ctx, "docker", "logs", fmt.Sprintf("--tail=%d", tailLines), name)
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("docker logs %s: %w", name, err)
diff --git a/controller/internal/monitor/healthcheck.go b/controller/internal/monitor/healthcheck.go
index 66e9b2c..e6fa5b2 100644
--- a/controller/internal/monitor/healthcheck.go
+++ b/controller/internal/monitor/healthcheck.go
@@ -2,9 +2,9 @@ package monitor
import (
"fmt"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"log"
"os"
- "os/exec"
"strings"
"time"
@@ -280,7 +280,7 @@ func (r *HealthReport) FormatMessage() string {
}
func checkDocker() error {
- cmd := exec.Command("docker", "info", "--format", "{{.ServerVersion}}")
+ cmd := dockerexec.Command("docker", "info", "--format", "{{.ServerVersion}}")
out, err := cmd.Output()
if err != nil {
return fmt.Errorf("docker not reachable: %v", err)
@@ -342,7 +342,7 @@ func EffectiveProtected(cfg *config.Config, smb settings.SMBSettings) []string {
func checkProtectedContainers(protected []string) []string {
var missing []string
for _, name := range protected {
- cmd := exec.Command("docker", "inspect", "--format", "{{.State.Running}}", name)
+ cmd := dockerexec.Command("docker", "inspect", "--format", "{{.State.Running}}", name)
out, err := cmd.Output()
if err != nil {
missing = append(missing, name)
diff --git a/controller/internal/selftest/selftest.go b/controller/internal/selftest/selftest.go
index e52a097..e565356 100644
--- a/controller/internal/selftest/selftest.go
+++ b/controller/internal/selftest/selftest.go
@@ -3,10 +3,10 @@ package selftest
import (
"context"
"fmt"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"log"
"net/http"
"os"
- "os/exec"
"path/filepath"
"strings"
"time"
@@ -91,7 +91,7 @@ func checkDockerSocket() CheckResult {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
- out, err := exec.CommandContext(ctx, "docker", "info", "--format", "{{.ServerVersion}}").Output()
+ out, err := dockerexec.CommandContext(ctx, "docker", "info", "--format", "{{.ServerVersion}}").Output()
if err != nil {
return CheckResult{Name: "Docker socket", Status: "fail", Message: fmt.Sprintf("docker info failed: %v", err)}
}
diff --git a/controller/internal/selfupdate/updater.go b/controller/internal/selfupdate/updater.go
index 985026c..488ca5b 100644
--- a/controller/internal/selfupdate/updater.go
+++ b/controller/internal/selfupdate/updater.go
@@ -5,12 +5,12 @@ import (
"context"
"encoding/json"
"fmt"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"io"
"log"
"net/http"
neturl "net/url"
- "os/exec"
"strings"
"sync"
"time"
@@ -858,7 +858,7 @@ func (u *Updater) VerifyStartup() *UpdateState {
// runCommand executes a command and returns combined stdout+stderr and error.
// Package var so tests can fake the docker CLI (no real exec in unit tests).
var runCommand = func(name string, args ...string) (string, error) {
- cmd := exec.Command(name, args...)
+ cmd := dockerexec.Command(name, args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
@@ -870,7 +870,7 @@ var runCommand = func(name string, args ...string) (string, error) {
// `docker login --password-stdin` so the token is never in argv/ps). Returns combined output.
// Package var so tests can fake the docker CLI.
var runCommandStdin = func(stdin, name string, args ...string) (string, error) {
- cmd := exec.Command(name, args...)
+ cmd := dockerexec.Command(name, args...)
cmd.Stdin = strings.NewReader(stdin)
var out bytes.Buffer
cmd.Stdout = &out
diff --git a/controller/internal/stacks/guestnet.go b/controller/internal/stacks/guestnet.go
index 3bb56c3..28111e8 100644
--- a/controller/internal/stacks/guestnet.go
+++ b/controller/internal/stacks/guestnet.go
@@ -2,8 +2,8 @@ package stacks
import (
"fmt"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"net"
- "os/exec"
"strings"
)
@@ -45,7 +45,7 @@ func (m *Manager) guestNetExec(args ...string) (string, error) {
if m.guestNetExecFn != nil {
return m.guestNetExecFn(args...)
}
- out, err := exec.Command("docker", append([]string{"exec", sambaContainer}, args...)...).Output()
+ out, err := dockerexec.Command("docker", append([]string{"exec", sambaContainer}, args...)...).Output()
if err != nil {
return "", fmt.Errorf("docker exec %s: %w", strings.Join(args, " "), err)
}
diff --git a/controller/internal/stacks/infra.go b/controller/internal/stacks/infra.go
index d283a06..936c409 100644
--- a/controller/internal/stacks/infra.go
+++ b/controller/internal/stacks/infra.go
@@ -2,8 +2,8 @@ package stacks
import (
"fmt"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"os"
- "os/exec"
"path/filepath"
"strings"
@@ -221,7 +221,7 @@ func (m *Manager) wireController(traefikDir string) error {
}
if !containerOnNetwork(controllerContainer, traefikNetwork) {
- out, err := exec.Command("docker", "network", "connect", traefikNetwork, controllerContainer).CombinedOutput()
+ out, err := dockerexec.Command("docker", "network", "connect", traefikNetwork, controllerContainer).CombinedOutput()
if err != nil && !strings.Contains(string(out), "already exists") {
return fmt.Errorf("network connect %s: %s: %w", controllerContainer, strings.TrimSpace(string(out)), err)
}
@@ -234,7 +234,7 @@ func (m *Manager) wireController(traefikDir string) error {
// We list the network names and match exactly — NOT `{{index .Networks "name"}}`, whose output for an
// absent key is "" (a non-empty string), which would falsely read as "already attached".
func containerOnNetwork(name, network string) bool {
- out, err := exec.Command("docker", "inspect", "--format",
+ out, err := dockerexec.Command("docker", "inspect", "--format",
"{{range $k, $_ := .NetworkSettings.Networks}}{{$k}}\n{{end}}", name).Output()
if err != nil {
return false
@@ -250,14 +250,14 @@ func containerOnNetwork(name, network string) bool {
// ensureTraefikNetwork creates the external traefik-public docker network if absent (idempotent;
// tolerates a create/inspect race). Uses the docker CLI directly — it's a network op, not compose.
func (m *Manager) ensureTraefikNetwork() error {
- if exec.Command("docker", "network", "inspect", traefikNetwork).Run() == nil {
+ if dockerexec.Command("docker", "network", "inspect", traefikNetwork).Run() == nil {
return nil
}
m.logger.Printf("[INFO] [infra] creating docker network %s", traefikNetwork)
- out, err := exec.Command("docker", "network", "create", traefikNetwork).CombinedOutput()
+ out, err := dockerexec.Command("docker", "network", "create", traefikNetwork).CombinedOutput()
if err != nil {
// Tolerate a race where another actor created it between our inspect and create.
- if exec.Command("docker", "network", "inspect", traefikNetwork).Run() == nil {
+ if dockerexec.Command("docker", "network", "inspect", traefikNetwork).Run() == nil {
return nil
}
return fmt.Errorf("network create %s: %s: %w", traefikNetwork, strings.TrimSpace(string(out)), err)
@@ -295,7 +295,7 @@ func writeInfraFiles(dir string, files map[string]infra.FileSpec) error {
// containerRunning reports whether a container with the given name is currently running. It asks the
// daemon directly (works before the stack dir exists), mirroring monitor.checkProtectedContainers.
func containerRunning(name string) bool {
- out, err := exec.Command("docker", "inspect", "--format", "{{.State.Running}}", name).Output()
+ out, err := dockerexec.Command("docker", "inspect", "--format", "{{.State.Running}}", name).Output()
if err != nil {
return false
}
diff --git a/controller/internal/stacks/initialcreds.go b/controller/internal/stacks/initialcreds.go
index 769ecf1..d1b6732 100644
--- a/controller/internal/stacks/initialcreds.go
+++ b/controller/internal/stacks/initialcreds.go
@@ -3,7 +3,7 @@ package stacks
import (
"encoding/json"
"fmt"
- "os/exec"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"regexp"
"strings"
)
@@ -46,7 +46,7 @@ func (m *Manager) ReadInitialCredentials(stackName string) (*ExtractedCreds, err
return &ExtractedCreds{Available: false}, nil
}
- out, err := exec.Command("docker", "exec", container, "cat", spec.File).Output()
+ out, err := dockerexec.Command("docker", "exec", container, "cat", spec.File).Output()
if err != nil {
// File missing / container not exec-able yet — expected during early boot or after the
// customer deletes the file. Not an error worth surfacing; hide the card.
diff --git a/controller/internal/stacks/installed.go b/controller/internal/stacks/installed.go
index 41a5df6..6afd0a8 100644
--- a/controller/internal/stacks/installed.go
+++ b/controller/internal/stacks/installed.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"os"
"os/exec"
"path/filepath"
@@ -32,7 +33,7 @@ const installedRecordTimeout = 30 * time.Second
type execRunner func(ctx context.Context, dir string, env []string, name string, args ...string) (string, error)
func defaultExecRunner(ctx context.Context, dir string, env []string, name string, args ...string) (string, error) {
- cmd := exec.CommandContext(ctx, name, args...)
+ cmd := dockerexec.CommandContext(ctx, name, args...)
if dir != "" {
cmd.Dir = dir
}
diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go
index c94c905..9ccda6b 100644
--- a/controller/internal/stacks/manager.go
+++ b/controller/internal/stacks/manager.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"log"
"os"
"os/exec"
@@ -500,7 +501,7 @@ func toTitleCase(s string) string {
}
func detectComposeCommand() string {
- if err := exec.Command("docker", "compose", "version").Run(); err == nil {
+ if err := dockerexec.Command("docker", "compose", "version").Run(); err == nil {
return "docker compose"
}
if _, err := exec.LookPath("docker-compose"); err == nil {
@@ -1377,9 +1378,9 @@ func (m *Manager) composeExecCustomEnv(dir string, env []string, args ...string)
if m.composeCmd == "docker compose" {
fullArgs := append([]string{"compose"}, args...)
- cmd = exec.Command("docker", fullArgs...)
+ cmd = dockerexec.Command("docker", fullArgs...)
} else {
- cmd = exec.Command("docker-compose", args...)
+ cmd = dockerexec.Command("docker-compose", args...)
}
cmd.Dir = dir
@@ -1456,7 +1457,7 @@ func (m *Manager) execCommand(name string, args ...string) (string, error) {
if m.execFn != nil {
return m.execFn(name, args...)
}
- cmd := exec.Command(name, args...)
+ cmd := dockerexec.Command(name, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
@@ -1545,7 +1546,7 @@ func (m *Manager) checkLocalImages(name, stackDir string) {
m.logger.Printf("[INFO] [stacks] Deploying stack %s — checking %d images...", name, len(images))
for _, img := range images {
- cmd := exec.Command("docker", "image", "inspect", img)
+ cmd := dockerexec.Command("docker", "image", "inspect", img)
if err := cmd.Run(); err != nil {
m.logger.Printf("[DEBUG] %s — not found locally, will pull", img)
} else {
diff --git a/controller/internal/stacks/r650_main_test.go b/controller/internal/stacks/r650_main_test.go
new file mode 100644
index 0000000..5e626ac
--- /dev/null
+++ b/controller/internal/stacks/r650_main_test.go
@@ -0,0 +1,12 @@
+package stacks
+
+import (
+ "os"
+ "testing"
+
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
+)
+
+// TestMain puts a silent docker stub on PATH for every test in this package: R-650, the fixtures
+// here build a real stacks.Manager, and on the build host (DooPlex) that reached production Docker.
+func TestMain(m *testing.M) { os.Exit(dockerexec.RunWithStub(m)) }
diff --git a/controller/internal/stacks/samba.go b/controller/internal/stacks/samba.go
index 225a42f..a8abedd 100644
--- a/controller/internal/stacks/samba.go
+++ b/controller/internal/stacks/samba.go
@@ -2,10 +2,10 @@ package stacks
import (
"fmt"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"net"
"os"
- "os/exec"
"path/filepath"
"strings"
"time"
@@ -98,7 +98,7 @@ func (m *Manager) SambaImagePresent() bool {
if m.sambaImgFn != nil {
return m.sambaImgFn()
}
- return exec.Command("docker", "image", "inspect", infra.SambaImage).Run() == nil
+ return dockerexec.Command("docker", "image", "inspect", infra.SambaImage).Run() == nil
}
// shareAvailable reports whether a share's folder can be exported right now: its owning registered
@@ -287,7 +287,7 @@ func (m *Manager) sambaSetPassword(password string) error {
var lastErr error
// The container may need a moment to accept exec right after `compose up -d`.
for attempt := 1; attempt <= 10; attempt++ {
- cmd := exec.Command("docker", "exec", "-i", sambaContainer,
+ cmd := dockerexec.Command("docker", "exec", "-i", sambaContainer,
"smbpasswd", "-s", "-a", infra.SambaHouseholdUser)
cmd.Stdin = strings.NewReader(password + "\n" + password + "\n")
out, err := cmd.CombinedOutput()
@@ -350,7 +350,7 @@ func (m *Manager) sambaLANAddr() (string, error) {
if m.sambaAddrFn != nil {
return m.sambaAddrFn()
}
- out, err := exec.Command("docker", "exec", sambaContainer,
+ out, err := dockerexec.Command("docker", "exec", sambaContainer,
"ip", "-4", "-o", "addr", "show", infra.SambaHostInterface).Output()
if err != nil {
return "", fmt.Errorf("docker exec ip addr: %w", err)
diff --git a/controller/internal/system/info_linux.go b/controller/internal/system/info_linux.go
index 5880bcb..209771c 100644
--- a/controller/internal/system/info_linux.go
+++ b/controller/internal/system/info_linux.go
@@ -6,8 +6,8 @@ import (
"bufio"
"context"
"fmt"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"os"
- "os/exec"
"path/filepath"
"sort"
"strconv"
@@ -166,7 +166,7 @@ func guestMemTotalMB() (uint64, bool) {
}
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
- out, err := exec.CommandContext(ctx, "docker", "info", "--format", "{{.MemTotal}}").Output()
+ out, err := dockerexec.CommandContext(ctx, "docker", "info", "--format", "{{.MemTotal}}").Output()
if err != nil {
return 0, false
}
diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go
index 977cdbd..23f870b 100644
--- a/controller/internal/web/handlers.go
+++ b/controller/internal/web/handlers.go
@@ -5,11 +5,11 @@ import (
"context"
"errors"
"fmt"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"log"
"net/http"
"net/url"
"os"
- "os/exec"
"path/filepath"
"sort"
"strings"
@@ -3383,7 +3383,7 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
s.logger.Printf("[INFO] [web] FileBrowser sources changed — resetting database (restore mode)")
resetCtx, resetCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer resetCancel()
- stop := exec.CommandContext(resetCtx, "docker", "compose", "down", "-v")
+ stop := dockerexec.CommandContext(resetCtx, "docker", "compose", "down", "-v")
stop.Dir = stackDir
if out, err := stop.CombinedOutput(); err != nil {
s.logger.Printf("[WARN] [web] FileBrowser down -v: %s — %v", strings.TrimSpace(string(out)), err)
@@ -3399,7 +3399,7 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
if changed {
args = []string{"compose", "up", "-d", "--force-recreate", "--remove-orphans"}
}
- cmd := exec.CommandContext(ctx, "docker", args...)
+ cmd := dockerexec.CommandContext(ctx, "docker", args...)
cmd.Dir = stackDir
if out, err := cmd.CombinedOutput(); err != nil {
s.logger.Printf("[ERROR] [web] Failed to bring up FileBrowser: %s — %v", string(out), err)
diff --git a/controller/internal/web/i18n_cases_b_test.go b/controller/internal/web/i18n_cases_b_test.go
index 1661561..15e6260 100644
--- a/controller/internal/web/i18n_cases_b_test.go
+++ b/controller/internal/web/i18n_cases_b_test.go
@@ -288,6 +288,29 @@ func i18nCasesB() []i18nCase {
d["Tier2"] = m{"IsHDDApp": false, "Disabled": false, "NoTarget": true}
return d
}},
+ // R-499 (v0.267.0): the system-disk sentence has one branch per whole-system backup target
+ // state. tier2_not_hdd above carries no SystemBackup, which renders the `unknown` branch.
+ {"tier2_not_hdd_protected", "tier2_config", func() map[string]interface{} {
+ d := i18nLayoutData("backups", "Második mentés beállítása")
+ d["DisplayName"], d["StackName"] = "Private Bin", "privatebin"
+ d["Tier2"] = m{"IsHDDApp": false}
+ d["SystemBackup"] = "protected"
+ return d
+ }},
+ {"tier2_not_hdd_same_disk", "tier2_config", func() map[string]interface{} {
+ d := i18nLayoutData("backups", "Második mentés beállítása")
+ d["DisplayName"], d["StackName"] = "Private Bin", "privatebin"
+ d["Tier2"] = m{"IsHDDApp": false}
+ d["SystemBackup"] = "same_disk"
+ return d
+ }},
+ {"tier2_not_hdd_absent", "tier2_config", func() map[string]interface{} {
+ d := i18nLayoutData("backups", "Második mentés beállítása")
+ d["DisplayName"], d["StackName"] = "Private Bin", "privatebin"
+ d["Tier2"] = m{"IsHDDApp": false}
+ d["SystemBackup"] = "absent"
+ return d
+ }},
{"tier2_notarget_disabled", "tier2_config", func() map[string]interface{} {
d := i18nLayoutData("backups", "Második mentés beállítása")
d["DisplayName"], d["StackName"] = "Immich Photos", "immich"
diff --git a/controller/internal/web/r499_system_backup_test.go b/controller/internal/web/r499_system_backup_test.go
new file mode 100644
index 0000000..324f085
--- /dev/null
+++ b/controller/internal/web/r499_system_backup_test.go
@@ -0,0 +1,105 @@
+package web
+
+import (
+ "context"
+ "errors"
+ "io"
+ "log"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/backup"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/config"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/settings"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
+)
+
+// R-499 — an app on the system disk was told „már szerepelnek a teljes rendszermentésben (PBS)" and
+// „nincs külön teendő" on every box, including one whose only whole-system copy sat on the SAME disk
+// (measured 2026-09-14). The page must now say what is true for THIS box.
+
+// tier2PageServer builds a real Server with one deployed, driveless app ("privatebin") and the
+// agent's tier/disk answers injected through the production seams, then renders the real handler.
+func tier2PageServer(t *testing.T, tiers func(context.Context) (agentapi.TiersResponse, error), disks []agentapi.DiskInfo) string {
+ t.Helper()
+ lg := log.New(io.Discard, "", 0)
+ dir := t.TempDir()
+ cfg := &config.Config{}
+ cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
+ cfg.Paths.DataDir = filepath.Join(dir, "data")
+ cfg.Paths.SystemDataPath = filepath.Join(dir, "system")
+ cfg.Stacks.ComposeCommand = "docker compose"
+ cfg.Backup.Enabled = true
+ app := filepath.Join(cfg.Paths.StacksDir, "privatebin")
+ if err := os.MkdirAll(app, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n privatebin:\n image: privatebin/pdo:2.0.6\n"), 0o644)
+ os.WriteFile(filepath.Join(app, "app.yaml"), []byte("deployed: true\n"), 0o600)
+ sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sm, err := stacks.NewManager(cfg, lg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := sm.ScanStacks(); err != nil {
+ t.Fatal(err)
+ }
+ bm := backup.NewManager(cfg, sett, lg)
+ bm.SetStackProvider(&blockProvider{hdd: ""}) // driveless: IsHDDApp false
+ s := &Server{cfg: cfg, settings: sett, stackMgr: sm, backupMgr: bm, logger: lg, version: "test"}
+ s.tiersFn = tiers
+ s.disksFn = func(context.Context) (agentapi.DisksResponse, error) { return agentapi.DisksResponse{Disks: disks}, nil }
+ s.loadTemplates()
+ w := httptest.NewRecorder()
+ s.tier2ConfigPageHandler(w, httptest.NewRequest("GET", "/stacks/privatebin/backup", nil), "privatebin")
+ if w.Code != 200 {
+ t.Fatalf("page answered %d", w.Code)
+ }
+ return w.Body.String()
+}
+
+func primaryTier(target string) func(context.Context) (agentapi.TiersResponse, error) {
+ return func(context.Context) (agentapi.TiersResponse, error) {
+ return agentapi.TiersResponse{Tiers: []agentapi.BackupTierInfo{{Target: target, Primary: true}}}, nil
+ }
+}
+
+// COMPANION RED-PROOF: rendering the old sentence for every branch (the pre-fix template) makes the
+// same_disk case fail on `promises the backup protects this app`.
+func TestR499_Tier2PageSaysWhatIsTrueForThisBox(t *testing.T) {
+ ownDrive := agentapi.DiskInfo{Name: "mentes", MountPath: "/mnt/mentes", GuestPath: "/mnt/felhom-drives/mentes", Role: "user-data", BackupTarget: true}
+ cases := []struct {
+ name, want string
+ tiers func(context.Context) (agentapi.TiersResponse, error)
+ disks []agentapi.DiskInfo
+ }{
+ {"same disk (the measured box)", "same_disk", primaryTier("local"), nil},
+ {"own drive", "protected", primaryTier("felhom-backup"), []agentapi.DiskInfo{ownDrive}},
+ {"configured drive gone", "absent", primaryTier("felhom-backup"), nil},
+ {"agent not askable", "unknown", func(context.Context) (agentapi.TiersResponse, error) { return agentapi.TiersResponse{}, errors.New("down") }, nil},
+ }
+ for _, c := range cases {
+ body := tier2PageServer(t, c.tiers, c.disks)
+ if !strings.Contains(body, `data-system-backup="`+c.want+`"`) {
+ t.Errorf("%s: want branch %q on the page", c.name, c.want)
+ }
+ // ASCII fragments (ui-hungarian rule): "(PBS)" and "nincs k" of „nincs külön teendő".
+ if strings.Contains(body, "(PBS)") {
+ t.Errorf("%s: the page still names PBS, which this box may not have", c.name)
+ }
+ promises := strings.Contains(body, "nincs k")
+ if c.want == "protected" && !promises {
+ t.Errorf("%s: control — the protected branch must still say there is nothing to do", c.name)
+ }
+ if c.want != "protected" && promises {
+ t.Errorf("%s: promises the backup protects this app (\"nothing to do\") on a box where it may not", c.name)
+ }
+ }
+}
diff --git a/controller/internal/web/r518_backup_downtime_copy_test.go b/controller/internal/web/r518_backup_downtime_copy_test.go
new file mode 100644
index 0000000..5dfcdf6
--- /dev/null
+++ b/controller/internal/web/r518_backup_downtime_copy_test.go
@@ -0,0 +1,38 @@
+package web
+
+import (
+ "strings"
+ "testing"
+)
+
+// R-518 — the whole-system backup button promised a short stop; measured 2026-09-14 on a 12-app box,
+// every app was down for about 7 m 45 s. The copy now states that measurement, in both languages, on
+// the page and in the confirm dialog. ASCII fragments only (ui-hungarian rule), each with a control.
+func TestR518_BackupButtonStatesTheMeasuredDowntime(t *testing.T) {
+ s := i18nTestServer(t)
+ var c i18nCase
+ for _, x := range i18nCases() {
+ if x.name == "backups_full" {
+ c = x
+ }
+ }
+ if c.name == "" {
+ t.Fatal("no backups_full case — the test would pass by looking at nothing")
+ }
+ for _, lang := range []string{"hu", "en"} {
+ body := renderI18nCase(t, s, lang, c)
+ measured, vague := "8 perc", "" // „kb. 8 perc"
+ if lang == "en" {
+ measured, vague = "about 8 minutes", "usually for a few minutes"
+ }
+ if n := strings.Count(body, measured); n < 2 {
+ t.Errorf("%s: the measured downtime appears %d times, want it on the page AND in the confirm", lang, n)
+ }
+ if lang == "en" && strings.Contains(body, vague) {
+ t.Errorf("en: the old vague promise %q is still on the page", vague)
+ }
+ if lang == "hu" && strings.Contains(body, "ltalában néhány perc, nagyobb") {
+ t.Errorf("hu: the old vague promise is still on the page")
+ }
+ }
+}
diff --git a/controller/internal/web/r650_main_test.go b/controller/internal/web/r650_main_test.go
new file mode 100644
index 0000000..7c1b7d3
--- /dev/null
+++ b/controller/internal/web/r650_main_test.go
@@ -0,0 +1,12 @@
+package web
+
+import (
+ "os"
+ "testing"
+
+ "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
+)
+
+// TestMain puts a silent docker stub on PATH for every test in this package: R-650, the fixtures
+// here build a real stacks.Manager, and on the build host (DooPlex) that reached production Docker.
+func TestMain(m *testing.M) { os.Exit(dockerexec.RunWithStub(m)) }
diff --git a/controller/internal/web/templates/tier2_config.html b/controller/internal/web/templates/tier2_config.html
index b41f0eb..7b99556 100644
--- a/controller/internal/web/templates/tier2_config.html
+++ b/controller/internal/web/templates/tier2_config.html
@@ -16,9 +16,23 @@
diff --git a/controller/internal/web/testdata/i18n_parity/backups_degraded.html b/controller/internal/web/testdata/i18n_parity/backups_degraded.html
index e90e884..74b6555 100644
--- a/controller/internal/web/testdata/i18n_parity/backups_degraded.html
+++ b/controller/internal/web/testdata/i18n_parity/backups_degraded.html
@@ -268,7 +268,7 @@
function triggerGuestBackup() {
var btn = document.getElementById('wg-backup-btn');
- felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.', function () {
+ felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.', function () {
var out = document.getElementById('wg-backup-result');
btn.disabled = true;
out.innerHTML = 'Mentés indítása…';
diff --git a/controller/internal/web/testdata/i18n_parity/backups_empty.html b/controller/internal/web/testdata/i18n_parity/backups_empty.html
index 7966363..b099f8c 100644
--- a/controller/internal/web/testdata/i18n_parity/backups_empty.html
+++ b/controller/internal/web/testdata/i18n_parity/backups_empty.html
@@ -197,7 +197,7 @@
function triggerGuestBackup() {
var btn = document.getElementById('wg-backup-btn');
- felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.', function () {
+ felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.', function () {
var out = document.getElementById('wg-backup-result');
btn.disabled = true;
out.innerHTML = 'Mentés indítása…';
diff --git a/controller/internal/web/testdata/i18n_parity/backups_full.html b/controller/internal/web/testdata/i18n_parity/backups_full.html
index 28ca284..5ccef05 100644
--- a/controller/internal/web/testdata/i18n_parity/backups_full.html
+++ b/controller/internal/web/testdata/i18n_parity/backups_full.html
@@ -341,7 +341,7 @@
- A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.
+ A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.
@@ -404,7 +404,7 @@
function triggerGuestBackup() {
var btn = document.getElementById('wg-backup-btn');
- felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.', function () {
+ felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.', function () {
var out = document.getElementById('wg-backup-result');
btn.disabled = true;
out.innerHTML = 'Mentés indítása…';
diff --git a/controller/internal/web/testdata/i18n_parity/backups_nobackup_yet.html b/controller/internal/web/testdata/i18n_parity/backups_nobackup_yet.html
index e39b636..8f7181e 100644
--- a/controller/internal/web/testdata/i18n_parity/backups_nobackup_yet.html
+++ b/controller/internal/web/testdata/i18n_parity/backups_nobackup_yet.html
@@ -289,7 +289,7 @@
function triggerGuestBackup() {
var btn = document.getElementById('wg-backup-btn');
- felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.', function () {
+ felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.', function () {
var out = document.getElementById('wg-backup-result');
btn.disabled = true;
out.innerHTML = 'Mentés indítása…';
diff --git a/controller/internal/web/testdata/i18n_parity/backups_tier_due.html b/controller/internal/web/testdata/i18n_parity/backups_tier_due.html
index e0365b1..1c8a412 100644
--- a/controller/internal/web/testdata/i18n_parity/backups_tier_due.html
+++ b/controller/internal/web/testdata/i18n_parity/backups_tier_due.html
@@ -251,7 +251,7 @@
- A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.
+ A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.
@@ -312,7 +312,7 @@
function triggerGuestBackup() {
var btn = document.getElementById('wg-backup-btn');
- felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.', function () {
+ felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.', function () {
var out = document.getElementById('wg-backup-result');
btn.disabled = true;
out.innerHTML = 'Mentés indítása…';
diff --git a/controller/internal/web/testdata/i18n_parity/tier2_not_hdd.html b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd.html
index 050688b..671eb58 100644
--- a/controller/internal/web/testdata/i18n_parity/tier2_not_hdd.html
+++ b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd.html
@@ -184,13 +184,13 @@
-
- Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, amelyek
- már szerepelnek a teljes rendszermentésben (PBS). A 2. (off-drive) másolat
- kiegészítő, és elsősorban a külső adatmeghajtón tárolt alkalmazásokhoz készül — ehhez az
- alkalmazáshoz nincs külön teendő.
+
+
+ Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, és a teljes rendszermentés része.
+ Hogy a rendszermentés most hová készül, azt nem tudtuk lekérdezni — a Mentések oldalon láthatod.
+ A 2. mentés egy másik fizikai meghajtóra készít másolatot az alkalmazás
+ helyreállítási csomagjáról és adatairól. Ez az egyetlen off-drive védelem a böngészhető
+ felhasználói fájlokhoz (a teljes rendszermentés/PBS nem éri el ezeket).
+
+
+
+
+
+ Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak. A teljes rendszermentés meghajtója
+ most nem érhető el — amíg vissza nem csatlakoztatod, erről az alkalmazásról sem készül friss rendszermentés.
+
+ A 2. mentés egy másik fizikai meghajtóra készít másolatot az alkalmazás
+ helyreállítási csomagjáról és adatairól. Ez az egyetlen off-drive védelem a böngészhető
+ felhasználói fájlokhoz (a teljes rendszermentés/PBS nem éri el ezeket).
+
+
+
+
+
+ Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, amelyek
+ szerepelnek a teljes rendszermentésben, és az egy külön meghajtóra készül. A 2. (off-drive) másolat
+ kiegészítő, és elsősorban a külső adatmeghajtón tárolt alkalmazásokhoz készül — ehhez az
+ alkalmazáshoz nincs külön teendő.
+
+ A 2. mentés egy másik fizikai meghajtóra készít másolatot az alkalmazás
+ helyreállítási csomagjáról és adatairól. Ez az egyetlen off-drive védelem a böngészhető
+ felhasználói fájlokhoz (a teljes rendszermentés/PBS nem éri el ezeket).
+
+
+
+
+
+ Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak. A teljes rendszermentés
+ jelenleg ugyanerre a lemezre készül — így hibás fájlok ellen véd, lemezhiba ellen nem.
+ Ha csatlakoztatsz egy második meghajtót, és a Mentések oldalon kijelölöd a rendszermentés helyéül, ez az alkalmazás is védett lesz.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/controller/internal/web/tier2_config_handler.go b/controller/internal/web/tier2_config_handler.go
index 5655ab7..26c4e4f 100644
--- a/controller/internal/web/tier2_config_handler.go
+++ b/controller/internal/web/tier2_config_handler.go
@@ -34,6 +34,11 @@ func (s *Server) tier2ConfigPageHandler(w http.ResponseWriter, r *http.Request,
data["StackName"] = name
data["DisplayName"] = stack.Meta.DisplayName
data["Tier2"] = info
+ if !info.IsHDDApp {
+ // R-499: the sentence about a system-disk app must say where THIS box's whole-system backup
+ // goes. It used to promise „már szerepelnek a teljes rendszermentésben (PBS)" on every box.
+ data["SystemBackup"] = systemBackupFact(s.resolveBackupTargetState(r.Context()))
+ }
if flash := s.flashFrom(r, "flash"); flash != "" {
data["Flash"] = flash
}
@@ -133,3 +138,27 @@ func (s *Server) redirectTier2(w http.ResponseWriter, r *http.Request, name, fla
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
+
+// systemBackupFact reduces the whole-system backup's target state to the one fact the Tier-2 page
+// states about an app on the system disk (R-499). The page used to tell every such app that its data
+// was „already in the full system backup (PBS)" and there was nothing to do — measured false on a box
+// whose only whole-system copy sat on the same disk (2026-09-14). Four states, four sentences:
+//
+// protected — the whole-system backup goes to a drive of its own
+// same_disk — it goes to the system disk itself: protects against bad files, not a dead disk
+// absent — its drive is configured and gone: no fresh whole-system backup is being made
+// unknown — the agent could not be asked: say so, promise nothing
+//
+// Pinned by TestR499_* (the mapping and one render per branch).
+func systemBackupFact(st BackupTargetState) string {
+ switch {
+ case !st.Known:
+ return "unknown"
+ case st.TargetAbsent:
+ return "absent"
+ case st.Degraded:
+ return "same_disk"
+ default:
+ return "protected"
+ }
+}
diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json
index e20cd5d..c78790b 100644
--- a/controller/scripts/i18n_go_keys.json
+++ b/controller/scripts/i18n_go_keys.json
@@ -62,7 +62,9 @@
"event.app_update_undone": "BORN AS A KEY, v0.264.0 (R-606 / the update events) -- a NEW sentence, never a Go literal.",
"settings_notifications.app_update_undone": "BORN AS A KEY, v0.264.0 (R-606 / the update events) -- a NEW sentence, never a Go literal.",
"settings_notifications.app_update_held": "BORN AS A KEY, v0.264.0 (R-606 / the update events) -- a NEW sentence, never a Go literal.",
- "badge.update.held": "BORN AS A KEY, v0.265.0 (R-625) -- a NEW badge for a held app; its Hungarian twin in updatebadge.go is pinned by TestR625_HeldBadgeHungarianMatchesTheBundle."
+ "badge.update.held": "BORN AS A KEY, v0.265.0 (R-625) -- a NEW badge for a held app; its Hungarian twin in updatebadge.go is pinned by TestR625_HeldBadgeHungarianMatchesTheBundle.",
+ "err.backup.adatbazis_masolat_csonka_nem_indult": "BORN AS A KEY, v0.267.0 (R-640) -- a NEW refusal for a cut-off database copy; pinned by TestR640_*.",
+ "err.backup.adatbazis_masolat_csonka_nem_toltve": "BORN AS A KEY, v0.267.0 (R-640) -- a NEW refusal for a cut-off database copy; pinned by TestR640_*."
},
"flash.share.already_on": "A megosztás már be van kapcsolva.",
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",