v0.95.0: enrollment wizards use the raw-device scan /disks/candidates (Impl-2b)
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>
This commit is contained in:
@@ -347,6 +347,40 @@ func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DiskCandidate mirrors one entry from the agent's GET /disks/candidates (Impl-2a candidates.go) —
|
||||
// a host disk the agent's unclaimed-disk filter proved is FREE for Felhom to enroll.
|
||||
type DiskCandidate struct {
|
||||
Device string `json:"device"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
Model string `json:"model,omitempty"`
|
||||
FSType string `json:"fstype,omitempty"`
|
||||
DataBearing bool `json:"data_bearing"`
|
||||
Mountable bool `json:"mountable"`
|
||||
MountSource string `json:"mount_source,omitempty"`
|
||||
DurableID string `json:"durable_id,omitempty"`
|
||||
}
|
||||
|
||||
// CandidatesResult mirrors GET /disks/candidates: disks free to enroll, split into initialize (all
|
||||
// unclaimed) and attach (the mountable-FS subset).
|
||||
type CandidatesResult struct {
|
||||
VMID int `json:"vmid"`
|
||||
Initialize []DiskCandidate `json:"initialize"`
|
||||
Attach []DiskCandidate `json:"attach"`
|
||||
}
|
||||
|
||||
// ListCandidates fetches the host disks free for Felhom to enroll (Impl-2b wizard source).
|
||||
func (c *Client) ListCandidates(ctx context.Context) (CandidatesResult, error) {
|
||||
var out CandidatesResult
|
||||
body, err := c.get(ctx, "/disks/candidates")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode /disks/candidates: %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{
|
||||
|
||||
@@ -15,6 +15,12 @@ func diskStub(t *testing.T) (*httptest.Server, string) {
|
||||
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"}}`))
|
||||
})
|
||||
@@ -71,6 +77,40 @@ func TestDisks_List(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user