v0.317.0: a second copy counts only on a proven different disk; the no-drive install speaks in words (felhom.eu architecture/16, build A)
gates / gates (push) Successful in 46s

Tier-2 and the one-copy notice ask the host agent (POST /disks/same-disk, agent 0.157.0): different +
proven -> made and counted; same disk -> not made (D1); unknown (VM, hidden USB serial) -> made, not
counted, the row says why (D2); a NAS never counts and no longer hides the notice (R-1075). An older
agent keeps the st_dev rule, logged once. D4: a data-folder or media app on a box with no drive is
refused in words with a link to the drives page (R-1068's raw-error half). MinAgent 0.157.0.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
2026-10-15 21:21:54 +02:00
parent c6a567122e
commit 0626b9fc8a
34 changed files with 665 additions and 200 deletions
@@ -0,0 +1,137 @@
package backup
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// felhom.eu architecture/16-storage-physical-identity.md §4 — the agent's verdict decides Tier-2 and the
// one-copy notice. Each test asserts the CONSEQUENCE (is a target picked, does it count, what does the page
// say), not the mechanism.
// judgeBy answers per target path; paths not in the map are "unmapped" (→ the old st_dev rule).
func judgeBy(m map[string]DiskVerdict) func(a, b string) (DiskVerdict, bool) {
return func(a, b string) (DiskVerdict, bool) {
if v, ok := m[b]; ok {
return v, true
}
return DiskVerdict{Reason: "unmapped"}, true
}
}
var (
provenDifferent = DiskVerdict{Different: true, Proven: true}
provenSame = DiskVerdict{Different: false, Proven: true, Reason: "same_disk"}
unknownVM = DiskVerdict{Different: true, Proven: false, Reason: "vm"}
)
func addDrive(t *testing.T, sett *settings.Settings, sp settings.StoragePath) {
t.Helper()
sp.Schedulable = true
if err := sett.AddStoragePath(sp); err != nil {
t.Fatal(err)
}
}
// Row 1: different + proven → made, counted, notice hidden.
func TestDisk16_ProvenDifferent_CountedNoNotice(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
addDrive(t, sett, settings.StoragePath{Path: "/mnt/b", Label: "B"})
m.SetDiskJudge(judgeBy(map[string]DiskVerdict{"/mnt/b": provenDifferent}))
tg, err := m.selectTier2TargetFrom("app", "/srv/sys", 1, 1)
if err != nil || tg.Unproven || tg.NamespaceRoot != "/mnt/b" {
t.Fatalf("want /mnt/b counted, got %+v err=%v", tg, err)
}
if w := m.singleCopyWarning(); w != "" {
t.Fatalf("a proven second disk must hide the notice, got %q", w)
}
}
// Row 2: same disk (two partitions) → NOT made (D1), notice shown — even though st_dev would say "different".
func TestDisk16_SameDisk_NotMadeAndNoticeShown(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
addDrive(t, sett, settings.StoragePath{Path: "/mnt/part2", Label: "P2"})
m.SetDiskJudge(func(a, b string) (DiskVerdict, bool) { return provenSame, true })
if tg, err := m.selectTier2TargetFrom("app", "/mnt/part1", 1, 1); err == nil {
t.Fatalf("a same-disk partition must not be a Tier-2 target, got %+v", tg)
}
if w := m.singleCopyWarning(); w != m.note("note.backup.single_copy") {
t.Fatalf("same disk: want the one-copy notice, got %q", w)
}
}
// Row 3: unknown (a VM) → made, NOT counted (D2), notice shown with the "cannot check" sentence.
func TestDisk16_Unknown_MadeNotCountedCannotCheck(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
addDrive(t, sett, settings.StoragePath{Path: "/mnt/felhom-drives/media_a", Label: "A"})
m.SetDiskJudge(func(a, b string) (DiskVerdict, bool) { return unknownVM, true })
tg, err := m.selectTier2TargetFrom("app", "/mnt/felhom-drives/adatlemez", 1, 1)
if err != nil || !tg.Unproven || tg.UnprovenReason != "vm" {
t.Fatalf("unknown: want the copy made and marked unproven, got %+v err=%v", tg, err)
}
w := m.singleCopyWarning()
if w != m.note("note.backup.single_copy_unproven") || !strings.Contains(w, "nem tudja ellenőrizni") {
t.Fatalf("unknown: want the cannot-check notice, got %q", w)
}
// A proven-different drive beats an unknown one.
addDrive(t, sett, settings.StoragePath{Path: "/mnt/felhom-drives/real", Label: "R"})
m.SetDiskJudge(judgeBy(map[string]DiskVerdict{"/mnt/felhom-drives/media_a": unknownVM, "/mnt/felhom-drives/real": provenDifferent}))
if tg, _ := m.selectTier2TargetFrom("app", "/mnt/felhom-drives/adatlemez", 1, 1); tg == nil || tg.NamespaceRoot != "/mnt/felhom-drives/real" || tg.Unproven {
t.Fatalf("a proven drive must win over an unknown one, got %+v", tg)
}
}
// Row 4: a network share → never a Tier-2 target and never hides the notice (R-1075).
func TestDisk16_NetworkShare_NeverHidesTheNotice(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
addDrive(t, sett, settings.StoragePath{Path: "/mnt/felhom-drives/nas", Label: "NAS", Kind: settings.StorageKindNetwork})
// No judge at all — the OLD st_dev rule calls the NAS "different", which is exactly R-1075.
if m.hasOffDriveTarget() {
t.Fatal("R-1075: a NAS made the box claim an off-drive copy")
}
if w := m.singleCopyWarning(); w != m.note("note.backup.single_copy") {
t.Fatalf("a NAS must not hide the one-copy notice, got %q", w)
}
if tg, err := m.selectTier2TargetFrom("app", "/srv/sys", 1, 1); err == nil && !tg.IsSystemDrive {
t.Fatalf("a NAS must never be a Tier-2 target, got %+v", tg)
}
}
// An older agent (supported=false) keeps today's behaviour exactly: the st_dev seam decides.
func TestDisk16_OldAgent_KeepsTheStDevRule(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
addDrive(t, sett, settings.StoragePath{Path: "/mnt/b", Label: "B"})
calls := 0
m.SetDiskJudge(func(a, b string) (DiskVerdict, bool) { calls++; return DiskVerdict{}, false })
tg, err := m.selectTier2TargetFrom("app", "/srv/sys", 1, 1)
if err != nil || tg.NamespaceRoot != "/mnt/b" || tg.Unproven {
t.Fatalf("old agent: want today's pick (/mnt/b, counted), got %+v err=%v", tg, err)
}
if calls == 0 {
t.Fatal("the judge was never asked")
}
if m.singleCopyWarning() != "" {
t.Fatal("old agent: a second drive by st_dev must still hide the notice (today's behaviour)")
}
// And the st_dev rule's own same-disk answer still stands.
m.samePhysicalDevice = func(a, b string) bool { return true }
if _, err := m.selectTier2TargetFrom("app", "/srv/x", 1, 1); err == nil {
t.Fatal("old agent: st_dev 'same' must still refuse the target")
}
}
// The success record carries the mark the app row renders.
func TestDisk16_UnprovenCopyIsRecorded(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
m.recordTier2Success("app", &Tier2Target{NamespaceRoot: "/mnt/a", Unproven: true, UnprovenReason: "vm"}, 1, "", 0)
cd := sett.GetCrossDriveConfig("app")
if cd == nil || !cd.Unproven || cd.UnprovenReason != "vm" {
t.Fatalf("want the unproven mark recorded, got %+v", cd)
}
m.recordTier2Success("app", &Tier2Target{NamespaceRoot: "/mnt/a"}, 1, "", 0)
if cd := sett.GetCrossDriveConfig("app"); cd.Unproven {
t.Fatal("a later proven copy must clear the mark")
}
}