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
+88 -16
View File
@@ -286,6 +286,11 @@ type Manager struct {
// and Tier-2 correctly refuses them — which makes the off-drive tests unrunnable rather than wrong.
// Nil → the real system.SamePhysicalDevice (production always takes this path).
samePhysicalDevice func(a, b string) bool
// diskJudge (v0.317.0, felhom.eu architecture/16-storage-physical-identity.md §4) asks the HOST AGENT
// whether two guest paths share a physical disk. supported=false (agent older than 0.157.0, or the
// call failed) → diskRelation keeps the old st_dev rule, logged once. nil → the old rule (tests).
diskJudge func(a, b string) (v DiskVerdict, supported bool)
diskLegacyOnce sync.Once
// migrationRunning, if set, reports whether a data migration is in progress. The scheduled
// backup paths skip when it returns true (Change 3 — backup ↔ migration mutual exclusion), so a
@@ -356,10 +361,6 @@ type FullBackupStatus struct {
// backup used to render with a blank drive label). Matches the tier-2 UI wording.
const systemDriveLabel = "Belső SSD (rendszer)"
// singleCopyNotice is the honest single-drive signal (F6): shown when no off-drive tier-2 target
// exists, instead of silently implying a 3-2-1 guarantee the box cannot provide.
const singleCopyNotice = "Csak egy másolat készül (nincs második meghajtó) — a 3-2-1 mentéshez csatlakoztasson egy második meghajtót vagy offsite tárolót."
// DBDumpStatus holds the last DB dump result.
type DBDumpStatus struct {
LastRun time.Time
@@ -1363,6 +1364,59 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
return status
}
// DiskVerdict is the agent's answer to "do these two paths share a physical disk?" (`16` §4).
type DiskVerdict struct {
Different bool
Proven bool
Reason string // "" | same_disk | vm | network | usb_bridge | weak_wwn | no_serial | unmapped
}
// DiskRelation is what a Tier-2 decision acts on.
type DiskRelation int
const (
// DiskDifferent — a different physical disk, proven by the agent (or the old st_dev rule said so):
// the copy is made and COUNTS as a second copy.
DiskDifferent DiskRelation = iota
// DiskSame — the same physical disk: no second copy is made (`16` D1).
DiskSame
// DiskUnknown — the agent cannot prove it (a VM, a USB bridge that hides the serial): the copy is made
// but does NOT count, and the page says why (`16` D2).
DiskUnknown
)
// SetDiskJudge wires the agent's same-disk call (main: agentapi FeatureSameDisk).
func (m *Manager) SetDiskJudge(fn func(a, b string) (DiskVerdict, bool)) { m.diskJudge = fn }
// diskRelation is the ONE place a Tier-2 or one-copy decision asks "same disk?" (`16` §4). The agent's
// verdict wins when it can give one; "unmapped" (a path the agent did not make, e.g. an absent drive) and
// an older agent fall back to today's st_dev rule — never a silent change, never a crash. Pinned by
// diskrelation_test.go.
func (m *Manager) diskRelation(a, b string) (DiskRelation, string) {
if m.diskJudge != nil {
v, ok := m.diskJudge(a, b)
if ok && v.Reason != "unmapped" {
switch {
case v.Proven && v.Different:
return DiskDifferent, ""
case v.Proven:
return DiskSame, v.Reason
default:
return DiskUnknown, v.Reason
}
}
if !ok {
m.diskLegacyOnce.Do(func() {
m.logger.Printf("[INFO] [backup] same-disk: the host agent cannot answer (older than 0.157.0, or the call failed) — using the filesystem-device rule")
})
}
}
if m.sameDevice(a, b) {
return DiskSame, "st_dev"
}
return DiskDifferent, "st_dev"
}
// sameDevice reports whether two paths sit on the same physical device, through the test seam when
// one is installed. Nil seam → system.SamePhysicalDevice, i.e. byte-for-byte the previous behaviour.
func (m *Manager) sameDevice(a, b string) bool {
@@ -1384,29 +1438,47 @@ func (m *Manager) sameDevice(a, b string) bool {
return system.SamePhysicalDevice(a, b)
}
// hasOffDriveTarget reports whether any registered, schedulable storage path lives on a physical disk
// OTHER than the system drive — i.e. whether a genuine off-drive (tier-2) copy is possible at all.
// When false the box is single-drive: tier-1 is the ONLY local copy and 3-2-1 needs a 2nd drive or
// offsite (F6 — surfaced honestly via SingleCopyWarning, never a faked guarantee).
func (m *Manager) hasOffDriveTarget() bool {
// offDriveCopyState reports whether any registered, schedulable, NON-network storage path is on a physical
// disk OTHER than the system drive — counted only when proven (`16` §4). unknown is true when such a path
// exists but the agent cannot prove it is another disk (a VM, a hidden USB serial). A NAS never counts
// (R-1075: it is never a Tier-2 target, so it must not hide the one-copy notice).
func (m *Manager) offDriveCopyState() (counted, unknown bool) {
if m.settings == nil || m.systemDataPath == "" {
return false
return false, false
}
for _, sp := range m.settings.GetSchedulableStoragePaths() {
if sp.Path == m.systemDataPath || m.sameDevice(m.systemDataPath, sp.Path) {
if sp.Path == m.systemDataPath || sp.IsNetwork() {
continue
}
return true
switch rel, _ := m.diskRelation(m.systemDataPath, sp.Path); rel {
case DiskDifferent:
return true, false
case DiskUnknown:
unknown = true
}
}
return false
return false, unknown
}
// singleCopyWarning returns the honest single-drive notice, or "" when an off-drive target exists.
// hasOffDriveTarget reports whether a COUNTED off-drive (tier-2) copy is possible at all.
func (m *Manager) hasOffDriveTarget() bool {
counted, _ := m.offDriveCopyState()
return counted
}
// singleCopyWarning returns the honest notice (`16` §4), or "" when a counted off-drive target exists:
// "only one copy" when there is no second disk (or it is the same disk); "cannot check" when a second
// drive exists whose disk the agent cannot prove. Both sentences are i18n (informal voice — R-1072).
func (m *Manager) singleCopyWarning() string {
if m.hasOffDriveTarget() {
counted, unknown := m.offDriveCopyState()
switch {
case counted:
return ""
case unknown:
return m.note("note.backup.single_copy_unproven")
default:
return m.note("note.backup.single_copy")
}
return singleCopyNotice
}
// sysDriveLabelFor returns the drive label for a stack's tier-1 restore point — the clear
@@ -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")
}
}
@@ -43,7 +43,7 @@ func TestRunAllTier2_IncludesVolumeOnlyApps(t *testing.T) {
func TestSingleCopyWarning_HonestOnSingleDrive(t *testing.T) {
// Single-drive: only the system drive, no schedulable off-drive paths.
m1, _ := newTestManager(t, "/srv/sys")
if got := m1.singleCopyWarning(); got != singleCopyNotice {
if got := m1.singleCopyWarning(); got != m1.note("note.backup.single_copy") {
t.Errorf("single-drive box must surface the honest notice, got %q", got)
}
+67 -16
View File
@@ -67,6 +67,11 @@ type Tier2Target struct {
IsSystemDrive bool // target is the internal SSD/system drive (DB/config only)
StateOnly bool // §2.2: this target carries unit + MANDATORY only (optional legs skipped) — the SSD tier
Reason string // why this target (Hungarian, for UI/logs)
// Unproven (`16` D2): the agent cannot prove this target is another physical disk (a VM, a USB bridge
// that hides the serial). The copy is still made; it does not count as a second copy, and the app's
// row says why. UnprovenReason is the agent's reason code.
Unproven bool
UnprovenReason string
}
// tier2FitsHeadroom reports whether a unit of unitGB fits on a system/rootfs drive while leaving a
@@ -116,47 +121,75 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
sawNetworkCandidate = true
break
}
if sp.Path == sourceDrive || m.sameDevice(sourceDrive, sp.Path) {
break // pinned target is on the same physical disk — not off-drive; fall through
if sp.Path == sourceDrive {
break
}
rel, why := m.diskRelation(sourceDrive, sp.Path)
if rel == DiskSame {
break // pinned target is on the same physical disk — not off-drive; fall through (`16` D1)
}
label := sp.Label
if label == "" {
label = filepath.Base(sp.Path)
}
return &Tier2Target{
NamespaceRoot: NamespaceRoot(sp.Path, true),
Label: label,
Reason: m.note("note.tier2.reason_manual"),
NamespaceRoot: NamespaceRoot(sp.Path, true),
Label: label,
Reason: m.note("note.tier2.reason_manual"),
Unproven: rel == DiskUnknown,
UnprovenReason: unprovenWhy(rel, why),
}, nil
}
}
}
// 1. Prefer another registered user-data drive on a DIFFERENT physical disk, NON-network.
// 1. Prefer another registered user-data drive on a DIFFERENT physical disk, NON-network. A drive the
// agent PROVES is another disk wins; failing that, the first drive it cannot judge (`16` D2: the copy
// is made, not counted). A drive on the SAME disk is never a target (`16` D1).
if m.settings != nil {
var firstUnknown *Tier2Target
for _, sp := range m.settings.GetSchedulableStoragePaths() {
if sp.Path == sourceDrive || m.sameDevice(sourceDrive, sp.Path) {
if sp.Path == sourceDrive {
continue
}
if sp.IsNetwork() {
sawNetworkCandidate = true // F-6C-1: never a tier-2 target
continue
}
rel, why := m.diskRelation(sourceDrive, sp.Path)
if rel == DiskSame {
continue
}
label := sp.Label
if label == "" {
label = filepath.Base(sp.Path)
}
return &Tier2Target{
NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root
Label: label,
Reason: m.note("note.tier2.reason_other_drive"),
}, nil
t := &Tier2Target{
NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root
Label: label,
Reason: m.note("note.tier2.reason_other_drive"),
Unproven: rel == DiskUnknown,
UnprovenReason: unprovenWhy(rel, why),
}
if rel == DiskDifferent {
return t, nil
}
if firstUnknown == nil {
firstUnknown = t
}
}
if firstUnknown != nil {
return firstUnknown, nil
}
}
// 2. Fall back to the internal SSD (system data path) — STATE-ONLY set only.
sys := m.systemDataPath
if sys == "" || m.sameDevice(sourceDrive, sys) {
sysRel, sysWhy := DiskSame, ""
if sys != "" {
sysRel, sysWhy = m.diskRelation(sourceDrive, sys)
}
if sys == "" || sysRel == DiskSame {
if sawNetworkCandidate {
return nil, errTier2NetworkOnly // the only off-disk candidate was a NAS
}
@@ -175,9 +208,20 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
IsSystemDrive: true,
StateOnly: true,
Reason: m.note("note.tier2.reason_no_second"),
// `16` D2: an app on a drive the agent cannot judge against the system disk — made, not counted.
Unproven: sysRel == DiskUnknown,
UnprovenReason: unprovenWhy(sysRel, sysWhy),
}, nil
}
// unprovenWhy keeps the agent's reason only for an unknown verdict.
func unprovenWhy(rel DiskRelation, why string) string {
if rel == DiskUnknown {
return why
}
return ""
}
// tier2FitsSystemDrive checks the size-aware rootfs-headroom guard for the SSD target.
func (m *Manager) tier2FitsSystemDrive(sys string, unitSizeBytes int64) bool {
di := system.GetDiskUsage(sys)
@@ -326,8 +370,9 @@ func (m *Manager) RunTier2(stackName string) error {
m.logger.Printf("[INFO] [backup] Tier 2 for %s: no off-drive target — %s", stackName, reason)
return nil
}
// Defense-in-depth off-drive guard (selection already enforced it).
if m.sameDevice(sourceDrive, target.NamespaceRoot) {
// Defense-in-depth off-drive guard (selection already enforced it) — asked again right before the run,
// so a disk swapped in the same bay is re-judged (`16` §3 "before each Tier-2 run").
if rel, _ := m.diskRelation(sourceDrive, target.NamespaceRoot); rel == DiskSame {
m.recordTier2NoTarget(stackName, m.note("note.tier2.same_disk"))
return nil
}
@@ -527,7 +572,10 @@ func (m *Manager) Tier2Info(stackName string) Tier2Info {
}
// Eligible alternative drives: registered, schedulable, on a DIFFERENT physical disk.
for _, sp := range m.settings.GetSchedulableStoragePaths() {
if sp.Path == source || m.sameDevice(source, sp.Path) {
if sp.Path == source {
continue
}
if rel, _ := m.diskRelation(source, sp.Path); rel == DiskSame {
continue
}
label := sp.Label
@@ -636,6 +684,9 @@ func (m *Manager) recordTier2SuccessWithUnit(stackName string, target *Tier2Targ
c.LastDuration = dur.Round(time.Second).String()
c.LastSizeHuman = humanizeBytes(sizeBytes)
c.LastError = "" // a success invalidates the previous error
// `16` D2: a copy on a disk the agent cannot prove is another one — kept, not counted.
c.Unproven = target.Unproven
c.UnprovenReason = target.UnprovenReason
})
}
+1 -1
View File
@@ -177,7 +177,7 @@ func (m *Manager) RunSharesTier2() error {
}
// Defense-in-depth off-drive guard (selection already enforced it): a leg's target may never
// be that leg's own source drive — that would be a same-disk "copy" pretending to be tier 2.
if m.sameDevice(g.sourceDrive, target.NamespaceRoot) {
if rel, _ := m.diskRelation(g.sourceDrive, target.NamespaceRoot); rel == DiskSame {
noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: a kiválasztott cél ugyanazon a fizikai lemezen van", g.sourceDrive))
continue
}