Compare commits

...

3 Commits

Author SHA1 Message Date
admin f2596ea433 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>
2026-06-14 20:08:52 +02:00
admin 16a4c3e878 B1: migrate UI wiring — /api/storage/migrate{,-app,/status} + settings & app pages
ServeStorageAPI gains POST /api/storage/migrate (whole-namespace), POST
/api/storage/migrate-app (single app), GET /api/storage/migrate/status (poll).
settings.html: the greyed migrate-all span becomes a real target-select + button +
shared progress panel; app_info.html gains a per-app 'Áthelyezés másik tárhelyre'
control. Both poll the shared status endpoint and render Hungarian phase progress.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:48:22 +02:00
admin b57150e3ab B1: data-migration engine (MigrateAll + MigrateApp) + backup mutual-exclusion
internal/stacks/migrate.go: crash-safe, resumable namespace migration over the
controller's /mnt RW mount. Two entry points (whole-namespace + per-app) share one
journaled pipeline: validate -> stop -> copy (rsync -a --checksum, additive; conflict-
merge walk for non-app content) -> verify -> flip+redeploy (RedeployFromEnv) -> cleanup.
CLEANUP (the only destructive step) is gated on all units verified AND all apps
redeployed. Single-flight; mutual exclusion with the backup orchestrator (Change 3).
Non-hollow tests incl. mutation-proven collision + cleanup-gate companions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:42:57 +02:00
18 changed files with 2097 additions and 26 deletions
+43
View File
@@ -1,5 +1,48 @@
## Changelog ## Changelog
### v0.65.0 — data migration + self-serve decommission (B1+B2) (2026-06-14)
Customer-self-serve storage **migration** (move app data between drives) and **decommission** (retire
a drive), implemented trunk-based with the locked spike design
(`felhom.eu/documentation/audits/SPIKE-decommission-migration-2026-06-14.md`). Pairs with agent
v0.32.0 (the self-serve `/disks/decommission` endpoint + intent-aware re-assert). Built + deployed to
demo guest 9201. **Live decommission/migration of real data is NOT yet validated — that is the
supervised B3 session.**
- **B1 — migration engine** (`internal/stacks/migrate.go`). In-process over the controller's
`/mnt:/mnt:rslave` RW mount; crash-safe + resumable via a single journal (`<dataDir>/migration.json`).
Two entry points share one pipeline: `MigrateAll` (whole namespace — every app + a conflict-merge
walk for non-app/customer content) and `MigrateApp` (one app subtree; handles drive→drive AND
SSD→drive). Pipeline: validate → stop → copy (`rsync -a --checksum`, additive, NO `--delete`) → verify
(`rsync -ani --checksum`, zero pending) → flip+redeploy (`RedeployFromEnv`, one idempotent unit) →
cleanup. **CLEANUP is the only destructive step and is gated on every unit verified AND every app
redeployed.** Conflict-merge: skip-identical (checksum vs the target file AND its `(N)` siblings),
rename-on-differ to the lowest-free `<base>(N)<ext>`, never overwrite; idempotent (no `(1)(1)`).
Single-flight; **mutual exclusion with the backup orchestrator** (Change 3 — migration refuses while a
backup runs; the scheduled DB-dump/Tier-2 skip while a migration runs).
- **B1 UI**`POST /api/storage/migrate` (whole-namespace), `POST /api/storage/migrate-app` (per-app),
`GET /api/storage/migrate/status` (poll). The greyed migrate-all `<span>` in settings.html is now a
real target-select + button; app_info.html gains a per-app "Áthelyezés másik tárhelyre" control; both
share a Hungarian progress panel.
- **B2b — decommission orchestration** (`handleStorageDecommission`, `POST /api/storage/decommission`).
Two choices, no partial (Change 2): **migrate-all-then-decommission** (runs `MigrateAll`; the
migration done-hook soft-marks the source + calls the agent once every app has moved) or
**decommission-anyway** (type-to-confirm; stops the apps but KEEPS their `HDD_PATH` so they show
"missing storage"). `agentapi.Decommission` added; both branches end at `SetDecommissioned` (soft
marker retained — blocks A1 resurrection) + agent `Decommission`.
- **"Hiányzó tárhely" indicator** — a deployed app whose `HDD_PATH` resolves to a decommissioned/
disconnected/absent registry path now shows a distinct warning badge on the dashboard, stacks page,
and app card (label via `GetStorageLabel`); persists until re-enroll or migrate.
- **Change 4 — re-enroll clears the marker.** `registerStoragePath` now un-retires a re-plugged
decommissioned drive (`ClearDecommissioned` + restore `Schedulable`) — previously `AddStoragePath`
deduped the re-register into a no-op and the soft marker (and the apps' missing-storage badge) would
persist forever. (`ClearDecommissioned` had zero callers before this.)
- Non-hollow tests across `internal/stacks` (engine: collision-refuse, merge dedup/idempotency,
cleanup-only-after-redeploy, verify-catches-corruption, resume, single-flight, SSD→drive, backup
exclusion), `internal/backup` (scheduled backup skipped while migrating), and `internal/web`
(finalize soft-mark+agent, re-enroll clears marker, missing-storage label). Companions for the
collision guard, cleanup gate, and Change-4 clearing were mutation-proven to fail on the pre-fix code.
### v0.64.0 — storage-lifecycle cleanups (2026-06-14) ### v0.64.0 — storage-lifecycle cleanups (2026-06-14)
Two settings-layer cleanups from the F9 storage-registration diagnosis Two settings-layer cleanups from the F9 storage-registration diagnosis
+12
View File
@@ -226,6 +226,14 @@ func main() {
backupMgr.SetVersion(Version) backupMgr.SetVersion(Version)
} }
// --- Wire the data-migration engine (B1) + backup↔migration mutual exclusion (Change 3) ---
stackMgr.SetMigrationDeps(sett, func() bool { return backupMgr != nil && backupMgr.IsRunning() })
if backupMgr != nil {
backupMgr.SetMigrationRunningCheck(stackMgr.IsMigrating)
}
// RecoverMigration is started AFTER the web server's done-hook is wired (below), so a resumed
// decommission-migration still finalizes the source decommission on completion.
// --- Initialize alert manager --- // --- Initialize alert manager ---
alertMgr := web.NewAlertManager(logger) alertMgr := web.NewAlertManager(logger)
@@ -630,6 +638,10 @@ func main() {
// --- Initialize web server --- // --- Initialize web server ---
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, sched, sett, alertMgr, notifier, updater, logger, Version) webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, sched, sett, alertMgr, notifier, updater, logger, Version)
// Migration done-hook: a decommission-initiated migration finalizes the source decommission on
// success (soft-mark + agent). Wire it before RecoverMigration so a resumed one still finalizes.
stackMgr.SetMigrationDoneHook(webServer.OnMigrationDone)
stackMgr.RecoverMigration(ctx)
webServer.SetEncryptionKey(encKey) webServer.SetEncryptionKey(encKey)
webServer.SetAppExporter(appExporter) webServer.SetAppExporter(appExporter)
webServer.SetIntegrationManager(integrationMgr) webServer.SetIntegrationManager(integrationMgr)
+25 -3
View File
@@ -134,10 +134,10 @@ type StatusResponse struct {
// BackupRecord mirrors the agent's hub.Backup — one whole-guest vzdump/PBS backup result. The // BackupRecord mirrors the agent's hub.Backup — one whole-guest vzdump/PBS backup result. The
// controller renders it read-only (it does NOT own whole-guest backup; the agent does). // controller renders it read-only (it does NOT own whole-guest backup; the agent does).
type BackupRecord struct { type BackupRecord struct {
TargetID string `json:"target_id"` // backup storage name (e.g. "local", "felhom-pbs") TargetID string `json:"target_id"` // backup storage name (e.g. "local", "felhom-pbs")
VMID int `json:"vmid"` VMID int `json:"vmid"`
Archive string `json:"archive"` // produced vzdump volid (e.g. "local:backup/vzdump-lxc-…") Archive string `json:"archive"` // produced vzdump volid (e.g. "local:backup/vzdump-lxc-…")
Mode string `json:"mode"` // snapshot | stop Mode string `json:"mode"` // snapshot | stop
CrashConsistent bool `json:"crash_consistent"` CrashConsistent bool `json:"crash_consistent"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
Success bool `json:"success"` Success bool `json:"success"`
@@ -368,6 +368,28 @@ func (c *Client) EjectDisk(ctx context.Context, where string) (EjectResult, erro
return out, nil return out, nil
} }
// DecommissionResult mirrors POST /disks/decommission.
type DecommissionResult struct {
VMID int `json:"vmid"`
Decommissioned string `json:"decommissioned"`
DependentGuests []int `json:"dependent_guests"`
}
// Decommission permanently removes a user-data drive (self-serve, non-destructive — the agent records
// IntentDecommissioned, prunes the bind record, and unmounts; it NEVER formats). Data stays on the
// drive. The agent role-gates to user-data and refuses a system/backup mount regardless.
func (c *Client) Decommission(ctx context.Context, where string) (DecommissionResult, error) {
var out DecommissionResult
body, err := c.post(ctx, "/disks/decommission", map[string]string{"where": where})
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /disks/decommission: %w", err)
}
return out, nil
}
// FormatDisk asks the agent to format a device. The AGENT inspects the device and tiers it by ROLE // FormatDisk asks the agent to format a device. The AGENT inspects the device and tiers it by ROLE
// (its own classification, never the controller's claim): // (its own classification, never the controller's claim):
// - blank device → formatted. // - blank device → formatted.
+20
View File
@@ -36,6 +36,11 @@ type Manager struct {
discoverDBs func(ctx context.Context) ([]DiscoveredDB, error) discoverDBs func(ctx context.Context) ([]DiscoveredDB, error)
importDBDump func(ctx context.Context, db DiscoveredDB, dumpPath string) error importDBDump func(ctx context.Context, db DiscoveredDB, dumpPath string) error
// migrationRunning, if set, reports whether a data migration is in progress. The scheduled
// backup paths skip when it returns true (Change 3 — backup ↔ migration mutual exclusion), so a
// nightly dump/Tier-2 can't race a migration copy/cleanup on the same drive.
migrationRunning func() bool
mu sync.Mutex mu sync.Mutex
lastDBDump *DBDumpStatus lastDBDump *DBDumpStatus
running bool running bool
@@ -158,8 +163,23 @@ func (m *Manager) groupStacksByDrive() map[string][]StackSummary {
return result return result
} }
// SetMigrationRunningCheck wires the mutual-exclusion guard (Change 3): when fn() reports a
// migration is active, the scheduled backup paths skip rather than race it.
func (m *Manager) SetMigrationRunningCheck(fn func() bool) {
m.migrationRunning = fn
}
// migrationActive reports whether a migration is in progress (false when no check is wired).
func (m *Manager) migrationActive() bool {
return m.migrationRunning != nil && m.migrationRunning()
}
// RunDBDumps discovers and dumps all databases to per-drive, per-app paths. // RunDBDumps discovers and dumps all databases to per-drive, per-app paths.
func (m *Manager) RunDBDumps(ctx context.Context) error { func (m *Manager) RunDBDumps(ctx context.Context) error {
if m.migrationActive() {
m.logger.Printf("[INFO] [backup] DB dump kihagyva: migráció folyamatban")
return nil
}
if err := m.acquireRunning(); err != nil { if err := m.acquireRunning(); err != nil {
return err return err
} }
@@ -0,0 +1,38 @@
package backup
import (
"context"
"io"
"log"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// TestRunDBDumps_SkippedWhileMigrating asserts the scheduled DB-dump path SKIPS when a migration is
// active (Change 3 — backup ↔ migration mutual exclusion), rather than racing the migration. The
// companion: without the migrationActive guard, RunDBDumps would proceed (and a non-nil migration
// check would not matter) — this test FAILS because lastDBDump would be set / discovery attempted.
func TestRunDBDumps_SkippedWhileMigrating(t *testing.T) {
cfg := &config.Config{}
cfg.Paths.SystemDataPath = "/mnt/sys_drive"
m := NewManager(cfg, nil, log.New(io.Discard, "", 0))
m.SetMigrationRunningCheck(func() bool { return true })
if err := m.RunDBDumps(context.Background()); err != nil {
t.Fatalf("RunDBDumps should skip cleanly, got %v", err)
}
// Skipped before runDBDumpsInternal → no status recorded and the running flag never taken.
if m.lastDBDump != nil {
t.Errorf("DB dump ran despite an active migration (lastDBDump set)")
}
if m.IsRunning() {
t.Errorf("running flag left set after a skipped dump")
}
// With no migration active, the guard does not block (it proceeds into discovery).
m.SetMigrationRunningCheck(func() bool { return false })
if m.migrationActive() {
t.Errorf("migrationActive should be false when the check returns false")
}
}
+4
View File
@@ -200,6 +200,10 @@ func (m *Manager) RunAllTier2() {
if m.stackProvider == nil { if m.stackProvider == nil {
return return
} }
if m.migrationActive() {
m.logger.Printf("[INFO] [backup] Tier 2 kihagyva: migráció folyamatban")
return
}
var n int var n int
for _, stack := range m.stackProvider.ListDeployedStacks() { for _, stack := range m.stackProvider.ListDeployedStacks() {
if m.stackProvider.GetStackHDDPath(stack.Name) == "" { if m.stackProvider.GetStackHDDPath(stack.Name) == "" {
+23 -12
View File
@@ -15,6 +15,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto" "gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
) )
// ContainerState represents the current state of a container. // ContainerState represents the current state of a container.
@@ -22,8 +23,8 @@ type ContainerState string
const ( const (
StateRunning ContainerState = "running" StateRunning ContainerState = "running"
StateStarting ContainerState = "starting" // running but health: starting StateStarting ContainerState = "starting" // running but health: starting
StateUnhealthy ContainerState = "unhealthy" // running but health: unhealthy StateUnhealthy ContainerState = "unhealthy" // running but health: unhealthy
StateStopped ContainerState = "stopped" StateStopped ContainerState = "stopped"
StateRestarting ContainerState = "restarting" StateRestarting ContainerState = "restarting"
StateExited ContainerState = "exited" StateExited ContainerState = "exited"
@@ -51,12 +52,12 @@ type HealthProbeResult struct {
// HealthCheckDetail holds the result of a single health check item. // HealthCheckDetail holds the result of a single health check item.
type HealthCheckDetail struct { type HealthCheckDetail struct {
Type string `json:"type"` // "http", "api", "tcp" Type string `json:"type"` // "http", "api", "tcp"
Target string `json:"target"` // e.g. ":3456/api/v1/info" Target string `json:"target"` // e.g. ":3456/api/v1/info"
Healthy bool `json:"healthy"` Healthy bool `json:"healthy"`
Status int `json:"status,omitempty"` // HTTP status code (for http/api) Status int `json:"status,omitempty"` // HTTP status code (for http/api)
Latency string `json:"latency"` // e.g. "45ms" Latency string `json:"latency"` // e.g. "45ms"
Error string `json:"error,omitempty"` // error message if unhealthy Error string `json:"error,omitempty"` // error message if unhealthy
} }
// Stack represents a docker compose stack on disk. // Stack represents a docker compose stack on disk.
@@ -65,14 +66,14 @@ type Stack struct {
Meta Metadata `json:"meta"` Meta Metadata `json:"meta"`
ComposePath string `json:"compose_path"` ComposePath string `json:"compose_path"`
State ContainerState `json:"state"` State ContainerState `json:"state"`
Deployed bool `json:"deployed"` // Has app.yaml with deployed=true Deployed bool `json:"deployed"` // Has app.yaml with deployed=true
Protected bool `json:"protected"` Protected bool `json:"protected"`
Orphaned bool `json:"orphaned"` // Deployed but no catalog template Orphaned bool `json:"orphaned"` // Deployed but no catalog template
Containers []ContainerInfo `json:"containers"` Containers []ContainerInfo `json:"containers"`
AppConfig *AppConfig `json:"app_config,omitempty"` AppConfig *AppConfig `json:"app_config,omitempty"`
Deploying bool `json:"deploying"` // compose up in progress Deploying bool `json:"deploying"` // compose up in progress
DeployError string `json:"deploy_error,omitempty"` // last async deploy error DeployError string `json:"deploy_error,omitempty"` // last async deploy error
HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result
LastUpdated time.Time `json:"last_updated"` LastUpdated time.Time `json:"last_updated"`
} }
@@ -85,6 +86,16 @@ type Manager struct {
mu sync.RWMutex mu sync.RWMutex
encKey []byte // AES-256 key for encrypting sensitive values in app.yaml encKey []byte // AES-256 key for encrypting sensitive values in app.yaml
infraMu sync.Mutex // single-flight guard for EnsureBaseStack (base-infra bring-up/self-heal) infraMu sync.Mutex // single-flight guard for EnsureBaseStack (base-infra bring-up/self-heal)
// Migration engine (B1): single-flight + live job + deps wired via SetMigrationDeps.
migrateMu sync.Mutex
migrating bool
migJob *MigrationJob
settings *settings.Settings
sysDataPath string
backupRunning func() bool // mutual exclusion with the backup orchestrator (Change 3)
migDoneHook func(*MigrationJob) // fired on successful completion (decommission policy lives in caller)
testSeams *migSeams // nil in production; tests inject fakes
} }
// NewManager creates a new stack manager. // NewManager creates a new stack manager.
File diff suppressed because it is too large Load Diff
+398
View File
@@ -0,0 +1,398 @@
package stacks
import (
"context"
"log"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// --- pure helpers: siblingName / lowest-free-N / merge walk ---
func TestSiblingName(t *testing.T) {
cases := map[string]string{
"/d/foo.txt": "/d/foo(1).txt",
"/d/backup.tar.gz": "/d/backup.tar(1).gz", // single (last) extension
"/d/README": "/d/README(1)",
}
for in, want := range cases {
if got := siblingName(filepath.FromSlash(in), 1); got != filepath.FromSlash(want) {
t.Errorf("siblingName(%q) = %q, want %q", in, got, want)
}
}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(b)
}
// TestMergeWalk_Conflicts covers differ→(1), identical→dedup, and re-run idempotency (no (1)(1)).
func TestMergeWalk_Conflicts(t *testing.T) {
lg := log.New(os.Stderr, "", 0)
src := t.TempDir()
dst := t.TempDir()
// identical file at same rel path → should dedup (no sibling)
writeFile(t, filepath.Join(src, "same.txt"), "IDENTICAL")
writeFile(t, filepath.Join(dst, "same.txt"), "IDENTICAL")
// differing file at same rel path → should land as differ(1).txt, original untouched
writeFile(t, filepath.Join(src, "differ.txt"), "SRC")
writeFile(t, filepath.Join(dst, "differ.txt"), "DST")
// fresh file absent at dst → copied as-is
writeFile(t, filepath.Join(src, "sub", "new.txt"), "NEW")
if err := walkMerge(lg, src, dst, nil, false, nil); err != nil {
t.Fatalf("walkMerge: %v", err)
}
if got := readFile(t, filepath.Join(dst, "same.txt")); got != "IDENTICAL" {
t.Errorf("identical file mutated: %q", got)
}
if pathExists(filepath.Join(dst, "same(1).txt")) {
t.Errorf("identical file should NOT create a (1) sibling")
}
if got := readFile(t, filepath.Join(dst, "differ.txt")); got != "DST" {
t.Errorf("existing target overwritten: %q (must never overwrite)", got)
}
if got := readFile(t, filepath.Join(dst, "differ(1).txt")); got != "SRC" {
t.Errorf("differing file should land as differ(1).txt, got %q", got)
}
if got := readFile(t, filepath.Join(dst, "sub", "new.txt")); got != "NEW" {
t.Errorf("fresh file = %q", got)
}
// Re-run must be idempotent: SRC now equals differ(1).txt, so no differ(1)(1).txt accrues.
if err := walkMerge(lg, src, dst, nil, false, nil); err != nil {
t.Fatalf("walkMerge re-run: %v", err)
}
if pathExists(filepath.Join(dst, "differ(1)(1).txt")) {
t.Errorf("re-run proliferated a (1)(1) sibling — NOT idempotent")
}
// A THIRD distinct content for the same rel path → lowest-free N = (2), not (1)(1).
writeFile(t, filepath.Join(src, "differ.txt"), "THIRD")
if err := walkMerge(lg, src, dst, nil, false, nil); err != nil {
t.Fatalf("walkMerge third: %v", err)
}
if got := readFile(t, filepath.Join(dst, "differ(2).txt")); got != "THIRD" {
t.Errorf("third distinct content should be differ(2).txt, got %q", got)
}
if pathExists(filepath.Join(dst, "differ(1)(1).txt")) {
t.Errorf("must not create differ(1)(1).txt")
}
}
// TestMergeWalk_AssertOnly: assert passes when every source file has an identical counterpart, and
// fails when one does not.
func TestMergeWalk_AssertOnly(t *testing.T) {
lg := log.New(os.Stderr, "", 0)
src := t.TempDir()
dst := t.TempDir()
writeFile(t, filepath.Join(src, "a.txt"), "A")
writeFile(t, filepath.Join(dst, "a.txt"), "A")
if err := walkMerge(lg, src, dst, nil, true, nil); err != nil {
t.Errorf("assert should pass when all counterparts identical: %v", err)
}
// add a source file with no counterpart at dst
writeFile(t, filepath.Join(src, "missing.txt"), "M")
if err := walkMerge(lg, src, dst, nil, true, nil); err == nil {
t.Errorf("assert must FAIL when a source file has no identical counterpart")
}
// a (N)-sibling counterpart also satisfies assert
writeFile(t, filepath.Join(dst, "missing(1).txt"), "M")
if err := walkMerge(lg, src, dst, nil, true, nil); err != nil {
t.Errorf("assert should accept a (N)-sibling counterpart: %v", err)
}
}
// TestMergeWalk_SkipDirs confirms app appdata dirs are pruned (handled by rsync).
func TestMergeWalk_SkipDirs(t *testing.T) {
lg := log.New(os.Stderr, "", 0)
src := t.TempDir()
dst := t.TempDir()
appdata := filepath.Join(src, "appdata", "romm")
writeFile(t, filepath.Join(appdata, "rom.bin"), "ROM")
writeFile(t, filepath.Join(src, "media", "movie.mkv"), "VID")
skip := map[string]bool{filepath.Clean(appdata): true}
if err := walkMerge(lg, src, dst, skip, false, nil); err != nil {
t.Fatalf("walkMerge: %v", err)
}
if pathExists(filepath.Join(dst, "appdata", "romm", "rom.bin")) {
t.Errorf("skipped appdata dir should NOT be merged")
}
if !pathExists(filepath.Join(dst, "media", "movie.mkv")) {
t.Errorf("non-app content should be merged")
}
}
// --- orchestration: phase machine via injected seams + real temp-dir FS ---
func newMigManager(t *testing.T, target string) *Manager {
t.Helper()
lg := log.New(os.Stderr, "", 0)
cfg := &config.Config{}
cfg.Paths.DataDir = t.TempDir()
cfg.Paths.SystemDataPath = "/mnt/sys_drive"
m := &Manager{cfg: cfg, logger: lg, stacks: map[string]*Stack{}, sysDataPath: cfg.Paths.SystemDataPath}
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
if target != "" {
_ = sett.AddStoragePath(settings.StoragePath{Path: target, Schedulable: true})
}
m.settings = sett
return m
}
// runJob runs a manually-built job synchronously to a terminal phase.
func (m *Manager) runJobSync(j *MigrationJob) {
_ = m.acquireMigrating()
m.setJob(j)
m.runMigration(context.Background(), j)
}
func newAllJob(srcNS, dstNS, source, target string, apps ...string) *MigrationJob {
j := &MigrationJob{
Scope: "all", Phase: PhaseStop, Source: source, Target: target,
SourceNS: srcNS, TargetNS: dstNS, Apps: apps, Units: map[string]*MigUnit{},
}
for _, a := range apps {
j.Units[a] = &MigUnit{App: a, State: UnitPending}
}
j.Units[nonAppUnit] = &MigUnit{App: nonAppUnit, State: UnitPending}
return j
}
// TestMigration_CleanupOnlyAfterRedeploy: a redeploy failure aborts BEFORE cleanup; source intact.
func TestMigration_CleanupOnlyAfterRedeploy(t *testing.T) {
m := newMigManager(t, "/mnt/target")
srcNS := t.TempDir()
dstNS := t.TempDir()
// real source data so we can assert it survives an abort
writeFile(t, filepath.Join(appbackup.AppDataDir(srcNS, "romm"), "rom.bin"), "ROM")
// seams: copy/verify succeed (no-op), redeploy FAILS for romm.
m.testSeams = &migSeams{
stop: func(string) error { return nil },
copy: func(_ context.Context, _, _ string, _ func(int64)) error { return nil },
verify: func(_ context.Context, _, _ string) error { return nil },
flipRedeploy: func(name, _ string) error {
return os.ErrPermission // simulate app failing to come up
},
}
j := newAllJob(srcNS, dstNS, "/mnt/source", "/mnt/target", "romm")
m.runJobSync(j)
if j.Phase != PhaseAborted {
t.Fatalf("phase = %s, want aborted", j.Phase)
}
if !pathExists(appbackup.AppDataDir(srcNS, "romm")) {
t.Errorf("SOURCE was deleted despite redeploy failure — cleanup must not run before redeploy")
}
}
// TestMigration_HappyPathCleansSource: full success removes the source only after verify+redeploy.
func TestMigration_HappyPathCleansSource(t *testing.T) {
m := newMigManager(t, "/mnt/target")
srcNS := t.TempDir()
dstNS := t.TempDir()
writeFile(t, filepath.Join(appbackup.AppDataDir(srcNS, "romm"), "rom.bin"), "ROM")
writeFile(t, filepath.Join(srcNS, "media", "movie.mkv"), "VID") // non-app content
flips := []string{}
m.testSeams = &migSeams{
stop: func(string) error { return nil },
copy: func(_ context.Context, _, _ string, _ func(int64)) error { return nil },
verify: func(_ context.Context, _, _ string) error { return nil },
flipRedeploy: func(name, target string) error {
flips = append(flips, name+"→"+target)
return nil
},
}
j := newAllJob(srcNS, dstNS, "/mnt/source", "/mnt/target", "romm")
m.runJobSync(j)
if j.Phase != PhaseDone {
t.Fatalf("phase = %s (err=%s), want done", j.Phase, j.Error)
}
if pathExists(appbackup.AppDataDir(srcNS, "romm")) {
t.Errorf("source appdata should be removed after success")
}
if pathExists(filepath.Join(srcNS, "media")) {
t.Errorf("source non-app content should be removed after success")
}
if len(flips) != 1 || flips[0] != "romm→/mnt/target" {
t.Errorf("flipRedeploy calls = %v", flips)
}
}
// TestMigration_VerifyCatchesCorruption: a verify failure aborts; source intact, no cleanup.
func TestMigration_VerifyCatchesCorruption(t *testing.T) {
m := newMigManager(t, "/mnt/target")
srcNS := t.TempDir()
dstNS := t.TempDir()
writeFile(t, filepath.Join(appbackup.AppDataDir(srcNS, "romm"), "rom.bin"), "ROM")
cleanupReached := false
m.testSeams = &migSeams{
stop: func(string) error { return nil },
copy: func(_ context.Context, _, _ string, _ func(int64)) error { return nil },
verify: func(_ context.Context, _, _ string) error { return os.ErrInvalid }, // corruption
flipRedeploy: func(string, string) error {
cleanupReached = true // would only be reachable past verify
return nil
},
}
j := newAllJob(srcNS, dstNS, "/mnt/source", "/mnt/target", "romm")
m.runJobSync(j)
if j.Phase != PhaseAborted {
t.Fatalf("phase = %s, want aborted", j.Phase)
}
if cleanupReached {
t.Errorf("flip/redeploy ran despite a verify failure")
}
if !pathExists(appbackup.AppDataDir(srcNS, "romm")) {
t.Errorf("source deleted despite verify failure")
}
}
// TestMigration_Resume: a journal at Phase=verify with units already copied must NOT re-copy.
func TestMigration_Resume(t *testing.T) {
m := newMigManager(t, "/mnt/target")
srcNS := t.TempDir()
dstNS := t.TempDir()
writeFile(t, filepath.Join(appbackup.AppDataDir(srcNS, "romm"), "rom.bin"), "ROM")
copied := 0
m.testSeams = &migSeams{
stop: func(string) error { return nil },
copy: func(_ context.Context, _, _ string, _ func(int64)) error {
copied++ // must NOT be called on resume from verify
return nil
},
verify: func(_ context.Context, _, _ string) error { return nil },
flipRedeploy: func(string, string) error { return nil },
}
j := newAllJob(srcNS, dstNS, "/mnt/source", "/mnt/target", "romm")
j.Phase = PhaseVerify
j.Units["romm"].State = UnitCopied
j.Units[nonAppUnit].State = UnitCopied
m.runJobSync(j)
if j.Phase != PhaseDone {
t.Fatalf("phase = %s (err=%s), want done", j.Phase, j.Error)
}
if copied != 0 {
t.Errorf("resume re-copied %d subtree(s); should re-copy none", copied)
}
}
// TestMigValidate_CollisionRefused: an existing app dir at target refuses, naming the app. (Companion:
// without the collision guard this returns nil, so the test would fail — non-hollow.)
func TestMigValidate_CollisionRefused(t *testing.T) {
m := newMigManager(t, "/mnt/target")
dstNS := t.TempDir()
m.testSeams = &migSeams{} // skip the rsync-binary check
// pre-existing app dir at the target namespace
writeFile(t, filepath.Join(appbackup.AppDataDir(dstNS, "romm"), "x"), "x")
j := &MigrationJob{Scope: "all", Source: "/mnt/source", Target: "/mnt/target",
SourceNS: t.TempDir(), TargetNS: dstNS, Apps: []string{"romm"}, Units: map[string]*MigUnit{}}
if err := m.migValidate(j); err == nil {
t.Fatalf("expected collision refusal")
} else if !contains(err.Error(), "romm") {
t.Errorf("collision error must name the app, got %q", err)
}
// No collision (fresh target) → validate passes.
j.TargetNS = t.TempDir()
if err := m.migValidate(j); err != nil {
t.Errorf("validate should pass without collision: %v", err)
}
}
// TestMigValidate_BackupExclusion: refuse to start while a backup is running (Change 3).
func TestMigValidate_BackupExclusion(t *testing.T) {
m := newMigManager(t, "/mnt/target")
m.testSeams = &migSeams{}
m.backupRunning = func() bool { return true }
j := &MigrationJob{Scope: "all", Source: "/mnt/source", Target: "/mnt/target",
SourceNS: t.TempDir(), TargetNS: t.TempDir(), Apps: nil, Units: map[string]*MigUnit{}}
if err := m.migValidate(j); err == nil || !contains(err.Error(), "mentés") {
t.Errorf("expected backup-in-progress refusal, got %v", err)
}
}
// TestMigrate_SingleFlight: a second Start while one is active is refused.
func TestMigrate_SingleFlight(t *testing.T) {
m := newMigManager(t, "/mnt/target")
_ = m.acquireMigrating() // simulate an active migration
if _, err := m.MigrateApp(context.Background(), "romm", "/mnt/target"); err == nil || !contains(err.Error(), "folyamatban") {
t.Errorf("second migration should be refused, got %v", err)
}
}
// TestAppSourceNS_SSDToDrive: an app with no HDD_PATH resolves to the system/SSD namespace.
func TestAppSourceNS_SSDToDrive(t *testing.T) {
m := newMigManager(t, "")
// Use filepath.Clean on expectations so the test holds on both Linux (the deploy target) and
// Windows (dev), where Clean uses backslashes.
src, ns := m.appSourceNS(&AppConfig{Env: map[string]string{}})
if src != filepath.Clean("/mnt/sys_drive") {
t.Errorf("SSD app source = %q, want %q", src, filepath.Clean("/mnt/sys_drive"))
}
wantNS := appbackup.NamespaceRoot("/mnt/sys_drive", false) // SSD → felhom-data subdir
if ns != wantNS {
t.Errorf("SSD app ns = %q, want %q", ns, wantNS)
}
// a drive-resident app uses its mount root as the namespace
src2, ns2 := m.appSourceNS(&AppConfig{Env: map[string]string{"HDD_PATH": "/mnt/felhom-usb"}})
if src2 != filepath.Clean("/mnt/felhom-usb") || ns2 != filepath.Clean("/mnt/felhom-usb") {
t.Errorf("drive app resolved to src=%q ns=%q", src2, ns2)
}
}
// TestCleanupGate: the cleanup gate refuses until all units verified AND all apps redeployed.
func TestCleanupGate(t *testing.T) {
m := newMigManager(t, "")
j := &MigrationJob{Scope: "all", Apps: []string{"romm"}, Units: map[string]*MigUnit{
"romm": {App: "romm", State: UnitVerified}, // verified but NOT redeployed
nonAppUnit: {App: nonAppUnit, State: UnitVerified},
}}
if err := m.migCleanupAllowed(j); err == nil {
t.Errorf("gate must refuse cleanup when an app is not redeployed")
}
j.Units["romm"].State = UnitRedeployed
if err := m.migCleanupAllowed(j); err != nil {
t.Errorf("gate should allow cleanup once all verified+redeployed: %v", err)
}
// non-app unit only verified is required; if it regressed below verified, refuse
j.Units[nonAppUnit].State = UnitCopied
if err := m.migCleanupAllowed(j); err == nil {
t.Errorf("gate must refuse when non-app content is not verified")
}
}
func contains(s, sub string) bool { return strings.Contains(s, sub) }
@@ -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)
}
}
+59 -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 := s.baseData("dashboard", "Vezérlőpult")
data["Stacks"] = deployedStacks data["Stacks"] = deployedStacks
data["MissingStorage"] = s.missingStorageMap(deployedStacks)
data["RunningCount"] = running data["RunningCount"] = running
data["StoppedCount"] = stopped data["StoppedCount"] = stopped
data["TotalCount"] = len(stackList) 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) { func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
data := s.baseData("stacks", "Alkalmazások") 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 // Build storage label lookup for deployed apps
storageLabels := make(map[string]string) // stack name → storage label storageLabels := make(map[string]string) // stack name → storage label
@@ -471,6 +474,26 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
data["HasAppInfo"] = found.Meta.HasAppInfo() data["HasAppInfo"] = found.Meta.HasAppInfo()
data["EffectiveSubdomain"] = effectiveSubdomain data["EffectiveSubdomain"] = effectiveSubdomain
// Per-app migration (B1): offer to move this app's data to another connected drive (≠ current).
if found.Deployed {
current := ""
if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil {
current = appCfg.Env["HDD_PATH"]
}
var targets []settings.StoragePath
for _, sp := range s.settings.GetStoragePaths() {
if sp.Path == current || sp.Decommissioned || sp.Disconnected || !sp.Schedulable {
continue
}
targets = append(targets, sp)
}
data["MigrateTargets"] = targets
data["MigrateCurrent"] = current
if label, missing := s.missingStorageLabel(current); missing {
data["MissingStorageLabel"] = label
}
}
s.executeTemplate(w, r, "app_info", data) s.executeTemplate(w, r, "app_info", data)
} }
@@ -1099,6 +1122,41 @@ func (s *Server) appsUsingPath(storagePath string) []string {
return appsUsingPathIn(s.stackMgr.GetStacks(), s.stackMgr.LoadAppConfigByName, storagePath) 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 // 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 // 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. // the "name the apps that break" list for the type-to-confirm wipe/eject UI.
+188
View File
@@ -16,6 +16,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system" "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) FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error)
AssignDisk(ctx context.Context, uuid, where, fstype, options string) error AssignDisk(ctx context.Context, uuid, where, fstype, options string) error
EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, 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 GuestAttach(ctx context.Context, where string) error
} }
@@ -195,12 +197,39 @@ func (s *Server) pendingActivationDrives() []string {
return pending 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 // registerStoragePath records a freshly-mounted path in the StoragePath registry (schedulable by
// default) and refreshes the FileBrowser mounts so it's usable immediately. // default) and refreshes the FileBrowser mounts so it's usable immediately.
func (s *Server) registerStoragePath(where, label string, setDefault bool) error { func (s *Server) registerStoragePath(where, label string, setDefault bool) error {
if strings.TrimSpace(label) == "" { if strings.TrimSpace(label) == "" {
label = settings.InferStorageLabel(where) 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{ sp := settings.StoragePath{
Path: where, Path: where,
Label: label, Label: label,
@@ -245,11 +274,170 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
s.handleStorageRegister(w, r) s.handleStorageRegister(w, r)
case r.URL.Path == "/api/storage/activate" && r.Method == http.MethodPost: case r.URL.Path == "/api/storage/activate" && r.Method == http.MethodPost:
s.handleStorageActivate(w, r) s.handleStorageActivate(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)
default: default:
http.NotFound(w, r) 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
}
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
}
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 { type storageProvReq struct {
Device string `json:"device"` Device string `json:"device"`
FSType string `json:"fstype"` 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. // mockAgent records calls so tests can assert the refusal path performs NO mount/destructive action.
type mockAgent struct { type mockAgent struct {
disks agentapi.DisksResponse disks agentapi.DisksResponse
formatRes agentapi.FormatResult formatRes agentapi.FormatResult
formatErr error formatErr error
assignErr error assignErr error
assignCalls []assignCall assignCalls []assignCall
disksCalls int disksCalls int
formatCalls []formatCall formatCalls []formatCall
guestAttachCalls []string guestAttachCalls []string
decommissionCalls []string
} }
type assignCall struct{ uuid, where, fstype 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) { func (m *mockAgent) EjectDisk(_ context.Context, where string) (agentapi.EjectResult, error) {
return agentapi.EjectResult{Ejected: where}, nil 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 { func (m *mockAgent) GuestAttach(_ context.Context, where string) error {
m.guestAttachCalls = append(m.guestAttachCalls, where) m.guestAttachCalls = append(m.guestAttachCalls, where)
return nil return nil
@@ -24,6 +24,13 @@
</div> </div>
</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 --> <!-- Hero section -->
<div class="app-info-hero"> <div class="app-info-hero">
<img class="app-info-logo" src="{{logoURL .Meta.Slug}}" <img class="app-info-logo" src="{{logoURL .Meta.Slug}}"
@@ -55,6 +62,55 @@
onerror="this.style.display='none'"> onerror="this.style.display='none'">
</div> </div>
{{if and .Stack.Deployed .MigrateTargets}}
<div class="app-info-card" style="margin-top:1rem">
<h3>Áthelyezés másik tárhelyre</h3>
<p class="form-hint">Ennek az alkalmazásnak az adatait másik csatlakoztatott tárhelyre helyezheted át. Az alkalmazás az áthelyezés alatt rövid időre leáll, az adatok pedig csak az ellenőrzés és a sikeres újraindítás után törlődnek a régi helyről.</p>
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-top:.5rem">
<select id="app-migrate-target" class="btn btn-sm btn-outline">
<option value="">Válassz céltárhelyet…</option>
{{range .MigrateTargets}}<option value="{{.Path}}">{{.Label}} ({{.Path}})</option>{{end}}
</select>
<button class="btn btn-sm btn-outline" onclick="appMigrate('{{.Stack.Name}}','{{.Meta.DisplayName}}')">Áthelyezés</button>
</div>
<div id="app-migrate-progress" style="display:none;margin-top:.75rem;padding:.75rem;border:1px solid var(--accent);border-radius:6px;background:rgba(0,136,204,0.06)"></div>
</div>
<script>
function appMigFmtGB(b){ return (Number(b||0)/1e9).toFixed(1)+' GB'; }
function appMigRender(job){
var names={stop:'Leállítás',copy:'Adatok másolása',verify:'Ellenőrzés',flip:'Újratelepítés',redeploy:'Újratelepítés',cleanup:'Régi adatok törlése'};
var s=names[job.phase]||job.phase;
if(job.phase==='copy'&&job.bytes_total>0){ s+=' ('+Math.floor(100*job.bytes_done/job.bytes_total)+'% — '+appMigFmtGB(job.bytes_done)+'/'+appMigFmtGB(job.bytes_total)+')'; }
return s+'…';
}
function appMigWatch(){
var panel=document.getElementById('app-migrate-progress');
if(panel) panel.style.display='block';
function tick(){
fetch('/api/storage/migrate/status').then(function(r){return r.json();}).then(function(d){
var job=d.data&&d.data.job;
if(!job){ if(panel) panel.textContent='Nincs folyamatban áthelyezés.'; return; }
if(panel) panel.innerHTML=appMigRender(job);
if(job.phase==='done'){ if(panel) panel.innerHTML+='<br><strong>Kész ✓</strong>'; setTimeout(function(){location.reload();},1500); return; }
if(job.phase==='aborted'){ if(panel) panel.innerHTML+='<br><strong style="color:var(--danger,#c0392b)">Megszakadt: '+(job.error||'')+'</strong><br>A régi adatok érintetlenek.'; return; }
setTimeout(tick,1500);
}).catch(function(){ setTimeout(tick,2000); });
}
tick();
}
function appMigrate(app,label){
var sel=document.getElementById('app-migrate-target');
var target=sel?sel.value:'';
if(!target){ alert('Válassz céltárhelyet.'); return; }
if(!confirm('Áthelyezed a(z) '+label+' adatait ide: '+target+'?\n\nAz alkalmazás rövid időre leáll. A régi adatok csak sikeres áthelyezés után törlődnek.')) return;
fetch('/api/storage/migrate-app',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({app:app,target:target})})
.then(function(r){return r.json();}).then(function(d){ if(d.ok){ appMigWatch(); } else { alert('Hiba: '+(d.error||'ismeretlen')); } })
.catch(function(e){ alert('Hiba: '+e); });
}
(function(){ fetch('/api/storage/migrate/status').then(function(r){return r.json();}).then(function(d){ if(d.data&&d.data.job){ appMigWatch(); } }).catch(function(){}); })();
</script>
{{end}}
{{if .HasAppInfo}} {{if .HasAppInfo}}
<div class="app-info-grid"> <div class="app-info-grid">
{{if .AppInfo.UseCases}} {{if .AppInfo.UseCases}}
@@ -149,6 +149,7 @@
<div class="stack-actions"> <div class="stack-actions">
<span class="stack-state-label">{{stateLabel .State}}</span> <span class="stack-state-label">{{stateLabel .State}}</span>
{{if .Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}} {{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 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}} {{if .Protected}}
@@ -334,13 +334,63 @@ function pollUntilBack() {
</form> </form>
{{end}} {{end}}
{{if and (gt .AppCount 0) .HasOtherPaths}} {{if and (gt .AppCount 0) .HasOtherPaths}}
<span class="btn btn-xs btn-outline" style="opacity:.45;cursor:not-allowed" title="Hamarosan">📦 Összes adat átköltöztetése</span> {{$src := .Path}}
<span class="migrate-inline" style="display:inline-flex;gap:.35rem;align-items:center">
<select id="migrate-target-{{.Path}}" class="btn btn-xs btn-outline">
<option value="">📦 Áthelyezés ide…</option>
{{range $.StoragePaths}}{{if and (ne .Path $src) .Schedulable (not .Disconnected) (not .Decommissioned)}}<option value="{{.Path}}">{{.Label}} ({{.Path}})</option>{{end}}{{end}}
</select>
<button class="btn btn-xs btn-outline" onclick="storageMigrateAll('{{.Path}}','{{.Label}}')">Összes adat áthelyezése</button>
</span>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
</div> </div>
<div id="migrate-progress" style="display:none;margin-top:1rem;padding:1rem;border:1px solid var(--accent);border-radius:6px;background:rgba(0,136,204,0.06)">
<strong>Adatok áthelyezése</strong>
<div id="migrate-progress-body" style="margin-top:.5rem"></div>
</div>
<script>
// Shared migration progress (used by both migrate-all here and per-app migrate on the app page).
function migFmtGB(b){ return (Number(b||0)/1e9).toFixed(1)+' GB'; }
function migRender(job){
var names={stop:'Alkalmazások leállítása',copy:'Adatok másolása',verify:'Ellenőrzés',flip:'Újratelepítés',redeploy:'Újratelepítés',cleanup:'Forrás törlése'};
var s=names[job.phase]||job.phase;
if(job.current_app) s+=': '+job.current_app;
if(job.phase==='copy'&&job.bytes_total>0){ s+=' ('+Math.floor(100*job.bytes_done/job.bytes_total)+'% — '+migFmtGB(job.bytes_done)+'/'+migFmtGB(job.bytes_total)+')'; }
return s+'…';
}
function migWatch(){
var panel=document.getElementById('migrate-progress');
var body=document.getElementById('migrate-progress-body');
if(panel) panel.style.display='block';
function tick(){
fetch('/api/storage/migrate/status').then(function(r){return r.json();}).then(function(d){
var job=d.data&&d.data.job;
if(!job){ if(body) body.textContent='Nincs folyamatban migráció.'; return; }
if(body) body.innerHTML=migRender(job);
if(job.phase==='done'){ if(body) body.innerHTML+='<br><strong>Kész ✓</strong>'; setTimeout(function(){location.reload();},1500); return; }
if(job.phase==='aborted'){ if(body) body.innerHTML+='<br><strong style="color:var(--danger,#c0392b)">Megszakadt: '+(job.error||'')+'</strong><br>A forrás adatai érintetlenek.'; return; }
setTimeout(tick,1500);
}).catch(function(){ setTimeout(tick,2000); });
}
tick();
}
function storageMigrateAll(source,label){
var sel=document.getElementById('migrate-target-'+source);
var target=sel?sel.value:'';
if(!target){ alert('Válassz céltárolót a legördülő menüből.'); return; }
if(!confirm('Áthelyezed a(z) '+label+' ÖSSZES adatát ide: '+target+'?\n\nAz alkalmazások az áthelyezés alatt rövid időre leállnak. A forrás adatai csak az ellenőrzés és a sikeres újraindítás után törlődnek.')) return;
fetch('/api/storage/migrate',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({source:source,target:target})})
.then(function(r){return r.json();}).then(function(d){ if(d.ok){ migWatch(); } else { alert('Hiba: '+(d.error||'ismeretlen')); } })
.catch(function(e){ alert('Hiba: '+e); });
}
// Resume view: if a migration is already running when the page loads, show the panel.
(function(){ fetch('/api/storage/migrate/status').then(function(r){return r.json();}).then(function(d){ if(d.data&&d.data.job){ migWatch(); } }).catch(function(){}); })();
</script>
{{else}} {{else}}
<div class="empty-state" style="padding:1.5rem"> <div class="empty-state" style="padding:1.5rem">
Nincs regisztrált adattároló. Adjon hozzá egyet az alábbi űrlappal. Nincs regisztrált adattároló. Adjon hozzá egyet az alábbi űrlappal.
@@ -38,6 +38,7 @@
</div> </div>
<span class="stack-state-badge state-{{stateColor .State}}">{{stateLabel .State}}</span> <span class="stack-state-badge state-{{stateColor .State}}">{{stateLabel .State}}</span>
{{if .Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}} {{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> </div>
{{if .Meta.Description}} {{if .Meta.Description}}
@@ -1249,6 +1249,13 @@ a.stat-card:hover {
color: var(--orange); 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). */ /* F5: an unhealthy/restarting deployed app has its public route withheld by Traefik (404 at the URL). */
.badge-route-unpublished { .badge-route-unpublished {
background: var(--orange-bg); background: var(--orange-bg);