controller v0.92.0: NAS network storage Part A2 (registry + UI + per-share health)
Controller-side of NAS network storage, proxying to agent A1 /netstorage/*. Distinct 'network' storage kind (no drive lifecycle); add/list/remove + per-share health UI; unreachable NAS is a recoverable warning, never the drive missing/stop cascade; SMB creds pass through to the agent, never persisted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// addNetworkPath registers a Kind=network StoragePath in the test server's settings.
|
||||
func addNetworkPath(t *testing.T, s *Server, name string) {
|
||||
t.Helper()
|
||||
if err := s.settings.AddStoragePath(settings.StoragePath{
|
||||
Path: settings.NetworkMountRoot + "/" + name,
|
||||
Label: "NAS " + name,
|
||||
Schedulable: true,
|
||||
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Kind: settings.StorageKindNetwork,
|
||||
Protocol: "nfs", Server: "10.0.0.5", Export: "/srv/" + name,
|
||||
MappedUID: 1000, MappedGID: 1000,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeEnvelope(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, w.Body.String())
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// TestNetStorage_RegisteredAndListed: a Kind=network path is registered and surfaced by the list handler
|
||||
// (with health "unknown" when the agent is not reachable in the test).
|
||||
func TestNetStorage_RegisteredAndListed(t *testing.T) {
|
||||
s := testServer(t)
|
||||
addNetworkPath(t, s, "media")
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/storage/netstorage", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleNetStorageList(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list: got %d want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"name":"media"`) || !strings.Contains(body, `"protocol":"nfs"`) {
|
||||
t.Fatalf("network share not listed: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"health":"unknown"`) {
|
||||
t.Fatalf("expected unknown health with no agent, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNetStorage_KindGate is the §10 companion: the drive-lifecycle handlers REFUSE a Kind=network path.
|
||||
// A network path → 400 refusal (the Kind-gate short-circuits BEFORE any agent call). The companion
|
||||
// red-proof: a DRIVE path (or an unknown path) is NOT refused by the gate — it proceeds and fails later
|
||||
// with a 503 (no agent configured in the test). A Kind-blind impl would treat both alike and FAIL here.
|
||||
func TestNetStorage_KindGate(t *testing.T) {
|
||||
s := testServer(t)
|
||||
addNetworkPath(t, s, "media")
|
||||
|
||||
lifecycle := []struct {
|
||||
name string
|
||||
path string
|
||||
handler func(http.ResponseWriter, *http.Request)
|
||||
driveCompan bool // a drive path fails cleanly at the agent step (no stackMgr needed) → safe to assert
|
||||
}{
|
||||
{"eject", "/api/storage/eject", s.handleStorageEject, true},
|
||||
{"wipe", "/api/storage/wipe", s.handleStorageWipe, true},
|
||||
{"decommission", "/api/storage/decommission", s.handleStorageDecommission, false},
|
||||
}
|
||||
for _, lc := range lifecycle {
|
||||
// A NETWORK path is refused by the Kind-gate (400, before any agent/stackMgr use).
|
||||
netBody := `{"where":"/mnt/felhom-drives/media","device":"/dev/sdz","mount_name":"media","mode":"anyway"}`
|
||||
rn := httptest.NewRequest(http.MethodPost, lc.path, strings.NewReader(netBody))
|
||||
wn := httptest.NewRecorder()
|
||||
lc.handler(wn, rn)
|
||||
if wn.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s on a network path: got %d want 400 (Kind-gate must refuse)", lc.name, wn.Code)
|
||||
}
|
||||
if env := decodeEnvelope(t, wn); env["ok"] != false {
|
||||
t.Fatalf("%s on a network path must be ok:false, got %v", lc.name, env["ok"])
|
||||
}
|
||||
|
||||
// COMPANION: a DRIVE path is NOT refused by the Kind-gate — it falls through to the agent step and
|
||||
// fails 503 (no agent), proving the gate keys on Kind, not the path. (Only for handlers that reach
|
||||
// the agent before any nil-dependency; decommission-anyway dives into stackMgr, covered by the
|
||||
// direct refuseNetworkLifecycle unit test instead.)
|
||||
if !lc.driveCompan {
|
||||
continue
|
||||
}
|
||||
drvBody := `{"where":"/mnt/hdd_1","device":"/dev/sdz","mount_name":"hdd_1","mode":"anyway"}`
|
||||
rd := httptest.NewRequest(http.MethodPost, lc.path, strings.NewReader(drvBody))
|
||||
wd := httptest.NewRecorder()
|
||||
lc.handler(wd, rd)
|
||||
if wd.Code == http.StatusBadRequest {
|
||||
t.Fatalf("%s on a DRIVE path must NOT hit the Kind-gate 400 (got 400) — the gate would be Kind-blind", lc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNetStorage_UnreachableNotMissing is the §10 health-surface companion: an `unreachable` NAS must
|
||||
// surface as a recoverable WARNING, never the drive "missing → stopped" cascade. Here: (1) a network path
|
||||
// is NEVER reported "missing" by missingStorageLabel, and (2) the drive-absent gate produces NO Stop
|
||||
// action for a network path even though it is absent from the agent's /disks list. The companion shows a
|
||||
// real DRIVE path under the same parent, absent from /disks, DOES get a Stop action — proving the network
|
||||
// exemption (not luck) is what prevents the cascade. An impl that mapped unreachable→missing FAILS (1).
|
||||
func TestNetStorage_UnreachableNotMissing(t *testing.T) {
|
||||
s := testServer(t)
|
||||
addNetworkPath(t, s, "media") // registered network path /mnt/felhom-drives/media
|
||||
|
||||
// (1) a network path is never "missing" (no stop-cascade label), regardless of agent reachability.
|
||||
if lbl, missing := s.missingStorageLabel("/mnt/felhom-drives/media"); missing {
|
||||
t.Fatalf("a network path must NOT be 'missing' (got missing=true, label=%q)", lbl)
|
||||
}
|
||||
|
||||
// (2) the drive-absent gate skips the network path: with the agent reporting NO disks (the NAS is not
|
||||
// a drive — Scenario D), the network path is absent from /disks but must produce NO Stop action.
|
||||
netPaths := s.settings.GetStoragePaths()
|
||||
if acts := planDriveGates(netPaths, nil); len(acts) != 0 {
|
||||
t.Fatalf("the drive gate must produce NO action for a network path, got %+v", acts)
|
||||
}
|
||||
|
||||
// COMPANION: a real DRIVE path under the same parent, absent from /disks, DOES get a Stop action —
|
||||
// proving the gate WOULD have stopped the network path's apps if it weren't exempted.
|
||||
drivePaths := []settings.StoragePath{{Path: StableParentDir + "/realhdd", Schedulable: true}}
|
||||
acts := planDriveGates(drivePaths, nil)
|
||||
if len(acts) != 1 || !acts[0].Stop {
|
||||
t.Fatalf("control: an absent DRIVE path under the parent must get a Stop action, got %+v", acts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefuseNetworkLifecycle is the unit-level discriminator: network → refused, drive/unknown → not.
|
||||
func TestRefuseNetworkLifecycle(t *testing.T) {
|
||||
s := testServer(t)
|
||||
addNetworkPath(t, s, "media")
|
||||
|
||||
w1 := httptest.NewRecorder()
|
||||
if !s.refuseNetworkLifecycle(w1, "/mnt/felhom-drives/media") {
|
||||
t.Fatal("a network path must be refused")
|
||||
}
|
||||
w2 := httptest.NewRecorder()
|
||||
if s.refuseNetworkLifecycle(w2, "/mnt/hdd_1") {
|
||||
t.Fatal("a drive/unknown path must NOT be refused by the network gate")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user