# Execution: KVM/libvirt provider 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) - [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 - [ ] Step 5 — Wiring, manifests, samples, docs, CHANGELOG - [ ] Step 6 — Integration test against a real hypervisor - [ ] Step 7 — Manual validation + MR ## Step 0 — Save plan + branch Plan committed alone on `feat/proxy-operator`, then branched: ```bash git checkout -b feat/kvm-provider ``` ## Step 1 — Config The new structs (`KVMConfig`, `KVMHost`, `KVMMachineType`, `KVMImage`) and `validateKVM` went into their own file, `internal/provider/config_kvm.go`, rather than being appended to `config.go` as the plan said: the KVM block has far more fields and rules than the other two providers, and keeping it separate leaves `config.go` readable. Tests likewise live in `config_kvm_test.go` instead of extending the table in `config_test.go`; the invalid-config table covers every required field, duplicate host names, image URL scheme, sha256 shape, and the disk format enum. While adding the `kvm` case to `validate()`, the pairwise "type is X but a Y block is set" checks (which would have needed six branches with three types) were replaced by a `setBlocks()` helper that lists the non-nil blocks and rejects any that don't match `type`. The error strings are unchanged, so the existing GCP/kubernetes tests still pass. Dependencies were fetched up front so the API signatures in the plan could be checked with `go doc`; they stay `// indirect` in `go.mod` until Step 2 imports them: ```bash go get github.com/digitalocean/go-libvirt@latest github.com/kdomanski/iso9660@v0.4.0 # go-libvirt resolved to v0.0.0-20260814190004-1a83157e1858 (no tagged release) # side effect: golang.org/x/crypto 0.54.0 → 0.56.0, x/mod, x/text, x/tools bumped ``` Worth noting: `go doc` showed that go-libvirt's `New(net.Conn)` is deprecated 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.