From 02945139069d2e0415f48b1099b1cb83c8a402c3 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 10 Jun 2026 13:23:00 +0200 Subject: [PATCH] slice 8C Phase B.1: agentapi disk client (Disks/AssignDisk/EjectDisk/FormatDisk) ErrFormatRefused surfaces the agent's data-bearing refusal distinctly. Tests: list, blank format OK, data-bearing refused, eject dependents. Co-Authored-By: Claude Opus 4.8 --- controller/internal/agentapi/client.go | 93 ++++++++++++++++++++++ controller/internal/agentapi/disks_test.go | 92 +++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 controller/internal/agentapi/disks_test.go diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index bdc5154..8102f5e 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -174,6 +174,99 @@ func (c *Client) BackupStatus(ctx context.Context) (StatusResponse, error) { return out, nil } +// ---- slice 8C: disk management (execution is the agent's) -------------------------------- + +// DiskInfo mirrors the agent's GET /disks entry. +type DiskInfo struct { + Name string `json:"name"` + Type string `json:"type"` + State string `json:"state"` + BackingDevice string `json:"backing_device"` + MountPath string `json:"mount_path"` + Class string `json:"class"` + DataBearing bool `json:"data_bearing"` + DataReason string `json:"data_reason"` +} + +// DisksResponse mirrors GET /disks. +type DisksResponse struct { + VMID int `json:"vmid"` + Disks []DiskInfo `json:"disks"` +} + +// 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"` +} + +// 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)") + +// Disks lists the host drives the agent manages, with a data-bearing flag per drive. +func (c *Client) Disks(ctx context.Context) (DisksResponse, error) { + var out DisksResponse + body, err := c.get(ctx, "/disks") + if err != nil { + return out, err + } + if err := json.Unmarshal(body, &out); err != nil { + return out, fmt.Errorf("agentapi: decode /disks: %w", err) + } + return out, nil +} + +// AssignDisk attaches a drive (by fs-UUID) as a host mount (benign, self-serve). +func (c *Client) AssignDisk(ctx context.Context, uuid, where, fstype, options string) error { + _, err := c.post(ctx, "/disks/assign", map[string]string{ + "uuid": uuid, "where": where, "fstype": fstype, "options": options, + }) + return err +} + +// EjectResult mirrors POST /disks/eject (the dependent-guest warning). +type EjectResult struct { + VMID int `json:"vmid"` + Ejected string `json:"ejected"` + DependentGuests []int `json:"dependent_guests"` +} + +// EjectDisk safe-unmounts a host mount (data preserved) and returns the dependent guests. +func (c *Client) EjectDisk(ctx context.Context, where string) (EjectResult, error) { + var out EjectResult + body, err := c.post(ctx, "/disks/eject", map[string]string{"where": where}) + if err != nil { + return out, err + } + if err := json.Unmarshal(body, &out); err != nil { + return out, fmt.Errorf("agentapi: decode /disks/eject: %w", err) + } + 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) { + var out FormatResult + body, err := c.post(ctx, "/disks/format", map[string]string{"device": device, "fstype": fstype}) + if err != nil { + // A data-bearing refusal comes back as HTTP 403 (the post helper turns it into an error). + if strings.Contains(err.Error(), "HTTP 403") { + return FormatResult{Device: device, Formatted: false, DataBearing: true}, ErrFormatRefused + } + return out, err + } + if err := json.Unmarshal(body, &out); err != nil { + return out, fmt.Errorf("agentapi: decode /disks/format: %w", err) + } + return out, nil +} + // get issues an authenticated GET and unwraps the {ok,data,error} envelope. func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) diff --git a/controller/internal/agentapi/disks_test.go b/controller/internal/agentapi/disks_test.go new file mode 100644 index 0000000..d9bfdc8 --- /dev/null +++ b/controller/internal/agentapi/disks_test.go @@ -0,0 +1,92 @@ +package agentapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func diskStub(t *testing.T) (*httptest.Server, string) { + mux := http.NewServeMux() + mux.HandleFunc("GET /disks", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":8200,"disks":[{"name":"bulk","data_bearing":true,"data_reason":"has ext4"}]}}`)) + }) + mux.HandleFunc("POST /disks/assign", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true,"data":{"assigned":"/mnt/data"}}`)) + }) + mux.HandleFunc("POST /disks/eject", func(w http.ResponseWriter, r *http.Request) { + _, _ = 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 + } + _, _ = w.Write([]byte(`{"ok":true,"data":{"device":"` + body.Device + `","formatted":true}}`)) + }) + s := httptest.NewTLSServer(mux) + return s, strings.TrimPrefix(s.URL, "https://") +} + +func decodeJSON(r *http.Request, v any) error { + return json.NewDecoder(r.Body).Decode(v) +} + +func clientFor(t *testing.T, s *httptest.Server, endpoint string) *Client { + pin := leafPin(t, s) + c, err := New(endpoint, "TOK", pin) + if err != nil { + t.Fatal(err) + } + return c +} + +func TestDisks_List(t *testing.T) { + s, ep := diskStub(t) + defer s.Close() + c := clientFor(t, s, ep) + resp, err := c.Disks(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(resp.Disks) != 1 || !resp.Disks[0].DataBearing { + t.Fatalf("unexpected: %+v", resp) + } +} + +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") + if err != nil || !res.Formatted { + t.Fatalf("blank format: %v %+v", err, res) + } +} + +func TestFormat_DataBearingRefused(t *testing.T) { + s, ep := diskStub(t) + defer s.Close() + c := clientFor(t, s, ep) + _, err := c.FormatDisk(context.Background(), "/dev/data-disk", "ext4") + if !errors.Is(err, ErrFormatRefused) { + t.Fatalf("expected ErrFormatRefused, got %v", err) + } +} + +func TestEject_Dependents(t *testing.T) { + s, ep := diskStub(t) + defer s.Close() + c := clientFor(t, s, ep) + res, err := c.EjectDisk(context.Background(), "/mnt/bulk") + if err != nil || len(res.DependentGuests) != 1 || res.DependentGuests[0] != 8200 { + t.Fatalf("eject: %v %+v", err, res) + } +}