agent v0.50.0: NAS network storage Part A1 (NFS/SMB automount foundation)
Host-side NFS/SMB automount of a bulk-media NAS share under /mnt/felhom-drives/<name> (propagates into the guest via the existing shared bind), the +100000 uid recipe, per-share liveness, and add/list/remove local-API endpoints. A NAS is a distinct class that bypasses the drive enroll/eject/decommission/SMART/watchdog machinery. 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,204 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||
)
|
||||
|
||||
// Network storage (NAS) — Part A1, doc 03 §6 + SPIKE-nas-storage-2026-06-29.md. The agent mounts a NAS
|
||||
// share HOST-SIDE under /mnt/felhom-drives/<name> (it propagates into the guest via the existing shared
|
||||
// bind); Docker then binds it into a media container. A NAS is a DISTINCT class from a physical drive:
|
||||
// it bypasses the disk enroll/eject/decommission/wipe machinery entirely (no durable-id, no SMART). The
|
||||
// controller registry/UI (A2) drives these endpoints; A1 is the agent foundation.
|
||||
//
|
||||
// Self-scoping is unchanged (withGuest): one customer per host, the token's guest authorizes the host's
|
||||
// network-mount surface. The mount is bulk-userdata role ONLY (role-gated below).
|
||||
|
||||
// NetworkStorageOps is the privileged network-mount surface the endpoints need. Satisfied by
|
||||
// *storage.SudoHostOps. Optional — the endpoints report "not configured" when absent.
|
||||
type NetworkStorageOps interface {
|
||||
EnsureNetworkMount(ctx context.Context, spec storage.NetworkMountSpec) error
|
||||
RemoveNetworkMount(ctx context.Context, name string) error
|
||||
ListNetworkMounts(ctx context.Context) ([]storage.NetworkMountStatus, error)
|
||||
}
|
||||
|
||||
// defaultSmbCredsDir is where the agent writes the 0600 SMB credentials files (out-of-band; never in
|
||||
// git, never in a plaintext registry, never logged — same posture as the rotated Resend key).
|
||||
const defaultSmbCredsDir = "/var/lib/felhom-agent/smb-creds"
|
||||
|
||||
// netStorageAddRequest is POST /netstorage/add. For SMB, username+password are written by the agent to a
|
||||
// 0600 creds file and NEVER logged or echoed back; for NFS they are ignored (server-side squash handles
|
||||
// uid mapping). mapped_uid/mapped_gid are the CONTAINER ids (the agent applies the +100000 host offset).
|
||||
type netStorageAddRequest struct {
|
||||
VMID int `json:"vmid"` // optional; must match the token's guest if set
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol"` // "nfs" | "smb"
|
||||
Server string `json:"server"`
|
||||
Export string `json:"export"` // NFS export path | SMB share name
|
||||
MappedUID int `json:"mapped_uid"` // container uid (e.g. 1000)
|
||||
MappedGID int `json:"mapped_gid"` // container gid
|
||||
IdleTimeoutSec int `json:"idle_timeout_sec"` // automount idle-unmount window; 0 → default
|
||||
Username string `json:"username,omitempty"` // SMB only (secret — written to creds file)
|
||||
Password string `json:"password,omitempty"` // SMB only (secret — written to creds file)
|
||||
}
|
||||
|
||||
// handleNetStorageAdd mounts a NAS share host-side. It role-gates the mount to the user-data namespace,
|
||||
// writes the SMB creds file out-of-band (0600), then calls EnsureNetworkMount (automount idle-unmount).
|
||||
func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
if s.netStorage == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "network storage not configured on this host")
|
||||
return
|
||||
}
|
||||
var req netStorageAddRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
|
||||
return
|
||||
}
|
||||
|
||||
// Role gate (SPIKE Q7): network storage is bulk-userdata ONLY. The mount lands under the
|
||||
// network-mount root; refuse anything that would resolve outside the user-data namespace.
|
||||
where := s.netMountRoot + "/" + strings.TrimSpace(req.Name)
|
||||
if storage.NetworkMountRole(where) != storage.RoleUserData {
|
||||
s.logger.Warn("local-api: network mount refused — not a user-data path",
|
||||
"name", req.Name, "where", where, "role", storage.NetworkMountRole(where))
|
||||
writeErr(w, http.StatusForbidden, "network storage is restricted to the user-data namespace")
|
||||
return
|
||||
}
|
||||
|
||||
spec := storage.NetworkMountSpec{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Protocol: storage.NetworkProtocol(strings.ToLower(strings.TrimSpace(req.Protocol))),
|
||||
Server: strings.TrimSpace(req.Server),
|
||||
Export: strings.TrimSpace(req.Export),
|
||||
MappedUID: req.MappedUID,
|
||||
MappedGID: req.MappedGID,
|
||||
IdleTimeoutSec: req.IdleTimeoutSec,
|
||||
}
|
||||
|
||||
// SMB: stage the credentials out-of-band (0600) BEFORE the mount. NFS needs none (server squash).
|
||||
if spec.Protocol == storage.ProtocolSMB {
|
||||
credsPath, err := s.writeSMBCreds(spec.Name, req.Username, req.Password)
|
||||
if err != nil {
|
||||
s.logger.Error("local-api: writing SMB credentials failed", "name", spec.Name, "err", err)
|
||||
writeErr(w, http.StatusBadRequest, "could not stage SMB credentials")
|
||||
return
|
||||
}
|
||||
spec.CredsRef = credsPath
|
||||
}
|
||||
|
||||
if err := s.netStorage.EnsureNetworkMount(r.Context(), spec); err != nil {
|
||||
s.logger.Error("local-api: network mount add failed", "name", spec.Name, "proto", spec.Protocol, "err", err)
|
||||
// Clean up a creds file we staged for a mount that then failed to apply.
|
||||
if spec.Protocol == storage.ProtocolSMB {
|
||||
s.removeSMBCreds(spec.Name)
|
||||
}
|
||||
writeErr(w, http.StatusBadGateway, "network mount failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
s.logger.Info("local-api: network mount added", "name", spec.Name, "proto", spec.Protocol,
|
||||
"server", spec.Server, "export", spec.Export, "where", spec.Where())
|
||||
writeOK(w, map[string]any{
|
||||
"name": spec.Name,
|
||||
"protocol": string(spec.Protocol),
|
||||
"where": spec.Where(),
|
||||
"host_uid": spec.HostUID(),
|
||||
"host_gid": spec.HostGID(),
|
||||
"guest_path": spec.Where(), // the in-guest path the controller (A2) repoints a media app's data dir to
|
||||
})
|
||||
}
|
||||
|
||||
// handleNetStorageList lists configured network mounts + per-share liveness (read-only/benign).
|
||||
func (s *Server) handleNetStorageList(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
if s.netStorage == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "network storage not configured on this host")
|
||||
return
|
||||
}
|
||||
mounts, err := s.netStorage.ListNetworkMounts(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("local-api: network mount list failed", "err", err)
|
||||
writeErr(w, http.StatusBadGateway, "could not list network mounts")
|
||||
return
|
||||
}
|
||||
if mounts == nil {
|
||||
mounts = []storage.NetworkMountStatus{}
|
||||
}
|
||||
writeOK(w, map[string]any{"vmid": vmid, "network_mounts": mounts})
|
||||
}
|
||||
|
||||
type netStorageRemoveRequest struct {
|
||||
VMID int `json:"vmid"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// handleNetStorageRemove unmounts + removes a network mount and its SMB creds file. It NEVER routes
|
||||
// through the drive eject/decommission path — a NAS has no device lifecycle to tear down.
|
||||
func (s *Server) handleNetStorageRemove(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
if s.netStorage == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "network storage not configured on this host")
|
||||
return
|
||||
}
|
||||
var req netStorageRemoveRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeErr(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
if err := s.netStorage.RemoveNetworkMount(r.Context(), name); err != nil {
|
||||
s.logger.Error("local-api: network mount remove failed", "name", name, "err", err)
|
||||
writeErr(w, http.StatusBadGateway, "network mount removal failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
s.removeSMBCreds(name) // best-effort: a NFS mount has no creds file (no-op)
|
||||
s.logger.Info("local-api: network mount removed", "name", name)
|
||||
writeOK(w, map[string]any{"name": name, "removed": true})
|
||||
}
|
||||
|
||||
// ---- SMB credentials (out-of-band) ---------------------------------------------------------------
|
||||
|
||||
// writeSMBCreds writes a 0600 mount.cifs credentials file for the share and returns its path. The file
|
||||
// is owned by the agent user; root (the systemd-triggered mount.cifs) still reads it. The secret is
|
||||
// NEVER logged. A missing username/password is an error (SMB needs credentials).
|
||||
func (s *Server) writeSMBCreds(name, username, password string) (string, error) {
|
||||
if username == "" || password == "" {
|
||||
return "", fmt.Errorf("smb credentials (username + password) are required")
|
||||
}
|
||||
if strings.ContainsAny(username, "\n\r") || strings.ContainsAny(password, "\n\r") {
|
||||
return "", fmt.Errorf("smb credentials must not contain newlines")
|
||||
}
|
||||
dir := s.smbCredsDir
|
||||
if dir == "" {
|
||||
dir = defaultSmbCredsDir
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("creds dir: %w", err)
|
||||
}
|
||||
path := filepath.Join(dir, name+".cred")
|
||||
body := "username=" + username + "\npassword=" + password + "\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
return "", fmt.Errorf("writing creds: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// removeSMBCreds deletes a share's creds file (best-effort; absent is fine).
|
||||
func (s *Server) removeSMBCreds(name string) {
|
||||
dir := s.smbCredsDir
|
||||
if dir == "" {
|
||||
dir = defaultSmbCredsDir
|
||||
}
|
||||
_ = os.Remove(filepath.Join(dir, name+".cred"))
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||
)
|
||||
|
||||
// fakeNetOps records the network-mount surface calls (never touches a real host).
|
||||
type fakeNetOps struct {
|
||||
ensured []storage.NetworkMountSpec
|
||||
removed []string
|
||||
list []storage.NetworkMountStatus
|
||||
ensErr error
|
||||
}
|
||||
|
||||
func (f *fakeNetOps) EnsureNetworkMount(_ context.Context, s storage.NetworkMountSpec) error {
|
||||
f.ensured = append(f.ensured, s)
|
||||
return f.ensErr
|
||||
}
|
||||
func (f *fakeNetOps) RemoveNetworkMount(_ context.Context, n string) error {
|
||||
f.removed = append(f.removed, n)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeNetOps) ListNetworkMounts(_ context.Context) ([]storage.NetworkMountStatus, error) {
|
||||
return f.list, nil
|
||||
}
|
||||
|
||||
func newNetServer(t *testing.T, n NetworkStorageOps, credsDir string) *Server {
|
||||
t.Helper()
|
||||
srv, err := NewServer(Options{
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
Guests: &fakeGuests{},
|
||||
Backups: &fakeBackups{},
|
||||
Store: &fakeStore{},
|
||||
Storage: fakeStorage{},
|
||||
Tokens: staticTokens{"A": 8200, "B": 9300},
|
||||
NetStorage: n,
|
||||
SmbCredsDir: credsDir,
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestNetStorage_AddNFS_HappyPath(t *testing.T) {
|
||||
n := &fakeNetOps{}
|
||||
h := newNetServer(t, n, t.TempDir()).Handler()
|
||||
body := `{"name":"media","protocol":"nfs","server":"10.0.0.5","export":"/srv/media","mapped_uid":1000,"mapped_gid":1000}`
|
||||
w := do(t, h, "POST", "/netstorage/add", "A", body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("add NFS: got %d want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if len(n.ensured) != 1 {
|
||||
t.Fatalf("EnsureNetworkMount not called once: %v", n.ensured)
|
||||
}
|
||||
got := n.ensured[0]
|
||||
if got.Name != "media" || got.Protocol != storage.ProtocolNFS || got.Server != "10.0.0.5" || got.Export != "/srv/media" {
|
||||
t.Fatalf("spec mismatch: %+v", got)
|
||||
}
|
||||
if got.HostUID() != 101000 {
|
||||
t.Errorf("host uid = %d want 101000 (the +100000 recipe)", got.HostUID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetStorage_AddSMB_WritesCreds0600(t *testing.T) {
|
||||
credsDir := t.TempDir()
|
||||
n := &fakeNetOps{}
|
||||
h := newNetServer(t, n, credsDir).Handler()
|
||||
body := `{"name":"vids","protocol":"smb","server":"nas","export":"vids","mapped_uid":1000,"mapped_gid":1000,"username":"u","password":"p"}`
|
||||
w := do(t, h, "POST", "/netstorage/add", "A", body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("add SMB: got %d want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if len(n.ensured) != 1 || n.ensured[0].CredsRef == "" {
|
||||
t.Fatalf("SMB spec must carry a creds ref: %+v", n.ensured)
|
||||
}
|
||||
credsPath := filepath.Join(credsDir, "vids.cred")
|
||||
info, err := os.Stat(credsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("creds file not written: %v", err)
|
||||
}
|
||||
// 0600 is enforced on Linux (the production OS); Windows does not honor Unix perms, so the perm
|
||||
// assertion runs on the Linux build server where it matters.
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
|
||||
t.Errorf("creds file is group/other-readable (mode %v) — must be 0600", info.Mode().Perm())
|
||||
}
|
||||
data, _ := os.ReadFile(credsPath)
|
||||
if !strings.Contains(string(data), "username=u") || !strings.Contains(string(data), "password=p") {
|
||||
t.Errorf("creds file content wrong: %q", data)
|
||||
}
|
||||
// The secret must NOT appear in the response body.
|
||||
if strings.Contains(w.Body.String(), "\"p\"") || strings.Contains(w.Body.String(), "password") {
|
||||
t.Errorf("response leaked credentials: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetStorage_AddSMB_MissingCreds_Refused(t *testing.T) {
|
||||
n := &fakeNetOps{}
|
||||
h := newNetServer(t, n, t.TempDir()).Handler()
|
||||
body := `{"name":"vids","protocol":"smb","server":"nas","export":"vids","mapped_uid":1000,"mapped_gid":1000}`
|
||||
w := do(t, h, "POST", "/netstorage/add", "A", body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("SMB without creds: got %d want 400", w.Code)
|
||||
}
|
||||
if len(n.ensured) != 0 {
|
||||
t.Fatal("EnsureNetworkMount must not be called when creds are missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNetStorage_RoleGate_NonUserDataRefused: a mount root outside the user-data namespace is refused
|
||||
// (403) and the mount surface is never touched — network storage is bulk-userdata ONLY.
|
||||
func TestNetStorage_RoleGate_NonUserDataRefused(t *testing.T) {
|
||||
n := &fakeNetOps{}
|
||||
srv := newNetServer(t, n, t.TempDir())
|
||||
srv.netMountRoot = "/srv/system" // a non-user-data path
|
||||
h := srv.Handler()
|
||||
body := `{"name":"media","protocol":"nfs","server":"10.0.0.5","export":"/srv/media","mapped_uid":1000,"mapped_gid":1000}`
|
||||
w := do(t, h, "POST", "/netstorage/add", "A", body)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-user-data path: got %d want 403 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if len(n.ensured) != 0 {
|
||||
t.Fatal("a role-gated request must not reach EnsureNetworkMount")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetStorage_List(t *testing.T) {
|
||||
n := &fakeNetOps{list: []storage.NetworkMountStatus{
|
||||
{Name: "media", Protocol: "nfs", Server: "10.0.0.5", Where: "/mnt/felhom-drives/media", Configured: true, Mounted: true, Reachable: true, Health: storage.NetHealthOK},
|
||||
}}
|
||||
h := newNetServer(t, n, t.TempDir()).Handler()
|
||||
w := do(t, h, "GET", "/netstorage", "A", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list: got %d want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Data struct {
|
||||
NetworkMounts []storage.NetworkMountStatus `json:"network_mounts"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Data.NetworkMounts) != 1 || resp.Data.NetworkMounts[0].Health != storage.NetHealthOK {
|
||||
t.Fatalf("list payload wrong: %+v", resp.Data.NetworkMounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetStorage_Remove(t *testing.T) {
|
||||
credsDir := t.TempDir()
|
||||
// Pre-stage a creds file to prove removal cleans it up.
|
||||
credsPath := filepath.Join(credsDir, "media.cred")
|
||||
if err := os.WriteFile(credsPath, []byte("username=u\npassword=p\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := &fakeNetOps{}
|
||||
h := newNetServer(t, n, credsDir).Handler()
|
||||
w := do(t, h, "POST", "/netstorage/remove", "A", `{"name":"media"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("remove: got %d want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if len(n.removed) != 1 || n.removed[0] != "media" {
|
||||
t.Fatalf("RemoveNetworkMount not called: %v", n.removed)
|
||||
}
|
||||
if _, err := os.Stat(credsPath); !os.IsNotExist(err) {
|
||||
t.Errorf("creds file should be removed, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetStorage_NotConfigured(t *testing.T) {
|
||||
h := newNetServer(t, nil, "").Handler() // NetStorage nil
|
||||
for _, tc := range []struct{ method, path, body string }{
|
||||
{"POST", "/netstorage/add", `{"name":"media","protocol":"nfs","server":"x","export":"/y","mapped_uid":1000,"mapped_gid":1000}`},
|
||||
{"GET", "/netstorage", ""},
|
||||
{"POST", "/netstorage/remove", `{"name":"media"}`},
|
||||
} {
|
||||
w := do(t, h, tc.method, tc.path, "A", tc.body)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("%s %s with no NetStorage: got %d want 503", tc.method, tc.path, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetStorage_RequiresAuth(t *testing.T) {
|
||||
h := newNetServer(t, &fakeNetOps{}, t.TempDir()).Handler()
|
||||
w := do(t, h, "GET", "/netstorage", "", "")
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("no token: got %d want 401", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,12 @@ type Options struct {
|
||||
// GuestAttach binds an enrolled user-data drive's felhom-data namespace into the guest (slice 10
|
||||
// P2, Model A). OPTIONAL — when nil, POST /disks/guest-attach reports "not configured".
|
||||
GuestAttach GuestAttacher
|
||||
// NetStorage is the privileged network-mount (NAS) surface (Part A1). OPTIONAL — when nil, the
|
||||
// /netstorage endpoints report "not configured". Satisfied by *storage.SudoHostOps.
|
||||
NetStorage NetworkStorageOps
|
||||
// SmbCredsDir is where the agent writes the 0600 SMB credentials files (out-of-band). "" →
|
||||
// /var/lib/felhom-agent/smb-creds.
|
||||
SmbCredsDir string
|
||||
// ControllerSwap runs guest commands (pct exec) for the agentic controller-update swap (Phase 1).
|
||||
// OPTIONAL — when nil, POST /controller/swap reports "not configured". Satisfied by *GuestBinder.
|
||||
ControllerSwap GuestExecutor
|
||||
@@ -158,6 +164,9 @@ type Server struct {
|
||||
diskGate StorageGate // slice 8C (optional)
|
||||
guestList GuestLister // slice 8C (optional)
|
||||
guestAttach GuestAttacher // slice 10 P2 (optional)
|
||||
netStorage NetworkStorageOps // Part A1: NAS network mounts (optional)
|
||||
netMountRoot string // the user-data namespace root for the network-mount role gate
|
||||
smbCredsDir string // where SMB creds files are written (out-of-band, 0600)
|
||||
intent IntentRecorder // slice 10 P3 (optional)
|
||||
guestBinds *GuestBindStore // F9 startup bind re-assert record (optional)
|
||||
formatJobs *FormatJobStore // F20-BUG3 detached-format job record (optional)
|
||||
@@ -225,6 +234,9 @@ func NewServer(o Options) (*Server, error) {
|
||||
diskGate: o.DiskGate,
|
||||
guestList: o.Guests2,
|
||||
guestAttach: o.GuestAttach,
|
||||
netStorage: o.NetStorage,
|
||||
netMountRoot: storage.NetworkMountRoot,
|
||||
smbCredsDir: o.SmbCredsDir,
|
||||
intent: o.Intent,
|
||||
guestBinds: o.GuestBinds,
|
||||
formatJobs: o.FormatJobs,
|
||||
@@ -268,6 +280,12 @@ func (s *Server) Handler() http.Handler {
|
||||
// Guest reboot (slice 10 P2 activation): user-triggered restart to activate pending drive binds.
|
||||
mux.HandleFunc("POST /guest/reboot", s.withGuest(s.handleGuestReboot))
|
||||
|
||||
// Network storage (NAS) — Part A1: mount/list/remove a bulk-media NAS share host-side (automount
|
||||
// idle-unmount; +100000 uid recipe). A distinct class from a drive — no enroll/eject/wipe.
|
||||
mux.HandleFunc("POST /netstorage/add", s.withGuest(s.handleNetStorageAdd))
|
||||
mux.HandleFunc("GET /netstorage", s.withGuest(s.handleNetStorageList))
|
||||
mux.HandleFunc("POST /netstorage/remove", s.withGuest(s.handleNetStorageRemove))
|
||||
|
||||
// agentic controller update (Phase 1): in-guest image swap + rollback, owned by the agent.
|
||||
mux.HandleFunc("POST /controller/swap", s.withGuest(s.handleControllerSwap))
|
||||
mux.HandleFunc("GET /controller/swap/status", s.withGuest(s.handleControllerSwapStatus))
|
||||
|
||||
Reference in New Issue
Block a user