Files
egress-proxies-operator/docs/plans-executions/2026-09-18-1502-proxmox-provider.md
T
kacerrandClaude Opus 5.5 ff94fe70bb proxmox: captured API fixtures and host facts from pv1-1 (PVE 9.2.20)
Phase 0 of the proxmox provider plan. 55 fixtures under
internal/provider/proxmox/testdata, each the status, reason phrase and
raw body of a real response from the user's host, and the execution log
answering the plan's facts F1 to F23.

Recon ran a clone of the Debian 13 template through the whole path the
provider will take, twice: untagged and on VLAN 4. On both, the NoCloud
seed on ide2 installed the guest agent and an address came back about 50
seconds after start. It also found two bugs in the plan: a VM being
cloned has no name in /cluster/resources, so name-first lookups would read
it as gone, and a crash-retry would clone a duplicate. Those and eight
smaller corrections are listed for the spec.

Decisions made with the user: seeds go to a dedicated proxy-seeds
storage where the token can delete them; proxies land on a VLAN set in
config (vlanTag); the API URL is https://192.168.0.8:8006, the address in
the node certificate.

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

15 KiB

Execution: proxmox, a Proxmox VE provider built from the provider contract

Pairs with docs/plans/2026-09-18-1502-proxmox-provider.md.

Status

  • Phase 0 — Plan file, branch, host recon, fixtures
  • Phase 1 — The spec (docs/proxmox-provider-spec.md)
  • Phase 2 — Config block, registration, package skeleton
  • Phase 3 — Extract nocloud and keyedlock from kvm2
  • Phase 4 — Seam, HTTP client, decorator, fake
  • Phase 5 — Get, ListByTag, task helpers
  • Phase 6 — Create
  • Phase 7 — Delete, Checker, CRD comment
  • Phase 8 — Contract and observability sweep, integration test, testing.md
  • Phase 9 — Deployment and documentation
  • Phase 10 — Review, real-host validation, changelog, MR

Phase 0 — Plan file, branch, host recon, fixtures

The plan was committed alone on main on 2026-09-18 (708989c, then 2167819 retargeting it at PVE 9.2). origin/main had not moved in the meantime, so the branch was cut straight from local main:

git fetch -q origin && git rev-list --left-right --count origin/main...main   # 0 2
git switch -c feat/proxmox-provider

The host is pv1-1, a single node (no cluster peers), pve-manager/9.2.20, kernel 7.0.14-17-pve, QEMU 11.0. The user prepared the token (~/.config/proxmox-token, privsep off) and the CA (~/.config/proxmox-ca.pem) before the session.

TLS: 192.168.2.8 is not in the node certificate. The first probe failed with curl exit 60. The CA is right (openssl s_client -CAfile verifies the chain), but the leaf's SANs are 127.0.0.1, ::1, localhost, 192.168.0.8, pv1-1, pv1-1.home.lab, and neither name resolves on the workstation. 192.168.0.8 and 192.168.2.8 present the same certificate fingerprint: the node sits on vmbr0 (192.168.0.8/24, untagged) with VLAN interfaces vmbr0.2 (192.168.2.8/24) and vmbr0.4 (192.168.4.8/24). All recon kept full verification by connecting to 192.168.2.8 while verifying the certificate as pv1-1.home.lab:

curl -s --resolve pv1-1.home.lab:8006:192.168.2.8 --cacert ~/.config/proxmox-ca.pem \
  -H @<(printf 'Authorization: PVEAPIToken=%s\n' "$(tr -d '[:space:]' < ~/.config/proxmox-token)") \
  https://pv1-1.home.lab:8006/api2/json/version
# {"data":{"release":"9.2","version":"9.2.20","repoid":"49318c671b82f31e"}}

Every capture went through a scratch helper wrapping exactly that call. It passes the token through a file descriptor so it never reaches argv or output, and writes {"status","reason","body"} to internal/provider/proxmox/testdata/<name>.json, refusing to save a body that contains PVEAPIToken.

A real NoCloud seed was used instead of the plan's 1 KiB placeholder, so the agent could be installed and F21 captured from a live guest. No ISO tool is installed locally, so a throwaway module in the scratchpad reused the repository's github.com/kdomanski/iso9660 v0.4.0 from the module cache and copied kvm2's guestAgentScript verbatim:

GOFLAGS=-mod=mod GOPROXY=off GOSUMDB=off go run . proxy-reconaaaaaaaaaaa proxy-reconaaaaaaaaaaa-seed.iso
# 45056 bytes, ISO 9660 'cidata'

Write experiments ran against VMID 199999 (proxy-reconaaaaaaaaaaa), a clone of template 9001. Sequence: validation error; clone; resources, config write and start while the clone lock was held; status and agent while running bare; stop; config read; clone onto the taken VMID; tag tests; upload twice plus a bad name; attach ide2, set cores and memory; three resizes; start and agent poll; suspend and resume; a clone onto a storage without image support; stop; destroy with purge=1&destroy-unreferenced-disks=1; destroy again; ISO delete.

Facts F1 to F23 (cited as Evidence by the spec)

  • F1. Errors carry the text twice: in the HTTP reason phrase and as the body's message, which ends in \n. A 400 adds errors, a map from parameter to message. A 401 has the reason phrase only and an empty body. The client should use the trimmed body message and fall back to the reason phrase. Headers are ordinary CRLF.
  • F2. Missing VM, identical for GET config, GET status/current and DELETE: 500 Configuration file 'nodes/pv1-1/qemu-server/999999.conf' does not exist.
  • F3. A start issued while the VM is clone-locked is accepted (200 with a UPID). The task log reads trying to acquire lock... OK and the VM starts once the clone finishes. Setting a lock through the API was not tried.
  • F4. Clone onto a taken VMID, synchronous: 500 unable to create VM 199999: config file already exists.
  • F5. Agent enabled but not running: 500 QEMU guest agent is not running. Agent not enabled in the config: 500 No QEMU guest agent configured. Stopped VM with agent: 1: 500 VM 199999 is not running.
  • F6. Not produced. local-lvm is thin and overprovisions, so quota: absent.
  • F7. Config write while clone-locked: 500 VM is locked (clone).
  • F8. /cluster/resources?type=vm entries carry vmid, name, node, status, type, template, tags, maxcpu, maxmem, maxdisk, uptime and more; lock appears only when set. Status values seen: running, stopped, and unknown during a clone. Mid-clone the entry has no name: only type, vmid, id, node, lock: clone, status: unknown.
  • F9. Hyphenated tags are accepted and stored as given. Uppercase is folded (Proxy_Operator → proxy_operator), the separator is ;, and characters such as a space or ! give 400 … tags: invalid format - invalid characters in tag. The datacenter has no tag-style or user-tag-access set.
  • F10. description on clone is accepted and survives verbatim, newlines included. The 22-character proxy-<base32> name is accepted.
  • F11. The clone's meta is copied from the template (creation-qemu=11.0.3,ctime=1790145676, identical to 9001), so it says nothing about when the clone was made. written-at-create is required.
  • F12. Upload: multipart with field content=iso and the file part named filename, carrying the target file name. Returns a UPID for an imgcopy task. A name without .iso gives 400 … filename: wrong file extension. Re-uploading an existing name silently overwrites it (the task log shows a plain cp). Datastore.AllocateTemplate suffices.
  • F13. Deleting a volume needs Datastore.Allocate on /storage/<id>; without it the call gives 403 Permission check failed (/storage/local, Datastore.Allocate). With it (on proxy-seeds), the delete runs as an imgdel task and returns a UPID. Deleting a volume that no longer exists is also accepted and its task still ends OK; the log reads disk image '…' does not exist, then Removed volume. Seed cleanup is idempotent with no message matching.
  • F14. Stopped: status: stopped, qmpstatus: stopped. Paused: status: running, qmpstatus: paused in status/current, while /cluster/resources shows plain running.
  • F15. A clone onto a storage without image support is rejected up front (400 … storage 'local' does not support vm images) and leaves no VM. A clone failing mid-task was not observed.
  • F16. Task status fields: status (running, stopped), exitstatus (OK or the error text), type, id, node, user, tokenid, upid, starttime, pid, pstart. A full clone of the 3 GiB template onto local-lvm finished within about two seconds.
  • F17. The planned role, granted on /, covered clone, config, resize, start, stop, suspend, agent read, upload and destroy. Missing: Datastore.Allocate for deleting the seed ISO. VM.Config.Cloudinit is not needed for the ide2 write. SDN.Use was held but never exercised, because no call rewrote net0.
  • F18. HTTP/1.1 only: curl --http2 negotiates HTTP/1.1.
  • F19. Template 9001 (debian-13-template): scsi0 on local-lvm, boot: order=scsi0, ide2 free, agent: 1, OVMF, q35, net0 untagged on vmbr0. Attaching ide2 makes Proxmox append it to the boot order (order=scsi0;ide2). Template 9000 (Ubuntu) is unsuitable: it has a Proxmox cloud-init drive on ide2 and VLAN tag 4 on net0.
  • F20. Resize runs as a task (returns a UPID). The same size is an OK no-op; shrinking fails inside the task with exitstatus shrinking disks is not supported.
  • F21. Agent response: result[] of {name, ip-addresses[{ip-address, ip-address-type}]}. The live guest reported lo and ens18 with 192.168.0.150, 50 seconds after start, via DHCP on untagged vmbr0. This proves the NoCloud-seed-on-ide2 route end to end on this host.
  • F22. A wrong secret and an unknown token id both give 401 Authentication failed!; no credentials gives 401 No ticket.
  • F23. Uploads are staged in /var/tmp/pveupload-* and then copied to /var/lib/vz/template/iso/, so the tmpfs /tmp is not involved.

Corrections to the plan that Phase 1 must fold into the spec

  1. Check lock before the name. A mid-clone VM has no name in /cluster/resources (F8), so name-first logic turns a clone in progress into NotFound. Get, Delete and Create's lookup must treat a locked or nameless entry at our VMID as in-progress (Transient or wait), never absent.
  2. Inspect a taken VMID before probing forward. After a crash mid-clone, the retry's name lookup misses the nameless VM, the clone onto the deterministic VMID fails with F4, and forward probing would clone a duplicate. On F4, read that VMID: lock: clone means wait for it, and our UID in the description means adopt it. Probe forward only for a VM that is demonstrably someone else's.
  3. A start queues behind a lock instead of failing (F3).
  4. Resize is asynchronous (F20); wait on its task.
  5. Drop delete-before-upload for the seed: upload overwrites (F12). Seed deletion is a task (F13): wait on it. A repeat delete succeeds, so Delete needs no absent-volume matcher.
  6. Get reads status/current, not only /cluster/resources, to tell paused from running (F14).
  7. Tag rationale: hyphens are accepted on 9.2.20 (F9). proxy_operator stays, since it is valid everywhere, but the claim that hyphens are rejected is removed.
  8. The boot-disk logic must skip ide2, which Proxmox adds to the boot order (F19). The plan's rule already excludes it; add a test with order=scsi0;ide2.
  9. Three agent messages, not one (F5): QEMU guest agent is not running (booting, or the install failed), No QEMU guest agent configured (our agent=1 was removed), and VM <vmid> is not running (stopped between the status read and the agent call). The spec decides each class; none may become NotFound.
  10. Internet on the proxy network is a hard prerequisite, twice over: the vendor-data agent install needs a package mirror, and the proxy itself needs egress. The guide says so, and recommends baking qemu-guest-agent into the template with installGuestAgent: false to drop the mirror dependency from IP discovery.

Decisions made with the user in Phase 0

  • Seeds go to a dedicated directory storage, proxy-seeds, with ISO content only. The token gets Datastore.Allocate there and nowhere else, so it can delete seeds but never backups or other ISOs on local. The sample config's isoStorage becomes proxy-seeds. The guide must say that an ACL on a more specific path replaces the inherited one for that user, so the role granted on /storage/proxy-seeds must repeat Datastore.AllocateTemplate, AllocateSpace and Audit alongside Allocate.
  • Proxies land on a VLAN set in config. ProxmoxConfig gains an optional vlanTag (1 to 4094), applied when Create rewrites net0 together with the optional bridge override, keeping the MAC. Unset leaves net0 as cloned. This is the first call that exercises SDN.Use (F17).
  • The API URL is https://192.168.0.8:8006, the address in the node certificate, so caFile verifies with no provider change. No tlsServerName option. The operator's cluster must be able to reach 192.168.0.x.

Seed storage and VLAN 4

The user created proxy-seeds (dir, ISO content only), granted the token Datastore.Allocate, AllocateSpace, AllocateTemplate and Audit on /storage/proxy-seeds through a role ProxyOperatorSeeds, and freed the leftover recon ISO on local. An upload to proxy-seeds and two deletes of a throwaway ISO there produced content_delete_response.json, content_delete_again_response.json and task_status_volume_missing.json.

The user gave the VLAN as "vlan 4, bridge: vmbr0.4@vmbr0". That is the host's own VLAN interface name as ip link prints it; a VM NIC cannot attach to it because it is not a bridge. On the VLAN-aware vmbr0 the Proxmox form is bridge=vmbr0,tag=4, which is what template 9000 uses, so the provider's vlanTag: 4 appends tag=4 to the cloned net0.

A second clone of 9001 at VMID 199999 got its seed from proxy-seeds and net0 rewritten from virtio=BC:24:11:EE:48:83,bridge=vmbr0 to virtio=BC:24:11:EE:48:83,bridge=vmbr0,tag=4. The write succeeded, so the token's SDN.Use on / covers a VLAN tag on vmbr0 (set_config_vlan_response.json).

The first VLAN 4 attempt never produced an agent in seven minutes of polling (every 13 seconds, 33 polls). The only signal was the agent call, so the cause could not be seen from the API. A debug seed added a recon user (user-data only, with a new instance id so cloud-init re-ran), and the user logged in on the console: DHCP worked, but VLAN 4 had no route to the internet because of a routing misconfiguration on the Proxmox host, so the vendor-data apt-get install qemu-guest-agent could not run. The user fixed the routing.

Rerun from scratch after the fix, same sequence the provider will use (destroy and seed delete first, then a fresh clone and seed, instance-id equal to the name):

Step Elapsed
clone task done 4 s
seed upload task done 4 s
config (ide2, net0 with tag=4, tag) 4 s
start task done 5 s
agent reports 192.168.4.147 47 s after start

The guest was reachable from the workstation (ping, TCP 22), as the contract requires of a proxy address. Stopping it gave the F5 stopped-VM fixture; destroy and seed delete then left the host with only its original VMs 100, 9000 and 9001, no ISOs on local or proxy-seeds, and no local-lvm volumes of 199999. The capture is agent_network_get_interfaces_vlan.json.

Worth noting

  • The token file is mode 664; 600 would be tighter.
  • Fixtures: 48 files, all valid JSON, none containing PVEAPIToken. Several names differ from the plan's list because what they captured differed from what was expected: start_while_clone_locked_response.json (the start succeeded), task_status_stopped_error.json (from the shrink, not a start), upload_overwrite_response.json (success, not an error), resize_same_size_response.json and resize_shrink_response.json (UPIDs), err_forbidden_volume_delete.json (the 403 from F13). err_no_space.json was not produced (F6).
  • The host is back to its original state; see the table above.
  • The scratch seed builder (seedgen in the session scratchpad) took the user-data file and instance id as arguments by the end, for the debug login. It is not part of the repository; Phase 3's nocloud package replaces it.
  • Time to IP was about 50 s on both networks, with the agent installed at first boot. Budgets in the plan (running wait 60 s for the VM state, the agent discovered by Get polling) are consistent with this.