Compare commits
3 Commits
c37ee6d43a
...
f2596ea433
| Author | SHA1 | Date | |
|---|---|---|---|
| f2596ea433 | |||
| 16a4c3e878 | |||
| b57150e3ab |
@@ -1,5 +1,48 @@
|
||||
## 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)
|
||||
|
||||
Two settings-layer cleanups from the F9 storage-registration diagnosis
|
||||
|
||||
@@ -226,6 +226,14 @@ func main() {
|
||||
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 ---
|
||||
alertMgr := web.NewAlertManager(logger)
|
||||
|
||||
@@ -630,6 +638,10 @@ func main() {
|
||||
|
||||
// --- Initialize web server ---
|
||||
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.SetAppExporter(appExporter)
|
||||
webServer.SetIntegrationManager(integrationMgr)
|
||||
|
||||
@@ -134,10 +134,10 @@ type StatusResponse struct {
|
||||
// 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).
|
||||
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"`
|
||||
Archive string `json:"archive"` // produced vzdump volid (e.g. "local:backup/vzdump-lxc-…")
|
||||
Mode string `json:"mode"` // snapshot | stop
|
||||
Archive string `json:"archive"` // produced vzdump volid (e.g. "local:backup/vzdump-lxc-…")
|
||||
Mode string `json:"mode"` // snapshot | stop
|
||||
CrashConsistent bool `json:"crash_consistent"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
Success bool `json:"success"`
|
||||
@@ -368,6 +368,28 @@ func (c *Client) EjectDisk(ctx context.Context, where string) (EjectResult, erro
|
||||
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
|
||||
// (its own classification, never the controller's claim):
|
||||
// - blank device → formatted.
|
||||
|
||||
@@ -36,6 +36,11 @@ type Manager struct {
|
||||
discoverDBs func(ctx context.Context) ([]DiscoveredDB, 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
|
||||
lastDBDump *DBDumpStatus
|
||||
running bool
|
||||
@@ -158,8 +163,23 @@ func (m *Manager) groupStacksByDrive() map[string][]StackSummary {
|
||||
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.
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,10 @@ func (m *Manager) RunAllTier2() {
|
||||
if m.stackProvider == nil {
|
||||
return
|
||||
}
|
||||
if m.migrationActive() {
|
||||
m.logger.Printf("[INFO] [backup] Tier 2 kihagyva: migráció folyamatban")
|
||||
return
|
||||
}
|
||||
var n int
|
||||
for _, stack := range m.stackProvider.ListDeployedStacks() {
|
||||
if m.stackProvider.GetStackHDDPath(stack.Name) == "" {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// ContainerState represents the current state of a container.
|
||||
@@ -22,8 +23,8 @@ type ContainerState string
|
||||
|
||||
const (
|
||||
StateRunning ContainerState = "running"
|
||||
StateStarting ContainerState = "starting" // running but health: starting
|
||||
StateUnhealthy ContainerState = "unhealthy" // running but health: unhealthy
|
||||
StateStarting ContainerState = "starting" // running but health: starting
|
||||
StateUnhealthy ContainerState = "unhealthy" // running but health: unhealthy
|
||||
StateStopped ContainerState = "stopped"
|
||||
StateRestarting ContainerState = "restarting"
|
||||
StateExited ContainerState = "exited"
|
||||
@@ -51,12 +52,12 @@ type HealthProbeResult struct {
|
||||
|
||||
// HealthCheckDetail holds the result of a single health check item.
|
||||
type HealthCheckDetail struct {
|
||||
Type string `json:"type"` // "http", "api", "tcp"
|
||||
Target string `json:"target"` // e.g. ":3456/api/v1/info"
|
||||
Type string `json:"type"` // "http", "api", "tcp"
|
||||
Target string `json:"target"` // e.g. ":3456/api/v1/info"
|
||||
Healthy bool `json:"healthy"`
|
||||
Status int `json:"status,omitempty"` // HTTP status code (for http/api)
|
||||
Latency string `json:"latency"` // e.g. "45ms"
|
||||
Error string `json:"error,omitempty"` // error message if unhealthy
|
||||
Status int `json:"status,omitempty"` // HTTP status code (for http/api)
|
||||
Latency string `json:"latency"` // e.g. "45ms"
|
||||
Error string `json:"error,omitempty"` // error message if unhealthy
|
||||
}
|
||||
|
||||
// Stack represents a docker compose stack on disk.
|
||||
@@ -65,14 +66,14 @@ type Stack struct {
|
||||
Meta Metadata `json:"meta"`
|
||||
ComposePath string `json:"compose_path"`
|
||||
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"`
|
||||
Orphaned bool `json:"orphaned"` // Deployed but no catalog template
|
||||
Orphaned bool `json:"orphaned"` // Deployed but no catalog template
|
||||
Containers []ContainerInfo `json:"containers"`
|
||||
AppConfig *AppConfig `json:"app_config,omitempty"`
|
||||
Deploying bool `json:"deploying"` // compose up in progress
|
||||
DeployError string `json:"deploy_error,omitempty"` // last async deploy error
|
||||
HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result
|
||||
Deploying bool `json:"deploying"` // compose up in progress
|
||||
DeployError string `json:"deploy_error,omitempty"` // last async deploy error
|
||||
HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
@@ -85,6 +86,16 @@ type Manager struct {
|
||||
mu sync.RWMutex
|
||||
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)
|
||||
|
||||
// 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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
data := s.baseData("dashboard", "Vezérlőpult")
|
||||
data["Stacks"] = deployedStacks
|
||||
data["MissingStorage"] = s.missingStorageMap(deployedStacks)
|
||||
data["RunningCount"] = running
|
||||
data["StoppedCount"] = stopped
|
||||
data["TotalCount"] = len(stackList)
|
||||
@@ -195,7 +196,9 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := s.baseData("stacks", "Alkalmazások")
|
||||
data["Stacks"] = s.stackMgr.GetStacks()
|
||||
allStacks := s.stackMgr.GetStacks()
|
||||
data["Stacks"] = allStacks
|
||||
data["MissingStorage"] = s.missingStorageMap(allStacks)
|
||||
|
||||
// Build storage label lookup for deployed apps
|
||||
storageLabels := make(map[string]string) // stack name → storage label
|
||||
@@ -471,6 +474,26 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
||||
data["HasAppInfo"] = found.Meta.HasAppInfo()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1099,6 +1122,41 @@ func (s *Server) appsUsingPath(storagePath string) []string {
|
||||
return appsUsingPathIn(s.stackMgr.GetStacks(), s.stackMgr.LoadAppConfigByName, storagePath)
|
||||
}
|
||||
|
||||
// missingStorageLabel reports whether a deployed app's HDD_PATH resolves to an UNAVAILABLE registry
|
||||
// path (decommissioned, disconnected, or no longer registered) and returns its human label. An app
|
||||
// with no HDD_PATH (SSD-resident) is never "missing".
|
||||
func (s *Server) missingStorageLabel(hddPath string) (string, bool) {
|
||||
if hddPath == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.Path == hddPath {
|
||||
if sp.Decommissioned || sp.Disconnected {
|
||||
return s.settings.GetStorageLabel(hddPath), true
|
||||
}
|
||||
return "", false // present + available
|
||||
}
|
||||
}
|
||||
return s.settings.GetStorageLabel(hddPath), true // not in registry → its drive is gone
|
||||
}
|
||||
|
||||
// missingStorageMap returns stack-name → storage label for every deployed app whose data drive is
|
||||
// currently unavailable (drives the "Hiányzó tárhely" dashboard/stacks/app-card indicator).
|
||||
func (s *Server) missingStorageMap(list []stacks.Stack) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, st := range list {
|
||||
if !st.Deployed {
|
||||
continue
|
||||
}
|
||||
if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil {
|
||||
if label, missing := s.missingStorageLabel(cfg.Env["HDD_PATH"]); missing {
|
||||
out[st.Name] = label
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// appsUsingPathIn is the pure core of appsUsingPath (testable without a live stacks.Manager): the
|
||||
// deployed apps whose data dir (app.yaml HDD_PATH) is exactly storagePath, by display name. This is
|
||||
// the "name the apps that break" list for the type-to-confirm wipe/eject UI.
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,7 @@ type diskAgent interface {
|
||||
FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error)
|
||||
AssignDisk(ctx context.Context, uuid, where, fstype, options string) error
|
||||
EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error)
|
||||
Decommission(ctx context.Context, where string) (agentapi.DecommissionResult, error)
|
||||
GuestAttach(ctx context.Context, where string) error
|
||||
}
|
||||
|
||||
@@ -195,12 +197,39 @@ func (s *Server) pendingActivationDrives() []string {
|
||||
return pending
|
||||
}
|
||||
|
||||
// reEnrollClearMarker un-retires a re-plugged decommissioned drive (Change 4): clears the soft marker
|
||||
// and restores Schedulable so its apps' "missing storage" indicator clears. Returns true if it acted.
|
||||
func (s *Server) reEnrollClearMarker(where string) (bool, error) {
|
||||
if !s.settings.IsDecommissioned(where) {
|
||||
return false, nil
|
||||
}
|
||||
if err := s.settings.ClearDecommissioned(where); err != nil {
|
||||
return false, fmt.Errorf("leszerelés visszavonása sikertelen: %w", err)
|
||||
}
|
||||
if err := s.settings.SetSchedulable(where, true); err != nil {
|
||||
return false, fmt.Errorf("ütemezhetőség visszaállítása sikertelen: %w", err)
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] re-enrolled decommissioned drive — marker cleared: %s", where)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// registerStoragePath records a freshly-mounted path in the StoragePath registry (schedulable by
|
||||
// default) and refreshes the FileBrowser mounts so it's usable immediately.
|
||||
func (s *Server) registerStoragePath(where, label string, setDefault bool) error {
|
||||
if strings.TrimSpace(label) == "" {
|
||||
label = settings.InferStorageLabel(where)
|
||||
}
|
||||
// Change 4: re-enrolling a previously-DECOMMISSIONED drive must un-retire it. AddStoragePath
|
||||
// dedups a re-register into a no-op, so without this the soft marker would persist forever and the
|
||||
// apps' "missing storage" indicator would never clear.
|
||||
if cleared, err := s.reEnrollClearMarker(where); err != nil {
|
||||
return err
|
||||
} else if cleared {
|
||||
if s.stackMgr != nil {
|
||||
go s.SyncFileBrowserMounts()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
sp := settings.StoragePath{
|
||||
Path: where,
|
||||
Label: label,
|
||||
@@ -245,11 +274,170 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleStorageRegister(w, r)
|
||||
case r.URL.Path == "/api/storage/activate" && r.Method == http.MethodPost:
|
||||
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:
|
||||
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 {
|
||||
Device string `json:"device"`
|
||||
FSType string `json:"fstype"`
|
||||
|
||||
@@ -27,14 +27,15 @@ func TestTemplatesParse(t *testing.T) {
|
||||
|
||||
// mockAgent records calls so tests can assert the refusal path performs NO mount/destructive action.
|
||||
type mockAgent struct {
|
||||
disks agentapi.DisksResponse
|
||||
formatRes agentapi.FormatResult
|
||||
formatErr error
|
||||
assignErr error
|
||||
assignCalls []assignCall
|
||||
disksCalls int
|
||||
formatCalls []formatCall
|
||||
guestAttachCalls []string
|
||||
disks agentapi.DisksResponse
|
||||
formatRes agentapi.FormatResult
|
||||
formatErr error
|
||||
assignErr error
|
||||
assignCalls []assignCall
|
||||
disksCalls int
|
||||
formatCalls []formatCall
|
||||
guestAttachCalls []string
|
||||
decommissionCalls []string
|
||||
}
|
||||
|
||||
type assignCall struct{ uuid, where, fstype string }
|
||||
@@ -58,6 +59,10 @@ func (m *mockAgent) AssignDisk(_ context.Context, uuid, where, fstype, _ string)
|
||||
func (m *mockAgent) EjectDisk(_ context.Context, where string) (agentapi.EjectResult, error) {
|
||||
return agentapi.EjectResult{Ejected: where}, nil
|
||||
}
|
||||
func (m *mockAgent) Decommission(_ context.Context, where string) (agentapi.DecommissionResult, error) {
|
||||
m.decommissionCalls = append(m.decommissionCalls, where)
|
||||
return agentapi.DecommissionResult{Decommissioned: where}, nil
|
||||
}
|
||||
func (m *mockAgent) GuestAttach(_ context.Context, where string) error {
|
||||
m.guestAttachCalls = append(m.guestAttachCalls, where)
|
||||
return nil
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .MissingStorageLabel}}
|
||||
<div class="alert alert-warning" style="margin-top:1rem">
|
||||
<strong>⚠ Hiányzó tárhely: {{.MissingStorageLabel}}</strong><br>
|
||||
Ennek az alkalmazásnak az adattárolója jelenleg nem elérhető, ezért le van állítva. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat egy másik tárhelyre.
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Hero section -->
|
||||
<div class="app-info-hero">
|
||||
<img class="app-info-logo" src="{{logoURL .Meta.Slug}}"
|
||||
@@ -55,6 +62,55 @@
|
||||
onerror="this.style.display='none'">
|
||||
</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}}
|
||||
<div class="app-info-grid">
|
||||
{{if .AppInfo.UseCases}}
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
<div class="stack-actions">
|
||||
<span class="stack-state-label">{{stateLabel .State}}</span>
|
||||
{{if .Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
||||
{{$ms := index $.MissingStorage .Name}}{{if $ms}}<span class="badge badge-missing-storage" title="Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat egy másik tárhelyre.">⚠ Hiányzó tárhely: {{$ms}}</span>{{end}}
|
||||
{{if and .Deployed (routeUnpublished .State)}}<span class="badge badge-route-unpublished" title="A proxy (Traefik) csak egészséges konténerhez publikál nyilvános útvonalat. Amíg az alkalmazás nem egészséges, az URL 404-et ad, pedig a konténer fut.">⚠ URL nem elérhető</span>{{end}}
|
||||
|
||||
{{if .Protected}}
|
||||
|
||||
@@ -334,13 +334,63 @@ function pollUntilBack() {
|
||||
</form>
|
||||
{{end}}
|
||||
{{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}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</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}}
|
||||
<div class="empty-state" style="padding:1.5rem">
|
||||
Nincs regisztrált adattároló. Adjon hozzá egyet az alábbi űrlappal.
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
</div>
|
||||
<span class="stack-state-badge state-{{stateColor .State}}">{{stateLabel .State}}</span>
|
||||
{{if .Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
||||
{{$ms := index $.MissingStorage .Name}}{{if $ms}}<span class="badge badge-missing-storage" title="Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat.">⚠ Hiányzó tárhely: {{$ms}}</span>{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Meta.Description}}
|
||||
|
||||
@@ -1249,6 +1249,13 @@ a.stat-card:hover {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
/* B2b: a deployed app whose data drive was decommissioned/disconnected — distinct warning, not error-spam. */
|
||||
.badge-missing-storage {
|
||||
background: var(--orange-bg);
|
||||
color: var(--orange);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* F5: an unhealthy/restarting deployed app has its public route withheld by Traefik (404 at the URL). */
|
||||
.badge-route-unpublished {
|
||||
background: var(--orange-bg);
|
||||
|
||||
Reference in New Issue
Block a user