v0.44.0: role-aware drive management — protected lockout + customer type-to-confirm wipe + drive-list restyle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:44:50 +02:00
parent 2c32c821fe
commit 12064dcd88
13 changed files with 696 additions and 182 deletions
+53 -20
View File
@@ -184,8 +184,15 @@ type DiskInfo struct {
BackingDevice string `json:"backing_device"`
MountPath string `json:"mount_path"`
Class string `json:"class"`
DataBearing bool `json:"data_bearing"`
DataReason string `json:"data_reason"`
// Role is the agent's AUTHORITATIVE protection tier: "system" | "backup" | "user-data". The UI
// is driven from it — system/backup get a lock badge and NO destructive controls; user-data is
// customer-manageable (eject/wipe with type-to-confirm).
Role string `json:"role"`
DataBearing bool `json:"data_bearing"`
DataReason string `json:"data_reason"`
TotalBytes int64 `json:"total_bytes"`
UsedBytes int64 `json:"used_bytes"`
UsedFraction float64 `json:"used_fraction"`
// DurableID is the target's stable identity (e.g. "uuid:<fs-uuid>" for usb/local-dir). The
// fs UUID (strip the "uuid:" prefix) is the key the controller passes to AssignDisk — it's the
// only way the de-privileged controller learns a mount key it cannot read off the device itself.
@@ -209,12 +216,19 @@ type DisksResponse struct {
// FormatResult mirrors POST /disks/format (the success/refusal payload).
type FormatResult struct {
VMID int `json:"vmid"`
Device string `json:"device"`
Formatted bool `json:"formatted"`
DataBearing bool `json:"data_bearing"`
Reason string `json:"reason"`
PendingOp *PendingOp `json:"pending_op,omitempty"`
VMID int `json:"vmid"`
Device string `json:"device"`
Formatted bool `json:"formatted"`
DataBearing bool `json:"data_bearing"`
Reason string `json:"reason"`
// Role is the agent's tier for this device (system | backup | user-data).
Role string `json:"role,omitempty"`
// NeedsConfirmation is set on a USER-DATA data-bearing refusal: re-submit with confirmed=true +
// DurableID after the type-to-confirm UI (NOT an operator signature).
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
DurableID string `json:"durable_id,omitempty"`
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the operator-signature op.
PendingOp *PendingOp `json:"pending_op,omitempty"`
}
// PendingOp mirrors the agent's bound destructive intent on a data-bearing refusal. The controller
@@ -231,9 +245,14 @@ func (p PendingOp) OpsignCommand() string {
return fmt.Sprintf("felhom-opsign -op %s -host %s -durable-id %s", p.Op, p.HostScope, p.DurableID)
}
// ErrFormatRefused is returned by FormatDisk when the agent refuses a data-bearing format
// (pending operator authorization — the 8C invariant). The UI surfaces this distinctly.
var ErrFormatRefused = fmt.Errorf("agentapi: format refused — device is data-bearing (operator authorization required)")
// ErrFormatRefused is returned by FormatDisk when the agent refuses a data-bearing format on a
// SYSTEM/BACKUP device (operator signature required). The UI surfaces the pending opsign command.
var ErrFormatRefused = fmt.Errorf("agentapi: format refused — system/backup device (operator authorization required)")
// ErrNeedsConfirmation is returned by FormatDisk when the agent refuses a data-bearing format on a
// USER-DATA device pending the CUSTOMER's informed-confirmation (bound to FormatResult.DurableID).
// The UI surfaces the type-to-confirm flow, then re-submits with confirmed=true + that durable id.
var ErrNeedsConfirmation = fmt.Errorf("agentapi: format needs customer confirmation — user-data device")
// Disks lists the host drives the agent manages, with a data-bearing flag per drive.
func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
@@ -276,14 +295,22 @@ func (c *Client) EjectDisk(ctx context.Context, where string) (EjectResult, erro
return out, nil
}
// FormatDisk asks the agent to format a device. The AGENT inspects the device and decides
// data-bearing-ness — a data-bearing device is refused (ErrFormatRefused), the controller's claim
// is irrelevant. Only a device the agent reads as blank is formatted.
func (c *Client) FormatDisk(ctx context.Context, device, fstype string) (FormatResult, error) {
// FormatDisk asks the agent to format a device. The AGENT inspects the device and tiers it by ROLE
// (its own classification, never the controller's claim):
// - blank device → formatted.
// - user-data, data-bearing, NOT confirmed → ErrNeedsConfirmation (out.DurableID = the id to confirm).
// - user-data, data-bearing, confirmed + matching durable id → formatted.
// - system/backup, data-bearing → ErrFormatRefused (out.PendingOp = the operator opsign command).
//
// confirmed + durableID authorize a user-data wipe (the durable id the agent gave on the prior
// ErrNeedsConfirmation); they are inert for system/backup.
func (c *Client) FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (FormatResult, error) {
var out FormatResult
// Status-aware POST: the agent returns the FULL FormatResponse (incl. pending_op) even on the
// 403 refusal, so we must read the body on non-2xx rather than discarding it.
data, status, err := c.postWithStatus(ctx, "/disks/format", map[string]string{"device": device, "fstype": fstype})
// Status-aware POST: the agent returns the FULL FormatResponse (incl. pending_op / durable_id)
// even on the 403 refusal, so we must read the body on non-2xx rather than discarding it.
data, status, err := c.postWithStatus(ctx, "/disks/format", map[string]any{
"device": device, "fstype": fstype, "confirmed": confirmed, "durable_id": durableID,
})
if err != nil {
return out, err
}
@@ -291,10 +318,16 @@ func (c *Client) FormatDisk(ctx context.Context, device, fstype string) (FormatR
if len(data) > 0 {
_ = json.Unmarshal(data, &out) // best-effort; fields default on a missing/partial body
}
if out.Formatted {
return out, nil
}
if out.NeedsConfirmation {
out.DataBearing = true
return out, ErrNeedsConfirmation // user-data: surface the type-to-confirm flow
}
if status == http.StatusForbidden || (out.DataBearing && !out.Formatted) {
out.DataBearing = true
out.Formatted = false
return out, ErrFormatRefused // carries PendingOp for the caller to surface the opsign command
return out, ErrFormatRefused // system/backup: surface the opsign command
}
return out, nil
}
+46 -10
View File
@@ -22,14 +22,21 @@ func diskStub(t *testing.T) (*httptest.Server, string) {
_, _ = w.Write([]byte(`{"ok":true,"data":{"ejected":"/mnt/bulk","dependent_guests":[8200]}}`))
})
mux.HandleFunc("POST /disks/format", func(w http.ResponseWriter, r *http.Request) {
var body struct{ Device, FSType string }
_ = decodeJSON(r, &body)
if strings.Contains(body.Device, "data") {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"ok":false,"error":"device is data-bearing — operator authorization (pending_signature)"}`))
return
var body struct {
Device, FSType, DurableID string
Confirmed bool
}
_ = decodeJSON(r, &body)
switch {
case strings.Contains(body.Device, "protected"): // system/backup → operator signature
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"ok":false,"data":{"device":"` + body.Device + `","data_bearing":true,"role":"system","pending_op":{"op":"storage_wipe","host_scope":"h","durable_id":"byid:x","fstype":"ext4"}}}`))
case strings.Contains(body.Device, "data") && !body.Confirmed: // user-data, not yet confirmed
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"ok":false,"data":{"device":"` + body.Device + `","data_bearing":true,"role":"user-data","needs_confirmation":true,"durable_id":"byid:wwn-1"}}`))
default: // blank, or user-data confirmed
_, _ = w.Write([]byte(`{"ok":true,"data":{"device":"` + body.Device + `","formatted":true,"role":"user-data"}}`))
}
_, _ = w.Write([]byte(`{"ok":true,"data":{"device":"` + body.Device + `","formatted":true}}`))
})
s := httptest.NewTLSServer(mux)
return s, strings.TrimPrefix(s.URL, "https://")
@@ -65,20 +72,49 @@ func TestFormat_BlankOK(t *testing.T) {
s, ep := diskStub(t)
defer s.Close()
c := clientFor(t, s, ep)
res, err := c.FormatDisk(context.Background(), "/dev/sdb", "ext4")
res, err := c.FormatDisk(context.Background(), "/dev/sdb", "ext4", false, "")
if err != nil || !res.Formatted {
t.Fatalf("blank format: %v %+v", err, res)
}
}
func TestFormat_DataBearingRefused(t *testing.T) {
// SYSTEM/BACKUP data-bearing → ErrFormatRefused with the operator pending op.
func TestFormat_ProtectedRefused(t *testing.T) {
s, ep := diskStub(t)
defer s.Close()
c := clientFor(t, s, ep)
_, err := c.FormatDisk(context.Background(), "/dev/data-disk", "ext4")
res, err := c.FormatDisk(context.Background(), "/dev/protected-disk", "ext4", false, "")
if !errors.Is(err, ErrFormatRefused) {
t.Fatalf("expected ErrFormatRefused, got %v", err)
}
if res.PendingOp == nil {
t.Fatal("protected refusal should carry the operator pending op")
}
}
// USER-DATA data-bearing, not confirmed → ErrNeedsConfirmation with the durable id to confirm against.
func TestFormat_UserDataNeedsConfirmation(t *testing.T) {
s, ep := diskStub(t)
defer s.Close()
c := clientFor(t, s, ep)
res, err := c.FormatDisk(context.Background(), "/dev/data-disk", "ext4", false, "")
if !errors.Is(err, ErrNeedsConfirmation) {
t.Fatalf("expected ErrNeedsConfirmation, got %v", err)
}
if !res.NeedsConfirmation || res.DurableID != "byid:wwn-1" || res.Role != "user-data" {
t.Fatalf("needs-confirmation payload not surfaced: %+v", res)
}
}
// USER-DATA data-bearing, confirmed + durable id → formatted.
func TestFormat_UserDataConfirmed(t *testing.T) {
s, ep := diskStub(t)
defer s.Close()
c := clientFor(t, s, ep)
res, err := c.FormatDisk(context.Background(), "/dev/data-disk", "ext4", true, "byid:wwn-1")
if err != nil || !res.Formatted {
t.Fatalf("confirmed user-data format: %v %+v", err, res)
}
}
func TestEject_Dependents(t *testing.T) {