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:
admin committed 2026-10-22 16:21:38 +02:00
1 parent cbc944c3c0
commit 6a17c95815
10 files changed
+246 -1

No files matched your search

+13
View File
@@ -51,6 +51,19 @@ type BackupSpec struct {
// WHOLE-BLOCK, so a stale `userdata: import/<app>` would discard the app's OTHER classifications
// (e.g. an hdd appdata path classed mandatory) and silently degrade it to legacy.
Import []BindSpec `yaml:"import,omitempty" json:"import,omitempty"`
// Volumes classifies the compose file's top-level NAMED volumes (R-49, operator ruling D21 = B, 2026-10-22).
// Only `excluded` means anything: the volume is never dumped, so it is in no recovery unit — not on the
// drive's copy, the second drive or off-site. For a pure cache the app rebuilds by itself (Immich's model
// cache). Deliberately NOT checked by ValidateBackupSpec: a wrong entry must never cost the app its bind
// classifications (that rejection is whole-block) — ExcludedDockerVolumes ignores it with a reason instead.
Volumes []VolumeSpec `yaml:"volumes,omitempty" json:"volumes,omitempty"`
}
// VolumeSpec is one classified named volume: Name is the compose `volumes:` key (not the project-prefixed
// Docker name).
type VolumeSpec struct {
Name string `yaml:"name" json:"name"`
Class BindClass `yaml:"class" json:"class"`
}
// BindSpec is one classified entry in a BackupSpec.
@@ -0,0 +1,40 @@
package appbackup
import (
"fmt"
"path/filepath"
)
// ExcludedDockerVolumes (R-49, operator ruling D21 = B, 2026-10-22) returns the project-prefixed Docker names of the
// named volumes the template's backup block marks `excluded`, and a sentence per entry it ignored. An entry is ignored
// when its class is not `excluded`, when its name is not a top-level named volume of the compose file, or — for every
// entry at once — when the exclusions would cover EVERY named volume: a unit with no volume dump at all is the shape
// the hollow-unit guard (R-403, r403_hollow.go) alarms on, and an app whose every volume is a cache needs no volume
// leg anyway, so that is a template mistake, never a silent hollow unit. Pinned by TestR49_*.
func ExcludedDockerVolumes(spec *BackupSpec, composePath string) (excluded []string, ignored []string) {
if spec == nil || len(spec.Volumes) == 0 {
return nil, nil
}
declared := map[string]bool{}
vols := ParseComposeNamedVolumes(composePath)
for _, v := range vols {
declared[v.Name] = true
}
project := filepath.Base(filepath.Dir(composePath))
seen := map[string]bool{}
for _, v := range spec.Volumes {
switch {
case v.Class != ClassExcluded:
ignored = append(ignored, fmt.Sprintf("volume %q: class %q — only \"excluded\" is meaningful for a named volume", v.Name, v.Class))
case !declared[v.Name]:
ignored = append(ignored, fmt.Sprintf("volume %q is not a named volume of the compose file", v.Name))
case !seen[v.Name]:
seen[v.Name] = true
excluded = append(excluded, project+"_"+v.Name)
}
}
if len(excluded) > 0 && len(excluded) == len(vols) {
return nil, append(ignored, fmt.Sprintf("the backup block excludes every named volume (%d) — ignored: a unit with no volume dump reads as hollow (R-403)", len(vols)))
}
return excluded, ignored
}
@@ -0,0 +1,46 @@
package appbackup
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// R-49 (D21 = B): the template's `backup.volumes` list — only `excluded`, only declared named volumes, never all of them.
func TestR49_ExcludedDockerVolumes(t *testing.T) {
dir := filepath.Join(t.TempDir(), "immich")
os.MkdirAll(dir, 0o755)
compose := filepath.Join(dir, "docker-compose.yml")
os.WriteFile(compose, []byte("services:\n a:\n image: x\n volumes:\n - immich_ml_cache:/cache\n - immich_postgres_data:/db\nvolumes:\n immich_ml_cache:\n immich_postgres_data:\n"), 0o644)
spec := &BackupSpec{Volumes: []VolumeSpec{
{Name: "immich_ml_cache", Class: ClassExcluded},
{Name: "no_such_volume", Class: ClassExcluded},
{Name: "immich_postgres_data", Class: ClassMandatory},
}}
ex, ignored := ExcludedDockerVolumes(spec, compose)
if !reflect.DeepEqual(ex, []string{"immich_immich_ml_cache"}) {
t.Fatalf("excluded = %v, want [immich_immich_ml_cache] (project-prefixed like ResolveDockerVolumeNames)", ex)
}
if len(ignored) != 2 {
t.Fatalf("ignored = %v, want the unknown name and the non-excluded class", ignored)
}
all := &BackupSpec{Volumes: []VolumeSpec{{Name: "immich_ml_cache", Class: ClassExcluded}, {Name: "immich_postgres_data", Class: ClassExcluded}}}
if ex, ig := ExcludedDockerVolumes(all, compose); ex != nil || len(ig) == 0 {
t.Fatalf("excluding EVERY named volume must be refused (a hollow unit), got %v / %v", ex, ig)
}
if ex, _ := ExcludedDockerVolumes(nil, compose); ex != nil {
t.Fatal("no backup block → nothing excluded")
}
}
// The new key never costs an app its bind classifications: a block whose only fault is a bad volume entry still
// validates (ValidateBackupSpec does not read Volumes).
func TestR49_AVolumeEntryNeverInvalidatesTheBlock(t *testing.T) {
spec := &BackupSpec{HDD: []BindSpec{{Path: "appdata/immich", Class: ClassMandatory}},
Volumes: []VolumeSpec{{Name: "nonsense", Class: "bogus"}}}
binds := []ComposeBind{{Root: RootHDD, RelPath: "appdata/immich"}}
if err := ValidateBackupSpec(spec, binds); err != nil {
t.Fatalf("a bad volume entry discarded the block: %v", err)
}
}