Files
egress-proxies-operator/docs/kvm2-provider-spec.md
kacerrandClaude Opus 5 c10f98a352 kvm2: name the cached base image after the provider entry (H4)
baseVolumeName was base-<key>.<format>, byte-identical to what the kvm
provider builds in the same pool -- for URL images too, since kvm takes
hex(sum[:8]) and kvm2 takes hex(sum[:])[:16], the same eight bytes -- with
a completely separate lock table. The shipped config has a live kvm-home
entry and a commented-out kvm2-home entry on the same host, the same key
debian-13 and the same default pool, and the comment invites the operator
to enable it. Both integration tests use key test-image. On beelink both
live overlays back onto kvm's base-debian-13.qcow2.

Since cachedImage accepts any 64-hex marker when no sha256 is configured
and never re-hashes the base, that meant two entries pointing one key at
different URLs silently shared a file; a concurrent cold fetch let one
entry delete the other's half-uploaded base and boot a guest from the
remains; and a sha256 edit deleted the base in place under live overlays,
which libvirt permits without complaint.

The name now carries the provider entry: base-<entry>-<key>.<format>.
imageSource gained an Entry field and resolveImage takes the provider
name, so every production call site follows from imageSource.volumeName().
That closes the cross-entry sharing outright, kvm<->kvm2 and kvm2<->kvm2
alike, and turns the per-Provider imageLocks from a latent bug into a
correct one: with names unique per entry, two entries cannot contend for a
file, so no process-wide lock table is needed.

The entry name now reaches a file name, so New checks it against the
existing kvmIdentifier rule, exported as provider.IsKVMIdentifier rather
than duplicated. Provider names are otherwise validated for all types at
once and only for being non-empty and unique, so a name with a slash would
have produced a volume nothing could address.

One companion fix, because the naming alone does not stop it:
cachedImage's digest-mismatch branch deleted the marker and the base and
refetched. It now returns ErrPermanent naming both digests and telling the
operator what to do. That branch was the remaining path that unlinks a
good file under running domains, and the contract already said "add a new
key instead" -- the code was contradicting it. The no-marker and
non-hex-marker branches keep deleting, since there the base is debris.

Not fixed, and said so in the docs rather than left to look closed:
editing images.<key>.url is still a silent no-op, since nothing records
the URL; and cross-process fencing still needs the deferred atomic publish
(upload to a temp name, verify, publish), which lands where H2 put the
discard.

Migration is a non-event. The base-debian-13.qcow2 on beelink is kvm's,
kvm keeps using it untouched, and kvm2 downloads its own copy once per
host. Nothing is replaced under a running guest; the pool needs room for
both, which the prerequisites now say.

The eviction recipe in the guide named base-debian-13.qcow2, which after
this change is kvm's file -- following it would have deleted the wrong
provider's base. It now greps for the prefix and deletes the marker first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:57:02 +00:00

666 lines
37 KiB
Markdown

# Provider spec: kvm2
Filled from [provider-spec-template.md](provider-spec-template.md) against
[provider-contract.md](provider-contract.md). This is the design the `kvm2`
package is built from. Where an entry names an axis in backticks, the answer is
one of that axis's profiles in contract §4. Nothing in this spec was taken from
the code of the existing `kvm` provider; backend facts come from
[kvm-provider.md](kvm-provider.md), the execution log of the first build, the
go-libvirt module in the module cache, and the beelink host.
## Header
- **Type**: `kvm2`
- **Example configured name**: `kvm2-home`
- **Backend and API version this spec was written against**: libvirt 11.6.0 and
QEMU 10.1.0 on Ubuntu (host `beelink`), reached with go-libvirt
`github.com/digitalocean/go-libvirt v0.0.0-20260814190004-1a83157e1858` over an
SSH-forwarded unix socket.
- **Client**: `rpc-over-ssh`. go-libvirt's RPC client (`libvirt.New(conn)`
then `Connect()`) over a `direct-streamlocal` channel opened with
`golang.org/x/crypto/ssh` to the host's libvirt socket. Both modules are
already direct requires in `go.mod`; no new dependency.
- **Author and date**: Claude (orchestrator session), 2026-09-16.
## 1. Identity and providerID
**Answer:** `hosts/<host>/domains/<name>`, where `<host>` is `hosts[].name`
from the config block and `<name>` is `req.Name`. Parsing is strict: exactly
four segments, literal `hosts` and `domains`, non-empty host and name, no
further slashes; anything else is `ErrPermanent` from `Get` and `Delete`. Host
names are validated at config load by the shared `kvmIdentifier` regexp in
`internal/provider/config_kvm.go` (letters, digits, dots, dashes, underscores),
so a host name can never contain the separator. Instances do not migrate
between hosts (libvirt domains are per host), so the location belongs in the
ID.
**Evidence:** contract §6; `kvmIdentifier` in `internal/provider/config_kvm.go`.
## 2. Primary key (`primary-key`)
**Answer:** `name-is-key`. libvirt addresses domains by name
(`DomainLookupByName`); `req.Name` is used verbatim as the domain name and as
the base of the per-VM volume names. Create looks the name up first and adopts
an existing domain only if it carries the kvm2 mark (`managed` true, entry 8)
and its metadata `uid` is empty or equals `req.UID`. A domain without the
mark returns `ErrPermanent` saying it was not created by this provider: kvm2
never starts or takes over a VM someone else defined under that name. A
different non-empty `uid` returns `ErrPermanent` naming both UIDs. This
closes the contract's "adoption is not verified" limit for this provider.
**Evidence:** `DomainLookupByName(Name string) (Domain, error)` in the
go-libvirt module; contract §2.1 idempotency and §13.
## 3. Placement mapping
**Answer:** three fields are catalogue keys checked before any backend call, one is ignored:
| Field | Meaning | Required | Unknown value |
|---|---|---|---|
| `Zone` | a `hosts[].name` | only when more than one host is configured; with exactly one host an empty zone selects it | `ErrPermanent`: `placement.zone "x" is not in kvm2.hosts` |
| `MachineType` | a key of `machineTypes` (`vcpus`, `memoryMiB`) | yes | `ErrPermanent`: `placement.machineType "x" is not in kvm2.machineTypes` |
| `Image` | a key of `images`, or an absolute http(s) URL used directly | yes | `ErrPermanent`: `placement.image "x" is neither a key in kvm2.images nor an http(s) URL` |
| `Region` | ignored | | still part of the spec hash: changing it replaces the VM |
All three checks run before any backend call. The guide documents the ignored
field and that catalogue edits affect only new VMs.
**Evidence:** contract §2.1 (reject early, MAY ignore with documentation);
`IsHTTPURL` in `internal/provider/config_kvm.go`.
## 4. Image source
**Answer:** a catalogue in the config block (`images.<key>.url`, optional
`sha256`, `format` qcow2 or raw, `firmware` uefi or bios) or a URL given
directly in `placement.image`. The provider downloads the image into the
host's storage pool on first use and caches it there as a base volume named
`base-<entry>-<key>.<format>` (for a URL: `<key>` is the first 16 hex of
sha256(url)), where `<entry>` is the provider entry's name, with a sidecar
marker volume `base-<entry>-<key>.<format>.sha256` holding the digest of
the verified upload, written last. The entry is in the name because the `kvm`
provider builds `base-<key>.<format>` in the same pool from the same catalogue
shape, and two entries pointing one key at different URLs would otherwise share
a file; the cost is one copy per entry per host, and the entry's name is checked
against `kvmIdentifier` at construction. A base volume without a marker is
incomplete and is deleted and redone. When `sha256` is configured and differs
from the marker's content, the image is **not** replaced: that is `ErrPermanent`
naming both digests, because overlays back onto the base file and libvirt
unlinks it under running domains without complaint. The download streams
from the URL through the operator into `StorageVolUpload` with a running
sha256; nothing is buffered. `Content-Length` is required because the volume's
capacity must be declared before the upload; a response without it is
`ErrPermanent` naming the URL. Per `(host, volume)` a keyed mutex makes
concurrent Creates download once. The download runs on a dedicated
connection to the host, not the pooled one, so a cancelled poll on another
Proxy cannot cut it off. After a successful upload `StoragePoolRefresh` makes
the file usable as a backing image.
Catalogue edit warning for the guide: overlays back onto the base file, so
evicting a base volume while Proxies use it corrupts those VMs. A `sha256`
change is refused for that reason; a `url` change is a silent no-op, since the
cache is keyed by entry and key. Add a new key for either.
**Evidence:** kvm-provider.md "The image cache" and "Known gaps"; execution log
of 2026-09-04 (raw qcow2 upload plus pool refresh accepted by libvirt 11.6;
435 MB trixie image); contract §1.4 (stream, no buffering) and §2.1
(catalogue changes).
## 5. Cloud-init delivery
**Answer:** a NoCloud seed ISO built in-process with
`github.com/kdomanski/iso9660`, volume label `cidata`, files `user-data` (the
Proxy's `CloudInit`, byte for byte, never parsed), `meta-data`
(`instance-id: <name>` and `local-hostname: <name>`, so a reboot is never taken
for a new instance) and `vendor-data`. The ISO is written into a temp
directory created under the default temp dir (`/tmp`, a 64Mi emptyDir in the
manager), uploaded as a raw volume `<name>-seed.iso` and attached as a CD-ROM.
The seed is rebuilt on every Create attempt: a previous attempt may have been
cut off mid-upload, and a truncated seed would boot a VM without cloud-init.
`vendor-data` carries a shell script that installs and starts
`qemu-guest-agent` with whichever of apt-get, dnf, yum, zypper or apk the image
has. It is a script and not a cloud-config on purpose: cloud-init merges a
vendor-data cloud-config under the user-data with mergers that keep the
user-data's value for every top-level key both define and never append lists,
so a Proxy with its own `packages:` would silently lose the agent. Scripts
bypass the merge. When the config's `vendorData` is set, the two travel as
separate parts of a MIME multipart (`text/plain` for the configured document,
`text/x-shellscript` for the agent script), headed by `MIME-Version` within
the first bytes. With `installGuestAgent: false` and no `vendorData` the file
is empty. Size: cloud-init has no practical limit for NoCloud; the ISO is a
few hundred KB.
**Evidence:** kvm-provider.md "What lands on the host"; execution log run 7
(vendor-data as YAML silently dropped) and run 8 (script survives); contract
§2.1 (byte for byte, never logged) and §11 (sentinel test).
## 6. Machine type mapping
**Answer:** a catalogue: `machineTypes.<key>` is `{vcpus, memoryMiB}`,
validated at load (both greater than zero, at least one profile). Disk size is
provider-wide: `diskSizeGb` (default 10) is the capacity of every VM's overlay
volume, thin-provisioned; cloud-init's growpart expands the root filesystem
into it.
**Evidence:** `KVMMachineType` and `validateKVM` in
`internal/provider/config_kvm.go`.
## 7. Network and IP discovery (`readiness`, `ip-source`)
**Answer:** `readiness`: `provisioning-window` (boot plus cloud-init, plus the
image download on a cold pool). `ip-source`: `discovered`. The VM gets one
virtio NIC on `hosts[].bridge`, which must lead to a LAN with DHCP that the
cluster can route to. The address comes from `DomainInterfaceAddresses`, guest
agent source first, then the ARP table (on beelink the agent answered about
20 s after start where ARP never did, because the host itself does not talk to
the guest). The first IPv4 that is not loopback, link-local or unspecified is
reported. One source failing means "no address yet" and `Get` returns
`StateProvisioning`, which is the ordinary boot on beelink: the agent is not up
while ARP answers with nothing. A connection already gone, or every source
failing, is `ErrTransient` instead — a running VM whose address cannot be read
is not a VM without an address, and reporting it as one would make the
reconciler clear its IP and the health engine drop its counters. The guest needs the agent: it is installed by the
vendor-data script (entry 5) unless `installGuestAgent: false`, which needs an
image that ships it or a network where ARP sees guest traffic. `Port` is not
used by the provider; nothing on the host filters it. Reachability is a
prerequisite in the guide: the operator's pod network and the clients must
route to the bridge's LAN.
**Evidence:** kvm-provider.md "Hypervisor prerequisites" and "Known gaps"
(IP discovery); execution log run 6 (agent 20 s, ARP never); contract §1.3
and §2.2 (Running only with IP).
## 8. GC marks and CreatedAt (`gc-mark`, `created-at`)
**Answer:** `gc-mark`: `written-metadata`. `created-at`: `written-at-create`.
The domain XML passed to `DomainDefineXML` carries, under `<metadata>`, an
element `<instance xmlns="https://gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/kvm2">`
with children `<managed>true</managed>`, `<uid>req.UID</uid>` and
`<createdAt>RFC3339 UTC</createdAt>`. The mark lands in the same call that
defines the domain, so there is no mark window. Read back with
`DomainGetMetadata(dom, DomainMetadataElement, uri, DomainAffectCurrent)`;
libvirt 11.6 returns the element without its namespace declaration, so the
parser matches on the local name `instance` only (the URI was already used to
select it). A domain without the element is not managed and reports an empty
UID. A `createdAt` that does not parse gives the zero time, which GC treats as
old; that is accepted. The volumes carry no mark (documented GC blind spot,
entry 24). The namespace is kvm2's own, so kvm2 never lists or adopts domains
made by the `kvm` provider; the two types may share a host.
**Evidence:** execution log run 3 (namespace asymmetry);
`DomainGetMetadata(Dom Domain, Type int32, Uri OptString, Flags DomainModificationImpact)`
in the go-libvirt module; contract §2.1 (mark in the create call) and §4.5.
## 9. State mapping (`provisioning-state`)
**Answer:** `provisioning-state`: `create-blocks`. Create defines and starts the
domain in one call and returns only once `DomainGetState` reports running, so
`Get` never sees a domain kvm2 defined but did not start. A domain that is
shut off later stopped on its own or was stopped by someone, and Stopped is
the right answer.
| Backend state (`DomainGetState`) | Lock or task | Provider state |
|---|---|---|
| nostate (0) | none | Provisioning (libvirt could not determine it; next poll decides) |
| running (1), blocked (2) | no IP found | Provisioning |
| running (1), blocked (2) | IP found | Running |
| paused (3), shutdown (4), shutoff (5), crashed (6), pmsuspended (7) | none | Stopped |
| any other code | none | Stopped |
libvirt has no locks or tasks; every RPC is synchronous.
**Evidence:** go-libvirt `DomainState` constants; contract §2.2 and §4.11.
## 10. Create: blocking and task tracking (`create-return`, `async-ops`)
**Answer:** `create-return`: `blocking`. `async-ops`: `none`. Create returns
when the domain is running, holding one reconcile worker for the duration,
including the first image download on a host (minutes on a cold pool; the
guide says so). The wait after `DomainCreate` is capped at 15 seconds:
`DomainCreate` is synchronous in libvirt, so the first `DomainGetState`
normally answers running and the cap only covers state settling. Two rules
keep the wait from pinning a worker: a state after the start that is neither
active nor `nostate` (shutoff, crashed, paused, pmsuspended, shutdown) is
`ErrTransient` at once, since polling will not bring that guest up; and a
domain found paused or pmsuspended before the start is `ErrPermanent`
(`cannot be started by the operator; resume or destroy it on the host`), an
admin's doing that `DomainCreate` would refuse and the reconciler must not
retry. Every RPC has its own deadline (entry 18).
Steps, each idempotent, in order, all under the per-name lock (entry 19):
1. Validate placement (entry 3). No backend call yet.
2. `DomainLookupByName`. Found: adoption guard (entry 2), then start it if it
is not running (paused or pmsuspended is `ErrPermanent`; `DomainCreate`;
`ErrOperationInvalid` means already running), poll as in step 9, return
the ID.
3. Pool space precheck (entry 13, quota).
4. Ensure the base image (entry 4): marker present and matching means done.
5. Overlay volume `<name>.qcow2`, qcow2 backed by the base file with
`diskSizeGb` capacity; `ErrStorageVolExist` means reuse (creation is
atomic, so an existing overlay is complete).
6. Seed volume `<name>-seed.iso`: delete if it exists, create, upload
(entry 5). Always rebuilt.
7. `StorageVolGetPath` for the overlay and the seed.
8. `DomainDefineXML` with file-type disks (entry 15's AppArmor note), the
metadata element, firmware per image.
9. `DomainCreate`, then poll `DomainGetState` until running: `nostate` is
polled again, any other inactive state is `ErrTransient` at once, and the
15-second cap is `ErrTransient` too.
10. Return `hosts/<host>/domains/<name>`.
A retry after a crash finds whatever the previous attempt got to: an existing
overlay is reused, a seed is rebuilt, a defined domain is started, a running
domain is returned. `ErrNoDomain` and `ErrNoStorageVol` inside Create (a
volume vanishing between steps) are `ErrTransient`, never `ErrNotFound`. The
log line for a Create records `cloudInitBytes`, never the content.
**Evidence:** contract §2.1, §4.2, §5 ("do not return NotFound from Create");
execution log run 1 (AppArmor).
## 11. Delete and secondary resources (`delete-return`, `secondary-resources`)
**Answer:** `delete-return`: `waits`. `secondary-resources`: `explicit`. Under
the per-name lock:
1. Parse the ID (`ErrPermanent` if malformed). A host no longer in the config
is a logged no-op success: the provider cannot reach it and refusing would
pin the finalizer; the guide tells admins to delete Proxies before retiring
a host.
2. `DomainLookupByName`; `ErrNoDomain` means the domain is already gone, skip
to step 5.
3. `DomainDestroy`; `ErrOperationInvalid` (not running) and `ErrNoDomain` are
success.
4. `DomainUndefineFlags` with `DomainUndefineNvram`,
`DomainUndefineManagedSave` and `DomainUndefineSnapshotsMetadata`
(UEFI domains keep NVRAM; undefine refuses without the flag); `ErrNoDomain`
is success.
5. Delete `<name>.qcow2` and `<name>-seed.iso` by name from the host's pool;
`ErrNoStorageVol` is success, and `ErrNoStoragePool` means nothing is left
to clean.
6. Return nil. The base image and its marker stay (shared cache).
Everything is gone when Delete returns nil, so `Get` afterwards is NotFound.
A second Delete during the first waits on the lock and then finds nothing.
Volumes that exist without a domain (a crash between step 5 and 8 of Create)
are removed by the same Proxy's next Delete but are invisible to GC (entry 24).
**Evidence:** kvm-provider.md "Known gaps"; execution log run 4 (NVRAM);
contract §2.3.
## 12. ListByTag scope and partial failure (`list-partial`)
**Answer:** `fail-whole`, per host, with single unreadable domains skipped.
Scope: every host in `hosts`, in config order, `ConnectListAllDomains` with
active and inactive domains. For each domain, read the metadata element; keep
those with `managed` true and build the `Instance` from the domain name and
the element alone: `ID`, `UID` and `CreatedAt`, which is all GC reads
(contract §2.4). `State` and `IP` are left empty; no state or address RPC is
made. A domain whose metadata cannot be read (mid teardown, corrupt element)
is skipped with a V(1) line, unless ctx ended, which fails the call; a host
that cannot be dialled or whose listing fails fails the whole call, so GC
skips this provider for the sweep rather than act on a partial view.
Filtering is client-side; libvirt has no label index. Cost: exactly one
`DomainGetMetadata` per domain per sweep, about half a millisecond each over
the tunnel.
**Evidence:** execution log run 6 (per-domain metadata cost); contract §2.4
and §4.7.
## 13. Error classification (`not-found`, `quota`)
**Answer:** `not-found`: `error-code`. `quota`: `detected`, from a pool-space
precheck rather than a backend error: before creating any volume, Create reads
`StoragePoolGetInfo` and compares `available` with the bytes it is about to
commit: the image's `Content-Length` when the base is not cached, plus
`diskSizeGb` in bytes, plus 16 MiB for the seed. Below that it returns
`ErrQuotaExceeded` naming the pool, available and needed, and the reconciler
waits `QuotaRetry`.
One `classify(op string, err error)`:
| Backend response | Class | Why |
|---|---|---|
| `libvirt.Error` code `ErrNoDomain`, `ErrNoStorageVol` | NotFound (Transient when `op == "create"`) | the object is gone; inside Create it vanished mid-call |
| `ErrNoStoragePool` | Permanent | config names a pool that does not exist |
| `ErrXMLError`, `ErrNoSupport`, `ErrInvalidArg` | Permanent | the request itself is wrong |
| `ErrAuthFailed`, `ErrAccessDenied` | Permanent | the SSH user may not use the socket |
| `ErrOperationInvalid` | Transient (Delete and Create treat the specific cases as success before classify) | state race; the next poll decides |
| any other `libvirt.Error` | Transient | unknown means retry |
| SSH: `ssh: unable to authenticate` (from `x/crypto/ssh`) | Permanent | the key is not in `authorized_keys` |
| SSH: `knownhosts.KeyError` with a known key that differs, or with no known key | Permanent | host key changed or missing from known_hosts; the admin must act |
| SSH key or known_hosts file missing (`fs.ErrNotExist`) | Transient | the Secret has not landed yet |
| SSH key present but does not parse | Permanent | wrong file in the Secret |
| `context.Canceled`, `context.DeadlineExceeded`, `net.OpError`, `io.EOF`, `io.ErrUnexpectedEOF` | Transient | network or cancellation |
| HTTP 404, 403, 410 on the image URL; missing `Content-Length`; sha256 mismatch | Permanent | the catalogue entry is wrong |
| HTTP 5xx, 429, network errors on the image URL | Transient | retry |
| an error already wrapped with a class (`errors.Is` any sentinel) | that class | validation and builders classify at the source |
| anything else | Transient | contract default |
The unit test table uses a `libvirt.Error` per code, the real SSH error
strings produced by `x/crypto/ssh` against the in-process server, and a real
`knownhosts.KeyError`.
**Evidence:** go-libvirt `ErrorNumber` constants and `Error{Code uint32, Message}`
(value receiver, so `errors.As` uses a `libvirt.Error` value); contract §5;
kvm-provider.md "Known gaps" (SSH auth retried forever in v1: closed here).
## 14. Credentials and TLS (`credentials`)
**Answer:** `mounted-files`. The config block names `sshKeyFile` (private key,
OpenSSH or PEM, parsed with `ssh.ParsePrivateKey`) and `knownHostsFile`
(OpenSSH `known_hosts`, parsed with `knownhosts.New`), both expected under the
existing optional `kvm-ssh` Secret mount at `/etc/proxy-operator/kvm`
(`config/manager/manager.yaml`; the mount is shared with the `kvm` provider and
its comment says so). `insecureIgnoreHostKey: true` replaces known_hosts with
`ssh.InsecureIgnoreHostKey()` and is logged as a warning by `New`. Both files
are read on every dial, so rotating the Secret takes effect on the next
connection without a restart. `New` tries to read them once to log a warning
when they are unusable, but never fails on them and never dials.
Minimum privilege: the SSH user must be able to open the libvirt socket
(member of the `libvirt` group); libvirt itself is used as `qemu:///system`
through the socket, so no further host permissions.
TLS: none (SSH provides transport security); `system-roots` for the image
download's HTTPS, which uses the distroless image's CA bundle.
Hygiene: the private key bytes and the known_hosts content never appear in
logs, spans or error strings; errors from parsing say "parsing ssh key file
<path>" plus the parser's message. Tested with a sentinel placed in a bad key
file.
**Evidence:** kvm-provider.md "Cluster-side setup"; execution log run 8
(lazy load: a missing Secret must not abort the provider set); contract §7.
## 15. Config block
**Answer:** the block is `kvm2:` and reuses the Go type `provider.KVMConfig`
from `internal/provider/config_kvm.go` unchanged, so an existing `kvm-home`
entry ports by changing `type: kvm` to `type: kvm2` and `kvm:` to `kvm2:`.
Validation calls the shared validator with the block name `kvm2` so messages
read `kvm2.hosts` and not `kvm.hosts`.
| Field | Required | Default | Validation | Ends up in |
|---|---|---|---|---|
| `sshKeyFile` | yes | | non-empty | dial |
| `knownHostsFile` | unless `insecureIgnoreHostKey` | | non-empty | dial |
| `insecureIgnoreHostKey` | | false | | dial, warning at `New` |
| `diskSizeGb` | | 10 | not negative | overlay capacity, quota precheck |
| `vendorData` | | | | seed `vendor-data` |
| `installGuestAgent` | | true | | seed `vendor-data` |
| `images.<key>` | | | key matches `kvmIdentifier` | volume name `base-<entry>-<key>.<format>` |
| `images.<key>.url` | yes per entry | | http(s) URL | download |
| `images.<key>.sha256` | | | 64 hex | marker comparison |
| `images.<key>.format` | | qcow2 | qcow2 or raw | base volume XML, overlay backing format |
| `images.<key>.firmware` | | uefi | uefi or bios | domain XML `<os>` |
| `machineTypes.<key>` | at least one | | vcpus and memoryMiB greater than zero | domain XML |
| `hosts[].name` | yes | | unique, matches `kvmIdentifier` | providerID segment |
| `hosts[].address` | yes | | non-empty `host:port` | TCP dial |
| `hosts[].user` | yes | | non-empty | SSH |
| `hosts[].socket` | | `/var/run/libvirt/libvirt-sock` | | SSH channel target |
| `hosts[].pool` | | `default` | | every storage RPC |
| `hosts[].bridge` | yes | | non-empty | domain XML NIC |
Wiring: `ProviderConfig.KVM2 *KVMConfig` with tag `kvm2,omitempty`;
`setBlocks` reports `kvm2`; the allow-list in `Config.validate` and the
constructor map in `cmd/main.go` both gain `kvm2` in the same commit.
**Evidence:** `KVMConfig`, `validateKVM`, `kvmIdentifier` in
`internal/provider/config_kvm.go`; contract §10.
## 16. Checker
**Answer:** one target per `hosts[]` entry, `Name` the host name, `Address`
the SSH address, checked concurrently in config order on a fresh dedicated
connection each time (dial, `ConnectGetLibVersion`, `ConnectGetHostname`,
`StoragePoolLookupByName`, `StoragePoolGetInfo`, close). It never touches the
pooled connection, so a check that runs out of time closes only its own
connection and cannot disrupt a reconcile. Cost: one SSH handshake and four
cheap RPCs per host per round.
Reasons: `Reachable`; `Timeout` (the round's ctx ended before the check
finished, whichever stage was running, the dial included, or an RPC's own
budget ran out once the connection was up); `Unreachable` (TCP connect or the
SSH transport failed, including on the dial's own 10 s budget while the round
still had time, or the link died mid-check); `Unauthorized` (SSH authentication refused, host key unknown or
changed); `Forbidden` (`ErrAccessDenied` or `ErrAuthFailed` from libvirt: the
socket refused the user); `APIError` (any other libvirt error);
`CredentialsUnavailable` (the key or known_hosts file is missing or
unparsable, i.e. the Secret is absent or wrong: fixed in the cluster, not on
the host); `PoolNotFound`; `PoolNotActive`.
Details on success: `user`, `hostname`, `libvirtVersion` (decoded
major.minor.micro), `pool`, `poolState`, `poolCapacityGiB`, `poolAvailableGiB`
(the closest thing to a capacity signal, and the same number the quota
precheck reads). The `status.type` comment in
`api/v1alpha1/providerhealth_types.go` gains `kvm2` and the CRD is
regenerated.
**Evidence:** contract §8; `StoragePoolGetInfo` returning state, capacity,
allocation, available; `ConnectGetLibVersion` returning major*1e6 + minor*1e3
+ micro.
## 17. Client and child spans (`client-kind`, `child-spans`)
**Answer:** `client-kind`: `rpc-over-ssh`. `child-spans`: `span-decorator`.
The seam is an interface over exactly the go-libvirt methods the provider
uses, with `ctx` as the first parameter of every method (go-libvirt's own
methods take none and block): `DomainLookupByName`, `DomainDefineXML`,
`DomainCreate`, `DomainDestroy`, `DomainUndefineFlags`, `DomainGetState`,
`DomainGetMetadata`, `DomainInterfaceAddresses`, `ConnectListAllDomains`,
`StoragePoolLookupByName`, `StoragePoolRefresh`, `StoragePoolGetInfo`,
`StorageVolLookupByName`, `StorageVolGetPath`, `StorageVolCreateXML`,
`StorageVolUpload`, `StorageVolDownload`, `StorageVolDelete`,
`ConnectGetLibVersion`, `ConnectGetHostname`, plus `Close`. One production
adapter wraps `*libvirt.Libvirt` and applies the per-call deadline
(entry 18); one in-memory fake implements the seam for unit tests. A dialer
seam (`dial(ctx, host) (backend, error)`) separates the SSH transport so tests
inject fakes and an in-process SSH server test covers the real dialer.
The decorator is applied once, inside the real dialer, at the point where a
connection is created, so provider code never sees an undecorated backend. Per
call it starts a span `kvm2.<Method>` (for example `kvm2.DomainDefineXML`) with
attributes `kvm2.host` and the identifiers passed (`kvm2.domain`, `kvm2.pool`,
`kvm2.volume`), records the libvirt error code on failure, and inside the span
writes the V(1) log line, so the line carries that span's ID. Two more spans:
`kvm2.dial` around the SSH handshake and libvirt connect (attributes address
and user) and `kvm2.download` around an image fetch (attributes URL host,
bytes, whether a sha256 was configured). All spans are children of the
decorator's `provider.create`, `provider.get`, `provider.delete`,
`provider.list` or the monitor's `providerhealth.check`. No CloudInit, key
material or upload bodies in attributes. A reflection test walks the seam
interface and asserts every method produces exactly one span and one log
line.
**Evidence:** contract §4.15, §4.18, §9; `tracing.Start` in
`internal/tracing/logger.go`.
## 18. Timeouts (`timeouts`)
**Answer:** `per-call`. The adapter runs each blocking go-libvirt call in a
goroutine and waits on the result, `ctx.Done()` and a timer:
| Call | Budget |
|---|---|
| TCP connect plus SSH handshake plus libvirt connect | 10 s |
| ordinary RPC | 30 s |
| `DomainCreate` | 90 s |
| `StorageVolUpload` (seed) | 60 s |
| `StorageVolUpload` (image), HTTP download | ctx, capped at 20 m |
| wait for running after `DomainCreate` | 15 s (entry 10) |
| HTTP client for images | no client timeout; the per-request ctx carries the cap |
When a call is abandoned (ctx cancelled or budget exceeded), the adapter
closes the underlying connection, because that is the only way to unblock
go-libvirt, and marks it dead so the next call redials; the abandoned call
returns `ErrTransient` wrapping the ctx or deadline error. Consequence,
documented in the guide: other RPCs in flight on that host's pooled connection
fail once with Transient and their callers retry. The image download uses a
dedicated connection so the expensive case (a cancelled poll killing a
435 MB upload) cannot happen. The keepalive (`keepalive@openssh.com` request
every 15 s) marks the connection dead on failure.
**Evidence:** contract §1.5 (no caller deadline; a hung ListByTag stalls GC),
§4.16; go-libvirt methods take no ctx.
## 19. Concurrency
**Answer:** shared state is the host connection table (one pooled connection
per host, dialled lazily, under a mutex with a per-host dial mutex so
concurrent callers dial once and share the result), the per-`(host, volume)`
download locks, and a lock per instance name. Both lock sets are one instance
each of `keyedLocks`: a one-slot semaphore per key, so a waiter gives up when
its ctx ends, reference-counted so an entry is removed from the map once no
caller holds or waits for it and the map does not grow with every name ever
seen, while two waiters on one key always share one semaphore. Create and
Delete for the same name run under that name's lock, so two Deletes (GC and
the reconciler) never race on destroy and undefine, and a Delete cannot
interleave with a Create's steps. Get and ListByTag take no name lock. go-libvirt multiplexes
RPCs on one connection, so calls for different names proceed concurrently
on the shared connection. Three reconciles, a GC sweep and a check round at
once are safe; the check uses its own connections.
**Evidence:** contract §1.2 (concurrency), §4.15.
## 20. Logging (`logging`)
**Answer:** `tiered`. Logger from `logf.FromContext(ctx)` on every call,
named `kvm2`, with `provider` on every line and `host` and `name` on every
line about an instance.
- V(1): one line per operation outcome (`kvm2 domain ensured running`,
`kvm2 domain fetched` with state, ip and uid, `kvm2 domain deleted`,
`kvm2 domains listed` with counts), one `libvirt call` line per RPC from the
decorator (`rpc`, identifiers, `duration`, on failure `error`, `libvirtCode`
and `libvirtError` name), connection events (dialled, dropped with reason),
and a line per failed backend call with the raw error.
- V(2): the same lines also carry domain and volume XML, the metadata element,
interface addresses, listed domain names, and the resolved Create parameters
(`cloudInitBytes`, never the content).
- No V(5) wire tier; go-libvirt exposes no hook for it.
- Never logged at any level: `CloudInit`, key material, known_hosts content,
upload or download bodies.
**Evidence:** contract §4.17, §9.
## 21. Tests (`backend-test`)
**Answer:** `backend-test`: `integration-tag`. Unit tests run over the
in-memory fake behind the seam (and an in-process `x/crypto/ssh` server for
the dialer); the integration test is `kvm2_integration_test.go` behind
`//go:build integration`, skipped unless `KVM2_TEST_HOST`, `KVM2_TEST_USER`,
`KVM2_TEST_SSH_KEY`, `KVM2_TEST_BRIDGE` and `KVM2_TEST_IMAGE_URL` are set,
optional `KVM2_TEST_KNOWN_HOSTS` (without it host keys are not verified and the
log says so), `KVM2_TEST_IMAGE_SHA256`, `KVM2_TEST_POOL`, `KVM2_TEST_SOCKET`,
`KVM2_TEST_TIMEOUT` (default 20m), `KVM2_TEST_KEEP`, `KVM2_TEST_LOG_LEVEL`;
run with `make test-integration-kvm2`. It asserts, in order: `New` returns
without dialling; `Check` reports the host Ready with details; `Create`
returns `hosts/<host>/domains/<name>`; a second `Create` returns the same ID
and the host's domain count is unchanged; `Get` reaches Running with an IPv4
within the budget and its `UID` and `CreatedAt` equal what Create wrote;
`ListByTag` contains it; `Delete` returns nil; `Get` is `ErrNotFound`;
`Delete` again returns nil; the pool holds the base image and its marker and
no `<name>.qcow2` or `<name>-seed.iso`; with an in-memory span recorder,
`kvm2.DomainDefineXML` and `kvm2.dial` spans are children of
`provider.create`.
Contract §11 list, with the test that covers each item:
- [x] repeat `Create`: `TestCreate_repeatReturnsSameIDAndCreatesNothing`
- [x] `Get` after `Delete`: `TestGet_afterDeleteReturnsNotFound`
- [x] `Delete` twice: `TestDelete_twiceSucceeds`
- [x] malformed ID: `TestGet_malformedIDIsPermanent`, `TestDelete_malformedIDIsPermanent`, `TestParseProviderID_malformed`
- [x] unknown state maps to `StateStopped`: `TestMapState_table`
- [x] `StateRunning` requires an IP: `TestGet_runningWithoutIPIsProvisioning`, `TestGet_runningWithIPIsRunning`
- [x] `UID` and `CreatedAt` read back: `TestGet_readsUIDAndCreatedAtFromBackend`
- [x] `ListByTag` excludes unmarked instances: `TestListByTag_excludesUnmarkedDomains`, `TestListByTag_emptyUIDStillListed`
- [x] `Get` during an in-progress delete: `TestGet_duringDeleteStillReturnsInstance`
- [x] `Get` during an asynchronous operation or lock: `TestGet_backendBusyIsTransientNeverNotFound`
- [x] never started is never `StateStopped`: `TestCreate_returnsOnlyWhenRunning`
- [x] cancelled `ctx` returns promptly: `TestProvider_cancelledContextReturnsPromptly`, `TestCall_cancelUnblocksRPC`
- [x] `classify` table with captured responses: `TestClassify_table`
- [x] Checker reasons, expired `ctx`, stable order: `TestCheck_reasonsTable`, `TestCheck_expiredContextReturnsPromptly`, `TestCheck_oneEntryPerHostInConfigOrder`
- [x] sentinel tests for `CloudInit` and the credential: `TestCreate_cloudInitSentinelNeverLoggedOrInErrors`, `TestSSH_keyMaterialNeverInErrorsOrLogs`
- [x] child spans under the operation span: `TestObserve_childSpansUnderParent`, `TestObserve_everySeamMethodInstrumented`
- [x] real-backend lifecycle test: `TestKVM2_lifecycle` and `TestKVM2Check_realHost` in `kvm2_integration_test.go`
Further tests per file are listed in the plan
(`docs/plans/2026-09-16-1333-kvm2-provider.md`).
**Evidence:** contract §11; docs/testing.md conventions.
## 22. Deployment
**Answer:** the existing `kvm-ssh` mount is reused; the rest is samples and a Makefile target:
- `config/manager/manager.yaml`: no new mount; the existing optional `kvm-ssh`
Secret at `/etc/proxy-operator/kvm` serves both `kvm` and `kvm2`, and its
comment says so. `/tmp` is already an emptyDir.
- `config/manager/providers_config.yaml` and
`config/samples/providers-config.yaml`: a commented `kvm2-home` entry, the
same block as `kvm-home` under `kvm2:`.
- `config/samples/proxy_kvm2.yaml`, listed in `config/samples/kustomization.yaml`:
provider `kvm2-home`, zone `beelink` (matching the shipped config, unlike the
`kvm` sample's `nas1`), machine type `small`, image `debian-13`, a cloud-init
that installs Squid.
- Makefile: `test-integration-kvm2` (`go test -race -tags=integration -count=1
-v -timeout 40m -run TestKVM2 ./internal/provider/kvm2/`). The Secret helper
`make kvm-ssh-secret` already exists and is reused.
- RBAC: none; the provider does not talk to the cluster's API.
- `hack/providers-dev.yaml`: no entry (needs credentials).
**Evidence:** contract §12; `config/manager/manager.yaml`.
## 23. Docs
**Answer:** `docs/kvm2-provider.md` (hypervisor prerequisites including OVMF
and the `libvirt` group and a bridge with DHCP; cluster-side setup reusing the
`kvm-ssh` Secret; config reference; placement mapping; what lands on the host
including the metadata namespace; the image cache and the quota precheck; the
health check reason table; logging tiers and span names; migrating an entry
from `kvm`, which requires deleting its Proxies first because kvm2 does not
adopt v1 domains; known gaps). A fourth column in the three tables of
`docs/providers-existing.md` and a kvm2 section. `docs/architecture.md`:
components row, a `kvm2` block in §5, the span topology in §10, a §11 row, and
Decisions bullets (dedicated download connection, checks on fresh connections,
own metadata namespace, quota precheck, SSH auth failures Permanent). A README
row. `docs/testing.md` rows and a kvm2 integration section. A `CHANGELOG.md`
entry after the user confirms.
**Evidence:** contract §12.
## 24. Known limits accepted at design time
**Answer:** nine limits, each with its workaround:
- **Create blocks**, including the first image download per host, holding one
of three reconcile workers. Workaround: warm the cache with one Proxy per
image per host before creating many.
- **Volumes without a domain are invisible to GC** (crash between volume
creation and define). The same Proxy's next reconcile removes them; a Proxy
deleted for good leaves them for `virsh vol-list`.
- **A host removed from the config orphans its VMs**: Delete is a no-op
success for it and GC does not sweep it. Delete the Proxies first.
- **No adoption of `kvm` (v1) domains**: switching an entry's type requires
deleting its Proxies first, or the old domains stay behind.
- **Cancelling one call drops the host's pooled connection**, so other RPCs
in flight on that host fail once with Transient and retry. The download is
isolated on its own connection.
- **`Content-Length` is required** for image downloads; a chunked mirror is
rejected as Permanent.
- **A paused or pmsuspended domain is not started.** A repeat `Create` for
a domain in that state is `ErrPermanent` (entry 10), so the Proxy latches
as Failed until its spec changes, rather than retrying a `DomainCreate`
that libvirt refuses. Resume the domain on the host (`virsh resume`) and
touch the spec, or delete the Proxy.
- **No v1 log-format compatibility**: the `libvirt call` lines have the same
keys where the same facts exist, but dashboards keyed on `kvm` logger names
need `kvm2`.
- Contract §13 limits that apply unchanged: config read once per process
(adding a host needs a restart; credentials do not), no installation identity
in GC, an unconfigured provider latches the Proxy.
**Evidence:** contract §13; entries 4, 8, 11, 18 above.