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
+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
})
}