958e54f6a6
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.
148 lines
6.5 KiB
Go
148 lines
6.5 KiB
Go
package localapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
|
)
|
|
|
|
// backupTargetServer builds a server whose PRIMARY tier is `felhom-backup`, mounted at /mnt/nvme-1tb
|
|
// on its own non-system device — i.e. the exact live shape E-1 created on demo-hp and demo-felhom:
|
|
// the drive is simultaneously the enrolled user-data drive AND the whole-guest vzdump target.
|
|
func backupTargetServer(t *testing.T) http.Handler {
|
|
t.Helper()
|
|
sv := fakeStorage{targets: []hub.StorageTarget{
|
|
{
|
|
Name: "felhom-backup", Type: hub.StorageTypeLocalDir, State: hub.StorageStateAttached,
|
|
Reachable: true, MountPath: "/mnt/nvme-1tb", BackingDevice: "/dev/nvme0n1",
|
|
Content: "backup",
|
|
},
|
|
{
|
|
Name: "spare-drive", Type: hub.StorageTypeLocalDir, State: hub.StorageStateAttached,
|
|
Reachable: true, MountPath: "/mnt/spare", BackingDevice: "/dev/sdz1",
|
|
Content: "backup",
|
|
},
|
|
}}
|
|
srv, err := NewServer(Options{
|
|
ListenAddr: "127.0.0.1:0",
|
|
Guests: &fakeGuests{}, Backups: &fakeBackups{}, Store: &fakeStore{}, Storage: sv,
|
|
Tokens: staticTokens{"A": 8200},
|
|
Disks: &fakeDiskOps{}, DiskGate: &fakeGate{}, HostReader: sysOnSDA(),
|
|
// Service is load-bearing: normalizeBackupTiers DROPS any tier with a nil Service and falls
|
|
// back to the legacy single tier with an empty TargetID — which silently made an earlier
|
|
// version of this test exercise nothing.
|
|
BackupTiers: []BackupTier{
|
|
{TargetID: "felhom-backup", Cadence: 24 * time.Hour, Primary: true, Service: &fakeBackups{}},
|
|
{TargetID: "felhom-pbs", Cadence: 168 * time.Hour, Service: &fakeBackups{}},
|
|
},
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
srv.baseCtx = context.Background()
|
|
srv.now = func() time.Time { return testNow }
|
|
return srv.Handler()
|
|
}
|
|
|
|
// E-2c — ejecting the drive that holds the only local whole-guest backup must be refused.
|
|
//
|
|
// This is a REGRESSION GUARD on a live configuration, not a hypothetical. E-1 (2026-07-28) moved the
|
|
// vzdump target onto each demo box's secondary drive, and `RoleForStorage` types a local-dir on a
|
|
// non-system device as user-data — so the pre-existing role gate PASSES it and the customer could
|
|
// self-serve eject the drive holding their backups. It would have succeeded silently.
|
|
//
|
|
// The assertion is on the CONSEQUENCE (the request is refused) plus the remedy being named, because a
|
|
// refusal the customer cannot act on just moves the failure.
|
|
func TestEjectRefusedOnTheBackupTargetDrive(t *testing.T) {
|
|
h := backupTargetServer(t)
|
|
rr := do(t, h, http.MethodPost, "/disks/eject", "A", `{"vmid":8200,"where":"/mnt/nvme-1tb"}`)
|
|
if rr.Code == http.StatusOK {
|
|
t.Fatalf("eject of the backup-target drive SUCCEEDED (%d) — the box would silently lose its "+
|
|
"local drive-loss protection with nothing alarming", rr.Code)
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "felhom-backup") {
|
|
t.Errorf("refusal must NAME the backup target so the customer knows which role blocks it; got: %s", body)
|
|
}
|
|
if !strings.Contains(strings.ToLower(body), "reassign") {
|
|
t.Errorf("refusal must name the REMEDY (reassign the target first), else it is a dead end; got: %s", body)
|
|
}
|
|
}
|
|
|
|
// Decommission strands the target just as thoroughly as eject — it migrates data off and retires the
|
|
// drive. Same gate, asserted separately because it is a different handler and a different caller.
|
|
func TestDecommissionRefusedOnTheBackupTargetDrive(t *testing.T) {
|
|
h := backupTargetServer(t)
|
|
rr := do(t, h, http.MethodPost, "/disks/decommission", "A", `{"vmid":8200,"where":"/mnt/nvme-1tb"}`)
|
|
if rr.Code == http.StatusOK {
|
|
t.Fatalf("decommission of the backup-target drive SUCCEEDED (%d)", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "felhom-backup") {
|
|
t.Errorf("refusal must name the backup target; got: %s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// THE OVER-CORRECTION GUARD, and the reason this is a narrow gate instead of a role reclassification.
|
|
//
|
|
// The tempting fix — make RoleForStorage return RoleBackup for the target — would also refuse every
|
|
// OTHER user-data drive op on a box, and on the demo boxes it would refuse the customer's own data
|
|
// drive, because that drive IS the target. This pins that a non-target drive stays ejectable: the new
|
|
// gate must block exactly one drive, not harden the whole eject path.
|
|
//
|
|
// It asserts "not blocked BY THIS GATE" rather than "succeeds", because eject has other legitimate
|
|
// failure modes in a fake harness; what must never appear is this gate's message.
|
|
func TestEjectStillAllowedOnANonTargetDrive(t *testing.T) {
|
|
h := backupTargetServer(t)
|
|
rr := do(t, h, http.MethodPost, "/disks/eject", "A", `{"vmid":8200,"where":"/mnt/spare"}`)
|
|
if strings.Contains(rr.Body.String(), "whole-guest backup target") {
|
|
t.Fatalf("the backup-target gate blocked a NON-target drive (/mnt/spare) — over-correction: "+
|
|
"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)
|
|
}
|
|
}
|