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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user