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
@@ -0,0 +1,110 @@
package web
import (
"context"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// TestFinalizeDecommission_SoftMarksAndCallsAgent: finalizeDecommissionWith soft-marks the registry
// (entry retained, MigratedTo set) and calls the agent's Decommission. Used by both the migrate-done
// hook (MigratedTo=target) and decommission-anyway (MigratedTo="").
func TestFinalizeDecommission_SoftMarksAndCallsAgent(t *testing.T) {
s := testServer(t)
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/old", Schedulable: true, IsDefault: true})
agent := &mockAgent{}
if err := s.finalizeDecommissionWith(context.Background(), agent, "/mnt/old", "/mnt/new"); err != nil {
t.Fatalf("finalize: %v", err)
}
// soft-marked, entry retained
if !s.settings.IsDecommissioned("/mnt/old") {
t.Errorf("path not soft-marked decommissioned")
}
found := false
for _, sp := range s.settings.GetStoragePaths() {
if sp.Path == "/mnt/old" {
found = true
if sp.MigratedTo != "/mnt/new" {
t.Errorf("MigratedTo = %q, want /mnt/new", sp.MigratedTo)
}
if sp.Schedulable || sp.IsDefault {
t.Errorf("decommissioned entry should not be schedulable/default")
}
}
}
if !found {
t.Errorf("entry was REMOVED — soft marker must retain it (blocks A1 resurrection)")
}
// agent told to decommission
if len(agent.decommissionCalls) != 1 || agent.decommissionCalls[0] != "/mnt/old" {
t.Errorf("agent.Decommission calls = %v, want [/mnt/old]", agent.decommissionCalls)
}
}
// TestOnMigrationDone_UnflaggedIsNoop: a plain migrate-all completion (DecommissionOnDone=false) must
// NOT decommission the source. (The flagged path's effects are covered by the finalize test; it would
// require a live agent client here.) Companion: if the early-return guard were removed, this would try
// to reach agentClient() and the path would change — so the no-op assertion pins the guard.
func TestOnMigrationDone_UnflaggedIsNoop(t *testing.T) {
s := testServer(t)
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/old", Schedulable: true})
s.OnMigrationDone(&stacks.MigrationJob{Source: "/mnt/old", Target: "/mnt/new", DecommissionOnDone: false})
if s.settings.IsDecommissioned("/mnt/old") {
t.Errorf("unflagged migration must not decommission the source")
}
}
// TestReEnrollClearMarker_Change4: re-enrolling a decommissioned path clears the marker + restores
// Schedulable so its apps' missing-storage indicator clears. (Companion: a non-decommissioned path is
// untouched — returns false.)
func TestReEnrollClearMarker_Change4(t *testing.T) {
s := testServer(t)
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/usb", Schedulable: true})
_ = s.settings.SetDecommissioned("/mnt/usb", "")
if !s.settings.IsDecommissioned("/mnt/usb") {
t.Fatal("precondition: should be decommissioned")
}
cleared, err := s.reEnrollClearMarker("/mnt/usb")
if err != nil {
t.Fatalf("reEnrollClearMarker: %v", err)
}
if !cleared {
t.Errorf("expected cleared=true for a decommissioned path")
}
if s.settings.IsDecommissioned("/mnt/usb") {
t.Errorf("marker not cleared")
}
if !s.settings.IsStoragePathSchedulable("/mnt/usb") {
t.Errorf("Schedulable not restored")
}
// companion: a non-decommissioned path is a no-op
if c, _ := s.reEnrollClearMarker("/mnt/usb"); c {
t.Errorf("non-decommissioned path should return cleared=false")
}
}
// TestMissingStorageLabel: an app's HDD_PATH on a decommissioned/disconnected/absent registry path is
// "missing"; on a present+schedulable path it is not; an empty HDD_PATH (SSD) is never missing.
func TestMissingStorageLabel(t *testing.T) {
s := testServer(t)
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/live", Label: "Élő", Schedulable: true})
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/dead", Label: "Holt", Schedulable: true})
_ = s.settings.SetDecommissioned("/mnt/dead", "")
if _, missing := s.missingStorageLabel(""); missing {
t.Errorf("empty HDD_PATH (SSD) must not be missing")
}
if _, missing := s.missingStorageLabel("/mnt/live"); missing {
t.Errorf("present+schedulable path must not be missing")
}
if label, missing := s.missingStorageLabel("/mnt/dead"); !missing || label != "Holt" {
t.Errorf("decommissioned path: missing=%v label=%q, want true/Holt", missing, label)
}
if label, missing := s.missingStorageLabel("/mnt/gone"); !missing || label == "" {
t.Errorf("unregistered path: missing=%v label=%q, want true/non-empty", missing, label)
}
}
+42 -1
View File
@@ -151,6 +151,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
data := s.baseData("dashboard", "Vezérlőpult")
data["Stacks"] = deployedStacks
data["MissingStorage"] = s.missingStorageMap(deployedStacks)
data["RunningCount"] = running
data["StoppedCount"] = stopped
data["TotalCount"] = len(stackList)
@@ -195,7 +196,9 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
data := s.baseData("stacks", "Alkalmazások")
data["Stacks"] = s.stackMgr.GetStacks()
allStacks := s.stackMgr.GetStacks()
data["Stacks"] = allStacks
data["MissingStorage"] = s.missingStorageMap(allStacks)
// Build storage label lookup for deployed apps
storageLabels := make(map[string]string) // stack name → storage label
@@ -486,6 +489,9 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
}
data["MigrateTargets"] = targets
data["MigrateCurrent"] = current
if label, missing := s.missingStorageLabel(current); missing {
data["MissingStorageLabel"] = label
}
}
s.executeTemplate(w, r, "app_info", data)
@@ -1116,6 +1122,41 @@ func (s *Server) appsUsingPath(storagePath string) []string {
return appsUsingPathIn(s.stackMgr.GetStacks(), s.stackMgr.LoadAppConfigByName, storagePath)
}
// missingStorageLabel reports whether a deployed app's HDD_PATH resolves to an UNAVAILABLE registry
// path (decommissioned, disconnected, or no longer registered) and returns its human label. An app
// with no HDD_PATH (SSD-resident) is never "missing".
func (s *Server) missingStorageLabel(hddPath string) (string, bool) {
if hddPath == "" {
return "", false
}
for _, sp := range s.settings.GetStoragePaths() {
if sp.Path == hddPath {
if sp.Decommissioned || sp.Disconnected {
return s.settings.GetStorageLabel(hddPath), true
}
return "", false // present + available
}
}
return s.settings.GetStorageLabel(hddPath), true // not in registry → its drive is gone
}
// missingStorageMap returns stack-name → storage label for every deployed app whose data drive is
// currently unavailable (drives the "Hiányzó tárhely" dashboard/stacks/app-card indicator).
func (s *Server) missingStorageMap(list []stacks.Stack) map[string]string {
out := map[string]string{}
for _, st := range list {
if !st.Deployed {
continue
}
if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil {
if label, missing := s.missingStorageLabel(cfg.Env["HDD_PATH"]); missing {
out[st.Name] = label
}
}
}
return out
}
// appsUsingPathIn is the pure core of appsUsingPath (testable without a live stacks.Manager): the
// deployed apps whose data dir (app.yaml HDD_PATH) is exactly storagePath, by display name. This is
// the "name the apps that break" list for the type-to-confirm wipe/eject UI.
+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"`
@@ -27,14 +27,15 @@ func TestTemplatesParse(t *testing.T) {
// mockAgent records calls so tests can assert the refusal path performs NO mount/destructive action.
type mockAgent struct {
disks agentapi.DisksResponse
formatRes agentapi.FormatResult
formatErr error
assignErr error
assignCalls []assignCall
disksCalls int
formatCalls []formatCall
guestAttachCalls []string
disks agentapi.DisksResponse
formatRes agentapi.FormatResult
formatErr error
assignErr error
assignCalls []assignCall
disksCalls int
formatCalls []formatCall
guestAttachCalls []string
decommissionCalls []string
}
type assignCall struct{ uuid, where, fstype string }
@@ -58,6 +59,10 @@ func (m *mockAgent) AssignDisk(_ context.Context, uuid, where, fstype, _ string)
func (m *mockAgent) EjectDisk(_ context.Context, where string) (agentapi.EjectResult, error) {
return agentapi.EjectResult{Ejected: where}, nil
}
func (m *mockAgent) Decommission(_ context.Context, where string) (agentapi.DecommissionResult, error) {
m.decommissionCalls = append(m.decommissionCalls, where)
return agentapi.DecommissionResult{Decommissioned: where}, nil
}
func (m *mockAgent) GuestAttach(_ context.Context, where string) error {
m.guestAttachCalls = append(m.guestAttachCalls, where)
return nil
@@ -24,6 +24,13 @@
</div>
</div>
{{if .MissingStorageLabel}}
<div class="alert alert-warning" style="margin-top:1rem">
<strong>⚠ Hiányzó tárhely: {{.MissingStorageLabel}}</strong><br>
Ennek az alkalmazásnak az adattárolója jelenleg nem elérhető, ezért le van állítva. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat egy másik tárhelyre.
</div>
{{end}}
<!-- Hero section -->
<div class="app-info-hero">
<img class="app-info-logo" src="{{logoURL .Meta.Slug}}"
@@ -149,6 +149,7 @@
<div class="stack-actions">
<span class="stack-state-label">{{stateLabel .State}}</span>
{{if .Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
{{$ms := index $.MissingStorage .Name}}{{if $ms}}<span class="badge badge-missing-storage" title="Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat egy másik tárhelyre.">⚠ Hiányzó tárhely: {{$ms}}</span>{{end}}
{{if and .Deployed (routeUnpublished .State)}}<span class="badge badge-route-unpublished" title="A proxy (Traefik) csak egészséges konténerhez publikál nyilvános útvonalat. Amíg az alkalmazás nem egészséges, az URL 404-et ad, pedig a konténer fut.">⚠ URL nem elérhető</span>{{end}}
{{if .Protected}}
@@ -38,6 +38,7 @@
</div>
<span class="stack-state-badge state-{{stateColor .State}}">{{stateLabel .State}}</span>
{{if .Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
{{$ms := index $.MissingStorage .Name}}{{if $ms}}<span class="badge badge-missing-storage" title="Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat.">⚠ Hiányzó tárhely: {{$ms}}</span>{{end}}
</div>
{{if .Meta.Description}}
@@ -1249,6 +1249,13 @@ a.stat-card:hover {
color: var(--orange);
}
/* B2b: a deployed app whose data drive was decommissioned/disconnected — distinct warning, not error-spam. */
.badge-missing-storage {
background: var(--orange-bg);
color: var(--orange);
white-space: nowrap;
}
/* F5: an unhealthy/restarting deployed app has its public route withheld by Traefik (404 at the URL). */
.badge-route-unpublished {
background: var(--orange-bg);