controller v0.68.0: storage lifecycle on intermediary model (H2/H3/M1/M3 + boot-id)
H2 decommission UI button (migrate / anyway); H3 one-click re-enroll of a
decommissioned drive; M1 default reassignment (auto-promote + block-if-none);
M3 migrate re-asserts 2775 setgid on userdata dirs; deterministic guest-reboot
recreate via agent boot_id (replaces the timed sample). Fixes the {path}/{where}
H1 JS bug. Non-hollow tests + companions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -275,6 +275,9 @@ func (d DiskInfo) FSUUID() string {
|
||||
type DisksResponse struct {
|
||||
VMID int `json:"vmid"`
|
||||
Disks []DiskInfo `json:"disks"`
|
||||
// GuestBootID changes on every guest boot (host or guest reboot) but is stable across a
|
||||
// controller-only restart — the deterministic signal the controller recreates drive-backed apps on.
|
||||
GuestBootID string `json:"guest_boot_id,omitempty"`
|
||||
}
|
||||
|
||||
// FormatResult mirrors POST /disks/format (the success/refusal payload).
|
||||
|
||||
@@ -39,6 +39,11 @@ type Settings struct {
|
||||
// Cross-drive restic repo password (auto-generated on first use)
|
||||
CrossDriveResticPassword string `json:"cross_drive_restic_password,omitempty"`
|
||||
|
||||
// Last-seen guest boot-id (intermediary-mount model): persisted so the controller can detect a guest
|
||||
// reboot across its own restart (it restarts with the guest) and deterministically recreate
|
||||
// drive-backed apps once the agent re-propagates the drive.
|
||||
LastGuestBootID string `json:"last_guest_boot_id,omitempty"`
|
||||
|
||||
// Hub verification state
|
||||
HubVerified bool `json:"hub_verified,omitempty"`
|
||||
HubVerifiedAt string `json:"hub_verified_at,omitempty"` // RFC3339
|
||||
@@ -931,6 +936,21 @@ func (s *Settings) SetHubVerified(verified bool, at time.Time) error {
|
||||
}
|
||||
|
||||
// SetHubLastCheck updates the last Hub check timestamp without changing verification status.
|
||||
// GetLastGuestBootID returns the persisted last-seen guest boot-id ("" if never recorded).
|
||||
func (s *Settings) GetLastGuestBootID() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.LastGuestBootID
|
||||
}
|
||||
|
||||
// SetLastGuestBootID persists the current guest boot-id (after a deterministic boot-recreate pass).
|
||||
func (s *Settings) SetLastGuestBootID(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.LastGuestBootID = id
|
||||
return s.save()
|
||||
}
|
||||
|
||||
func (s *Settings) SetHubLastCheck(at time.Time) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -851,9 +851,12 @@ func walkMerge(lg *log.Logger, srcNS, dstNS string, skip map[string]bool, assert
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// #8 (v0.66.0): preserve the SOURCE dir's full mode (incl. setgid) + group, so the userdata
|
||||
// ownership convention (2775 setgid, gid 1000) survives a whole-drive migration. MkdirAll's
|
||||
// mode is umask-masked + drops setgid, so re-stamp explicitly from the source.
|
||||
// M3: userdata dirs ALWAYS get the 2775-setgid/gid-1000 convention RE-ASSERTED (not merely
|
||||
// source-preserved), so a PRE-EXISTING stale 755 target dir is corrected regardless of the
|
||||
// source mode. Other dirs keep #8's source-mode preservation.
|
||||
if isUserdataDir(rel) {
|
||||
return appbackup.EnsureUserdataDir(dst)
|
||||
}
|
||||
return preserveDirOwnership(dst, d)
|
||||
}
|
||||
|
||||
@@ -1018,6 +1021,14 @@ func copyFile(src, dst string) (int64, error) {
|
||||
|
||||
// preserveDirOwnership re-stamps a freshly-created target dir with the SOURCE dir's full mode (incl.
|
||||
// setgid) and group — part of the #8 fix so the userdata convention survives a migration.
|
||||
// isUserdataDir reports whether a namespace-relative path is the userdata tree (the customer-facing
|
||||
// shared-content area) — `userdata` itself or anything under it — which must carry the 2775-setgid
|
||||
// convention. Normalised to forward slashes so it matches on any host.
|
||||
func isUserdataDir(rel string) bool {
|
||||
r := filepath.ToSlash(rel)
|
||||
return r == "userdata" || strings.HasPrefix(r, "userdata/")
|
||||
}
|
||||
|
||||
func preserveDirOwnership(dst string, d fs.DirEntry) error {
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package stacks
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsUserdataDir pins which namespace-relative dirs the migrate merge-walk routes through the
|
||||
// 2775-setgid re-assert (EnsureUserdataDir) vs plain source-mode preservation. This is the M3 routing
|
||||
// decision: only the customer-facing `userdata` tree gets the convention ENFORCED on pre-existing target
|
||||
// dirs.
|
||||
//
|
||||
// COMPANION GUARD: the pre-fix merge-walk had NO userdata branch (everything went through
|
||||
// preserveDirOwnership, which copies the SOURCE mode) — equivalent to isUserdataDir always returning
|
||||
// false. The `userdata/...` → true cases below fail that impl, so a stale 755 target userdata dir would
|
||||
// keep its 755 instead of becoming 2775.
|
||||
func TestIsUserdataDir(t *testing.T) {
|
||||
yes := []string{"userdata", "userdata/import", "userdata/import/calibre", "userdata/media/movies"}
|
||||
no := []string{"appdata", "appdata/romm", "backups", "media", "userdataX", "", "."}
|
||||
for _, r := range yes {
|
||||
if !isUserdataDir(r) {
|
||||
t.Errorf("isUserdataDir(%q) = false, want true", r)
|
||||
}
|
||||
}
|
||||
for _, r := range no {
|
||||
if isUserdataDir(r) {
|
||||
t.Errorf("isUserdataDir(%q) = true, want false (only the userdata tree gets the setgid re-assert)", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,18 @@ import (
|
||||
func TestFinalizeDecommission_SoftMarksAndCallsAgent(t *testing.T) {
|
||||
s := testServer(t)
|
||||
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/old", Schedulable: true, IsDefault: true})
|
||||
// The migrate target is a registered schedulable drive (realistic for migrate-then-decommission); M1
|
||||
// promotes it to default since /mnt/old was the default.
|
||||
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/new", Schedulable: true})
|
||||
agent := &mockAgent{}
|
||||
|
||||
if err := s.finalizeDecommissionWith(context.Background(), agent, "/mnt/old", "/mnt/new"); err != nil {
|
||||
t.Fatalf("finalize: %v", err)
|
||||
}
|
||||
// M1: the default was reassigned to the migrate target (never zero default).
|
||||
if s.settings.GetDefaultStoragePath() != "/mnt/new" {
|
||||
t.Errorf("default not reassigned to /mnt/new (M1), got %q", s.settings.GetDefaultStoragePath())
|
||||
}
|
||||
// soft-marked, entry retained
|
||||
if !s.settings.IsDecommissioned("/mnt/old") {
|
||||
t.Errorf("path not soft-marked decommissioned")
|
||||
|
||||
@@ -43,23 +43,51 @@ func agentWhere(registeredPath string) string {
|
||||
// stackStartedRecently heuristically reports whether the stack's containers started within ~5 minutes
|
||||
// (i.e. a fresh guest boot, not a long-running app across a controller-only restart) — read from docker's
|
||||
// human "Up X …" status string. Used to limit the startup boot-stale recreate to the guest-reboot case.
|
||||
func (s *Server) stackStartedRecently(name string, _ time.Duration) bool {
|
||||
st, ok := s.stackMgr.GetStack(name)
|
||||
if !ok {
|
||||
// shouldRecreateOnBoot is the PURE decision for the boot-id recreate: on a fresh guest boot, which
|
||||
// deployed drive-backed app to recreate onto its (re-propagated) drive. Recreate every app whose drive
|
||||
// is present (BoundUnderParent) AND that docker BROUGHT BACK on this boot (State not stopped/not_deployed
|
||||
// — i.e. its containers exist). A cleanly user-stopped app (compose down → no containers → Stopped) is
|
||||
// respected; a gate-stopped app is the gate's job (StoppedStacks). Deterministic — depends only on
|
||||
// deployed + drive-present + state, NOT a fragile container-uptime sample (the old `stackStartedRecently`
|
||||
// missed an app that was healthy-but-stale or stopped at the sample instant).
|
||||
func shouldRecreateOnBoot(deployed bool, hdd string, state stacks.ContainerState, presentStable map[string]bool) bool {
|
||||
if !deployed || hdd == "" || !strings.HasPrefix(hdd, StableParentDir+"/") || !presentStable[hdd] {
|
||||
return false
|
||||
}
|
||||
for _, c := range st.Containers {
|
||||
status := strings.ToLower(c.Status)
|
||||
if strings.Contains(status, "second") || strings.Contains(status, "about a minute") {
|
||||
return true
|
||||
return state != stacks.StateStopped && state != stacks.StateNotDeployed
|
||||
}
|
||||
|
||||
// defaultPromotionTarget decides M1 (never leave zero default). If the path being decommissioned is NOT
|
||||
// the current default → ("", false): nothing to do. If it IS the default → pick a promotion target from
|
||||
// the OTHER schedulable, non-decommissioned paths (preferring `migratedTo` — the migrate-then-decommission
|
||||
// target — when valid); ("", true) = MUST BLOCK (no other usable drive exists). Pure → unit-testable.
|
||||
func defaultPromotionTarget(paths []settings.StoragePath, decommissioning, migratedTo string) (target string, mustBlock bool) {
|
||||
isDefault := false
|
||||
for _, sp := range paths {
|
||||
if sp.Path == decommissioning && sp.IsDefault {
|
||||
isDefault = true
|
||||
}
|
||||
for _, m := range []string{"up 1 minute", "up 2 minute", "up 3 minute", "up 4 minute"} {
|
||||
if strings.Contains(status, m) {
|
||||
}
|
||||
if !isDefault {
|
||||
return "", false
|
||||
}
|
||||
usable := func(p string) bool {
|
||||
for _, sp := range paths {
|
||||
if sp.Path == p && sp.Path != decommissioning && sp.Schedulable && !sp.Decommissioned {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
if migratedTo != "" && usable(migratedTo) {
|
||||
return migratedTo, false
|
||||
}
|
||||
for _, sp := range paths {
|
||||
if sp.Path != decommissioning && sp.Schedulable && !sp.Decommissioned {
|
||||
return sp.Path, false
|
||||
}
|
||||
}
|
||||
return "", true // no other usable drive → block the decommission
|
||||
}
|
||||
|
||||
// appsOnStoragePath returns the deployed stack names whose HDD_PATH equals the given (stable) storage
|
||||
@@ -204,14 +232,14 @@ func (s *Server) ReconcileDriveGates() {
|
||||
// periodic gate.
|
||||
func (s *Server) driveGateLoop() {
|
||||
// Wait for the stack scan to complete (GetStacks empty at NewServer time) and the agent to come up,
|
||||
// so the one-time boot-stale recreate sees the real deployed apps + drive state. Bounded poll.
|
||||
// so the boot-id recreate sees the real deployed apps + drive state. Bounded poll.
|
||||
for i := 0; i < 30; i++ {
|
||||
if s.stackMgr != nil && len(s.stackMgr.GetStacks()) > 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
s.recreateBootStaleApps()
|
||||
s.processGuestBootChange()
|
||||
s.ReconcileDriveGates()
|
||||
t := time.NewTicker(30 * time.Second)
|
||||
defer t.Stop()
|
||||
@@ -220,16 +248,16 @@ func (s *Server) driveGateLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// recreateBootStaleApps converges a GUEST REBOOT deterministically. On a guest reboot docker auto-starts
|
||||
// the app containers (restart:unless-stopped) potentially BEFORE the agent has re-propagated the drive
|
||||
// under the parent — so those containers bind the empty fail-closed stable dir (and the non-recursive
|
||||
// parent bind + leaf-bind pinning means they never pick up the later propagation in their own ns). This
|
||||
// runs ONCE at controller startup (the controller itself restarts with the guest): for every deployed
|
||||
// drive-backed app whose drive is NOW present (BoundUnderParent) AND whose containers started recently
|
||||
// (a fresh guest boot, not a long-running app across a controller-only restart), it recreates the app
|
||||
// (Stop=down + Start=up) so it binds the populated path. Apps whose drive is still absent are left to the
|
||||
// normal gate (stop→return→restart). Best-effort.
|
||||
func (s *Server) recreateBootStaleApps() {
|
||||
// processGuestBootChange converges a GUEST REBOOT DETERMINISTICALLY. On a guest reboot docker auto-starts
|
||||
// the app containers (restart:unless-stopped) potentially BEFORE the agent re-propagates the drive under
|
||||
// the parent — so they bind the empty fail-closed stable dir and (non-recursive parent bind + leaf-bind
|
||||
// pinning) never pick up the later propagation. The agent reports a `guest_boot_id` that changes on every
|
||||
// guest boot but is stable across a controller-only restart; the controller persists the last-seen value.
|
||||
// When it changes (the controller restarts WITH the guest), this recreates every deployed drive-backed
|
||||
// app whose drive is present + that docker brought back (`shouldRecreateOnBoot`) onto the populated path,
|
||||
// then persists the new boot-id. Apps on a still-absent drive are handled by the normal gate
|
||||
// (stop→return→restart). Replaces the old fragile container-uptime sample. Best-effort.
|
||||
func (s *Server) processGuestBootChange() {
|
||||
if s.settings == nil || s.stackMgr == nil {
|
||||
return
|
||||
}
|
||||
@@ -243,6 +271,9 @@ func (s *Server) recreateBootStaleApps() {
|
||||
if derr != nil {
|
||||
return
|
||||
}
|
||||
if resp.GuestBootID == "" || resp.GuestBootID == s.settings.GetLastGuestBootID() {
|
||||
return // no boot-id, or unchanged (controller-only restart) → no recreate
|
||||
}
|
||||
presentStable := map[string]bool{}
|
||||
for _, d := range resp.Disks {
|
||||
if d.GuestPath != "" && d.BoundUnderParent {
|
||||
@@ -254,25 +285,18 @@ func (s *Server) recreateBootStaleApps() {
|
||||
if cfg == nil {
|
||||
continue
|
||||
}
|
||||
hdd := cfg.Env["HDD_PATH"]
|
||||
if hdd == "" || !strings.HasPrefix(hdd, StableParentDir+"/") || !presentStable[hdd] {
|
||||
if !shouldRecreateOnBoot(cfg.Deployed, cfg.Env["HDD_PATH"], st.State, presentStable) {
|
||||
continue
|
||||
}
|
||||
// Recreate when the app is boot-stale or not cleanly running: recently-started (it likely came up
|
||||
// on the empty bind before the drive was re-propagated) OR currently exited/restarting/unhealthy
|
||||
// (came up wrong and bailed). SKIP a healthy long-running app (no bounce on a controller-only
|
||||
// restart) and a cleanly user-Stopped app (respect the user's intent).
|
||||
needs := s.stackStartedRecently(st.Name, 5*time.Minute) ||
|
||||
st.State == stacks.StateExited || st.State == stacks.StateRestarting || st.State == stacks.StateUnhealthy
|
||||
if !needs {
|
||||
continue
|
||||
}
|
||||
s.logger.Printf("[INFO] [gate] startup: recreating drive-backed app %s (state=%s) onto its drive %s", st.Name, st.State, hdd)
|
||||
s.logger.Printf("[INFO] [gate] boot %s: recreating drive-backed app %s (state=%s) onto %s", resp.GuestBootID, st.Name, st.State, cfg.Env["HDD_PATH"])
|
||||
_ = s.stackMgr.StopStack(st.Name)
|
||||
if serr := s.stackMgr.StartStack(st.Name); serr != nil {
|
||||
s.logger.Printf("[WARN] [gate] startup recreate %s: %v", st.Name, serr)
|
||||
s.logger.Printf("[WARN] [gate] boot recreate %s: %v", st.Name, serr)
|
||||
}
|
||||
}
|
||||
if serr := s.settings.SetLastGuestBootID(resp.GuestBootID); serr != nil {
|
||||
s.logger.Printf("[WARN] [gate] persist boot-id: %v", serr)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- H1 endpoints (the UI's settings.js calls these; previously 404/unrouted) -----------------
|
||||
@@ -302,17 +326,30 @@ func (s *Server) handleStorageDisconnect(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// handleStorageReconnect re-attaches a returned drive without restart: agent-attach the felhom-data bind
|
||||
// under the parent (live), restart the gate-stopped apps, clear the disconnected mark.
|
||||
// handleStorageReconnect re-enrolls a DISCONNECTED **or** DECOMMISSIONED drive in one click (H3):
|
||||
// (for a decommissioned drive) clears the soft marker + restores schedulable, then re-attaches the
|
||||
// felhom-data under the parent (agent, raw path, no reboot) and restarts the gate-stopped apps. The data
|
||||
// is intact — decommission is non-destructive — so the apps resolve once the bind is live.
|
||||
func (s *Server) handleStorageReconnect(w http.ResponseWriter, r *http.Request) {
|
||||
where, ok := s.gateWhere(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
decommissioned := s.settings.IsDecommissioned(where)
|
||||
var stopped []string
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.Path == where {
|
||||
stopped = sp.StoppedStacks
|
||||
}
|
||||
}
|
||||
if decommissioned {
|
||||
if _, cerr := s.reEnrollClearMarker(where); cerr != nil {
|
||||
writeDiskJSON(w, http.StatusInternalServerError, false, cerr.Error(), nil)
|
||||
return
|
||||
}
|
||||
// decommission-anyway stopped the apps WITHOUT persisting StoppedStacks — re-discover them.
|
||||
stopped = s.appsOnStoragePath(where)
|
||||
}
|
||||
agent, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
@@ -328,7 +365,7 @@ func (s *Server) handleStorageReconnect(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
go s.SyncFileBrowserMounts()
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"where": where, "restarted": stopped})
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"where": where, "restarted": stopped, "reenrolled": decommissioned})
|
||||
}
|
||||
|
||||
// handleStorageRestartApps restarts the gate-stopped apps on a path (without changing connection state) —
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
func TestAgentWhere(t *testing.T) {
|
||||
@@ -20,6 +21,64 @@ func TestAgentWhere(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestShouldRecreateOnBoot pins the deterministic boot-id recreate decision.
|
||||
//
|
||||
// COMPANION GUARD: the OLD timed-sample (`stackStartedRecently || State∈{exited,restarting,unhealthy}`)
|
||||
// MISSED a healthy-but-stale Running app sampled minutes after boot — the first case below would be
|
||||
// `false` under it. The boot-id path recreates it (it came back on this boot, drive present). It still
|
||||
// respects a cleanly user-Stopped app and never touches absent-drive / SSD / not-deployed apps.
|
||||
func TestShouldRecreateOnBoot(t *testing.T) {
|
||||
present := map[string]bool{"/mnt/felhom-drives/felhom-flash": true}
|
||||
cases := []struct {
|
||||
name string
|
||||
deployed bool
|
||||
hdd string
|
||||
state stacks.ContainerState
|
||||
want bool
|
||||
}{
|
||||
{"healthy-but-stale (old sample MISSED this)", true, "/mnt/felhom-drives/felhom-flash", stacks.StateRunning, true},
|
||||
{"exited", true, "/mnt/felhom-drives/felhom-flash", stacks.StateExited, true},
|
||||
{"unhealthy", true, "/mnt/felhom-drives/felhom-flash", stacks.StateUnhealthy, true},
|
||||
{"user-stopped (respected)", true, "/mnt/felhom-drives/felhom-flash", stacks.StateStopped, false},
|
||||
{"not-deployed", true, "/mnt/felhom-drives/felhom-flash", stacks.StateNotDeployed, false},
|
||||
{"drive absent (gate handles)", true, "/mnt/felhom-drives/felhom-usb", stacks.StateRunning, false},
|
||||
{"SSD path never", true, "/mnt/sys_drive/felhom-data", stacks.StateRunning, false},
|
||||
{"app.yaml not deployed", false, "/mnt/felhom-drives/felhom-flash", stacks.StateRunning, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := shouldRecreateOnBoot(c.deployed, c.hdd, c.state, present); got != c.want {
|
||||
t.Errorf("%s: shouldRecreateOnBoot = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultPromotionTarget pins M1 (never leave zero default).
|
||||
//
|
||||
// COMPANION GUARD: the pre-fix decommission blanked the default and promoted nothing — equivalent to this
|
||||
// always returning ("", false). The "promote another" + "block when only drive" cases below fail that.
|
||||
func TestDefaultPromotionTarget(t *testing.T) {
|
||||
flash := "/mnt/felhom-drives/felhom-flash"
|
||||
usb := "/mnt/felhom-drives/felhom-usb"
|
||||
// decommissioning a NON-default → no action.
|
||||
paths := []settings.StoragePath{{Path: flash, IsDefault: true, Schedulable: true}, {Path: usb, Schedulable: true}}
|
||||
if tgt, blk := defaultPromotionTarget(paths, usb, ""); tgt != "" || blk {
|
||||
t.Fatalf("non-default decommission: got (%q,%v), want (\"\",false)", tgt, blk)
|
||||
}
|
||||
// decommissioning the DEFAULT with another usable → promote it.
|
||||
if tgt, blk := defaultPromotionTarget(paths, flash, ""); tgt != usb || blk {
|
||||
t.Fatalf("default decommission: got (%q,%v), want (%q,false)", tgt, blk, usb)
|
||||
}
|
||||
// prefer the migrate target when valid.
|
||||
if tgt, _ := defaultPromotionTarget(paths, flash, usb); tgt != usb {
|
||||
t.Fatalf("should prefer migrate target %q, got %q", usb, tgt)
|
||||
}
|
||||
// the ONLY usable drive (other is decommissioned) → BLOCK.
|
||||
only := []settings.StoragePath{{Path: flash, IsDefault: true, Schedulable: true}, {Path: usb, Decommissioned: true}}
|
||||
if tgt, blk := defaultPromotionTarget(only, flash, ""); tgt != "" || !blk {
|
||||
t.Fatalf("only-drive decommission: got (%q,%v), want (\"\",true)", tgt, blk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStablePathForName(t *testing.T) {
|
||||
if got := stablePathForName("felhom-usb"); got != "/mnt/felhom-drives/felhom-usb" {
|
||||
t.Errorf("stablePathForName = %q", got)
|
||||
|
||||
@@ -429,9 +429,23 @@ func (s *Server) finalizeDecommission(ctx context.Context, where, migratedTo str
|
||||
// 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)
|
||||
|
||||
@@ -247,6 +247,7 @@ function pollUntilBack() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="storage-path-actions">
|
||||
<button class="btn btn-xs btn-primary" onclick="storageReEnroll('{{.Path}}','{{.Label}}')">Visszacsatlakoztatás</button>
|
||||
<form method="POST" action="/settings/storage/remove" style="display:inline"
|
||||
onsubmit="return confirm('Biztosan eltávolítja a(z) {{.Label}} ({{.Path}}) meghajtót a rendszerből?\n\nA meghajtó adatai NEM törlődnek.')">
|
||||
{{$.CSRFField}}
|
||||
@@ -343,6 +344,7 @@ function pollUntilBack() {
|
||||
<button class="btn btn-xs btn-outline" onclick="storageMigrateAll('{{.Path}}','{{.Label}}')">Összes adat áthelyezése</button>
|
||||
</span>
|
||||
{{end}}
|
||||
<button class="btn btn-xs btn-danger-outline" onclick="storageDecommission('{{.Path}}','{{.Label}}',{{.AppCount}})">Leszerelés</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -1130,7 +1132,7 @@ function storageDisconnect(path, label, appCount) {
|
||||
fetch('/api/storage/disconnect', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({path: path})
|
||||
body: JSON.stringify({where: path})
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (data.ok) {
|
||||
alert('A meghajtó biztonságosan eltávolítható.');
|
||||
@@ -1146,7 +1148,7 @@ function storageReconnect(path) {
|
||||
fetch('/api/storage/reconnect', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({path: path})
|
||||
body: JSON.stringify({where: path})
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (data.ok) {
|
||||
location.reload();
|
||||
@@ -1163,19 +1165,56 @@ function storageRestartApps(path) {
|
||||
fetch('/api/storage/restart-apps', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({path: path})
|
||||
body: JSON.stringify({where: path})
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (data.ok) {
|
||||
var msg = '';
|
||||
if (data.started && data.started.length) msg += 'Elindítva: ' + data.started.join(', ');
|
||||
if (data.failed && data.failed.length) msg += (msg ? '\n' : '') + 'Sikertelen: ' + data.failed.join(', ');
|
||||
if (msg) alert(msg);
|
||||
var r2 = data.restarted || [];
|
||||
if (r2.length) alert('Elindítva: ' + r2.join(', '));
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Hiba: ' + (data.error || 'ismeretlen'));
|
||||
}
|
||||
}).catch(function(e) { alert('Hiba: ' + e); });
|
||||
}
|
||||
// H2: decommission a drive (non-destructive). If a migrate target is picked in the inline select →
|
||||
// migrate-then-decommission; otherwise decommission-anyway with type-to-confirm.
|
||||
function storageDecommission(path, label, appCount) {
|
||||
var sel = document.getElementById('migrate-target-' + path);
|
||||
var target = sel ? sel.value : '';
|
||||
var body;
|
||||
if (target) {
|
||||
if (!confirm('Leszerelés áthelyezéssel: minden adat átmásolása ide: ' + target + ', majd a(z) ' + label + ' leszerelése?\n\nAz adatok NEM törlődnek.')) return;
|
||||
body = {where: path, mode: 'migrate', target: target};
|
||||
} else {
|
||||
var name = path.split('/').pop();
|
||||
var typed = prompt('A(z) ' + label + ' leszereléséhez (áthelyezés nélkül) írja be a meghajtó nevét megerősítésként:\n\n' + name +
|
||||
(appCount > 0 ? '\n\nFIGYELEM: ' + appCount + ' alkalmazás leáll (az adatok megmaradnak).' : ''));
|
||||
if (typed === null) return;
|
||||
if (typed.trim() !== name) { alert('A név nem egyezik — megszakítva.'); return; }
|
||||
body = {where: path, mode: 'anyway', mount_name: name};
|
||||
}
|
||||
fetch('/api/storage/decommission', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify(body)
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (data.ok) { alert('Leszerelés elindítva.'); location.reload(); }
|
||||
else { alert('Hiba: ' + (data.error || 'ismeretlen')); }
|
||||
}).catch(function(e) { alert('Hiba: ' + e); });
|
||||
}
|
||||
// H3: one-click re-enroll a decommissioned/ejected drive — clears the marker, re-attaches under the
|
||||
// parent, restarts the gate-stopped apps (data is intact).
|
||||
function storageReEnroll(path, label) {
|
||||
if (!confirm('Visszacsatlakoztatja a(z) ' + label + ' meghajtót?\n\nAz adatok érintetlenek; a leállított alkalmazások újraindulnak.')) return;
|
||||
fetch('/api/storage/reconnect', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({where: path})
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (data.ok) { location.reload(); }
|
||||
else { alert('Hiba: ' + (data.error || 'ismeretlen')); }
|
||||
}).catch(function(e) { alert('Hiba: ' + e); });
|
||||
}
|
||||
function cancelEditLabel(path, label) {
|
||||
var wrap = document.getElementById('label-wrap-' + path);
|
||||
if (!wrap) return;
|
||||
|
||||
Reference in New Issue
Block a user