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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user