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:
2026-07-11 21:12:48 +02:00
parent 6e9dd1bfa1
commit c0f3e12483
21 changed files with 767 additions and 38 deletions
+94 -34
View File
@@ -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