v0.218.0: attribute a DB container by its compose project, and replay volumes on the off-site restore
gates / gates (push) Successful in 11s

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.
This commit is contained in:
2026-08-22 09:43:22 +02:00
parent f94543ee5c
commit 5ce3a44645
10 changed files with 871 additions and 24 deletions
+53 -5
View File
@@ -91,7 +91,17 @@ func DiscoverDatabases(ctx context.Context, logger *log.Logger, debug bool, know
if debug {
logger.Printf("[DEBUG] DiscoverDatabases: running docker ps to find database containers")
}
cmd := exec.CommandContext(ctx, "docker", "ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}", "--filter", "status=running")
// R-355: the compose PROJECT label is asked for, because it is the stack name BY CONSTRUCTION and
// the container name is only a guess at it. The controller runs compose with `cmd.Dir` set to
// `/opt/docker/stacks/<stack>` and never passes `-p` (stacks/manager.go:1218-1226), so compose
// derives the project from that directory — i.e. from the stack name itself.
//
// The label sits in the MIDDLE of the format on purpose: `strings.TrimSpace` is applied to the
// 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",
"{{.ID}}\t{{.Names}}\t{{.Label \"com.docker.compose.project\"}}\t{{.Image}}", "--filter", "status=running")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("docker ps failed: %w", err)
@@ -108,12 +118,12 @@ func DiscoverDatabases(ctx context.Context, logger *log.Logger, debug bool, know
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 3)
if len(parts) < 3 {
parts := strings.SplitN(line, "\t", 4)
if len(parts) < 4 {
continue
}
id, name, image := parts[0], parts[1], strings.ToLower(parts[2])
id, name, project, image := parts[0], parts[1], strings.TrimSpace(parts[2]), strings.ToLower(parts[3])
// R-47: the same predicate that DBServiceNames applies to compose `image:` values, so a dump
// that exists is always attributable to a startable service (see dbservices.go).
@@ -134,7 +144,7 @@ func DiscoverDatabases(ctx context.Context, logger *log.Logger, debug bool, know
ContainerID: id,
ContainerName: name,
DBType: dbType,
StackName: deriveStackName(name, known),
StackName: resolveStackName(name, project, known, logger, debug),
}
// Get env vars from container
@@ -767,6 +777,44 @@ func getMariaDBPassword(ctx context.Context, containerID string) string {
// the stack list is empty/unavailable, so nothing regresses).
//
// A nil/empty `known` map = the legacy fast path (pure suffix-strip).
// resolveStackName attributes a running DB container to the stack that owns it.
//
// R-355, and the reason this exists rather than more string-munging: the container name is a GUESS at
// the stack name and the compose project label is the ANSWER. `paperless-ngx` runs a container called
// `paperless-postgres`; no amount of suffix-stripping or prefix-matching can bridge that, and
// deriveStackName's last line returns its unresolved candidate as though it had resolved it. The dump
// then went to `backups/primary/paperless/db-dumps/` — a directory for a stack that does not exist, on
// the system drive — while the app's own recovery unit recorded `db_dumps: null`. Nothing collected it,
// nothing off-sited it, nothing restored it, and because writeSafetyDump filters on this same value the
// destructive restore took NO undo copy and told the customer the app had no database. Measured live
// 2026-08-21: 284 617 bytes, 72 tables, valid, and unreachable.
//
// The label is authoritative BY CONSTRUCTION — see the note on the `docker ps` format above — so it is
// preferred whenever it names a stack we know. It is not trusted blindly: a project label naming an
// unknown stack would write into an unknown app's directory, which is the very fault being fixed.
//
// The fallback is unchanged, so every container whose name already resolves keeps its exact previous
// attribution (pinned by TestResolveStackName_UnaffectedAppsAreUnchanged).
//
// When NEITHER route resolves to a known stack we still return the old candidate — refusing here would
// change behaviour for any container legitimately relying on the legacy path — but we say so loudly,
// because the silent version of this line is what hid R-355 for the life of the feature.
func resolveStackName(containerName, composeProject string, known map[string]bool, logger *log.Logger, debug bool) string {
if composeProject != "" && (len(known) == 0 || known[composeProject]) {
if debug && composeProject != deriveStackName(containerName, known) {
logger.Printf("[DEBUG] DiscoverDatabases: %s → stack %q from the compose project label (the container name would have given %q)",
containerName, composeProject, deriveStackName(containerName, known))
}
return composeProject
}
fallback := deriveStackName(containerName, known)
if len(known) > 0 && !known[fallback] {
logger.Printf("[WARN] [backup] DB container %q could not be attributed to any deployed stack (compose project %q, name gives %q) — its dump will be written under %q, which no recovery unit reads. This is the R-355 shape; investigate before trusting that app's backup.",
containerName, composeProject, fallback, fallback)
}
return fallback
}
func deriveStackName(containerName string, known map[string]bool) string {
candidate := suffixStripStackName(containerName)
@@ -0,0 +1,175 @@
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")
}
}