Files
egress-proxies-operator/docs/proxmox-provider.md
T
kacerrandClaude Opus 5.5 7f2073f94b proxmox: deployment files, sample, user guide and doc rows
The optional proxmox-token Secret (token, ca.pem) mounted at
/etc/proxy-operator/proxmox, make proxmox-token-secret with content
checks that never print the token, a commented proxmox-home entry in
both providers configs, and config/samples/proxy_proxmox.yaml.
TestLoadConfig_proxmoxSampleEntry loads the commented entry from both
files, and a second test ties the sample Proxy's placement to it.

docs/proxmox-provider.md is the user guide: prerequisites and privileges,
cluster setup, config reference, placement, what lands on Proxmox,
logging, tracing, timeouts, health check, error classes, known gaps and
testing. providers-existing.md gains the proxmox column and section;
architecture.md, the README, providers.md and CLAUDE.md gain their rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 21:14:18 +00:00

647 lines
36 KiB
Markdown

# The proxmox provider
Runs Managed proxies as VMs on a Proxmox VE cluster. It is the third
VM-style provider and the second built from
[provider-contract.md](provider-contract.md), from the design in
[proxmox-provider-spec.md](proxmox-provider-spec.md). The operator talks to
the cluster's REST API only (`https://<host>:8006/api2/json`) with an API
token. It needs no SSH, no shell on the nodes, and no library beyond Go's
standard `net/http`.
Each VM is a **full clone of a template** you prepare once. The operator
builds the Proxy's cloud-init into a NoCloud seed ISO in-process, uploads
it to a storage on the VM's node, attaches it as a CD-ROM, sets the size,
starts the VM and learns its address from the QEMU guest agent. Everything
here was proven against **Proxmox VE 9.2.20** (QEMU 11.0) on the single
node `pv1-1`; every error text the provider matches was captured from that
host.
## Proxmox prerequisites
Once per cluster, as root on a node:
1. **Proxmox VE 9.x with the API reachable** from the operator's pods on
port 8006. The operator's pods and the crawler clients must also route to
the network the proxies land on (see step 5).
2. **A template without a Proxmox cloud-init drive.** The provider brings
its own seed on `ide2`; a Proxmox cloud-init drive would compete with it
and the Proxy's user-data would be ignored. Debian 13's generic cloud
image boots UEFI (under BIOS it hangs in GRUB on a headless VM), so the
template does too:
```sh
wget https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
qm create 9001 --name debian-13-template --memory 2048 --cores 2 --ostype l26 \
--machine q35 --bios ovmf --efidisk0 local-lvm:1,efitype=4m,pre-enrolled-keys=0 \
--scsihw virtio-scsi-pci --net0 virtio,bridge=vmbr0 \
--serial0 socket --vga serial0 --agent 1
qm set 9001 --scsi0 local-lvm:0,import-from=/root/debian-13-generic-amd64.qcow2
qm set 9001 --boot order=scsi0
qm template 9001
```
The template must boot from its first disk, run cloud-init with the
NoCloud datasource (the Debian and Ubuntu cloud images do), and either
ship `qemu-guest-agent` or be able to install it at first boot. Before
the first clone for a Proxy, `Create` reads the template's config and
refuses it with a permanent error when:
- it is not a template (`qm template` was not run);
- any disk slot holds a Proxmox cloud-init drive (a value containing
`cloudinit`), such as the one `qm set <vmid> --ide2 local-lvm:cloudinit`
adds for Proxmox's own cloud-init support;
- it has no `net0` while the config sets `bridge` or `vlanTag`;
- the machine type sets `diskGiB` and the template has no boot disk, or
one without a readable `size=`.
These are checked on the template because the clone copies its config:
a VM cloned from a bad template could not be fixed by fixing the
template. The template is checked only when a VM is about to be cloned;
a VM that already exists no longer depends on it. The health check sees
only whether each template exists and is a template.
3. **A storage for the seed ISOs**, holding `iso` content. A dedicated
directory storage is best, because the token needs `Datastore.Allocate`
on it to delete seeds, and on a dedicated storage that privilege cannot
touch backups or other ISOs:
```sh
pvesm add dir proxy-seeds --path /var/lib/proxy-seeds --content iso
```
A seed is tens of KiB. Proxmox stages uploads under `/var/tmp`, not the
node's tmpfs `/tmp`. On a cluster, a storage that is not shared keeps
each seed on the node the VM was created on (see *Known gaps*).
4. **A user, two roles and a token.** `VM.Monitor` no longer exists in PVE
9 and `pveum` rejects it; the agent read needs `VM.GuestAgent.Audit`
alone. `VM.Config.Cloudinit` is not needed for the seed on `ide2`.
`SDN.Use` on `/` is what lets the config write put a VLAN tag on
`vmbr0`:
```sh
pveum user add proxy-operator@pve --comment "egress-proxies-operator"
pveum role add ProxyOperator -privs "VM.Allocate VM.Clone VM.Audit VM.Config.CDROM VM.Config.CPU VM.Config.Memory VM.Config.Disk VM.Config.Network VM.Config.Options VM.PowerMgmt VM.GuestAgent.Audit Datastore.Audit Datastore.AllocateSpace Datastore.AllocateTemplate Sys.Audit SDN.Use"
pveum aclmod / -user proxy-operator@pve -role ProxyOperator
pveum role add ProxyOperatorSeeds -privs "Datastore.Allocate Datastore.AllocateTemplate Datastore.AllocateSpace Datastore.Audit"
pveum aclmod /storage/proxy-seeds -user proxy-operator@pve -role ProxyOperatorSeeds
pveum user token add proxy-operator@pve operator --privsep 0
```
The second role repeats three privileges of the first on purpose: an
ACL on a more specific path **replaces** what the user inherits from
`/` for that path, so a role with `Datastore.Allocate` alone would take
the upload's `Datastore.AllocateTemplate` away. The role on `/` covers
clone, config, resize, start, stop, the agent read, the upload and the
destroy; what it lacks is `Datastore.Allocate`, without which deleting
a seed is a 403 (`Permission check failed (/storage/<id>,
Datastore.Allocate)`). With `--privsep 0` the token carries the user's
privileges.
The token's secret is printed once. Save it on your workstation as one
line, `proxy-operator@pve!operator=<secret>`, in
`~/.config/proxmox-token` with mode 0600. Copy the node's CA,
`/etc/pve/pve-root-ca.pem`, to `~/.config/proxmox-ca.pem`.
5. **A proxy network with DHCP and a route to the internet.** Each VM
keeps the template's one NIC, optionally moved to another bridge or
VLAN by the config, and takes its address from that network's DHCP. The
network needs internet twice over: the vendor-data script installs
`qemu-guest-agent` from the distro mirrors at first boot, and the agent
is the only way the operator learns the VM's address; and an egress
proxy needs egress anyway. Baking `qemu-guest-agent` into the template
and setting `installGuestAgent: false` removes the mirror from IP
discovery. The operator's pods and the clients must route to that
network: the health engine probes `http://<ip>:<port>` through the
proxy, and clients get the same address from the discovery API. The
provider does not use `spec.port` and configures no Proxmox firewall.
6. **A reserved VMID range.** VMIDs are allocated from `vmidRange`
(default 100000 to 199999). Keep hand-made VMs out of it: one there
costs a probe, and one named like a proxy is refused (see *What lands
on Proxmox*).
Check from the operator's point of view before wiring it in. This sends
the token through a file descriptor, so it never reaches the command line:
```sh
pve() {
curl -s --cacert ~/.config/proxmox-ca.pem \
-H @<(printf 'Authorization: PVEAPIToken=%s\n' "$(tr -d '[:space:]' < ~/.config/proxmox-token)") \
"https://192.168.0.8:8006/api2/json/$1"; echo
}
pve version # the token works, TLS verifies
pve nodes/pv1-1/storage/proxy-seeds/status # active, content includes iso
pve 'cluster/resources?type=vm' # the template is listed with "template":1
```
## Cluster-side setup
1. Create the Secret. It holds two keys, `token` (the one-line token file)
and `ca.pem` (the node's CA):
```sh
make proxmox-token-secret
# or by hand:
kubectl -n egress-proxies-operator-system create secret generic proxmox-token \
--from-file=token=$HOME/.config/proxmox-token \
--from-file=ca.pem=$HOME/.config/proxmox-ca.pem
```
The make target runs that command as an apply, so it also updates an
existing Secret. Override the paths with
`PROXMOX_TOKEN_FILE=... PROXMOX_CA_FILE=...`. Before it applies, it
checks that both files exist, that the token file is exactly one line
of `<user>@<realm>!<tokenid>=<secret>` (without printing it), and that
the CA file holds a PEM certificate. With `insecureSkipTLSVerify`
instead of a CA, create the Secret by hand with the `token` key only.
`config/manager/manager.yaml` mounts the Secret (optional, read-only)
at `/etc/proxy-operator/proxmox`, so the files are
`/etc/proxy-operator/proxmox/token` and
`/etc/proxy-operator/proxmox/ca.pem`. The deployment starts fine
without it, even with a proxmox provider configured: `New` makes no
request, only checks that the token file exists, and logs a `WARNING:`
line when it does not. The provider's proxies then fail transiently and
recover once the Secret lands; the health check reports
`CredentialsUnavailable` meanwhile.
**Rotating the token needs no restart.** The token file is read on
every request, so the next request after the kubelet refreshes the
mount uses the new token. **Rotating the CA does**: it is loaded on
first use and cached for the life of the process. A missing CA file is
retried until it lands; one without a certificate is a permanent error.
2. Add a provider entry to the providers config
(`config/manager/providers_config.yaml` in-cluster; the full sample,
commented out, is in `config/samples/providers-config.yaml`):
```yaml
- name: proxmox-home
type: proxmox
proxmox:
url: https://192.168.0.8:8006
tokenFile: /etc/proxy-operator/proxmox/token
caFile: /etc/proxy-operator/proxmox/ca.pem
nodes: [pv1-1]
isoStorage: proxy-seeds
vlanTag: 4
templates:
debian-13: {vmid: 9001}
machineTypes:
small: {cores: 1, memoryMiB: 1024}
```
**The URL's host must be in the node certificate.** The node's
self-signed certificate lists its own addresses and names, and TLS
verification checks the URL's host against them. On `pv1-1` it lists
`192.168.0.8`, `pv1-1` and `pv1-1.home.lab`, so
`https://192.168.2.8:8006` reaches the same node and still fails
verification (`CertificateInvalid` in the health check, a permanent
error on every call). See what yours lists with:
```sh
openssl s_client -connect 192.168.0.8:8006 </dev/null 2>/dev/null | openssl x509 -noout -ext subjectAltName
```
If the address the operator must use is not there, either give the
node a certificate that names it (a custom certificate in the node's
Certificates panel) and put that certificate's CA in `caFile`, or set
`insecureSkipTLSVerify: true`, which `New` logs as a `WARNING:` line.
`nodes` holds node names as Proxmox knows them, not addresses: the one
URL reaches the whole cluster. The providers config is read once per
process, so adding a node, a template or a machine type needs a manager
restart.
3. Apply a Proxy (`config/samples/proxy_proxmox.yaml`) and watch it:
```sh
kubectl get px -w
# NAME MODE PROVIDER PHASE IP HEALTHY
# proxy-proxmox-sample Managed proxmox-home Provisioning
# proxy-proxmox-sample Managed proxmox-home Running 192.168.4.147
# proxy-proxmox-sample Managed proxmox-home Ready 192.168.4.147 True
```
(Illustrative.) `Create` returns once the VM runs, a few seconds after
the clone; the address follows when the guest agent is up, about 50
seconds after the start on `pv1-1` with the agent installed at first
boot.
## Config reference
The block is `proxmox:` and its Go type is `ProxmoxConfig` in
`internal/provider/config_proxmox.go`, validated at load by
`validateProxmox`; unknown keys are rejected. Defaults are applied by
`withDefaults` in `internal/provider/proxmox/proxmox.go`.
| Field | Required | Default | Meaning |
|---|---|---|---|
| `url` | yes | | `https://<host>:8006`, with no path (the client appends `/api2/json`). The host must be in the node certificate |
| `tokenFile` | yes | | file holding one line, `<user>@<realm>!<tokenid>=<secret>`, read on every request and sent as `Authorization: PVEAPIToken=<line>` |
| `caFile` | | system roots | PEM bundle trusted for the API's certificate, typically the node's `/etc/pve/pve-root-ca.pem`. Loaded on first use and cached |
| `insecureSkipTLSVerify` | | `false` | skip certificate verification; `New` logs a `WARNING:` line when it is on |
| `nodes` | yes, at least one | | node names `placement.zone` selects from; unique, lowercase letters, digits and dashes. They end up in request paths and as the clone's `target` |
| `isoStorage` | yes | | storage id the seeds are uploaded to and deleted from. Must hold `iso` content; the token needs `Datastore.Allocate` on it. No default on purpose |
| `storage` | | the template's disk storage | storage id the full clone's disks go to |
| `bridge` | | as cloned | replaces `bridge=` in the cloned `net0` |
| `vlanTag` | | as cloned | 1 to 4094; replaces or appends `tag=` in the cloned `net0` |
| `vmidRange.min`, `vmidRange.max` | | `100000`, `199999` | where VMIDs are allocated; at least 100, at most 999999999, min below max |
| `templates.<key>.vmid` | at least one template | | the key names the template in `placement.image`; the VMID is at least 100 |
| `machineTypes.<key>` | at least one | | `{cores, memoryMiB, diskGiB}` size profile. `cores` and `memoryMiB` above zero. `diskGiB` is optional: when larger than the cloned boot disk the disk is grown to it; it is never shrunk |
| `requestTimeoutSeconds` | | `30` | bound on one API request, response included. The seed upload is one request, so a multi-megabyte `vendorData` needs more |
| `vendorData` | | | extra cloud-init vendor-data placed on every seed next to the Proxy's user-data; a MIME multipart part beside the agent script when `installGuestAgent` is on. Cloud-init drops any top-level key of a vendor-data cloud-config that the user-data also defines |
| `installGuestAgent` | | `true` | add the vendor-data script that installs and starts `qemu-guest-agent`. Turn it off for templates that ship the agent |
## Placement
All three catalogue fields are checked before any request. An unknown
value is `ErrPermanent`, which the reconciler latches as `Failed` with the
message below until the spec changes.
| Proxy field | Meaning for proxmox | Error when unknown |
|---|---|---|
| `placement.zone` | a node from `nodes`. May be omitted when exactly one node is configured; otherwise required | `placement.zone "x" is not in proxmox.nodes`; with several nodes and no zone, `placement.zone is required when proxmox.nodes lists more than one node` |
| `placement.machineType` | a key in `machineTypes` | `placement.machineType "x" is not in proxmox.machineTypes` |
| `placement.image` | a key in `templates` | `placement.image "x" is not in proxmox.templates` |
| `placement.region` | ignored. It is still part of the spec hash, so changing it replaces the VM and does nothing else | |
The messages come from `placement` in `internal/provider/proxmox/create.go`
and `node` in `proxmox.go`. A key that exists but names a VMID that is
missing or not a template fails later, before any clone:
`placement.image "x": template VM 9001 does not exist` or `... is not a
template`.
**Catalogue edits affect only new VMs.** The spec hash covers the
placement values, not what the config maps them to, so repointing
`templates.debian-13.vmid` at another template changes the VMs created
afterwards and leaves the running ones alone. Add a new key rather than
repointing one in use, so the Proxies that name a key keep a meaning you
can reason about. A template may be edited, replaced or deleted without
affecting existing VMs: they are full clones and share nothing with it.
## What lands on Proxmox
For a Proxy whose deterministic name is `proxy-abc…`:
| Object | Name | Lifetime |
|---|---|---|
| VM | name `proxy-abc…`, a VMID from `vmidRange` | with the Proxy |
| its disks | the template's disks, fully copied onto `storage` (or the template's storage) | destroyed with the VM (`purge=1&destroy-unreferenced-disks=1`) |
| seed ISO | `<isoStorage>:iso/proxy-abc…-seed.iso` on the VM's node | with the Proxy |
**The VMID** is derived from the Proxy's UID, so a retried Create lands on
the same number: an FNV-1a hash of the UID placed in `vmidRange`, moving
forward one at a time on collision, up to 32 probes (`vmidFor` in
`id.go`). Before allocating, `Create` looks the name up in the cluster's
resources. A VMID that is taken, or a VM that already has the name, goes
through an ownership check (`inspect` in `create.go`): a VM with a lock
(a clone still running, which also has no name yet) is waited on; then
- another name: someone else's VM, so the next VMID is probed;
- our name and our mark with our UID or an empty one: **adopted**, and
Create carries on from where the previous attempt stopped;
- our name without the mark, or with another UID: **permanent error**
naming the VMID and both UIDs. The operator never takes over a VM it did
not create;
- our name, our mark, hibernated (`lock: suspended`): **permanent error**,
`resume or destroy it in Proxmox` (see *Known gaps*).
Two VMs with the proxy's name are a permanent error naming every VMID;
remove all but one.
**The providerID** is `vms/<vmid>/<name>`. It names no node, because VMs
migrate: the node is looked up in `GET /cluster/resources` on every call.
`Get` and `Delete` check that the VM at that VMID still has that name and
the mark. Another name means ours is gone and the VMID was reused: `Get`
answers NotFound, `Delete` succeeds without touching it.
**The mark** is three lines in the VM's description (Notes in the UI),
passed in the clone call itself, so a VM is never unmarked:
```
proxy-operator-managed=true
proxy-operator-uid=0f3a…
proxy-operator-created-at=2026-09-23T17:42:11Z
```
That is how orphan GC recognises its own VMs and how a repeat `Create`
knows whether a VM of the same name belongs to this Proxy. Lines an admin
adds to the description are kept and ignored. The clone's own
`meta: ctime=` is useless for this: Proxmox copies it from the template.
**The config write.** After the clone and the seed upload, one
`PUT /nodes/{node}/qemu/{vmid}/config` sets:
| Key | Value |
|---|---|
| `cores`, `memory` | from the machine type |
| `agent` | `1` |
| `tags` | `proxy_operator` |
| `ide2` | `<isoStorage>:iso/<name>-seed.iso,media=cdrom` |
| `net0` | only when `bridge` or `vlanTag` is set: the cloned value with `bridge=` and `tag=` replaced or appended, the model, MAC and every other option kept |
The write **replaces the template's `agent` option and tags**: a
template's `agent: 1,fstrim_cloned_disks=1` becomes `agent: 1`, and its
tags give way to `proxy_operator`. The tag is for filtering in the
Proxmox UI and narrows GC's candidates; the mark decides. Proxmox appends
`ide2` to the boot order on its own (`order=scsi0;ide2`), which is
harmless because the boot disk stays first. When the machine type's
`diskGiB` is larger than the boot disk (the first disk in `boot: order=`
that is not a CD-ROM), a resize task grows it; cloud-init's growpart
expands the root filesystem at first boot.
**The seed** (`nocloud.BuildSeedISO`, shared with kvm2 in
`internal/provider/nocloud`) is an ISO 9660 image labelled `cidata`.
`user-data` is the Proxy's `cloudInit` **byte for byte**; the operator
never parses or re-renders it. `meta-data` holds `instance-id: <name>` and
`local-hostname: <name>`. `vendor-data` carries the guest-agent script
(a shell script, not a cloud-config, so a Proxy's own `packages:` cannot
shadow it), joined with `vendorData` as a MIME multipart when both are
set. It is staged under `/tmp` (the manager's emptyDir), built in memory
and uploaded with `POST /nodes/{node}/storage/{isoStorage}/upload`. An
upload overwrites a file of the same name, so it is rebuilt and
re-uploaded on every Create attempt with no delete first: a previous
attempt may have been cut off. Only the API is used; the storage upload
endpoint does not accept `snippets`, so Proxmox's own `cicustom` would
need shell access to the node.
**What a retried Create finds.** Every step is safe to repeat. After a
crash mid-clone, the retry lands on the same VMID, waits for the clone's
lock, and adopts the VM. A stopped VM of ours gets the seed, the config
and the resize again (the upload overwrites, the config is a replace, the
resize is skipped once large enough), then the start. A running one is
returned at once.
**A clone lock that never clears.** A node that crashes mid-clone can
leave the VM locked with `lock: clone` for good. Every Create for that
Proxy then waits out the 10 minute clone budget and returns a transient
error, and the reconciler keeps retrying rather than latching. Clear it
on the node:
```sh
qm unlock <vmid> # then let the next Create adopt and finish it
qm destroy <vmid> --purge # or remove it and let Create clone again
```
**Seeds without a VM.** A VM destroyed outside the operator leaves its
seed ISO, which GC cannot see (it lists VMs). Seeds are named
`proxy-*-seed.iso` on `isoStorage`; any without a matching VM may be
removed by hand:
```sh
pvesm list proxy-seeds --content iso
pvesm free proxy-seeds:iso/proxy-abc…-seed.iso
```
**Delete** runs under a per-name lock shared with Create: stop (a paused
VM too), destroy with `purge=1&destroy-unreferenced-disks=1`, then delete
the seed on the VM's node. Each is a task that is waited on. A VM that is
already gone, not ours, or unmarked is left alone, and the seed is then
looked for on every configured node, where a node that fails is logged
and skipped. On the VM's own node a failed seed delete fails the call:
**without `Datastore.Allocate` on `isoStorage` the 403 makes Delete
permanent**, and the Proxy's deletion stays stuck with that message until
the privilege is granted. When Delete returns nil, nothing of the Proxy
is left.
## Logging
The provider logs through controller-runtime's logger, named `proxmox`,
with `provider` (the configured name) on every line, and `vmid`, `name`
and `node` on every line about an instance. Every line inside a traced
operation also carries `traceID` and `spanID`.
- At startup (always on): `WARNING: token file is not readable; every
call fails until it is` with `path` and `reason`, and `WARNING:
insecureSkipTLSVerify is set`.
- `--zap-log-level=debug` (logr V(1)):
- one line per operation outcome: `proxmox vm ensured running`,
`proxmox vm adopted` with `status` and `qmpstatus`,
`proxmox vm fetched` with `state`, `ip`, `uid`, `status` and
`qmpstatus`, `proxmox vm not found` with `reason`,
`proxmox vm deleted` with `destroyed` (false when no VM of ours was
left to destroy), `proxmox vms listed` with `vms`, `candidates`,
`managed` and `skipped` counts, and `proxmox node checked` with
`ready`, `reason` and `latency`;
- **one `proxmox api call` line per API request**, written by the
decorator in `internal/provider/proxmox/observe.go`: `call` (the seam
method, for example `Clone`), the identifiers it was given (`node`,
`vmid`, `storage`), `upid` for a task, `newid` and `name` on a clone,
`filename` on an upload, `volid` on a volume delete, `duration`, and
on failure `httpStatus` and `error`. A running `Get` is four calls; a
fresh Create is a dozen or more, depending on how many polls each
task takes;
- `proxmox task failed` with `upid` and `exitstatus`, and
`proxmox task finished with warnings`;
- the no-op lines: `proxmox vm already gone`,
`proxmox vm at this vmid is not ours, left alone` with `vmName`,
`proxmox vm carries no proxy-operator mark, left alone`,
`proxmox seed delete failed on a node, skipped`,
`skipping unreadable proxmox vm`, and the probe's
`proxmox vmid taken by another vm, probing on`.
- `--zap-log-level=2`: `proxmox create resolved` with the resolved
parameters (node, machine type, cores, memory, disk, template VMID,
storages, bridge, VLAN tag) and `cloudInitBytes`; the `proxmox api
call` lines also carry the config write's parameters, a summary of each
config read, the clone's description, storage and target, the resize
size, VM and task status, the agent's addresses and the upload's byte
count; `proxmox guest agent answered`; `proxmox listed instance` per
listed VM.
- Never logged at any level: the cloud-init content, the token, the CA
content and the upload body. There is no raw wire tier.
## Tracing
With an OTLP exporter configured ([architecture.md
§10](architecture.md#10-tracing-internaltracing)), every API request is a
child span of the operation span the decorators open (`provider.create`,
`provider.get`, `provider.delete`, `provider.list`) or of the monitor's
`providerhealth.check`:
| Span | Around | Attributes |
|---|---|---|
| `HTTP GET`, `HTTP POST`, `HTTP PUT`, `HTTP DELETE` | one API request, from `otelhttp` on the client's transport | otelhttp's standard HTTP client attributes: method, URL, server address and port, response status |
The transport is `tracing.RestConfigWrapper()`, the same parent-gated
wrapper the kubernetes provider uses, so a request without a parent span
starts none. The logging decorator starts no span of its own, so nothing
is counted twice; its `proxmox api call` line carries the operation
span's `spanID`, not the HTTP span's. The token travels in a header,
which otelhttp does not record, and no attribute carries cloud-init.
## Timeouts
No caller puts a deadline on the context, so every request and every wait
carries its own budget (`defaultBudgets` in
`internal/provider/proxmox/proxmox.go`, and the transport in `client.go`):
| Bound | Budget |
|---|---|
| TCP connect | 10 s |
| TLS handshake | 10 s |
| one request, response included | `requestTimeoutSeconds`, default 30 s |
| clone task, and the wait for a VM's lock in the ownership check | 10 m |
| upload task, resize task, start task, stop task | 2 m each |
| destroy task | 5 m |
| seed delete task | 1 m |
| wait for `running` after the start | 60 s |
| task and status poll interval | 1 s |
A task wait that runs out is a transient error naming the UPID; the task
keeps running on Proxmox, and the next attempt finds its result through
the ownership check or the lock. A task that ends with any `exitstatus`
other than `OK` or `WARNINGS: <n>` is transient too, with Proxmox's text.
Requests share one HTTP client and its idle connections (4 per host,
closed after 90 s idle). A cancelled or overlong request ends only
itself: nothing else in flight is disturbed, and there is no connection
to kill as there is for kvm2. HTTP/2 is disabled, because pveproxy speaks
HTTP/1.1 only. Create and Delete for the same name are serialised on a
per-name lock, so the reconciler's Delete and GC's Delete never race on
stop and destroy; Get and ListByTag take no lock.
## Health check
The operator checks every configured node every
`--provider-check-interval` (default 1m) and publishes the result in the
`ProviderHealth` object named after the provider
(`kubectl get ph proxmox-home -o yaml`) and in the
`proxy_operator_provider_target_up{provider,target}` gauge. One target per
`nodes` entry, named after the node with the URL as its address, in
config order, all checked concurrently.
Each check (`checkNode` in `internal/provider/proxmox/check.go`) first
reads the token file for its id, then makes four requests on the shared
client: `GET /version`, `GET /nodes/{node}/status`,
`GET /nodes/{node}/storage/{isoStorage}/status` (the storage must exist,
be active and hold `iso` content), and `GET /cluster/resources?type=vm`
(every configured template must exist and be a template). A check that
runs out of time cancels only its own requests.
Every target's `details` carry `isoStorage`. As facts arrive they gain
`tokenID` (the part of the token before `=`, never the secret),
`pveVersion`, `kernel`, `isoStorageAvailGiB` and `templates`
(`<found>/<configured>`), so a target that fails late still says what the
node answered before. There is no quota signal; the storage's free space
is here instead.
| Reason | Meaning | Look at |
|---|---|---|
| `Reachable` | ready | |
| `CredentialsUnavailable` | the token file is missing or not `<user>@<realm>!<tokenid>=<secret>`; no request was made | the `proxmox-token` Secret; fixed in the cluster, not in Proxmox |
| `CertificateInvalid` | TLS verification failed, or `caFile` is missing or holds no certificate | `url` (its host must be in the node certificate) or `caFile`; not the node |
| `Unreachable` | the TCP connect or TLS handshake failed for a network reason, the connection was cut mid-response, or pveproxy answered 595 or 596 (it cannot reach the node behind it) | routing and firewalls between the cluster and `url`; `pveproxy` running; the node up |
| `Unauthorized` | 401: the token id or secret is wrong | the token file; `pveum user token list proxy-operator@pve` |
| `Forbidden` | 403; the message names the path and the privilege | the roles in *Proxmox prerequisites* |
| `Timeout` | the monitor's `--provider-check-timeout` (default 15s) ran out, whichever request was running, or a request ran out of `requestTimeoutSeconds` while the round still had time | a slow or hung pveproxy, a saturated link |
| `StorageNotFound` | `isoStorage` answered with a 500 on that node (it does not exist, is not enabled there, or is offline), is not active, or holds no `iso` content; the message carries Proxmox's text | `pvesm status`; the storage's nodes and content types |
| `TemplateNotFound` | a configured template VMID does not exist or is not a template | `qm list`; the `templates` catalogue |
| `APIError` | any other failure | the message carries Proxmox's own text |
The check does not look inside the templates: a cloud-init drive, a
missing `net0` or an unreadable disk size surface only when a Create is
about to clone.
## Error classes
Every error is classified once, in `classify` in
`internal/provider/proxmox/errors.go`, with errors that carry a class from
their source (validation, the token and CA files, the ownership check)
keeping it. The reconciler latches `ErrPermanent` as `Failed` until the
spec changes, recreates on `ErrNotFound` from `Get`, and retries
everything else with backoff. The provider never returns
`ErrQuotaExceeded`: the thin `local-lvm` storage overprovisions, so no
out-of-space answer could be produced to match, and capacity failures are
transient.
| Class | What lands there |
|---|---|
| `ErrPermanent` | placement values not in the catalogue; a template VMID that is missing or not a template, has a cloud-init drive, or lacks `net0` or a sized boot disk when the config needs them; a malformed providerID; any 4xx (401 `Authentication failed!` or `No ticket`: the token; 403 `Permission check failed (<path>, <privilege>)`: a missing privilege, including `Datastore.Allocate` for the seed delete; 400 `Parameter verification failed.` with the field errors); a TLS verification failure; a token file that exists but is not `<user>@<realm>!<tokenid>=<secret>`; a CA file with no certificate; a VM with the proxy's name that carries no mark or another UID; two VMs with the proxy's name; our VM hibernated (`lock: suspended`), in both Create and Delete |
| `ErrNotFound` | from `Get`: the VMID absent from the cluster's resources, the 500 `Configuration file 'nodes/<node>/qemu-server/<vmid>.conf' does not exist`, the VM at the VMID now named differently or unmarked. Inside `Create` the `does not exist` answer is `ErrTransient`, because the contract forbids NotFound from Create; `Delete` treats all of these as done |
| `ErrTransient` | everything else: a missing token or CA file (the Secret has not landed yet); network errors and cancellation; 500 `VM is locked (<lock>)` and any lock seen in the resources or config; a VM whose node does not answer (`status: unknown`); the agent's answers that `Get` cannot place, such as `VM <vmid> is not running`; any other 5xx, including pveproxy's 595 and 596; a task that fails or runs out of its budget; no free VMID after 32 probes; the wait for running expiring, or a VM that stops right after its start |
`Get` turns two agent answers into states rather than errors:
`QEMU guest agent is not running` is Provisioning (booting, or the agent
install has not finished), and `No QEMU guest agent configured` is
Stopped (someone removed `agent: 1`; the reconciler replaces the VM).
Error strings name VMIDs, nodes, storages, UPIDs and Proxmox's own text.
They never contain the token, the CA content or cloud-init.
## Known gaps
- **Create blocks** until the VM runs, holding one of the three reconcile
workers for its duration. On `pv1-1` that is about five seconds plus the
clone; large templates on slow storage take longer. Other Proxies keep
reconciling on the remaining workers.
- **A clone lock that never clears** (a node crash mid-clone) makes every
Create for that Proxy wait out the 10 minute clone budget and return a
transient error. Clear it with `qm unlock <vmid>` or destroy the VM (see
*What lands on Proxmox*).
- **Seed ISOs of VMs destroyed outside the operator are invisible to
GC.** Remove `proxy-*-seed.iso` files without a matching VM by hand.
On a storage that is not shared, a VM migrated to another node leaves
its seed on the original node; Delete then looks on the VM's current
node, finds nothing, and the seed stays. Shared ISO storage avoids it.
- **The VMID range is shared with humans.** A VM created by hand inside
it costs a probe; a hand-made VM named like a proxy is refused as
`ErrPermanent`. Reserve the range.
- **Cross-node clones need shared storage and are untested.** When the
zone's node differs from the template's, the clone is sent with
`target`, which Proxmox allows only when the template's disks are on
shared storage. The host this was built on has one node.
- **A VM whose agent was switched off is replaced**, not repaired: with
`agent: 1` removed it can never report an address, so `Get` reports it
Stopped.
- **The proxy network needs internet** for the agent install, unless the
template ships the agent and `installGuestAgent` is `false`. Without it
the VM boots, gets an address, and stays `Provisioning` forever,
because the agent is the only address source. Check
`systemctl status qemu-guest-agent` and
`/var/log/cloud-init-output.log` on the guest (`qm terminal <vmid>` on
the node, or the console in the UI).
- **The CA is cached** after first use; rotating it needs a manager
restart. The token rotates freely.
- **A node that stops answering** makes `Get` and `Delete` for its VMs
transient until it returns, so deleting such a Proxy waits. Proxmox
lists an unreachable node's guests with `status: unknown` rather than
dropping them, so they are never mistaken for gone. That is Proxmox's
documented behaviour; a single-node host could not show it.
- **Contract limits that apply unchanged** ([provider-contract.md
§13](provider-contract.md)): the providers config is read once per
process; GC has no installation identity, so two operators sharing a
Proxmox cluster and a VMID range delete each other's VMs; and a Proxy
naming an unconfigured provider latches until its spec changes.
Beyond the ten limits accepted in the design:
- **A hibernated VM latches its Proxy.** A VM of ours with
`lock: suspended` (hibernated to disk) makes both Create and Delete fail
permanently with `resume or destroy it in Proxmox`: the lock never
clears on its own, and the destroy refuses a locked VM. `Get` reports it
Stopped. Resume it by starting it (`qm start <vmid>` restores the
hibernated state) or destroy it on the node, then touch the Proxy's
spec, or let the pending Delete retry.
- **The seed upload is one request**, bounded by `requestTimeoutSeconds`.
A seed is tens of KiB, but a multi-megabyte `vendorData` would need a
larger timeout.
- **The sample cloud-init is an open proxy.**
`config/samples/proxy_proxmox.yaml` installs Squid with
`http_access allow all`; restrict it before exposing the proxy network
to anything but the cluster.
- **Not yet proven end to end.** The unit tests run over captured
responses from `pv1-1` and an in-memory fake; the integration tests
below compile but have not run against a host yet. Three answers were
never captured: the storage status for a missing storage, a destroy on
a hibernated VM, and a stop on a paused VM.
## Testing
Unit tests run over an in-memory fake of the API seam, and the HTTP
client over a TLS test server replaying the responses captured from
`pv1-1` (`internal/provider/proxmox/testdata/`). The optional integration
test against a real host is described in
[testing.md](testing.md#the-proxmox-integration-test-real-proxmox-ve-host),
which also lists the variables it needs; run it with
`make test-integration-proxmox`. `TestProxmoxCheck_realHost` runs the
health check against the same host and creates nothing:
`go test -tags=integration -count=1 -v -run TestProxmoxCheck ./internal/provider/proxmox/`.