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:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user