a5055b3c7a
CI / Lint + build + test (push) Has been cancelled
Collapses the LXC side of PXE enablement from a six-step manual dance (build, fetch iPXE, scp, bridge, hand-edit yaml) into: make release # dev box (Linux/WSL) scp bundle.tar.gz lxc:/tmp/ sudo ./install.sh # base install, unchanged sudo ./pxe-setup.sh --interface ... --dhcp-range ... --orchestrator-url ... pxe-setup.sh fetches iPXE from boot.ipxe.org, verifies against pinned SHA256s in deploy/ipxe-shas.txt (fail-closed), places vmlinuz/initrd.img from the bundle, and rewrites only the pxe: block of vetting.yaml. Idempotent; --force gates overwriting a hand-edited block. Adds Supervisor.Validate() — called before dnsmasq spawn — so typo'd configs fail at orchestrator startup with clear errors naming the missing file or yaml key, instead of silently serving broken TFTP until a real host tries to PXE-boot. Nine tests cover missing files, bogus interface, malformed dhcp_range, bad orchestrator_url, and aggregate reporting. Hypervisor bridge creation stays documented (LXC can't do it) but everything downstream of the bridge is now scripted. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
192 lines
5.4 KiB
Go
192 lines
5.4 KiB
Go
package pxe
|
|
|
|
import (
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// existingInterface returns any real interface on the host so the
|
|
// Validate tests can exercise the happy path without hardcoding
|
|
// "lo" (which exists on Linux but might be gated elsewhere).
|
|
func existingInterface(t *testing.T) string {
|
|
t.Helper()
|
|
ifaces, err := net.Interfaces()
|
|
if err != nil || len(ifaces) == 0 {
|
|
t.Skipf("no network interfaces: %v", err)
|
|
}
|
|
return ifaces[0].Name
|
|
}
|
|
|
|
// seedTFTP drops zero-byte ipxe.efi + undionly.kpxe into dir so the
|
|
// stat check passes. Callers can omit a name to simulate "missing".
|
|
func seedTFTP(t *testing.T, dir string, names ...string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir tftp: %v", err)
|
|
}
|
|
for _, name := range names {
|
|
if err := os.WriteFile(filepath.Join(dir, name), nil, 0o644); err != nil {
|
|
t.Fatalf("seed %s: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func goodCfg(t *testing.T, tftpRoot string) SupervisorConfig {
|
|
t.Helper()
|
|
return SupervisorConfig{
|
|
Enabled: true,
|
|
Interface: existingInterface(t),
|
|
DHCPRange: "10.77.0.100,10.77.0.200,12h",
|
|
OrchestratorURL: "http://10.77.0.1:8080",
|
|
TFTPRoot: tftpRoot,
|
|
}
|
|
}
|
|
|
|
func TestValidate_DisabledSkipsChecks(t *testing.T) {
|
|
s := NewSupervisor(SupervisorConfig{Enabled: false})
|
|
if err := s.Validate(); err != nil {
|
|
t.Fatalf("disabled supervisor should skip validation, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_HappyPath(t *testing.T) {
|
|
tftp := t.TempDir()
|
|
seedTFTP(t, tftp, "ipxe.efi", "undionly.kpxe")
|
|
s := NewSupervisor(goodCfg(t, tftp))
|
|
if err := s.Validate(); err != nil {
|
|
t.Fatalf("happy-path validate: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_MissingIPXEBinary(t *testing.T) {
|
|
tftp := t.TempDir()
|
|
// Only seed one of the two required files.
|
|
seedTFTP(t, tftp, "undionly.kpxe")
|
|
s := NewSupervisor(goodCfg(t, tftp))
|
|
err := s.Validate()
|
|
if err == nil {
|
|
t.Fatalf("expected error for missing ipxe.efi")
|
|
}
|
|
if !strings.Contains(err.Error(), "ipxe.efi") {
|
|
t.Fatalf("error should name the missing file, got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "pxe-setup.sh") {
|
|
t.Fatalf("error should point operator at pxe-setup.sh, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_MissingUndionly(t *testing.T) {
|
|
tftp := t.TempDir()
|
|
seedTFTP(t, tftp, "ipxe.efi")
|
|
s := NewSupervisor(goodCfg(t, tftp))
|
|
err := s.Validate()
|
|
if err == nil || !strings.Contains(err.Error(), "undionly.kpxe") {
|
|
t.Fatalf("expected undionly.kpxe error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_MissingInterface(t *testing.T) {
|
|
tftp := t.TempDir()
|
|
seedTFTP(t, tftp, "ipxe.efi", "undionly.kpxe")
|
|
cfg := goodCfg(t, tftp)
|
|
cfg.Interface = "definitely-not-a-real-iface-9999"
|
|
s := NewSupervisor(cfg)
|
|
err := s.Validate()
|
|
if err == nil || !strings.Contains(err.Error(), "pxe.interface") {
|
|
t.Fatalf("expected interface error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_MissingLiveImage(t *testing.T) {
|
|
tftp := t.TempDir()
|
|
seedTFTP(t, tftp, "ipxe.efi", "undionly.kpxe")
|
|
cfg := goodCfg(t, tftp)
|
|
cfg.LiveDir = t.TempDir() // empty dir; vmlinuz + initrd.img missing
|
|
s := NewSupervisor(cfg)
|
|
err := s.Validate()
|
|
if err == nil {
|
|
t.Fatalf("expected live image error")
|
|
}
|
|
for _, want := range []string{"vmlinuz", "initrd.img"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("error should name %s, got: %v", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidate_LiveDirEmptySkipsLiveChecks(t *testing.T) {
|
|
tftp := t.TempDir()
|
|
seedTFTP(t, tftp, "ipxe.efi", "undionly.kpxe")
|
|
cfg := goodCfg(t, tftp)
|
|
cfg.LiveDir = "" // explicit opt-out; HTTP /live just 404s
|
|
s := NewSupervisor(cfg)
|
|
if err := s.Validate(); err != nil {
|
|
t.Fatalf("empty LiveDir should not trigger live checks, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_MalformedDHCPRange(t *testing.T) {
|
|
tftp := t.TempDir()
|
|
seedTFTP(t, tftp, "ipxe.efi", "undionly.kpxe")
|
|
cases := []struct {
|
|
name string
|
|
dhcp string
|
|
}{
|
|
{"single field", "10.77.0.100"},
|
|
{"two fields", "10.77.0.100,10.77.0.200"},
|
|
{"non-ip start", "hello,10.77.0.200,12h"},
|
|
{"empty lease", "10.77.0.100,10.77.0.200,"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := goodCfg(t, tftp)
|
|
cfg.DHCPRange = tc.dhcp
|
|
s := NewSupervisor(cfg)
|
|
err := s.Validate()
|
|
if err == nil || !strings.Contains(err.Error(), "dhcp_range") {
|
|
t.Fatalf("expected dhcp_range error for %q, got: %v", tc.dhcp, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidate_BadOrchestratorURL(t *testing.T) {
|
|
tftp := t.TempDir()
|
|
seedTFTP(t, tftp, "ipxe.efi", "undionly.kpxe")
|
|
cases := []string{"", "not a url", "ftp://10.0.0.1", "http://"}
|
|
for _, u := range cases {
|
|
t.Run(u, func(t *testing.T) {
|
|
cfg := goodCfg(t, tftp)
|
|
cfg.OrchestratorURL = u
|
|
s := NewSupervisor(cfg)
|
|
err := s.Validate()
|
|
if err == nil || !strings.Contains(err.Error(), "orchestrator_url") {
|
|
t.Fatalf("expected orchestrator_url error for %q, got: %v", u, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidate_AggregatesErrors(t *testing.T) {
|
|
// Multiple problems at once: Validate must report them all in
|
|
// one pass so the operator sees the full picture instead of
|
|
// whack-a-mole-ing one error per restart.
|
|
cfg := SupervisorConfig{
|
|
Enabled: true,
|
|
// Everything else zero.
|
|
}
|
|
s := NewSupervisor(cfg)
|
|
err := s.Validate()
|
|
if err == nil {
|
|
t.Fatalf("expected aggregated error")
|
|
}
|
|
for _, want := range []string{"pxe.interface", "pxe.tftp_root", "pxe.dhcp_range", "pxe.orchestrator_url"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("expected %q in aggregated error, got: %v", want, err)
|
|
}
|
|
}
|
|
}
|