From feab92ccfc78e03c534b02068f4bc88f5c8d1b3c Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 1 Jul 2026 18:04:49 +0200 Subject: [PATCH] v0.95.0: enrollment wizards use the raw-device scan /disks/candidates (Impl-2b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 25 ++++++++++++ controller/README.md | 11 +++++ controller/internal/agentapi/client.go | 34 ++++++++++++++++ controller/internal/agentapi/disks_test.go | 40 +++++++++++++++++++ .../internal/web/agent_disk_handlers.go | 21 ++++++++++ .../web/templates/storage_attach.html | 39 ++++++++++-------- .../internal/web/templates/storage_init.html | 37 +++++++++-------- 7 files changed, 174 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7599fa..721f795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ ## Changelog +### v0.95.0 — enrollment wizards use the raw-device scan `/disks/candidates` (Impl-2b) (2026-07-01) + +Final drive-enrollment piece: both enrollment wizards now source candidates from the agent's Impl-2a +raw-device scan instead of the `Observe()`-based `/api/disks` list — so a brand-new (non-PVE-storage) +drive is finally visible + enrollable end-to-end. The enroll flow (`runStorageInit`/`runStorageAttach`) +and the Impl-1 guarded `mkfs` are UNCHANGED; the wizards just get the right candidate list. + +- **`internal/agentapi/client.go`:** `ListCandidates(ctx) (CandidatesResult, error)` → agent + `GET /disks/candidates`; types `CandidatesResult{Initialize,Attach []DiskCandidate}` + + `DiskCandidate{Device,SizeBytes,Model,FSType,DataBearing,Mountable,MountSource,DurableID}` mirroring + the agent's `candidates.go`. +- **`internal/web/agent_disk_handlers.go`:** `GET /api/disks/candidates` proxy + (`agentDiskCandidatesHandler`, copy of `agentDisksListHandler`) — passthrough, NO controller-side + filtering (the agent's unclaimed-disk filter is authoritative + fail-safe). +- **`templates/storage_init.html`:** fetch `/api/disks/candidates` → render the `initialize` list + (model/size/current-FS + a data-bearing marker); dropped the client-side "already-managed" filter + (the server list already excludes OS/enrolled/claimed disks). Data-bearing → the existing wipe-confirm. +- **`templates/storage_attach.html`:** fetch `/api/disks/candidates` → render the `attach` list + (mountable-FS disks); selecting posts the FS-bearing node + its fstype to the existing + `/api/storage/attach` (mount + bind, NO format). +- **TOCTOU:** the wizard trusts the agent's Impl-1 `Format` guard as the backstop (re-checks unclaimed at + format time), not the list's freshness — a device claimed between scan and enroll is refused. +- Tests: `agentapi` `TestListCandidates` + `_Error`. `go build/vet/test ./...` clean. Live end-to-end + raw enrollment of `/dev/sdd` validated through the real UI (see REPORT). + ### v0.94.0 — pull-based config-refresh (re-pull controller.yaml + self-restart on a config change) (2026-06-30) Config delivery is now pull-based, riding the report ACK exactly like the Phase 2 version floor — the hub diff --git a/controller/README.md b/controller/README.md index bc9510e..843dd11 100644 --- a/controller/README.md +++ b/controller/README.md @@ -852,6 +852,17 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an The storage subsystem handles the full lifecycle of external storage: detection, initialization, path registration, and data migration. +> **CURRENT (post-de-privileging + Impl-2b, v0.95.0):** the in-guest storage code below +> (`internal/storage/scan.go`, `format.go`, `attach.go`) is **retired** — all disk ops are delegated to +> the host agent via `internal/agentapi`. The two enrollment wizards (`/settings/storage/init`, +> `/settings/storage/attach`) now populate candidates from the agent's **raw-device scan** +> (`GET /api/disks/candidates` → agent Impl-2a, proxied by `agentDiskCandidatesHandler`): `initialize` = +> every unclaimed disk (blank or data-bearing), `attach` = the mountable-FS subset. The agent's +> unclaimed-disk filter (Impl-1 `claim.go`) is authoritative + fail-safe (never offers OS/enrolled/claimed +> disks), so the controller does NO client- or server-side filtering. Enrollment posts to the unchanged +> `/api/storage/init` (format via the agent's Impl-1 guarded `mkfs` → mount → bind → intent) or +> `/api/storage/attach` (mount + bind, no format). The legacy text below is kept for historical context. + #### Disk Scanning (`internal/storage/scan.go`) - `ScanDisks()` uses `lsblk -J -b` for block device enumeration diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index 17393b0..7289079 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -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{ diff --git a/controller/internal/agentapi/disks_test.go b/controller/internal/agentapi/disks_test.go index 7a95daf..482d0ce 100644 --- a/controller/internal/agentapi/disks_test.go +++ b/controller/internal/agentapi/disks_test.go @@ -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() diff --git a/controller/internal/web/agent_disk_handlers.go b/controller/internal/web/agent_disk_handlers.go index 9ef7fcf..a64d975 100644 --- a/controller/internal/web/agent_disk_handlers.go +++ b/controller/internal/web/agent_disk_handlers.go @@ -28,6 +28,8 @@ func (s *Server) ServeDiskAPI(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/api/disks" && r.Method == http.MethodGet: s.agentDisksListHandler(w, r) + case r.URL.Path == "/api/disks/candidates" && r.Method == http.MethodGet: + s.agentDiskCandidatesHandler(w, r) case r.URL.Path == "/api/disks/assign" && r.Method == http.MethodPost: s.agentDiskAssignHandler(w, r) case r.URL.Path == "/api/disks/eject" && r.Method == http.MethodPost: @@ -110,6 +112,25 @@ func (s *Server) agentDisksListHandler(w http.ResponseWriter, r *http.Request) { writeDiskJSON(w, http.StatusOK, true, "", resp) } +// agentDiskCandidatesHandler proxies GET /api/disks/candidates → agent GET /disks/candidates (Impl-2b): +// the raw-device scan (Impl-2a) that feeds the enrollment wizards. The agent's unclaimed-disk filter +// already excludes claimed/OS/enrolled disks (fail-safe), so the controller passes the list through +// untouched — no controller-side filtering. +func (s *Server) agentDiskCandidatesHandler(w http.ResponseWriter, r *http.Request) { + client, err := s.agentClient() + if err != nil { + writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) + return + } + resp, err := client.ListCandidates(r.Context()) + if err != nil { + s.logger.Printf("[ERROR] [web] disk candidates via agent failed: %v", err) + writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) + return + } + writeDiskJSON(w, http.StatusOK, true, "", resp) +} + // sortDisksForView orders the agent's disk list deterministically (user-data → system → backup → // unrecognized; alphabetical by storage name within each tier). A stable Go-side contract beats // relying on map iteration order or template JS alone. diff --git a/controller/internal/web/templates/storage_attach.html b/controller/internal/web/templates/storage_attach.html index 3717cca..fba4ae2 100644 --- a/controller/internal/web/templates/storage_attach.html +++ b/controller/internal/web/templates/storage_attach.html @@ -51,27 +51,34 @@