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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user