v0.81.0: NAS verify-before-commit — retry=0, journal classifier, detached verify job + auto-rollback

Agent half of the verify-before-commit task (SPIKE-nas-verify-2026-07-11, b57f6c1):
retry=0 in the production NFS options (Q4-vi); ClassifyNetVerifyFailure on the live
Q4 strings (nfs_export merges not-found/not-permitted); add = sync fast-fail (2s TCP
pre-probe, nothing installed) + detached in-memory verify job judging /proc/mounts
only, auto-rollback on failure; GET /netstorage/verify-status (phase none = the
controller's Scenario-F rollback signal); unprivileged journalctl (systemd-journal
group, NO new sudoers grants).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-11 09:44:06 +02:00
parent 300f06722b
commit added9d226
11 changed files with 859 additions and 26 deletions
+20
View File
@@ -216,6 +216,19 @@ type Server struct {
swapMu sync.Mutex
swapInFlight map[int]bool
// Network-storage verify job (SPIKE-nas-verify): the IN-MEMORY single slot + the seams the
// detached pipeline runs through (tests inject; production defaults set in NewServer).
netVerifyMu sync.Mutex
netVerifyCur *netVerifyJob
// netTrigger performs the mount-waking directory read through the automount path (Q1).
netTrigger func(where string) error
// netMounted judges mount success from /proc/mounts — the §8 truth source (never readability).
netMounted func(where string) bool
// netJournal reads a mount unit's journal tail UNPRIVILEGED (systemd-journal group, no sudo).
netJournal func(ctx context.Context, unit string) (string, error)
// netReachable is the 2 s TCP endpoint pre-probe (sync fast-fail + classification tiebreak).
netReachable func(proto storage.NetworkProtocol, server string) bool
baseCtx context.Context // for fire-and-forget backups; set in Run
}
@@ -270,6 +283,10 @@ func NewServer(o Options) (*Server, error) {
s.reresolveWipe = s.reresolveDurableForWipe
s.reresolveBlank = s.reresolveDurableForBlankFormat
s.deviceDurableID = storage.DeviceDurableID
s.netTrigger = triggerNetMount
s.netMounted = storage.NetworkMountedAt
s.netJournal = readUnitJournal
s.netReachable = storage.NetworkEndpointReachable
if o.ControllerSwap != nil {
s.swap = NewControllerSwapper(o.ControllerSwap, o.ControllerSwapStateDir, o.Logger)
}
@@ -304,8 +321,11 @@ func (s *Server) Handler() http.Handler {
// 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.
// Add is verify-before-commit (SPIKE-nas-verify): it starts a detached verify job the caller
// polls on /netstorage/verify-status; a failed verify auto-rolls-back agent-side.
mux.HandleFunc("POST /netstorage/add", s.withGuest(s.handleNetStorageAdd))
mux.HandleFunc("GET /netstorage", s.withGuest(s.handleNetStorageList))
mux.HandleFunc("GET /netstorage/verify-status", s.withGuest(s.handleNetVerifyStatus))
mux.HandleFunc("POST /netstorage/remove", s.withGuest(s.handleNetStorageRemove))
// agentic controller update (Phase 1): in-guest image swap + rollback, owned by the agent.