Run 5 on the real host bisected the guest hang: the Debian 13 image hangs after GRUB under SeaBIOS with or without our seed and with either CPU model, and boots under OVMF. The image's legacy boot chain is the problem, not the provider. images.<key>.firmware is "uefi" (default) or "bios". The domain XML emits <os firmware="efi"> with secure boot off, so libvirt selects the OVMF binary and manages the NVRAM file, which the existing nvram undefine flag removes. Direct-URL images default to UEFI too. OVMF joins the hypervisor prerequisites; sample, config table, known gaps, decision log and the execution log record the finding. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
214 lines
7.1 KiB
Go
214 lines
7.1 KiB
Go
package provider
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const validKVMYAML = `
|
|
providers:
|
|
- name: kvm-home
|
|
type: kvm
|
|
kvm:
|
|
sshKeyFile: /etc/proxy-operator/kvm/id_ed25519
|
|
knownHostsFile: /etc/proxy-operator/kvm/known_hosts
|
|
diskSizeGb: 20
|
|
images:
|
|
debian-13:
|
|
url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
|
|
sha256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
ubuntu:
|
|
url: http://mirror.local/noble.img
|
|
format: raw
|
|
machineTypes:
|
|
small: {vcpus: 1, memoryMiB: 1024}
|
|
medium: {vcpus: 2, memoryMiB: 2048}
|
|
hosts:
|
|
- name: nas1
|
|
address: nas1.home.lan:22
|
|
user: virt
|
|
bridge: br0
|
|
- name: lab2
|
|
address: 10.0.0.2:2222
|
|
user: root
|
|
socket: /run/libvirt/virtqemud-sock
|
|
pool: fast
|
|
bridge: br1
|
|
`
|
|
|
|
func TestLoadConfig_kvmValid(t *testing.T) {
|
|
t.Parallel()
|
|
cfg, err := LoadConfig([]byte(validKVMYAML))
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v, want nil", err)
|
|
}
|
|
k := cfg.Providers[0].KVM
|
|
if k == nil {
|
|
t.Fatal("providers[0].kvm = nil")
|
|
}
|
|
if k.SSHKeyFile != "/etc/proxy-operator/kvm/id_ed25519" || k.DiskSizeGB != 20 {
|
|
t.Errorf("kvm = %+v, want sshKeyFile and diskSizeGb parsed", k)
|
|
}
|
|
if got := k.Images["debian-13"].SHA256; !strings.HasPrefix(got, "0123") {
|
|
t.Errorf("images[debian-13].sha256 = %q", got)
|
|
}
|
|
if k.Images["ubuntu"].Format != "raw" {
|
|
t.Errorf("images[ubuntu].format = %q, want raw", k.Images["ubuntu"].Format)
|
|
}
|
|
if mt := k.MachineTypes["medium"]; mt.VCPUs != 2 || mt.MemoryMiB != 2048 {
|
|
t.Errorf("machineTypes[medium] = %+v, want {2 2048}", mt)
|
|
}
|
|
if len(k.Hosts) != 2 || k.Hosts[1].Socket != "/run/libvirt/virtqemud-sock" || k.Hosts[1].Pool != "fast" {
|
|
t.Errorf("hosts = %+v, want two hosts with lab2's socket and pool parsed", k.Hosts)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfig_kvmInsecureHostKeyNeedsNoKnownHosts(t *testing.T) {
|
|
t.Parallel()
|
|
yaml := strings.Replace(validKVMYAML,
|
|
"knownHostsFile: /etc/proxy-operator/kvm/known_hosts",
|
|
"insecureIgnoreHostKey: true", 1)
|
|
if _, err := LoadConfig([]byte(yaml)); err != nil {
|
|
t.Fatalf("LoadConfig() error = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfig_kvmInvalid(t *testing.T) {
|
|
t.Parallel()
|
|
minimal := func(kvmBody string) string {
|
|
return "providers:\n - name: kvm-home\n type: kvm\n kvm:\n" + indent(kvmBody, 6)
|
|
}
|
|
hostsAndTypes := `
|
|
machineTypes:
|
|
small: {vcpus: 1, memoryMiB: 1024}
|
|
hosts:
|
|
- {name: nas1, address: nas1:22, user: virt, bridge: br0}
|
|
`
|
|
tests := []struct {
|
|
name string
|
|
yaml string
|
|
wantErrSub string
|
|
}{
|
|
{
|
|
name: "missing kvm block",
|
|
yaml: "providers:\n - name: kvm-home\n type: kvm\n",
|
|
wantErrSub: "kvm block is required",
|
|
},
|
|
{
|
|
name: "missing sshKeyFile",
|
|
yaml: minimal("knownHostsFile: /kh\n" + hostsAndTypes),
|
|
wantErrSub: "kvm.sshKeyFile is required",
|
|
},
|
|
{
|
|
name: "missing knownHostsFile without insecure flag",
|
|
yaml: minimal("sshKeyFile: /key\n" + hostsAndTypes),
|
|
wantErrSub: "kvm.knownHostsFile is required unless",
|
|
},
|
|
{
|
|
name: "negative disk size",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\ndiskSizeGb: -1\n" + hostsAndTypes),
|
|
wantErrSub: "diskSizeGb must not be negative",
|
|
},
|
|
{
|
|
name: "no machine types",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nhosts:\n - {name: nas1, address: nas1:22, user: virt, bridge: br0}\n"),
|
|
wantErrSub: "machineTypes must define at least one",
|
|
},
|
|
{
|
|
name: "machine type with zero memory",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1}\nhosts:\n - {name: nas1, address: nas1:22, user: virt, bridge: br0}\n"),
|
|
wantErrSub: `machineTypes["small"] needs vcpus > 0 and memoryMiB > 0`,
|
|
},
|
|
{
|
|
name: "no hosts",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\n"),
|
|
wantErrSub: "kvm.hosts must list at least one host",
|
|
},
|
|
{
|
|
name: "duplicate host name",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: nas1, address: a:22, user: virt, bridge: br0}\n - {name: nas1, address: b:22, user: virt, bridge: br0}\n"),
|
|
wantErrSub: `duplicate host name "nas1"`,
|
|
},
|
|
{
|
|
name: "host missing address",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: nas1, user: virt, bridge: br0}\n"),
|
|
wantErrSub: "address is required",
|
|
},
|
|
{
|
|
name: "host missing user",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: nas1, address: a:22, bridge: br0}\n"),
|
|
wantErrSub: "user is required",
|
|
},
|
|
{
|
|
name: "host missing bridge",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: nas1, address: a:22, user: virt}\n"),
|
|
wantErrSub: "bridge is required",
|
|
},
|
|
{
|
|
name: "image with non-http url",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n d: {url: file:///tmp/x.qcow2}\n" + hostsAndTypes),
|
|
wantErrSub: `images["d"].url must be an http(s) URL`,
|
|
},
|
|
{
|
|
name: "image with bad sha256",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n d: {url: https://x/y.qcow2, sha256: abc}\n" + hostsAndTypes),
|
|
wantErrSub: `images["d"].sha256 must be 64 hex characters`,
|
|
},
|
|
{
|
|
name: "image with unknown firmware",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n d: {url: https://x/y.qcow2, firmware: coreboot}\n" + hostsAndTypes),
|
|
wantErrSub: `images["d"].firmware must be uefi or bios`,
|
|
},
|
|
{
|
|
name: "image with unknown format",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n d: {url: https://x/y.vmdk, format: vmdk}\n" + hostsAndTypes),
|
|
wantErrSub: `images["d"].format must be qcow2 or raw`,
|
|
},
|
|
{
|
|
name: "kvm type with gcp block",
|
|
yaml: `
|
|
providers:
|
|
- name: p1
|
|
type: kvm
|
|
gcp:
|
|
project: my-project`,
|
|
wantErrSub: "type is kvm but a gcp block is set",
|
|
},
|
|
{
|
|
name: "gcp type with kvm block",
|
|
yaml: `
|
|
providers:
|
|
- name: p1
|
|
type: gcp
|
|
gcp:
|
|
project: my-project
|
|
kvm:
|
|
sshKeyFile: /key`,
|
|
wantErrSub: "type is gcp but a kvm block is set",
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := LoadConfig([]byte(tc.yaml))
|
|
if err == nil {
|
|
t.Fatalf("LoadConfig() error = nil, want error containing %q", tc.wantErrSub)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wantErrSub) {
|
|
t.Errorf("LoadConfig() error = %q, want substring %q", err.Error(), tc.wantErrSub)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func indent(s string, n int) string {
|
|
pad := strings.Repeat(" ", n)
|
|
lines := strings.Split(strings.TrimLeft(s, "\n"), "\n")
|
|
for i, l := range lines {
|
|
if l != "" {
|
|
lines[i] = pad + l
|
|
}
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|