feab92ccfc
Both wizards now source candidates from the agent's Impl-2a raw-device scan (GET /disks/candidates, proxied) instead of the Observe-based /api/disks — so a brand-new non-PVE-storage drive is finally discoverable + enrollable end-to-end. agentapi.ListCandidates + a passthrough proxy (no controller-side filtering; the agent's unclaimed filter is authoritative). storage_init renders `initialize`, storage_attach renders `attach`; the enroll flow + Impl-1 guarded mkfs unchanged. Tests + go build/vet/test clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
194 lines
7.3 KiB
Go
194 lines
7.3 KiB
Go
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("GET /disks/candidates", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":8200,` +
|
|
`"initialize":[{"device":"/dev/sdd","size_bytes":64000000000,"model":"USB","fstype":"ext4","data_bearing":true,"mountable":true,"mount_source":"/dev/sdd","durable_id":"uuid:abc"},` +
|
|
`{"device":"/dev/sde","size_bytes":1000,"data_bearing":false,"mountable":false}],` +
|
|
`"attach":[{"device":"/dev/sdd","fstype":"ext4","mountable":true,"mount_source":"/dev/sdd","durable_id":"uuid:abc"}]}}`))
|
|
})
|
|
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, 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"}}`))
|
|
case strings.Contains(body.Device, "mounted"): // mkfs failed (e.g. device mounted) → agent 502, data:null
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
_, _ = w.Write([]byte(`{"ok":false,"error":"format failed: /dev/sdb1 is mounted; will not make a filesystem here!","data":null}`))
|
|
default: // blank, or user-data confirmed
|
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"device":"` + body.Device + `","formatted":true,"role":"user-data"}}`))
|
|
}
|
|
})
|
|
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 TestListCandidates(t *testing.T) {
|
|
s, ep := diskStub(t)
|
|
defer s.Close()
|
|
c := clientFor(t, s, ep)
|
|
res, err := c.ListCandidates(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Initialize) != 2 {
|
|
t.Fatalf("want 2 initialize candidates, got %+v", res.Initialize)
|
|
}
|
|
if len(res.Attach) != 1 || res.Attach[0].Device != "/dev/sdd" || res.Attach[0].FSType != "ext4" {
|
|
t.Fatalf("attach candidate wrong: %+v", res.Attach)
|
|
}
|
|
if res.Initialize[0].DurableID != "uuid:abc" || !res.Initialize[0].DataBearing {
|
|
t.Fatalf("initialize[0] fields wrong: %+v", res.Initialize[0])
|
|
}
|
|
}
|
|
|
|
func TestListCandidates_Error(t *testing.T) {
|
|
// A non-2xx / malformed agent response surfaces as an error, not a silent empty list.
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /disks/candidates", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
_, _ = w.Write([]byte(`{"ok":false,"error":"agent unreachable"}`))
|
|
})
|
|
s := httptest.NewTLSServer(mux)
|
|
defer s.Close()
|
|
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
|
if _, err := c.ListCandidates(context.Background()); err == nil {
|
|
t.Fatal("expected an error from a 502 candidates response")
|
|
}
|
|
}
|
|
|
|
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", false, "")
|
|
if err != nil || !res.Formatted {
|
|
t.Fatalf("blank format: %v %+v", err, res)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// F20-BUG1: a real mkfs failure (agent 502, ok:false, data:null) must surface as a non-nil error —
|
|
// NOT a zero-value FormatResult with nil err (which read as a silent SUCCESS in the web layer).
|
|
func TestFormat_MountedFailureSurfacesError(t *testing.T) {
|
|
s, ep := diskStub(t)
|
|
defer s.Close()
|
|
c := clientFor(t, s, ep)
|
|
res, err := c.FormatDisk(context.Background(), "/dev/sdb1-mounted", "ext4", true, "byid:wwn-1")
|
|
if err == nil {
|
|
t.Fatalf("expected a non-nil error for a failed format, got (res=%+v, err=nil) — silent success regression", res)
|
|
}
|
|
if res.Formatted {
|
|
t.Fatalf("Formatted must be false on a failed format: %+v", res)
|
|
}
|
|
if !strings.Contains(err.Error(), "502") || !strings.Contains(err.Error(), "mounted") {
|
|
t.Fatalf("error should carry the HTTP status + agent message, got: %v", err)
|
|
}
|
|
// Must not be misclassified as one of the gated refusals.
|
|
if errors.Is(err, ErrNeedsConfirmation) || errors.Is(err, ErrFormatRefused) {
|
|
t.Fatalf("a 502 mkfs failure must not be reported as a refusal: %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)
|
|
}
|
|
}
|