kvm2: domain and volume XML, metadata element and NoCloud seed builders
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
package kvm2
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||
)
|
||||
|
||||
// checkedDomain is an independent view of the marshalled XML, so the tests
|
||||
// assert on what libvirt would see rather than on the builder's own structs.
|
||||
type checkedDomain struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Name string `xml:"name"`
|
||||
Memory struct {
|
||||
Unit string `xml:"unit,attr"`
|
||||
Value int64 `xml:",chardata"`
|
||||
} `xml:"memory"`
|
||||
VCPU int32 `xml:"vcpu"`
|
||||
Metadata struct {
|
||||
Inner string `xml:",innerxml"`
|
||||
} `xml:"metadata"`
|
||||
OS struct {
|
||||
Firmware string `xml:"firmware,attr"`
|
||||
Type struct {
|
||||
Arch string `xml:"arch,attr"`
|
||||
Machine string `xml:"machine,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
} `xml:"type"`
|
||||
FirmwareElems []struct {
|
||||
Features []struct {
|
||||
Enabled string `xml:"enabled,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
} `xml:"feature"`
|
||||
} `xml:"firmware"`
|
||||
} `xml:"os"`
|
||||
Features struct {
|
||||
ACPI []struct{} `xml:"acpi"`
|
||||
APIC []struct{} `xml:"apic"`
|
||||
} `xml:"features"`
|
||||
CPU struct {
|
||||
Mode string `xml:"mode,attr"`
|
||||
} `xml:"cpu"`
|
||||
OnPoweroff string `xml:"on_poweroff"`
|
||||
OnReboot string `xml:"on_reboot"`
|
||||
OnCrash string `xml:"on_crash"`
|
||||
Devices struct {
|
||||
Disks []struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Device string `xml:"device,attr"`
|
||||
Driver struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
} `xml:"driver"`
|
||||
Source struct {
|
||||
File string `xml:"file,attr"`
|
||||
Pool string `xml:"pool,attr"`
|
||||
Vol string `xml:"volume,attr"`
|
||||
} `xml:"source"`
|
||||
Target struct {
|
||||
Dev string `xml:"dev,attr"`
|
||||
Bus string `xml:"bus,attr"`
|
||||
} `xml:"target"`
|
||||
ReadOnly []struct{} `xml:"readonly"`
|
||||
} `xml:"disk"`
|
||||
Interfaces []struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Source struct {
|
||||
Bridge string `xml:"bridge,attr"`
|
||||
} `xml:"source"`
|
||||
Model struct {
|
||||
Type string `xml:"type,attr"`
|
||||
} `xml:"model"`
|
||||
} `xml:"interface"`
|
||||
Serials []struct {
|
||||
Type string `xml:"type,attr"`
|
||||
} `xml:"serial"`
|
||||
Consoles []struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Target struct {
|
||||
Type string `xml:"type,attr"`
|
||||
} `xml:"target"`
|
||||
} `xml:"console"`
|
||||
Channels []struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Target struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
} `xml:"target"`
|
||||
} `xml:"channel"`
|
||||
RNGs []struct {
|
||||
Model string `xml:"model,attr"`
|
||||
Backend struct {
|
||||
Model string `xml:"model,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
} `xml:"backend"`
|
||||
} `xml:"rng"`
|
||||
Graphics []struct{} `xml:"graphics"`
|
||||
} `xml:"devices"`
|
||||
}
|
||||
|
||||
var testCreatedAt = time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func testDomainParams() domainParams {
|
||||
return domainParams{
|
||||
Name: "proxy-abc",
|
||||
UID: "0b1f9a2e-uid",
|
||||
CreatedAt: testCreatedAt,
|
||||
VCPUs: 2,
|
||||
MemoryMiB: 2048,
|
||||
Firmware: provider.KVMFirmwareUEFI,
|
||||
Bridge: "br0",
|
||||
DiskPath: "/var/lib/libvirt/images/proxy-abc.qcow2",
|
||||
SeedPath: "/var/lib/libvirt/images/proxy-abc-seed.iso",
|
||||
DiskFormat: provider.KVMFormatQcow2,
|
||||
}
|
||||
}
|
||||
|
||||
func mustBuildDomain(t *testing.T, p domainParams) (string, checkedDomain) {
|
||||
t.Helper()
|
||||
out, err := buildDomainXML(p)
|
||||
if err != nil {
|
||||
t.Fatalf("buildDomainXML: %v", err)
|
||||
}
|
||||
var d checkedDomain
|
||||
if err := xml.Unmarshal([]byte(out), &d); err != nil {
|
||||
t.Fatalf("unmarshalling produced XML: %v\n%s", err, out)
|
||||
}
|
||||
return out, d
|
||||
}
|
||||
|
||||
func TestDomainXML_fileDisksWithResolvedPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := testDomainParams()
|
||||
_, d := mustBuildDomain(t, p)
|
||||
if d.Type != "kvm" || d.Name != p.Name {
|
||||
t.Errorf("domain type=%q name=%q, want kvm/%s", d.Type, d.Name, p.Name)
|
||||
}
|
||||
if d.Memory.Unit != "MiB" || d.Memory.Value != p.MemoryMiB || d.VCPU != p.VCPUs {
|
||||
t.Errorf("memory=%d %s vcpu=%d, want %d MiB / %d", d.Memory.Value, d.Memory.Unit, d.VCPU, p.MemoryMiB, p.VCPUs)
|
||||
}
|
||||
if len(d.Devices.Disks) != 2 {
|
||||
t.Fatalf("got %d disks, want 2", len(d.Devices.Disks))
|
||||
}
|
||||
overlay, seed := d.Devices.Disks[0], d.Devices.Disks[1]
|
||||
if overlay.Type != "file" || overlay.Device != "disk" {
|
||||
t.Errorf("overlay type=%q device=%q, want file/disk", overlay.Type, overlay.Device)
|
||||
}
|
||||
if overlay.Source.File != p.DiskPath || overlay.Source.Pool != "" || overlay.Source.Vol != "" {
|
||||
t.Errorf("overlay source = %+v, want file=%q only", overlay.Source, p.DiskPath)
|
||||
}
|
||||
if overlay.Driver.Name != "qemu" || overlay.Driver.Type != "qcow2" {
|
||||
t.Errorf("overlay driver = %+v, want qemu/qcow2", overlay.Driver)
|
||||
}
|
||||
if overlay.Target.Dev != "vda" || overlay.Target.Bus != "virtio" {
|
||||
t.Errorf("overlay target = %+v, want vda/virtio", overlay.Target)
|
||||
}
|
||||
if len(overlay.ReadOnly) != 0 {
|
||||
t.Error("overlay is readonly")
|
||||
}
|
||||
if seed.Type != "file" || seed.Device != "cdrom" {
|
||||
t.Errorf("seed type=%q device=%q, want file/cdrom", seed.Type, seed.Device)
|
||||
}
|
||||
if seed.Source.File != p.SeedPath || seed.Source.Pool != "" || seed.Source.Vol != "" {
|
||||
t.Errorf("seed source = %+v, want file=%q only", seed.Source, p.SeedPath)
|
||||
}
|
||||
if seed.Driver.Name != "qemu" || seed.Driver.Type != "raw" {
|
||||
t.Errorf("seed driver = %+v, want qemu/raw", seed.Driver)
|
||||
}
|
||||
if seed.Target.Dev != "sda" || seed.Target.Bus != "sata" {
|
||||
t.Errorf("seed target = %+v, want sda/sata", seed.Target)
|
||||
}
|
||||
if len(seed.ReadOnly) != 1 {
|
||||
t.Error("seed is not readonly")
|
||||
}
|
||||
if d.OnPoweroff != "destroy" || d.OnReboot != "restart" || d.OnCrash != "destroy" {
|
||||
t.Errorf("lifecycle actions = %q/%q/%q, want destroy/restart/destroy", d.OnPoweroff, d.OnReboot, d.OnCrash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainXML_uefiDefaultSecureBootOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, d := mustBuildDomain(t, testDomainParams())
|
||||
if d.OS.Firmware != "efi" {
|
||||
t.Errorf(`<os firmware> = %q, want "efi"`, d.OS.Firmware)
|
||||
}
|
||||
if len(d.OS.FirmwareElems) != 1 {
|
||||
t.Fatalf("got %d <firmware> elements, want 1", len(d.OS.FirmwareElems))
|
||||
}
|
||||
feats := d.OS.FirmwareElems[0].Features
|
||||
if len(feats) != 1 || feats[0].Name != "secure-boot" || feats[0].Enabled != "no" {
|
||||
t.Errorf("firmware features = %+v, want one secure-boot enabled=no", feats)
|
||||
}
|
||||
if d.OS.Type.Value != "hvm" {
|
||||
t.Errorf("os type = %q, want hvm", d.OS.Type.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainXML_biosEscape(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := testDomainParams()
|
||||
p.Firmware = provider.KVMFirmwareBIOS
|
||||
out, d := mustBuildDomain(t, p)
|
||||
if d.OS.Firmware != "" {
|
||||
t.Errorf(`<os firmware> = %q, want absent`, d.OS.Firmware)
|
||||
}
|
||||
if len(d.OS.FirmwareElems) != 0 {
|
||||
t.Errorf("got %d <firmware> elements, want none", len(d.OS.FirmwareElems))
|
||||
}
|
||||
if strings.Contains(out, "firmware") {
|
||||
t.Errorf("BIOS domain XML still mentions firmware:\n%s", out)
|
||||
}
|
||||
if d.OS.Type.Value != "hvm" || d.OS.Type.Machine != "q35" {
|
||||
t.Errorf("os type = %+v, want hvm on q35", d.OS.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainXML_metadataElement(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := testDomainParams()
|
||||
out, d := mustBuildDomain(t, p)
|
||||
if !strings.Contains(out, `<instance xmlns="`+metadataURI+`">`) {
|
||||
t.Errorf("domain XML lacks the namespaced instance element:\n%s", out)
|
||||
}
|
||||
m, err := parseInstanceMetadata(d.Metadata.Inner)
|
||||
if err != nil {
|
||||
t.Fatalf("parseInstanceMetadata(%q): %v", d.Metadata.Inner, err)
|
||||
}
|
||||
if !m.managed() {
|
||||
t.Error("managed() = false, want true")
|
||||
}
|
||||
if m.UID != p.UID {
|
||||
t.Errorf("UID = %q, want %q", m.UID, p.UID)
|
||||
}
|
||||
if m.CreatedAt != "2026-09-16T12:00:00Z" {
|
||||
t.Errorf("CreatedAt = %q, want RFC3339 UTC", m.CreatedAt)
|
||||
}
|
||||
if !m.createdAt().Equal(p.CreatedAt) {
|
||||
t.Errorf("createdAt() = %v, want %v", m.createdAt(), p.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainXML_guestAgentChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := testDomainParams()
|
||||
_, d := mustBuildDomain(t, p)
|
||||
if len(d.Devices.Channels) != 1 || d.Devices.Channels[0].Type != "unix" ||
|
||||
d.Devices.Channels[0].Target.Type != "virtio" || d.Devices.Channels[0].Target.Name != "org.qemu.guest_agent.0" {
|
||||
t.Errorf("channels = %+v, want one unix/virtio org.qemu.guest_agent.0", d.Devices.Channels)
|
||||
}
|
||||
if len(d.Devices.Interfaces) != 1 || d.Devices.Interfaces[0].Type != "bridge" ||
|
||||
d.Devices.Interfaces[0].Source.Bridge != p.Bridge || d.Devices.Interfaces[0].Model.Type != "virtio" {
|
||||
t.Errorf("interfaces = %+v, want one bridge %s with virtio model", d.Devices.Interfaces, p.Bridge)
|
||||
}
|
||||
if len(d.Devices.Serials) != 1 || d.Devices.Serials[0].Type != "pty" {
|
||||
t.Errorf("serials = %+v, want one pty", d.Devices.Serials)
|
||||
}
|
||||
if len(d.Devices.Consoles) != 1 || d.Devices.Consoles[0].Type != "pty" || d.Devices.Consoles[0].Target.Type != "serial" {
|
||||
t.Errorf("consoles = %+v, want one pty/serial", d.Devices.Consoles)
|
||||
}
|
||||
if len(d.Devices.RNGs) != 1 || d.Devices.RNGs[0].Model != "virtio" ||
|
||||
d.Devices.RNGs[0].Backend.Model != "random" || d.Devices.RNGs[0].Backend.Value != "/dev/urandom" {
|
||||
t.Errorf("rngs = %+v, want one virtio random /dev/urandom", d.Devices.RNGs)
|
||||
}
|
||||
if len(d.Devices.Graphics) != 0 {
|
||||
t.Error("domain has <graphics>, want none")
|
||||
}
|
||||
if d.OS.Type.Arch != "x86_64" || d.OS.Type.Machine != "q35" {
|
||||
t.Errorf("os type = %+v, want x86_64/q35", d.OS.Type)
|
||||
}
|
||||
if d.CPU.Mode != "host-passthrough" {
|
||||
t.Errorf("cpu mode = %q, want host-passthrough", d.CPU.Mode)
|
||||
}
|
||||
if len(d.Features.ACPI) != 1 || len(d.Features.APIC) != 1 {
|
||||
t.Errorf("features acpi=%d apic=%d, want one each", len(d.Features.ACPI), len(d.Features.APIC))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainXML_rejectsBadInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*domainParams)
|
||||
}{
|
||||
{name: "unknown firmware", mutate: func(p *domainParams) { p.Firmware = "coreboot" }},
|
||||
{name: "empty firmware", mutate: func(p *domainParams) { p.Firmware = "" }},
|
||||
{name: "empty name", mutate: func(p *domainParams) { p.Name = "" }},
|
||||
{name: "empty disk path", mutate: func(p *domainParams) { p.DiskPath = "" }},
|
||||
{name: "empty seed path", mutate: func(p *domainParams) { p.SeedPath = "" }},
|
||||
{name: "unknown disk format", mutate: func(p *domainParams) { p.DiskFormat = "vmdk" }},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := testDomainParams()
|
||||
tc.mutate(&p)
|
||||
out, err := buildDomainXML(p)
|
||||
if !errors.Is(err, provider.ErrPermanent) {
|
||||
t.Errorf("err = %v, want ErrPermanent", err)
|
||||
}
|
||||
if out != "" {
|
||||
t.Errorf("got XML %q alongside the error", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user