Phase 1 of the proxmox provider plan: docs/proxmox-provider-spec.md answers all 24 entries of the provider spec template against the contract, citing the Phase 0 facts from pv1-1 as evidence. It folds in the ten corrections recon found. The two that matter most: Create inspects a taken VMID, waiting out a clone lock, before probing past it, so a crash-retry adopts its own half-cloned VM instead of cloning a duplicate; and Get checks the lock before the name, because a VM being cloned has no name yet. The plan now opens with a note that the spec wins where they disagree. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
56 KiB
Plan: proxmox, a Proxmox VE provider built from the provider contract
Created: 2026-09-18 15:02
Context
Superseded in places by the spec. Phase 0 recon found ten corrections to the design below (listed in the execution log), and Phase 1 folded them into docs/proxmox-provider-spec.md. Where a phase step here disagrees with the spec (the Create allocation loop, Get's check order, resize and seed delete as tasks, no delete before upload,
isoStoragerequired,vlanTag, the agent messages), the spec wins. Test names in the phases below remain the minimum; spec entry 21 adds the ones for the corrections.
The provider contract (docs/provider-contract.md), the spec template
(docs/provider-spec-template.md) and the kvm2 build proved the workflow:
spec first, then implementation against the contract, one commit per step.
This plan adds a third VM-style backend, proxmox, on the Proxmox VE 9.x
REST API at https://<host>:8006/api2/json. The user has a real host at
192.168.2.8 running 9.2.20 (Debian 13 Trixie, QEMU 11) and prepares the
API token and a cloud-init-capable template before Phase 0 (checklist
below). Everything below is written against 9.2; where 8.x differed the
difference is called out, because the appendix and the guide must name the
version they were proven on.
The implementation runs in sessions whose context gets compacted, so every
phase is self-contained: it names what to read first, what it creates or
edits, the test names it must add, a Verify: clause, a "Done when" line
and a commit subject. A fresh session reading only this plan, the spec
(docs/proxmox-provider-spec.md, from Phase 1 on) and the execution log's
checklist (docs/plans-executions/2026-09-18-1502-proxmox-provider.md) must be able to
pick up where the last one stopped.
Resumption protocol, run at the start of every phase. Read this plan's
Context and the phase entry; read the spec; read the execution log's
checklist; run git branch --show-current (must be feat/proxmox-provider
from Phase 0 on), git status --short and git log --oneline -8. The first
unchecked box is the phase to do. If its files already exist partially, its
"Done when" line says whether it counts as done; finish it, never redo it. A
phase is finished only when its "Done when" holds and its execution-log entry
(a few sentences, exact commands, "worth noting", the box ticked) is
committed together with the code. Follow CLAUDE.md for the log format.
Before Phase 0: what the user prepares on 192.168.2.8
Run as root on the Proxmox node. VM.Monitor was removed in PVE 9.0, so
the agent read rests on VM.GuestAgent.Audit alone and the role below must
not mention VM.Monitor. Drop any privilege pveum still rejects and note
it for F17.
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 user token add proxy-operator@pve operator --privsep 0
# The secret is printed once. Save it on the workstation as one line:
# proxy-operator@pve!operator=<secret>
# in ~/.config/proxmox-token (mode 0600). The same file becomes the
# Kubernetes Secret later.
cp /etc/pve/pve-root-ca.pem /tmp/ # copy to the workstation as ~/.config/proxmox-ca.pem, or use insecureSkipTLSVerify
A cloud-init template without a Proxmox cloud-init drive (the provider puts
its own NoCloud ISO on ide2). Debian 13's BIOS boot path did not boot
under kvm2, so the template boots UEFI:
wget https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
qm create 9000 --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 9000 --scsi0 local-lvm:0,import-from=/root/debian-13-generic-amd64.qcow2
qm set 9000 --boot order=scsi0
qm template 9000
Facts the user hands over at Phase 0: the token file path, the CA file path
(or "insecure"), the node name, the template VMID, the ISO-content storage
id (usually local), the VM disk storage (local-lvm), the bridge, and
whether the operator's cluster can route to that bridge's LAN. Everything
else Phase 0 reads from the API.
Design decisions made with the user (not re-opened)
- Backend and client. Proxmox VE 9.x REST API, JSON, HTTP/1.1 only
(pveproxy offers no HTTP/2 on any release; only Proxmox Backup Server
does).
Thin client on
net/http(client-kind: thin-client), no SDK, no new dependency. Auth headerAuthorization: PVEAPIToken=<user>@<realm>!<tokenid>=<secret>, read from a mounted token file on every request (rotation free). TLS:caFileorinsecureSkipTLSVerify(warned at startup). Child spans from a parent-gated otelhttp transport:tracing.RestConfigWrapper()(base)returns a plainhttp.RoundTripperand works as is (child-spans: transport-wrapper). - Image source. Clone a prepared template. Config catalogue
templates: {<key>: {vmid: 9000}};Placement.Imageis a key.Placement.Zoneis a node name fromnodes: [..](optional with one node).Placement.MachineTypeis a key ofmachineTypes: {<key>: {cores, memoryMiB, diskGiB}};diskGiB(optional) grows the boot disk viaPUT .../resize.Placement.Regionis ignored (documented). - Identity.
primary-key: allocated-id. VMID derived deterministically from the Proxy UID (FNV-1a 64 intovmidRange: {min, max}, default 100000 to 199999) with forward probing on collision; a clone whosenewidis taken fails with a 500, which is the allocate-then-verify step. Create first looks the name up inGET /cluster/resources?type=vm; an existing VM with that name must carry our mark and a matching or empty UID, else Permanent. providerID is location-free:vms/<vmid>/<name>(VMs migrate; the node is resolved from/cluster/resourceson every call). Get and Delete verify the VM at that VMID has that name and our mark; a different name means our VM is gone and the VMID was reused: Get returns NotFound, Delete returns nil without touching it. - Cloud-init. NoCloud seed ISO built in-process by reusing kvm2's seed
code, extracted into a shared package
internal/provider/nocloud(its own phase). Uploaded toisoStorage(defaultlocal) on the target node viaPOST /nodes/{node}/storage/{storage}/upload(multipart,content=iso,filename=<name>-seed.iso, streamed throughio.Pipe, returns a UPID), attached aside2: <storage>:iso/<name>-seed.iso,media=cdrom. The template must have no Proxmox cloudinit drive on ide2 (documented prerequisite). Vendor-data installs qemu-guest-agent like kvm2 (installGuestAgent,vendorData). - GC mark.
gc-mark: written-metadatain the VMdescription, which the clone call accepts (tagsis not a clone parameter, so tags would be a mark window). Content, one per line:proxy-operator-managed=true,proxy-operator-uid=<uid>,proxy-operator-created-at=<RFC3339>.created-at: written-at-create. The config step after the clone also sets tagproxy_operatorfor UI filtering only. Underscore, not hyphen: current PVE tag validation accepts only lowercase letters, digits and underscore, and a hyphen is rejected; the underscore form is valid under both the strict and the permissive reading, so it is used regardless of what F9 finds. ListByTag: oneGET /cluster/resources?type=vm; candidates are entries with tagproxy_operatoror name prefixproxy-; aGET /nodes/{node}/qemu/{vmid}/configper candidate for the description; unmarked skipped; one unreadable VM skipped with a V(1) line unless ctx ended; the resources call failing fails the whole list (list-partial: fail-whole). - Create (
create-return: blocking,provisioning-state: create-blocks,async-ops: task-handleandbackend-lock), every step idempotent, in this order: validate placement (Permanent before any call); resources lookup by name (adopt or allocate);POST /nodes/{tmplnode}/qemu/{tmplvmid}/clone{newid, name, description, full=1, target?, storage?}; wait the task (exitstatus != OKis Transient with the message; a VM withlock=cloneon retry is waited on); upload the seed (always rebuilt, delete then upload; before the config step becauseide2may refuse a volume that does not exist yet); config (cores,memory,net0bridge override if configured,agent=1,tags,ide2seed); optional resize;POST .../status/startand wait; pollstatus/currentuntilrunning(capped); returnvms/<vmid>/<name>. Retry: name found running returns the ID; found stopped with our mark continues from the seed step; found locked waits. - Get. Resources lookup by vmid: absent is NotFound; name mismatch or no
mark is NotFound (logged);
lockset is Transient;runningreadsGET /nodes/{node}/qemu/{vmid}/agent/network-get-interfaces, first routable IPv4 excluding loopback and link-local gives Running, else Provisioning (the agent not running is a 500 "QEMU guest agent is not running", which is Provisioning, not an error);stoppedis Stopped;unknownis Transient;qmpstatus=pausedis Stopped; anything else is Stopped. UID and CreatedAt come from the description on every call, so a Get is three calls: resources, config, agent. - Delete (
delete-return: waits,secondary-resources: explicit): resolve by vmid via resources; absent or foreign is nil (logged); running getsPOST status/stopand a task wait; thenDELETE /nodes/{node}/qemu/{vmid}?purge=1&destroy-unreferenced-disks=1and a task wait; then delete the seed ISODELETE /nodes/{node}/storage/{storage}/content/{storage}:iso/<name>-seed.isotolerating absence; a locked VM is Transient. Called again mid-delete it repeats the same tolerant steps. GC blind spot: seed ISOs without a VM (documented; the ISO name carries the instance name). - Errors, one
classify(op, err)over*apiError{Status, Message, Errors}: 401, 403, 400 and 404 are Permanent (404 means the path never exists; NotFound comes only from the list-and-miss on resources and from the 500 "does not exist" message in Get and Delete, matched against captured fixtures); 500 "does not exist" is NotFound (Transient inside Create); 500 "VM is locked" is Transient; 500 "already exists" is handled by the allocator, otherwise Transient; 500 "not enough free space" or "no space left" is QuotaExceeded once a real message is captured, elsequota: absent; other 5xx, 595, 596, network and ctx errors are Transient; anything not an apiError is Transient. Error text: Proxmox puts most of it in the HTTP status reason phrase (HTTP/1.1 500 Configuration file '...' does not exist) and newer releases also in the body'smessage; the client reads both, body first. - Checker. One target per configured node, in config order, concurrently,
on the same http client:
GET /version(DetailspveVersion),GET /nodes/{node}/status(Detailskernel),GET /nodes/{node}/storage/{isoStorage}/status(DetailsisoStorage,isoStorageAvailGiB), template existence fromGET /cluster/resources?type=vm(thetemplateflag, any node; Detailstemplatesas<found>/<configured>). Reasons: the shared ones plusTemplateNotFoundandStorageNotFound. Thestatus.typecomment inapi/v1alpha1/providerhealth_types.gogainsproxmox;make manifests. - Timeouts (
timeouts: per-call): request timeout 30s (requestTimeoutSeconds); task-wait caps clone 10m, upload 2m, start and stop 2m, destroy 5m, running wait 60s; all in abudgetsstruct overridable in tests. A cancelled ctx cancels that request only; the connection pool is unaffected. Per-name keyed lock for Create and Delete (internal/provider/keyedlock, extracted from kvm2); Get and ListByTag lock-free. - Logging (
logging: tiered):logf.FromContext(ctx).WithName("proxmox").WithValues("provider", name),vmidandnameon instance lines; V(1) outcomes and oneproxmox api callline per seam call from theobserveddecorator; V(2) request params and response summaries; never the token, never cloud-init, never the multipart body, neverdescriptionat V(1); no V(5). The decorator logs, the transport traces: noproxmox.<Method>spans, so nothing is double-counted. - Config writes use
PUT .../config(synchronous; the VM is stopped at that point).resizeis skipped when the boot disk's currentsize=is at or above the target. Boot disk: first disk key inboot: order=...that is notnet*and not the ide2 cdrom, elsebootdisk, else the first ofscsi0,virtio0,sata0,ide0; none is Permanent. - A UPID names its node (
UPID:<node>:...); task polling uses the node parsed from the UPID. - Two VMs with our name (Proxmox does not enforce unique names) make Create and Get return Permanent naming both VMIDs; an admin resolves it.
- Seam.
apiinterface with one method per endpoint (table below),httpAPIproduction adapter,fakeAPIinfake_test.gokeeping VMs, tasks and ISOs in memory with lock simulation, task polls, per-method errors and block channels, and a call log. - Integration test
proxmox_integration_test.gobehind//go:build integration,PROXMOX_TEST_URL,PROXMOX_TEST_TOKEN_FILE,PROXMOX_TEST_NODE,PROXMOX_TEST_TEMPLATE_VMID,PROXMOX_TEST_ISO_STORAGE, optional_CA_FILE,_INSECURE,_BRIDGE,_STORAGE,_VMID_MIN,_VMID_MAX,_TIMEOUT,_KEEP,_LOG_LEVEL;make test-integration-proxmox. - Deployment. Secret
proxmox-tokenmounted optional at/etc/proxy-operator/proxmox/token,make proxmox-token-secret, commented entries inconfig/manager/providers_config.yamlandconfig/samples/providers-config.yaml,config/samples/proxy_proxmox.yaml,docs/proxmox-provider.md,providers-existing.mdcolumn and section,architecture.mdrows, README row,testing.mdsection, CHANGELOG after confirmation. Branchfeat/proxmox-provider, MR via the Gitea API (notea).
Backend facts Phase 0 must verify on 192.168.2.8 (numbered for citation)
- F1. Where error text lives: status reason phrase vs. JSON
message; theerrorsmap shape on a 400. - F2. Exact 500 message for a missing VM (
GET /nodes/{node}/qemu/999999/config). - F3. Exact 500 message for a locked VM (
POST status/starton a VM withlockset), and whetherPUT configwithlock=backupsets the lock anddelete=lockclears it through the API, orqm unlockis needed. - F4. Exact 500 message when
newidis in use on clone. - F5. Exact 500 message for the agent not running, and for the agent call on a stopped VM.
- F6. Exact message for no space, if it can be produced cheaply; otherwise
quota: absent. - F7. Whether
PUT .../configis refused whilelock=clone, and with which message. - F8.
/cluster/resources?type=vmfields present:vmid,name,node,statusvalues (running,stopped,unknown),type(qemu,lxc),lock,tags(separator),template; whetherlockappears there during a clone. - F9. Tag rules on 9.2: confirm a hyphen is rejected and
proxy_operatoraccepted, plus case folding, length, and thetagsseparator onPUT configand in/cluster/resources. The underscore form stays either way; this fact only decides what the guide tells users about their own tags. - F10.
descriptiononPOST .../clone: accepted, survives verbatim (newlines), max length; whethernameaccepts the 22-characterproxy-<base32>form. - F11.
metain the cloned config: isctimethe clone time (evidence only;written-at-createstays the profile). - F12. Upload endpoint: field names (
content,filename, file part namefilename), the filename rule (.isosuffix, no slashes), the response (UPID), the privilege it needs, and whether re-uploading an existing name is refused (then delete-then-upload) or overwrites. On 9.xcontentacceptsiso,vztmplorimportand still refusessnippets, which is why the seed travels as an ISO; confirmisois what an.isoneeds. - F13.
DELETE /nodes/{node}/storage/{storage}/content/{volid}: response shape (UPID or null) and the message for a missing volume. - F14.
status/currenton a stopped VM: isqmpstatuspresent; on a paused VM:status=running,qmpstatus=paused. - F15.
POST .../clone: whether a failed clone destroys the partial VM by itself, and what a VM interrupted mid-clone looks like (config present,lock=clone). - F16. Task status shape:
status(running,stopped),exitstatus(OKor the error string), and how long a full clone of the template takes. - F17. Privileges: which of the prepared list the token really needs, in
particular the exact ACL path that satisfies
SDN.Usefor thenet0write (the 9.x docs describe it as "access SDN vnets and local network bridges" without spelling out the path for a plain bridge), andVM.GuestAgent.Auditfor the agent read. Also whether the config step needsVM.Config.Cloudiniteven though onlyide2is written. - F18. HTTP/2 is not offered by pveproxy on any release (documented), so the
client disables it; confirm with
curl --http2 -Ithat the connection falls back to HTTP/1.1. - F19. The template's disk key (
scsi0orvirtio0), itsbootline, that ide2 is free and nocloudinitdrive exists. - F20.
resizeon a stopped VM: request shape, the message when the target is smaller or equal, and that it is synchronous. - F21. Agent response shape:
result[].name,result[]["ip-addresses"][]withip-address,ip-address-type. - F22. The 401 body and reason phrase on 9.2. PVE 9.2 unified the
authentication-failure message so a failed login no longer leaks whether
the user exists, so any 401 text remembered from 8.x is wrong. Capture it
fresh into
err_unauthorized.json. - F23. Where the upload endpoint stages the request body, given that PVE 9
moved the node's
/tmpto a tmpfs capped at half of RAM and cleans/tmpand/var/tmpperiodically. A seed ISO is tens of KiB so this cannot bite in practice, but the observation belongs in the guide next to the size limit.
Proxmox endpoint map for the seam
api method |
HTTP |
|---|---|
Version(ctx) |
GET /version |
NodeStatus(ctx, node) |
GET /nodes/{node}/status |
StorageStatus(ctx, node, storage) |
GET /nodes/{node}/storage/{storage}/status |
ClusterResources(ctx) |
GET /cluster/resources?type=vm |
VMConfig(ctx, node, vmid) |
GET /nodes/{node}/qemu/{vmid}/config |
SetVMConfig(ctx, node, vmid, params) |
PUT /nodes/{node}/qemu/{vmid}/config |
Clone(ctx, node, vmid, cloneParams) |
POST /nodes/{node}/qemu/{vmid}/clone |
Resize(ctx, node, vmid, disk, size) |
PUT /nodes/{node}/qemu/{vmid}/resize |
Start(ctx, node, vmid) |
POST /nodes/{node}/qemu/{vmid}/status/start |
Stop(ctx, node, vmid) |
POST /nodes/{node}/qemu/{vmid}/status/stop |
VMStatus(ctx, node, vmid) |
GET /nodes/{node}/qemu/{vmid}/status/current |
Destroy(ctx, node, vmid) |
DELETE /nodes/{node}/qemu/{vmid}?purge=1&destroy-unreferenced-disks=1 |
TaskStatus(ctx, node, upid) |
GET /nodes/{node}/tasks/{upid}/status |
AgentNetworkInterfaces(ctx, node, vmid) |
GET /nodes/{node}/qemu/{vmid}/agent/network-get-interfaces |
UploadISO(ctx, node, storage, filename, r, size) |
POST /nodes/{node}/storage/{storage}/upload (multipart) |
DeleteVolume(ctx, node, storage, volid) |
DELETE /nodes/{node}/storage/{storage}/content/{volid} |
Package layout (internal/provider/proxmox/)
proxmox.go (Provider, New, newProvider, withDefaults, logger,
node, wrapErr, lockName, providerOption, budgets), api.go (the
api interface and DTOs vmResource, vmConfig, vmStatus, taskStatus,
agentInterface, storageStatus, nodeStatus, versionInfo,
cloneParams), client.go (httpAPI, newHTTPAPI, do, readToken,
decodeConfig), errors.go (apiError, classify, permanentf,
transientf, isNotExist, isLocked, isAlreadyExists,
isAgentNotRunning, isNoSpace), id.go (formatProviderID,
parseProviderID, vmidFor), mark.go (instanceMark,
formatDescription, parseDescription), state.go (mapState,
firstIPv4), observe.go (observed, observe), task.go (parseUPID,
waitTask, waitUnlocked, waitRunning), seed.go (seedISOName,
seedVolID, uploadSeed), create.go, get.go, delete.go, list.go,
check.go, plus fake_test.go, provider_test.go,
proxmox_integration_test.go, testdata/*.json. Shared:
internal/provider/config_proxmox.go, internal/provider/nocloud/,
internal/provider/keyedlock/.
Reused as-is: provider.NameFromUID, provider.Wrap, the Err* sentinels,
provider.Label* constants, provider.Reason* constants,
tracing.RestConfigWrapper, tracing.WithTracerProvider, kvm2's test
idioms (fixture, assertProviderError, logCapture).
Steps (one commit per phase, on feat/proxmox-provider off main)
-
Phase 0 (orchestrator with the user): plan file, branch, host recon, fixtures. Read first: contract §5, §4.9, §7;
docs/plans-executions/2026-09-16-1333-kvm2-provider.mdStep 0 entry. Do, in this order:git switch main && git pull --ff-only; copy this plan todocs/plans/2026-09-18-1502-proxmox-provider.mdwith the real timestamp (date "+%Y-%m-%d-%H%M"), commit it alone onmain(docs: plan the proxmox provider); createfeat/proxmox-provider. Ask the user for the hand-over facts listed above. Then, with the user's go-ahead (eachcurlis a permission prompt; read-only ones first, one at a time), capture F1 to F21 withcurl -s --cacert "$CA" -D /tmp/h.txt -o /tmp/b.json -w '%{http_code}\n' -H "Authorization: PVEAPIToken=$(cat "$TOKEN_FILE")" https://192.168.2.8:8006/api2/json/<path>, storing each response asinternal/provider/proxmox/testdata/<name>.jsonin the shape{"status": <code>, "reason": "<status line after the code>", "body": "<raw body>"}:version.json,node_status.json,storage_status.json,cluster_resources.json,vm_config_template.json,vm_config_clone.json,status_current_running.json,status_current_stopped.json,agent_network_get_interfaces.json,task_status_running.json,task_status_stopped_ok.json,task_status_stopped_error.json,upload_response.json,content_delete_response.json,err_vm_missing.json,err_locked.json,err_newid_in_use.json,err_agent_not_running.json,err_agent_vm_stopped.json,err_unauthorized.json(a wrong secret),err_forbidden.json(a path the token lacks),err_bad_param.json(newid=abc),err_no_space.json(only if F6 is cheap),err_volume_missing.json(F13). The write experiments: one clone of the template into the top of the VMID range (newid=199999, a multi-linedescription, anameof theproxy-form), a config read of the clone (F10, F11, F19), aPUT configwhile it is locked if timing allows (F7), an upload of a 1 KiBrecon-seed.iso(F12) and its delete (F13), a resize attempt (F20), a start, an agent read (F5, F21), a stop, then destroy withpurge=1&destroy-unreferenced-disks=1; a final resources read shows nothing remains. Redact nothing but the token. Write the answers to F1 to F21 and the privilege outcome into the execution log's Phase 0 entry, one line per fact; Phase 1 cites them as Evidence. Create the execution logdocs/plans-executions/2026-09-18-1502-proxmox-provider.mdwith the checklist (Phase 0 to Phase 10) and this entry. Verify:ls internal/provider/proxmox/testdata | wc -lis at least 22;grep -rl 'PVEAPIToken' internal/provider/proxmox/testdatais empty; every fixture parses as JSON; the host shows no VM in the range and norecon-seed.iso. Done when: the plan commit is onmain, the branch exists, the fixtures and the execution log with F1 to F21 are committed. Commit:proxmox: captured API fixtures and host facts from 192.168.2.8. -
Phase 1 (orchestrator): the spec. Read first: contract §2, §4, §5, §6, §11, §12;
docs/provider-spec-template.mdend to end;docs/kvm2-provider-spec.mdfor the level of detail; this plan's decisions; the Phase 0 log entry. Createdocs/proxmox-provider-spec.mdanswering all 24 entries: 1vms/<vmid>/<name>, strict parsing, the node-name and storage-id regexps; 2allocated-id,vmidFor(uid, rng, attempt), the two-VMs-with-our-name rule, the adoption guard; 3 the placement table (Region ignored); 4 the template catalogue and the "add a key, never edit" warning; 5 the seed ISO vianocloudand the ide2 prerequisite; 6machineTypeswithdiskGiB; 7provisioning-window,discoveredvia the guest agent, the IPv4 filter,Portunused; 8written-metadataindescriptionplus the tag,written-at-create, F9 to F11 as Evidence; 9 the state table,create-blocks; 10 the ordered Create steps with what a retry finds at each,task-handleplusbackend-lock, the budgets; 11 the Delete order, the ISO blind spot, node-local ISO storage after a migration; 12fail-whole, the candidate rule, one config read per candidate; 13 the classify table with the F1 to F6 messages,not-found: error-codeplus list-and-miss on resources (allowed because offline nodes' guests appear asunknown, not absent); 14mounted-files(Secretproxmox-token, keytoken, path/etc/proxy-operator/proxmox/token),ca-fileorinsecureSkipTLSVerify, the privilege list from F17, rotation free; 15 the config table (Phase 2's struct); 16 the Checker; 17thin-client,transport-wrapper, the seam's method list; 18 the budgets table; 19 the keyed lock per name; 20 the log keys; 21 the §11 list with the test names from Phases 2 to 8 (copy from this plan); 22 the deployment list; 23 the docs list; 24 the accepted limits (blocking Create up to the clone budget, ISO blind spot, VMID range shared with humans, node-local ISO storage, no adoption of foreign VMs, two-installs GC caveat,unknownstatus while a node is down). Verify:grep -c '^\*\*Answer:\*\*$' docs/proxmox-provider-spec.mdis 0; every axis code used appears in contract §4.20; Evidence lines cite F numbers;grep -c '—' docs/proxmox-provider-spec.mdis 0. Done when: the spec is committed with all 24 entries and the Phase 1 box ticked. Commit:docs: proxmox provider spec. -
Phase 2 (implementer): config block, registration, package skeleton. Read first: spec entries 1, 2, 3, 6, 8, 9, 13, 15; contract §10, §5, §6;
internal/provider/config.go,config_kvm.go,config_test.go,config_kvm_test.go;internal/provider/kvm2/{kvm2.go,id.go,metadata.go,state.go,errors.go}and their tests as the pattern;cmd/main.go;testdata/err_*.json. Createinternal/provider/config_proxmox.go:ProxmoxConfig{URL, TokenFile, CAFile string; InsecureSkipTLSVerify bool; Nodes []string; ISOStorage, Storage, Bridge string; VMIDRange *ProxmoxVMIDRange; Templates map[string]ProxmoxTemplate; MachineTypes map[string]ProxmoxMachineType; RequestTimeoutSeconds int; VendorData string; InstallGuestAgent *bool},ProxmoxVMIDRange{Min, Max int},ProxmoxTemplate{VMID int},ProxmoxMachineType{Cores int32; MemoryMiB int64; DiskGiB int64},validateProxmox(prefix string, c *ProxmoxConfig) error(URL http(s) with a host;tokenFilenon-empty; at least one node, unique, matchingproxmoxNodeName = ^[a-z0-9][a-z0-9-]*$;isoStorageandstoragematchingproxmoxStorageID = ^[A-Za-z][A-Za-z0-9_.-]*$;vmidRangemin at least 100, max at most 999999999, min below max; at least one template withvmidat least 100; at least one machine type with cores and memoryMiB above zero and diskGiB not negative;requestTimeoutSecondsnot negative). Editconfig.go:Proxmox *ProxmoxConfigwith tagproxmox,omitempty,setBlocks, the allow-list, the dispatch. Editcmd/main.go:"proxmox": proxmox.New. Create ininternal/provider/proxmox/:proxmox.go(Provider{name, cfg, api, now, budgets, nameLocks, tracing}; until Phase 3 a localkeyedLockscopy inlocks.gothat Phase 3 deletes;Newnever dials: builds the client,os.Stats the token file and warns, warns oninsecureSkipTLSVerify;withDefaults:isoStoragelocal,vmidRange100000 to 199999,requestTimeoutSeconds30,installGuestAgenttrue;node(zone)mirrors kvm2'shost; the four methods stubbed withtransientf("not implemented")),api.go(the interface and DTOs;vmConfigismap[string]stringwith scalars rendered as strings bydecodeConfig),id.go,mark.go(keys fromprovider.LabelManaged,provider.LabelManagedYes,provider.LabelUIDandproxy-operator-created-at;parseDescriptiontolerates extra lines and CRLF),state.go,errors.go(apiError{Status int; Message string; Errors map[string]string}whoseError()never includes headers or URLs;classifyper spec entry 13; matcher helpers keyed on the exact substrings captured in F2 to F6). Tests:config_proxmox_test.goTestValidateProxmox_table(missing url, non-http scheme, missing tokenFile, no nodes, duplicate node, node with a slash, bad isoStorage id, vmidRange min below 100, min above max, no templates, template vmid below 100, no machineTypes, cores zero, negative diskGiB, negative timeout, valid full, valid minimal) with avalidProxmoxYAMLconst;config_test.gorowsproxmox type with kvm block,kvm type with proxmox block,proxmox minimal entry loads.id_test.go:TestFormatProviderID_roundTrip,TestParseProviderID_malformed,TestVMIDFor_deterministicAndInRange,TestVMIDFor_probesForwardAndWraps.mark_test.go:TestFormatDescription_keys,TestParseDescription_table.state_test.go:TestMapState_table,TestFirstIPv4_filters.errors_test.go:TestClassify_table(every class, every matched message loaded fromtestdata/err_*.jsonthroughloadFixture(t, name) *apiError,op == "create"turning NotFound into Transient,context.Canceled, a*net.OpError,errors.New("x"), already-classified errors keep their class),TestAPIError_matchers,TestPermanentfAndTransientf.proxmox_test.go:TestNew_doesNotDialAndWarnsOnMissingToken,TestNew_insecureSkipTLSVerifyWarns,TestNew_nilBlockDoesNotPanic,TestWithDefaults_table,TestNode_table,TestWrapErr_carriesProviderNameOpAndID. Verify:grep -c '"proxmox"' internal/provider/config.gois 3;go build ./... && go vet ./... && go test -race -count=1 ./internal/provider/... ./cmd/... && go test -short ./...; the fixture loader fails loudly on a missing file. Done when:go test -race ./internal/provider/proxmox/passes with the stubs in place and the constructor is registered. Commit:proxmox: config block, IDs, marks, state mapping, classify, registration. -
Phase 3 (implementer): extract
nocloudandkeyedlockfrom kvm2. Read first:internal/provider/kvm2/{seed.go,seed_test.go,kvm2.go,kvm2_test.go,create.go}; contract §1.4, §2.1; spec entries 5 and 19;grep -n 'seed.go\|keyedLocks\|buildSeedISO' docs/*.md. Createinternal/provider/nocloud/seed.goexportingBuildSeedISO(name, userData, vendorData string, w io.Writer) error(plain errors, anErrEmptyNamesentinel instead of kvm2'spermanentf),BuildVendorData(configured string, installAgent bool) (string, error),GuestAgentScript,SeedLabel; move the seed tests tonocloud/seed_test.gounder their existing names. Createinternal/provider/keyedlock/keyedlock.goexportingLocks,New() *Locks,(*Locks).Acquire(ctx, key) (release func(), err error); move theTestKeyedLocks_*tests. Edit kvm2: deleteseed.go,seed_test.go, thekeyedLockstype and its tests;create.gocallsnocloud.BuildVendorDataandnocloud.BuildSeedISOand wraps their errors withpermanentf;kvm2.gouses*keyedlock.Locks. Editinternal/provider/proxmox/proxmox.goto usekeyedlock.Locksand deletelocks.go. Fix the doc mentions so they name the new packages. Verify:go test -race -count=1 ./internal/provider/...; kvm2's test count drops by exactly the moved tests and the two new packages carry them;grep -rn 'iso9660' internal/provider/kvm2/is empty;go vet -tags=integration ./...still compiles. Done when: kvm2 and proxmox import both packages, all tests pass, the docs name the new packages. Commit:provider: extract nocloud seed builder and keyedlock from kvm2. -
Phase 4 (implementer): seam, HTTP client, decorator, fake. Read first: spec entries 14, 17, 18, 20; contract §4.15, §7, §9;
internal/tracing/transport.go;internal/provider/kvm2/{observe.go,observe_test.go,fake_test.go};internal/provider/proxmox/{api.go,errors.go};testdata/*.json. Createclient.go:httpAPI{base *url.URL; http *http.Client; tokenFile string};newHTTPAPI(cfg provider.ProxmoxConfig, opts ...tracing.Option) (*httpAPI, error)builds a transport withDialContexttimeout 10s,TLSHandshakeTimeout10s,ResponseHeaderTimeoutfrom the request timeout,IdleConnTimeout90s,MaxIdleConnsPerHost4,ForceAttemptHTTP2: false, an emptyTLSNextProtomap (F18),RootCAsfromcaFileorInsecureSkipVerifyfrom the flag, wrapped withtracing.RestConfigWrapper(opts...)(transport);http.Client.Timeoutis the request timeout;do(ctx, method, path string, query, form url.Values, out any) errorreads the token file on every call (missing filetransientf, a file without!and=permanentf), sets the header, decodes{"data": ...}intoout, turns any non-2xx into*apiErrorper F1 with the error body read capped at 64 KiB;UploadISOstreams a multipart body throughio.Pipewith a goroutine writingcontent=iso,filenameand the file part. Createobserve.go:observed{inner api; provider string}writing one V(1)proxmox api callline per seam call withcall,node,vmid,storage,upid,duration,httpStatusanderroron failure, V(2) adding request params and a response summary;observe(inner api, providerName string) api;NewwrapshttpAPIin it. Createfake_test.go:fakeAPI{mu; vms map[int]*fakeVM; tasks map[string]*fakeTask; isos map[string]map[string][]byte; hooks; block; calls; log; taskPolls int; cloneFailsWith, startFailsWith string; agentNotRunning, cloneLeavesLock bool},fakeVM{node, name, status, qmpstatus, lock string; template bool; config map[string]string; ifaces []agentInterface},fakeTask{node string; polls int; exit string; onDone func()};Clonecreates the VM withlock=cloneand a task that clears the lock or, percloneFailsWith, removes the VM (F15);StartandStopflip status through a task;Destroyremoves the VM through a task;UploadISOrecords bytes;DeleteVolumeerrors with the F13 message when absent;enter(ctx, method)for blocks and hooks like kvm2; helpersaddVM,addTemplate,failWith,blockOn,callCount,callLog,fakeErr(status int, msg string) *apiError. Tests:client_test.gooverhttptest.NewTLSServer:TestClient_sendsTokenHeaderFromFileOnEveryRequest(rotate the file between two calls),TestClient_missingTokenFileIsTransient,TestClient_unparsableTokenFileIsPermanent,TestClient_apiErrorFromStatusReasonAndBody(serve eachtestdata/err_*.jsonverbatim),TestClient_decodesDataEnvelope,TestClient_http1Only,TestClient_requestTimeoutIsTransient,TestClient_cancelledContextReturnsPromptly,TestClient_childSpanUnderParent,TestClient_noSpanWithoutParent,TestClient_tokenNeverInErrorsOrLogs(sentinel secret echoed by the server in a 401 body),TestClient_caFileTrustsPrivateCA,TestClient_insecureSkipsVerification,TestClient_uploadStreamsMultipart(the server parses the multipart; the client neverio.ReadAlls the reader),TestClient_destroyQueryFlags.observe_test.go:TestObserve_everySeamMethodLogsOneLine(reflection overapi),TestObserve_detailOnlyAtV2,TestObserve_uploadBodyNeverLogged,TestObserve_errorFieldsOnFailure,TestObserve_v0Silent. Verify:go test -race -count=2 ./internal/provider/proxmox/;grep -n 'ReadAll' internal/provider/proxmox/client.goshows only the bounded error-body read;grep -n 'context.Background()' internal/provider/proxmox/*.gois empty outside tests. Done when: the client passes its tests against the fixtures and the fake compiles against the fullapiinterface. Commit:proxmox: thin HTTP client, seam, log decorator and in-memory fake. -
Phase 5 (implementer): Get, ListByTag, task helpers. Read first: spec entries 7, 8, 9, 12, 13; contract §2.2, §2.4, §4.7, §4.12;
internal/provider/kvm2/{get.go,get_test.go,list.go,list_test.go}; the resources, config, status, agent and task fixtures. Createtask.go:parseUPID(upid) (node string, err error)(Permanent when malformed),waitTask(ctx, log, api, upid, budget) errorpollingTaskStatuseverybudgets.taskPoll(2s; tests shrink it) untilstatus == "stopped",exitstatus != "OK"givingtransientfwith the message, the budget givingtransientf, ctx giving Transient;waitUnlocked(ctx, api, node, vmid, budget)pollingVMConfiguntil nolock. Createget.go:resolve(ctx, api, vmid) (vmResource, bool, error)(one resources call,Type == "qemu"and the vmid), the name check,VMConfigfor the mark,lockTransient,unknownTransient,runningreads the agent (isAgentNotRunninggives no IP, other errors Transient),mapState. Createlist.goper spec entry 12;Instancecarries ID, UID, CreatedAt only. Replace the two stubs. Tests:task_test.goTestParseUPID_table,TestWaitTask_table(ok, error exitstatus, budget exceeded, ctx cancelled, poll count),TestWaitUnlocked_table.get_test.go:TestGet_missingVMIsNotFound,TestGet_nameMismatchIsNotFound,TestGet_unmarkedIsNotFound,TestGet_runningWithoutIPIsProvisioning,TestGet_runningWithIPIsRunning,TestGet_agentNotRunningIsProvisioning,TestGet_agentOtherErrorIsTransient,TestGet_ipFilterSkipsLoopbackAndLinkLocal,TestGet_stoppedIsStopped,TestGet_pausedIsStopped,TestGet_unknownStatusIsTransient,TestGet_lockedIsTransientNeverNotFound,TestGet_unknownStateIsStopped,TestGet_readsUIDAndCreatedAtFromBackend,TestGet_unmanagedDescriptionHasEmptyUID,TestGet_malformedIDIsPermanent,TestGet_resourcesFailureIsTransient,TestGet_twoVMsWithOurNameIsPermanent.list_test.go:TestListByTag_excludesUnmarkedVMs,TestListByTag_emptyUIDStillListed,TestListByTag_candidatesByTagOrNamePrefix,TestListByTag_skipsLXCAndTemplates,TestListByTag_resourcesFailureFailsWhole,TestListByTag_unreadableVMSkipped,TestListByTag_unreadableVMWithEndedContextFails,TestListByTag_unknownStatusStillAttempted,TestListByTag_doesNotReadStatusOrAgent,TestListByTag_idsAcceptedByParse. Verify:go test -race -count=2 ./internal/provider/proxmox/;grep -n 'not implemented' internal/provider/proxmox/*.golists only Create and Delete. Done when: Get and ListByTag are real and every listed test passes. Commit:proxmox: Get, ListByTag and task polling. -
Phase 6 (implementer): Create. Read first: spec entries 2, 3, 4, 5, 6, 10, 19; contract §2.1, §4.2, §4.10, §4.11, §5;
internal/provider/kvm2/{create.go,create_test.go};internal/provider/nocloud; F4, F7, F10, F12, F15, F16, F19, F20 in the Phase 0 log. Createseed.go:seedISOName(name)is<name>-seed.iso,seedVolID(storage, name)is<storage>:iso/<name>-seed.iso,uploadSeed(ctx, log, api, node, storage, req, cfg)builds vendor-data and the ISO into abytes.Buffer(tens of KiB), deletes an existing volume tolerating absence, uploads with the buffer's length, waits the task withbudgets.upload, logscloudInitBytesonly. Createcreate.goimplementing the ordered steps: placement validation (node,MachineTypes,Templates, all Permanent naming field and value), the per-name lock,ClusterResourcesonce for both the name lookup (adoption guard, thenwaitUnlockedif locked, then continue from the seed step if stopped, return if running) and the template's node (absent template is Permanent); allocation loopfor attempt := 0; attempt < maxProbes (32)callingClonewithvmidFor(req.UID, rng, attempt),isAlreadyExistsmoving to the next attempt, exhaustion Transient;waitTaskwithbudgets.clone;uploadSeed;SetVMConfigwithcores,memory,agent=1,tags=proxy_operator,ide2=<volid>,media=cdrom, andnet0rewritten from the cloned config withbridge=<cfg.Bridge>only when configured (MAC kept);bootDisk(cfg)andResizewhendiskGiBis above the currentsize=;StartandwaitTaskwithbudgets.start;waitRunningonVMStatuswithbudgets.running; return the ID.isNotExistinside Create is Transient (classify withop == "create"). Testscreate_test.go:TestCreate_repeatReturnsSameIDAndCreatesNothing,TestCreate_resumesAfterEachPartialStep(after clone task, after seed upload, after config, after resize, after start, after running),TestCreate_returnsOnlyWhenRunning,TestCreate_runningWaitCapIsTransient,TestCreate_rejectsUnknownPlacement,TestCreate_zoneOptionalWithOneNode,TestCreate_regionIgnored,TestCreate_adoptionWithForeignUIDIsPermanent,TestCreate_adoptsOnlyMarkedVMs,TestCreate_duplicateNameIsPermanent,TestCreate_vmidCollisionProbesForward,TestCreate_vmidRangeExhaustedIsTransient,TestCreate_waitsForCloneLockOnRetry,TestCreate_cloneTaskFailureIsTransient,TestCreate_cloneTaskTimeoutIsTransient,TestCreate_markWrittenInCloneCall,TestCreate_tagSetInConfigStep,TestCreate_seedAlwaysRebuilt,TestCreate_seedUploadedBeforeConfig,TestCreate_configSetsCoresMemoryAgentAndSeed,TestCreate_bridgeOverrideKeepsMAC,TestCreate_noBridgeOverrideLeavesNet0,TestCreate_resizeOnlyWhenLarger,TestBootDisk_table,TestCreate_startTaskFailureIsTransient,TestCreate_templateMissingIsPermanent,TestCreate_notExistInsideCreateIsTransientNotNotFound,TestCreate_cloudInitSentinelNeverLoggedOrInErrors(V(2) capture, every error path),TestCreate_concurrentSameNameSerialised,TestCreate_cancelledMidWaitReturnsPromptly. Verify:grep -n 'CloudInit' internal/provider/proxmox/create.go internal/provider/proxmox/seed.goshows onlylen(req.CloudInit)and the pass-through intonocloud.BuildSeedISO;go test -race -count=2 ./internal/provider/proxmox/;grep -n 'not implemented'lists only Delete. Done when: Create is real and every listed test passes. Commit:proxmox: Create by cloning a template, seed upload, config, start. -
Phase 7 (implementer): Delete, Checker, CRD comment. Read first: spec entries 11, 16; contract §2.3, §4.3, §4.13, §8;
internal/provider/kvm2/{delete.go,delete_test.go,check.go,check_test.go};internal/provider/check.go;api/v1alpha1/providerhealth_types.go; F13, F17 in the Phase 0 log. Createdelete.go: parse the ID (Permanent), the per-name lock,resolve; absent is a logged no-op; name mismatch or unmarked is a logged no-op;lockTransient;unknownTransient;runninggetsStopandwaitTask(budgets.stop);DestroyandwaitTask(budgets.destroy),isNotExistfromDestroybeing success;DeleteVolumeof the seed on the VM's node tolerating the F13 absent message; nil. Createcheck.go:var _ provider.Checker = (*Provider)(nil);reasonTemplateNotFound = "TemplateNotFound",reasonStorageNotFound = "StorageNotFound";Checkfans out one goroutine per node in config order (wg.Go), eachcheckNoderunningVersion,NodeStatus,StorageStatus(a 500 "does not exist" isStorageNotFound; a storage whosecontentlacksisoisStorageNotFoundwith a message saying so),ClusterResourcesfor the templates;checkReason(err): ctx endedTimeout, 401Unauthorized, 403Forbidden, other*apiErrorAPIError, networkUnreachable; DetailspveVersion,kernel,isoStorage,isoStorageAvailGiB,templates;Nameis the node,Addressthe base URL. Editapi/v1alpha1/providerhealth_types.go(kubernetes, gcp, kvm, kvm2 or proxmox) and runmake manifests. Testsdelete_test.go:TestDelete_malformedIDIsPermanent,TestDelete_twiceSucceeds,TestDelete_absentIsNoOp,TestDelete_foreignNameIsNoOp,TestDelete_unmarkedIsNoOp,TestDelete_runningVMStoppedFirst,TestDelete_stoppedVMSkipsStop,TestDelete_lockedIsTransient,TestDelete_unknownStatusIsTransient,TestDelete_removesSeedISO,TestDelete_toleratesMissingISO,TestDelete_destroyTaskFailureIsTransient,TestDelete_usesPurgeAndDestroyUnreferencedDisks,TestDelete_concurrentSameNameSafe,TestDelete_noOpLinesAreSilentAtV0; inget_test.go:TestGet_afterDeleteReturnsNotFound,TestGet_duringDeleteStillReturnsInstance(block the fake'sDestroy).check_test.go:TestCheck_oneEntryPerNodeInConfigOrder,TestCheck_nodesCheckedConcurrently,TestCheck_expiredContextReturnsPromptly,TestCheck_reasonsTable,TestCheck_detailsKeys,TestCheck_logsVerdictPerNode,TestCheckReason_table. Verify:go test -race -count=2 ./internal/provider/proxmox/ ./internal/providerhealth/;git diff --stat config/shows only the CRD description line;grep -rn 'not implemented' internal/provider/proxmox/is empty. Done when: all four contract methods andCheckare real. Commit:proxmox: Delete with seed cleanup, health Checker, CRD type comment. -
Phase 8 (implementer): contract sweep, observability sweep, integration test, Makefile, testing.md. Read first: contract §11 and §9; spec entries 17, 18, 20, 21;
internal/provider/kvm2/{provider_test.go,kvm2_integration_test.go};internal/provider/tracing_test.go;Makefileintegration targets;docs/testing.mdkvm2 rows and section. Createprovider_test.go: the sharedfixture(fake, log capture, budgets shrunk),assertProviderError,TestProvider_cancelledContextReturnsPromptly(each method blocked on its first seam call),TestProvider_everyErrorIsWrappedWithConfiguredName,TestLogging_v0SilentV1OutcomeV2Detail(V(1) linesproxmox vm ensured running,proxmox vm fetched,proxmox vm deleted,proxmox vms listed,proxmox node checked, every line withprovider, instance lines withvmidandname; V(2) hascloudInitBytesand never the text),TestTracing_httpSpansUnderOperationSpan(a realhttpAPIagainst anhttptestserver playing the fixtures for a Get, wrapped inprovider.WithTracing, with a span recorder: every HTTP span is a child ofprovider.getand no attribute carries the token). Createproxmox_integration_test.go(//go:build integration):loadIntegrationEnvwith thePROXMOX_TEST_*variables,providerConfig(), theintegrationLogpattern from kvm2;TestProxmox_lifecycleasserting in order:Newreturns within 100 ms without a request;CheckReady withpveVersion,isoStorageAvailGiBandtemplates;Createreturnsvms/<vmid>/<name>with the vmid in range; a secondCreatereturns the same ID and the resources count is unchanged;Getreaches Running with an IPv4 within the budget, UID and CreatedAt as written;ListByTagcontains it; a TCP connect to the guest's port is logged only;Deletenil;GetNotFound;Deleteagain nil; the ISO storage has no<name>-seed.isoand resources has no VM with the name; an HTTP span is a child ofprovider.createand no attribute carries the cloud-init text;PROXMOX_TEST_KEEPskips the teardown and prints theqm destroyand ISO delete commands.TestProxmoxCheck_realHostcreates nothing. EditMakefile:test-integration-proxmox(go test -race -tags=integration -count=1 -v -timeout 30m -run TestProxmox ./internal/provider/proxmox/). Editdocs/testing.md: rows for the package in the suites table and a "The proxmox integration test" section. Update spec entry 21's tick list with the final test names. Verify:go test -race -count=2 ./internal/provider/proxmox/;go vet -tags=integration ./... && go vet -tags=e2e ./...;make -n test-integration-proxmox; every §11 item has a test name in spec entry 21 that exists. Done when: the package covers every §11 item by name, the integration test compiles, the target exists. Commit:proxmox: contract and observability tests, integration test, testing docs. -
Phase 9 (implementer): deployment and documentation. Read first: contract §12 items 11 to 13; spec entries 22, 23, 24;
config/manager/manager.yaml,config/manager/providers_config.yaml,config/samples/{providers-config.yaml,proxy_kvm2.yaml,kustomization.yaml};Makefilekvm-ssh-secret;docs/kvm2-provider.mdheadings;docs/providers-existing.md;docs/architecture.mdlines mentioning kvm2;README.md;docs/providers.md;CLAUDE.mdGotchas. Editconfig/manager/manager.yaml: an optional Secret mountproxmox-tokenat/etc/proxy-operator/proxmox(keytoken). EditMakefile:PROXMOX_TOKEN_FILE ?= $(HOME)/.config/proxmox-token,proxmox-token-secretcreatingproxmox-tokenfrom--from-file=token=. Edit both providers-config files: a commentedproxmox-homeentry (url: https://192.168.2.8:8006,tokenFile,caFileorinsecureSkipTLSVerify,nodes,isoStorage,storage,bridge,vmidRange,templates,machineTypes). Createconfig/samples/proxy_proxmox.yaml(providerproxmox-home, zone the node, machine typesmall, image the template key, the Squid cloud-init fromproxy_kvm2.yaml) and list it inconfig/samples/kustomization.yaml. Createdocs/proxmox-provider.mdwith sections: Proxmox prerequisites (the template commands from this plan, no cloudinit drive on ide2, the guest agent or outbound access, the bridge, the privilege list from F17, reserving the VMID range), Cluster-side setup (the Secret, the config entry), Config reference (spec entry 15's table), Placement (Region ignored, catalogue-edit warning), What lands on Proxmox (VMID scheme, description mark, tag, seed ISO, config keys,qm unlockfor a stuck clone), Logging, Tracing, Timeouts, Health check, Error classes, Known gaps (spec entry 24), Testing. Editdocs/providers-existing.md: a fifth column in the three tables and a proxmox section mirroring kvm2's. Editdocs/architecture.md: components row, a proxmox block in §5, the span note in §10, a §11 row (and the "All four providers" count), Decisions bullets. EditREADME.md,docs/providers.md,CLAUDE.mdGotchas (make test-integration-proxmox). Verify:bin/kustomize build config/default >/dev/null && bin/kustomize build config/samples >/dev/null; a testTestLoadConfig_proxmoxSampleEntryloads the uncommented block fromconfig/samples/providers-config.yamlthroughprovider.LoadConfig; a link sweep over the edited docs finds no missing target;grep -c '—' docs/proxmox-provider.mdis 0. Done when: manifests build, the sample loads, docs are linked and link-clean. Commit:proxmox: deployment files, sample, user guide and doc rows. -
Phase 10 (orchestrator with the user): review, real-host validation, changelog, MR. Read first:
docs/plans-executions/2026-09-17-1515-kvm2-review-fixes.md;.claude/agents/go-operator-reviewer.md; the latest report underdocs/reviews/for the shape; the spec; this plan's Risks. Do: rungo-operator-revieweron the branch with the spec and the contract as inputs, then/code-review high,make lint(if the local toolchain panics in staticcheck as it did during kvm2, record it and runbin/golangci-lint run ./internal/provider/proxmox/...with staticcheck disabled),make test. Fix accepted findings with regression tests in one commit; record rejected ones underdocs/reviews/<date>-proxmox-provider-review/. Hand the user the host checklist (template ready, no leftover VMs in the range, ISO storage has space, the token's privileges per F17) and thePROXMOX_TEST_*export lines; with the go-ahead runmake test-integration-proxmoxand record the clone duration and time to IP. Optionally the in-cluster pass on kind: build and load the image,make proxmox-token-secret, a scratch ConfigMap with theproxmox-homeentry, applyproxy_proxmox.yaml, wait forReady,kubectl get providerhealth proxmox-home -o yaml, acurl -xthrough the proxy, delete and confirm the VM and ISO are gone; scratch overrides are not committed. After the user confirms: CHANGELOG entry (date "+%Y-%m-%d %H:%M %Z"), push with-uusing the token helper from memory (~/.config/gitea-token), open the MR with the Gitea API (POST /api/v1/repos/kacerr/egress-proxies-operator/pullswithhead: feat/proxmox-provider,base: main, the body ending with the attribution line;pr.jsonin the scratchpad, never committed). Never merge. Verify: the integration run's ordered assertions pass on 192.168.2.8;make testgreen; the review report lists every finding as accepted or rejected. Done when: the MR URL is printed and the execution log's last box is ticked. Commit:proxmox: review fixes, real-host validation record, changelog.
Verification
- Per phase: the
Verify:clause, plusgo build ./... && go test -short ./...before every commit andgit diff --statreviewed for files outside the phase's list. - Contract mapping: after Phase 8 every rule of contract §2 and §8 has a named test in the package, and spec entry 21 maps the §11 list 1:1 to existing test names.
- Fixture discipline: every message
classifyor a matcher helper compares against is loaded fromtestdata/inTestClassify_tableorTestAPIError_matchers; a string literal inerrors.gowithout a fixture is a review finding. - Secrets:
TestClient_tokenNeverInErrorsOrLogsandTestCreate_cloudInitSentinelNeverLoggedOrInErrorsrun at V(2), and the integration test asserts no span attribute carries the cloud-init text. - End to end: the integration run (Phase 8's assertions) on 192.168.2.8 and the optional kind pass in Phase 10.
Risks
- Error text location and exact wording (F1 to F6) decide the whole NotFound path; if messages vary by storage type or PVE minor version, the matchers get one fixture per variant and the guide names the tested version. This is not hypothetical: 9.2 rewrote the authentication-failure message (F22), so fixtures captured from an 8.x host would already be wrong. Every matched string comes from 192.168.2.8 at 9.2.20, and the guide says so.
- Clone cleanup (F15). A failed or interrupted clone may leave a VM with
lock=clonethat never clears; Create then waits outbudgets.cloneand returns Transient every time. The guide documentsqm unlock; the reconciler keeps retrying rather than latching. SDN.Use(F17). PVE 9 refusesnet0writes to non-root users without it; a missing privilege is a 403 Permanent that latches the Proxy until its spec changes. The guide's privilege list is the mitigation.- The ide2 seed and a template with a cloudinit drive both claim cloud-init; the Proxy's user-data would be ignored. Documented prerequisite; the Checker cannot see it.
- VMID range shared with humans. A human VM inside the range costs a probe; a human VM carrying our name is Permanent. The guide says to reserve the range.
- Node-local ISO storage after a migration leaves the seed on the original node; Delete tolerates its absence and the ISO is a documented blind spot. Shared ISO storage avoids it.
- Blocking Create holds a reconcile worker for up to the clone budget; the guide sets expectations.
- The 30s request timeout on the seed upload is fine for tens of KiB; a
multi-megabyte
vendorDatawould trip it. Documented. - Two operator installs on one Proxmox cluster delete each other's VMs (contract §13); documented in entry 24.
- kvm2 edits in Phase 3 touch a merged provider; the moved tests are the
guard, and
go vet -tags=integrationkeeps its integration test compiling.