package guesthook import ( "context" "fmt" "log/slog" "os" "strings" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" "gitea.dooplex.hu/admin/felhom-agent/internal/storage" ) // post-start network-storage reassert (RCA AUDIT-nas-cwa-rca-2026-07-11 fix 1, hook leg). // // A freshly started guest's namespace does NOT inherit an idle NAS autofs trigger (only real // mounts), so its /mnt/felhom-drives/ path is a silent local stub until the trigger is // re-created host-side. PVE runs the hookscript as root in the start task, so this leg calls // systemctl DIRECTLY (no sudo) — the daemon leg (localapi.ReassertNetworkMounts) is the sudo path. // Like the pre-start heal, this must NEVER fail the hook: all errors go to stderr (the PVE task // log) and the guest start proceeds regardless. // 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 // the (now running) guest actually sees each share path. Best-effort throughout. func PostStartNetworkReassert(ctx context.Context, vmid string) { runner := &proxmox.ExecRunner{Mode: proxmox.RunnerDirect} ops := storage.NewSudoHostOps(storage.SudoHostOpsConfig{ Runner: runner, Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})), }) postStartNetworkReassert(ctx, vmid, ops, func(ctx context.Context, vmid, path string) bool { return GuestSeesPath(ctx, runner, vmid, path) }) } // postStartNetworkReassert is the seam-injected core (unit-tested; the wrapper above binds the // real host surface). func postStartNetworkReassert(ctx context.Context, vmid string, ops netReasserter, sees func(ctx context.Context, vmid, path string) bool) { for _, res := range ops.ReassertNetworkAutomounts(ctx) { if !res.Remediates() { continue // foreign/errored rows expect nothing in the guest (already logged by the ops layer) } 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 STILL NOT visible after re-arm\n", vmid, res.Name) } } } // GuestSeesPath reports whether vmid's guest has `path` as a mount target in its own namespace — // the hook-process mirror of localapi's GuestBinder.GuestSeesMount (which is method-bound to the // daemon's binder and unavailable here). Resolution/read errors → false. func GuestSeesPath(ctx context.Context, runner proxmox.Runner, vmid, path string) bool { out, _, err := runner.Run(ctx, "lxc-info", "-n", vmid, "-p", "-H") if err != nil { return false } pid := strings.TrimSpace(string(out)) if pid == "" { return false } data, err := os.ReadFile("/proc/" + pid + "/mountinfo") if err != nil { return false } for _, line := range strings.Split(string(data), "\n") { f := strings.Fields(line) if len(f) >= 5 && f[4] == path { return true } } return false }