diff --git a/internal/guesthook/netreassert.go b/internal/guesthook/netreassert.go index 31d0a20..c5050bf 100644 --- a/internal/guesthook/netreassert.go +++ b/internal/guesthook/netreassert.go @@ -23,6 +23,7 @@ import ( // netReasserter is the reassert capability (satisfied by *storage.SudoHostOps; faked in tests). type netReasserter interface { ReassertNetworkAutomounts(ctx context.Context) []storage.NetReassertResult + RearmNetworkAutomount(ctx context.Context, where string) error } // PostStartNetworkReassert re-arms idle NAS automount triggers after vmid started, then verifies @@ -48,9 +49,21 @@ func postStartNetworkReassert(ctx context.Context, vmid string, ops netReasserte if sees(ctx, vmid, res.Where) { fmt.Fprintf(os.Stderr, "felhom-agent guest-hook: vmid %s post-start — network share %s visible in guest (%s)\n", vmid, res.Name, res.Action) + continue + } + // F11 matrix-correction: the guest is blind even though the pass classified this share + // skip-active/rearmed. A freshly-started namespace does NOT inherit an ACTIVE host mount — only + // a FRESH trigger event propagates in. Re-arm this specific share and re-verify once. + fmt.Fprintf(os.Stderr, "felhom-agent guest-hook: vmid %s post-start — network share %s not visible after reassert (%s) — re-arming\n", + vmid, res.Name, res.Action) + if err := ops.RearmNetworkAutomount(ctx, res.Where); err != nil { + fmt.Fprintf(os.Stderr, "felhom-agent guest-hook: vmid %s post-start — WARNING: re-arm of %s failed: %v\n", vmid, res.Name, err) + continue + } + if sees(ctx, vmid, res.Where) { + fmt.Fprintf(os.Stderr, "felhom-agent guest-hook: vmid %s post-start — network share %s visible in guest after re-arm (healed)\n", vmid, res.Name) } else { - fmt.Fprintf(os.Stderr, "felhom-agent guest-hook: vmid %s post-start — WARNING: network share %s NOT visible in guest after reassert (%s)\n", - vmid, res.Name, res.Action) + fmt.Fprintf(os.Stderr, "felhom-agent guest-hook: vmid %s post-start — WARNING: network share %s STILL NOT visible after re-arm\n", vmid, res.Name) } } } diff --git a/internal/guesthook/netreassert_test.go b/internal/guesthook/netreassert_test.go index 609130c..85a8113 100644 --- a/internal/guesthook/netreassert_test.go +++ b/internal/guesthook/netreassert_test.go @@ -8,8 +8,10 @@ import ( ) type fakeReasserter struct { - invoked int - results []storage.NetReassertResult + invoked int + rearmed []string + rearmErr error + results []storage.NetReassertResult } func (f *fakeReasserter) ReassertNetworkAutomounts(context.Context) []storage.NetReassertResult { @@ -17,6 +19,11 @@ func (f *fakeReasserter) ReassertNetworkAutomounts(context.Context) []storage.Ne return f.results } +func (f *fakeReasserter) RearmNetworkAutomount(_ context.Context, where string) error { + f.rearmed = append(f.rearmed, where) + return f.rearmErr +} + // The post-start core must run the reassert pass and verify guest visibility for every share the // pass acted on (or found actively mounted) — and never for foreign/errored rows. func TestPostStartNetworkReassert_Core(t *testing.T) { @@ -41,16 +48,41 @@ func TestPostStartNetworkReassert_Core(t *testing.T) { } } -// A failed verify must be non-fatal: the core returns normally (the hook exits 0 regardless). -func TestPostStartNetworkReassert_VerifyFailureNonFatal(t *testing.T) { +// F11 matrix-correction: a guest that does NOT see a share (even a skip-active one) must trigger a +// re-arm of that specific share, then a re-verify. Encodes the live 2026-07-12 finding that a rebooted +// guest inherits neither an idle trigger nor an active mount. +func TestPostStartNetworkReassert_RearmsWhenGuestBlind(t *testing.T) { ops := &fakeReasserter{results: []storage.NetReassertResult{ - {Name: "media", Where: "/mnt/felhom-drives/media", Action: storage.NetReassertRearmed}, + {Name: "media", Where: "/mnt/felhom-drives/media", Action: storage.NetReassertSkipActive}, }} - // Must not panic or abort; the WARNING goes to stderr (PVE task log). + // sees: false the first time (blind), true the second (post-rearm) — the heal path. + calls := 0 + postStartNetworkReassert(context.Background(), "9201", ops, func(context.Context, string, string) bool { + calls++ + return calls >= 2 + }) + if len(ops.rearmed) != 1 || ops.rearmed[0] != "/mnt/felhom-drives/media" { + t.Fatalf("a blind guest must trigger exactly one re-arm of the share, got %v", ops.rearmed) + } +} + +// A failed verify must be non-fatal even if the re-arm also fails: the core returns normally (hook +// exits 0 regardless). +func TestPostStartNetworkReassert_VerifyFailureNonFatal(t *testing.T) { + ops := &fakeReasserter{ + rearmErr: context.DeadlineExceeded, + results: []storage.NetReassertResult{ + {Name: "media", Where: "/mnt/felhom-drives/media", Action: storage.NetReassertRearmed}, + }, + } + // Must not panic or abort; the WARNING goes to stderr (PVE task log). Guest never sees it, re-arm errors. postStartNetworkReassert(context.Background(), "9201", ops, func(context.Context, string, string) bool { return false }) if ops.invoked != 1 { t.Fatalf("reassert pass invoked %d times, want 1", ops.invoked) } + if len(ops.rearmed) != 1 { + t.Fatalf("a blind guest must attempt one re-arm, got %d", len(ops.rearmed)) + } } diff --git a/internal/localapi/netreassert.go b/internal/localapi/netreassert.go index d5106e6..fb8219d 100644 --- a/internal/localapi/netreassert.go +++ b/internal/localapi/netreassert.go @@ -11,6 +11,7 @@ import ( // NetworkStorageOps interface (and its test fakes) stay unchanged. type networkReasserter interface { ReassertNetworkAutomounts(ctx context.Context) []storage.NetReassertResult + RearmNetworkAutomount(ctx context.Context, where string) error } // ReassertNetworkMounts is the NAS counterpart of ReassertGuestBinds (RCA @@ -50,9 +51,26 @@ func (s *Server) ReassertNetworkMounts(ctx context.Context) { } if s.guestAttach.GuestSeesMount(ctx, vmid, res.Where) { s.logger.Debug("netreassert: guest sees network share", "vmid", vmid, "name", res.Name, "where", res.Where) + continue + } + // F11 matrix-correction (2026-07-12): a running guest that does NOT see a share must be + // remediated — a fresh namespace inherits neither an idle trigger NOR an active mount; only a + // FRESH trigger event propagates in. Re-arm this share and re-verify once (covers guests that + // autostarted before the agent). + r, ok := s.netStorage.(networkReasserter) + if !ok { + continue + } + s.logger.Warn("netreassert: guest does NOT see network share after reassert — re-arming", + "vmid", vmid, "name", res.Name, "where", res.Where, "action", res.Action) + if err := r.RearmNetworkAutomount(ctx, res.Where); err != nil { + s.logger.Warn("netreassert: re-arm failed", "vmid", vmid, "name", res.Name, "err", err) + continue + } + if s.guestAttach.GuestSeesMount(ctx, vmid, res.Where) { + s.logger.Info("netreassert: guest sees network share after re-arm (healed)", "vmid", vmid, "name", res.Name, "where", res.Where) } else { - s.logger.Warn("netreassert: guest does NOT see network share after reassert", - "vmid", vmid, "name", res.Name, "where", res.Where, "action", res.Action) + s.logger.Warn("netreassert: guest STILL does not see network share after re-arm", "vmid", vmid, "name", res.Name, "where", res.Where) } } } diff --git a/internal/localapi/netreassert_test.go b/internal/localapi/netreassert_test.go index 3b4d7a8..06c0c92 100644 --- a/internal/localapi/netreassert_test.go +++ b/internal/localapi/netreassert_test.go @@ -11,6 +11,7 @@ import ( type fakeNetOpsReassert struct { fakeNetOps reassertN int + rearmed []string results []storage.NetReassertResult } @@ -19,6 +20,11 @@ func (f *fakeNetOpsReassert) ReassertNetworkAutomounts(context.Context) []storag return f.results } +func (f *fakeNetOpsReassert) RearmNetworkAutomount(_ context.Context, where string) error { + f.rearmed = append(f.rearmed, where) + return nil +} + // seeingAttacher records GuestSeesMount calls and controls per-vmid running state. type seeingAttacher struct { fakeGuestAttacher diff --git a/internal/storage/netreassert.go b/internal/storage/netreassert.go index 46b6e4f..2fef628 100644 --- a/internal/storage/netreassert.go +++ b/internal/storage/netreassert.go @@ -156,6 +156,17 @@ func (h *SudoHostOps) resetNetworkAutomountIfFailed(ctx context.Context, where s return reset } +// RearmNetworkAutomount is the caller-driven remediation (CAMPAIGN-3 F11, matrix-corrected): when a +// running guest does NOT see a share after the reassert pass, re-arm its trigger regardless of the +// host's mount state. LIVE FINDING (matrix row 1/2, 2026-07-12): a freshly-rebooted guest does NOT +// inherit an ACTIVE host mount either — only a fresh trigger-mount event propagates into the new +// namespace — so skip-active is safe to skip PROACTIVELY (don't churn shares guests already see) but +// must be remediated REACTIVELY when a specific guest is blind. reset-failed first (F10), then re-arm. +func (h *SudoHostOps) RearmNetworkAutomount(ctx context.Context, where string) error { + h.resetNetworkAutomountIfFailed(ctx, where) + return h.rearmNetworkAutomount(ctx, where) +} + // rearmNetworkAutomount stops then re-enables+starts the .automount for a mountpoint. The stop is // tolerated failing (unit not loaded); the enable --now is the action that must succeed. Both verbs // are the existing FELHOM_NETMOUNT sudoers grants.