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:
@@ -509,6 +509,92 @@ func (c *Client) FormatDisk(ctx context.Context, device, fstype string, confirme
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- NAS network storage (Part A2 → agent A1 /netstorage/*) ------------------------------
|
||||
//
|
||||
// A NAS share is a DISTINCT storage class from a drive: the controller proxies add/list/remove to the
|
||||
// agent (which owns the host-side automount), holds NO mount authority, and persists NO SMB password
|
||||
// (it passes the credentials straight through to the agent's add request — the agent writes the 0600
|
||||
// creds file). There is NO eject/decommission/migrate/wipe/SMART here — those are drive-only.
|
||||
|
||||
// NetworkMountStatus mirrors the agent's GET /netstorage entry (A1). health ∈ {ok, idle, unreachable}:
|
||||
// `idle` (reachable + automount idle-unmounted) is BENIGN, not a fault; only `unreachable` is degraded.
|
||||
type NetworkMountStatus struct {
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol"`
|
||||
Server string `json:"server"`
|
||||
Export string `json:"export"`
|
||||
Where string `json:"where"`
|
||||
Configured bool `json:"configured"`
|
||||
Mounted bool `json:"mounted"`
|
||||
Reachable bool `json:"reachable"`
|
||||
Health string `json:"health"` // ok | idle | unreachable
|
||||
}
|
||||
|
||||
// Unreachable reports the degraded state (NAS not reachable). `idle` is explicitly NOT unreachable — an
|
||||
// idle-unmounted automount is the normal steady state, never a warning.
|
||||
func (n NetworkMountStatus) Unreachable() bool { return n.Health == "unreachable" }
|
||||
|
||||
// AddNetStorageRequest is the controller→agent POST /netstorage/add body (A1). Username/Password are SMB
|
||||
// only and flow STRAIGHT THROUGH to the agent (which writes the 0600 creds file) — the controller never
|
||||
// stores the password at rest.
|
||||
type AddNetStorageRequest struct {
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol"` // nfs | smb
|
||||
Server string `json:"server"`
|
||||
Export string `json:"export"`
|
||||
MappedUID int `json:"mapped_uid"`
|
||||
MappedGID int `json:"mapped_gid"`
|
||||
IdleTimeoutSec int `json:"idle_timeout_sec,omitempty"`
|
||||
Username string `json:"username,omitempty"` // SMB secret — pass-through, never persisted
|
||||
Password string `json:"password,omitempty"` // SMB secret — pass-through, never persisted
|
||||
}
|
||||
|
||||
// NetStorageAddResult mirrors the agent's add response (the in-guest path the media app's data dir points at).
|
||||
type NetStorageAddResult struct {
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol"`
|
||||
Where string `json:"where"`
|
||||
GuestPath string `json:"guest_path"`
|
||||
HostUID int `json:"host_uid"`
|
||||
HostGID int `json:"host_gid"`
|
||||
}
|
||||
|
||||
// AddNetStorage mounts a NAS share host-side (the agent automounts it; it propagates into this guest via
|
||||
// the shared bind). Returns the in-guest path the media app's data dir is pointed at.
|
||||
func (c *Client) AddNetStorage(ctx context.Context, req AddNetStorageRequest) (NetStorageAddResult, error) {
|
||||
var out NetStorageAddResult
|
||||
body, err := c.post(ctx, "/netstorage/add", req)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode /netstorage/add: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListNetStorage returns the configured NAS shares + per-share liveness.
|
||||
func (c *Client) ListNetStorage(ctx context.Context) ([]NetworkMountStatus, error) {
|
||||
body, err := c.get(ctx, "/netstorage")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrap struct {
|
||||
NetworkMounts []NetworkMountStatus `json:"network_mounts"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrap); err != nil {
|
||||
return nil, fmt.Errorf("agentapi: decode /netstorage: %w", err)
|
||||
}
|
||||
return wrap.NetworkMounts, nil
|
||||
}
|
||||
|
||||
// RemoveNetStorage unmounts + removes a NAS share (the agent drops the mount + creds file). This is NOT a
|
||||
// drive decommission/migrate — a NAS has no device lifecycle.
|
||||
func (c *Client) RemoveNetStorage(ctx context.Context, name string) error {
|
||||
_, err := c.post(ctx, "/netstorage/remove", map[string]string{"name": name})
|
||||
return err
|
||||
}
|
||||
|
||||
// postWithStatus issues an authenticated JSON POST and returns the envelope's data payload + the HTTP
|
||||
// status, even on a non-2xx (so callers like FormatDisk can read a 403 refusal body). A transport or
|
||||
// envelope-parse failure is still an error; an `ok:false` business refusal is NOT (the data carries it).
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func netStub(t *testing.T) (*httptest.Server, string, *struct {
|
||||
addBody AddNetStorageRequest
|
||||
removed string
|
||||
}) {
|
||||
captured := &struct {
|
||||
addBody AddNetStorageRequest
|
||||
removed string
|
||||
}{}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /netstorage/add", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = decodeJSON(r, &captured.addBody)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"name":"media","protocol":"nfs","where":"/mnt/felhom-drives/media","guest_path":"/mnt/felhom-drives/media","host_uid":101000,"host_gid":101000}}`))
|
||||
})
|
||||
mux.HandleFunc("GET /netstorage", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":8200,"network_mounts":[
|
||||
{"name":"media","protocol":"nfs","server":"10.0.0.5","export":"/srv/media","where":"/mnt/felhom-drives/media","configured":true,"mounted":true,"reachable":true,"health":"ok"},
|
||||
{"name":"photos","protocol":"smb","server":"10.0.0.5","export":"photos","where":"/mnt/felhom-drives/photos","configured":true,"mounted":false,"reachable":true,"health":"idle"},
|
||||
{"name":"vids","protocol":"nfs","server":"10.0.0.6","export":"/srv/vids","where":"/mnt/felhom-drives/vids","configured":true,"mounted":true,"reachable":false,"health":"unreachable"}
|
||||
]}}`))
|
||||
})
|
||||
mux.HandleFunc("POST /netstorage/remove", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = decodeJSON(r, &body)
|
||||
captured.removed = body.Name
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"name":"` + body.Name + `","removed":true}}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
return s, strings.TrimPrefix(s.URL, "https://"), captured
|
||||
}
|
||||
|
||||
func TestNetStorage_Add_ForwardsCredsAndMapping(t *testing.T) {
|
||||
s, ep, cap := netStub(t)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, ep)
|
||||
|
||||
res, err := c.AddNetStorage(context.Background(), AddNetStorageRequest{
|
||||
Name: "media", Protocol: "smb", Server: "10.0.0.5", Export: "media",
|
||||
MappedUID: 1000, MappedGID: 1000, Username: "u", Password: "p",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.GuestPath != "/mnt/felhom-drives/media" || res.HostUID != 101000 {
|
||||
t.Fatalf("add result mismatch: %+v", res)
|
||||
}
|
||||
// The SMB creds + mapping must be forwarded to the agent verbatim (the agent writes the 0600 file).
|
||||
if cap.addBody.Username != "u" || cap.addBody.Password != "p" {
|
||||
t.Fatalf("creds not forwarded to the agent: %+v", cap.addBody)
|
||||
}
|
||||
if cap.addBody.MappedUID != 1000 || cap.addBody.Protocol != "smb" {
|
||||
t.Fatalf("mapping/protocol not forwarded: %+v", cap.addBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetStorage_List_HealthStates(t *testing.T) {
|
||||
s, ep, _ := netStub(t)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, ep)
|
||||
|
||||
mounts, err := c.ListNetStorage(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mounts) != 3 {
|
||||
t.Fatalf("want 3 mounts, got %d", len(mounts))
|
||||
}
|
||||
byName := map[string]NetworkMountStatus{}
|
||||
for _, m := range mounts {
|
||||
byName[m.Name] = m
|
||||
}
|
||||
// ok + idle are NOT degraded; only unreachable is.
|
||||
if byName["media"].Unreachable() || byName["photos"].Unreachable() {
|
||||
t.Fatalf("ok/idle must not be unreachable: %+v", byName)
|
||||
}
|
||||
if byName["photos"].Health != "idle" {
|
||||
t.Fatalf("photos should be idle (benign), got %q", byName["photos"].Health)
|
||||
}
|
||||
if !byName["vids"].Unreachable() {
|
||||
t.Fatalf("vids should be unreachable (degraded), got %q", byName["vids"].Health)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetStorage_Remove(t *testing.T) {
|
||||
s, ep, cap := netStub(t)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, ep)
|
||||
|
||||
if err := c.RemoveNetStorage(context.Background(), "media"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cap.removed != "media" {
|
||||
t.Fatalf("remove did not forward the name, got %q", cap.removed)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user