abe4e8e619
Retired (~12.3k LOC): internal/storage/* (scan/format/attach/migrate/safety), backup restic/crossdrive/restore_drives/disk_layout/local_infra/restore_scan/ paths + restore_app, report/infra_backup*/infra_pull, setup/scanner, monitor/watchdog+pinger, web/storage_handlers+handler_restore. Surgically split backup.Manager to app-data only (DB dumps + volume tars + app restore; dropped restic + cross-drive + snapshot history). Fixed router/main/web wiring. Added agent-backed disk API (web/agent_disk_handlers.go): /api/disks list/ assign/eject/format proxying agentapi; data-bearing format refusal -> HTTP 409 'operator authorization required'. report/config_pull.go keeps the setup fresh-install config download. go build + go test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
171 lines
6.0 KiB
Go
171 lines
6.0 KiB
Go
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)
|
|
}
|