Phase 10. Two review passes (go-operator-reviewer, /code-review high) over the branch, recorded in docs/reviews/2026-09-23-proxmox-provider-review/: 9 findings fixed, 3 accepted as documented limits, 7 rejected with a reason. - Delete removes the seed between the stop and the destroy, so a failed seed delete leaves the VM for the next Delete instead of leaking the ISO; a Permanent refusal on the every-node path fails the call. - Create skips a probe VMID whose listed name is someone else's without waiting on its lock. - The three answers Phase 0 never captured are fixtures now: a missing storage is a 400 (the Checker reports StorageNotFound), a hibernated VM is destroyable (Delete destroys ours), a paused VM stops. - TestProxmox_lifecycle and TestProxmoxCheck_realHost passed on pv1-1 (PVE 9.2.20); the docs record the run and drop "not yet run". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
38 KiB
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, from the design in
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:
-
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).
-
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: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 9001The 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-agentor be able to install it at first boot. Before the first clone for a Proxy,Createreads the template's config and refuses it with a permanent error when:- it is not a template (
qm templatewas not run); - any disk slot holds a Proxmox cloud-init drive (a value containing
cloudinit), such as the oneqm set <vmid> --ide2 local-lvm:cloudinitadds for Proxmox's own cloud-init support; - it has no
net0while the config setsbridgeorvlanTag; - the machine type sets
diskGiBand the template has no boot disk, or one without a readablesize=.
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.
- it is not a template (
-
A storage for the seed ISOs, holding
isocontent. A dedicated directory storage is best, because the token needsDatastore.Allocateon it to delete seeds, and on a dedicated storage that privilege cannot touch backups or other ISOs:pvesm add dir proxy-seeds --path /var/lib/proxy-seeds --content isoA 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). -
A user, two roles and a token.
VM.Monitorno longer exists in PVE 9 andpveumrejects it; the agent read needsVM.GuestAgent.Auditalone.VM.Config.Cloudinitis not needed for the seed onide2.SDN.Useon/is what lets the config write put a VLAN tag onvmbr0: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 0The 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 withDatastore.Allocatealone would take the upload'sDatastore.AllocateTemplateaway. The role on/covers clone, config, resize, start, stop, the agent read, the upload and the destroy; what it lacks isDatastore.Allocate, without which deleting a seed is a 403 (Permission check failed (/storage/<id>, Datastore.Allocate)). With--privsep 0the 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-tokenwith mode 0600. Copy the node's CA,/etc/pve/pve-root-ca.pem, to~/.config/proxmox-ca.pem. -
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-agentfrom 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. Bakingqemu-guest-agentinto the template and settinginstallGuestAgent: falseremoves the mirror from IP discovery. The operator's pods and the clients must route to that network: the health engine probeshttp://<ip>:<port>through the proxy, and clients get the same address from the discovery API. The provider does not usespec.portand configures no Proxmox firewall. -
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:
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
-
Create the Secret. It holds two keys,
token(the one-line token file) andca.pem(the node's CA):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.pemThe 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. WithinsecureSkipTLSVerifyinstead of a CA, create the Secret by hand with thetokenkey only.config/manager/manager.yamlmounts the Secret (optional, read-only) at/etc/proxy-operator/proxmox, so the files are/etc/proxy-operator/proxmox/tokenand/etc/proxy-operator/proxmox/ca.pem. The deployment starts fine without it, even with a proxmox provider configured:Newmakes no request, only checks that the token file exists, and logs aWARNING:line when it does not. The provider's proxies then fail transiently and recover once the Secret lands; the health check reportsCredentialsUnavailablemeanwhile.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. Keep the old token valid until then: a 401 is a permanent error, so a Proxy whose request lands in the gap latches until its spec changes. Add the new token (
pveum user token add), update the Secret, wait a couple of minutes for the mount to refresh, and only then remove the old token. A token created with--expireneeds the same rotation before it expires. Rotating the CA does need a restart: 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. -
Add a provider entry to the providers config (
config/manager/providers_config.yamlin-cluster; the full sample, commented out, is inconfig/samples/providers-config.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-1it lists192.168.0.8,pv1-1andpv1-1.home.lab, sohttps://192.168.2.8:8006reaches the same node and still fails verification (CertificateInvalidin the health check, a permanent error on every call). See what yours lists with:openssl s_client -connect 192.168.0.8:8006 </dev/null 2>/dev/null | openssl x509 -noout -ext subjectAltNameIf 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 setinsecureSkipTLSVerify: true, whichNewlogs as aWARNING:line.nodesholds 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. -
Apply a Proxy (
config/samples/proxy_proxmox.yaml) and watch it: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.)
Createreturns 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 onpv1-1with 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). This only happens when Create has not recorded the VM yet; a recorded VM that gets hibernated is reported Stopped and replaced.
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:
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:
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), delete the seed on the VM's node, then destroy with
purge=1&destroy-unreferenced-disks=1. Each is a task that is waited on.
The seed goes before the VM because once the VM is gone nothing calls
Delete again; a failed seed delete leaves the VM stopped, and the next
Delete finishes both. 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. A refusal every node would
give fails the call instead: without Datastore.Allocate on
isoStorage the 403 makes Delete permanent on either path, and the
Proxy's deletion stays stuck with that message (a replaced Proxy stays
failed, its VM stopped) 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 iswithpathandreason, andWARNING: insecureSkipTLSVerify is set. --zap-log-level=debug(logr V(1)):- one line per operation outcome:
proxmox vm ensured running,proxmox vm adoptedwithstatusandqmpstatus,proxmox vm fetchedwithstate,ip,uid,statusandqmpstatus,proxmox vm not foundwithreason,proxmox vm deletedwithdestroyed(false when no VM of ours was left to destroy),proxmox vms listedwithvms,candidates,managedandskippedcounts, andproxmox node checkedwithready,reasonandlatency; - one
proxmox api callline per API request, written by the decorator ininternal/provider/proxmox/observe.go:call(the seam method, for exampleClone), the identifiers it was given (node,vmid,storage),upidfor a task,newidandnameon a clone,filenameon an upload,volidon a volume delete,duration, and on failurehttpStatusanderror. A runningGetis four calls; a fresh Create is a dozen or more, depending on how many polls each task takes; proxmox task failedwithupidandexitstatus, andproxmox task finished with warnings;- the no-op lines:
proxmox vm already gone,proxmox vm at this vmid is not ours, left alonewithvmName,proxmox vm carries no proxy-operator mark, left alone,proxmox seed delete failed on a node, skipped,skipping unreadable proxmox vm, and the probe'sproxmox vmid taken by another vm, probing on.
- one line per operation outcome:
--zap-log-level=2:proxmox create resolvedwith the resolved parameters (node, machine type, cores, memory, disk, template VMID, storages, bridge, VLAN tag) andcloudInitBytes; theproxmox api calllines 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 instanceper 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), 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) found by Create's adoption |
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, or one a few seconds old the listing has not reported yet (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-1that 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.isofiles 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: 1removed it can never report an address, soGetreports it Stopped. - The proxy network needs internet for the agent install, unless the
template ships the agent and
installGuestAgentisfalse. Without it the VM boots, gets an address, and staysProvisioningforever, because the agent is the only address source. Checksystemctl status qemu-guest-agentand/var/log/cloud-init-output.logon 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
GetandDeletefor its VMs transient until it returns, so deleting such a Proxy waits. Proxmox lists an unreachable node's guests withstatus: unknownrather 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): 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 is replaced, or latches a Create.
Getreports a VM of ours withlock: suspended(hibernated to disk) as Stopped, so the reconciler deletes it (the destroy removes its suspend state volume too) and creates a new one. Only a Create that finds such a VM before it was ever recorded, by name, fails permanently withresume or destroy it in Proxmox: resume it by starting it (qm start <vmid>restores the hibernated state) or destroy it on the node, then touch the Proxy's spec. - A Create that failed permanently after its clone keeps its VM.
The stopped VM is adopted by name on the next Create, even when the
Proxy's
placementhas changed since: the Proxy then runs the old template on the old node. After changingplacementon such a Proxy, destroy the VM on the node (qm destroy <vmid> --purge) first, or delete and recreate the Proxy. - A retry can clone a second VM in one rare sequence: a Create is
cut off while cloning onto a later probe VMID (the first was taken by
someone else's VM), that other VM is removed, and the retry comes
while the clone is still running, so its VM is still nameless. The
retry clones onto the now free first VMID. The next Create then fails
permanently with
VMs … are all named …; destroy either VM. - The seed upload is one request, bounded by
requestTimeoutSeconds. A seed is tens of KiB, but a multi-megabytevendorDatawould need a larger timeout. - The sample cloud-init is an open proxy.
config/samples/proxy_proxmox.yamlinstalls Squid withhttp_access allow all; restrict it before exposing the proxy network to anything but the cluster.
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,
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/.
Both passed on pv1-1 (PVE 9.2.20) on 2026-09-24: Create took 6.2 s,
and the guest reported an address 50 s after Create returned.