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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user