From 958e54f6a6332e08096f446a169d2822c5c2133b Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 29 Jul 2026 08:20:27 +0200 Subject: [PATCH] =?UTF-8?q?v0.112.0=20=E2=80=94=20E-2:=20GET=20/disks=20fl?= =?UTF-8?q?ags=20the=20backup-target=20drive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive backup_target field, true for the drive backing the PRIMARY tier. The controller cannot work this out itself: settings.StoragePath.BackupTarget is customer INTENT, and on the two boxes migrated by hand in E-1 that intent was never recorded -- intent is empty while the drive really IS the target. Without this flag the absent-target alarm could not name the drive on exactly the boxes that currently have one. omitempty + false on an older agent, so an old controller degrades to the generic disconnect alarm rather than a wrong one. Test asserts the target IS flagged AND the non-target is NOT, as a pair -- a blanket true would satisfy a naive one-sided check. --- CHANGELOG.md | 13 +++++++ internal/localapi/backup_target_guard_test.go | 39 +++++++++++++++++++ internal/localapi/disks.go | 13 +++++++ 3 files changed, 65 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb5a43e..992596b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## v0.112.0 — E-2: GET /disks flags the backup-target drive (2026-07-29) + +Additive field `backup_target` on each `/disks` entry, true for the drive backing the PRIMARY tier. + +**The controller cannot work this out for itself.** Its `settings.StoragePath.BackupTarget` is +customer INTENT, and on the two boxes migrated by hand in E-1 that intent was never recorded — so +intent is empty while the drive really is the target. Without this flag the absent-target alarm could +not name the drive on exactly the boxes that currently have one. `omitempty` + false on an older +agent, so an old controller degrades to the generic disconnect alarm rather than a wrong one. + +Test asserts the target IS flagged **and** the non-target is NOT, as a pair — a blanket `true` would +satisfy a naive one-sided check. + ## v0.111.0 — E-2c: the backup-target drive can no longer be ejected out from under the backup (2026-07-29) **A regression guard on a configuration that is live right now.** E-1 (2026-07-28) moved each demo diff --git a/internal/localapi/backup_target_guard_test.go b/internal/localapi/backup_target_guard_test.go index f5a987c..f72214d 100644 --- a/internal/localapi/backup_target_guard_test.go +++ b/internal/localapi/backup_target_guard_test.go @@ -2,6 +2,7 @@ package localapi import ( "context" + "encoding/json" "io" "log/slog" "net/http" @@ -106,3 +107,41 @@ func TestEjectStillAllowedOnANonTargetDrive(t *testing.T) { "it must block exactly the target, not harden the whole eject path; got: %s", rr.Body.String()) } } + +// E-2 — GET /disks must FLAG the backup-target drive, because the controller cannot work it out. +// +// The controller's own StoragePath.BackupTarget is customer INTENT, and on a box migrated by hand +// (E-1, both demo boxes) nobody ever assigned it — intent is empty while the drive really is the +// target. Without this flag the absent-target alarm could not name the drive on exactly the boxes +// that have one, which is the only place it currently matters. +func TestDisksFlagsTheBackupTargetDrive(t *testing.T) { + h := backupTargetServer(t) + rr := do(t, h, http.MethodGet, "/disks", "A", "") + if rr.Code != http.StatusOK { + t.Fatalf("GET /disks = %d, body %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + // The target must be flagged and the non-target must not be — asserted as a pair, since a + // blanket true would satisfy a naive "is it flagged?" check. + var got struct { + Data struct { + Disks []struct { + Name string `json:"name"` + BackupTarget bool `json:"backup_target"` + } `json:"disks"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(body), &got); err != nil { + t.Fatalf("decode: %v (body %s)", err, body) + } + seen := map[string]bool{} + for _, d := range got.Data.Disks { + seen[d.Name] = d.BackupTarget + } + if !seen["felhom-backup"] { + t.Errorf("felhom-backup is the primary tier's storage but backup_target is false; body: %s", body) + } + if seen["spare-drive"] { + t.Errorf("spare-drive is NOT the target but was flagged — a blanket true is not a signal; body: %s", body) + } +} diff --git a/internal/localapi/disks.go b/internal/localapi/disks.go index 3293483..b03843d 100644 --- a/internal/localapi/disks.go +++ b/internal/localapi/disks.go @@ -143,6 +143,10 @@ type DiskInfo struct { // HDD look available when it wasn't bound. Only meaningful for user-data drives. LEGACY (per-drive // `pct set -mpN` model) — the intermediary model uses BoundUnderParent. GuestAttached bool `json:"guest_attached"` + // BackupTarget (E-2) reports that this drive backs the PRIMARY whole-guest backup tier. Additive: + // an older controller ignores it. It is the agent's answer, not the controller's intent flag — + // on a hand-migrated box (E-1) intent is unset while the drive really is the target. + BackupTarget bool `json:"backup_target,omitempty"` // GuestPath is the drive's STABLE in-guest path in the intermediary-mount model // (/mnt/felhom-drives/). This is what the controller repoints HDD_PATH to and registers as the // storage path. Set for /mnt/ drives; "" otherwise. Distinct from MountPath (the RAW host PVE @@ -177,6 +181,9 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) { // F9: which host mount paths are actually BOUND into THIS guest's config (guest-usable, not just // host-present). A bind's mp= equals the guest path, which is the drive's host mount path (`where`). boundPaths := s.guestBoundPaths(r.Context(), vmid) + // E-2: the PRIMARY tier's storage id — the whole-guest vzdump destination. "" when no tier + // carries a target (the legacy single-tier shape), which correctly flags nothing. + primaryTargetID := s.primaryTier().TargetID out := make([]DiskInfo, 0, len(targets)) for _, t := range targets { di := DiskInfo{ @@ -188,6 +195,12 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) { UsedBytes: t.UsedBytes, UsedFraction: t.UsedFraction, GuestAttached: t.MountPath != "" && boundPaths[t.MountPath], + // E-2: is THIS drive the whole-guest backup target? The agent is the only component that + // can answer — the controller's own StoragePath.BackupTarget is customer INTENT, and on a + // box migrated by hand (E-1) nobody ever assigned it, so intent is empty while the drive + // really is the target. Reported here so the controller can name the drive in an + // absent-target alarm and hide the destructive controls the agent would refuse anyway. + BackupTarget: t.Name == primaryTargetID, } // Intermediary model: the stable in-guest path + whether felhom-data is bound under the parent. // Only user-data /mnt/ drives have a guest path (system/backup mounts never cross in).