package web import ( "encoding/json" "errors" "net/http" "sort" "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/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 builds a pinned client for the host agent's per-guest local API. // Returns a clear error if the local API is not configured (unprovisioned guest). func (s *Server) agentClient() (*agentapi.Client, error) { if s.cfg.LocalAPI.Endpoint == "" { return nil, errors.New("agent not configured") } return agentapi.New(s.cfg.LocalAPI.Endpoint, s.cfg.LocalAPI.Token, s.cfg.LocalAPI.Fingerprint) } // 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{}) { 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, err.Error(), 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, err.Error(), 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) } // 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, err.Error(), 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, err.Error(), 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, err.Error(), 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, err.Error(), 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, err.Error(), 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, err.Error(), nil) return } writeDiskJSON(w, http.StatusOK, true, "", resp) }