v0.117.0: consuming-namespace NAS verification + deploy-view truth (RCA fixes 2+4)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -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 `<path> (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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 `<path> (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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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 <option ...>...</option> 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], "<option")
|
||||
end := strings.Index(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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -159,6 +159,7 @@
|
||||
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
|
||||
{{if .Orphaned}}<span class="tag tag-warn">Elavult</span>{{end}}
|
||||
{{$ms := index $.MissingStorage .Name}}{{if $ms}}<span class="tag tag-warn" 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."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hiányzó tárhely: {{$ms}}</span>{{end}}
|
||||
{{$ns := index $.NetworkStubs .Name}}{{if $ns}}<span class="tag tag-warn" title="Az alkalmazás környezetében a hálózati tárhely helyén üres helyi könyvtár van — az adatok nem a NAS-ra kerülnek. Jelezze az üzemeltetőnek."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja</span>{{end}}
|
||||
{{$nw := index $.NetworkWarnings .Name}}{{if $nw}}<span class="tag tag-warn" title="A hálózati tárhely (NAS) jelenleg nem érhető el. Az alkalmazás fut; az adatok elérése a NAS visszatértével helyreáll."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hálózati tárhely nem elérhető: {{$nw}}</span>{{end}}
|
||||
{{if and .Deployed (routeUnpublished .State)}}<span class="tag tag-warn" 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."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>URL nem elérhető</span>{{end}}
|
||||
|
||||
|
||||
@@ -564,10 +564,13 @@
|
||||
onchange="checkStorageSpace(this)">
|
||||
{{range $.StoragePaths}}
|
||||
<option value="{{.Path}}" data-free-percent="{{printf "%.0f" .FreePercent}}"
|
||||
{{if .IsDefault}}selected{{end}}>
|
||||
{{if $.AlreadyDeployed}}{{if eq .Path $.CurrentHDDPath}}selected{{end}}{{else if .IsDefault}}selected{{end}}>
|
||||
{{.Label}} — {{.FreeHuman}} szabad{{if .IsDefault}} (alapértelmezett){{end}}
|
||||
</option>
|
||||
{{end}}
|
||||
{{if $.CurrentHDDPathMissing}}
|
||||
<option value="{{$.CurrentHDDPath}}" selected disabled>{{$.CurrentHDDPath}} (nem elérhető)</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<div id="storage-space-warn" class="form-hint" style="color:var(--warn);display:none">
|
||||
<svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> A kiválasztott tárhely majdnem megtelt.
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
|
||||
{{if .Orphaned}}<span class="tag tag-warn">Elavult</span>{{end}}
|
||||
{{$ms := index $.MissingStorage .Name}}{{if $ms}}<span class="tag tag-warn" title="Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hiányzó tárhely: {{$ms}}</span>{{end}}
|
||||
{{$ns := index $.NetworkStubs .Name}}{{if $ns}}<span class="tag tag-warn" title="Az alkalmazás környezetében a hálózati tárhely helyén üres helyi könyvtár van — az adatok nem a NAS-ra kerülnek. Jelezze az üzemeltetőnek."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja</span>{{end}}
|
||||
{{$nw := index $.NetworkWarnings .Name}}{{if $nw}}<span class="tag tag-warn" title="A hálózati tárhely (NAS) jelenleg nem érhető el. Az alkalmazás fut; az adatok elérése a NAS visszatértével helyreáll."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hálózati tárhely nem elérhető: {{$nw}}</span>{{end}}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user