From c0f3e124830d03f6857438f943f0148f7a671f31 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 11 Jul 2026 21:12:48 +0200 Subject: [PATCH] v0.117.0: consuming-namespace NAS verification + deploy-view truth (RCA fixes 2+4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit statfs fsclass helper (network/autofs/stub/unknown, fail-open); probe not_network_fs assertion (stub can never verify — red-proven); deploy-time stub refusal (idle autofs proceeds — red-proven); distinct stub badge, stub wins over unreachable (unreachable line byte-identical); deployed select shows stored HDD_PATH (red-proven vs IsDefault-only). MinAgent unchanged 0.81.0. Gates green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- CHANGELOG.md | 30 +++ REUSE.md | 1 + controller/README.md | 17 ++ controller/internal/api/deploygate_test.go | 68 ++++++ controller/internal/api/router.go | 30 +++ controller/internal/system/fsclass.go | 64 +++++ controller/internal/system/fsclass_linux.go | 19 ++ .../internal/system/fsclass_linux_test.go | 36 +++ controller/internal/system/fsclass_other.go | 9 + controller/internal/system/fsclass_test.go | 28 +++ controller/internal/web/handlers.go | 128 +++++++--- controller/internal/web/netprobe.go | 21 +- controller/internal/web/netprobe_linux.go | 8 + controller/internal/web/netprobe_other.go | 13 +- controller/internal/web/netprobe_stub_test.go | 98 ++++++++ controller/internal/web/netstorage_job.go | 2 + controller/internal/web/networkstub_test.go | 222 ++++++++++++++++++ controller/internal/web/server.go | 4 + .../internal/web/templates/dashboard.html | 1 + controller/internal/web/templates/deploy.html | 5 +- controller/internal/web/templates/stacks.html | 1 + 21 files changed, 767 insertions(+), 38 deletions(-) create mode 100644 controller/internal/api/deploygate_test.go create mode 100644 controller/internal/system/fsclass.go create mode 100644 controller/internal/system/fsclass_linux.go create mode 100644 controller/internal/system/fsclass_linux_test.go create mode 100644 controller/internal/system/fsclass_other.go create mode 100644 controller/internal/system/fsclass_test.go create mode 100644 controller/internal/web/netprobe_stub_test.go create mode 100644 controller/internal/web/networkstub_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ef59f5..96f9554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ ## Changelog +### v0.117.0 — consuming-namespace NAS verification + deploy-view truth (RCA fixes 2+4) (2026-07-11) — MinAgent: 0.81.0 + +Controller half of the RCA fix pair (agent v0.84.0 ReassertNetworkMounts). Source: +`felhom.eu/documentation/audits/AUDIT-nas-cwa-rca-2026-07-11.md`. MinAgent UNCHANGED (0.81.0) — +every new check is controller-namespace-local; no new agent API is consumed. + +- **`internal/system/fsclass*.go`** — statfs f_type classifier for THIS process's namespace: + `network` (NFS 0x6969 / CIFS 0xFF534D42 / SMB2 0xFE534D42) | `autofs` (0x0187 — the HEALTHY idle + trigger; NEVER force-mounted) | `stub` (anything local — the RCA's silent guest-reboot state) | + `unknown` (statfs error/3 s timeout — fail open). Seams: `statfsFn` + per-caller injectables. +- **Probe fstype assertion (fix 2a):** the `--netprobe` child creates the probe file FIRST (the + create legitimately triggers the automount), THEN requires a MOUNTED network fs — exit 5 → + category `not_network_fs` (new §3.2 Hungarian message), full rollback, nothing registered. A + writable local stub can never verify again. Red-proof: assertion disabled → the stub VERIFIED + (exit 0 / job phase `done`) → FAIL. +- **Deploy-time refusal (fix 2b):** `POST /api/stacks/{name}/deploy` refuses (409, Hungarian) when + `HDD_PATH` is a registered network path classifying as a stub (`Router.refuseNetworkStubDeploy`, + `classifyFSPath` seam). Idle autofs / live / unknown / local / unregistered / empty all proceed. + Red-proof: a mounted-only gate wrongly refuses the healthy idle trigger → FAIL. +- **Stub badge (fix 2c):** `networkStorageWarnings` returns (warnings, stubs); the controller-side + classification runs even when the agent is unreachable. Dashboard + stacks cards render the new + distinct badge "Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja"; stub WINS over the + recoverable unreachable badge (never both); the unreachable line stays byte-identical + (template-asserted). Pure mapping core `networkStorageWarningsIn` (appsUsingPathIn pattern). +- **Deploy-view truth (fix 4, the RCA S-C symptom):** the deployed-app storage select marks + `selected` by the STORED `HDD_PATH` (`CurrentHDDPath`); a stored path absent from the schedulable + list renders an extra disabled ` (nem elérhető)` option; `IsDefault` selects only for NEW + deploys. Red-proof: IsDefault-only revert → the default drive shows selected → FAIL. +- Gates: template_id_gate + emoji_gate OK; full `go build/vet/test ./...` green. + ### v0.116.1 — debug surface ungated from logging.level (2026-07-11) — MinAgent: 0.81.0 Live validation of v0.116.0 caught the last blind spot: `/debug` + `/api/debug/*` (and the nav diff --git a/REUSE.md b/REUSE.md index b63510f..2630871 100644 --- a/REUSE.md +++ b/REUSE.md @@ -182,6 +182,7 @@ | `util.ParseVersion` / `util.Version.Compare` | controller/internal/util/version.go | THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it | rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81) | | `agentapi.AgentVersionReporter` + `featureMinAgent` | controller/internal/agentapi/features.go | version-first Supports (v0.82.0 header channel); probe = fallback for header-less agents | a coupled feature adds BOTH a featureProbes row AND a featureMinAgent row; v0.116.0: `SupportsWithSource` also reports HOW the verdict was reached (version/probe-cache/probe) for the gate log line | | `netProbeReadBack` (package var) | controller/internal/web/netprobe.go | `os.ReadFile` | overridden in TestNetProbeChild (nonce-tamper + cleanup-fail rows); package var because the child is a RE-EXEC'd process in production | +| `system.ClassifyPathFS(Timeout)` + `netProbeFSClass` / `Server.classifyFSPath` / `Router.classifyFSPath` | controller/internal/system/fsclass*.go (+ web/netprobe.go, web/server.go, api/router.go seams) | statfs f_type → network/autofs/stub/unknown in THIS namespace (RCA fix 2) | idle autofs = HEALTHY, never force-mount; unknown = fail OPEN; seams injected in netprobe_stub_test.go / networkstub_test.go / deploygate_test.go | | `quiesce.Backend` / `quiesce.Stacks` | controller/internal/quiesce/quiesce.go | adapter over `*agentapi.Client` / `*stacks.Manager` | `fakeBackend`/`fakeStacks` in controller/internal/quiesce/quiesce_test.go | | `channelhealth.Probe` (func) + `Sink` | controller/internal/channelhealth/checker.go | `Server.ProbeAgentChannel` / notifier adapter | `fakeSink` in controller/internal/channelhealth/checker_test.go | | `appbackup.StackDataProvider` | controller/internal/appbackup/appdata.go | `*stacks.Manager` (via `backup.SetStackProvider`) | `fakeRecoveryProvider` in controller/internal/backup/recovery_unit_test.go | diff --git a/controller/README.md b/controller/README.md index a4596f6..5f7be2b 100644 --- a/controller/README.md +++ b/controller/README.md @@ -824,6 +824,23 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an > every state). Convention: every future coupled feature adds a `featureProbes` row + a gate call at > its entry point and declares `MinAgent` in its CHANGELOG header — see > `felhom.eu/documentation/runbooks/publish-train-rules.md`. +> - **Consuming-namespace verification (v0.117.0, RCA AUDIT-nas-cwa-rca-2026-07-11 fix 2):** a guest +> reboot silently replaces an idle NAS trigger with a plain local STUB dir in the app namespace while +> host-side (agent) health stays green — so the controller now verifies where the apps consume: +> `internal/system/fsclass*.go` classifies a path by **statfs f_type** in THIS process's namespace +> (`network` nfs/cifs/smb2 | `autofs` idle trigger = HEALTHY, never force-mounted | `stub` | `unknown` +> = fail-open). Three consumers: (1) the `--netprobe` child now REQUIRES a mounted network fs after +> its create (exit 5 → category `not_network_fs`, full rollback — a writable stub can never verify); +> (2) `POST /api/stacks/{name}/deploy` refuses (409) a registered network `HDD_PATH` that classifies +> as a stub (`refuseNetworkStubDeploy`; idle autofs deploys fine — first app access mounts it); +> (3) the dashboard/stacks app cards gain a distinct **stub badge** ("Hálózati tárhely hibás — az +> alkalmazás nem a NAS-t látja", `NetworkStubs`) that WINS over the recoverable `unreachable` badge. +> The agent pair (v0.84.0 `ReassertNetworkMounts`) re-arms triggers on guest start; this layer is the +> detection net beneath it. +> - **Deploy view truth (v0.117.0, RCA fix 4):** a deployed app's read-only storage select now marks +> `selected` by the app's STORED `HDD_PATH` (extra disabled ` (nem elérhető)` option when the +> stored path left the schedulable list) — `IsDefault` selects only for NEW deploys. Pre-fix the view +> showed the default drive regardless of app.yaml (the RCA's S-C symptom). > - **Limits (v1):** a share's `+100000` uid mapping is fixed at add-time (one app / same-uid apps); for > write apps on a soft NFS mount, an in-flight file can truncate if the NAS vanishes mid-write (prefer > atomic-write apps / SSD-staging). diff --git a/controller/internal/api/deploygate_test.go b/controller/internal/api/deploygate_test.go new file mode 100644 index 0000000..d7e14e8 --- /dev/null +++ b/controller/internal/api/deploygate_test.go @@ -0,0 +1,68 @@ +package api + +import ( + "io" + "log" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/system" +) + +func newDeployGateRouter(t *testing.T, class string) *Router { + t.Helper() + lg := log.New(io.Discard, "", 0) + sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + if err := sett.AddStoragePath(settings.StoragePath{ + Path: "/mnt/felhom-drives/nas-media", Label: "NAS", Schedulable: true, Kind: settings.StorageKindNetwork, + }); err != nil { + t.Fatal(err) + } + if err := sett.AddStoragePath(settings.StoragePath{ + Path: "/mnt/felhom-drives/felhom-usb", Label: "USB", Schedulable: true, + }); err != nil { + t.Fatal(err) + } + r := &Router{sett: sett, logger: lg} + r.classifyFSPath = func(string) string { return class } + return r +} + +// The deploy-time stub gate (RCA fix 2): a registered network HDD_PATH that classifies as a STUB +// in this namespace refuses with the §2.3 Hungarian message; the healthy idle autofs trigger, a +// live mount, an unknown verdict (fail open), local paths and empty paths all proceed. +// Companion red-proofs: +// - remove the gate call from deployStack → the refusal test fails (deploy proceeds onto a stub); +// - an impl requiring MOUNTED-only (refusing autofs) → the idle-autofs row fails (it would +// wrongly block deploying onto a healthy idle share). +func TestRefuseNetworkStubDeploy_Table(t *testing.T) { + cases := []struct { + name string + class string + hdd string + refuse bool + }{ + {"stub on network path → REFUSE", system.FSClassStub, "/mnt/felhom-drives/nas-media", true}, + {"idle autofs on network path → proceed (healthy)", system.FSClassAutofs, "/mnt/felhom-drives/nas-media", false}, + {"live network fs → proceed", system.FSClassNetwork, "/mnt/felhom-drives/nas-media", false}, + {"unknown (timeout) → proceed (fail open)", system.FSClassUnknown, "/mnt/felhom-drives/nas-media", false}, + {"local path → proceed even when classifier says stub", system.FSClassStub, "/mnt/felhom-drives/felhom-usb", false}, + {"unregistered path → proceed", system.FSClassStub, "/mnt/elsewhere", false}, + {"empty HDD_PATH (SSD app) → proceed", system.FSClassStub, "", false}, + } + for _, c := range cases { + r := newDeployGateRouter(t, c.class) + msg := r.refuseNetworkStubDeploy(c.hdd) + if c.refuse && !strings.Contains(msg, "a telepítés nem indítható") { + t.Errorf("%s: want the refusal message, got %q", c.name, msg) + } + if !c.refuse && msg != "" { + t.Errorf("%s: must proceed, got refusal %q", c.name, msg) + } + } +} diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 999e08f..3e4544a 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -47,6 +47,10 @@ type Router struct { // process back with fresh config; tests inject a recorder via SetRestarter. restart func() + // classifyFSPath classifies a path's filesystem in this process's namespace (the deploy-time + // stub gate, RCA fix 2). Defaults to system.ClassifyPathFSTimeout; tests inject fake classes. + classifyFSPath func(path string) string + // triggerReportPush fires an out-of-band, non-blocking hub report push (e.g. after a // geo settings change so the hub reflects the new state immediately). Nil = no-op. triggerReportPush func() @@ -95,6 +99,7 @@ func (r *Router) SetIntegrationManager(im *integrations.Manager) { func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, notif *notify.Notifier, logger *log.Logger) *Router { r := &Router{cfg: cfg, configPath: configPath, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, metricsStore: metricsStore, updater: updater, notifier: notif, logger: logger} r.restart = func() { gracefulSelfRestart(r.logger) } + r.classifyFSPath = system.ClassifyPathFSTimeout return r } @@ -102,6 +107,21 @@ func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, s // process is not actually killed. func (r *Router) SetRestarter(fn func()) { r.restart = fn } +// refuseNetworkStubDeploy is the deploy-time stub gate (RCA fix 2). Non-empty return = the +// Hungarian refusal for a registered NETWORK HDD_PATH whose filesystem in THIS namespace is a +// local stub. Everything else proceeds: idle autofs is HEALTHY (first app access mounts it); +// classification timeout/unknown fails OPEN (a wedged share is the unreachable badge's business); +// local (non-network) and empty paths keep today's behavior exactly. +func (r *Router) refuseNetworkStubDeploy(hdd string) string { + if hdd == "" || r.sett == nil || !r.sett.IsNetworkStoragePath(hdd) { + return "" + } + if r.classifyFSPath(hdd) != system.FSClassStub { + return "" + } + return "A kiválasztott hálózati tárhely jelenleg nem érhető el az alkalmazások környezetéből — a telepítés nem indítható. Próbálja újra pár perc múlva, vagy jelezze az üzemeltetőnek." +} + // SetReportPushTrigger wires the out-of-band hub report push used after geo changes. // The provided func MUST be non-blocking (it is called from request handlers). func (r *Router) SetReportPushTrigger(fn func()) { r.triggerReportPush = fn } @@ -403,6 +423,16 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri return } + // RCA fix 2 (AUDIT-nas-cwa-rca-2026-07-11): a deploy targeting a registered NETWORK storage path + // must see a network filesystem (or its healthy idle autofs trigger) in THIS namespace — the one + // the app will consume the path in. A stub (plain local dir after a guest reboot) would silently + // send the app's data to the system drive. + if msg := r.refuseNetworkStubDeploy(body.Values["HDD_PATH"]); msg != "" { + r.logger.Printf("[WARN] [api] Deploy refused for %s: network HDD_PATH %s is a stub in the controller namespace", name, body.Values["HDD_PATH"]) + writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: msg}) + return + } + deployReq := stacks.DeployRequest{ StackName: name, Values: body.Values, diff --git a/controller/internal/system/fsclass.go b/controller/internal/system/fsclass.go new file mode 100644 index 0000000..16de322 --- /dev/null +++ b/controller/internal/system/fsclass.go @@ -0,0 +1,64 @@ +package system + +import "time" + +// Consuming-namespace filesystem classification (RCA AUDIT-nas-cwa-rca-2026-07-11 fix 2). +// +// THE LESSON: the add-time probe, the deploy flow and the dashboard all trusted host-side (agent) +// health for network storage — while the namespace the apps actually consume the path in (the +// controller container's, an rslave copy of the guest's) can silently hold a plain local stub +// after a guest reboot. This classifier answers "what IS this path in THIS process's namespace" +// from statfs f_type, so verification happens where consumption happens. + +// FS classes. +const ( + // FSClassNetwork: a real network filesystem is mounted here (nfs/cifs/smb2). + FSClassNetwork = "network" + // FSClassAutofs: an idle automount trigger — HEALTHY (first access mounts it). Callers must + // NOT force-mount to "check deeper"; waking the NAS defeats the idle-unmount design. + FSClassAutofs = "autofs" + // FSClassStub: anything else (ext4/tmpfs/plain dir on the system device) — the RCA's silent + // local stub. For a registered network path this is always a defect. + FSClassStub = "stub" + // FSClassUnknown: statfs failed or timed out — no verdict (callers fail open; a wedged share + // is the agent-unreachable branch's business). + FSClassUnknown = "unknown" +) + +// classifyFSMagic maps a statfs f_type (linux/magic.h) to an FS class. Pure — unit-tested against +// the magic table. Compared through the unsigned-32 view: the kernel returns f_type as a signed +// long, so CIFS_MAGIC_NUMBER (0xFF534D42) can arrive negative depending on how it was widened. +func classifyFSMagic(ftype int64) string { + u := uint64(ftype) & 0xFFFFFFFF + switch u { + case 0x0187: // AUTOFS_SUPER_MAGIC + return FSClassAutofs + case 0x6969: // NFS_SUPER_MAGIC (all nfs versions) + return FSClassNetwork + case 0xFF534D42: // CIFS_MAGIC_NUMBER + return FSClassNetwork + case 0xFE534D42: // SMB2_MAGIC_NUMBER + return FSClassNetwork + default: + return FSClassStub + } +} + +// fsClassTimeout bounds a classification statfs — a mounted-but-dead network fs can block statfs +// for the NFS soft-timeout window; the dashboard/deploy paths must not hang on it. +const fsClassTimeout = 3 * time.Second + +// ClassifyPathFSTimeout classifies path in this process's mount namespace, bounded by +// fsClassTimeout. Timeout or statfs error → FSClassUnknown (no verdict — fail open). This is the +// entry point for the deploy-time gate and the dashboard stub badge; the probe child uses the +// unbounded ClassifyPathFS (its whole run is already deadline-bounded by the parent). +func ClassifyPathFSTimeout(path string) string { + ch := make(chan string, 1) + go func() { ch <- ClassifyPathFS(path) }() + select { + case c := <-ch: + return c + case <-time.After(fsClassTimeout): + return FSClassUnknown + } +} diff --git a/controller/internal/system/fsclass_linux.go b/controller/internal/system/fsclass_linux.go new file mode 100644 index 0000000..961dbba --- /dev/null +++ b/controller/internal/system/fsclass_linux.go @@ -0,0 +1,19 @@ +//go:build linux + +package system + +import "syscall" + +// statfsFn is the syscall seam (tests inject fake f_types without real mounts). +var statfsFn = syscall.Statfs + +// ClassifyPathFS classifies path by its filesystem magic in this process's mount namespace. +// UNBOUNDED — statfs on a mounted-but-dead network fs can block for the soft-timeout window; +// interactive callers use ClassifyPathFSTimeout. statfs error → FSClassUnknown. +func ClassifyPathFS(path string) string { + var st syscall.Statfs_t + if err := statfsFn(path, &st); err != nil { + return FSClassUnknown + } + return classifyFSMagic(int64(st.Type)) +} diff --git a/controller/internal/system/fsclass_linux_test.go b/controller/internal/system/fsclass_linux_test.go new file mode 100644 index 0000000..da0efa7 --- /dev/null +++ b/controller/internal/system/fsclass_linux_test.go @@ -0,0 +1,36 @@ +//go:build linux + +package system + +import ( + "syscall" + "testing" +) + +// The statfs seam wires f_type into the classifier; a statfs error yields UNKNOWN (fail open), not +// a stub verdict. +func TestClassifyPathFS_Seam(t *testing.T) { + orig := statfsFn + t.Cleanup(func() { statfsFn = orig }) + + statfsFn = func(_ string, st *syscall.Statfs_t) error { st.Type = 0x6969; return nil } + if got := ClassifyPathFS("/anything"); got != FSClassNetwork { + t.Errorf("nfs magic → %q, want network", got) + } + statfsFn = func(_ string, st *syscall.Statfs_t) error { st.Type = 0x0187; return nil } + if got := ClassifyPathFS("/anything"); got != FSClassAutofs { + t.Errorf("autofs magic → %q, want autofs", got) + } + statfsFn = func(_ string, _ *syscall.Statfs_t) error { return syscall.EIO } + if got := ClassifyPathFS("/anything"); got != FSClassUnknown { + t.Errorf("statfs error → %q, want unknown (fail open, never stub)", got) + } +} + +// Real-IO: a plain local directory MUST classify as a stub — this is the exact state the RCA's +// guest reboot produced, and the verdict everything downstream keys on. +func TestClassifyPathFS_RealLocalDirIsStub(t *testing.T) { + if got := ClassifyPathFS(t.TempDir()); got != FSClassStub { + t.Fatalf("a plain local dir must classify as stub, got %q", got) + } +} diff --git a/controller/internal/system/fsclass_other.go b/controller/internal/system/fsclass_other.go new file mode 100644 index 0000000..0951dfa --- /dev/null +++ b/controller/internal/system/fsclass_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package system + +// ClassifyPathFS is Linux-only (statfs f_type); off-linux (dev hosts) there is no mount namespace +// to interrogate — no verdict, callers fail open. +func ClassifyPathFS(path string) string { + return FSClassUnknown +} diff --git a/controller/internal/system/fsclass_test.go b/controller/internal/system/fsclass_test.go new file mode 100644 index 0000000..7bc5811 --- /dev/null +++ b/controller/internal/system/fsclass_test.go @@ -0,0 +1,28 @@ +package system + +import "testing" + +// The classification table (RCA fix 2): network fs magics and the healthy idle autofs trigger are +// OK; anything local is a STUB. +func TestClassifyFSMagic_Table(t *testing.T) { + cifsU32 := uint32(0xFF534D42) // runtime value: the sign-extended form must classify identically + cases := []struct { + name string + ftype int64 + want string + }{ + {"autofs trigger (idle — healthy)", 0x0187, FSClassAutofs}, + {"nfs (all versions)", 0x6969, FSClassNetwork}, + {"cifs", 0xFF534D42, FSClassNetwork}, + {"cifs as sign-extended negative", int64(int32(cifsU32)), FSClassNetwork}, + {"smb2", 0xFE534D42, FSClassNetwork}, + {"ext4 (the RCA stub)", 0xEF53, FSClassStub}, + {"tmpfs", 0x01021994, FSClassStub}, + {"overlayfs", 0x794C7630, FSClassStub}, + } + for _, c := range cases { + if got := classifyFSMagic(c.ftype); got != c.want { + t.Errorf("%s: classifyFSMagic(%#x) = %q, want %q", c.name, c.ftype, got, c.want) + } + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index deb4f4c..be87c44 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -156,7 +156,9 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) { data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption data["Stacks"] = deployedStacks data["MissingStorage"] = s.missingStorageMap(deployedStacks) - data["NetworkWarnings"] = s.networkStorageWarnings(deployedStacks) // NAS unreachable — recoverable warning, not "missing" + nw, ns := s.networkStorageWarnings(deployedStacks) // NAS unreachable (recoverable) / guest-side stub (defect) + data["NetworkWarnings"] = nw + data["NetworkStubs"] = ns data["RunningCount"] = running data["StoppedCount"] = stopped data["TotalCount"] = len(stackList) @@ -204,7 +206,9 @@ func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) { allStacks := s.stackMgr.GetStacks() data["Stacks"] = allStacks data["MissingStorage"] = s.missingStorageMap(allStacks) - data["NetworkWarnings"] = s.networkStorageWarnings(allStacks) // NAS unreachable — recoverable warning, not "missing" + nw, ns := s.networkStorageWarnings(allStacks) // NAS unreachable (recoverable) / guest-side stub (defect) + data["NetworkWarnings"] = nw + data["NetworkStubs"] = ns // Build storage label lookup for deployed apps storageLabels := make(map[string]string) // stack name → storage label @@ -340,6 +344,25 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri deployPaths = append(deployPaths, dp) } data["StoragePaths"] = deployPaths + // RCA fix 4: a deployed app's read-only storage select must show the app's STORED HDD_PATH — + // never the default drive (the pre-fix render selected by IsDefault only, so the settings view + // lied about where the data lives). When the stored path is no longer schedulable, an extra + // disabled option names it verbatim rather than silently showing a different storage. + data["CurrentHDDPath"] = "" + data["CurrentHDDPathMissing"] = false + if alreadyDeployed && appCfg != nil { + if hdd := appCfg.Env["HDD_PATH"]; hdd != "" { + data["CurrentHDDPath"] = hdd + inList := false + for _, dp := range deployPaths { + if dp.Path == hdd { + inList = true + break + } + } + data["CurrentHDDPathMissing"] = !inList + } + } // Prevention layer (storage-split): surface the Docker-data volume's reserved-buffer state so the // customer sees BEFORE deploying when free space is too low (the API gate also hard-refuses). Only @@ -1376,14 +1399,21 @@ func (s *Server) missingStorageLabel(hddPath string) (string, bool) { return s.settings.GetStorageLabel(hddPath), true // not in registry → its drive is gone } -// networkStorageWarnings returns stack-name → share label for every deployed app whose HDD_PATH is a NAS -// network path the agent currently reports `unreachable`. This is a RECOVERABLE warning ("hálózati -// tárhely nem elérhető"), explicitly NOT the drive missing/stop-cascade — the app keeps running and the -// badge clears when the NAS returns. Best-effort: an agent error or no network paths → no warnings. -func (s *Server) networkStorageWarnings(list []stacks.Stack) map[string]string { - out := map[string]string{} +// networkStorageWarnings returns two stack-name → share-label maps for deployed apps on NAS +// network paths: +// - warnings: the agent reports the share `unreachable` — RECOVERABLE ("hálózati tárhely nem +// elérhető"), explicitly NOT the drive missing/stop-cascade; clears when the NAS returns. +// - stubs: the path is a plain local STUB in the controller's namespace (RCA fix 2 — the +// guest-reboot state where apps silently see an empty dir while the agent's host-side view is +// healthy). Checked from THIS process (the consuming namespace), independent of the agent. +// +// Stub wins: a stack never appears in both. An idle autofs trigger is HEALTHY and is never +// force-mounted from here (classification reads the fs magic only). Best-effort: an agent error +// drops the unreachable leg but the stub leg still runs. +func (s *Server) networkStorageWarnings(list []stacks.Stack) (warnings, stubs map[string]string) { + warnings, stubs = map[string]string{}, map[string]string{} if s.settings == nil || s.stackMgr == nil { - return out + return warnings, stubs } netPaths := map[string]settings.StoragePath{} for _, sp := range s.settings.GetStoragePaths() { @@ -1392,47 +1422,77 @@ func (s *Server) networkStorageWarnings(list []stacks.Stack) map[string]string { } } if len(netPaths) == 0 { - return out - } - agent, err := s.agentClient() - if err != nil { - return out - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - mounts, err := agent.ListNetStorage(ctx) - if err != nil { - s.logger.Printf("[WARN] [web] network storage health unavailable for warnings: %v", err) - return out + return warnings, stubs } + // Stub leg — the consuming namespace's own verdict (no agent, no force-mount). + stubPaths := s.stubNetworkPaths(netPaths) + // Unreachable leg — the agent's host-side liveness view (unchanged behavior). unreachable := map[string]string{} // path → label - for _, m := range mounts { - if !m.Unreachable() { // only `unreachable` is degraded; `idle`/`ok` are benign - continue + if agent, err := s.agentClient(); err == nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if mounts, err := agent.ListNetStorage(ctx); err == nil { + for _, m := range mounts { + if !m.Unreachable() { // only `unreachable` is degraded; `idle`/`ok` are benign + continue + } + p := settings.NetworkMountRoot + "/" + m.Name + if sp, ok := netPaths[p]; ok { + lbl := sp.Label + if lbl == "" { + lbl = m.Name + } + unreachable[p] = lbl + } + } + } else { + s.logger.Printf("[WARN] [web] network storage health unavailable for warnings: %v", err) } - p := settings.NetworkMountRoot + "/" + m.Name - if sp, ok := netPaths[p]; ok { + } + return networkStorageWarningsIn(list, s.stackMgr.LoadAppConfigByName, unreachable, stubPaths) +} + +// stubNetworkPaths classifies each registered network path in the controller's namespace and +// returns path→label for every STUB (RCA fix 2). Idle autofs is healthy and classification never +// force-mounts (fs magic read only); unknown (timeout/statfs error) is NOT a stub — fail open. +func (s *Server) stubNetworkPaths(netPaths map[string]settings.StoragePath) map[string]string { + out := map[string]string{} + for p, sp := range netPaths { + if s.classifyFSPath(p) == system.FSClassStub { lbl := sp.Label if lbl == "" { - lbl = m.Name + lbl = strings.TrimPrefix(p, settings.NetworkMountRoot+"/") } - unreachable[p] = lbl + out[p] = lbl } } - if len(unreachable) == 0 { - return out + return out +} + +// networkStorageWarningsIn is the pure per-stack mapping core (the appsUsingPathIn pattern): given +// the path→label verdict sets, assign each deployed stack its badge. Stub WINS over unreachable — +// a stack never appears in both maps. +func networkStorageWarningsIn(list []stacks.Stack, load func(string) *stacks.AppConfig, unreachable, stubPaths map[string]string) (warnings, stubs map[string]string) { + warnings, stubs = map[string]string{}, map[string]string{} + if len(unreachable) == 0 && len(stubPaths) == 0 { + return warnings, stubs } for _, st := range list { if !st.Deployed { continue } - if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil { - if lbl, bad := unreachable[cfg.Env["HDD_PATH"]]; bad { - out[st.Name] = lbl + if cfg := load(st.Name); cfg != nil { + hdd := cfg.Env["HDD_PATH"] + if lbl, bad := stubPaths[hdd]; bad { + stubs[st.Name] = lbl // stub wins over unreachable + continue + } + if lbl, bad := unreachable[hdd]; bad { + warnings[st.Name] = lbl } } } - return out + return warnings, stubs } // missingStorageMap returns stack-name → storage label for every deployed app whose data drive is diff --git a/controller/internal/web/netprobe.go b/controller/internal/web/netprobe.go index f229e57..7a0dd03 100644 --- a/controller/internal/web/netprobe.go +++ b/controller/internal/web/netprobe.go @@ -5,6 +5,8 @@ import ( "encoding/hex" "os" "path/filepath" + + "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) // In-guest uid-1000 write probe (NAS verify-before-commit, SPIKE-nas-verify Q2/Q3). The agent's @@ -20,21 +22,34 @@ const ( netProbeExitNoWrite = 2 // create/write failed → not_writable (the squash trap) netProbeExitMismatch = 3 // readback failed or differed → probe_io netProbeExitCleanup = 4 // wrote fine but delete failed → OK + warn (never a failure) + netProbeExitNotNetFS = 5 // dir is not a mounted network fs → not_network_fs (the stub trap, RCA fix 2) ) // netProbeReadBack is the child's readback seam (package var — the child is a re-exec'd process in // production, so a struct seam can't reach it; tests override in-process). var netProbeReadBack = os.ReadFile +// netProbeFSClass is the child's namespace-classification seam (RCA fix 2): the real value is +// system.ClassifyPathFS (statfs f_type) on linux, a vacuous FSClassNetwork off-linux (the child +// only ever runs in the linux container). Tests override to model a stub without a real mount. +var netProbeFSClass = platformNetProbeFSClass + // NetProbeChild is the --netprobe body, run AS uid/gid 1000 by the re-exec parent: create a -// dot-file with a random name + nonce in dir, read it back, compare, remove. Pure file logic — -// unit-tested directly in t.TempDir(). Exposed for cmd/controller's hidden mode. +// dot-file with a random name + nonce in dir — the create legitimately triggers the automount — +// THEN require the dir to be a MOUNTED network filesystem (after a create, an autofs or local +// answer means the mount did not materialize: the RCA's silent-stub trap), then read back, +// compare, remove. Pure file logic — unit-tested directly in t.TempDir(). Exposed for +// cmd/controller's hidden mode. func NetProbeChild(dir string) int { name := filepath.Join(dir, ".felhom-proba-"+randHexToken(8)) nonce := randHexToken(32) if err := os.WriteFile(name, []byte(nonce), 0o644); err != nil { return netProbeExitNoWrite } + if class := netProbeFSClass(dir); class != system.FSClassNetwork { + _ = os.Remove(name) // best-effort — the verdict is already not-network-fs + return netProbeExitNotNetFS + } back, err := netProbeReadBack(name) if err != nil || string(back) != nonce { _ = os.Remove(name) // best-effort — the verdict is already mismatch @@ -65,6 +80,8 @@ func netProbeVerdict(exitCode int, output string) probeOutcome { return probeOutcome{OK: false, Category: "not_writable", Detail: "uid-1000 write probe: create/write refused | " + output} case netProbeExitMismatch: return probeOutcome{OK: false, Category: "probe_io", Detail: "uid-1000 write probe: readback failed or differed | " + output} + case netProbeExitNotNetFS: + return probeOutcome{OK: false, Category: "not_network_fs", Detail: "uid-1000 write probe: dir is not a mounted network filesystem in the controller namespace (stub) | " + output} default: return probeOutcome{OK: false, Category: "probe_io", Detail: "uid-1000 write probe: unexpected exit | " + output} } diff --git a/controller/internal/web/netprobe_linux.go b/controller/internal/web/netprobe_linux.go index 91a0d04..37daab3 100644 --- a/controller/internal/web/netprobe_linux.go +++ b/controller/internal/web/netprobe_linux.go @@ -9,8 +9,16 @@ import ( "strings" "syscall" "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) +// platformNetProbeFSClass is the real namespace classifier (statfs f_type) the probe child runs. +// Unbounded on purpose: the child's whole run is already bounded by the parent's netProbeTimeout. +func platformNetProbeFSClass(dir string) string { + return system.ClassifyPathFS(dir) +} + // netProbeTimeout bounds one probe run (LAN write+readback is sub-second; a wedged share must not // hold the orchestrator — the NFS soft/retry=0 options error out well inside this). const netProbeTimeout = 30 * time.Second diff --git a/controller/internal/web/netprobe_other.go b/controller/internal/web/netprobe_other.go index 12a814e..f1cccce 100644 --- a/controller/internal/web/netprobe_other.go +++ b/controller/internal/web/netprobe_other.go @@ -2,10 +2,21 @@ package web -import "context" +import ( + "context" + + "gitea.dooplex.hu/admin/felhom-controller/internal/system" +) // runNetProbe is Linux-only (SysProcAttr.Credential). Off-linux (dev/test hosts) the seam must be // injected; reaching this stub is a wiring error, reported as a failed probe — never a false PASS. func runNetProbe(_ context.Context, _ string) probeOutcome { return probeOutcome{OK: false, Category: "probe_io", Detail: "uid-1000 probe unavailable on this platform"} } + +// platformNetProbeFSClass off-linux: the child never runs here in production (statfs f_type is a +// linux concept); vacuously "network" so cross-platform child unit tests exercise the write/readback +// logic — the fstype assertion itself is tested via the netProbeFSClass seam + linux-only real-IO. +func platformNetProbeFSClass(_ string) string { + return system.FSClassNetwork +} diff --git a/controller/internal/web/netprobe_stub_test.go b/controller/internal/web/netprobe_stub_test.go new file mode 100644 index 0000000..23c0ec9 --- /dev/null +++ b/controller/internal/web/netprobe_stub_test.go @@ -0,0 +1,98 @@ +package web + +import ( + "context" + "os" + "runtime" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" + "gitea.dooplex.hu/admin/felhom-controller/internal/system" +) + +// The stub trap (RCA fix 2): a probe dir that is NOT a mounted network filesystem must exit +// not-network-fs — writability alone can never verify a share again. Seam-modeled (cross-platform); +// the real-statfs leg is TestNetProbeChild_RealLocalDirRefused below. +// Companion red-proof: remove the fstype assertion from NetProbeChild → this expects exit 5 but +// gets 0 (the stub VERIFIES) → FAIL — the exact regression this task exists to prevent. +func TestNetProbeChild_StubRefused(t *testing.T) { + orig := netProbeFSClass + t.Cleanup(func() { netProbeFSClass = orig }) + netProbeFSClass = func(string) string { return system.FSClassStub } + + dir := t.TempDir() + if got := NetProbeChild(dir); got != netProbeExitNotNetFS { + t.Fatalf("probe against a stub dir: exit = %d, want %d (not_network_fs)", got, netProbeExitNotNetFS) + } + // The probe file must not linger after the refusal. + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Errorf("probe file not cleaned up after stub refusal: %v", entries) + } +} + +// After a create, an AUTOFS answer means the mount did not materialize — mounted network forms +// only pass the child (idle-trigger tolerance belongs to the deploy gate, not the probe). +func TestNetProbeChild_AutofsAfterCreateRefused(t *testing.T) { + orig := netProbeFSClass + t.Cleanup(func() { netProbeFSClass = orig }) + netProbeFSClass = func(string) string { return system.FSClassAutofs } + + if got := NetProbeChild(t.TempDir()); got != netProbeExitNotNetFS { + t.Fatalf("autofs-after-create: exit = %d, want %d", got, netProbeExitNotNetFS) + } +} + +// Real-IO (linux): the platform classifier against a plain local dir — no seam. THE state the RCA +// remediation left behind must never verify again. +func TestNetProbeChild_RealLocalDirRefused(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("statfs f_type is linux-only; exercised on the Linux build server") + } + if got := NetProbeChild(t.TempDir()); got != netProbeExitNotNetFS { + t.Fatalf("real local dir: exit = %d, want %d (the stub verified!)", got, netProbeExitNotNetFS) + } +} + +// The parent verdict map: exit 5 → failed category not_network_fs. +func TestNetProbeVerdict_NotNetworkFS(t *testing.T) { + o := netProbeVerdict(netProbeExitNotNetFS, "detail") + if o.OK || o.Category != "not_network_fs" { + t.Fatalf("verdict = %+v, want !OK + not_network_fs", o) + } +} + +// Job-level: a stub probe verdict rolls the agent install back, registers NOTHING, and surfaces the +// §3.2 Hungarian message for not_network_fs. +func TestNetAdd_StubProbe_RollsBackNotRegistered(t *testing.T) { + s := testServer(t) + agent := &fakeNetAgent{addRes: okAddRes("media"), verify: agentapi.NetVerifyStatus{Phase: "done", JobID: "job-1"}} + s.netProbeFn = func(_ context.Context, _ string) probeOutcome { + // The real chain: child exit → verdict (the seam models the stub without a real mount). + orig := netProbeFSClass + defer func() { netProbeFSClass = orig }() + netProbeFSClass = func(string) string { return system.FSClassStub } + return netProbeVerdict(NetProbeChild(t.TempDir()), "") + } + + if !s.startNetAdd(agent, netAddReq("media"), "NAS media") { + t.Fatal("startNetAdd refused") + } + job := waitNetAdd(t, s) + if job.Phase != netAddPhaseFailed || job.Category != "not_network_fs" { + t.Fatalf("phase/category = %s/%s, want failed/not_network_fs", job.Phase, job.Category) + } + if !strings.Contains(job.Message, "nem jött létre megfelelően") { + t.Errorf("not_network_fs Hungarian message missing, got %q", job.Message) + } + if got := agent.removed(); len(got) != 1 || got[0] != "media" { + t.Errorf("stub probe must roll the agent install back: removes=%v", got) + } + if got := networkPathCount(s); got != 0 { + t.Errorf("a stub share must NOT be registered (got %d paths)", got) + } +} diff --git a/controller/internal/web/netstorage_job.go b/controller/internal/web/netstorage_job.go index 96fce93..2997951 100644 --- a/controller/internal/web/netstorage_job.go +++ b/controller/internal/web/netstorage_job.go @@ -332,6 +332,8 @@ func netAddMessage(category, server string, mappedUID int) string { return "Időtúllépés a csatolás közben — a szerver elérhető a hálózaton, de a megosztás nem csatolható. Ellenőrizze a NAS NFS/SMB szolgáltatását." case "not_writable": return "A megosztás csatolható, de az alkalmazások nem tudnak rá írni. NFS esetén kapcsolja be a NAS-on a „minden felhasználó leképezése” (map all users / all squash) beállítást a megosztáson — vagy állítsa a fájlok tulajdonosát a(z) " + fmt.Sprint(mappedUID+100000) + " azonosítóra. SMB esetén ellenőrizze, hogy a felhasználónak írási joga van a megosztáson." + case "not_network_fs": + return "A hálózati tárhely csatolása a rendszeren belül nem jött létre megfelelően. Próbálja újra a csatlakoztatást; ha a hiba ismétlődik, jelezze az üzemeltetőnek." case "probe_io": return "Írási hiba a megosztáson (az adat nem olvasható vissza hibátlanul). Ellenőrizze a megosztást és a hálózatot." case "busy": diff --git a/controller/internal/web/networkstub_test.go b/controller/internal/web/networkstub_test.go new file mode 100644 index 0000000..fa7aed2 --- /dev/null +++ b/controller/internal/web/networkstub_test.go @@ -0,0 +1,222 @@ +package web + +import ( + "io" + "log" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" + "gitea.dooplex.hu/admin/felhom-controller/internal/system" +) + +// --- stub detection (the controller-namespace leg of the badge, RCA fix 2) -------------------------- + +func TestStubNetworkPaths_Classification(t *testing.T) { + s := testServer(t) + classes := map[string]string{ + "/mnt/felhom-drives/stubbed": system.FSClassStub, + "/mnt/felhom-drives/idle": system.FSClassAutofs, // healthy — never a stub + "/mnt/felhom-drives/live": system.FSClassNetwork, // healthy + "/mnt/felhom-drives/wedged": system.FSClassUnknown, // fail open — never a stub + } + s.classifyFSPath = func(p string) string { return classes[p] } + netPaths := map[string]settings.StoragePath{} + for p := range classes { + netPaths[p] = settings.StoragePath{Path: p, Label: "L:" + p, Kind: settings.StorageKindNetwork} + } + + got := s.stubNetworkPaths(netPaths) + if len(got) != 1 { + t.Fatalf("stub set = %v, want exactly the stubbed path", got) + } + if lbl := got["/mnt/felhom-drives/stubbed"]; lbl != "L:/mnt/felhom-drives/stubbed" { + t.Fatalf("stub label = %q", lbl) + } +} + +// --- per-stack mapping: stub wins, unreachable byte-behavior preserved ------------------------------- + +func TestNetworkStorageWarningsIn_StubWins(t *testing.T) { + list := []stacks.Stack{ + {Name: "cwa", Deployed: true}, + {Name: "jellyfin", Deployed: true}, + {Name: "undeployed", Deployed: false}, + } + env := map[string]map[string]string{ + "cwa": {"HDD_PATH": "/mnt/felhom-drives/media"}, + "jellyfin": {"HDD_PATH": "/mnt/felhom-drives/other"}, + "undeployed": {"HDD_PATH": "/mnt/felhom-drives/media"}, + } + load := func(name string) *stacks.AppConfig { + if e, ok := env[name]; ok { + return &stacks.AppConfig{Env: e} + } + return nil + } + unreachable := map[string]string{ + "/mnt/felhom-drives/media": "Média", // ALSO unreachable — stub must win + "/mnt/felhom-drives/other": "Másik", + } + stubPaths := map[string]string{"/mnt/felhom-drives/media": "Média"} + + warnings, stubs := networkStorageWarningsIn(list, load, unreachable, stubPaths) + if len(stubs) != 1 || stubs["cwa"] != "Média" { + t.Fatalf("stubs = %v, want cwa only (deployed, on the stub path)", stubs) + } + if _, both := warnings["cwa"]; both { + t.Fatalf("cwa must not ALSO carry the unreachable badge (stub wins): %v", warnings) + } + if len(warnings) != 1 || warnings["jellyfin"] != "Másik" { + t.Fatalf("warnings = %v, want jellyfin only (unreachable-alone → unchanged behavior)", warnings) + } +} + +func TestNetworkStorageWarningsIn_UnreachableAloneUnchanged(t *testing.T) { + list := []stacks.Stack{{Name: "jellyfin", Deployed: true}} + load := func(string) *stacks.AppConfig { + return &stacks.AppConfig{Env: map[string]string{"HDD_PATH": "/mnt/felhom-drives/media"}} + } + warnings, stubs := networkStorageWarningsIn(list, load, + map[string]string{"/mnt/felhom-drives/media": "Média"}, map[string]string{}) + if len(warnings) != 1 || warnings["jellyfin"] != "Média" || len(stubs) != 0 { + t.Fatalf("unreachable-alone must keep the old shape: warnings=%v stubs=%v", warnings, stubs) + } +} + +// The two badge sentences are distinct strings and both present in the templates (the stub badge +// must never reuse the recoverable-unreachable copy; the unreachable line stays byte-identical). +func TestNetworkBadgeTemplates_DistinctStrings(t *testing.T) { + for _, tpl := range []string{"templates/dashboard.html", "templates/stacks.html"} { + body, err := os.ReadFile(tpl) + if err != nil { + t.Fatal(err) + } + s := string(body) + if !strings.Contains(s, "Hálózati tárhely nem elérhető: {{$nw}}") { + t.Errorf("%s: the unreachable badge line changed (must stay byte-identical)", tpl) + } + if !strings.Contains(s, "Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja") { + t.Errorf("%s: the stub badge string missing", tpl) + } + } +} + +// --- rider (RCA fix 4): the deployed-app storage select shows the STORED HDD_PATH ------------------- + +// testDeployPageServer: a real Manager over a temp stacks dir holding one deployed app with a +// path-type deploy field, plus two registered storage paths (the default ≠ the app's stored path — +// the exact pre-fix trap). +func testDeployPageServer(t *testing.T, storedHDD string) *Server { + t.Helper() + lg := log.New(io.Discard, "", 0) + dir := t.TempDir() + cfg := &config.Config{} + cfg.Customer.ID = "test-customer" + cfg.Customer.Domain = "example.hu" + cfg.Paths.StacksDir = filepath.Join(dir, "stacks") + cfg.Paths.DataDir = filepath.Join(dir, "data") + cfg.Stacks.ComposeCommand = "docker compose" + + stackDir := filepath.Join(cfg.Paths.StacksDir, "testapp") + if err := os.MkdirAll(stackDir, 0o755); err != nil { + t.Fatal(err) + } + meta := `display_name: Testapp +deploy_fields: + - env_var: HDD_PATH + label: "Tárhely útvonal" + type: path + required: true +` + if err := os.WriteFile(filepath.Join(stackDir, ".felhom.yml"), []byte(meta), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil { + t.Fatal(err) + } + appYAML := "deployed: true\nenv:\n HDD_PATH: " + storedHDD + "\nlocked_fields:\n - HDD_PATH\n" + if err := os.WriteFile(filepath.Join(stackDir, "app.yaml"), []byte(appYAML), 0o644); err != nil { + t.Fatal(err) + } + + sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + // The default drive is NOT the app's stored path — pre-fix the select showed this one. + if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/felhom-drives/felhom-usb", Label: "Tárhely (felhom-usb)", IsDefault: true, Schedulable: true}); err != nil { + t.Fatal(err) + } + if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/felhom-drives/nas-media", Label: "Hálózati tárhely: nas-media", Schedulable: true, Kind: settings.StorageKindNetwork}); err != nil { + t.Fatal(err) + } + + mgr, err := stacks.NewManager(cfg, lg) + if err != nil { + t.Fatal(err) + } + // ScanStacks registers the stack dirs first and only then refreshes container status via + // `docker ps` — tolerate that last step failing on docker-less test hosts, but require the + // stack itself to have been discovered. + _ = mgr.ScanStacks() + if _, ok := mgr.GetStack("testapp"); !ok { + t.Fatal("testapp not discovered by ScanStacks") + } + s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"} + s.classifyFSPath = func(string) string { return system.FSClassUnknown } + s.loadTemplates() + return s +} + +// optionTag returns the full block whose value attribute equals path. +func optionTag(t *testing.T, body, path string) string { + t.Helper() + marker := `value="` + path + `"` + i := strings.Index(body, marker) + if i < 0 { + t.Fatalf("no option with value %q in rendered page", path) + } + start := strings.LastIndex(body[:i], "") + return body[start : i+end] +} + +// Deployed on the NAS → the NAS option carries `selected`; the default drive does NOT. +// Companion red-proof: revert deploy.html to the IsDefault-only selection → the default option is +// selected instead → both assertions FAIL (the exact S-C lie from the RCA). +func TestDeployPage_DeployedSelectShowsStoredHDDPath(t *testing.T) { + s := testDeployPageServer(t, "/mnt/felhom-drives/nas-media") + rec := getPage(t, s, "/stacks/testapp/deploy") + if rec.Code != 200 { + t.Fatalf("GET deploy page = %d: %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if nas := optionTag(t, body, "/mnt/felhom-drives/nas-media"); !strings.Contains(nas, "selected") { + t.Errorf("the STORED path's option must be selected, got: %s", nas) + } + if usb := optionTag(t, body, "/mnt/felhom-drives/felhom-usb"); strings.Contains(usb, "selected") { + t.Errorf("the default drive must NOT be selected for a deployed app, got: %s", usb) + } +} + +// Stored path absent from the schedulable list → an extra disabled option names it verbatim (the +// view must never silently show a different storage than app.yaml). +func TestDeployPage_MissingStoredPathRendersTruthOption(t *testing.T) { + s := testDeployPageServer(t, "/mnt/felhom-drives/gone") + rec := getPage(t, s, "/stacks/testapp/deploy") + if rec.Code != 200 { + t.Fatalf("GET deploy page = %d: %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, "/mnt/felhom-drives/gone (nem elérhető)") { + t.Errorf("missing stored path must render as a disabled truth option") + } + if gone := optionTag(t, body, "/mnt/felhom-drives/gone"); !strings.Contains(gone, "selected") || !strings.Contains(gone, "disabled") { + t.Errorf("the truth option must be selected+disabled, got: %s", gone) + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 711e8a6..df81b17 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -77,6 +77,9 @@ type Server struct { // agentLogsFn is the Debug-page agent-tab seam (v0.116.0). nil → the shared // agentClient().DebugLogs; tests inject (incl. the pre-0.83 typed-404 path). agentLogsFn func(ctx context.Context) (agentapi.AgentLogsResponse, error) + // classifyFSPath classifies a network path's filesystem in THIS process's namespace (the stub + // badge, RCA fix 2). Set to system.ClassifyPathFSTimeout by NewServer; tests inject fake classes. + classifyFSPath func(path string) string // netFeatures caches the agent-capability probe (agentapi features.go) for the coupled NAS add // semantics — the add gate + the settings-page banner read it. Zero value ready. netFeatures agentapi.SupportCache @@ -137,6 +140,7 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste loginAttempts: make(map[string]*loginAttempt), done: make(chan struct{}), } + s.classifyFSPath = system.ClassifyPathFSTimeout if cfg.Logging.Level == "debug" { logger.Printf("[DEBUG] [web] NewServer: initializing web server v%s", version) diff --git a/controller/internal/web/templates/dashboard.html b/controller/internal/web/templates/dashboard.html index cd430cf..01ef778 100644 --- a/controller/internal/web/templates/dashboard.html +++ b/controller/internal/web/templates/dashboard.html @@ -159,6 +159,7 @@ {{stateLabel .State}} {{if .Orphaned}}Elavult{{end}} {{$ms := index $.MissingStorage .Name}}{{if $ms}}Hiányzó tárhely: {{$ms}}{{end}} + {{$ns := index $.NetworkStubs .Name}}{{if $ns}}Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja{{end}} {{$nw := index $.NetworkWarnings .Name}}{{if $nw}}Hálózati tárhely nem elérhető: {{$nw}}{{end}} {{if and .Deployed (routeUnpublished .State)}}URL nem elérhető{{end}} diff --git a/controller/internal/web/templates/deploy.html b/controller/internal/web/templates/deploy.html index 1c26ad0..f982c4f 100644 --- a/controller/internal/web/templates/deploy.html +++ b/controller/internal/web/templates/deploy.html @@ -564,10 +564,13 @@ onchange="checkStorageSpace(this)"> {{range $.StoragePaths}} {{end}} + {{if $.CurrentHDDPathMissing}} + + {{end}}