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>
80 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
nocloudandkeyedlockfrom 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 addserrors, a map from parameter to message. A 401 has the reason phrase only and an empty body. The client should use the trimmed bodymessageand fall back to the reason phrase. Headers are ordinary CRLF. - F2. Missing VM, identical for
GET config,GET status/currentandDELETE: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... OKand 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 withagent: 1:500 VM 199999 is not running. - F6. Not produced.
local-lvmis thin and overprovisions, soquota: absent. - F7. Config write while clone-locked:
500 VM is locked (clone). - F8.
/cluster/resources?type=vmentries carryvmid, name, node, status, type, template, tags, maxcpu, maxmem, maxdisk, uptimeand more;lockappears only when set. Status values seen:running,stopped, andunknownduring a clone. Mid-clone the entry has noname: onlytype, 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!give400 … tags: invalid format - invalid characters in tag. The datacenter has notag-styleoruser-tag-accessset. - F10.
descriptionon clone is accepted and survives verbatim, newlines included. The 22-characterproxy-<base32>name is accepted. - F11. The clone's
metais 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-createis required. - F12. Upload: multipart with field
content=isoand the file part namedfilename, carrying the target file name. Returns a UPID for animgcopytask. A name without.isogives400 … filename: wrong file extension. Re-uploading an existing name silently overwrites it (the task log shows a plaincp).Datastore.AllocateTemplatesuffices. - F13. Deleting a volume needs
Datastore.Allocateon/storage/<id>; without it the call gives403 Permission check failed (/storage/local, Datastore.Allocate). With it (onproxy-seeds), the delete runs as animgdeltask and returns a UPID. Deleting a volume that no longer exists is also accepted and its task still endsOK; the log readsdisk image '…' does not exist, thenRemoved volume. Seed cleanup is idempotent with no message matching. - F14. Stopped:
status: stopped, qmpstatus: stopped. Paused:status: running, qmpstatus: pausedinstatus/current, while/cluster/resourcesshows plainrunning. - 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(OKor the error text),type, id, node, user, tokenid, upid, starttime, pid, pstart. A full clone of the 3 GiB template ontolocal-lvmfinished within about two seconds. - F17. The planned role, granted on
/, covered clone, config, resize, start, stop, suspend, agent read, upload and destroy. Missing:Datastore.Allocatefor deleting the seed ISO.VM.Config.Cloudinitis not needed for theide2write.SDN.Usewas held but never exercised, because no call rewrotenet0. - F18. HTTP/1.1 only:
curl --http2negotiatesHTTP/1.1. - F19. Template 9001 (
debian-13-template):scsi0onlocal-lvm,boot: order=scsi0,ide2free,agent: 1, OVMF, q35,net0untagged onvmbr0. Attachingide2makes Proxmox append it to the boot order (order=scsi0;ide2). Template 9000 (Ubuntu) is unsuitable: it has a Proxmox cloud-init drive onide2and VLAN tag 4 onnet0. - F20. Resize runs as a task (returns a UPID). The same size is an
OKno-op; shrinking fails inside the task withexitstatusshrinking disks is not supported. - F21. Agent response:
result[]of{name, ip-addresses[{ip-address, ip-address-type}]}. The live guest reportedloandens18with192.168.0.150, 50 seconds after start, via DHCP on untaggedvmbr0. This proves the NoCloud-seed-on-ide2route end to end on this host. - F22. A wrong secret and an unknown token id both give
401 Authentication failed!; no credentials gives401 No ticket. - F23. Uploads are staged in
/var/tmp/pveupload-*and then copied to/var/lib/vz/template/iso/, so the tmpfs/tmpis not involved.
Corrections to the plan that Phase 1 must fold into the spec
- Check
lockbefore 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. - 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: clonemeans wait for it, and our UID in the description means adopt it. Probe forward only for a VM that is demonstrably someone else's. - A start queues behind a lock instead of failing (F3).
- Resize is asynchronous (F20); wait on its task.
- 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.
- Get reads
status/current, not only/cluster/resources, to tell paused from running (F14). - Tag rationale: hyphens are accepted on 9.2.20 (F9).
proxy_operatorstays, since it is valid everywhere, but the claim that hyphens are rejected is removed. - 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 withorder=scsi0;ide2. - Three agent messages, not one (F5):
QEMU guest agent is not running(booting, or the install failed),No QEMU guest agent configured(ouragent=1was removed), andVM <vmid> is not running(stopped between the status read and the agent call). The spec decides each class; none may become NotFound. - 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-agentinto the template withinstallGuestAgent: falseto 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 getsDatastore.Allocatethere and nowhere else, so it can delete seeds but never backups or other ISOs onlocal. The sample config'sisoStoragebecomesproxy-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-seedsmust repeatDatastore.AllocateTemplate,AllocateSpaceandAuditalongsideAllocate. - Proxies land on a VLAN set in config.
ProxmoxConfiggains an optionalvlanTag(1 to 4094), applied when Create rewritesnet0together with the optional bridge override, keeping the MAC. Unset leavesnet0as cloned. This is the first call that exercisesSDN.Use(F17). - The API URL is
https://192.168.0.8:8006, the address in the node certificate, socaFileverifies with no provider change. NotlsServerNameoption. 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.jsonandresize_shrink_response.json(UPIDs),err_forbidden_volume_delete.json(the 403 from F13).err_no_space.jsonwas not produced (F6). - The host is back to its original state; see the table above.
- The scratch seed builder (
seedgenin 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'snocloudpackage 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
Getpolling) are consistent with this.
Phase 1 — The spec
Wrote docs/proxmox-provider-spec.md, all 24 entries of the template, from the plan, the contract and the Phase 0 facts. The ten Phase 0 corrections are folded in, and the plan now opens with a note that the spec wins where the two disagree.
Judgment calls made here, each recorded in its entry:
- Create's allocation loop (entry 2) runs an ownership check on any taken VMID: wait out a lock or a nameless entry, then adopt when the name and UID are ours, skip when the name differs, refuse as Permanent when the name is ours but the mark is not. This is the fix for the duplicate-VM bug.
- Get's check order (entry 9): lock, then name and mark from the config,
then
status/current, then the agent.lock: suspendedalone maps to Stopped, since a hibernated VM never resumes by itself. - Agent messages (entry 7):
QEMU guest agent is not runningis Provisioning,VM <vmid> is not runningis Transient, andNo QEMU guest agent configuredis Stopped, so the reconciler replaces a VM that can never report an address instead of latching the Proxy. - Templates are checked before cloning (entry 4): not a template, or a Proxmox cloud-init drive in any slot, is Permanent. This makes the "no cloud-init drive" prerequisite enforceable instead of a silent failure.
isoStorageis required, with no default (entry 15), because the token must holdDatastore.Allocateon it.- TLS verification failures are Permanent, with a Checker reason
CertificateInvalid(entries 13, 16), since the fix is the URL or CA file. - The CA is loaded lazily and cached; the token is read per request (entry 14).
Checks, as the plan's Verify clause asks:
grep -c '^## [0-9]*\. ' docs/proxmox-provider-spec.md # 24
grep -n '^\*\*Answer:\*\*$' docs/proxmox-provider-spec.md # nothing
grep -c '—' docs/proxmox-provider-spec.md # 0
plus a script comparing every `<axis>`: `<profile>` pair against the
contract's §4.20 table (no mismatches, every axis answered) and a list of
the F numbers cited (all 23).
Worth noting: the first pass stated two things as fact that recon could not
show and was corrected before commit. That an unreachable node's guests stay
listed as unknown is Proxmox's documented design, unverifiable on a single
node; entry 13 now says so. And the matcher for VM <vmid> is not running
is a whole-message regexp, because is not running alone also matches
QEMU guest agent is not running.
Phase 2 — Config block, registration, package skeleton
Added the proxmox: block (internal/provider/config_proxmox.go), wired it
into config.go (the field, setBlocks, the allow-list and the dispatch:
three "proxmox" sites) and registered "proxmox": proxmox.New in
cmd/main.go, so the two type lists changed in one commit. The package
internal/provider/proxmox now holds the seam interface and its data types
(api.go), id.go, mark.go, state.go, errors.go and the constructor
(proxmox.go). The four contract methods are stubs that answer
ErrTransient, so a Proxy on a configured proxmox entry retries instead of
latching until Phases 5 to 7 land. New passes a nil api until Phase 4
builds the HTTP client.
Every test that depends on Proxmox's wording or JSON shape reads the Phase 0
captures through fixtures_test.go (readCapture, loadAPIError,
decodeData), so no message string is repeated in a test. The one exception
is TestClassify_table's synthetic rows (a 404, 502, 595, 596, a storage
message), for statuses recon never produced.
Deviations from the plan's Phase 2 list, both making it smaller:
- No temporary
locks.go. Only Create and Delete take the per-name lock, and they arrive after Phase 3 extractsinternal/provider/keyedlock, so copying kvm2's lock now would only be deleted next phase. - No
provider.Checkerassertion yet.cmd/main.gotype-asserts the Checker and would call a stub; the assertion comes with the realCheckin Phase 7.
Added beyond the plan: api_test.go decodes the captured responses into the
seam's types, so a misspelled JSON tag fails now rather than in Phase 5, and
TestParseDescription_fromClone reads the mark back from the description of
the VM cloned in Phase 0.
A mutation check confirmed the suite can fail. Matching VM <vmid> is not running as a plain suffix (the first spec draft's mistake) failed
TestAPIError_matchers on the agent-not-running capture; treating a paused
VM as running failed TestMapState_table and TestMapState_fromCaptures.
Both mutations were reverted and the files compared identical to backups.
gofmt -w cmd/main.go # realigned the constructor map for the longer key
grep -c '"proxmox"' internal/provider/config.go # 3
go build ./... && go vet ./...
go test -race -count=1 ./internal/provider/... ./cmd/... # all ok
go test -short -count=1 ./... # all ok
go test -count=1 -v ./internal/provider/proxmox/ | grep -c '^=== RUN' # 152
Worth noting: the validProxmoxYAML constant is the shape Phase 9's commented
sample entry should take, with the user's values (192.168.0.8, pv1-1,
proxy-seeds, vlanTag: 4, template 9001).
Phase 3 — Extract nocloud and keyedlock from kvm2
kvm2's seed.go and seed_test.go moved to internal/provider/nocloud
with git mv, so history follows them. The package exports BuildSeedISO,
BuildVendorData, GuestAgentScript, SeedLabel and a new ErrEmptyName
sentinel in place of kvm2's permanentf. kvm2's keyedLocks became
internal/provider/keyedlock (Locks, New, Acquire), and its three
TestKeyedLocks_* tests moved with it under their old names. kvm2 now
imports both packages, and its create.go still wraps every seed-build error
with permanentf, so kvm2's behaviour is unchanged. proxmox gained the
nameLocks field and lockName.
The test counts match the plan's Verify clause: kvm2 dropped from 465 to 447
=== RUN entries, which is the 12 moved tests plus the 6 subtests of
TestSeedISO_userDataByteForByte. nocloud (15) and keyedlock (3) carry
exactly those 18.
git mv internal/provider/kvm2/seed.go internal/provider/nocloud/seed.go
git mv internal/provider/kvm2/seed_test.go internal/provider/nocloud/seed_test.go
go test -count=1 -v ./internal/provider/kvm2/ | grep -c '^=== RUN' # 465 before, 447 after
grep -rn 'iso9660' internal/provider/kvm2/ # nothing
go build ./... && go vet ./... && go vet -tags=integration ./...
go test -race -count=1 ./internal/provider/... # all ok, run three times
go test -short -count=1 ./... # all ok
Pulled forward from Phase 6 so proxmox imports nocloud now, as the
"Done when" line requires: seed.go with seedISOName, seedVolID and
buildSeed, the pure part of the seed step. uploadSeed still belongs to
Phase 6, which calls buildSeed. The new tests are TestSeedNames_table,
TestBuildSeed_table (4 rows),
TestBuildSeed_emptyNameIsPermanentWithoutPayload,
TestBuildSeed_stagingFailureIsTransient and
TestLockName_givesUpWithContextAsTransient. proxmox went from 152 to 161
test entries.
proxmox does not copy kvm2's open review finding S5.1
(docs/reviews/2026-09-17-kvm2-provider-review/sweep.md). In kvm2, a
staging failure such as a missing /tmp or a full emptyDir becomes
Permanent, and that latches the Proxy. buildSeed makes only
ErrEmptyName Permanent and wraps any other error with %w, so
classify reads it as Transient. The sentinel makes the same three-line
fix possible in kvm2, but this phase leaves kvm2's behaviour unchanged.
Worth noting:
- The older
kvmprovider has its ownseed.gowith a different signature (buildSeedISOreturns[]byte). The plan scopes the extraction to kvm2, sokvmis untouched. GuestAgentScriptnow says the operator reads the address "from the hypervisor" instead of "through libvirt", in both the Go comment and the script's comment line inside the guest. The script's commands are unchanged.- The docs now name the new packages:
kvm2-provider.md,kvm2-provider-spec.mdentry 19, the kvm2 columns and section ofproviders-existing.md, plus a "Shared provider helpers" row in thearchitecture.mdcomponent table.docs/reviews/keeps the old names, because it records the code as it was reviewed. TestCheck_hostsCheckedConcurrentlyin kvm2 failed once under the full-racesuite (latency 199.96 ms against a 200 ms floor). It passed 20 runs out of 20 in isolation, with and without this phase's changes. The test starts its 200 ms release timers beforeCheckstarts its clock, so under load the measured latency can come in just under 200 ms. The flake predates this phase, and the test is not touched here.
Phase 4 — Seam, HTTP client, decorator, fake
client.go holds httpAPI, the thin net/http adapter for all sixteen
seam methods. It reads the token file on every request, turns any non-2xx
answer into *apiError from the body's message or the reason phrase
(F1), and wraps its transport in tracing.RestConfigWrapper. observe.go
is the logging decorator: one V(1) proxmox api call line per call,
detail at V(2), and no span, because the transport already traces each
request. New now builds observe(newHTTPAPI(...)) instead of passing a
nil api, still without touching the network or the token file.
fake_test.go holds fakeAPI, which Phases 5 to 7 build on.
Where this phase departs from the plan's Phase 4 text:
- The
http.Clientis built on first use, not innewHTTPAPI. Spec entry 14 wins over the plan: the CA is loaded lazily, a missing CA file is Transient and retried until the file lands, one without a certificate is Permanent, and the pool is cached only once it loads. A transport'sRootCAscannot change after first use, so the whole client waits for the CA (TestClient_caFileLoadedLazily). - The upload is not an
io.Pipewith a goroutine. The multipart framing (thecontent=isofield, thefilenamepart header and the closing boundary) is rendered into two small buffers, and the body isio.MultiReader(head, io.LimitReader(r, size), tail)with an exactContent-Length. The seed is still streamed and never held by the client. There is also no goroutine to leak, and the request carries a length, as Phase 0'scurl -Fupload did. A chunked upload to pveproxy was never tried.TestClient_uploadStreamsMultipartproves the streaming with a reader that stalls after its first 256 KiB until the server has seen file bytes. - The fake's
DeleteVolumetolerates a missing volume, as spec correction 5 and F13 say, instead of the plan's error. - Added: secret redaction in
apiError(contract §7), because otherwise a server that echoes the header would put the secret intoProxystatus; anerrCredentialssentinel on token-file errors, so Phase 7's Checker can reportCredentialsUnavailable; and the V(1) identifiersnewidandnameon a clone,filenameon an upload andvolidon a volume delete. Spec entry 20 now lists them.
The volid's / travels as %2F. Neither the plan nor the spec says
how {volid} is encoded in the content-delete path. The Phase 0 session's
transcript shows every delete went through a helper encoding the whole
volid:
enc() { python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$1"; }
curl ... -X DELETE ".../storage/proxy-seeds/content/$(enc 'proxy-seeds:iso/proxy-reconaaaaaaaaaaa-seed.iso')"
So apiPath runs url.PathEscape over each segment
(TestClient_volIDSlashIsEscaped).
net/http cannot send a custom reason phrase, so the client tests replay
every capture byte for byte through a hijacked connection (serveCapture).
TestClient_apiErrorFromStatusReasonAndBody globs testdata/err_*.json
and skips err_tag_hyphen.json, which is a 200 despite its name: hyphenated
tags were accepted (F9).
Tests beyond the plan's list: TestClient_methodPathAndParams (the method,
path, query and form of every seam method, with a reflection check that no
method lacks a row), TestClient_cloneOmitsEmptyStorageAndTarget,
TestClient_volIDSlashIsEscaped, TestClient_caFileLoadedLazily,
TestClient_x509FailureIsPermanent (from spec entry 21),
TestDecodeConfig_scalarsAsStrings, TestReadToken_trimsTrailingNewline,
TestObserve_startsNoSpan, TestObserve_identifiersAtV1,
TestNew_wiresObservedHTTPClient, and six TestFakeAPI_* tests.
TestFakeAPI_errorsMatchCaptures compares the fake's error texts with the
captures, so every test that goes through the fake exercises the real
wording. Texts that recon never produced start with fake:.
A mutation check confirmed the new tests can fail: dropping redact,
buffering the upload with io.ReadAll, and logging V(2) detail at V(1)
each failed their test. Removing only the empty TLSNextProto map did
not fail TestClient_http1Only. A transport with a custom
TLSClientConfig and DialContext never offers h2 unless
ForceAttemptHTTP2 is set, so the map is a second guard. With both
guards removed the test failed with client spoke HTTP/2. Every mutation
was reverted, and the files were compared identical to backups.
go test -race -count=2 ./internal/provider/proxmox/ # ok, run three times
grep -n 'ReadAll' internal/provider/proxmox/client.go # only the 64 KiB error-body read
grep -n 'context.Background()' internal/provider/proxmox/*.go | grep -v _test.go # nothing
go build ./... && go vet ./... && go vet -tags=integration ./...
go test -short -count=1 ./... # all ok
go test -count=1 -v ./internal/provider/proxmox/ | grep -c '^=== RUN' # 161 before, 289 after
Worth noting:
bin/golangci-lint run ./internal/provider/proxmox/...panics inside staticcheck's IR builder (unexpected expr: *ast.KeyValueExprin the Go 1.27 standard library'spollpackage). This is a toolchain problem, not this code. With--disable staticcheckthe new files' findings are goconst on form keys (kvm2 carries 50 of the same) and two unparam hits on the fake'saddTemplateandresourceByID, which Phases 5 and 6 will call with other VMIDs. The mapsloop and gocyclo findings inproxmox.goandapi_test.godate from Phase 2.- Nothing in this phase talked to the host. The client has only met
httptestservers replaying the captures. The first real request is Phase 10's integration run, which is where theContent-Lengthupload and the%2Fvolid get proven against pveproxy. - The fake refuses to destroy a running VM with an uncaptured
fake: VM <vmid> is running - destroy failed, so Phase 7's stop-before-destroy order is enforced by the fake even though the real text is unknown.
Phase 5 — Get, ListByTag, task helpers
task.go holds parseUPID, a shared pollUntil loop, waitTask and
waitUnlocked. get.go holds Get, the guest-agent read and resolve
(one resources call, QEMU entries only, which Delete will reuse). list.go
holds ListByTag and its candidate rule. The Get and ListByTag stubs are
gone, and TestStubs_answerTransient now covers only Create and Delete.
The spec overrides the plan in the places the Phase 4 notes listed. Get
checks in entry 9's order, so a lock or status: unknown in the resources
entry is Transient before the name is ever read, and a running Get makes
four calls (TestGet_runningMakesFourCalls). The three agent messages
follow entry 7. The poll interval is budgets.poll, and a failed task logs
a V(1) proxmox task failed line with upid and exitstatus.
Judgment calls, most of them spec gaps:
WARNINGS: <n>counts as a successful task. Proxmox reports a task that finished but logged warnings with that exitstatus and treats it as finished, not failed. Under the spec's old "anything butOKis Transient" rule, a start that logged a warning would make Create retry forever. Recon never produced one, so this row relies on how Proxmox is known to behave, not on a capture. The spec's entry 10 text and entry 13 table now say so.- A
suspendedlock still goes through the name and mark checks. The entry 9 table puts "lock suspended is Stopped" before the config rows. Taken literally, a hibernated VM that reused our VMID would be reported as ours and Stopped, and Delete would then skip it as foreign in a loop. Get therefore reads the config, which it needs anyway for UID and CreatedAt, applies the name and mark checks, and only then answers Stopped. It skipsstatus/currentand the agent. Entry 9 now says this. TestGet_twoVMsWithOurNameIsPermanentbecameTestGet_otherVMWithOurNameIsIgnored. Spec entries 2 and 9 address Get by VMID, and only Create refuses a duplicate name. Get answering Permanent would latch a working Proxy as Failed because of a VM someone else created.TestGet_unmanagedDescriptionHasEmptyUIDtests a mark with nouidline. The plan's name read literally ("unmanaged") would contradict "no mark is NotFound".- Agent errors outside the entry 7 table are forced to Transient, even
a 403 or a
does not exist. Otherwiseclassifywould make them Permanent or NotFound, and the spec says "anything else: Transient". waitUnlockedreturns the unlocked config, which saves Create's ownership check one read. When the VM disappears while it waits (a failed clone), it returns the does-not-exist error and lets Create decide.
Pulled forward and left behind:
provider_test.gonow exists withnewFixture,testBudgets,addOurVMandassertProviderError, because every Get and ListByTag test needs them. The plan created this file in Phase 8. Phase 8 adds the contract sweep to it.waitRunningis left to Phase 6. The plan's package layout puts it intask.go, but Phase 5 lists no test for it and only Create uses it. It should be built onpollUntil.- The fake gained two things.
fakeVM.kindmakes an entry listed aslxcinvisible to the qemu endpoints.hookOnis a general per-method hook, andfailWithis now built on it. Tests usehookOn(throughmutateOninget_test.go) to change a VM between two of Get's calls.
A mutation check confirmed the new tests can fail. Each of these failed a
test: classifying agent errors instead of forcing Transient; dropping the
config lock check; dropping the suspended special case; ignoring an ended
ctx in ListByTag; treating WARNINGS as failure; and not mapping
No QEMU guest agent configured to Stopped. Removing the resources-entry
lock and unknown checks together failed only
TestGet_unknownStatusIsTransient. The fake reports a lock in the config
as well, so the config check catches every lock row, including a mid-clone
VM. The resources check stays because the spec orders it first and
because it spares a config read. Every mutation was reverted, and the
files were compared identical to backups.
go test -race -count=2 ./internal/provider/proxmox/ # ok
grep -n 'not implemented' internal/provider/proxmox/*.go | grep -v _test # Create and Delete only
go build ./... && go vet ./... && go vet -tags=integration ./...
go test -race -count=1 ./internal/provider/... ./cmd/... # all ok
go test -short -count=1 ./... # all ok
go test -count=1 -v ./internal/provider/proxmox/ | grep -c '^=== RUN' # 289 before, 393 after
Lint ran with staticcheck disabled, because staticcheck panics on Go 1.27:
bin/golangci-lint run --disable staticcheck ./internal/provider/proxmox/...
Worth noting:
- The lint findings in the new files are goconst on test strings and
logcheck on
get,agentAddressandwaitTasktaking both a ctx and a logger. That shape copies kvm2, which has five of the same findings. The errcheck hits onassertGetFailswere fixed by returning the error text. The two unparam hits and the mapsloop hits predate this phase. waitUnlockedwaits on any lock,suspendedincluded. Phase 6's ownership check should decide whether a hibernated VM at our VMID is worth waiting out the clone budget for, or should be refused at once.- Nothing in this phase talked to the host. Every Proxmox message a test
relies on comes from
testdata/or from fake errors thatTestFakeAPI_errorsMatchCapturesties to the captures.
Phase 6 — Create
create.go holds Create and its steps: placement, the by-name lookup,
the template check, the VMID probe with its ownership check (inspect),
the clone, and provision (seed, config, resize, start). configParams,
rewriteNet0, resizeTarget, bootDisk and diskBytes are pure helpers
with their own table tests. seed.go gains uploadSeed, which uploads
over any existing file with no delete first (F12). task.go gains
waitRunning, built on pollUntil with budgets.running. It finishes
only at running/running, and a stopped status after the start is
Transient at once. The Create stub is gone, and TestStubs_answerTransient
now covers only Delete.
The spec won over the plan wherever the Phase 6 notes said it would. The
plan's allocation loop is replaced by entry 2: a taken VMID and a clone
answering F4 both go through the ownership check before any probing, and a
locked or nameless entry is waited on with waitUnlocked and the clone
budget. The seed is uploaded without a delete. The resize is a task. net0
changes only when bridge or vlanTag is set, and tag= is replaced or
appended in place.
Judgment calls, with spec entries 2, 4 and 10 updated to match:
- The Phase 5 open question: a
suspendedlock is not waited on.waitUnlockednow returns at once when it seessuspended, because a hibernated VM is not a task and would otherwise burn the 10-minute clone budget on every attempt. The ownership check then judges the VM the wayGetdoes. A foreign hibernated VM is probed past. Ours is Permanent ("resume or destroy it"), following kvm2's precedent for a paused or pmsuspended domain in Create. Transient was the alternative: it would self-heal once an admin acts, but it would retry indefinitely on something only an admin can fix.TestWaitUnlocked_tablehas a new row, andTestCreate_hibernatedVMIsNotWaitedOncovers both cases. - The template is checked only when a clone is needed. Spec entry 10
read the template's config in step 2, before the by-name lookup. A retry
for a VM that already exists would then fail Permanent if the template
had been deleted since, which entry 4 explicitly allows. The template
check now runs after the by-name lookup misses, so a repeat Create costs
three calls: resources, config and status
(
TestCreate_adoptionDoesNotNeedTheTemplate). - The template's shape is checked before the clone. No
net0whilebridgeorvlanTagis set, or no boot disk (or none with a readablesize=) whilediskGiBis set, is Permanent. Found after the clone, the VM would already carry the bad config, so fixing the template could not help that Proxy's next attempt.template()runs the sameconfigParamsandresizeTargetover the template's config thatprovisionlater runs over the clone's. - An empty
req.Nameis Permanent at placement. A nameless request would match nameless mid-clone entries in the by-name lookup. - Adopted VMs get one
status/currentread rather than trusting the listing's status, which may be stale after a lock wait. A running VM, whatever itsqmpstatus, returns its ID at once, as entry 10 says.Getthen reports prelaunch as Provisioning and paused as Stopped. - After the clone task, Create reads the config through
waitUnlocked. That read supplies thenet0and boot disk the later steps need, and it turns a clone that ended but kept its lock (F15) into a Transient lock wait rather than a refused config write. - An F4 whose VMID then resolves to no QEMU VM probes on. Either the racer's clone failed and removed it, or a container took it.
- Errors carry no ID (
wrapErr("create", "", …)), since the VMID is known only after allocation. The messages name the VMID wherever one exists.
Test names: every plan and entry 21 name exists except
TestCreate_resizeOnlyWhenLarger. It is covered by the spec's pair:
TestCreate_resizeWaitsForTask for the larger case and
TestCreate_resizeSkippedWhenNotLarger for equal, smaller and unset. Tests
beyond both lists: TestCreate_vmThatStopsAfterStartIsTransientAtOnce,
TestCreate_hibernatedVMIsNotWaitedOn,
TestCreate_templateShapeRefusedBeforeClone,
TestCreate_adoptionDoesNotNeedTheTemplate,
TestCreate_cloneTargetAndStorage (read from the decorator's V(2) clone
line), TestCreate_adoptedVMStaysOnItsNode, TestCreate_outcomeLine,
TestCreate_quietAtV0, TestRewriteNet0_table, TestBootDisk_capturedClone
and TestDiskBytes_table. The only new test helpers are the small ones in
create_test.go (slot, newCreateFixture, req/create/createFails,
assertRunning, callsBefore, clearLockOnVMConfig). Everything else
reuses the Phase 5 fixture and fake. Two tests check config-write contents
without a recording wrapper. TestCreate_markWrittenInCloneCall reads the
VM at the start of UploadISO, which is after the clone and before the
config write. TestCreate_noBridgeOverrideLeavesNet0 changes net0 at the
start of SetVMConfig, so a write that carried net0 would put the old
value back.
A mutation check applied 18 mutations, and each failed at least one test.
They were: no lock wait in the ownership check; taken VMIDs never
inspected; the F4 race skipping the ownership check; no cloud-init drive
check; upload or resize task not waited on; resize at equal size; a
CD-ROM counted as a boot disk; prelaunch counted as running; the suspended
lock waited on; net0 always written; bridge= appended instead of
replaced; duplicate names ignored; a running VM re-provisioned; target
always set; a foreign UID adopted; the tag dropped; probes not capped by
the range. The last one was first missed. A 4-VMID range with 32
probes wraps, re-inspects each VM eight times and still ends Transient, so
TestCreate_vmidRangeExhaustedIsTransient now pins five config reads.
Five mutations did not compile on the first try and were redone. Every
mutation was reverted, and the files were compared identical to backups.
go test -race -count=2 ./internal/provider/proxmox/ # ok
grep -n 'CloudInit' internal/provider/proxmox/create.go internal/provider/proxmox/seed.go
# seed.go: the pass-through into nocloud.BuildSeedISO; create.go: len(req.CloudInit)
grep -n 'not implemented' internal/provider/proxmox/*.go | grep -v _test # Delete only
grep -n 'context.Background()' internal/provider/proxmox/*.go | grep -v _test.go # nothing
go build ./... && go vet ./... && go vet -tags=integration ./...
go test -short -count=1 ./... # all ok but the parked kvm2 flake, below
go test -count=1 -v ./internal/provider/proxmox/ | grep -c '^=== RUN' # 393 before, 519 after
bin/golangci-lint run --disable staticcheck ./internal/provider/proxmox/...
Worth noting:
go test -short ./...failed once on the parked kvm2 flakeTestCheck_hostsCheckedConcurrently(measured 199.98 ms against a 200 ms floor). Three reruns passed. This phase does not touch kvm2.- The new lint findings follow the baseline's shape. There are logcheck
hits on
create,allocateandclone(ctx plus logger, as in kvm2), and goconst on test strings. One more unparam than the notes list:(*fakeAPI).iso'sstoragealways receivesfakeISOStorage, because this phase is its first caller.addTemplate's hit changed from a constant VMID to an unused result. - The config write sets
agent=1andtags=proxy_operatoras values. A template's agent options (such asfstrim_cloned_disks=1) and its own tags are replaced, not merged. That is what entry 10 specifies, and the guide should say so. - In the fake, a cross-node clone puts the config on the target at once.
Real Proxmox may move it at the end of the task, in which case a lock
wait on the target node would see
does not existand the next attempt would find the VM. This stays untested, like every cross-node path (entry 24). - Nothing in this phase talked to the host.
Phase 7 — Delete, Checker, CRD comment
delete.go holds Delete: parse, the per-name lock, destroy (resolve,
the lock and status checks, config, ownership, stop, destroy) and the seed
delete. check.go holds Check, which fans out one checkNode per node
with wg.Go and returns the verdicts in config order, plus checkReason
and the four provider reasons. The Delete stub and
TestStubs_answerTransient are gone, and grep -rn 'not implemented' internal/provider/proxmox/ is empty. All four contract methods and
Check are real. The status.type comment in
api/v1alpha1/providerhealth_types.go now ends kvm2 or proxmox, and
make manifests changed only that description line in the ProviderHealth
CRD.
The spec won over the plan as the Phase 7 notes said. An absent VM still
gets its seed deleted, on every configured node. A task waits on the seed
delete, and nothing matches F13's absent-volume message. A paused VM gets
the stop too. The Checker's reasons include CertificateInvalid and
CredentialsUnavailable, and pveproxy's 595 and 596 are Unreachable.
Open questions decided (spec entries 11 and 16 updated):
- Our hibernated VM (
lock: suspended) is Permanent in Delete: "resume or destroy it in Proxmox", the same answer Create's ownership check gives. Proxmox's destroy refuses a locked VM (the fake answers with F7'sVM is locked (…)text; the suspended variant was never captured), and that lock never clears on its own. Transient would retry forever. A Permanent from Delete inreconcileManaged's Stopped branch goes throughproviderFailureand latches the Proxy with the admin action in its status. On the finalizer pathdeletionFailurekeeps retrying, and the retries can be seen. Nothing captured shows what a hibernated VM does on a stop or start through the API, so there was no evidence for a self-healing path (for example, start to resume, then stop and destroy). A hibernated VM that is not ours is left alone. The resources-entry lock check skipssuspended, so ownership is judged from the config, as in Get. - The Checker reads
p.cfg.TokenFileitself, through the client'sreadToken, before its first request, and keeps only the part before=. A seam method would break the rule of one method per endpoint, and bothTestClient_methodPathAndParamsandTestObserve_everySeamMethodLogsOneLineenumerate the interface. An unusable file answersCredentialsUnavailablewithout a request.readToken's errors never carry the content. Every row ofTestCheck_reasonsTableruns at V(2) over the decorator with a sentinel secret and asserts that the secret is in no message, detail or log line.
Other judgment calls:
- The seed is deleted on the VM's node only when Delete destroyed a VM of ours; otherwise on every node. Entry 11 said "skip to step 7" for a VM that is foreign, unmarked, or gone by the time the config is read, without naming a node. In each of those cases our VM's node is unknown: the node in the listing belongs to someone else's VM, or to one that just vanished. A container at the VMID takes the same path.
- Stop's
does not existmeans the VM is gone, the same as Destroy's. The seed is then deleted on its node, and Destroy is not called. errCAFilejoinserrCredentialsinclient.go. A missing CA file or one without a certificate isCertificateInvalid("fixcaFile"). Without the sentinel it would beAPIError. Its class is unchanged (Transient or Permanent, as entry 14 says).- Any 500 from the storage status call is
StorageNotFound, carrying Proxmox's text. The plan matched a 500 "does not exist", but no response for a missing storage was captured. Matching an invented string would go against the fixture discipline, and every 500 there (missing, not enabled on that node, offline) is fixed on the storage anyway. A 401, 403, 595 or 596 keeps its shared reason. checkReasonputs*net.OpErrorbefore the deadline check, so a dial that runs out of its own 10 s budget isUnreachable, notTimeout. A request timing out on its own while the round still runs isTimeout. The round's ctx ending isTimeoutwhatever the error says, and that guard is incheckNode, as in kvm2.- The outcome line
proxmox vm deletedcarriesdestroyed, which is false when there was no VM of ours left to destroy. It has nonodein that case. Entry 20 lists the new V(1) no-op lines. templatescounts catalogue entries, not unique VMIDs.
Test names. These plan names changed with the behaviour:
TestDelete_absentIsNoOp → TestDelete_absentStillDeletesSeedOnEveryNode,
TestDelete_foreignNameIsNoOp → TestDelete_foreignNameLeavesVMAlone,
TestDelete_unmarkedIsNoOp → TestDelete_unmarkedLeavesVMAlone, and
TestDelete_toleratesMissingISO merged into entry 21's
TestDelete_seedDeleteIsTaskAndIdempotent, which runs with the seed both
present and absent and ties the absent case to
task_status_volume_missing.json. Every other plan and entry 21 name
exists. TestDelete_usesPurgeAndDestroyUnreferencedDisks runs Delete over
the real httpAPI against the Phase 0 destroy captures
(cluster_resources_during_destroy, config_during_destroy,
destroy_response, content_delete_response,
task_status_volume_missing). TestGet_duringDeleteStillReturnsInstance
holds the destroy task in its first poll and first checks that the
mid-destroy captures show the VM listed, stopped and without a lock.
Tests beyond both lists: TestDelete_containerAtVMIDIsLeftAlone,
TestDelete_hibernatedVMIsPermanentIfOurs,
TestDelete_seedDeleteFailureOnVMNodeFails,
TestDelete_seedDeleteNodeFailureSkippedWhenNodeUnknown,
TestDelete_goneMidwayIsSuccess, TestDelete_resourcesFailureIsTransient,
TestDelete_waitsOnTheNameLock (a held name lock means no call at all,
which is what makes a Delete racing a Create wait),
TestDelete_cancelledMidWaitReturnsPromptly, TestDelete_outcomeLine and
TestCheck_reasonsOverHTTPClient, which takes the transport reasons from
real net/http and crypto/tls errors: unknown CA, hostname, missing CA file,
a replayed 401, 596, a closed server and a cut response. The fake gained
destroyFailsWith. New test helpers are only the small ones in
delete_test.go (putSeed, seedPresent, delete/deleteFails,
seedDeleteNodes read from the decorator's lines, twoNodeFixture) and
check_test.go (newCheckFixture, assertNoSecret, setStorage).
TestCheck_nodesCheckedConcurrently uses a barrier, not a timer: every
node's Version waits until all have arrived, so checking the nodes one
at a time fails the verdicts deterministically and does not measure
elapsed time. That avoids the kind of flake kvm2's 200 ms test has.
The mutation check applied 34 mutations: 17 in delete.go, 16 in
check.go and 1 in client.go. Every one compiled and every one failed at
least one test. Three did not compile on the first try (an unused upid
or tokenID) and were redone. The first run of "never stop" did fail
tests, but it also hung TestDelete_concurrentSameNameSafe, whose
wait for the first Stop call could not end. The wait is now bounded by
the first Delete returning, and the rerun fails fast. One reasons row
("dial cut short by the round's deadline") was added before the run,
because no existing row would have caught dropping checkNode's ctx
guard. Every mutation was reverted by a script that compared each file's
hash with the original.
go test -race -count=2 ./internal/provider/proxmox/ ./internal/providerhealth/ # ok
make manifests && git diff --stat config/ # crawl.example.com_providerhealths.yaml | 2 +-
grep -rn 'not implemented' internal/provider/proxmox/ # nothing
go build ./... && go vet ./... && go vet -tags=integration ./...
go test -short -count=1 ./... # all ok
go test -count=1 -v ./internal/provider/proxmox/ | grep -c '^=== RUN' # 519 before, 649 after
bin/golangci-lint run --disable staticcheck --max-same-issues 0 --max-issues-per-linter 0 ./internal/provider/proxmox/...
Worth noting:
- Lint defaults hide repeats: with
--max-same-issues 0the logcheck hits are 4 increate.go, 4 in the newdelete.go, and 2, 1 and 1 inget.go,seed.goandtask.go, all the ctx-plus-logger shape. Nothing else is new except goconst on test strings. A prealloc hit indelete_test.gowas fixed. TestCreate_concurrentSameNameSerialisedhas the same unboundedfor callCount("Clone") == 0wait that hung the Delete test under a mutation. It is left for the Phase 8 sweep, because this phase does not touch Create.- Never seen on the host, for Phase 10: what the storage status call answers for a missing or disabled storage, what destroy says for a hibernated VM, and a stop on a paused VM. The try-every-node seed delete and the multi-node Checker have only met the fake (entry 24).
- Nothing in this phase talked to the host.
Phase 8 — Contract and observability sweep, integration test, testing.md
The sweep went into the existing provider_test.go and reuses the earlier
phases' helpers. The new helpers are cancelWhileBlocked, within,
newLoggingFixture with lifecycle/sentinelReq, assertNeverLogged,
assertEveryV1Line and msgLine.
TestProvider_cancelledContextReturnsPromptlyblocks Create, Get, Delete and ListByTag onClusterResources, and Check onVersion, withblockOn. It asserts that the blocked call was the first in the call log, then cancels. Each must return within 1 s, as a Transient that still carriescontext.Canceled(Check: one target,Timeout). Two more rows block mid-operation: Create onUploadISOand Delete onStop.TestProvider_everyErrorIsWrappedWithConfiguredNamehas 20 rows, one or more behind everywrapErrcall site: placement, the name lock with an ended ctx, resources, a 403 on the clone, a failed start task, the malformed, missing, locked and 401 Get, the hibernated VM, a failed destroy task, a 403 on the seed delete, and the list with a ctx that ends on a candidate. Each row also checksError.ID(empty for Create) and theprovider proxmox-home: <op>prefix.TestLogging_v0SilentV1OutcomeV2Detailruns one whole lifecycle over the observed fake with a token file: Create with a sentinel in CloudInit, a Get that finds the captured address, ListByTag, Delete, Delete of the gone VM, and Check. It runs at V(0), V(1) and V(2). V(0) logs nothing. V(1) has each outcome line once, with its keys, and twoproxmox vm deletedlines:destroyed=truewith node, thendestroyed=falsewithout. Every line has theproxmoxlogger, the provider and level 1, and no V(2) key. V(2) has the resolved line withcloudInitBytes, agent addresses, the listed instance, and the clone's description. The sentinel,#cloud-configand the token secret appear at no level.TestLogging_noOpLinesAtV1(a name beyond the plan) covers entry 20's no-op lines. Each carries provider and vmid: VM already gone, foreign, unmarked, seed delete skipped on each of two nodes, unreadable candidate.TestTracing_httpSpansUnderOperationSpanroutes captures by path, asTestDelete_usesPurgeAndDestroyUnreferencedDisksdoes. The captures arecluster_resources_during_destroy,config_during_destroy,status_current_runningandagent_network_get_interfaces. A Get runs over the realhttpAPI, wrapped inobserveandprovider.WithTracing, with the recorder's tracer passed throughnewHTTPAPI's opts. It asserts Running at 192.168.0.150, and exactly four spans namedHTTP GET(otelhttp v0.70'sdefaultTransportFormatter), each a child ofprovider.getin its trace. No attribute on any span carries the secret orPVEAPIToken.
Judgment calls:
- "Instance lines carry vmid and name" is checked on the provider's
lines, not the decorator's. Entry 20 gives
proxmox api callthe fieldscall,node,vmid,storage,upidandduration, withnameonly on a clone. SoassertEveryV1Lineexempts that line and the two lines that are not about one instance (vms listed,node checked), and requires vmid and name on all the others. - The integration test compares
CreatedAtwith the mark Create wrote. kvm2 only checks that it is non-zero. Entry 21 says "equal what Create wrote", so the test reads the VM's config through the bare client and parses its description. - Get's poll tolerates Transient. kvm2's poll fails on any error. Entry 7 makes every agent answer the provider cannot place Transient, and a booting guest gives some. Those answers are logged and polled through; any other class fails.
- The integration machine type has
diskGiB: 4. The 3 GiB recon template is then grown by the resize task on the real host. A larger template skips the resize. - The seed check after Delete is one extra read-only GET in the test:
client.doon/nodes/{node}/storage/{storage}/content?content=iso, decoded the waystorage_content_iso.jsonis shaped. No seam method was added. The VM count and the by-name lookup use the existingClusterResources. - The cleanup finds the VM by name, so a Create that failed halfway
is also removed. With
PROXMOX_TEST_KEEPit printsqm stop <vmid>beforeqm destroy <vmid> --purge, because destroy refuses a running VM. It then printspvesm free <isoStorage>:iso/<name>-seed.iso.
Unbounded waits. TestCreate_concurrentSameNameSerialised now uses
the first channel shape of TestDelete_concurrentSameNameSafe. The grep
found no other sleep loop; client_test.go's for { reads multipart
parts until EOF. The mutation check found a different shape of the same
hang: tests that call a blocking operation directly on the test goroutine,
where the lock or blockOn is released only after the call returns. Under
"lockName ignores ctx", "Check ignores ctx" and "pollUntil ignores ctx"
the package ran into its 60 s timeout on
TestDelete_waitsOnTheNameLock, TestLockName_givesUpWithContextAsTransient,
TestCheck_expiredContextReturnsPromptly, two TestCheck_reasonsTable
rows, TestCreate_cancelledMidWaitReturnsPromptly,
TestDelete_cancelledMidWaitReturnsPromptly, and the ended-ctx rows of
TestWaitTask_table and TestWaitUnlocked_table. Each call now runs in
within(t, 5s or 10s, …), which fails the test by name. After the fix
the same mutations fail those tests in seconds.
The mutation check ran 14 mutations on production code. The script copied
each file, applied the edit (asserting that it matched exactly once), and
ran go test -c before counting a mutation. It then ran
go test -count=1 -timeout=60s, restored the file from the copy, and
asserted the sha256 matched. Afterwards sha256sum -c over the eight
files hashed up front was OK. task.go, added for M14, was covered by the
script's own assertion. git status showed no production file changed.
Results:
- Caught by the new tests:
- Get drops the span context before its requests →
TestTracing_httpSpansUnderOperationSpan. - Cloud-init logged at V(2) →
TestLogging_…andTestCreate_cloudInitSentinel…. - Delete's destroy path skips
wrapErr→ the wrap test and 6 Delete tests. - ListByTag's ended-ctx path skips
wrapErr→ the wrap test'slist ended on a candidaterow. - Delete's resolve with
context.Background()→ the cancel test'sdeleterow. lockNamewithcontext.Background()→ the wrap test's two lock rows.- Check's probe with
context.Background()→ the cancel test'scheckrow. - Delete's logger without the name,
destroyedalways true,vms listedat V(0), the clone description at V(1), and the list skip line without vmid →TestLogging_*.
- Get drops the span context before its requests →
- Caught only by the older tests:
pollUntilwithout itsctx.Done()case → the four waits tests above. The first attempt deleted the case and did not compile (theproviderimport became unused). The rerun replaced it with a nil channel. - Survived, and equivalent:
newHTTPAPIwithout the tracer-provider option. The transport starts a span only under a valid parent. With noWithTracerProvider, otelhttp v0.70 takes the tracer fromtrace.SpanFromContext(r.Context()).TracerProvider()(transport.go:99), so the child always lands in the parent's provider. No test can tell the two apart.
Makefile: test-integration-proxmox next to the kvm2 target, with a
## help line naming the five required variables. docs/testing.md
gained:
- rows for
nocloud,keyedlockand five forproxmox(both packages were missing); - a "Live Proxmox, by default" bullet under "deliberately do NOT cover", naming the cross-node paths and the three answers never captured;
- a "The proxmox integration test" section: variables, the eleven assertions in order, the KEEP cleanup, the cost, and that it has not run yet.
In spec entry 21 every §11 box is ticked. The list's intro says the two integration tests compile but have not run against a host. The cancel and sentinel items name the new coverage.
# the names in entry 21, each checked before ticking: 42 of 42 exist
awk '/^## 21\. Tests/,/^## 22\./' docs/proxmox-provider-spec.md | grep -o '`Test[A-Za-z0-9_]*`' | tr -d '`' | sort -u \
| while read n; do grep -rqE "^func $n\(" internal/provider/proxmox/ && echo "ok $n" || echo "MISSING $n"; done
go test -race -count=2 ./internal/provider/proxmox/ # ok
go vet -tags=integration ./... && go vet -tags=e2e ./... # ok
make -n test-integration-proxmox
# go test -race -tags=integration -count=1 -v -timeout 30m -run TestProxmox ./internal/provider/proxmox/
go test -tags=integration -count=1 -v -run TestProxmox ./internal/provider/proxmox/ # both SKIP: PROXMOX_TEST_URL is not set
go build ./... && go test -short -count=1 ./... # all ok
go test -count=1 -v ./internal/provider/proxmox/ | grep -c '^=== RUN' # 649 before, 688 after
bin/golangci-lint run --disable staticcheck --max-same-issues 0 --max-issues-per-linter 0 ./internal/provider/proxmox/...
bin/golangci-lint run --build-tags integration --disable staticcheck --max-same-issues 0 --max-issues-per-linter 0 ./internal/provider/proxmox/...
Worth noting:
- Lint is at the baseline:
- the logcheck hits (4/4/2/1/1), mapsloop ×2 in
proxmox.go, unparam ×3 infake_test.go, gocyclo inapi_test.go, and goconst on test strings. - Two new gocyclo hits were removed, not accepted.
TestLogging_v0SilentV1OutcomeV2Detailwas at 32; the per-line loop becameassertEveryV1Line.TestProxmox_lifecyclewas at 40; steps 6 and 7 becameassertMarkReadBackandassertListed. kvm2's lifecycle test is still at 39.
- the logcheck hits (4/4/2/1/1), mapsloop ×2 in
- The integration tests only compile so far. The first real run is Phase
10's. That run meets the Datastore.Allocate point too: a token without
it on the ISO storage fails step 9 as Permanent. Phase 0 granted it on
proxy-seeds, and it is already a Phase 9 guide item. - The parked items did not bite: kvm2's S5.1 and its 200 ms
TestCheck_hostsCheckedConcurrentlypassed in everygo test -short ./...run of this phase. .claude/settings.jsonstays out of the commit. Nothing in this phase talked to the host.
Phase 9 — Deployment and documentation
Deployment: the optional proxmox-token Secret (keys token and ca.pem)
is mounted read-only at /etc/proxy-operator/proxmox in
config/manager/manager.yaml, next to kvm-ssh and commented the same way.
The Makefile gains proxmox-token-secret. Both providers configs gain a
commented proxmox-home entry after kvm2's. config/samples/proxy_proxmox.yaml
is listed in the samples kustomization. There is no RBAC change and no
hack/providers-dev.yaml entry. Docs: docs/proxmox-provider.md follows
kvm2's guide heading for heading and has no —. The rows and sections
named below went into providers-existing.md, architecture.md, the
README, providers.md and CLAUDE.md. docs/testing.md and the
integration Makefile target were done in Phase 8 and were not touched; the
guide's Testing section links to them.
Where the spec and the plan's Phase 9 text disagree, the spec won. The URL
is https://192.168.0.8:8006, not 192.168.2.8, which is not in the node
certificate. The Secret has two keys, and the target takes
PROXMOX_CA_FILE as well as PROXMOX_TOKEN_FILE.
Judgment calls:
- The target also checks both files' content. The token file must be
exactly one non-blank line matching
<user>@<realm>!<tokenid>=<secret>. That is the shapereadTokenaccepts:TrimSpace, then an anchored regexp. The check usesgrep -candgrep -qx, so the secret is never printed. The CA check mirrorskvm-ssh-secret'sPRIVATE KEYcheck: the file must containBEGIN CERTIFICATE. WithinsecureSkipTLSVerifythe guide says to create the Secret by hand with thetokenkey only. - The sample-entry test covers both files.
commentedEntryfinds the# - name: proxmox-homeline and takes its indent. It keeps the lines that continue at<indent>#and strips#at that indent. The same helper then works for the ConfigMap, whose entry sits six spaces deep inside a|block. SoTestLoadConfig_proxmoxSampleEntryhas asamplesubtest and aconfigmapsubtest. It checks every field the spec names, and that the optional fields stay commented out. - A second test goes beyond the plan:
TestLoadConfig_proxmoxSampleProxyMatchesSampleEntry. The sample Proxy's provider, zone, machine type and image must resolve in the sample entry. kvm has exactly this bug as a known gap (proxy_kvm.yamlplaces onnas1). - The sample entry also has
medium: {cores: 2, memoryMiB: 2048, diskGiB: 10}, next to thesmallthe spec asks for, sodiskGiBappears somewhere. The optional fields are commented out with a short note each, because no live proxmox entry exists to explain them. The kvm2 entry can rely on kvm's. - In the ConfigMap the proxmox entry is commented out, although kvm2's is live there. It needs a host and a token.
- providers-existing.md needed more than the column and the section. The intro now says five providers and links the guide. In the shared gaps, six sentences would otherwise be false, and each now names proxmox where it applies: adoption verified in two of the five, Create blocking in all three VM providers, no Terminated, the list policy, the collector gap, and the "kvm2 does this" in proposed features. The new section is §8, so the shared gaps and proposed features move to §9 and §10. No doc links those sections by anchor (grep).
- architecture.md §11 now says "All five providers do". The sentence counts Checkers, and proxmox has one. The four Decisions bullets are entry 23's: the VMID from the UID with the ownership check, the mark in the description, the seed uploaded through the API, and the node resolved per call.
- The guide lists the ten design limits first, then four more. They
are the hibernated-VM latch, the one-request seed upload, the open-proxy
sample (kvm2's guide lists it too), and "not yet proven end to end" with
the three answers never captured. The Phase 6 to 8 guide items are
each in the section where a reader looks for them. The config write
replacing
agentandtagsis in What lands on Proxmox, and so are the seed cleanup,qm unlockand the 403 that makes Delete Permanent. The certificate rule is in Cluster-side setup. CA caching versus token rotation is in both. - The guide resumes a hibernated VM with
qm start, notqm resume. A VM suspended to disk resumes from its state file on its next start;qm resumeis for a VM paused in RAM. - The guide's
pvesm add dir proxy-seeds --path /var/lib/proxy-seeds --content isois the standard form, not a recorded command. Phase 0 says only that the user createdproxy-seedsas adirstorage with ISO content; the path is illustrative.
The token-file check, run without touching a cluster: KUBECTL=echo
prints only the apply stage, and KUBECTL=true runs nothing. Scratch
files covered one good line, two lines, a bare secret, blank lines around
a good line, a non-PEM CA and a missing file. The real files on this
workstation pass:
make -n proxmox-token-secret
make -s proxmox-token-secret PROXMOX_TOKEN_FILE=$D/<case> PROXMOX_CA_FILE=$D/ca KUBECTL=echo
# good, ws: "apply -f -"; two, bad: "... is not one line of user@realm!tokenid=secret"; noca: "... holds no PEM certificate"
make -s proxmox-token-secret KUBECTL=true && echo "real files pass" # real files pass
The mutation check for the sample tests edited one line per run,
asserting a single match. It ran the two tests, restored the file from a
copy, and at the end sha256sum -c confirmed the three files. All five
mutations failed the tests: vlanTag: 5000 and isoStorag: in the
sample, 192.168.2.8 and type: kvm2 in the ConfigMap, and
image: debian-12 in the sample Proxy. type: kvm2 was rerun after lint
removed the test's own type comparison. It still fails, because
LoadConfig refuses a proxmox block under another type.
The link sweep is a short script. It takes the docs as arguments, so it
can be rerun after any later edit. It skips fenced code and inline code
spans. For each relative link it checks that the file exists, and that
the #anchor matches a GitHub-style slug of a heading in the target file
(or the same file). Before the real run it was checked on a scratch copy
of the guide with five links added. It reported exactly the three bad
ones: a missing file, a missing anchor in testing.md, and a missing
same-file anchor. It checked 78 relative links in the six edited docs,
plus this log's, and found 0 broken:
cat > doclinks.py <<'PY'
import os, re, sys
def slug(h, seen):
s = re.sub(r"[^\w\- ]", "", h.strip().lower()).replace(" ", "-")
n = seen.get(s, 0); seen[s] = n + 1
return s if n == 0 else f"{s}-{n}"
def prose(path):
out, fence = [], False
for line in open(path, encoding="utf-8"):
if line.lstrip().startswith("```"): fence = not fence; continue
if not fence: out.append(line)
return out
def anchors(path):
seen = {}
return {slug(re.sub(r"`", "", l.lstrip("#").strip()), seen) for l in prose(path) if re.match(r"#{1,6} ", l)}
bad = 0
for doc in sys.argv[1:]:
for n, line in enumerate(prose(doc), 1):
for target in re.findall(r"\]\(([^)\s]+)\)", re.sub(r"`[^`]*`", "", line)):
if re.match(r"[a-z]+:", target): continue
f, _, a = target.partition("#")
p = os.path.normpath(os.path.join(os.path.dirname(doc), f)) if f else doc
if not os.path.exists(p): print(f"{doc}: missing file {target}"); bad += 1
elif a and p.endswith(".md") and a not in anchors(p): print(f"{doc}: missing anchor {target}"); bad += 1
print(f"{bad} broken"); sys.exit(1 if bad else 0)
PY
python3 doclinks.py docs/proxmox-provider.md docs/providers-existing.md docs/architecture.md \
README.md docs/providers.md CLAUDE.md docs/plans-executions/2026-09-18-1502-proxmox-provider.md # 0 broken
Verification:
bin/kustomize build config/default >/dev/null && bin/kustomize build config/samples >/dev/null # ok
go test -count=1 -run 'TestLoadConfig_proxmoxSample' -v ./internal/provider/ # 3 PASS
grep -c '—' docs/proxmox-provider.md # 0
go build ./... && go vet ./... && go vet -tags=integration ./... && go test -short -count=1 ./... # all ok
bin/golangci-lint run --disable staticcheck --max-same-issues 0 --max-issues-per-linter 0 ./internal/provider/
git status --short config/ # the four edited files named above, plus proxy_proxmox.yaml
Worth noting:
- Lint on
internal/provideris at its baseline of three goconst hits. The first version of the new test pushed it to five: itsp.Type != "proxmox"made"proxmox"a three-time literal, flagged in bothconfig.goand the test. That comparison added nothing, so it went.internal/provider/proxmoxwas not touched. - The
tmpvolume comment inmanager.yamlstill says only the kvm provider stages seeds there. kvm2 and proxmox do too. It was already stale for kvm2, so it was left out of this phase's diff. - The architecture §5 block's right-hand bracket was aligned by
character, not byte (
→is three bytes), so every line puts│in column 83. - Nothing in this phase talked to the Proxmox host or a cluster.
.claude/settings.jsonstays out of the commit. The parked kvm2 items (S5.1, and the 200 msTestCheck_hostsCheckedConcurrently) did not bite:go test -short ./...passed. - Next is Phase 10: the first real run of
TestProxmox_lifecycleandTestProxmoxCheck_realHoston pv1-1, with the env block indocs/testing.md. The three answers never captured go with it, and the guide's "Not yet proven end to end" gap and the README's matching clause come out once they pass. Then the CHANGELOG entry after the user confirms, and the MR.
Phase 10 — Review, real-host validation, changelog, MR
Where the plan's Phase 10 text and the spec disagreed, the spec won, as in
every phase since Phase 1. The host is pv1-1 at https://192.168.0.8:8006,
not 192.168.2.8, the template is 9001 and the ISO storage proxy-seeds.
Review. Two read-only passes over git diff main...HEAD: the
go-operator-reviewer agent, briefed with the spec and the contract
(P1 to P6, S1 to S3), then /code-review high on the branch (CR1 to
CR10). Of the 19 findings (two found by both passes), 9 were fixed, 3
accepted as documented limits and 7 rejected with a reason, all recorded in
docs/reviews/2026-09-23-proxmox-provider-review/REPORT.md.
The one that mattered was CR1/P1. Delete destroyed the VM and then deleted
the seed, so a failed seed delete left a seed nothing would ever remove:
the next Get is NotFound, the finalizer goes, and GC lists only VMs.
Delete now removes the seed between the stop and the destroy, so the VM
Get looks at goes last. A 403 on the every-node path now fails instead
of being skipped. P2 (CR3) was the other code change: a probe VMID whose
listed name is someone else's is skipped at once, instead of waiting up to
the clone budget on its lock on every retry.
Lint. make lint still panics inside staticcheck on Go 1.27, so, as
in Phases 8 and 9:
bin/golangci-lint run --disable staticcheck --max-same-issues 0 --max-issues-per-linter 0 ./internal/provider/proxmox/... ./internal/provider/
bin/golangci-lint run --build-tags integration --disable staticcheck --max-same-issues 0 --max-issues-per-linter 0 ./internal/provider/proxmox/... ./internal/provider/
Each run was diffed against the pre-fix baseline by file and message,
with line numbers and occurrence counts stripped. Three new goconst hits
came in along the way, and all three were fixed rather than accepted. The
P2 test's lock value made "backup", then "migrate", a repeated
literal; it now uses "snapshot-delete". The fake's missing-storage error
repeated "Parameter verification failed."; it is now built from the raw
captured body. Final counts: goconst 224 (231 with the tag), logcheck 12,
modernize 2, unparam 3, gocyclo 1.
Real host. The user ran the host checklist on pv1-1 and then:
export PROXMOX_TEST_URL=https://192.168.0.8:8006
export PROXMOX_TEST_TOKEN_FILE=$HOME/.config/proxmox-token
export PROXMOX_TEST_CA_FILE=$HOME/.config/proxmox-ca.pem
export PROXMOX_TEST_NODE=pv1-1
export PROXMOX_TEST_TEMPLATE_VMID=9001
export PROXMOX_TEST_ISO_STORAGE=proxy-seeds
export PROXMOX_TEST_VLAN_TAG=4
make test-integration-proxmox
# --- PASS: TestProxmox_lifecycle (59.68s)
# --- PASS: TestProxmoxCheck_realHost (0.13s)
It ran the review fixes, uncommitted, on the first try. Clone task about
3 s, Create 6.2 s, address 50.2 s after Create returned, Delete plus Get
plus a second Delete 3.1 s. The Delete deleted the seed while the stopped
VM's ide2 still referenced it (imgdel OK), then destroyed the VM, and
step 10 found neither left. That settles the one assumption behind the
reorder that the fake could not prove. The run confirmed P6 as well: the
first Get, about 6 s after the clone, met status: unknown in the
listing and answered Transient, blaming the node, which was up. The
message now names both causes.
The three answers Phase 0 never captured. Captured with a scratch
helper that passes the token to curl through a process substitution and
writes the Phase 0 fixture shape. Its first offline test caught a bug:
Python's text mode folds \r\n, so the header split found nothing, and
the file is now opened with newline="". The calls were:
pvecap storage_status_missing GET /nodes/pv1-1/storage/proxy-missing/status
pve POST /nodes/pv1-1/qemu/9001/clone -d newid=199999 -d name=proxy-reconaaaaaaaaaaa -d full=1
pve POST /nodes/pv1-1/qemu/199999/status/start
pve POST /nodes/pv1-1/qemu/199999/status/suspend # pause
pvecap stop_paused_response POST /nodes/pv1-1/qemu/199999/status/stop
pvecap task_status_stop_paused GET /nodes/pv1-1/tasks/<upid>/status
pvecap status_current_after_stop_paused GET /nodes/pv1-1/qemu/199999/status/current
pve POST /nodes/pv1-1/qemu/199999/status/start
pve POST /nodes/pv1-1/qemu/199999/status/suspend -d todisk=1 # hibernate: stopped, lock suspended
pvecap err_destroy_hibernated DELETE '/nodes/pv1-1/qemu/199999?purge=1&destroy-unreferenced-disks=1'
pvecap task_status_destroy_hibernated GET /nodes/pv1-1/tasks/<upid>/status
Two of the three contradicted the code:
- A missing storage is a 400 with the field error
storage: No such storage., not a 500, so the Checker reportedAPIError.isNoSuchStoragematches that exact field error, and a 500 is still taken as missing (not captured). The matcher matrix caught a first version that fired on anystoragefield error, which a clone onto a storage without image support also has. - The destroy accepts a hibernated VM. Its task ended
OK, removing both disks and thevm-199999-state-suspend-…volume. Delete refused such a VM of ours as Permanent, believing the destroy would, so it now destroys it like a stopped one. Create's adoption still refuses one it finds before it was ever recorded. The capture was renameddestroy_hibernated_response.json, because the client test globserr_*.jsonas errors. - A stop on a paused VM ends
OKwith the VM stopped, as assumed.
The destroy also cleaned up the scratch VM, and a listing of local-lvm content for VMID 199999 came back empty.
Kind pass. The user built and loaded the image, deployed, applied a
scratch providers config with only kubernetes and proxmox-home, ran
the sample Proxy to Ready, ran a curl -x through it, deleted it and
checked pv1-1 for leftovers. Everything passed. The first attempt at the
override went to a ConfigMap named providers-config. config/default's
namePrefix makes the mounted one egress-proxies-operator-providers-config,
so the pod kept the shipped config: no proxmox-home ProviderHealth, and
kvm-home and kvm2-home failing on the missing SSH key. The corrected
command replaced the prefixed ConfigMap and deleted the stray one. The
Secret keeps its plain name (proxmox-token), since kustomize renames
only resources it builds. make deploy's image edit to
config/manager/kustomization.yaml was reverted, not committed.
Worth noting:
- Behaviour changes from Phase 9. Delete's order is now stop, seed,
destroy. A hibernated VM of ours is destroyed rather than latched. The
Checker reports a missing ISO storage as
StorageNotFound. A foreign-named VM on a probe VMID is skipped without a config read. - Accepted, not fixed (guide Known gaps, spec entry 24):
- P3: Create adopts a leftover stopped VM whatever the current
placementsays. - P4: a retry can clone a second VM in a narrow crash window.
- P3: Create adopts a leftover stopped VM whatever the current
- Still unproven:
- A storage that exists but cannot be used on a node.
- Every cross-node path. The host has one node.
- Flakes. kvm's
TestKeepalive_keepsHealthyClientOpenfailed once under-race("unexpected packet in response to channel open") and passed 5 of 5 on rerun. This branch does not touchinternal/provider/kvm. The parked kvm2 items (S5.1, the 200 msTestCheck_hostsCheckedConcurrently) did not bite, though the reviewer agent saw the latter fail once. - Docs. The "not yet run" wording is gone from the README, the
guide,
testing.md, spec entry 21 andproviders-existing.md.testing.mdgained a timings table and thePROXMOX_TEST_VLAN_TAGexport line. The link sweep script from the Phase 9 entry found 0 broken links over the seven edited docs, and the guide has no—. .claude/settings.jsonstays out of the commit.