Files
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

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)
}
}