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
+77 -17
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)
@@ -48,8 +49,12 @@ type netStorageAddRequest struct {
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).
// handleNetStorageAdd installs a NAS share host-side — verify-before-commit (SPIKE-nas-verify).
// Pipeline: decode + scope + role-gate (unchanged) → SYNC fast-fail (spec validation + a 2 s TCP
// pre-probe; an unreachable server is refused with NOTHING installed — Scenario E) → stage SMB creds
// (0600) → EnsureNetworkMount → start the DETACHED verify job → respond {verify:"started"}. The
// caller (controller orchestrator) polls GET /netstorage/verify-status; a failed verify has already
// auto-rolled-back agent-side.
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")
@@ -83,10 +88,57 @@ func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request, vmi
IdleTimeoutSec: req.IdleTimeoutSec,
}
// SMB: stage the credentials out-of-band (0600) BEFORE the mount. NFS needs none (server squash).
// SYNC fast-fail 1/2 — full spec validation BEFORE anything is written or installed. For SMB the
// spec is validated against the CANONICAL creds path (a fixed POSIX constant + the
// charset-validated name), not the configured dir — the configured dir is agent-controlled, not
// user input, and EnsureNetworkMount re-validates the real spec anyway. The file itself is
// written only after every sync check has passed.
if spec.Protocol == storage.ProtocolSMB {
if req.Username == "" || req.Password == "" {
writeErr(w, http.StatusBadRequest, "smb credentials (username + password) are required")
return
}
spec.CredsRef = defaultSmbCredsDir + "/" + spec.Name + ".cred"
}
if err := storage.ValidateNetworkMountSpec(spec); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
// Single-flight (Scenario G): claim the one verify slot before any side effect, so two racing
// adds can never interleave creds/install/verify.
job := &netVerifyJob{
JobID: s.nowFn().UTC().Format("20060102T150405.000Z"),
Name: spec.Name,
Where: spec.Where(),
Protocol: string(spec.Protocol),
Phase: netVerifyPhaseRunning,
StartedAt: s.nowFn().UTC().Format(time.RFC3339),
UpdatedAt: s.nowFn().UTC().Format(time.RFC3339),
}
if !s.tryStartNetVerify(job) {
writeStatus(w, http.StatusConflict, false, map[string]any{"code": "busy"},
"a network storage add is already in progress")
return
}
// SYNC fast-fail 2/2 (Scenario E): a dead endpoint is refused in ~2 s, BEFORE any unit or creds
// file exists. The refusal carries the category code so the UI message is exact.
if !s.netReachable(spec.Protocol, spec.Server) {
s.releaseNetVerify(job)
s.logger.Warn("local-api: network mount refused — endpoint not reachable",
"name", spec.Name, "server", spec.Server, "proto", spec.Protocol)
writeStatus(w, http.StatusBadGateway, false,
map[string]any{"code": storage.NetVerifyUnreachable},
"NAS endpoint not reachable")
return
}
// SMB: stage the credentials out-of-band (0600). NFS needs none (server squash).
if spec.Protocol == storage.ProtocolSMB {
credsPath, err := s.writeSMBCreds(spec.Name, req.Username, req.Password)
if err != nil {
s.releaseNetVerify(job)
s.logger.Error("local-api: writing SMB credentials failed", "name", spec.Name, "err", err)
writeErr(w, http.StatusBadRequest, "could not stage SMB credentials")
return
@@ -100,18 +152,25 @@ func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request, vmi
if spec.Protocol == storage.ProtocolSMB {
s.removeSMBCreds(spec.Name)
}
s.releaseNetVerify(job)
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())
// Units installed + automount enabled — hand off to the detached verify (it rolls back on fail).
s.runNetVerify(spec, job)
s.logger.Info("local-api: network mount installed — verify started", "name", spec.Name,
"proto", spec.Protocol, "server", spec.Server, "export", spec.Export, "where", spec.Where(),
"job_id", job.JobID)
writeOK(w, map[string]any{
"name": spec.Name,
"protocol": string(spec.Protocol),
"where": spec.Where(),
"host_uid": spec.HostUID(),
"host_gid": spec.HostGID(),
"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
"verify": "started",
"job_id": job.JobID,
})
}
@@ -179,14 +238,10 @@ func (s *Server) writeSMBCreds(name, username, password string) (string, error)
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 {
path := s.smbCredsPath(name)
if err := os.MkdirAll(filepath.Dir(path), 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)
@@ -196,9 +251,14 @@ func (s *Server) writeSMBCreds(name, username, password string) (string, error)
// removeSMBCreds deletes a share's creds file (best-effort; absent is fine).
func (s *Server) removeSMBCreds(name string) {
_ = os.Remove(s.smbCredsPath(name))
}
// smbCredsPath computes a share's creds file path (pure — used by validation BEFORE the file exists).
func (s *Server) smbCredsPath(name string) string {
dir := s.smbCredsDir
if dir == "" {
dir = defaultSmbCredsDir
}
_ = os.Remove(filepath.Join(dir, name+".cred"))
return filepath.Join(dir, name+".cred")
}