Files
felhom-controller/controller/internal/web/agent_disk_handlers.go
T
admin f65ace0ca6
gates / gates (push) Successful in 1m2s
R-542: the disk candidates no longer offer a registered, in-use drive
The format wizard rendered the agent's initialize list as-is, and the
agent deliberately allows re-initialising Felhom's own drives, so a
registered data drive was offered for formatting. The controller proxy
now drops every candidate that backs a registered storage path (joined
through the guest mount table) from both lists; an unreadable mount
table empties initialize.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 20:52:15 +02:00

379 lines
16 KiB
Go

package web
import (
"context"
"encoding/json"
"errors"
"net/http"
"path"
"sort"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// Agent-backed disk management (slice 8C, Phase B.2).
//
// Disk EXECUTION (scan/format/mount/migrate) lives in the host agent now; the
// controller is Docker-only and holds no Proxmox/disk credentials. These handlers
// are THIN proxies: they build an agentapi.Client from cfg.LocalAPI and forward
// list/assign/eject/format to the agent's GET/POST /disks endpoints, returning the
// agent's view as JSON. A data-bearing format is refused by the agent (operator
// authorization required) and surfaced here as HTTP 409.
// ServeDiskAPI dispatches /api/disks and /api/disks/* routes.
// Wired in main.go behind RequireAuth + CsrfProtect.
func (s *Server) ServeDiskAPI(w http.ResponseWriter, r *http.Request) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] ServeDiskAPI: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
}
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:
s.agentDiskEjectHandler(w, r)
case r.URL.Path == "/api/disks/format" && r.Method == http.MethodPost:
s.agentDiskFormatHandler(w, r)
default:
http.NotFound(w, r)
}
}
// agentClient returns the shared pinned client for the host agent's per-guest local API.
// Returns a clear error if the local API is not configured (unprovisioned guest).
//
// The client is built ONCE and reused (cfg.LocalAPI is static per process — a config-apply triggers a
// graceful self-restart). Reusing one *agentapi.Client (one pooled http.Transport) eliminates the
// per-call Transport leak that accumulated idle ESTABLISHED sockets to the agent's :8443 and exhausted
// the ephemeral source-port range after ~5 days of uptime. *agentapi.Client/*http.Client are safe for
// concurrent use, so the (19) callers need no extra locking. The empty-endpoint guard stays OUTSIDE the
// Once so it is re-checked each call (defensive; the endpoint is static); only a real build result
// (client, or a construction error like a bad fingerprint — neither changes without a restart) is memoized.
func (s *Server) agentClient() (*agentapi.Client, error) {
if s.cfg.LocalAPI.Endpoint == "" {
return nil, errors.New("agent not configured")
}
s.agentCliOnce.Do(func() {
s.agentCli, s.agentCliErr = agentapi.New(
s.cfg.LocalAPI.Endpoint, s.cfg.LocalAPI.Token, s.cfg.LocalAPI.Fingerprint)
if s.agentCliErr == nil {
// v0.116.0: per-call DEBUG traces into the capture ring (method/path/status/duration).
s.agentCli.SetLogger(s.logger)
}
})
return s.agentCli, s.agentCliErr
}
// ProbeAgentChannel runs one controller→agent channel health probe using the PRODUCTION memoized
// client (NOT a fresh one — spike SPIKE-controller-agent-channel-health-2026-06-29: it self-heals,
// reflects exactly what the disk UI sees, and avoids the per-call transport leak the singleton fixed).
// It returns whether the failure was a CONSTRUCTION error (agentClient() couldn't build — a latching
// config fault, distinct from a runtime channel failure) and the error (nil = channel up). The probe
// is GET /storage (cheap, read-only — the same call probeLocalAPI uses at startup). This is the
// channelhealth.Probe seam.
func (s *Server) ProbeAgentChannel(ctx context.Context) (constructionErr bool, err error) {
client, cerr := s.agentClient()
if cerr != nil {
return true, cerr
}
_, serr := client.Storage(ctx)
return false, serr
}
// writeDiskJSON writes the standard {ok,data,error} envelope used by the disk API.
func writeDiskJSON(w http.ResponseWriter, status int, ok bool, errMsg string, data interface{}) {
// Cloudflare replaces origin 502/504 bodies with its OWN HTML error page (no passthru on
// this plan) — the page JS then fails to parse JSON and the customer sees a SyntaxError
// alert instead of the Hungarian message (live-hit 2026-07-13: the M1 decommission refusal
// surfaced as "<!DOCTYPE ... is not valid JSON"). App-level JSON errors must never leave
// the origin as 502/504; 500 carries the body through the edge intact.
if status == http.StatusBadGateway || status == http.StatusGatewayTimeout {
status = http.StatusInternalServerError
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
resp := map[string]interface{}{"ok": ok}
if errMsg != "" {
resp["error"] = errMsg
}
if data != nil {
resp["data"] = data
}
_ = json.NewEncoder(w).Encode(resp)
}
// agentDisksListHandler proxies GET /api/disks → agent GET /disks.
func (s *Server) agentDisksListHandler(w http.ResponseWriter, r *http.Request) {
client, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
return
}
resp, err := client.Disks(r.Context())
if err != nil {
s.logger.Printf("[ERROR] [web] disk list via agent failed: %v", err)
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
return
}
// Deterministic order: the agent's storage view iterates a Go map (unordered), so the list would
// otherwise reorder on every reload (CLAUDE.md lesson #3). The customer's manageable drives go on
// top, in a stable order: user-data first, then system, then backup, alpha by name within a tier.
sortDisksForView(resp.Disks)
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 disks (fail-safe), and the system/backup drives it hides from the format
// wizard stay hidden. It deliberately does NOT exclude Felhom's own drives, so the controller removes
// every drive backing a REGISTERED storage path from both lists (R-542, dropRegisteredDrives) — the
// one filter the controller adds; it only ever removes entries.
//
// R-280: `attach` additionally carries the controller's own mounted-but-unregistered filesystems.
// The agent's scan alone left a rebuilt box with an empty picker under a sentence promising „két
// kattintás", because the drive that must be re-registered is an in-guest filesystem no host-disk
// scan can see. Attaching is non-destructive, so this list is additive by nature — it can only ever
// offer MORE places to put data back, never a new way to erase any. Why the union rather than a
// replacement: the agent's entries serve the case this endpoint was built for — a fresh external
// drive that already carries a filesystem and is not yet mounted — which the mount table cannot
// report precisely because it is not mounted. Dropping them would fix the reinstall and break the USB.
func (s *Server) agentDiskCandidatesHandler(w http.ResponseWriter, r *http.Request) {
client, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), 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, s.errText(r, err), nil)
return
}
mounts := readMountTable()
resp = dropRegisteredDrives(resp, mounts, s.registeredStoragePaths())
writeDiskJSON(w, http.StatusOK, true, "", mergeAttachCandidates(resp, s.attachableStores()))
}
// dropRegisteredDrives (R-542) removes from BOTH lists every agent candidate that backs a REGISTERED
// storage path. The agent deliberately lets Felhom re-initialise its own drives (a mount under
// /mnt/felhom-drives is not a foreign claim — felhom-agent internal/storage/claim.go), so a drive the
// household registered and uses came back under `initialize` — the format wizard (storage_init.html)
// renders that list as-is. Measured 2026-09-16 on a fresh box: the registered default data drive was
// offered for formatting and again under `attach`.
//
// The join is the guest's own mount table: a registered path's mount SOURCE is the host device
// (measured on demo-hp 2026-10-06: `/dev/nvme0n1 /mnt/felhom-drives/hdd_1`), which is the candidate's
// Device (whole disk) or MountSource (partition).
//
// ⚠ FAIL-SAFE: an unreadable mount table cannot prove any candidate is NOT in use, so `initialize`
// comes back EMPTY (the wizard then says no drive is available) — never the unfiltered list. `attach`
// is non-destructive and is left as the agent sent it in that case.
func dropRegisteredDrives(resp agentapi.CandidatesResult, mountsText string, registered map[string]bool) agentapi.CandidatesResult {
if strings.TrimSpace(mountsText) == "" {
resp.Initialize = []agentapi.DiskCandidate{}
return resp
}
inUse := map[string]bool{}
for _, row := range parseMountTable(mountsText) {
if registered[path.Clean(row[1])] && strings.HasPrefix(row[0], "/dev/") {
inUse[row[0]] = true
}
}
if len(inUse) == 0 {
return resp
}
keep := func(in []agentapi.DiskCandidate) []agentapi.DiskCandidate {
out := make([]agentapi.DiskCandidate, 0, len(in))
for _, c := range in {
if candidateBacksInUse(c, inUse) {
continue
}
out = append(out, c)
}
return out
}
resp.Initialize = keep(resp.Initialize)
resp.Attach = keep(resp.Attach)
return resp
}
// candidateBacksInUse reports whether a candidate's disk is a source in inUse: the whole disk itself,
// its mountable partition, or any partition of it (/dev/sdb1, /dev/nvme0n1p1 under /dev/sdb, /dev/nvme0n1).
func candidateBacksInUse(c agentapi.DiskCandidate, inUse map[string]bool) bool {
if inUse[c.Device] || (c.MountSource != "" && inUse[c.MountSource]) {
return true
}
if c.Device == "" {
return false
}
for src := range inUse {
rest, ok := strings.CutPrefix(src, c.Device)
if !ok || rest == "" {
continue
}
rest = strings.TrimPrefix(rest, "p")
if rest != "" && strings.Trim(rest, "0123456789") == "" {
return true
}
}
return false
}
// mergeAttachCandidates adds the mounted-but-unregistered stores to `attach` and returns the result.
// `initialize` is passed through untouched — the ONE line that keeps the format wizard's protection
// intact, and the reason this is a separate function rather than two appends at the call site: it can
// be tested, and a change to it fails a test instead of shipping.
func mergeAttachCandidates(resp agentapi.CandidatesResult, stores []mountedStore) agentapi.CandidatesResult {
resp.Attach = append(resp.Attach, mountedStoreCandidates(stores)...)
return resp
}
// mountedStoreCandidates renders mounted-but-unregistered stores in the picker's shape. MountSource
// carries the mountpoint (the thing the register action needs); Device is display only.
func mountedStoreCandidates(stores []mountedStore) []agentapi.DiskCandidate {
out := make([]agentapi.DiskCandidate, 0, len(stores))
for _, m := range stores {
out = append(out, agentapi.DiskCandidate{
Device: m.Device,
FSType: m.FSType,
MountSource: m.Path,
DataBearing: true,
Mountable: true,
// Size is deliberately absent: measuring it means statfs on a possibly-wedged device
// inside a request handler, and a picker entry is actionable without it.
AlreadyMounted: true,
})
}
return out
}
// 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.
func sortDisksForView(disks []agentapi.DiskInfo) {
sort.SliceStable(disks, func(i, j int) bool {
if ri, rj := diskRoleRank(disks[i].Role), diskRoleRank(disks[j].Role); ri != rj {
return ri < rj
}
return disks[i].Name < disks[j].Name
})
}
// diskRoleRank ranks a role for the overview ordering (lower sorts first).
func diskRoleRank(role string) int {
switch role {
case "user-data":
return 0
case "system":
return 1
case "backup":
return 2
default:
return 3
}
}
// agentDiskAssignHandler proxies POST /api/disks/assign → agent POST /disks/assign.
func (s *Server) agentDiskAssignHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
UUID string `json:"uuid"`
Where string `json:"where"`
FSType string `json:"fstype"`
Options string `json:"options"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "invalid request body", nil)
return
}
if req.UUID == "" || req.Where == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "uuid and where are required", nil)
return
}
client, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
return
}
if err := client.AssignDisk(r.Context(), req.UUID, req.Where, req.FSType, req.Options); err != nil {
s.logger.Printf("[ERROR] [web] disk assign via agent failed: %v", err)
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]interface{}{
"uuid": req.UUID, "where": req.Where,
})
}
// agentDiskEjectHandler proxies POST /api/disks/eject → agent POST /disks/eject.
func (s *Server) agentDiskEjectHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Where string `json:"where"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "invalid request body", nil)
return
}
if req.Where == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "where is required", nil)
return
}
client, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
return
}
resp, err := client.EjectDisk(r.Context(), req.Where)
if err != nil {
s.logger.Printf("[ERROR] [web] disk eject via agent failed: %v", err)
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", resp)
}
// agentDiskFormatHandler proxies POST /api/disks/format → agent POST /disks/format.
// A data-bearing format refusal (ErrFormatRefused) is surfaced as HTTP 409 so the UI
// can show "operator authorization required".
func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Device string `json:"device"`
FSType string `json:"fstype"`
Confirmed bool `json:"confirmed"`
DurableID string `json:"durable_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "invalid request body", nil)
return
}
if req.Device == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "device is required", nil)
return
}
client, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
return
}
resp, err := client.FormatDisk(r.Context(), req.Device, req.FSType, req.Confirmed, req.DurableID)
if errors.Is(err, agentapi.ErrNeedsConfirmation) {
s.logger.Printf("[INFO] [web] disk format needs customer confirmation (user-data): %s", req.Device)
writeDiskJSON(w, http.StatusConflict, false, "customer confirmation required", resp)
return
}
if errors.Is(err, agentapi.ErrFormatRefused) {
s.logger.Printf("[WARN] [web] disk format refused by agent (system/backup-protected): %s", req.Device)
writeDiskJSON(w, http.StatusConflict, false, "operator authorization required", resp)
return
}
if err != nil {
s.logger.Printf("[ERROR] [web] disk format via agent failed: %v", err)
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", resp)
}