R-49 (D21 = B): a template may exclude a named volume from every backup (backup.volumes, class excluded) — Immich's model cache; guarded, red-proved; unreleased
gates / gates (push) Successful in 1m50s

This commit is contained in:
2026-10-22 16:21:38 +02:00
parent cbc944c3c0
commit 6a17c95815
10 changed files with 246 additions and 1 deletions
+3 -1
View File
@@ -856,7 +856,7 @@ func (m *Manager) DumpAppVolumes(stackName string) error {
return nil
}
volumes := m.stackProvider.GetDockerVolumes(stackName)
volumes := m.dumpableVolumes(stackName)
if len(volumes) == 0 {
return nil
}
@@ -929,6 +929,8 @@ func (m *Manager) DumpAppVolumes(stackName string) error {
continue
}
if !activeVols[name] && strings.HasSuffix(name, ".tar") {
// R-49 (D21 = B): this is also how a volume the template now EXCLUDES leaves the unit — its last
// tar would otherwise ride every later copy (Immich's 824 MB model cache, off-site each night).
os.Remove(filepath.Join(dumpDir, name))
if m.isDebug() {
m.logger.Printf("[DEBUG] [backup] Removed stale volume dump: %s/%s", stackName, name)
@@ -0,0 +1,37 @@
package backup
// R-49, operator ruling D21 = B (2026-10-22): a template may mark a named volume `excluded` (a pure cache the app
// rebuilds by itself — Immich's machine-learning model cache, 62 % of its off-site copy). Such a volume is never
// dumped, so it is in no recovery unit: not on the drive's copy, not on the second drive, not off-site. Its last tar
// is removed by DumpAppVolumes' stale-tar clean-up. A restore then finds no tar for it and leaves the volume as the
// app makes it (Immich downloads its models again). The rule and its guards: appbackup.ExcludedDockerVolumes.
// volumeExclusionProvider is implemented by the production stack adapter (cmd/controller); test fakes need not.
type volumeExclusionProvider interface {
GetExcludedDockerVolumes(name string) []string
}
// dumpableVolumes is the stack's named Docker volumes minus the ones its template excludes.
func (m *Manager) dumpableVolumes(stackName string) []string {
all := m.stackProvider.GetDockerVolumes(stackName)
ex, ok := m.stackProvider.(volumeExclusionProvider)
if !ok || len(all) == 0 {
return all
}
skip := map[string]bool{}
for _, v := range ex.GetExcludedDockerVolumes(stackName) {
skip[v] = true
}
if len(skip) == 0 {
return all
}
out := make([]string, 0, len(all))
for _, v := range all {
if skip[v] {
m.logger.Printf("[INFO] [backup] Volume %s/%s is excluded by the template (a cache the app rebuilds) — not dumped (R-49)", stackName, v)
continue
}
out = append(out, v)
}
return out
}
@@ -0,0 +1,66 @@
package backup
import (
"io"
"log"
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// exclFake is volDumpFakeProvider plus the production adapter's exclusion answer.
type exclFake struct {
volDumpFakeProvider
excluded []string
}
func (f *exclFake) GetExcludedDockerVolumes(string) []string { return f.excluded }
// R-49 (D21 = B) — the consequence across a whole dump run, through the F7 tar seam (no Docker): the excluded cache
// volume is not dumped, the other volume is, and the cache's OLD tar (from before the template marked it) is gone
// from the unit, so no later copy carries it.
// RED-PROOF: with DumpAppVolumes listing GetDockerVolumes again (no exclusion), the cache is dumped and its old tar
// replaced, not removed — seen failing 2026-10-22.
func TestR49_AnExcludedVolumeLeavesTheUnit(t *testing.T) {
drive := t.TempDir()
cfg := &config.Config{}
cfg.Paths.SystemDataPath = drive
fake := &exclFake{volDumpFakeProvider: volDumpFakeProvider{
stacks: []StackSummary{{Name: "immich"}},
volumes: map[string][]string{"immich": {"immich_immich_ml_cache", "immich_immich_postgres_data"}},
}, excluded: []string{"immich_immich_ml_cache"}}
var tarred []string
m := &Manager{cfg: cfg, logger: log.New(io.Discard, "", 0), systemDataPath: drive, stackProvider: fake,
tarVolume: func(vol, dir string) ([]byte, error) {
tarred = append(tarred, vol)
return nil, os.WriteFile(filepath.Join(dir, vol+".tar.tmp"), []byte("TAR "+vol), 0o644)
}}
dumpDir := AppVolumeDumpPath(m.namespaceRoot(drive), "immich")
os.MkdirAll(dumpDir, 0o755)
old := filepath.Join(dumpDir, "immich_immich_ml_cache.tar")
os.WriteFile(old, []byte("824 MB OF MODELS"), 0o644)
if err := m.DumpAppVolumes("immich"); err != nil {
t.Fatalf("DumpAppVolumes: %v", err)
}
if len(tarred) != 1 || tarred[0] != "immich_immich_postgres_data" {
t.Fatalf("dumped %v, want only the database volume", tarred)
}
if _, err := os.Stat(old); !os.IsNotExist(err) {
t.Fatal("the excluded cache's old tar is still in the unit — every later copy would carry it")
}
if _, err := os.Stat(filepath.Join(dumpDir, "immich_immich_postgres_data.tar")); err != nil {
t.Fatalf("the database volume's tar is missing: %v", err)
}
}
// A provider without the exclusion answer (every test fake, an older wiring) dumps every volume, as before.
func TestR49_NoExclusionProviderDumpsEverything(t *testing.T) {
m := &Manager{logger: log.New(io.Discard, "", 0), stackProvider: &volDumpFakeProvider{
volumes: map[string][]string{"a": {"a_x", "a_y"}}}}
if got := m.dumpableVolumes("a"); len(got) != 2 {
t.Fatalf("dumpable = %v, want both", got)
}
}