proxmox: thin HTTP client, seam, log decorator and in-memory fake

httpAPI implements the sixteen seam methods over net/http: the token is
read per request, non-2xx answers become apiError from the body message or
the reason phrase, echoed secrets are redacted, the CA loads lazily, HTTP/2
is off, and uploads stream with an exact Content-Length. The observed
decorator logs one V(1) line per call and starts no span; New wires both.
fakeAPI simulates clone locks, tasks and ISOs with the captured error texts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 14:47:50 +00:00
co-authored by Claude Opus 5.5
parent 0a6c8624f3
commit e05d994974
9 changed files with 2775 additions and 7 deletions
@@ -8,7 +8,7 @@ Pairs with [docs/plans/2026-09-18-1502-proxmox-provider.md](../plans/2026-09-18-
- [x] Phase 1 — The spec (`docs/proxmox-provider-spec.md`)
- [x] Phase 2 — Config block, registration, package skeleton
- [x] Phase 3 — Extract `nocloud` and `keyedlock` from kvm2
- [ ] Phase 4 — Seam, HTTP client, decorator, fake
- [x] Phase 4 — Seam, HTTP client, decorator, fake
- [ ] Phase 5 — Get, ListByTag, task helpers
- [ ] Phase 6 — Create
- [ ] Phase 7 — Delete, Checker, CRD comment
@@ -447,3 +447,110 @@ Worth noting:
test starts its 200 ms release timers before `Check` starts 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.Client` is built on first use, not in `newHTTPAPI`.** 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's
`RootCAs` cannot change after first use, so the whole client waits for
the CA (`TestClient_caFileLoadedLazily`).
- **The upload is not an `io.Pipe` with a goroutine.** The multipart
framing (the `content=iso` field, the `filename` part header and the
closing boundary) is rendered into two small buffers, and the body is
`io.MultiReader(head, io.LimitReader(r, size), tail)` with an exact
`Content-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's `curl -F` upload did. A chunked upload to pveproxy
was never tried. `TestClient_uploadStreamsMultipart` proves the
streaming with a reader that stalls after its first 256 KiB until the
server has seen file bytes.
- **The fake's `DeleteVolume` tolerates 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 into
`Proxy` status; an `errCredentials` sentinel on token-file errors, so
Phase 7's Checker can report `CredentialsUnavailable`; and the V(1)
identifiers `newid` and `name` on a clone, `filename` on an upload and
`volid` on 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:
```bash
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.
```bash
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.KeyValueExpr` in the
Go 1.27 standard library's `poll` package). This is a toolchain problem,
not this code. With `--disable staticcheck` the new files' findings are
goconst on form keys (kvm2 carries 50 of the same) and two unparam hits
on the fake's `addTemplate` and `resourceByID`, which Phases 5 and 6 will
call with other VMIDs. The mapsloop and gocyclo findings in `proxmox.go`
and `api_test.go` date from Phase 2.
- Nothing in this phase talked to the host. The client has only met
`httptest` servers replaying the captures. The first real request is
Phase 10's integration run, which is where the `Content-Length` upload
and the `%2F` volid 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.