bea05ea600
runStorageInit/runStorageAttach resolved the fs UUID only via agent.Disks(),
which does NOT include a raw (unenrolled, non-PVE-storage) device — so a raw
candidate could be offered but never enrolled ("no fs identifier"). New
resolveEnrollUUID falls back to the raw-device scan (/disks/candidates), which
reports each free disk's durable_id (uuid:<fs-uuid>). Both enroll paths use it;
legacy re-attach (drive in /disks) still works. Test + red-proof.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
791 lines
36 KiB
Go
791 lines
36 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// 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)
|
|
ListCandidates(ctx context.Context) (agentapi.CandidatesResult, 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)
|
|
Decommission(ctx context.Context, where string) (agentapi.DecommissionResult, error)
|
|
GuestAttach(ctx context.Context, where string) error
|
|
GuestReboot(ctx context.Context) 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 ""
|
|
}
|
|
|
|
// resolveEnrollUUID resolves a device's filesystem UUID for enrollment. A RAW candidate (Impl-2b) is
|
|
// NOT in the /disks list (it has no PVE storage / isn't enrolled), so fsUUIDForDevice can't see it —
|
|
// fall back to the raw-device scan (/disks/candidates), which reports each free disk's durable_id
|
|
// (uuid:<fs-uuid>). Matches the whole disk OR its FS-bearing node (mount_source, e.g. /dev/sdd1).
|
|
// Returns "" only if the device truly has no resolvable fs UUID.
|
|
func resolveEnrollUUID(ctx context.Context, agent diskAgent, device string) string {
|
|
if disks, err := agent.Disks(ctx); err == nil {
|
|
if u := fsUUIDForDevice(disks, device); u != "" {
|
|
return u
|
|
}
|
|
}
|
|
cands, err := agent.ListCandidates(ctx)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, c := range append(append([]agentapi.DiskCandidate{}, cands.Initialize...), cands.Attach...) {
|
|
if c.Device == device || c.MountSource == device {
|
|
if rest, ok := strings.CutPrefix(c.DurableID, "uuid:"); ok {
|
|
return rest
|
|
}
|
|
}
|
|
}
|
|
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. A freshly-formatted RAW device isn't in /disks (not enrolled yet), so
|
|
// resolve via the raw-device scan too (Impl-2b) — the device now appears there with its new durable_id.
|
|
uuid := resolveEnrollUUID(ctx, agent, 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) at the raw /mnt/<name>. 4. Bind felhom-data under the shared parent FIRST
|
|
// (intermediary model) so the drive's STABLE path is live in the guest, THEN register + skeleton there
|
|
// (the controller can only see/write the drive at the stable path post-attach). 5. Register the stable
|
|
// path — that is what apps' HDD_PATH / FileBrowser / monitoring use.
|
|
if err := agent.AssignDisk(ctx, uuid, where, fstype, ""); err != nil {
|
|
return storageInitResult{}, fmt.Errorf("csatlakoztatás sikertelen: %w", err)
|
|
}
|
|
s.attachIntoGuest(ctx, agent, where)
|
|
stable := stablePathForName(path.Base(where))
|
|
if err := s.registerStoragePath(stable, label, setDefault); err != nil {
|
|
return storageInitResult{}, err
|
|
}
|
|
return storageInitResult{Registered: true, Where: stable}, nil
|
|
}
|
|
|
|
// attachIntoGuest passes an enrolled drive INTO the guest (slice 10 P2) so the controller + apps can
|
|
// use it. Best-effort: the StoragePath registration is the durable intent, so a transient attach
|
|
// failure is logged (not fatal) — P3 self-heal reconcile will complete it on the next tick.
|
|
func (s *Server) attachIntoGuest(ctx context.Context, agent diskAgent, where string) {
|
|
if err := agent.GuestAttach(ctx, where); err != nil {
|
|
s.logger.Printf("[WARN] [web] enroll: guest-attach %s failed (registered; will be retried): %v", where, err)
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] enroll: drive bound into guest: %s", where)
|
|
}
|
|
|
|
// 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) {
|
|
// Resolve the fs UUID. Works for a legacy re-attach (drive in /disks) AND a raw candidate (Impl-2b:
|
|
// not in /disks — resolved via the raw-device scan's durable_id).
|
|
uuid := resolveEnrollUUID(ctx, agent, 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)
|
|
}
|
|
s.attachIntoGuest(ctx, agent, where)
|
|
stable := stablePathForName(path.Base(where))
|
|
if err := s.registerStoragePath(stable, label, setDefault); err != nil {
|
|
return storageInitResult{}, err
|
|
}
|
|
return storageInitResult{Registered: true, Where: stable}, nil
|
|
}
|
|
|
|
// reEnrollClearMarker un-retires a re-plugged decommissioned drive (Change 4): clears the soft marker
|
|
// and restores Schedulable so its apps' "missing storage" indicator clears. Returns true if it acted.
|
|
func (s *Server) reEnrollClearMarker(where string) (bool, error) {
|
|
if !s.settings.IsDecommissioned(where) {
|
|
return false, nil
|
|
}
|
|
if err := s.settings.ClearDecommissioned(where); err != nil {
|
|
return false, fmt.Errorf("leszerelés visszavonása sikertelen: %w", err)
|
|
}
|
|
if err := s.settings.SetSchedulable(where, true); err != nil {
|
|
return false, fmt.Errorf("ütemezhetőség visszaállítása sikertelen: %w", err)
|
|
}
|
|
s.logger.Printf("[INFO] [web] re-enrolled decommissioned drive — marker cleared: %s", where)
|
|
return true, 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)
|
|
}
|
|
// v0.66.0: create the full userdata skeleton with the shared-storage convention (2775 setgid,
|
|
// gid 1000) the moment a drive is registered — system drive AND additional drives. Idempotent;
|
|
// best-effort (a perms hiccup shouldn't block registration).
|
|
if err := appbackup.EnsureUserdataSkeleton(where); err != nil {
|
|
s.logger.Printf("[WARN] [web] userdata skeleton on %s: %v", where, err)
|
|
}
|
|
// Change 4: re-enrolling a previously-DECOMMISSIONED drive must un-retire it. AddStoragePath
|
|
// dedups a re-register into a no-op, so without this the soft marker would persist forever and the
|
|
// apps' "missing storage" indicator would never clear.
|
|
if cleared, err := s.reEnrollClearMarker(where); err != nil {
|
|
return err
|
|
} else if cleared {
|
|
if s.stackMgr != nil {
|
|
go s.SyncFileBrowserMounts()
|
|
}
|
|
return nil
|
|
}
|
|
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
|
|
}
|
|
|
|
// refuseNetworkLifecycle blocks a drive-lifecycle action (eject/decommission/migrate/wipe) on a NAS
|
|
// network-storage path. A network share is a DISTINCT class with no device lifecycle — its only
|
|
// lifecycle action is /api/netstorage/remove. Returns true (and writes the refusal) when the path is a
|
|
// network path; the caller must then return. This is the server-side Kind-gate that backs the UI gating.
|
|
func (s *Server) refuseNetworkLifecycle(w http.ResponseWriter, where string) bool {
|
|
if s.settings != nil && s.settings.IsNetworkStoragePath(where) {
|
|
writeDiskJSON(w, http.StatusBadRequest, false,
|
|
"ez hálózati tárhely (NAS) — a meghajtó-műveletek (leválasztás/leszerelés/áthelyezés/törlés) nem alkalmazhatók rá; használd az „Eltávolítás” gombot", nil)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ---- 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)
|
|
case r.URL.Path == "/api/storage/migrate" && r.Method == http.MethodPost:
|
|
s.handleStorageMigrate(w, r)
|
|
case r.URL.Path == "/api/storage/migrate-app" && r.Method == http.MethodPost:
|
|
s.handleStorageMigrateApp(w, r)
|
|
case r.URL.Path == "/api/storage/migrate/status" && r.Method == http.MethodGet:
|
|
s.handleStorageMigrateStatus(w, r)
|
|
case r.URL.Path == "/api/storage/decommission" && r.Method == http.MethodPost:
|
|
s.handleStorageDecommission(w, r)
|
|
// NAS network storage (Part A2) — distinct from the drive lifecycle above (proxy to agent /netstorage/*).
|
|
case r.URL.Path == "/api/storage/netstorage/add" && r.Method == http.MethodPost:
|
|
s.handleNetStorageAdd(w, r)
|
|
case r.URL.Path == "/api/storage/netstorage" && r.Method == http.MethodGet:
|
|
s.handleNetStorageList(w, r)
|
|
case r.URL.Path == "/api/storage/netstorage/remove" && r.Method == http.MethodPost:
|
|
s.handleNetStorageRemove(w, r)
|
|
case r.URL.Path == "/api/storage/disconnect" && r.Method == http.MethodPost:
|
|
s.handleStorageDisconnect(w, r)
|
|
case r.URL.Path == "/api/storage/reconnect" && r.Method == http.MethodPost:
|
|
s.handleStorageReconnect(w, r)
|
|
case r.URL.Path == "/api/storage/restart-apps" && r.Method == http.MethodPost:
|
|
s.handleStorageRestartApps(w, r)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
// handleStorageMigrate starts a whole-namespace migration (all apps + non-app content) off the source
|
|
// drive onto the chosen target. Async: VALIDATE runs synchronously (a refusal is returned here and
|
|
// changes nothing); the copy/redeploy/cleanup run in the background and the UI polls migrate/status.
|
|
func (s *Server) handleStorageMigrate(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Source string `json:"source"`
|
|
Target string `json:"target"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
|
|
return
|
|
}
|
|
if s.refuseNetworkLifecycle(w, strings.TrimSpace(req.Source)) || s.refuseNetworkLifecycle(w, strings.TrimSpace(req.Target)) {
|
|
return
|
|
}
|
|
id, err := s.stackMgr.MigrateAll(r.Context(), strings.TrimSpace(req.Source), strings.TrimSpace(req.Target))
|
|
if err != nil {
|
|
writeDiskJSON(w, http.StatusConflict, false, err.Error(), nil)
|
|
return
|
|
}
|
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "id": id})
|
|
}
|
|
|
|
// handleStorageMigrateApp starts a single-app migration onto the chosen target.
|
|
func (s *Server) handleStorageMigrateApp(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
App string `json:"app"`
|
|
Target string `json:"target"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
|
|
return
|
|
}
|
|
id, err := s.stackMgr.MigrateApp(r.Context(), strings.TrimSpace(req.App), strings.TrimSpace(req.Target))
|
|
if err != nil {
|
|
writeDiskJSON(w, http.StatusConflict, false, err.Error(), nil)
|
|
return
|
|
}
|
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "id": id})
|
|
}
|
|
|
|
// handleStorageMigrateStatus returns the live migration job (nil/idle when none is running) for the
|
|
// progress panel poll.
|
|
func (s *Server) handleStorageMigrateStatus(w http.ResponseWriter, r *http.Request) {
|
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"job": s.stackMgr.MigrationStatus()})
|
|
}
|
|
|
|
// handleStorageDecommission is the SELF-SERVE drive decommission (B2b). Exactly two choices, no partial
|
|
// (Change 2): mode="migrate" moves ALL apps to a target then decommissions the now-empty source (the
|
|
// done-hook finalizes); mode="anyway" decommissions immediately, stopping the apps (keeping their
|
|
// HDD_PATH so they show "missing storage"). Non-destructive — the drive's data is never formatted.
|
|
func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Where string `json:"where"`
|
|
Mode string `json:"mode"` // "migrate" | "anyway"
|
|
Target string `json:"target"` // required for mode=migrate
|
|
MountName string `json:"mount_name"` // type-to-confirm for mode=anyway
|
|
}
|
|
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 s.refuseNetworkLifecycle(w, req.Where) {
|
|
return
|
|
}
|
|
|
|
switch req.Mode {
|
|
case "migrate":
|
|
if strings.TrimSpace(req.Target) == "" {
|
|
writeDiskJSON(w, http.StatusBadRequest, false, "céltároló kötelező az áthelyezéshez", nil)
|
|
return
|
|
}
|
|
// Start the migration; the done-hook (onMigrationDone) soft-marks + agent-decommissions the
|
|
// source once every app has moved and come up on the target. A VALIDATE refusal returns here.
|
|
id, err := s.stackMgr.MigrateAllAndDecommission(r.Context(), req.Where, strings.TrimSpace(req.Target))
|
|
if err != nil {
|
|
writeDiskJSON(w, http.StatusConflict, false, err.Error(), nil)
|
|
return
|
|
}
|
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "id": id, "mode": "migrate"})
|
|
|
|
case "anyway":
|
|
// Type-to-confirm: the typed name must match the mount basename exactly (mirrors wipe).
|
|
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
|
|
}
|
|
// Stop the apps that live on this drive — but KEEP their HDD_PATH so the dashboard can name the
|
|
// drive in the "missing storage" indicator until the customer re-enrolls or migrates.
|
|
var stopped []string
|
|
for _, st := range s.stackMgr.GetStacks() {
|
|
if !st.Deployed {
|
|
continue
|
|
}
|
|
if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil && cfg.Env["HDD_PATH"] == req.Where {
|
|
if err := s.stackMgr.StopStack(st.Name); err != nil {
|
|
s.logger.Printf("[WARN] [web] decommission: stop %s failed: %v", st.Name, err)
|
|
}
|
|
stopped = append(stopped, st.Meta.DisplayName)
|
|
}
|
|
}
|
|
if err := s.finalizeDecommission(r.Context(), req.Where, ""); err != nil {
|
|
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
|
return
|
|
}
|
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"decommissioned": true, "where": req.Where, "stopped_apps": stopped})
|
|
|
|
default:
|
|
writeDiskJSON(w, http.StatusBadRequest, false, "ismeretlen mód (migrate vagy anyway)", nil)
|
|
}
|
|
}
|
|
|
|
// finalizeDecommission resolves the agent client then soft-marks + decommissions (see *With).
|
|
func (s *Server) finalizeDecommission(ctx context.Context, where, migratedTo string) error {
|
|
agent, err := s.agentClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.finalizeDecommissionWith(ctx, agent, where, migratedTo)
|
|
}
|
|
|
|
// finalizeDecommissionWith soft-marks the registry path (keeping the entry — blocks A1 resurrection)
|
|
// and tells the agent to decommission the drive (intent + unmount; never formats). migratedTo is the
|
|
// target path for a migrate-then-decommission, or "" for decommission-anyway. The agent is injected so
|
|
// the orchestration is testable without a live agent.
|
|
func (s *Server) finalizeDecommissionWith(ctx context.Context, agent diskAgent, where, migratedTo string) error {
|
|
// M1: never leave zero default. If `where` is the default, promote another usable drive; if none
|
|
// exists, BLOCK before any side-effect (compute against the registry BEFORE SetDecommissioned, which
|
|
// flips IsDefault/Schedulable on `where`).
|
|
promote, mustBlock := defaultPromotionTarget(s.settings.GetStoragePaths(), where, migratedTo)
|
|
if mustBlock {
|
|
return fmt.Errorf("ez az egyetlen használható tárhely — a leszerelés megtagadva (előbb adj hozzá vagy állíts be másik alapértelmezett meghajtót)")
|
|
}
|
|
if err := s.settings.SetDecommissioned(where, migratedTo); err != nil {
|
|
return fmt.Errorf("nyilvántartás frissítése sikertelen: %w", err)
|
|
}
|
|
if promote != "" {
|
|
if derr := s.settings.SetDefaultStoragePath(promote); derr != nil {
|
|
s.logger.Printf("[WARN] [web] default reassignment to %s failed: %v", promote, derr)
|
|
} else {
|
|
s.logger.Printf("[INFO] [web] default drive reassigned %s → %s (M1)", where, promote)
|
|
}
|
|
}
|
|
// Registered path is the STABLE /mnt/felhom-drives/<name>; the agent decommissions the raw mount.
|
|
if _, err := agent.Decommission(ctx, agentWhere(where)); err != nil {
|
|
return fmt.Errorf("a meghajtó leszerelése sikertelen: %w", err)
|
|
}
|
|
if s.stackMgr != nil {
|
|
go s.SyncFileBrowserMounts()
|
|
}
|
|
s.logger.Printf("[INFO] [web] storage decommissioned: %s (migrated_to=%q)", where, migratedTo)
|
|
return nil
|
|
}
|
|
|
|
// onMigrationDone is the migration completion hook: when a decommission-initiated migration succeeds,
|
|
// the source drive is now empty (all apps flipped to the target), so soft-mark + agent-decommission it.
|
|
func (s *Server) OnMigrationDone(j *stacks.MigrationJob) {
|
|
if j == nil || !j.DecommissionOnDone {
|
|
return
|
|
}
|
|
if err := s.finalizeDecommission(context.Background(), j.Source, j.Target); err != nil {
|
|
s.logger.Printf("[ERROR] [web] post-migration decommission of %s failed: %v", j.Source, err)
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] source %s decommissioned after migration to %s", j.Source, j.Target)
|
|
}
|
|
|
|
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{}
|
|
}
|
|
// P4 (4B): a user-data drive is ALSO backup-target-eligible — it may hold cross-drive backup copies
|
|
// of OTHER drives' app data. A wipe destroys those copies too, so name them in the confirmation.
|
|
// (The copies are redundant — the originals live on the source drive — so the wipe stays customer-
|
|
// confirmable, NOT operator-signature; the warning just makes the loss explicit.)
|
|
backupCopies := backupCopiesOnPath(where)
|
|
if backupCopies == nil {
|
|
backupCopies = []string{}
|
|
}
|
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
|
|
"where": where, "apps": apps, "backup_copies": backupCopies,
|
|
})
|
|
}
|
|
|
|
// backupCopiesOnPath lists the apps whose CROSS-DRIVE (secondary) backup copies are stored on the
|
|
// drive mounted at `where` (slice 10 P4) — the backups/secondary/<app> dirs. A wipe of this drive
|
|
// removes these copies. Best-effort filesystem scan; empty until the cross-drive backup ENGINE (a
|
|
// follow-on slice) actually writes here. Shared/aggregate dirs (restic repo, _infra) are not apps
|
|
// and are skipped. Model A: `where` is the in-guest drive mount, which IS the felhom-data namespace
|
|
// root, so backups/ sits directly under it (no felhom-data segment — avoids the double-nest).
|
|
func backupCopiesOnPath(where string) []string {
|
|
secondary := filepath.Join(appbackup.NamespaceRoot(where, true), "backups", "secondary")
|
|
entries, err := os.ReadDir(secondary)
|
|
if err != nil {
|
|
return nil // no secondary backups here (or the path isn't readable) — nothing to warn about
|
|
}
|
|
var apps []string
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
if name == "restic" || name == "_infra" { // shared repo / infra, not a per-app copy
|
|
continue
|
|
}
|
|
apps = append(apps, name)
|
|
}
|
|
return 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
|
|
}
|
|
if s.refuseNetworkLifecycle(w, req.Where) {
|
|
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. req.Where is
|
|
// the STABLE registered path; the agent operates on the raw mount (agentWhere), the registry stores
|
|
// the stable path (RemoveStoragePath gets req.Where).
|
|
if _, eerr := agent.EjectDisk(r.Context(), agentWhere(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})
|
|
}
|
|
|
|
// HandleServerReboot reboots the whole guest (server) as a deliberate maintenance action — the
|
|
// standalone "Kiszolgáló újraindítása" affordance, a sibling to the controller-only restart
|
|
// (/api/selfrestart). The agent reboots detached + returns 202; this controller restarts with the
|
|
// guest, so the caller's response may be cut short — the UI handles that and reloads after the
|
|
// restart window. (It reuses the agent GuestReboot primitive that previously backed the now-retired
|
|
// drive-activation banner; in the intermediary-mount model a drive binds live, so no reboot is needed
|
|
// to activate storage — this button is purely a full-server restart.)
|
|
func (s *Server) HandleServerReboot(w http.ResponseWriter, r *http.Request) {
|
|
agent, err := s.agentClient()
|
|
if err != nil {
|
|
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
|
return
|
|
}
|
|
s.serverReboot(w, r, agent)
|
|
}
|
|
|
|
// serverReboot is the testable core of HandleServerReboot: invoke the agent's guest reboot and return
|
|
// the 202 envelope (or the agent error). Split out so it can be exercised with a fake diskAgent.
|
|
func (s *Server) serverReboot(w http.ResponseWriter, r *http.Request, agent diskAgent) {
|
|
if err := agent.GuestReboot(r.Context()); err != nil {
|
|
s.logger.Printf("[ERROR] [web] server reboot (guest restart) failed: %v", err)
|
|
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
|
return
|
|
}
|
|
s.logger.Printf("[WARN] [web] full server (guest) restart requested by operator")
|
|
writeDiskJSON(w, http.StatusAccepted, true, "", map[string]any{"rebooting": true})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// req.Where is the RAW /mnt/<name> host mount the agent reports; in the intermediary model the drive
|
|
// is actually live in the guest at the STABLE path /mnt/felhom-drives/<name>, so REGISTER the stable
|
|
// path (matching runStorageInit/runStorageAttach). Registering the raw path made the controller watch
|
|
// an empty rootfs placeholder → "Rendszermeghajtón" + stuck "activation pending" banner
|
|
// (DIAGNOSE-drive-bind-after-reprovision-2026-06-23.md). attachIntoGuest still uses the RAW path —
|
|
// the agent operates on raw.
|
|
stable := stablePathForName(path.Base(req.Where))
|
|
if err := s.registerStoragePath(stable, req.Label, req.SetDefault); err != nil {
|
|
s.logger.Printf("[WARN] [web] storage register %s (stable %s) failed: %v", req.Where, stable, err)
|
|
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] storage path registered (existing mount): %s → %s", req.Where, stable)
|
|
// Pass the drive into the guest too (slice 10 P2) — registering a host-only mount otherwise leaves
|
|
// it guest-invisible (the exact gap that produced the "nem elérhető" banner). The agent operates on
|
|
// the RAW path, so attach with req.Where (NOT the stable path).
|
|
if agent, aerr := s.agentClient(); aerr == nil {
|
|
s.attachIntoGuest(r.Context(), agent, req.Where)
|
|
}
|
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"registered": true, "where": stable, "raw": 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
|
|
}
|
|
if s.refuseNetworkLifecycle(w, req.Where) {
|
|
return
|
|
}
|
|
agent, err := s.agentClient()
|
|
if err != nil {
|
|
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
|
return
|
|
}
|
|
// The registered path is the STABLE /mnt/felhom-drives/<name>; the agent operates on the raw mount.
|
|
res, err := agent.EjectDisk(r.Context(), agentWhere(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)
|
|
}
|