E-2 Part 1: the backup-target role on StoragePath (foundation, not yet wired)

Foundation only, no version bump -- nothing customer-visible changes yet. The
offer UI (Part 3), the degraded banner (Part 4) and the controller half of the
absent-target signal (Part 5) are NOT here; they are tracked as E-2 in
OPEN-ITEMS.md so this cannot become a sixth "seam built but never wired". The
fifth was found by E-2's own Phase 0: NotifyStorageDisconnected is defined and
called nowhere, so a drive going absent emits no event at all today.

StoragePath gains BackupTarget bool -- the sibling role to Schedulable/
IsDefault/Kind. It is INTENT, not truth: the authority is the agent's
backup.local_backup_target, and truth is read from GET /backup/tiers. This
records what the customer ASSIGNED so the controller can render the state,
notice the drive going absent, and detect drift.

Invariants, each pinned by a test asserting the CONSEQUENCE not the mechanism:
  - a drive NEVER acquires the role by appearing (registration does not set it)
  - exactly one carrier; assigning moves rather than duplicates
  - sticky: a new bigger/faster drive does not steal an assigned target
  - an absent target STAYS assigned -- clearing on disconnect would be a silent
    retarget by omission ("no target configured" instead of "drive missing")
  - a network share is refused (R-108 risk model; the role is about LOCAL disk
    failure)

Red-proof C: adding auto-elevation to AddStoragePath fails
TestRegisteringDrivesNeverAssignsTheBackupTarget with
  registering drives assigned the backup target "/mnt/hdd_1"

Attributes may suggest and refuse the absurd, never select: demo-felhom's backup
drive is an external USB HDD and BOTH demo boxes report removable=0, so a
transport rule disqualifies the reference drive and a removable rule finds no
candidate at all.

Green gate: build + vet + test rc=0, run separately from this commit.
This commit is contained in:
2026-07-29 08:00:47 +02:00
parent fd50a73e65
commit ff058a4f10
3 changed files with 249 additions and 0 deletions
+91
View File
@@ -287,6 +287,28 @@ type StoragePath struct {
// Kind discriminates a physical drive ("" / "drive") from a NAS network share ("network"). A network
// share is bulk-media only and carries the fields below; the drive lifecycle does NOT apply to it.
Kind string `json:"kind,omitempty"`
// BackupTarget (E-2) marks the drive the WHOLE-GUEST vzdump is written to — the sibling role to
// Schedulable/IsDefault/Kind.
//
// THIS FIELD IS INTENT, NOT TRUTH. The authority is the agent's `backup.local_backup_target` in
// agent.json; this records which drive the customer ASSIGNED so the controller can (a) render the
// degraded/healthy state, (b) notice when the assigned drive goes absent, and (c) detect drift from
// what the agent is actually doing. Read the agent's `GET /backup/tiers` for truth; never assume
// this flag and the agent agree.
//
// THE RULE THIS EXISTS TO ENFORCE: a drive NEVER acquires this role by appearing. Nothing in the
// registration path sets it — only an explicit customer choice through SetBackupTarget does.
// Attributes (transport, removable, size) may SUGGEST a candidate and may refuse the absurd; they
// may never select one. The reference hardware settles it: demo-felhom's backup drive is an
// external USB HDD and BOTH demo boxes' drives report removable=0, so a transport rule would
// disqualify the reference drive and a removable rule would find no candidate at all.
//
// Exactly one path may carry it (SetBackupTarget clears the others), and it is STICKY: it does not
// move because a new drive appeared, and it is never cleared just because the drive is absent —
// an absent target must stay assigned and ALARM (E-2 Part 5), because silently retargeting is how
// a backup lands somewhere nobody expects. See [[storage-authz-redesign]] for the role vocabulary.
BackupTarget bool `json:"backup_target,omitempty"`
// Network-storage descriptors (Kind=="network" only; mirror the agent A1 add request). NO password
// is stored — the SMB credential is passed through to the agent at add-time and never persisted here.
Protocol string `json:"protocol,omitempty"` // nfs | smb
@@ -1018,6 +1040,75 @@ func (s *Settings) SetSchedulable(path string, schedulable bool) error {
return fmt.Errorf("storage path %q not found", path)
}
// SetBackupTarget assigns the whole-guest backup-target role to exactly one storage path, clearing it
// from every other. This is the ONLY writer of StoragePath.BackupTarget — registration must never set
// it (E-2 §3: a drive never acquires a role by appearing).
//
// Refusals, both structural rather than advisory:
// - a NETWORK share can never be the target. vzdump writes a multi-GB archive through the host, and
// the whole point of the role is surviving a local disk failure — a share that is itself remote,
// credential-bound and mounted at its own root (R-108) is a different risk model entirely.
// - a path that is not registered cannot hold a role.
//
// It deliberately does NOT refuse a DISCONNECTED path: reassigning to a drive that is currently absent
// is a legitimate recovery order ("this is the drive, go find it"), and the absent-target alarm is what
// covers the gap. Refusing here would instead force a silent retarget elsewhere.
func (s *Settings) SetBackupTarget(path string) error {
s.mu.Lock()
defer s.mu.Unlock()
idx := -1
for i := range s.StoragePaths {
if s.StoragePaths[i].Path == path {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("storage path %q not found", path)
}
if s.StoragePaths[idx].IsNetwork() {
return fmt.Errorf("a hálózati tárhely nem lehet a rendszermentés célja: %s", path)
}
for i := range s.StoragePaths {
s.StoragePaths[i].BackupTarget = i == idx
}
if s.log != nil {
s.log.Printf("[INFO] [settings] backup target assigned: %s", path)
}
return s.save()
}
// ClearBackupTarget removes the role from every path, leaving the box with no assigned target (the
// degraded state). Separate from SetBackupTarget on purpose: "no target" is a real, nameable state
// that must be reachable deliberately, not an accident of passing an empty string to a setter.
func (s *Settings) ClearBackupTarget() error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.StoragePaths {
s.StoragePaths[i].BackupTarget = false
}
if s.log != nil {
s.log.Printf("[INFO] [settings] backup target cleared — the box is now in the degraded (system-drive) state")
}
return s.save()
}
// BackupTargetPath returns the assigned target's path, or "" when none is assigned (degraded).
func (s *Settings) BackupTargetPath() string {
s.mu.RLock()
defer s.mu.RUnlock()
for _, sp := range s.StoragePaths {
if sp.BackupTarget {
return sp.Path
}
}
return ""
}
// BackupTargetAssigned reports whether ANY path carries the role. The negation is the degraded state
// the customer must be told about (E-2 Part 4).
func (s *Settings) BackupTargetAssigned() bool { return s.BackupTargetPath() != "" }
// SetStorageLabel updates the label for a storage path.
func (s *Settings) SetStorageLabel(path, label string) error {
s.mu.Lock()