Files
felhom-controller/controller/internal/web/storage_handlers.go
T
admin 9ed844fd0b controller v0.45.0: storage UX polish — deterministic order, init filter, register shortcut, system-storage clarity
B1 sort /api/disks (user-data→system→backup, alpha within); B2 init wizard
excludes mounted drives; B3 Regisztrálás primary action for mounted-unregistered
user-data drives (POST /api/storage/register); B4 per-card purpose descriptions +
app-backing tags + tiering note (local & local-lvm both kept); B5 eject already
names affected apps. Pairs with felhom-agent v0.24.0 eject role-gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:35:31 +02:00

420 lines
18 KiB
Go

package web
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"path"
"regexp"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Guided storage provisioning (rebuilt on the agent-delegated disk model). The controller is a thin
// orchestrator over the agent's authoritative disk endpoints (format/assign/eject, all data-bearing-
// gated on the agent) + the local StoragePath registry. It holds NO destructive authority: a data-
// bearing format is REFUSED by the agent, and the only thing the controller does with that refusal is
// surface the exact `felhom-opsign` command — there is no force-format path here.
// diskAgent is the subset of *agentapi.Client the orchestration needs (an interface so it's testable
// without a live agent). *agentapi.Client satisfies it.
type diskAgent interface {
Disks(ctx context.Context) (agentapi.DisksResponse, error)
FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error)
AssignDisk(ctx context.Context, uuid, where, fstype, options string) error
EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error)
}
// mountNameRe is the safe `/mnt/<name>` component (DNS-ish: letters, digits, _ , -).
var mountNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,40}$`)
// validFSTypes are the filesystems the init flow offers (the agent re-validates).
var validFSTypes = map[string]bool{"ext4": true, "xfs": true}
// mountWhere builds + validates the mount target from a user-supplied name → "/mnt/<name>".
func mountWhere(name string) (string, error) {
name = strings.TrimSpace(name)
if !mountNameRe.MatchString(name) {
return "", fmt.Errorf("érvénytelen csatlakoztatási név (csak betűk, számok, _ és - engedélyezett)")
}
return "/mnt/" + name, nil
}
// fsUUIDForDevice re-lists the agent's disks and returns the fs UUID of the storage backed by `device`
// (the only way the de-privileged controller learns the UUID it must pass to assign). "" if not found.
func fsUUIDForDevice(disks agentapi.DisksResponse, device string) string {
for _, d := range disks.Disks {
if d.BackingDevice == device {
return d.FSUUID()
}
}
return ""
}
// storageInitResult is the outcome of an init attempt (JSON-rendered to the wizard).
type storageInitResult struct {
Registered bool `json:"registered"`
Where string `json:"where,omitempty"`
// NeedsConfirmation (USER-DATA data-bearing): the customer must confirm the wipe (type-to-confirm),
// then the wizard re-submits with confirmed=true. NOT an operator signature.
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
Role string `json:"role,omitempty"`
DurableID string `json:"durable_id,omitempty"`
// Refusal (system/backup data-bearing): the operator must sign offline. No bypass.
Refused bool `json:"refused,omitempty"`
Reason string `json:"reason,omitempty"`
Opsign string `json:"opsign,omitempty"`
}
// runStorageInit is the testable core of the init flow: format → (confirm/refuse?) → resolve new
// UUID → assign → register. A USER-DATA data-bearing device requires the customer's confirmation
// (NeedsConfirmation); a SYSTEM/BACKUP device requires an operator signature (Refused+Opsign). In
// either refusal it performs NO further (destructive or mount) action.
func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault, confirmed bool, durableID string) (storageInitResult, error) {
if !validFSTypes[fstype] {
return storageInitResult{}, fmt.Errorf("nem támogatott fájlrendszer: %q (ext4 vagy xfs)", fstype)
}
// 1. Format — the AGENT inspects the device and tiers it by role. A data-bearing user-data device
// is allowed only with the customer's confirmation bound to its durable id; system/backup needs
// an operator signature.
fr, err := agent.FormatDisk(ctx, device, fstype, confirmed, durableID)
if errors.Is(err, agentapi.ErrNeedsConfirmation) {
// USER-DATA: surface the type-to-confirm requirement + the durable id to confirm against.
return storageInitResult{NeedsConfirmation: true, Role: fr.Role, DurableID: fr.DurableID, Reason: fr.Reason}, nil
}
if errors.Is(err, agentapi.ErrFormatRefused) {
res := storageInitResult{Refused: true, Reason: fr.Reason}
if fr.PendingOp != nil {
res.Opsign = fr.PendingOp.OpsignCommand()
}
return res, nil // STOP — no bypass; the UI surfaces Opsign.
}
if err != nil {
return storageInitResult{}, fmt.Errorf("formázás sikertelen: %w", err)
}
if !fr.Formatted {
return storageInitResult{}, fmt.Errorf("az eszköz nem lett megformázva (%s)", fr.Reason)
}
// 2. Resolve the NEW fs UUID (re-list disks; the device's storage now carries a fresh UUID).
disks, err := agent.Disks(ctx)
if err != nil {
return storageInitResult{}, fmt.Errorf("formázás kész, de a meghajtólista nem olvasható: %w", err)
}
uuid := fsUUIDForDevice(disks, device)
if uuid == "" {
return storageInitResult{}, fmt.Errorf("formázás kész, de az új fájlrendszer-azonosító nem feloldható — frissítsen és használja a Csatolás funkciót")
}
// 3. Mount (benign assign) + 4. register.
if err := agent.AssignDisk(ctx, uuid, where, fstype, ""); err != nil {
return storageInitResult{}, fmt.Errorf("csatlakoztatás sikertelen: %w", err)
}
if err := s.registerStoragePath(where, label, setDefault); err != nil {
return storageInitResult{}, err
}
return storageInitResult{Registered: true, Where: where}, nil
}
// runStorageAttach mounts an existing-filesystem device (non-destructive — never touches the gate)
// and registers it. The UUID is resolved server-side from the device.
func (s *Server) runStorageAttach(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault bool) (storageInitResult, error) {
disks, err := agent.Disks(ctx)
if err != nil {
return storageInitResult{}, fmt.Errorf("a meghajtólista nem olvasható: %w", err)
}
uuid := fsUUIDForDevice(disks, device)
if uuid == "" {
return storageInitResult{}, fmt.Errorf("a kiválasztott meghajtóhoz nem található fájlrendszer-azonosító (csak fájlrendszerrel rendelkező meghajtó csatolható)")
}
if err := agent.AssignDisk(ctx, uuid, where, fstype, ""); err != nil {
return storageInitResult{}, fmt.Errorf("csatlakoztatás sikertelen: %w", err)
}
if err := s.registerStoragePath(where, label, setDefault); err != nil {
return storageInitResult{}, err
}
return storageInitResult{Registered: true, Where: where}, nil
}
// registerStoragePath records a freshly-mounted path in the StoragePath registry (schedulable by
// default) and refreshes the FileBrowser mounts so it's usable immediately.
func (s *Server) registerStoragePath(where, label string, setDefault bool) error {
if strings.TrimSpace(label) == "" {
label = settings.InferStorageLabel(where)
}
sp := settings.StoragePath{
Path: where,
Label: label,
IsDefault: setDefault,
Schedulable: true,
AddedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := s.settings.AddStoragePath(sp); err != nil {
return fmt.Errorf("regisztráció sikertelen: %w", err)
}
go s.SyncFileBrowserMounts()
return nil
}
// ---- HTTP handlers (behind RequireAuth + CsrfProtect) -----------------------------------------
// storageWizardPageHandler renders the init/attach wizard page (the disk list + actions are driven
// client-side from GET /api/disks; the form posts to /api/storage/{init,attach}).
func (s *Server) storageWizardPageHandler(w http.ResponseWriter, r *http.Request, tmpl string) {
title := "Új meghajtó inicializálása"
if tmpl == "storage_attach" {
title = "Meglévő meghajtó csatolása"
}
data := s.baseData(tmpl, title)
s.render(w, tmpl, data)
}
// ServeStorageAPI dispatches /api/storage/* (guided init/attach/eject orchestration).
func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/storage/init" && r.Method == http.MethodPost:
s.handleStorageInit(w, r)
case r.URL.Path == "/api/storage/attach" && r.Method == http.MethodPost:
s.handleStorageAttach(w, r)
case r.URL.Path == "/api/storage/eject" && r.Method == http.MethodPost:
s.handleStorageEject(w, r)
case r.URL.Path == "/api/storage/wipe" && r.Method == http.MethodPost:
s.handleStorageWipe(w, r)
case r.URL.Path == "/api/storage/impact" && r.Method == http.MethodGet:
s.handleStorageImpact(w, r)
case r.URL.Path == "/api/storage/register" && r.Method == http.MethodPost:
s.handleStorageRegister(w, r)
default:
http.NotFound(w, r)
}
}
type storageProvReq struct {
Device string `json:"device"`
FSType string `json:"fstype"`
MountName string `json:"mount_name"`
Label string `json:"label"`
SetDefault bool `json:"set_default"`
// Confirmed + DurableID: the customer's type-to-confirm authorization for a USER-DATA data-bearing
// wipe (the durable id the agent returned on the prior NeedsConfirmation response).
Confirmed bool `json:"confirmed"`
DurableID string `json:"durable_id"`
}
func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
var req storageProvReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
return
}
where, err := mountWhere(req.MountName)
if err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, err.Error(), nil)
return
}
if req.Device == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
return
}
agent, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return
}
res, err := s.runStorageInit(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault, req.Confirmed, req.DurableID)
if err != nil {
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
if res.NeedsConfirmation {
writeDiskJSON(w, http.StatusConflict, false, "ügyfél-megerősítés szükséges", res)
return
}
if res.Refused {
writeDiskJSON(w, http.StatusConflict, false, "operátori aláírás szükséges", res)
return
}
writeDiskJSON(w, http.StatusOK, true, "", res)
}
// storageImpactReq / handleStorageImpact return the deployed apps whose data lives on a given mount —
// the "name the apps that break" requirement for the type-to-confirm wipe/eject UI.
func (s *Server) handleStorageImpact(w http.ResponseWriter, r *http.Request) {
where := path.Clean(strings.TrimSpace(r.URL.Query().Get("where")))
if where == "" || where == "." || !strings.HasPrefix(where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
return
}
apps := s.appsUsingPath(where)
if apps == nil {
apps = []string{}
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"where": where, "apps": apps})
}
// handleStorageWipe is the customer-confirmed wipe of a USER-DATA drive: it unmounts (eject —
// deregisters + frees the device) then formats with the customer's confirmation bound to the device's
// durable id. The agent re-classifies the role and re-resolves the durable id itself — a system/backup
// device is refused by the agent regardless of what the controller sends. The mount name must be typed
// to match (type-to-confirm) — enforced both client-side (disabled button) and here (server-side).
func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
var req struct {
Device string `json:"device"`
Where string `json:"where"`
MountName string `json:"mount_name"` // the typed confirmation (must equal the basename of Where)
FSType string `json:"fstype"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
return
}
if req.Device == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
return
}
req.Where = path.Clean(strings.TrimSpace(req.Where))
if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
return
}
// Server-side type-to-confirm: the typed name must match the mount's basename exactly.
if strings.TrimSpace(req.MountName) != path.Base(req.Where) {
writeDiskJSON(w, http.StatusBadRequest, false, "a beírt név nem egyezik a csatlakoztatási névvel", nil)
return
}
fstype := req.FSType
if !validFSTypes[fstype] {
fstype = "ext4"
}
agent, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return
}
// 1. Unmount (benign — frees the device so mkfs can run) + deregister the StoragePath.
if _, eerr := agent.EjectDisk(r.Context(), req.Where); eerr != nil {
s.logger.Printf("[WARN] [web] wipe: eject %s failed (continuing to format): %v", req.Where, eerr)
} else if rerr := s.settings.RemoveStoragePath(req.Where); rerr != nil {
s.logger.Printf("[WARN] [web] wipe: deregister %s failed: %v", req.Where, rerr)
} else {
go s.SyncFileBrowserMounts()
}
// 2. Two-step customer-confirmed format: learn the agent's durable id (NeedsConfirmation), then
// re-submit confirmed:true bound to it. The agent re-resolves + matches the durable id and
// re-classifies the role — a protected device is refused here even though we send confirmed:true.
probe, perr := agent.FormatDisk(r.Context(), req.Device, fstype, false, "")
if errors.Is(perr, agentapi.ErrFormatRefused) {
writeDiskJSON(w, http.StatusConflict, false, "a meghajtó védett (rendszer/biztonsági mentés) — törlés csak operátori aláírással", probe)
return
}
if !errors.Is(perr, agentapi.ErrNeedsConfirmation) {
if perr != nil {
writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+perr.Error(), nil)
return
}
// Already blank (no confirmation needed) — the format the agent just ran is the wipe.
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": true})
return
}
fr, ferr := agent.FormatDisk(r.Context(), req.Device, fstype, true, probe.DurableID)
if ferr != nil {
writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+ferr.Error(), nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": fr.Formatted, "durable_id": fr.DurableID})
}
// handleStorageRegister records an ALREADY-mounted, unregistered user-data drive into the StoragePath
// registry — no format, no eject. It is the natural primary action for a mounted-but-unregistered data
// drive (e.g. felhom-usb): the customer's intent is to USE the existing data, not wipe it. It reuses
// registerStoragePath (the manual-add path) — AddStoragePath dedupes, so a double-register is a clean
// error, not a duplicate.
func (s *Server) handleStorageRegister(w http.ResponseWriter, r *http.Request) {
var req struct {
Where string `json:"where"`
Label string `json:"label"`
SetDefault bool `json:"set_default"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
return
}
req.Where = path.Clean(strings.TrimSpace(req.Where))
if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
return
}
if err := s.registerStoragePath(req.Where, req.Label, req.SetDefault); err != nil {
s.logger.Printf("[WARN] [web] storage register %s failed: %v", req.Where, err)
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
s.logger.Printf("[INFO] [web] storage path registered (existing mount): %s", req.Where)
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"registered": true, "where": req.Where})
}
func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
var req storageProvReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
return
}
where, err := mountWhere(req.MountName)
if err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, err.Error(), nil)
return
}
if req.Device == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
return
}
agent, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return
}
res, err := s.runStorageAttach(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault)
if err != nil {
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", res)
}
// handleStorageEject unmounts a host mount (benign, data preserved) and DEREGISTERS its StoragePath.
// It surfaces the agent's dependent-guest warning. (Distinct from the signature-gated decommission.)
func (s *Server) handleStorageEject(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, "érvénytelen kérés", nil)
return
}
req.Where = path.Clean(strings.TrimSpace(req.Where))
if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
return
}
agent, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return
}
res, err := agent.EjectDisk(r.Context(), req.Where)
if err != nil {
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
// Deregister the path (best-effort — the unmount already succeeded).
if rerr := s.settings.RemoveStoragePath(req.Where); rerr != nil {
s.logger.Printf("[WARN] [web] eject: unmounted %s but deregister failed: %v", req.Where, rerr)
} else {
go s.SyncFileBrowserMounts()
}
writeDiskJSON(w, http.StatusOK, true, "", res)
}