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 <noreply@anthropic.com>
This commit is contained in:
@@ -174,6 +174,99 @@ func (c *Client) BackupStatus(ctx context.Context) (StatusResponse, error) {
|
|||||||
return out, nil
|
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.
|
// get issues an authenticated GET and unwraps the {ok,data,error} envelope.
|
||||||
func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error) {
|
func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user