diff --git a/docs/plans-executions/2026-09-04-1404-kvm-provider.md b/docs/plans-executions/2026-09-04-1404-kvm-provider.md index 656015c..b630134 100644 --- a/docs/plans-executions/2026-09-04-1404-kvm-provider.md +++ b/docs/plans-executions/2026-09-04-1404-kvm-provider.md @@ -4,7 +4,7 @@ Plan: [2026-09-04-1404-kvm-provider.md](../plans/2026-09-04-1404-kvm-provider.md - [x] Step 0 — Save plan + branch `feat/kvm-provider` - [x] Step 1 — Config (`KVMConfig` & friends, `validate()` case, tests) -- [ ] Step 2 — Pure builders (`domainxml.go`, `volume.go`, `seed.go`, `id.go`, `metadata.go`) +- [x] Step 2 — Pure builders (`domainxml.go`, `volume.go`, `seed.go`, `id.go`, `metadata.go`) - [ ] Step 3 — Transport + errors (`conn.go`, `errors.go`) - [ ] Step 3b — Image cache (`image.go`) - [ ] Step 4 — Provider methods (`kvm.go`) over a fake API @@ -52,3 +52,50 @@ in favour of `NewWithDialer(socket.Dialer)`, where `Dialer` is a one-method `Dial() (net.Conn, error)` interface. Step 3 should wrap the SSH client's unix forward in that interface instead of using `New`; the plan's transport design is otherwise unaffected. + +## Step 2 — Pure builders + +`internal/provider/kvm/` now has the five builders from the plan plus +`kvm.go` holding the package doc and `withDefaults` (disk size 10 GiB, the +default vendor-data, `/var/run/libvirt/libvirt-sock`, pool `default`, image +format `qcow2`). Everything is a pure function over `encoding/xml` structs or +strings, unit-tested by unmarshalling the output back and checking fields. + +Two design choices differ from the plan text: + +- **Domain disks reference pool volumes, not host paths.** The plan had + `buildDomainXML` take the overlay and seed paths (needing two + `StorageVolGetPath` round-trips per Create). The domain XML now uses + ``, so only the *base* + image path is ever looked up (the overlay's `` needs a real + path). Fewer RPCs and the builder's inputs are all names. +- **Capacities are always `unit="bytes"`.** libvirt's `G` means GiB but + `GB` means 10^9; spelling the byte count out removes the ambiguity from + both the overlay and the seed/base volumes. + +The metadata element is written with a default namespace +(``) because `encoding/xml` cannot emit prefixes on +marshal. libvirt may hand it back prefixed; `parseInstanceMetadata` is +tested against both spellings since `encoding/xml` matches on the namespace +URI. Whether libvirt accepts the default-namespace form on `DomainDefineXML` +is confirmed in Step 6 against a real host, not here. + +Checked before relying on `github.com/kdomanski/iso9660` for the seed: its +filename mangling lowercases and keeps `-`, so `user-data`/`meta-data`/ +`vendor-data` come out as `user-data;1` etc. in the directory record, which +the Linux isofs driver (and therefore cloud-init's NoCloud mount) presents as +`user-data`. The seed test opens the ISO with the same library's reader and +asserts label `cidata`, all three files, and byte-identical output for +identical inputs. `NewWriter()` stages files in a temp directory, so +`Cleanup()` is deferred. + +`go mod tidy` ran as part of this step; it promoted `kdomanski/iso9660` to a +direct dependency and dropped `go-libvirt` again because nothing imports it +yet — Step 3 brings it back. + +Worth noting: `withDefaults` copies the `Images` map and `Hosts` slice +instead of mutating the config in place, so the registry's shared +`ProviderConfig` stays untouched. The default vendor-data installs +`qemu-guest-agent` via `packages:` + `runcmd`, which means the first boot +needs network access to the distro mirrors; a Proxy on an isolated bridge +would still work (ARP discovery), just without the agent. diff --git a/go.mod b/go.mod index 48cfaa6..de8c5e3 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require ( cloud.google.com/go/compute v1.65.0 github.com/go-logr/logr v1.4.4 + github.com/kdomanski/iso9660 v0.4.0 github.com/onsi/ginkgo/v2 v2.27.4 github.com/onsi/gomega v1.39.0 github.com/prometheus/client_golang v1.23.2 diff --git a/go.sum b/go.sum index 152847c..807f975 100644 --- a/go.sum +++ b/go.sum @@ -96,6 +96,8 @@ github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kdomanski/iso9660 v0.4.0 h1:BPKKdcINz3m0MdjIMwS0wx1nofsOjxOq8TOr45WGHFg= +github.com/kdomanski/iso9660 v0.4.0/go.mod h1:OxUSupHsO9ceI8lBLPJKWBTphLemjrCQY8LPXM7qSzU= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= diff --git a/internal/provider/kvm/domainxml.go b/internal/provider/kvm/domainxml.go new file mode 100644 index 0000000..32f0732 --- /dev/null +++ b/internal/provider/kvm/domainxml.go @@ -0,0 +1,224 @@ +package kvm + +import ( + "encoding/xml" + "fmt" + "time" +) + +const guestAgentChannel = "org.qemu.guest_agent.0" + +// domainParams is everything buildDomainXML needs, already resolved from the +// config and placement so the builder stays a pure function. +type domainParams struct { + Name string + UID string + CreatedAt time.Time + VCPUs int32 + MemoryMiB int64 + Pool string + Bridge string + DiskVol string + SeedVol string +} + +// buildDomainXML renders a q35 KVM guest: host-passthrough CPU, a virtio +// overlay disk and a SATA cdrom for the seed (both referenced as pool +// volumes so no host paths are needed), one virtio NIC on the host bridge, +// a serial console, the guest-agent channel, a virtio RNG, no graphics, and +// the operator's metadata element. +func buildDomainXML(p domainParams) (string, error) { + d := domainXML{ + Type: "kvm", + Name: p.Name, + Metadata: domainMetadataXML{Instance: newInstanceMetadata(p.UID, p.CreatedAt)}, + Memory: sizeXML{Unit: "MiB", Value: p.MemoryMiB}, + VCPU: p.VCPUs, + OS: osXML{ + Type: osTypeXML{Arch: "x86_64", Machine: "q35", Value: "hvm"}, + Boot: bootXML{Dev: "hd"}, + }, + Features: featuresXML{ACPI: &struct{}{}, APIC: &struct{}{}}, + CPU: cpuXML{Mode: "host-passthrough"}, + Clock: clockXML{Offset: "utc"}, + OnPoweroff: "destroy", + OnReboot: "restart", + OnCrash: "destroy", + Devices: devicesXML{ + Disks: []diskXML{ + { + Type: "volume", + Device: "disk", + Driver: driverXML{Name: "qemu", Type: formatQcow2}, + Source: diskSourceXML{Pool: p.Pool, Volume: p.DiskVol}, + Target: targetXML{Dev: "vda", Bus: "virtio"}, + }, + { + Type: "volume", + Device: "cdrom", + Driver: driverXML{Name: "qemu", Type: formatRaw}, + Source: diskSourceXML{Pool: p.Pool, Volume: p.SeedVol}, + Target: targetXML{Dev: "sda", Bus: "sata"}, + ReadOnly: &struct{}{}, + }, + }, + Interfaces: []interfaceXML{{ + Type: "bridge", + Source: ifaceSourceXML{Bridge: p.Bridge}, + Model: modelXML{Type: "virtio"}, + }}, + Serials: []serialXML{{Type: "pty", Target: portXML{Port: 0}}}, + Consoles: []consoleXML{{Type: "pty", Target: consoleTargetXML{Type: "serial", Port: 0}}}, + Channels: []channelXML{{ + Type: "unix", + Target: channelTargetXML{Type: "virtio", Name: guestAgentChannel}, + }}, + RNG: rngXML{Model: "virtio", Backend: rngBackendXML{Model: "random", Value: "/dev/urandom"}}, + }, + } + out, err := xml.Marshal(d) + if err != nil { + return "", fmt.Errorf("marshalling domain xml: %w", err) + } + return string(out), nil +} + +type domainXML struct { + XMLName xml.Name `xml:"domain"` + Type string `xml:"type,attr"` + Name string `xml:"name"` + Metadata domainMetadataXML `xml:"metadata"` + Memory sizeXML `xml:"memory"` + VCPU int32 `xml:"vcpu"` + OS osXML `xml:"os"` + Features featuresXML `xml:"features"` + CPU cpuXML `xml:"cpu"` + Clock clockXML `xml:"clock"` + OnPoweroff string `xml:"on_poweroff"` + OnReboot string `xml:"on_reboot"` + OnCrash string `xml:"on_crash"` + Devices devicesXML `xml:"devices"` +} + +type domainMetadataXML struct { + Instance instanceMetadata +} + +type sizeXML struct { + Unit string `xml:"unit,attr"` + Value int64 `xml:",chardata"` +} + +type osXML struct { + Type osTypeXML `xml:"type"` + Boot bootXML `xml:"boot"` +} + +type osTypeXML struct { + Arch string `xml:"arch,attr"` + Machine string `xml:"machine,attr"` + Value string `xml:",chardata"` +} + +type bootXML struct { + Dev string `xml:"dev,attr"` +} + +type featuresXML struct { + ACPI *struct{} `xml:"acpi"` + APIC *struct{} `xml:"apic"` +} + +type cpuXML struct { + Mode string `xml:"mode,attr"` +} + +type clockXML struct { + Offset string `xml:"offset,attr"` +} + +type devicesXML struct { + Disks []diskXML `xml:"disk"` + Interfaces []interfaceXML `xml:"interface"` + Serials []serialXML `xml:"serial"` + Consoles []consoleXML `xml:"console"` + Channels []channelXML `xml:"channel"` + RNG rngXML `xml:"rng"` +} + +type diskXML struct { + Type string `xml:"type,attr"` + Device string `xml:"device,attr"` + Driver driverXML `xml:"driver"` + Source diskSourceXML `xml:"source"` + Target targetXML `xml:"target"` + ReadOnly *struct{} `xml:"readonly"` +} + +type driverXML struct { + Name string `xml:"name,attr"` + Type string `xml:"type,attr"` +} + +type diskSourceXML struct { + Pool string `xml:"pool,attr"` + Volume string `xml:"volume,attr"` +} + +type targetXML struct { + Dev string `xml:"dev,attr"` + Bus string `xml:"bus,attr"` +} + +type interfaceXML struct { + Type string `xml:"type,attr"` + Source ifaceSourceXML `xml:"source"` + Model modelXML `xml:"model"` +} + +type ifaceSourceXML struct { + Bridge string `xml:"bridge,attr"` +} + +type modelXML struct { + Type string `xml:"type,attr"` +} + +type serialXML struct { + Type string `xml:"type,attr"` + Target portXML `xml:"target"` +} + +type portXML struct { + Port int `xml:"port,attr"` +} + +type consoleXML struct { + Type string `xml:"type,attr"` + Target consoleTargetXML `xml:"target"` +} + +type consoleTargetXML struct { + Type string `xml:"type,attr"` + Port int `xml:"port,attr"` +} + +type channelXML struct { + Type string `xml:"type,attr"` + Target channelTargetXML `xml:"target"` +} + +type channelTargetXML struct { + Type string `xml:"type,attr"` + Name string `xml:"name,attr"` +} + +type rngXML struct { + Model string `xml:"model,attr"` + Backend rngBackendXML `xml:"backend"` +} + +type rngBackendXML struct { + Model string `xml:"model,attr"` + Value string `xml:",chardata"` +} diff --git a/internal/provider/kvm/domainxml_test.go b/internal/provider/kvm/domainxml_test.go new file mode 100644 index 0000000..d52474c --- /dev/null +++ b/internal/provider/kvm/domainxml_test.go @@ -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, "/domains/", id) + } + return parts[1], parts[3], nil +} diff --git a/internal/provider/kvm/id_test.go b/internal/provider/kvm/id_test.go new file mode 100644 index 0000000..0a7c158 --- /dev/null +++ b/internal/provider/kvm/id_test.go @@ -0,0 +1,41 @@ +package kvm + +import "testing" + +func TestParseProviderID_roundTrip(t *testing.T) { + t.Parallel() + id := formatProviderID("nas1", "proxy-abc123def456ghij") + if id != "hosts/nas1/domains/proxy-abc123def456ghij" { + t.Fatalf("formatProviderID() = %q", id) + } + host, name, err := parseProviderID(id) + if err != nil { + t.Fatalf("parseProviderID(%q) error = %v", id, err) + } + if host != "nas1" || name != "proxy-abc123def456ghij" { + t.Errorf("parseProviderID(%q) = %q, %q", id, host, name) + } +} + +func TestParseProviderID_malformed(t *testing.T) { + t.Parallel() + tests := []struct { + name string + id string + }{ + {"empty", ""}, + {"gcp style", "zones/europe-west1-b/instances/proxy-x"}, + {"missing name", "hosts/nas1/domains/"}, + {"missing host", "hosts//domains/proxy-x"}, + {"too many segments", "hosts/nas1/domains/proxy-x/extra"}, + {"bare name", "proxy-x"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if _, _, err := parseProviderID(tc.id); err == nil { + t.Errorf("parseProviderID(%q) error = nil, want error", tc.id) + } + }) + } +} diff --git a/internal/provider/kvm/kvm.go b/internal/provider/kvm/kvm.go new file mode 100644 index 0000000..e5653e3 --- /dev/null +++ b/internal/provider/kvm/kvm.go @@ -0,0 +1,47 @@ +// Package kvm implements the provider contract on libvirt/KVM hypervisors. +// Each configured host is reached over SSH with the libvirt unix socket +// forwarded through the connection, so the hypervisors need no libvirtd TCP +// or TLS listener and the operator image needs no libvirt libraries. VMs +// boot a stock cloud image (fetched into the host's storage pool on first +// use) through a qcow2 overlay, with the Proxy's cloud-init delivered on a +// NoCloud seed ISO built in-process. +package kvm + +import "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" + +const ( + defaultDiskSizeGB = 10 + defaultSocket = "/var/run/libvirt/libvirt-sock" + defaultPool = "default" + formatQcow2 = "qcow2" + formatRaw = "raw" +) + +func withDefaults(cfg provider.KVMConfig) provider.KVMConfig { + if cfg.DiskSizeGB == 0 { + cfg.DiskSizeGB = defaultDiskSizeGB + } + if cfg.VendorData == "" { + cfg.VendorData = defaultVendorData + } + images := make(map[string]provider.KVMImage, len(cfg.Images)) + for k, img := range cfg.Images { + if img.Format == "" { + img.Format = formatQcow2 + } + images[k] = img + } + cfg.Images = images + hosts := make([]provider.KVMHost, len(cfg.Hosts)) + for i, h := range cfg.Hosts { + if h.Socket == "" { + h.Socket = defaultSocket + } + if h.Pool == "" { + h.Pool = defaultPool + } + hosts[i] = h + } + cfg.Hosts = hosts + return cfg +} diff --git a/internal/provider/kvm/kvm_test.go b/internal/provider/kvm/kvm_test.go new file mode 100644 index 0000000..0f04970 --- /dev/null +++ b/internal/provider/kvm/kvm_test.go @@ -0,0 +1,44 @@ +package kvm + +import ( + "testing" + + "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" +) + +func TestWithDefaults(t *testing.T) { + t.Parallel() + cfg := withDefaults(provider.KVMConfig{ + Images: map[string]provider.KVMImage{ + "debian": {URL: "https://x/d.qcow2"}, + "raw": {URL: "https://x/r.img", Format: "raw"}, + }, + Hosts: []provider.KVMHost{ + {Name: "a", Address: "a:22", User: "virt", Bridge: "br0"}, + {Name: "b", Address: "b:22", User: "virt", Bridge: "br1", Socket: "/run/s", Pool: "fast"}, + }, + }) + if cfg.DiskSizeGB != defaultDiskSizeGB { + t.Errorf("diskSizeGb = %d, want %d", cfg.DiskSizeGB, defaultDiskSizeGB) + } + if cfg.VendorData != defaultVendorData { + t.Errorf("vendorData = %q, want the default", cfg.VendorData) + } + if cfg.Images["debian"].Format != "qcow2" || cfg.Images["raw"].Format != "raw" { + t.Errorf("image formats = %q/%q, want qcow2 default and raw kept", cfg.Images["debian"].Format, cfg.Images["raw"].Format) + } + if a := cfg.Hosts[0]; a.Socket != defaultSocket || a.Pool != defaultPool { + t.Errorf("host a = %+v, want default socket and pool", a) + } + if b := cfg.Hosts[1]; b.Socket != "/run/s" || b.Pool != "fast" { + t.Errorf("host b = %+v, want explicit socket and pool kept", b) + } +} + +func TestWithDefaults_keepsExplicitValues(t *testing.T) { + t.Parallel() + cfg := withDefaults(provider.KVMConfig{DiskSizeGB: 42, VendorData: "#cloud-config\n"}) + if cfg.DiskSizeGB != 42 || cfg.VendorData != "#cloud-config\n" { + t.Errorf("cfg = %+v, want explicit values kept", cfg) + } +} diff --git a/internal/provider/kvm/metadata.go b/internal/provider/kvm/metadata.go new file mode 100644 index 0000000..55ab4e7 --- /dev/null +++ b/internal/provider/kvm/metadata.go @@ -0,0 +1,57 @@ +package kvm + +import ( + "encoding/xml" + "fmt" + "time" + + "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" +) + +// metadataURI namespaces the operator's element inside the domain's +// . libvirt has no labels or tags, but it stores any namespaced XML +// there and hands it back through DomainGetMetadata, which is how the GC +// contract (managed flag + owning UID) and the creation time survive across +// operator restarts. +const metadataURI = "https://gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/kvm" + +type instanceMetadata struct { + XMLName xml.Name `xml:"https://gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/kvm instance"` + Managed string `xml:"managed"` + UID string `xml:"uid"` + CreatedAt string `xml:"createdAt"` +} + +func newInstanceMetadata(uid string, createdAt time.Time) instanceMetadata { + return instanceMetadata{ + Managed: provider.LabelManagedYes, + UID: uid, + CreatedAt: createdAt.UTC().Format(time.RFC3339), + } +} + +// parseInstanceMetadata decodes the element as DomainGetMetadata returns it. +// libvirt may re-serialise it with a namespace prefix rather than the default +// namespace it was defined with; encoding/xml matches on namespace URI, so +// both spellings decode. +func parseInstanceMetadata(s string) (instanceMetadata, error) { + var m instanceMetadata + if err := xml.Unmarshal([]byte(s), &m); err != nil { + return instanceMetadata{}, fmt.Errorf("parsing domain metadata: %w", err) + } + return m, nil +} + +func (m instanceMetadata) managed() bool { + return m.Managed == provider.LabelManagedYes +} + +// createdAt returns the zero time on a malformed timestamp: orphan GC treats +// zero as "old", so a corrupt value can never protect a domain forever. +func (m instanceMetadata) createdAt() time.Time { + t, err := time.Parse(time.RFC3339, m.CreatedAt) + if err != nil { + return time.Time{} + } + return t +} diff --git a/internal/provider/kvm/metadata_test.go b/internal/provider/kvm/metadata_test.go new file mode 100644 index 0000000..f3ab94f --- /dev/null +++ b/internal/provider/kvm/metadata_test.go @@ -0,0 +1,79 @@ +package kvm + +import ( + "encoding/xml" + "strings" + "testing" + "time" +) + +func TestInstanceMetadata_roundTrip(t *testing.T) { + t.Parallel() + created := time.Date(2026, 9, 4, 12, 30, 0, 0, time.FixedZone("CEST", 2*3600)) + raw, err := xml.Marshal(newInstanceMetadata("uid-1", created)) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if !strings.Contains(string(raw), `xmlns="`+metadataURI+`"`) { + t.Errorf("marshalled metadata lacks the namespace declaration: %s", raw) + } + m, err := parseInstanceMetadata(string(raw)) + if err != nil { + t.Fatalf("parseInstanceMetadata() error = %v", err) + } + if !m.managed() || m.UID != "uid-1" { + t.Errorf("parsed = %+v, want managed with uid-1", m) + } + if got := m.createdAt(); !got.Equal(created) || got.Location() != time.UTC { + t.Errorf("createdAt() = %v, want %v in UTC", got, created) + } +} + +func TestParseInstanceMetadata_libvirtPrefixedForm(t *testing.T) { + t.Parallel() + // libvirt re-serialises the stored element with whatever prefix it + // chooses; only the namespace URI is contractual. + in := `` + + `trueuid-2` + + `2026-09-04T10:00:00Z` + m, err := parseInstanceMetadata(in) + if err != nil { + t.Fatalf("parseInstanceMetadata() error = %v", err) + } + if !m.managed() || m.UID != "uid-2" || m.createdAt().Hour() != 10 { + t.Errorf("parsed = %+v", m) + } +} + +func TestParseInstanceMetadata_notManaged(t *testing.T) { + t.Parallel() + tests := []struct { + name string + in string + wantErr bool + }{ + {"managed false", `falseu`, false}, + {"foreign element", `true`, true}, + {"garbage", `not xml`, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m, err := parseInstanceMetadata(tc.in) + if (err != nil) != tc.wantErr { + t.Fatalf("error = %v, wantErr %v", err, tc.wantErr) + } + if m.managed() { + t.Errorf("managed() = true for %q", tc.in) + } + }) + } +} + +func TestInstanceMetadata_createdAtMalformedIsZero(t *testing.T) { + t.Parallel() + m := instanceMetadata{CreatedAt: "yesterday"} + if !m.createdAt().IsZero() { + t.Errorf("createdAt() = %v, want zero time", m.createdAt()) + } +} diff --git a/internal/provider/kvm/seed.go b/internal/provider/kvm/seed.go new file mode 100644 index 0000000..49e23cd --- /dev/null +++ b/internal/provider/kvm/seed.go @@ -0,0 +1,52 @@ +package kvm + +import ( + "bytes" + "fmt" + "strings" + + "github.com/kdomanski/iso9660" +) + +// seedLabel is the volume label cloud-init's NoCloud datasource looks for. +const seedLabel = "cidata" + +// defaultVendorData is the cloud-init vendor-data shipped on every seed +// unless the config overrides it. cloud-init merges it under the Proxy's own +// user-data, so stock images grow a guest agent (which lets the provider +// read the VM's IP before ARP sees traffic) without the user's cloud-init +// having to know about it. +const defaultVendorData = `#cloud-config +packages: + - qemu-guest-agent +runcmd: + - [systemctl, enable, --now, qemu-guest-agent] +` + +// buildSeedISO renders a NoCloud seed: user-data verbatim, vendor-data as +// given, and a meta-data whose instance-id is the deterministic VM name, so a +// re-created seed for the same name is byte-identical and cloud-init never +// mistakes a reboot for a new instance. +func buildSeedISO(name, userData, vendorData string) ([]byte, error) { + w, err := iso9660.NewWriter() + if err != nil { + return nil, fmt.Errorf("creating iso writer: %w", err) + } + defer w.Cleanup() //nolint:errcheck // temp-dir cleanup; nothing to do on failure + + files := []struct{ name, body string }{ + {"user-data", userData}, + {"meta-data", fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", name, name)}, + {"vendor-data", vendorData}, + } + for _, f := range files { + if err := w.AddFile(strings.NewReader(f.body), f.name); err != nil { + return nil, fmt.Errorf("adding %s to seed iso: %w", f.name, err) + } + } + var buf bytes.Buffer + if err := w.WriteTo(&buf, seedLabel); err != nil { + return nil, fmt.Errorf("writing seed iso: %w", err) + } + return buf.Bytes(), nil +} diff --git a/internal/provider/kvm/seed_test.go b/internal/provider/kvm/seed_test.go new file mode 100644 index 0000000..4bd9c08 --- /dev/null +++ b/internal/provider/kvm/seed_test.go @@ -0,0 +1,91 @@ +package kvm + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/kdomanski/iso9660" +) + +func readSeed(t *testing.T, iso []byte) (label string, files map[string]string) { + t.Helper() + img, err := iso9660.OpenImage(bytes.NewReader(iso)) + if err != nil { + t.Fatalf("OpenImage() error = %v", err) + } + label, err = img.Label() + if err != nil { + t.Fatalf("Label() error = %v", err) + } + root, err := img.RootDir() + if err != nil { + t.Fatalf("RootDir() error = %v", err) + } + children, err := root.GetChildren() + if err != nil { + t.Fatalf("GetChildren() error = %v", err) + } + files = make(map[string]string, len(children)) + for _, c := range children { + body, err := io.ReadAll(c.Reader()) + if err != nil { + t.Fatalf("reading %s: %v", c.Name(), err) + } + files[c.Name()] = string(body) + } + return label, files +} + +func TestBuildSeedISO_contents(t *testing.T) { + t.Parallel() + userData := "#cloud-config\npackages: [squid]\n" + iso, err := buildSeedISO("proxy-abc123def456ghij", userData, defaultVendorData) + if err != nil { + t.Fatalf("buildSeedISO() error = %v", err) + } + label, files := readSeed(t, iso) + if !strings.EqualFold(label, seedLabel) { + t.Errorf("label = %q, want %q", label, seedLabel) + } + if len(files) != 3 { + t.Errorf("files = %v, want exactly user-data, meta-data, vendor-data", files) + } + if files["user-data"] != userData { + t.Errorf("user-data = %q, want verbatim cloud-init", files["user-data"]) + } + if files["vendor-data"] != defaultVendorData { + t.Errorf("vendor-data = %q", files["vendor-data"]) + } + if got, want := files["meta-data"], "instance-id: proxy-abc123def456ghij\nlocal-hostname: proxy-abc123def456ghij\n"; got != want { + t.Errorf("meta-data = %q, want %q", got, want) + } +} + +func TestBuildSeedISO_deterministic(t *testing.T) { + t.Parallel() + a, err := buildSeedISO("proxy-x", "#cloud-config\n", "") + if err != nil { + t.Fatalf("first buildSeedISO() error = %v", err) + } + b, err := buildSeedISO("proxy-x", "#cloud-config\n", "") + if err != nil { + t.Fatalf("second buildSeedISO() error = %v", err) + } + if !bytes.Equal(a, b) { + t.Error("two builds for the same inputs differ; a re-created seed must be byte-identical") + } +} + +func TestBuildSeedISO_emptyUserData(t *testing.T) { + t.Parallel() + iso, err := buildSeedISO("proxy-x", "", defaultVendorData) + if err != nil { + t.Fatalf("buildSeedISO() error = %v", err) + } + _, files := readSeed(t, iso) + if body, ok := files["user-data"]; !ok || body != "" { + t.Errorf("user-data = %q (present=%v), want an empty file to still exist", body, ok) + } +} diff --git a/internal/provider/kvm/volume.go b/internal/provider/kvm/volume.go new file mode 100644 index 0000000..da0814d --- /dev/null +++ b/internal/provider/kvm/volume.go @@ -0,0 +1,78 @@ +package kvm + +import ( + "encoding/xml" + "fmt" +) + +const bytesPerGiB = 1 << 30 + +func overlayVolumeName(name string) string { return name + ".qcow2" } + +func seedVolumeName(name string) string { return name + "-seed.iso" } + +// volumeSpec describes a storage volume to create. A non-empty BackingPath +// makes it a copy-on-write overlay of that file. +type volumeSpec struct { + Name string + SizeBytes int64 + Format string + BackingPath string + BackingFormat string +} + +func overlayVolume(name string, sizeGB int64, basePath, baseFormat string) volumeSpec { + return volumeSpec{ + Name: overlayVolumeName(name), + SizeBytes: sizeGB * bytesPerGiB, + Format: formatQcow2, + BackingPath: basePath, + BackingFormat: baseFormat, + } +} + +func rawVolume(name string, sizeBytes int64) volumeSpec { + return volumeSpec{Name: name, SizeBytes: sizeBytes, Format: formatRaw} +} + +// buildVolumeXML renders the volume for StorageVolCreateXML. Capacity is +// always given in bytes so no libvirt unit-suffix ambiguity (G vs GiB) can +// creep in. +func buildVolumeXML(v volumeSpec) (string, error) { + x := volumeXML{ + Type: "file", + Name: v.Name, + Capacity: sizeXML{Unit: "bytes", Value: v.SizeBytes}, + Target: volumeTargetXML{Format: formatXML{Type: v.Format}}, + } + if v.BackingPath != "" { + x.BackingStore = &backingStoreXML{Path: v.BackingPath, Format: formatXML{Type: v.BackingFormat}} + } + out, err := xml.Marshal(x) + if err != nil { + return "", fmt.Errorf("marshalling volume xml: %w", err) + } + return string(out), nil +} + +type volumeXML struct { + XMLName xml.Name `xml:"volume"` + Type string `xml:"type,attr"` + Name string `xml:"name"` + Capacity sizeXML `xml:"capacity"` + Target volumeTargetXML `xml:"target"` + BackingStore *backingStoreXML `xml:"backingStore"` +} + +type volumeTargetXML struct { + Format formatXML `xml:"format"` +} + +type formatXML struct { + Type string `xml:"type,attr"` +} + +type backingStoreXML struct { + Path string `xml:"path"` + Format formatXML `xml:"format"` +} diff --git a/internal/provider/kvm/volume_test.go b/internal/provider/kvm/volume_test.go new file mode 100644 index 0000000..c9b68a8 --- /dev/null +++ b/internal/provider/kvm/volume_test.go @@ -0,0 +1,62 @@ +package kvm + +import ( + "encoding/xml" + "strings" + "testing" +) + +func TestVolumeNames(t *testing.T) { + t.Parallel() + if got := overlayVolumeName("proxy-x"); got != "proxy-x.qcow2" { + t.Errorf("overlayVolumeName = %q", got) + } + if got := seedVolumeName("proxy-x"); got != "proxy-x-seed.iso" { + t.Errorf("seedVolumeName = %q", got) + } +} + +func TestBuildVolumeXML_overlay(t *testing.T) { + t.Parallel() + raw, err := buildVolumeXML(overlayVolume("proxy-x", 10, "/var/lib/libvirt/images/base-debian-13.qcow2", "qcow2")) + if err != nil { + t.Fatalf("buildVolumeXML() error = %v", err) + } + var v volumeXML + if err := xml.Unmarshal([]byte(raw), &v); err != nil { + t.Fatalf("output is not well-formed XML: %v", err) + } + if v.Type != "file" || v.Name != "proxy-x.qcow2" { + t.Errorf("type/name = %s/%s", v.Type, v.Name) + } + if v.Capacity.Unit != "bytes" || v.Capacity.Value != 10*bytesPerGiB { + t.Errorf("capacity = %d %s, want %d bytes", v.Capacity.Value, v.Capacity.Unit, 10*bytesPerGiB) + } + if v.Target.Format.Type != "qcow2" { + t.Errorf("format = %q", v.Target.Format.Type) + } + if v.BackingStore == nil { + t.Fatalf("backingStore missing:\n%s", raw) + } + if v.BackingStore.Path != "/var/lib/libvirt/images/base-debian-13.qcow2" || v.BackingStore.Format.Type != "qcow2" { + t.Errorf("backingStore = %+v", v.BackingStore) + } +} + +func TestBuildVolumeXML_rawHasNoBackingStore(t *testing.T) { + t.Parallel() + raw, err := buildVolumeXML(rawVolume("proxy-x-seed.iso", 372736)) + if err != nil { + t.Fatalf("buildVolumeXML() error = %v", err) + } + if strings.Contains(raw, "backingStore") { + t.Errorf("raw volume must not declare a backing store:\n%s", raw) + } + var v volumeXML + if err := xml.Unmarshal([]byte(raw), &v); err != nil { + t.Fatalf("output is not well-formed XML: %v", err) + } + if v.Name != "proxy-x-seed.iso" || v.Capacity.Value != 372736 || v.Target.Format.Type != "raw" { + t.Errorf("volume = %+v", v) + } +}