Introduces internal/provider/kvm with the side-effect-free pieces of the provider: providerID format/parse, the namespaced <metadata> element that carries the GC contract, q35 domain XML over pool volumes, volume XML for overlays and raw seeds, the NoCloud seed ISO built with kdomanski/iso9660, and config defaults. All unit-tested. Step 2 of docs/plans/2026-09-04-1404-kvm-provider.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
114 lines
4.0 KiB
Go
114 lines
4.0 KiB
Go
package kvm
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func testDomainParams() domainParams {
|
|
return domainParams{
|
|
Name: "proxy-abc123def456ghij",
|
|
UID: "11111111-2222-3333-4444-555555555555",
|
|
CreatedAt: time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC),
|
|
VCPUs: 2,
|
|
MemoryMiB: 2048,
|
|
Pool: "default",
|
|
Bridge: "br0",
|
|
DiskVol: "proxy-abc123def456ghij.qcow2",
|
|
SeedVol: "proxy-abc123def456ghij-seed.iso",
|
|
}
|
|
}
|
|
|
|
func TestBuildDomainXML_fieldByField(t *testing.T) {
|
|
t.Parallel()
|
|
raw, err := buildDomainXML(testDomainParams())
|
|
if err != nil {
|
|
t.Fatalf("buildDomainXML() error = %v", err)
|
|
}
|
|
var d domainXML
|
|
if err := xml.Unmarshal([]byte(raw), &d); err != nil {
|
|
t.Fatalf("output is not well-formed XML: %v\n%s", err, raw)
|
|
}
|
|
|
|
if d.Type != "kvm" || d.Name != "proxy-abc123def456ghij" {
|
|
t.Errorf("type/name = %s/%s", d.Type, d.Name)
|
|
}
|
|
if d.VCPU != 2 || d.Memory.Value != 2048 || d.Memory.Unit != "MiB" {
|
|
t.Errorf("vcpu/memory = %d/%d %s, want 2/2048 MiB", d.VCPU, d.Memory.Value, d.Memory.Unit)
|
|
}
|
|
if d.OS.Type.Value != "hvm" || d.OS.Type.Arch != "x86_64" || d.OS.Type.Machine != "q35" || d.OS.Boot.Dev != "hd" {
|
|
t.Errorf("os = %+v", d.OS)
|
|
}
|
|
if d.Features.ACPI == nil || d.Features.APIC == nil {
|
|
t.Errorf("features = %+v, want acpi and apic", d.Features)
|
|
}
|
|
if d.CPU.Mode != "host-passthrough" {
|
|
t.Errorf("cpu mode = %q", d.CPU.Mode)
|
|
}
|
|
if d.OnPoweroff != "destroy" || d.OnReboot != "restart" || d.OnCrash != "destroy" {
|
|
t.Errorf("lifecycle = %s/%s/%s", d.OnPoweroff, d.OnReboot, d.OnCrash)
|
|
}
|
|
|
|
m := d.Metadata.Instance
|
|
if !m.managed() || m.UID != "11111111-2222-3333-4444-555555555555" || m.CreatedAt != "2026-09-04T12:00:00Z" {
|
|
t.Errorf("metadata = %+v", m)
|
|
}
|
|
if !strings.Contains(raw, `xmlns="`+metadataURI+`"`) {
|
|
t.Errorf("metadata element is not namespaced; libvirt rejects that:\n%s", raw)
|
|
}
|
|
|
|
if len(d.Devices.Disks) != 2 {
|
|
t.Fatalf("disks = %d, want 2", len(d.Devices.Disks))
|
|
}
|
|
disk, seed := d.Devices.Disks[0], d.Devices.Disks[1]
|
|
if disk.Type != "volume" || disk.Device != "disk" || disk.Driver.Type != "qcow2" ||
|
|
disk.Source.Pool != "default" || disk.Source.Volume != "proxy-abc123def456ghij.qcow2" ||
|
|
disk.Target.Dev != "vda" || disk.Target.Bus != "virtio" || disk.ReadOnly != nil {
|
|
t.Errorf("overlay disk = %+v", disk)
|
|
}
|
|
if seed.Type != "volume" || seed.Device != "cdrom" || seed.Driver.Type != "raw" ||
|
|
seed.Source.Pool != "default" || seed.Source.Volume != "proxy-abc123def456ghij-seed.iso" ||
|
|
seed.Target.Bus != "sata" || seed.ReadOnly == nil {
|
|
t.Errorf("seed cdrom = %+v", seed)
|
|
}
|
|
|
|
if len(d.Devices.Interfaces) != 1 {
|
|
t.Fatalf("interfaces = %d, want 1", len(d.Devices.Interfaces))
|
|
}
|
|
if nic := d.Devices.Interfaces[0]; nic.Type != "bridge" || nic.Source.Bridge != "br0" || nic.Model.Type != "virtio" {
|
|
t.Errorf("interface = %+v", nic)
|
|
}
|
|
|
|
if len(d.Devices.Channels) != 1 || d.Devices.Channels[0].Target.Name != guestAgentChannel || d.Devices.Channels[0].Target.Type != "virtio" {
|
|
t.Errorf("channels = %+v, want one virtio %s channel", d.Devices.Channels, guestAgentChannel)
|
|
}
|
|
if len(d.Devices.Serials) != 1 || len(d.Devices.Consoles) != 1 || d.Devices.Consoles[0].Target.Type != "serial" {
|
|
t.Errorf("serial/console = %+v/%+v", d.Devices.Serials, d.Devices.Consoles)
|
|
}
|
|
if d.Devices.RNG.Model != "virtio" || d.Devices.RNG.Backend.Value != "/dev/urandom" {
|
|
t.Errorf("rng = %+v", d.Devices.RNG)
|
|
}
|
|
if strings.Contains(raw, "<graphics") {
|
|
t.Errorf("domain has a graphics device; headless VMs must not:\n%s", raw)
|
|
}
|
|
}
|
|
|
|
func TestBuildDomainXML_escapesUserValues(t *testing.T) {
|
|
t.Parallel()
|
|
p := testDomainParams()
|
|
p.Bridge = `br0" onload="x`
|
|
raw, err := buildDomainXML(p)
|
|
if err != nil {
|
|
t.Fatalf("buildDomainXML() error = %v", err)
|
|
}
|
|
var d domainXML
|
|
if err := xml.Unmarshal([]byte(raw), &d); err != nil {
|
|
t.Fatalf("output is not well-formed XML: %v", err)
|
|
}
|
|
if d.Devices.Interfaces[0].Source.Bridge != p.Bridge {
|
|
t.Errorf("bridge = %q, want the raw value back", d.Devices.Interfaces[0].Source.Bridge)
|
|
}
|
|
}
|