Design for a kvm provider type: SSH-forwarded libvirt socket, multi-host config with placement.zone selecting the host, bridged networking with ARP/guest-agent IP discovery, operator-fetched distro cloud images cached in the host's storage pool, and a NoCloud seed ISO carrying the CRD's cloud-init user-data. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
17 KiB
Plan: KVM/libvirt provider
Created: 2026-09-04 14:04
Context
The operator provisions egress proxies through named provider instances (today
gcp and kubernetes). The user has several KVM hypervisors on the home
network and wants proxy VMs created there, using stock Debian/Ubuntu cloud
images so the VM stays a normal machine they can customise through their own
cloud-init user-data. Longer term the operator may become a generic "run a VM
or container with a set of scripts" tool; the KVM provider should not
foreclose that.
The provider contract (internal/provider/provider.go: Create/Get/Delete/
ListByTag, keyed by NameFromUID, cloud-init passed verbatim, GC-by-tag) fits
libvirt with no CRD or reconciler changes. All work is a new provider
package plus config/wiring/docs, following the GCP provider's shape.
Decisions taken with the user (2026-09-04):
- Transport: SSH to each hypervisor, forwarding the libvirt unix socket
(
ssh.Client.Dial("unix", "/var/run/libvirt/libvirt-sock")→libvirt.New(conn)). Pure Go, no cgo, no libvirt libs in the image. Verified:x/crypto/sshClient.Dialhas a"unix"case (dialStreamLocal), go-libvirtNew(net.Conn). - Topology: one provider instance can span several hosts from day one
(config has a
hostslist; providerID encodes the host), so adding hosts later is a config edit with no providerID migration. With one host configuredplacement.zonemay be omitted. - Networking: VM NIC on a host bridge to the LAN, IP from the router's
DHCP. IP discovery via
DomainInterfaceAddresses(ARP source first, then guest agent). Provider-injectedvendor-datainstallsqemu-guest-agentso agent discovery works on stock images without touching user-data. - Base image: a stock Debian/Ubuntu cloud image (qcow2, cloud-init
preinstalled) that the operator fetches itself on first use per host:
placement.imagenames an entry in the provider'simages:map (url + optional sha256) or is anhttps://URL directly. The provider streams the download through libvirt'sStorageVolUploadinto the host's pool, so nothing is pre-placed and the operator needs no filesystem access on the host. Per-VM disk is a qcow2 overlay with the cached base as backing store. - Seed: NoCloud ISO (
user-data,meta-data,vendor-data) built in pure Go withgithub.com/kdomanski/iso9660(WriteTo(w, "cidata")) and streamed to the pool withStorageVolUpload(vol, io.Reader, ...). Nothing to install on the hypervisors.
Design
Config (internal/provider/config.go)
- name: kvm-home
type: kvm
kvm:
sshKeyFile: /etc/proxy-operator/kvm/id_ed25519 # required
knownHostsFile: /etc/proxy-operator/kvm/known_hosts # required unless insecureIgnoreHostKey
insecureIgnoreHostKey: false
diskSizeGb: 10 # overlay capacity; cloud-init growpart expands root fs
vendorData: | # optional override; default installs qemu-guest-agent
#cloud-config
...
images: # optional; placement.image names a key (or is an https URL)
debian-13:
url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
sha256: "…" # optional; verified after download, re-fetch when it changes
format: qcow2 # default qcow2
ubuntu-24.04:
url: https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
machineTypes: # required, ≥1; placement.machineType names a key
small: {vcpus: 1, memoryMiB: 1024}
medium: {vcpus: 2, memoryMiB: 2048}
hosts: # required, ≥1, unique names
- name: nas1 # placement.zone value
address: nas1.home.lan:22 # required
user: virt # required; must be in the libvirt group
socket: /var/run/libvirt/libvirt-sock # default
pool: default # default "default"
bridge: br0 # required
Add KVMConfig, KVMHost, KVMMachineType, KVMImage structs; a "kvm"
case in validate() (cross-block checks like the others, required fields
above, unique host names, image URLs must be http(s), sha256 if set must be
64 hex chars). Tests in config_test.go extend the existing table.
Placement mapping
| Placement field | KVM meaning |
|---|---|
zone |
host name from hosts; empty → the single configured host, else ErrPermanent |
machineType |
key in machineTypes; unknown → ErrPermanent |
image |
key in images:, or an https:// URL; anything else → ErrPermanent. Fetched into the host's pool on first use (see Image cache) |
region |
ignored |
providerID
hosts/<host>/domains/<name> with formatProviderID/parseProviderID
(same self-contained style as GCP's zones/<zone>/instances/<name>).
GC contract without labels
Domain XML gets a <metadata> child in a private namespace, e.g.
xmlns="https://gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/kvm",
carrying <managed>true</managed><uid>…</uid><createdAt>RFC3339</createdAt>.
Get reads it via DomainGetMetadata(dom, DomainMetadataElement, uri);
ListByTag does ConnectListAllDomains on every host and keeps domains where
that call succeeds and managed == "true". CreatedAt comes from the metadata
(libvirt has no creation time). A host that is unreachable makes ListByTag
return an error (GC skips the sweep that round rather than acting on a partial view).
Connection management (internal/provider/kvm/conn.go)
Per-host lazily dialed *ssh.Client + *libvirt.Libvirt, cached under a
mutex, re-dialed when Disconnected() is closed or a call fails with a
transport error. SSH auth: private key from sshKeyFile; host key check via
knownhosts.New(knownHostsFile) unless insecureIgnoreHostKey. Dial timeout
from ctx. The provider calls libvirt through a narrow interface:
type libvirtAPI interface {
DomainLookupByName(name string) (libvirt.Domain, error)
DomainDefineXML(xml string) (libvirt.Domain, error)
DomainCreate(d libvirt.Domain) error
DomainDestroy(d libvirt.Domain) error
DomainUndefineFlags(d libvirt.Domain, f libvirt.DomainUndefineFlagsValues) error
DomainGetState(d libvirt.Domain, flags uint32) (int32, int32, error)
DomainGetMetadata(d libvirt.Domain, typ int32, uri libvirt.OptString, f libvirt.DomainModificationImpact) (string, error)
DomainInterfaceAddresses(d libvirt.Domain, src uint32, flags uint32) ([]libvirt.DomainInterface, error)
ConnectListAllDomains(need int32, f libvirt.ConnectListAllDomainsFlags) ([]libvirt.Domain, uint32, error)
StoragePoolLookupByName(name string) (libvirt.StoragePool, error)
StorageVolLookupByName(p libvirt.StoragePool, name string) (libvirt.StorageVol, error)
StorageVolGetPath(v libvirt.StorageVol) (string, error)
StorageVolCreateXML(p libvirt.StoragePool, xml string, f libvirt.StorageVolCreateFlags) (libvirt.StorageVol, error)
StorageVolUpload(v libvirt.StorageVol, r io.Reader, off, n uint64, f libvirt.StorageVolUploadFlags) error
StorageVolDownload(v libvirt.StorageVol, w io.Writer, off, n uint64, f libvirt.StorageVolDownloadFlags) error
StorageVolDelete(v libvirt.StorageVol, f libvirt.StorageVolDeleteFlags) error
StoragePoolRefresh(p libvirt.StoragePool, flags uint32) error
}
(All signatures verified against go-libvirt main on 2026-09-04.) A
hostDialer func(ctx, KVMHost) (libvirtAPI, io.Closer, error) is the test seam;
newWithDialer(cfg, dialer) mirrors GCP's newWithAPI.
Image cache (internal/provider/kvm/image.go)
ensureImage(ctx, host, api, img) (basePath string, err error) makes the base
image present in the host's pool, once per host per image:
- Volume names:
base-<key>.<format>for animages:key, orbase-<sha256(url)[:16]>.<format>for a URL given directly inplacement.image. Marker volume<base>.sha256(raw, 64 bytes) holds the digest of the verified upload. - Ready check: base volume exists and marker exists and (no
sha256configured, or marker content == configuredsha256). Read the marker withStorageVolDownload. Ready → returnStorageVolGetPath(base). - Not ready: delete both volumes if present (partial upload from a crash,
or a changed checksum), then
GET urlwith the ctx, require aContent-Length(ErrPermanentotherwise),StorageVolCreateXMLthe base with<capacity unit="bytes">Content-Length</capacity>and the configured format,StorageVolUpload(base, io.TeeReader(body, sha256), 0, 0, 0)(length 0 = until EOF, so nothing is buffered in the operator), then compare the digest with the configured one (mismatch → delete base,ErrPermanent),StoragePoolRefreshso libvirt re-reads the real qcow2 capacity, create the marker volume and upload the digest last. - HTTP 4xx →
ErrPermanent; 5xx, network, or upload transport errors →ErrTransient(partial volumes are cleaned on the next attempt). - A per-
(host, image)sync.Mutexmap serialises concurrent Creates for the same image on the same host; the second caller finds it ready. - Judgment call: the fetch runs synchronously inside
Create, so the first Proxy for a given image on a given host holds a reconcile worker for the download (LAN speed, a few hundred MB, once). This keepsCreateidempotent and needs no background state. Async fetch with aProvisioning-style progress status is a possible later improvement, noted in the docs.
Create (idempotent, fire-and-forget after the image is cached)
- Resolve host, machine type, pool, and image source (
images:key or URL; elseErrPermanent).ensureImage→ base path. - Overlay volume
<name>.qcow2:StorageVolCreateXMLwith<capacity unit="G">diskSizeGb</capacity>,<target><format type="qcow2"/></target>,<backingStore><path>base</path><format type="qcow2"/></backingStore>.ErrStorageVolExist→ reuse. - Seed volume
<name>-seed.iso: build ISO in memory (user-data= req.CloudInit,meta-data=instance-id: <name>\nlocal-hostname: <name>\n,vendor-data= cfg or default),StorageVolCreateXMLwith exact capacity +<format type="raw"/>, thenStorageVolUpload. Exists → skip upload (content is deterministic per spec hash; spec changes recreate the whole instance anyway). DomainLookupByName(name); ifErrNoDomain→DomainDefineXML(buildDomainXML(...)).DomainGetState; if not running →DomainCreate. Return providerID.
buildDomainXML is a pure function over encoding/xml structs: type="kvm",
q35 machine, host-passthrough CPU, vcpus/memory from the profile, virtio disk
(overlay) + sata cdrom (seed), interface type="bridge" with virtio model,
org.qemu.guest_agent.0 virtio channel, serial console, virtio rng, no
graphics, and the <metadata> block. Unit-tested field by field like
buildInsertRequest.
Get
parseProviderID → host → DomainLookupByName (ErrNoDomain → ErrNotFound)
→ DomainGetState + DomainGetMetadata + DomainInterfaceAddresses
(ARP, then agent; first IPv4 wins; agent errors while the agent isn't up yet
are swallowed). Mapping:
| libvirt state | IP | Instance.State |
|---|---|---|
| running | yes | Running |
| running | no | Provisioning |
| shutoff / crashed / paused / pmsuspended | – | Stopped |
| no domain | – | ErrNotFound |
Delete (idempotent)
Destroy if running (ErrOperationInvalid on a stopped domain is fine) →
DomainUndefineFlags(managed-save | snapshots-metadata | nvram) → delete
<name>.qcow2 and <name>-seed.iso (ErrNoStorageVol fine). Volumes are
deleted by name even when the domain is already gone, so a crash between
volume creation and define still gets cleaned up on the next reconcile.
Known gap (documented, not solved now): volumes orphaned with no domain and
no CR are invisible to ListByTag.
Errors (internal/provider/kvm/errors.go)
classify(err) on libvirt.Error.Code: ErrNoDomain, ErrNoStorageVol →
ErrNotFound; ErrNoStoragePool, ErrXMLError, ErrConfigUnsupported,
ErrNoSupport, ErrAuthFailed, ErrInvalidArg → ErrPermanent; SSH dial /
host-key / transport errors and everything else → ErrTransient. Image
download: HTTP 4xx, missing Content-Length, checksum mismatch → ErrPermanent;
5xx and network errors → ErrTransient. Quota is not detectable from libvirt;
left as transient (revisit with a capacity check later).
Wiring & manifests
cmd/main.go: add"kvm": kvm.Newto the builtins map.config/manager/manager.yaml: mount Secretkvm-ssh(optional) at/etc/proxy-operator/kvm(keysid_ed25519,known_hosts); document thekubectl create secret generic kvm-ssh --from-file=...command.config/samples/providers-config.yaml: add thekvm-homeblock with theimages:map (Debian 13 and Ubuntu 24.04 official cloud-image URLs);config/samples/proxy_kvm.yaml: Proxy withplacement: {zone: nas1, machineType: small, image: debian-13}and the same squid cloud-init as the GCP sample plusssh_authorized_keys.hack/providers-dev.yamlstays kubernetes-only (needs no secrets).- Deps:
github.com/digitalocean/go-libvirt(latest pseudo-version),github.com/kdomanski/iso9660 v0.4.0, promotegolang.org/x/cryptoto direct.
Docs
- New
docs/kvm-provider.md: hypervisor prerequisites (user inlibvirtgroup, a bridge to the LAN, a storage pool with enough space), the config reference includingimages:, how the image cache works (base-*and*.sha256volumes in the pool, when a re-download happens, how to evict withvirsh vol-delete), the placement table, how to log into a VM (ssh_authorized_keysin user-data,virsh consoleon the host), and the known gaps (no quota detection, orphan volumes without a domain, synchronous first-time image fetch). - Update the provider tables in
README.md,docs/architecture.md§5,docs/testing.md(integration test section).
Towards the generic runner (not built now, just kept open)
Provider knows nothing about squid. Future hooks: vendor-data for injecting an
agent, QEMUDomainAgentCommand for reading exit status/output, and a future
Placement-independent "job" mode in the CRD. None of this changes the KVM
provider's shape.
Steps
- Save plan + branch. Copy this file to
docs/plans/<ts>-kvm-provider.md, commit alone, thengit checkout -b feat/kvm-providerofffeat/proxy-operator(the working main for this project). - Config.
KVMConfig& friends,validate()case, table tests. - Pure builders.
internal/provider/kvm/{domainxml.go,volume.go,seed.go,id.go,metadata.go}- tests: domain XML fields, volume XML, seed contents (unpack the ISO with
the same library's reader and assert the three files + label
cidata), providerID round-trip, metadata marshal/parse.
- tests: domain XML fields, volume XML, seed contents (unpack the ISO with
the same library's reader and assert the three files + label
- Transport + errors.
conn.go(SSH dial, host-key policy, per-host cache, reconnect),errors.go+ classification table test. SSH dialer tested against an in-processx/crypto/sshserver that serves a unix socket forward (cheap, keeps the dial code covered). 3b. Image cache.image.go+image_test.go:httptest.Serverserving a small fake image over thefakeAPI(which records uploaded bytes); cases: fresh fetch, already ready, marker missing (partial upload → redo), checksum changed → redo, checksum mismatch → Permanent + cleanup, 404 → Permanent, 503 → Transient, two concurrent callers → one download. - Provider methods.
kvm.go:New,newWithDialer,Create/Get/Delete/ListByTagoverlibvirtAPI;kvm_test.gowith a hand-writtenfakeAPI(domains and volumes in maps, per-call error injection) covering idempotent re-Create after each partial step, state/IP mapping, delete-when-gone, ListByTag across two hosts and with one host down. - Wiring, manifests, samples, docs, CHANGELOG.
- Integration test (
//go:build integration,kvm_integration_test.go): skipped unlessKVM_TEST_HOST,KVM_TEST_USER,KVM_TEST_SSH_KEY,KVM_TEST_BRIDGE,KVM_TEST_IMAGE_URLare set; fetches the image (or finds it cached), creates, waits for Running+IP, deletes, asserts per-VM volumes gone and the base image still cached. Deviation from the testcontainers convention, called out indocs/testing.md: libvirt+KVM can't run inside a container without/dev/kvm, so this targets a real hypervisor. - Manual validation on one real hypervisor, results in
docs/plans-executions/(likedocs/gcp-vm-validation.md), then MR viatea.
Verification
make testgreen (-race, envtest suite untouched but must still pass).go test -short ./internal/provider/...covers builders, fake-API provider, errors, config.- Integration:
KVM_TEST_HOST=nas1.home.lan … go test -tags=integration -run TestKVM ./internal/provider/kvm/. - End to end: deploy with the
kvm-sshSecret andkvm-homeconfig against a host with no cached image, applyconfig/samples/proxy_kvm.yaml, watch the operator log the image fetch andvirsh vol-listshowbase-debian-13.qcow2plus its.sha256marker, watchkubectl get px -wreachReadywith a LAN IP, apply a second Proxy and confirm no second download, confirm the VM shows up inGET /v1/proxiesand works withhack/demo egress-IP check, delete the Proxy and confirmvirsh list --allandvirsh vol-list defaultare clean.