341 lines
17 KiB
Markdown
341 lines
17 KiB
Markdown
# Execution: kvm2, a clean-room libvirt/KVM provider built from the provider contract
|
|
|
|
Plan: [docs/plans/2026-09-16-1333-kvm2-provider.md](../plans/2026-09-16-1333-kvm2-provider.md)
|
|
|
|
- [x] Step 0 — Plan file and branch
|
|
- [x] Step 1 — The spec, `docs/kvm2-provider-spec.md`
|
|
- [x] Step 2 — Config wiring, IDs, state mapping, classify
|
|
- [x] Step 3 — Pure builders: domain and volume XML, metadata, seed
|
|
- [x] Step 4 — Transport, seam, decorator, fakes
|
|
- [x] Step 5 — Image cache and pool-space precheck
|
|
- [x] Step 6 — The four contract methods
|
|
- [x] Step 7 — Checker and the CRD comment
|
|
- [x] Step 8 — Deployment files, Makefile, integration test
|
|
- [x] Step 9 — Documentation
|
|
- [ ] Step 10 — Milestone review
|
|
- [ ] Step 11 — Real-host validation, changelog, MR
|
|
|
|
## Step 0 — Plan file and branch
|
|
|
|
`main` was fast-forwarded to the merge of PR #7 (`0dc25a1`), which carries the
|
|
three provider documents, and `feat/kvm2-provider` was cut from it. The plan
|
|
was committed alone as `854856c`. The pre-existing local modification of
|
|
`.claude/settings.json` stays uncommitted throughout.
|
|
|
|
## Step 1 — The spec, `docs/kvm2-provider-spec.md`
|
|
|
|
2026-09-16 13:37. Written by the orchestrator, not a subagent, since the spec
|
|
is the "specifics" a user hands to the implementer. All 24 entries answered;
|
|
every Evidence line cites the v1 user guide, the 2026-09-04 execution log, a
|
|
go-libvirt symbol, or the contract. Nothing was taken from
|
|
`internal/provider/kvm/`.
|
|
|
|
Design choices that go beyond the user's profile answers, all recorded in the
|
|
spec: an adoption guard on `Create` (foreign UID is Permanent), a pool-space
|
|
precheck mapped to `ErrQuotaExceeded`, a dedicated connection for image
|
|
downloads so a cancelled poll cannot cut one off, the Checker on fresh
|
|
connections rather than the pool, SSH authentication and host-key failures
|
|
classified Permanent (v1 retried them forever), SSH material re-read on every
|
|
dial so rotation needs no restart, kvm2's own metadata namespace (no adoption
|
|
of v1 domains), and the shared `validateKVM` refactored to take the block name
|
|
so messages say `kvm2.`.
|
|
|
|
Checks run on the spec:
|
|
|
|
```sh
|
|
grep -c '^\*\*Answer:\*\*$' docs/kvm2-provider-spec.md # 0 blank answers
|
|
grep -n 'internal/provider/kvm/' docs/kvm2-provider-spec.md # empty
|
|
```
|
|
|
|
plus the axis-code check against contract §4 headings and the path-existence
|
|
loop from the docs-split log. All clean.
|
|
|
|
Worth noting: the spec's entry 21 lists the concrete test name for every
|
|
contract §11 item, so the later steps' briefs can quote it rather than
|
|
re-derive the mapping.
|
|
|
|
## Step 2 — Config wiring, IDs, state mapping, classify
|
|
|
|
2026-09-16 16:38. First subagent step. The brief named the allowed files,
|
|
the spec entries, the test names and the prohibition on
|
|
`internal/provider/kvm/`; the agent reported nothing opened there, with one
|
|
honest caveat: a `golangci-lint` run printed seven single source lines from
|
|
v1 files among its findings, which it did not act on. The review greps
|
|
(`grep -rn 'provider/kvm"' internal/provider/kvm2/`, the v1 identifier
|
|
smell list) are clean.
|
|
|
|
What landed: `ProviderConfig.KVM2` reusing `KVMConfig`, `setBlocks`, the
|
|
allow-list and dispatch in `Config.validate`, `validateKVM` refactored into
|
|
`validateKVMBlock(prefix, block, c)` with `validateKVM` kept as a wrapper so
|
|
v1 messages and tests are untouched, `"kvm2": kvm2.New` in `cmd/main.go`
|
|
(both type lists in one commit), and the package skeleton: `kvm2.go`
|
|
(`New` never dials, warns on unreadable files, `withDefaults`, `host`,
|
|
`wrapErr`, stubbed methods), `id.go`, `state.go`, `errors.go` with 34
|
|
`classify` rows.
|
|
|
|
Decisions the agent made that the spec left open, all accepted: `New`
|
|
checks file existence only (parsing belongs to the dialer); `withDefaults`
|
|
copies hosts and images; `firstIPv4` also drops an address whose type tag and
|
|
literal disagree; there is no explicit `fs.ErrNotExist` branch in `classify`
|
|
because it falls to the Transient default anyway.
|
|
|
|
Commands as run by the agent and re-run by the orchestrator:
|
|
|
|
```sh
|
|
go vet ./... && go build ./... && go test -race -count=1 ./internal/provider/... ./cmd/...
|
|
go test -short ./...
|
|
```
|
|
|
|
All packages `ok`.
|
|
|
|
Worth noting: `bin/golangci-lint` panics inside staticcheck's IR builder on
|
|
this machine (`unexpected expr: *ast.KeyValueExpr` on stdlib `internal/poll`)
|
|
because the local toolchain is Go 1.27.0 while `go.mod` says 1.26 and the
|
|
bundled staticcheck predates it; it reproduces on untouched packages, so lint
|
|
is deferred to Step 10 with a toolchain note. The `.claude/settings.json`
|
|
modification is still local and uncommitted.
|
|
|
|
## Step 3 — Pure builders: domain and volume XML, metadata, seed
|
|
|
|
2026-09-16 16:55. Second subagent step, new files only: `xml.go`,
|
|
`metadata.go`, `volume.go`, `seed.go` and their tests (26 tests). The
|
|
domain XML is built from `encoding/xml` structs, both disks are
|
|
`type="file"` with resolved paths (the AppArmor lesson), UEFI is the default
|
|
with the secure-boot feature disabled, the metadata element parses with or
|
|
without its namespace declaration, and the seed ISO is staged under
|
|
`os.TempDir()` (the library uses `os.MkdirTemp("", "")`, so `TMPDIR` is
|
|
honoured; the test points it at a `t.TempDir()` and asserts nothing is left
|
|
behind).
|
|
|
|
Decisions the agent made, all accepted: `buildDomainXML` defaults an empty
|
|
disk format to qcow2 and rejects anything but qcow2 or raw;
|
|
`buildVolumeXML` rejects a non-positive capacity; `buildSeedISO` rejects an
|
|
empty name; the guest-agent script still runs the enable-and-start step when
|
|
the agent is already installed; the multipart boundary is random, so
|
|
vendor-data is not byte-deterministic across calls (the seed is rebuilt every
|
|
Create anyway).
|
|
|
|
Commands:
|
|
|
|
```sh
|
|
gofmt -l ./internal/provider/kvm2 ; go vet ./internal/provider/kvm2/ && go test -race -count=1 ./internal/provider/kvm2/
|
|
go build ./... && go test -short ./...
|
|
```
|
|
|
|
All `ok`. Review greps for a v1 import or v1 identifiers are empty; the only
|
|
`type="volume"` in the package is the comment explaining why it is not used.
|
|
The agent reported "files read outside the allowed set: none".
|
|
|
|
## Step 4 — Transport, seam, decorator, fakes
|
|
|
|
2026-09-16 17:42. Third subagent step, the largest: `backend.go` (the
|
|
ctx-first seam `backend`, the production adapter `libvirtBackend`, a
|
|
generic `call[T]` helper with per-call budgets and the abandon policy),
|
|
`ssh.go` (credentials read on every dial, stage-tagged `dialError`, the
|
|
forwarded socket, keepalive), `hosts.go` (`hostTable` with a per-host
|
|
channel semaphore so waiting for a dial ends with the caller's ctx,
|
|
`dialFresh` for dedicated connections, the production `libvirtDialer`
|
|
starting the `kvm2.dial` span and applying the decorator once),
|
|
`observe.go` (one span `kvm2.<Method>` and one `libvirt call` line per
|
|
RPC), plus an in-memory `fakeBackend`, an in-process SSH server and 41
|
|
tests. The agent also drove the real adapter over go-libvirt's own
|
|
`libvirttest` mock through a `net.Pipe`, which the brief did not ask for.
|
|
|
|
Abandon policy as built: a call that outlives its ctx or budget kills the
|
|
link (closes the socket and the SSH client), returns Transient wrapping the
|
|
ctx or deadline error, and other in-flight calls on that connection fail once
|
|
with go-libvirt's `ErrInterrupted`; the next `get` redials. Results travel
|
|
through a channel so a late-finishing abandoned call never writes into its
|
|
caller's frame; the agent reported that the first `-race` run caught exactly
|
|
that bug in an earlier version.
|
|
|
|
Decisions the agent made, accepted: liveness is a separate unexported
|
|
interface so the reflection test over `backend` stays at the 20 RPCs plus
|
|
`Close`; `Close` sends a bounded `ConnectClose` before killing the link;
|
|
`observe` and the dialer take `...tracing.Option` so tests can inject a
|
|
recording tracer provider, as `provider.WithTracing` does.
|
|
|
|
Clean-room note: three type names coincide with names v1 uses
|
|
(`dialError`, `dialStage`, `hostEntry`). The brief described each concept
|
|
("an error type that records which stage failed", "a per-host entry") so the
|
|
names follow from the description; the greps for a v1 import and for the
|
|
listed v1 identifiers are empty and the agent attested to reading nothing
|
|
under `internal/provider/kvm/`.
|
|
|
|
Commands (agent and orchestrator):
|
|
|
|
```sh
|
|
gofmt -l ./internal/provider/kvm2 ; go vet ./internal/provider/kvm2/ && go test -race -count=2 ./internal/provider/kvm2/
|
|
go build ./... && go test -short ./...
|
|
grep -n 'context.Background()' internal/provider/kvm2/*.go | grep -v _test.go # empty
|
|
```
|
|
|
|
All `ok`; the agent also ran the timing-sensitive tests at `-count=8`.
|
|
|
|
## Step 5 — Image cache and pool-space precheck
|
|
|
|
2026-09-16 18:11. Fourth subagent step: `image.go` and `image_test.go`
|
|
(27 tests), plus additive changes to `fake_test.go` (a `twin()` sharing
|
|
state with its own call log, so a test can tell the pooled connection from
|
|
the dedicated one) and `kvm2.go` (`http` client, budgets and tracing
|
|
options behind functional options, so `New` is unchanged). The pipeline:
|
|
marker lookup and bounded read on the pooled connection, then a GET bound to
|
|
ctx plus the 20-minute cap under a `kvm2.download` span, `StorageVolCreateXML`
|
|
and a streamed `StorageVolUpload` through sha256 on a fresh connection,
|
|
`StoragePoolRefresh`, and the marker written last; every failure after the
|
|
volume exists discards it with the cancellation stripped from ctx so the
|
|
cleanup RPC still runs.
|
|
|
|
Streaming is asserted by counting: a hook on the dedicated fake's upload
|
|
snapshots how many bytes the 64 MiB test server had written when the upload
|
|
began, and asserts under 16 MiB. The agent chose that over a process-wide
|
|
`MemStats` delta because the tests run in parallel.
|
|
|
|
Decisions the agent made, accepted: the base volume is created on the
|
|
dedicated connection (nothing exists if the dial fails); `Accept-Encoding:
|
|
identity` is sent so transparent gunzip cannot hide the Content-Length; a
|
|
marker whose content is not 64 hex characters counts as debris;
|
|
`Content-Length: 0` is its own Permanent error; the image lock is a
|
|
one-slot channel so a waiter gives up with its ctx instead of sitting out a
|
|
transfer; the fetch logger is derived after the span starts so its lines
|
|
carry the span ID (the agent's first test run caught that).
|
|
|
|
Commands (agent and orchestrator):
|
|
|
|
```sh
|
|
gofmt -l ./internal/provider/kvm2 ; go vet ./internal/provider/kvm2/ && go test -race -count=2 ./internal/provider/kvm2/
|
|
go build ./... && go test -short ./...
|
|
grep -n 'io.ReadAll\|bytes.Buffer' internal/provider/kvm2/image.go # empty
|
|
grep -n 'context.Background()' internal/provider/kvm2/*.go | grep -v _test.go # empty
|
|
```
|
|
|
|
All `ok`. Attestation: "files read outside the allowed set: none".
|
|
|
|
## Step 6 — The four contract methods
|
|
|
|
2026-09-16 18:36. Fifth subagent step: `create.go`, `get.go`, `delete.go`,
|
|
`list.go` and five test files (the package now has 150 top-level tests).
|
|
Create follows spec entry 10 step for step under a per-name lock, with the
|
|
adoption guard, the quota precheck, the image cache, an overlay that is
|
|
reused and a seed that is always rebuilt, file-type disks from resolved
|
|
paths, the metadata element in the define call, and a wait for running
|
|
capped at two minutes. Get maps only `ErrNoDomain` to NotFound, prefers the
|
|
guest agent over ARP, and reads UID and CreatedAt back every time. Delete is
|
|
idempotent at every step and a no-op success for an unconfigured host.
|
|
ListByTag fails whole per host and skips single unreadable domains.
|
|
|
|
Decisions the agent made, accepted: the adoption path also waits for
|
|
running, so a repeat Create on a shut-off domain blocks like a first one;
|
|
`address` and `ListByTag` propagate ctx cancellation instead of reading it as
|
|
"no address" or "skip", so a cancelled Get is Transient rather than a false
|
|
Provisioning; NotFound from Get gets its own V(1) line because polling to
|
|
NotFound is expected; the domain XML rides on the `domain defined` line at
|
|
V(2). Flagged, not solved: adopting a paused domain fails Transient on every
|
|
retry (`DomainCreate` refuses and the wait expires), which the spec does not
|
|
cover; recorded as a known gap for the docs step.
|
|
|
|
The agent found a one-word conflict between the brief and the spec
|
|
(`DomainAffectCurrent` versus `DomainAffectConfig` for `DomainGetMetadata`);
|
|
the code uses `DomainAffectCurrent`, and spec entry 8 was corrected to
|
|
match in this commit.
|
|
|
|
Commands (agent and orchestrator):
|
|
|
|
```sh
|
|
gofmt -l ./internal/provider/kvm2 ; go vet ./internal/provider/kvm2/ && go test -race -count=2 ./internal/provider/kvm2/
|
|
go build ./... && go test -short ./...
|
|
grep -n 'CloudInit' internal/provider/kvm2/create.go # only len(req.CloudInit) and the seed builder call
|
|
grep -n 'not implemented' internal/provider/kvm2/*.go # empty
|
|
grep -n 'provider.Wrap(' internal/provider/kvm2/*.go | grep -v _test.go | grep -v 'p.name' # empty
|
|
```
|
|
|
|
All `ok`. Attestation: "files read outside the allowed set: none".
|
|
|
|
## Step 7 — Checker and the CRD comment
|
|
|
|
2026-09-16 18:52. Sixth subagent step: `check.go` and `check_test.go`
|
|
(ten tests, the reasons table has 19 rows). Every host is checked
|
|
concurrently on a fresh dedicated connection that is closed when the probe
|
|
ends; the pooled table is never consulted, which the test proves with a
|
|
counting dialer. Details fill in as facts arrive, so a `PoolNotFound`
|
|
verdict still says which libvirt version answered. The `status.type` comment
|
|
in `api/v1alpha1/providerhealth_types.go` gained `kvm2` and `make manifests`
|
|
changed only the CRD's description line:
|
|
|
|
```sh
|
|
make manifests && git diff --stat config/ api/
|
|
# api/v1alpha1/providerhealth_types.go | 2 +-
|
|
# config/crd/bases/crawl.example.com_providerhealths.yaml | 2 +-
|
|
```
|
|
|
|
Decisions the agent made, accepted: a dial that runs out of time is
|
|
`Unreachable` (the host did not answer), and `Timeout` is reserved for ctx or
|
|
budget expiry on an established connection; `Close` runs synchronously in
|
|
the probe so the close-count assertion is deterministic, at the cost of up
|
|
to five seconds past ctx if a disconnect hangs on a live link.
|
|
|
|
The agent also reported that the `captureContext` helper in `kvm2_test.go`
|
|
appends to an unlocked slice, which the race detector flagged once `Check`
|
|
logged from several goroutines. The helper was outside its allowed file set,
|
|
so it built its own locked capture; the orchestrator fixed `captureContext`
|
|
with a mutex in this commit and re-ran the package at `-race -count=3`.
|
|
|
|
Attestation: "files read outside the allowed set: none".
|
|
|
|
## Step 8 — Deployment files, Makefile, integration test
|
|
|
|
2026-09-16 19:39. Seventh subagent step. `kvm2_integration_test.go`
|
|
(`//go:build integration`, `KVM2_TEST_*` variables, the eleven ordered
|
|
assertions of spec entry 21 plus `TestKVM2Check_realHost`),
|
|
`config/samples/proxy_kvm2.yaml` (zone `beelink`, so the sample matches the
|
|
shipped config), a commented `kvm2-home` entry in both providers configs
|
|
with the porting note, comment-only edits on the shared `kvm-ssh` mount, the
|
|
`test-integration-kvm2` Makefile target and the kvm2 rows and section in
|
|
`docs/testing.md`. The integration test was not run; that is Step 11 with
|
|
the user's go-ahead.
|
|
|
|
The agent found that `New` builds the production dialer without tracing
|
|
options, so in production the dialer and decorator use the global tracer
|
|
provider (correct: `cmd/main.go` sets it up), while a test that wants every
|
|
span family in one recorder sets the dialer's option itself. The test does
|
|
so and asserts that `kvm2.DomainDefineXML` and `kvm2.dial` are children of
|
|
`provider.create`.
|
|
|
|
Decisions the agent made, accepted: the UID is generated from `crypto/rand`
|
|
rather than promoting `google/uuid` to a direct dependency; the sentinel
|
|
check looks for the full cloud-init string because the provider name also
|
|
appears in attributes; `-run TestKVM2` matches both tests, as the kvm
|
|
target's pattern does. Forward links to `docs/kvm2-provider.md` dangle until
|
|
Step 9.
|
|
|
|
Commands:
|
|
|
|
```sh
|
|
go vet ./... && go vet -tags=integration ./... && go vet -tags=e2e ./...
|
|
bin/kustomize build config/default > /dev/null && bin/kustomize build config/samples > /dev/null
|
|
make -n test-integration-kvm2
|
|
# go test -race -tags=integration -count=1 -v -timeout 40m -run TestKVM2 ./internal/provider/kvm2/
|
|
```
|
|
|
|
All clean. Attestation: "files read outside the allowed set: none".
|
|
|
|
## Step 9 — Documentation
|
|
|
|
2026-09-16 19:54. Eighth subagent step: `docs/kvm2-provider.md` (sixteen
|
|
sections modelled on the kvm guide), a `kvm2` column in the three tables of
|
|
`docs/providers-existing.md` plus a kvm2 section and renumbering, the
|
|
components row, §5 block, §10 span bullet, §11 row and eight Decisions
|
|
bullets in `docs/architecture.md`, a README row and paragraph, and the
|
|
guide in the `docs/providers.md` index. The agent verified each claim
|
|
against the package rather than the spec and dropped a draft sentence
|
|
claiming kvm and kvm2 share the base-image cache, because checking it would
|
|
have meant reading v1's volume names.
|
|
|
|
Checks re-run by the orchestrator: no em-dash in the guide, no line
|
|
references, each of the 19 axis codes appears once in the appendix table
|
|
with seven cells, every link and anchor across the seven touched documents
|
|
resolves. The one added em-dash is the `Leader-elected` cell of the new
|
|
components-table row, where the table's own convention wins.
|
|
|
|
Attestation: "files read outside the allowed set: none".
|