package web import ( "encoding/json" "errors" "net/http" "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 } writeDiskJSON(w, http.StatusOK, true, "", resp) } // 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"` } 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) if errors.Is(err, agentapi.ErrFormatRefused) { s.logger.Printf("[WARN] [web] disk format refused by agent (data-bearing): %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) }