Files
felhom-agent/internal/storage/netreassert.go
T
admin 474b858c0b 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
2026-07-11 20:46:59 +02:00

118 lines
5.1 KiB
Go

package storage
import (
"context"
"fmt"
"strings"
)
// Network-mount guest-reboot reassert (RCA AUDIT-nas-cwa-rca-2026-07-11 fix 1).
//
// THE BUG IT FIXES: a fresh guest namespace inherits REAL submounts of the shared parent (ext4, or
// an actively-mounted nfs4) but NOT an idle autofs trigger — so after any guest reboot an idle NAS
// share silently degrades to a local stub directory inside the guest. The heal is host-side and
// host-global: re-creating the .automount unit emits a FRESH trigger-mount event, which propagates
// live into every running guest's slave bind (live-proven in the RCA remediation, 2026-07-11).
//
// The action uses the sudoers-granted verbs only (`systemctl stop -- *.automount` +
// `systemctl enable --now -- *.automount`; there is NO restart grant). Idempotent: re-arming an
// already-armed trigger just recreates it — same end state, and the fresh mount event is harmless.
// An ACTIVE real mount is never touched (stopping the automount of a live mount would churn it).
// Reassert actions (the §8 decision table, encoded).
const (
// NetReassertRearmed: the trigger was re-created (stop + enable --now) — the propagation heal.
NetReassertRearmed = "rearmed"
// NetReassertSkipActive: a real network fs is mounted at the path — inherited by fresh
// namespaces, nothing to do (verify only).
NetReassertSkipActive = "skip-active"
// NetReassertSkipNone: neither a real mount nor an armed trigger at the path — a removed or
// orphan state owned by the add/remove flows, not this reconcile.
NetReassertSkipNone = "skip-none"
)
// NetReassertResult is one share's outcome in a reassert pass.
type NetReassertResult struct {
Name string // share name (mount dir basename)
Where string // guest-visible mountpoint (/mnt/felhom-drives/<name>)
Action string // NetReassert* constant
Err error // set when the rearm action failed (skip rows never error)
}
// netReassertAction is the pure §8 decision: the /proc/mounts fstype at the share's mountpoint
// ("" = nothing mounted there) → the action to take.
func netReassertAction(fstype string) string {
switch {
case isNetworkMounted(fstype):
return NetReassertSkipActive
case fstype == "autofs":
return NetReassertRearmed
default:
return NetReassertSkipNone
}
}
// ReassertNetworkAutomounts runs the reassert pass over every configured network mount: for each
// installed pair, decide per netReassertAction and re-arm idle triggers. Returns one result per
// share so callers (agent startup / guest-hook post-start) can verify guest visibility. Errors on
// one share never stop the pass. Callers MUST NOT invoke this from periodic health paths — an idle
// trigger is healthy, and the pass is only needed after a guest (re)start or at agent startup.
func (h *SudoHostOps) ReassertNetworkAutomounts(ctx context.Context) []NetReassertResult {
entries, err := h.networkUnitEntries()
if err != nil {
h.logger.Warn("netreassert: unit enumeration failed", "err", err)
return nil
}
fstypes := map[string]string{}
if h.host != nil {
if mounts, merr := h.host.Mounts(); merr == nil {
for _, m := range mounts {
fstypes[m.MountPoint] = m.FSType
}
} else {
h.logger.Warn("netreassert: mount-table read failed — treating all shares as unmounted", "err", merr)
}
}
var out []NetReassertResult
for _, e := range entries {
res := NetReassertResult{Name: e.name, Where: e.where, Action: netReassertAction(fstypes[e.where])}
switch res.Action {
case NetReassertSkipActive:
h.logger.Debug("netreassert: share actively mounted — skip (fresh namespaces inherit real mounts)",
"name", e.name, "where", e.where)
case NetReassertSkipNone:
h.logger.Debug("netreassert: no mount and no armed trigger — skip (removed/orphan state owned elsewhere)",
"name", e.name, "where", e.where)
case NetReassertRearmed:
if err := h.rearmNetworkAutomount(ctx, e.where); err != nil {
res.Err = err
h.logger.Warn("netreassert: trigger re-arm failed", "name", e.name, "where", e.where, "err", err)
} else {
h.logger.Info("netreassert: automount trigger re-armed (fresh mount event propagates into running guests)",
"name", e.name, "where", e.where)
}
}
out = append(out, res)
}
return out
}
// 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.
func (h *SudoHostOps) rearmNetworkAutomount(ctx context.Context, where string) error {
mountUnit, err := UnitNameForMount(where)
if err != nil {
return fmt.Errorf("netreassert: unit name for %s: %w", where, err)
}
automountUnit := strings.TrimSuffix(mountUnit, ".mount") + ".automount"
if err := h.run(ctx, h.bins.Systemctl, "stop", "--", automountUnit); err != nil {
// Tolerated: a not-loaded unit still enables cleanly below.
h.logger.Debug("netreassert: automount stop tolerated failure", "unit", automountUnit, "err", err)
}
if err := h.run(ctx, h.bins.Systemctl, "enable", "--now", "--", automountUnit); err != nil {
return fmt.Errorf("netreassert: enabling automount %s: %w", automountUnit, err)
}
return nil
}