44cdf82631
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>
136 lines
5.6 KiB
Go
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
|
|
}
|