Files
felhom-agent/internal/guesthook/heal.go
T
admin 44cdf82631 agent v0.33.0: C1 net — pre-start self-heal hook + decommission mp-delete
Pre-start PVE hookscript (internal/guesthook) creates host-root placeholders for
absent bind-mount sources so the guest always boots (fail-closed); decommission
now pct set --delete's the dead mp (GuestBinder.DetachBind) so a missing source
can't brick the next reboot (B3 C1 bug). Non-hollow tests + companions. Installed
+ registered per-guest by the provision back-half. Transitional ahead of the
intermediary-mount re-architecture which makes C1 structural.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:11:10 +02:00

136 lines
5.6 KiB
Go

// Package guesthook is the LXC guest pre-start self-heal (C1 net, transitional).
//
// THE BUG IT FIXES (C1, B3 audit): in the per-drive bind model an external data drive is bound into the
// guest as `pct set <vmid> -mpN <drive>/felhom-data,mp=/mnt/<name>`. When that drive is ABSENT at guest
// boot, the bind SOURCE `<drive>/felhom-data` does not exist, `pct start` fails the mount, and the guest
// BRICKS (pre-start exit 255 — ALL apps down). Today nothing recovers it.
//
// THE FIX: a PVE `pre-start` hookscript runs this code; for every BIND mountpoint whose source path is
// missing it CREATES an empty, host-root-owned placeholder directory so the mount succeeds and the guest
// boots. It is fail-closed: the placeholder is owned by host root (uid 0), which is UNMAPPED in the
// unprivileged-LXC user namespace, so the in-guest controller/apps (even as guest-root) cannot write to
// it — and a returning drive simply shadows it (the agent mounts over it).
//
// WHY CREATE, NOT DELETE: removing the dead mp would need `pct set <vmid> --delete mpN`, which takes the
// per-guest config lock the start task ALREADY holds → it dead-times-out (~10s) and the guest still
// bricks. So in pre-start we NEUTRALISE (placeholder) rather than mutate config. Proper mp removal runs
// OUTSIDE the start lock — at decommission (handleDiskDecommission → DetachBind) and the startup
// reconcile. The intermediary-mount re-architecture later makes C1 STRUCTURAL (the only bind source is
// the permanent, always-present /mnt/felhom-drives parent), after which this hook is pure defense-in-depth.
package guesthook
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// PhasePreStart is the PVE hook phase at which we self-heal (before the container mounts are set up).
const PhasePreStart = "pre-start"
// placeholderMode is the mode for a created bind-source placeholder. Host-root-owned + this mode =
// fail-closed against the unprivileged guest (host uid 0 is unmapped in the guest userns).
const placeholderMode = 0o755
// ParseConfMounts parses an LXC config file body (/etc/pve/lxc/<vmid>.conf) and returns each mount key
// (`mp0`..`mp255`, plus `rootfs`) mapped to its SOURCE — the first comma-field of the value, before any
// `mp=`/`size=`/`backup=` options. A BIND mount has an absolute-path source (`/mnt/...`); a storage
// volume has a `<storage>:<volid>` source (no leading slash). Lines that aren't a mountpoint/rootfs key
// are ignored.
func ParseConfMounts(conf string) map[string]string {
out := map[string]string{}
for _, line := range strings.Split(conf, "\n") {
line = strings.TrimSpace(line)
colon := strings.IndexByte(line, ':')
if colon <= 0 {
continue
}
key := line[:colon]
if key != "rootfs" && !(strings.HasPrefix(key, "mp") && isAllDigits(strings.TrimPrefix(key, "mp"))) {
continue
}
val := strings.TrimSpace(line[colon+1:])
if val == "" {
continue
}
src := val
if c := strings.IndexByte(val, ','); c >= 0 {
src = val[:c]
}
out[key] = strings.TrimSpace(src)
}
return out
}
// isBindSource reports whether an mp source is a host-path BIND (an absolute path) rather than a PVE
// storage volume (`<storage>:<volid>`, never absolute). On the Linux host a bind source is `/mnt/...`
// (leading slash); the filepath.IsAbs arm additionally recognises an OS-absolute path so the real-IO
// tests pass under a Windows temp dir too — on Linux both arms agree and a storage volid matches neither.
func isBindSource(src string) bool {
return strings.HasPrefix(src, "/") || filepath.IsAbs(src)
}
func isAllDigits(s string) bool {
if s == "" {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
// MissingBindSources returns the BIND-mount source paths (absolute host paths) that do NOT exist, sorted
// and de-duplicated. Storage-volume sources (`<storage>:<volid>`, no leading '/') are NEVER returned —
// only a real host-path bind can have a vanished source we must heal; a storage volume that's missing is
// PVE's own concern, not ours to mkdir. `exists` reports whether a path is present (injected for tests).
func MissingBindSources(mounts map[string]string, exists func(string) bool) []string {
seen := map[string]bool{}
var miss []string
for _, src := range mounts {
if !isBindSource(src) { // storage volume (<storage>:<volid>), not a host-path bind — never touch
continue
}
if seen[src] || exists(src) {
continue
}
seen[src] = true
miss = append(miss, src)
}
sort.Strings(miss)
return miss
}
// Heal reads the LXC config at confPath and creates a placeholder directory for every bind-mount source
// that is missing, returning the list of paths it created. It never returns a fatal error for an
// unreadable/empty config (a guest with no config simply has nothing to heal) — the hook must NEVER block
// a start. A mkdir failure on one path is collected into err but the others still proceed.
func Heal(confPath string) (created []string, err error) {
data, readErr := os.ReadFile(confPath)
if readErr != nil {
// No config = nothing to heal. Never block the start over a read error.
return nil, nil
}
mounts := ParseConfMounts(string(data))
miss := MissingBindSources(mounts, func(p string) bool {
_, statErr := os.Stat(p)
return statErr == nil
})
var errs []string
for _, p := range miss {
if mkErr := os.MkdirAll(p, placeholderMode); mkErr != nil {
errs = append(errs, fmt.Sprintf("%s: %v", p, mkErr))
continue
}
created = append(created, p)
}
if len(errs) > 0 {
return created, fmt.Errorf("guesthook: placeholder creation failed for: %s", strings.Join(errs, "; "))
}
return created, nil
}