474b858c0b
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
118 lines
5.1 KiB
Go
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
|
|
}
|