364dc50794
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
153 lines
6.5 KiB
Go
153 lines
6.5 KiB
Go
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")
|
|
}
|
|
}
|