v0.43.0: rebuilt storage management (guided init/attach/eject on agent disk model)
Controller-only UI/orchestration over the agent's disk endpoints + StoragePath registry. New: storage overview (data_bearing badges), guided init (format -> resolve fs UUID -> assign -> register; data-bearing REFUSAL surfaces the felhom-opsign command, no force-format), guided attach, eject (+deregister, dependent-guest warning). agentapi: DiskInfo.DurableID/FSUUID + FormatResult. PendingOp (parsed from the 403). Honest buttons (migrate disabled, no 404s). Phase 3: removed dead CrossDrive blocks in deploy.html/backups.html. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
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) (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"`
|
||||
// Refusal (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 → (refuse?) → resolve new UUID →
|
||||
// assign → register. On a data-bearing refusal it returns a result with Refused+Opsign and performs
|
||||
// NO further (destructive or mount) action.
|
||||
func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault bool) (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 decides. A data-bearing device is refused.
|
||||
fr, err := agent.FormatDisk(ctx, device, fstype)
|
||||
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)
|
||||
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"`
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user