Add KVM provider pure builders: domain XML, volumes, seed ISO, metadata
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>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user