Files
egress-proxies-operator/docs/kvm-provider.md
T
kacerrandClaude Opus 5 19b4a82ae9 Document the provider health checks
Architecture gets a provider-health section, component rows and four
decisions; the KVM provider doc gets a health-check section with the
reason table and the rotated-Secret gap; the testing guide lists the new
suites. CLAUDE.md's Architecture TODO and stale "only go.mod exists"
gotcha now point at the real docs, plus the RBAC-marker gotcha found
while wiring the monitor.

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

369 lines
20 KiB
Markdown

# The KVM provider
Runs Managed proxies as VMs on your own libvirt/KVM hypervisors. The
operator reaches each host over SSH, forwards the libvirt unix socket
through that connection, and talks libvirt's own RPC over it — so the
hosts need nothing beyond `sshd` and `libvirtd`, and the operator image
carries no libvirt libraries. VMs boot a stock distro cloud image that the
operator downloads into the host's storage pool on first use, with the
Proxy's cloud-init delivered on a NoCloud seed ISO built in-process.
## Hypervisor prerequisites
Per host, once:
1. **libvirt + KVM** (`libvirtd` running, `/dev/kvm` present).
2. **An SSH user in the `libvirt` group**, so it may open
`/var/run/libvirt/libvirt-sock`. The operator authenticates with a
private key; nothing else on the host is touched.
3. **A bridge to a network with DHCP** (e.g. `br0` bridged onto the LAN).
Each VM gets one virtio NIC on it and takes its IP from that network's
DHCP; the cluster must be able to reach those IPs for health probes
and for crawlers to use the proxy.
4. **A storage pool** (default `default`, usually `/var/lib/libvirt/images`)
with room for one base image per distro (a few hundred MB) plus the
per-VM overlays (`diskSizeGb` each, thin-provisioned).
5. **UEFI firmware for guests**: the `ovmf` package on Debian/Ubuntu,
`edk2-ovmf` on Fedora/RHEL. VMs boot UEFI by default (libvirt picks
the OVMF binary itself); a domain start fails with "no firmware
found" without it. Only `firmware: bios` images skip this.
6. Optionally, **outbound internet from the VMs**: a vendor-data script
installs `qemu-guest-agent` from the distro mirrors on first boot.
Without it the VM still works — the operator falls back to the host's
ARP table to find the IP, which fills in once the guest sends traffic —
but the ARP table only sees traffic to the host itself, so on a plain
bridge the agent is in practice required.
Check from the operator's point of view before wiring it in:
```sh
ssh virt@nas1.home.lan virsh -c qemu:///system list --all # user can drive libvirt
ssh virt@nas1.home.lan virsh -c qemu:///system pool-info default
ssh virt@nas1.home.lan ip link show br0
```
## Cluster-side setup
1. Create the SSH Secret from a key pair dedicated to the operator and a
`known_hosts` file holding every host's key. The worked example below
uses `~/.ssh/proxy-operator_ed25519` and `~/.ssh/kvm_known_hosts`,
the same paths the integration test and `make kvm-ssh-secret` default
to.
```sh
# once: a key pair for the operator; the .pub goes on every host
ssh-keygen -t ed25519 -f ~/.ssh/proxy-operator_ed25519 -N '' -C proxy-operator
ssh-copy-id -i ~/.ssh/proxy-operator_ed25519.pub virt@nas1.home.lan
# once per host: its host key, under the address the *operator* will
# dial (hosts[].address), which may differ from what your laptop uses
ssh-keyscan -H nas1.home.lan >> ~/.ssh/kvm_known_hosts
# the Secret: the PRIVATE key (not the .pub) plus known_hosts
kubectl -n egress-proxies-operator-system create secret generic kvm-ssh \
--from-file=id_ed25519=$HOME/.ssh/proxy-operator_ed25519 \
--from-file=known_hosts=$HOME/.ssh/kvm_known_hosts
```
`make kvm-ssh-secret` runs that last command as an apply, so it also
updates an existing Secret; override the paths with
`KVM_SSH_KEY=... KVM_KNOWN_HOSTS=...`. Putting the `.pub` in the
Secret is the classic mistake: the operator then fails at startup
with a "not a private key" error. Without `known_hosts`, set
`insecureIgnoreHostKey: true` and accept that a MITM could hand you a
fake hypervisor.
`config/manager/manager.yaml` mounts the Secret (optional) at
`/etc/proxy-operator/kvm` as two files, `id_ed25519` and
`known_hosts`, which is what `sshKeyFile` and `knownHostsFile` in the
provider config point at. The deployment starts fine without the
Secret, even with a `kvm` provider configured: only that provider's
proxies fail, and they recover once the Secret exists. Rotating the
key: re-run the command (or the make target) and restart the manager;
both files are read once, on the first connection, and cached.
2. Add a provider entry to the providers config
(`config/manager/providers_config.yaml` in-cluster; full sample in
`config/samples/providers-config.yaml`):
```yaml
- name: kvm-home
type: kvm
kvm:
sshKeyFile: /etc/proxy-operator/kvm/id_ed25519
knownHostsFile: /etc/proxy-operator/kvm/known_hosts
images:
debian-13:
url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
machineTypes:
small: {vcpus: 1, memoryMiB: 1024}
hosts:
- name: nas1
address: nas1.home.lan:22
user: virt
bridge: br0
```
The operator checks both files at startup and logs a `WARNING:` line
when either is unreadable, but it does not refuse to start: the
provider set is built all-or-nothing, so exiting here would crash-loop
the manager and take the `kubernetes` and `gcp` providers with it.
The files are read for real on the first connection. A missing file is
a transient failure, so proxies heal by themselves once the Secret
lands; a key that does not parse is permanent and needs a fix.
3. Apply a Proxy (`config/samples/proxy_kvm.yaml`) and watch it:
```sh
kubectl get px -w
# NAME MODE PROVIDER PHASE IP HEALTHY
# proxy-kvm-sample Managed kvm-home Provisioning
# proxy-kvm-sample Managed kvm-home Running 192.168.1.73
# proxy-kvm-sample Managed kvm-home Ready 192.168.1.73 True
```
The first Proxy for a given image on a given host sits in
`Provisioning` for the length of the image download; the operator log
shows `fetching base image into host pool` and `base image cached`
with the size and duration.
## Config reference
| Field | Required | Default | Meaning |
|---|---|---|---|
| `sshKeyFile` | yes | | private key used for every host |
| `knownHostsFile` | unless `insecureIgnoreHostKey` | | OpenSSH `known_hosts` to verify host keys against |
| `insecureIgnoreHostKey` | | `false` | skip host-key verification entirely |
| `diskSizeGb` | | `10` | capacity of each VM's overlay disk; cloud-init's growpart expands the root fs into it |
| `vendorData` | | | extra cloud-init vendor-data placed on every seed next to the Proxy's user-data. Beware that cloud-init drops any top-level key of a vendor-data **cloud-config** that the user-data also defines. Shipped as one part of a MIME multipart alongside the agent script when `installGuestAgent` is on |
| `installGuestAgent` | | `true` | add the vendor-data script that installs and starts `qemu-guest-agent`. Turn off for images that ship it already or cannot reach a package mirror; IP discovery then depends on the host's ARP table |
| `images.<key>` | | | the key names the image in `placement.image`. Letters, digits, dots, dashes and underscores only — it becomes the cached volume name `base-<key>.<format>` |
| `images.<key>.url` | yes per entry | | http(s) URL of a cloud image (qcow2 or raw) |
| `images.<key>.sha256` | | | expected digest; verified after download, a change triggers a re-download |
| `images.<key>.format` | | `qcow2` | `qcow2` or `raw` |
| `images.<key>.firmware` | | `uefi` | `uefi` (OVMF, secure boot off) or `bios` (SeaBIOS) for images without an EFI system partition; Debian 13 hangs in GRUB under BIOS on a headless VM, see *Known gaps* |
| `machineTypes.<key>` | ≥ 1 | | `{vcpus, memoryMiB}` size profile |
| `hosts[].name` | yes | | the `placement.zone` value that selects this host; unique. Letters, digits, dots, dashes and underscores only — it becomes part of the providerID `hosts/<name>/domains/<vm>` |
| `hosts[].address` | yes | | SSH endpoint, `host:port` |
| `hosts[].user` | yes | | SSH user, member of the `libvirt` group |
| `hosts[].socket` | | `/var/run/libvirt/libvirt-sock` | libvirt socket path on the host |
| `hosts[].pool` | | `default` | storage pool for base images, overlays and seeds |
| `hosts[].bridge` | yes | | host bridge for the VM's NIC |
## Placement
| Proxy field | Meaning for kvm |
|---|---|
| `placement.zone` | host name from `hosts`. May be omitted when exactly one host is configured; otherwise required. Unknown → `Failed` |
| `placement.machineType` | key in `machineTypes`. Unknown → `Failed` |
| `placement.image` | key in `images`, or an `http(s)://` URL used directly. Anything else → `Failed` |
| `placement.region` | ignored |
`Failed` here means the reconciler stops retrying and the message names
the offending value; fix the spec (which replaces the VM) or the config.
## What lands on the host
For a Proxy whose deterministic name is `proxy-abc…`, on the chosen
host's pool:
| Object | Name | Lifetime |
|---|---|---|
| base image | `base-<key>.qcow2` (or `base-<url-hash>.qcow2` for a URL) | cached; survives Proxy deletion |
| image marker | `base-<key>.qcow2.sha256` | with the base image |
| overlay disk | `proxy-abc….qcow2`, qcow2 backed by the base | with the Proxy |
| seed ISO | `proxy-abc…-seed.iso` | with the Proxy |
| domain | `proxy-abc…` | with the Proxy |
The domain is a q35 machine with host-passthrough CPU, the overlay on
virtio, the seed on a SATA cdrom, one virtio NIC on the bridge, a serial
console, the guest-agent channel, a virtio RNG and no graphics. Its
`<metadata>` carries the operator's element (managed flag, owning UID,
creation time) — that is how orphan GC recognises its own VMs, since
libvirt has no labels.
The seed's `user-data` is the Proxy's `cloudInit`, **byte for byte**. Any
format cloud-init accepts works, including a Jinja template, a MIME
archive, a shell script or a gzipped payload; the operator never parses or
re-renders it.
The guest agent rides along in `vendor-data`, as a **shell script** rather
than a cloud-config. That is the only shape cloud-init cannot drop:
vendor-data cloud-config is merged under the user's own document with
mergers that keep the user-data's value for every key both define and never
append lists, so a Proxy carrying its own `packages:` silently lost the
agent and sat in `Provisioning` forever. The strategy cannot be overridden
from either document — `merge_how` in user-data governs only the first
source, and `merge_how` in vendor-data is consumed and discarded before the
document is stored. A vendor-data script skips all of it: cloud-init gives
vendor-data its own handler chain, writes scripts to a separate directory
and runs them from `cc_scripts_vendor`, after the user's packages are
installed and before the user's `runcmd`. The script is idempotent, picks
whichever of `apt-get`, `dnf`, `yum`, `zypper` or `apk` the image has, and
logs into `/var/log/cloud-init-output.log`. Set `installGuestAgent: false`
to leave it out. When `vendorData` is also configured, the two travel as
separate parts of a MIME multipart vendor-data.
## The image cache
The base image is downloaded **by the operator, through libvirt**, into
the host's pool the first time a Proxy asks for it on that host — nothing
is pre-placed on the hypervisor, and the operator needs no filesystem
access there. The bytes stream from the URL through the operator into
the pool with a running sha256; nothing is buffered. The marker volume is
written last and holds the digest, so a crash mid-download leaves a base
volume without a marker, which the next attempt deletes and redoes.
A re-download happens when: the marker is missing, or `sha256` is
configured and differs from the marker's content. Otherwise the cached
copy is used for as long as it exists, even if the URL's content changed
upstream (a `latest` URL is pinned at the moment of first fetch, per
host).
To evict manually:
```sh
ssh virt@nas1 virsh -c qemu:///system vol-delete --pool default base-debian-13.qcow2.sha256
ssh virt@nas1 virsh -c qemu:///system vol-delete --pool default base-debian-13.qcow2
```
⚠️ **Do not change an image entry's `url` or `sha256`, and do not evict
its base volume, while Proxies using that image exist on the host.** The
overlays back onto the base file; deleting or replacing it corrupts every
VM that uses it. Delete those Proxies first, or add the new image under a
new key and move Proxies over by changing their `placement.image` (which
replaces the VM).
## Logging into a VM
- Put your key in the Proxy's cloud-init (`ssh_authorized_keys`) and
`ssh debian@<ip>` (Debian) / `ssh ubuntu@<ip>` (Ubuntu).
- Or on the host: `virsh console proxy-abc…` attaches to the serial
console; cloud-init's output scrolls by there on first boot.
- `virsh domifaddr proxy-abc… --source arp` / `--source agent` shows what
the operator sees when it looks for the IP.
## Logging
The provider logs through controller-runtime's logger, named `kvm`, with
`provider`, `host` and `name` (the domain) on every line.
- `--zap-log-level=debug` (logr V(1)): one line per provider operation
(`domain defined`, `KVM domain ensured running`, ...) **and one
`libvirt call` line per libvirt RPC**, named `kvm.libvirt`, with
`rpc` (the libvirt API function, e.g. `virDomainLookupByName`), the
identifiers it was given (`domain`, `pool`, `volume`), curated results
(`state`, `path`, `interfaces`), `duration`, and on failure `error`
plus `libvirtError` (the code name, e.g. `ErrNoDomain`) and
`libvirtCode`. Connection events (`libvirt host connected`, dial
failures, `libvirt connection dropped` with a reason) log at the same
level. A Create is about ten RPCs; a Get is four.
- `--zap-log-level=2`: the same lines also carry the domain and volume
`xml`, the `metadata` element, the interface `addresses` and the
domain `names` from a listing.
- Cloud-init is never logged at any level: the seed ISO upload reports
only `length` and `duration`. There is no raw wire tier like the GCP
provider's V(5); go-libvirt exposes no hook for it.
## Health check
The operator checks every configured host every `--provider-check-interval`
(default 1m). It publishes the result in two places: the `ProviderHealth`
object named after the provider (`kubectl get ph kvm-home -o yaml`), and the
`proxy_operator_provider_target_up{provider,target}` gauge.
All hosts are checked concurrently, each over the same pooled connection
that Create and Get use:
1. The SSH and libvirt handshakes. These only happen on the first check, or
after the link dropped.
2. `virConnectGetLibVersion` and `virConnectGetHostname`.
3. `virStoragePoolLookupByName` and `virStoragePoolGetInfo` on the host's
`pool`.
A passing target's `details` carry `hostname`, `libvirtVersion`, `pool`,
`poolState`, `poolCapacity` and `poolAvailable` (in GiB). The free space is
the closest thing this provider has to a capacity signal. Every target also
shows its SSH `user`. A failing target says where the check broke:
| Reason | Meaning | Look at |
|---|---|---|
| `SSHMaterialUnavailable` | the key or known_hosts file is missing or does not parse | the `kvm-ssh` Secret (see *Cluster-side setup*) |
| `Unreachable` | the TCP connect failed, or the link died mid-check | DNS, Tailscale or a firewall between the cluster and `address` |
| `SSHHandshakeFailed` | the host key is missing from known_hosts or has changed, or `user` rejected the key | the known_hosts entry; `user`'s `authorized_keys` |
| `LibvirtConnectFailed` | SSH worked, the libvirt socket did not | `user` in the `libvirt` group, `libvirtd` running, the `socket` path |
| `Timeout` | no answer within `--provider-check-timeout` (default 15s) | a hung `libvirtd` or a saturated link |
| `PoolNotFound` | there is no pool named `pool` | `virsh pool-list --all` |
| `PoolNotActive` | the pool exists but is not running | `virsh pool-start <pool>` and `virsh pool-autostart <pool>` |
| `LibvirtError` | any other libvirt error | the message carries libvirt's own text |
Sharing the connection has two consequences:
- The leader now keeps one SSH connection per host open from startup, with a
keepalive every 15s. Before, the first connection opened with the first KVM
reconcile.
- A check that hits its timeout drops the host's connection, just as a
cancelled reconcile already does. Calls in flight on a hung link then fail
fast, and the next call redials.
## Known gaps
- **No quota or capacity detection.** libvirt reports an out-of-space
pool or an over-committed host as an ordinary error; the reconciler
treats it as transient and retries with backoff rather than the slower
quota backoff. The health check does report each pool's free space
(`poolAvailable`), so a pool that is filling up shows before Creates
start failing.
- **A rotated `kvm-ssh` Secret needs a manager restart.** The key and
known_hosts files are read on first use and cached for the life of the
process. Replacing them in the Secret therefore changes nothing for
later connections, health checks included. After a host key change, the
check keeps reporting `SSHHandshakeFailed` until the manager restarts.
- **Orphan volumes without a domain are invisible to GC.** A crash
between creating the volumes and defining the domain leaves an overlay
and seed that `ListByTag` cannot see (it lists domains). They are
cleaned when the same Proxy reconciles again (Delete removes volumes by
name even with no domain), but a Proxy CR that is gone for good leaves
them for `virsh vol-list` to find.
- **A host removed from the config takes its VMs with it.** `Delete` for a
providerID naming an unconfigured host is a no-op success, so the Proxy
finishes deleting instead of pinning its finalizer, but the domain and its
volumes stay on the hypervisor and orphan GC cannot see them either (it
only sweeps configured hosts). Delete the Proxies before retiring a host,
or clean up afterwards with `virsh undefine --nvram` plus `virsh vol-delete`.
`Get` still fails permanently for such a providerID on purpose: a typo in
`hosts` must not look like a missing VM and trigger a rebuild elsewhere.
- **The first image fetch is synchronous.** It holds a reconcile worker
for the download (LAN speed, a few hundred MB, once per image per
host). Other Proxies keep reconciling on the remaining workers.
- **Replacing an image in use breaks running VMs** — see the warning
under *The image cache*.
- **IP discovery needs either guest traffic or the agent.** A VM on a
bridge with no DHCP never gets an address and stays `Provisioning`
forever; check `virsh console`. The same symptom with a booted, addressed
VM means the guest agent never came up: check `systemctl status
qemu-guest-agent`, `cloud-init status --long` and the vendor script's
output in `/var/log/cloud-init-output.log` (a VM without outbound
internet cannot install it). A user-data that disables vendor-data
(`vendor_data: {enabled: false}`) also removes the agent, by design.
- **Debian 13 cloud images hang in GRUB under BIOS on a headless VM**
([Debian bug #1111240](https://bugs.debian.org/1111240), open, in
`grub2`): trixie's image ships `terminal_output gfxterm serial` in its
GRUB config, and with a serial console but no video device (exactly
the domain this provider defines) GRUB prints "Booting" and never
hands over to the kernel. Not host-specific; reproduced by the Debian
cloud team. The same image boots under UEFI with the serial console
intact, which is why `firmware` defaults to `uefi`. Debian 12
(bookworm) images are unaffected and boot either way. Images that
need `firmware: bios` are the exception, not the rule.
## Testing
Unit tests run over an in-memory fake of the libvirt API. The optional
integration test against a real host is described in
[testing.md](testing.md#the-kvm-integration-test-real-hypervisor); its
`-v` output includes the `libvirt call` trace above, at the verbosity
`KVM_TEST_LOG_LEVEL` selects (default 1). `TestKVMCheck_realHost` runs the
health check against the same host and creates nothing:
`go test -tags=integration -count=1 -v -run TestKVMCheck ./internal/provider/kvm/`.