v0.23.0: device-ROLE classification + tiered storage-wipe gate (user-data customer-confirmable; system/backup operator-only)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+125
-36
@@ -27,10 +27,31 @@ type DiskOps interface {
|
||||
}
|
||||
|
||||
// StorageGate authorizes a DESTRUCTIVE storage op (a data-bearing wipe/format) through the
|
||||
// slice-4 reversibility gate. Satisfied by an adapter over reconcile.Gate in main.go. In 8C an
|
||||
// unsigned destructive op returns (false, "pending_signature"); the signed path is slice 10.
|
||||
// reversibility gate, TIERED by the agent's authoritative device-role verdict. Satisfied by an
|
||||
// adapter over reconcile.Gate in main.go.
|
||||
// - user-data → customer-confirmable: allowed iff the request carries the customer's confirmation
|
||||
// bound to the device's durable id (no operator signature).
|
||||
// - system/backup → operator-signature only (unsigned → pending_signature; Confirmed is ignored).
|
||||
type StorageGate interface {
|
||||
AuthorizeWipe(device string) (allowed bool, reason string)
|
||||
AuthorizeWipe(req WipeRequest) WipeDecision
|
||||
}
|
||||
|
||||
// WipeRequest is the inspection-derived input to the wipe gate. Role + DeviceDurableID are
|
||||
// AGENT-INTERNAL (the agent classified the role and re-resolved the durable id); Confirmed +
|
||||
// ConfirmDurableID are the caller's claim, honored ONLY for user-data and ONLY on a durable-id match.
|
||||
type WipeRequest struct {
|
||||
Role string // "system" | "backup" | "user-data" (agent-classified)
|
||||
DeviceDurableID string // agent-re-resolved durable id of the device ("" if unresolvable)
|
||||
Confirmed bool
|
||||
ConfirmDurableID string
|
||||
}
|
||||
|
||||
// WipeDecision is the gate's verdict.
|
||||
type WipeDecision struct {
|
||||
Allowed bool
|
||||
Tier string // "customer_confirmable" | "destructive"
|
||||
Reason string // machine reason (audit/UI)
|
||||
NeedsConfirmation bool // user-data, not-yet-confirmed → ask the customer (NOT a signature)
|
||||
}
|
||||
|
||||
// GuestLister lists the host's guests (to map a mount to the guests that depend on it for the
|
||||
@@ -49,6 +70,11 @@ type DiskInfo struct {
|
||||
BackingDevice string `json:"backing_device"` // /dev/sdb1, … ("" for network/lvm)
|
||||
MountPath string `json:"mount_path"`
|
||||
Class string `json:"class"` // fast | slow | ""
|
||||
// Role is the agent's AUTHORITATIVE protection tier (system | backup | user-data), derived from
|
||||
// the agent's own storage view + host topology — never from the controller. The controller drives
|
||||
// the UI from it: system/backup get a lock badge and NO destructive controls; user-data is
|
||||
// customer-manageable. Defense in depth — the agent re-enforces role at wipe time regardless.
|
||||
Role string `json:"role"`
|
||||
DataBearing bool `json:"data_bearing"` // agent device-inspection verdict (UI hint)
|
||||
DataReason string `json:"data_reason,omitempty"`
|
||||
// DurableID is the target's stable identity (e.g. "uuid:<fs-uuid>" for usb/local-dir). The
|
||||
@@ -68,12 +94,16 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
writeErr(w, http.StatusBadGateway, "could not read storage view")
|
||||
return
|
||||
}
|
||||
// Resolve the OS/system disks ONCE for this request — role classification is agent-authoritative
|
||||
// (the agent's own mount/topology read, never the caller's claim).
|
||||
sysDisks, sysKnown := storage.SystemDisks(storage.NewProcHostReader())
|
||||
out := make([]DiskInfo, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
di := DiskInfo{
|
||||
Name: t.Name, Type: t.Type, State: t.State,
|
||||
BackingDevice: t.BackingDevice, MountPath: t.MountPath, Class: t.ClassHint,
|
||||
DurableID: t.DurableID,
|
||||
Role: string(storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)),
|
||||
}
|
||||
// Inspect the backing device for the UI's data-bearing hint (the authoritative check
|
||||
// is re-run at format time on the actual device).
|
||||
@@ -159,7 +189,14 @@ type formatRequest struct {
|
||||
VMID int `json:"vmid"`
|
||||
Device string `json:"device"`
|
||||
FSType string `json:"fstype"`
|
||||
// NOTE: any caller-supplied "blank"/"force" claim is deliberately IGNORED — the agent
|
||||
// Confirmed + DurableID authorize a USER-DATA data-bearing wipe by the customer's informed-
|
||||
// confirmation bound to the device's durable id. The agent RE-RESOLVES the device's durable id
|
||||
// and matches it against DurableID — a confirmation for one disk can't wipe another. Both are
|
||||
// INERT for system/backup devices (those stay operator-signature only — the role is the agent's,
|
||||
// never the caller's, so confirmed:true on a system device is refused).
|
||||
Confirmed bool `json:"confirmed"`
|
||||
DurableID string `json:"durable_id"`
|
||||
// NOTE: any caller-supplied "blank"/"force" claim is still deliberately IGNORED — the agent
|
||||
// inspects the device itself (8C invariant).
|
||||
}
|
||||
|
||||
@@ -174,7 +211,14 @@ type FormatResponse struct {
|
||||
Formatted bool `json:"formatted"`
|
||||
DataBearing bool `json:"data_bearing"`
|
||||
Reason string `json:"reason"`
|
||||
// PendingOp is set on a data-bearing refusal — the exact op to sign (slice 10B).
|
||||
// Role is the agent's authoritative tier for the device (system | backup | user-data).
|
||||
Role string `json:"role,omitempty"`
|
||||
// NeedsConfirmation is set on a USER-DATA data-bearing refusal: the customer must re-submit with
|
||||
// confirmed:true + DurableID (below) after the controller's type-to-confirm UI. NOT an operator
|
||||
// signature — the customer authorizes the wipe of their own data drive.
|
||||
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
|
||||
DurableID string `json:"durable_id,omitempty"` // the durable id to confirm against (user-data)
|
||||
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the exact op the operator must sign.
|
||||
PendingOp *PendingOp `json:"pending_op,omitempty"`
|
||||
}
|
||||
|
||||
@@ -217,44 +261,89 @@ func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid i
|
||||
s.logger.Error("local-api: format device inspect", "device", req.Device, "err", err)
|
||||
// inspect error → fail-safe data-bearing (probe.DataBearing() is true on !Probed)
|
||||
}
|
||||
if probe.DataBearing() {
|
||||
// Destructive: route through the gate. With no operator signature → pending_signature.
|
||||
allowed, reason := s.diskGate.AuthorizeWipe(req.Device)
|
||||
if !allowed {
|
||||
// Surface the bound op the operator must sign (slice 10B): derive the DURABLE device id
|
||||
// so the signed wipe binds to this exact physical disk (not the mutable path), and the
|
||||
// runner can re-resolve it at execution. A durable-id derivation failure is non-fatal —
|
||||
// the refusal still stands; we just can't pre-fill the durable id.
|
||||
var pending *PendingOp
|
||||
if durableID, derr := storage.DeviceDurableID(req.Device); derr == nil {
|
||||
pending = &PendingOp{Op: "storage_wipe", HostScope: s.hostID, DurableID: durableID, FSType: req.FSType}
|
||||
s.logger.Warn("local-api: data-bearing format refused — PENDING OPERATOR SIGNATURE",
|
||||
"vmid", vmid, "device", req.Device, "durable_id", durableID, "fstype", req.FSType,
|
||||
"why", probe.Reason(), "to_authorize", "felhom-opsign -op storage_wipe -host "+s.hostID+" -durable-id "+durableID)
|
||||
} else {
|
||||
s.logger.Warn("local-api: data-bearing format refused (no durable id)",
|
||||
"vmid", vmid, "device", req.Device, "why", probe.Reason(), "derive_err", derr)
|
||||
}
|
||||
writeStatus(w, http.StatusForbidden, false,
|
||||
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Reason: probe.Reason(), PendingOp: pending},
|
||||
"device is data-bearing — format requires an operator signature ("+reason+")")
|
||||
if !probe.DataBearing() {
|
||||
// Blank device → benign → mkfs (role is irrelevant; there is nothing to destroy).
|
||||
if err := s.disks.Format(r.Context(), req.Device, req.FSType); err != nil {
|
||||
s.logger.Error("local-api: format", "vmid", vmid, "device", req.Device, "err", err)
|
||||
writeErr(w, http.StatusBadGateway, "format failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
// A signed wipe is executed by the signed-jobs runner (queue → verify gate → durable
|
||||
// re-resolve + re-inspect → mkfs), NOT this synchronous path. This branch (gate ALLOWED a
|
||||
// data-bearing format inline) is unreachable: the inline path passes signed=nil → always
|
||||
// pending. Fail safe.
|
||||
writeErr(w, http.StatusForbidden, "data-bearing format must be completed via a signed job (felhom-opsign → hub queue)")
|
||||
writeOK(w, FormatResponse{VMID: vmid, Device: req.Device, Formatted: true, DataBearing: false, Reason: "blank device formatted " + req.FSType})
|
||||
return
|
||||
}
|
||||
|
||||
// Blank device → benign → mkfs.
|
||||
if err := s.disks.Format(r.Context(), req.Device, req.FSType); err != nil {
|
||||
s.logger.Error("local-api: format", "vmid", vmid, "device", req.Device, "err", err)
|
||||
writeErr(w, http.StatusBadGateway, "format failed: "+err.Error())
|
||||
// Data-bearing → TIER by the agent's authoritative role classification (its own inspection,
|
||||
// never the caller's claim). The agent also re-resolves the device's durable id; the customer's
|
||||
// confirmation must bind to it.
|
||||
role := s.deviceRole(r.Context(), req.Device)
|
||||
deviceDurable, derr := storage.DeviceDurableID(req.Device)
|
||||
if derr != nil {
|
||||
deviceDurable = "" // refusal still stands; binding/pending-op just lack the id
|
||||
}
|
||||
dec := s.diskGate.AuthorizeWipe(WipeRequest{
|
||||
Role: string(role), DeviceDurableID: deviceDurable,
|
||||
Confirmed: req.Confirmed, ConfirmDurableID: req.DurableID,
|
||||
})
|
||||
|
||||
if dec.Allowed {
|
||||
// USER-DATA, customer-confirmed (durable-id-bound). The gate already AUDITED it. Wipe.
|
||||
if err := s.disks.Format(r.Context(), req.Device, req.FSType); err != nil {
|
||||
s.logger.Error("local-api: customer-confirmed format", "vmid", vmid, "device", req.Device, "err", err)
|
||||
writeErr(w, http.StatusBadGateway, "format failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
s.logger.Warn("local-api: USER-DATA data-bearing format — CUSTOMER CONFIRMED (no operator signature)",
|
||||
"vmid", vmid, "device", req.Device, "durable_id", deviceDurable, "fstype", req.FSType, "why", probe.Reason())
|
||||
writeOK(w, FormatResponse{VMID: vmid, Device: req.Device, Formatted: true, DataBearing: true,
|
||||
Role: string(role), DurableID: deviceDurable, Reason: "customer-confirmed wipe (" + probe.Reason() + ")"})
|
||||
return
|
||||
}
|
||||
writeOK(w, FormatResponse{VMID: vmid, Device: req.Device, Formatted: true, DataBearing: false, Reason: "blank device formatted " + req.FSType})
|
||||
|
||||
if dec.Tier == "customer_confirmable" {
|
||||
// USER-DATA refusal: either awaiting the customer's confirmation, or the confirmation didn't
|
||||
// bind to THIS device. Surface the durable id to confirm against — NOT an operator signature.
|
||||
msg := "device is data-bearing — customer confirmation required"
|
||||
if !dec.NeedsConfirmation {
|
||||
msg = "confirmation does not match this device — refused (" + dec.Reason + ")"
|
||||
}
|
||||
s.logger.Warn("local-api: user-data data-bearing format refused",
|
||||
"vmid", vmid, "device", req.Device, "durable_id", deviceDurable, "reason", dec.Reason, "why", probe.Reason())
|
||||
writeStatus(w, http.StatusForbidden, false,
|
||||
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Role: string(role),
|
||||
NeedsConfirmation: dec.NeedsConfirmation, DurableID: deviceDurable, Reason: probe.Reason()}, msg)
|
||||
return
|
||||
}
|
||||
|
||||
// SYSTEM / BACKUP refusal: operator signature required. Surface the bound op to sign (the durable
|
||||
// id binds the signed wipe to THIS exact physical disk). confirmed:true was ignored — by role.
|
||||
var pending *PendingOp
|
||||
if deviceDurable != "" {
|
||||
pending = &PendingOp{Op: "storage_wipe", HostScope: s.hostID, DurableID: deviceDurable, FSType: req.FSType}
|
||||
s.logger.Warn("local-api: protected (system/backup) data-bearing format refused — PENDING OPERATOR SIGNATURE",
|
||||
"vmid", vmid, "device", req.Device, "role", role, "durable_id", deviceDurable, "fstype", req.FSType,
|
||||
"why", probe.Reason(), "to_authorize", "felhom-opsign -op storage_wipe -host "+s.hostID+" -durable-id "+deviceDurable)
|
||||
} else {
|
||||
s.logger.Warn("local-api: protected data-bearing format refused (no durable id)",
|
||||
"vmid", vmid, "device", req.Device, "role", role, "why", probe.Reason())
|
||||
}
|
||||
writeStatus(w, http.StatusForbidden, false,
|
||||
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Role: string(role), PendingOp: pending, Reason: probe.Reason()},
|
||||
"device is system/backup-protected — format requires an operator signature ("+dec.Reason+")")
|
||||
}
|
||||
|
||||
// deviceRole resolves a device's AUTHORITATIVE protection tier. It prefers a known storage target's
|
||||
// role (so a PBS-backed device is recognized as backup), falling back to a raw-device classification
|
||||
// (for a fresh disk not yet a PVE storage — the init flow). Defaults to system on ambiguity.
|
||||
func (s *Server) deviceRole(ctx context.Context, device string) storage.DeviceRole {
|
||||
sysDisks, sysKnown := storage.SystemDisks(storage.NewProcHostReader())
|
||||
if targets, err := s.storage.Observe(ctx); err == nil {
|
||||
for _, t := range targets {
|
||||
if t.BackingDevice != "" && t.BackingDevice == device {
|
||||
return storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)
|
||||
}
|
||||
}
|
||||
}
|
||||
return storage.RoleForRawDevice(device, sysDisks, sysKnown)
|
||||
}
|
||||
|
||||
// dependentGuests returns the VMIDs whose config has a mount whose storage backs the ejected
|
||||
|
||||
@@ -52,14 +52,21 @@ func (f *fakeDiskOps) Unmount(_ context.Context, where string) error {
|
||||
func (f *fakeDiskOps) formatted() []string { f.mu.Lock(); defer f.mu.Unlock(); return append([]string(nil), f.formatCalls...) }
|
||||
|
||||
type fakeGate struct {
|
||||
allowed bool
|
||||
reason string
|
||||
calls []string
|
||||
mu sync.Mutex
|
||||
decision WipeDecision
|
||||
reqs []WipeRequest
|
||||
}
|
||||
|
||||
func (g *fakeGate) AuthorizeWipe(device string) (bool, string) {
|
||||
g.calls = append(g.calls, device)
|
||||
return g.allowed, g.reason
|
||||
func (g *fakeGate) AuthorizeWipe(req WipeRequest) WipeDecision {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.reqs = append(g.reqs, req)
|
||||
return g.decision
|
||||
}
|
||||
func (g *fakeGate) requests() []WipeRequest {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return append([]WipeRequest(nil), g.reqs...)
|
||||
}
|
||||
|
||||
type fakeGuestList struct{ guests []proxmox.Guest }
|
||||
@@ -96,7 +103,7 @@ func newDiskServer(t *testing.T, d *fakeDiskOps, g *fakeGate, sv StorageView, gl
|
||||
// A blank device (the agent's own probe says blank) is formatted — mkfs called, gate NOT consulted.
|
||||
func TestFormat_BlankDevice_Formats(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}} // blank: Probed, nothing set
|
||||
g := &fakeGate{allowed: false, reason: "pending_signature"}
|
||||
g := &fakeGate{}
|
||||
h := newDiskServer(t, d, g, nil, nil)
|
||||
|
||||
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
|
||||
@@ -106,39 +113,106 @@ func TestFormat_BlankDevice_Formats(t *testing.T) {
|
||||
if got := d.formatted(); len(got) != 1 || got[0] != "/dev/sdb" {
|
||||
t.Fatalf("mkfs not called for blank device: %v", got)
|
||||
}
|
||||
if len(g.calls) != 0 {
|
||||
if len(g.requests()) != 0 {
|
||||
t.Fatal("gate was consulted for a blank-device format (should be benign)")
|
||||
}
|
||||
}
|
||||
|
||||
// THE HEADLINE 8C TEST: a caller asks to format a DATA-BEARING device. The agent inspects the
|
||||
// device itself, classifies it destructive, the gate refuses pending_signature, and **mkfs is
|
||||
// NEVER called** — the caller's intent cannot wipe data-bearing storage.
|
||||
func TestFormat_DataBearingDevice_RefusedNoMkfs(t *testing.T) {
|
||||
// THE HEADLINE SECURITY TEST: a caller asks to format a DATA-BEARING device the gate tiers as
|
||||
// SYSTEM/BACKUP (destructive). The gate refuses pending_signature, **mkfs is NEVER called**, and the
|
||||
// response surfaces the operator-signature pending op (NOT a customer-confirmation prompt).
|
||||
func TestFormat_DataBearing_ProtectedRole_RefusedNoMkfs(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
|
||||
g := &fakeGate{allowed: false, reason: "pending_signature"}
|
||||
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}}
|
||||
h := newDiskServer(t, d, g, nil, nil)
|
||||
|
||||
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("data-bearing format: got %d want 403", w.Code)
|
||||
t.Fatalf("data-bearing protected format: got %d want 403", w.Code)
|
||||
}
|
||||
if got := d.formatted(); len(got) != 0 {
|
||||
t.Fatalf("mkfs WAS called on a data-bearing device — security invariant violated: %v", got)
|
||||
t.Fatalf("mkfs WAS called on a protected data-bearing device — security invariant violated: %v", got)
|
||||
}
|
||||
if len(g.calls) != 1 || g.calls[0] != "/dev/sdb" {
|
||||
t.Fatalf("gate not consulted for the destructive format: %v", g.calls)
|
||||
if len(g.requests()) != 1 {
|
||||
t.Fatalf("gate not consulted for the destructive format: %v", g.requests())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "operator signature") {
|
||||
t.Fatalf("response did not signal an operator signature is needed: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A hand-issued confirmed:true on a data-bearing device the gate tiers as SYSTEM/BACKUP is STILL
|
||||
// refused (operator-signature) and mkfs is NOT called — role beats confirmation. (The gate's
|
||||
// role-tiering is itself asserted in reconcile/gate_test; here we assert the handler honors a
|
||||
// destructive verdict even when the caller claimed confirmed:true.)
|
||||
func TestFormat_DataBearing_ConfirmedTrueButProtected_StillRefused(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
|
||||
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}}
|
||||
h := newDiskServer(t, d, g, nil, nil)
|
||||
|
||||
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4","confirmed":true,"durable_id":"byid:wwn-xyz"}`)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("confirmed-but-protected format: got %d want 403", w.Code)
|
||||
}
|
||||
if got := d.formatted(); len(got) != 0 {
|
||||
t.Fatalf("mkfs called despite protected role — confirmation must not beat role: %v", got)
|
||||
}
|
||||
// The handler must have forwarded the caller's confirmation claim to the gate (the gate, not the
|
||||
// handler, ignores it for protected roles).
|
||||
if reqs := g.requests(); len(reqs) != 1 || !reqs[0].Confirmed || reqs[0].ConfirmDurableID != "byid:wwn-xyz" {
|
||||
t.Fatalf("handler did not forward the confirmation claim to the gate: %+v", reqs)
|
||||
}
|
||||
}
|
||||
|
||||
// USER-DATA + customer-confirmed (gate Allowed): the wipe proceeds — mkfs IS called, 200.
|
||||
func TestFormat_DataBearing_UserDataConfirmed_Formats(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
|
||||
g := &fakeGate{decision: WipeDecision{Allowed: true, Tier: "customer_confirmable", Reason: "customer_confirmed"}}
|
||||
h := newDiskServer(t, d, g, nil, nil)
|
||||
|
||||
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4","confirmed":true,"durable_id":"byuuid:abc"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("user-data confirmed format: got %d want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if got := d.formatted(); len(got) != 1 || got[0] != "/dev/sdb" {
|
||||
t.Fatalf("mkfs not called for a customer-confirmed user-data wipe: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// USER-DATA + NOT yet confirmed (gate refuses pending_confirmation): 403 with needs_confirmation,
|
||||
// mkfs NOT called, and NO operator-signature pending op (it's the customer's to confirm).
|
||||
func TestFormat_DataBearing_UserDataNeedsConfirmation(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
|
||||
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "customer_confirmable", Reason: "pending_confirmation", NeedsConfirmation: true}}
|
||||
h := newDiskServer(t, d, g, nil, nil)
|
||||
|
||||
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("user-data unconfirmed: got %d want 403", w.Code)
|
||||
}
|
||||
if len(d.formatted()) != 0 {
|
||||
t.Fatal("mkfs called for an unconfirmed user-data wipe")
|
||||
}
|
||||
var resp struct {
|
||||
Data FormatResponse `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if !resp.Data.NeedsConfirmation {
|
||||
t.Fatalf("response missing needs_confirmation: %s", w.Body.String())
|
||||
}
|
||||
if resp.Data.PendingOp != nil {
|
||||
t.Fatal("user-data refusal must NOT surface an operator-signature pending op")
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "operator signature") {
|
||||
t.Fatalf("user-data refusal must not ask for an operator signature: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Fail-safe: a device the agent could NOT reliably inspect (Probed=false) is treated as
|
||||
// data-bearing → refused, mkfs not called.
|
||||
// data-bearing → routed through the gate, mkfs not called.
|
||||
func TestFormat_AmbiguousProbe_TreatedDestructive(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: false}} // probe failed → DataBearing()=true
|
||||
g := &fakeGate{allowed: false, reason: "pending_signature"}
|
||||
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}}
|
||||
h := newDiskServer(t, d, g, nil, nil)
|
||||
|
||||
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
|
||||
@@ -150,21 +224,6 @@ func TestFormat_AmbiguousProbe_TreatedDestructive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Even a (hypothetical) gate that ALLOWS does not format a data-bearing device in 8C (the signed
|
||||
// completion is slice 10).
|
||||
func TestFormat_DataBearing_GateAllows_StillNoMkfsIn8C(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasPartitionTable: true}}
|
||||
g := &fakeGate{allowed: true, reason: "signed"}
|
||||
h := newDiskServer(t, d, g, nil, nil)
|
||||
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("got %d want 403 (8C never formats data-bearing)", w.Code)
|
||||
}
|
||||
if len(d.formatted()) != 0 {
|
||||
t.Fatal("mkfs called on a data-bearing device even though 8C must refuse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_RejectsBadDeviceOrFSType(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}}
|
||||
h := newDiskServer(t, d, &fakeGate{}, nil, nil)
|
||||
|
||||
@@ -49,6 +49,23 @@ const (
|
||||
Benign Disposition = "benign"
|
||||
// Destructive — an operator signature bound to the action is REQUIRED.
|
||||
Destructive Disposition = "destructive"
|
||||
// CustomerConfirmable — a USER-DATA storage wipe: authorized by the customer's informed-
|
||||
// confirmation bound to the device's durable id, NOT an operator signature. This tier is
|
||||
// reachable ONLY for ClassStorageWipe on an agent-classified user-data device (see
|
||||
// Gate.AuthorizeStorageWipe). A user-data drive is already within the in-guest controller's
|
||||
// blast radius (it bind-mounts /mnt), so customer-confirmation adds no new reach — the data was
|
||||
// already destroyable. Every OTHER destructive class keeps the operator signature.
|
||||
CustomerConfirmable Disposition = "customer_confirmable"
|
||||
)
|
||||
|
||||
// Storage ROLE values — the protection tier the AGENT assigns a device by its own inspection
|
||||
// (mirrors storage.DeviceRole). Kept as plain strings here to avoid a storage→reconcile import edge:
|
||||
// the localapi adapter passes the agent's classification through verbatim. The gate NEVER derives the
|
||||
// role from a caller's claim — it only consumes the agent's verdict.
|
||||
const (
|
||||
StorageRoleSystem = "system"
|
||||
StorageRoleBackup = "backup"
|
||||
StorageRoleUserData = "user-data"
|
||||
)
|
||||
|
||||
// Provenance is AGENT-INTERNAL evidence that an otherwise-destructive action is
|
||||
|
||||
@@ -53,7 +53,11 @@ const (
|
||||
ReasonPendingSignature RefuseReason = "pending_signature" // destructive, no/again-needed signature
|
||||
ReasonRejected RefuseReason = "rejected" // signature failed authz verification
|
||||
ReasonRoleDenied RefuseReason = "role_denied" // signer role not authorized for this op class
|
||||
ReasonBindingMismatch RefuseReason = "binding_mismatch" // signature is for a different action
|
||||
ReasonBindingMismatch RefuseReason = "binding_mismatch" // signature/confirmation is for a different action/device
|
||||
|
||||
// Storage-wipe customer-confirmable tier (user-data only).
|
||||
ReasonCustomerConfirmed RefuseReason = "customer_confirmed" // allowed by the customer's durable-id-bound confirmation
|
||||
ReasonPendingConfirmation RefuseReason = "pending_confirmation" // user-data wipe awaiting the customer's confirmation
|
||||
)
|
||||
|
||||
// Decision is the gate verdict.
|
||||
@@ -94,6 +98,9 @@ type AuditRecord struct {
|
||||
Reason RefuseReason
|
||||
KeyID string // matched signer's key id, when signed
|
||||
Nonce string // the op nonce, when signed
|
||||
// DurableID is the device's durable id on a customer-confirmable storage wipe — the load-bearing
|
||||
// audit detail ("who/what/when + the durable id") so a customer-authorized wipe is attributable.
|
||||
DurableID string
|
||||
}
|
||||
|
||||
// Gate is the reversibility gate: it sits in front of the per-guest queue's executor
|
||||
@@ -172,6 +179,75 @@ func (g *Gate) Authorize(intent Intent, signed *SignedOp) Decision {
|
||||
return d
|
||||
}
|
||||
|
||||
// StorageWipeAuthz is the input to AuthorizeStorageWipe. Role is the agent's AUTHORITATIVE
|
||||
// classification (StorageRole* in classify.go) — never a caller's claim. DeviceDurableID is the
|
||||
// durable id the AGENT re-resolved for the device. Confirmed/ConfirmDurableID are the customer's
|
||||
// informed-confirmation, honored ONLY for user-data and ONLY when ConfirmDurableID matches the agent's
|
||||
// DeviceDurableID (a confirmation for one disk can't wipe another).
|
||||
type StorageWipeAuthz struct {
|
||||
HostID string
|
||||
Role string // StorageRoleSystem | StorageRoleBackup | StorageRoleUserData
|
||||
TargetID string // storage target identity for the operator-signed (system/backup) binding
|
||||
DeviceDurableID string // agent-re-resolved durable id of the device ("" if unresolvable)
|
||||
Confirmed bool
|
||||
ConfirmDurableID string
|
||||
}
|
||||
|
||||
// AuthorizeStorageWipe tiers a data-bearing storage wipe by the agent's role verdict:
|
||||
//
|
||||
// - user-data → CUSTOMER-CONFIRMABLE: allowed iff the request carries an explicit customer
|
||||
// confirmation BOUND to the device's durable id (the agent re-resolved DeviceDurableID and it
|
||||
// matches ConfirmDurableID). No operator signature. The wipe is recorded in the customer-visible
|
||||
// audit log with the durable id.
|
||||
// - system / backup / anything else → the standard DESTRUCTIVE path (operator-signature /
|
||||
// pending_signature). The Confirmed flag is DELIBERATELY IGNORED: no customer confirmation can
|
||||
// wipe the appliance's system storage or the backup safety-net. A compromised controller
|
||||
// asserting confirmed:true is refused here BY ROLE — role is the agent's, never the caller's.
|
||||
//
|
||||
// signed is the operator signature for the system/backup path (nil on the inline format path → always
|
||||
// pending_signature; the signed completion runs via the signed-jobs runner).
|
||||
func (g *Gate) AuthorizeStorageWipe(in StorageWipeAuthz, signed *SignedOp) Decision {
|
||||
if in.Role == StorageRoleUserData {
|
||||
if !in.Confirmed {
|
||||
d := Decision{Allowed: false, Disposition: CustomerConfirmable, Reason: ReasonPendingConfirmation}
|
||||
g.recordWipe(in, d)
|
||||
return d
|
||||
}
|
||||
// Durable-id binding: the confirmation must name THIS exact device (re-resolved by the agent).
|
||||
// An unresolved/empty id fails safe (refuse), and a mismatch can't be retargeted to another disk.
|
||||
if in.DeviceDurableID == "" || in.ConfirmDurableID == "" || in.ConfirmDurableID != in.DeviceDurableID {
|
||||
d := Decision{Allowed: false, Disposition: CustomerConfirmable, Reason: ReasonBindingMismatch}
|
||||
g.recordWipe(in, d)
|
||||
return d
|
||||
}
|
||||
d := Decision{Allowed: true, Disposition: CustomerConfirmable, Reason: ReasonCustomerConfirmed}
|
||||
g.recordWipe(in, d)
|
||||
return d
|
||||
}
|
||||
// system / backup / unknown → operator-signature path. Confirmed is NOT consulted.
|
||||
intent := IntentForStorageDestructive(ClassStorageWipe, in.HostID, in.TargetID, nil, SourceOneShotJob)
|
||||
return g.Authorize(intent, signed)
|
||||
}
|
||||
|
||||
// recordWipe audits a customer-confirmable storage-wipe decision (allowed or refused) with the
|
||||
// device's durable id — the customer-visible "who/what/when + durable id" record.
|
||||
func (g *Gate) recordWipe(in StorageWipeAuthz, d Decision) {
|
||||
g.audit.Record(AuditRecord{
|
||||
Time: time.Now().UTC(),
|
||||
Class: ClassStorageWipe,
|
||||
HostID: in.HostID,
|
||||
GuestID: in.TargetID,
|
||||
Source: SourceOneShotJob,
|
||||
Disposition: d.Disposition,
|
||||
Allowed: d.Allowed,
|
||||
Reason: d.Reason,
|
||||
DurableID: in.DeviceDurableID,
|
||||
})
|
||||
g.logger.Info("gate decision (storage wipe)",
|
||||
"role", in.Role, "disposition", d.Disposition, "allowed", d.Allowed,
|
||||
"reason", d.Reason, "durable_id", in.DeviceDurableID)
|
||||
}
|
||||
|
||||
// roleAuthorizes enforces the doc 04 §4 two-key role model: the cold recovery key
|
||||
// authorizes ONLY key-rotation re-pins; the operational key authorizes ordinary
|
||||
// destructive ops AND planned key-rotation.
|
||||
@@ -278,7 +354,7 @@ func (s SlogAudit) Record(rec AuditRecord) {
|
||||
s.Logger.Info("audit: gate decision",
|
||||
"class", rec.Class, "host", rec.HostID, "guest", rec.GuestID, "source", rec.Source,
|
||||
"disposition", rec.Disposition, "allowed", rec.Allowed, "reason", rec.Reason,
|
||||
"key_id", rec.KeyID, "nonce", auditNonce(rec.Nonce))
|
||||
"key_id", rec.KeyID, "nonce", auditNonce(rec.Nonce), "durable_id", rec.DurableID)
|
||||
}
|
||||
|
||||
// auditNonce shortens a nonce for the log (full nonce is high-cardinality; a prefix is
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package reconcile
|
||||
|
||||
import "testing"
|
||||
|
||||
// The storage-authorization redesign: a USER-DATA data-bearing wipe is customer-confirmable
|
||||
// (durable-id-bound, no operator signature); SYSTEM and BACKUP stay operator-signature ONLY, and a
|
||||
// `confirmed:true` claim on them is REFUSED by role (a compromised controller can't relabel a
|
||||
// protected device to walk the gate). These assert the gate's tiering — the non-hollow checks the
|
||||
// spec calls for: "the gate refuses a `confirmed` wipe on system/backup (assert no exec); a user-data
|
||||
// confirmed wipe binds to the durable id (a mismatched id is refused)".
|
||||
|
||||
const durA = "byid:wwn-0x5000c500a"
|
||||
const durB = "byuuid:1111-2222"
|
||||
|
||||
// user-data + confirmed + matching durable id → allowed (customer-confirmable), audited with the id.
|
||||
func TestStorageWipe_UserDataConfirmed_Allowed(t *testing.T) {
|
||||
aud := &captureAudit{}
|
||||
g := NewGate(nil, testHost, aud, nil) // NO verifier pinned — proves no operator signature is needed
|
||||
d := g.AuthorizeStorageWipe(StorageWipeAuthz{
|
||||
HostID: testHost, Role: StorageRoleUserData,
|
||||
DeviceDurableID: durA, Confirmed: true, ConfirmDurableID: durA,
|
||||
}, nil)
|
||||
if !d.Allowed || d.Disposition != CustomerConfirmable || d.Reason != ReasonCustomerConfirmed {
|
||||
t.Fatalf("user-data confirmed+matching: got allowed=%v disp=%s reason=%s", d.Allowed, d.Disposition, d.Reason)
|
||||
}
|
||||
if len(aud.recs) != 1 || !aud.recs[0].Allowed || aud.recs[0].DurableID != durA {
|
||||
t.Fatalf("customer-confirmed wipe must be audited with the durable id: %+v", aud.recs)
|
||||
}
|
||||
}
|
||||
|
||||
// user-data + confirmed but the confirmation binds to a DIFFERENT disk's durable id → refused
|
||||
// (binding_mismatch). A confirmation for one disk can't wipe another.
|
||||
func TestStorageWipe_UserDataDurableMismatch_Refused(t *testing.T) {
|
||||
g := NewGate(nil, testHost, &captureAudit{}, nil)
|
||||
d := g.AuthorizeStorageWipe(StorageWipeAuthz{
|
||||
HostID: testHost, Role: StorageRoleUserData,
|
||||
DeviceDurableID: durA, Confirmed: true, ConfirmDurableID: durB, // confirms B, device is A
|
||||
}, nil)
|
||||
if d.Allowed || d.Reason != ReasonBindingMismatch {
|
||||
t.Fatalf("durable-id mismatch: got allowed=%v reason=%s, want refused binding_mismatch", d.Allowed, d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// user-data + an UNRESOLVABLE device durable id fails safe (refused) even when confirmed.
|
||||
func TestStorageWipe_UserDataNoDurable_Refused(t *testing.T) {
|
||||
g := NewGate(nil, testHost, &captureAudit{}, nil)
|
||||
d := g.AuthorizeStorageWipe(StorageWipeAuthz{
|
||||
HostID: testHost, Role: StorageRoleUserData,
|
||||
DeviceDurableID: "", Confirmed: true, ConfirmDurableID: "",
|
||||
}, nil)
|
||||
if d.Allowed {
|
||||
t.Fatal("a wipe with no resolvable durable id must not be allowed even when confirmed")
|
||||
}
|
||||
}
|
||||
|
||||
// user-data, NOT confirmed → pending_confirmation (ask the customer; not a signature).
|
||||
func TestStorageWipe_UserDataUnconfirmed_PendingConfirmation(t *testing.T) {
|
||||
g := NewGate(nil, testHost, &captureAudit{}, nil)
|
||||
d := g.AuthorizeStorageWipe(StorageWipeAuthz{
|
||||
HostID: testHost, Role: StorageRoleUserData, DeviceDurableID: durA,
|
||||
}, nil)
|
||||
if d.Allowed || d.Disposition != CustomerConfirmable || d.Reason != ReasonPendingConfirmation {
|
||||
t.Fatalf("user-data unconfirmed: got allowed=%v disp=%s reason=%s", d.Allowed, d.Disposition, d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// THE HEADLINE: a `confirmed:true` wipe on a SYSTEM device is REFUSED — it falls to the
|
||||
// operator-signature path (no signer pinned → pending_signature), NOT customer-confirmable. The
|
||||
// customer's confirmation is ignored BY ROLE. Same for BACKUP.
|
||||
func TestStorageWipe_SystemConfirmedTrue_RefusedBySignature(t *testing.T) {
|
||||
for _, role := range []string{StorageRoleSystem, StorageRoleBackup} {
|
||||
g := NewGate(nil, testHost, &captureAudit{}, nil)
|
||||
d := g.AuthorizeStorageWipe(StorageWipeAuthz{
|
||||
HostID: testHost, Role: role,
|
||||
DeviceDurableID: durA, Confirmed: true, ConfirmDurableID: durA, // a matching confirmation — must NOT help
|
||||
}, nil)
|
||||
if d.Allowed {
|
||||
t.Fatalf("role=%s: a confirmed wipe of a protected device was ALLOWED — invariant violated", role)
|
||||
}
|
||||
if d.Disposition != Destructive || d.Reason != ReasonPendingSignature {
|
||||
t.Fatalf("role=%s: got disp=%s reason=%s, want destructive/pending_signature", role, d.Disposition, d.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An UNKNOWN/empty role fails safe to the protected (operator-signature) path, never user-data.
|
||||
func TestStorageWipe_UnknownRole_FailsSafeDestructive(t *testing.T) {
|
||||
g := NewGate(nil, testHost, &captureAudit{}, nil)
|
||||
d := g.AuthorizeStorageWipe(StorageWipeAuthz{
|
||||
HostID: testHost, Role: "", Confirmed: true, DeviceDurableID: durA, ConfirmDurableID: durA,
|
||||
}, nil)
|
||||
if d.Allowed || d.Disposition != Destructive {
|
||||
t.Fatalf("unknown role must fail safe to destructive: got allowed=%v disp=%s", d.Allowed, d.Disposition)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
)
|
||||
|
||||
// Device ROLE classification — the protection tier the AGENT assigns a storage/device by its OWN
|
||||
// inspection (the PVE storage view + the host mount/topology reads it already gathers), NEVER from
|
||||
// the controller's or the hub's claim. This is the storage analog of classify.go's data-bearing
|
||||
// verdict: "provenance is agent-internal, never populated from an external input, else a compromised
|
||||
// hub could relabel a protected device to walk the gate."
|
||||
//
|
||||
// The tier decides who may authorize a destructive wipe of the device:
|
||||
// - system : the appliance's OS/boot/EFI/guest-rootfs storage. Operator-signature ONLY.
|
||||
// - backup : the backup safety-net (PBS). Operator-signature ONLY (wiping it destroys the net).
|
||||
// - user-data : a customer external data drive — already within the controller's blast radius
|
||||
// (it bind-mounts /mnt), so a customer informed-confirmation may authorize a wipe.
|
||||
//
|
||||
// On ANY ambiguity the agent defaults to the MOST-PROTECTED role (system) — consistent with the
|
||||
// destructive-on-ambiguity invariant: an unrecognized device is treated as protected, never silently
|
||||
// user-data.
|
||||
type DeviceRole string
|
||||
|
||||
const (
|
||||
RoleSystem DeviceRole = "system"
|
||||
RoleBackup DeviceRole = "backup"
|
||||
RoleUserData DeviceRole = "user-data"
|
||||
)
|
||||
|
||||
// reWholeDisk matches a whole raw disk path (no partition suffix): /dev/sda, /dev/vdb, /dev/nvme0n1.
|
||||
var reWholeDisk = regexp.MustCompile(`^/dev/(?:sd|hd|vd)[a-z]+$|^/dev/nvme[0-9]+n[0-9]+$`)
|
||||
|
||||
// systemMountPoints are the host mountpoints whose backing whole-disk is, by definition, the OS /
|
||||
// system disk. /boot and /boot/efi are the load-bearing ones: on a typical Proxmox/Debian install
|
||||
// the ESP is a raw partition directly on the OS disk, so it pins the OS whole-disk even when / is on
|
||||
// LVM/device-mapper (which we cannot trace back to a raw disk without privileged LVM introspection).
|
||||
var systemMountPoints = map[string]bool{"/": true, "/boot": true, "/boot/efi": true}
|
||||
|
||||
// SystemDisks resolves the set of whole-disk device paths that host the OS (the disks backing /,
|
||||
// /boot and /boot/efi). ok=false when NONE could be resolved (no system mountpoint mapped to a raw
|
||||
// disk) — callers then treat every candidate as system (most protected). Root-free: it parses the
|
||||
// mount table + world-readable /dev symlinks only (the root-CLI fence is untouched).
|
||||
func SystemDisks(host HostReader) (set map[string]bool, ok bool) {
|
||||
if host == nil {
|
||||
return nil, false
|
||||
}
|
||||
mounts, err := host.Mounts()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
set = map[string]bool{}
|
||||
for _, m := range mounts {
|
||||
if !systemMountPoints[cleanMountPath(m.MountPoint)] {
|
||||
continue
|
||||
}
|
||||
if wd, wok := wholeDiskOf(m.Device); wok {
|
||||
set[wd] = true
|
||||
}
|
||||
}
|
||||
return set, len(set) > 0
|
||||
}
|
||||
|
||||
// wholeDiskOf maps a device path (a partition, a whole disk, or a /dev/disk/by-* symlink) to its
|
||||
// whole-disk /dev path. ok=false when the result is not a recognizable raw disk (device-mapper / LVM
|
||||
// / network) — the caller then treats the topology as undeterminable (→ most-protected).
|
||||
func wholeDiskOf(device string) (string, bool) {
|
||||
if device == "" {
|
||||
return "", false
|
||||
}
|
||||
dev := device
|
||||
if resolved, err := filepath.EvalSymlinks(device); err == nil {
|
||||
dev = resolved // canonicalize /dev/disk/by-uuid/… → /dev/sdXN
|
||||
}
|
||||
if m := reNVMePart.FindStringSubmatch(dev); m != nil {
|
||||
return m[1], true // /dev/nvme0n1p2 → /dev/nvme0n1
|
||||
}
|
||||
if m := reSDPart.FindStringSubmatch(dev); m != nil {
|
||||
return m[1], true // /dev/sdb1 → /dev/sdb
|
||||
}
|
||||
if reWholeDisk.MatchString(dev) {
|
||||
return dev, true // already a whole disk
|
||||
}
|
||||
return "", false // /dev/mapper/*, network, unrecognized → undeterminable
|
||||
}
|
||||
|
||||
// isSystemBacked reports whether device's whole-disk is an OS/system disk. It FAILS SAFE: any
|
||||
// ambiguity (system set unknown, or the device's whole-disk unrecognizable) returns true (system).
|
||||
func isSystemBacked(device string, sysDisks map[string]bool, sysKnown bool) bool {
|
||||
if !sysKnown {
|
||||
return true // can't determine the system disks → treat as system (most protected)
|
||||
}
|
||||
wd, ok := wholeDiskOf(device)
|
||||
if !ok {
|
||||
return true // unrecognizable device topology → most protected
|
||||
}
|
||||
return sysDisks[wd]
|
||||
}
|
||||
|
||||
// RoleForStorage classifies a storage TARGET (from the agent's storage view) into its protection
|
||||
// tier. typ is the reported storage type; backingDevice is its resolved block device ("" for
|
||||
// network/lvm/dir-on-root). sysDisks/sysKnown come from SystemDisks (resolved once per request).
|
||||
func RoleForStorage(typ, backingDevice string, sysDisks map[string]bool, sysKnown bool) DeviceRole {
|
||||
switch typ {
|
||||
case hub.StorageTypePBS:
|
||||
return RoleBackup // the backup safety-net — protected
|
||||
case hub.StorageTypeUSB, hub.StorageTypeLocalDir:
|
||||
// A removable/extra dir storage is user-data ONLY when it has its OWN block device that is
|
||||
// NOT part of the system disk. No device (a dir on the root fs) or a system-disk-backed dir
|
||||
// → system. This is exactly "local-dir/usb on a non-root external device → user-data".
|
||||
if backingDevice == "" {
|
||||
return RoleSystem
|
||||
}
|
||||
if isSystemBacked(backingDevice, sysDisks, sysKnown) {
|
||||
return RoleSystem
|
||||
}
|
||||
return RoleUserData
|
||||
default:
|
||||
// local (builtin, on the root fs), lvmthin (local-lvm), lvm (thick), nfs, cifs, and anything
|
||||
// unrecognized → protected. Network shares are NOT customer-managed external drives in this
|
||||
// model and are not within the controller's blast radius, so protecting them is safe.
|
||||
return RoleSystem
|
||||
}
|
||||
}
|
||||
|
||||
// RoleForRawDevice classifies a RAW block device (the format endpoint's target, which may not yet be
|
||||
// a registered PVE storage — e.g. a fresh external disk in the init flow). It distinguishes system
|
||||
// from user-data by system-disk membership. A raw /dev path is never a PBS datastore (those are
|
||||
// network/API), so the backup tier is not reachable here — the storage-list view (RoleForStorage)
|
||||
// tiers PBS. Defaults to system on ambiguity.
|
||||
func RoleForRawDevice(device string, sysDisks map[string]bool, sysKnown bool) DeviceRole {
|
||||
if isSystemBacked(device, sysDisks, sysKnown) {
|
||||
return RoleSystem
|
||||
}
|
||||
return RoleUserData
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
)
|
||||
|
||||
// Role classification is AGENT-AUTHORITATIVE (the agent's own storage view + host topology). These
|
||||
// assert the demo storages map to the right protection tier, and that ambiguity fails safe to the
|
||||
// MOST-PROTECTED role (system) — never silently user-data.
|
||||
|
||||
// demoHost models the demo N100: the OS disk /dev/sda (ESP at /dev/sda1, root at /dev/sda2) plus an
|
||||
// external data disk /dev/sdb (felhom-usb at /dev/sdb1).
|
||||
func demoHost() *fakeHostReader {
|
||||
return &fakeHostReader{
|
||||
mounts: []Mount{
|
||||
{Device: "/dev/sda2", MountPoint: "/", FSType: "ext4"},
|
||||
{Device: "/dev/sda1", MountPoint: "/boot/efi", FSType: "vfat"},
|
||||
{Device: "/dev/sdb1", MountPoint: "/mnt/hdd_1", FSType: "ext4"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemDisks_FromBootMounts(t *testing.T) {
|
||||
sys, ok := SystemDisks(demoHost())
|
||||
if !ok {
|
||||
t.Fatal("SystemDisks should resolve the OS disk from / and /boot/efi")
|
||||
}
|
||||
if !sys["/dev/sda"] {
|
||||
t.Fatalf("OS whole-disk /dev/sda not in system set: %v", sys)
|
||||
}
|
||||
if sys["/dev/sdb"] {
|
||||
t.Fatalf("external data disk /dev/sdb wrongly classified as system: %v", sys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleForStorage_DemoMapping(t *testing.T) {
|
||||
sys, ok := SystemDisks(demoHost())
|
||||
cases := []struct {
|
||||
name string
|
||||
typ string
|
||||
device string
|
||||
want DeviceRole
|
||||
}{
|
||||
{"builtin local (root fs)", hub.StorageTypeLocal, "", RoleSystem},
|
||||
{"local-lvm (lvmthin)", hub.StorageTypeLVMThin, "", RoleSystem},
|
||||
{"felhom-pbs (backup net)", hub.StorageTypePBS, "", RoleBackup},
|
||||
{"nfs share", hub.StorageTypeNFS, "", RoleSystem},
|
||||
{"felhom-usb on external /dev/sdb1", hub.StorageTypeUSB, "/dev/sdb1", RoleUserData},
|
||||
{"local-dir on external /dev/sdb1", hub.StorageTypeLocalDir, "/dev/sdb1", RoleUserData},
|
||||
{"usb-typed but ON the system disk", hub.StorageTypeUSB, "/dev/sda2", RoleSystem},
|
||||
{"local-dir with no device (on root)", hub.StorageTypeLocalDir, "", RoleSystem},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := RoleForStorage(c.typ, c.device, sys, ok); got != c.want {
|
||||
t.Errorf("%s: got role %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleForRawDevice_SystemVsUserData(t *testing.T) {
|
||||
sys, ok := SystemDisks(demoHost())
|
||||
if got := RoleForRawDevice("/dev/sdb1", sys, ok); got != RoleUserData {
|
||||
t.Errorf("external /dev/sdb1: got %q, want user-data", got)
|
||||
}
|
||||
if got := RoleForRawDevice("/dev/sda2", sys, ok); got != RoleSystem {
|
||||
t.Errorf("system /dev/sda2: got %q, want system", got)
|
||||
}
|
||||
// A partition on the OS disk is treated as system (protected), not user-data.
|
||||
if got := RoleForRawDevice("/dev/sda3", sys, ok); got != RoleSystem {
|
||||
t.Errorf("OS-disk partition /dev/sda3: got %q, want system", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Ambiguity fails safe: if the system disks cannot be determined, EVERY candidate is system.
|
||||
func TestRole_FailsSafeWhenSystemUnknown(t *testing.T) {
|
||||
noMounts := &fakeHostReader{mountsErr: errFake}
|
||||
sys, ok := SystemDisks(noMounts)
|
||||
if ok {
|
||||
t.Fatal("SystemDisks should report not-ok when mounts cannot be read")
|
||||
}
|
||||
if got := RoleForRawDevice("/dev/sdb1", sys, ok); got != RoleSystem {
|
||||
t.Errorf("unknown system disks → external device must default to system, got %q", got)
|
||||
}
|
||||
if got := RoleForStorage(hub.StorageTypeUSB, "/dev/sdb1", sys, ok); got != RoleSystem {
|
||||
t.Errorf("unknown system disks → usb target must default to system, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
var errFake = &fakeErr{}
|
||||
|
||||
type fakeErr struct{}
|
||||
|
||||
func (*fakeErr) Error() string { return "fake" }
|
||||
Reference in New Issue
Block a user