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>
119 lines
4.5 KiB
Go
119 lines
4.5 KiB
Go
package guesthook
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
const sampleConf = `arch: amd64
|
|
cores: 2
|
|
hostname: demo-felhom
|
|
memory: 12288
|
|
mp0: local-lvm:vm-9201-disk-1,mp=/var/lib/docker,backup=1,size=256G
|
|
mp1: /mnt/felhom-usb/felhom-data,mp=/mnt/felhom-usb
|
|
mp2: /mnt/felhom-flash/felhom-data,mp=/mnt/felhom-flash
|
|
mp9: /var/lib/felhom-agent/guests/9201/bootstrap,mp=/etc/felhom-bootstrap,ro=1
|
|
net0: name=eth0,bridge=vmbr0
|
|
rootfs: local-lvm:vm-9201-disk-0,size=32G
|
|
swap: 4096
|
|
unprivileged: 1
|
|
`
|
|
|
|
func TestParseConfMounts(t *testing.T) {
|
|
got := ParseConfMounts(sampleConf)
|
|
want := map[string]string{
|
|
"mp0": "local-lvm:vm-9201-disk-1",
|
|
"mp1": "/mnt/felhom-usb/felhom-data",
|
|
"mp2": "/mnt/felhom-flash/felhom-data",
|
|
"mp9": "/var/lib/felhom-agent/guests/9201/bootstrap",
|
|
"rootfs": "local-lvm:vm-9201-disk-0",
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("ParseConfMounts mismatch:\n got=%v\nwant=%v", got, want)
|
|
}
|
|
// net0/arch/etc. (non-mount keys) must NOT leak in.
|
|
if _, bad := got["net0"]; bad {
|
|
t.Fatalf("net0 was parsed as a mount source")
|
|
}
|
|
}
|
|
|
|
// TestMissingBindSources is the load-bearing selector test. The flash drive is absent (its felhom-data
|
|
// source missing); usb is present; mp0/rootfs are STORAGE volumes (must never be selected); mp9's
|
|
// bootstrap source is present. Only the flash source may be returned.
|
|
//
|
|
// COMPANION GUARD — this test FAILS on the two trivial impls the spec warns about:
|
|
// - "return nothing" (the pre-fix no-op hook) → flash not selected → guest still bricks → FAIL.
|
|
// - "return every source" (mkdir everything) → would include the present usb bind AND the
|
|
// local-lvm storage volumes (creating bogus dirs that shadow real data) → FAIL.
|
|
func TestMissingBindSources(t *testing.T) {
|
|
mounts := ParseConfMounts(sampleConf)
|
|
present := map[string]bool{
|
|
"/mnt/felhom-usb/felhom-data": true, // usb attached
|
|
"/var/lib/felhom-agent/guests/9201/bootstrap": true, // bootstrap always present
|
|
// "/mnt/felhom-flash/felhom-data" is ABSENT (drive unplugged)
|
|
}
|
|
got := MissingBindSources(mounts, func(p string) bool { return present[p] })
|
|
want := []string{"/mnt/felhom-flash/felhom-data"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("MissingBindSources mismatch:\n got=%v\nwant=%v", got, want)
|
|
}
|
|
|
|
// Explicit companion assertions (pin both failure directions independently of want):
|
|
for _, p := range got {
|
|
if p == "/mnt/felhom-usb/felhom-data" {
|
|
t.Fatalf("selected a PRESENT bind source — over-eager (would shadow live data)")
|
|
}
|
|
if p == "local-lvm:vm-9201-disk-1" || p == "local-lvm:vm-9201-disk-0" {
|
|
t.Fatalf("selected a STORAGE VOLUME source — must only heal host-path binds")
|
|
}
|
|
}
|
|
if len(got) == 0 {
|
|
t.Fatalf("selected nothing — the absent flash bind would brick the guest (pre-fix no-op)")
|
|
}
|
|
}
|
|
|
|
// TestHealCreatesOnlyMissingBind drives the real filesystem path in a temp dir: a present bind source is
|
|
// left untouched, an absent one is created (so the guest boots), a storage volume is never created.
|
|
func TestHealCreatesOnlyMissingBind(t *testing.T) {
|
|
root := t.TempDir()
|
|
presentSrc := filepath.Join(root, "usb", "felhom-data")
|
|
absentSrc := filepath.Join(root, "flash", "felhom-data")
|
|
if err := os.MkdirAll(presentSrc, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conf := "" +
|
|
"mp0: local-lvm:vm-9-disk-0,mp=/var/lib/docker,backup=1\n" +
|
|
"mp1: " + presentSrc + ",mp=/mnt/usb\n" +
|
|
"mp2: " + absentSrc + ",mp=/mnt/flash\n" +
|
|
"rootfs: local-lvm:vm-9-disk-1,size=32G\n"
|
|
confPath := filepath.Join(root, "9.conf")
|
|
if err := os.WriteFile(confPath, []byte(conf), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
created, err := Heal(confPath)
|
|
if err != nil {
|
|
t.Fatalf("Heal: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(created, []string{absentSrc}) {
|
|
t.Fatalf("Heal created %v, want [%s]", created, absentSrc)
|
|
}
|
|
if _, err := os.Stat(absentSrc); err != nil {
|
|
t.Fatalf("absent bind source not created — guest would still brick: %v", err)
|
|
}
|
|
// A storage-volume mp must never produce a bogus host directory.
|
|
if _, err := os.Stat(filepath.Join(root, "local-lvm:vm-9-disk-0")); err == nil {
|
|
t.Fatalf("a storage volume source was materialised as a directory")
|
|
}
|
|
}
|
|
|
|
// TestHealMissingConfNeverErrors — a hook must never block a start, even for an unreadable config.
|
|
func TestHealMissingConfNeverErrors(t *testing.T) {
|
|
created, err := Heal(filepath.Join(t.TempDir(), "does-not-exist.conf"))
|
|
if err != nil || created != nil {
|
|
t.Fatalf("Heal on missing conf: created=%v err=%v (want nil,nil)", created, err)
|
|
}
|
|
}
|