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