Files
admin 5ce3a44645
gates / gates (push) Successful in 11s
v0.218.0: attribute a DB container by its compose project, and replay volumes on the off-site restore
R-355 (first, because it is the only one where data can be lost for good). paperless-ngx's
PostgreSQL was dumped into backups/primary/paperless/db-dumps/ — a directory for a stack that
does not exist, on the system drive — while the app's own unit recorded db_dumps: null. The
same misattribution reached writeSafetyDump, so a destructive restore of that app took NO undo
copy and the fail-closed refusal was never reached. Fixed by reading the compose project label,
which is the stack name by construction (compose runs with cmd.Dir set to the stack dir and no
-p). The old derivation stays as the fallback and an unresolvable attribution is now loud.
Catalogue sweep, proven able to convict: one affected app of 53. The fix is in the controller,
not the catalogue.

R-354. ReconstituteFromOffsite skipped every unit placement and the volume archives live inside
the unit, so the off-site restore had no volume leg at all — proven live with planted files:
calibre-web's 1,422,848-byte config archive was in the unit, the snapshot and the checking
folder, and the restore reported success without it. For the 40 of 53 apps that declare no data
drive that archive is the whole dataset. restoreDockerVolumesFrom is the local path's own replay
with an explicit directory: ONE implementation, two callers. Volumes replay before the database
and inside the stopped window. VolumesReplayed reaches the message.

The comment beside the skip was half false and is corrected; the half that still holds — the
live unit is the local path's source — is named, and scenario D fingerprints the whole live unit
across the operation.

Seven red-proofs, each asserted applied and reverted. Two found defects in the tests, not the
code: scenario D passed with the unit guard removed because the fingerprint had been narrowed
and was blind to the unit root.
2026-08-22 09:43:22 +02:00

176 lines
7.5 KiB
Go

package appbackup
import (
"bytes"
"log"
"path/filepath"
"strings"
"testing"
)
// The deployed set on demo-hp on 2026-08-22, used by every case below so the tests argue about the
// real fleet rather than a convenient fiction.
func fleetKnown() map[string]bool {
return map[string]bool{
"calibre-web": true, "kimai": true, "opengist": true,
"paperless-ngx": true, "privatebin": true, "romm": true,
}
}
func quietLogger() (*log.Logger, *bytes.Buffer) {
var buf bytes.Buffer
return log.New(&buf, "", 0), &buf
}
// TestResolveStackName_R355_ComposeProjectResolvesWhatTheNameCannot is the headline case.
//
// `paperless-ngx` runs its database in a container called `paperless-postgres`. The container name
// carries no route to the stack name: suffix-stripping gives `paperless`, which is not a stack, and
// no known stack is a prefix of it. deriveStackName then returns that unresolved candidate anyway.
// The compose project label is the answer the name cannot give.
func TestResolveStackName_R355_ComposeProjectResolvesWhatTheNameCannot(t *testing.T) {
known := fleetKnown()
lg, _ := quietLogger()
// First, pin the shape of the defect, so this test still means something if the fallback changes.
if got := deriveStackName("paperless-postgres", known); got == "paperless-ngx" {
t.Fatalf("precondition lost: deriveStackName now resolves paperless-postgres correctly (%q) — "+
"this test exists because it cannot", got)
} else if got != "paperless" {
t.Fatalf("precondition changed: deriveStackName(paperless-postgres) = %q, expected the "+
"unresolved candidate %q", got, "paperless")
}
got := resolveStackName("paperless-postgres", "paperless-ngx", known, lg, false)
if got != "paperless-ngx" {
t.Errorf("resolveStackName(paperless-postgres, project=paperless-ngx) = %q, want %q", got, "paperless-ngx")
}
}
// TestR355_DumpLandsWhereTheRecoveryUnitReads asserts the CONSEQUENCE rather than the mechanism.
//
// It is not enough that the name resolves: the dump must land in the directory the unit assembler
// enumerates. Before the fix those were two different directories on two different drives —
// `…/backups/primary/paperless/db-dumps/` was written and `…/backups/primary/paperless-ngx/db-dumps/`
// was read — which is exactly why the manifest recorded `db_dumps: null` while a valid 72-table dump
// existed on disk.
func TestR355_DumpLandsWhereTheRecoveryUnitReads(t *testing.T) {
const nsRoot = "/mnt/felhom-drives/hdd_1/felhom-data"
const stack = "paperless-ngx"
known := fleetKnown()
lg, _ := quietLogger()
// Where the unit assembler looks (CaptureRecoveryUnit enumerates exactly this).
readBy := AppDBDumpPath(nsRoot, stack)
// Where the dump writer would put it, for the container this app actually runs.
writtenTo := AppDBDumpPath(nsRoot, resolveStackName("paperless-postgres", "paperless-ngx", known, lg, false))
if writtenTo != readBy {
t.Errorf("the dump would be written to %q but the recovery unit reads %q — the unit will record no database", writtenTo, readBy)
}
// And demonstrate the pre-fix divergence explicitly, so the failure mode is documented by the suite
// rather than only by the report.
preFix := AppDBDumpPath(nsRoot, deriveStackName("paperless-postgres", known))
if preFix == readBy {
t.Fatalf("precondition lost: the pre-fix path %q no longer diverges from %q", preFix, readBy)
}
if !strings.Contains(preFix, filepath.Join("primary", "paperless")+string(filepath.Separator)) {
t.Errorf("expected the pre-fix path to point at the phantom `paperless` stack, got %q", preFix)
}
}
// TestResolveStackName_UnaffectedAppsAreUnchanged is scenario D: every container whose name already
// resolved must keep its exact previous attribution. A naming change that moved another app's dumps
// would be a far worse defect than the one being fixed.
func TestResolveStackName_UnaffectedAppsAreUnchanged(t *testing.T) {
known := fleetKnown()
lg, _ := quietLogger()
// container name → compose project, as read from `docker ps` on demo-hp 2026-08-22.
fleet := map[string]string{
"kimai-db": "kimai",
"romm-db": "romm",
"romm-redis": "romm",
"calibre-web": "calibre-web",
"privatebin": "privatebin",
"opengist": "opengist",
"paperless-redis": "paperless-ngx",
}
for container, project := range fleet {
old := deriveStackName(container, known)
got := resolveStackName(container, project, known, lg, false)
if container == "paperless-redis" {
// The one app the fix moves — asserted by the headline test, not here.
continue
}
if got != old {
t.Errorf("resolveStackName(%q, project=%q) = %q but deriveStackName gave %q — the fix moved an app it should not have",
container, project, got, old)
}
}
}
// TestResolveStackName_NoLabelFallsBack covers a container not started by compose: the label is empty
// and the legacy path is all there is.
func TestResolveStackName_NoLabelFallsBack(t *testing.T) {
known := fleetKnown()
lg, _ := quietLogger()
if got := resolveStackName("romm-db", "", known, lg, false); got != "romm" {
t.Errorf("with no compose label, resolveStackName(romm-db) = %q, want %q", got, "romm")
}
}
// TestResolveStackName_UnknownProjectIsNotTrusted covers the fail-safe direction. A project label
// naming a stack we do not know must NOT be used to choose a directory — writing into an unknown
// app's tree is the very fault being fixed, and doing it on the strength of an unverified label would
// simply move the bug.
func TestResolveStackName_UnknownProjectIsNotTrusted(t *testing.T) {
known := fleetKnown()
lg, buf := quietLogger()
got := resolveStackName("romm-db", "some-other-project", known, lg, false)
if got != "romm" {
t.Errorf("an unknown compose project must not be trusted: got %q, want the derived %q", got, "romm")
}
if buf.Len() != 0 {
t.Errorf("a container that still resolves via the fallback must not warn; logged: %q", buf.String())
}
}
// TestResolveStackName_UnresolvableIsLoud pins the residual behaviour. When neither route reaches a
// known stack we keep the legacy answer — refusing would change behaviour for containers legitimately
// on the old path — but the line must be LOUD, because the silent version of it is what hid R-355 for
// the life of the feature.
func TestResolveStackName_UnresolvableIsLoud(t *testing.T) {
known := fleetKnown()
lg, buf := quietLogger()
got := resolveStackName("mystery-postgres", "", known, lg, false)
if got != "mystery" {
t.Errorf("unresolvable container: got %q, want the legacy candidate %q", got, "mystery")
}
out := buf.String()
if !strings.Contains(out, "[WARN]") {
t.Errorf("an unattributable DB container must WARN; logged: %q", out)
}
for _, want := range []string{"mystery-postgres", "R-355"} {
if !strings.Contains(out, want) {
t.Errorf("the warning must name %q so it is greppable; logged: %q", want, out)
}
}
}
// TestResolveStackName_LegacyCallersUnaffected: appexport passes no known set. The label must still be
// honoured there (len(known)==0 → trust it), because that caller has no other route to the truth.
func TestResolveStackName_LegacyCallersUnaffected(t *testing.T) {
lg, _ := quietLogger()
if got := resolveStackName("paperless-postgres", "paperless-ngx", nil, lg, false); got != "paperless-ngx" {
t.Errorf("with no known set the compose label is the only truth available: got %q, want %q", got, "paperless-ngx")
}
if got := resolveStackName("romm-postgres", "", nil, lg, false); got != "romm" {
t.Errorf("no label and no known set → legacy strip: got %q, want %q", got, "romm")
}
}