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 @@

{{if not .IsHDDApp}} -
- {{T "tier2_config.ennek_az_alkalmazasnak_az_adatai"}} + {{if eq $.SystemBackup "protected"}} +
+ {{T "tier2_config.rendszerlemez.vedett"}}
+ {{else if eq $.SystemBackup "same_disk"}} +
+ {{T "tier2_config.rendszerlemez.ugyanaz"}} +
+ {{else if eq $.SystemBackup "absent"}} +
+ {{T "tier2_config.rendszerlemez.hianyzik"}} +
+ {{else}} +
+ {{T "tier2_config.rendszerlemez.ismeretlen"}} +
+ {{end}} {{else}}

{{T "tier2_config.jelenlegi_allapot"}}

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.
+
diff --git a/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_absent.html b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_absent.html new file mode 100644 index 0000000..2ecef54 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_absent.html @@ -0,0 +1,525 @@ + + + + + + + + Második mentés beállítása — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + +
+ +

+ 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. +
+ + + +
+ + +
+ + + + diff --git a/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_protected.html b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_protected.html new file mode 100644 index 0000000..7b425f7 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_protected.html @@ -0,0 +1,527 @@ + + + + + + + + Második mentés beállítása — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + +
+ +

+ 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ő. +
+ + + +
+ + +
+ + + + diff --git a/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_same_disk.html b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_same_disk.html new file mode 100644 index 0000000..1cd8101 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_same_disk.html @@ -0,0 +1,526 @@ + + + + + + + + Második mentés beállítása — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + +
+ +

+ 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.",