B2b: decommission orchestration + missing-storage indicator + re-enroll fix (v0.65.0)

agentapi.Decommission + handleStorageDecommission (migrate-all-or-none, Change 2):
migrate-then-decommission via the migration done-hook, or decommission-anyway (stop
apps, keep HDD_PATH). 'Hiányzó tárhely' badge on dashboard/stacks/app card when an
app's drive is decommissioned/disconnected/absent. Change 4: registerStoragePath
clears the decommissioned marker on re-enroll (ClearDecommissioned had no callers).
Non-hollow tests incl. mutation-proven Change-4 companion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 20:08:52 +02:00
parent 16a4c3e878
commit f2596ea433
13 changed files with 435 additions and 35 deletions
+138
View File
@@ -16,6 +16,7 @@ import (
"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"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
@@ -32,6 +33,7 @@ type diskAgent interface {
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
}
@@ -195,12 +197,39 @@ func (s *Server) pendingActivationDrives() []string {
return pending
}
// 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)
}
// 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,
@@ -251,6 +280,8 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
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)
default:
http.NotFound(w, r)
}
@@ -300,6 +331,113 @@ func (s *Server) handleStorageMigrateStatus(w http.ResponseWriter, r *http.Reque
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
}
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 {
if err := s.settings.SetDecommissioned(where, migratedTo); err != nil {
return fmt.Errorf("nyilvántartás frissítése sikertelen: %w", err)
}
if _, err := agent.Decommission(ctx, 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"`