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:
2026-07-11 20:46:59 +02:00
parent 0df72ea643
commit 474b858c0b
12 changed files with 715 additions and 27 deletions
+59
View File
@@ -0,0 +1,59 @@
package localapi
import (
"context"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)
// networkReasserter is the optional concrete capability of the netStorage surface (satisfied by
// *storage.SudoHostOps). Type-asserted like main.go's ReassertEnrolledMounts so the lean
// NetworkStorageOps interface (and its test fakes) stay unchanged.
type networkReasserter interface {
ReassertNetworkAutomounts(ctx context.Context) []storage.NetReassertResult
}
// ReassertNetworkMounts is the NAS counterpart of ReassertGuestBinds (RCA
// AUDIT-nas-cwa-rca-2026-07-11 fix 1): re-arm every idle network-share automount trigger host-side
// (a fresh trigger-mount event propagates live into running guests' slave binds), then best-effort
// verify each running guest actually sees each share's path. Runs at agent startup (after the
// guest-bind reconcile — covers guests that autostarted before the agent) and from the guest-hook
// post-start phase (its own process; this method is the daemon path). NEVER call this from a
// periodic health path — an idle trigger is healthy and must not be churned.
func (s *Server) ReassertNetworkMounts(ctx context.Context) {
if s.netStorage == nil {
return
}
r, ok := s.netStorage.(networkReasserter)
if !ok {
s.logger.Debug("netreassert: storage surface has no reassert capability — skipping")
return
}
results := r.ReassertNetworkAutomounts(ctx)
if len(results) == 0 {
return
}
// Best-effort guest-visibility verify (the RCA's masking lesson: host-side health said nothing
// about what the guests see). Only running guests; a failed verify is a WARN, never an error —
// the next guest start re-runs the hook path anyway.
if s.guestBinds == nil || s.guestAttach == nil {
return
}
for vmid := range s.guestBinds.Guests() {
if s.guestAttach.GuestBootID(ctx, vmid) == "" {
s.logger.Debug("netreassert: guest not running — visibility verify skipped", "vmid", vmid)
continue
}
for _, res := range results {
if res.Action == storage.NetReassertSkipNone || res.Err != nil {
continue // nothing expected in the guest for these rows
}
if s.guestAttach.GuestSeesMount(ctx, vmid, res.Where) {
s.logger.Debug("netreassert: guest sees network share", "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)
}
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package localapi
import (
"context"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)
// fakeNetOpsReassert is fakeNetOps plus the type-asserted reassert capability.
type fakeNetOpsReassert struct {
fakeNetOps
reassertN int
results []storage.NetReassertResult
}
func (f *fakeNetOpsReassert) ReassertNetworkAutomounts(context.Context) []storage.NetReassertResult {
f.reassertN++
return f.results
}
// seeingAttacher records GuestSeesMount calls and controls per-vmid running state.
type seeingAttacher struct {
fakeGuestAttacher
running map[int]bool
seen []struct {
vmid int
path string
}
sees bool
}
func (s *seeingAttacher) GuestBootID(_ context.Context, vmid int) string {
if s.running[vmid] {
return "boot-1"
}
return ""
}
func (s *seeingAttacher) GuestSeesMount(_ context.Context, vmid int, path string) bool {
s.seen = append(s.seen, struct {
vmid int
path string
}{vmid, path})
return s.sees
}
// The daemon leg: reassert runs once, then visibility is verified per RUNNING guest per acted
// share; stopped guests are skipped entirely.
func TestReassertNetworkMounts_VerifiesRunningGuestsOnly(t *testing.T) {
n := &fakeNetOpsReassert{results: []storage.NetReassertResult{
{Name: "media", Where: "/mnt/felhom-drives/media", Action: storage.NetReassertRearmed},
}}
srv := newNetServer(t, n, t.TempDir())
ga := &seeingAttacher{running: map[int]bool{8200: true, 9300: false}, sees: true}
srv.guestAttach = ga
gb := tempBindStore(t)
if err := gb.Record(8200, "uuid:aaa"); err != nil {
t.Fatal(err)
}
if err := gb.Record(9300, "uuid:bbb"); err != nil {
t.Fatal(err)
}
srv.guestBinds = gb
srv.ReassertNetworkMounts(context.Background())
if n.reassertN != 1 {
t.Fatalf("host-side reassert must run exactly once (host-global), ran %d", n.reassertN)
}
if len(ga.seen) != 1 || ga.seen[0].vmid != 8200 || ga.seen[0].path != "/mnt/felhom-drives/media" {
t.Fatalf("visibility verify must cover the RUNNING guest only, got %+v", ga.seen)
}
}
// A guest that does NOT see the share after reassert is a WARN, never an error — the method
// returns normally (non-fatal proof) and the pass still ran.
func TestReassertNetworkMounts_InvisibleShareNonFatal(t *testing.T) {
n := &fakeNetOpsReassert{results: []storage.NetReassertResult{
{Name: "media", Where: "/mnt/felhom-drives/media", Action: storage.NetReassertRearmed},
}}
srv := newNetServer(t, n, t.TempDir())
ga := &seeingAttacher{running: map[int]bool{8200: true}, sees: false}
srv.guestAttach = ga
gb := tempBindStore(t)
if err := gb.Record(8200, "uuid:aaa"); err != nil {
t.Fatal(err)
}
srv.guestBinds = gb
srv.ReassertNetworkMounts(context.Background()) // must not panic / abort
if n.reassertN != 1 || len(ga.seen) != 1 {
t.Fatalf("pass must complete despite invisible share: reassert=%d seen=%+v", n.reassertN, ga.seen)
}
}
// A netStorage surface WITHOUT the reassert capability (the lean interface, e.g. plain fakes) is a
// clean no-op — the type-assert gate.
func TestReassertNetworkMounts_NoCapabilityNoOp(t *testing.T) {
srv := newNetServer(t, &fakeNetOps{}, t.TempDir())
srv.ReassertNetworkMounts(context.Background()) // must not panic
}