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
+43 -6
View File
@@ -205,13 +205,20 @@ func (s NetworkMountSpec) fsType() string {
return "nfs4" // vers=4.1 → nfs4 (avoids the rpcbind/lock-manager surface of v3)
}
// mountOptions returns the exact, validated option set for the protocol (SPIKE Q2/Q5):
// - NFS: vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev — soft is the failure-isolation knob
// (clean EIO, never a wedge); the +100000 squash is the EXPORT's job (anonuid=101000), not the
// client mount, so no uid appears here.
// mountOptions returns the exact, validated option set for the protocol (SPIKE Q2/Q5 +
// SPIKE-nas-verify Q4-vi):
// - NFS: vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev,retry=0 — soft is the failure-isolation
// knob (clean EIO, never a wedge); the +100000 squash is the EXPORT's job (anonuid=101000), not
// the client mount, so no uid appears here. retry=0 (SPIKE-nas-verify Q4-vi): without it a
// dead-NAS on-demand access wedges the app until systemd's 90 s start cap (measured 91 s); with
// it the access fails clean in ~3.8 s (ENODEV) AND the verify sees a classifiable
// "No route to host" instead of a diagnostic-free systemd timeout. retry only governs retrying
// a FAILED first attempt — the happy path is untouched, and each autofs re-access is a fresh
// attempt anyway.
// - SMB: vers=3.0,credentials=<file>,uid=<+100000>,gid=<+100000>,forceuid,forcegid,file_mode=0664,
// dir_mode=0775,_netdev — modes are PLAIN octal (not setgid 2775); the client forces the
// guest-visible owner to the mapped id so the container reads+writes.
// guest-visible owner to the mapped id so the container reads+writes. NO retry= here — retry is
// a mount.nfs option; mount.cifs would reject it.
//
// Every interpolated value is pre-validated by ValidateNetworkMountSpec, so the string carries no
// newline / no extra directive. NEVER a default `hard` NFS mount (it wedges) — soft is mandatory.
@@ -229,7 +236,7 @@ func (s NetworkMountSpec) mountOptions() string {
"_netdev",
}, ",")
}
return "vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev"
return "vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev,retry=0"
}
// renderNetworkMountUnit builds the .mount unit (triggered by the .automount; deliberately NO [Install]
@@ -496,6 +503,36 @@ func isNetworkMounted(fstype string) bool {
}
}
// NetworkEndpointReachable TCP-dials a share's NAS endpoint (NFS 2049 / SMB 445) with the short
// liveness timeout. This is the add endpoint's SYNC pre-probe (SPIKE-nas-verify Scenario E): an
// unreachable server is refused in ~2 s BEFORE any unit is installed.
func NetworkEndpointReachable(proto NetworkProtocol, server string) bool {
return endpointReachable(netEndpoint(string(proto), server))
}
// NetworkMountedAt reports whether a REAL network filesystem (nfs/nfs4/cifs) is currently mounted at
// where, per /proc/mounts. The autofs trigger does NOT count. This is the verify job's mount-success
// truth source (SPIKE-nas-verify §8): success is judged from /proc/mounts, NEVER from readability —
// a 0700 export owned by the squashed uid gives the agent user EACCES on a perfectly good mount.
func NetworkMountedAt(where string) bool {
data, err := os.ReadFile("/proc/mounts")
if err != nil {
return false
}
return networkMountedIn(string(data), where)
}
// networkMountedIn is the pure core of NetworkMountedAt (unit-tested against fixture tables).
func networkMountedIn(procMounts, where string) bool {
for _, line := range strings.Split(procMounts, "\n") {
f := strings.Fields(line)
if len(f) >= 3 && f[1] == where && isNetworkMounted(f[2]) {
return true
}
}
return false
}
// endpointReachable TCP-dials a NAS endpoint with a short timeout (the liveness probe that never touches
// the mount). "" endpoint → not reachable.
func endpointReachable(endpoint string) bool {