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:
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user