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:
2026-06-30 14:40:47 +02:00
parent 9391f4ea71
commit 364dc50794
13 changed files with 837 additions and 1 deletions
+86
View File
@@ -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).