v0.84.0: ReassertNetworkMounts — NAS automount survives guest reboots (RCA fix 1)
Storage §8 decision table (stop + enable --now on idle triggers; active mounts untouched), daemon leg at startup with per-running-guest visibility verify, guest-hook post-start leg (root, direct systemctl, non-fatal). Red-proofs: always-rearm table FAIL; unwired hook FAIL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The hook WIRING red-proof target: `guest-hook <vmid> post-start` must invoke the network
|
||||
// reassert with the vmid; pre-start and unknown phases must NOT. (Companion red-proof: remove the
|
||||
// PhasePostStart case from runGuestHook → the invoked assertion fails.)
|
||||
func TestRunGuestHook_PostStartInvokesNetworkReassert(t *testing.T) {
|
||||
orig := postStartNetworkReassertFn
|
||||
t.Cleanup(func() { postStartNetworkReassertFn = orig })
|
||||
|
||||
var gotVMIDs []string
|
||||
postStartNetworkReassertFn = func(_ context.Context, vmid string) {
|
||||
gotVMIDs = append(gotVMIDs, vmid)
|
||||
}
|
||||
|
||||
runGuestHook([]string{"9201", "post-start"})
|
||||
if len(gotVMIDs) != 1 || gotVMIDs[0] != "9201" {
|
||||
t.Fatalf("post-start must invoke the network reassert with vmid 9201, got %v", gotVMIDs)
|
||||
}
|
||||
|
||||
// pre-start must not touch the network reassert (it is the placeholder-heal phase; the heal
|
||||
// no-ops on a nonexistent config path and never blocks).
|
||||
runGuestHook([]string{"9201", "pre-start"})
|
||||
// unknown phases are ignored entirely.
|
||||
runGuestHook([]string{"9201", "pre-stop"})
|
||||
if len(gotVMIDs) != 1 {
|
||||
t.Fatalf("only post-start may invoke the network reassert, got %v", gotVMIDs)
|
||||
}
|
||||
}
|
||||
+29
-13
@@ -54,27 +54,38 @@ import (
|
||||
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
|
||||
var version = "0.63.0"
|
||||
|
||||
// runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook <vmid> <phase>`). On the
|
||||
// pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots
|
||||
// (the C1 net). It ALWAYS returns cleanly (exit 0) — a hook must never block a guest start. Heal output
|
||||
// is written to stderr (PVE captures hook output into the task log).
|
||||
// runGuestHook is the PVE hook body (`felhom-agent guest-hook <vmid> <phase>`). On pre-start it
|
||||
// creates placeholder dirs for any absent bind-mount source so the guest always boots (the C1 net);
|
||||
// on post-start it re-arms idle NAS automount triggers so the fresh guest namespace sees network
|
||||
// shares again (RCA fix 1 — a new namespace inherits real mounts but not idle autofs triggers).
|
||||
// It ALWAYS returns cleanly (exit 0) — a hook must never block a guest start. Output is written to
|
||||
// stderr (PVE captures hook output into the task log).
|
||||
func runGuestHook(args []string) {
|
||||
if len(args) < 2 {
|
||||
return
|
||||
}
|
||||
vmid, phase := args[0], args[1]
|
||||
if phase != guesthook.PhasePreStart {
|
||||
return
|
||||
}
|
||||
created, err := guesthook.Heal("/etc/pve/lxc/" + vmid + ".conf")
|
||||
if len(created) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "felhom-agent guest-hook: vmid %s pre-start — created %d placeholder(s) for absent drive(s): %v\n", vmid, len(created), created)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "felhom-agent guest-hook: vmid %s heal error (boot continues): %v\n", vmid, err)
|
||||
switch phase {
|
||||
case guesthook.PhasePreStart:
|
||||
created, err := guesthook.Heal("/etc/pve/lxc/" + vmid + ".conf")
|
||||
if len(created) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "felhom-agent guest-hook: vmid %s pre-start — created %d placeholder(s) for absent drive(s): %v\n", vmid, len(created), created)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "felhom-agent guest-hook: vmid %s heal error (boot continues): %v\n", vmid, err)
|
||||
}
|
||||
case guesthook.PhasePostStart:
|
||||
// Bounded: a hook must be fast; a wedged systemd call must not hold the start task.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
postStartNetworkReassertFn(ctx, vmid)
|
||||
}
|
||||
}
|
||||
|
||||
// postStartNetworkReassertFn is the post-start hook action (seam — tests assert the wiring without
|
||||
// touching real systemctl).
|
||||
var postStartNetworkReassertFn = guesthook.PostStartNetworkReassert
|
||||
|
||||
func main() {
|
||||
// Pre-start self-heal hook entrypoint. PVE invokes the registered hookscript as
|
||||
// `<bin> guest-hook <vmid> <phase>`. Handled BEFORE flag parsing — it takes positional args, must be
|
||||
@@ -784,6 +795,11 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
|
||||
// bind that a re-provision dropped — before serving, so the drive is back in the guest config
|
||||
// (activates on the guest's next reboot). On-durable-id-match; absent/swapped drives are skipped.
|
||||
localSrv.ReassertGuestBinds(ctx)
|
||||
// RCA fix 1 (AUDIT-nas-cwa-rca-2026-07-11): re-arm idle NAS automount triggers ONCE at startup —
|
||||
// covers the host-boot ordering where guests autostarted before the agent (their fresh namespaces
|
||||
// missed the triggers). Deliberately NOT in the 20 s ticker: an idle trigger is healthy and must
|
||||
// not be churned; guest starts are covered by the guest-hook post-start leg.
|
||||
localSrv.ReassertNetworkMounts(ctx)
|
||||
// Intermediary-mount GUEST-REBOOT self-heal: re-run the reconcile periodically. A guest reboot
|
||||
// (without an agent restart) leaves enrolled drives bound on the HOST but INVISIBLE in the fresh
|
||||
// guest namespace (a non-recursive parent bind doesn't carry pre-existing submounts); the periodic
|
||||
|
||||
Reference in New Issue
Block a user