Author SHA1 Message Date
kacerrandClaude Opus 5.5 3d72507894 proxmox: review fixes, real-host validation record, changelog
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>
2026-09-24 10:28:28 +00:00
kacerrandClaude Opus 5.5 7f2073f94b proxmox: deployment files, sample, user guide and doc rows
The optional proxmox-token Secret (token, ca.pem) mounted at
/etc/proxy-operator/proxmox, make proxmox-token-secret with content
checks that never print the token, a commented proxmox-home entry in
both providers configs, and config/samples/proxy_proxmox.yaml.
TestLoadConfig_proxmoxSampleEntry loads the commented entry from both
files, and a second test ties the sample Proxy's placement to it.

docs/proxmox-provider.md is the user guide: prerequisites and privileges,
cluster setup, config reference, placement, what lands on Proxmox,
logging, tracing, timeouts, health check, error classes, known gaps and
testing. providers-existing.md gains the proxmox column and section;
architecture.md, the README, providers.md and CLAUDE.md gain their rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 21:14:18 +00:00
kacerrandClaude Opus 5.5 6e9ef26dcb proxmox: contract and observability tests, integration test, testing docs
The contract sweep in provider_test.go: every method and Check returns
within a second of a cancel, every wrapErr path carries the configured
name, op and id, the V0/V1/V2 logging tiers and the no-op lines, and a
Get over the real client whose HTTP spans are children of provider.get.
proxmox_integration_test.go (-tags=integration) holds TestProxmox_lifecycle
and TestProxmoxCheck_realHost; both compile and skip without
PROXMOX_TEST_*, and have not run against a host yet. make
test-integration-proxmox runs them. docs/testing.md gains the proxmox,
nocloud and keyedlock rows, the Live Proxmox gaps and the integration
section; spec entry 21's section 11 list is ticked.

Waits that could hang the package under a mutation are bounded: the
concurrent-Create loop and eight direct blocking calls in older tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 20:43:00 +00:00
kacerrandClaude Opus 5.5 2f89c1fffb proxmox: Delete with seed cleanup, health Checker, CRD type comment
Delete stops and destroys a VM of ours under the per-name lock, then
deletes its seed on the VM's node, or on every node when no VM of ours
was left. A hibernated VM of ours is Permanent, as in Create. The
Checker verifies each node concurrently (version, node status, ISO
storage, templates) and adds CertificateInvalid, CredentialsUnavailable,
StorageNotFound and TemplateNotFound to the shared reasons. The
ProviderHealth type comment gains proxmox.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 20:14:14 +00:00
kacerrandClaude Opus 5.5 34f132d421 proxmox: Create by cloning a template, seed upload, config, start
Create validates placement, then under the per-name lock adopts the VM a
previous attempt left (by name, or at a taken VMID after the ownership
check of spec entry 2) or full-clones the template onto the UID's
deterministic VMID with the mark in the clone call. It then uploads the
seed over any existing file, writes cores, memory, agent, tag, ide2 and
net0 when configured, grows the boot disk when diskGiB is larger, starts,
and waits for running/running.

waitUnlocked no longer waits on a suspended lock: a hibernated VM is
judged at once, so ours is Permanent and a foreign one is probed past.
The template is checked only on the allocation path, and its shape
(net0, boot disk size) is refused before any clone exists. Spec entries
2, 4 and 10 updated to match.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:32:31 +00:00
kacerrandClaude Opus 5.5 803716459a proxmox: Get, ListByTag and task polling
Get follows spec entry 9's check order (lock and unknown status before
the name, suspended is Stopped after the name and mark checks), reads
status/current so paused is Stopped, and maps the three agent messages
of entry 7. ListByTag reads one config per candidate and fails whole
only on the resources call or an ended ctx. waitTask polls the UPID's
node every budgets.poll, logs failed tasks at V(1) and counts
"WARNINGS: <n>" as success; waitUnlocked serves Create's ownership check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:11:20 +00:00
kacerrandClaude Opus 5.5 e05d994974 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>
2026-09-23 14:47:50 +00:00
kacerrandClaude Opus 5.5 0a6c8624f3 provider: extract nocloud seed builder and keyedlock from kvm2
kvm2's NoCloud seed builder moves to internal/provider/nocloud (exported,
with an ErrEmptyName sentinel instead of permanentf) and its keyed locks
to internal/provider/keyedlock, tests moved under their existing names.
kvm2 behaviour is unchanged. proxmox gains the per-name lock and the pure
seed helpers (seedISOName, seedVolID, buildSeed); unlike kvm2 (review
finding S5.1), a seed staging failure there is Transient, not Permanent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 14:01:27 +00:00
kacerrandClaude Opus 5.5 6e996ae2c7 proxmox: config block, IDs, marks, state mapping, classify, registration
Phase 2 of the proxmox provider plan. Adds the proxmox: config block with
its validation, registers the type in the config allow-list and the
constructor map, and starts internal/provider/proxmox: the api seam and
its data types, providerIDs (vms/<vmid>/<name>) with the UID-derived VMID,
the description mark, state mapping, and error classification.

Tests read the responses captured from pv1-1 in Phase 0 rather than
repeating Proxmox's wording, and every matcher is run against every
captured error so none fires on the wrong message. The contract methods
are Transient stubs until Phases 5 to 7.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 09:42:46 +00:00
kacerrandClaude Opus 5.5 4e974f30f6 docs: proxmox provider spec
Phase 1 of the proxmox provider plan: docs/proxmox-provider-spec.md
answers all 24 entries of the provider spec template against the
contract, citing the Phase 0 facts from pv1-1 as evidence.

It folds in the ten corrections recon found. The two that matter most:
Create inspects a taken VMID, waiting out a clone lock, before probing
past it, so a crash-retry adopts its own half-cloned VM instead of cloning
a duplicate; and Get checks the lock before the name, because a VM being
cloned has no name yet.

The plan now opens with a note that the spec wins where they disagree.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 09:18:29 +00:00
kacerrandClaude Opus 5.5 ff94fe70bb proxmox: captured API fixtures and host facts from pv1-1 (PVE 9.2.20)
Phase 0 of the proxmox provider plan. 55 fixtures under
internal/provider/proxmox/testdata, each the status, reason phrase and
raw body of a real response from the user's host, and the execution log
answering the plan's facts F1 to F23.

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 08:22:41 +00:00
kacerrandClaude Opus 5 2167819157 docs: retarget the proxmox plan at Proxmox VE 9.2
The host is 9.2.20, not 8.x. Three corrections that would have failed
at runtime:

- VM.Monitor was removed in PVE 9.0, so it is dropped from the role and
  the agent read rests on VM.GuestAgent.Audit alone.
- The GC-assist tag becomes proxy_operator: current tag validation takes
  only lowercase letters, digits and underscore, so proxy-operator would
  have been rejected on every Create. The description mark keeps its
  hyphenated keys; it is free text, not a tag.
- HTTP/2 is documented as absent from pveproxy on every release, so the
  client disabling it is a fact rather than a guess.

Two new Phase 0 facts: F22 for the 9.2 unified authentication-failure
message, which invalidates any 401 text remembered from 8.x, and F23 for
the node /tmp tmpfs move. F12 records that the upload endpoint takes
iso, vztmpl or import on 9.x and still refuses snippets, which is what
keeps the seed ISO route the only API-only path for user-data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 15:49:14 +00:00
kacerrandClaude Fable 5.1 708989c40f docs: plan the proxmox provider
Eleven self-contained phases (host recon and fixtures, spec, config and
skeleton, nocloud/keyedlock extraction from kvm2, HTTP client and fake,
Get/List, Create, Delete/Checker, test sweep and integration test,
deployment and docs, review and MR), with the Proxmox host setup
checklist and the backend facts Phase 0 must verify on 192.168.2.8.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 15:02:53 +00:00
kacerrandClaude Opus 5 8fa9e8aad9 Housekeeping: review docs into docs/, refreshed agent guide and settings
Four unrelated working-tree changes, none of them touching operator
behaviour:

- review/*.md -> docs/reviews/2026-09-17-kvm2-provider-review/, byte for
  byte, so the kvm2 review sits with the rest of the docs under a name
  that says which review it was.
- AGENTS.md: replaced the generic kubebuilder scaffold guide with what is
  actually true of this repo — the real component layout, the lint/test
  commands that work here, and the behavioural gotchas (replace-on-change
  hashing, the cloud-init Secret label, in-memory leases).
- .claude/settings.json: the allow list still carried /Users/jan.novak
  paths from the macOS checkout; re-pointed at /home/novakj and collapsed
  the one-off entries into prefixes.
- config/samples/proxy_kvm2_crawl_only_one.yaml: a one-VM cut of the crawl
  pool, the rest of the members left commented out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 08:12:27 +00:00
kacerrandClaude Opus 5 1fa3e55b9c docs: start docs/operating.md with the libvirt-RPC log query
A place for helpers against a running operator, as opposed to the design
docs. First entry is the jq filter for timestamp + rpc: both libvirt
providers log the same "libvirt call" msg, so one select covers kvm and
kvm2, and a fromjson? variant survives a stream with non-JSON lines in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 07:53:12 +00:00
kacerrandClaude Opus 5 c3a581af95 samples: a kvm2 crawl pool, not just the single-proxy sample
proxy_kvm2.yaml shows one VM; nothing showed the shape a crawler actually
leases from. Add a five-Proxy pool on the kvm2-home entry: four share one
labelled cloud-init Secret, and the fifth carries its own inline block
because it runs squid on 8080 and spec.port has to match the http_port a
shared Secret would pin.

It stays out of the samples kustomization so `apply -k config/samples`
doesn't boot a pool along with the one-per-provider samples. All six
objects validate against the installed CRD under --dry-run=server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 07:45:16 +00:00
kacerr 5928c3fe75 Merge pull request 'kvm2 provider: clean-room libvirt/KVM provider, code review, and the four high findings' (#8) from feat/kvm2-provider into main
Reviewed-on: #8
2026-09-17 23:12:46 +02:00
kacerrandClaude Opus 5 54525bf67a kvm2: gofmt volume.go
Making baseVolumeName multi-line in the H4 commit collapsed the
alignment gofmt gives a run of single-line funcs, and the commit went out
with the old spacing. Caught by make test, whose fmt target rewrites the
file and leaves the tree dirty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:07:10 +00:00
kacerrandClaude Opus 5 41fbd5e7e6 kvm2: record the review validation, enable the provider entry, changelog
Closes out the review-fix campaign. The kvm2 provider was deployed
in-cluster and validated against beelink -- the first time any of this
code had run against real libvirt rather than the in-memory fake.

config/manager/providers_config.yaml ships the kvm2-home entry enabled,
since the operator is now running it for real.

review/ carries the code review the four H-commits reference, so H1..H4
resolve to something rather than dangling: scope, the seven-chunk sweep,
the escalations, the ten deep dives and the final report. .claude/agents
carries the two reviewer definitions that produced them.

What the validation did not prove is recorded next to what it did: H2's
error paths need a body that fails part way through, and a healthy 433 MB
download never finishes short, so the unverified libvirtd short-stream
behaviour is still open. The kvm2 integration test has also still never
been run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:50:45 +00:00
kacerrandClaude Opus 5 4114d43e87 Route klog and net/http's own errors into the structured logger
Some lines ignored the zap encoder entirely, so `kubectl logs | jq` could
never work:

  I0917 19:50:02.580097  1 leaderelection.go:258] "Attempting to acquire
  leader lease..." lock="egress-proxies-operator-system/b47711d1..."

That is klog's own stderr writer, not ours. client-go's leader elector
calls klog.FromContext(ctx), and controller-runtime hands the elector a
context with no logr in it -- mgr.Start receives ctrl.SetupSignalHandler(),
which carries no logger. FromContext falls back to Background(), and with
no logger registered that is klog's text sink. Nothing here imported or
configured klog; it was an indirect dependency that around 60 linked
packages use, including client-go/tools/{leaderelection,cache,record} and
client-go/transport. controller-runtime does not route it either -- its
only klog.SetLogger call is in its own test suite.

Plain klog.SetLogger, deliberately not SetLoggerWithOptions with
ContextualLogger(true). The contextual form is what klog's docs recommend
because it skips a klogr round trip, but it also disables klog's verbosity
gate and hands the decision to zap, whose level under Development is Debug
-- so client-go's V(1) chatter, suppressed today by klog's own -v=0, would
have started appearing. Plain SetLogger keeps klog's check in front and
still redirects what it does emit. Same fix to the format, no new volume,
which matters because volume is what prompted this.

The discovery API's http.Server set no ErrorLog, so net/http's own faults
(a failed Accept, a panic escaping a handler) went to the stdlib logger on
stderr. It now drains into logr through logr.ToSlogHandler, the pattern
internal/provider/gcp/wirelog.go already uses for the same purpose.

The deployment gets --zap-encoder=json and --zap-time-encoding=rfc3339.
The flag beats the in-code Development default -- addDefaults fills
NewEncoder only when it is nil -- and the time encoding is not optional,
since the JSON encoder defaults to epoch seconds. Verbosity stays at debug
on purpose; --zap-log-level=info drops the per-RPC lines.

zap.Options{Development: true} stays as the in-code default: flipping it
would change the format for every deployment of this operator, and the
flags get the same result here without that reach.

k8s.io/klog/v2 moves from indirect to direct in go.mod. It was already in
the module graph, so nothing new is downloaded.

No tests: three changes, all wiring, in a file with no test coverage, and
pinning klog.SetLogger means mutating process-global state that leaks into
every other test in the package. Verified in-cluster instead --
`kubectl logs | jq -e -s 'length > 0'` now passes, which fails if any
single line is not JSON.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:24:37 +00:00
kacerrandClaude Opus 5 82d01efb96 Plan routing klog into the structured logger (Step 6)
Found while watching the Step 5 validation, not a review finding: the
leader election lines ignore the zap encoder because they are not ours.
client-go calls klog.FromContext, controller-runtime hands the elector a
context with no logr in it, and klog falls back to its own stderr writer.
Nothing in the repo configures klog; it is an indirect dependency that
~60 linked packages use.

Plain klog.SetLogger rather than the contextual form the klog docs
recommend: the contextual option disables klog verbosity checking and
hands the decision to zap, which at Development level would let
client-go V(1) chatter through that klog suppresses today. Same fix to
the format, no new volume.

Also the one other in-process stderr bypass (the discovery http.Server
has no ErrorLog) and json/rfc3339 encoder flags on the deployment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:05:02 +00:00
kacerrandClaude Opus 5 966807bf57 Plan the in-cluster kvm2 validation (Step 5)
First contact between the kvm2 provider and a real hypervisor: it has
never run against libvirt, so H1-H4 landed on code no host has executed.

The run happens in the existing kind cluster on purpose. That cluster
already owns two live kvm-home VMs on beelink, and orphan GC deletes any
instance whose UID is missing from its own cluster live set after
gc-min-age. A second operator carrying the kvm-home entry would therefore
destroy both within ten minutes; using the existing cluster makes its
live set the thing that protects them.

The plan also records what the run cannot prove: H2 error paths need a
body that fails part way through, and a healthy download never finishes
short, so the unverified libvirtd short-stream behaviour stays open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:23:44 +00:00
kacerrandClaude Opus 5 c10f98a352 kvm2: name the cached base image after the provider entry (H4)
baseVolumeName was base-<key>.<format>, byte-identical to what the kvm
provider builds in the same pool -- for URL images too, since kvm takes
hex(sum[:8]) and kvm2 takes hex(sum[:])[:16], the same eight bytes -- with
a completely separate lock table. The shipped config has a live kvm-home
entry and a commented-out kvm2-home entry on the same host, the same key
debian-13 and the same default pool, and the comment invites the operator
to enable it. Both integration tests use key test-image. On beelink both
live overlays back onto kvm's base-debian-13.qcow2.

Since cachedImage accepts any 64-hex marker when no sha256 is configured
and never re-hashes the base, that meant two entries pointing one key at
different URLs silently shared a file; a concurrent cold fetch let one
entry delete the other's half-uploaded base and boot a guest from the
remains; and a sha256 edit deleted the base in place under live overlays,
which libvirt permits without complaint.

The name now carries the provider entry: base-<entry>-<key>.<format>.
imageSource gained an Entry field and resolveImage takes the provider
name, so every production call site follows from imageSource.volumeName().
That closes the cross-entry sharing outright, kvm<->kvm2 and kvm2<->kvm2
alike, and turns the per-Provider imageLocks from a latent bug into a
correct one: with names unique per entry, two entries cannot contend for a
file, so no process-wide lock table is needed.

The entry name now reaches a file name, so New checks it against the
existing kvmIdentifier rule, exported as provider.IsKVMIdentifier rather
than duplicated. Provider names are otherwise validated for all types at
once and only for being non-empty and unique, so a name with a slash would
have produced a volume nothing could address.

One companion fix, because the naming alone does not stop it:
cachedImage's digest-mismatch branch deleted the marker and the base and
refetched. It now returns ErrPermanent naming both digests and telling the
operator what to do. That branch was the remaining path that unlinks a
good file under running domains, and the contract already said "add a new
key instead" -- the code was contradicting it. The no-marker and
non-hex-marker branches keep deleting, since there the base is debris.

Not fixed, and said so in the docs rather than left to look closed:
editing images.<key>.url is still a silent no-op, since nothing records
the URL; and cross-process fencing still needs the deferred atomic publish
(upload to a temp name, verify, publish), which lands where H2 put the
discard.

Migration is a non-event. The base-debian-13.qcow2 on beelink is kvm's,
kvm keeps using it untouched, and kvm2 downloads its own copy once per
host. Nothing is replaced under a running guest; the pool needs room for
both, which the prerequisites now say.

The eviction recipe in the guide named base-debian-13.qcow2, which after
this change is kvm's file -- following it would have deleted the wrong
provider's base. It now greps for the prefix and deletes the marker first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:57:02 +00:00
kacerrandClaude Opus 5 9682737c69 kvm2: an unreadable address is not a missing one (H3)
address() tried the guest agent then the host's ARP table and skipped any
error that was not a context error, returning ("", nil) after both. An
active domain with no IP maps to StateProvisioning; the controller clears
status.IP on that, and the health engine prunes the proxy's counters and
calls ForgetProxy in one tick once EffectiveHost() goes empty.

The damaging trigger was not the guest. The libvirt connection is pooled
per host, so when another reconcile's cancel or budget overrun killed it,
call returned "connection is closed" -- carrying no context sentinel, so
the check did not recognise it -- and Get returned a *successful*
instance in Provisioning with an empty IP. An unrelated proxy's timeout
dropped a healthy proxy out of rotation.

The discriminator was already in the spec without the conclusion being
drawn: during a normal boot on beelink exactly one source fails. The
agent errors because it is not up yet, while ARP answers without error
and with nothing, since the host never sees the guest's traffic. So one
source failing stays "no address yet"; every source failing, or a
connection already dead, is ErrTransient.

The explicit provider.ErrTransient wrap is load-bearing, not decoration.
Without it classify picks the first libvirt code out of the joined error,
and ErrInvalidArg, ErrAccessDenied and ErrNoSupport are Permanent -- an
address read failing would latch a running proxy as Failed, a worse
version of the harm being fixed.

Half of the finding is deliberately still open, and it is the half that
fires more often: a guest agent restarting inside a running VM still
demotes it, because that is indistinguishable from a VM still booting.
The discriminator is the previous IP, which the provider does not hold by
design and the controller already has. Follow-up finding to file against
proxy_controller.go:208-211 -- on StateProvisioning, keep an existing IP
and Provisioned condition for a few polls instead of clearing on the
first. The kvm2-local alternative (use the mark's createdAt as a
provisioning window) was rejected: createdAt is creation, not boot, and
it invents a deadline that belongs to the controller.

TestGet_addressErrorIsProvisioning failed against the new rule, correctly
-- it fails both sources with ErrAgentUnresponsive, and code 86 is
agent-specific, so the ARP source cannot produce it. Re-pointed as
TestGet_agentFailsAndARPEmptyIsProvisioning, the beelink boot it was
reaching for, which nothing covered before.
TestGet_everyAddressSourceFailingIsTransient covers the other side, both
sources erroring and the pooled link dying under the read; against the
unfixed code both return a Provisioning instance instead of an error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:48:57 +00:00
kacerrandClaude Opus 5 62561c21fe kvm2: stop handing go-libvirt a reader that can error (H2)
uploadImage streamed the HTTP body straight into StorageVolUpload. When
that body failed part way through, go-libvirt deadlocked: requestStream
starts its stream sender on two unbuffered channels and, on the error
path, returns without ever reading outErr, so SendStream emits a
StatusError packet and parks on `outErr <-` forever while the main
goroutine parks on `abort <- true` forever. StorageVolUpload never
returned. The 20-minute transfer budget rescued the caller only, and the
socket kill did nothing, because both ends are blocked on channel sends
rather than on I/O -- which is what the comment at backend.go:152 got
wrong. Each failed upload leaked kvm2's call goroutine, go-libvirt's
sender goroutine and a 4 MiB buffer, permanently, against a 128Mi pod
limit and a reconciler that retries on backoff.

The defect is upstream and stays there: no fork, no replace. Instead
eofReader, at the go-libvirt seam so it covers the seed and marker
uploads too, converts any source error to io.EOF -- the one outcome that
ends a stream the normal way -- and keeps the real error for the caller.
countingReader sits underneath untouched, so the existing length and
digest checks report exactly what they did before.

The download also gets its own deadline a minute inside the transfer
budget. fetchImage already bounded the whole fetch at 20 minutes, so the
body's death and the budget's socket kill were racing and the socket
usually won; with the body a minute ahead, a stalled mirror ends through
its reader and unwinds cleanly instead of leaking.

discardVolume moves from uploadImage into fetchImage's upload-error
branch, so the dedicated connection is closed and libvirtd has let go of
the stream's fd before the file is unlinked. Moving it rather than adding
a second Close keeps image_test's closeCount() == 1 true. That discard is
still unfenced against a second provider entry sharing the volume name;
H4 closes that by making the name per-entry.

What remains: a failure originating in libvirtd mid-upload still leaks,
since SendStream has already returned by then. Confirmed unfixable from
here. Known gaps quotes the two-character upstream fix
(make(chan bool, 1) / make(chan error, 1)) and the replace escape hatch.

Unverified at ~85%: that libvirtd answers a short-but-clean stream finish
with OK rather than an error. virFDStreamWrite enforces length only on
writes past it and nothing compares offset to length on the close path,
which is also why virsh vol-upload of a small file into a large volume
works. If it errors we are back to a deadlock by a different door, which
is exactly today's behaviour, so this cannot be worse. The execution file
carries a probe recipe to settle it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:46:15 +00:00
kacerrandClaude Opus 5 d826114fd3 Plan the kvm2 H2, H3 and H4 fixes
Extends the campaign plan with the three remaining high findings, each its
own step and commit, in the order H2 -> H3 -> H4 (H2 and H4 both touch
image.go, and H2 restructures the failure path H4 would otherwise be
written against).

H2 is a workaround, not a repair: the deadlock is upstream in go-libvirt,
whose requestStream never drains an unbuffered outErr on its error path.
Per the decision taken while planning, no fork and no replace directive;
we stop handing it a reader that can error, move the transfer deadline
onto the HTTP source so a stalled mirror unwinds cleanly, and document the
residual with the two-character upstream patch.

H4 names the base image per provider entry, which is what was chosen over
a content fingerprint. That fixes cross-entry sharing but not the silent
no-op on a url edit, so the plan says so rather than implying the finding
is closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:42:34 +00:00
kacerrandClaude Opus 5 0239fb7e69 kvm2: do not let a stalled write delay the connection teardown (H1)
sshSocket.Close was errors.Join(s.Conn.Close(), s.client.Close()). Go
evaluates those left to right, so the blocking close ran before the
unblocking one. s.Conn is the forwarded libvirt socket, and closing an SSH
channel writes a close message under ch.writeMu — which go-libvirt already
holds while streaming a base image through that same channel. Against a
host that vanished without RST or FIN, that write blocks until the kernel
stops retransmitting (tcp_retries2, ~15 min), and s.client.Close(), a bare
net.Conn.Close() that takes no lock and unblocks the stalled writer through
netpoll, was never reached. Close the client only; nothing wants the polite
channel-close message, and reordering instead would leave an error to
filter in a memoised closeErr.

link.markDead compounded it by running kill() before close(l.closed), both
inside a sync.Once. Reordering alone was not enough: sync.Once.Do blocks
concurrent callers until the first body returns, so every other markDead —
including the one in call is own ctx-cancel branch — queued behind the
stalled kill, and cancelling the reconcile did not help. The once field is
gone; the mutex the struct already had guards the close-exactly-once
invariant, reason is set before the signal, and kill runs outside the lock.

Blast radius was the whole operator, not one host: MaxConcurrentReconciles
is 3 and ensureImage holds a per-image lock whose acquire is ctx-aware but
reconcile contexts carry no deadline, so three Proxies on one host with an
uncached image parked every worker.

The same edits close a second route to the same pin: go-libvirt is
Disconnect closes the net.Conn it was handed (libvirt.go:331 ->
socket.go:164), and that conn is our sshSocket, so libvirtBackend.Close is
Disconnect goroutine entered closeOnce and hung; the 5 s closeBudget
released Close, which called markDead -> kill -> sshSocket.Close and
blocked in the other goroutine is sync.Once.

Verified against the pinned x/crypto v0.54.0 rather than assumed: the
flow-control park (remoteWin.reserve, channel.go:268) is outside writeMu
and only writePacket holds it across the TCP write, so a writer parked in
SSH flow control never blocks the close and a writer inside the TCP write
always does. To reproduce by hand, shrink SO_SNDBUF well under the 2 MiB
channel window (channelWindowSize = 64 * channelMaxPacket) so the socket
buffer fills before the window does.

The kvm provider does not have this bug: it closes client only and never
touches the forwarded channel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:30:41 +00:00
kacerrandClaude Opus 5 85ea319cc2 Plan the kvm2 code-review fixes
The review of feat/kvm2-provider confirmed 52 findings (4 high, 20 medium,
28 low); review/REPORT.md has them. This is the campaign plan: one step and
one commit per finding, highest severity first, with only the step in hand
detailed so the plan is not a fiction about work not yet designed. Same
shape as the kvm campaign in 2026-09-10-2222-kvm-review-fixes.md.

Step 1 (H1) is detailed: sshSocket.Close runs the blocking close before the
unblocking one, so a transfer stuck in a TCP write pins the teardown for as
long as the kernel retransmits, and markDead compounds it by killing inside
a sync.Once before signalling dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:27:04 +00:00
kacerrandClaude Fable 5.1 2510842396 kvm2: apply the milestone review
Shorter start wait with immediate verdicts for dead or paused domains,
ListByTag without state or address RPCs, Timeout for an expired check
round, no adoption of unmarked domains, reference-counted keyed locks,
dead options removed, HEAD fallback on any non-2xx.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:19:50 +00:00
kacerrandClaude Fable 5.1 fcbf8ddc91 kvm2: user guide, appendix column and section, architecture and README entries
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:54:25 +00:00
kacerrandClaude Fable 5.1 fb6f59090b kvm2: integration test, sample Proxy, providers-config entries and Makefile target
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:40:50 +00:00
kacerrandClaude Fable 5.1 ce62fe57f6 kvm2: health checker on dedicated connections; kvm2 in the ProviderHealth type comment
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:26:51 +00:00
kacerrandClaude Fable 5.1 794a2aef3d kvm2: Create, Get, Delete and ListByTag with the contract tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:23:49 +00:00
kacerrandClaude Fable 5.1 6b3c985b4a kvm2: base image cache with a streamed upload and the pool-space precheck
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 18:19:05 +00:00
kacerrandClaude Fable 5.1 b45f83327a kvm2: SSH transport, ctx-first libvirt seam, host table and span decorator
Every go-libvirt call runs under its own budget and a cancelled or overlong
call kills the connection; credentials are read on every dial; one span and
one log line per RPC come from a decorator applied where connections are
made.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:43:27 +00:00
kacerrandClaude Fable 5.1 1f10a51f82 kvm2: domain and volume XML, metadata element and NoCloud seed builders
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:57:30 +00:00
kacerrandClaude Fable 5.1 95768281be kvm2: config block, constructor registration, IDs, state mapping and classify
The kvm2 block reuses KVMConfig; validateKVM gains a block-name variant so
messages say kvm2. The package skeleton has a non-dialing New, strict
providerID parsing, the state table and a 34-row classify test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:38:48 +00:00
kacerrandClaude Fable 5.1 4bd9aae03b Write the kvm2 provider spec from the template
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 13:38:29 +00:00
kacerrandClaude Fable 5.1 854856cf0f Plan the clean-room kvm2 provider built from the provider contract
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 13:33:56 +00:00
kacerr 0dc25a1637 Merge pull request 'Provider docs: generic contract, spec template and appendix on the existing providers' (#7) from feat/provider-blueprint into main
Reviewed-on: #7
2026-09-16 13:48:40 +02:00
kacerrandClaude Fable 5.1 625cc7cd64 Log the provider docs split in the changelog
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:47:24 +00:00
kacerrandClaude Fable 5.1 c6dda03e51 Record the verification sweep for the provider docs split
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:37:53 +00:00
kacerrandClaude Fable 5.1 86b8d21be3 Turn docs/providers.md into an index and repoint its inbound links
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:36:49 +00:00
kacerrandClaude Fable 5.1 2d959af313 Add the appendix on the existing providers
Profile table keyed on the contract's axis codes, per-provider reference
sections for kubernetes, gcp and kvm, shared gaps and proposed features.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:36:03 +00:00
kacerrandClaude Fable 5.1 d61868fe2d Add the provider spec template
Twenty-four fill-in entries, one per decision the contract leaves to the
provider, numbered to match the contract's profile table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:28:52 +00:00
kacerrandClaude Fable 5.1 8d764de336 Write the generic provider contract
Fully abstract: MUST/SHOULD/MAY rules with the caller behind each one,
19 behaviour-profile axes with stable codes, credential hygiene, testing
obligations, the checklist and the contract's known limits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:17:56 +00:00
kacerrandClaude Fable 5.1 cf89ac4c91 Plan the split of the provider blueprint into contract, appendix, template and index
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:13:01 +00:00
kacerrandClaude Opus 5 0bb699bf09 Fill the provider blueprint's gaps after a full sweep
A pass over the API types, spec hash, health and discovery call sites,
manager manifests, samples, README, provider docs, plans and the PR review
found rules the blueprint did not state: the IP must be reachable on the
proxy port by the health engine and clients, providers must be safe for
concurrent use, the manager's read-only/128Mi runtime envelope, catalogue
edits bypass the spec hash, Delete is re-called while in progress, and
provider names must be DNS-1123. Adds matrix rows, a deployment checklist
item, and the still-open review findings and broken samples as gaps.

README's "Adding a provider" restated an outdated contract; it now points
to docs/providers.md, and the providers table gains the kvm row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:18:40 +00:00
kacerrandClaude Opus 5 01c27b5f35 Cover tracing in the provider blueprint
The blueprint reduced tracing to "don't open per-operation spans", which
reads as "nothing to do". Add an observability section: the operation
spans the decorators give for free, the child spans per backend call a
provider should add (parent-gated transport or a span decorator over the
SDK seam), the ctx/logger rules that keep traceID on log lines, and the
status per provider: kubernetes and gcp have child spans, kvm has none.
Matrix rows, the checklist item, gaps and a kvm proposal follow suit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:14:06 +00:00
kacerrandClaude Opus 5 e08f23bcb3 Correct the provider blueprint after verification
A re-check against the code found four mismatches: the kvm connection is
opened at startup by the leader's first health check, so that isn't doc
drift; cmd/main.go, not the monitor, type-asserts Checker; QuotaExceeded
and PermanentError are reasons on Provisioned, not conditions; and the
latch line range. The checklist also now matches how the existing
providers do interface assertions and RBAC markers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:57:52 +00:00
kacerrandClaude Opus 5 d96e56f1a0 Write the provider blueprint
docs/providers.md collects the provider contract that was spread across
comments, callers and per-provider docs: MUST/SHOULD/MAY rules per method,
each tied to the reconciler or GC behaviour it protects, the error taxonomy,
a feature matrix for kubernetes/gcp/kvm with file:line notes, a checklist for
adding a provider, and the known gaps. Linked from architecture.md and
CLAUDE.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:54:40 +00:00
kacerr cacd9583a3 Merge pull request 'Provider health checks: ProviderHealth CRD, per-provider checks and gauges' (#6) from feat/provider-health into main
Reviewed-on: #6
2026-09-16 10:46:47 +02:00
kacerrandClaude Opus 5 e6ed46fd7c Record the provider health validation and add the changelog entry
The user ran the real-hypervisor checks against beelink and confirmed they
passed, which completes Step 7 of the provider health plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:45:58 +00:00
kacerrandClaude Opus 5 80b2e9c85e Plan the provider blueprint document
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:41:42 +00:00
kacerrandClaude Opus 5 4e23347760 Point the KVM integration tests at a dedicated known_hosts
Leaving KVM_TEST_KNOWN_HOSTS unset silently disables host-key
verification for the run, so document it as part of the normal setup
rather than as an optional extra, and suggest a kvm_known_hosts holding
only the hypervisors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 05:59:22 +00:00
kacerrandClaude Opus 5 4e5b5d62df Probe hosts for health instead of risking the shared connection
The health check shares each host's libvirt connection with the reconciles,
so it must not be able to take that connection away from them.

- A timed-out check no longer drops the link. The check goes through the new
  hostPool.probe, which abandons its RPCs when the context ends and leaves
  the connection alone, so an image upload or a Create running on the same
  link is not cut off. A link that really died is closed by the keepalive.
  One abandoned probe per host may stay waiting; until it returns, the next
  check reports Timeout at once (errProbeBusy).
- Waiting for a host's connection slot now ends with the caller's context.
  hostEntry used a mutex, so a check could wait out a reconcile redialing a
  dead host — minutes — past its own timeout. The slot is a one-slot channel
  and acquire bounds only the wait, never the uncontended take.
- --provider-check-timeout defaults to 0 and derives min(15s, interval).
  The flat 15s default contradicted the validation that the timeout may not
  exceed the interval, so --provider-check-interval=12s alone failed to
  start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 05:04:45 +00:00
kacerrandClaude Opus 5 0e456affdd Record the automated validation of the provider health checks
make test (envtest, -race) and the kind e2e suite pass; the e2e
ProviderHealth for the kubernetes provider turned Ready within a second
of the manager starting and its gauge appeared in the scrape. make lint
is blocked by the local go1.27 toolchain crashing staticcheck on the
standard library, unrelated to this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 20:31:15 +00:00
kacerrandClaude Opus 5 19b4a82ae9 Document the provider health checks
Architecture gets a provider-health section, component rows and four
decisions; the KVM provider doc gets a health-check section with the
reason table and the rotated-Secret gap; the testing guide lists the new
suites. CLAUDE.md's Architecture TODO and stale "only go.mod exists"
gotcha now point at the real docs, plus the RBAC-marker gotcha found
while wiring the monitor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 20:28:09 +00:00
kacerrandClaude Opus 5 9a3ee963a0 Wire the provider health monitor into the manager, with gauges
--provider-check-interval (1m; 0 = off, else at least 10s) and
--provider-check-timeout (15s) are validated before the providers config
loads. Checkers are collected from the unwrapped providers in config
order, so checks never count as provider requests. Three scrape-time
gauges per {provider,target} read the monitor's snapshot: up, check
duration and check timestamp. The e2e suite waits for the kubernetes
ProviderHealth to turn Ready and asserts its gauge in the scrape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 20:23:55 +00:00
kacerrandClaude Opus 5 88a15ecdb5 Add the provider health Monitor
A leader-only runnable checks every configured provider concurrently
under one timeout per round (first round at start, then every interval),
keeps the latest results for the metrics collector, logs targets going
down, changing reason or coming back, and publishes one ProviderHealth
object per provider: created with a managed-by label when missing, then
a status merge patch. Per-target lastTransitionTime survives rounds in
which ready does not flip. Objects of providers that are no longer
configured are deleted once at start; with checks turned off, all are.
Reads go through the API reader, so no cluster-wide informer is needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 20:20:52 +00:00
kacerrandClaude Opus 5 04dd2c0284 Add the KVM health check: reachability and storage pool per host
Each configured host is checked concurrently over the pooled SSH+libvirt
connection: libvirt version and hostname prove the daemon answers, then
the configured pool must exist and be running, with its free space
reported. Dial failures are now tagged with their stage (SSH material,
TCP, SSH handshake, libvirt connect) by a dialError that keeps the
message and unwraps to the cause, so classification is unchanged and the
check can say why a host is unreachable. A pool verdict is decided after
the call returns so it never drops a working connection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 20:17:35 +00:00
kacerrandClaude Opus 5 1962365f44 Add the provider Checker contract and the kubernetes and GCP checks
Checker is an optional interface next to Provider: Check returns one
verdict per target and never fails as a whole. The kubernetes provider
lists at most one managed Pod cluster-wide (the verb and scope GC relies
on); the GCP provider fetches a single page of at most one managed
instance through a new Probe seam method, since the SDK treats
MaxResults as a page size and the existing AggregatedList drains every
page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 20:08:21 +00:00
kacerrandClaude Opus 5 1c773c24ad Add the cluster-scoped ProviderHealth CRD
One operator-owned object per configured provider will carry the
periodic check result: a Ready condition, per-target verdicts with their
own transition times, and a ready/total count for kubectl get. There is
no spec; only a viewer role is shipped since the operator is the sole
writer. Written by hand (kubebuilder is not installed), registered in
the existing AddKnownTypes call rather than a second init().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 18:30:31 +00:00
kacerrandClaude Opus 5 18a3378507 Add plan: provider health checks
Periodic, leader-only checks of every configured provider (KVM per host
over the pooled SSH+libvirt connection including the storage pool,
kubernetes apiserver/RBAC, GCP credentials/API), surfaced through a new
cluster-scoped ProviderHealth CRD, Prometheus gauges and transition logs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 18:08:59 +00:00
kacerr eb8f075f7a Merge pull request 'KVM/libvirt provider: proxies as VMs on your own hypervisors' (#5) from feat/kvm-provider into main
Reviewed-on: #5
2026-09-11 17:02:33 +02:00
kacerrandClaude Opus 5 43d429aef7 Point the manager config and sample at the beelink KVM host
The default manager config gains a kvm-home provider reaching beelink
over Tailscale, the provider sample names the same host, and the
manager kustomization uses the locally built kvm-dev image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 06:26:44 +00:00
kacerrandClaude Opus 5 82eeeef9da Simplify the KVM provider after the /simplify review
Collapse duplicated logic the review found in PR #5 without changing
behaviour: one releaseFinalizer helper for the four finalizer exits in
reconcileDelete, classify reusing isClassified and provider.Class, the
image URL check and format/firmware values shared from the provider
package, imageSource and the metadata parser embedding their config
structs instead of copying fields, and the SSH loader as a small struct
that keeps only the SSH fields and is seeded by New instead of loading
the key twice. Tracing builds its V(2)-only lists only at V(2).

Tests: drop a duplicate New test, fold near-identical tests into tables
(connection drops, metadata parsing, cached images), add mustSSHAuth,
and use wantClass where classes were checked by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 06:24:43 +00:00
kacerrandClaude Opus 5 2b4b61cdc4 Record the review-fix validation: integration, in-cluster and the missing-Secret check
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 22:59:01 +00:00
kacerrandClaude Opus 5 c23def0051 Install the guest agent from a vendor-data script instead of editing the user's cloud-config
Merging the bootstrap into each Proxy's cloud-config was the wrong
mechanism twice over. Detection only matched a literal #cloud-config
first line, so a jinja template or a MIME archive still fell back to
vendor-data, where cloud-init's default mergers drop it — the exact
failure the merge existed to prevent. And the YAML round-trip re-typed
unquoted scalars: version: 1.10 reached the guest as 1.1, comments were
dropped and keys reordered.

Neither is worth patching, because vendor-data delivered as a shell
script bypasses the merger entirely: cloud-init hands vendor-data to its
own handler chain, writes scripts to a separate directory and runs them
from cc_scripts_vendor, after the user's packages and before the user's
runcmd. The merge could not have been rescued anyway — merge_how in
user-data governs only the first source, and merge_how in vendor-data is
discarded before the document is stored.

The Proxy's cloud-init now reaches the seed byte for byte in any format.
installGuestAgent (default true) turns the script off; a configured
vendorData travels beside it as a MIME multipart, declared text/plain so
cloud-init re-sniffs it and this code never classifies user content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 22:42:55 +00:00
kacerrandClaude Opus 5 10ca71331e Load the KVM ssh material on first use instead of at startup
The kvm-ssh Secret is mounted optional, but kvm.New read the key and
known_hosts eagerly and registry.Build aborts the whole provider set on a
constructor error, so a missing or misnamed Secret exited the manager
before any controller started and took the kubernetes and gcp providers
down with it.

lazyAuth loads the material on the first connection and caches it once it
works, so a Secret created after the manager starts heals the affected
proxies on the next reconcile with no restart. A missing file classifies
transient for that reason; a key that does not parse is permanent.

New still checks both files and logs a WARNING: line when they are
unreadable, keeping the startup diagnostic the eager load was there for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 22:34:28 +00:00
kacerrandClaude Opus 5 bbe9b751a9 Reject KVM host names and image keys that would break the identifiers they land in
A host name is pasted into the providerID hosts/<name>/domains/<vm>, which
parseProviderID splits on "/", so a name containing a slash produced an ID
that never parsed again: Get, Delete and GC all failed permanently and the
VM leaked with the Proxy's finalizer stuck.

The image map key has the same defect and lands in a file name rather than
an ID: base-<key>.<format> in the host's storage pool. Both now have to
match a plain identifier pattern. Machine-type keys are left alone; they
are only ever compared, never embedded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 22:31:34 +00:00
kacerrandClaude Opus 5 ba24d7e3d9 Let a Proxy finish deleting when its instance can no longer be reached
reconcileDelete asked Get first and dropped the finalizer only on
not-found, so a permanent Get retried forever: a KVM host removed from
the config, or a providerID the provider cannot resolve, left the Proxy
in Terminating with no way out. It now tries Delete anyway, which is
idempotent and can still finish where Get cannot converge, and releases
the object when that succeeds.

crawl.example.com/skip-cleanup abandons the instance for the cases that
stay stuck, such as revoked credentials. It is checked before the
provider lookup, so it also frees a Proxy whose provider is gone from
the config.

In the KVM provider, Delete over an unconfigured host is now a no-op
success, mirroring removeVolumes over a missing pool. Get keeps failing
permanently there on purpose: the live path reads not-found as "rebuild
it", so a typo in hosts must not look like a missing VM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 22:30:15 +00:00
kacerrandClaude Opus 5 ce365e2602 Add plan: fix the five code-review findings on the KVM branch
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 22:26:47 +00:00
kacerrandClaude Fable 5.1 0daf56bbad Close out the KVM provider plan: Step 7 done, MR #5 opened
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 21:41:38 +00:00
kacerrandClaude Fable 5.1 366736f53f Merge the qemu-guest-agent bootstrap into each Proxy's cloud-config instead of vendor-data
In the kind cluster two KVM Proxies with real cloud-configs booted fine
but never left Provisioning: the guest agent was not installed, so the
provider could not learn the IP. cloud-init merges vendor-data under
user-data with its default mergers, which keep the user-data's value
for any key both define and never append lists, so a cloud-config with
its own `packages:` silently dropped the vendor-data's packages and
runcmd. The integration test passed only because its user-data was
empty.

withGuestAgent now appends qemu-guest-agent to `packages` and the
enable command to `runcmd` of the Proxy's own cloud-config (skipping
entries already present) and re-renders it. Vendor-data is no longer
defaulted; `vendorData` is plain extra data. User-data that is not a
cloud-config document (script, #include, MIME) still gets the bootstrap
as vendor-data. A cloud-config that is not a YAML mapping, or whose
packages/runcmd is not a list, fails the Proxy as ErrPermanent.

Validated in-cluster: both Proxies Ready and healthy in two minutes, 204
through squid from the LAN. Docs, sample comment, CHANGELOG and the
plan execution log (Run 7) updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 21:06:54 +00:00
kacerrandClaude Fable 5.1 220967660d Give the manager a writable /tmp for seed ISOs; keep the libvirt connection on classified errors
The manager runs with a read-only root filesystem and the seed ISO
writer stages files in a temp directory, so the first in-cluster Create
failed with "creating iso writer: read-only file system". Mount a 64 MiB
emptyDir on /tmp.

That error is classified ErrPermanent by writeSeed, yet hostPool.call
treated every non-libvirt error as a transport failure and dropped the
cached SSH connection, redialling on each retry. Errors that already
carry a provider sentinel are local verdicts, not evidence about the
link; leave the connection alone for those.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 21:06:34 +00:00
kacerrandClaude Fable 5.1 c5ebb2e044 Worked example and make target for the kvm-ssh Secret
docs/kvm-provider.md walks through key generation, ssh-copy-id,
ssh-keyscan under the address the operator dials, and the Secret from
the private key plus ~/.ssh/kvm_known_hosts. make kvm-ssh-secret runs
the same as an apply with KVM_SSH_KEY / KVM_KNOWN_HOSTS overrides and
refuses a .pub, the classic mistake. README mirrors the short form.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerrandClaude Fable 5.1 cc0606d24f Record the passing KVM integration run; CHANGELOG for validation, UEFI and tracing
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerrandClaude Fable 5.1 c4f59bd153 Name the upstream cause of the Debian 13 BIOS hang: Debian bug #1111240
The known-gaps entry, decision log, sample comment and builder comment
blamed the host; the cause is trixie's GRUB config ("terminal_output
gfxterm serial" with a serial console and no video device), reported
and reproduced upstream and still open. UEFI remains the default for the
reason recorded; bookworm images are noted as unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerrandClaude Fable 5.1 9a393b1df8 Boot KVM guests via UEFI by default; per-image firmware setting
Run 5 on the real host bisected the guest hang: the Debian 13 image
hangs after GRUB under SeaBIOS with or without our seed and with either
CPU model, and boots under OVMF. The image's legacy boot chain is the
problem, not the provider.

images.<key>.firmware is "uefi" (default) or "bios". The domain XML
emits <os firmware="efi"> with secure boot off, so libvirt selects the
OVMF binary and manages the NVRAM file, which the existing nvram undefine
flag removes. Direct-URL images default to UEFI too. OVMF joins the
hypervisor prerequisites; sample, config table, known gaps, decision log
and the execution log record the finding.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerrandClaude Fable 5.1 5a6c889531 Make the libvirt trace compact: libvirt API names, one error field, terse test output
The first live run showed the trace working but noisy: testr printed
ts/level/msg and the per-run constants on every line, the error text
appeared twice, host appeared twice on image-cache lines, and the Go
method name was less useful than the libvirt function it maps to.

The tracer now logs rpc=virDomainLookupByName (go-libvirt's names are the
public API names without the vir prefix), error plus libvirtError (code
name) and libvirtCode, and image.go stops re-adding host. The integration
test replaces testr with a compact logr sink: time, logger name, the rpc
as headline, then fields, with provider/host/name omitted since the first
line of the run states them. Docs and execution log updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerrandClaude Fable 5.1 f50d30e58e Trace every libvirt call in the KVM provider at V(1), visible in the integration test
A tracingAPI decorator wraps the 17-method libvirt seam and logs one
"libvirt call" line per RPC after it returns: method, the identifiers it
was given, curated results, duration, and on failure the error with its
libvirt code, name and message. At V(2) the same line carries the domain
and volume XML, metadata and addresses; upload bodies are never logged.
The pool applies it in call() when V(1) is enabled and logs connect,
dial-failure and drop events, taking the caller's logger explicitly like
every other helper in the package.

The integration test installs a testr logger at KVM_TEST_LOG_LEVEL
(default 1) so the trace interleaves with t.Log output, which also ends
the "log.SetLogger(...) was never called" warning. Docs gained a Logging
section and the test env var; the KVM provider execution log records run
4 (guest hang at GRUB) that prompted this.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerrandClaude Fable 5.1 aa8b36e1d0 Add plan: trace every libvirt call in the KVM provider
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerrandClaude Fable 5.1 ffb328661d Parse KVM domain metadata by local name: libvirt returns it without a namespace
Run 3 on the real host got past Create and failed in Get: libvirt 11.6
selects the <instance> element by our URI in DomainGetMetadata but hands
it back with no namespace declaration, and the parser required the URI
on the element. The lookup already guarantees the namespace, so match on
the local name only. The fake now strips the declaration the way libvirt
does, and a new test pins the bare form. Execution log updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerrandClaude Fable 5.1 cd51886139 Reference KVM domain disks by host path so AppArmor-confined QEMU can open them
The first run against a real hypervisor (beelink, Ubuntu, libvirt 11.6)
failed at DomainCreate with "Could not open base image: Permission denied"
even with correct DAC ownership. libvirt's virt-aa-helper only grants QEMU
access to disk sources that carry a path; the <disk type="volume"> form
chosen in Step 2 gives it pool and volume names instead, so the overlay and
its backing image get no AppArmor rule on any stock Debian/Ubuntu host.

buildDomainXML now takes DiskPath/SeedPath and emits type="file" disks.
ensureOverlay and writeSeed return the volume handles and Create resolves
both paths with StorageVolGetPath before defining the domain. The domain
XML test rejects any volume/pool reference so this cannot drift back; the
Create test asserts the sources equal the paths libvirt reported.

Execution log: Step 2 note marked reverted, Step 7 entry opened with the
two failed runs and the diagnosis.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerrandClaude Fable 5.1 791988b5a0 Wire the KVM provider: main.go, kvm-ssh Secret mount, samples, docs, CHANGELOG
Registers "kvm" in the provider builtins, mounts an optional kvm-ssh
Secret at /etc/proxy-operator/kvm, adds a kvm-home providers-config
sample and a proxy_kvm.yaml Proxy sample, documents the provider in
docs/kvm-provider.md (prerequisites, config reference, image cache and
its do-not-replace-in-use rule, known gaps), and updates the README
and architecture provider sections and decision log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerrandClaude Fable 5.1 b3984ca69d Add KVM provider integration test against a real hypervisor
Behind -tags=integration and self-skipping unless KVM_TEST_* names a
host: one sequential lifecycle (image fetch, Create, idempotent
re-Create, Running with IP, ListByTag, Delete, pool state afterwards),
driven through the provider directly so it needs no Step 5 wiring.
Adds `make test-integration-kvm` and documents the run and the
deviation from the testcontainers convention in docs/testing.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerrandClaude Fable 5.1 97cb854693 Add KVM provider methods: Create/Get/Delete/ListByTag over the libvirt seam
Create looks the domain up first and only starts it when it exists;
otherwise it caches the image, reuses or creates the overlay, rebuilds
the seed ISO unconditionally (a cancelled upload can leave a truncated
seed that no later attempt would repair), defines and starts the domain.
Get maps libvirt state plus the first routable IPv4 from ARP or the
guest agent onto the provider states. Delete destroys, undefines and
removes the per-VM volumes by name, tolerating every already-gone case.
ListByTag keeps managed domains across all hosts, skipping domains it
cannot read and failing the sweep only for an unreachable host.

Also corrects the Step 2 seed determinism test: the iso9660 writer
stamps the build time into the volume descriptor, so builds differ by a
few bytes; the test now compares label, files and size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerrandClaude Fable 5.1 6dba33adf2 Add KVM provider image cache: fetch base images into host pools on first use
resolveImage maps placement.image to a configured key or URL; imageCache
streams the download through StorageVolUpload with a sha256 tee, writes
a marker volume when the upload is verified, and re-fetches when the
marker is missing or the configured checksum changed. Errors carry
taxonomy sentinels that classify now honours. Adds the in-memory fake
libvirt API used from here on.

Step 3b of docs/plans/2026-09-04-1404-kvm-provider.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerrandClaude Fable 5.1 6322184d50 Add KVM provider transport: SSH-forwarded libvirt connections and error classification
Per-host connection pool over SSH with the libvirt unix socket forwarded
through the session, lazy dial and redial after disconnect, context
cancellation for go-libvirt's context-less RPCs, handshake deadline and
SSH keepalives. Error classification onto the provider taxonomy. Tested
with a fake dialer and an in-process SSH server that forwards unix sockets.

Step 3 of docs/plans/2026-09-04-1404-kvm-provider.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerrandClaude Fable 5.1 162607dfe2 Add KVM provider pure builders: domain XML, volumes, seed ISO, metadata
Introduces internal/provider/kvm with the side-effect-free pieces of the
provider: providerID format/parse, the namespaced <metadata> element that
carries the GC contract, q35 domain XML over pool volumes, volume XML for
overlays and raw seeds, the NoCloud seed ISO built with kdomanski/iso9660,
and config defaults. All unit-tested.

Step 2 of docs/plans/2026-09-04-1404-kvm-provider.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerrandClaude Fable 5.1 84dce0462e Add KVM provider config: KVMConfig structs and validation
Adds the kvm block to the providers config (SSH access, images map,
machine-type profiles, hosts list) with validation and table tests.
validate() now rejects mismatched type-specific blocks generically via
setBlocks() instead of pairwise checks.

Step 1 of docs/plans/2026-09-04-1404-kvm-provider.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerrandClaude Fable 5.1 bdd1c6ce6c Add plan: KVM/libvirt provider
Design for a kvm provider type: SSH-forwarded libvirt socket, multi-host
config with placement.zone selecting the host, bridged networking with
ARP/guest-agent IP discovery, operator-fetched distro cloud images cached
in the host's storage pool, and a NoCloud seed ISO carrying the CRD's
cloud-init user-data.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:13:51 +00:00
kacerr 81b44d426b Merge pull request 'OpenTelemetry tracing integrated with logr/zap logging' (#4) from feat/otel-tracing into main
Reviewed-on: #4
2026-09-10 21:10:17 +02:00
kacerrandClaude f8b911f1db Record live and negative Tempo e2e runs in the execution log
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 12:39:05 +02:00
kacerrandClaude e691105f89 Add Tempo-gated e2e test for OTel tracing
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 12:31:41 +02:00
kacerrandClaude 026acea279 Add plan: e2e test of OTel tracing against real Tempo
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 12:25:30 +02:00
kacerrandClaude aeb4115c72 Add tracing manifests and docs; clean up branch lint findings
Manager env block (downward-API resource attrs, commented OTLP
examples), architecture §10 + Decisions entries, README section.
Lint: goconst constants, gofmt, logcheck (Setup now takes its logger
from ctx via logf.FromContext).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:30:27 +02:00
kacerrandClaude 53c0d77ef5 Enrich GCP wire logs with trace context from the request ctx
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:09:48 +02:00
kacerrandClaude bca32f10d3 Trace GC sweeps and (opt-in) health probes
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:07:13 +02:00
kacerrandClaude ec7267b8de Trace discovery API requests and enrich request logs
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 10:59:48 +02:00
kacerrandClaude 896bf89a19 Trace reconciles: root span wrapper plus state-machine sub-spans
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 10:53:52 +02:00
kacerrandClaude 8e4f3b9b13 Wire tracing into the composition root
Setup after SetLogger, wrapped rest configs (manager + kubernetes
provider), tracing-outermost provider decorators, and an explicit
trace flush after mgr.Start returns (os.Exit skips defers).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 10:51:24 +02:00
kacerrandClaude d15b06ec45 Add provider.WithTracing decorator mirroring WithMetrics
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 10:50:13 +02:00
kacerrandClaude 6b34f68469 Add internal/tracing: env-gated OTel setup, span/log helpers, decorators
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 10:43:55 +02:00
kacerrandClaude b1a16774bf Align OTel deps at v1.45.0 and add trace exporter modules
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 10:41:06 +02:00
kacerrandClaude 328877e000 Add plan: OTel tracing integrated with logr/zap logging
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 10:26:21 +02:00
kacerrandClaude 5ccd317bec Extend Claude permission allowlist with session-approved commands
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 00:18:09 +02:00
kacerrandClaude 6ff9eebefc Document providers and how to add one in README
New Providers section: named-instance config model, a table of the
implemented kubernetes and gcp providers, and a four-step guide for
adding a new backend (interface contract, GC tagging, error taxonomy,
wiring in config.go and main.go).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 00:16:54 +02:00
kacerrandClaude f6b67006dc Add tmux demo driver and table variant of egress IP check
run-demo.sh opens a 2x2 tmux grid: egress-IP table looping in a
netshoot pod, kubectl get px watch, and both create scripts running
with COUNT (default 4) proxies. show-egress-ips-table.sh is the
pane-sized one-line-per-proxy variant used by the driver.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 23:10:23 +02:00
kacerrandClaude 0d68111bc2 Add demo scripts: egress IP check and bulk proxy creation
docs/demo/show-egress-ips.sh probes each healthy proxy from the
discovery API against an IP-echo site; create-kubernetes-proxies.sh and
create-gcp-proxies.sh bulk-create demo Proxies, the gcp one spreading
them across randomly picked EU zones.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 22:40:24 +02:00
kacerrandClaude e1abac3e8f Add plan: demo script for egress IP check via proxies
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 22:21:28 +02:00
kacerrandClaude f3ff6a0ca2 Use $BASE_URL variable in docs/api.md curl examples
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 22:01:50 +02:00
kacerrandClaude 09845e4eaf Document the discovery API in docs/api.md
Full client-facing reference: auth, all routes with schemas and curl
examples, selection/cooldown semantics, caveats. README and
architecture.md link to it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 21:55:16 +02:00
kacerrandClaude e7fdae0859 Add plan: discovery API documentation
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 21:53:02 +02:00
286 changed files with 50853 additions and 510 deletions
+11
View File
@@ -0,0 +1,11 @@
---
name: deep-reviewer
description: Deep-dive verification of a single escalated hypothesis from the sweep.
model: fable
effort: high
tools: Read, Grep, Glob, Bash
---
You get ONE hypothesis with entry points. Follow it across the whole repo: callers,
callees, tests, configs. Verdict: CONFIRMED / REFUTED / PLAUSIBLE, with the exact
path/interleaving that breaks, or the reason it can't. Propose a minimal fix.
Don't re-review the package generally. Stay on the hypothesis.
+23
View File
@@ -0,0 +1,23 @@
---
name: sweep-reviewer
description: First-pass broad code review of one package/module. Use for fan-out sweeps.
model: opus
effort: high
tools: Read, Grep, Glob, Bash
---
You review ONE package/module given in the prompt. Read-only, never edit.
Output two sections:
## Findings
Concrete defects you verified. Per item: file:line, severity, one-line defect,
failure scenario (inputs/state -> wrong behavior). No style nits unless they hide a bug.
## Escalate
Things you could NOT confirm or refute within this package, where being wrong is costly.
Escalate when: shared mutable state across goroutines, lock ordering, context/cancel
propagation, retry+timeout+idempotency interactions, reconcile races, authn/authz
boundaries, invariants that depend on code outside this package, state machines.
Per item: entry-point files, the specific hypothesis ("X may be read without mu
on path A->B->C"), what you already checked, what's still unknown.
Do NOT escalate things you can resolve yourself. Go resolve them.
+164 -76
View File
@@ -1,97 +1,185 @@
{
"permissions": {
"allow": [
"Bash(go version *)",
"Bash(go build *)",
"Bash(go test *)",
"Bash(go vet *)",
"Bash(go run *)",
"Bash(go mod *)",
"Read(//Users/jan.novak/.claude/skills/go-project-bootstrap/references/**)",
"Bash(ls -la /Users/jan.novak/.claude/skills/go-project-bootstrap/assets/ /Users/jan.novak/.claude/skills/go-project-bootstrap/assets/commands/ /Users/jan.novak/.claude/skills/go-project-bootstrap/assets/agents/ 2>&1)",
"Read(//Users/jan.novak/.claude/skills/go-project-bootstrap/assets/**)",
"Read(//Users/jan.novak/.claude/skills/go-project-bootstrap/assets/agents/**)",
"Read(//Users/jan.novak/.claude/skills/go-project-bootstrap/assets/commands/**)",
"Bash(mkdir -p /Users/jan.novak/srv/go/egress-proxies-operator/docs/plans && touch /Users/jan.novak/srv/go/egress-proxies-operator/docs/plans/.gitkeep && mkdir -p /Users/jan.novak/srv/go/egress-proxies-operator/.claude/commands /Users/jan.novak/srv/go/egress-proxies-operator/.claude/agents && ls -la /Users/jan.novak/srv/go/egress-proxies-operator)",
"Bash(git status)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git branch:*)",
"Bash(git add:*)",
"Bash(git checkout:*)",
"Bash(git pull:*)",
"Bash(git fetch:*)",
"Bash(date:*)",
"Read(//tmp/**)",
"Read(//private/tmp/**)",
"Bash(go build:*)",
"Bash(go test:*)",
"Bash(go vet:*)",
"Bash(go mod:*)",
"Bash(go run:*)",
"Bash(go version:*)",
"Bash(go get:*)",
"Bash(golangci-lint run:*)",
"Bash(golangci-lint --version)",
"Bash(gofumpt:*)",
"Bash(tea pr create:*)",
"Bash(curl -sL https://go.dev/dl/?mode=json)",
"Bash(find / -iname \"docs-conventions.md\" -maxdepth 6)",
"Read(//Users/jan.novak/.claude/**)",
"Read(//Users/jan.novak/.claude/skills/**)",
"Bash(mkdir -p docs/plans && date \"+%Y-%m-%d-%H%M\")",
"Bash(go get *)",
"Bash(go install *)",
"Bash(go env *)",
"Bash(git rm *)",
"Bash(mkdir -p /Users/jan.novak/srv/go/egress-proxies-operator/docs/plans-executions)",
"Bash(cd /Users/jan.novak/srv/go/egress-proxies-operator *)",
"Bash(echo \"build: $?\")",
"Bash(echo \"vet: $?\")",
"Bash(perl -i -pe 's{^\\\\t\\\\t\\\\t\\\\t\\\\t// TODO\\\\\\(user\\\\\\): Specify other spec details if needed\\\\.\\\\n}{\\\\t\\\\t\\\\t\\\\t\\\\t// A minimal, schema-valid spec so this placeholder test survives the\\\\n\\\\t\\\\t\\\\t\\\\t\\\\t// CEL/CRD validation added in Step 1. Rewritten wholesale in Step 4\\\\n\\\\t\\\\t\\\\t\\\\t\\\\t// alongside the real reconciler and envtest suite.\\\\n\\\\t\\\\t\\\\t\\\\t\\\\tSpec: crawlv1alpha1.ProxySpec{\\\\n\\\\t\\\\t\\\\t\\\\t\\\\t\\\\tMode: crawlv1alpha1.ModeExternal,\\\\n\\\\t\\\\t\\\\t\\\\t\\\\t\\\\tEndpoint: &crawlv1alpha1.EndpointSpec{Host: \"10.0.0.1\"},\\\\n\\\\t\\\\t\\\\t\\\\t\\\\t},\\\\n}' internal/controller/proxy_controller_test.go)",
"Bash(go tool *)",
"Bash(docker version *)",
"Bash(curl -sI --max-time 5 https://hub.docker.com)",
"Bash(kind get *)",
"Bash(curl -s --max-time 5 \"https://hub.docker.com/v2/repositories/ubuntu/squid/tags?page_size=10\")",
"Bash(python3 -c ' *)",
"Bash(curl -s --max-time 5 \"https://hub.docker.com/v2/repositories/ubuntu/squid/tags?page_size=100\")",
"Bash(go doc *)",
"Bash(go list *)",
"Bash(gofmt -w internal/provider/config.go)",
"Bash(gofmt -l .)",
"Bash(git restore *)",
"Bash(go version *)",
"Bash(gofmt *)",
"Bash(gofumpt *)",
"Bash(golangci-lint run *)",
"Bash(golangci-lint --version)",
"Bash(./bin/manager* --version)",
"Bash(make -n *)",
"Bash(make manifests *)",
"Bash(make test *)",
"Bash(KUBEBUILDER_ASSETS=\"/Users/jan.novak/srv/go/egress-proxies-operator/bin/k8s/1.36.2-darwin-arm64\" go test -race ./internal/controller/)",
"Bash(grep -n 'func Channel' -A8 __CMDSUB_OUTPUT__/sigs.k8s.io/controller-runtime@v0.24.1/pkg/source/source.go)",
"Bash(grep -n 'type GenericEvent' __CMDSUB_OUTPUT__/sigs.k8s.io/controller-runtime@v0.24.1/pkg/event/event.go)",
"Bash(KUBEBUILDER_ASSETS=__TRACKED_VAR__/bin/k8s/1.36.2-darwin-arm64 go test -race ./...)",
"Bash(cat >> *)",
"Bash(make test-integration-kvm *)",
"Bash(make run-dev *)",
"Bash(kubectl get *)",
"Bash(kubectl delete *)",
"Bash(make docker-build *)",
"Bash(kind load *)",
"Bash(make deploy *)",
"Bash(kubectl -n egress-proxies-operator-system rollout status deploy/egress-proxies-operator-controller-manager --timeout=120s)",
"Bash(kubectl -n egress-proxies-operator-system rollout restart deploy/egress-proxies-operator-controller-manager)",
"Bash(git -C /Users/jan.novak/srv/go/egress-proxies-operator add docs/plans/2026-08-11-1742-gcp-provider-verbose-logging.md)",
"Bash(git -C /Users/jan.novak/srv/go/egress-proxies-operator commit -m 'Add plan: verbose V-level logging in the GCP provider *)",
"Bash(echo \"exit: $?\")",
"Bash(echo \"tests exit: $?\")",
"Bash(./bin/manager --version)",
"Bash(./bin/manager-stamped --version)",
"Bash(./bin/manager-pkg --version)",
"Bash(make kvm-ssh-secret *)",
"Bash(git status *)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git branch)",
"Bash(git branch -a)",
"Bash(git branch --show-current)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git fetch *)",
"Bash(git pull *)",
"Bash(git worktree list *)",
"Bash(git config --get *)",
"Bash(git config --list *)",
"Bash(git merge-tree *)",
"Bash(git ls-remote *)",
"Bash(git -c credential.helper='!f\\(\\) { echo username=kacerr; echo \"password=$\\(tr -d \"[:space:]\" < ~/.config/gitea-token\\)\"; }; f' push origin feat/kvm-provider)",
"Bash(git -c credential.helper='!f\\(\\) { echo username=kacerr; echo \"password=$\\(tr -d \"[:space:]\" < ~/.config/gitea-token\\)\"; }; f' push -q origin feat/kvm-provider)",
"Bash(git -c credential.helper='!f\\(\\) { echo username=kacerr; echo \"password=$\\(tr -d \"[:space:]\" < ~/.config/gitea-token\\)\"; }; f' push -u origin feat/kvm-provider)",
"Bash(tea pr create *)",
"Bash(tea pr list *)",
"Bash(curl -sL https://go.dev/dl/?mode=json)",
"Bash(curl -s --max-time 10 https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/*)",
"Bash(curl -s --max-time 10 'https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/*')",
"Bash(curl -sI --max-time 10 https://gitea.home.hrajfrisbee.cz/v2/kacerr/egress-proxies-operator/*)",
"Bash(curl -s -H \"Authorization: token $\\(tr -d '[:space:]' < ~/.config/gitea-token\\)\" https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/pulls/5)",
"Bash(kubectl get *)",
"Bash(kubectl wait *)",
"Bash(kubectl kustomize *)",
"Bash(kubectl apply *)",
"Bash(kubectl run *)",
"Bash(kubectl config current-context)",
"Bash(kubectl config get-contexts *)",
"Bash(kubectl config view *)",
"Bash(kubectl -n egress-proxies-operator-system get *)",
"Bash(kubectl -n egress-proxies-operator-system logs *)",
"Bash(kubectl -n egress-proxies-operator-system rollout status *)",
"Bash(kubectl -n egress-proxies-operator-system rollout restart *)",
"Bash(kind get *)",
"Bash(kind load *)",
"Bash(kind version *)",
"Bash(docker version *)",
"Bash(docker info *)",
"Bash(docker image ls *)",
"Bash(docker image inspect *)",
"Bash(docker manifest inspect *)",
"Bash(docker history *)",
"Bash(docker save *)",
"Bash(docker run *)",
"Bash(kubectl -n egress-proxies-operator-system get pods -o wide)",
"Bash(kubectl -n egress-proxies-operator-system get deploy egress-proxies-operator-controller-manager -o jsonpath='{.spec.template.spec.containers[0].args}')",
"Bash(kubectl -n egress-proxies-operator-system logs deploy/egress-proxies-operator-controller-manager)",
"Bash(python3 -c \"import json; d=json.load\\(open\\('docs/deploy/sa_key.json'\\)\\); print\\(d.get\\('type'\\), d.get\\('client_email'\\)\\)\")"
"Bash(docker exec *)",
"Bash(virsh -c qemu:///system list *)",
"Bash(virsh -c qemu:///system net-list *)",
"Bash(virsh -c qemu:///system net-dumpxml *)",
"Bash(virsh -c qemu:///system pool-list *)",
"Bash(virsh -c qemu:///system pool-dumpxml *)",
"Bash(virsh -c qemu:///system vol-list *)",
"Bash(ssh -i ~/.ssh/proxy-operator_ed25519 * novakj@beelink.tail8c69c.ts.net *)",
"Bash(ip -br *)",
"Bash(getent hosts *)",
"Bash(tailscale status *)",
"Bash(python3 *)",
"Bash(grep *)",
"Bash(echo \"*$?\")",
"Bash(date *)",
"Bash(env)",
"Bash(command -v *)",
"Bash(cat >> *)",
"Bash(tar -tf *)",
"Bash(bash -n *)",
"Bash(mkdir -p docs/*)",
"Read(//home/novakj/**)",
"Read(//Users/jan.novak/.claude/**)",
"Read(//tmp/**)",
"Read(//private/tmp/**)",
"Read(//etc/**)",
"Read(//dev/**)",
"Read(//run/libvirt/**)",
"WebSearch",
"WebFetch(domain:gitlab.com)",
"WebFetch(domain:bugs.debian.org)",
"WebFetch(domain:tracker.debian.org)",
"WebFetch(domain:cloud.debian.org)",
"WebFetch(domain:alioth-lists.debian.net)",
"WebFetch(domain:www.mail-archive.com)",
"WebFetch(domain:forum.proxmox.com)",
"Bash(curl -s --max-time 10 'https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/pulls?state=open')",
"Bash(python3 -c 'import json,sys; [print\\(p[\"number\"], p[\"head\"][\"ref\"], \"->\", p[\"base\"][\"ref\"], \"|\", p[\"title\"]\\) for p in json.load\\(sys.stdin\\)]')",
"Bash(curl -s --max-time 10 'https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/pulls/5')",
"Bash(python3 -c 'import json,sys; p=json.load\\(sys.stdin\\); print\\(p[\"state\"], \"merged=\", p[\"merged\"], p.get\\(\"merge_commit_sha\"\\)\\)')",
"Read(//home/novakj/srv/egress-proxies-operator-simplify/origin/**)",
"Bash(echo \"exit=$?\")",
"Read(//home/novakj/srv/egress-proxies-operator-simplify/**)",
"Bash(git -C /home/novakj/srv/egress-proxies-operator-simplify diff 82eeeef -- internal/controller/proxy_controller.go internal/provider/kvm/errors.go)",
"Bash(git switch *)",
"Bash(git -C /home/novakj/srv/egress-proxies-operator-simplify add -A)",
"Bash(git -C /home/novakj/srv/egress-proxies-operator-simplify commit -q -m 'WIP: uncommitted simplify-pass edits preserved before worktree removal *)",
"Bash(git -C /home/novakj/srv/egress-proxies-operator-simplify log --oneline -1)",
"Bash(git worktree *)",
"Bash(git branch *)",
"Bash(git stash *)",
"Bash(make generate *)",
"Bash(bin/kustomize build *)",
"Bash(grep -Ev \"^\\\\s*$\")",
"Bash(make lint *)",
"Bash(command -v kind docker)",
"Bash(bin/golangci-lint version *)",
"Bash(make test-e2e *)",
"Bash(echo \"make test-e2e exit=$?\")",
"Bash(ls \"$\\(go env GOMODCACHE\\)/golang.org/\")",
"Bash(ls \"$\\(go env GOMODCACHE\\)/golang.org/toolchain@\"* -d)",
"Bash(ps -eo pid,ppid,etime,args)",
"Bash(kubectl config *)",
"Bash(git commit -q -m 'Plan the provider blueprint document *)",
"Bash(grep -nE '^#{1,3} ' docs/architecture.md; echo ---; sed -n '/Implemented providers/,/^## /p' README.md | head -30; echo ---; sed -n 140,180p config/manager/manager.yaml; echo ---; cat Dockerfile; echo ---; ls -a | grep -i golangci; echo ---; grep -nE 'Placement|placement|Region|Zone|MachineType|Image ' api/v1alpha1/proxy_types.go | head -40; echo ---; grep -nE 'Type|type' api/v1alpha1/providerhealth_types.go | head -20; echo ---; grep -n -i 'placement' docs/api.md | head; echo ---; grep -nE 'WithTimeout|Deadline|timeout' internal/controller/proxy_controller.go internal/gc/gc.go internal/providerhealth/monitor.go | head -20; echo ---; cat .gitea/workflows/build.yaml | sed -n 1,60p)",
"WebFetch(domain:pve.proxmox.com)",
"Bash(git -c credential.helper='!f\\(\\) { echo username=kacerr; echo \"password=$\\(tr -d \"[:space:]\" < ~/.config/gitea-token\\)\"; }; f' push -u origin feat/provider-blueprint)",
"Bash(curl -s -H \"Authorization: token $\\(tr -d '[:space:]' < ~/.config/gitea-token\\)\" \"https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/pulls?state=open\")",
"Bash(curl -s -H \"Authorization: token $\\(tr -d '[:space:]' < ~/.config/gitea-token\\)\" \"https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/pulls/7\")",
"Bash(curl -s -X PATCH -H \"Authorization: token $\\(tr -d '[:space:]' < ~/.config/gitea-token\\)\" -H 'Content-Type: application/json' -d @/tmp/claude-1000/-home-novakj-srv-egress-proxies-operator/7662ab9d-4540-4676-a943-0874ac6b081d/scratchpad/pr7.json \"https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/pulls/7\")",
"Bash(bin/golangci-lint run *)",
"Bash(awk '/^## The image cache/,/^## [^T]/' docs/kvm-provider.md)",
"Bash(awk '/^#+ .*Step 11/,0' docs/plans/2026-09-16-1333-kvm2-provider.md)",
"Bash(sort -t: -k2 -u)",
"Bash(awk '/kvm2/&&/^#/{f=1} f' docs/testing.md)",
"Bash(awk -F'\"' '{print $2}')",
"Bash(sed -n '/targets:/,/type: object/p' config/crd/bases/*providerhealths.yaml | sed -n 1,45p)",
"Bash(ssh -i ~/.ssh/proxy-operator_ed25519 -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10 -o UserKnownHostsFile=~/.ssh/kvm_known_hosts -o StrictHostKeyChecking=yes novakj@beelink 'hostname; id -un; virsh -c qemu:///system version; virsh -c qemu:///system pool-list --all; virsh -c qemu:///system list --all')",
"Bash(awk '{print $1, $2}' ~/.ssh/kvm_known_hosts)",
"Bash(ssh-keygen -F beelink -f ~/.ssh/kvm_known_hosts)",
"Bash(ssh -i ~/.ssh/proxy-operator_ed25519 -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10 -o UserKnownHostsFile=~/.ssh/kvm_known_hosts -o StrictHostKeyChecking=yes novakj@beelink.tail8c69c.ts.net 'hostname; id -un; id -Gn; virsh -c qemu:///system version; virsh -c qemu:///system pool-list --all --details; virsh -c qemu:///system list --all; command -v qemu-img')",
"Bash(qemu-img --version)",
"Bash(./probe)",
"Bash(./probe -proxyrcv 8192)",
"Bash(./probe -small)",
"Bash(sysctl net.ipv4.tcp_wmem net.ipv4.tcp_retries2)",
"Bash(GOFLAGS=-mod=mod GOPROXY=off go build -o probe .)",
"Bash(./probe -clientsnd 131072 -proxyrcv 8192)",
"Bash(truncate -s 64M gpt.raw)",
"Bash(sfdisk -l gptshort.raw)",
"Bash(sgdisk -v gptshort.raw)",
"Bash(mkdir -p /tmp/claude-1000/-home-novakj-srv-egress-proxies-operator/8db56daf-938c-48b8-8a17-084f3e1dea44/scratchpad)",
"Bash(curl -sS -m 30 -o storage_driver.c https://raw.githubusercontent.com/libvirt/libvirt/v11.6.0/src/storage/storage_driver.c)",
"Bash(GOFLAGS=-mod=mod GOPROXY=off go vet ./...)",
"Bash(GOFLAGS=-mod=mod GOPROXY=off go test -count=1 -run TestLeak -v . -args -n 1000 -transport unix)",
"Bash(curl -sS -m 30 -o storage_util.c https://raw.githubusercontent.com/libvirt/libvirt/v11.6.0/src/storage/storage_util.c)",
"Bash(awk '/^virStorageBackendVolUploadLocal/,/^}/' storage_util.c)",
"Bash(export GOFLAGS=-mod=mod GOPROXY=off)",
"Bash(awk '/WARNING: DATA RACE/{c++} c==1' race_ssh.log)",
"Bash(kubectl version *)",
"Bash(timeout 5 kubectl cluster-info)"
],
"additionalDirectories": [
"/Users/jan.novak/srv/go/egress-proxies-operator/.claude",
"/Users/jan.novak/srv/go/egress-proxies-operator/docs/plans",
"/Users/jan.novak/srv/go/egress-proxies-operator/docs",
"/Users/jan.novak/srv/go/egress-proxies-operator/docs/prompts",
"/tmp",
"/Users/jan.novak/srv/go/egress-proxies-operator/docs/plans-executions"
"/home/novakj/srv/egress-proxies-operator",
"/home/novakj/srv/egress-proxies-operator/config/crd/bases"
]
}
}
+42 -320
View File
@@ -1,320 +1,42 @@
# egress-proxies-operator - AI Agent Guide
## Project Structure
**Single-group layout (default):**
```
cmd/main.go Manager entry (registers controllers/webhooks)
api/<version>/*_types.go CRD schemas (+kubebuilder markers)
api/<version>/zz_generated.* Auto-generated (DO NOT EDIT)
internal/controller/* Reconciliation logic
internal/webhook/* Validation/defaulting (if present)
config/crd/bases/* Generated CRDs (DO NOT EDIT)
config/rbac/role.yaml Generated RBAC (DO NOT EDIT)
config/samples/* Example CRs (edit these)
Makefile Build/test/deploy commands
PROJECT Kubebuilder metadata Auto-generated (DO NOT EDIT)
```
**Multi-group layout** (for projects with multiple API groups):
```
api/<group>/<version>/*_types.go CRD schemas by group
internal/controller/<group>/* Controllers by group
internal/webhook/<group>/<version>/* Webhooks by group and version (if present)
```
Multi-group layout organizes APIs by group name (e.g., `batch`, `apps`). Check the `PROJECT` file for `multigroup: true`.
**To convert to multi-group layout:**
1. Run: `kubebuilder edit --multigroup=true`
2. Move APIs: `mkdir -p api/<group> && mv api/<version> api/<group>/`
3. Move controllers: `mkdir -p internal/controller/<group> && mv internal/controller/*.go internal/controller/<group>/`
4. Move webhooks (if present): `mkdir -p internal/webhook/<group> && mv internal/webhook/<version> internal/webhook/<group>/`
5. Update import paths in all files
6. Fix `path` in `PROJECT` file for each resource
7. Update test suite CRD paths (add one more `..` to relative paths)
## Critical Rules
### Never Edit These (Auto-Generated)
- `config/crd/bases/*.yaml` - from `make manifests`
- `config/rbac/role.yaml` - from `make manifests`
- `config/webhook/manifests.yaml` - from `make manifests`
- `**/zz_generated.*.go` - from `make generate`
- `PROJECT` - from `kubebuilder [OPTIONS]`
### Never Remove Scaffold Markers
Do NOT delete `// +kubebuilder:scaffold:*` comments. CLI injects code at these markers.
### Keep Project Structure
Do not move files around. The CLI expects files in specific locations.
### Always Use CLI Commands
Always use `kubebuilder create api` and `kubebuilder create webhook` to scaffold. Do NOT create files manually.
### E2E Tests Require an Isolated Kind Cluster
The e2e tests are designed to validate the solution in an isolated environment (similar to GitHub Actions CI).
Ensure you run them against a dedicated [Kind](https://kind.sigs.k8s.io/) cluster (not your “real” dev/prod cluster).
## After Making Changes
**After editing `*_types.go` or markers:**
```
make manifests # Regenerate CRDs/RBAC from markers
make generate # Regenerate DeepCopy methods
```
**After editing `*.go` files:**
```
make lint-fix # Auto-fix code style
make test # Run unit tests
```
## CLI Commands Cheat Sheet
### Create API (your own types)
```bash
kubebuilder create api --group <group> --version <version> --kind <Kind>
```
### Deploy Image Plugin (scaffold to deploy/manage ANY container image)
Generate a controller that deploys and manages a container image (nginx, redis, memcached, your app, etc.):
```bash
# Example: deploying memcached
kubebuilder create api --group example.com --version v1alpha1 --kind Memcached \
--image=memcached:alpine \
--plugins=deploy-image.go.kubebuilder.io/v1-alpha
```
Scaffolds good-practice code: reconciliation logic, status conditions, finalizers, RBAC. Use as a reference implementation.
### Create Webhooks
```bash
# Validation + defaulting
kubebuilder create webhook --group <group> --version <version> --kind <Kind> \
--defaulting --programmatic-validation
# Conversion webhook (for multi-version APIs)
kubebuilder create webhook --group <group> --version v1 --kind <Kind> \
--conversion --spoke v2
```
### Controller for Core Kubernetes Types
```bash
# Watch Pods
kubebuilder create api --group core --version v1 --kind Pod \
--controller=true --resource=false
# Watch Deployments
kubebuilder create api --group apps --version v1 --kind Deployment \
--controller=true --resource=false
```
### Controller for External Types (e.g., from other operators)
Watch resources from external APIs (cert-manager, Argo CD, Istio, etc.):
```bash
# Example: watching cert-manager Certificate resources
kubebuilder create api \
--group cert-manager --version v1 --kind Certificate \
--controller=true --resource=false \
--external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 \
--external-api-domain=io \
--external-api-module=github.com/cert-manager/cert-manager
```
**Note:** Use `--external-api-module=<module>@<version>` only if you need a specific version. Otherwise, omit `@<version>` to use what's in go.mod.
### Webhook for External Types
```bash
# Example: validating external resources
kubebuilder create webhook \
--group cert-manager --version v1 --kind Issuer \
--defaulting \
--external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 \
--external-api-domain=io \
--external-api-module=github.com/cert-manager/cert-manager
```
## Testing & Development
```bash
make test # Run unit tests (uses envtest: real K8s API + etcd)
make run # Run locally (uses current kubeconfig context)
```
Tests use **Ginkgo + Gomega** (BDD style). Check `suite_test.go` for setup.
## Deployment Workflow
```bash
# 1. Regenerate manifests
make manifests generate
# 2. Build & deploy
export IMG=<registry>/<project>:tag
make docker-build docker-push IMG=$IMG # Or: kind load docker-image $IMG --name <cluster>
make deploy IMG=$IMG
# 3. Test
kubectl apply -k config/samples/
# 4. Debug
kubectl logs -n <project>-system deployment/<project>-controller-manager -c manager -f
```
### API Design
**Key markers for** `api/<version>/*_types.go`:
```go
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Namespaced
// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=".status.conditions[?(@.type=='Ready')].status"
// On fields:
// +kubebuilder:validation:Required
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:MaxLength=100
// +kubebuilder:validation:Pattern="^[a-z]+$"
// +kubebuilder:default="value"
```
- **Use** `metav1.Condition` for status (not custom string fields)
- **Use predefined types**: `metav1.Time` instead of `string` for dates
- **Follow K8s API conventions**: Standard field names (`spec`, `status`, `metadata`)
### Controller Design
**RBAC markers in** `internal/controller/*_controller.go`:
```go
// +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds/finalizers,verbs=update
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
```
**Implementation rules:**
- **Idempotent reconciliation**: Safe to run multiple times
- **Re-fetch before updates**: `r.Get(ctx, req.NamespacedName, obj)` before `r.Update` to avoid conflicts
- **Structured logging**: `log := log.FromContext(ctx); log.Info("msg", "key", val)`
- **Owner references**: Enable automatic garbage collection (`SetControllerReference`)
- **Watch secondary resources**: Use `.Owns()` or `.Watches()`, not just `RequeueAfter`
- **Finalizers**: Clean up external resources (buckets, VMs, DNS entries)
### Logging
**Follow Kubernetes logging message style guidelines:**
- Start from a capital letter
- Do not end the message with a period
- Active voice: subject present (`"Deployment could not create Pod"`) or omitted (`"Could not create Pod"`)
- Past tense: `"Could not delete Pod"` not `"Cannot delete Pod"`
- Specify object type: `"Deleted Pod"` not `"Deleted"`
- Balanced key-value pairs
```go
log.Info("Starting reconciliation")
log.Info("Created Deployment", "name", deploy.Name)
log.Error(err, "Failed to create Pod", "name", name)
```
**Reference:** https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md#message-style-guidelines
### Webhooks
- **Create all types together**: `--defaulting --programmatic-validation --conversion`
- **When`--force`is used**: Backup custom logic first, then restore after scaffolding
- **For multi-version APIs**: Use hub-and-spoke pattern (`--conversion --spoke v2`)
- Hub version: Usually oldest stable version (v1)
- Spoke versions: Newer versions that convert to/from hub (v2, v3)
- Example: `--group crew --version v1 --kind Captain --conversion --spoke v2` (v1 is hub, v2 is spoke)
### Learning from Examples
The **deploy-image plugin** scaffolds a complete controller following good practices. Use it as a reference implementation:
```bash
kubebuilder create api --group example --version v1alpha1 --kind MyApp \
--image=<your-image> --plugins=deploy-image.go.kubebuilder.io/v1-alpha
```
Generated code includes: status conditions (`metav1.Condition`), finalizers, owner references, events, idempotent reconciliation.
## Distribution Options
### Option 1: YAML Bundle (Kustomize)
```bash
# Generate dist/install.yaml from Kustomize manifests
make build-installer IMG=<registry>/<project>:tag
```
**Key points:**
- The `dist/install.yaml` is generated from Kustomize manifests (CRDs, RBAC, Deployment)
- Commit this file to your repository for easy distribution
- Users only need `kubectl` to install (no additional tools required)
**Example:** Users install with a single command:
```bash
kubectl apply -f https://raw.githubusercontent.com/<org>/<repo>/<tag>/dist/install.yaml
```
### Option 2: Helm Chart
```bash
kubebuilder edit --plugins=helm/v2-alpha # Generates dist/chart/ (default)
kubebuilder edit --plugins=helm/v2-alpha --output-dir=charts # Generates charts/chart/
```
**For development:**
```bash
make helm-deploy IMG=<registry>/<project>:<tag> # Deploy manager via Helm
make helm-deploy IMG=$IMG HELM_EXTRA_ARGS="--set ..." # Deploy with custom values
make helm-status # Show release status
make helm-uninstall # Remove release
make helm-history # View release history
make helm-rollback # Rollback to previous version
```
**For end users/production:**
```bash
helm install my-release ./<output-dir>/chart/ --namespace <ns> --create-namespace
```
**Important:** If you add webhooks or modify manifests after initial chart generation:
1. Backup any customizations in `<output-dir>/chart/values.yaml` and `<output-dir>/chart/manager/manager.yaml`
2. Re-run: `kubebuilder edit --plugins=helm/v2-alpha --force` (use same `--output-dir` if customized)
3. Manually restore your custom values from the backup
### Publish Container Image
```bash
export IMG=<registry>/<project>:<version>
make docker-build docker-push IMG=$IMG
```
## References
### Essential Reading
- **Kubebuilder Book**: https://book.kubebuilder.io (comprehensive guide)
- **controller-runtime FAQ**: https://github.com/kubernetes-sigs/controller-runtime/blob/main/FAQ.md (common patterns and questions)
- **Good Practices**: https://book.kubebuilder.io/reference/good-practices.html (why reconciliation is idempotent, status conditions, etc.)
- **Logging Conventions**: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md#message-style-guidelines (message style, verbosity levels)
### API Design & Implementation
- **API Conventions**: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md
- **Operator Pattern**: https://kubernetes.io/docs/concepts/extend-kubernetes/operator/
- **Markers Reference**: https://book.kubebuilder.io/reference/markers.html
### Tools & Libraries
- **controller-runtime**: https://github.com/kubernetes-sigs/controller-runtime
- **controller-tools**: https://github.com/kubernetes-sigs/controller-tools
- **Kubebuilder Repo**: https://github.com/kubernetes-sigs/kubebuilder
# egress-proxies-operator
## Sources and architecture
- Trust `go.mod`, `Makefile`, and current code over version tables or bootstrap prose. `CLAUDE.md`'s opening architecture/commands sections are stale; this is a working operator, not an empty scaffold.
- `cmd/main.go` assembles the provider registry, reconciler, proxy health engine, in-memory lease store, discovery HTTP API, orphan GC, and provider-health monitor on one controller-runtime manager.
- `api/v1alpha1` contains namespaced `Proxy` and cluster-scoped `ProviderHealth` resources (`crawl.example.com`). Validation/defaulting uses CRD markers/CEL; there are no admission webhooks.
- `internal/provider` defines the backend contract independently of CRD types. Constructors are registered in `cmd/main.go`; `spec.provider` selects a configured **instance name**, not a provider type. `kvm` and `kvm2` are separate implementations and do not adopt each other's VMs.
- Before changing a provider, read `docs/provider-contract.md`; use `docs/provider-spec-template.md` for a new one. Callers rely on self-contained provider IDs, truthful `ErrNotFound`, idempotent create/delete, and persisted UID/creation time for orphan GC. See `docs/providers-existing.md` for implementation-specific exceptions.
- `docs/architecture.md` explains component ownership and state transitions; `docs/api.md` documents discovery/leases; `docs/testing.md` covers test boundaries and real-infrastructure setup.
## Commands and verification
- Go version comes from `go.mod` (currently 1.26.0). Make installs development tools into `bin/`.
- After Go changes: `make lint-fix`, then `make test`. The latter regenerates manifests/DeepCopy, formats, vets, sets up envtest, then runs non-e2e tests with `-race` and writes `cover.out`.
- Fast checks: `go test -short -race ./...` skips envtest. Focused package/test: `go test -race ./internal/lease/` or `go test -race -run TestAcquire ./internal/lease/`.
- Envtest needs local API-server/etcd binaries; use `make setup-envtest` before targeted runs. For the controller suite with the current Kubernetes minor:
```sh
KUBEBUILDER_ASSETS="$(./bin/setup-envtest use 1.36 --bin-dir "$PWD/bin" -p path)" go test -race ./internal/controller/ -run TestControllers -ginkgo.focus='<spec regex>'
```
The Makefile derives the envtest **minor** from `go.mod`; do not replace it with a dependency patch version that may have no published binary bundle.
- Tests are mostly stdlib `testing`; controller envtest and e2e use Ginkgo/Gomega. Fake clients do not execute CEL or schema defaults: test those with envtest, and use the API's `*OrDefault` helpers in unit tests. Envtest has no kubelet, so it cannot prove Squid starts or serves traffic.
- `make lint` / `make lint-fix` builds a custom golangci-lint with the `logcheck` plugin from `.custom-gcl.yml`; a stock global binary is insufficient. `.golangci.yml` forbids `sort` (use `slices`). Logging uses controller-runtime/logr, with Kubernetes-style messages.
- Real-host tests are opt-in: `make test-integration-kvm` / `make test-integration-kvm2` require the corresponding `KVM_TEST_*` / `KVM2_TEST_*` variables from `docs/testing.md`. After provider refactors, compile-check these with `go vet -tags=integration ./...`.
- E2E requires Docker, kind, kubectl, and an isolated cluster. `make test-e2e` uses `KIND_CLUSTER=egress-proxies-operator-test-e2e`; ensure kubectl targets that cluster, especially if it already exists. On failure, run `make cleanup-test-e2e` explicitly. Compile-check with `go vet -tags=e2e ./...`. Tracing specs additionally require both `TEMPO_URL` and `OTLP_ENDPOINT`.
- `.gitea/workflows/build.yaml` runs only on tags/manual dispatch; its gate is `go vet ./...`, `go build ./...`, `go test -short ./...`. It does not replace local lint, race, or envtest verification.
## Code generation
- After API types or Kubebuilder markers change, run `make manifests generate`. Edit source markers, never generated `config/crd/bases/*.yaml`, `config/rbac/role.yaml`, or `**/zz_generated.*.go`.
- Keep `+kubebuilder:rbac` markers in standalone package-level comment blocks, outside function/type documentation; otherwise controller-gen can silently ignore them. Inspect `git diff config/rbac/role.yaml` after generation.
- Preserve `+kubebuilder:scaffold:*` markers and the single-group layout. Use `kubebuilder create api` / `kubebuilder create webhook` for new scaffolding; `PROJECT` is CLI-managed.
## Behaviour and operational gotchas
- Managed proxies are replace-on-change: `internal/controller/spechash.go` hashes placement, resolved cloud-init content, and effective port. Do not hash all of `ProxySpec`: attributes, lease limits, and health settings must not churn VMs.
- Cloud-init Secrets must carry `crawl.example.com/cloud-init: "true"`; the manager's Secret cache excludes unlabelled Secrets. Rotating labelled Secret content triggers VM replacement.
- Proxy health checks fetch **through** the proxy. The health engine supplies snapshots/events and the reconciler writes status; status-affecting transitions drive writes, so `lastHealthCheckTime` is not a last-probe heartbeat.
- Leases/cooldowns are in-memory and disappear on restart. Discovery is not leader-elected; the deployment uses one replica. Adding replicas alone does not provide shared lease capacity.
- Use `make run-dev` locally: plain `make run` omits the required `--providers-config`. Host-side probes generally cannot route to kind pod IPs; deploy in-cluster using the README quickstart to verify kubernetes-provider health.
- `make deploy IMG=...` and `make build-installer IMG=...` modify `config/manager/kustomization.yaml` in place. Check that diff before including it in a change.
- Namespace-restricted mode disables orphan GC by default. `--gc-allow-namespaced` bypasses that guard and can classify instances owned outside the visible namespace as orphans.
+207
View File
@@ -1,5 +1,212 @@
# Changelog
## 2026-09-24 10:28 UTC — proxmox provider: reviewed, fixed, proven on a real Proxmox VE 9.2 host
The `proxmox` provider (VMs cloned from a template on Proxmox VE through its REST API)
finished its last phase: a two-pass review, a real-host run on pv1-1, and captures of the
three API answers the design had only guessed.
- Delete now removes the seed ISO **before** destroying the VM. Before, a seed delete that
failed after the destroy was never retried (the next Get is NotFound), so the ISO leaked.
A 403 for `Datastore.Allocate` now fails Delete on every path instead of being skipped.
- Create skips a probe VMID held by someone else's VM at once, instead of waiting up to 10
minutes on that VM's lock on every retry.
- The captures corrected two assumptions: a missing ISO storage is a 400 (the health check
now says `StorageNotFound`, not `APIError`), and a hibernated VM can be destroyed (Delete
destroys ours instead of latching the Proxy).
- `TestProxmox_lifecycle` and `TestProxmoxCheck_realHost` passed on PVE 9.2.20: Create 6.2 s,
an address 50 s later. A kind pass took a sample Proxy to Ready and back.
- Review record: `docs/reviews/2026-09-23-proxmox-provider-review/REPORT.md`. Timings:
`docs/testing.md`. Code: `internal/provider/proxmox/{delete,create,check,errors}.go`.
## 2026-09-18 07:52 UTC — docs/operating.md, starting with the libvirt-RPC log query
New home for day-to-day helpers against a running operator, seeded with the `jq` query
that pulls timestamp + rpc out of the manager's JSON logs (both libvirt providers log the
same `"libvirt call"` msg, so one filter covers `kvm` and `kvm2`), plus the `fromjson?`
variant for streams carrying non-JSON lines. Linked from the README's doc index.
## 2026-09-18 07:44 UTC — a kvm2 crawl-pool sample
Until now the only kvm2 manifest was the one-VM `config/samples/proxy_kvm2.yaml`, which
left the multi-proxy shape — the one a crawler actually leases from — undocumented.
- Added `config/samples/proxy_kvm2_crawl_pool.yaml`: five Managed Proxies on the `kvm2-home`
entry, four sharing one labelled cloud-init Secret and one inline (it runs squid on 8080,
so it cannot share a Secret that pins `http_port 3128`). Covers the `tier` attribute the
discovery API selects on, a `medium`/`ubuntu-24.04` member with a raised `maxLeases`, and
a member with a tightened `healthCheck`.
- Kept out of `config/samples/kustomization.yaml` on purpose so `apply -k` doesn't boot a
whole pool; the exclusion is commented there next to the existing `providers-config.yaml`
one. Validated with `kubectl apply --dry-run=server`.
## 2026-09-17 20:36 UTC — kvm2: the four high findings from the code review, and its first run on a real hypervisor
A scoped review of the kvm2 provider (`review/REPORT.md`: 52 findings, 4 high, 20 medium,
28 low) produced four fixes, one commit each, then the provider was deployed in-cluster and
validated against beelink — the first time any of this code had run against real libvirt.
- **H1 — a stalled write no longer delays a connection teardown.** `sshSocket.Close` ran the
blocking close before the unblocking one, so a transfer stuck in a TCP write pinned the
teardown for as long as the kernel retransmitted (~15 min). `link.markDead` compounded it by
killing inside a `sync.Once` before signalling dead, which made even cancelling the reconcile
useless. With `MaxConcurrentReconciles: 3`, one silent host could stall every Proxy on every
provider. `internal/provider/kvm2/{ssh,backend}.go`.
- **H2 — a source error no longer reaches go-libvirt's stream sender.** A failing HTTP body
deadlocked `StorageVolUpload` outright and leaked two goroutines plus 4 MiB per occurrence,
against a 128Mi pod limit. The defect is upstream (`requestStream` never drains an unbuffered
`outErr`); the workaround wraps the reader so it can only ever end the stream cleanly, and
moves the transfer deadline onto the HTTP source so a stalled mirror unwinds instead of
leaking. A daemon-side failure mid-upload still leaks — documented in *Known gaps* with the
two-character upstream patch. `internal/provider/kvm2/{backend,image}.go`.
- **H3 — an unreadable address is no longer reported as a missing one.** Any error from both
address sources read as "no address yet", so a pooled connection killed by an unrelated
reconcile demoted a healthy proxy to Provisioning, cleared its IP and made the health engine
drop its counters. One source failing stays "no address yet"; every source failing, or a dead
connection, is now Transient. `internal/provider/kvm2/get.go`.
- **H4 — the base image cache is no longer shared between provider entries.** kvm2 built
byte-identical volume names to the `kvm` provider in the same pool, so two entries could share
one file, a concurrent fetch could boot a guest from a half-written base, and a `sha256` edit
deleted the base under live overlays. Names now carry the entry
(`base-<entry>-<key>.<format>`), and a digest mismatch is refused rather than resolved by
deleting. `internal/provider/kvm2/{volume,image}.go`, `internal/provider/config_kvm.go`.
- **klog and `net/http` errors now join the structured log stream.** client-go's leader election
wrote `I0917 ...` lines straight to stderr because nothing routed klog into logr, and the
discovery server's `http.Server` had no `ErrorLog`. The deployment also gained
`--zap-encoder=json` and `--zap-time-encoding=rfc3339`, so `kubectl logs | jq` works.
`cmd/main.go`, `internal/discovery/server.go`, `config/manager/manager.yaml`.
- **The `kvm2-home` provider entry is enabled** in `config/manager/providers_config.yaml`.
Validated end to end: health check Ready, a 433 MB image cached in 8.3 s under the new name
beside kvm's untouched one, a second Proxy reusing the cache, Squid answering a real CONNECT
probe, and Delete leaving the base image while removing the domain and its volumes.
## 2026-09-16 11:46 UTC — Provider blueprint split into contract, spec template and appendix
- **`docs/provider-contract.md`** is the generic provider specification: MUST/SHOULD/MAY
rules with the caller behind each one, 19 behaviour-profile axes with stable codes
(where providers may differ, such as blocking vs submit-and-return Create, or a
backend with no provisioning state), error taxonomy, credential hygiene, testing
obligations, the adding-a-provider checklist and the contract's known limits. It
names no existing provider.
- **`docs/provider-spec-template.md`** is the fill-in questionnaire a new provider's
design answers before implementation; entry numbers match the contract's profile
table.
- **`docs/providers-existing.md`** maps kubernetes, gcp and kvm to every profile and
keeps their per-provider reference notes, shared gaps and proposed features.
- **`docs/providers.md`** is now an index; README, CLAUDE.md and architecture.md point
at the new files. This supersedes the single-file blueprint written earlier on the
same branch.
- Two rules the callers enforce that were not written down before: orphan GC sweeps
providers sequentially with no deadline, so a hung `ListByTag` stalls all collection;
and the reconciler deletes an instance a backend reports as Stopped before start.
## 2026-09-16 08:45 UTC — Provider health checks: ProviderHealth CRD, per-provider checks, gauges
- **Each configured provider is now checked on a schedule** and reported in a
cluster-scoped `ProviderHealth` object (`kubectl get ph`), one per provider, with a
`Ready` condition, a reason, and per-target verdicts.
- **Checks:** kubernetes lists one object from the API server; GCP fetches a single
instance page; KVM checks SSH/libvirt reachability and the storage pool on every
host, in parallel. Failures map to fixed reasons (`Unreachable`,
`SSHHandshakeFailed`, `PoolNotFound`, `Timeout`, …).
- **The KVM check can't hurt reconciles:** it uses `hostPool.probe`, which gives up on
a slow RPC without dropping the shared connection, and waiting for a host slot can
be interrupted.
- **Metrics:** `proxy_operator_provider_target_up`, `_check_duration_seconds` and
`_check_timestamp_seconds`. The flags are `--provider-check-interval` and
`--provider-check-timeout` (defaults to `min(15s, interval)`).
- Validated with `make test`, `make test-e2e` and the real-hypervisor KVM run against
beelink. `make lint` is blocked by a Go 1.27 toolchain/staticcheck panic that has
nothing to do with this change.
- Key files: `api/v1alpha1/providerhealth_types.go`, `internal/provider/check.go`,
`internal/provider/kvm/`, `internal/providerhealth/`, `docs/architecture.md`.
## 2026-09-10 22:58 UTC — Code-review fixes on the KVM branch: deletion, validation, lazy SSH, guest agent
Five findings from a review of `feat/kvm-provider`, all fixed on the branch.
- **A Proxy can always finish deleting.** The delete path used to drop the finalizer
only when `Get` answered not-found, so a permanent error there retried forever —
a KVM host removed from the config was enough. It now calls `Delete` anyway, since
that is idempotent and can finish where `Get` cannot, and
`crawl.example.com/skip-cleanup: "true"` abandons the instance for whatever stays
stuck. In the KVM provider, deleting over an unconfigured host is a no-op success;
`Get` still fails permanently there so a typo cannot look like a missing VM.
- **The guest agent ships as a vendor-data script**, superseding the cloud-config
merge in the entry below. Merging could never be made safe: cloud-init drops
vendor-data keys the user-data defines, and the strategy cannot be overridden from
either document. The Proxy's cloud-init now reaches the seed byte for byte in any
format — Jinja, MIME, gzip, scripts — where the round-trip previously re-typed
unquoted scalars (`version: 1.10` arrived as `1.1`), dropped comments and reordered
keys. New `installGuestAgent` setting, default on.
- **A missing `kvm-ssh` Secret no longer crash-loops the manager.** The SSH material
is read on first use and cached, so only KVM Proxies fail and they recover when the
Secret appears. A missing file is transient, an unparseable key permanent, and
startup still logs a warning.
- **Host names and image keys are validated.** Either can end up in a providerID or a
volume file name, where a slash made the VM unaddressable for good.
- Verified end to end: unit suite, the integration test against a real hypervisor
(22 s, agent reporting the IP), both in-cluster Proxies Ready in 2 min with 204
through squid, the guest showing `qemu-guest-agent` active with its cloud-config
comments intact, and the manager staying up with the Secret pointed away.
- Key files: `internal/provider/kvm/{seed.go,conn.go,kvm.go}`,
`internal/controller/proxy_controller.go`, `internal/provider/config_kvm.go`,
`api/v1alpha1/proxy_types.go`.
## 2026-09-10 21:04 UTC — KVM provider passes in-cluster; guest agent merged into cloud-config
- Two KVM Proxies with real cloud-configs reach `Ready` from inside the kind cluster
(2 min from apply, IP via the guest agent, 204 through squid from the LAN).
- The `qemu-guest-agent` bootstrap is now merged into each Proxy's cloud-config
(`packages` + `runcmd`) instead of shipped as vendor-data: cloud-init's default
merger drops vendor-data keys the user-data also defines, so any Proxy with its own
`packages:` never got the agent and stayed `Provisioning` forever. `vendorData` is
now plain extra vendor-data with no default; non-cloud-config user-data (scripts,
`#include`) still gets the bootstrap as vendor-data. A cloud-config that is not a
YAML mapping fails the Proxy permanently instead of booting without its config.
- The manager pod mounts a 64 MiB `emptyDir` on `/tmp`: the seed ISO writer stages
files on disk and the root filesystem is read-only.
- Errors already classified with a provider sentinel no longer drop the cached
libvirt SSH connection; only libvirt and transport errors do.
- Key files: `internal/provider/kvm/{seed.go,conn.go,errors.go}`,
`config/manager/manager.yaml`, `docs/kvm-provider.md`.
## 2026-09-10 10:07 UTC — KVM provider validated on a real hypervisor; UEFI default; libvirt call tracing
- `make test-integration-kvm` passes end to end against a libvirt 11.6 / QEMU 10.1
host (Debian 13 image, UEFI, guest agent reporting the IP after ~20 s). Four
defects found only on the real host, all fixed on the branch:
- domain disks are now `type="file"` with resolved paths: pool-volume references
get no AppArmor rule from `virt-aa-helper`, so QEMU was denied the disks;
- domain metadata is parsed by local name: libvirt returns the element without
its namespace declaration;
- guests boot **UEFI by default** (`images.<key>.firmware: uefi|bios`, OVMF now a
host prerequisite): Debian 13's image hangs in GRUB under SeaBIOS on a headless
VM with a serial console (Debian bug #1111240, open); bookworm is unaffected;
- the image cache's raw-upload path is confirmed to produce a byte-identical file.
- Every libvirt RPC is logged at `--zap-log-level=debug` as one `libvirt call` line
(`rpc=virDomainCreate`, identifiers, duration, `libvirtError` on failure); level 2
adds XML, metadata and addresses; cloud-init is never logged. The integration test
shows the trace in `-v` output (`KVM_TEST_LOG_LEVEL`, default 1).
- Key files: `internal/provider/kvm/{domainxml.go,metadata.go,trace.go,conn.go}`,
`internal/provider/config_kvm.go`, `docs/kvm-provider.md`.
## 2026-09-08 11:00 UTC — KVM/libvirt provider
- New `kvm` provider type: Managed proxies as VMs on your own libvirt/KVM hosts. Each
host is reached over SSH with the libvirt unix socket forwarded through the
connection (pure Go, nothing to expose on the hypervisors beyond sshd); one provider
instance can span several hosts, selected by `placement.zone`.
- Base images (`images:` map or a direct URL in `placement.image`) are downloaded by the
operator into each host's storage pool on first use, verified by sha256 when configured,
and cached there. Per-VM disks are qcow2 overlays; cloud-init arrives on a NoCloud seed
ISO built in-process. GC identity lives in the domain's `<metadata>` element.
- Wiring: `"kvm": kvm.New` in `cmd/main.go`; optional `kvm-ssh` Secret mounted at
`/etc/proxy-operator/kvm`; samples `config/samples/proxy_kvm.yaml` and a `kvm-home`
block in `providers-config.yaml`; `docs/kvm-provider.md`; opt-in integration test
(`make test-integration-kvm`) against a real hypervisor.
- Not validated on a real host at the time of this entry; see 2026-09-10 above.
## 2026-08-10 09:34 CEST — kind e2e verified; fix Squid OOM in containers; quickstart goes in-cluster
- Full end-to-end pass on a throwaway kind cluster: Squid pod Ready with a real CONNECT
+19 -8
View File
@@ -25,10 +25,16 @@ entrypoint yet, so there's nothing to run locally.
## Architecture
Not yet built. TODO: once the controller-runtime manager and reconcile loop exist,
document the data-flow diagram and package/role table here, following the standard
reconcile-loop shape: fetch → resolve → list related → compute desired state → merge
→ patch only on diff.
Documented in [docs/architecture.md](docs/architecture.md). It holds the component
table, the reconcile flow, and one section per runnable: health engine, discovery and
lease API, orphan GC, metrics, tracing, and provider health checks. The generic
provider contract (MUST/SHOULD rules, behaviour profiles, checklist for adding a
provider, known limits) is in [docs/provider-contract.md](docs/provider-contract.md);
a new provider starts from [docs/provider-spec-template.md](docs/provider-spec-template.md);
how the existing providers meet the contract, with their gaps, is in
[docs/providers-existing.md](docs/providers-existing.md); [docs/providers.md](docs/providers.md)
indexes them. Provider-specific detail is in `docs/kvm-provider.md` and the GCP
docs; the test layout is in `docs/testing.md`.
## Conventions
@@ -214,7 +220,12 @@ A lightweight `check` job (`go vet` / `go build` / `go test -short`) gates the b
## Gotchas
- This repo currently contains only `go.mod` (module
`gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator`) — no controller code,
`main.go`, Dockerfile, k8s manifests, Makefile, or CI config exist yet. Nothing in
this file describes working software; it's the convention scaffold to build against.
- The "What this is" and "Commands" sections above predate the code and are stale.
The Makefile targets (`make test`, `make manifests generate`, `make test-e2e`,
`make test-integration-kvm`) and `docs/testing.md` are authoritative.
- `+kubebuilder:rbac` markers are package-level. Keep them in their own comment block,
never inside a type's or function's doc comment: controller-gen silently ignores
them there. After `make manifests`, check `git diff config/rbac/role.yaml`.
- `make test-integration-proxmox` runs the proxmox provider's integration tests against
a real Proxmox VE host. They skip unless `PROXMOX_TEST_URL`, `_TOKEN_FILE`, `_NODE`,
`_TEMPLATE_VMID` and `_ISO_STORAGE` are set; see docs/testing.md for the full env block.
+45 -1
View File
@@ -66,6 +66,18 @@ vet: ## Run go vet against code.
test: manifests generate fmt vet setup-envtest ## Run tests.
KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" go test -race $$(go list ./... | grep -v /e2e) -coverprofile cover.out
.PHONY: test-integration-kvm
test-integration-kvm: ## Run the KVM provider test against a real hypervisor. Needs KVM_TEST_HOST/USER/SSH_KEY/BRIDGE/IMAGE_URL; see docs/testing.md.
go test -race -tags=integration -count=1 -v -timeout 40m -run TestKVM ./internal/provider/kvm/
.PHONY: test-integration-kvm2
test-integration-kvm2: ## Run the kvm2 provider test against a real hypervisor. Needs KVM2_TEST_HOST/USER/SSH_KEY/BRIDGE/IMAGE_URL; see docs/testing.md.
go test -race -tags=integration -count=1 -v -timeout 40m -run TestKVM2 ./internal/provider/kvm2/
.PHONY: test-integration-proxmox
test-integration-proxmox: ## Run the proxmox provider test against a real Proxmox VE host. Needs PROXMOX_TEST_URL/TOKEN_FILE/NODE/TEMPLATE_VMID/ISO_STORAGE; see docs/testing.md.
go test -race -tags=integration -count=1 -v -timeout 30m -run TestProxmox ./internal/provider/proxmox/
# TODO(user): To use a different vendor for e2e tests, modify the setup under 'tests/e2e'.
# The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally.
# kubectl kuberc is disabled by default for test isolation; enable with:
@@ -90,7 +102,8 @@ setup-test-e2e: ## Set up a Kind cluster for e2e tests if it does not exist
.PHONY: test-e2e
test-e2e: setup-test-e2e manifests generate fmt vet ## Run the e2e tests. Expected an isolated environment using Kind.
KIND=$(KIND) KIND_CLUSTER=$(KIND_CLUSTER) go test -tags=e2e ./test/e2e/ -v -ginkgo.v
KIND=$(KIND) KIND_CLUSTER=$(KIND_CLUSTER) TEMPO_URL=$(TEMPO_URL) OTLP_ENDPOINT=$(OTLP_ENDPOINT) \
go test -tags=e2e ./test/e2e/ -v -ginkgo.v -timeout 30m
$(MAKE) cleanup-test-e2e
.PHONY: cleanup-test-e2e
@@ -173,6 +186,37 @@ uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified
@out="$$( "$(KUSTOMIZE)" build config/crd 2>/dev/null || true )"; \
if [ -n "$$out" ]; then echo "$$out" | "$(KUBECTL)" delete --ignore-not-found=$(ignore-not-found) -f -; else echo "No CRDs to delete; skipping."; fi
KVM_SSH_KEY ?= $(HOME)/.ssh/proxy-operator_ed25519
KVM_KNOWN_HOSTS ?= $(HOME)/.ssh/kvm_known_hosts
KVM_SSH_NAMESPACE ?= egress-proxies-operator-system
.PHONY: kvm-ssh-secret
kvm-ssh-secret: ## Create or update the kvm-ssh Secret from KVM_SSH_KEY (private key) and KVM_KNOWN_HOSTS.
@test -f "$(KVM_SSH_KEY)" || { echo "KVM_SSH_KEY=$(KVM_SSH_KEY) not found"; exit 1; }
@test -f "$(KVM_KNOWN_HOSTS)" || { echo "KVM_KNOWN_HOSTS=$(KVM_KNOWN_HOSTS) not found"; exit 1; }
@grep -q 'PRIVATE KEY' "$(KVM_SSH_KEY)" || { echo "KVM_SSH_KEY=$(KVM_SSH_KEY) is not a private key (did you pass the .pub?)"; exit 1; }
"$(KUBECTL)" -n $(KVM_SSH_NAMESPACE) create secret generic kvm-ssh \
--from-file=id_ed25519="$(KVM_SSH_KEY)" \
--from-file=known_hosts="$(KVM_KNOWN_HOSTS)" \
--dry-run=client -o yaml | "$(KUBECTL)" apply -f -
PROXMOX_TOKEN_FILE ?= $(HOME)/.config/proxmox-token
PROXMOX_CA_FILE ?= $(HOME)/.config/proxmox-ca.pem
PROXMOX_NAMESPACE ?= egress-proxies-operator-system
.PHONY: proxmox-token-secret
proxmox-token-secret: ## Create or update the proxmox-token Secret from PROXMOX_TOKEN_FILE (user@realm!tokenid=secret) and PROXMOX_CA_FILE.
@test -f "$(PROXMOX_TOKEN_FILE)" || { echo "PROXMOX_TOKEN_FILE=$(PROXMOX_TOKEN_FILE) not found"; exit 1; }
@test -f "$(PROXMOX_CA_FILE)" || { echo "PROXMOX_CA_FILE=$(PROXMOX_CA_FILE) not found"; exit 1; }
@test "$$(grep -cv '^[[:space:]]*$$' "$(PROXMOX_TOKEN_FILE)")" = 1 && \
grep -qxE '[[:space:]]*[^@[:space:]]+@[^![:space:]]+![^=[:space:]]+=[^[:space:]]+[[:space:]]*' "$(PROXMOX_TOKEN_FILE)" || \
{ echo "PROXMOX_TOKEN_FILE=$(PROXMOX_TOKEN_FILE) is not one line of user@realm!tokenid=secret"; exit 1; }
@grep -q 'BEGIN CERTIFICATE' "$(PROXMOX_CA_FILE)" || { echo "PROXMOX_CA_FILE=$(PROXMOX_CA_FILE) holds no PEM certificate"; exit 1; }
"$(KUBECTL)" -n $(PROXMOX_NAMESPACE) create secret generic proxmox-token \
--from-file=token="$(PROXMOX_TOKEN_FILE)" \
--from-file=ca.pem="$(PROXMOX_CA_FILE)" \
--dry-run=client -o yaml | "$(KUBECTL)" apply -f -
.PHONY: deploy
deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config.
cd config/manager && "$(KUSTOMIZE)" edit set image controller=${IMG}
+7
View File
@@ -18,4 +18,11 @@ resources:
kind: Proxy
path: gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1
version: v1alpha1
- api:
crdVersion: v1
domain: example.com
group: crawl
kind: ProviderHealth
path: gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1
version: v1alpha1
version: "3"
+98 -6
View File
@@ -15,9 +15,10 @@ itself**, and hands out to crawler clients via an HTTP list/lease API.
cattle**: any meaningful spec change (placement, cloud-init, port)
deletes and recreates the VM — never in-place mutation.
- **Providers** behind one minimal interface: `kubernetes` (a real Squid
pod in this cluster — local dev/CI) and `gcp` (Compute Engine VMs with
ephemeral external IPs — the real egress fleet). Config is a YAML file
(`--providers-config`) with named instances (`gcp-eu`, `gcp-us`, ...).
pod in this cluster — local dev/CI), `gcp` (Compute Engine VMs with
ephemeral external IPs — the real egress fleet) and `kvm` (VMs on your
own libvirt hypervisors, reached over SSH). Config is a YAML file
(`--providers-config`) with named instances (`gcp-eu`, `kvm-home`, ...).
- **Health engine** probes every proxy by fetching a URL *through* it (a
real CONNECT tunnel — a proxy that accepts TCP but can't egress goes
Unhealthy), with threshold logic and transition-only status writes.
@@ -59,7 +60,8 @@ kubectl get px -w
# proxy-kubernetes-sample Managed kubernetes Ready 10.244.x.x True
```
Once it's `Ready`, port-forward the discovery API and use it:
Once it's `Ready`, port-forward the discovery API and use it (full
reference with schemas and error codes: [docs/api.md](docs/api.md)):
```sh
kubectl -n egress-proxies-operator-system port-forward \
@@ -102,7 +104,8 @@ make deploy IMG=<registry>/egress-proxies-operator:dev
- Provider config comes from the `providers-config` ConfigMap
([config/manager/providers_config.yaml](config/manager/providers_config.yaml));
the default ships only the kubernetes provider.
the default ships the kubernetes provider plus a `kvm-home` entry that needs
the `kvm-ssh` Secret (without it the manager starts and warns).
- The discovery API is exposed by the
`controller-manager-discovery-service` Service on port 8090.
- Auth: create the token Secret, or the API serves **unauthenticated**
@@ -130,11 +133,98 @@ make deploy IMG=<registry>/egress-proxies-operator:dev
which also installs Squid via cloud-init). A missing field fails the
Proxy with a message naming it.
## KVM setup
Managed proxies as VMs on libvirt/KVM hosts you own. Full guide:
[docs/kvm-provider.md](docs/kvm-provider.md).
1. Each host needs `libvirtd`, an SSH user in the `libvirt` group, a
bridge to a network with DHCP, and a storage pool.
2. Create the `kvm-ssh` Secret from the operator's **private** key and a
`known_hosts` with each host's key; the manager mounts it at
`/etc/proxy-operator/kvm`:
```sh
ssh-keyscan -H <host> >> ~/.ssh/kvm_known_hosts
kubectl -n egress-proxies-operator-system create secret generic kvm-ssh \
--from-file=id_ed25519=$HOME/.ssh/proxy-operator_ed25519 \
--from-file=known_hosts=$HOME/.ssh/kvm_known_hosts
# or: make kvm-ssh-secret (same paths by default; KVM_SSH_KEY / KVM_KNOWN_HOSTS to override)
```
3. Add a `kvm` entry to the providers config with `images`,
`machineTypes` and `hosts` (see
[config/samples/providers-config.yaml](config/samples/providers-config.yaml)).
Base images are downloaded into each host's pool on first use.
4. A Proxy names a host (`placement.zone`, optional with one host), a
size (`placement.machineType`) and an image (`placement.image`) — see
[config/samples/proxy_kvm.yaml](config/samples/proxy_kvm.yaml).
The `kvm2` provider uses the same Secret and the same block shape (`type: kvm2`,
block `kvm2:`), so the steps above apply to it unchanged; its guide is
[docs/kvm2-provider.md](docs/kvm2-provider.md).
Cloud-init from a Secret: the Secret **must** carry the label
`crawl.example.com/cloud-init: "true"` — the operator's cache only holds
labelled Secrets, so an unlabelled one is invisible (the Proxy reports
`CloudInitError`). Rotating the Secret's content triggers VM replacement.
Stuck deletion: a Proxy stays `Terminating` while the operator keeps failing
to clean its instance up — credentials revoked, a provider or a KVM host
dropped from the config. Annotate it with
`crawl.example.com/skip-cleanup: "true"` to abandon the instance and release
the object. Whatever is still running then has to be removed by hand.
## Providers
`Managed` proxies are provisioned by a provider — a small compute backend
behind one minimal interface. Providers are configured in the
`--providers-config` YAML file as **named instances**: `spec.provider` on a
Proxy refers to an entry's `name`, not its `type`, so `gcp-eu` and `gcp-us`
can be two differently-configured instances of the same `gcp` type (see
[config/samples/providers-config.yaml](config/samples/providers-config.yaml)).
### Implemented providers
| Type | Creates | Per-instance config | Notes |
| --- | --- | --- | --- |
| `kubernetes` | A real Squid pod (`ubuntu/squid:6.6-24.04_edge` by default) in the same cluster the operator runs in | `image` (optional) | Needs no cloud account — local dev, CI, and the kind quickstart. Pods share the cluster's egress IP, so it exercises the full lifecycle but not distinct egress paths. |
| `gcp` | A Compute Engine VM with an ephemeral external IP | `project` (required), `network`, `networkTag`, `diskSizeGb` | The real egress fleet. Deliberately uses only four API calls (Insert / Get / Delete / AggregatedList), all fire-and-forget: `Create` returns as soon as the operation is submitted and the reconciler discovers progress by polling `Get`. Auth is Application Default Credentials — workload identity in-cluster, `gcloud` ADC locally; no key-file plumbing. |
| `kvm` | A libvirt VM on one of your own hypervisors, reached over SSH | `sshKeyFile`, `knownHostsFile` (unless `insecureIgnoreHostKey`), `machineTypes`, `hosts` (required); `images`, `diskSizeGb`, `vendorData`, `installGuestAgent` | Home-lab or on-prem egress. The base image is downloaded into each host's pool on first use; `Create` waits for the VM to start. See [docs/kvm-provider.md](docs/kvm-provider.md). |
| `kvm2` | The same: a libvirt VM on one of your own hypervisors, reached over SSH | the same block shape as `kvm`, under `kvm2:` | The second-generation libvirt provider, built from the provider contract. Adds a child span per libvirt RPC, a pool-space precheck reported as quota (the reconciler waits instead of failing), SSH credentials re-read on every dial, and image downloads and health checks on connections of their own. It does not adopt `kvm`'s VMs. See [docs/kvm2-provider.md](docs/kvm2-provider.md). |
| `proxmox` | A VM on a Proxmox VE cluster: a full clone of a template you prepare, reached through the Proxmox REST API with an API token | `url`, `tokenFile`, `nodes`, `isoStorage`, `templates`, `machineTypes` (required); `caFile` or `insecureSkipTLSVerify`, `storage`, `bridge`, `vlanTag`, `vmidRange`, `requestTimeoutSeconds`, `vendorData`, `installGuestAgent` | Home-lab or on-prem egress without SSH. Cloud-init goes on a seed ISO uploaded through the API; the address comes from the QEMU guest agent, so the proxy network needs a route to a package mirror unless the template ships the agent. `Create` waits for the VM to run. The token is read on every request, from the optional `proxmox-token` Secret (`make proxmox-token-secret`). Built from the provider contract and run end to end against Proxmox VE 9.2.20. See [docs/proxmox-provider.md](docs/proxmox-provider.md). |
### Adding a provider
The generic contract is [docs/provider-contract.md](docs/provider-contract.md),
the questionnaire a new provider's design fills in is
[docs/provider-spec-template.md](docs/provider-spec-template.md), and how the
existing providers meet the contract is
[docs/providers-existing.md](docs/providers-existing.md);
[docs/providers.md](docs/providers.md) indexes them. Start there: several rules (a
self-contained `providerID`, `Get` returning `ErrNotFound` only when the
instance is really gone, `CreatedAt` read back for orphan GC) are enforced by
callers, not by the interface.
## Tracing
OpenTelemetry tracing is built in but **off by default** — the operator
only exports spans when pointed at an OTLP receiver:
```bash
# In config/manager/manager.yaml (commented-out block is already there):
# OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger-collector.observability:4318
# Local dev: print spans to stdout instead
OTEL_TRACES_EXPORTER=console go run ./cmd --providers-config hack/providers-dev.yaml
```
Every reconcile, discovery API request, and GC sweep becomes a trace, with
child spans for provider and Kubernetes API calls; log lines inside a traced
operation carry matching `traceID`/`spanID` fields (point Grafana/Loki
derived fields at `traceID`). Sampling and endpoints follow the standard
`OTEL_*` env vars; `--trace-health-probes` additionally traces each health
probe (high volume). Details in `docs/architecture.md` §10.
## Caveats — read these two
**Changing a proxy changes its IP.** Proxies are immutable cattle: editing
@@ -221,4 +311,6 @@ and the manual kind verification procedure — is in
Project layout, reconcile-loop diagrams, and the decision log are in
[docs/architecture.md](docs/architecture.md); the build history is in
[docs/plans-executions/](docs/plans-executions/).
[docs/plans-executions/](docs/plans-executions/). Day-to-day helpers for a
running operator — log queries and one-off inspections — are collected in
[docs/operating.md](docs/operating.md).
+127
View File
@@ -0,0 +1,127 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// ConditionReady summarizes a ProviderHealth's targets: True when every
// target passed its last check.
const ConditionReady = "Ready"
// ProviderTarget is the latest check verdict for one thing a provider
// depends on: a KVM hypervisor, the Kubernetes API server, a GCP project.
type ProviderTarget struct {
// name identifies the target within its provider: the KVM host name,
// "apiserver", or the GCP project.
// +kubebuilder:validation:MinLength=1
// +required
Name string `json:"name"`
// address is where the check connected to.
// +optional
Address string `json:"address,omitempty"`
// ready is true when the last check succeeded.
// +required
Ready bool `json:"ready"`
// reason is a CamelCase verdict such as Reachable, Unreachable,
// Forbidden, SSHHandshakeFailed or PoolNotActive.
// +optional
Reason string `json:"reason,omitempty"`
// message is human-readable detail, usually the error of a failed check.
// +kubebuilder:validation:MaxLength=1024
// +optional
Message string `json:"message,omitempty"`
// latencyMillis is how long the last check took.
// +optional
LatencyMillis int64 `json:"latencyMillis,omitempty"`
// lastTransitionTime is when ready last changed.
// +required
LastTransitionTime metav1.Time `json:"lastTransitionTime"`
// details are provider-specific facts from the last check, e.g. the
// libvirt version and free storage-pool space of a KVM host.
// +optional
Details map[string]string `json:"details,omitempty"`
}
// ProviderHealthStatus is the result of the latest check of one provider.
type ProviderHealthStatus struct {
// type is the provider's type from the providers config: kubernetes,
// gcp, kvm, kvm2 or proxmox.
// +optional
Type string `json:"type,omitempty"`
// conditions hold Ready, which summarizes the targets.
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
// targets hold one verdict per checked target.
// +listType=map
// +listMapKey=name
// +optional
Targets []ProviderTarget `json:"targets,omitempty"`
// targetsReady counts the ready targets as "ready/total".
// +optional
TargetsReady string `json:"targetsReady,omitempty"`
// lastCheckTime is when the latest check finished.
// +optional
LastCheckTime *metav1.Time `json:"lastCheckTime,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster,shortName=ph
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.status.type`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
// +kubebuilder:printcolumn:name="Targets",type=string,JSONPath=`.status.targetsReady`
// +kubebuilder:printcolumn:name="Last Check",type=date,JSONPath=`.status.lastCheckTime`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
// ProviderHealth reports the periodic health check of one configured
// provider. It is named after the provider, and the operator creates,
// updates and deletes it itself; there is no spec to edit.
type ProviderHealth struct {
metav1.TypeMeta `json:",inline"`
// metadata is a standard object metadata
// +optional
metav1.ObjectMeta `json:"metadata,omitzero"`
// status is the result of the latest check
// +optional
Status ProviderHealthStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
// ProviderHealthList contains a list of ProviderHealth
type ProviderHealthList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []ProviderHealth `json:"items"`
}
+10 -1
View File
@@ -62,6 +62,15 @@ const (
// must be replaced.
AnnotationSpecHash = "crawl.example.com/spec-hash"
// AnnotationSkipCleanup abandons the provider-side instance: set it to
// "true" and deletion removes the finalizer without asking the provider
// to clean anything up. It is the escape hatch for a cleanup that cannot
// succeed — a provider dropped from the config, credentials revoked, a
// backend that answers permanently — where the object would otherwise
// stay in Terminating forever. The instance is left running and becomes
// the administrator's to remove.
AnnotationSkipCleanup = "crawl.example.com/skip-cleanup"
// LabelCloudInit must be set (to "true") on every Secret referenced by
// spec.cloudInit.secretRef: the manager's cache only holds Secrets
// carrying this label, so an unlabelled Secret is invisible to the
@@ -352,7 +361,7 @@ type ProxyList struct {
func init() {
SchemeBuilder.Register(func(s *runtime.Scheme) error {
s.AddKnownTypes(SchemeGroupVersion, &Proxy{}, &ProxyList{})
s.AddKnownTypes(SchemeGroupVersion, &Proxy{}, &ProxyList{}, &ProviderHealth{}, &ProviderHealthList{})
return nil
})
}
+114
View File
@@ -95,6 +95,120 @@ func (in *PlacementSpec) DeepCopy() *PlacementSpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ProviderHealth) DeepCopyInto(out *ProviderHealth) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderHealth.
func (in *ProviderHealth) DeepCopy() *ProviderHealth {
if in == nil {
return nil
}
out := new(ProviderHealth)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ProviderHealth) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ProviderHealthList) DeepCopyInto(out *ProviderHealthList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]ProviderHealth, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderHealthList.
func (in *ProviderHealthList) DeepCopy() *ProviderHealthList {
if in == nil {
return nil
}
out := new(ProviderHealthList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ProviderHealthList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ProviderHealthStatus) DeepCopyInto(out *ProviderHealthStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.Targets != nil {
in, out := &in.Targets, &out.Targets
*out = make([]ProviderTarget, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.LastCheckTime != nil {
in, out := &in.LastCheckTime, &out.LastCheckTime
*out = (*in).DeepCopy()
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderHealthStatus.
func (in *ProviderHealthStatus) DeepCopy() *ProviderHealthStatus {
if in == nil {
return nil
}
out := new(ProviderHealthStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ProviderTarget) DeepCopyInto(out *ProviderTarget) {
*out = *in
in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime)
if in.Details != nil {
in, out := &in.Details, &out.Details
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderTarget.
func (in *ProviderTarget) DeepCopy() *ProviderTarget {
if in == nil {
return nil
}
out := new(ProviderTarget)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Proxy) DeepCopyInto(out *Proxy) {
*out = *in
+116 -8
View File
@@ -16,8 +16,8 @@ limitations under the License.
// Package main is the composition root: it loads the provider config (fail
// fast), assembles the provider registry, and wires the reconciler, health
// engine, lease store, discovery API, orphan GC, and metrics onto one
// controller-runtime manager.
// engine, lease store, discovery API, orphan GC, provider health checks and
// metrics onto one controller-runtime manager.
package main
import (
@@ -38,10 +38,12 @@ import (
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/klog/v2"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/healthz"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
ctrlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics"
"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
@@ -57,7 +59,12 @@ import (
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/gcp"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/kubernetes"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/kvm"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/kvm2"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/proxmox"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/registry"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/providerhealth"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/version"
// +kubebuilder:scaffold:imports
)
@@ -93,6 +100,8 @@ func main() {
var leaseCooldown, maxLeaseTTL time.Duration
var showVersion bool
var gcpWireFullPayloads bool
var traceHealthProbes bool
var providerCheckInterval, providerCheckTimeout time.Duration
flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+
"Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
@@ -133,6 +142,16 @@ func main() {
"Print the commit the binary was built from and exit.")
flag.BoolVar(&gcpWireFullPayloads, "gcp-wire-log-full-payloads", false,
"Log GCP V(5) wire payloads verbatim instead of eliding fields larger than 1KiB.")
flag.BoolVar(&traceHealthProbes, "trace-health-probes", false,
"Emit one trace span per health probe. Off by default: probes run about "+
"once per second per proxy and would dominate trace volume.")
flag.DurationVar(&providerCheckInterval, "provider-check-interval", time.Minute,
"Interval between provider health checks (can the cluster reach each hypervisor, API "+
"and account), published as ProviderHealth objects and metrics. 0 turns them off; "+
"otherwise at least 10s.")
flag.DurationVar(&providerCheckTimeout, "provider-check-timeout", 0,
"Time limit for one round of provider health checks: 15s, or the interval when that is "+
"shorter. If set, at most the interval.")
opts := zap.Options{
Development: true,
@@ -145,9 +164,28 @@ func main() {
os.Exit(0)
}
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
logger := zap.New(zap.UseFlagOptions(&opts))
ctrl.SetLogger(logger)
// client-go logs through klog, and controller-runtime hands the leader
// elector a context with no logr in it, so klog.FromContext falls back
// to klog's own stderr writer and those lines ignore the encoder
// entirely. Plain SetLogger rather than the contextual form: it keeps
// klog's verbosity check in front, so this changes the format of what
// klog already emits without letting client-go's V(1) chatter through.
klog.SetLogger(logger)
setupLog.Info("Starting egress-proxies-operator",
"commit", version.Resolve(), "goVersion", goruntime.Version())
// Off (no-op spans, unchanged logs) unless OTEL_* env opts in; see
// internal/tracing. Shutdown is called explicitly after mgr.Start
// returns — the os.Exit paths below skip defers.
tracingShutdown, err := tracing.Setup(logf.IntoContext(context.Background(), setupLog),
"egress-proxies-operator", version.Resolve())
if err != nil {
setupLog.Error(err, "Failed to set up tracing")
os.Exit(1)
}
ctx := ctrl.SetupSignalHandler()
// Providers load first and fail fast: a manager that comes up without
@@ -156,24 +194,44 @@ func main() {
setupLog.Error(nil, "--providers-config is required")
os.Exit(1)
}
if err := providerhealth.ValidateSchedule(providerCheckInterval, providerCheckTimeout); err != nil {
setupLog.Error(err, "Invalid provider check flags")
os.Exit(1)
}
cfg, err := provider.LoadConfigFile(providersConfig)
if err != nil {
setupLog.Error(err, "Failed to load providers config", "path", providersConfig)
os.Exit(1)
}
providers, err := registry.Build(ctx, cfg, map[string]registry.Constructor{
"kubernetes": kubernetes.New,
"kubernetes": func(ctx context.Context, pc provider.ProviderConfig) (provider.Provider, error) {
// The kubernetes provider builds its own uncached client;
// wrap its transport so its API calls join the caller's trace.
return kubernetes.NewWithTransportWrapper(ctx, pc, tracing.RestConfigWrapper())
},
"gcp": func(ctx context.Context, pc provider.ProviderConfig) (provider.Provider, error) {
return gcp.NewWithWireOptions(ctx, pc, gcp.WireLogOptions{FullPayloads: gcpWireFullPayloads})
},
"kvm": kvm.New,
"kvm2": kvm2.New,
"proxmox": proxmox.New,
})
if err != nil {
setupLog.Error(err, "Failed to build providers")
os.Exit(1)
}
m := metrics.New()
// Checkers come from the unwrapped providers, in config order: the
// metrics and tracing wrappers implement only Provider, and a check is
// not a provider request to be counted anyway.
checkTargets := make([]providerhealth.Target, 0, len(cfg.Providers))
for _, pc := range cfg.Providers {
checker, _ := providers[pc.Name].(provider.Checker)
checkTargets = append(checkTargets, providerhealth.Target{Name: pc.Name, Type: pc.Type, Checker: checker})
}
for name, p := range providers {
providers[name] = provider.WithMetrics(name, p, m)
// Tracing outermost: the span covers the metrics recording too.
providers[name] = provider.WithTracing(name, provider.WithMetrics(name, p, m))
}
// if the enable-http2 flag is false (the default), http/2 should be disabled
@@ -236,7 +294,9 @@ func main() {
cacheOpts.DefaultNamespaces = map[string]cache.Config{proxyNamespace: {}}
}
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
restCfg := ctrl.GetConfigOrDie()
restCfg.Wrap(tracing.RestConfigWrapper())
mgr, err := ctrl.NewManager(restCfg, ctrl.Options{
Scheme: scheme,
Metrics: metricsServerOptions,
HealthProbeBindAddress: probeAddr,
@@ -258,6 +318,7 @@ func main() {
engine := health.NewEngine(mgr.GetClient())
engine.Workers = healthWorkers
engine.Metrics = m
engine.TraceProbes = traceHealthProbes
if err := mgr.Add(engine); err != nil {
setupLog.Error(err, "Failed to add health engine")
os.Exit(1)
@@ -287,6 +348,18 @@ func main() {
os.Exit(1)
}
monitor := &providerhealth.Monitor{
Client: mgr.GetClient(),
Reader: mgr.GetAPIReader(),
Targets: checkTargets,
Interval: providerCheckInterval,
Timeout: providerCheckTimeout,
}
if err := mgr.Add(monitor); err != nil {
setupLog.Error(err, "Failed to add provider health monitor")
os.Exit(1)
}
if err := m.Register(ctrlmetrics.Registry,
proxyPhaseCounts(mgr.GetClient()),
func() int {
@@ -300,6 +373,10 @@ func main() {
setupLog.Error(err, "Failed to register metrics")
os.Exit(1)
}
if err := metrics.RegisterProviderTargets(ctrlmetrics.Registry, providerTargetSamples(monitor)); err != nil {
setupLog.Error(err, "Failed to register provider check metrics")
os.Exit(1)
}
if err := (&controller.ProxyReconciler{
Client: mgr.GetClient(),
@@ -323,8 +400,19 @@ func main() {
}
setupLog.Info("Starting manager")
if err := mgr.Start(ctx); err != nil {
setupLog.Error(err, "Failed to run manager")
startErr := mgr.Start(ctx)
// Flush pending spans on the way out, error path included. Fresh
// context: the signal ctx is already cancelled by the time Start
// returns.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
if err := tracingShutdown(shutdownCtx); err != nil {
setupLog.Error(err, "Failed to flush traces on shutdown")
}
cancel()
if startErr != nil {
setupLog.Error(startErr, "Failed to run manager")
os.Exit(1)
}
}
@@ -351,3 +439,23 @@ func proxyPhaseCounts(c client.Reader) func() map[string]int {
return counts
}
}
// providerTargetSamples flattens the monitor's latest results for the
// scrape-time provider-check gauges.
func providerTargetSamples(mon *providerhealth.Monitor) func() []metrics.ProviderTarget {
return func() []metrics.ProviderTarget {
var out []metrics.ProviderTarget
for _, r := range mon.Snapshot() {
for _, ts := range r.Targets {
out = append(out, metrics.ProviderTarget{
Provider: r.Provider,
Target: ts.Name,
Up: ts.Ready,
Duration: ts.Latency,
CheckedAt: r.CheckedAt,
})
}
}
return out
}
}
@@ -0,0 +1,193 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.21.0
name: providerhealths.crawl.example.com
spec:
group: crawl.example.com
names:
kind: ProviderHealth
listKind: ProviderHealthList
plural: providerhealths
shortNames:
- ph
singular: providerhealth
scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .status.type
name: Type
type: string
- jsonPath: .status.conditions[?(@.type=="Ready")].status
name: Ready
type: string
- jsonPath: .status.targetsReady
name: Targets
type: string
- jsonPath: .status.lastCheckTime
name: Last Check
type: date
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
ProviderHealth reports the periodic health check of one configured
provider. It is named after the provider, and the operator creates,
updates and deletes it itself; there is no spec to edit.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
status:
description: status is the result of the latest check
properties:
conditions:
description: conditions hold Ready, which summarizes the targets.
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
lastCheckTime:
description: lastCheckTime is when the latest check finished.
format: date-time
type: string
targets:
description: targets hold one verdict per checked target.
items:
description: |-
ProviderTarget is the latest check verdict for one thing a provider
depends on: a KVM hypervisor, the Kubernetes API server, a GCP project.
properties:
address:
description: address is where the check connected to.
type: string
details:
additionalProperties:
type: string
description: |-
details are provider-specific facts from the last check, e.g. the
libvirt version and free storage-pool space of a KVM host.
type: object
lastTransitionTime:
description: lastTransitionTime is when ready last changed.
format: date-time
type: string
latencyMillis:
description: latencyMillis is how long the last check took.
format: int64
type: integer
message:
description: message is human-readable detail, usually the error
of a failed check.
maxLength: 1024
type: string
name:
description: |-
name identifies the target within its provider: the KVM host name,
"apiserver", or the GCP project.
minLength: 1
type: string
ready:
description: ready is true when the last check succeeded.
type: boolean
reason:
description: |-
reason is a CamelCase verdict such as Reachable, Unreachable,
Forbidden, SSHHandshakeFailed or PoolNotActive.
type: string
required:
- lastTransitionTime
- name
- ready
type: object
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
targetsReady:
description: targetsReady counts the ready targets as "ready/total".
type: string
type:
description: |-
type is the provider's type from the providers config: kubernetes,
gcp, kvm, kvm2 or proxmox.
type: string
type: object
type: object
served: true
storage: true
subresources:
status: {}
+1
View File
@@ -3,4 +3,5 @@
# It should be run by config/default
resources:
- bases/crawl.example.com_proxies.yaml
- bases/crawl.example.com_providerhealths.yaml
# +kubebuilder:scaffold:crdkustomizeresource
+2 -2
View File
@@ -5,5 +5,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
images:
- name: controller
newName: example.com/egress-proxies-operator
newTag: v0.0.1
newName: egress-proxies-operator
newTag: kvm-dev
+80
View File
@@ -64,6 +64,19 @@ spec:
- --leader-elect
- --health-probe-bind-address=:8081
- --providers-config=/etc/proxy-operator/providers.yaml
# Structured logs: every line is one JSON object, so `kubectl logs
# | jq` works. The time encoding is not optional — the JSON encoder
# defaults to epoch seconds. Verbosity stays at the binary's
# default (debug, one line per provider API call); add
# --zap-log-level=info for outcomes only.
- --zap-encoder=json
- --zap-time-encoding=rfc3339
# Add --trace-health-probes to emit one span per health probe
# (high volume: ~1/s per proxy).
# Provider health checks (kubectl get providerhealth) run every
# minute; tune with --provider-check-interval (0 turns them off,
# otherwise at least 10s) and --provider-check-timeout (15s, or
# the interval if that is shorter).
env:
# Bearer token for the discovery API. Optional: without the
# Secret the API serves unauthenticated (with a loud warning).
@@ -76,6 +89,33 @@ spec:
name: discovery-token
key: token
optional: true
# --- OpenTelemetry tracing ---
# Tracing is OFF until OTEL_EXPORTER_OTLP_ENDPOINT (or
# OTEL_TRACES_EXPORTER) is set; without it the operator runs
# exactly as before. The downward-API vars must stay listed
# before OTEL_RESOURCE_ATTRIBUTES: $(VAR) expansion only sees
# earlier-listed vars.
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: OTEL_SERVICE_NAME
value: egress-proxies-operator
- name: OTEL_RESOURCE_ATTRIBUTES
value: k8s.pod.name=$(POD_NAME),k8s.namespace.name=$(POD_NAMESPACE)
# Point at an OTLP receiver (Tempo, Jaeger, otel-collector) to
# enable tracing; http/protobuf (4318) is the default protocol,
# set OTEL_EXPORTER_OTLP_PROTOCOL=grpc for 4317.
# - name: OTEL_EXPORTER_OTLP_ENDPOINT
# value: http://jaeger-collector.observability:4318
# - name: OTEL_TRACES_SAMPLER
# value: parentbased_traceidratio
# - name: OTEL_TRACES_SAMPLER_ARG
# value: "0.1"
image: controller:latest
name: manager
ports:
@@ -116,9 +156,49 @@ spec:
- name: providers-config
mountPath: /etc/proxy-operator
readOnly: true
# SSH material for the kvm and kvm2 providers (sshKeyFile /
# knownHostsFile in the providers config point here). One Secret
# serves both types. Optional: the mount is empty when the Secret
# does not exist, and only kvm and kvm2 entries need it. Create it with:
# kubectl -n egress-proxies-operator-system create secret generic kvm-ssh \
# --from-file=id_ed25519=<private key> \
# --from-file=known_hosts=<known_hosts with every host's key>
- name: kvm-ssh
mountPath: /etc/proxy-operator/kvm
readOnly: true
# API token and CA for the proxmox provider (tokenFile / caFile in
# the providers config point here). Optional: the mount is empty when
# the Secret does not exist, and only proxmox entries need it. The
# token file is read on every request; the CA once, on first use.
# Create it with:
# kubectl -n egress-proxies-operator-system create secret generic proxmox-token \
# --from-file=token=<file holding user@realm!tokenid=secret> \
# --from-file=ca.pem=<the node's /etc/pve/pve-root-ca.pem>
# or `make proxmox-token-secret`.
- name: proxmox-token
mountPath: /etc/proxy-operator/proxmox
readOnly: true
# Scratch space: the root filesystem is read-only, and the kvm
# provider builds each VM's cloud-init seed ISO through a temp
# directory (a few hundred KB, deleted right after upload).
- name: tmp
mountPath: /tmp
volumes:
- name: providers-config
configMap:
name: providers-config
# Shared by the kvm and kvm2 providers; see the mount above.
- name: kvm-ssh
secret:
secretName: kvm-ssh
optional: true
# The proxmox provider's token and CA; see the mount above.
- name: proxmox-token
secret:
secretName: proxmox-token
optional: true
- name: tmp
emptyDir:
sizeLimit: 64Mi
serviceAccountName: controller-manager
terminationGracePeriodSeconds: 10
+100 -2
View File
@@ -9,9 +9,107 @@ metadata:
data:
# Mounted at /etc/proxy-operator/providers.yaml (--providers-config).
# The default ships only the kubernetes-pod provider so the operator runs
# out of the box; add gcp entries (type: gcp, gcp.project: ...) for real
# egress fleets — see config/samples/providers-config.yaml.
# out of the box; add gcp entries (type: gcp, gcp.project: ...) or kvm
# entries (type: kvm, hosts reached over SSH) for real egress fleets —
# see config/samples/providers-config.yaml.
providers.yaml: |
providers:
- name: kubernetes
type: kubernetes
- name: kvm-home
type: kvm
kvm:
# SSH to each host with the libvirt unix socket forwarded through the
# connection; nothing to open on the hypervisors beyond sshd. Both
# files come from the optional kvm-ssh Secret mounted by
# config/manager/manager.yaml — see docs/kvm-provider.md.
sshKeyFile: /etc/proxy-operator/kvm/id_ed25519
knownHostsFile: /etc/proxy-operator/kvm/known_hosts
# insecureIgnoreHostKey: true # skip host-key checks (not recommended)
# diskSizeGb: 10 # per-VM overlay; cloud-init grows the root fs into it
# vendorData: | # extra vendor-data; the qemu-guest-agent bootstrap is merged
# #cloud-config # into each Proxy's cloud-config, not configured here
# ...
images:
# placement.image names a key here (or is an https:// URL directly).
# Fetched into each host's pool on first use and cached there as
# base-<key>.qcow2; do not change an entry while Proxies use it.
debian-13:
url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
# sha256: "<64 hex>" # optional; verified after download
# firmware: uefi # default; "bios" only for images with no EFI partition
# # (Debian 13 hangs in GRUB under BIOS headless: Debian bug #1111240)
ubuntu-24.04:
url: https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
machineTypes: # placement.machineType names a key here
small: {vcpus: 1, memoryMiB: 1024}
medium: {vcpus: 2, memoryMiB: 2048}
hosts: # placement.zone names a host; optional with one host
- name: beelink
address: beelink.tail8c69c.ts.net:22
user: novakj # must be in the libvirt group
bridge: br0 # to a LAN with DHCP; the VM's IP comes from there
# socket: /var/run/libvirt/libvirt-sock
# pool: default
# kvm2 takes the same block shape as kvm: an entry ports by changing
# type to kvm2 and the block name to kvm2. kvm2 does not adopt domains
# made by kvm, so delete those Proxies first. It caches its base images
# under base-<entry>-<key>.<format>, so it downloads its own copy rather
# than sharing kvm's; budget the pool space. See docs/kvm2-provider.md.
- name: kvm2-home
type: kvm2
kvm2:
sshKeyFile: /etc/proxy-operator/kvm/id_ed25519
knownHostsFile: /etc/proxy-operator/kvm/known_hosts
# insecureIgnoreHostKey: true
# diskSizeGb: 10
# installGuestAgent: false
# vendorData: |
# #cloud-config
# ...
images:
debian-13:
url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
# sha256: "<64 hex>"
# firmware: uefi
ubuntu-24.04:
url: https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
machineTypes:
small: {vcpus: 1, memoryMiB: 1024}
medium: {vcpus: 2, memoryMiB: 2048}
hosts:
- name: beelink
address: beelink.tail8c69c.ts.net:22
user: novakj
bridge: br0
# socket: /var/run/libvirt/libvirt-sock
# pool: default
# proxmox clones a prepared template on a Proxmox VE cluster through its
# REST API and seeds it with a NoCloud ISO uploaded to isoStorage; it needs
# no SSH. Both files come from the optional proxmox-token Secret mounted by
# config/manager/manager.yaml. The url's host must be a name or address in
# the node certificate. See docs/proxmox-provider.md.
# - name: proxmox-home
# type: proxmox
# proxmox:
# url: https://192.168.0.8:8006
# tokenFile: /etc/proxy-operator/proxmox/token
# caFile: /etc/proxy-operator/proxmox/ca.pem
# # insecureSkipTLSVerify: true # instead of caFile; logged as a warning
# nodes: [pv1-1] # placement.zone names a node; optional with one node
# isoStorage: proxy-seeds # iso content; the token needs Datastore.Allocate here
# # storage: local-lvm # clone target; default is the template's disk storage
# # bridge: vmbr0 # replaces the cloned net0's bridge
# vlanTag: 4 # set on the cloned net0, MAC kept
# # vmidRange: {min: 100000, max: 199999}
# # requestTimeoutSeconds: 30
# # installGuestAgent: false
# # vendorData: |
# # #cloud-config
# # ...
# templates: # placement.image names a key; add keys, never repoint one
# debian-13: {vmid: 9001}
# machineTypes: # placement.machineType names a key here
# small: {cores: 1, memoryMiB: 1024}
# medium: {cores: 2, memoryMiB: 2048, diskGiB: 10}
+3
View File
@@ -25,4 +25,7 @@ resources:
- proxy_admin_role.yaml
- proxy_editor_role.yaml
- proxy_viewer_role.yaml
# ProviderHealth objects are written only by the operator, so they get a
# viewer role and nothing else.
- providerhealth_viewer_role.yaml
@@ -0,0 +1,29 @@
# This rule is not used by the project egress-proxies-operator itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants read-only access to ProviderHealth objects, the results of the
# operator's periodic provider checks. The operator creates, updates and
# deletes them itself, so no admin or editor role is provided.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: egress-proxies-operator
app.kubernetes.io/managed-by: kustomize
name: providerhealth-viewer-role
rules:
- apiGroups:
- crawl.example.com
resources:
- providerhealths
verbs:
- get
- list
- watch
- apiGroups:
- crawl.example.com
resources:
- providerhealths/status
verbs:
- get
+16
View File
@@ -22,6 +22,22 @@ rules:
- get
- list
- watch
- apiGroups:
- crawl.example.com
resources:
- providerhealths
verbs:
- create
- delete
- get
- list
- apiGroups:
- crawl.example.com
resources:
- providerhealths/status
verbs:
- get
- patch
- apiGroups:
- crawl.example.com
resources:
+6
View File
@@ -1,8 +1,14 @@
## Append samples of your project ##
# providers-config.yaml is deliberately absent: it is a sample
# --providers-config file, not a Kubernetes manifest.
# proxy_kvm2_crawl_pool.yaml is deliberately absent too: it is a whole pool
# of VMs, so it stays an explicit `kubectl apply -f` rather than something
# `apply -k` boots along with the one-per-provider samples.
resources:
- proxy_kubernetes.yaml
- proxy_gcp.yaml
- proxy_kvm.yaml
- proxy_kvm2.yaml
- proxy_proxmox.yaml
- proxy_external.yaml
# +kubebuilder:scaffold:manifestskustomizesamples
+97
View File
@@ -19,3 +19,100 @@ providers:
# diskSizeGb: 10
# auth: Application Default Credentials (workload identity
# in-cluster, gcloud ADC locally). No key-file plumbing.
- name: kvm-home
type: kvm
kvm:
# SSH to each host with the libvirt unix socket forwarded through the
# connection; nothing to open on the hypervisors beyond sshd. Both
# files come from the optional kvm-ssh Secret mounted by
# config/manager/manager.yaml — see docs/kvm-provider.md.
sshKeyFile: /etc/proxy-operator/kvm/id_ed25519
knownHostsFile: /etc/proxy-operator/kvm/known_hosts
# insecureIgnoreHostKey: true # skip host-key checks (not recommended)
# diskSizeGb: 10 # per-VM overlay; cloud-init grows the root fs into it
# installGuestAgent: false # skip the vendor-data script that installs qemu-guest-agent
# vendorData: | # extra vendor-data, carried beside the agent script as a
# #cloud-config # MIME multipart. The Proxy's own cloud-init is never rewritten.
# ...
images:
# placement.image names a key here (or is an https:// URL directly).
# Fetched into each host's pool on first use and cached there as
# base-<key>.qcow2; do not change an entry while Proxies use it.
debian-13:
url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
# sha256: "<64 hex>" # optional; verified after download
# firmware: uefi # default; "bios" only for images with no EFI partition
# # (Debian 13 hangs in GRUB under BIOS headless: Debian bug #1111240)
ubuntu-24.04:
url: https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
machineTypes: # placement.machineType names a key here
small: {vcpus: 1, memoryMiB: 1024}
medium: {vcpus: 2, memoryMiB: 2048}
hosts: # placement.zone names a host; optional with one host
- name: beelink
address: beelink:22
user: novakj # must be in the libvirt group
bridge: br0 # to a LAN with DHCP; the VM's IP comes from there
# socket: /var/run/libvirt/libvirt-sock
# pool: default
# kvm2 takes the same block shape as kvm: an entry ports by changing type
# to kvm2 and the block name to kvm2. kvm2 does not adopt domains made by
# kvm, so delete those Proxies first. It caches its base images under
# base-<entry>-<key>.<format>, so it downloads its own copy rather than
# sharing kvm's; budget the pool space. See docs/kvm2-provider.md.
# - name: kvm2-home
# type: kvm2
# kvm2:
# sshKeyFile: /etc/proxy-operator/kvm/id_ed25519
# knownHostsFile: /etc/proxy-operator/kvm/known_hosts
# # insecureIgnoreHostKey: true
# # diskSizeGb: 10
# # installGuestAgent: false
# # vendorData: |
# # #cloud-config
# # ...
# images:
# debian-13:
# url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
# # sha256: "<64 hex>"
# # firmware: uefi
# ubuntu-24.04:
# url: https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
# machineTypes:
# small: {vcpus: 1, memoryMiB: 1024}
# medium: {vcpus: 2, memoryMiB: 2048}
# hosts:
# - name: beelink
# address: beelink:22
# user: novakj
# bridge: br0
# # socket: /var/run/libvirt/libvirt-sock
# # pool: default
# proxmox clones a prepared template on a Proxmox VE cluster through its
# REST API and seeds it with a NoCloud ISO uploaded to isoStorage; it needs
# no SSH. Both files come from the optional proxmox-token Secret mounted by
# config/manager/manager.yaml. The url's host must be a name or address in
# the node certificate. See docs/proxmox-provider.md.
# - name: proxmox-home
# type: proxmox
# proxmox:
# url: https://192.168.0.8:8006
# tokenFile: /etc/proxy-operator/proxmox/token
# caFile: /etc/proxy-operator/proxmox/ca.pem
# # insecureSkipTLSVerify: true # instead of caFile; logged as a warning
# nodes: [pv1-1] # placement.zone names a node; optional with one node
# isoStorage: proxy-seeds # iso content; the token needs Datastore.Allocate here
# # storage: local-lvm # clone target; default is the template's disk storage
# # bridge: vmbr0 # replaces the cloned net0's bridge
# vlanTag: 4 # set on the cloned net0, MAC kept
# # vmidRange: {min: 100000, max: 199999}
# # requestTimeoutSeconds: 30
# # installGuestAgent: false
# # vendorData: |
# # #cloud-config
# # ...
# templates: # placement.image names a key; add keys, never repoint one
# debian-13: {vmid: 9001}
# machineTypes: # placement.machineType names a key here
# small: {cores: 1, memoryMiB: 1024}
# medium: {cores: 2, memoryMiB: 2048, diskGiB: 10}
+43
View File
@@ -0,0 +1,43 @@
# A Managed proxy backed by a libvirt/KVM hypervisor on the home network:
# the operator boots a stock Debian cloud image as a VM on the named host,
# with a NIC on the host's bridge (IP from the LAN's DHCP), and installs
# Squid via cloud-init. Requires a providers-config entry named "kvm-home"
# (see providers-config.yaml) and the kvm-ssh Secret (docs/kvm-provider.md).
#
# placement.zone is the host name from kvm.hosts (optional when only one
# host is configured); machineType and image are keys in the provider's
# machineTypes and images maps. The first Proxy for an image on a host
# waits for the image download; later ones reuse the cached copy.
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: proxy-kvm-sample
spec:
mode: Managed
provider: kvm-home
placement:
zone: nas1
machineType: small
image: debian-13
port: 3128
cloudInit:
inline: |
#cloud-config
packages:
- squid
write_files:
- path: /etc/squid/conf.d/proxy-operator.conf
content: |
http_port 3128
http_access allow all
via off
forwarded_for off
runcmd:
- systemctl restart squid
# Optional: log in with `ssh debian@<ip>` (the Debian image's default
# user) to look around; `virsh console <name>` on the host works too.
# ssh_authorized_keys:
# - ssh-ed25519 AAAA... you@laptop
attributes:
geo: home
purpose: crawl
+44
View File
@@ -0,0 +1,44 @@
# A Managed proxy backed by a libvirt/KVM hypervisor through the kvm2
# provider: the operator boots a stock Debian cloud image as a VM on the
# named host, with a NIC on the host's bridge (IP from the LAN's DHCP), and
# installs Squid via cloud-init. Requires a providers-config entry named
# "kvm2-home" (see providers-config.yaml) and the kvm-ssh Secret, which the
# kvm and kvm2 providers share (docs/kvm2-provider.md).
#
# placement.zone is the host name from kvm2.hosts (optional when only one
# host is configured); machineType and image are keys in the provider's
# machineTypes and images maps. The first Proxy for an image on a host
# waits for the image download; later ones reuse the cached copy.
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: proxy-kvm2-sample
spec:
mode: Managed
provider: kvm2-home
placement:
zone: beelink
machineType: small
image: debian-13
port: 3128
cloudInit:
inline: |
#cloud-config
packages:
- squid
write_files:
- path: /etc/squid/conf.d/proxy-operator.conf
content: |
http_port 3128
http_access allow all
via off
forwarded_for off
runcmd:
- systemctl restart squid
# Optional: log in with `ssh debian@<ip>` (the Debian image's default
# user) to look around; `virsh console <name>` on the host works too.
# ssh_authorized_keys:
# - ssh-ed25519 AAAA... you@laptop
attributes:
geo: home
purpose: crawl
@@ -0,0 +1,103 @@
# A pool of Managed crawl proxies on the kvm2 provider — the multi-proxy
# counterpart to the single proxy_kvm2.yaml sample. Everything here boots on
# the one host the kvm2 entry configures (beelink); the proxies differ in the
# attributes crawlers select on, in size, and in image.
#
# Requires the same prerequisites as proxy_kvm2.yaml: a providers-config
# entry named "kvm2-home" and the kvm-ssh Secret that the kvm and kvm2
# providers share (docs/kvm2-provider.md).
#
# Deliberately NOT in kustomization.yaml: `kubectl apply -k config/samples`
# would boot every VM in here at once. Apply it on its own when you want the
# pool:
#
# kubectl apply -f config/samples/proxy_kvm2_crawl_pool.yaml
#
# Crawlers reach these through the discovery API by attribute, e.g.
# `GET /v1/proxies?attr.purpose=crawl&healthy=true`, or by leasing one with
# the same selector. Attribute matching is exact equality, so keep the keys
# consistent across the pool and vary only the values you want to select on.
#
# The first Proxy for an image on the host waits for that image to download;
# the rest reuse the cached copy. debian-13 and ubuntu-24.04 are separate
# downloads, so the ubuntu member below is the slow one on a cold host.
---
# One cloud-init Secret shared by the whole pool: the proxies below point at
# it with secretRef instead of each carrying its own copy of the squid
# config. The crawl.example.com/cloud-init label is mandatory — the manager's
# cache only holds Secrets that carry it, so an unlabelled Secret is
# invisible to the operator and the Proxy never resolves.
#
# Editing this Secret changes the resolved cloud-init of every Proxy that
# references it, and that replaces each of their VMs. That is the trade for
# sharing one definition: convenient for a config rollout, disruptive if you
# only meant to touch one member. Give a member its own Secret (or an inline
# block) when it needs to diverge.
apiVersion: v1
kind: Secret
metadata:
name: crawl-squid-cloud-init
labels:
crawl.example.com/cloud-init: "true"
type: Opaque
stringData:
# The default key name; SecretKeySelector.key defaults to user-data, so
# the proxies below omit it.
user-data: |
#cloud-config
packages:
- squid
write_files:
- path: /etc/squid/conf.d/proxy-operator.conf
content: |
http_port 3128
http_access allow all
via off
forwarded_for off
runcmd:
- systemctl restart squid
# Optional: log in with `ssh debian@<ip>` (or `ubuntu@<ip>` on the
# ubuntu-24.04 member) to look around; `virsh console <name>` on the
# host works too.
# ssh_authorized_keys:
# - ssh-ed25519 AAAA... you@laptop
---
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: crawl-home-1
spec:
mode: Managed
provider: kvm2-home
placement:
zone: beelink
machineType: small
image: debian-13
port: 3128
cloudInit:
secretRef:
name: crawl-squid-cloud-init
attributes:
geo: home
purpose: crawl
tier: general
# ---
# apiVersion: crawl.example.com/v1alpha1
# kind: Proxy
# metadata:
# name: crawl-home-2
# spec:
# mode: Managed
# provider: kvm2-home
# placement:
# zone: beelink
# machineType: small
# image: debian-13
# port: 3128
# cloudInit:
# secretRef:
# name: crawl-squid-cloud-init
# attributes:
# geo: home
# purpose: crawl
# tier: general
+190
View File
@@ -0,0 +1,190 @@
# A pool of Managed crawl proxies on the kvm2 provider — the multi-proxy
# counterpart to the single proxy_kvm2.yaml sample. Everything here boots on
# the one host the kvm2 entry configures (beelink); the proxies differ in the
# attributes crawlers select on, in size, and in image.
#
# Requires the same prerequisites as proxy_kvm2.yaml: a providers-config
# entry named "kvm2-home" and the kvm-ssh Secret that the kvm and kvm2
# providers share (docs/kvm2-provider.md).
#
# Deliberately NOT in kustomization.yaml: `kubectl apply -k config/samples`
# would boot every VM in here at once. Apply it on its own when you want the
# pool:
#
# kubectl apply -f config/samples/proxy_kvm2_crawl_pool.yaml
#
# Crawlers reach these through the discovery API by attribute, e.g.
# `GET /v1/proxies?attr.purpose=crawl&healthy=true`, or by leasing one with
# the same selector. Attribute matching is exact equality, so keep the keys
# consistent across the pool and vary only the values you want to select on.
#
# The first Proxy for an image on the host waits for that image to download;
# the rest reuse the cached copy. debian-13 and ubuntu-24.04 are separate
# downloads, so the ubuntu member below is the slow one on a cold host.
---
# One cloud-init Secret shared by the whole pool: the proxies below point at
# it with secretRef instead of each carrying its own copy of the squid
# config. The crawl.example.com/cloud-init label is mandatory — the manager's
# cache only holds Secrets that carry it, so an unlabelled Secret is
# invisible to the operator and the Proxy never resolves.
#
# Editing this Secret changes the resolved cloud-init of every Proxy that
# references it, and that replaces each of their VMs. That is the trade for
# sharing one definition: convenient for a config rollout, disruptive if you
# only meant to touch one member. Give a member its own Secret (or an inline
# block) when it needs to diverge.
apiVersion: v1
kind: Secret
metadata:
name: crawl-squid-cloud-init
labels:
crawl.example.com/cloud-init: "true"
type: Opaque
stringData:
# The default key name; SecretKeySelector.key defaults to user-data, so
# the proxies below omit it.
user-data: |
#cloud-config
packages:
- squid
write_files:
- path: /etc/squid/conf.d/proxy-operator.conf
content: |
http_port 3128
http_access allow all
via off
forwarded_for off
runcmd:
- systemctl restart squid
# Optional: log in with `ssh debian@<ip>` (or `ubuntu@<ip>` on the
# ubuntu-24.04 member) to look around; `virsh console <name>` on the
# host works too.
# ssh_authorized_keys:
# - ssh-ed25519 AAAA... you@laptop
---
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: crawl-home-1
spec:
mode: Managed
provider: kvm2-home
placement:
zone: beelink
machineType: small
image: debian-13
port: 3128
cloudInit:
secretRef:
name: crawl-squid-cloud-init
attributes:
geo: home
purpose: crawl
tier: general
---
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: crawl-home-2
spec:
mode: Managed
provider: kvm2-home
placement:
zone: beelink
machineType: small
image: debian-13
port: 3128
cloudInit:
secretRef:
name: crawl-squid-cloud-init
attributes:
geo: home
purpose: crawl
tier: general
---
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: crawl-home-3
spec:
mode: Managed
provider: kvm2-home
placement:
zone: beelink
machineType: small
image: debian-13
port: 3128
cloudInit:
secretRef:
name: crawl-squid-cloud-init
attributes:
geo: home
purpose: crawl
tier: general
---
# A heavier member: two vCPUs and 2 GiB from the medium machine type, a
# different base image, and room for more concurrent leases. tier=heavy is
# what a crawler selects on to land here rather than on the general members.
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: crawl-home-heavy-1
spec:
mode: Managed
provider: kvm2-home
placement:
zone: beelink
machineType: medium
image: ubuntu-24.04
port: 3128
cloudInit:
secretRef:
name: crawl-squid-cloud-init
attributes:
geo: home
purpose: crawl
tier: heavy
maxLeases: 10
---
# A member that probes harder than the pool default (30s interval, 3 failures
# to go unhealthy): a tighter loop takes a flapping proxy out of rotation
# sooner, at the cost of more probe traffic. Everything under healthCheck is
# defaulted, so set only what you want to change.
#
# This one also runs squid on a non-default port, which is why it carries its
# own inline cloud-init: spec.port and the http_port in the config have to
# agree, and the shared Secret above pins 3128.
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: crawl-home-probe-fast-1
spec:
mode: Managed
provider: kvm2-home
placement:
zone: beelink
machineType: small
image: debian-13
port: 8080
cloudInit:
inline: |
#cloud-config
packages:
- squid
write_files:
- path: /etc/squid/conf.d/proxy-operator.conf
content: |
http_port 8080
http_access allow all
via off
forwarded_for off
runcmd:
- systemctl restart squid
healthCheck:
intervalSeconds: 10
timeoutSeconds: 3
failureThreshold: 2
attributes:
geo: home
purpose: crawl
tier: general
+46
View File
@@ -0,0 +1,46 @@
# A Managed proxy backed by a Proxmox VE cluster through the proxmox
# provider: the operator full-clones a prepared template on the named node,
# attaches a NoCloud seed ISO carrying this cloud-init, and starts the VM on
# the configured network (here VLAN 4 on vmbr0, IP from that network's
# DHCP), where cloud-init installs Squid. Requires a providers-config entry
# named "proxmox-home" (see providers-config.yaml) and the proxmox-token
# Secret (docs/proxmox-provider.md).
#
# placement.zone is a node name from proxmox.nodes (optional when only one
# node is configured); machineType and image are keys in the provider's
# machineTypes and templates maps. The address comes from the guest agent,
# which a vendor-data script installs at first boot, so the VM's network
# needs a route to a package mirror.
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: proxy-proxmox-sample
spec:
mode: Managed
provider: proxmox-home
placement:
zone: pv1-1
machineType: small
image: debian-13
port: 3128
cloudInit:
inline: |
#cloud-config
packages:
- squid
write_files:
- path: /etc/squid/conf.d/proxy-operator.conf
content: |
http_port 3128
http_access allow all
via off
forwarded_for off
runcmd:
- systemctl restart squid
# Optional: log in with `ssh debian@<ip>` (the Debian image's default
# user) to look around; `qm terminal <vmid>` on the node works too.
# ssh_authorized_keys:
# - ssh-ed25519 AAAA... you@laptop
attributes:
geo: home
purpose: crawl
+323
View File
@@ -0,0 +1,323 @@
# Discovery API reference
The operator serves an HTTP API (the *discovery API*) that crawler clients
use to find and lease egress proxies: list healthy proxies filtered by
attributes, acquire a TTL-based lease on one, release it early, and report
how a target site treated the proxy. It is implemented in
[`internal/discovery`](../internal/discovery/) with lease state in
[`internal/lease`](../internal/lease/); the only Kubernetes interaction is
reading `Proxy` resources from the manager's cache.
## Base URL
The API listens on `:8090` (`--discovery-addr`) inside the manager pod and
is exposed by a Service
([config/default/discovery_service.yaml](../config/default/discovery_service.yaml)).
In-cluster:
```text
http://egress-proxies-operator-controller-manager-discovery-service.egress-proxies-operator-system.svc.cluster.local:8090
```
From a workstation, port-forward:
```sh
kubectl -n egress-proxies-operator-system port-forward \
svc/egress-proxies-operator-controller-manager-discovery-service 8090:8090 &
```
Set `BASE_URL` to wherever you reach the API; all examples below use it:
```sh
export BASE_URL=localhost:8090 # via the port-forward above
# or, from inside the cluster:
# export BASE_URL=http://egress-proxies-operator-controller-manager-discovery-service.egress-proxies-operator-system.svc.cluster.local:8090
```
## Authentication
A single static bearer token, read from the `DISCOVERY_TOKEN` environment
variable at startup. The shipped Deployment populates it from the
`discovery-token` Secret (key `token`), which is **optional** — if the
Secret is absent or the token is empty, the API serves **unauthenticated**
(the manager logs a loud warning at startup). Create the Secret:
```sh
kubectl -n egress-proxies-operator-system create secret generic discovery-token \
--from-literal=token="$(openssl rand -hex 24)"
```
Send the token on every request:
```sh
export TOKEN=<the token>
curl -s -H "Authorization: Bearer $TOKEN" "$BASE_URL/v1/proxies" | jq
```
A missing or wrong token gets `401 {"error":"unauthorized",...}`.
`GET /healthz` is always exempt.
The curl examples below omit the `-H "Authorization: Bearer $TOKEN"` flag
for brevity — add it to every call when auth is enabled.
## Conventions
- Requests and responses are JSON. Errors share one envelope:
```json
{"error": "<machine_code>", "message": "<human-readable text>"}
```
- Request bodies are capped at **64 KiB** (larger bodies fail the JSON
decode with `400 invalid_body`).
- Proxies with a deletion timestamp (being finalized) are excluded from
every response and never offered for lease.
## Configuration
| Setting | Default | Meaning |
|---|---|---|
| `--discovery-addr` | `:8090` | Listen address of the API |
| `--max-lease-ttl` | `1h` | Maximum `ttlSeconds` a client may request |
| `--lease-cooldown` | `15m` | Cooldown window applied on `rate_limited`/`banned` reports |
| `DISCOVERY_TOKEN` (env) | empty | Bearer token; empty disables auth |
The shipped Deployment passes none of these flags, so the defaults apply.
## Endpoints
### `GET /healthz`
Liveness check. Unauthenticated, always `200` with body `ok`.
```sh
curl -s "$BASE_URL/healthz"
```
### `GET /v1/proxies` — list proxies
Query parameters (all optional):
| Parameter | Values | Effect |
|---|---|---|
| `healthy` | `true` \| `false` | Keep only proxies whose `Healthy` condition matches. Any other value → `400 invalid_query`. |
| `attr.<key>` | any string | Exact match on `spec.attributes[<key>]`. Repeatable; **all** given pairs must match. |
List everything:
```sh
curl -s "$BASE_URL/v1/proxies" | jq
```
List healthy proxies in a given geo:
```sh
curl -s "$BASE_URL/v1/proxies?healthy=true&attr.geo=eu" | jq
```
Response — `200`, proxies sorted by `id`, an empty match is `200` with
`"count": 0` (never `404`):
```json
{
"proxies": [
{
"id": "default/proxy-kubernetes-sample",
"ip": "10.244.1.7",
"port": 3128,
"attributes": {"geo": "local"},
"phase": "Ready",
"healthy": true,
"latencyMillis": 42,
"activeLeases": 1,
"maxLeases": 5
}
],
"count": 1
}
```
Proxy object fields (the same shape appears inside lease responses):
| Field | Meaning |
|---|---|
| `id` | `namespace/name` of the `Proxy` resource; used as the stable key everywhere |
| `ip` | Effective host — `spec.endpoint.host` for `External` proxies, `status.ip` for `Managed` (empty until the backing VM/pod is up) |
| `port` | Effective port (default `3128`) |
| `attributes` | `spec.attributes` — free-form selection labels (`geo`, `asn`, `purpose`, …); omitted when empty |
| `phase` | `Pending` \| `Provisioning` \| `Ready` \| `Unhealthy` \| `Deleting` \| `Failed` |
| `healthy` | `true` iff the `Healthy` condition is `True` (the through-the-proxy health probe passes) |
| `latencyMillis` | Latency of the last status-affecting health probe |
| `activeLeases` | Currently active leases on this proxy |
| `maxLeases` | Lease capacity (default `5`; an explicit `0` means unleasable) |
### `POST /v1/leases` — acquire a lease
Picks a healthy proxy with free capacity matching the selector and grants
an exclusive-slot, TTL-based lease on it.
Request body (every field optional; `{}` is valid):
```json
{
"selector": {"geo": "eu"},
"ttlSeconds": 300,
"target": "example.com"
}
```
| Field | Default | Meaning |
|---|---|---|
| `selector` | none | Attribute equality filter, same semantics as `attr.<key>` above |
| `ttlSeconds` | `300` (5 min) | Lease lifetime; must be ≤ `--max-lease-ttl` (default 1 h), else `400 invalid_ttl` |
| `target` | none | The site you intend to crawl; enables per-target cooldowns (see below) |
```sh
curl -s -XPOST "$BASE_URL/v1/leases" \
-d '{"selector":{"geo":"eu"},"ttlSeconds":300,"target":"example.com"}' | jq
```
Success — `201`:
```json
{
"leaseID": "P3X6HHQTPCM5UTGVGE3B5UPS3A",
"proxy": {
"id": "default/proxy-eu-1",
"ip": "34.88.10.20",
"port": 3128,
"attributes": {"geo": "eu"},
"phase": "Ready",
"healthy": true,
"latencyMillis": 42,
"activeLeases": 1,
"maxLeases": 5
},
"expiresAt": "2026-08-11T22:05:00Z",
"ttlSeconds": 300
}
```
Use `proxy.ip` and `proxy.port` as an HTTP proxy for the lease's lifetime:
```sh
curl -x http://34.88.10.20:3128 https://example.com
```
No match — `409` with diagnostic counts explaining why nothing qualified:
```json
{
"error": "no_match",
"message": "no healthy proxy with free capacity matched the selector",
"considered": 3,
"atCapacity": 1,
"inCooldown": 1,
"unhealthy": 1
}
```
| Count | Meaning |
|---|---|
| `considered` | Proxies that matched the selector (before health/capacity checks) |
| `atCapacity` | Skipped because `activeLeases >= maxLeases` |
| `inCooldown` | Skipped because of an active cooldown for this target (or a global one) |
| `unhealthy` | Skipped because the `Healthy` condition is not `True` |
Leases expire on their own — releasing is only needed to free the slot
early. There is no renew/extend endpoint; acquire a new lease instead.
### `DELETE /v1/leases/{id}` — release early
Frees the lease's capacity slot immediately. Idempotent: always `204`,
including for unknown or already-expired lease IDs.
```sh
curl -si -XDELETE "$BASE_URL/v1/leases/P3X6HHQTPCM5UTGVGE3B5UPS3A"
```
### `POST /v1/leases/{id}/report` — report an outcome
Tell the operator how the target site treated the proxy. This is the
feedback signal that drives cooldowns.
Request body:
```json
{"result": "rate_limited", "target": "example.com"}
```
| Field | Values | Meaning |
|---|---|---|
| `result` | `ok` \| `rate_limited` \| `banned` | Anything else → `400 invalid_result` |
| `target` | optional | Which site produced the result; falls back to the lease's `target`, then to global |
```sh
curl -si -XPOST "$BASE_URL/v1/leases/P3X6HHQTPCM5UTGVGE3B5UPS3A/report" \
-d '{"result":"rate_limited","target":"example.com"}'
```
Responses: `204` on success, `404 unknown_lease` if the lease ID was never
issued or has aged out.
Semantics:
- `ok` is a pure acknowledgement — nothing is recorded.
- `rate_limited` and `banned` currently behave **identically**: both put
the proxy in one cooldown window (default 15 min, `--lease-cooldown`)
for the resolved target.
- An expired lease remains reportable for one cooldown window past its
TTL, so a late "we got rate-limited" still lands.
## Proxy selection and cooldowns
How `POST /v1/leases` picks among eligible proxies (healthy, matching the
selector, not being deleted, not at capacity, not in cooldown), in order:
1. fewest `activeLeases` (least-loaded),
2. lowest `latencyMillis`,
3. lexicographic `id` (deterministic tie-break).
Cooldowns are keyed by **(proxy, target)**:
- A report **with a target** blocks that proxy only for lease requests
naming the **same target**. Other targets — and requests with no
target — still get the proxy.
- A report **without a target**, on a lease that also had no target,
creates a **global** cooldown: the proxy is blocked for *all* lease
requests until the window passes. Always pass `target` on leases and
reports unless you really mean "this proxy is bad for everyone".
## End-to-end example
```sh
# 1. Acquire a lease for crawling example.com through an EU proxy
LEASE=$(curl -s -XPOST "$BASE_URL/v1/leases" \
-H "Authorization: Bearer $TOKEN" \
-d '{"selector":{"geo":"eu"},"ttlSeconds":600,"target":"example.com"}')
LEASE_ID=$(echo "$LEASE" | jq -r .leaseID)
PROXY=$(echo "$LEASE" | jq -r '"\(.proxy.ip):\(.proxy.port)"')
# 2. Crawl through the leased proxy
curl -x "http://$PROXY" https://example.com/some/page
# 3. Got a 429? Report it — example.com-bound leases will avoid this
# proxy for the next 15 minutes
curl -s -XPOST "$BASE_URL/v1/leases/$LEASE_ID/report" \
-H "Authorization: Bearer $TOKEN" \
-d '{"result":"rate_limited","target":"example.com"}'
# 4. Done early? Release the slot (otherwise the TTL frees it)
curl -s -XDELETE "$BASE_URL/v1/leases/$LEASE_ID" \
-H "Authorization: Bearer $TOKEN"
```
## Caveats
- **Lease and cooldown state is in-memory and per-process.** An operator
restart drops all active leases and cooldowns. Clients must tolerate a
granted lease disappearing (a subsequent report returns `404`).
- **Run a single replica.** The API is served by every manager replica but
is not leader-elected, and lease state is not shared between replicas;
the shipped Deployment pins `replicas: 1`.
+370 -2
View File
@@ -4,15 +4,20 @@
| Component | Package | Runs as | Leader-elected | Role |
|---|---|---|---|---|
| Proxy CRD + helpers | `api/v1alpha1` | types | — | `Proxy` spec/status, CEL validation, defaulting, pure helpers |
| Proxy + ProviderHealth CRDs | `api/v1alpha1` | types | — | `Proxy` spec/status, CEL validation, defaulting, pure helpers; the status-only, cluster-scoped `ProviderHealth` |
| Reconciler | `internal/controller` | controller | yes (with the manager) | the state machine: provision, replace, delete, represent health |
| Provider contract | `internal/provider` | library | — | `Provider` interface, error taxonomy, deterministic naming, config, metrics decorator |
| Provider contract | `internal/provider` | library | — | `Provider` interface, optional `Checker`, error taxonomy, deterministic naming, config, metrics decorator; rules in [provider-contract.md](provider-contract.md), per-provider profile table in [providers-existing.md](providers-existing.md) |
| kubernetes provider | `internal/provider/kubernetes` | library | — | real Squid pods in this cluster (local dev/CI) |
| gcp provider | `internal/provider/gcp` | library | — | Compute Engine VMs, four API calls, fire-and-forget ops |
| kvm provider | `internal/provider/kvm` | library | — | VMs on libvirt hosts over SSH-forwarded sockets; image cache, overlay disks, NoCloud seeds |
| kvm2 provider | `internal/provider/kvm2` | library | — | the same backend, built clean-room from the provider contract as a second type: per-RPC child spans, pool-space quota, per-call budgets, its own metadata namespace, credentials re-read on every dial |
| proxmox provider | `internal/provider/proxmox` | library | — | VMs on a Proxmox VE cluster through its REST API: full clones of a template, a NoCloud seed uploaded through the API, the VMID derived from the Proxy UID, every task waited on, HTTP child spans |
| Shared provider helpers | `internal/provider/nocloud`, `internal/provider/keyedlock` | library | — | the NoCloud seed ISO and vendor-data builder, and one-slot semaphores by key; used by kvm2 and proxmox |
| Health engine | `internal/health` | Runnable | yes | through-the-proxy probes, thresholds, transition events |
| Lease store | `internal/lease` | Runnable (expiry sweep) | no | in-memory leases + cooldowns, single mutex |
| Discovery API | `internal/discovery` | Runnable | no | HTTP list/lease/release/report on `:8090` |
| Orphan GC | `internal/gc` | Runnable | yes | deletes tagged instances whose CR is gone |
| Provider health monitor | `internal/providerhealth` | Runnable | yes | periodic provider checks → one `ProviderHealth` per provider, target gauges, transition logs |
| Metrics | `internal/metrics` | library | — | explicit registration, scrape-time collectors |
| Composition root | `cmd/main.go` | binary | — | flags, provider registry, wires everything onto one manager |
@@ -147,6 +152,91 @@ prov.ListByTag ─► AggregatedList(label filter, ─┘ prov
ReturnPartialSuccess: true)
providerID = zones/<zone>/instances/<name> — zone-qualified, so Get/Delete
stay correct even mid-replacement after a zone edit
kvm provider (internal/provider/kvm/) — libvirt RPC over an SSH-forwarded unix socket, per host
prov.Create ──► DomainLookupByName: exists → start if needed, done ─┐ every step tolerates
else: ensure base image in pool (download on first use) │ the previous attempt
overlay volume (exists = reuse) │ having got that far;
seed ISO (always rebuilt — a cut-off upload must │ the seed is the one
never be trusted) │ thing redone
DomainDefineXML + DomainCreate ─┘
prov.Get ──► DomainGetState + DomainGetMetadata + DomainInterfaceAddresses
(ARP, then guest agent; first routable IPv4)
running without IP = Provisioning; nostate = Provisioning
prov.Delete ──► destroy (not running = fine) → undefine → delete overlay + seed by name
prov.ListByTag ─► ConnectListAllDomains on every host, keep domains whose
<metadata> carries managed=true; one host down fails the sweep,
one unreadable domain is skipped
providerID = hosts/<host>/domains/<name>; GC identity lives in the domain's
<metadata> element (libvirt has no labels). All RPCs run under a context-
bounded call: go-libvirt never times out on its own, so a cancelled reconcile
closes the SSH connection to unblock it.
kvm2 provider (internal/provider/kvm2/) - the same transport, a second type built from the contract
prov.Create ──► placement checked against the catalogue (Permanent before any dial)
DomainLookupByName: exists → mark+uid guard (no mark or foreign ─┐ every step
uid = Permanent; paused = Permanent), │
start if needed, wait for running, done │ idempotent,
else: StoragePoolGetInfo precheck: image (if uncached) + diskSizeGb │ under a
+ 16 MiB must fit, else ErrQuotaExceeded (reconciler │ per-name lock
waits 5m) │ shared with
ensure base image: marker present = cached; otherwise GET │ Delete
streamed into StorageVolUpload on a DEDICATED connection, │
StoragePoolRefresh, marker written last │
overlay volume (exists = reuse) │
seed ISO (always rebuilt) │
DomainDefineXML (<metadata> in kvm2's own namespace: │
managed, uid, createdAt) + DomainCreate, poll until │
running (15s cap; a domain that dies at once = Transient) ─┘
prov.Get ──► DomainLookupByName + DomainGetMetadata + DomainGetState
+ DomainInterfaceAddresses (guest agent, then ARP; first routable IPv4)
running without IP = Provisioning; nostate = Provisioning;
only ErrNoDomain is NotFound
prov.Delete ──► destroy (not running = fine) → undefine (nvram, managed save, snapshots)
→ delete overlay + seed by name; unconfigured host = logged no-op
prov.ListByTag ─► ConnectListAllDomains on every host, keep domains whose kvm2
<metadata> says managed=true; one host down fails the sweep,
one unreadable domain is skipped
Check ──► per host, on a FRESH connection: version, hostname, pool lookup,
pool info, close; never the pooled one
providerID = hosts/<host>/domains/<name>. Every RPC has its own budget (dial 10s,
RPC 30s, DomainCreate 90s, seed upload 60s, image transfer 20m): a call that
outlives its ctx or budget kills the pooled connection (the only way to unblock
go-libvirt), so other calls in flight on that host fail once with Transient and
retry; the next call redials. Each RPC is a child span kvm2.<Method> under
provider.*, plus kvm2.dial and kvm2.download.
proxmox provider (internal/provider/proxmox/) - the Proxmox VE REST API, one URL per cluster
prov.Create ──► placement checked against the catalogue (Permanent before any request)
GET /cluster/resources: a VM with the name → ownership check ─┐ every step
else: template config checked (not a template, cloud-init drive, │ idempotent,
no net0 or boot disk it needs = Permanent) │ under a
VMID = hash(UID) in vmidRange, probing on; a taken VMID → │ per-name lock
ownership check: wait out a lock, adopt our name + mark + │ shared with
UID, probe past another name, Permanent otherwise │ Delete; each
POST .../clone (full, description = the mark) → wait task │ task's UPID
upload seed ISO to isoStorage (overwrites) → wait task │ polled every
PUT .../config: cores, memory, agent=1, tag, ide2 = seed, │ 1s to its end
net0 bridge/tag when configured │
resize if diskGiB is larger → wait task │
start → wait task → poll status/current until running (60s) ─┘
prov.Get ──► /cluster/resources (absent = NotFound; lock or node unknown = Transient)
+ config (name and mark must match, else NotFound: VMID reused)
+ status/current (paused and hibernated = Stopped)
+ agent network-get-interfaces (first routable IPv4; agent not
up yet = Provisioning)
prov.Delete ──► resources + config: gone, not ours or unmarked = leave it;
stop (unless stopped or hibernated) → delete the seed on the
VM's node → destroy (purge); with no VM of ours left, the seed
is deleted on every node
prov.ListByTag ─► one /cluster/resources for the whole cluster, one config read per
candidate (tag or proxy- name); the description's mark decides
Check ──► per node: /version, node status, isoStorage status, templates in
/cluster/resources
providerID = vms/<vmid>/<name>: no node, because VMs migrate; the node is looked
up in /cluster/resources on every call. Token read from its file on every
request. Each request is an HTTP <METHOD> child span from the parent-gated
transport wrapper; a cancelled request ends only itself.
```
The reconciler never watches provider-side resources (Pods or GCP VMs).
@@ -213,6 +303,8 @@ Kubernetes interaction is reading Proxies from the manager's cache. The
server is a non-leader-elected Runnable (all replicas would serve, but the
deployment ships `replicas: 1` because lease state is per-process — an
operator restart drops all leases and cooldowns, a documented caveat).
Client-facing reference with request/response schemas and curl examples:
[api.md](api.md).
```text
crawler client
@@ -284,6 +376,8 @@ registries). Two kinds:
- **Scrape-time collectors** — `proxy_operator_proxies{phase}` and
`proxy_operator_leases_active` read the cache / lease store at every
scrape; reconcile-incremented gauges inevitably drift and leak series.
The provider-check gauges (§11) read the health monitor's snapshot the
same way.
- **Fed vectors** — `healthcheck_duration_seconds{proxy}` and
`healthcheck_failures_total{proxy}` observe EVERY probe (status writes
are transition-only; metrics carry the high-frequency signal), and the
@@ -298,6 +392,120 @@ Each consuming package defines its own small recorder interface
`metrics.Metrics` satisfies all of them structurally, so no package other
than `cmd/main.go` imports the metrics package.
### 10. Tracing (`internal/tracing/`)
OpenTelemetry tracing, integrated with — not replacing — the logr/zap
logging. Everything hangs off standard `OTEL_*` env vars: with no
`OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_TRACES_EXPORTER` set, no SDK is
installed, spans are no-ops, and logs are byte-for-byte what they were.
`OTEL_SDK_DISABLED=true` and `OTEL_TRACES_EXPORTER=none` force it off;
`console` prints spans to stdout for local dev; sampling follows
`OTEL_TRACES_SAMPLER(_ARG)`.
Span topology — each unit of work is a new trace (watch events carry no
incoming trace context):
- `Reconcile Proxy` (root, via a `reconcile.Reconciler` decorator) →
`reconcile.managed` / `reconcile.replaceInstance` / `reconcile.delete`,
plus `status.patch` from the deferred flush. `reconcileExternal` does no
I/O and is unspanned.
- `provider.create|get|delete|list` (client spans, `provider.WithTracing`
decorator wired outermost around `WithMetrics`); the GCP SDK's own
otelhttp transport contributes HTTP child spans automatically.
- kvm2 child spans: under `provider.*`, one `kvm2.<Method>` client span per
libvirt RPC (for example `kvm2.DomainDefineXML`, attributes `kvm2.host`,
`kvm2.domain`, `kvm2.pool`, `kvm2.volume`, `kvm2.libvirt_code` on
failure), `kvm2.dial` around the SSH handshake and libvirt connect, and
`kvm2.download` around an image fetch; under `providerhealth.check`,
`kvm2.dial` and the four `kvm2.<Method>` spans of a check. They come
from a span decorator over the seam, applied once in the dialer, so no
RPC is unspanned. The `libvirt call` log line of an RPC is written
inside its span. The `kvm` provider has no child spans.
- proxmox child spans: one `HTTP <METHOD>` client span per API request,
from `tracing.RestConfigWrapper()` on the provider's own HTTP transport,
under `provider.*` or `providerhealth.check`. Parent-gated like the
Kubernetes API spans, so a request without a parent starts none. The
provider's logging decorator starts no span; its `proxmox api call` line
carries the operation span's IDs.
- Kubernetes API calls become client child spans via a wrapped
`rest.Config` transport — gated on an existing parent span, so informer
list/watch long-polls and leader-election renewals never create root
spans. The kube-apiserver ignores incoming `traceparent` by design;
these spans are leaves.
- Discovery API: one server span per request (named from the route
pattern), incoming W3C `traceparent` honored so clients' traces continue
into the operator; `/healthz` excluded.
- `gc.sweep` per GC pass; `providerhealth.check` per round of provider
checks; `health.probe` per probe **only** with
`--trace-health-probes` (default off — probes run ~1/s per proxy).
Log correlation: logr sinks never see a context, so trace IDs ride on the
logger — `tracing.Start` re-derives the ctx logger from a captured base
with `traceID`/`spanID` values (lowerCamel, matching `reconcileID`;
Grafana/Loki derived-field regexes must match `traceID`, not `trace_id`).
Re-deriving from the base rather than layering keeps zap from emitting
duplicate keys on nested spans. GCP V(5) wire logs get the same keys from
the slog handler's ctx.
### 11. Provider health checks (`internal/providerhealth/`)
Answers "can this cluster reach each provider's backend right now?"
without waiting for a Proxy to fail. A provider opts in by implementing
`provider.Checker`: `Check(ctx) []TargetStatus` returns one verdict per
target and never fails as a whole. All five providers do:
| Provider | Target | What one check does |
|---|---|---|
| kvm | each `kvm.hosts` entry | over the pooled SSH+libvirt connection: libvirt version and hostname, then the configured pool must exist and be running; reports pool capacity and free space ([details](kvm-provider.md#health-check)) |
| kvm2 | each `kvm2.hosts` entry | on a fresh SSH+libvirt connection dialled for the check and closed after it: libvirt version and hostname, then the configured pool must exist and be running; `details` carry `user`, `pool`, `hostname`, `libvirtVersion`, `poolState`, `poolCapacityGiB` and `poolAvailableGiB` ([details](kvm2-provider.md#health-check)) |
| proxmox | each `proxmox.nodes` entry | four requests on the shared HTTP client: API version, node status, the `isoStorage` status (it must be active and hold `iso` content), and the cluster's resources (every configured template must exist and be a template); `details` carry `isoStorage`, `tokenID`, `pveVersion`, `kernel`, `isoStorageAvailGiB` and `templates` ([details](proxmox-provider.md#health-check)) |
| kubernetes | `apiserver` | lists at most one managed Pod cluster-wide: the verb and scope `ListByTag` and GC rely on |
| gcp | the project | one page of an aggregated instance list with `maxResults=1`: credentials, Compute API enabled, `compute.instances.list` |
```text
Monitor.Start(ctx) ── leader-only; --provider-check-interval (1m; 0 = off,
│ otherwise ≥ 10s), --provider-check-timeout (15s, or
│ the interval if that is shorter)
├─ once: delete managed-by-labelled ProviderHealth objects whose provider
│ is not configured (all of them when checks are off) — the providers
│ config is read once per process
└─ at start, then every Interval: checkOnce
│ every provider's Check concurrently, under one context.WithTimeout
│ snapshot → the scrape-time gauges
│ log transitions: down or reason changed → "WARNING: provider target
│ not ready"; back up → "Provider target ready"; steady → silent
└ per provider: APIReader.Get → Create if missing → status merge patch
```
- **One status write per provider per round**, since `lastCheckTime`
moves every round. At the 10s minimum interval that is still far below
leader-election renewals. Proxy status avoids writes like this only
because its probes run once a second per proxy.
- **Reads go through the API reader**, not the cache. The objects are
cluster-scoped; a cached Get would start a cluster-wide informer (and
need `watch`) even with `--proxy-namespace`.
- **Checks bypass the provider decorators.** `cmd/main.go` collects the
Checkers before wrapping the providers in `WithTracing(WithMetrics(...))`,
so a check never counts as a provider request. KVM RPCs still appear in
the V(1) `libvirt call` trace, and each round is one
`providerhealth.check` span.
- **Not wired to `/readyz`.** A dead hypervisor must not make the manager
unready while the other providers keep working.
- **Gauges** `proxy_operator_provider_target_up`,
`proxy_operator_provider_check_duration_seconds` and
`proxy_operator_provider_check_timestamp_seconds`, each labelled
`{provider,target}`. Only the leader exports them.
Illustrative output:
```sh
$ kubectl get providerhealth # short name: ph
NAME TYPE READY TARGETS LAST CHECK AGE
kubernetes kubernetes True 1/1 12s 3d
kvm-home kvm False 0/1 12s 3d
$ kubectl get ph kvm-home -o yaml # per target: reason, message, details
```
## Decisions
Judgment calls the spec left open, and deliberate deviations — recorded so
@@ -374,6 +582,41 @@ they read as choices, not accidents. Chronological by build step.
- **Unknown GCP instance statuses map to `Stopped`:** the reconciler's
answer to Stopped is delete-and-recreate, the always-safe move for
cattle when the API grows a new state.
- **KVM hosts are reached over SSH with the libvirt socket forwarded,**
not libvirtd's TCP/TLS listener: nothing to configure or expose on the
hypervisors beyond sshd, pure Go on the operator side (no cgo, no
libvirt libraries in the image), and the hosts list can grow by a
config edit because the providerID carries the host name.
- **KVM base images are fetched by the operator into the host's pool**
through libvirt's upload stream, rather than expected to be pre-placed:
the operator then needs no filesystem access on the host and a new
host is usable with zero manual staging. The cost is a synchronous
first-time download inside Create.
- **The KVM seed ISO is rebuilt on every Create attempt** before the
domain exists, instead of "exists → skip": a reconcile cancelled mid
upload leaves a truncated seed, and the compensating delete cannot run
because the connection was already closed to unblock the RPC. Skipping
it would boot a VM with no cloud-init and no retry would ever repair it.
- **KVM `nostate` maps to `Provisioning`, not `Stopped`:** unlike GCP's
unknown statuses, `nostate` means libvirt could not determine the
state at all; answering that with delete-and-recreate is too drastic,
so the next poll decides.
- **KVM guests boot UEFI by default, secure boot off.** Found on the
first real host: Debian 13's generic image hangs in GRUB under SeaBIOS
on a headless VM (Debian bug #1111240: `terminal_output gfxterm
serial` with a serial console and no video device) regardless of CPU
model or seed, and boots under OVMF with the serial console working.
The alternatives were a virtio console (loses the kernel's serial
output) or a video device the VMs have no use for. Every
current distro cloud image supports UEFI, so it is the default and
`firmware: bios` is the per-image escape; libvirt's firmware
autoselection (`<os firmware="efi">`) keeps the OVMF path out of the
operator's config. Secure boot is off so unsigned images boot too.
- **A KVM domain GC cannot read is skipped, a host it cannot reach fails
the sweep.** GC never deletes what it does not see, so leaving one
domain out is safe; one corrupt domain blocking GC for every other one
is not. A whole host missing from the view would hide many, so that
still aborts.
- **Kubernetes 403s classify as `ErrPermanent`** even though quota
exhaustion also surfaces as 403 (indistinguishable from RBAC denial in
`apierrors`): not hammering an API server that may never allow the
@@ -393,6 +636,15 @@ they read as choices, not accidents. Chronological by build step.
cluster-wide Secret read RBAC — an unrestricted cache would hold every
Secret in scope). Unlabelled referenced Secrets are invisible by
construction, surfacing as `CloudInitError`.
- **Deletion never gives up on its own, but can be released by hand:** a
permanent cleanup failure keeps retrying with the finalizer on, because
dropping it automatically would leak a running instance on any provider
that answers "permission denied". Where `Get` fails permanently the
reconciler still calls `Delete`, since it is idempotent and may finish
where `Get` cannot converge. `crawl.example.com/skip-cleanup: "true"`
abandons the instance and releases the object; it is checked before the
provider lookup, so it also frees a Proxy whose provider is gone from the
config.
- **Events RBAC from the plan is omitted:** nothing wires an
EventRecorder in the prototype, and granting verbs nothing uses would
be RBAC lint noise. Add the marker together with the recorder if events
@@ -402,3 +654,119 @@ they read as choices, not accidents. Chronological by build step.
logger to everything running under the manager — fighting that would
mean two logging systems in one process. Noted as a deviation rather
than silently ignored.
- **Tracing is env-gated, not flag-gated:** it activates only when
`OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_TRACES_EXPORTER` is set (user
decision). No collector configured means no SDK installed, no-op spans,
unchanged logs — the safe default for every existing deployment. The
one flag is `--trace-health-probes`, off by default, because probes at
~1/s per proxy would dominate trace volume.
- **Trace keys are `traceID`/`spanID`**, lowerCamel like `reconcileID` and
`providerID`, deliberately not the `trace_id` many Grafana derived-field
examples assume — configure the derived-field regex accordingly. logr
sinks can't read ctx, so the IDs ride on the ctx logger, re-derived from
a captured base per span so zap never emits duplicate keys.
- **Probe→reconcile trace links are not attempted:** the health engine's
`GenericEvent` carries only namespace/name (no ctx), and the workqueue
coalesces events, so any link would be a guess. A health-triggered
reconcile starts a fresh trace; the probe that caused it is findable via
its own (opt-in) span and shared proxy attributes.
- **No `traceparent` toward probe targets:** probe transports stay
uninstrumented so trace headers can never leak through a proxy to
external sites. The kube-apiserver ignores incoming `traceparent` by
design (public endpoint), so k8s client spans are leaves — in-process
traces, not cross-process ones.
- **Provider health is a status-only, cluster-scoped CRD** (user
decision). `kubectl get providerhealth` works without Prometheus, which
the deployment does not ship (its ServiceMonitor is commented out). There
is no spec: the providers config file stays the one place providers are
configured, and the object only reports on it. The gauges carry the same
verdicts for alerting.
- **KVM checks reuse the pooled connection instead of dialing fresh.** A
fresh dial would not pick up a rotated Secret anyway, since the SSH
material is cached. It would also open an sshd session per host per
minute. And a working cached connection is exactly what reconciles use.
Sharing must not cost the reconciles anything, so the check calls
through `hostPool.probe`. A check that runs out of time abandons its
RPCs instead of closing the connection, which would kill an upload or a
Create running on it. At most one abandoned check per host is left
waiting, and until it returns, the next check reports `Timeout` at once.
Waiting for a host's connection slot also ends with the caller's
deadline, so a reconcile redialing a dead host cannot stall the round.
- **Provider transitions are logs, not Events**, in line with the
EventRecorder decision above. They log at Info with a `WARNING:` prefix,
and each target's `lastTransitionTime` in the object records when it
flipped.
- **A provider name that is not a valid object name** gets metrics but no
`ProviderHealth` object, with a warning at start. Tightening config
validation instead would crash-loop existing deployments on upgrade.
- **kvm2 is a second provider type built clean-room from the contract,**
not a rewrite of `kvm` in place. Its spec
(`docs/kvm2-provider-spec.md`) and code were written from
[provider-contract.md](provider-contract.md), the kvm user guide and
go-libvirt alone, never from `internal/provider/kvm/`, as a test of
whether the contract is enough to build a provider. Both types ship,
share the `KVMConfig` block shape and the `kvm-ssh` Secret, and may
share a host.
- **kvm2 image downloads run on a dedicated connection.** A cancelled or
overlong call kills the connection it ran on (the only way to unblock
go-libvirt), so a 435 MB upload on the pooled connection could die
because a poll for another Proxy was cancelled. The transfer dials its
own connection and closes it afterwards; the cheap checks and the marker
stay on the pooled one.
- **kvm2 health checks dial fresh connections**, the opposite of the kvm
decision above. With credentials re-read on every dial a fresh
connection does pick up a rotated Secret, and a check that runs out of
time then closes only its own connection, so it cannot disrupt a
reconcile. The cost is one sshd session per host per round.
- **A cancelled kvm2 call kills the pooled connection.** Every RPC has a
budget; when a call outlives its ctx or budget the adapter closes the
socket and the SSH client, returns Transient, and marks the connection
dead so the next call redials. Other calls in flight on that host fail
once with Transient and their callers retry. Documented in the guide
rather than avoided: the alternative, leaving a hung RPC blocked
forever, pins a worker.
- **kvm2 marks domains in its own metadata namespace**, so it never lists
or adopts domains the `kvm` provider made. Switching an entry from `kvm`
to `kvm2` means deleting its Proxies first; the two types can share a
host without one collecting the other's VMs.
- **kvm2 turns pool free space into the quota signal.** libvirt reports a
full pool as an ordinary error, which would retry on the fast backoff.
Before creating any volume, `Create` compares `StoragePoolGetInfo`'s
available bytes with the image (if uncached) plus `diskSizeGb` plus 16
MiB for the seed, and returns `ErrQuotaExceeded` below that, so the
reconciler waits 5 minutes instead.
- **kvm2 classifies SSH authentication and host-key failures as
Permanent.** A key missing from `authorized_keys` or a host key missing
from or different in known_hosts needs an admin; kvm retried these
forever. A missing key or known_hosts file stays Transient, because the
optional Secret may land later.
- **kvm2 re-reads the SSH key and known_hosts on every dial**, so rotating
the Secret needs no restart; nothing is cached. The constructor only
checks that the files exist, to warn early without failing the provider
set.
- **proxmox derives the VMID from the Proxy UID and checks ownership
before using a taken one.** Proxmox addresses VMs by VMID, and names are
not unique, so the name cannot be the key. A hash of the UID placed in
`vmidRange` makes a retry land on the same VMID; a taken one is waited
on while locked, then adopted when it has our name, mark and UID,
probed past when it has another name, and refused otherwise. The wait
is what stops a retry after a crash mid-clone from cloning a duplicate:
a clone in progress has no name yet, so the by-name lookup misses it.
- **proxmox writes its mark into the VM's description**, in the clone
call itself, so no VM of ours exists unmarked. Tags cannot be passed to
the clone, and the clone's own creation time is copied from the
template. The `proxy_operator` tag is set afterwards for the Proxmox UI
and to narrow GC's candidates; the mark decides.
- **proxmox delivers cloud-init as a seed ISO uploaded through the API**,
not as Proxmox's own cloud-init drive or a `cicustom` snippet. The
upload endpoint does not accept snippets, and the operator has no shell
on the nodes; the seed code is kvm2's, extracted into
`internal/provider/nocloud`. Templates must therefore carry no Proxmox
cloud-init drive, and seeds of VMs destroyed outside the operator are a
documented GC blind spot.
- **proxmox resolves a VM's node on every call** from
`/cluster/resources`, and its providerID names none, because VMs migrate
between nodes. The cost is one extra request per operation; the gain is
that a migrated VM stays addressable and the listing, which includes the
guests of an unreachable node as `status: unknown`, never mistakes a
down node for a deleted VM.
+94
View File
@@ -0,0 +1,94 @@
#!/bin/bash
# Demo: create N Managed proxies backed by the gcp provider, named
# proxy-gcp-demo-1 .. proxy-gcp-demo-N, each in a randomly picked EU zone
# so the fleet gets egress IPs from different locations.
#
# Usage:
# ./create-gcp-proxies.sh <count>
#
# Optional environment:
# NAMESPACE namespace to create the proxies in (default: current context)
# GCP_PROVIDER provider NAME from providers.yaml (default: gcp-eu)
# ZONES space-separated zone list to pick from (default: EU zones below)
set -euo pipefail
if [ $# -ne 1 ] || ! [[ "$1" =~ ^[1-9][0-9]*$ ]]; then
echo "usage: $(basename "$0") <count> (positive integer)" >&2
exit 1
fi
count="$1"
provider="${GCP_PROVIDER:-gcp-eu}"
# GCP zones in the EU where e2-micro is generally available. Override with
# ZONES="zone1 zone2 ..." if your project has quota only in some of them.
default_zones=(
europe-west1-b europe-west1-c europe-west1-d # Belgium
europe-west2-a europe-west2-b europe-west2-c # London
europe-west3-a europe-west3-b europe-west3-c # Frankfurt
europe-west4-a europe-west4-b europe-west4-c # Netherlands
europe-west6-a europe-west6-b europe-west6-c # Zurich
europe-west8-a europe-west8-b europe-west8-c # Milan
europe-west9-a europe-west9-b europe-west9-c # Paris
europe-central2-a europe-central2-b europe-central2-c # Warsaw
europe-north1-a europe-north1-b europe-north1-c # Finland
europe-southwest1-a europe-southwest1-b europe-southwest1-c # Madrid
)
if [ -n "${ZONES:-}" ]; then
read -r -a zones <<< "${ZONES}"
else
zones=("${default_zones[@]}")
fi
if ! command -v kubectl &> /dev/null; then
echo "ERROR: kubectl is required but not installed" >&2
exit 1
fi
ns_args=()
if [ -n "${NAMESPACE:-}" ]; then
ns_args=(-n "${NAMESPACE}")
fi
for i in $(seq 1 "${count}"); do
zone="${zones[RANDOM % ${#zones[@]}]}"
echo "Creating proxy-gcp-demo-${i} in ${zone} ..."
kubectl apply "${ns_args[@]}" -f - << EOF
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: proxy-gcp-demo-${i}
spec:
mode: Managed
provider: ${provider} # must match a provider NAME in providers.yaml
placement:
zone: ${zone}
machineType: e2-micro
# debian-cloud images have no cloud-init, so spec.cloudInit (passed as
# user-data metadata) would be silently ignored there. Ubuntu images do.
image: projects/ubuntu-os-cloud/global/images/family/ubuntu-2404-lts-amd64
port: 3128
cloudInit:
inline: |
#cloud-config
package_update: true
packages:
- squid
write_files:
- path: /etc/squid/conf.d/proxy-operator.conf
content: |
http_access allow all
via off
forwarded_for off
runcmd:
- systemctl restart squid
attributes:
geo: eu
zone: ${zone}
purpose: crawl
EOF
done
echo ""
echo "Created ${count} proxies. VMs take a few minutes to provision and pass"
echo "the health check. Watch them come up with:"
echo " kubectl get px ${ns_args[*]:-} -w"
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# Demo: create N Managed proxies backed by the kubernetes-pod provider,
# named proxy-kubernetes-demo-1 .. proxy-kubernetes-demo-N. Pods share the
# cluster's egress IP — this exercises the full lifecycle, not distinct
# egress paths (use create-gcp-proxies.sh for that).
#
# Usage:
# ./create-kubernetes-proxies.sh <count>
#
# Optional environment:
# NAMESPACE namespace to create the proxies in (default: current context)
set -euo pipefail
if [ $# -ne 1 ] || ! [[ "$1" =~ ^[1-9][0-9]*$ ]]; then
echo "usage: $(basename "$0") <count> (positive integer)" >&2
exit 1
fi
count="$1"
if ! command -v kubectl &> /dev/null; then
echo "ERROR: kubectl is required but not installed" >&2
exit 1
fi
ns_args=()
if [ -n "${NAMESPACE:-}" ]; then
ns_args=(-n "${NAMESPACE}")
fi
for i in $(seq 1 "${count}"); do
echo "Creating proxy-kubernetes-demo-${i} ..."
kubectl apply "${ns_args[@]}" -f - << EOF
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: proxy-kubernetes-demo-${i}
spec:
mode: Managed
provider: kubernetes
attributes:
geo: local
purpose: crawl
EOF
done
echo ""
echo "Created ${count} proxies. Watch them come up with:"
echo " kubectl get px ${ns_args[*]:-} -w"
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
# Demo driver: opens a tmux session with a 2x2 pane grid:
#
# top-left: show-egress-ips-table.sh in a 10s loop, run inside the
# netshoot pod against the in-cluster discovery Service
# top-right: watch -n3 kubectl get px
# bottom-left: create-kubernetes-proxies.sh <count>
# bottom-right: create-gcp-proxies.sh <count>
#
# Usage:
# ./run-demo.sh
#
# Optional environment:
# COUNT proxies each create script makes (default: 4)
# SESSION tmux session name (default: proxy-demo; an existing
# session with this name is killed and recreated)
# NETSHOOT_POD pod to exec into for the egress-IP loop (default: netshoot)
# DEMO_DIR where the demo scripts live (default: this script's dir)
set -euo pipefail
COUNT="${COUNT:-4}"
SESSION="${SESSION:-proxy-demo}"
NETSHOOT_POD="${NETSHOOT_POD:-netshoot}"
DEMO_DIR="${DEMO_DIR:-$(cd "$(dirname "$0")" && pwd)}"
BASE_URL="http://egress-proxies-operator-controller-manager-discovery-service.egress-proxies-operator-system.svc.cluster.local:8090"
for tool in tmux kubectl; do
if ! command -v "${tool}" &> /dev/null; then
echo "ERROR: ${tool} is required but not installed" >&2
exit 1
fi
done
if ! kubectl get pod "${NETSHOOT_POD}" &> /dev/null; then
echo "ERROR: pod ${NETSHOOT_POD} not found — start one with:" >&2
echo " kubectl run netshoot --image=nicolaka/netshoot -- sleep infinity" >&2
exit 1
fi
echo "Copying show-egress-ips-table.sh into pod ${NETSHOOT_POD} ..."
kubectl cp "${DEMO_DIR}/show-egress-ips-table.sh" "${NETSHOOT_POD}:/tmp/show-egress-ips-table.sh"
if tmux has-session -t "${SESSION}" 2> /dev/null; then
echo "Killing existing tmux session ${SESSION}"
tmux kill-session -t "${SESSION}"
fi
# 2x2 grid: after these splits pane indexes are 0 top-left, 1 top-right,
# 2 bottom-left, 3 bottom-right; tiled layout evens them into quarters.
tmux new-session -d -s "${SESSION}"
tmux split-window -h -t "${SESSION}:0"
tmux split-window -v -t "${SESSION}:0.0"
tmux split-window -v -t "${SESSION}:0.1"
tmux select-layout -t "${SESSION}:0" tiled
loop_cmd="BASE_URL=${BASE_URL}; while true; do bash /tmp/show-egress-ips-table.sh \"\$BASE_URL\"; echo; sleep 10; done"
tmux send-keys -t "${SESSION}:0.0" "kubectl exec -it ${NETSHOOT_POD} -- bash -c '${loop_cmd}'" C-m
tmux send-keys -t "${SESSION}:0.1" "watch -n3 kubectl get px" C-m
tmux send-keys -t "${SESSION}:0.2" "bash ${DEMO_DIR}/create-kubernetes-proxies.sh ${COUNT}" C-m
tmux send-keys -t "${SESSION}:0.3" "bash ${DEMO_DIR}/create-gcp-proxies.sh ${COUNT}" C-m
tmux select-pane -t "${SESSION}:0.2"
if [ -n "${TMUX:-}" ]; then
tmux switch-client -t "${SESSION}"
else
tmux attach-session -t "${SESSION}"
fi
+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
# Demo: condensed-table variant of show-egress-ips.sh, made to fit a small
# tmux pane. One line per proxy: which proxy the request goes through, its
# endpoint, location (zone/geo attribute), and the egress IP the IP-echo
# site saw — or unhealthy/FAILED.
#
# Usage:
# ./show-egress-ips-table.sh <BASE_URL> e.g. ./show-egress-ips-table.sh localhost:8090
#
# Optional environment:
# TOKEN bearer token for the discovery API (see docs/api.md)
# IP_ECHO_URL site that returns the caller's IP as JSON with an "ip" field
# (default: https://api.ipify.org?format=json)
set -euo pipefail
if [ $# -ne 1 ]; then
echo "usage: $(basename "$0") <BASE_URL> (e.g. localhost:8090)" >&2
exit 1
fi
BASE_URL="$1"
IP_ECHO_URL="${IP_ECHO_URL:-https://api.ipify.org?format=json}"
for tool in curl jq; do
if ! command -v "${tool}" &> /dev/null; then
echo "ERROR: ${tool} is required but not installed" >&2
exit 1
fi
done
auth_args=()
if [ -n "${TOKEN:-}" ]; then
auth_args=(-H "Authorization: Bearer ${TOKEN}")
fi
if ! proxies_json=$(curl -sS --fail "${auth_args[@]}" "${BASE_URL}/v1/proxies") \
|| ! echo "${proxies_json}" | jq -e . > /dev/null 2>&1; then
echo "ERROR: could not fetch proxy list from ${BASE_URL}" >&2
exit 1
fi
total=$(echo "${proxies_json}" | jq -r '.count')
fmt="%-31s %-21s %-21s %s\n"
echo "${total} proxies @ $(date +%H:%M:%S)"
# shellcheck disable=SC2059
printf "${fmt}" "PROXY" "ENDPOINT" "LOCATION" "EGRESS-IP"
probed=0
skipped=0
failed=0
while IFS= read -r proxy; do
id=$(echo "${proxy}" | jq -r '.id')
endpoint=$(echo "${proxy}" | jq -r '"\(.ip):\(.port)"')
location=$(echo "${proxy}" | jq -r '.attributes.zone // .attributes.geo // "-"')
healthy=$(echo "${proxy}" | jq -r '.healthy')
if [ "${healthy}" != "true" ]; then
# shellcheck disable=SC2059
printf "${fmt}" "${id}" "${endpoint}" "${location}" "(unhealthy)"
skipped=$((skipped + 1))
continue
fi
if response=$(curl -sS --max-time 10 -x "http://${endpoint}" "${IP_ECHO_URL}" 2> /dev/null); then
egress=$(echo "${response}" | jq -r '.ip // "?"' 2> /dev/null || echo "?")
probed=$((probed + 1))
else
egress="FAILED"
failed=$((failed + 1))
fi
# shellcheck disable=SC2059
printf "${fmt}" "${id}" "${endpoint}" "${location}" "${egress}"
done < <(echo "${proxies_json}" | jq -c '.proxies[]')
echo "-- ${probed} probed, ${skipped} unhealthy, ${failed} failed --"
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# Demo: list proxies from the discovery API and show the egress IP each
# healthy one provides, by calling an IP-echo site through it.
#
# Usage:
# ./show-egress-ips.sh <BASE_URL> e.g. ./show-egress-ips.sh localhost:8090
#
# Optional environment:
# TOKEN bearer token for the discovery API (see docs/api.md)
# IP_ECHO_URL site that returns the caller's IP as JSON
# (default: https://api.ipify.org?format=json)
set -euo pipefail
if [ $# -ne 1 ]; then
echo "usage: $(basename "$0") <BASE_URL> (e.g. localhost:8090)" >&2
exit 1
fi
BASE_URL="$1"
IP_ECHO_URL="${IP_ECHO_URL:-https://api.ipify.org?format=json}"
for tool in curl jq; do
if ! command -v "${tool}" &> /dev/null; then
echo "ERROR: ${tool} is required but not installed" >&2
exit 1
fi
done
auth_args=()
if [ -n "${TOKEN:-}" ]; then
auth_args=(-H "Authorization: Bearer ${TOKEN}")
fi
echo "Fetching proxies from ${BASE_URL}/v1/proxies ..."
if ! proxies_json=$(curl -sS --fail "${auth_args[@]}" "${BASE_URL}/v1/proxies"); then
echo "ERROR: could not fetch proxy list from ${BASE_URL}" >&2
exit 1
fi
if ! echo "${proxies_json}" | jq -e . > /dev/null; then
echo "ERROR: response from ${BASE_URL}/v1/proxies is not valid JSON" >&2
exit 1
fi
total=$(echo "${proxies_json}" | jq -r '.count')
echo "Found ${total} proxies"
echo ""
probed=0
skipped=0
failed=0
while IFS= read -r proxy; do
id=$(echo "${proxy}" | jq -r '.id')
ip=$(echo "${proxy}" | jq -r '.ip')
port=$(echo "${proxy}" | jq -r '.port')
healthy=$(echo "${proxy}" | jq -r '.healthy')
if [ "${healthy}" != "true" ]; then
echo "--- skipping ${id} (unhealthy) ---"
echo ""
skipped=$((skipped + 1))
continue
fi
echo "=== via ${id} — http://${ip}:${port} ==="
if response=$(curl -sS --max-time 10 -x "http://${ip}:${port}" "${IP_ECHO_URL}"); then
echo "${response}" | jq . 2> /dev/null || echo "${response}"
probed=$((probed + 1))
else
echo "WARNING: request through ${id} failed" >&2
failed=$((failed + 1))
fi
echo ""
done < <(echo "${proxies_json}" | jq -c '.proxies[]')
echo "Done: ${total} proxies — ${probed} probed, ${skipped} skipped (unhealthy), ${failed} failed"
+373
View File
@@ -0,0 +1,373 @@
# The KVM provider
Runs Managed proxies as VMs on your own libvirt/KVM hypervisors. The
operator reaches each host over SSH, forwards the libvirt unix socket
through that connection, and talks libvirt's own RPC over it — so the
hosts need nothing beyond `sshd` and `libvirtd`, and the operator image
carries no libvirt libraries. VMs boot a stock distro cloud image that the
operator downloads into the host's storage pool on first use, with the
Proxy's cloud-init delivered on a NoCloud seed ISO built in-process.
## Hypervisor prerequisites
Per host, once:
1. **libvirt + KVM** (`libvirtd` running, `/dev/kvm` present).
2. **An SSH user in the `libvirt` group**, so it may open
`/var/run/libvirt/libvirt-sock`. The operator authenticates with a
private key; nothing else on the host is touched.
3. **A bridge to a network with DHCP** (e.g. `br0` bridged onto the LAN).
Each VM gets one virtio NIC on it and takes its IP from that network's
DHCP; the cluster must be able to reach those IPs for health probes
and for crawlers to use the proxy.
4. **A storage pool** (default `default`, usually `/var/lib/libvirt/images`)
with room for one base image per distro (a few hundred MB) plus the
per-VM overlays (`diskSizeGb` each, thin-provisioned).
5. **UEFI firmware for guests**: the `ovmf` package on Debian/Ubuntu,
`edk2-ovmf` on Fedora/RHEL. VMs boot UEFI by default (libvirt picks
the OVMF binary itself); a domain start fails with "no firmware
found" without it. Only `firmware: bios` images skip this.
6. Optionally, **outbound internet from the VMs**: a vendor-data script
installs `qemu-guest-agent` from the distro mirrors on first boot.
Without it the VM still works — the operator falls back to the host's
ARP table to find the IP, which fills in once the guest sends traffic —
but the ARP table only sees traffic to the host itself, so on a plain
bridge the agent is in practice required.
Check from the operator's point of view before wiring it in:
```sh
ssh virt@nas1.home.lan virsh -c qemu:///system list --all # user can drive libvirt
ssh virt@nas1.home.lan virsh -c qemu:///system pool-info default
ssh virt@nas1.home.lan ip link show br0
```
## Cluster-side setup
1. Create the SSH Secret from a key pair dedicated to the operator and a
`known_hosts` file holding every host's key. The worked example below
uses `~/.ssh/proxy-operator_ed25519` and `~/.ssh/kvm_known_hosts`,
the same paths the integration test and `make kvm-ssh-secret` default
to.
```sh
# once: a key pair for the operator; the .pub goes on every host
ssh-keygen -t ed25519 -f ~/.ssh/proxy-operator_ed25519 -N '' -C proxy-operator
ssh-copy-id -i ~/.ssh/proxy-operator_ed25519.pub virt@nas1.home.lan
# once per host: its host key, under the address the *operator* will
# dial (hosts[].address), which may differ from what your laptop uses
ssh-keyscan -H nas1.home.lan >> ~/.ssh/kvm_known_hosts
# the Secret: the PRIVATE key (not the .pub) plus known_hosts
kubectl -n egress-proxies-operator-system create secret generic kvm-ssh \
--from-file=id_ed25519=$HOME/.ssh/proxy-operator_ed25519 \
--from-file=known_hosts=$HOME/.ssh/kvm_known_hosts
```
`make kvm-ssh-secret` runs that last command as an apply, so it also
updates an existing Secret; override the paths with
`KVM_SSH_KEY=... KVM_KNOWN_HOSTS=...`. Putting the `.pub` in the
Secret is the classic mistake: the operator then fails at startup
with a "not a private key" error. Without `known_hosts`, set
`insecureIgnoreHostKey: true` and accept that a MITM could hand you a
fake hypervisor.
`config/manager/manager.yaml` mounts the Secret (optional) at
`/etc/proxy-operator/kvm` as two files, `id_ed25519` and
`known_hosts`, which is what `sshKeyFile` and `knownHostsFile` in the
provider config point at. The deployment starts fine without the
Secret, even with a `kvm` provider configured: only that provider's
proxies fail, and they recover once the Secret exists. Rotating the
key: re-run the command (or the make target) and restart the manager;
both files are read once, on the first connection, and cached.
2. Add a provider entry to the providers config
(`config/manager/providers_config.yaml` in-cluster; full sample in
`config/samples/providers-config.yaml`):
```yaml
- name: kvm-home
type: kvm
kvm:
sshKeyFile: /etc/proxy-operator/kvm/id_ed25519
knownHostsFile: /etc/proxy-operator/kvm/known_hosts
images:
debian-13:
url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
machineTypes:
small: {vcpus: 1, memoryMiB: 1024}
hosts:
- name: nas1
address: nas1.home.lan:22
user: virt
bridge: br0
```
The operator checks both files at startup and logs a `WARNING:` line
when either is unreadable, but it does not refuse to start: the
provider set is built all-or-nothing, so exiting here would crash-loop
the manager and take the `kubernetes` and `gcp` providers with it.
The files are read for real on the first connection. A missing file is
a transient failure, so proxies heal by themselves once the Secret
lands; a key that does not parse is permanent and needs a fix.
3. Apply a Proxy (`config/samples/proxy_kvm.yaml`) and watch it:
```sh
kubectl get px -w
# NAME MODE PROVIDER PHASE IP HEALTHY
# proxy-kvm-sample Managed kvm-home Provisioning
# proxy-kvm-sample Managed kvm-home Running 192.168.1.73
# proxy-kvm-sample Managed kvm-home Ready 192.168.1.73 True
```
The first Proxy for a given image on a given host sits in
`Provisioning` for the length of the image download; the operator log
shows `fetching base image into host pool` and `base image cached`
with the size and duration.
## Config reference
| Field | Required | Default | Meaning |
|---|---|---|---|
| `sshKeyFile` | yes | | private key used for every host |
| `knownHostsFile` | unless `insecureIgnoreHostKey` | | OpenSSH `known_hosts` to verify host keys against |
| `insecureIgnoreHostKey` | | `false` | skip host-key verification entirely |
| `diskSizeGb` | | `10` | capacity of each VM's overlay disk; cloud-init's growpart expands the root fs into it |
| `vendorData` | | | extra cloud-init vendor-data placed on every seed next to the Proxy's user-data. Beware that cloud-init drops any top-level key of a vendor-data **cloud-config** that the user-data also defines. Shipped as one part of a MIME multipart alongside the agent script when `installGuestAgent` is on |
| `installGuestAgent` | | `true` | add the vendor-data script that installs and starts `qemu-guest-agent`. Turn off for images that ship it already or cannot reach a package mirror; IP discovery then depends on the host's ARP table |
| `images.<key>` | | | the key names the image in `placement.image`. Letters, digits, dots, dashes and underscores only — it becomes the cached volume name `base-<key>.<format>` |
| `images.<key>.url` | yes per entry | | http(s) URL of a cloud image (qcow2 or raw) |
| `images.<key>.sha256` | | | expected digest; verified after download, a change triggers a re-download |
| `images.<key>.format` | | `qcow2` | `qcow2` or `raw` |
| `images.<key>.firmware` | | `uefi` | `uefi` (OVMF, secure boot off) or `bios` (SeaBIOS) for images without an EFI system partition; Debian 13 hangs in GRUB under BIOS on a headless VM, see *Known gaps* |
| `machineTypes.<key>` | ≥ 1 | | `{vcpus, memoryMiB}` size profile |
| `hosts[].name` | yes | | the `placement.zone` value that selects this host; unique. Letters, digits, dots, dashes and underscores only — it becomes part of the providerID `hosts/<name>/domains/<vm>` |
| `hosts[].address` | yes | | SSH endpoint, `host:port` |
| `hosts[].user` | yes | | SSH user, member of the `libvirt` group |
| `hosts[].socket` | | `/var/run/libvirt/libvirt-sock` | libvirt socket path on the host |
| `hosts[].pool` | | `default` | storage pool for base images, overlays and seeds |
| `hosts[].bridge` | yes | | host bridge for the VM's NIC |
## Placement
| Proxy field | Meaning for kvm |
|---|---|
| `placement.zone` | host name from `hosts`. May be omitted when exactly one host is configured; otherwise required. Unknown → `Failed` |
| `placement.machineType` | key in `machineTypes`. Unknown → `Failed` |
| `placement.image` | key in `images`, or an `http(s)://` URL used directly. Anything else → `Failed` |
| `placement.region` | ignored |
`Failed` here means the reconciler stops retrying and the message names
the offending value; fix the spec (which replaces the VM) or the config.
## What lands on the host
For a Proxy whose deterministic name is `proxy-abc…`, on the chosen
host's pool:
| Object | Name | Lifetime |
|---|---|---|
| base image | `base-<key>.qcow2` (or `base-<url-hash>.qcow2` for a URL) | cached; survives Proxy deletion |
| image marker | `base-<key>.qcow2.sha256` | with the base image |
| overlay disk | `proxy-abc….qcow2`, qcow2 backed by the base | with the Proxy |
| seed ISO | `proxy-abc…-seed.iso` | with the Proxy |
| domain | `proxy-abc…` | with the Proxy |
The domain is a q35 machine with host-passthrough CPU, the overlay on
virtio, the seed on a SATA cdrom, one virtio NIC on the bridge, a serial
console, the guest-agent channel, a virtio RNG and no graphics. Its
`<metadata>` carries the operator's element (managed flag, owning UID,
creation time) — that is how orphan GC recognises its own VMs, since
libvirt has no labels.
The seed's `user-data` is the Proxy's `cloudInit`, **byte for byte**. Any
format cloud-init accepts works, including a Jinja template, a MIME
archive, a shell script or a gzipped payload; the operator never parses or
re-renders it.
The guest agent rides along in `vendor-data`, as a **shell script** rather
than a cloud-config. That is the only shape cloud-init cannot drop:
vendor-data cloud-config is merged under the user's own document with
mergers that keep the user-data's value for every key both define and never
append lists, so a Proxy carrying its own `packages:` silently lost the
agent and sat in `Provisioning` forever. The strategy cannot be overridden
from either document — `merge_how` in user-data governs only the first
source, and `merge_how` in vendor-data is consumed and discarded before the
document is stored. A vendor-data script skips all of it: cloud-init gives
vendor-data its own handler chain, writes scripts to a separate directory
and runs them from `cc_scripts_vendor`, after the user's packages are
installed and before the user's `runcmd`. The script is idempotent, picks
whichever of `apt-get`, `dnf`, `yum`, `zypper` or `apk` the image has, and
logs into `/var/log/cloud-init-output.log`. Set `installGuestAgent: false`
to leave it out. When `vendorData` is also configured, the two travel as
separate parts of a MIME multipart vendor-data.
## The image cache
The base image is downloaded **by the operator, through libvirt**, into
the host's pool the first time a Proxy asks for it on that host — nothing
is pre-placed on the hypervisor, and the operator needs no filesystem
access there. The bytes stream from the URL through the operator into
the pool with a running sha256; nothing is buffered. The marker volume is
written last and holds the digest, so a crash mid-download leaves a base
volume without a marker, which the next attempt deletes and redoes.
A re-download happens when: the marker is missing, or `sha256` is
configured and differs from the marker's content. Otherwise the cached
copy is used for as long as it exists, even if the URL's content changed
upstream (a `latest` URL is pinned at the moment of first fetch, per
host).
To evict manually:
```sh
ssh virt@nas1 virsh -c qemu:///system vol-delete --pool default base-debian-13.qcow2.sha256
ssh virt@nas1 virsh -c qemu:///system vol-delete --pool default base-debian-13.qcow2
```
⚠️ **Do not change an image entry's `url` or `sha256`, and do not evict
its base volume, while Proxies using that image exist on the host.** The
overlays back onto the base file; deleting or replacing it corrupts every
VM that uses it. Delete those Proxies first, or add the new image under a
new key and move Proxies over by changing their `placement.image` (which
replaces the VM).
## Logging into a VM
- Put your key in the Proxy's cloud-init (`ssh_authorized_keys`) and
`ssh debian@<ip>` (Debian) / `ssh ubuntu@<ip>` (Ubuntu).
- Or on the host: `virsh console proxy-abc…` attaches to the serial
console; cloud-init's output scrolls by there on first boot.
- `virsh domifaddr proxy-abc… --source arp` / `--source agent` shows what
the operator sees when it looks for the IP.
## Logging
The provider logs through controller-runtime's logger, named `kvm`, with
`provider`, `host` and `name` (the domain) on every line.
- `--zap-log-level=debug` (logr V(1)): one line per provider operation
(`domain defined`, `KVM domain ensured running`, ...) **and one
`libvirt call` line per libvirt RPC**, named `kvm.libvirt`, with
`rpc` (the libvirt API function, e.g. `virDomainLookupByName`), the
identifiers it was given (`domain`, `pool`, `volume`), curated results
(`state`, `path`, `interfaces`), `duration`, and on failure `error`
plus `libvirtError` (the code name, e.g. `ErrNoDomain`) and
`libvirtCode`. Connection events (`libvirt host connected`, dial
failures, `libvirt connection dropped` with a reason) log at the same
level. A Create is about ten RPCs; a Get is four.
- `--zap-log-level=2`: the same lines also carry the domain and volume
`xml`, the `metadata` element, the interface `addresses` and the
domain `names` from a listing.
- Cloud-init is never logged at any level: the seed ISO upload reports
only `length` and `duration`. There is no raw wire tier like the GCP
provider's V(5); go-libvirt exposes no hook for it.
## Health check
The operator checks every configured host every `--provider-check-interval`
(default 1m). It publishes the result in two places: the `ProviderHealth`
object named after the provider (`kubectl get ph kvm-home -o yaml`), and the
`proxy_operator_provider_target_up{provider,target}` gauge.
All hosts are checked concurrently, each over the same pooled connection
that Create and Get use:
1. The SSH and libvirt handshakes. These only happen on the first check, or
after the link dropped.
2. `virConnectGetLibVersion` and `virConnectGetHostname`.
3. `virStoragePoolLookupByName` and `virStoragePoolGetInfo` on the host's
`pool`.
A passing target's `details` carry `hostname`, `libvirtVersion`, `pool`,
`poolState`, `poolCapacity` and `poolAvailable` (in GiB). The free space is
the closest thing this provider has to a capacity signal. Every target also
shows its SSH `user`. A failing target says where the check broke:
| Reason | Meaning | Look at |
|---|---|---|
| `SSHMaterialUnavailable` | the key or known_hosts file is missing or does not parse | the `kvm-ssh` Secret (see *Cluster-side setup*) |
| `Unreachable` | the TCP connect failed, or the link died mid-check | DNS, Tailscale or a firewall between the cluster and `address` |
| `SSHHandshakeFailed` | the host key is missing from known_hosts or has changed, or `user` rejected the key | the known_hosts entry; `user`'s `authorized_keys` |
| `LibvirtConnectFailed` | SSH worked, the libvirt socket did not | `user` in the `libvirt` group, `libvirtd` running, the `socket` path |
| `Timeout` | no answer within `--provider-check-timeout` (default 15s), or the previous check of the host is still waiting | a hung `libvirtd`, a saturated link, or a reconcile stuck redialing the host |
| `PoolNotFound` | there is no pool named `pool` | `virsh pool-list --all` |
| `PoolNotActive` | the pool exists but is not running | `virsh pool-start <pool>` and `virsh pool-autostart <pool>` |
| `LibvirtError` | any other libvirt error | the message carries libvirt's own text |
Sharing the connection has two consequences:
- The leader now keeps one SSH connection per host open from startup, with a
keepalive every 15s. Before, the first connection opened with the first KVM
reconcile.
- A check that runs out of time gives up on its RPCs but leaves the
connection alone, so an image upload or a Create running on it is not cut
off. At most one abandoned check per host keeps waiting; until it
returns, the next check reports `Timeout` straight away. A link that is
really dead is closed by the keepalive within about two keepalive
intervals.
- A check never waits longer than its timeout for the host's connection,
even while a reconcile holds it to redial a host that is down.
## Known gaps
- **No quota or capacity detection.** libvirt reports an out-of-space
pool or an over-committed host as an ordinary error; the reconciler
treats it as transient and retries with backoff rather than the slower
quota backoff. The health check does report each pool's free space
(`poolAvailable`), so a pool that is filling up shows before Creates
start failing.
- **A rotated `kvm-ssh` Secret needs a manager restart.** The key and
known_hosts files are read on first use and cached for the life of the
process. Replacing them in the Secret therefore changes nothing for
later connections, health checks included. After a host key change, the
check keeps reporting `SSHHandshakeFailed` until the manager restarts.
- **Orphan volumes without a domain are invisible to GC.** A crash
between creating the volumes and defining the domain leaves an overlay
and seed that `ListByTag` cannot see (it lists domains). They are
cleaned when the same Proxy reconciles again (Delete removes volumes by
name even with no domain), but a Proxy CR that is gone for good leaves
them for `virsh vol-list` to find.
- **A host removed from the config takes its VMs with it.** `Delete` for a
providerID naming an unconfigured host is a no-op success, so the Proxy
finishes deleting instead of pinning its finalizer, but the domain and its
volumes stay on the hypervisor and orphan GC cannot see them either (it
only sweeps configured hosts). Delete the Proxies before retiring a host,
or clean up afterwards with `virsh undefine --nvram` plus `virsh vol-delete`.
`Get` still fails permanently for such a providerID on purpose: a typo in
`hosts` must not look like a missing VM and trigger a rebuild elsewhere.
- **The first image fetch is synchronous.** It holds a reconcile worker
for the download (LAN speed, a few hundred MB, once per image per
host). Other Proxies keep reconciling on the remaining workers.
- **Replacing an image in use breaks running VMs** — see the warning
under *The image cache*.
- **IP discovery needs either guest traffic or the agent.** A VM on a
bridge with no DHCP never gets an address and stays `Provisioning`
forever; check `virsh console`. The same symptom with a booted, addressed
VM means the guest agent never came up: check `systemctl status
qemu-guest-agent`, `cloud-init status --long` and the vendor script's
output in `/var/log/cloud-init-output.log` (a VM without outbound
internet cannot install it). A user-data that disables vendor-data
(`vendor_data: {enabled: false}`) also removes the agent, by design.
- **Debian 13 cloud images hang in GRUB under BIOS on a headless VM**
([Debian bug #1111240](https://bugs.debian.org/1111240), open, in
`grub2`): trixie's image ships `terminal_output gfxterm serial` in its
GRUB config, and with a serial console but no video device (exactly
the domain this provider defines) GRUB prints "Booting" and never
hands over to the kernel. Not host-specific; reproduced by the Debian
cloud team. The same image boots under UEFI with the serial console
intact, which is why `firmware` defaults to `uefi`. Debian 12
(bookworm) images are unaffected and boot either way. Images that
need `firmware: bios` are the exception, not the rule.
## Testing
Unit tests run over an in-memory fake of the libvirt API. The optional
integration test against a real host is described in
[testing.md](testing.md#the-kvm-integration-test-real-hypervisor); its
`-v` output includes the `libvirt call` trace above, at the verbosity
`KVM_TEST_LOG_LEVEL` selects (default 1). `TestKVMCheck_realHost` runs the
health check against the same host and creates nothing:
`go test -tags=integration -count=1 -v -run TestKVMCheck ./internal/provider/kvm/`.
+665
View File
@@ -0,0 +1,665 @@
# Provider spec: kvm2
Filled from [provider-spec-template.md](provider-spec-template.md) against
[provider-contract.md](provider-contract.md). This is the design the `kvm2`
package is built from. Where an entry names an axis in backticks, the answer is
one of that axis's profiles in contract §4. Nothing in this spec was taken from
the code of the existing `kvm` provider; backend facts come from
[kvm-provider.md](kvm-provider.md), the execution log of the first build, the
go-libvirt module in the module cache, and the beelink host.
## Header
- **Type**: `kvm2`
- **Example configured name**: `kvm2-home`
- **Backend and API version this spec was written against**: libvirt 11.6.0 and
QEMU 10.1.0 on Ubuntu (host `beelink`), reached with go-libvirt
`github.com/digitalocean/go-libvirt v0.0.0-20260814190004-1a83157e1858` over an
SSH-forwarded unix socket.
- **Client**: `rpc-over-ssh`. go-libvirt's RPC client (`libvirt.New(conn)`
then `Connect()`) over a `direct-streamlocal` channel opened with
`golang.org/x/crypto/ssh` to the host's libvirt socket. Both modules are
already direct requires in `go.mod`; no new dependency.
- **Author and date**: Claude (orchestrator session), 2026-09-16.
## 1. Identity and providerID
**Answer:** `hosts/<host>/domains/<name>`, where `<host>` is `hosts[].name`
from the config block and `<name>` is `req.Name`. Parsing is strict: exactly
four segments, literal `hosts` and `domains`, non-empty host and name, no
further slashes; anything else is `ErrPermanent` from `Get` and `Delete`. Host
names are validated at config load by the shared `kvmIdentifier` regexp in
`internal/provider/config_kvm.go` (letters, digits, dots, dashes, underscores),
so a host name can never contain the separator. Instances do not migrate
between hosts (libvirt domains are per host), so the location belongs in the
ID.
**Evidence:** contract §6; `kvmIdentifier` in `internal/provider/config_kvm.go`.
## 2. Primary key (`primary-key`)
**Answer:** `name-is-key`. libvirt addresses domains by name
(`DomainLookupByName`); `req.Name` is used verbatim as the domain name and as
the base of the per-VM volume names. Create looks the name up first and adopts
an existing domain only if it carries the kvm2 mark (`managed` true, entry 8)
and its metadata `uid` is empty or equals `req.UID`. A domain without the
mark returns `ErrPermanent` saying it was not created by this provider: kvm2
never starts or takes over a VM someone else defined under that name. A
different non-empty `uid` returns `ErrPermanent` naming both UIDs. This
closes the contract's "adoption is not verified" limit for this provider.
**Evidence:** `DomainLookupByName(Name string) (Domain, error)` in the
go-libvirt module; contract §2.1 idempotency and §13.
## 3. Placement mapping
**Answer:** three fields are catalogue keys checked before any backend call, one is ignored:
| Field | Meaning | Required | Unknown value |
|---|---|---|---|
| `Zone` | a `hosts[].name` | only when more than one host is configured; with exactly one host an empty zone selects it | `ErrPermanent`: `placement.zone "x" is not in kvm2.hosts` |
| `MachineType` | a key of `machineTypes` (`vcpus`, `memoryMiB`) | yes | `ErrPermanent`: `placement.machineType "x" is not in kvm2.machineTypes` |
| `Image` | a key of `images`, or an absolute http(s) URL used directly | yes | `ErrPermanent`: `placement.image "x" is neither a key in kvm2.images nor an http(s) URL` |
| `Region` | ignored | | still part of the spec hash: changing it replaces the VM |
All three checks run before any backend call. The guide documents the ignored
field and that catalogue edits affect only new VMs.
**Evidence:** contract §2.1 (reject early, MAY ignore with documentation);
`IsHTTPURL` in `internal/provider/config_kvm.go`.
## 4. Image source
**Answer:** a catalogue in the config block (`images.<key>.url`, optional
`sha256`, `format` qcow2 or raw, `firmware` uefi or bios) or a URL given
directly in `placement.image`. The provider downloads the image into the
host's storage pool on first use and caches it there as a base volume named
`base-<entry>-<key>.<format>` (for a URL: `<key>` is the first 16 hex of
sha256(url)), where `<entry>` is the provider entry's name, with a sidecar
marker volume `base-<entry>-<key>.<format>.sha256` holding the digest of
the verified upload, written last. The entry is in the name because the `kvm`
provider builds `base-<key>.<format>` in the same pool from the same catalogue
shape, and two entries pointing one key at different URLs would otherwise share
a file; the cost is one copy per entry per host, and the entry's name is checked
against `kvmIdentifier` at construction. A base volume without a marker is
incomplete and is deleted and redone. When `sha256` is configured and differs
from the marker's content, the image is **not** replaced: that is `ErrPermanent`
naming both digests, because overlays back onto the base file and libvirt
unlinks it under running domains without complaint. The download streams
from the URL through the operator into `StorageVolUpload` with a running
sha256; nothing is buffered. `Content-Length` is required because the volume's
capacity must be declared before the upload; a response without it is
`ErrPermanent` naming the URL. Per `(host, volume)` a keyed mutex makes
concurrent Creates download once. The download runs on a dedicated
connection to the host, not the pooled one, so a cancelled poll on another
Proxy cannot cut it off. After a successful upload `StoragePoolRefresh` makes
the file usable as a backing image.
Catalogue edit warning for the guide: overlays back onto the base file, so
evicting a base volume while Proxies use it corrupts those VMs. A `sha256`
change is refused for that reason; a `url` change is a silent no-op, since the
cache is keyed by entry and key. Add a new key for either.
**Evidence:** kvm-provider.md "The image cache" and "Known gaps"; execution log
of 2026-09-04 (raw qcow2 upload plus pool refresh accepted by libvirt 11.6;
435 MB trixie image); contract §1.4 (stream, no buffering) and §2.1
(catalogue changes).
## 5. Cloud-init delivery
**Answer:** a NoCloud seed ISO built in-process with
`github.com/kdomanski/iso9660`, volume label `cidata`, files `user-data` (the
Proxy's `CloudInit`, byte for byte, never parsed), `meta-data`
(`instance-id: <name>` and `local-hostname: <name>`, so a reboot is never taken
for a new instance) and `vendor-data`. The ISO is written into a temp
directory created under the default temp dir (`/tmp`, a 64Mi emptyDir in the
manager), uploaded as a raw volume `<name>-seed.iso` and attached as a CD-ROM.
The seed is rebuilt on every Create attempt: a previous attempt may have been
cut off mid-upload, and a truncated seed would boot a VM without cloud-init.
`vendor-data` carries a shell script that installs and starts
`qemu-guest-agent` with whichever of apt-get, dnf, yum, zypper or apk the image
has. It is a script and not a cloud-config on purpose: cloud-init merges a
vendor-data cloud-config under the user-data with mergers that keep the
user-data's value for every top-level key both define and never append lists,
so a Proxy with its own `packages:` would silently lose the agent. Scripts
bypass the merge. When the config's `vendorData` is set, the two travel as
separate parts of a MIME multipart (`text/plain` for the configured document,
`text/x-shellscript` for the agent script), headed by `MIME-Version` within
the first bytes. With `installGuestAgent: false` and no `vendorData` the file
is empty. Size: cloud-init has no practical limit for NoCloud; the ISO is a
few hundred KB.
**Evidence:** kvm-provider.md "What lands on the host"; execution log run 7
(vendor-data as YAML silently dropped) and run 8 (script survives); contract
§2.1 (byte for byte, never logged) and §11 (sentinel test).
## 6. Machine type mapping
**Answer:** a catalogue: `machineTypes.<key>` is `{vcpus, memoryMiB}`,
validated at load (both greater than zero, at least one profile). Disk size is
provider-wide: `diskSizeGb` (default 10) is the capacity of every VM's overlay
volume, thin-provisioned; cloud-init's growpart expands the root filesystem
into it.
**Evidence:** `KVMMachineType` and `validateKVM` in
`internal/provider/config_kvm.go`.
## 7. Network and IP discovery (`readiness`, `ip-source`)
**Answer:** `readiness`: `provisioning-window` (boot plus cloud-init, plus the
image download on a cold pool). `ip-source`: `discovered`. The VM gets one
virtio NIC on `hosts[].bridge`, which must lead to a LAN with DHCP that the
cluster can route to. The address comes from `DomainInterfaceAddresses`, guest
agent source first, then the ARP table (on beelink the agent answered about
20 s after start where ARP never did, because the host itself does not talk to
the guest). The first IPv4 that is not loopback, link-local or unspecified is
reported. One source failing means "no address yet" and `Get` returns
`StateProvisioning`, which is the ordinary boot on beelink: the agent is not up
while ARP answers with nothing. A connection already gone, or every source
failing, is `ErrTransient` instead — a running VM whose address cannot be read
is not a VM without an address, and reporting it as one would make the
reconciler clear its IP and the health engine drop its counters. The guest needs the agent: it is installed by the
vendor-data script (entry 5) unless `installGuestAgent: false`, which needs an
image that ships it or a network where ARP sees guest traffic. `Port` is not
used by the provider; nothing on the host filters it. Reachability is a
prerequisite in the guide: the operator's pod network and the clients must
route to the bridge's LAN.
**Evidence:** kvm-provider.md "Hypervisor prerequisites" and "Known gaps"
(IP discovery); execution log run 6 (agent 20 s, ARP never); contract §1.3
and §2.2 (Running only with IP).
## 8. GC marks and CreatedAt (`gc-mark`, `created-at`)
**Answer:** `gc-mark`: `written-metadata`. `created-at`: `written-at-create`.
The domain XML passed to `DomainDefineXML` carries, under `<metadata>`, an
element `<instance xmlns="https://gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/kvm2">`
with children `<managed>true</managed>`, `<uid>req.UID</uid>` and
`<createdAt>RFC3339 UTC</createdAt>`. The mark lands in the same call that
defines the domain, so there is no mark window. Read back with
`DomainGetMetadata(dom, DomainMetadataElement, uri, DomainAffectCurrent)`;
libvirt 11.6 returns the element without its namespace declaration, so the
parser matches on the local name `instance` only (the URI was already used to
select it). A domain without the element is not managed and reports an empty
UID. A `createdAt` that does not parse gives the zero time, which GC treats as
old; that is accepted. The volumes carry no mark (documented GC blind spot,
entry 24). The namespace is kvm2's own, so kvm2 never lists or adopts domains
made by the `kvm` provider; the two types may share a host.
**Evidence:** execution log run 3 (namespace asymmetry);
`DomainGetMetadata(Dom Domain, Type int32, Uri OptString, Flags DomainModificationImpact)`
in the go-libvirt module; contract §2.1 (mark in the create call) and §4.5.
## 9. State mapping (`provisioning-state`)
**Answer:** `provisioning-state`: `create-blocks`. Create defines and starts the
domain in one call and returns only once `DomainGetState` reports running, so
`Get` never sees a domain kvm2 defined but did not start. A domain that is
shut off later stopped on its own or was stopped by someone, and Stopped is
the right answer.
| Backend state (`DomainGetState`) | Lock or task | Provider state |
|---|---|---|
| nostate (0) | none | Provisioning (libvirt could not determine it; next poll decides) |
| running (1), blocked (2) | no IP found | Provisioning |
| running (1), blocked (2) | IP found | Running |
| paused (3), shutdown (4), shutoff (5), crashed (6), pmsuspended (7) | none | Stopped |
| any other code | none | Stopped |
libvirt has no locks or tasks; every RPC is synchronous.
**Evidence:** go-libvirt `DomainState` constants; contract §2.2 and §4.11.
## 10. Create: blocking and task tracking (`create-return`, `async-ops`)
**Answer:** `create-return`: `blocking`. `async-ops`: `none`. Create returns
when the domain is running, holding one reconcile worker for the duration,
including the first image download on a host (minutes on a cold pool; the
guide says so). The wait after `DomainCreate` is capped at 15 seconds:
`DomainCreate` is synchronous in libvirt, so the first `DomainGetState`
normally answers running and the cap only covers state settling. Two rules
keep the wait from pinning a worker: a state after the start that is neither
active nor `nostate` (shutoff, crashed, paused, pmsuspended, shutdown) is
`ErrTransient` at once, since polling will not bring that guest up; and a
domain found paused or pmsuspended before the start is `ErrPermanent`
(`cannot be started by the operator; resume or destroy it on the host`), an
admin's doing that `DomainCreate` would refuse and the reconciler must not
retry. Every RPC has its own deadline (entry 18).
Steps, each idempotent, in order, all under the per-name lock (entry 19):
1. Validate placement (entry 3). No backend call yet.
2. `DomainLookupByName`. Found: adoption guard (entry 2), then start it if it
is not running (paused or pmsuspended is `ErrPermanent`; `DomainCreate`;
`ErrOperationInvalid` means already running), poll as in step 9, return
the ID.
3. Pool space precheck (entry 13, quota).
4. Ensure the base image (entry 4): marker present and matching means done.
5. Overlay volume `<name>.qcow2`, qcow2 backed by the base file with
`diskSizeGb` capacity; `ErrStorageVolExist` means reuse (creation is
atomic, so an existing overlay is complete).
6. Seed volume `<name>-seed.iso`: delete if it exists, create, upload
(entry 5). Always rebuilt.
7. `StorageVolGetPath` for the overlay and the seed.
8. `DomainDefineXML` with file-type disks (entry 15's AppArmor note), the
metadata element, firmware per image.
9. `DomainCreate`, then poll `DomainGetState` until running: `nostate` is
polled again, any other inactive state is `ErrTransient` at once, and the
15-second cap is `ErrTransient` too.
10. Return `hosts/<host>/domains/<name>`.
A retry after a crash finds whatever the previous attempt got to: an existing
overlay is reused, a seed is rebuilt, a defined domain is started, a running
domain is returned. `ErrNoDomain` and `ErrNoStorageVol` inside Create (a
volume vanishing between steps) are `ErrTransient`, never `ErrNotFound`. The
log line for a Create records `cloudInitBytes`, never the content.
**Evidence:** contract §2.1, §4.2, §5 ("do not return NotFound from Create");
execution log run 1 (AppArmor).
## 11. Delete and secondary resources (`delete-return`, `secondary-resources`)
**Answer:** `delete-return`: `waits`. `secondary-resources`: `explicit`. Under
the per-name lock:
1. Parse the ID (`ErrPermanent` if malformed). A host no longer in the config
is a logged no-op success: the provider cannot reach it and refusing would
pin the finalizer; the guide tells admins to delete Proxies before retiring
a host.
2. `DomainLookupByName`; `ErrNoDomain` means the domain is already gone, skip
to step 5.
3. `DomainDestroy`; `ErrOperationInvalid` (not running) and `ErrNoDomain` are
success.
4. `DomainUndefineFlags` with `DomainUndefineNvram`,
`DomainUndefineManagedSave` and `DomainUndefineSnapshotsMetadata`
(UEFI domains keep NVRAM; undefine refuses without the flag); `ErrNoDomain`
is success.
5. Delete `<name>.qcow2` and `<name>-seed.iso` by name from the host's pool;
`ErrNoStorageVol` is success, and `ErrNoStoragePool` means nothing is left
to clean.
6. Return nil. The base image and its marker stay (shared cache).
Everything is gone when Delete returns nil, so `Get` afterwards is NotFound.
A second Delete during the first waits on the lock and then finds nothing.
Volumes that exist without a domain (a crash between step 5 and 8 of Create)
are removed by the same Proxy's next Delete but are invisible to GC (entry 24).
**Evidence:** kvm-provider.md "Known gaps"; execution log run 4 (NVRAM);
contract §2.3.
## 12. ListByTag scope and partial failure (`list-partial`)
**Answer:** `fail-whole`, per host, with single unreadable domains skipped.
Scope: every host in `hosts`, in config order, `ConnectListAllDomains` with
active and inactive domains. For each domain, read the metadata element; keep
those with `managed` true and build the `Instance` from the domain name and
the element alone: `ID`, `UID` and `CreatedAt`, which is all GC reads
(contract §2.4). `State` and `IP` are left empty; no state or address RPC is
made. A domain whose metadata cannot be read (mid teardown, corrupt element)
is skipped with a V(1) line, unless ctx ended, which fails the call; a host
that cannot be dialled or whose listing fails fails the whole call, so GC
skips this provider for the sweep rather than act on a partial view.
Filtering is client-side; libvirt has no label index. Cost: exactly one
`DomainGetMetadata` per domain per sweep, about half a millisecond each over
the tunnel.
**Evidence:** execution log run 6 (per-domain metadata cost); contract §2.4
and §4.7.
## 13. Error classification (`not-found`, `quota`)
**Answer:** `not-found`: `error-code`. `quota`: `detected`, from a pool-space
precheck rather than a backend error: before creating any volume, Create reads
`StoragePoolGetInfo` and compares `available` with the bytes it is about to
commit: the image's `Content-Length` when the base is not cached, plus
`diskSizeGb` in bytes, plus 16 MiB for the seed. Below that it returns
`ErrQuotaExceeded` naming the pool, available and needed, and the reconciler
waits `QuotaRetry`.
One `classify(op string, err error)`:
| Backend response | Class | Why |
|---|---|---|
| `libvirt.Error` code `ErrNoDomain`, `ErrNoStorageVol` | NotFound (Transient when `op == "create"`) | the object is gone; inside Create it vanished mid-call |
| `ErrNoStoragePool` | Permanent | config names a pool that does not exist |
| `ErrXMLError`, `ErrNoSupport`, `ErrInvalidArg` | Permanent | the request itself is wrong |
| `ErrAuthFailed`, `ErrAccessDenied` | Permanent | the SSH user may not use the socket |
| `ErrOperationInvalid` | Transient (Delete and Create treat the specific cases as success before classify) | state race; the next poll decides |
| any other `libvirt.Error` | Transient | unknown means retry |
| SSH: `ssh: unable to authenticate` (from `x/crypto/ssh`) | Permanent | the key is not in `authorized_keys` |
| SSH: `knownhosts.KeyError` with a known key that differs, or with no known key | Permanent | host key changed or missing from known_hosts; the admin must act |
| SSH key or known_hosts file missing (`fs.ErrNotExist`) | Transient | the Secret has not landed yet |
| SSH key present but does not parse | Permanent | wrong file in the Secret |
| `context.Canceled`, `context.DeadlineExceeded`, `net.OpError`, `io.EOF`, `io.ErrUnexpectedEOF` | Transient | network or cancellation |
| HTTP 404, 403, 410 on the image URL; missing `Content-Length`; sha256 mismatch | Permanent | the catalogue entry is wrong |
| HTTP 5xx, 429, network errors on the image URL | Transient | retry |
| an error already wrapped with a class (`errors.Is` any sentinel) | that class | validation and builders classify at the source |
| anything else | Transient | contract default |
The unit test table uses a `libvirt.Error` per code, the real SSH error
strings produced by `x/crypto/ssh` against the in-process server, and a real
`knownhosts.KeyError`.
**Evidence:** go-libvirt `ErrorNumber` constants and `Error{Code uint32, Message}`
(value receiver, so `errors.As` uses a `libvirt.Error` value); contract §5;
kvm-provider.md "Known gaps" (SSH auth retried forever in v1: closed here).
## 14. Credentials and TLS (`credentials`)
**Answer:** `mounted-files`. The config block names `sshKeyFile` (private key,
OpenSSH or PEM, parsed with `ssh.ParsePrivateKey`) and `knownHostsFile`
(OpenSSH `known_hosts`, parsed with `knownhosts.New`), both expected under the
existing optional `kvm-ssh` Secret mount at `/etc/proxy-operator/kvm`
(`config/manager/manager.yaml`; the mount is shared with the `kvm` provider and
its comment says so). `insecureIgnoreHostKey: true` replaces known_hosts with
`ssh.InsecureIgnoreHostKey()` and is logged as a warning by `New`. Both files
are read on every dial, so rotating the Secret takes effect on the next
connection without a restart. `New` tries to read them once to log a warning
when they are unusable, but never fails on them and never dials.
Minimum privilege: the SSH user must be able to open the libvirt socket
(member of the `libvirt` group); libvirt itself is used as `qemu:///system`
through the socket, so no further host permissions.
TLS: none (SSH provides transport security); `system-roots` for the image
download's HTTPS, which uses the distroless image's CA bundle.
Hygiene: the private key bytes and the known_hosts content never appear in
logs, spans or error strings; errors from parsing say "parsing ssh key file
<path>" plus the parser's message. Tested with a sentinel placed in a bad key
file.
**Evidence:** kvm-provider.md "Cluster-side setup"; execution log run 8
(lazy load: a missing Secret must not abort the provider set); contract §7.
## 15. Config block
**Answer:** the block is `kvm2:` and reuses the Go type `provider.KVMConfig`
from `internal/provider/config_kvm.go` unchanged, so an existing `kvm-home`
entry ports by changing `type: kvm` to `type: kvm2` and `kvm:` to `kvm2:`.
Validation calls the shared validator with the block name `kvm2` so messages
read `kvm2.hosts` and not `kvm.hosts`.
| Field | Required | Default | Validation | Ends up in |
|---|---|---|---|---|
| `sshKeyFile` | yes | | non-empty | dial |
| `knownHostsFile` | unless `insecureIgnoreHostKey` | | non-empty | dial |
| `insecureIgnoreHostKey` | | false | | dial, warning at `New` |
| `diskSizeGb` | | 10 | not negative | overlay capacity, quota precheck |
| `vendorData` | | | | seed `vendor-data` |
| `installGuestAgent` | | true | | seed `vendor-data` |
| `images.<key>` | | | key matches `kvmIdentifier` | volume name `base-<entry>-<key>.<format>` |
| `images.<key>.url` | yes per entry | | http(s) URL | download |
| `images.<key>.sha256` | | | 64 hex | marker comparison |
| `images.<key>.format` | | qcow2 | qcow2 or raw | base volume XML, overlay backing format |
| `images.<key>.firmware` | | uefi | uefi or bios | domain XML `<os>` |
| `machineTypes.<key>` | at least one | | vcpus and memoryMiB greater than zero | domain XML |
| `hosts[].name` | yes | | unique, matches `kvmIdentifier` | providerID segment |
| `hosts[].address` | yes | | non-empty `host:port` | TCP dial |
| `hosts[].user` | yes | | non-empty | SSH |
| `hosts[].socket` | | `/var/run/libvirt/libvirt-sock` | | SSH channel target |
| `hosts[].pool` | | `default` | | every storage RPC |
| `hosts[].bridge` | yes | | non-empty | domain XML NIC |
Wiring: `ProviderConfig.KVM2 *KVMConfig` with tag `kvm2,omitempty`;
`setBlocks` reports `kvm2`; the allow-list in `Config.validate` and the
constructor map in `cmd/main.go` both gain `kvm2` in the same commit.
**Evidence:** `KVMConfig`, `validateKVM`, `kvmIdentifier` in
`internal/provider/config_kvm.go`; contract §10.
## 16. Checker
**Answer:** one target per `hosts[]` entry, `Name` the host name, `Address`
the SSH address, checked concurrently in config order on a fresh dedicated
connection each time (dial, `ConnectGetLibVersion`, `ConnectGetHostname`,
`StoragePoolLookupByName`, `StoragePoolGetInfo`, close). It never touches the
pooled connection, so a check that runs out of time closes only its own
connection and cannot disrupt a reconcile. Cost: one SSH handshake and four
cheap RPCs per host per round.
Reasons: `Reachable`; `Timeout` (the round's ctx ended before the check
finished, whichever stage was running, the dial included, or an RPC's own
budget ran out once the connection was up); `Unreachable` (TCP connect or the
SSH transport failed, including on the dial's own 10 s budget while the round
still had time, or the link died mid-check); `Unauthorized` (SSH authentication refused, host key unknown or
changed); `Forbidden` (`ErrAccessDenied` or `ErrAuthFailed` from libvirt: the
socket refused the user); `APIError` (any other libvirt error);
`CredentialsUnavailable` (the key or known_hosts file is missing or
unparsable, i.e. the Secret is absent or wrong: fixed in the cluster, not on
the host); `PoolNotFound`; `PoolNotActive`.
Details on success: `user`, `hostname`, `libvirtVersion` (decoded
major.minor.micro), `pool`, `poolState`, `poolCapacityGiB`, `poolAvailableGiB`
(the closest thing to a capacity signal, and the same number the quota
precheck reads). The `status.type` comment in
`api/v1alpha1/providerhealth_types.go` gains `kvm2` and the CRD is
regenerated.
**Evidence:** contract §8; `StoragePoolGetInfo` returning state, capacity,
allocation, available; `ConnectGetLibVersion` returning major*1e6 + minor*1e3
+ micro.
## 17. Client and child spans (`client-kind`, `child-spans`)
**Answer:** `client-kind`: `rpc-over-ssh`. `child-spans`: `span-decorator`.
The seam is an interface over exactly the go-libvirt methods the provider
uses, with `ctx` as the first parameter of every method (go-libvirt's own
methods take none and block): `DomainLookupByName`, `DomainDefineXML`,
`DomainCreate`, `DomainDestroy`, `DomainUndefineFlags`, `DomainGetState`,
`DomainGetMetadata`, `DomainInterfaceAddresses`, `ConnectListAllDomains`,
`StoragePoolLookupByName`, `StoragePoolRefresh`, `StoragePoolGetInfo`,
`StorageVolLookupByName`, `StorageVolGetPath`, `StorageVolCreateXML`,
`StorageVolUpload`, `StorageVolDownload`, `StorageVolDelete`,
`ConnectGetLibVersion`, `ConnectGetHostname`, plus `Close`. One production
adapter wraps `*libvirt.Libvirt` and applies the per-call deadline
(entry 18); one in-memory fake implements the seam for unit tests. A dialer
seam (`dial(ctx, host) (backend, error)`) separates the SSH transport so tests
inject fakes and an in-process SSH server test covers the real dialer.
The decorator is applied once, inside the real dialer, at the point where a
connection is created, so provider code never sees an undecorated backend. Per
call it starts a span `kvm2.<Method>` (for example `kvm2.DomainDefineXML`) with
attributes `kvm2.host` and the identifiers passed (`kvm2.domain`, `kvm2.pool`,
`kvm2.volume`), records the libvirt error code on failure, and inside the span
writes the V(1) log line, so the line carries that span's ID. Two more spans:
`kvm2.dial` around the SSH handshake and libvirt connect (attributes address
and user) and `kvm2.download` around an image fetch (attributes URL host,
bytes, whether a sha256 was configured). All spans are children of the
decorator's `provider.create`, `provider.get`, `provider.delete`,
`provider.list` or the monitor's `providerhealth.check`. No CloudInit, key
material or upload bodies in attributes. A reflection test walks the seam
interface and asserts every method produces exactly one span and one log
line.
**Evidence:** contract §4.15, §4.18, §9; `tracing.Start` in
`internal/tracing/logger.go`.
## 18. Timeouts (`timeouts`)
**Answer:** `per-call`. The adapter runs each blocking go-libvirt call in a
goroutine and waits on the result, `ctx.Done()` and a timer:
| Call | Budget |
|---|---|
| TCP connect plus SSH handshake plus libvirt connect | 10 s |
| ordinary RPC | 30 s |
| `DomainCreate` | 90 s |
| `StorageVolUpload` (seed) | 60 s |
| `StorageVolUpload` (image), HTTP download | ctx, capped at 20 m |
| wait for running after `DomainCreate` | 15 s (entry 10) |
| HTTP client for images | no client timeout; the per-request ctx carries the cap |
When a call is abandoned (ctx cancelled or budget exceeded), the adapter
closes the underlying connection, because that is the only way to unblock
go-libvirt, and marks it dead so the next call redials; the abandoned call
returns `ErrTransient` wrapping the ctx or deadline error. Consequence,
documented in the guide: other RPCs in flight on that host's pooled connection
fail once with Transient and their callers retry. The image download uses a
dedicated connection so the expensive case (a cancelled poll killing a
435 MB upload) cannot happen. The keepalive (`keepalive@openssh.com` request
every 15 s) marks the connection dead on failure.
**Evidence:** contract §1.5 (no caller deadline; a hung ListByTag stalls GC),
§4.16; go-libvirt methods take no ctx.
## 19. Concurrency
**Answer:** shared state is the host connection table (one pooled connection
per host, dialled lazily, under a mutex with a per-host dial mutex so
concurrent callers dial once and share the result), the per-`(host, volume)`
download locks, and a lock per instance name. Both lock sets are one instance
each of `keyedlock.Locks` (`internal/provider/keyedlock`): a one-slot semaphore per key, so a waiter gives up when
its ctx ends, reference-counted so an entry is removed from the map once no
caller holds or waits for it and the map does not grow with every name ever
seen, while two waiters on one key always share one semaphore. Create and
Delete for the same name run under that name's lock, so two Deletes (GC and
the reconciler) never race on destroy and undefine, and a Delete cannot
interleave with a Create's steps. Get and ListByTag take no name lock. go-libvirt multiplexes
RPCs on one connection, so calls for different names proceed concurrently
on the shared connection. Three reconciles, a GC sweep and a check round at
once are safe; the check uses its own connections.
**Evidence:** contract §1.2 (concurrency), §4.15.
## 20. Logging (`logging`)
**Answer:** `tiered`. Logger from `logf.FromContext(ctx)` on every call,
named `kvm2`, with `provider` on every line and `host` and `name` on every
line about an instance.
- V(1): one line per operation outcome (`kvm2 domain ensured running`,
`kvm2 domain fetched` with state, ip and uid, `kvm2 domain deleted`,
`kvm2 domains listed` with counts), one `libvirt call` line per RPC from the
decorator (`rpc`, identifiers, `duration`, on failure `error`, `libvirtCode`
and `libvirtError` name), connection events (dialled, dropped with reason),
and a line per failed backend call with the raw error.
- V(2): the same lines also carry domain and volume XML, the metadata element,
interface addresses, listed domain names, and the resolved Create parameters
(`cloudInitBytes`, never the content).
- No V(5) wire tier; go-libvirt exposes no hook for it.
- Never logged at any level: `CloudInit`, key material, known_hosts content,
upload or download bodies.
**Evidence:** contract §4.17, §9.
## 21. Tests (`backend-test`)
**Answer:** `backend-test`: `integration-tag`. Unit tests run over the
in-memory fake behind the seam (and an in-process `x/crypto/ssh` server for
the dialer); the integration test is `kvm2_integration_test.go` behind
`//go:build integration`, skipped unless `KVM2_TEST_HOST`, `KVM2_TEST_USER`,
`KVM2_TEST_SSH_KEY`, `KVM2_TEST_BRIDGE` and `KVM2_TEST_IMAGE_URL` are set,
optional `KVM2_TEST_KNOWN_HOSTS` (without it host keys are not verified and the
log says so), `KVM2_TEST_IMAGE_SHA256`, `KVM2_TEST_POOL`, `KVM2_TEST_SOCKET`,
`KVM2_TEST_TIMEOUT` (default 20m), `KVM2_TEST_KEEP`, `KVM2_TEST_LOG_LEVEL`;
run with `make test-integration-kvm2`. It asserts, in order: `New` returns
without dialling; `Check` reports the host Ready with details; `Create`
returns `hosts/<host>/domains/<name>`; a second `Create` returns the same ID
and the host's domain count is unchanged; `Get` reaches Running with an IPv4
within the budget and its `UID` and `CreatedAt` equal what Create wrote;
`ListByTag` contains it; `Delete` returns nil; `Get` is `ErrNotFound`;
`Delete` again returns nil; the pool holds the base image and its marker and
no `<name>.qcow2` or `<name>-seed.iso`; with an in-memory span recorder,
`kvm2.DomainDefineXML` and `kvm2.dial` spans are children of
`provider.create`.
Contract §11 list, with the test that covers each item:
- [x] repeat `Create`: `TestCreate_repeatReturnsSameIDAndCreatesNothing`
- [x] `Get` after `Delete`: `TestGet_afterDeleteReturnsNotFound`
- [x] `Delete` twice: `TestDelete_twiceSucceeds`
- [x] malformed ID: `TestGet_malformedIDIsPermanent`, `TestDelete_malformedIDIsPermanent`, `TestParseProviderID_malformed`
- [x] unknown state maps to `StateStopped`: `TestMapState_table`
- [x] `StateRunning` requires an IP: `TestGet_runningWithoutIPIsProvisioning`, `TestGet_runningWithIPIsRunning`
- [x] `UID` and `CreatedAt` read back: `TestGet_readsUIDAndCreatedAtFromBackend`
- [x] `ListByTag` excludes unmarked instances: `TestListByTag_excludesUnmarkedDomains`, `TestListByTag_emptyUIDStillListed`
- [x] `Get` during an in-progress delete: `TestGet_duringDeleteStillReturnsInstance`
- [x] `Get` during an asynchronous operation or lock: `TestGet_backendBusyIsTransientNeverNotFound`
- [x] never started is never `StateStopped`: `TestCreate_returnsOnlyWhenRunning`
- [x] cancelled `ctx` returns promptly: `TestProvider_cancelledContextReturnsPromptly`, `TestCall_cancelUnblocksRPC`
- [x] `classify` table with captured responses: `TestClassify_table`
- [x] Checker reasons, expired `ctx`, stable order: `TestCheck_reasonsTable`, `TestCheck_expiredContextReturnsPromptly`, `TestCheck_oneEntryPerHostInConfigOrder`
- [x] sentinel tests for `CloudInit` and the credential: `TestCreate_cloudInitSentinelNeverLoggedOrInErrors`, `TestSSH_keyMaterialNeverInErrorsOrLogs`
- [x] child spans under the operation span: `TestObserve_childSpansUnderParent`, `TestObserve_everySeamMethodInstrumented`
- [x] real-backend lifecycle test: `TestKVM2_lifecycle` and `TestKVM2Check_realHost` in `kvm2_integration_test.go`
Further tests per file are listed in the plan
(`docs/plans/2026-09-16-1333-kvm2-provider.md`).
**Evidence:** contract §11; docs/testing.md conventions.
## 22. Deployment
**Answer:** the existing `kvm-ssh` mount is reused; the rest is samples and a Makefile target:
- `config/manager/manager.yaml`: no new mount; the existing optional `kvm-ssh`
Secret at `/etc/proxy-operator/kvm` serves both `kvm` and `kvm2`, and its
comment says so. `/tmp` is already an emptyDir.
- `config/manager/providers_config.yaml` and
`config/samples/providers-config.yaml`: a commented `kvm2-home` entry, the
same block as `kvm-home` under `kvm2:`.
- `config/samples/proxy_kvm2.yaml`, listed in `config/samples/kustomization.yaml`:
provider `kvm2-home`, zone `beelink` (matching the shipped config, unlike the
`kvm` sample's `nas1`), machine type `small`, image `debian-13`, a cloud-init
that installs Squid.
- Makefile: `test-integration-kvm2` (`go test -race -tags=integration -count=1
-v -timeout 40m -run TestKVM2 ./internal/provider/kvm2/`). The Secret helper
`make kvm-ssh-secret` already exists and is reused.
- RBAC: none; the provider does not talk to the cluster's API.
- `hack/providers-dev.yaml`: no entry (needs credentials).
**Evidence:** contract §12; `config/manager/manager.yaml`.
## 23. Docs
**Answer:** `docs/kvm2-provider.md` (hypervisor prerequisites including OVMF
and the `libvirt` group and a bridge with DHCP; cluster-side setup reusing the
`kvm-ssh` Secret; config reference; placement mapping; what lands on the host
including the metadata namespace; the image cache and the quota precheck; the
health check reason table; logging tiers and span names; migrating an entry
from `kvm`, which requires deleting its Proxies first because kvm2 does not
adopt v1 domains; known gaps). A fourth column in the three tables of
`docs/providers-existing.md` and a kvm2 section. `docs/architecture.md`:
components row, a `kvm2` block in §5, the span topology in §10, a §11 row, and
Decisions bullets (dedicated download connection, checks on fresh connections,
own metadata namespace, quota precheck, SSH auth failures Permanent). A README
row. `docs/testing.md` rows and a kvm2 integration section. A `CHANGELOG.md`
entry after the user confirms.
**Evidence:** contract §12.
## 24. Known limits accepted at design time
**Answer:** nine limits, each with its workaround:
- **Create blocks**, including the first image download per host, holding one
of three reconcile workers. Workaround: warm the cache with one Proxy per
image per host before creating many.
- **Volumes without a domain are invisible to GC** (crash between volume
creation and define). The same Proxy's next reconcile removes them; a Proxy
deleted for good leaves them for `virsh vol-list`.
- **A host removed from the config orphans its VMs**: Delete is a no-op
success for it and GC does not sweep it. Delete the Proxies first.
- **No adoption of `kvm` (v1) domains**: switching an entry's type requires
deleting its Proxies first, or the old domains stay behind.
- **Cancelling one call drops the host's pooled connection**, so other RPCs
in flight on that host fail once with Transient and retry. The download is
isolated on its own connection.
- **`Content-Length` is required** for image downloads; a chunked mirror is
rejected as Permanent.
- **A paused or pmsuspended domain is not started.** A repeat `Create` for
a domain in that state is `ErrPermanent` (entry 10), so the Proxy latches
as Failed until its spec changes, rather than retrying a `DomainCreate`
that libvirt refuses. Resume the domain on the host (`virsh resume`) and
touch the spec, or delete the Proxy.
- **No v1 log-format compatibility**: the `libvirt call` lines have the same
keys where the same facts exist, but dashboards keyed on `kvm` logger names
need `kvm2`.
- Contract §13 limits that apply unchanged: config read once per process
(adding a host needs a restart; credentials do not), no installation identity
in GC, an unconfigured provider latches the Proxy.
**Evidence:** contract §13; entries 4, 8, 11, 18 above.
+680
View File
@@ -0,0 +1,680 @@
# The kvm2 provider
Runs Managed proxies as VMs on your own libvirt/KVM hypervisors. It is the
second-generation libvirt provider, built clean-room from
[provider-contract.md](provider-contract.md) and the design in
[kvm2-provider-spec.md](kvm2-provider-spec.md), and it ships side by side
with the first-generation `kvm` provider: both types can be configured at
once, both take the same config block shape, and both read the same
`kvm-ssh` Secret. What kvm2 adds over kvm: a child span per libvirt RPC,
a pool-space precheck that maps to the reconciler's quota wait, SSH
credentials re-read on every dial, image downloads and health checks on
connections of their own, an adoption guard on the owning UID, and SSH
authentication failures that stop retrying.
Like kvm, the operator reaches each host over SSH, forwards the libvirt
unix socket through that connection and speaks libvirt's own RPC over it,
so the hosts need nothing beyond `sshd` and `libvirtd` and the operator
image carries no libvirt libraries. VMs boot a stock distro cloud image
that the operator downloads into the host's storage pool on first use,
with the Proxy's cloud-init delivered on a NoCloud seed ISO built
in-process.
## Hypervisor prerequisites
Per host, once:
1. **libvirt and KVM** (`libvirtd` running, `/dev/kvm` present).
2. **An SSH user in the `libvirt` group**, so it may open
`/var/run/libvirt/libvirt-sock`. The operator authenticates with a
private key; nothing else on the host is touched. The socket is used
as `qemu:///system`, so no further host permissions are needed.
3. **A bridge to a network with DHCP** (for example `br0` bridged onto
the LAN). Each VM gets one virtio NIC on it and takes its IP from that
network's DHCP. The operator's pod network and the crawler clients
must route to those IPs: the health engine probes `http://<ip>:<port>`
through the proxy and clients get the same address from the discovery
API. Nothing on the host filters the port; the provider does not use
`spec.port` at all.
4. **A storage pool** (default `default`, usually `/var/lib/libvirt/images`)
with room for one base image per distro and provider entry (a few
hundred MB each) plus the
per-VM overlays (`diskSizeGb` each, thin-provisioned). The pool must be
running: a stopped pool fails the health check with `PoolNotActive` and
fails `Create` permanently.
5. **UEFI firmware for guests**: the `ovmf` package on Debian/Ubuntu,
`edk2-ovmf` on Fedora/RHEL. VMs boot UEFI by default with secure boot
off (libvirt picks the OVMF binary itself); a domain start fails with
"no firmware found" without it. Only `firmware: bios` images skip this.
6. **Outbound internet from the VMs**, in practice: a vendor-data script
installs `qemu-guest-agent` from the distro mirrors on first boot, and
the agent is how the operator learns the VM's address. Without it the
operator falls back to the host's ARP table, which only sees traffic
addressed to the host itself, so on a plain bridge a VM without the
agent stays `Provisioning`.
Check from the operator's point of view before wiring it in:
```sh
ssh novakj@beelink virsh -c qemu:///system list --all # user can drive libvirt
ssh novakj@beelink virsh -c qemu:///system pool-info default
ssh novakj@beelink ip link show br0
```
## Cluster-side setup
1. Create the SSH Secret from a key pair dedicated to the operator and a
`known_hosts` file holding every host's key. It is the same `kvm-ssh`
Secret the `kvm` provider uses; one Secret serves both types, and if
you already have it there is nothing to do here.
```sh
# once: a key pair for the operator; the .pub goes on every host
ssh-keygen -t ed25519 -f ~/.ssh/proxy-operator_ed25519 -N '' -C proxy-operator
ssh-copy-id -i ~/.ssh/proxy-operator_ed25519.pub novakj@beelink
# once per host: its host key, under the address the *operator* will
# dial (hosts[].address), which may differ from what your laptop uses
ssh-keyscan -H beelink.tail8c69c.ts.net >> ~/.ssh/kvm_known_hosts
# the Secret: the PRIVATE key (not the .pub) plus known_hosts
kubectl -n egress-proxies-operator-system create secret generic kvm-ssh \
--from-file=id_ed25519=$HOME/.ssh/proxy-operator_ed25519 \
--from-file=known_hosts=$HOME/.ssh/kvm_known_hosts
```
`make kvm-ssh-secret` runs that last command as an apply, so it also
updates an existing Secret; override the paths with
`KVM_SSH_KEY=... KVM_KNOWN_HOSTS=...`. Putting the `.pub` in the
Secret is the classic mistake: the target's `kvm-ssh-secret` check
refuses a file without `PRIVATE KEY` in it, and the provider itself
would fail every call permanently with a "parsing ssh key file" error.
Without `known_hosts`, set `insecureIgnoreHostKey: true` and accept
that a MITM could hand you a fake hypervisor.
`config/manager/manager.yaml` mounts the Secret (optional) at
`/etc/proxy-operator/kvm` as two files, `id_ed25519` and
`known_hosts`, which is what `sshKeyFile` and `knownHostsFile` in the
provider config point at. The deployment starts fine without the
Secret, even with a kvm2 provider configured: `New` only checks that
the files exist and logs a `WARNING:` line when one does not; the
provider's proxies fail transiently and recover once the Secret lands.
**Rotating the key or a host key needs no restart.** Both files are
read and parsed on every dial (`loadCredentials` in
`internal/provider/kvm2/ssh.go`); nothing is cached. Re-run the
command or the make target, and the next connection, health check
included, uses the new material. Connections that are already open
stay open until they drop.
2. Add a provider entry to the providers config
(`config/manager/providers_config.yaml` in-cluster; full sample in
`config/samples/providers-config.yaml`). The type is `kvm2` and the
block is `kvm2:`; its fields are those of the `kvm` block:
```yaml
- name: kvm2-home
type: kvm2
kvm2:
sshKeyFile: /etc/proxy-operator/kvm/id_ed25519
knownHostsFile: /etc/proxy-operator/kvm/known_hosts
images:
debian-13:
url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
machineTypes:
small: {vcpus: 1, memoryMiB: 1024}
hosts:
- name: beelink
address: beelink.tail8c69c.ts.net:22
user: novakj
bridge: br0
```
Validation messages name the block, so a mistake reads
`kvm2.hosts[0]: bridge is required`, not `kvm.hosts`. The providers
config is read once per process: adding a host or an image entry needs
a manager restart; credentials do not.
3. Apply a Proxy (`config/samples/proxy_kvm2.yaml`) and watch it:
```sh
kubectl get px -w
# NAME MODE PROVIDER PHASE IP HEALTHY
# proxy-kvm2-sample Managed kvm2-home Provisioning
# proxy-kvm2-sample Managed kvm2-home Running 192.168.1.73
# proxy-kvm2-sample Managed kvm2-home Ready 192.168.1.73 True
```
The first Proxy for a given image on a given host sits in
`Provisioning` for the length of the image download; the operator log
shows `fetching base image into host pool` with the URL and size and
`base image cached` with the size, duration and digest.
## Config reference
The block is `kvm2:` and its Go type is the shared `KVMConfig` in
`internal/provider/config_kvm.go`, so the fields, defaults and validation
are the same as for `kvm`. Defaults are applied by `withDefaults` in
`internal/provider/kvm2/kvm2.go`.
| Field | Required | Default | Meaning |
|---|---|---|---|
| `sshKeyFile` | yes | | private key used for every host; OpenSSH or PEM, parsed on every dial |
| `knownHostsFile` | unless `insecureIgnoreHostKey` | | OpenSSH `known_hosts` to verify host keys against, read on every dial |
| `insecureIgnoreHostKey` | | `false` | skip host-key verification entirely; `New` logs a `WARNING:` line when it is on |
| `diskSizeGb` | | `10` | capacity of each VM's overlay disk; cloud-init's growpart expands the root fs into it. Also what the pool-space precheck reserves per VM |
| `vendorData` | | | extra cloud-init vendor-data placed on every seed next to the Proxy's user-data. Beware that cloud-init drops any top-level key of a vendor-data **cloud-config** that the user-data also defines. Shipped as one part of a MIME multipart alongside the agent script when `installGuestAgent` is on |
| `installGuestAgent` | | `true` | add the vendor-data script that installs and starts `qemu-guest-agent`. Turn off for images that ship it already or cannot reach a package mirror; IP discovery then depends on the host's ARP table |
| `images.<key>` | | | the key names the image in `placement.image`. Letters, digits, dots, dashes and underscores only: it becomes the cached volume name `base-<key>.<format>` |
| `images.<key>.url` | yes per entry | | http(s) URL of a cloud image (qcow2 or raw). The server must send `Content-Length` |
| `images.<key>.sha256` | | | expected digest, 64 hex characters; verified after download, a change triggers a re-download |
| `images.<key>.format` | | `qcow2` | `qcow2` or `raw` |
| `images.<key>.firmware` | | `uefi` | `uefi` (OVMF, secure boot off) or `bios` (SeaBIOS) for images without an EFI system partition |
| `machineTypes.<key>` | at least one | | `{vcpus, memoryMiB}` size profile, both greater than zero |
| `hosts[].name` | yes | | the `placement.zone` value that selects this host; unique. Letters, digits, dots, dashes and underscores only: it becomes part of the providerID `hosts/<name>/domains/<vm>` |
| `hosts[].address` | yes | | SSH endpoint, `host:port` |
| `hosts[].user` | yes | | SSH user, member of the `libvirt` group |
| `hosts[].socket` | | `/var/run/libvirt/libvirt-sock` | libvirt socket path on the host |
| `hosts[].pool` | | `default` | storage pool for base images, overlays and seeds |
| `hosts[].bridge` | yes | | host bridge for the VM's NIC |
## Placement
All three fields are checked before any host is dialled. An unknown value
is `ErrPermanent`, which the reconciler latches as `Failed` with the
message below until the spec changes.
| Proxy field | Meaning for kvm2 | Error when unknown |
|---|---|---|
| `placement.zone` | host name from `hosts`. May be omitted when exactly one host is configured; otherwise required | `placement.zone "x" is not in kvm2.hosts`; with several hosts and no zone, `placement.zone is required when kvm2.hosts lists more than one host` |
| `placement.machineType` | key in `machineTypes` | `placement.machineType "x" is not in kvm2.machineTypes` |
| `placement.image` | key in `images`, or an `http(s)://` URL used directly with the defaults (`qcow2`, `uefi`) | `placement.image "x" is neither a key in kvm2.images nor an http(s) URL` |
| `placement.region` | ignored. It is still part of the spec hash, so changing it replaces the VM and does nothing else | |
The messages come from `Create` in `internal/provider/kvm2/create.go`,
`host` in `kvm2.go` and `resolveImage` in `image.go`. Fix the spec (which
replaces the VM) or the config. Catalogue edits affect only new VMs: the
spec hash covers the placement values, not what the config maps them to.
## What lands on the host
For a Proxy whose deterministic name is `proxy-abc…`, on the chosen
host's pool:
| Object | Name | Lifetime |
|---|---|---|
| base image | `base-<entry>-<key>.<format>`, where `<entry>` is the provider entry's name (for a URL image, `<key>` is the first 16 hex characters of the URL's sha256) | cached; survives Proxy deletion |
| image marker | `base-<entry>-<key>.<format>.sha256`, a 64-byte raw volume holding the digest | with the base image |
| overlay disk | `proxy-abc….qcow2`, qcow2 backed by the base file, `diskSizeGb` capacity | with the Proxy |
| seed ISO | `proxy-abc…-seed.iso`, raw | with the Proxy |
| domain | `proxy-abc…` | with the Proxy |
The names are built in `internal/provider/kvm2/volume.go`. The base image
carries the **provider entry's name** because the file is shared by every
Proxy on that host but must not be shared by two entries: the `kvm`
provider builds `base-<key>.<format>` in the same pool from the same
catalogue shape, and two kvm2 entries pointing one key at different URLs
would otherwise agree on the name and disagree on the contents. The cost
is one copy of each image per entry per host. For that reason the entry's
name must itself be a usable file name — letters, digits, dots, dashes and
underscores — which `New` checks at startup, since provider names are
otherwise only required to be non-empty and unique.
The domain (`buildDomainXML` in `xml.go`) is a `q35` machine with a
host-passthrough CPU, ACPI and APIC, memory in MiB and vcpus from the
machine type, the overlay on `vda` (virtio) and the seed as a read-only
SATA cdrom on `sda`, one virtio NIC on the bridge, a serial console on a
pty, the guest-agent channel `org.qemu.guest_agent.0`, a virtio RNG fed
from `/dev/urandom`, and no graphics. Both disks are `type="file"` with
the paths `StorageVolGetPath` returned: AppArmor derives QEMU's profile
from those paths, and volume-type disks fail with permission denied on
stock Debian and Ubuntu hosts. UEFI images get `<os firmware="efi">` with
the `secure-boot` feature disabled; BIOS images get neither.
Its `<metadata>` carries one element in kvm2's own namespace,
`https://gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/kvm2`
(`metadataURI` in `metadata.go`):
```xml
<instance xmlns="https://gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/kvm2">
<managed>true</managed>
<uid>0f3a…</uid>
<createdAt>2026-09-16T17:42:11Z</createdAt>
</instance>
```
That is how orphan GC recognises its own VMs, since libvirt has no
labels, and how a repeat `Create` knows whether an existing domain of the
same name belongs to this Proxy: a domain without the element is a
permanent error (`carries no kvm2 mark; it was not created by this
provider`), a different non-empty `uid` is a permanent error naming both
UIDs, and a marked domain with a matching or empty `uid` is adopted. The
namespace is kvm2's own, so the `kvm` provider's domains are invisible to
it (see *Migrating from kvm*).
The seed (`nocloud.BuildSeedISO`, shared with the proxmox provider in
`internal/provider/nocloud`) is an ISO 9660 image labelled
`cidata` with three files. `user-data` is the Proxy's `cloudInit`, **byte
for byte**: any format cloud-init accepts works, including a Jinja
template, a MIME archive, a shell script or a gzipped payload; the
operator never parses or re-renders it. `meta-data` holds
`instance-id: <name>` and `local-hostname: <name>`, so a reboot is never
taken for a new instance. `vendor-data` carries the guest-agent
bootstrap. The ISO is staged in a temp directory under `/tmp` (the
manager's 64Mi emptyDir), assembled in memory (tens of KiB) and uploaded
as a raw volume; the staging directory is removed before the call
returns. The seed is rebuilt on every `Create` attempt, because a
previous attempt may have been cut off mid-upload and a truncated seed
would boot a VM without cloud-init.
The guest agent rides along in `vendor-data` as a **shell script**
(`nocloud.GuestAgentScript`), not a cloud-config, on purpose:
cloud-init merges a vendor-data cloud-config under the user-data with
mergers that keep the user-data's value for every top-level key both
define and never append lists, so a Proxy carrying its own `packages:`
would silently lose the agent. Scripts bypass the merge. The script is
idempotent, picks whichever of `apt-get`, `dnf`, `yum`, `zypper` or `apk`
the image has, enables the service with `systemctl` or OpenRC, and logs
into `/var/log/cloud-init-output.log`. With `vendorData` also configured,
the two travel as separate parts of a MIME multipart (`text/plain` for
your document, `text/x-shellscript` for the script), with the
`MIME-Version` header first so cloud-init detects it. With
`installGuestAgent: false` the file is your `vendorData` alone, or empty.
## The image cache
The base image is downloaded **by the operator, through libvirt**, into
the host's pool the first time a Proxy asks for it on that host. Nothing
is pre-placed on the hypervisor and the operator needs no filesystem
access there. The bytes stream from the URL through the operator into
`StorageVolUpload` with a running sha256; nothing is buffered
(`fetchImage` and `uploadImage` in `internal/provider/kvm2/image.go`).
The upload runs on a **dedicated connection** to the host, dialled for
the transfer and closed after it, not on the pooled connection the other
operations share. A cancelled or overlong call kills the connection it
ran on (see *Timeouts and connections*), and a poll for some other Proxy
being cancelled must not cut off a 20-minute upload.
A body that fails part way through is reported to the caller **out of
band**: the reader handed to `StorageVolUpload` turns any source error
into a clean end of stream and keeps the real error for afterwards, so
the short upload is caught by the length and digest checks rather than
by the stream. This is not tidiness. go-libvirt answers a reader error
by parking its stream sender forever on a channel it no longer reads, so
handing it one wedges the call and leaks the sender with its 4 MiB
buffer; see *Known gaps*. For the same reason the download carries its
own deadline, a minute inside the transfer budget, so a stalled mirror
ends through its body rather than through the killed socket the budget
would otherwise leave behind.
The download needs a `Content-Length`: the volume's capacity is declared
before the upload starts. The request sends `Accept-Encoding: identity`
so a transparently gzipped body cannot hide the length. A response
without it, or with length 0, is a permanent error naming the URL, and so
is any 4xx status; 429 and 5xx are transient and retried.
The marker volume is written **last**, on the pooled connection, after
`StoragePoolRefresh` has made libvirt read the file's real format. Its
presence is what marks a base complete: a crash mid-download leaves a
base volume without a marker, which the next attempt deletes and redoes
(`cachedImage`). A re-download also happens when the marker's content is
not a 64-character hex digest, or when `sha256` is configured and differs
from it. Otherwise the cached copy is used for as long as it exists, even
if the URL's content changed upstream: a `latest` URL is pinned at the
moment of first fetch, per host. Concurrent Creates for the same image
on the same host queue on a per-(host, volume) lock; the first fetches,
the rest find the marker.
To evict manually:
```sh
ssh novakj@beelink virsh -c qemu:///system vol-list --pool default | grep base-kvm2-home-
ssh novakj@beelink virsh -c qemu:///system vol-delete --pool default base-kvm2-home-debian-13.qcow2.sha256
ssh novakj@beelink virsh -c qemu:///system vol-delete --pool default base-kvm2-home-debian-13.qcow2
```
Marker first: a base without its marker is refetched, a marker without its
base is cleaned up, and neither leaves a Proxy pointing at nothing.
**Warning: do not evict a base volume while Proxies using that image exist
on the host.** The overlays back onto the base file, and libvirt will
unlink it under running domains without complaint; the guests keep running
on the open file descriptor and fail at their next start. Delete those
Proxies first.
Changing `sha256` on an entry whose base is already cached is **refused**,
permanently and by name, rather than resolved by replacing the file — that
replacement was the same corruption, just automated. Add the image under a
new key and move Proxies over by changing their `placement.image` (which
replaces the VM), or delete the Proxies and evict the base by hand.
Changing `url` alone does nothing at all: the cache is keyed by entry and
key, so the new URL is never fetched. Add a new key for that too.
## Quota: the pool-space precheck
Before creating any volume, `Create` reads `StoragePoolGetInfo` and
compares the pool's free space with what it is about to commit
(`checkPoolSpace` and `neededBytes` in `image.go`): the image's
`Content-Length` when the base is not cached yet (found with a `HEAD`
request, or a `GET` whose body is closed at once when `HEAD` answers
anything but 2xx, since mirrors refuse it with 405, 403 or 501 alike; the
`GET`'s status is then what counts), plus `diskSizeGb` in bytes for the
overlay, plus 16 MiB for the
seed. Below that it returns `ErrQuotaExceeded` with a message naming the
pool, the bytes available and the bytes needed. The reconciler answers
quota with a wait, not a failure: the Proxy shows `Provisioned=False`
with reason `QuotaExceeded` and is retried after `QuotaRetry` (5
minutes), off the error backoff curve and never `Failed`. Free up the
pool or lower `diskSizeGb` and the next attempt goes through.
The precheck is the only quota signal; libvirt itself reports a full pool
as an ordinary error, which would be transient. The health check exposes
the same number as `poolAvailableGiB`, so a pool that is filling up shows
there before Creates start waiting.
A pool that is not running fails the precheck permanently (`storage pool
<pool> is not running`): start it with `virsh pool-start` and
`virsh pool-autostart`.
## Migrating from `kvm`
The `kvm2` block is the `kvm` block under another name, so an entry ports
by changing `type: kvm` to `type: kvm2` and `kvm:` to `kvm2:`. The
`kvm-ssh` Secret is shared; nothing changes on the hosts.
What does not port is the VMs. kvm2 marks its domains in its own metadata
namespace and neither lists nor adopts domains the `kvm` provider made:
`ListByTag` skips them, so orphan GC never sees them, and `Create` refuses
a domain of the same name that lacks the kvm2 mark with a permanent error
rather than start it (a Proxy recreated under the new entry gets the same
deterministic name, so this is what happens when step 1 below is skipped).
A Proxy's `spec.provider` is immutable, so an existing Proxy cannot be
moved to the new entry anyway. Do it in this order:
1. Delete the Proxies on the `kvm` entry and wait for them to go; the
`kvm` provider removes their domains and volumes.
2. Change the entry (or add a second one under a new name and keep the
old one for a while; both types may share a host).
3. Restart the manager, then recreate the Proxies with the new
`spec.provider`.
A `kvm` domain left behind on a host is invisible to kvm2 forever; clean
it up on the host with `virsh undefine --nvram <name>` and
`virsh vol-delete` for its `<name>.qcow2` and `<name>-seed.iso`.
The base image cache is **not** shared between the two types, and that is
deliberate: kvm names its copy `base-<key>.<format>` while kvm2 names its
own `base-<entry>-<key>.<format>`, so kvm2 downloads the image once more
per host rather than adopting a file another provider maintains. Nothing
needs deleting; the `kvm` base stays where it is and keeps serving the
overlays that back onto it. Budget the extra few hundred MB in the pool.
## Logging
The provider logs through controller-runtime's logger, named `kvm2`, with
`provider` (the configured name) on every line, and `host` and `name`
(the domain) on every line about an instance. Every line inside a traced
operation also carries `traceID` and `spanID`.
- `--zap-log-level=debug` (logr V(1)):
- one line per operation outcome: `kvm2 domain ensured running` with the
`id`, `kvm2 domain fetched` with `state`, `ip` and `uid`,
`kvm2 domain not found`, `kvm2 domain deleted`, `kvm2 domains listed`
with `hosts` and `managed` counts, `kvm2 host checked` with `ready`,
`reason` and `latency`; plus the steps inside a Create
(`kvm2 domain already defined`, `overlay volume created`,
`seed volume uploaded` with `bytes`, `domain defined` with `uid` and
`firmware`, `domain started`, `fetching base image into host pool`,
`base image cached`);
- **one `libvirt call` line per libvirt RPC**, written by the decorator
in `internal/provider/kvm2/observe.go` inside that RPC's span: `rpc`
(the go-libvirt method name, for example `DomainLookupByName`), the
identifiers it was given (`domain`, `pool`, `volume`), `duration`, and
on failure `error` plus `libvirtCode` and `libvirtError` (the code's
name, for example `ErrNoDomain`). A Get is four or five RPCs; a
Create with a cached image is around twenty;
- connection events from `internal/provider/kvm2/hosts.go`:
`libvirt host connected` with `address`, `user` and `duration`, and
`libvirt host connect failed` with the same plus `error` and `stage`
(`credentials`, `tcp`, `ssh` or `socket`);
- `kvm2 backend call failed` with `op` and the raw `error` once per
failed operation, before the error is returned;
- a line per domain that `ListByTag` skips, and a line when the image
marker forces a refetch.
- `--zap-log-level=2`: the `libvirt call` lines also carry the domain and
volume `xml`, the `metadata` element, the interface `addresses`, the
listed `domains`, the pool `state` and sizes and the volume `path`; the
`domain defined` line carries the `xml`; `kvm2 create resolved` logs the
resolved parameters (pool, bridge, machine type, sizes, image volume,
firmware) and `cloudInitBytes`; `kvm2 listed instance` logs each listed
domain.
- Never logged at any level: the cloud-init content, the SSH key, the
known_hosts content, and upload or download bodies. The seed and image
uploads report `offset` and `length` only. There is no raw wire tier
like the GCP provider's V(5); go-libvirt exposes no hook for it.
## Tracing
With an OTLP exporter configured ([architecture.md
§10](architecture.md#10-tracing-internaltracing)), every backend call is
a child span of the operation span the decorators open
(`provider.create`, `provider.get`, `provider.delete`, `provider.list`)
or of the monitor's `providerhealth.check`:
| Span | Around | Attributes |
|---|---|---|
| `kvm2.<Method>`, for example `kvm2.DomainDefineXML` | one libvirt RPC (`observe.go`) | `kvm2.host`, and the identifiers passed: `kvm2.domain`, `kvm2.pool`, `kvm2.volume`; on failure the status is Error and `kvm2.libvirt_code` carries the libvirt error number |
| `kvm2.dial` | the SSH handshake and the libvirt connect (`hosts.go`) | `kvm2.host`, `kvm2.address`, `kvm2.user` |
| `kvm2.download` | one image fetch, from the HTTP request to the marker (`image.go`) | `kvm2.host`, `kvm2.volume`, `kvm2.url_host`, `kvm2.sha256_configured`, and `kvm2.bytes` once the response arrived |
All spans are client spans. No attribute carries cloud-init, key material
or a URL beyond its host. The `libvirt call` log line for an RPC is
written inside that RPC's span, so its `spanID` points at the span.
## Timeouts and connections
No caller puts a deadline on the context, so every backend call carries
its own budget (`defaultBudgets` in `internal/provider/kvm2/backend.go`,
applied by `call`):
| Call | Budget |
|---|---|
| TCP connect plus SSH handshake plus libvirt connect | 10 s |
| ordinary RPC | 30 s |
| `DomainCreate` | 90 s |
| `StorageVolUpload` of at most 16 MiB (the seed, the marker) | 60 s |
| `StorageVolUpload` of an image, `StorageVolDownload`, and the whole image fetch from HTTP request to marker | 20 m; the caller's ctx may end it earlier |
| wait for running after `DomainCreate` | 15 s, polled every 500 ms (`defaultRunningWait` and `defaultRunningPoll` in `create.go`); `DomainCreate` is synchronous, so the first poll normally answers running, and a domain that is shut off, crashed or paused right after the start fails at once rather than waiting out the cap |
| the `HEAD` request of the pool-space precheck | 30 s |
| the courtesy `ConnectClose` when a connection is closed | 5 s |
go-libvirt's calls take no context and block, so the only way to abandon
one is to close the connection under it. That is what a cancelled or
overlong call does: the adapter marks the connection dead so the next call
redials, kills it by closing the SSH client's TCP connection, which takes
the forwarded socket with it, and returns `ErrTransient` wrapping the
context or deadline error. It marks dead before it kills, and closes the
TCP connection rather than the forwarded channel, because both of those are
lock-free: closing the channel writes a close message under the channel's
write lock, so it would queue behind a transfer stuck in a TCP write to a
host that has gone away — for as long as the kernel retransmits, which is
the one case the kill exists to survive. The consequence:
**other RPCs in flight on that host's pooled connection fail once with
`ErrTransient`** and their callers retry on a fresh connection. In the
reconciler that is one requeue with backoff; in GC it is one skipped
sweep.
Two callers are kept off the pooled connection for that reason
(`dialFresh` in `hosts.go`): the image upload, because a 435 MB transfer
must not die because a poll for another Proxy was cancelled, and the
health check, because a check that runs out of the monitor's timeout must
close only its own connection.
Otherwise there is one pooled SSH plus libvirt connection per host,
dialled lazily on the first call that needs it and shared by every
operation for that host (go-libvirt multiplexes RPCs on one connection).
Concurrent callers wait for one dial and share the result; waiting ends
with the caller's context. A `keepalive@openssh.com` request every 15 s
marks the connection dead when it fails or gets no reply within another
15 s, as does go-libvirt noticing the socket is gone; the next call
redials. Create and Delete for the same domain name are serialised on a
per-name lock, so two Deletes (GC and the reconciler) never race on
destroy and undefine, and a Delete cannot interleave with a Create's
steps; Get and ListByTag take no lock.
## Health check
The operator checks every configured host every `--provider-check-interval`
(default 1m) and publishes the result in the `ProviderHealth` object named
after the provider (`kubectl get ph kvm2-home -o yaml`) and in the
`proxy_operator_provider_target_up{provider,target}` gauge. One target per
`hosts[]` entry, in config order, all checked concurrently.
Each check dials a **fresh connection** to the host and closes it when
done (`probeHost` in `internal/provider/kvm2/check.go`); the pooled
connection is never touched, so a check that runs out of time cannot fail
a reconcile's call in flight, and a fresh dial picks up a rotated Secret
at once. The sequence is the SSH and libvirt handshakes, then four RPCs:
`ConnectGetLibVersion`, `ConnectGetHostname`, `StoragePoolLookupByName`
and `StoragePoolGetInfo` on the host's `pool`. The cost per host per
round is one sshd session and those four calls.
Every target's `details` carry `user` and `pool`. As facts arrive they
gain `libvirtVersion` (decoded `major.minor.micro`), `hostname`,
`poolState` (virsh's word: `running`, `inactive`, ...),
`poolCapacityGiB` and `poolAvailableGiB`, so a target that fails late in
the sequence still says what the host answered before. The free space is
the number the quota precheck compares against.
A failing target says where the check broke (`checkReason` in `check.go`):
| Reason | Meaning | Look at |
|---|---|---|
| `Reachable` | ready | |
| `CredentialsUnavailable` | the key or known_hosts file is missing or does not parse | the `kvm-ssh` Secret (see *Cluster-side setup*); fixed in the cluster, not on the host |
| `Unreachable` | the TCP connect failed or ran out of the 10 s dial budget while the round still had time, the SSH transport failed for a reason other than authentication, the forwarded libvirt socket was refused, or the link died mid-check | DNS, Tailscale or a firewall between the cluster and `address`; `libvirtd` running and the `socket` path |
| `Unauthorized` | the SSH server rejected the key, or the host key is missing from known_hosts or has changed | `user`'s `authorized_keys`; the known_hosts entry (re-run `ssh-keyscan` and update the Secret) |
| `Forbidden` | SSH worked, but libvirt refused the user (`ErrAccessDenied` or `ErrAuthFailed`) | `user` in the `libvirt` group |
| `Timeout` | the monitor's `--provider-check-timeout` (default 15s) ran out before the check finished, whichever stage was running, the dial included; or the connection was up and an RPC's own budget ran out | a hung `libvirtd`, a saturated link, or a round timeout shorter than the dial takes |
| `PoolNotFound` | there is no pool named `pool` | `virsh pool-list --all` |
| `PoolNotActive` | the pool exists but is not running | `virsh pool-start <pool>` and `virsh pool-autostart <pool>` |
| `APIError` | any other libvirt error | the message carries libvirt's own text |
## Error classes
Every error is classified once, in `classify` in
`internal/provider/kvm2/errors.go`, with errors that carry a class from
their source (validation, the image fetch, the dial) keeping it. The
reconciler latches `ErrPermanent` as `Failed` until the spec changes,
waits 5 minutes on `ErrQuotaExceeded`, recreates on `ErrNotFound` from
`Get`, and retries everything else with backoff.
| Class | What lands there |
|---|---|
| `ErrPermanent` | placement values not in the catalogue; a malformed providerID; a host no longer in the config (from `Get`; `Delete` treats it as done); libvirt `ErrXMLError`, `ErrNoSupport`, `ErrInvalidArg`, `ErrAuthFailed`, `ErrAccessDenied` and `ErrNoStoragePool`; a pool that is not running; SSH authentication refused (`unable to authenticate`); a host key missing from known_hosts or changed (`knownhosts.KeyError`); a key or known_hosts file that exists but does not parse; a domain of the same name owned by another UID or carrying no kvm2 mark; a domain of the same name that is paused or pmsuspended; corrupt metadata on a domain; an image URL answering 4xx, a response without `Content-Length` or with length 0, a sha256 that does not match the configured one |
| `ErrQuotaExceeded` | the pool-space precheck |
| `ErrNotFound` | libvirt `ErrNoDomain` or `ErrNoStorageVol` from `Get`, `Delete` or `ListByTag`; inside `Create` the same codes are `ErrTransient`, because the contract forbids NotFound from Create |
| `ErrTransient` | everything else: a missing key or known_hosts file (the Secret has not landed yet), TCP and network errors, context cancellation, an abandoned or budget-exceeded call, a connection killed by another caller, libvirt `ErrOperationInvalid` and every unrecognised libvirt code, an image URL answering 429 or 5xx or ending the body early, every address source failing on an active domain, the wait for running expiring or the domain going shut off or crashed right after its start |
Error strings name paths, hosts, volumes and libvirt's own text. They
never contain key bytes, the known_hosts content or cloud-init.
## Known gaps
- **An upload that libvirtd itself fails leaks a goroutine, upstream.**
go-libvirt's `requestStream` hands its stream sender an unbuffered
channel and then, on the error path, returns without ever reading it:
```go
abort := make(chan bool) // rpc.go
outErr := make(chan error)
go func() { outErr <- l.socket.SendStream(...) }()
resp, err = l.processIncomingStream(c, in)
if err != nil {
abort <- true
return resp, err // outErr is never drained
}
```
Whenever the daemon errors mid-upload — a pool that fills, a volume
removed underneath — the sender parks on `outErr` for good, taking its
4 MiB buffer with it. Closing the socket does not help: both ends are
blocked on channel sends, not on I/O. kvm2 avoids the two cases it
controls (a failing body, and its own transfer deadline; see *The image
cache*), so what is left needs a server-side failure. The fix is two
characters upstream, `make(chan bool, 1)` and `make(chan error, 1)`; if
it ever bites hard enough, a `replace` carrying that patch is the
escape hatch.
- **Create blocks**, including the first image download on each host,
holding one of the three reconcile workers for its duration (LAN
speed, a few hundred MB, once per image per host). Other Proxies keep
reconciling on the remaining workers. Warm the cache with one Proxy per
image per host before creating many.
- **Volumes without a domain are invisible to GC.** A crash between
creating the overlay or seed and defining the domain leaves volumes
that `ListByTag` cannot see (it lists domains). The same Proxy's next
Create reuses the overlay and rebuilds the seed, and its Delete removes
both by name even with no domain, but a Proxy CR that is gone for good
leaves them for `virsh vol-list` to find.
- **A host removed from the config orphans its VMs.** `Delete` for a
providerID naming an unconfigured host is a logged no-op success, so
the Proxy finishes deleting instead of pinning its finalizer, but the
domain and its volumes stay on the hypervisor and orphan GC cannot see
them either (it only sweeps configured hosts). Delete the Proxies before
retiring a host, or clean up afterwards with `virsh undefine --nvram`
plus `virsh vol-delete`. `Get` fails permanently for such a providerID
on purpose: a typo in `hosts` must not look like a missing VM and
trigger a rebuild elsewhere.
- **No adoption of `kvm` domains.** See *Migrating from kvm*.
- **A cancelled call drops the host's pooled connection once**, so other
RPCs in flight on that host fail once with `ErrTransient` and retry.
The image upload and the health check are isolated on their own
connections.
- **`Content-Length` is required** for image downloads; a mirror that
only chunks is rejected permanently. Point the catalogue at one that
sends the length.
- **A paused or pmsuspended domain is not started.** A repeat `Create`
for a domain in that state fails permanently (`is DomainPaused and
cannot be started by the operator; resume or destroy it on the host`),
so the Proxy shows `Failed` until its spec changes; the operator never
resumes a VM an admin paused. Resume it on the host
(`virsh resume <name>`) and touch the spec, or delete the Proxy.
- **Parser text in credential errors.** A key or known_hosts file that
does not parse fails with the parser's own message, which can quote a
PEM header type or a malformed known_hosts token. Key bytes are never
quoted.
- **The sample cloud-init is an open proxy.**
`config/samples/proxy_kvm2.yaml` installs Squid with
`http_access allow all`; restrict it before exposing the bridge network
to anything but the cluster.
- **IP discovery needs the agent or guest traffic to the host.** A VM on
a bridge with no DHCP never gets an address and stays `Provisioning`
forever; check `virsh console <name>` on the host. The same symptom
with a booted, addressed VM means the guest agent never came up: check
`systemctl status qemu-guest-agent`, `cloud-init status --long` and the
vendor script's output in `/var/log/cloud-init-output.log` (a VM without
outbound internet cannot install it). A user-data that disables
vendor-data (`vendor_data: {enabled: false}`) also removes the agent, by
design. A host where the ARP source *errors* rather than answering with
nothing is the one case this reads differently: with no source left to
answer, a booting VM retries with `ErrTransient` instead of resting in
`Provisioning`. It still converges; the giveaway is two "no addresses
from source" lines per poll at `-v 2`.
`virsh domifaddr <name> --source agent` and `--source arp` show
what the operator sees, in that order.
- **Debian 13 cloud images hang in GRUB under BIOS on a headless VM**
([Debian bug #1111240](https://bugs.debian.org/1111240)), which is why
`firmware` defaults to `uefi`; the same image boots under UEFI with the
serial console intact. Images that need `firmware: bios` are the
exception.
## Testing
Unit tests run over an in-memory fake of the libvirt seam and an
in-process SSH server. The optional integration test against a real host
is described in
[testing.md](testing.md#the-kvm2-integration-test-real-hypervisor); run it
with `make test-integration-kvm2`. `TestKVM2Check_realHost` runs the
health check against the same host and creates nothing:
`go test -tags=integration -count=1 -v -run TestKVM2Check ./internal/provider/kvm2/`.
+58
View File
@@ -0,0 +1,58 @@
# Operating the operator
Helpers for running the operator day to day: log queries, one-off
inspections, and the small recipes that are easier to look up than to
reconstruct. Not a runbook — it assumes you already know what you are
looking for.
## Log queries
The manager logs one JSON object per line, so `jq` is the tool. The
`.msg` field is the discriminator: it is a fixed string per event, and
the variable parts live in their own fields rather than being
interpolated into the message.
### Libvirt RPCs, with timestamps
Both libvirt providers log one `"libvirt call"` per RPC, so this catches
`kvm` and `kvm2` alike:
```sh
jq -r 'select(.msg == "libvirt call") | "\(.ts) \(.rpc)"'
```
```text
2026-09-18T07:48:29Z virStoragePoolGetInfo
2026-09-18T07:48:29Z ConnectGetLibVersion
2026-09-18T07:48:29Z ConnectGetHostname
2026-09-18T07:48:29Z StoragePoolLookupByName
2026-09-18T07:48:29Z StoragePoolGetInfo
```
These lines are debug level, which is the binary's default
(`zap.Options{Development: true}` in `cmd/main.go`) — nothing to turn on.
They disappear if the Deployment has been given `--zap-log-level=info`,
which is the switch for outcomes only.
Piping from a live manager:
```sh
kubectl logs -n egress-proxies-operator-system \
deploy/egress-proxies-operator-controller-manager -f \
| jq -r 'select(.msg == "libvirt call") | "\(.ts) \(.rpc)"'
```
`jq` aborts on the first line that is not JSON, which happens when
something bypasses the structured logger — a panic trace, or output from
a tool wrapping the manager. Reading line-by-line with `fromjson?` skips
those instead:
```sh
jq -rR 'fromjson? | select(.msg == "libvirt call") | [.ts, .rpc] | @tsv'
```
Note that the connection-level events use their own `.msg` values
(`"libvirt host connected"`, `"kvm2 host checked"`), so they drop out of
the filters above. Widen to `select(.msg | startswith("libvirt"))` to
keep the connect lines — usually what you want when chasing a latency
spike rather than a call count.
@@ -0,0 +1,38 @@
# Execution: Discovery API documentation
Plan: [2026-08-11-2152-discovery-api-docs.md](../plans/2026-08-11-2152-discovery-api-docs.md)
- [x] Step 1 — Write `docs/api.md` full API reference
- [x] Step 2 — Add pointers in README and architecture.md
## Step 1 — docs/api.md
Wrote the full reference: base URL (in-cluster FQDN + port-forward), bearer
auth, error envelope, configuration table, all five routes with schemas,
status codes and curl examples, the selection/cooldown semantics section,
an end-to-end curl walkthrough, and the in-memory/single-replica caveats.
All facts were taken from the code, not from memory of prior docs.
Worth noting: the doc explicitly calls out two things no earlier doc
stated for clients — that a report **without** a target on a targetless
lease creates a *global* cooldown (blocking the proxy for everyone), and
that `rate_limited` and `banned` currently behave identically. Both came
straight from `internal/lease/store.go` and are easy to trip over.
## Step 2 — Pointers + verification
Added one-line links to the new doc in README's quickstart (above the curl
block) and in `docs/architecture.md` §7. Verified the documented behavior
against the tree rather than trusting the write-up:
```sh
go build ./... && go test -short ./internal/discovery/ ./internal/lease/
```
Both pass; a grep of `server_test.go` confirmed every documented status
code and error code (`invalid_ttl`, `invalid_query`, `invalid_result`,
`no_match`, `unknown_lease`, 201/204/401/404/409) is asserted by tests.
Worth noting: CHANGELOG entry deliberately deferred until the user
confirms the docs read well, per the CHANGELOG convention's
"once the user confirms it works" clause.
@@ -0,0 +1,55 @@
# Execution: Demo scripts
Plan: [2026-08-11-2220-demo-scripts.md](../plans/2026-08-11-2220-demo-scripts.md)
- [x] Step 0 — Branch `feat/demo-scripts` + plan commit
- [x] Step 1 — `docs/demo/show-egress-ips.sh`
- [x] Extra (added iteratively, not in the original plan) — proxy-creation scripts
## Step 0 + Step 1
Branched off `main`, committed the plan alone, then wrote
`docs/demo/show-egress-ips.sh`: takes `BASE_URL` as its argument, lists
`/v1/proxies` (bearer auth via optional `TOKEN` env), probes each healthy
proxy with `curl -x http://ip:port` against an IP-echo site
(`IP_ECHO_URL`, default ipify JSON), banners which proxy each request goes
through, skips unhealthy ones, and ends with a probed/skipped/failed
summary. Per user request the script was left uncommitted for iteration
and no verification beyond `bash -n` was run.
## Extra — create-kubernetes-proxies.sh, create-gcp-proxies.sh
Added on the same branch before the first commit:
- `create-kubernetes-proxies.sh <count>` — creates
`proxy-kubernetes-demo-1..N` with the kubernetes provider, spec taken
from `config/samples/proxy_kubernetes.yaml`, applied via
`kubectl apply -f -` heredocs. Optional `NAMESPACE` env.
- `create-gcp-proxies.sh <count>` — creates `proxy-gcp-demo-1..N` from the
user-supplied gcp-eu manifest (e2-micro, Ubuntu 24.04, Squid
cloud-init), each with a zone picked randomly from a hardcoded list of
27 EU zones so the fleet gets egress IPs from different locations.
Env overrides: `ZONES`, `GCP_PROVIDER` (default `gcp-eu`), `NAMESPACE`.
## Extra — run-demo.sh, show-egress-ips-table.sh
Second round of iterative additions:
- `run-demo.sh` — tmux demo driver: 2x2 tiled grid with the egress-IP
table looping every 10s inside a netshoot pod (script `kubectl cp`'d
into the pod, `BASE_URL` set to the in-cluster Service FQDN),
`watch -n3 kubectl get px`, and both create scripts auto-running with
`COUNT` proxies each (default 4). Env knobs: `COUNT`, `SESSION`,
`NETSHOOT_POD`, `DEMO_DIR` (defaults to the script's own dir).
- `show-egress-ips-table.sh` — condensed one-line-per-proxy variant of
`show-egress-ips.sh` sized for a tmux pane: PROXY / ENDPOINT /
LOCATION (zone→geo attribute fallback) / EGRESS-IP columns, with
`(unhealthy)` and `FAILED` inline instead of verbose output. The
verbose script stays for standalone use; the demo driver uses the
table variant.
Worth noting: beyond the user's sample manifest, the gcp script also
writes the picked zone into `attributes.zone`, so the discovery API
exposes each proxy's location and leases can select on it. The zone list
is static — if a project lacks quota in some region, `ZONES` narrows the
pool; nothing validates zones against the live project.
@@ -0,0 +1,183 @@
# Execution log: OpenTelemetry tracing integrated with logr/zap logging
Plan: `docs/plans/2026-08-24-1025-otel-tracing.md`
- [x] Step 1 — Dependencies
- [x] Step 2 — New package `internal/tracing`
- [x] Step 3 — `provider.WithTracing` decorator
- [x] Step 4 — `cmd/main.go` wiring
- [x] Step 5 — Reconciler spans
- [x] Step 6 — Discovery server
- [x] Step 7 — GC + health
- [x] Step 8 — GCP wire-log enrichment
- [x] Step 9 — Manifests + docs
## Step 1 — Dependencies
Aligned the pre-existing indirect skew (otel core v1.44.0 vs otlptrace exporters
v1.40.0) and added the new direct deps in one shot:
```bash
go get go.opentelemetry.io/otel@v1.45.0 \
go.opentelemetry.io/otel/sdk@v1.45.0 \
go.opentelemetry.io/otel/trace@v1.45.0 \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@v1.45.0 \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.45.0 \
go.opentelemetry.io/otel/exporters/stdout/stdouttrace@v1.45.0 \
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp@v0.70.0
go mod tidy
```
MVS side-effects worth recording: `logr v1.4.3→v1.4.4`, `httpsnoop
v1.0.4→v1.1.0`, `grpc-gateway/v2 v2.27.7→v2.29.0`, `proto/otlp v1.9.0→v1.11.0`,
plus a `genproto/googleapis/api` pseudo-version bump. Full `make test` passed
against the bumped graph (`google.golang.org/api v0.292.0` and k8s v0.36
tolerate otelhttp v0.70.0).
Worth noting: the first `go mod tidy` ran *before* any first-party code
imported `otlptracehttp`/`stdouttrace`, so it silently dropped those two
modules again; the Step 2 tidy re-added them. The plan's semconv question
resolved to `semconv/v1.43.0` — that's what `sdk@v1.45.0/resource/builtin.go`
imports, so first-party code uses the same version to avoid
`ErrSchemaURLConflict` in the common path.
## Step 2 — `internal/tracing` package
Landed as planned: `tracing.go` (env-gated `Setup`, hand-rolled exporter
selection), `logger.go` (`Start`/`StartSpan`/`ContextWithLogger` with the
base-logger ctx key that prevents duplicate `traceID` zap fields on nested
spans), `reconciler.go`, `transport.go`, `http.go`, `options.go`; tests for
all of it (first use of `sdk/trace/tracetest` in the repo).
Two deviations from the plan's letter:
- **k8s transport gating is a custom RoundTripper, not `otelhttp.WithFilter`**
(`parentGatedTransport`): requests without a parent span bypass the otel
transport entirely, so the no-root-spans-from-informers guarantee doesn't
depend on otelhttp filter semantics for transports.
- **`HTTPMiddleware` must copy the route pattern back.** The logger-injecting
inner handler wraps the request via `WithContext` (a shallow copy), so the
mux records the matched pattern on the copy while otelhttp's post-routing
span rename reads the original. Found by the middleware test (span named
`"GET"` instead of `"GET /v1/things/{id}"`); fixed with `r.Pattern =
r2.Pattern` after `next.ServeHTTP`.
Also: both the transport and the middleware pass explicit W3C propagators
instead of relying on the global, so behavior is deterministic under tests
and when tracing is disabled. `Setup` tests reset the global provider to a
fresh noop per case — restoring otel's own default delegate triggers a
"Setting tracer provider to its current value" warning from the SDK.
## Step 3 — `provider.WithTracing`
`internal/provider/tracing.go` mirrors `metrics.go` exactly (same wrap shape,
same `resultLabel` classification reused as the `provider.result` span
attribute); tests reuse `staticProvider` from `metrics_test.go`. `ErrNotFound`
maps to span status Ok as planned — the reconciler polls `Get` to NotFound
during replacement/deletion, so it's an answer, not a failure.
One small API addition to `internal/tracing` for this: exported
`tracing.Tracer(opts...)`, since the decorator lives in package `provider`
and couldn't reach the unexported option resolver. The `cmd/main.go` wiring
(`WithTracing` outermost around `WithMetrics`) lands with Step 4's commit —
same file, one commit.
## Step 4 — `cmd/main.go` wiring
As planned: `tracing.Setup` right after `ctrl.SetLogger`; manager rest.Config
wrapped via `restCfg := ctrl.GetConfigOrDie(); restCfg.Wrap(...)` (the
previous inline call left nowhere to wrap); providers decorated tracing-
outermost; explicit trace flush after `mgr.Start` returns on both the error
and clean paths, with a fresh 10s context since the signal ctx is already
cancelled by then.
The kubernetes provider grew `NewWithTransportWrapper(ctx, cfg, wrap)` (its
client is built from its own `ctrl.GetConfig()`, invisible to the manager's
wrapped config); `New` now delegates with a nil wrapper, so `newWithClient`
tests stayed untouched, and main registers a closure — the same pattern the
gcp constructor already used for wire-log options.
Deviation: the plan put `--trace-health-probes` here, but the flag needs the
`health.Engine.TraceProbes` field that Step 7 introduces — moved there to
keep every commit compiling.
## Step 5 — Reconciler spans
`SetupWithManager` completes with `tracing.NewReconciler("Proxy", r)`;
sub-spans `reconcile.managed` / `reconcile.replaceInstance` /
`reconcile.delete` open at the top of each state machine, and `status.patch`
opens inside the deferred flush closure so it stays within the root span
while its error still folds into the recorded result. `reconcileExternal`
left unspanned as planned (no I/O).
Two small judgment calls: the sub-spans carry no extra attributes — the
provider decorator already records `provider.id`, and the root span carries
the object identity, so duplicating them was noise; and `status.patch` is
emitted every reconcile even when nothing changed (the no-op compare is the
span's content — a real PATCH shows up as its k8s HTTP child). Tests that
call `r.Reconcile` directly bypass the wrapper; with no global tracer set
they see no-op spans, so the existing fake-client and envtest suites run
unchanged.
## Step 6 — Discovery server
Middleware chain is now recover → tracing (server span + request logger) →
request-log → body cap → auth; the request-log middleware and the three
handler error sites log via `logf.FromContext(r.Context())` — which is
`s.log` enriched with the request's traceID/spanID by the tracing
middleware, not a different logger (logr sinks can't read ctx at log time,
so per-request values must ride on the logger instance in the ctx; user
asked, answered in-session).
Deviation from the plan's letter: `recoverMiddleware` keeps `s.log`. It sits
*outside* the tracing layer, so its request ctx never has the enriched
logger — switching it to `FromContext` would silently drop the `"discovery"`
name and gain nothing.
## Step 7 — GC + health
`gc.sweep` runs in a root span with `gc.providers` / `gc.deleted` counters
(a failed orphan delete no longer counts as deleted — the loop grew an
explicit `continue`). The health engine got the deferred `--trace-health-probes`
flag and `TraceProbes` field; the probe call moved into `runProbe`, which
wraps it in a `health.probe` client span only when enabled, ending before
`record` (which stays ctx-free by design). Probe transports remain
uninstrumented so no traceparent can leak through a proxy to external
targets. Probe→reconcile span links stay out of scope (GenericEvent carries
no ctx; the workqueue coalesces events) — recorded in the architecture
Decisions in Step 9.
## Step 8 — GCP wire-log enrichment
`wireFilterHandler.Handle` clones the record and appends `traceID`/`spanID`
when the ctx carries a valid span — same keys as the logr enrichment, added
before the elision pass (both values are well under the 1KiB threshold).
The new `wirelog_test.go` covers the enrichment, that the JWT/token security
filter still drops non-wire Debug records even with a span present, and that
spanless records stay unchanged.
## Step 9 — Manifests, docs, lint
`config/manager/manager.yaml` gained the OTel env block (downward-API
`POD_NAME`/`POD_NAMESPACE` deliberately listed *before*
`OTEL_RESOURCE_ATTRIBUTES` — `$(VAR)` expansion only sees earlier vars),
with the OTLP endpoint and sampler left as commented examples so deployments
stay tracing-off by default. `docs/architecture.md` got "### 10. Tracing"
plus five Decisions entries; README got a Tracing section.
`make lint` surfaced 16 new-on-branch issues (checked with
`golangci-lint run --new-from-rev main`; the other ~43 pre-date this work):
- one gofmt slip, 13 goconst repeats — fixed with `env*`/`exporter*`
constants in `internal/tracing/tracing.go` shared by the tests;
- two logcheck hits ("function takes both a context and a logger"):
`Setup` now reads its logger from ctx (`logf.FromContext`, caller seeds
via `logf.IntoContext` — the linter's sanctioned pattern), and
`ContextWithLogger` carries a justified `//nolint:logcheck`, since being
the IntoContext-analogue is its purpose.
Worth noting: pre-existing lint debt (50 goconst etc. on `main`) was left
untouched — `make lint` still fails overall; only branch-introduced issues
were cleaned. The lint fixes changed `Setup`'s signature after Step 2/4 had
landed, so those commits show the two-arg version; this commit is where it
settles.
@@ -0,0 +1,68 @@
# Execution log: e2e test — OTel tracing against real Tempo
Plan: `docs/plans/2026-08-24-1224-tracing-e2e.md`
- [x] Step 1 — `test/e2e/tracing_test.go`
- [x] Step 2 — Makefile
- [x] Step 3 — Docs
## Steps 1–3 — spec, Makefile, docs (one commit)
The three steps landed together — the spec is one new file and the other
two are its wiring. `Describe("OTel tracing", Ordered)` is fully
self-contained (own ns create → `make install`/`make deploy` → teardown)
because Ginkgo randomizes top-level container order, so it cannot share the
Manager Describe's deployment. It reuses the package-level `namespace` /
`managerImage` and the suite's idioms (`utils.Run`, curl-pod with the
restricted-PSS overrides JSON, log-substring `Eventually`s).
Judgment calls beyond the plan's letter:
- The squid pre-pull (`docker pull` + `kind load`) is **best effort** — a
missing docker binary logs a note and continues rather than failing the
spec; the 5m Ready timeout still covers an in-cluster pull.
- The OTLP preflight pod prints per-attempt HTTP codes and a final
`OTLP_OK`/`OTLP_UNREACHABLE` marker; the assertion quotes the pod's
output, so an unreachable endpoint names itself in the failure.
- `kubectl set env` is passed the literal
`OTEL_RESOURCE_ATTRIBUTES=...$(POD_NAME)...` string via `exec.Command` —
no shell involved, kubectl stores `$()` verbatim, and the in-place update
keeps the var after the downward-API vars it references.
- Tempo helpers are stdlib-only; `/api/traces/<id>` is decoded as
OTLP-JSON (`batches[].scopeSpans[].spans[].name`), which is Tempo's
actual shape (not Jaeger's).
Verified so far: `go vet -tags=e2e ./...` clean.
## Live run against homelab Tempo
First attempt failed before the suite started — Docker Desktop wasn't
running (`kind` could not create the cluster), and the failure was masked
to exit 0 by a `| tail` pipe on the make invocation (no pipefail in that
shell). Rerun with Docker started first and no pipe:
```bash
TEMPO_URL=http://192.168.0.30:3200 OTLP_ENDPOINT=http://192.168.0.30:4318 make test-e2e
```
Result: **SUCCESS — 4/4 specs (Manager smoke + both tracing Its), 219 s**,
kind cluster auto-deleted. Run id `e2e-1787567624494810000`; the traces are
findable in Grafana with TraceQL
`{resource.test.run.id="e2e-1787567624494810000"}`. Both proxies reached
`Ready` on real squid pods; the create trace carried
`Reconcile Proxy` / `reconcile.managed` / `provider.create` /
`status.patch` and the expected resource attrs; the deletion trace carried
`reconcile.delete` / `provider.delete`.
Worth noting: the OTLP preflight and both Tempo polls succeeded on the
in-cluster → LAN path (kind on macOS reaches 192.168.0.30 through Docker
Desktop's NAT), so no extra networking setup is needed on this machine.
The plan's negative verification also ran: with a deliberately wrong
endpoint (`OTLP_ENDPOINT=http://192.168.0.30:9999`) the tracing spec
failed in the BeforeAll preflight after ~52 s with
`OTLP endpoint http://192.168.0.30:9999 is not reachable from inside the
kind cluster; curl output: OTLP_UNREACHABLE` — a named, fast failure
instead of a 2-minute opaque search timeout — and teardown still deleted
the kind cluster (`make cleanup-test-e2e` run explicitly, since a failing
`go test` skips the Makefile's cleanup step).
@@ -0,0 +1,673 @@
# Execution: KVM/libvirt provider
Plan: [2026-09-04-1404-kvm-provider.md](../plans/2026-09-04-1404-kvm-provider.md)
- [x] Step 0 — Save plan + branch `feat/kvm-provider`
- [x] Step 1 — Config (`KVMConfig` & friends, `validate()` case, tests)
- [x] Step 2 — Pure builders (`domainxml.go`, `volume.go`, `seed.go`, `id.go`, `metadata.go`)
- [x] Step 3 — Transport + errors (`conn.go`, `errors.go`)
- [x] Step 3b — Image cache (`image.go`)
- [x] Step 4 — Provider methods (`kvm.go`) over a fake API
- [x] Step 5 — Wiring, manifests, samples, docs, CHANGELOG
- [x] Step 6 — Integration test against a real hypervisor (written and compiled; first real run is part of Step 7)
- [x] Step 7 — Manual validation + MR
## Step 0 — Save plan + branch
Plan committed alone on `feat/proxy-operator`, then branched:
```bash
git checkout -b feat/kvm-provider
```
## Step 1 — Config
The new structs (`KVMConfig`, `KVMHost`, `KVMMachineType`, `KVMImage`) and
`validateKVM` went into their own file, `internal/provider/config_kvm.go`,
rather than being appended to `config.go` as the plan said: the KVM block has
far more fields and rules than the other two providers, and keeping it
separate leaves `config.go` readable. Tests likewise live in
`config_kvm_test.go` instead of extending the table in `config_test.go`; the
invalid-config table covers every required field, duplicate host names, image
URL scheme, sha256 shape, and the disk format enum.
While adding the `kvm` case to `validate()`, the pairwise "type is X but a Y
block is set" checks (which would have needed six branches with three types)
were replaced by a `setBlocks()` helper that lists the non-nil blocks and
rejects any that don't match `type`. The error strings are unchanged, so the
existing GCP/kubernetes tests still pass.
Dependencies were fetched up front so the API signatures in the plan could be
checked with `go doc`; they stay `// indirect` in `go.mod` until Step 2 imports
them:
```bash
go get github.com/digitalocean/go-libvirt@latest github.com/kdomanski/iso9660@v0.4.0
# go-libvirt resolved to v0.0.0-20260814190004-1a83157e1858 (no tagged release)
# side effect: golang.org/x/crypto 0.54.0 → 0.56.0, x/mod, x/text, x/tools bumped
```
Worth noting: `go doc` showed that go-libvirt's `New(net.Conn)` is deprecated
in favour of `NewWithDialer(socket.Dialer)`, where `Dialer` is a one-method
`Dial() (net.Conn, error)` interface. Step 3 should wrap the SSH client's unix
forward in that interface instead of using `New`; the plan's transport design
is otherwise unaffected.
## Step 2 — Pure builders
`internal/provider/kvm/` now has the five builders from the plan plus
`kvm.go` holding the package doc and `withDefaults` (disk size 10 GiB, the
default vendor-data, `/var/run/libvirt/libvirt-sock`, pool `default`, image
format `qcow2`). Everything is a pure function over `encoding/xml` structs or
strings, unit-tested by unmarshalling the output back and checking fields.
Two design choices differ from the plan text:
- **Domain disks reference pool volumes, not host paths.** The plan had
`buildDomainXML` take the overlay and seed paths (needing two
`StorageVolGetPath` round-trips per Create). The domain XML now uses
`<disk type="volume"><source pool=... volume=.../>`, so only the *base*
image path is ever looked up (the overlay's `<backingStore>` needs a real
path). Fewer RPCs and the builder's inputs are all names.
**Reverted in Step 7**: volume-referenced disks cannot start on an
AppArmor host, see there. The domain XML is back to `type="file"` with
the two `StorageVolGetPath` calls the plan had.
- **Capacities are always `unit="bytes"`.** libvirt's `G` means GiB but
`GB` means 10^9; spelling the byte count out removes the ambiguity from
both the overlay and the seed/base volumes.
The metadata element is written with a default namespace
(`<instance xmlns="…/kvm">`) because `encoding/xml` cannot emit prefixes on
marshal. libvirt may hand it back prefixed; `parseInstanceMetadata` is
tested against both spellings since `encoding/xml` matches on the namespace
URI. Whether libvirt accepts the default-namespace form on `DomainDefineXML`
is confirmed in Step 6 against a real host, not here. (Step 7: it does,
but `DomainGetMetadata` returns the element with no namespace at all; the
parser now matches on local name only.)
Checked before relying on `github.com/kdomanski/iso9660` for the seed: its
filename mangling lowercases and keeps `-`, so `user-data`/`meta-data`/
`vendor-data` come out as `user-data;1` etc. in the directory record, which
the Linux isofs driver (and therefore cloud-init's NoCloud mount) presents as
`user-data`. The seed test opens the ISO with the same library's reader and
asserts label `cidata`, all three files, and byte-identical output for
identical inputs. `NewWriter()` stages files in a temp directory, so
`Cleanup()` is deferred.
`go mod tidy` ran as part of this step; it promoted `kdomanski/iso9660` to a
direct dependency and dropped `go-libvirt` again because nothing imports it
yet — Step 3 brings it back.
Worth noting: `withDefaults` copies the `Images` map and `Hosts` slice
instead of mutating the config in place, so the registry's shared
`ProviderConfig` stays untouched. The default vendor-data installs
`qemu-guest-agent` via `packages:` + `runcmd`, which means the first boot
needs network access to the distro mirrors; a Proxy on an isolated bridge
would still work (ARP discovery), just without the agent.
## Step 3 — Transport + errors
`conn.go` holds the `libvirtAPI` seam (17 methods, signatures as in the
plan), a per-host connection pool, and the SSH dialer; `errors.go` holds
`classify`, `isLibvirtError`, `hasCode` and `logAPIError`. Both are
unit-tested; the whole package still runs in about a second under `-race`.
The one thing the plan did not anticipate: **go-libvirt RPCs take no
context and never time out**. A hung hypervisor would pin a reconcile worker
forever. So the pool exposes `call(ctx, host, fn)` rather than handing out
raw clients: `fn` runs in a goroutine, and if `ctx` ends first the pool
closes the SSH connection out from under it (which makes the blocked RPC
return) and reports `ctx.Err()`. The same helper (`awaitOrCancel`) bounds
`lv.Connect()` during dial. Two more liveness measures for the same reason:
the SSH handshake gets a deadline (ctx's, or 30 s), and each connection runs
an OpenSSH `keepalive@openssh.com` loop every 15 s that closes the client
when a request errors or goes unanswered for one interval.
Connection-drop policy in `call`: any error that is *not* a `libvirt.Error`
(so io.EOF, "connection closed", SSH failures) drops the cached connection
and the next call redials; a daemon-reported `libvirt.Error` keeps it. Dials
are serialised per host (concurrent Creates on one host share a dial) but
hosts don't block each other.
As Step 1 noted, the dialer uses `libvirt.NewWithDialer` with a
`socket.Dialer` that wraps `ssh.Client.Dial("unix", socket)` rather than the
deprecated `New(net.Conn)`.
Tests: the pool is exercised through a fake dialer (reuse, redial after
`Disconnected`, drop on transport error, keep on libvirt error, cancel
releases a blocked call, concurrent callers share one dial, hosts
independent). The SSH layer is tested against an in-process
`x/crypto/ssh` server that serves `direct-streamlocal@openssh.com` channels
by dialing the requested unix path (an echo socket standing in for
`libvirt-sock`), with keys generated per test and a real `known_hosts` file
written via `knownhosts.Line`. Covered: forward round-trip and the socket
path the server saw, unknown host key rejected with `knownhosts.KeyError`,
`insecureIgnoreHostKey`, wrong client key, cancelled ctx, missing key or
known_hosts file, and keepalive both closing a stalled client and leaving a
healthy one alone. The libvirt handshake (`Connect()`) itself is *not*
covered here — faking libvirt's XDR RPC just for that is not worth it; the
Step 6 integration test covers it against a real host.
`go mod tidy` promoted `go-libvirt` and `golang.org/x/crypto` to direct
dependencies.
Worth noting: `classify` maps `ErrOperationInvalid` (e.g. destroying a
domain that is already off) to Transient; Delete in Step 4 must check that
code itself via `hasCode` and treat it as success rather than relying on
the classifier. Nothing maps to `ErrQuotaExceeded` — libvirt has no quota
concept; an out-of-space pool surfaces as a Transient `ErrInternalError`.
## Step 3b — Image cache
`image.go` follows the plan closely: `resolveImage` maps `placement.image`
to a configured key or a direct http(s) URL (else Permanent);
`imageCache.ensure` checks base volume + marker (+ marker content when a
checksum is configured), tears down and re-fetches when anything is off,
streams the HTTP body through `StorageVolUpload` with a `sha256` tee,
refreshes the pool, then writes the marker last. A per-`(host, volume)`
mutex serialises concurrent callers; the pool itself is the only cache
state.
Errors from this file are pre-tagged with the taxonomy sentinel via
`fmt.Errorf("%w: …", provider.ErrPermanent, …)` and `classify` now honours
such tags before looking at libvirt codes. That keeps one `wrapErr` path in
the provider instead of special-casing image errors.
`fake_test.go` landed with this step rather than Step 4: an in-memory
`libvirtAPI` with pools, volumes (content, format, capacity, backing path),
domains (state, metadata, per-source interface addresses), per-method error
injection (`fail` always / `failOnce`) and an `uploadHook` to break a stream
mid-way. It models the libvirt error codes the provider branches on
(`ErrStorageVolExist`, `ErrNoStorageVol`, `ErrNoDomain`,
`ErrOperationInvalid`, `ErrNoDomainMetadata`, `ErrAgentUnresponsive`).
Tests cover every case in the plan plus: no checksum configured (marker is
still written with the computed digest; second call needs no marker read),
403/500 alongside 404/503, missing pool → Permanent, cancelled ctx →
Transient with nothing created, and different hosts fetching independently.
One test-harness lesson worth keeping: `httptest`'s server drops
`Content-Length` and switches to chunked encoding for bodies larger than
its 2 KiB write buffer, so the fake mirror sets the header explicitly.
Real distro mirrors send it; a server that doesn't is reported as
Permanent with a message naming the header.
Worth noting: the base volume is created with `capacity = Content-Length`
and the *image's* format (`qcow2`), and the raw bytes are uploaded over it.
That relies on libvirt's upload writing the file verbatim (which is how
`virsh vol-upload` populates volumes); the following `StoragePoolRefresh`
makes libvirt re-read the real virtual size from the qcow2 header. Step 6
confirms this on a real host. Also, the whole download runs inside one
`pool.call`, so cancellation aborts the HTTP body (ctx-bound) and the
upload stream fails; the half-written base volume is deleted on the spot
and, failing that, on the next attempt via the missing marker.
## Step 4 — Provider methods
`kvm.go` now holds `Provider`, `New` (loads the SSH key and known_hosts
up front via `newSSHAuth`, so a bad path fails at startup), the
`newWithDialer` seam, and `Create`/`Get`/`Delete`/`ListByTag`. Each
method resolves its host from config, then runs the whole libvirt
conversation inside one `hostPool.call`, so a cancelled reconcile drops
the connection and the blocked RPC returns. Errors pass through `classify`
in one `wrapErr`, as the Step 3b note intended.
Behaviour that goes beyond the plan text, or that a future reader should
know before touching this file:
- **Create looks up the domain first.** If it exists, only the running
check runs and the volumes are never touched. If not, nothing can be
using the volumes yet: the overlay is reused when it exists (its
creation is atomic; `ErrStorageVolExist` → reuse) but the **seed is
always deleted and rebuilt**. The plan said "exists → skip upload"; the
reviewer pointed out that a reconcile cancelled mid-upload leaves a
truncated seed that the compensating delete cannot remove, because
`hostPool.call` has already closed the connection the delete would go
over. Skipping such a seed would boot the VM without cloud-init and no
retry would ever repair it. Rebuilding costs one ~40 KiB upload per
attempt before the domain exists, nothing afterwards. The table test
breaks Create after each step and re-runs it, and a separate test
starts from a leftover truncated seed.
- **Get swallows address-source errors.** `DomainInterfaceAddresses` from
ARP returns nothing until the guest has sent traffic and the agent
source errors with `ErrAgentUnresponsive` until `qemu-guest-agent` is
up, so both are logged at V(2) and mean "no IP yet" → `Provisioning`.
Loopback, link-local and unspecified addresses are skipped when
choosing the first IPv4 (the agent reports `lo` too).
- **State mapping:** `running`/`blocked` are active; `nostate` →
Provisioning (the reconciler answers Stopped with delete-and-recreate,
too drastic for a state libvirt could not determine); everything else
(paused, pmsuspended, shutdown, shutoff, crashed) → Stopped.
- **Delete treats a missing pool as "nothing to clean"** rather than
Permanent: failing there would pin the Proxy's finalizer on a config
edit that renamed the pool. Domain removal is destroy (`ErrOperationInvalid`
on a stopped domain is fine) → undefine with managed-save, snapshot
metadata and nvram flags → overlay and seed deleted by name.
- **ListByTag** reads each domain's metadata once (passed into the same
`inspect` that Get uses, so no second `DomainGetMetadata` round-trip)
and skips domains without the operator's element. A domain that cannot
be read at all — vanished mid-sweep, corrupt metadata, state call
failing — is *skipped*, not fatal: GC never deletes what it does not
see, so leaving one out is safe, whereas one bad domain blocking GC for
every other one (the first version's behaviour, caught in review) is
not. An unreachable host still fails the whole sweep.
`ConnectListAllDomains` is called with flags 0 (active and inactive).
- A host named in a providerID but no longer in `kvm.hosts` is Permanent
for Get and Delete: the operator cannot reach it, and retrying forever
would hide the config problem.
Tests run the provider over a `hostDialer` that maps host names to
`fakeAPI` instances (an unmapped host is "unreachable"). The image is
pre-seeded in the pool for most tests; one test runs the full first-use
download through `httptest` and confirms a second Proxy on the same host
does not download again.
Found while running the package: `TestBuildSeedISO_deterministic` from
Step 2 was wrong. `iso9660.ImageWriter` stamps `time.Now()` into the
volume descriptor, so two builds in different seconds differ by six
timestamp bytes; the test had only passed because both builds landed in
the same second. Byte identity is nothing the provider depends on (an
existing seed is never re-uploaded), so the test now compares label,
files and size, and the comment on `buildSeedISO` no longer claims
identical output.
The `go-operator-reviewer` agent ran on the first version of this step
and found the two bugs above (truncated seed, wedged GC sweep) plus the
double metadata read and the `nostate` mapping; all were fixed before
committing. One finding it raised is deliberately **not** fixed here
because it lives in Step 3b's `image.go` and changes the volume naming
the plan chose: re-fetching a base image (checksum edited in config, or
upstream content changed) deletes `base-<key>.<fmt>` while existing
overlays still back onto it, breaking those VMs' disks. Content-addressed
base names would avoid it. Flagged for the user; until decided, Step 5's
docs must warn that an image's `sha256`/`url` must not change while
Proxies using it exist.
Worth noting: `go test -cover` reports 88.2 % for the package; the
uncovered lines are the real SSH/libvirt dial path and log-only
branches. `go vet` and `-race` (three runs) are clean. Wiring into
`cmd/main.go` is Step 5, so `New` is not yet reachable from a running
operator.
## Step 6 — Integration test
Done before Step 5, at the user's request, after a discussion of what the
unit tests can and cannot prove: they run over an in-memory fake of the
libvirt calls, so every assumption about libvirt's real behaviour (domain
XML accepted, raw qcow2 upload + pool refresh usable as a backing image,
guest boots and reports an address) needed a test against a real host.
The wiring in Step 5 is not a prerequisite — the test drives the provider
directly.
`internal/provider/kvm/kvm_integration_test.go` is behind
`//go:build integration` and skips itself under `-short` or when any of
`KVM_TEST_HOST`, `KVM_TEST_USER`, `KVM_TEST_SSH_KEY`, `KVM_TEST_BRIDGE`,
`KVM_TEST_IMAGE_URL` is unset. Optional knobs beyond the plan's list:
`KVM_TEST_KNOWN_HOSTS` (without it the run disables host-key checking
and logs that), `KVM_TEST_IMAGE_SHA256`, `KVM_TEST_POOL`,
`KVM_TEST_SOCKET`, `KVM_TEST_TIMEOUT` (default 20 m, must cover a cold
image download) and `KVM_TEST_KEEP` to leave a failed run's VM behind for
`virsh console`.
One sequential test, `TestKVM_lifecycle`, not subtests: each stage
depends on hypervisor state the previous one left. Create (timed, so the
log shows the image fetch), re-Create returns the same ID, poll Get to
Running with an IPv4 (Stopped fails fast with a hint to use the console),
UID and CreatedAt survive the metadata round-trip, ListByTag includes it,
Delete, Get → NotFound, second Delete is a no-op, ListByTag excludes it,
and the pool is inspected through the provider's own connection pool
(`p.hosts.call`, reachable because the test is in package `kvm`): overlay
and seed gone, base image and marker kept. A TCP connect to the guest's
port 22 is attempted but only logged, since the test machine may not
route to the bridge network. Cleanup deletes the VM on failure with its
own two-minute budget, because the test context may already be spent.
Makefile target, so the invocation is not something to remember:
```bash
make test-integration-kvm
# = go test -race -tags=integration -count=1 -v -timeout 40m -run TestKVM ./internal/provider/kvm/
```
`docs/testing.md` gained the kvm package rows in the suite table, a
"live libvirt" bullet under the deliberately-not-covered list, and a
section on running this test, with the deviation from the testcontainers
convention called out as the plan asked.
Worth noting: **the test has not been executed yet.** This session had
no hypervisor credentials; it was verified with
`go vet -tags=integration ./...` and by running it without the
environment (self-skip). Its first real run is the opening act of
Step 7's manual validation, and it is the point at which the Step 2 and
Step 3b "confirm on a real host" notes get answered. The test's config
is built directly rather than through the provider package's validation
(unexported), so a typo in an env var surfaces from `New` or from
libvirt, not from a validation message.
## Step 5 — Wiring, manifests, samples, docs, CHANGELOG
Done after Step 6 (see that entry for why the order flipped). Everything
the plan listed, plus a few things it did not:
- `cmd/main.go`: `"kvm": kvm.New` in the builtins map. No RBAC change —
the provider talks to hypervisors, not the API server.
- `config/manager/manager.yaml`: `kvm-ssh` Secret mounted read-only at
`/etc/proxy-operator/kvm` with `optional: true`, so deployments without
a kvm provider are unaffected. It nests under the providers-config
mount at `/etc/proxy-operator`; kustomize renders it (`kubectl
kustomize config/default` checked). The container runs as uid 65532
and Secret files default to 0644, so the key is readable without an
`fsGroup`; the `kubectl create secret` recipe is in a comment next to
the mount, in `docs/kvm-provider.md` and in the README.
- `config/samples/providers-config.yaml`: `kvm-home` block with Debian 13
and Ubuntu 24.04 official image URLs, two machine types, one host, and
every optional field shown commented out. Parsed through
`provider.LoadConfigFile` in a throwaway test to make sure the sample
validates. `config/samples/proxy_kvm.yaml` uses the GCP sample's squid
cloud-init and shows `ssh_authorized_keys` commented out rather than
set — a sample with a placeholder key would either fail cloud-init or
invite pasting a real key into a committed file. Added to the samples
kustomization; `config/manager/providers_config.yaml`'s comment now
mentions kvm.
- `docs/kvm-provider.md`: prerequisites with a three-line preflight
(`virsh list`, `pool-info`, `ip link` over ssh), cluster setup, the full
config reference table, placement table, a table of what lands on the
host and its lifetime, how the image cache works and how to evict, the
**warning not to change or evict an image while Proxies use it** (the
reviewer's finding from Step 4, carried as a documented rule since the
content-addressed-naming alternative was not chosen), how to log in,
and the known gaps.
- README: providers bullet and a "KVM setup" section mirroring the GCP
one. `docs/architecture.md`: components row, a §5 block for the kvm
call flow in the same style as the other two, and five decision-log
entries (SSH transport, operator-fetched images, seed always rebuilt,
`nostate` → Provisioning, skip-vs-fail in the GC sweep).
- `CHANGELOG.md` entry at the top, stating explicitly that the provider
is not yet validated on a real host.
Verification: `go build ./...`, `go vet ./...`, `go test -short` on
`cmd` and `internal/provider/...`, and both kustomizations rendered.
`hack/providers-dev.yaml` untouched, as planned.
Worth noting: the CRD has no rule requiring all three placement fields
(that is GCP's own check), so the single-host "zone may be omitted"
behaviour works without an API change. And with this step the branch is
feature-complete but **unproven**: Step 7 runs the integration test and
the end-to-end checklist against a real hypervisor before the MR.
## Step 7 — Manual validation + MR (in progress)
Host: `beelink` (Ubuntu, libvirt 11.6.0, QEMU 10.1.0, pool `default` at
`/srv/vms/images` on local ext4, bridge `br0`), reached over Tailscale as
`beelink.tail8c69c.ts.net`, SSH user `novakj`. Not the `nas1.home.lan`
the plan and samples name; that host does not resolve from the dev box.
```bash
export KVM_TEST_HOST=beelink.tail8c69c.ts.net:22
export KVM_TEST_USER=novakj
export KVM_TEST_SSH_KEY=$HOME/.ssh/proxy-operator_ed25519
export KVM_TEST_KNOWN_HOSTS=$HOME/.ssh/kvm_known_hosts
export KVM_TEST_BRIDGE=br0
export KVM_TEST_IMAGE_URL=https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
make test-integration-kvm
```
### Run 1 and 2: `Could not open '/srv/vms/images/base-test-image.qcow2': Permission denied`
Everything up to `DomainCreate` worked on the first try against a real
host, which answers the open notes from Steps 2 and 3b: the SSH-forwarded
libvirt handshake, the raw qcow2 upload plus pool refresh as a backing
image, the default-namespace metadata element, and `DomainDefineXML` for
the generated XML were all accepted by libvirt 11.6. QEMU then exited at
start with `Permission denied` on the base image.
Ownership was ruled out the slow way: the file was `root:root 0600` (the
dir-pool default; libvirt's DAC driver chowns the chain at start and
restores it after a failed start, so that observation meant nothing),
`qemu.conf` was all defaults (`libvirt-qemu`, `dynamic_ownership=1`), and
after a manual `chown libvirt-qemu:kvm` the second run failed identically.
The actual cause is the Step 2 choice of `<disk type="volume">`.
libvirt's AppArmor helper builds each domain's profile from the domain XML
without consulting the storage driver, and its `add_file_path` only emits a
rule when the source has a `path`; a volume-referenced disk has pool and
volume names instead, so neither the overlay nor its backing image gets a
rule and QEMU is denied on the first one it opens. Every stock Debian and
Ubuntu hypervisor confines QEMU this way. The unit tests could not have
caught it: the fake accepts either form.
Fix, in this step: `buildDomainXML` takes `DiskPath`/`SeedPath` and emits
`type="file"` disks; `ensureOverlay` and `writeSeed` return the volume
handles (the exists-already branch now does a `StorageVolLookupByName`),
and Create resolves both paths with `StorageVolGetPath` before defining
the domain. Two extra cheap RPCs per Create. `TestBuildDomainXML_fieldByField`
now fails on any `type="volume"` or `pool=` attribute so the design cannot
drift back, and `TestCreate_freshHost` asserts the disk sources equal the
paths the fake reports.
Worth noting: the first two runs left the base image and its marker in
the pool as designed, so later runs skip the 435 MB download.
### Run 3: `parsing domain metadata: expected element <instance> in name space … but have no name space`
With file-backed disks, Create returned in 1 s (image cached) and the
domain started. Get then failed to parse the metadata: libvirt 11.6 finds
the element by our URI but returns it as a bare `<instance>` with no
namespace declaration at all. Step 2 had tested the default-namespace and
prefixed spellings and left "does libvirt accept the default-namespace
form" for the real host; it does, it just does not give it back that way.
Fix: `parseInstanceMetadata` matches on the local name only, since the
lookup already selected the element by URI. The fake now strips the
declaration when storing metadata, so the provider's Get and ListByTag
tests parse the shape libvirt really returns, and a new
`TestParseInstanceMetadata_libvirtBareForm` pins it.
### Run 4: guest hangs at GRUB's "Booting" line
Create and start passed; Get polled `Provisioning` with no IP while the
VM sat at 100 % CPU after GRUB printed "Booting `Debian GNU/Linux`" and
before the kernel wrote anything to the serial console. The domain XML
matches a working VM on the same host (q35, BIOS, host-passthrough,
virtio disk, SATA cdrom, br0), so the difference is the disk content:
the cached image was downloaded without a checksum and never verified.
Diagnosis was handed to the user (sha512 against Debian's published
sums, `qemu-img check`, and a `virt-install --import` of the same image
outside the operator). Because the provider's own logs were invisible in
the test output, this run also produced a side plan:
[2026-09-10-0805-kvm-libvirt-tracing](2026-09-10-0805-kvm-libvirt-tracing.md),
which traces every libvirt RPC at V(1) and routes it into `-v` output.
### Run 5: the guest hang is Debian 13's BIOS boot path; UEFI fixes it
The cached image's sha512 matched Debian's published sum, and
`qemu-img info` showed a sound 3 GiB qcow2, which also confirms that
the raw upload over a libvirt-created volume plus pool refresh yields a
byte-identical file (the Step 3b note is answered). Bisected by hand on
beelink with `virt-install --import` on a fresh overlay of the same
image:
| variant | seed | CPU | firmware | result |
|---|---|---|---|---|
| operator's domain | ours | host-passthrough | SeaBIOS | hangs after GRUB "Booting", 100 % CPU |
| bare | none | host-passthrough | SeaBIOS | same hang |
| bare | none | host-model | SeaBIOS | same hang |
| bare | none | host-passthrough | **OVMF (`--boot uefi`)** | **boots to login** |
So neither the seed ISO, the domain XML nor the CPU model was the
problem: the image's legacy GRUB → kernel handoff does not work on this
host, while its EFI stub path does. Not diagnosed further; it is
Debian's chain, not ours, and every current cloud image boots UEFI.
Change: `images.<key>.firmware` (`uefi` default, `bios` escape),
threaded through `withDefaults` → `imageSource` → `domainParams`, and
`buildDomainXML` emits `<os firmware="efi"><firmware><feature
enabled="no" name="secure-boot"/></firmware>` so libvirt selects the
OVMF binary and creates the NVRAM file; the `nvram` undefine flag Delete
already passes removes it (seen on the host: `virsh undefine` without
`--nvram` refuses such a domain). Direct-URL images default to UEFI
too. Docs: OVMF is a new hypervisor prerequisite, config table row,
sample comment, known-gaps entry, decision-log entry.
Worth noting: the first image download went through with no checksum
configured, and the cache reuses a base image by key, so switching
`KVM_TEST_IMAGE_URL` requires deleting `base-test-image.qcow2` and its
marker by hand first. That is the reviewer's Step 4 finding showing up
in practice; still documented rather than fixed.
Follow-up, same day: the cause is known upstream. [Debian bug
#1111240](https://bugs.debian.org/1111240) (filed 2025-08-16 against
the cloud image, reassigned to `grub2`, still open as of 2026-09-10;
trixie ships grub2 2.12-9+deb13u2) reports exactly this with a domain
XML almost identical to ours: BIOS, virtio disk, SATA cdrom seed, bridge
NIC, `<console><target type='serial'/>`, no `<graphics>`/`<video>`.
The cloud team reproduced it: trixie's grub.cfg has `terminal_output
gfxterm serial`, and with a serial port present but no video adapter
GRUB never hands over to the kernel. Their fix in the image
(`terminal_output serial`) is not something an operator can apply;
the reporter's workaround (console `target type='virtio'`) boots but
takes the kernel's `console=ttyS0` output with it. UEFI sidesteps the
gfxterm path and keeps the serial console, so the decision stands, but
the docs now name the bug rather than blaming the host, and note that
bookworm images are unaffected. `latest` on cloud.debian.org was the
2026-08-31 build when this was hit, so every trixie point release to
date carries it.
### Run 6: pass
With UEFI the full lifecycle passed in 23 s on a warm cache: Create 2 s
(domain start 0.9 s), guest agent answering with `192.168.0.89` after
20 s (ARP never did; the host had not talked to the guest), TCP 22
reachable from the dev machine, ListByTag seeing it, Delete leaving only
the base image and marker in the pool, second Delete a no-op. Trace
excerpt worth keeping for the record:
```
09:44:28.603 libvirt virDomainDefineXML xmlBytes=1509 domain=proxy-hk5oavoqk45n3twq duration=13.51ms
09:44:28.603 kvm domain defined uid=9bbceb41-… firmware=uefi
09:44:29.542 libvirt virDomainCreate domain=proxy-hk5oavoqk45n3twq duration=938.705ms
09:44:49.573 libvirt virDomainInterfaceAddresses domain=proxy-hk5oavoqk45n3twq source=DomainInterfaceAddressesSrcAgent interfaces=2 duration=1.742ms
09:44:49.573 kvm KVM domain fetched state=Running ip=192.168.0.89 uid=9bbceb41-…
```
Worth noting: the sweep listed a second managed domain,
`proxy-qxge3dbm2mnpc3ih`, shut off, left behind by an earlier failed
run whose cleanup never ran. That is exactly the orphan GC exists for;
on the host it needs `virsh undefine --nvram` plus its two volumes by
hand. The `ListByTag` sweep costs one `virDomainGetMetadata` per
domain on the host (21 here, ~0.4 ms each over the tunnel), which is
fine at this scale but is the call to watch on a busy hypervisor.
### Run 7: in-cluster, two defects the integration test could not see
The e2e pass ran the operator inside the kind cluster
(`egress-proxies-operator:kvm-dev`, built with `make docker-build`,
loaded with `kind load docker-image`, deployed via the kustomize
`config/manager` with the `kvm-home` provider pointing at beelink) and
two Proxies with real cloud-configs: one plain squid, one with a debug
login user. Both defects come from the difference between the test
harness and the real thing, not from libvirt.
**Read-only root filesystem.** The manager runs with
`readOnlyRootFilesystem: true`, and `iso9660.NewWriter` stages the seed
in a temp directory, so the first Create failed with `creating iso
writer: read-only file system`. Fix: a 64 MiB `emptyDir` on `/tmp` in
`config/manager/manager.yaml`. Side finding: the error is classified
`ErrPermanent` by `writeSeed`, yet `hostPool.call` treated any
non-libvirt error as a transport failure and dropped the SSH connection,
so every retry redialled. `call` now leaves the connection alone for
errors already carrying a provider sentinel (`isClassified`), with a
test.
**The guest agent never got installed.** Both VMs booted (confirmed on
the console by hand) but sat in `Provisioning` for 25 minutes: the
trace showed `virDomainInterfaceAddresses` from the agent source failing
with `ErrAgentUnresponsive` on every poll and the ARP source empty. The
integration test had passed with `CloudInit: "#cloud-config\n"`; the
real Proxies have `packages: [squid]` and `runcmd:`. cloud-init merges
vendor-data under user-data with its default mergers, which keep the
user-data's value for any key both define and never append lists, so the
vendor-data's `packages`/`runcmd` were dropped wholesale. Confirmed
locally with the dev box's cloud-init 26.1 before changing anything:
```sh
python3 - <<'EOF'
from cloudinit import mergers
user = {"packages": ["squid"], "runcmd": ["systemctl restart squid"]}
vendor = {"packages": ["qemu-guest-agent"], "runcmd": [["systemctl", "enable", "--now", "qemu-guest-agent"]]}
print(mergers.construct(mergers.default_mergers()).merge(dict(user), dict(vendor)))
EOF
# {'packages': ['squid'], 'runcmd': ['systemctl restart squid']}
```
Fix: `withGuestAgent` in `seed.go` merges the bootstrap into the Proxy's
cloud-config itself (append to `packages` and `runcmd`, skip if present,
re-render with `sigs.k8s.io/yaml`), so vendor-data is no longer defaulted
and `vendorData` is plain extra data. User-data that is not a cloud-config
document (script, `#include`, MIME) still gets the bootstrap as
vendor-data since there is nothing to collide with; a cloud-config that
does not parse as a mapping fails the Proxy as `ErrPermanent`. The
rendered document for the squid Proxy passed `cloud-init schema
--config-file`. The logging test's fixture (`#cloud-config` + a bare
sentinel line) is not a mapping and had to become `hostname: <sentinel>`.
Rebuilt, reloaded, restarted, deleted and recreated both Proxies: both
`Ready`/`HEALTHY True` in 2 min 1 s from `kubectl apply`, IP via the
agent 110 s after `virDomainCreate` (the guest installs squid and the
agent from the mirrors first), and `curl -x http://<ip>:3128
https://www.gstatic.com/generate_204` from the dev box returns 204
through both.
Worth noting: the `#cloud-config` round-trip drops comments and reorders
keys alphabetically; semantics are preserved (numbers, booleans and
block scalars survive, checked in tests) but a user diffing the seed
against their CR will see a different document. Vendor-data supplied via
`vendorData` is subject to the same cloud-init merge rule and is now
documented as such. The kind pod also restarted twice during the session
from "Pod sandbox changed" (the node briefly lost the API server), which
is unrelated to the operator. Still uncommitted on purpose: the
`kustomization.yaml` image override (`egress-proxies-operator:kvm-dev`)
and the `beelink` host entries in `config/manager/providers_config.yaml`
and `config/samples/providers-config.yaml`, which are this machine's dev
setup rather than the repo's defaults.
### Run 8: review fixes
A `/code-review high` pass over the branch found five defects, fixed in
[plan 2026-09-10-2222](../plans/2026-09-10-2222-kvm-review-fixes.md) with
its own execution log. Two of them land on Run 7's conclusions above and
supersede them:
- The guest-agent **merge described in Run 7 is gone**. It could not be made
safe for user-data that is not a plain cloud-config, and its YAML
round-trip re-typed unquoted scalars. The bootstrap now ships as a
vendor-data shell script, which cloud-init never merges, and the Proxy's
cloud-init reaches the seed byte for byte.
- The read-only-filesystem fix from Run 7 still stands, but the eager SSH
key load it sat next to does not: a missing `kvm-ssh` Secret aborted the
whole provider set and crash-looped the manager. The material is read on
first use now.
Re-validated the same way: integration test against beelink, both Proxies
recreated in kind, 204 through squid.
### MR opened
[PR #5](https://gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/pulls/5),
`feat/kvm-provider` → `main`, 19 commits. `tea` is not installed on the
dev box and the HTTPS remote has no credential helper, so the push used a
one-off helper reading a Gitea access token from `~/.config/gitea-token`,
and the MR was created with the API instead of `tea pr create`:
```sh
git -c credential.helper='!f() { echo username=kacerr; echo "password=$(tr -d "[:space:]" < ~/.config/gitea-token)"; }; f' \
push -u origin feat/kvm-provider
curl -s -H "Authorization: token $(tr -d '[:space:]' < ~/.config/gitea-token)" \
-H 'Content-Type: application/json' -d @pr.json \
https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/pulls
# pr.json: {"title": ..., "head": "feat/kvm-provider", "base": "main", "body": ...}
```
The push printed a libsecret "store failed" warning from the system
credential store; harmless, the token was not persisted anywhere but the
file.
@@ -0,0 +1,109 @@
# Execution: Trace every libvirt call in the KVM provider
Plan: [2026-09-10-0805-kvm-libvirt-tracing.md](../plans/2026-09-10-0805-kvm-libvirt-tracing.md)
- [x] Step 0 — Save plan
- [x] Step 1 — `errors.go`: shared error fields
- [x] Step 2 — `trace.go`: the `tracingAPI` decorator
- [x] Step 3 — `conn.go`: pool logging and wrapping
- [x] Step 4 — Call sites
- [x] Step 5 — Integration test output
- [x] Step 6 — Unit tests
- [x] Step 7 — Docs, execution log, CHANGELOG
## Steps 1–6 — Tracer, pool logging, tests, integration wiring
Done as one commit on `feat/kvm-provider`; the plan's assumption that
this belongs on the KVM branch rather than its own feature branch was
accepted at plan approval.
The code follows the plan closely. Things that came out differently
or are worth knowing:
- **Logger names join with `/` in funcr, `.` in zap.** The trace lines
are `kvm/libvirt` in the unit-test capture and in `testr` output, and
`kvm.libvirt` under the operator's zap logger. The docs use the zap
spelling; the unit test asserts the funcr one. First run of the tier
test failed on exactly this.
- **Cancel is not reported as a transport error.** The original `call`
dropped the connection twice on cancellation (once from the abort
closure, once from the "not a libvirt error" branch, harmlessly, since
drop is idempotent). With a log line on each drop that would have
printed both `context cancelled` and `transport error` for one event,
so the transport branch now skips `ctx.Err()`. Covered by
`TestHostPool_call_logsDropReason`.
- **`ConnectListAllDomains` in the fake includes every defined domain**,
so the trace test's fixture (one running managed domain plus one
stopped domain for `DomainCreate` to start) lists two; the V(2)
assertion checks for the `names` key rather than an exact list.
- **`libvirtErrorFields` also feeds `logAPIError`**, which therefore
gains `libvirtCodeName` (e.g. `ErrNoDomain`) next to the numeric code.
- The seam-coverage guard (`reflect` count of `libvirtAPI` methods vs
the test table) is the only thing standing between a future 18th RPC
and a silently untraced call; keep it.
Verification run before committing:
```bash
go build ./... && go vet ./... && go vet -tags=integration ./internal/provider/kvm/
go test -short ./...
go test -race -count=1 -cover ./internal/provider/kvm/ # 90.2 %, up from 88.2
go test -tags=integration -count=1 -run TestKVM ./internal/provider/kvm/ # self-skips without env
```
Worth noting: the live run (`make test-integration-kvm` against beelink)
had not happened when this was written; it is what Step 7 of the KVM
provider plan is waiting on, and its output is the acceptance test for
this feature. Expected shape is in the plan's Verification section.
## Revision after the first live run — less noise, libvirt names
The first live run on beelink showed the trace working but hard to read:
`testr` printed `"ts"`, `"level"`, `"msg"` and the three per-run
constants on every line, the error text appeared twice (`error` and
`libvirtMessage`), `host` appeared twice on image-cache lines (the
logger already carried it), and `method=DomainLookupByName` was
go-libvirt's name rather than something to look up in libvirt's docs.
The user asked for "less verbose but more informative". Changes:
- The tracer's headline field is now `rpc=virDomainLookupByName`: the
public libvirt API function, which go-libvirt's method names are
exactly minus the `vir` prefix (they come from the same
`remote_protocol.x`). Not gRPC, for the record: libvirt's own
XDR-based RPC over the forwarded unix socket.
- Errors are one `error` field plus `libvirtError=ErrNoDomain` and
`libvirtCode=42`; `libvirtMessage` is gone from both the tracer and
`logAPIError`, since it is the error text.
- `image.go` no longer adds `host` to lines whose logger has it.
- The integration test replaces `testr` with a small `compactSink`
(`logr.LogSink` plus `GetCallStackHelper`, so `t.Log` still
attributes lines to `trace.go`): `HH:MM:SS.mmm libvirt
virDomainCreate domain=… duration=… error=… libvirtError=…`. The
`rpc` value becomes the headline, `provider`/`host`/`name` are
dropped (the run's first `t.Log` line states them), and values with
spaces are quoted. Production zap output is unchanged apart from the
field renames; structured logs are meant to repeat context.
Previewed by driving the sink through the fake provider with an
injected `DomainCreate` failure (a throwaway integration-tagged test,
not committed):
```
trace.go:55: 09:04:33.165 libvirt virDomainLookupByName domain=proxy-abc123def456ghij duration=4µs error="Domain not found: …" libvirtError=ErrNoDomain libvirtCode=42
trace.go:55: 09:04:33.165 libvirt virStorageVolUpload pool=default volume=proxy-abc123def456ghij-seed.iso offset=0 length=0 duration=71µs
kvm.go:262: 09:04:33.165 kvm seed volume uploaded volume=proxy-abc123def456ghij-seed.iso bytes=45056
trace.go:55: 09:04:33.165 libvirt virDomainCreate domain=proxy-abc123def456ghij duration=0s error="internal error: process exited…" libvirtError=ErrResourceBusy libvirtCode=87
```
Worth noting: `length=0` on `virStorageVolUpload` is what the provider
passes (libvirt reads to EOF); the following `seed volume uploaded`
line has the byte count.
## Step 7 — Live run confirmed; CHANGELOG
The compact trace was accepted on the first passing live run
(2026-09-10, beelink): one line per RPC with the libvirt function name,
the provider's own lines between them, `Provisioning` polls readable at
a glance (`SrcArp interfaces=0`, `SrcAgent … ErrAgentUnresponsive`
until the agent came up at +20 s), and a full `ListByTag` sweep over 21
domains in 15 ms. CHANGELOG entry added under the KVM validation entry.
@@ -0,0 +1,229 @@
# Execution: fix the five code-review findings on `feat/kvm-provider`
Plan: [docs/plans/2026-09-10-2222-kvm-review-fixes.md](../plans/2026-09-10-2222-kvm-review-fixes.md)
- [x] Step 0 — Save plan into the repo
- [x] Step 1 — Unwedge deletion (finding 1)
- [x] Step 2 — Validate names that become identifiers (finding 2)
- [x] Step 3 — Read the SSH material lazily (finding 3)
- [x] Step 4 — Guest agent via vendor-data script (findings 4 and 5)
- [x] Step 5 — Validate, record, push
## Step 0 — plan committed
Copied out of `~/.claude/plans/` and committed alone before touching code, as
the repo's plan rules require.
## Step 1 — Unwedge deletion
Three changes, in the order the bug unfolds.
`reconcileDelete` calls `Get` first and only drops the finalizer when it
answers not-found, so any permanent error there retried forever. It now
switches on the class: a permanent `Get` still calls `Delete`, which is
idempotent, and releases the object when that succeeds. Polling a `Get`
that cannot converge was the wedge; `Delete` is the only call that can
still make progress.
`crawl.example.com/skip-cleanup: "true"` abandons the instance and removes
the finalizer. It is checked **before** the provider lookup, which also
frees a Proxy whose provider was deleted from the config — a second wedge
sitting three lines above the reported one, with the same shape. No
`EventRecorder` exists on the reconciler and none was added, so the
abandonment is an Info log; `docs/architecture.md` already records that
decision for the whole controller.
In the KVM provider, `Delete` for a providerID naming an unconfigured host
now logs and returns success. The precedent is two functions further down:
`removeVolumes` treats a missing pool the same way, with a comment saying
outright that refusing "would pin the Proxy's finalizer".
**`Get` deliberately still fails permanently for that case.** Making it
answer not-found would have been a one-line fix for the wedge, but the live
reconcile path reads not-found as "the VM is gone" and rebuilds it
elsewhere. A typo in `hosts` would then quietly duplicate every Proxy on
that host, with the originals still running where GC cannot see them, since
`ListByTag` only sweeps configured hosts. The asymmetry is now pinned by a
test that asserts both halves at once.
## Step 2 — Validate names that become identifiers
One regexp, `^[A-Za-z0-9][A-Za-z0-9_.-]*$`, applied to `hosts[].name` and to
the `images` map keys. Dots stay legal because a host name like
`nas1.home.lan` is ordinary and harmless; the character that actually
breaks things is `/`.
The review only reported the host name. The image key has the same defect
and is arguably worse: it is not merely embedded in an ID but pasted into a
file name, `base-<key>.<format>`, so `../etc/x` would be a path traversal
into the storage pool rather than a parse failure. Both are validated in the
same pass. Machine-type keys are left alone deliberately — they are only
ever compared against `placement.machineType`, never embedded anywhere.
Worth noting: this is validation of the operator's own config file, which
only a cluster admin can write, so the traversal was never a privilege
boundary. It is still cheaper to reject at load than to explain a stuck
Proxy later. Both new table cases assert the message text, so the wording
that names the consequence is now pinned by tests.
## Step 3 — Read the SSH material lazily
`kvm.New` no longer loads the key; `lazyAuth` does, on the first connection,
caching the result once it works. `registry.Build` is all-or-nothing by
design, so the old constructor error did not merely fail the KVM provider,
it exited the manager before any controller started.
The plan said "lazy dialer", and the first attempt was exactly that — one
closure wrapping both the loading and the dial. It could not be tested: the
fixture's SSH server is real, but the socket behind it is an echo server, so
every successful dial dies in the libvirt handshake and the assertion could
not tell "the key worked" from "the key did not". Splitting the seam in two,
`lazyAuth` for the material and `lazyDialer` for the wiring, made both
testable: the healing path against `lazyAuth`, and a separate test that the
dialer really authenticates, asserted by the error being past SSH and by the
server having seen the forward request.
Error classes carry the intent. A missing file stays unclassified, so
`provider.Class` defaults it to transient and a Proxy heals on its own when
the Secret appears — the common case, since the Secret is usually created
after the manager. A key that does not parse wraps `ErrPermanent`, because
retrying cannot fix it. `newSSHAuth` distinguishes the two with
`errors.Is(err, fs.ErrNotExist)`.
Worth noting: the startup check is kept, just non-fatally, logged with the
`WARNING:` prefix the repo already uses where logr has no warn level. That
preserves the diagnostic the eager version was written for. The caching test
deliberately deletes the key file after a successful load and asserts the
cached value is reused, which pins the rotation behaviour the docs promise:
a rotated Secret needs a manager restart. `TestNew_badKeyPathFailsFast` was
inverted rather than deleted, so the old contract stays visible in the diff
next to the new one.
## Step 4 — Guest agent via vendor-data script
The review filed two findings against `withGuestAgent` — detection too
narrow, and YAML re-typing on the round-trip. Reading cloud-init 26.1's
sources while planning turned both into one finding: the operator should
not be editing the user's document at all.
Vendor-data gets its own handler chain (`stages.py:_default_vendordata_handlers`),
with separate script and cloud-config paths. A vendor-data **script** is
therefore written to the vendor script directory and run by
`cc_scripts_vendor`, never touching the config merger that drops colliding
keys. On the final-stage module list it lands after
`package_update_upgrade_install` and before the user's `runcmd`, so the
agent now comes up *earlier* than the old `runcmd` append did.
That also closed the door the previous approach had left ajar. The merge
cannot be rescued from inside either document: `merge_how` in user-data
governs only the first source, and `merge_how` in vendor-data is popped by
`dict_extract_mergers` and discarded before the document is written. There
was no fix that kept the merge.
`withGuestAgent`, `isCloudConfig` and `appendUnique` are gone, along with
seed.go's `sigs.k8s.io/yaml` dependency. `installGuestAgent` (default true)
turns the script off. When `vendorData` is also configured the two ship as
a MIME multipart, with the user's document declared `text/plain` — a type
cloud-init re-sniffs from the content's own header line, so this code never
classifies what the user wrote.
Before deleting the old path, its damage was captured rather than taken on
trust. Round-tripping a plausible cloud-config through it:
```sh
# temporary test in the package, deleted afterwards
go test -run TestZZProbeRetyping -v ./internal/provider/kvm/
# in: version: 1.10
# out: version: 1.1
```
`1.10` arriving as `1.1` is worse than the reviewer's `passwd` example,
since nothing about it looks wrong in the seed. Comments were dropped and
keys reordered on the same pass.
The generated vendor-data was then checked against the cloud-init actually
installed on this machine, rather than against the docs:
```sh
python3 -c 'from cloudinit import user_data, handlers; ...'
# vendor-script: multipart=False sniffed=text/x-shellscript
# vendor-multipart: multipart=True part 1 text/plain -> text/cloud-config
# part 2 text/x-shellscript
```
and the script itself through `sh -n`.
Worth noting: the script has to choose a package manager where cloud-init's
`packages:` key was distro-agnostic. It covers apt, dnf, yum, zypper and
apk, and both systemd and OpenRC for the enable step, but an image outside
that set now needs `installGuestAgent: false` and the agent baked in. The
seed's user-data is byte-identical to the Proxy's `cloudInit` from here on,
which is what the new `TestBuildSeedISO_userDataIsNeverRewritten` pins
across seven formats. One consequence worth remembering: a user-data that
sets `vendor_data: {enabled: false}` disables the agent, and cloud-init
documents that as intended behaviour.
## Step 5 — Validation
Unit suite, `go vet` and `gofmt` clean across the repo. Then the two real
runs.
**Integration against beelink**, unchanged environment from the KVM plan's
log, passed in 22 s on a warm cache, with
`virDomainInterfaceAddresses source=DomainInterfaceAddressesSrcAgent
interfaces=2` — the agent installed by the new vendor script answering, on
a stock Debian 13 image.
**In-cluster**, both Proxies deleted and recreated against the rebuilt
image: Ready with an address 2 min 3 s after `kubectl apply`, 204 through
squid from the dev box for both. Inside the debug VM:
```sh
ssh novakj@192.168.0.94 'systemctl is-active qemu-guest-agent; cloud-init status; \
sudo head -4 /var/lib/cloud/instance/user-data.txt; sudo ls /var/lib/cloud/instance/scripts/vendor/'
# active
# status: done
# #cloud-config
# # Login for debugging: user "novakj", password "kvm-dev", passwordless sudo.
# ...
# part-001
```
Two things worth having seen with your own eyes there: the user's **comment
lines survived** into the guest, which the old merge stripped, and the
script really did land in the vendor directory rather than being merged
into the user's config.
**Negative check for the Secret**, done by pointing the deployment's volume
at a name that does not exist rather than deleting the real Secret:
```sh
kubectl -n egress-proxies-operator-system patch deploy egress-proxies-operator-controller-manager \
--type=json -p '[{"op":"replace","path":"/spec/template/spec/volumes/1/secret/secretName","value":"kvm-ssh-absent"}]'
```
The manager came up and stayed up, 0 restarts, logging
`WARNING: kvm ssh material is not usable ... open /etc/proxy-operator/kvm/id_ed25519:
no such file or directory`. The Kubernetes Proxies kept reconciling; the
KVM ones failed per reconcile and retried. Before this branch the same
config exited the process before any controller started. Patched back
afterwards, everything Ready.
Worth noting: the KVM Proxies kept reporting `Ready` during that window
rather than degrading visibly. That is existing behaviour, not a
regression — a transient provider error rides the workqueue and does not
clear a healthy status — but it does mean the only signal of a missing
Secret is the log and the reconcile errors. Also, the ListByTag sweep on
beelink still reports `managed=2` where one domain is an orphan from an
older failed run; unrelated to this work, and the same leftover noted in
the KVM plan's Run 6.
Worth noting: the review filed this as a KVM finding, but the fix is mostly
in the controller, because KVM is the only provider that resolves a
providerID segment against operator config at all. GCP and Kubernetes hand
the lookup to the backend, which answers 404 for a retired zone or a deleted
namespace, so they reach not-found and release on their own. Anything else
that classifies permanently on the delete path — revoked RBAC on the
Kubernetes provider is the realistic one — was equally stuck before this
step and is what the annotation is really for. The KVM table case
`"host not configured"` moved out of `TestDelete_errors` into its own named
test, since it is no longer an error.
@@ -0,0 +1,378 @@
# Execution: provider health checks
Plan: [docs/plans/2026-09-11-1526-provider-health-checks.md](../plans/2026-09-11-1526-provider-health-checks.md)
- [x] Step 0 — Save the plan
- [x] Step 1 — ProviderHealth API and CRD
- [x] Step 2 — Contract plus the kubernetes and GCP checks
- [x] Step 3 — KVM check
- [x] Step 4 — Monitor
- [x] Step 5 — Metrics and wiring
- [x] Step 6 — Docs
- [x] Step 7 — Validate, record, push, open the PR
## Step 0 — Save the plan
Branched off `main` (at the PR #5 merge, `eb8f075`). The plan was copied out of
`~/.claude/plans/` and committed on its own, before any code, as the repo's plan rules
require:
```bash
git checkout -b feat/provider-health main
cp ~/.claude/plans/now-i-would-like-flickering-wall.md docs/plans/2026-09-11-1526-provider-health-checks.md
```
The uncommitted `.claude/settings.json` change was left unstaged and stays out of this
branch.
## Step 1 — ProviderHealth API and CRD
`kubebuilder` isn't installed here (only `bin/controller-gen`, `bin/kustomize` and
`bin/setup-envtest` are), so `api/v1alpha1/providerhealth_types.go` and the `PROJECT`
entry were written by hand instead of with `kubebuilder create api`. For a cluster-scoped
resource `PROJECT` simply omits `namespaced`, which is what kubebuilder itself writes.
The new types are registered in the existing `AddKnownTypes` call in `proxy_types.go`,
not in a second `init()`.
The deepcopy code, the CRD and the RBAC come from the usual generators:
```bash
make generate manifests
go build ./... && go vet ./api/... && go test -short ./api/...
```
To confirm the new CRD and the viewer role actually reach the rendered install, I built
the kustomization and grepped for them:
```bash
bin/kustomize build config/default | grep -n providerhealth
# providerhealths.crawl.example.com (scope: Cluster, shortName ph)
# egress-proxies-operator-providerhealth-viewer-role
```
Worth noting:
- The only role added is the viewer role. The operator is the object's only writer, so
the admin and editor roles kubebuilder scaffolds for Proxy were not added.
- The type file has no unit test, since it has no logic. The schema, including the
required `ready`/`lastTransitionTime` and the list-map keys, is proven by the envtest
round-trip in Step 4.
- `config/rbac/role.yaml` is unchanged so far. The manager's own permissions arrive in
Step 4 with the Monitor's RBAC markers.
## Step 2 — Contract plus the kubernetes and GCP checks
`provider.Checker` is an optional interface in `internal/provider/check.go`, kept out of
`Provider`. Each implementation has a compile-time assertion, because the monitor's
type assertion would otherwise fail silently.
- **kubernetes:** lists at most one managed Pod cluster-wide, using the same client, verb
and scope as `ListByTag`. The target is `apiserver`, at the `restCfg.Host` now stored
in the new `apiHost` field.
- **GCP:** calls a new seam method, `Probe`. `realInstances.Probe` lives in `check.go`,
next to its only caller. `ListByTag`'s `Sprintf` filter became the shared
`managedFilter` constant, so the check and the GC sweep can't drift apart.
Before relying on "MaxResults is only a page size", I checked the SDK's REST
`AggregatedList` in the module cache:
```bash
grep -n "func (c \*instancesRESTClient) AggregatedList" -A80 \
"$(go list -m -f '{{.Dir}}' cloud.google.com/go/compute)"/apiv1/instances_client.go
# InternalFetch: req.MaxResults = pageSize ... return elems, resp.GetNextPageToken(), nil
```
```bash
go build ./... && go vet ./internal/provider/... && go test -race -count=1 ./internal/provider/...
```
Worth noting:
- The planned `TestRealInstances_Probe_fetchesOnePage` became a three-case
`TestRealInstances_Probe` table.
- It keeps the first page's `nextPageToken`, and asserts exactly one request with
`maxResults=1` for each case.
- "empty project" covers `iterator.Done`.
- "api error keeps its status" proves the real client returns a `*googleapi.Error`, so
a disabled Compute API is reported as `Forbidden` rather than `Unreachable`.
- Once a context has ended, both checks report `Timeout` whatever the error says. The
monitor's timeout is the usual cause, and the underlying error only repeats it.
## Step 3 — KVM check
`conn.go` now wraps each of the four dial stages in a `dialError`: ssh-material, tcp,
ssh-handshake and libvirt-connect. `Error()` returns the inner message unchanged and
`Unwrap()` returns the cause, so `classify`/`isClassified` and every existing
message-matching test behave as before. `TestDialError_keepsClassification` pins this
down.
The three new RPCs went into `libvirtAPI`, `tracingAPI` and `fakeAPI`. The method-count
guard in `TestTracingAPI_logsEveryMethod` fails until a traced row exists for each new
method, which is what forced the three new rows.
`Check` runs one goroutine per host, over the shared connection, through
`hostPool.call`. The pool verdict is decided only after `call` returns, because
returning it as an error from inside the callback would make `call` drop a perfectly
good connection.
Build, vet (also with the integration tag, since that file changed) and race tests:
```bash
go build ./internal/provider/... && go vet ./internal/provider/kvm/ \
&& go vet -tags=integration ./internal/provider/kvm/ \
&& go test -race -count=1 ./internal/provider/kvm/
```
Worth noting:
- **Pool state wording:** `poolState` uses virsh's words (`running`, `inactive`, …)
rather than go-libvirt's constant names, so it reads like `virsh pool-list` output.
- **Details:** each host also reports its SSH `user`. That is the first thing to check
when the reason is `SSHHandshakeFailed`.
- **Tests renamed or merged:**
- The planned `TestSSHAuth_dial_stages` is `TestLazySSH_dial_tagsTheFailingStage`. It
dials through `lazySSH`, so the ssh-material stage is covered as well as the three
stages the in-process SSH server can trigger.
- The pool-inactive and pool-missing cases are merged into `TestCheck_libvirtVerdicts`,
which also covers a daemon error. Each case runs two checks and asserts a single
dial.
- **Tests added beyond the plan:**
- `TestCheck_timeout`: a daemon that stops answering gives `Timeout`.
- `TestDialError_keepsClassification`.
- **Not run yet:** `TestKVMCheck_realHost` in the integration file only compiles here.
It needs beelink, so it goes on the Step 7 checklist for the user to run.
## Step 4 — Monitor
`internal/providerhealth` has the `Monitor` runnable plus the pure `buildStatus`.
- **Scheduling:** the Monitor is leader-only. It cleans up once at start, checks
immediately, then checks on a ticker.
- **Each round:** providers are checked concurrently under one timeout, then logged and
published one after another. A failed write is logged, and the next round writes
again.
- **Reads:** objects are read through the reader it is given. `main.go` passes the
manager's API reader (Step 5).
The first `make manifests` left `config/rbac/role.yaml` unchanged, with no error.
I had put the `+kubebuilder:rbac` markers inside `Monitor`'s doc comment, and
controller-gen only reads RBAC markers from package-level comment groups. So they were
silently attached to the type and ignored. An empty `git diff config/rbac/role.yaml` was
the only sign. Moving them into their own comment block fixed it:
```bash
make manifests && git diff config/rbac/role.yaml
# + providerhealths: create, delete, get, list
# + providerhealths/status: get, patch
```
Vet, race tests and the envtest round-trip (using the `bin/k8s/1.36.2-linux-amd64`
binaries `make setup-envtest` had already fetched):
```bash
go vet ./internal/providerhealth/ && go test -race -count=1 -v ./internal/providerhealth/
# --- PASS: TestMonitor_envtestRoundTrip (6.45s) ... ok
```
Worth noting:
- **Fake client scope:** the fake client needs a RESTMapper that registers
ProviderHealth as `RESTScopeRoot`. Its default mapper treats every kind as namespaced.
- **Why the envtest calls `publish` directly:** `checkOnce` only logs write errors, so a
schema rejection would look like a missing object. Calling `publish` makes the API
server's answer the test failure.
- Its first round writes a 2000-character message; the stored message is truncated to
1024.
- Its second round proves that only the target that flipped gets a new
`lastTransitionTime`.
- **No guard against a hung checker:** the Monitor trusts `Checker`'s contract to return
once the context ends, and doesn't abandon a checker that ignores it. All three
checkers stop when the context ends: KVM through `awaitOrCancel`, and kubernetes and
GCP through their clients.
- **Test renamed:** the planned `TestMonitor_timeoutBoundsBlockingChecker` is
`TestMonitor_checkOnce_timeoutBoundsEachRound`. It also asserts that a fast provider
still publishes next to a hung one.
## Step 5 — Metrics and wiring
`metrics.RegisterProviderTargets` registers a scrape-time collector for three gauges,
each labelled `{provider,target}`: `up`, check duration and check timestamp.
`Register`'s signature is unchanged.
`cmd/main.go`:
- gains `--provider-check-interval` (1m) and `--provider-check-timeout` (15s);
- validates them before even loading the providers config;
- collects the `Checker`s from the unwrapped providers in config order, just before the
metrics and tracing wrap loop;
- adds the Monitor with the manager's API reader;
- registers the collector through a small `providerTargetSamples` closure next to
`proxyPhaseCounts`.
Build, vet and metrics tests:
```bash
go build ./... && go vet ./cmd/... ./internal/metrics/ && go test -race -count=1 ./internal/metrics/
```
To confirm bad flags fail fast, before anything touches the cluster, I ran the binary
with a 5s interval. zap's development mode prints a stack trace after the error, hence
the grep:
```bash
go run ./cmd --providers-config=/dev/null --provider-check-interval=5s 2>&1 | grep -m1 "Invalid provider check flags"
# ERROR setup Invalid provider check flags {"error": "--provider-check-interval must be 0 (off) or at least 10s, got 5s"}
go vet -tags=e2e ./test/e2e/
```
The e2e suite gets a new ordered spec, "should publish provider health". It waits for
`providerhealth kubernetes` to report `Ready=True`. It runs before the existing metrics
spec, which now also asserts
`proxy_operator_provider_target_up{provider="kubernetes",target="apiserver"} 1`. By then
the first round is known to be done, so the series is certain to exist.
Worth noting:
- **Main-only wiring:** `providerTargetSamples` is an 8-line loop in `package main`,
which has no test file. The e2e metrics assertion is its only coverage.
- **The KVM entry isn't asserted in e2e:** the default providers config also ships
`kvm-home` (beelink), which would report `SSHMaterialUnavailable` in kind. That entry
is the user's own host, and asserting it would break the suite as soon as the default
config changes.
- **Manifest:** `config/manager/manager.yaml` only gained a comment about the two flags.
The defaults need no args.
## Step 6 — Docs
- **`docs/architecture.md`:**
- component rows for the CRD, the `Checker` contract and the monitor;
- a new §11 covering the provider table, the loop diagram, the write, read and
decorator choices, the gauges, and sample `kubectl get ph` output;
- cross-references from the metrics (§9) and tracing (§10) sections;
- four Decisions entries.
- **`docs/kvm-provider.md`:**
- a "Health check" section with the reason → meaning → where-to-look table and the
two consequences of sharing the connection;
- under Known gaps, the capacity bullet now points at `poolAvailable`, and a new
bullet says a rotated `kvm-ssh` Secret needs a restart;
- the Testing section names `TestKVMCheck_realHost`.
- **`docs/testing.md`:**
- suite rows for the three `check_test.go` files, `internal/providerhealth` (unit and
envtest) and the metrics gauges;
- "17 libvirt calls" is now 20;
- the integration and e2e sections describe the new tests.
- **`CLAUDE.md`:**
- the Architecture TODO now points at `docs/architecture.md`;
- the stale "only `go.mod` exists" gotcha is replaced by a note that the "What this
is" and "Commands" sections predate the code, plus Step 4's RBAC-marker gotcha.
Worth noting:
- `docs/api.md` is untouched. It documents the discovery HTTP API, which this change
doesn't alter.
- `CLAUDE.md`'s "What this is" and "Commands" sections were left stale on purpose.
Rewriting them is outside this plan, so the new gotcha flags them instead of leaving
them silently wrong.
## Step 7 — Validate, record, push, open the PR
The full suite runs codegen, `go fmt` and `go vet`, then `go test -race` on every
package, with both envtest suites included:
```bash
make test
# every package ok; internal/providerhealth 92.5 %, internal/provider/kvm 92.0 % coverage
```
`go fmt` rewrote nothing, and `git status` showed only the Step 6 docs.
`make lint` could not run, for a reason unrelated to this branch. The Makefile rebuilt
the custom golangci-lint, which reports "built with go1.27.0", and its bundled
staticcheck (honnef.co/go/tools v0.7.0) panics on the standard library before it
reaches any repo package:
```bash
make lint
# buildir: package "poll" (isInitialPkg: false, needAnalyzeSource: true): unexpected expr: *ast.KeyValueExpr
# make: *** [Makefile:107: lint] Error 3
```
The local toolchain is go1.27.0 while `go.mod` says 1.26.0; the otel-tracing plan's
log shows `make lint` running normally before the toolchain moved. Retrying with
`GOTOOLCHAIN=go1.26.0` would have meant downloading a toolchain, and the user declined
that. `go vet` is clean in all three configurations: plain inside `make test`, plus
`-tags=integration` and `-tags=e2e`.
The kind e2e suite creates and deletes its own `egress-proxies-operator-test-e2e`
cluster. I checked `KIND_CLUSTER` first, because the two clusters already on the
machine (`kind`, `proxy-operator-demo`) must not be torn down by `cleanup-test-e2e`:
```bash
make test-e2e > <scratch>/e2e.log 2>&1
# Ran 3 of 5 Specs in 86.210 seconds
# SUCCESS! -- 3 Passed | 0 Failed | 0 Pending | 2 Skipped
```
In the deployed manager, the `kubernetes` ProviderHealth reported `Ready=True` about a
second after the pod was up (20:30:05.6 → 20:30:06.7). The metrics scrape that followed
contained `proxy_operator_provider_target_up{provider="kubernetes",target="apiserver"} 1`.
That exercises the generated RBAC, the API-reader path and the `main.go` gauge wiring
end to end. The two skipped specs are the Tempo-gated tracing specs; `TEMPO_URL` was not
set.
### Follow-up — the check must not cost the reconciles their connection
Reviewing the shared-connection decision after the automated validation turned up two
ways the health check could hurt the work it shares the link with, both of which the
plan's "a check against a hung host drops the shared connection" line accepted too
easily:
- A check that timed out went through `hostPool.call`, whose cancel path drops the
connection. A `libvirtd` slow to answer one `ConnectGetLibVersion` would therefore
cut off an image upload or a `Create` running on the same link.
- `hostEntry` was guarded by a `sync.Mutex`, so waiting for a host's slot could not be
interrupted. A reconcile redialing a host that is down holds the slot for as long as
the SSH dial takes — minutes on a blackholed address — and the whole check round
would wait behind it, past its own timeout.
So the check now calls `hostPool.probe` instead of `call`: on `ctx` ending it abandons
the RPC and leaves the connection alone (a link that is really dead is closed by the
keepalive anyway, which ends the abandoned `fn` too). One abandoned probe per host is
allowed to stay waiting — `hostEntry.probing` is an `atomic.Bool`, not a counter, so a
daemon that never answers cannot pile up goroutines; further probes get the new
`errProbeBusy`, which `checkReason` maps to `Timeout`. `hostEntry.mu` became a one-slot
channel with an `acquire(ctx)` that only bounds *waiting*: an uncontended caller takes a
free slot even with an expired context, so `call`'s behaviour is unchanged where nothing
is contended.
The second change came out of the same pass: `--provider-check-timeout` defaulted to a
flat 15s while the flag validation rejected a timeout longer than the interval, so
`--provider-check-interval=12s` alone failed to start. The flag now defaults to 0 and
`Monitor.setDefaults` derives `min(15s, Interval)`; `ValidateSchedule` accepts 0 and
rejects only negatives and timeouts past the interval.
Re-verified with the same commands as above:
```bash
go vet ./...
go test -race -count=1 ./internal/provider/kvm/ ./internal/providerhealth/
make test
# every package ok; internal/provider/kvm 92.2 %, internal/providerhealth 93.7 % coverage
```
Worth noting:
- `make lint` still cannot run, for the toolchain reason recorded above; unchanged by
this follow-up.
- The two new `kvm` tests are timing-shaped by necessity — they assert that a caller
with a 50ms deadline does not wait out a dial that is blocked on a channel. The
assertions are one-sided (`> 1s` fails) so a slow machine does not make them flaky.
- `docs/architecture.md`, `docs/kvm-provider.md` and `docs/testing.md` were corrected in
the same commit: the old text promised the drop-on-timeout behaviour that is now gone.
- The real-hypervisor commands from the plan's Verification section are still the user's
to run; nothing here replaces them.
### Real-hypervisor validation and PR
The user ran the plan's real-hypervisor checks against beelink (the `TestKVMCheck`
integration test plus the manual `ProviderHealth` and metrics checks) and confirmed
they passed. After that the CHANGELOG entry went in, and the branch was pushed with
the token-file credential helper. The PR against `main` was opened through the Gitea
API because `tea` isn't installed.
Worth noting:
- The branch also carries `docs/plans/2026-09-16-0841-provider-blueprint.md`, on
purpose: that plan reaches `main` with this merge, and the blueprint work starts on a
new branch afterwards.
@@ -0,0 +1,177 @@
# Execution: provider blueprint
Plan: [docs/plans/2026-09-16-0841-provider-blueprint.md](../plans/2026-09-16-0841-provider-blueprint.md)
- [x] Step 0 — Save the plan; wait for the merge
- [x] Step 1 — Collect evidence (read-only, in parallel)
- [x] Step 2 — Write `docs/providers.md`
- [x] Step 3 — Verify, then open the MR
## Step 0 — Save the plan; wait for the merge
The plan was committed on `feat/provider-health` (`80b2e9c`) and reached `main`
through PR #6. After the user confirmed the merge, the branch was cut from the
updated `main`:
```bash
git switch main && git pull --ff-only
git switch -c feat/provider-blueprint
```
Worth noting: `.claude/settings.json` has local, uncommitted permission edits
that carried over through the switch. They are deliberately left out of this
branch.
## Step 1 — Collect evidence (read-only, in parallel)
Three Explore agents answered the plan's 12-item questionnaire, one each for
kubernetes, gcp and kvm. Meanwhile I read the shared layer:
`internal/provider/*.go`, `registry.go`, the provider wiring in `cmd/main.go`,
`reconcileManaged`/`replaceInstance`/`reconcileDelete` in the controller,
`internal/gc/gc.go`, and how the monitor uses `Checker`.
Before relying on the agents' claims, I spot-checked them against the code. One
was wrong and did not go into the doc: the kvm agent said `Delete` skips volume
removal when `DomainDestroy` returns `ErrNoDomain`. In fact `removeDomain`
returns nil there, and `Delete` still calls `removeVolumes` afterwards
(`kvm.go:477-482`).
Rules that exist only in the callers, and not in any comment, were found by
reading the controller and GC. They went into the doc as MUST rules:
- A zero `CreatedAt` makes an instance immediately collectable (`gc.go:120`).
- `Get` returning `ErrPermanent` on the deletion path makes the reconciler call
`Delete` anyway.
- Replacement waits for `Get` to return NotFound before creating an instance
with the same name.
- `Stopped` and `Terminated` both lead to delete-and-recreate.
Worth noting, found outside this plan's scope and not changed here:
- `docs/gcp-vm-validation.md` filters on `labels.proxy-operator-managed=yes`,
but the value is `true`.
- According to the gcp agent, `docs/gcp-in-specific-project.md` holds a real
project ID, IAM output and an egress IP. The user was told.
## Step 2 — Write `docs/providers.md`
Wrote the eight sections the plan lists. Linked the new doc from the Provider
contract row in `docs/architecture.md` and from the CLAUDE.md "Architecture"
section.
Judgment calls:
- The file:line evidence sits in per-provider notes under the matrix, as
planned. The contract rules in §2 cite the reconciler and GC lines each rule
protects.
- The gap list includes documentation drift (kvm-provider.md's ARP order and
log keys, gcp-vm-validation.md's label value and its claim that async errors
reach status), as well as code gaps. Both would mislead the next
provider author in the same way.
- "Proposed features" stays a flat list with no design, as the user decided.
Worth noting: the line numbers in the doc are a snapshot. The doc says so and
asks readers to look for the named function when a line has moved.
## Step 3 — Verify, then open the MR
An Explore agent re-checked the doc against the code: about 150 claims,
including every matrix cell. It found four mismatches, and all four were fixed:
- **Startup dial:** I had listed kvm-provider.md's "connection kept open from
startup" as doc drift. It isn't: the pool dials lazily, but the provider
health monitor runs a check as soon as the leader starts
(`monitor.go:147`, default interval 1m), and that check opens the connection.
The bullet was removed and the kvm notes now say this.
- **Checker wording:** §5 said the monitor type-asserts `Checker`. It is
`cmd/main.go:216`; the monitor only sees nil or not.
- **Reasons, not conditions:** §3 called `QuotaExceeded` and `PermanentError`
conditions. They are reasons on `Provisioned`.
- **Latch lines:** the line range is `:133-139`, not `:130-135`.
It also noted that none of the providers assert `var _ provider.Provider`, and
that the kubernetes provider's RBAC markers live in the controller package. The
§7 checklist now says both.
Link check (every relative `](…)` target and anchor in the new and edited docs
resolves), then the sanity build:
```bash
go build ./... && go test -short ./...
# all packages ok
```
Worth noting: no CHANGELOG entry yet. Per CLAUDE.md it goes in after the user
confirms the doc.
### Follow-up — tracing was missing from the blueprint
The user pointed out, after the PR was opened, that the blueprint said almost
nothing about tracing. The plan's questionnaire item 10 had collected the
evidence, but the doc reduced it to a matrix row about logging and a checklist
line telling authors not to open spans. That line even suggests tracing needs
no work. A provider author needs the opposite message: the operation span comes
free, and child spans for backend calls are theirs to add.
Added a new §6, "Observability: tracing, logs and metrics", and renumbered the
matrix, checklist and gaps sections to §7–§9. It covers:
- **Three layers:** decorator operation spans; child spans per backend call,
with parent-gated HTTP transports or a span decorator over the SDK seam; and
logs.
- **The ctx and logger rules** that keep `traceID` on log lines.
- **A per-provider status table:**
- kubernetes: parent-gated `RestConfigWrapper` spans.
- gcp: the Google SDK's own `otelhttp`, never disabled by the provider.
- kvm: no spans. `trace.go` is a log decorator.
Also added two tracing rows to the §7 matrix, rewrote checklist item 8 as
"Tracing and logging" with an in-memory span-recorder test, and added gaps (kvm
has no child spans; only kubernetes is checked against Tempo, and only at
operation level). A proposal notes that the kvm `libvirtAPI` seam takes no
`ctx`, so per-RPC spans need that plumbed in.
The links were re-checked, including the new `architecture.md#10-tracing-internaltracing` anchor.
### Follow-up — a completeness sweep
The user asked for one more pass over everything provider-related. After the
tracing miss, this pass read beyond the provider packages:
- the Proxy API types and the spec hash;
- the health engine and discovery call sites that consume `Instance.IP`;
- the manager Deployment, the providers ConfigMap and the samples;
- README, testing.md, kvm-provider.md and the GCP docs;
- the initial prompt, the proxy-operator and kvm plans, and the 2026-08-10 PR
review;
- the CHANGELOG.
What the blueprint was missing, now added:
- **What the instance is for.** The IP and port must be reachable from the
operator's health probes and from discovery clients. Providers don't know
which proxy runs; the proxy comes from cloud-init, except for kubernetes.
- **Concurrency.** Three reconciles, GC and checks can call a provider at once,
so it MUST be safe for concurrent use.
- **The runtime envelope:** read-only root filesystem, a 64Mi `/tmp`, a 128Mi
memory limit, credentials as optional mounts.
- **Create:** `placement` can't be validated by the CRD, so error messages must
name the field. Catalogue edits in config aren't in the spec hash.
- **Delete** is re-called every `DeletionPoll` while a delete is in progress.
- **Provider names** must be DNS-1123 for `ProviderHealth`, and `spec.provider`
is immutable.
- **Metric names** in §6.
- **Three matrix rows:** proxy software, IP reachability, distinct egress.
- **A deployment checklist item:** credentials mount, config entries, sample
Proxy, Makefile helper.
- **Gaps:**
- still open from the PR review: GC has no installation identity; an
unconfigured provider latches Proxies; kubernetes Pod RBAC always installed
and no `OwnerReference` on Pods;
- the duplicated type list;
- samples that can't work: `proxy_gcp.yaml` uses a debian-cloud image without
cloud-init, and `proxy_kvm.yaml` places on `nas1` where configs define
`beelink`;
- the `"mock"` example in the `spec.provider` doc.
Outside the plan's "leave other docs alone" line, README.md changed:
- Its "Adding a provider" section restated the contract, with the old "Create
never blocks" rule that kvm contradicts. It now points to `docs/providers.md`
instead of keeping a second copy that disagrees.
- The "Implemented providers" table gained the missing kvm row.
- The deploy note no longer claims the default ConfigMap ships only the
kubernetes provider.
The links were re-checked for providers.md and README.md.
@@ -0,0 +1,228 @@
# Execution: Split the provider blueprint into contract, appendix, template and index
Plan: [docs/plans/2026-09-16-1108-provider-docs-split.md](../plans/2026-09-16-1108-provider-docs-split.md)
- [x] Step 0 — Save and commit the plan
- [x] Step 1 — Write `docs/provider-contract.md`
- [x] Step 2 — Write `docs/provider-spec-template.md`
- [x] Step 3 — Write `docs/providers-existing.md`
- [x] Step 4 — Rewrite `docs/providers.md` as the index; link edits
- [x] Step 5 — Verification sweep
- [x] Step 6 — Final summary, CHANGELOG after confirmation
## Step 0 — Save and commit the plan
Committed as `cf89ac4` on the existing `feat/provider-blueprint` branch, on
its own, before any document work. The pre-existing local modification of
`.claude/settings.json` was left out of the commit.
## Step 1 — Write `docs/provider-contract.md`
2026-09-16 11:17. Written from scratch rather than edited down from the old
blueprint, so that nothing provider-specific could survive by accident. Every
MUST names its caller by function (`reconcileManaged`, `replaceInstance`,
`reconcileDelete`, `providerFailure`, `deletionFailure`, `Sweeper.sweep`,
`Monitor.checkOnce`, `Monitor.run`, `registry.Build`) and states what breaks.
**Axis codes frozen** (19, §4.1 to §4.19): `readiness`, `create-return`,
`delete-return`, `ip-source`, `gc-mark`, `created-at`, `list-partial`,
`quota`, `not-found`, `primary-key`, `provisioning-state`, `async-ops`,
`secondary-resources`, `credentials`, `client-kind`, `timeouts`, `logging`,
`child-spans`, `backend-test`. The appendix table and the template are keyed
on these strings; the §4.20 summary table's last column gives the template
entry number each axis maps to.
**Two rules that were not in the old blueprint**, both found by reading the
callers while verifying:
- `Sweeper.sweep` loops providers sequentially in one goroutine, and no
caller puts a deadline on the context (only `Monitor.checkOnce` uses
`WithTimeout`). So a hung `ListByTag` stalls orphan GC for every provider.
Recorded in §1.5 and the `timeouts` axis.
- The Stopped branch of `reconcileManaged` calls `Delete` and requeues
without clearing `ProviderID`, then deletes again on every poll until
NotFound. A backend that reports a freshly created, never started instance
as stopped would have it deleted right after creation. Recorded as a MUST
in §2.2 and as the `provisioning-state` axis with three allowed profiles.
Checks run on the draft (all clean):
```sh
grep -niE "kubernetes|gcp|kvm|libvirt|squid|google|compute engine" docs/provider-contract.md
grep -n "—" docs/provider-contract.md
grep -nE '\.go:[0-9]+|:[0-9]+-[0-9]+' docs/provider-contract.md
grep -ohE "(internal|cmd|config|docs|api|hack|test)/[A-Za-z0-9_./-]+" docs/provider-contract.md | sort -u | while read p; do test -e "$p" || echo "MISSING $p"; done
```
The path check reports three expected misses: `config/samples/proxy_<type>.yaml`
is a pattern, `docs/provider-spec-template.md` is written in Step 2, and
`internal/provider/providertest` is a proposal named in §13.
Worth noting: the contract refers to "the operator pod" and "the cluster's
own API" rather than naming the platform, so that the abstractness grep stays
meaningful. The `hack/providers-dev.yaml` rule is phrased as "only if the type
needs no credentials" because that file exists for `make run-dev` without any
Secret.
## Step 2 — Write `docs/provider-spec-template.md`
2026-09-16 11:28. Twenty-four entries in a fixed shape (Question, Allowed
answers, Obligations, Answer, Evidence). Thirteen of them carry an axis code
and their numbers are the ones the contract's §4.20 table points at; the
other eleven (identity, placement, image, cloud-init, machine type, config
block, Checker, concurrency, deployment, docs, accepted limits) are decisions
the contract leaves open without being a profile axis. Entries 9, 13 and 15
carry an empty table to fill in (state mapping, error classification, config
fields); entry 21 carries the contract §11 test list as checkboxes so a
filled spec doubles as the test plan.
Check that every axis code appears in the template:
```sh
for c in $(grep -oE '^### 4\.[0-9]+ `[a-z-]+`' docs/provider-contract.md | grep -oE '`[a-z-]+`' | tr -d '`'); do
grep -q "\`$c\`" docs/provider-spec-template.md || echo "template missing $c"
done
```
Clean. Worth noting: the preamble allows "verify against the target version"
only on an Evidence line, never as an Answer, because the whole point of the
file is that the implementer does not guess.
## Step 3 — Write `docs/providers-existing.md`
2026-09-16 11:35. The profile table has exactly one row per contract axis
code, checked by the same loop as Step 2 with a row count. Everything else
comes from the old blueprint's §6, §7 and §9 plus a fresh read of the three
packages, with every `file:line` replaced by a symbol name.
Corrections to earlier notes applied while writing: kvm's Checker `Details`
also carry `user` and `pool`; the kubernetes provider has no seam interface
(`newWithClient` injects a `client.Client`, the fake client stands in); kvm's
`classify` has an `isClassified` pass-through. Timeouts were checked per
provider for the `timeouts` row: kubernetes sets no `rest.Config.Timeout`,
gcp relies on SDK defaults, kvm bounds only the SSH handshake
(`defaultHandshakeTimeout` in `dialSSH`), so all three are weaker than the
contract's SHOULD and the table says so.
Symbol check on the appendix (every backticked identifier grepped for in
Go, Makefile and YAML sources):
```sh
grep -ohE '`[A-Za-z_][A-Za-z0-9_.]*`' docs/providers-existing.md | tr -d '`' | sort -u | while read s; do
grep -rqwF --include='*.go' --include='Makefile' --include='*.yaml' -- "$s" . || echo "$s"
done
```
The residue is file names, dotted `Type.method` forms, the `timeouts` axis
code, libvirt's `nostate`, and the API names `OwnerReference` and
`rest.Config.Timeout`; nothing unexpected.
Worth noting: matrix rows that the profile table now carries (Create
non-blocking, GC marks, IP source, quota detection, Checker presence,
credentials, logging, tracing, tests) were dropped from "Other differences"
rather than kept twice.
## Step 4 — Rewrite `docs/providers.md` as the index; link edits
2026-09-16 11:40. `docs/providers.md` was rewritten in place (same path, so
the inbound links from `README.md`, `CLAUDE.md`, `docs/architecture.md` and
the historical plan files still resolve, and the pre-split blueprint stays
reachable in the path's git history). The three inbound sentences were
updated to name the new files:
- `README.md` "### Adding a provider": the first sentence now names the
contract, the template and the appendix, with `docs/providers.md` as the
index; the "Start there" sentence is unchanged.
- `CLAUDE.md` "## Architecture": the providers.md sentence now names all
three files and the index.
- `docs/architecture.md` components row "Provider contract": "rules in
provider-contract.md, per-provider profile table in providers-existing.md".
The historical files under `docs/plans/` and `docs/plans-executions/` keep
their `providers.md` links on purpose.
## Step 5 — Verification sweep
2026-09-16 11:45. All ten checks from the plan, run against the working tree
after Step 4; nothing needed fixing.
Links (file targets and anchors) across the four new files plus `README.md`,
`CLAUDE.md` and `docs/architecture.md`, with anchors compared against
slugified headings of the target file:
```sh
python3 - <<'PY'
import re, pathlib
files = ["docs/provider-contract.md","docs/provider-spec-template.md","docs/providers-existing.md","docs/providers.md","README.md","CLAUDE.md","docs/architecture.md"]
def slug(h):
h = re.sub(r'`','',h).strip().lower(); h = re.sub(r'[^\w\s-]','',h)
return re.sub(r'\s+','-',h).strip('-')
def headings(p):
return {slug(m.group(2)) for m in (re.match(r'^(#{1,6})\s+(.*)$', l) for l in open(p)) if m}
bad = 0
for f in files:
for m in re.finditer(r'\]\(([^)\s]+)\)', open(f).read()):
t = m.group(1)
if t.startswith(('http://','https://','mailto:')): continue
path, _, anchor = t.partition('#')
target = pathlib.Path(f) if path == '' else pathlib.Path(f).parent/path
if not target.exists(): print(f, "missing", t); bad += 1; continue
if anchor and target.suffix == '.md' and anchor not in headings(target): print(f, "bad anchor", t); bad += 1
print("link problems:", bad)
PY
```
Output: `link problems: 0`.
Em-dashes, line references and the contract's abstractness:
```sh
grep -n "—" docs/provider-contract.md docs/provider-spec-template.md docs/providers-existing.md docs/providers.md
git diff 2d959af -- README.md CLAUDE.md docs/architecture.md | grep '^+' | grep "—"
grep -nE '\.go:[0-9]+|:[0-9]+-[0-9]+' docs/provider-contract.md docs/provider-spec-template.md docs/providers-existing.md docs/providers.md
grep -niE "kubernetes|gcp|kvm|libvirt|squid|google|compute engine" docs/provider-contract.md
```
The only em-dash hit is the pre-existing "Leader-elected" cell of the
`architecture.md` components row, which shows as an added line because the
row's last cell was edited; no new em-dash was written. The other three
greps are empty.
Nothing lost from the old blueprint's §9: the bold leads of every gap and
proposal bullet in `git show 0bb699b:docs/providers.md` were looked up in
the new files. The literal-prefix match reported 22 misses, all caused by
rewording ("isn't" to "is not", bold removed), so each topic was re-checked
by a distinctive phrase and all 22 are present in `providers-existing.md`
or contract §13.
Paths and symbols: every `path/...` token in the four files exists except
the `config/samples/proxy_<type>.yaml` pattern and the
`internal/provider/providertest` proposal; every backticked identifier in
the contract and template resolves in Go, Makefile or YAML sources except
file names, dotted `Type.method` forms, profile names and the `NewWith...`
placeholder.
Axis alignment: the 19 axis codes from contract §4 each appear once as a
row in the appendix table (six cells per row) and at least once in the
template.
`go build ./... && go test -short ./...`: all packages `ok`.
## Step 6 — Final summary
The split is complete on `feat/provider-blueprint`: five commits after the
plan commit, one per step. The CHANGELOG entry is deliberately not written
yet; per `CLAUDE.md` it is added once the user confirms the documents read
the way they want. Nothing has been pushed.
Worth noting for whoever picks this up:
- The contract's §4.20 table maps axis codes to template entry numbers; if
entries are renumbered, that column must follow.
- The `CHANGELOG.md` heading format in `CLAUDE.md` uses an em-dash; the
"no em-dashes" rule applied to the four new documents only.
- The stale `mock` comments in `internal/provider/provider.go`,
`internal/provider/registry/registry.go` and `api/v1alpha1/proxy_types.go`
are still listed as gaps, not fixed here.
- The next planned use of these files is a Proxmox provider: copy the
template to `docs/proxmox-provider-spec.md` and fill it in before any code.
@@ -0,0 +1,382 @@
# 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
- [x] 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".
## Step 10 — Milestone review
2026-09-16 21:19. The `go-operator-reviewer` agent reviewed the package
against the contract and the spec (v1 code off limits to it as well) and
returned seven findings, all accepted and applied by a fresh fix agent in
one commit with regression tests:
1. `waitRunning`'s two-minute cap only ever elapsed in the bad cases and
pinned a reconcile worker; now 15 s, a domain that dies right after
start is Transient at once, and a paused or pmsuspended domain is
Permanent before `DomainCreate` (an admin's doing).
2. `ListByTag` read state and addresses that GC never uses, one agent RPC
per active domain per sweep; it now builds instances from the name and
the metadata only (`TestListByTag_doesNotReadStateOrAddresses`).
3. A health round that ran out of time was blamed on the host
(`Unreachable`); `checkHost` now reports `Timeout` whenever the round's
ctx ended, whichever dial stage was running, and the promptness test
uses the production error shape.
4. `Create` adopted an unmarked domain and never wrote the mark, so GC could
not see it; an unmarked domain under the name is now Permanent
(`TestCreate_adoptsOnlyMarkedDomains`).
5. `hostTable.closeAll` had no production caller and `withBudgets` could not
work (the dialer captured the defaults first); both removed.
6. The per-name and per-image lock maps grew without bound; replaced by a
reference-counted `keyedLocks` whose entries vanish when uncontended.
7. `imageSizeIfUncached` falls back to GET on any non-2xx HEAD, and two
Delete no-op lines moved from V(0) to V(1).
The spec (entries 2, 10, 12, 16, 18, 19, 24), the guide, the appendix and
the architecture flow were updated in the same commit. The reviewer also
listed what it checked and found correct: the error boundary, the state
rules, Delete's idempotency and the replacement window, ListByTag's
partial-failure policy, the abandon policy and double-close guards,
resource handling on every path, and secret hygiene in the format strings.
Lint: `bin/golangci-lint` still panics on this toolchain (Go 1.27 against
a staticcheck built for 1.26), reproducible on untouched packages, so
`make lint` could not gate this step; `go vet` with every build tag is
clean. Tests: `go test -race -count=3 ./internal/provider/kvm2/` (168
top-level tests) and `go test -short ./...` pass; `make test` with envtest
runs next.
@@ -0,0 +1,531 @@
# Execution: fix the kvm2 code-review findings
Plan: [docs/plans/2026-09-17-1515-kvm2-review-fixes.md](../plans/2026-09-17-1515-kvm2-review-fixes.md)
- [x] Step 0 — Save plan into the repo
- [x] Step 1 — H1: a stalled write must not delay the teardown
- [x] Step 2 — H2: a source error must not reach go-libvirt's stream sender
- [x] Step 3 — H3: an unreadable address must not look like a missing one
- [x] Step 4 — H4: the base image cache must not be shared across provider entries
- [x] Step 5 — Validate H1-H4 in-cluster against beelink
- [x] Step 6 — Route klog into the structured logger
Remaining after those: the 48 mediums and lows in `review/REPORT.md`. Each gets its own
checklist line here when it is planned, so this list stays a record of decided work rather
than a wish.
## Step 0 — plan committed
Copied out of `~/.claude/plans/` and committed alone before touching code, as the repo's
plan rules require.
## Step 1 — H1: close the SSH client, and signal dead before killing
Two edits, both small, in the order the bug unfolds.
`sshSocket.Close` was `errors.Join(s.Conn.Close(), s.client.Close())`. Go evaluates those
arguments left to right, so the blocking close ran before the unblocking one. `s.Conn` is
the forwarded libvirt socket, and closing an SSH channel writes a close message under
`ch.writeMu`, which go-libvirt already holds while streaming a base image through the same
channel. Against a host that vanished without RST or FIN, that write blocks until the
kernel stops retransmitting — `tcp_retries2`, about 15 minutes. `s.client.Close()`, a bare
`net.Conn.Close()` on the TCP socket that takes no lock and unblocks the stalled writer
through netpoll, was never reached. It now closes the client only.
**Dropping the channel close rather than reordering it** was the judgment call. Reordering
works, but afterwards the channel close returns an error — `net.ErrClosed`, a
`*net.OpError`, the transport's sticky write error, or `io.EOF` once `mux.loop` has run
`ch.close()` — and `closeErr` is memoised, so a filter that missed a case would poison
every later `Close` and break `ssh_test.go:66,69`, which assert both closes return nil.
Nothing wants the polite channel-close message: `dialSSHSocket` builds the client solely
for this one channel, the `sshSocket` owns it exclusively, and `dialSSHSocket`'s own error
paths already tear down by closing the client.
`link.markDead` then compounded it by running `kill()` before `close(l.closed)`, and doing
both inside a `sync.Once`. Reordering alone would not have been enough: `sync.Once.Do`
blocks concurrent callers until the first body returns, so every other `markDead` — including
the one in `call`'s own ctx-cancel branch — queued behind the stalled kill. **Cancelling the
reconcile did not help.** The `once` field is gone; the mutex the struct already had now
guards the "close it exactly once" invariant, `reason` is set before the signal so a
released caller always reads a populated `deadReason()`, and `kill` runs outside the lock.
**A second route to the same pin, found while planning and fixed by the same edits.**
go-libvirt's `Disconnect` closes the `net.Conn` it was handed (`libvirt.go:331` →
`socket.go:164`) — and that conn *is* our `*sshSocket`. So `libvirtBackend.Close`'s
`Disconnect` goroutine entered `sshSocket.closeOnce` and hung there; the 5 s `closeBudget`
released `Close`, which called `markDead("closed")` → `kill()` → `sshSocket.Close()` →
blocked in the other goroutine's `sync.Once`. The budget did not protect it.
The precondition was verified in the pinned x/crypto v0.54.0 rather than assumed: the
flow-control park (`remoteWin.reserve`, `channel.go:268`) happens *outside* `writeMu`, and
only `writePacket` holds it across the TCP write. So a writer parked in SSH flow control
never blocks the close; a writer inside the TCP write always does. Since the channel window
is 2 MiB (`channelWindowSize = 64 * channelMaxPacket`), reproducing it by hand needs a
kernel send buffer that fills first — shrink `SO_SNDBUF` well under 2 MiB.
The older kvm provider does not have this bug: it closes `client` only
(`internal/provider/kvm/conn.go:429,456`) and never touches the forwarded channel. kvm2's
rewrite introduced it.
### Tests: planned four, shipped one
The first draft had three hermetic tests plus a `//go:build integration && linux`
stalled-write test with a TCP proxy and shrunken socket buffers. The user pushed back —
once the bug is fixed the connection closes properly, so what is left to test? — and they
were right about most of it. `TestDial_forwardsUnixSocket` already covers "Close tears the
connection down", and after the fix `Close` is a single call with no ordering to get wrong.
The stalled-write test was the worst of the four: ~150 lines of Linux-only,
buffer-autotuning-dependent scaffolding whose own false-green guard made it *skip* whenever
the kernel did not cooperate, to demonstrate a bug that reading x/crypto settles and that
the fix deletes the code path for.
What survived is `TestMarkDead_slowKillDoesNotBlockCallers`, because the `markDead` change
*adds* a guarantee that did not exist before and nothing else touches it —
`TestCall_killedByAnotherCallerFailsTransient` passes a nil kill, so it cannot see the
property. It is also the plausible regression: restoring the `sync.Once` looks like a
correctness improvement. The call's own `fn` signals when it is in flight, so the parked
caller is genuinely parked rather than refused by the dead check on the way in.
The step that actually validates a regression test is running it against the unfixed code.
`markDead` was reverted in place, the test run, and the fixed version restored from a copy:
```bash
cp internal/provider/kvm2/backend.go "$SCRATCH/backend.go.fixed"
# ... revert markDead to the sync.Once form and re-add the once field ...
go test -race -count=1 -run 'TestMarkDead_slowKillDoesNotBlockCallers' ./internal/provider/kvm2/
# --- FAIL: TestMarkDead_slowKillDoesNotBlockCallers (1.00s)
# backend_test.go:186: link not signalled dead while its kill is still running
cp "$SCRATCH/backend.go.fixed" internal/provider/kvm2/backend.go
```
It fails in a second with the right diagnostic rather than hanging the suite, because the
blocked kill is released by `t.Cleanup`.
```bash
go build ./... && go vet ./... && go test -race -count=1 ./internal/provider/kvm2/
```
### Worth noting
**An accepted risk, deliberately not covered by a test.** Nothing now stops someone
re-adding `s.Conn.Close()` to `sshSocket.Close` as tidiness; it would look harmless and
every test would still pass, because in a healthy test the channel close returns instantly.
The guard is the doc comment on `sshSocket`, which says outright that closing the channel
would queue behind a stalled transfer. That is in idiom for this package — `hosts.go:25-34`
and `create.go:151-153` carry the same kind of hard-won decision — but it is a comment, not
a test, and the failure it prevents is a 15-minute operator-wide stall.
**Left for later, on purpose.** `ReconciliationTimeout` in `SetupWithManager` does not exist
in this repo at all; adding a per-reconcile deadline would change the contract for every
provider, since `hosts.go` and `image.go` both cite "no caller deadline", so it needs its own
change and its own spec entry. `sshSocket.keepalive` still has no test (sweep finding S1.2).
**Docs.** `docs/kvm2-provider.md` said the adapter "kills the connection (socket and SSH
client)", which is no longer true and was the description that made the bug look correct.
It now states the order (mark dead, then kill) and why both steps are lock-free.
`docs/kvm2-provider-spec.md` entries 18 and 19 were checked and are not falsified.
**No CHANGELOG entry yet** — the repo's rule is to add one once the user confirms the change
works, so it lands with a later "validate and record" step, as in the kvm campaign.
## Step 2 — H2: a source error must not reach go-libvirt's stream sender
This one is a workaround, not a repair. The defect is upstream, and the entry says so in
three places (code comment, `docs/kvm2-provider.md` *Known gaps*, commit message) so nobody
later reads the wrapper as ours to delete.
`requestStream` in the pinned go-libvirt starts its stream sender on two **unbuffered**
channels and then, on the error path, returns without ever reading `outErr`. A non-EOF error
from the reader makes `SendStream` emit a StatusError packet and return, whereupon it parks on
`outErr <-` forever; libvirtd answers the StatusError with an error, so the main goroutine
parks on `abort <- true` forever. `StorageVolUpload` never returns. The 20-minute budget
rescues only the caller, and the socket kill does nothing at all here, because both ends are
blocked on channel sends rather than on I/O — which is what the comment at `backend.go:152`
got wrong. Every failed upload leaked two goroutines and 4 MiB against a 128Mi pod limit,
with the reconciler retrying on backoff.
Three changes.
`eofReader` sits at the go-libvirt seam in `backend.go`, not in `image.go`, so it also covers
the seed and marker uploads, and so it stays out of the file Step 4 rewrites. It converts any
source error to `io.EOF` — the one outcome that ends a stream the normal way — and keeps the
real error for `StorageVolUpload` to return afterwards. `countingReader` is untouched
underneath it and still sees the true error, so `uploadImage`'s existing length and digest
checks produce exactly the messages they did before.
The download now carries its own deadline, one minute inside the transfer budget.
`fetchImage` already bounded the whole fetch at 20 minutes, so the body's death and the
budget's socket kill were racing, and the socket usually won. With the body a minute ahead, a
stalled mirror ends through the reader and unwinds cleanly. This is the difference between a
slow mirror costing a Transient error and costing a permanent leak, and slow mirrors are the
realistic repeat offender.
`discardVolume` moved from `uploadImage` up into `fetchImage`'s upload-error branch, so the
dedicated connection is closed — and libvirtd has let go of the stream's fd — before the file
is unlinked. Moving it rather than adding a second `Close` is what keeps
`image_test.go`'s `closeCount() == 1` assertion true. `uploadImage` lost its `be` parameter
and its private logger in the process.
**What is left, deliberately.** A failure that originates in libvirtd mid-upload (ENOSPC, or
the volume deleted underneath) still leaks, because by then `SendStream` has already returned
and nothing we pass, cancel or close can reach it. Confirmed unfixable from this side. The
upstream fix is two characters and is quoted in *Known gaps* along with the `replace` escape
hatch, should it ever bite hard enough to be worth the maintenance.
**Unverified, ~85%.** That libvirtd answers a short-but-clean stream finish with OK rather
than an error. `virFDStreamWrite` enforces `length` only on writes past it and nothing
compares `offset` to `length` on the close path, which is also why `virsh vol-upload` of a
small file into a large volume works. If it answers with an error we land back in a deadlock
by a different door — but that is precisely today's behaviour, so the change cannot make
things worse. To settle it, on any box with a libvirtd (not beelink, which is read-only
here), against the pinned go-libvirt:
```sh
virsh -c qemu:///session pool-define-as probe dir --target ~/vmprobe
virsh -c qemu:///session pool-start probe
virsh -c qemu:///session vol-create-as probe short.raw 10M --format raw
# then a ~40-line main.go: StorageVolUpload(vol, r, 0, 10<<20, 0) where r
# yields 1 MiB then io.EOF; print the error, then runtime.NumGoroutine()
# and a goroutine profile after a two-second settle.
```
Expected: `err == nil` and the goroutine count back at baseline. The same program with a
reader that returns a non-EOF error should wedge against the *unpatched* seam, which also
confirms the diagnosis end to end.
The negative check that matters was run on the unit test instead — `eofReader.Read` was
changed back to returning the real error, and both new tests failed with
`io.Copy through eofReader = connection reset by peer, want nil`, then passed again once
restored.
```bash
go build ./... && go vet ./... && go test -race -count=1 ./internal/provider/kvm2/
```
### Worth noting
The `fetchImage` discard is still unfenced against a second provider entry that shares the
volume name — Step 4 (H4) is what closes that, by making the name per-entry. The deferred
atomic-publish work then lands exactly where this step put the discard.
**Deliberately not tested:** the abort protocol end to end. `libvirttest` reads a fixed
28-byte header per packet and implements no stream procedures, so pinning this hermetically
would mean a 150-250 line libvirt RPC server stub whose only assertion is about *upstream's*
goroutines. Same call as H1's: the test would cost more than it protects. The two tests that
did land pin the one property the seam actually needs.
## Step 3 — H3: an unreadable address must not look like a missing one
`address()` tried the guest agent then the host's ARP table and skipped any error that was
not a context error, returning `("", nil)` after both. An active domain with no IP maps to
`StateProvisioning`, the controller clears `status.IP` on that, and the health engine prunes
the proxy's counters and calls `ForgetProxy` in one tick once `EffectiveHost()` is empty.
The damaging trigger was not the guest. The libvirt connection is pooled per host, so when
another reconcile's cancel or budget overrun killed it, `call` returned
`"connection is closed"` — carrying no context sentinel, so the check did not recognise it —
and `Get` returned a *successful* instance in Provisioning with an empty IP. An unrelated
proxy's timeout dropped a healthy proxy out of rotation.
The fix rests on a discriminator the spec had already recorded without drawing the
conclusion: during a normal boot on beelink exactly one source fails. The agent errors
because it is not up yet, while ARP answers *without* error and with nothing, since the host
never sees the guest's traffic. So one source failing is "no address yet"; every source
failing, or a connection already dead, is `ErrTransient`.
The explicit `provider.ErrTransient` wrap on the joined error is load-bearing rather than
decoration. Without it `classify` picks the first libvirt code out of the join tree, and
`ErrInvalidArg`, `ErrAccessDenied` and `ErrNoSupport` are Permanent — an address read failing
would have latched a *running* proxy as Failed, a worse version of the harm being fixed.
`isDead(be)` is checked first in the error branch. It is not strictly needed, since a dead
link fails both sources and the count rule catches it anyway, but it fails on the first
source instead of the second, attributes the error better, and is the same predicate M15 will
want for `ListByTag`.
### Tests
`TestGet_addressErrorIsProvisioning` failed against the new rule, which is correct: it fails
*both* sources with `ErrAgentUnresponsive`, and code 86 is agent-specific — the ARP source
cannot produce it. Re-pointed as `TestGet_agentFailsAndARPEmptyIsProvisioning`, the beelink
boot it was really reaching for: agent errors, ARP returns no addresses and no error, result
is Provisioning. Nothing covered that before, and it is the case the new rule must not break.
`TestGet_everyAddressSourceFailingIsTransient` covers the other side, two subtests: both
sources erroring, and the pooled connection dying under the read. Against the unfixed code
both return a Provisioning instance instead of an error, which is exactly the bug:
```bash
# with isDead() and the every-source-failed arm removed from address():
go test -count=1 -run TestGet_everyAddressSourceFailingIsTransient ./internal/provider/kvm2/
# get_test.go:215: Get = &{... IP: State:Provisioning ...}, want no instance
```
`TestGet_arpFallbackWhenAgentFails` passes untouched — one source of two.
### Worth noting
**Half of H3 is deliberately still open, and it is the half that fires more often.** A guest
agent restarting inside a running VM still demotes it to Provisioning and clears its IP: the
agent errors, ARP answers empty, and that is indistinguishable from a VM still booting. The
discriminator is the *previous* IP, which the provider does not hold by design
(`TestGet_readsUIDAndCreatedAtFromBackend` pins "read back, not remembered") and the
controller already has. Follow-up finding, to be filed against
`internal/controller/proxy_controller.go:208-211`: on `StateProvisioning`, keep an existing
IP and `Provisioned` condition for a few polls rather than clearing on the first, or give the
health engine a grace period before pruning.
The one kvm2-local alternative was considered and rejected: the mark carries `createdAt`, so
`get` could treat "active, no address from any source, domain older than a provisioning
window" as an error. It uses state the provider genuinely has, but `createdAt` is creation
rather than boot, it fires on every genuinely stuck VM as a retry-forever error, and it
invents a provisioning deadline that belongs to the controller. A design change wearing a bug
fix's clothes.
**L6 not folded in.** A domain that vanishes between `DomainGetState` and the address read
now returns Transient rather than a fake Provisioning — better, and self-correcting on the
next poll. Returning NotFound one poll earlier is a two-line addition at the same spot, left
for when L6 comes up so this commit stays one idea.
## Step 4 — H4: the base image cache must not be shared across provider entries
`baseVolumeName` was `base-<key>.<format>` — byte-identical to what
`internal/provider/kvm/image.go` builds in the same pool, for URL images too (kvm takes
`hex(sum[:8])`, kvm2 takes `hex(sum[:])[:16]`, the same eight bytes), with a completely
separate lock table. The shipped config has a live `kvm-home` entry and a commented-out
`kvm2-home` entry on the same host, the same key `debian-13` and the same default pool, and
the comment invites the operator to enable it. Both integration tests use key `test-image`.
On beelink both live overlays back onto kvm's `base-debian-13.qcow2`.
Since `cachedImage` accepts any 64-hex marker when no `sha256` is configured and never
re-hashes the base, that meant: two entries pointing one key at different URLs silently share
a file; a concurrent cold fetch lets one entry delete the other's half-uploaded base and boot
a guest from the remains; and a `sha256` edit deletes the base in place under live overlays,
which libvirt permits without complaint.
The name now carries the provider entry: `base-<entry>-<key>.<format>`. `imageSource` gained
an `Entry` field and `resolveImage` takes the provider name, so the four production call
sites all follow from `imageSource.volumeName()`. That closes the cross-entry sharing
outright, kvm↔kvm2 and kvm2↔kvm2 alike, and it turns the existing per-`Provider` `imageLocks`
from a latent bug into a correct one — with names unique per entry, no two entries can
contend for a file, so a process-wide lock table is not needed.
**The entry name reaches a file name, so it now has to be a usable one.** Provider names are
validated for all types at once and only for being non-empty and unique
(`internal/provider/config.go`), so `New` checks its own against the existing `kvmIdentifier`
rule, exported as `provider.IsKVMIdentifier` rather than duplicated. A name with a slash
fails at startup instead of producing a volume nothing can address.
**One companion fix, because the naming alone does not stop it.** `cachedImage`'s
digest-mismatch branch deleted the marker and the base and refetched. It now returns
`ErrPermanent` naming both digests and telling the operator what to do. That branch was the
remaining path that unlinks a good file under running domains, and the contract already said
"add a new key instead" — the code was contradicting it. The no-marker and non-hex-marker
branches keep deleting, since there the base is genuine debris.
### What this does NOT fix
Stated plainly rather than left to look closed:
- **Editing `images.<key>.url` is still a silent no-op.** The cache is keyed by entry and
key, and nothing records the URL, so the new one is never fetched. Pre-existing, already
documented as "add a new key"; the eviction section now says it outright.
- **Cross-process fencing.** Two managers, or a manager plus a hand-run `virsh`, can still
meet on one name. E4 refuted the rollout and leader-handover triggers for the shipped
manifests, so this is narrow, and its fix is the deferred atomic publish — upload to a temp
name, verify, publish — which lands where H2 put the discard.
### Migration
Nothing to do. The `base-debian-13.qcow2` on beelink is kvm's, kvm keeps using it untouched,
and kvm2 downloads its own copy once per host when `kvm2-home` is enabled. No window in which
anything is replaced under a running guest. The pool needs room for both, which the
prerequisites now say.
### Tests
Mostly literals becoming helpers, plus a new `fx.baseName()` in `provider_test.go` for the
three fixtures that asserted `"base-debian-13.qcow2"` by hand. The log and span assertions in
`TestEnsureImage_downloadsAndWritesMarkerLast` keep asserting a full name rather than loosening
to a prefix, so they still pin that the key stays readable in operator-facing output.
One test changed behaviour rather than spelling: `TestEnsureImage_sha256ChangeRedownloads`
became `TestEnsureImage_sha256ChangeRefusesInsteadOfReplacing`, asserting Permanent, zero
server hits, zero deletes, and both volumes untouched. Two added:
`TestNew_rejectsUnsafeEntryName`, and a `TestVolumeNames` row for a second entry with the same
key.
Both negative checks were run:
```bash
# baseVolumeName reverted to base-<key>.<format>:
go test -count=1 -run TestVolumeNames ./internal/provider/kvm2/
# volume_test.go:135: got "base-debian-13.qcow2", want "base-kvm2-lab-debian-13.qcow2"
# the digest branch reverted to delete-and-refetch:
go test -count=1 -run TestEnsureImage_sha256ChangeRefuses ./internal/provider/kvm2/
# image_test.go:412: ensureImage() error = <nil>, want ErrPermanent telling the operator what to do
```
`go vet -tags=integration` was run too, since `kvm2_integration_test.go:484` builds the base
name and only compiles under that tag.
### Worth noting
**The integration test has still never run on beelink.** Step 11 is unchecked in
`docs/plans-executions/2026-09-16-1333-kvm2-provider.md`, every `base-*` on the host predates
kvm2, and both live domains carry the kvm v1 metadata namespace. After this change it is also
the first end-to-end exercise of the new name, and it will do a real cold download under
`base-kvm2-integration-test-image.qcow2` beside kvm's untouched `base-test-image.qcow2`.
**Docs touched beyond the obvious:** the eviction recipe named `base-debian-13.qcow2`, which
after this change is *kvm's* file — following it would have deleted the wrong provider's base.
It now greps for the prefix and deletes the marker first.
## Step 6 — Route klog into the structured logger
Not a review finding. Found while watching the Step 5 validation: some lines ignored the zap
encoder no matter what, so `kubectl logs | jq` could never work.
```
I0917 19:50:02.580097 1 leaderelection.go:258] "Attempting to acquire leader lease..." lock="..."
```
That is klog's own stderr writer, not ours. client-go's leader elector calls
`klog.FromContext(ctx)` (`leaderelection.go:257`), and controller-runtime hands the elector a
context with no logr in it — `mgr.Start` receives `ctrl.SetupSignalHandler()`, which carries no
logger (`pkg/manager/internal.go:461-473`). `FromContext` falls back to `Background()`, and with
no logger registered that is klog's text sink. Nothing in this repo imported or configured klog;
it was an indirect dependency that around 60 linked packages use, including
`client-go/tools/{leaderelection,cache,record}` and `client-go/transport`. controller-runtime
does not route it either — its only `klog.SetLogger` call is in its own test suite.
**`klog.SetLogger(logger)`, deliberately not `SetLoggerWithOptions(logger, ContextualLogger(true))`.**
The contextual form is what klog's docs recommend, because it skips a klogr round trip. It also
disables klog's verbosity gate ("Such a logger cannot rely on verbosity checking in klog",
`contextual.go:83-86`), handing the decision to zap — whose level under `Development: true` is
Debug, which enables V(1). client-go's V(1) chatter, suppressed today by klog's own `-v=0`,
would have started appearing. Plain `SetLogger` keeps klog's check in front and still redirects
what it does emit ("all log lines will be suppressed from the regular output, and redirected to
the logr implementation", `contextual.go:47-48`). Same fix to the format, no new volume — which
matters, because the complaint that started this was volume.
`k8s.io/klog/v2` moves from indirect to direct in `go.mod`. It was already in the module graph;
nothing new is downloaded.
**The other bypass.** The discovery API's `http.Server` set no `ErrorLog`, so `net/http`'s own
faults — a failed `Accept`, a panic escaping a handler — went to the stdlib logger on stderr.
It now drains into logr through `logr.ToSlogHandler`, the pattern
`internal/provider/gcp/wirelog.go:31-36` already uses. This server is plain HTTP with no TLS, so
`ErrorLog` carries real faults rather than handshake noise, and Error level is right.
**Deployment.** `--zap-encoder=json` and `--zap-time-encoding=rfc3339` on the manager.
Verified the flag beats the in-code default rather than assuming it: `addDefaults` fills
`NewEncoder` only `if o.NewEncoder == nil` (`zap.go:176-191`) and `BindFlags` sets it from the
flag. The time encoding is not optional — the JSON encoder defaults to epoch seconds.
Verbosity was left at debug on purpose; `--zap-log-level=info` drops the per-RPC lines.
`opts := zap.Options{Development: true}` stays. Flipping it would change the format for every
deployment of this operator, and the flags get the same result here without that reach.
### Tests
None. Three changes, all wiring: two struct fields and a manifest key. `cmd/main.go` has no test
coverage, and pinning `klog.SetLogger` means mutating process-global state that leaks into every
other test in the package. Verified by observation instead, which for this change is stronger
than a unit test would be.
### Verification
```bash
kubectl -n egress-proxies-operator-system logs deploy/... | grep -i leader
# {"level":"info","ts":"2026-09-17T20:10:00Z","msg":"Attempting to acquire leader lease...",
# "lock":"egress-proxies-operator-system/b47711d1.example.com"}
kubectl -n egress-proxies-operator-system logs deploy/... | jq -e -s 'length > 0'
# YES - every line is valid JSON
```
The `jq -s` check is the one that counts: it fails if *any* line is not JSON, which is exactly
the property being fixed. All four existing proxies stayed Ready across the rollout and all
three ProviderHealth entries stayed Ready.
### Worth noting
**The JSON encoder renders durations as float seconds**, so `"duration":"566.12µs"` became
`"duration":0.000336664`. Machine-friendlier, worse to read. Either format in the query —
`(.duration*1000000|round)` for µs — or pass an encoder config option in code, which
controller-runtime exposes (`zap.Options.EncoderConfigOptions`, `zap.go:140-144`):
```go
opts := zap.Options{
Development: true,
EncoderConfigOptions: []zap.EncoderConfigOption{
func(c *zapcore.EncoderConfig) { c.EncodeDuration = zapcore.StringDurationEncoder },
},
}
```
Left out of this step as scope the plan did not cover.
## Step 5 — Validation against beelink
First contact between the kvm2 provider and real libvirt. Everything before this ran against
an in-memory fake of the twenty libvirt calls the provider uses.
Run in the existing `kind-proxy-operator-demo` cluster rather than a fresh one, deliberately.
That cluster already owned two live `kvm-home` VMs on beelink, and orphan GC deletes any
instance whose UID is missing from *its own* cluster's live set after `gc-min-age`. A second
operator carrying the `kvm-home` entry would have destroyed both within ten minutes. Using the
existing cluster made its live set the thing that protected them — the isolation argument
pointed the opposite way from instinct.
Baseline recorded first (4 Ready proxies, 3 ProviderHealth entries, beelink's domains, volumes
and pool space), so "did we break something" stayed answerable.
The image tag had to change from the running `kvm-dev`. `make deploy` sets the image through
kustomize, so an identical tag leaves the Deployment spec untouched, no rollout happens, the
new process never starts, and the providers config — which is read once per process — is never
re-read. The ConfigMap carries no generator hash either, so nothing else would have forced it.
Results, in the order they were checked:
| Check | Outcome |
|---|---|
| `kvm2-home` ProviderHealth | `Ready 1/1`, `Reachable`, 189 ms — hostname, libvirt 11.6.0, pool running, 247.8 GiB free |
| First Create, cold cache | `base image cached`, 433,651,712 bytes in 8.3 s, digest verified |
| Volume naming (H4) | `base-kvm2-home-debian-13.qcow2` created **beside** kvm's untouched `base-debian-13.qcow2` |
| Second Proxy | no `fetching` line at all — cache hit under the new name |
| State reporting (H3) | `Running` held steady across every poll, no flapping to Provisioning |
| Squid | `Healthy=True` on a real CONNECT probe |
| Delete | domain and both per-VM volumes gone; base image survived |
| Existing infrastructure | all four pre-existing proxies stayed Ready across two rollouts |
The health check alone is worth calling out as a gate: it dials SSH, verifies the host key,
completes the libvirt handshake, runs four RPCs and tears down a dedicated connection — the
exact path H1 changed — without creating anything. Putting the stop gate there meant the
riskiest unknown was settled before a single byte was written to the hypervisor.
### What this run did NOT prove
**H2's error paths.** The unverified claim — that libvirtd answers a short stream finish with
OK rather than an error — only arises when a body *fails* part way through, and a healthy
433 MB download never finishes short. The 19-minute HTTP deadline never fired either. What was
proved is narrower: the wrapper does not break normal uploads. Settling the rest still needs
the scratch-pool probe in the Step 2 entry, and it would be easy to mistake this green run for
more than it is.
**The `kvm2` integration test still has not run.** `make test-integration-kvm2` remains
unexecuted; this was a cluster-level validation, not that suite.
### Worth noting
`config/manager/providers_config.yaml` now ships with the `kvm2-home` entry **enabled**, since
the operator is running it for real. `config/manager/kustomization.yaml` was left at its
committed tag: `make deploy` rewrites it to whatever local image was last deployed, which is
churn rather than work.
Two host-side leftovers were *not* touched, both pre-existing and unrelated to kvm2: five
orphan `proxy-*` volumes from earlier kvm runs, and the `base-test-image.qcow2` pair from the
kvm integration test. Every write to beelink in this step came from the operator itself; no
`virsh` write was issued by hand.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
# Plan: Discovery API documentation (docs/api.md)
**Created:** 2026-08-11 21:52
## Context
The operator serves an HTTP discovery/lease API on `:8090` ([internal/discovery/](internal/discovery/)) that crawler clients use to list proxies, acquire TTL leases, release them, and report rate-limiting. There is no dedicated API reference today: README has four quickstart curls (no auth header, no schemas), and `docs/architecture.md` §7 has an ASCII route map. The user wants full documentation with curl examples for every feature.
**Decisions made with user:** doc lives in a new `docs/api.md`; commit straight to `main` (no MR).
## Deliverable
### 1. New file `docs/api.md` — full API reference
Content (all facts verified against code during planning):
- **Overview & base URL** — what the API is; in-cluster FQDN `http://egress-proxies-operator-controller-manager-discovery-service.egress-proxies-operator-system.svc.cluster.local:8090` (Service: `config/default/discovery_service.yaml`); local access via `kubectl port-forward svc/egress-proxies-operator-controller-manager-discovery-service 8090:8090`.
- **Authentication** — static bearer token from `DISCOVERY_TOKEN` env var (populated from the optional `discovery-token` Secret, key `token`; `config/manager/manager.yaml`). Empty token ⇒ auth disabled with startup warning. Curl: `-H "Authorization: Bearer $TOKEN"` on every example. `/healthz` always exempt.
- **Conventions** — JSON everywhere; error envelope `{"error":"<code>","message":"<text>"}`; request bodies capped at 64 KiB; proxies with a deletion timestamp are excluded from all responses.
- **Configuration table** — `--discovery-addr` (default `:8090`), `--max-lease-ttl` (default 1h), `--lease-cooldown` (default 15m), `DISCOVERY_TOKEN`. Note the shipped Deployment passes none of these flags, so defaults apply.
- **Endpoints**, each with request/response schema, status codes, and a copy-pasteable curl example:
- `GET /healthz` — liveness, unauthenticated.
- `GET /v1/proxies` — filters `healthy=true|false` (else 400 `invalid_query`) and repeatable `attr.<key>=<value>` (verbatim equality on `spec.attributes`, all pairs must match). Response `{"proxies":[proxyView...],"count":N}` sorted by id. Full `proxyView` field table: `id` (ns/name), `ip`, `port`, `attributes`, `phase` (Pending/Provisioning/Ready/Unhealthy/Deleting/Failed), `healthy` (condition `Healthy` == True), `latencyMillis`, `activeLeases`, `maxLeases` (default 5; explicit 0 = unleasable).
- `POST /v1/leases` — body `{selector, ttlSeconds, target}` all optional; TTL defaults 5m, capped at max-lease-ttl (else 400 `invalid_ttl`). 201 `{leaseID, proxy, expiresAt, ttlSeconds}`; 409 `no_match` with `considered/atCapacity/inCooldown/unhealthy` counts (documented meanings).
- `DELETE /v1/leases/{id}` — early release; always 204, idempotent.
- `POST /v1/leases/{id}/report` — body `{result: ok|rate_limited|banned, target}`; 204, 404 `unknown_lease`, 400 `invalid_result`. `ok` is a pure ack; `rate_limited` and `banned` behave identically today (both start one cooldown window).
- **Selection & cooldown semantics** (short section — this is the non-obvious part clients need):
- Selection order: fewest active leases → lowest latency → lexicographic id; deterministic; only healthy, non-deleting proxies with free capacity are candidates.
- Cooldown: 15m default (`--lease-cooldown`), keyed `{proxy, target}`. Report with a target blocks only leases requesting that target; report without a target (and lease without one) creates a **global** cooldown blocking all acquisitions of that proxy — call this footgun out explicitly.
- Expired leases stay reportable for one cooldown window past TTL.
- **End-to-end workflow example** — numbered curl walkthrough: acquire → use `proxy.ip:port` as HTTP proxy (`curl -x`) → report `rate_limited` on 429 → release. Using a `jq`-extracted `leaseID`.
- **Caveats** — lease/cooldown state is in-memory and per-process: single replica only, operator restart drops all leases and cooldowns.
### 2. Pointers to the new doc (small edits)
- `README.md`: one-line link near the quickstart curl section ("full reference: docs/api.md").
- `docs/architecture.md` §7: one-line link to `docs/api.md` as the detailed reference.
### 3. Housekeeping per CLAUDE.md
- First action post-approval: copy this plan to `docs/plans/<timestamp>-discovery-api-docs.md` (timestamp from `date "+%Y-%m-%d-%H%M"`), commit it alone.
- Then write the docs, commit to `main` with `Co-Authored-By: Claude <noreply@anthropic.com>` trailer, push.
- Append execution summary + status checklist to `docs/plans-executions/<same-timestamp>-discovery-api-docs.md` in the docs commit.
- Add `CHANGELOG.md` entry (timestamp via `date "+%Y-%m-%d %H:%M %Z"`) once the user confirms.
## Key source files (facts source of truth)
- [internal/discovery/handlers.go](internal/discovery/handlers.go), [internal/discovery/server.go](internal/discovery/server.go) — routes, schemas, status codes, auth, limits.
- [internal/lease/store.go](internal/lease/store.go) — selection order, cooldown/retention, stats.
- [api/v1alpha1/proxy_types.go](api/v1alpha1/proxy_types.go), [api/v1alpha1/helpers.go](api/v1alpha1/helpers.go) — defaults (port 3128, maxLeases 5), phases, conditions.
- [cmd/main.go](cmd/main.go) — flags/env defaults.
- [config/default/discovery_service.yaml](config/default/discovery_service.yaml), [config/manager/manager.yaml](config/manager/manager.yaml) — service DNS, token secret.
## Verification
- Cross-check every documented status code / field name against `internal/discovery/server_test.go` expectations.
- Sanity-run `go build ./... && go test -short ./internal/discovery/ ./internal/lease/` (no code changes expected — confirms docs match current behavior, not a stale tree).
- Optionally lint the curl JSON bodies by piping each through `jq .` locally.
@@ -0,0 +1,33 @@
# Plan: Demo script — egress IP check through each healthy proxy
**Created:** 2026-08-11 22:20
## Context
First of a planned series of demo scripts under `docs/demo/`. This one showcases the discovery API end to end without leases: list proxies from `$BASE_URL/v1/proxies`, and for each **healthy** one, call an IP-echo site through it (`curl -x`) to show the egress IP that proxy provides — clearly labeling which proxy each request goes through. The user explicitly asked to start by switching to a new branch. The script will be iterated on: **write it but do not commit it** — the user wants to add things to it before anything is committed.
## Steps
### Step 0 — Branch
Create `feat/demo-scripts` off `main`. Commit only the plan copy (`docs/plans/<timestamp>-demo-scripts.md`, timestamp via `date "+%Y-%m-%d-%H%M"`) per CLAUDE.md — nothing else gets committed this round.
### Step 1 — `docs/demo/show-egress-ips.sh` (new file, executable, left uncommitted)
Style: match [.devcontainer/post-install.sh](.devcontainer/post-install.sh) — `#!/bin/bash`, `set -euo pipefail`, `ERROR:`/`WARNING:` messages, `${VAR}` braces.
Behavior:
1. **Args/env:** `BASE_URL` is `$1` (required; missing → usage text + exit 1, e.g. `usage: show-egress-ips.sh <BASE_URL> (e.g. localhost:8090)`). Optional env: `TOKEN` (bearer token, same name docs/api.md uses; when set, send `Authorization: Bearer $TOKEN`), `IP_ECHO_URL` (default `https://api.ipify.org?format=json` — returns `{"ip":"..."}`).
2. **Dependency check:** `command -v curl`, `command -v jq` → `ERROR` + exit 1 if missing.
3. **Fetch** `"$BASE_URL/v1/proxies"` once (no server-side `healthy` filter — fetch all so unhealthy ones can be shown as skipped, which makes the demo more informative). Fail with a clear error if curl or JSON parsing fails.
4. **Iterate** proxies with `jq -c '.proxies[]'`; for each, extract `id`, `ip`, `port`, `healthy`:
- unhealthy → print `--- skipping <id> (unhealthy) ---`
- healthy → print a clear banner naming the proxy before the request, e.g. `=== via <id> — http://<ip>:<port> ===`, then `curl -sS --max-time 10 -x "http://${ip}:${port}" "$IP_ECHO_URL"`; print the JSON response. A failed probe prints `WARNING: request through <id> failed` and continues (guard so `set -e` doesn't kill the loop).
5. Finish with a one-line summary: N proxies, M probed, K skipped/failed.
Reference for API shapes: [docs/api.md](docs/api.md) (`proxies[].id/ip/port/healthy`; `curl -x http://ip:port` usage is already documented there and in README).
### Deliberately deferred (user will iterate on the script first)
- No commit of the script, no push beyond the plan commit, no MR, no execution summary, no CHANGELOG — all wait until the user says the script (or script set) is ready.
+107
View File
@@ -0,0 +1,107 @@
# Plan: OpenTelemetry tracing integrated with the existing logr/zap logging
**Created:** 2026-08-24 10:25
## Context
The operator has structured logging (controller-runtime zap → logr, `logf.FromContext(ctx)` everywhere) and Prometheus metrics, but no tracing. A single reconcile fans out into k8s API calls, provider calls (GCP VMs / squid Pods), and status patches; the discovery API serves lease requests; GC and health engines run on tickers. Today correlating one operation across those hops means grepping for `reconcileID` or `providerID`. Goal: proper OTel traces — a new trace per unit of work (reconcile, HTTP request, GC sweep, optionally health probe), child spans for k8s/provider/HTTP calls — **and every log line inside a traced operation enriched with the trace context**, without disturbing the existing logr/zap system.
User decisions (confirmed):
- **Enablement:** auto from standard `OTEL_*` env vars — no collector configured ⇒ tracing fully off, zero new flags for the common case.
- **Health probes:** not traced by default; new `--trace-health-probes` flag enables one root span per probe.
- **Backend:** none exists yet — verification uses the console exporter and a throwaway Jaeger all-in-one in kind; manifests ship commented-out OTLP env examples.
## Key design facts (verified, non-obvious)
- **logr can't read ctx:** `logr.LogSink` never sees `context.Context`, so trace IDs cannot be added inside the zap sink. They must be attached as `WithValues` at span-creation points via `logf.IntoContext`. zap does **not** dedupe repeated keys, so enrichment must happen exactly once per ctx chain (see `tracing.Start` below).
- Log keys: **`traceID` / `spanID`** (lowerCamel matches `reconcileID`/`providerID` house style; note in docs that Grafana/Loki derived-field regexes must match `traceID`, not the `trace_id` default).
- **GCP calls trace themselves:** `google.golang.org/api/transport/http` already wraps its transport in `otelhttp.NewTransport` — once a global TracerProvider is set, every Compute call emits an HTTP client span. Do not add HTTP-level spans in the gcp provider; the `provider.*` decorator span becomes their parent.
- **kube-apiserver ignores incoming `traceparent`** by design; our k8s-API client spans are leaves. Don't promise cross-process traces into the apiserver.
- The Go SDK does **not** implement `OTEL_SDK_DISABLED` — handle it ourselves.
- `contrib/exporters/autoexport` drags in metric/log/prometheus exporters (binary + deps on a 128Mi pod) — hand-roll trace-exporter selection (~40 lines) instead.
- Span events are deprecated (OTel spec, Mar 2026): use `span.SetStatus` + `error.type`-style attributes, not `RecordError`/`AddEvent`.
- `rest.Config.Wrap` puts our transport innermost (after auth), so spans see the final request — correct; but informer list/watch + leader election run outside any span ⇒ the transport must **filter out requests with no parent span** or every watch becomes a long-lived root span.
- Manager runnables receive no logger in ctx (only HTTP servers do) — `logf.FromContext` falls back to the global logger there, which is fine; the discovery server must keep seeding from `s.log`.
## Steps
### Step 1 — Dependencies
`go get` (aligning the existing indirect skew: otel core v1.44 / otlptrace v1.40):
`go.opentelemetry.io/otel@v1.45.0`, `otel/sdk@v1.45.0`, `otel/trace@v1.45.0`,
`otel/exporters/otlp/otlptrace/otlptracegrpc@v1.45.0`, `.../otlptracehttp@v1.45.0`,
`otel/exporters/stdout/stdouttrace@v1.45.0`,
`go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp@v0.70.0`, plus the
`semconv` version matching the SDK's `resource.Default()` (treat `ErrSchemaURLConflict` from `resource.New` as non-fatal if versions drift).
`go mod tidy`; full build + `make test` to confirm `google.golang.org/api v0.292.0` / k8s v0.36 tolerate the bump (MVS picks the higher otelhttp).
### Step 2 — New package `internal/tracing`
- **`tracing.go` — `Setup(ctx, log logr.Logger, service, version string) (shutdown func(context.Context) error, err error)`**
- Disabled (returns no-op shutdown, installs nothing) when `OTEL_SDK_DISABLED=true`, or when neither `OTEL_TRACES_EXPORTER` nor `OTEL_EXPORTER_OTLP_ENDPOINT`/`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` is set, or `OTEL_TRACES_EXPORTER=none`. One startup Info line either way stating the effective state/exporter.
- Exporter selection (hand-rolled, no autoexport): `OTEL_TRACES_EXPORTER` ∈ `otlp` (default) | `console`; for otlp, `OTEL_EXPORTER_OTLP_(TRACES_)PROTOCOL` ∈ `http/protobuf` (default) | `grpc` → `otlptracehttp` / `otlptracegrpc` (endpoint/TLS/headers via their native env handling).
- `sdktrace.NewTracerProvider` with batch processor (`WithMaxQueueSize(512)` — bounded on the 128Mi pod; sampler stays the SDK default so `OTEL_TRACES_SAMPLER(_ARG)` works), resource = `resource.New(WithAttributes(service.name, service.version=commit), WithFromEnv())` with env last so it wins.
- Globals: `otel.SetTracerProvider`, `otel.SetTextMapPropagator(TraceContext+Baggage)`, `otel.SetLogger(log.WithName("otel"))`, `otel.SetErrorHandler` → logr at `V(1)` (an unreachable collector errors every few seconds; Error level would spam).
- **`logger.go` — the one logger-enrichment mechanism** (used by everything; prevents duplicate `traceID` keys):
- private ctx key holding the *base* (pre-enrichment) logr.Logger.
- `Start(ctx, spanName, opts...) (context.Context, trace.Span)`: starts a span from the global tracer; if the resulting span context is valid, capture base = stored base or `logf.FromContext(ctx)` on first call, then `logf.IntoContext(ctx, base.WithValues("traceID", ..., "spanID", ...))`. Nested `Start` re-derives from base — never stacks. Documented trade-off: `WithValues` pushed via `logf.IntoContext` *between* two `Start` calls is dropped (nothing first-party does that; controller-runtime's `reconcileID` logger is the base and survives).
- `ContextWithLogger(ctx, base)` seeds the base explicitly (discovery middleware).
- **`reconciler.go` — `NewReconciler(kind string, inner reconcile.Reconciler, opts...) reconcile.Reconciler`**: root span `Reconcile <kind>` (SpanKind Internal) via `Start`; attrs `k8s.namespace.name`, `k8s.object.name`, `reconcile.id` (`controller.ReconcileIDFromContext`); on return records `reconcile.requeue_after`, sets Error status from err. `WithTracerProvider` option for tests.
- **`transport.go` — `RestConfigWrapper() transport.WrapperFunc`**: `otelhttp.NewTransport` with `WithFilter` requiring `trace.SpanContextFromContext(r.Context()).IsValid()` — k8s API spans only under an existing trace (excludes informers, leader election, metrics authn).
- **`http.go` — `HTTPMiddleware(operation string, base logr.Logger) func(http.Handler) http.Handler`**: `otelhttp.NewHandler` (filter `/healthz`; otelhttp renames the span from `r.Pattern` after routing — no custom naming needed) + inner layer calling `ContextWithLogger`-then-enrich so handler logs carry `traceID`. Accepts incoming `traceparent` (clients continue their traces).
Tests (all with `sdktrace` + `tracetest.NewSpanRecorder` via the `WithTracerProvider` options, parallel; `Setup` env tests use `t.Setenv`, not parallel):
- `Start`: nested spans → child span recorded; **funcr-captured log line contains `traceID` exactly once** (pattern: `internal/provider/gcp/gcp_test.go:277`); disabled tracer → no `traceID` in logs.
- `Setup`: table over env combinations (unset→disabled, `none`→disabled, endpoint set→otlp, `console`, `OTEL_SDK_DISABLED`).
- Reconciler decorator: fake inner returns result/err → span name/attrs/status.
- Transport filter: request without parent span produces no span.
- Middleware: `httptest` request → server span named from route pattern; `/healthz` unspanned; handler logger carries `traceID`.
### Step 3 — `provider.WithTracing` decorator
`internal/provider/tracing.go`, mirroring `metrics.go`: `WithTracing(name string, p Provider, opts...) Provider` — one Client span per call (`provider.create|get|delete|list`), attrs `provider.name`, `provider.id` (where present), result class attr from `resultLabel(err)`; status Error only for quota/permanent/transient — `ErrNotFound` stays Ok (expected during deletion polling). Returns the inner error **unmodified**. Uses `tracing.Start` so provider-internal logs (gcp `p.logger(ctx)`) inherit `traceID`. Tests in `internal/provider/tracing_test.go` reusing `staticProvider` from `metrics_test.go:19`.
Wiring in `cmd/main.go:174-177` — tracing outermost: `provider.WithTracing(name, provider.WithMetrics(name, p, m))`.
### Step 4 — `cmd/main.go` wiring
- `tracing.Setup(...)` immediately after `ctrl.SetLogger` (cmd/main.go:148); on error: log + `os.Exit(1)`.
- Explicit `shutdown(context.WithTimeout(context.Background(), 10*time.Second))` (fresh ctx — signal ctx is already cancelled) after `mgr.Start` returns, on **both** the error path before `os.Exit(1)` and the clean path. Earlier `os.Exit` sites have nothing to flush.
- `cfg := ctrl.GetConfigOrDie(); cfg.Wrap(tracing.RestConfigWrapper())` before `ctrl.NewManager` (cmd/main.go:239).
- kubernetes provider: add `kubernetes.NewWithTransportWrapper(ctx, cfg, wrap transport.WrapperFunc)` beside `New` (internal/provider/kubernetes/kubernetes.go:42) and register a closure in main's constructor map (same pattern as gcp at cmd/main.go:166) passing `tracing.RestConfigWrapper()` — keeps `internal/tracing` out of provider packages and `newWithClient` tests untouched.
- New flag `--trace-health-probes` (default false) → `engine.TraceProbes`.
### Step 5 — Reconciler spans
- `SetupWithManager` (proxy_controller.go:412): `b.Complete(tracing.NewReconciler("Proxy", r))`.
- Sub-spans via `tracing.Start`: `reconcile.managed` (:116), `reconcile.replaceInstance` (:226), `reconcile.delete` (:254), and `status.patch` opened **inside the deferred flush closure** (:98-104, runs within the root span; its error already folds into `err`). `reconcileExternal` stays unspanned — no I/O, pure noise.
- Useful attrs where cheap: phase transitions, `providerID`.
### Step 6 — Discovery server
- `handler()` (internal/discovery/server.go:151-156): chain becomes recover → `tracing.HTTPMiddleware("discovery", s.log)` → log → maxBytes → auth (otelhttp must stay outside `maxBytesMiddleware`).
- Handlers and `logMiddleware`/`recoverMiddleware` switch `s.log` → `logf.FromContext(r.Context())` (handlers.go:85, :141, :234; server.go:163, :191) so request logs carry `traceID`. `s.log` remains for startup lines.
- k8s `List` calls in handlers already use `r.Context()` → child spans appear via the wrapped rest transport.
### Step 7 — GC + health
- `gc.Sweeper.sweep` (internal/gc/gc.go:83): wrap in root span `gc.sweep` via `tracing.Start` (before the `.WithName("orphan-gc")` derivation so it inherits), attrs: providers swept, orphans deleted. Provider/k8s child spans come free from Steps 3/4.
- `health.Engine`: new field `TraceProbes bool`. In the worker loop (engine.go:158) when enabled: root span `health.probe` (SpanKind Client) around `probeFn`, attrs proxy key/ok/latency/error class, ended before `record` (which stays ctx-free). No transport wrap and no propagation — never inject `traceparent` toward external targets through the proxies. Probe→reconcile span links are **out of scope**: `GenericEvent` (engine.go:330) carries no ctx and the workqueue coalesces events; documented in architecture Decisions.
- Lease store sweep: not traced (in-memory only).
### Step 8 — GCP wire-log enrichment
`wireFilterHandler.Handle` (internal/provider/gcp/wirelog.go:46) receives the real request ctx from the SDK's slog calls: append `traceID`/`spanID` attrs when `trace.SpanContextFromContext(ctx)` is valid — V(5) wire logs become correlatable with the `provider.*` span. Add `wirelog_test.go` case.
### Step 9 — Manifests + docs
- `config/manager/manager.yaml` env (after `DISCOVERY_TOKEN`): `POD_NAME`/`POD_NAMESPACE` via downward API **first**, then `OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES: k8s.pod.name=$(POD_NAME),k8s.namespace.name=$(POD_NAMESPACE)` ($(VAR) expansion only sees earlier vars); commented-out `OTEL_EXPORTER_OTLP_ENDPOINT` + `OTEL_TRACES_SAMPLER` examples with a "tracing is off until an endpoint is set" comment. Note `--trace-health-probes` beside the args list.
- `docs/architecture.md`: new "### 10. Tracing" section after Metrics (:281) — span topology, enablement, `traceID` log-key choice; Decisions entries (:303): env-gated enablement, probes off by default, no probe→reconcile links (why), `traceID` naming vs Grafana defaults, no traceparent into apiserver.
- README ops note; CHANGELOG entry (dated via `date`) once the user confirms it works.
- Run `make lint` (CI has no lint step; `lll`/`revive` will hit new files).
## Verification
1. `make test` (unit, includes all new `tracetest` assertions) and `make lint`.
2. Console smoke: `OTEL_TRACES_EXPORTER=console go run ./cmd --providers-config hack/providers-dev.yaml ...` against kind — create a Proxy, confirm (a) `Reconcile Proxy` span JSON on stdout with nested `provider.create` + k8s PATCH spans, (b) the reconcile log lines carry the same `traceID` as the span, (c) no spans from informer watches or leader election.
3. End-to-end in kind: deploy Jaeger all-in-one (`jaegertracing/all-in-one`, OTLP 4318), set `OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318` on the manager Deployment; exercise a reconcile + a `POST /v1/leases` (with and without a client `traceparent`) + wait one GC interval; in the Jaeger UI: reconcile trace shows provider+k8s children; lease trace shows route-named server span with k8s child; grep manager logs for a `traceID` and resolve it in Jaeger.
4. Negative: run with no `OTEL_*` env — startup line says tracing disabled, no export-error spam, logs unchanged (no `traceID` keys).
+150
View File
@@ -0,0 +1,150 @@
# Plan: e2e test — OTel tracing against real Tempo
**Created:** 2026-08-24 12:24
## Context
The tracing feature (MR #4, branch `feat/otel-tracing`) is covered by unit
tests with an in-memory span recorder, but nothing proves the full pipeline:
operator in a real cluster → OTLP export → Tempo ingest → queryable traces
with the documented span topology. The homelab Tempo lives at
`http://192.168.0.30:3200` (query API — verified live from this host:
`/api/echo`, TraceQL `GET /api/search?q=…`, `GET /api/traces/<id>`), with
OTLP ingest on `:4318` (HTTP, verified 200) and `:4317` (gRPC, open). User
decisions: **Go Ginkgo e2e test** (not a shell script), **structural
assertions** (span tree, not just trace-exists), flow per the user's sketch:
deploy operator in kind with tracing on → create kubernetes-provider proxies
→ delete them → shut down — with traces tagged for easy discovery per test
run. This work continues on `feat/otel-tracing`; MR #4 stays the vehicle.
## Design decisions (from review, with evidence)
- **Proxy CRs go in the `default` namespace, not the operator namespace.**
The squid pods the kubernetes provider creates carry no securityContext
(internal/provider/kubernetes/pod.go:33-49) and are created in the CR's
namespace; the operator ns is labeled `pod-security enforce=restricted`
(test/e2e/e2e_test.go:61-62) and would reject them. Pod RBAC is
cluster-scoped (config/rbac/role.yaml:10), so `default` works. All
kubectl calls use explicit `-n`.
- **Anchor assertions on `provider.create`, not the root-span search.** A
`name="Reconcile Proxy"` hit may be the finalizer-add or a drift
reconcile (no provider call inside). TraceQL matches span names anywhere
in a trace, so search `{resource.test.run.id="<runID>" && name="provider.create"}`,
fetch *that* traceID, then assert companion span names.
- **Run identification with zero code changes:** OTel resource attribute
`test.run.id=<runID>` via `OTEL_RESOURCE_ATTRIBUTES` (env wins —
`resource.WithFromEnv()` merges last, internal/tracing/tracing.go). Tempo
vParquet searches arbitrary resource attrs without config; fallback query
documented: `{resource.service.name="egress-proxies-operator" && name="provider.create"}`
+ start/end window (Unix **seconds**).
- **OTLP preflight from inside the cluster:** host-reachability of
192.168.0.30 doesn't prove pod-reachability from kind on macOS, and
export failures log only at V(1) (invisible) — without a preflight, a
broken path is a 2-minute opaque timeout. Reuse the suite's curl-pod
pattern (e2e_test.go:218-248, incl. the restricted-PSS overrides JSON)
to POST to `<OTLP_ENDPOINT>/v1/traces` and fail fast.
- **No homelab defaults baked into the Makefile.** The spec Skips unless
`TEMPO_URL`/`OTLP_ENDPOINT` are set (otherwise everyone without that LAN
host inherits a 2-minute failure). The copy-pasteable invocation lives in
docs/testing.md.
- **Ginkgo ordering:** top-level container order is randomized, so the
tracing Describe is fully self-contained (own ns create → make install →
make deploy → teardown), same shape as the Manager Describe. `kubectl
delete ns` blocks until termination, so no create/delete race between
the two Describes.
## Steps
### Step 1 — `test/e2e/tracing_test.go` (new; `//go:build e2e`, package e2e)
`Describe("OTel tracing", Ordered)`:
- **BeforeAll:**
1. Read `TEMPO_URL` + `OTLP_ENDPOINT`; `Skip("TEMPO_URL/OTLP_ENDPOINT not set")` when empty.
2. `runID := "e2e-" + strconv.FormatInt(time.Now().UnixNano(), 10)`;
print it to `GinkgoWriter` so the run is findable in Grafana by hand.
3. Create operator ns + restricted-PSS label; `make install`;
`make deploy IMG=<managerImage>` (same commands as e2e_test.go:54-75,
via `utils.Run`).
4. Pre-pull the proxy image to kill the biggest flake source:
`docker pull ubuntu/squid:6.6-24.04_edge` + `kind load docker-image`
(via `utils.Run`, honoring `KIND`/`KIND_CLUSTER` env like
utils.LoadImageToKindClusterWithName).
5. OTLP preflight: curl pod POSTing `{}` to `<OTLP_ENDPOINT>/v1/traces`,
assert HTTP 200 in its logs (fail message names the endpoint).
6. `kubectl set env deployment/egress-proxies-operator-controller-manager
-n <ns> OTEL_EXPORTER_OTLP_ENDPOINT=<OTLP_ENDPOINT>
"OTEL_RESOURCE_ATTRIBUTES=k8s.pod.name=$(POD_NAME),k8s.namespace.name=$(POD_NAMESPACE),test.run.id=<runID>"`
— in-place update keeps the var after the downward-API vars ($(VAR)
expansion needs that ordering); `exec.Command` means no shell mangling
of `$()`. Then `kubectl rollout status`.
7. `Eventually`: newest controller pod (sorted by creationTimestamp — a
terminating old pod may otherwise be picked) logs contain
`"tracing enabled"`.
- **It "creates proxies and reports reconcile traces":**
- Write 2 Proxy manifests (names `proxy-tracing-e2e-1/2`,
`provider: kubernetes`, modeled on config/samples/proxy_kubernetes.yaml)
to `GinkgoT().TempDir()` (absolute paths — `utils.Run` chdirs the
process); `kubectl apply -n default`.
- `Eventually` (5m — first squid start) `.status.phase == "Ready"`
(confirmed field, api/v1alpha1/proxy_types.go:41).
- `Eventually`: Tempo search
`{resource.test.run.id="<runID>" && name="provider.create"}` ≥1 hit
(BSP flushes every ~5s; 2m default timeout is ample).
- Fetch that traceID via `/api/traces/<id>`; assert span names include
`Reconcile Proxy`, `reconcile.managed`, `provider.create`,
`status.patch`, and resource attr `service.name=egress-proxies-operator`.
- **It "traces deletion":**
- `kubectl delete proxy -n default …`; `Eventually` CRs gone (finalizer
→ `provider.delete`, DeletionPoll 10s).
- `Eventually`: Tempo search finds `provider.delete` for the runID; fetch
and assert `reconcile.delete` in the same trace.
- **AfterAll** (= the sketch's "shut down operator"): delete leftover
proxies in `default`; `make undeploy` (SIGTERM → `tracingShutdown`
flushes); `make uninstall`; delete operator ns — errors discarded
(`_, _ = utils.Run(...)`), mirroring e2e_test.go:79-95.
- **Tempo client helpers** (same file, stdlib only — the test process runs
on the host, which reaches Tempo directly):
- `tempoSearch(traceql string)` → `GET {TEMPO_URL}/api/search?q=…&start=…&end=…`
(Unix seconds, window = suite start − 5m → now); minimal struct
`{Traces []struct{ TraceID string }}`.
- `tempoTrace(id string)` → `/api/traces/<id>`, decoded as **OTLP-JSON**
(`batches[].scopeSpans[].spans[].name`, resource attrs as
`{key, value:{stringValue}}`) — not Jaeger's shape. Helper flattens to
a span-name set + resource attr map.
### Step 2 — Makefile
- `test-e2e`: pass `TEMPO_URL`/`OTLP_ENDPOINT` through to `go test` env
(no defaults) and add `-timeout 30m` — the suite already runs
docker-build + kind-load in BeforeSuite plus 3–5m Eventuallys, and this
adds a second full deploy cycle + image pulls; the 10m default will be
exceeded on cold caches.
### Step 3 — Docs
- `docs/testing.md` (§ e2e, currently "scaffold… only asserts manager runs
and serves metrics"): document the tracing spec, its Skip gate, and the
invocation:
`TEMPO_URL=http://192.168.0.30:3200 OTLP_ENDPOINT=http://192.168.0.30:4318 make test-e2e`.
- Execution-log entries per step in `docs/plans-executions/2026-08-24-1224-tracing-e2e.md`.
- CHANGELOG entry only after the user confirms a green run (house rule).
## Not doing (and why)
- No dedicated flush assertion on shutdown — teardown exercises the flush
path, but attributing a specific late span to it is guesswork.
- No gRPC (4317) variant — http/protobuf is the operator default and the
verified path; a variant run is a one-env-var change if ever wanted.
- No changes to `internal/` — the whole test works through public surface
(env vars, kubectl, Tempo API), which is the point of an e2e test.
## Verification
1. `TEMPO_URL=http://192.168.0.30:3200 OTLP_ENDPOINT=http://192.168.0.30:4318 make test-e2e`
→ tracing spec passes (both Its); without the env vars → spec reports
Skipped, remainder of suite unaffected.
2. In Grafana: TraceQL `{resource.test.run.id="<runID printed in test log>"}`
shows the run's traces with the expected tree.
3. `go vet -tags=e2e ./...` clean (documented routine).
4. A deliberately wrong `OTLP_ENDPOINT` (e.g. port 9) fails fast in the
preflight step with a clear message, not a 2-minute search timeout.
+313
View File
@@ -0,0 +1,313 @@
# Plan: KVM/libvirt provider
**Created:** 2026-09-04 14:04
## Context
The operator provisions egress proxies through named provider instances (today
`gcp` and `kubernetes`). The user has several KVM hypervisors on the home
network and wants proxy VMs created there, using stock Debian/Ubuntu cloud
images so the VM stays a normal machine they can customise through their own
cloud-init user-data. Longer term the operator may become a generic "run a VM
or container with a set of scripts" tool; the KVM provider should not
foreclose that.
The provider contract (`internal/provider/provider.go`: `Create`/`Get`/`Delete`/
`ListByTag`, keyed by `NameFromUID`, cloud-init passed verbatim, GC-by-tag) fits
libvirt with **no CRD or reconciler changes**. All work is a new provider
package plus config/wiring/docs, following the GCP provider's shape.
Decisions taken with the user (2026-09-04):
- **Transport:** SSH to each hypervisor, forwarding the libvirt unix socket
(`ssh.Client.Dial("unix", "/var/run/libvirt/libvirt-sock")` → `libvirt.New(conn)`).
Pure Go, no cgo, no libvirt libs in the image. Verified: `x/crypto/ssh`
`Client.Dial` has a `"unix"` case (`dialStreamLocal`), go-libvirt `New(net.Conn)`.
- **Topology:** one provider instance can span several hosts from day one
(config has a `hosts` list; providerID encodes the host), so adding hosts
later is a config edit with no providerID migration. With one host configured
`placement.zone` may be omitted.
- **Networking:** VM NIC on a host bridge to the LAN, IP from the router's
DHCP. IP discovery via `DomainInterfaceAddresses` (ARP source first, then
guest agent). Provider-injected `vendor-data` installs `qemu-guest-agent`
so agent discovery works on stock images without touching user-data.
- **Base image:** a stock Debian/Ubuntu cloud image (qcow2, cloud-init
preinstalled) that **the operator fetches itself** on first use per host:
`placement.image` names an entry in the provider's `images:` map (url +
optional sha256) or is an `https://` URL directly. The provider streams the
download through libvirt's `StorageVolUpload` into the host's pool, so
nothing is pre-placed and the operator needs no filesystem access on the
host. Per-VM disk is a qcow2 overlay with the cached base as backing store.
- **Seed:** NoCloud ISO (`user-data`, `meta-data`, `vendor-data`) built in
pure Go with `github.com/kdomanski/iso9660` (`WriteTo(w, "cidata")`) and
streamed to the pool with `StorageVolUpload(vol, io.Reader, ...)`. Nothing
to install on the hypervisors.
## Design
### Config (`internal/provider/config.go`)
```yaml
- name: kvm-home
type: kvm
kvm:
sshKeyFile: /etc/proxy-operator/kvm/id_ed25519 # required
knownHostsFile: /etc/proxy-operator/kvm/known_hosts # required unless insecureIgnoreHostKey
insecureIgnoreHostKey: false
diskSizeGb: 10 # overlay capacity; cloud-init growpart expands root fs
vendorData: | # optional override; default installs qemu-guest-agent
#cloud-config
...
images: # optional; placement.image names a key (or is an https URL)
debian-13:
url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
sha256: "…" # optional; verified after download, re-fetch when it changes
format: qcow2 # default qcow2
ubuntu-24.04:
url: https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
machineTypes: # required, ≥1; placement.machineType names a key
small: {vcpus: 1, memoryMiB: 1024}
medium: {vcpus: 2, memoryMiB: 2048}
hosts: # required, ≥1, unique names
- name: nas1 # placement.zone value
address: nas1.home.lan:22 # required
user: virt # required; must be in the libvirt group
socket: /var/run/libvirt/libvirt-sock # default
pool: default # default "default"
bridge: br0 # required
```
Add `KVMConfig`, `KVMHost`, `KVMMachineType`, `KVMImage` structs; a `"kvm"`
case in `validate()` (cross-block checks like the others, required fields
above, unique host names, image URLs must be `http(s)`, sha256 if set must be
64 hex chars). Tests in `config_test.go` extend the existing table.
### Placement mapping
| Placement field | KVM meaning |
|---|---|
| `zone` | host name from `hosts`; empty → the single configured host, else `ErrPermanent` |
| `machineType` | key in `machineTypes`; unknown → `ErrPermanent` |
| `image` | key in `images:`, or an `https://` URL; anything else → `ErrPermanent`. Fetched into the host's pool on first use (see *Image cache*) |
| `region` | ignored |
### providerID
`hosts/<host>/domains/<name>` with `formatProviderID`/`parseProviderID`
(same self-contained style as GCP's `zones/<zone>/instances/<name>`).
### GC contract without labels
Domain XML gets a `<metadata>` child in a private namespace, e.g.
`xmlns="https://gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/kvm"`,
carrying `<managed>true</managed><uid>…</uid><createdAt>RFC3339</createdAt>`.
`Get` reads it via `DomainGetMetadata(dom, DomainMetadataElement, uri)`;
`ListByTag` does `ConnectListAllDomains` on every host and keeps domains where
that call succeeds and `managed == "true"`. `CreatedAt` comes from the metadata
(libvirt has no creation time). A host that is unreachable makes `ListByTag`
return an error (GC skips the sweep that round rather than acting on a partial view).
### Connection management (`internal/provider/kvm/conn.go`)
Per-host lazily dialed `*ssh.Client` + `*libvirt.Libvirt`, cached under a
mutex, re-dialed when `Disconnected()` is closed or a call fails with a
transport error. SSH auth: private key from `sshKeyFile`; host key check via
`knownhosts.New(knownHostsFile)` unless `insecureIgnoreHostKey`. Dial timeout
from `ctx`. The provider calls libvirt through a narrow interface:
```go
type libvirtAPI interface {
DomainLookupByName(name string) (libvirt.Domain, error)
DomainDefineXML(xml string) (libvirt.Domain, error)
DomainCreate(d libvirt.Domain) error
DomainDestroy(d libvirt.Domain) error
DomainUndefineFlags(d libvirt.Domain, f libvirt.DomainUndefineFlagsValues) error
DomainGetState(d libvirt.Domain, flags uint32) (int32, int32, error)
DomainGetMetadata(d libvirt.Domain, typ int32, uri libvirt.OptString, f libvirt.DomainModificationImpact) (string, error)
DomainInterfaceAddresses(d libvirt.Domain, src uint32, flags uint32) ([]libvirt.DomainInterface, error)
ConnectListAllDomains(need int32, f libvirt.ConnectListAllDomainsFlags) ([]libvirt.Domain, uint32, error)
StoragePoolLookupByName(name string) (libvirt.StoragePool, error)
StorageVolLookupByName(p libvirt.StoragePool, name string) (libvirt.StorageVol, error)
StorageVolGetPath(v libvirt.StorageVol) (string, error)
StorageVolCreateXML(p libvirt.StoragePool, xml string, f libvirt.StorageVolCreateFlags) (libvirt.StorageVol, error)
StorageVolUpload(v libvirt.StorageVol, r io.Reader, off, n uint64, f libvirt.StorageVolUploadFlags) error
StorageVolDownload(v libvirt.StorageVol, w io.Writer, off, n uint64, f libvirt.StorageVolDownloadFlags) error
StorageVolDelete(v libvirt.StorageVol, f libvirt.StorageVolDeleteFlags) error
StoragePoolRefresh(p libvirt.StoragePool, flags uint32) error
}
```
(All signatures verified against go-libvirt `main` on 2026-09-04.) A
`hostDialer func(ctx, KVMHost) (libvirtAPI, io.Closer, error)` is the test seam;
`newWithDialer(cfg, dialer)` mirrors GCP's `newWithAPI`.
### Image cache (`internal/provider/kvm/image.go`)
`ensureImage(ctx, host, api, img) (basePath string, err error)` makes the base
image present in the host's pool, once per host per image:
- Volume names: `base-<key>.<format>` for an `images:` key, or
`base-<sha256(url)[:16]>.<format>` for a URL given directly in
`placement.image`. Marker volume `<base>.sha256` (raw, 64 bytes) holds the
digest of the verified upload.
- **Ready check:** base volume exists *and* marker exists *and* (no `sha256`
configured, or marker content == configured `sha256`). Read the marker with
`StorageVolDownload`. Ready → return `StorageVolGetPath(base)`.
- **Not ready:** delete both volumes if present (partial upload from a crash,
or a changed checksum), then `GET url` with the ctx, require a
`Content-Length` (`ErrPermanent` otherwise), `StorageVolCreateXML` the base
with `<capacity unit="bytes">Content-Length</capacity>` and the configured
format, `StorageVolUpload(base, io.TeeReader(body, sha256), 0, 0, 0)` (length
0 = until EOF, so nothing is buffered in the operator), then compare the
digest with the configured one (mismatch → delete base, `ErrPermanent`),
`StoragePoolRefresh` so libvirt re-reads the real qcow2 capacity, create the
marker volume and upload the digest last.
- HTTP 4xx → `ErrPermanent`; 5xx, network, or upload transport errors →
`ErrTransient` (partial volumes are cleaned on the next attempt).
- A per-`(host, image)` `sync.Mutex` map serialises concurrent Creates for the
same image on the same host; the second caller finds it ready.
- **Judgment call:** the fetch runs synchronously inside `Create`, so the first
Proxy for a given image on a given host holds a reconcile worker for the
download (LAN speed, a few hundred MB, once). This keeps `Create` idempotent
and needs no background state. Async fetch with a `Provisioning`-style
progress status is a possible later improvement, noted in the docs.
### Create (idempotent, fire-and-forget after the image is cached)
1. Resolve host, machine type, pool, and image source (`images:` key or URL;
else `ErrPermanent`). `ensureImage` → base path.
2. Overlay volume `<name>.qcow2`: `StorageVolCreateXML` with
`<capacity unit="G">diskSizeGb</capacity>`, `<target><format type="qcow2"/></target>`,
`<backingStore><path>base</path><format type="qcow2"/></backingStore>`.
`ErrStorageVolExist` → reuse.
3. Seed volume `<name>-seed.iso`: build ISO in memory (`user-data` = req.CloudInit,
`meta-data` = `instance-id: <name>\nlocal-hostname: <name>\n`, `vendor-data` =
cfg or default), `StorageVolCreateXML` with exact capacity + `<format type="raw"/>`,
then `StorageVolUpload`. Exists → skip upload (content is deterministic per spec hash;
spec changes recreate the whole instance anyway).
4. `DomainLookupByName(name)`; if `ErrNoDomain` → `DomainDefineXML(buildDomainXML(...))`.
5. `DomainGetState`; if not running → `DomainCreate`. Return providerID.
`buildDomainXML` is a pure function over `encoding/xml` structs: `type="kvm"`,
q35 machine, `host-passthrough` CPU, vcpus/memory from the profile, virtio disk
(overlay) + sata cdrom (seed), `interface type="bridge"` with virtio model,
`org.qemu.guest_agent.0` virtio channel, serial console, virtio rng, no
graphics, and the `<metadata>` block. Unit-tested field by field like
`buildInsertRequest`.
### Get
`parseProviderID` → host → `DomainLookupByName` (`ErrNoDomain` → `ErrNotFound`)
→ `DomainGetState` + `DomainGetMetadata` + `DomainInterfaceAddresses`
(ARP, then agent; first IPv4 wins; agent errors while the agent isn't up yet
are swallowed). Mapping:
| libvirt state | IP | Instance.State |
|---|---|---|
| running | yes | Running |
| running | no | Provisioning |
| shutoff / crashed / paused / pmsuspended | – | Stopped |
| no domain | – | `ErrNotFound` |
### Delete (idempotent)
Destroy if running (`ErrOperationInvalid` on a stopped domain is fine) →
`DomainUndefineFlags(managed-save | snapshots-metadata | nvram)` → delete
`<name>.qcow2` and `<name>-seed.iso` (`ErrNoStorageVol` fine). Volumes are
deleted by name even when the domain is already gone, so a crash between
volume creation and define still gets cleaned up on the next reconcile.
Known gap (documented, not solved now): volumes orphaned with no domain *and*
no CR are invisible to `ListByTag`.
### Errors (`internal/provider/kvm/errors.go`)
`classify(err)` on `libvirt.Error.Code`: `ErrNoDomain`, `ErrNoStorageVol` →
`ErrNotFound`; `ErrNoStoragePool`, `ErrXMLError`, `ErrConfigUnsupported`,
`ErrNoSupport`, `ErrAuthFailed`, `ErrInvalidArg` → `ErrPermanent`; SSH dial /
host-key / transport errors and everything else → `ErrTransient`. Image
download: HTTP 4xx, missing Content-Length, checksum mismatch → `ErrPermanent`;
5xx and network errors → `ErrTransient`. Quota is not detectable from libvirt;
left as transient (revisit with a capacity check later).
### Wiring & manifests
- `cmd/main.go`: add `"kvm": kvm.New` to the builtins map.
- `config/manager/manager.yaml`: mount Secret `kvm-ssh` (optional) at
`/etc/proxy-operator/kvm` (keys `id_ed25519`, `known_hosts`); document the
`kubectl create secret generic kvm-ssh --from-file=...` command.
- `config/samples/providers-config.yaml`: add the `kvm-home` block with the
`images:` map (Debian 13 and Ubuntu 24.04 official cloud-image URLs);
`config/samples/proxy_kvm.yaml`: Proxy with `placement: {zone: nas1,
machineType: small, image: debian-13}` and the same squid cloud-init as the
GCP sample plus `ssh_authorized_keys`.
- `hack/providers-dev.yaml` stays kubernetes-only (needs no secrets).
- Deps: `github.com/digitalocean/go-libvirt` (latest pseudo-version),
`github.com/kdomanski/iso9660 v0.4.0`, promote `golang.org/x/crypto` to direct.
### Docs
- New `docs/kvm-provider.md`: hypervisor prerequisites (user in `libvirt`
group, a bridge to the LAN, a storage pool with enough space), the config
reference including `images:`, how the image cache works (`base-*` and
`*.sha256` volumes in the pool, when a re-download happens, how to evict
with `virsh vol-delete`), the placement table, how to log into a VM
(`ssh_authorized_keys` in user-data, `virsh console` on the host), and the
known gaps (no quota detection, orphan volumes without a domain,
synchronous first-time image fetch).
- Update the provider tables in `README.md`, `docs/architecture.md` §5,
`docs/testing.md` (integration test section).
### Towards the generic runner (not built now, just kept open)
Provider knows nothing about squid. Future hooks: `vendor-data` for injecting an
agent, `QEMUDomainAgentCommand` for reading exit status/output, and a future
`Placement`-independent "job" mode in the CRD. None of this changes the KVM
provider's shape.
## Steps
0. **Save plan + branch.** Copy this file to `docs/plans/<ts>-kvm-provider.md`,
commit alone, then `git checkout -b feat/kvm-provider` off `feat/proxy-operator`
(the working main for this project).
1. **Config.** `KVMConfig` & friends, `validate()` case, table tests.
2. **Pure builders.** `internal/provider/kvm/{domainxml.go,volume.go,seed.go,id.go,metadata.go}`
+ tests: domain XML fields, volume XML, seed contents (unpack the ISO with
the same library's reader and assert the three files + label `cidata`),
providerID round-trip, metadata marshal/parse.
3. **Transport + errors.** `conn.go` (SSH dial, host-key policy, per-host cache,
reconnect), `errors.go` + classification table test. SSH dialer tested
against an in-process `x/crypto/ssh` server that serves a unix socket
forward (cheap, keeps the dial code covered).
3b. **Image cache.** `image.go` + `image_test.go`: `httptest.Server` serving a
small fake image over the `fakeAPI` (which records uploaded bytes);
cases: fresh fetch, already ready, marker missing (partial upload → redo),
checksum changed → redo, checksum mismatch → Permanent + cleanup, 404 →
Permanent, 503 → Transient, two concurrent callers → one download.
4. **Provider methods.** `kvm.go`: `New`, `newWithDialer`, `Create/Get/Delete/ListByTag`
over `libvirtAPI`; `kvm_test.go` with a hand-written `fakeAPI` (domains and
volumes in maps, per-call error injection) covering idempotent re-Create
after each partial step, state/IP mapping, delete-when-gone, ListByTag
across two hosts and with one host down.
5. **Wiring, manifests, samples, docs, CHANGELOG.**
6. **Integration test** (`//go:build integration`, `kvm_integration_test.go`):
skipped unless `KVM_TEST_HOST`, `KVM_TEST_USER`, `KVM_TEST_SSH_KEY`,
`KVM_TEST_BRIDGE`, `KVM_TEST_IMAGE_URL` are set; fetches the image (or finds
it cached), creates, waits for Running+IP, deletes, asserts per-VM volumes
gone and the base image still cached. Deviation from the testcontainers convention,
called out in `docs/testing.md`: libvirt+KVM can't run inside a container
without `/dev/kvm`, so this targets a real hypervisor.
7. **Manual validation** on one real hypervisor, results in
`docs/plans-executions/` (like `docs/gcp-vm-validation.md`), then MR via `tea`.
## Verification
- `make test` green (`-race`, envtest suite untouched but must still pass).
- `go test -short ./internal/provider/...` covers builders, fake-API provider, errors, config.
- Integration: `KVM_TEST_HOST=nas1.home.lan … go test -tags=integration -run TestKVM ./internal/provider/kvm/`.
- End to end: deploy with the `kvm-ssh` Secret and `kvm-home` config against a
host with no cached image, apply `config/samples/proxy_kvm.yaml`, watch the
operator log the image fetch and `virsh vol-list` show `base-debian-13.qcow2`
plus its `.sha256` marker, watch `kubectl get px -w` reach `Ready` with a LAN
IP, apply a second Proxy and confirm no second download, confirm the VM shows up in `GET /v1/proxies` and works with
`hack/` demo egress-IP check, delete the Proxy and confirm `virsh list --all`
and `virsh vol-list default` are clean.
@@ -0,0 +1,186 @@
# Plan: Trace every libvirt call in the KVM provider
**Created:** 2026-09-10 08:05
## Context
Step 7 of the KVM provider plan (live validation on `beelink`) has gone through
three fixes found only on the real host, and the current failure (guest hangs at
GRUB's "Booting" line) is being diagnosed blind: the provider logs at V(1)/V(2)
but nothing shows them. The integration test never installs a logger, so
controller-runtime prints "log.SetLogger(...) was never called" and swallows
everything; and even with a logger, the provider only logs per *operation*
(Create, Get…), not per libvirt RPC, so the sequence and timing of what was
actually sent to the hypervisor is invisible.
Goal: one log line per libvirt call at debug level (`--zap-log-level=debug`, logr
V(1) in this repo's scheme: V(1) = outcome per operation, V(2) = curated detail,
V(5) = raw wire, per the GCP verbose-logging plans), with method, host, domain or
volume, duration and the libvirt error code on failure; and those lines visible in
`make test-integration-kvm` output.
Assumption: this lands on the current `feat/kvm-provider` branch, since it exists
to finish that branch's validation, and the plan file plus execution log follow
CLAUDE.md conventions (plan committed alone first, execution summary alongside
each step). Say so if you want a separate branch instead.
## Design
- **Decorator, not call-site logging.** New `tracingAPI` implements the 17-method
`libvirtAPI` seam (`internal/provider/kvm/conn.go:26-44`), wrapping the real
client. Every method logs one V(1) line after the inner call returns. Applied in
`hostPool.call`, so every provider method, `image.go`'s cache and the
integration test's own `p.hosts.call` are traced with no per-site changes.
- **Logger passed explicitly.** `call(ctx, log, host, fn)` and `get(ctx, log, host)`
gain a `logr.Logger` parameter, matching how this package already threads `log`
into every helper. The five call sites (`kvm.go:169, 322, 458, 530`,
`kvm_integration_test.go:220`) already hold the right logger with
provider/host/name values. `call` does `log = log.WithName("libvirt")`, so trace
lines are named `kvm.libvirt`.
- **Wrap only when `log.V(1).Enabled()`.** Zero cost at info level; the
controller-runtime unfulfilled root sink reports disabled, so unit tests and
`conn_test.go`'s `hostClient{api: nil}` cases are unaffected.
- **One line per RPC at every level.** At V(2) the same line gains extra keys
(`xml`, `metadata`, `addresses`, `names`) instead of a second line. Upload and
download log offset/length/duration only, never bytes (the seed ISO carries
cloud-init). Domain and volume XML contain no secrets, so V(2) is fine for them.
- **Connection events traced too** in the pool: dial with duration, dial failure,
dead cached client redial, and both drop reasons (context cancelled, transport
error). `dialSSH`/`connectLibvirt`/`keepalive` stay untouched: changing
`hostDialer`'s signature would ripple into every test dialer, and the pool
already observes every event.
- **Rendering.** Handles by name only (`domain`, `pool`, `volume`), never UUID/Key.
`libvirt.DomainState` and `DomainInterfaceAddressesSource` have stringers, pass
them typed. Bitmask flags don't stringify, log as integers only when non-zero.
`libvirt.OptString` → first element or `""` under `uri`.
- **awaitOrCancel.** `done` runs inside fn's goroutine; on the normal path it logs
before `call` returns. On cancel, the abandoned RPC logs one late line when the
closed connection makes it fail: desirable in production, and in the integration
test only reachable in an already-failing timeout run (testr panics on a log after
the test ends; note that in the header, don't engineer around it).
## Steps
### Step 0 — Save plan
Copy this file to `docs/plans/2026-09-10-0805-kvm-libvirt-tracing.md`, commit alone.
### Step 1 — `errors.go`: shared error fields
Factor `libvirtErrorFields(err error) []any` out of `logAPIError` (`errors.go:62-72`):
returns `libvirtCode` (uint32), `libvirtCodeName` (`libvirt.ErrorNumber(code)`,
e.g. `ErrNoDomain`), `libvirtMessage`; nil for non-libvirt errors; works through
`%w` wrapping via `errors.As`. `logAPIError` keeps its message and gains
`libvirtCodeName`. Test `TestLibvirtErrorFields` (table: libvirt.Error, wrapped,
plain error) in `errors_test.go`.
### Step 2 — new `trace.go`
```go
type tracingAPI struct { inner libvirtAPI; log logr.Logger }
type tracedCall struct { t *tracingAPI; method string; start time.Time; kv []any }
func (t *tracingAPI) begin(method string, kv ...any) *tracedCall
func (c *tracedCall) detail(kv ...any) *tracedCall // appended only if V(2) enabled
func (c *tracedCall) done(err error, kv ...any) // one V(1) line
```
`done` emits `log.V(1).Info("libvirt call", ...)` with keys in order: `method`,
begin args, result kv, `duration` (rounded to µs), then on error `error` plus
`libvirtErrorFields`. Per-method keys:
| method | V(1) args | V(1) results | V(2) extra |
|---|---|---|---|
| DomainLookupByName | domain | | |
| DomainDefineXML | xmlBytes | domain | xml |
| DomainCreate, DomainDestroy | domain | | |
| DomainUndefineFlags | domain, flags | | |
| DomainGetState | domain | state, reason | |
| DomainGetMetadata | domain, type, uri | metadataBytes | metadata |
| DomainInterfaceAddresses | domain, source | interfaces (count) | addresses |
| ConnectListAllDomains | | count | names |
| StoragePoolLookupByName, StoragePoolRefresh | pool | | |
| StorageVolLookupByName | pool, volume | | |
| StorageVolGetPath | pool, volume | path | |
| StorageVolCreateXML | pool, xmlBytes | volume | xml |
| StorageVolUpload, StorageVolDownload | pool, volume, offset, length | | (never content) |
| StorageVolDelete | pool, volume | | |
Return values pass through untouched.
### Step 3 — `conn.go`: pool logging and wrapping
- `get(ctx, log, host)`: dead cached client → `"libvirt connection lost, redialing"`;
time the dial; `"libvirt host connected"` with `address`, `duration`; failure →
`"libvirt host dial failed"` with `address`, `duration`, `error`.
- `call(ctx, log, host, fn)`: `log = log.WithName("libvirt")`; wrap when
`log.V(1).Enabled()`; abort closure logs `"libvirt connection dropped"`
`reason="context cancelled"` before `drop`; transport-error branch logs the same
message with `reason="transport error"`. Update `call`'s doc comment.
### Step 4 — call sites
`kvm.go` Create/Get/Delete pass `log`, ListByTag passes `hlog`;
`kvm_integration_test.go:220` passes `p.logger(ctx).WithValues("host", host.Name)`;
`conn_test.go` (12 `p.call(` sites) gets `var discard = logr.Discard()`.
### Step 5 — integration test output
- `integrationLogger(t)`: `KVM_TEST_LOG_LEVEL` (default `1`, `strconv.Atoi`,
`t.Fatalf` on garbage) → `testr.NewWithOptions(t, testr.Options{LogTimestamp:
true, Verbosity: n})` (`github.com/go-logr/logr/testr`, present in logr v1.4.3,
no go.mod change); `logf.SetLogger(log)` once so nothing falls back to the
unfulfilled root.
- `TestKVM_lifecycle`: `ctx = logr.NewContext(ctx, log)` after `WithTimeout`; the
cleanup ctx gets the same logger.
- Header comment: document `KVM_TEST_LOG_LEVEL` (1 = one line per RPC, 2 adds
XML/metadata/addresses, 0 = only `t.Log`) and the late-line-after-timeout caveat.
### Step 6 — unit tests
- `kvm_test.go`: `captureContext(verbosity)` like `gcp_test.go:277-286` but with a
mutex around the slice; `TestLogging_verbosityTiers` (mirror `gcp_test.go:322-399`):
V0 → no lines; V1 → `"msg"="libvirt call"`, `"method"="DomainDefineXML"`,
`"method"="StorageVolUpload"`, `"msg"="libvirt host connected"`, provider/host/name
values, `"libvirtCodeName"="ErrNoDomain"` from the first lookup, no `"xml"=`;
V2 → `"xml"="<domain` and `"metadata"=`; a cloud-init sentinel absent at every tier.
- `trace_test.go`: `TestTracingAPI_logsEveryMethod` (table of 17 calls over a
`readyHost()` fake, exactly one line each, plus
`reflect.TypeOf((*libvirtAPI)(nil)).Elem().NumMethod() == len(cases)` so a new
interface method without tracing fails); `TestTracingAPI_passesResultsThrough`;
`TestTracingAPI_errorFields` (libvirt error → code/name/message; `io.EOF` →
`"error"="EOF"`, no code); `TestTracingAPI_detailOnlyAtV2`;
`TestTracingAPI_uploadLogsSizeNotContent`.
- `conn_test.go`: `fakeDialer` gains an `api` field; `TestHostPool_call_tracesAtV1`
(V1 → connected + method lines; V0 → no lines and fn receives the bare
`*fakeAPI`); `TestHostPool_call_logsDropReason` (transport error, cancel).
### Step 7 — docs, execution log, CHANGELOG
- `docs/kvm-provider.md`: `## Logging` section before "Known gaps":
`--zap-log-level=debug` = one `libvirt call` line per RPC plus connect/drop lines;
`--zap-log-level=2` adds XML, metadata, addresses; cloud-init never logged; no
V(5) wire tier because go-libvirt exposes no hook. Mention `KVM_TEST_LOG_LEVEL`
in its Testing paragraph.
- `docs/testing.md`: `KVM_TEST_LOG_LEVEL` in the optional vars; `-v` now shows every
RPC; extend the `kvm_test.go` table row.
- `docs/plans-executions/2026-09-10-0805-kvm-libvirt-tracing.md` with the checklist
and per-step entries; also a short cross-reference line in the KVM provider
execution log's Step 7 section.
- `CHANGELOG.md` entry once the user confirms the live run shows the trace.
## Verification
```bash
go build ./... && go vet ./... && go vet -tags=integration ./internal/provider/kvm/
go test -race -count=1 ./internal/provider/kvm/
go test -short ./...
```
Live, with the usual env: `make test-integration-kvm`. Expect, interleaved with
`t.Log` lines and attributed to `trace.go:<line>`, lines like
```
kvm.libvirt: "level"=1 "msg"="libvirt call" "provider"="kvm-integration" "host"="kvm-test" "name"="proxy-…" "method"="DomainLookupByName" "domain"="proxy-…" "duration"="2.1ms" "error"="…" "libvirtCode"=42 "libvirtCodeName"="ErrNoDomain" "libvirtMessage"="…"
```
plus one `libvirt host connected` line with `duration`, a `StorageVolUpload` line
whose duration is the seed upload, and no "log.SetLogger(...) was never called"
text. `KVM_TEST_LOG_LEVEL=2` shows `"xml"="<domain` and `"addresses"=`;
`KVM_TEST_LOG_LEVEL=0` shows only `t.Log` output. No line at any level contains
`#cloud-config`.
## Critical files
- `internal/provider/kvm/conn.go`, `trace.go` (new), `trace_test.go` (new)
- `internal/provider/kvm/errors.go`, `errors_test.go`
- `internal/provider/kvm/kvm.go`, `kvm_test.go`, `conn_test.go`
- `internal/provider/kvm/kvm_integration_test.go`
- `docs/kvm-provider.md`, `docs/testing.md`
@@ -0,0 +1,195 @@
# Plan: Fix the five code-review findings on `feat/kvm-provider`
**Created:** 2026-09-10 22:22 UTC
## Context
PR #5 (`feat/kvm-provider` → `main`, 19 commits) is open and awaiting merge. A
`/code-review high` pass over the branch returned five confirmed findings: two that can
pin a Proxy's finalizer forever, one startup crash-loop, and two in the guest-agent merge
committed earlier today. All five are fixed on the same branch so the PR updates in place,
then re-validated against the real hypervisor before merging.
Four decisions, made by the user during planning:
1. **Wedged deletes** — attempt `Delete` even when `Get` fails permanently, make an
unconfigured KVM host a no-op delete, and add an annotation that abandons cleanup for
whatever still cannot finish.
2. **Missing `kvm-ssh` Secret** — read the SSH material lazily, so only KVM Proxies fail.
3. **Guest agent** — stop rewriting the user's cloud-config. Ship the bootstrap as a
vendor-data shell script instead.
4. Validation is unit tests, the integration test against beelink, and the kind e2e. The
uncommitted dev-only files stay exactly as they are and out of the PR.
## Why the guest-agent fix is a rewrite, not a patch
Both cloud-init findings come from the same root cause: this operator edits the user's
document. Reading cloud-init 26.1's sources settles that it never has to.
Vendor-data is consumed by its **own** handler chain with separate script and config paths
(`stages.py:_default_vendordata_handlers`). A vendor-data **shell script** is therefore
written to the vendor script directory and run by `cc_scripts_vendor`, never passing
through the config merger that drops colliding keys. On the final-stage module list it
runs after `package_update_upgrade_install` and before the user's own `runcmd`, so the
agent comes up early regardless of what the user's config does.
That also closes the door the previous approach left open. The merge cannot be rescued
from inside the documents: `merge_how` in the user-data governs only the first source, and
`merge_how` in vendor-data is popped and discarded by `dict_extract_mergers` before the
document is ever written. There is no supported way to make vendor-data cloud-config keys
survive a user-data that defines the same key.
Consequences: user-data reaches the seed byte for byte in any format, so Jinja templates,
MIME archives, gzip payloads and shell scripts stop being special cases (finding 4), and
nothing re-serialises YAML, so unquoted scalars keep their type (finding 5). The cost is
that the script picks the package manager itself where cloud-init's `packages` key was
distro-agnostic.
## Why the delete fix is shaped this way
KVM is the only provider that resolves a providerID segment against operator config. GCP
and Kubernetes let the backend answer "not found", so retiring a location yields
`ErrNotFound` and the finalizer drops. `Provider.host()`
([kvm.go:118-139](internal/provider/kvm/kvm.go#L118-L139)) returns `ErrPermanent`, and
`reconcileDelete` ([proxy_controller.go:281-291](internal/controller/proxy_controller.go#L281-L291))
drops the finalizer only on `ErrNotFound`. `deletionFailure`
([:327-336](internal/controller/proxy_controller.go#L327-L336)) says in its own comment
that it retries "until an operator intervenes", and no intervention mechanism exists. The
annotation is that mechanism, and it also rescues the unconfigured-provider wedge at
[:276-279](internal/controller/proxy_controller.go#L276-L279).
`Get` keeps returning `ErrPermanent` for an unknown host deliberately. Turning it into
`ErrNotFound` would make a typo in the host list look like a missing VM on the live path,
and the reconciler would recreate every affected Proxy elsewhere while the originals keep
running where garbage collection cannot see them.
## Steps
One commit per step on `feat/kvm-provider`, each ending with
`Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`.
### Step 0 — Save this plan into the repo
Copy to `docs/plans/2026-09-10-2222-kvm-review-fixes.md` with the same header timestamp and
commit it alone, before any code change. Start
`docs/plans-executions/2026-09-10-2222-kvm-review-fixes.md` with the status checklist when
Step 1 lands.
### Step 1 — Unwedge deletion (finding 1)
- `api/v1alpha1/proxy_types.go`: add `AnnotationSkipCleanup = "crawl.example.com/skip-cleanup"`
beside `AnnotationSpecHash` ([:59-63](api/v1alpha1/proxy_types.go#L59-L63)), documented as
abandoning the backend instance.
- `internal/controller/proxy_controller.go`, `reconcileDelete`: honour the annotation after
the empty-providerID branch and **before** the provider lookup, so it also covers a
provider that is no longer configured. Log the abandoned providerID at Info.
`ProxyReconciler` has no `EventRecorder`; do not add one for this.
- Same function: replace the two `if`s after `Get` with a switch. On
`provider.Class(err) == provider.ErrPermanent`, call `Delete` anyway, since it is
idempotent, and remove the finalizer when it succeeds rather than requeueing into a `Get`
that can never converge. On failure, `deletionFailure(errors.Join(getErr, delErr))`.
Guard on `err != nil`, since `Class(nil)` is nil.
- `internal/provider/kvm/kvm.go`, `Delete`: an unconfigured host segment means there is
nothing this operator can clean, so log it and return success, mirroring `removeVolumes`
([kvm.go:506-514](internal/provider/kvm/kvm.go#L506-L514)) for a missing pool. `Get` is
unchanged.
- Tests: extend the deletion table in `internal/controller/reconcile_test.go`
([:357-393](internal/controller/reconcile_test.go#L357-L393)) with permanent-Get plus
succeeding Delete, permanent-Get plus failing Delete, the annotation with the provider
never called, and the annotation on an unconfigured provider. In `kvm_test.go`, the
`"host not configured"` case at [:737](internal/provider/kvm/kvm_test.go#L737) flips from
`ErrPermanent` to success; add a named test beside `TestDelete_missingPoolIsNotAnError`
([:715](internal/provider/kvm/kvm_test.go#L715)).
- Docs: the annotation in `README.md` (~line 163) and `docs/architecture.md` (~line 487);
a *Known gaps* entry in `docs/kvm-provider.md` saying a domain on a host removed from
config can no longer be cleaned up by the operator, the Proxy still finishes deleting,
and the VM must be removed by hand because garbage collection only sweeps configured
hosts.
### Step 2 — Validate names that become identifiers (finding 2)
- `internal/provider/config_kvm.go`: add a name regexp beside `sha256Hex`
([:6](internal/provider/config_kvm.go#L6)) allowing letters, digits, dot, dash and
underscore. Apply it to `hosts[].name` after the existing empty check
([:129-131](internal/provider/config_kvm.go#L129-L131)) and to each `images` map key,
which becomes the cached volume name `base-<key>.<format>`
([image.go:49-55](internal/provider/kvm/image.go#L49-L55)) and half of the image-cache
lock key ([image.go:96](internal/provider/kvm/image.go#L96)). `machineTypes` keys are only
compared, never embedded, so leave them.
- Keep the file's first-error-wins style and `fmt.Sprintf` field paths. The host message
should name the consequence: the value becomes part of `hosts/<name>/domains/<vm>`, which
`parseProviderID` splits on `/` ([id.go:11-21](internal/provider/kvm/id.go#L11-L21)).
- Tests: two entries in the `TestLoadConfig_kvmInvalid` table
([config_kvm_test.go:76](internal/provider/config_kvm_test.go#L76)), existing
name/yaml/wantErrSub shape.
### Step 3 — Read the SSH material lazily (finding 3)
- `internal/provider/kvm/conn.go`: keep `newSSHAuth`, and add a lazy dialer that builds it
on first dial and caches it once it succeeds, so a `kvm-ssh` Secret created after the
manager starts heals on the next reconcile with no restart.
- `internal/provider/kvm/kvm.go`, `New`: stop calling `newSSHAuth`; pass the lazy dialer.
Use the context parameter it currently ignores to log a warning when either file is
unreadable at startup, keeping the diagnostic without the exit.
- Error classes: a missing or unreadable file stays unclassified, so `Class` defaults it to
transient and the Proxy retries until the Secret appears; an unparseable key or malformed
known_hosts wraps `ErrPermanent`. Distinguish with `errors.Is(err, fs.ErrNotExist)`.
- Fix the two doc comments promising startup failure
([kvm.go:79-81](internal/provider/kvm/kvm.go#L79-L81),
[conn.go:182-185](internal/provider/kvm/conn.go#L182-L185)) and the matching claim in
`docs/kvm-provider.md` (~lines 107-109). `DISCOVERY_TOKEN` already sets the house
convention for an optional mount: degrade with a loud warning.
- Tests in `conn_test.go`, which already writes key files: `New` succeeds with no key
present, the first dial fails transiently, a dial after the file appears succeeds, and a
corrupt key is permanent.
### Step 4 — Guest agent via vendor-data script (findings 4 and 5)
- `internal/provider/kvm/seed.go`: delete `withGuestAgent`, `isCloudConfig`, `appendUnique`
and the `sigs.k8s.io/yaml` import. User-data goes onto the seed exactly as the Proxy
supplied it.
- Add the bootstrap as a `#!/bin/sh` vendor-data script: skip when the agent is already
present, otherwise install `qemu-guest-agent` through whichever of `apt-get`, `dnf`,
`yum`, `zypper` or `apk` exists, then `systemctl enable --now`. Run `apt-get update`
before installing, since nothing else guarantees a fresh index when the user's config has
no `packages` key. Keep it idempotent and let it log to stdout, which lands in
`cloud-init-output.log`.
- When the provider config also sets `vendorData`, wrap both into a MIME multipart
vendor-data: the user's document as one part, the script as another. Cloud-init detects
multipart by finding `mime-version:` in the first 4096 bytes, so the header must lead.
- `internal/provider/config_kvm.go`: add `installGuestAgent`, defaulting to on. Off means
the seed carries only the user's own vendor-data and IP discovery falls back to the host
ARP table. Nothing fails permanently any more, so the error path in `writeSeed`
([kvm.go:252-255](internal/provider/kvm/kvm.go#L252-L255)) narrows to MIME assembly.
- Tests in `seed_test.go`: user-data byte-identical for cloud-config, Jinja, MIME, gzip and
script inputs; the script present in vendor-data; the opt-out; the multipart case
including the `mime-version:` position; keep the existing ISO tests.
- Docs: rewrite the *What lands on the host* passage added earlier today, the `vendorData`
row and a new `installGuestAgent` row in the config table, and the ARP note in
*Known gaps*. Cite the cloud-init behaviour that forced the change.
### Step 5 — Validate, record, push
- `gofmt -l internal/`, `go vet ./...`, `go test -short ./...`.
- `make test-integration-kvm` against beelink, environment as recorded in the KVM execution
log.
- Rebuild the image, `kind load`, restart the manager, delete and recreate both Proxies,
curl a test URL through each.
- `CHANGELOG.md` entry at the top with a real `date` timestamp, noting it supersedes the
merge approach described in today's earlier entry. Add a Run 8 section to
`docs/plans-executions/2026-09-04-1404-kvm-provider.md` plus this plan's own execution
file. Push with the token helper; PR #5 updates in place.
## Verification
- Unit: the controller cases prove a permanent `Get` no longer pins the finalizer and that
the annotation short-circuits before the provider is touched. `TestDelete_*` proves an
unconfigured host is a clean delete while `TestGet_*` stays permanent, which is what keeps
the live path from recreating VMs.
- Seed: assert the uploaded user-data equals the Proxy's `cloudInit` byte for byte, which is
the property the old merge could not offer.
- Integration on beelink: Create, IP via the guest agent, ListByTag, Delete. In the guest,
`cloud-init status --long` clean and `systemctl is-active qemu-guest-agent` active.
- In-cluster: both Proxies reach Ready with an IP and return 204 through squid. Their
cloud-config defines its own `packages`, which is the exact case that failed in Run 7.
- Negative check in kind: rename the `kvm-ssh` Secret away and restart. The pod must stay
up, the Kubernetes Proxies must stay Ready, and only the KVM Proxies degrade. Restore the
Secret and confirm they recover without a restart.
## Out of scope
- The two items the review noted but did not report: deleting a cached base image while
running VMs back onto it, and an ad-hoc image URL always being treated as qcow2. Both are
documented known gaps and neither is a regression from this branch.
- The uncommitted dev-only files, by the user's decision.
@@ -0,0 +1,354 @@
# Plan: Provider health checks — see whether the cluster can reach each provider
**Created:** 2026-09-11 15:26 UTC
## Context
Today the operator can't say whether a provider's backend is reachable. The `Provider`
interface ([provider.go:89-109](internal/provider/provider.go#L89-L109)) only has
Create/Get/Delete/ListByTag, and `/healthz` and `/readyz` are bare `healthz.Ping`. A
broken KVM path only shows up later: as a Proxy stuck in Provisioning, or as an orphan-GC
sweep logging an error. Several failures stay invisible until a Proxy needs that host:
- a missing `kvm-ssh` Secret;
- a rotated host key;
- a Tailscale route that is down;
- a storage pool that is inactive or full.
The goal is a periodic sanity check of every configured provider from inside the cluster,
with results you can see through `kubectl` and Prometheus.
Decisions made by the user during planning:
1. **Surface:** a new operator-owned, read-only, cluster-scoped **`ProviderHealth`** CRD,
with one object per configured provider, a `Ready` condition and per-target detail.
Also Prometheus gauges, and a log line whenever a target goes up or down. No
EventRecorder, matching the earlier decision.
2. **Scope:** real checks for all three providers.
- **kvm:** checked per host.
- **kubernetes:** confirms the API server and RBAC work.
- **gcp:** confirms credentials and the API work.
3. **KVM depth:** reachability plus the storage pool.
- Over the existing pooled SSH+libvirt connection, call the libvirt version and
hostname RPCs.
- Confirm the configured pool is running and report its free space.
- No bridge check.
Not affected: the Proxy reconcile behaviour, and the manager's `/healthz` and `/readyz`.
A dead hypervisor must never make the manager unready.
## Design
### Contract: `internal/provider/check.go`
```go
type TargetStatus struct {
Name, Address string
Ready bool
Reason, Message string
Latency time.Duration
Details map[string]string
}
// Check never errors; each target carries its own verdict. Must return promptly once ctx ends.
type Checker interface{ Check(ctx context.Context) []TargetStatus }
```
- **Shared reasons:** `Reachable`, `Timeout`, `Unreachable`, `Unauthorized`, `Forbidden`,
`APIError`.
- **Compile-time checks:** each provider's `check.go` has `var _ provider.Checker = (*Provider)(nil)`.
- **Bypass the wrappers.** `cmd/main.go:197-201` wraps every provider in
`WithTracing(WithMetrics(...))`, so the Checkers are collected from the **raw** providers
map, before that loop. As a result, checks don't inflate `provider_requests_total`.
### The checks
**kvm** (`internal/provider/kvm/check.go`)
- **Concurrency:** one goroutine per `cfg.Hosts` entry (`wg.Go`), writing to `out[i]` so
results stay in config order.
- **Inside `p.hosts.call`:** `ConnectGetLibVersion`, `ConnectGetHostname`,
`StoragePoolLookupByName(host.Pool)`, then `StoragePoolGetInfo`.
- **Rules for `fn`:**
- It returns only raw RPC errors. `call` drops the shared connection on any other error
([conn.go:160](internal/provider/kvm/conn.go#L160)), so "pool not running" is decided
after `call` returns, never returned from `fn`.
- It fills a captured result struct only after all four calls succeed. That struct is
read only when `call` returned nil, which avoids a race with an abandoned goroutine.
- **Details:** `hostname`, `libvirtVersion` (decoded as major.minor.patch), `pool`,
`poolState`, `poolCapacity` and `poolAvailable` (in GiB).
- **New RPCs:** add the three to `libvirtAPI` ([conn.go:29-47](internal/provider/kvm/conn.go#L29-L47)),
`tracingAPI` (`trace.go`) and `fakeAPI` (`fake_test.go`). `fakeAPI` gets a hostname, a
fixed version, and a per-pool state map that defaults to Running.
- **Telling failure stages apart without matching strings:** add
`type dialError struct{ stage dialStage; err error }` in `conn.go`, with `Error()`
returning the inner message unchanged and `Unwrap()`. It wraps at four places:
- `lazySSH.dial`, when loading the key material fails (`ssh-material`)
- `dialSSH`, at the dial step (`tcp`)
- `dialSSH`, at the handshake (`ssh-handshake`)
- `connectLibvirt` (`libvirt-connect`)
Because `Unwrap` is kept, `classify` and `isClassified` still see `ErrPermanent` and
`fs.ErrNotExist`, so reconcile behaviour doesn't change.
- **`checkReason(err)`, tested in order:**
| Condition | Reason |
|---|---|
| stage ssh-material | `SSHMaterialUnavailable` |
| stage tcp | `Unreachable` |
| stage ssh-handshake | `SSHHandshakeFailed` |
| stage libvirt-connect | `LibvirtConnectFailed` |
| ctx deadline or cancel | `Timeout` |
| `ErrNoStoragePool` | `PoolNotFound` |
| any other libvirt error | `LibvirtError` |
| anything else | `Unreachable` |
| success, pool not running | `PoolNotActive` |
| success | `Reachable` |
- **Accepted behaviour:**
- A check that hits its timeout drops the shared connection. That already happens for
any cancelled reconcile context, and it only fires when the host isn't answering.
- The leader now keeps one SSH connection per host open from startup, with keepalives
every 15s. Before, the connection only opened with the first KVM reconcile.
- No forced periodic redial. `lazySSH` caches the key material forever, so a redial
would not pick up a rotated Secret anyway; that gap goes into the docs under
"Known gaps".
**kubernetes** (`internal/provider/kubernetes/check.go`)
- **Call:** `List` pods with the managed-label selector and `client.Limit(1)`,
cluster-wide. It uses the same uncached client, verb and RBAC as `ListByTag` and GC.
There is no namespace setting in the kubernetes config.
- **Target:** one target named `apiserver`, with the address from a new `apiHost` field
set in `NewWithTransportWrapper`.
- **Reasons:**
- `Timeout`: the context ended, or the error is `IsTimeout`/`IsServerTimeout`.
- `Unauthorized`, `Forbidden`.
- `APIError`: any other API status.
- `Unreachable`: anything else.
**gcp** (`internal/provider/gcp/check.go`, plus a seam change in `gcp.go`)
- **Why a new seam method:** the existing `AggregatedList` seam reads every page
([gcp.go:63-78](internal/provider/gcp/gcp.go#L63-L78)), and MaxResults is only the page
size. So add `Probe(ctx, *computepb.AggregatedListInstancesRequest) error`, which makes
one `it.Next()` call and treats `iterator.Done` as nil.
- **Request:** `Project`, `MaxResults: 1`, `ReturnPartialSuccess: true`, and the same
managed-label filter `ListByTag` uses.
- **Target:** one per project, `Name: cfg.Project`, `Address: compute.googleapis.com`.
GCP config has no zones, and reading them from Proxy placements would tie the check to
the Proxy cache.
- **Reasons:**
- `Timeout`: the context ended.
- For `*googleapi.Error`: 401 is `Unauthorized`, 403 is `Forbidden` (this also covers a
disabled API), anything else is `APIError`.
- `Unreachable`: anything else, including a failure to get a token.
### The `ProviderHealth` CRD: `api/v1alpha1/providerhealth_types.go`
- **Written by hand**, since kubebuilder isn't installed; `PROJECT` is edited by hand too.
- **Scope and markers:** cluster-scoped, `shortName=ph`, status subresource, no spec.
- **Scheme registration:** add the type to the existing `AddKnownTypes` call in
[proxy_types.go:362](api/v1alpha1/proxy_types.go#L362), not a second `init()`.
- **Status:**
- `type`
- `conditions` (`Ready`; `ConditionReady` constant)
- `targets[]`, as a list-map keyed by `name`, each with: `name`, `address`, `ready`,
`reason`, `message` (at most 1024 characters), `latencyMillis`,
`lastTransitionTime`, `details`
- `targetsReady` (for example `"1/2"`, used by a printer column)
- `lastCheckTime`
- **Printer columns:** Type, Ready, Targets, Last Check, Age.
- **Viewer role:** `config/rbac/providerhealth_viewer_role.yaml`. No editor role, since the
operator owns the object.
### Monitor: `internal/providerhealth`
- **Fields:** `Monitor{Client, Reader, Targets []Target{Name, Type, Checker}, Interval, Timeout}`.
- **Scheduling:**
- `NeedLeaderElection() true`.
- Checks once immediately, then on a ticker. Copy the ticker loop from
[gc.go:54-80](internal/gc/gc.go#L54-L80).
- **Each cycle:**
- Runs every provider concurrently under `context.WithTimeout(Timeout)`, inside one
`tracing.Start` span.
- Stores the snapshot under a mutex; `Snapshot()` returns a deep copy.
- Logs changes. Going down logs `WARNING: provider target not ready` with provider,
target, address, reason and message. Coming back logs `Provider target ready`. A
target that is ready on first sight logs at V(1) only.
- **Writes:**
- Get through `mgr.GetAPIReader()`, not the cache. That avoids a cluster-wide informer
and the watch permission it would need, whatever `--proxy-namespace` is set to.
- Create the object if it is missing, labelled
`app.kubernetes.io/managed-by=egress-proxies-operator`.
- Then `Status().Patch(MergeFrom(base))` when the status changed, following
[status.go:114-121](internal/controller/status.go#L114-L121).
- This means one write per provider per interval, because `lastCheckTime` moves each
time. That's acceptable given the 10s minimum interval.
- **`buildStatus(prev, result, now)` is a pure function:**
- A target keeps its `lastTransitionTime` unless `ready` flipped.
- The `Ready` condition is set through `apimeta.SetStatusCondition`:
- `True/AllTargetsReady` when every target is ready
- `False/TargetsNotReady` otherwise, with the failing targets in the message
- `Unknown/ChecksNotImplemented` when the provider has no Checker
- `Unknown/NoTargets` when there are zero targets
- **Cleanup, once at `Start`:** delete labelled objects whose name isn't in the config.
The config is loaded once per process, and unlabelled objects are never touched.
- **Interval 0 (disabled):** run the cleanup with nothing kept, then return nil, so no
object is left showing a stale `Ready=True`.
- **Invalid provider names:** provider names aren't validated as DNS-1123 names. For an
invalid name, warn once and skip the object writes; metrics are still emitted.
- **`ValidateSchedule(interval, timeout)`** is called in `main.go` before the manager
starts. Interval must be 0 or at least 10s; timeout must be in (0, interval].
- **RBAC markers on `Monitor`:**
- `providerhealths`: get, list, create, delete
- `providerhealths/status`: get, patch
- `make manifests` regenerates `config/rbac/role.yaml`.
### Metrics and wiring
- **New function:** `metrics.RegisterProviderTargets(reg, read func() []ProviderTarget) error`.
`Register`'s signature is left alone.
- **Gauges:** a scrape-time collector, in the style of `constCollector`, reads the Monitor
snapshot and emits three gauges labelled `{provider,target}`:
- `proxy_operator_provider_target_up`
- `proxy_operator_provider_check_duration_seconds`
- `proxy_operator_provider_check_timestamp_seconds`
- **Conversion closure:** the one that turns the Monitor snapshot into gauges lives in
`main.go`, next to `proxyPhaseCounts`. So `providerhealth` doesn't import `metrics`.
- **No pool-bytes gauge:** that value is in status, and node-exporter is the right source
for disk alerts.
- **`cmd/main.go`:**
- New flags `--provider-check-interval` (1m) and `--provider-check-timeout` (15s).
- Build the `Targets` from `cfg.Providers` in config order, with
`c, _ := raw[pc.Name].(provider.Checker)`.
- `mgr.Add(&providerhealth.Monitor{Client: mgr.GetClient(), Reader: mgr.GetAPIReader(), ...})`,
then register the collector.
## Steps (one commit each, execution-log entry per step)
**Step 0 — Save the plan.**
- Create branch `feat/provider-health` off `main`.
- Copy this plan to `docs/plans/2026-09-11-1526-provider-health-checks.md` and commit it
alone.
- The uncommitted `.claude/settings.json` change stays out of every commit.
**Step 1 — ProviderHealth API and CRD.**
- `api/v1alpha1/providerhealth_types.go`
- `zz_generated.deepcopy.go`, from `make generate`
- `config/crd/bases/crawl.example.com_providerhealths.yaml`, from `make manifests`
- the CRD and RBAC `kustomization.yaml` files, plus the viewer role
- `PROJECT`
**Step 2 — Contract plus the kubernetes and GCP checks.**
- `internal/provider/check.go`
- `internal/provider/kubernetes/{kubernetes.go,check.go,check_test.go}`
- `internal/provider/gcp/{gcp.go,check.go,check_test.go,gcp_test.go}`
**Step 3 — KVM check.**
- `internal/provider/kvm/{conn.go,trace.go,check.go,check_test.go,fake_test.go,conn_test.go,trace_test.go,kvm_integration_test.go}`
**Step 4 — Monitor.**
- `internal/providerhealth/{monitor.go,status.go,monitor_test.go,status_test.go,envtest_test.go}`
- `config/rbac/role.yaml`, from `make manifests`
**Step 5 — Metrics and wiring.**
- `internal/metrics/{metrics.go,metrics_test.go}`
- `cmd/main.go`
- `config/manager/manager.yaml`: a comment for the new flags
- `test/e2e/e2e_test.go`
**Step 6 — Docs.**
- `docs/architecture.md`: a component row plus a provider-health section
- `docs/kvm-provider.md`: the reason table, the shared-connection behaviour, and the
Secret-rotation gap under "Known gaps"
- `docs/testing.md`: the new tests and the commands
- `CLAUDE.md`: its Architecture and Gotchas sections still say "empty scaffold"; point
them at `docs/architecture.md`
**Step 7 — Validate, record, push, open the PR.**
- Run `go vet ./...`, `go test -short ./...`, `make test` and `make test-e2e`.
- Hand the user the KVM commands below, and record their results in the execution log.
- Add a CHANGELOG entry once the user confirms.
- Push, using the token-file credential helper from memory.
- Open the PR against `main` through the Gitea API (`tea` isn't installed).
- Don't merge.
## Tests
**Provider checks** (table-driven, `Test<Func>_<scenario>`):
- kubernetes:
- reachable
- list options: `Limit==1`, the label selector, no namespace
- failure reasons: forbidden, unauthorized, timeout, cancelled, internal, and a
network `*url.Error`
- gcp:
- reachable, asserting the request fields
- failure reasons: 401, 403, 500, deadline, network
- `TestRealInstances_Probe_fetchesOnePage` against `httptest`: exactly one request with
`maxResults=1`, even though the page has a `nextPageToken`
- kvm (using `hostFakes`/`newTestProvider` from `kvm_test.go`):
- reachable, with details filled in
- per-host verdicts, order preserved
- pool inactive, pool missing
- hosts checked concurrently (a sequential check would deadlock)
- a pool verdict keeps the connection (the dial count stays 1)
- a `checkReason` table
- `TestSSHAuth_dial_stages`, using the in-process SSH server in `conn_test.go`
- three new rows in the tracing table
**Monitor:**
- Setup: the fake client is built with a status subresource and a RESTMapper that
registers ProviderHealth as cluster-scoped.
- Tests:
- `buildStatus` table
- objects are created with the label, and existing ones are patched
- the timeout bounds a checker that blocks
- a nil checker gives Unknown
- only transitions are logged (`funcr` logger)
- cleanup deletes only labelled objects that aren't configured
- the disabled path cleans up and returns
- an invalid name skips the writes
- `ValidateSchedule` table
- An envtest round-trip, skipped under `-short`, which proves the generated schema is
accepted by a real apiserver.
**Metrics:** `TestRegisterProviderTargets_scrape`, using a fresh registry and
`GatherAndCompare`; an empty snapshot produces no series.
**e2e (kind):** `providerhealth kubernetes` reaches `Ready=True`, which proves the
generated RBAC works end to end.
## Verification
**Automated (Claude runs):** `go vet ./...`, `go test -short ./...`, `make test` (envtest
with `-race`) and `make test-e2e`.
**Against the real hypervisor (handed to the user, not run by Claude)**, with beelink as
the host:
```bash
KVM_TEST_HOST=beelink.tail8c69c.ts.net:22 KVM_TEST_USER=novakj KVM_TEST_SSH_KEY=~/.ssh/id_ed25519 \
KVM_TEST_KNOWN_HOSTS=~/.ssh/known_hosts KVM_TEST_BRIDGE=<bridge> KVM_TEST_IMAGE_URL=<image-url> \
go test -tags=integration -count=1 -v -run TestKVMCheck ./internal/provider/kvm/
make install && go run ./cmd/main.go --providers-config <scratch copy of kvm-home config, local key paths> \
--metrics-bind-address :8080 --metrics-secure=false --provider-check-interval=15s
kubectl get ph -w
kubectl get ph kvm-home -o yaml
curl -s localhost:8080/metrics | grep -E 'proxy_operator_provider_(target_up|check_)'
```
Each negative case is a one-line edit to the scratch config; nothing changes on the host.
| Edit | Expected reason |
|---|---|
| `pool: nope` | `PoolNotFound` |
| `address: …:2` | `Unreachable` |
| `knownHostsFile: /dev/null` or `user: nobody` | `SSHHandshakeFailed` |
| `sshKeyFile: /nonexistent` | `SSHMaterialUnavailable` |
| `socket: /nonexistent` | `LibvirtConnectFailed` |
## Out of scope
- Gating Proxy creates on a host that is known to be down.
- Tying `/readyz` to provider health.
- Bridge checks.
- SelfSubjectAccessReview checks of create/delete rights.
- Hot-reloading the providers config.
- Picking up a rotated `kvm-ssh` Secret without a restart. This is an existing gap; it
gets documented.
@@ -0,0 +1,119 @@
# Plan: Provider blueprint (docs/providers.md)
**Created:** 2026-09-16 08:41
## Context
The provider contract is spread across `docs/prompts/__initial-prompt.md` §5,
the comments in `internal/provider/{provider,errors,check}.go`, `docs/architecture.md`
§5 and §11, `docs/kvm-provider.md`, and plan Step 2/3/8. Many rules exist only in
code: providerID must be self-contained, UID/CreatedAt must be read back, Create
must survive a crash partway through, calls must stop when ctx is cancelled,
decorators are wired in `cmd/main.go`, and so on. With "~5 providers soon", the
next provider author needs one blueprint. It should say what a provider MUST,
SHOULD or MAY do, show which features each existing provider (kubernetes, gcp,
kvm) actually implements (with file:line evidence), give a checklist for adding
one, and list the gaps.
Decisions (user): store in `docs/providers.md`; scope = current state plus a
separate "Gaps and proposed features" section (nothing implemented); wait for
`feat/provider-health` to merge, then branch off `main`.
## Step 0 — Save the plan; wait for the merge
- Right after ExitPlanMode, copy this plan to `docs/plans/2026-09-16-0841-provider-blueprint.md`
and commit it on its own. The current branch is `feat/provider-health`, so the
plan reaches `main` with that merge. CLAUDE.md requires the plan commit to
come before any branching.
- Stop and wait for the user to confirm that `feat/provider-health` is merged.
Then `git switch main && git pull`, and `git switch -c feat/provider-blueprint`.
## Step 1 — Collect evidence (read-only, in parallel)
Run 3 Explore agents at the same time, one each for `internal/provider/kubernetes`,
`internal/provider/gcp` and `internal/provider/kvm`, including their tests. Each
answers the **same questionnaire**, citing `file:line` for every answer and writing
"not implemented" explicitly when that's the case:
1. **Identity**: providerID format and parsing; what it carries (zone, host, namespace).
2. **Create**: how it stays idempotent (name lookup, 409, DomainLookupByName);
whether it returns before the VM is up or waits; what happens when an earlier
attempt crashed partway; which Placement fields are used or ignored; how
cloud-init is delivered; how the port is used; how GC labels/UID are attached.
3. **Get**: native state → InstanceState mapping; IP discovery rules; UID and
CreatedAt readback; how NotFound is detected.
4. **Delete**: idempotency; what gets removed (disks, volumes, seeds); returns
before or after the resource is gone; unknown/malformed providerID behaviour.
5. **ListByTag**: scope (namespaces, zones, hosts); partial-failure behaviour; how
managed resources are recognised.
6. **Error classification**: table of backend error → NotFound/Quota/Transient/
Permanent; whether quota is detected at all.
7. **Health check** (`Checker`): targets, provider-specific Reasons, Details keys.
8. **Config**: YAML section, defaults, validation (`internal/provider/config*.go`),
credentials/Secrets, which settings are read once per process.
9. **Connections and cancellation**: pooling, reconnects, timeouts, ctx honouring.
10. **Observability**: log keys and verbosity levels, spans (`kvm/trace.go`, gcp
`wirelog.go`), anything beyond the shared metrics/tracing decorators.
11. **Testing**: fakes, envtest/kind, `//go:build integration` tests, what is
not covered.
12. **Known gaps**: TODOs, stale comments, documented limitations.
Meanwhile, read the shared layer myself: `internal/provider/{provider,errors,check,
name,config,metrics,tracing}.go`, `internal/provider/registry/registry.go`,
`cmd/main.go` (registry wiring, decorator order, Checker collection), and the
places that consume the contract, so every MUST rule traces to a caller that depends on it:
`internal/controller` (error-class branching, ErrNotFound → replace/adopt),
`internal/gc` (UID, CreatedAt age skip, ListByTag errors),
`internal/providerhealth`. Then cross-check against the existing docs listed in Context.
## Step 2 — Write `docs/providers.md`
Sections:
1. **What a provider is**: a few sentences plus links to architecture.md §5/§11.
2. **The contract**: per method (Create/Get/Delete/ListByTag), MUST/SHOULD/MAY
rules, each followed by *why*: the reconciler or GC behaviour it protects,
linked to code.
3. **Error taxonomy**: what each class makes the reconciler do; how to map backend
errors; "unclassified = transient" pitfall.
4. **Identity and GC labels**: providerID guidance, `LabelManaged`/`LabelUID`,
CreatedAt, deterministic `NameFromUID`.
5. **Optional interfaces**: `Checker` (shared reasons, never fails as a whole,
collected before decorators).
6. **Feature matrix**: one row per questionnaire item, columns kubernetes/gcp/kvm,
cells `yes`/`partial`/`no` plus a short note. Put the file:line evidence in a
compact per-provider notes subsection under the table, not in the cells.
7. **Adding a provider: checklist**: package layout, config section and validation,
registry constructor, `cmd/main.go` wiring, Checker, error mapping with tests,
`name`/label tests, integration test behind a build tag, provider doc page,
architecture.md rows, demo script (optional).
8. **Gaps and proposed features**: current-state gaps per provider (e.g. no kvm
quota detection, orphan volumes invisible to GC, Secret rotation needs a
restart, ListByTag partial-failure inconsistency, stale `mock` references in
`provider.go` comments), plus proposals that are clearly marked as not
implemented. Only list them; don't design them.
Also: link it from `docs/architecture.md` (Components table → provider contract row)
and from the CLAUDE.md "Architecture" section. Leave per-provider docs where they are.
Follow the plan-execution convention: create `docs/plans-executions/2026-09-16-0841-provider-blueprint.md`
with the status checklist and step entries, and commit it with each step.
## Step 3 — Verify, then open the MR
- A verification agent (Explore) re-checks each matrix cell and each MUST rule
against the cited file:line and reports any mismatch. Fix what it finds.
- Check that every relative link in the new and edited docs resolves (grep the
`](` targets and `ls` each one).
- `go build ./... && go test -short ./...` as a sanity check (docs-only change,
expected no-op).
- Push `feat/provider-blueprint` and open the MR against `main` via the Gitea API,
following the project's push/MR memory note. After the user confirms, add a CHANGELOG.md entry.
## Critical files
- New: `docs/providers.md`, `docs/plans/2026-09-16-0841-provider-blueprint.md`,
`docs/plans-executions/2026-09-16-0841-provider-blueprint.md`
- Edited: `docs/architecture.md` (link), `CLAUDE.md` (link)
- Read-only sources: `internal/provider/**`, `cmd/main.go`, `internal/controller`,
`internal/gc`, `internal/providerhealth`, `docs/prompts/__initial-prompt.md`,
`docs/kvm-provider.md`, `docs/gcp-vm-validation.md`
@@ -0,0 +1,99 @@
# Plan: Split the provider blueprint into contract, appendix, template and index
**Created:** 2026-09-16 11:08
## Context
`docs/providers.md` is one 731-line blueprint that mixes the generic provider contract with how kubernetes, gcp and kvm happen to meet it, plus gaps and proposals. The user wants to hand an agent a self-contained set of rules for building a new provider (Proxmox is next), so the generic part must stand alone, state explicitly where providers are allowed to differ (a pod is ready at once, a VM has a provisioning window, kvm blocks in Create, gcp does not), and leave implementation detail to a separate appendix. A third file, a fill-in spec template, lets the user write the new provider's specifics in the same vocabulary. `docs/providers.md` becomes a short index so every existing link keeps working.
Decisions already made by the user: two new files plus a template; `providers.md` becomes an index; the contract is fully abstract (never names an existing provider); work continues on the checked-out branch `feat/provider-blueprint`.
Conventions for all four files: code references are `path` plus function or symbol name, never line numbers; rule keywords MUST / SHOULD / MAY as today, and every MUST names the caller and what breaks; no em-dashes in prose. The contract cites only shared-layer files (`internal/provider/*.go`, `internal/provider/registry`, `internal/controller`, `internal/gc`, `internal/providerhealth`, `internal/tracing`, `cmd/main.go`, `config/manager`, `api/v1alpha1`) and never `internal/provider/{kubernetes,gcp,kvm}`.
## Facts verified against the code that the new contract must carry
- Callers put no deadline on the context. Only `Monitor.checkOnce` (`internal/providerhealth/monitor.go`) uses `WithTimeout`; the reconcile and GC contexts are cancel-only. `Sweeper.sweep` (`internal/gc/gc.go`) loops providers sequentially, so one hung `ListByTag` stalls orphan GC for every provider.
- The reconciler's Stopped/Terminated branch in `reconcileManaged` (`internal/controller/proxy_controller.go`) deletes and requeues without clearing `ProviderID`, so a backend that reports a freshly created, not-yet-started instance as Stopped gets it deleted right after creation. kvm avoids this only by blocking `Create` through start.
- `Sweeper.sweep` treats a zero `CreatedAt` as collectable now, skips `UID == ""`, and skips anything younger than `MinAge` (10m).
- `cmd/main.go` type-asserts `provider.Checker` on the unwrapped provider before `WithTracing(WithMetrics(...))`; `registry.Build` fails the whole manager on a constructor error, so constructors must not dial.
- Manager runtime: non-root, read-only rootfs, `/tmp` emptyDir 64Mi, 128Mi limit, distroless static image with CA roots (`config/manager/manager.yaml`, `Dockerfile`).
- Corrections to earlier notes, for the appendix: kvm Checker `Details` also carry `user` and `pool`; the kubernetes provider has no seam interface (it injects `client.Client` via `newWithClient` and tests use the fake client); kvm `classify` has an `isClassified` pass-through for errors already wrapped with a class.
## Files
### 1. `docs/provider-contract.md` (new, the generic contract)
Every behaviour axis gets a stable kebab-case code in backticks, used verbatim by the appendix table and the template so the three files can be cross-checked by grep.
Sections:
1. **Preamble**: audience, reading order (contract, template, appendix), rule keywords, "path plus symbol, never line numbers", where the rest lives.
2. **What a provider is and who calls it**: the types in `provider.go`, `name.go`; callers and entry points (`reconcileManaged`, `replaceInstance`, `reconcileDelete`, `providerFailure`, `deletionFailure`, `placementFrom`, poll intervals, `MaxConcurrentReconciles`; `Sweeper.sweep` and `MinAge`; `Monitor.checkOnce`; decorators; `registry.Build`); what the instance is for (health probe CONNECT in `internal/health`, discovery handing out ip and port); leader-only vs every-replica; concurrency (MUST be safe, including two calls for one name); runtime envelope; time and cancellation (no caller deadline, MUST honour cancel, SHOULD bound each backend call, what breaks: a pinned worker, a stalled GC sweep, a wedged check round).
3. **The contract, method by method**: current §2 rewritten abstractly, plus the new points. Create: idempotent by name, mark on create or within a documented window shorter than `MinAge`, self-contained ID, ctx, blocking vs submit, reject-early with `ErrPermanent` naming field and value, catalogue changes are outside the spec hash (`spechash.go`), MAY ignore Placement fields but MUST document, SHOULD deliver CloudInit, MUST NOT log it, and an eventually consistent backend MUST NOT let `Get` answer NotFound for an ID `Create` just returned. Get: NotFound only when gone, keep answering during delete, Running only with IP, Stopped/Terminated only when it will not recover, the "defined but not started" rule (block Create through start, or report Provisioning using written metadata or a task handle), behaviour during async operations and locks (last known state or Transient, never NotFound or Permanent), UID and CreatedAt read back, Permanent for an unresolvable ID. Delete: idempotent, re-entrant while deleting, removes secondary resources, MAY return before gone, SHOULD succeed where Get fails permanently. ListByTag: marked instances only with ID, UID, CreatedAt; error rather than an unvouched list; MAY omit unreadable parts; SHOULD cover the whole creatable scope; no installation identity.
4. **Behaviour profiles**: one subsection per axis with the question, allowed profiles, what callers tolerate, obligations per profile. Axes: `readiness`, `create-return`, `delete-return`, `ip-source`, `gc-mark`, `created-at`, `list-partial`, `quota`, `not-found`, `primary-key`, `provisioning-state`, `async-ops`, `secondary-resources`, `credentials`, `client-kind`, `timeouts`, `logging`, `child-spans`, `backend-test`. Closing table: code, profiles, tolerated by which caller, template entry.
5. **Error taxonomy**: `Wrap`, `Class`, `Error` fields, `Op` values, configured name not type; the class-to-reaction table; pitfalls; NotFound detection guidance (404, error code, list-and-miss, and why list-and-miss is forbidden with a partial list); the quota-vs-permission 403 case stated abstractly.
6. **Identity, names, marks, CreatedAt**: `NameFromUID` shape, providerID strict parsing, marks and written metadata, provider name as DNS-1123 (`Monitor` skip, `spec.provider` MaxLength), primary key not the name.
7. **Credentials, TLS and secret hygiene** (new): source and read time (constructor MUST NOT dial, SHOULD warn on a missing optional mount and fail at first use), never in logs, spans, error strings or `Error.Err`, TLS trust options and the naming of an insecure toggle, scratch only under `/tmp`.
8. **Optional interfaces: Checker**: current §5 rules; `status.type` comment in `api/v1alpha1/providerhealth_types.go` must list the new type and the CRD be regenerated.
9. **Observability**: the three layers, span and metric names, ctx and logger rules, child-span strategies by `client-kind`, no secrets or CloudInit in attributes, log tiers.
10. **Configuration and wiring**: `ProviderConfig`, `setBlocks`, the allow-list in `Config.validate`, `UnmarshalStrict`, `config_<type>.go`, identifiers pasted into IDs, `registry.Constructor`, the map in `cmd/main.go`, decorator order, the duplicated type list.
11. **Testing obligations**: fake behind a seam; the contract test list (current checklist item 5 plus Get during an async operation, the not-yet-started state, NotFound after Create is forbidden); classify table test; Checker tests; sentinel tests for CloudInit and credentials; span recorder test; integration test behind `//go:build integration` with `<TYPE>_TEST_*` variables, `make test-integration-<type>`, a section in `docs/testing.md`.
12. **Checklist for adding a provider**: current §8 generalised, plus: Makefile target, testing.md section, `hack/providers-dev.yaml` decision, `ProviderHealth` type comment and `make manifests`, RBAC diff, CHANGELOG, appendix row and section, the filled template committed as `docs/<type>-provider-spec.md`, README row, architecture.md rows.
13. **Known limits of the contract**: adoption unverified; Stopped and Terminated indistinguishable; config read once; no installation identity in GC; unconfigured provider latches; type list duplicated; Delete resubmitted every poll; no caller deadline; no conformance suite.
14. **Related documents**.
### 2. `docs/provider-spec-template.md` (new, the questionnaire)
Preamble: copy to `docs/<type>-provider-spec.md`, fill every Answer line, hand it to the implementing agent with the contract and appendix; unanswered entries block implementation. Header block: type, example configured name, backend and API version, SDK or thin client, author, date.
Fixed entry format: Question; Allowed answers (profile names linking to the contract §4 subsection); What each answer obliges; `Answer:` blank; `Evidence:` blank.
Entries: identity and providerID shape; primary key (`primary-key`); placement mapping (Region, Zone, MachineType, Image, ignored fields); image source; cloud-init delivery; machine type mapping; network and IP discovery (`ip-source`, `readiness`); GC marks and CreatedAt (`gc-mark`, `created-at`); state mapping table incl. not yet started (`provisioning-state`); Create blocking and task tracking (`create-return`, `async-ops`); Delete and secondary resources (`delete-return`, `secondary-resources`); ListByTag scope and partial failure (`list-partial`); error classification table (`not-found`, `quota`); credentials and TLS (`credentials`); config block fields, defaults, validation; Checker targets, reasons, Details; SDK vs thin client and child spans (`client-kind`, `child-spans`); timeouts (`timeouts`); concurrency; logging tiers (`logging`); tests (`backend-test`); deployment (mounts, samples, Makefile helper, RBAC, dev config); docs; known limits accepted at design time.
### 3. `docs/providers-existing.md` (new, the appendix)
Sources: current `providers.md` §6 table, §7 matrix and per-provider notes, §9 per-provider gaps and proposed features, `docs/kvm-provider.md`, the gcp and kubernetes package facts. Links to architecture.md §5, §11 and Decisions, and to the README table, rather than duplicating them.
Sections: preamble; **profile table** with exactly one row per contract axis code and one column per existing provider, each cell a profile name plus a symbol as evidence; **other differences** (matrix rows that are not axes: Placement used, Port used, CloudInit delivered, states produced, Error.Provider correctness, credential rotation, connection handling, Checker Details, fakes, proxy software, reachability, distinct egress, adopted-UID check); **observability status** (current §6 table); one section each for **kubernetes**, **gcp**, **kvm** with the same sub-headings: package layout and seam, config block, ID format, Create, Get and state mapping table, IP source, Delete, ListByTag, classify table, Check (targets, reasons, Details), logging, tracing layer, tests and fakes, deployment bits, known gaps; **gaps shared by the existing providers**; **proposed features** (current list, unchanged).
### 4. `docs/providers.md` (rewritten in place as the index)
One paragraph on the split; one line each for contract, template, appendix; the per-provider guides (`docs/kvm-provider.md`, `docs/gcp-vm-validation.md`, `docs/gcp-in-specific-project.md`); the architecture.md sections; reading order for a new provider; a note that the pre-split blueprint is in git history under this path.
### 5. One-line link edits
- `README.md` "### Adding a provider": point at the contract, appendix and template, with `docs/providers.md` named as the index. Keep the "Start there" sentence.
- `CLAUDE.md` "## Architecture": same replacement for the providers.md sentence.
- `docs/architecture.md` components row "Provider contract": rules in `provider-contract.md`, profile table in `providers-existing.md`.
- Historical files under `docs/plans*` keep their links; they resolve to the index.
## Steps (one commit per step, on `feat/provider-blueprint`)
- **Step 0**: copy this plan to `docs/plans/<ts>-provider-docs-split.md` with the real timestamp and commit it alone, before anything else.
- **Step 1**: write `docs/provider-contract.md`; freeze the axis code list. Create `docs/plans-executions/<ts>-provider-docs-split.md` with the status checklist.
- **Step 2**: write `docs/provider-spec-template.md` from contract §4 and §12.
- **Step 3**: write `docs/providers-existing.md`, replacing every `file:line` from the old blueprint with `file` plus symbol.
- **Step 4**: rewrite `docs/providers.md` as the index; apply the three link edits.
- **Step 5**: run the verification below; fix findings.
- **Step 6**: final execution summary entry. CHANGELOG entry at the top only after the user confirms. Push and MR only when the user asks (per memory, push uses the token helper and the MR goes through the Gitea API).
## Verification (read-only shell, from the repo root)
1. Every MUST names a caller: `grep -n MUST docs/provider-contract.md` and confirm each bullet cites a shared-layer path or symbol and states what breaks.
2. Every cited path exists: extract `(internal|cmd|config|docs|api|hack|test)/...` tokens from the four files and `test -e` each.
3. Every cited symbol exists: extract backticked identifiers, drop paths, keywords and YAML keys, and `grep -rqw` each across `*.go`, `Makefile`, `*.yaml`.
4. Contract is abstract: `grep -niE "kubernetes|gcp|kvm|libvirt|squid|google|compute engine|internal/provider/(kubernetes|gcp|kvm)" docs/provider-contract.md` is empty.
5. Profiles line up 1:1: for each axis code from contract §4 headings, it appears in the appendix table and in the template; the appendix table has one row per code.
6. Links resolve: extract `](target)` from the four files plus README, CLAUDE.md and architecture.md; check file targets exist and anchors match slugified headings.
7. No em-dashes in the four files or in the added lines of the three edited files.
8. No line references: `grep -nE '\.go:[0-9]+|:[0-9]+-[0-9]+' <four files>` is empty.
9. Nothing lost: compare the gap and proposal bullets of `git show HEAD:docs/providers.md` against the appendix and contract §13 by keyword.
10. `go build ./... && go test -short ./...` still pass (docs-only change).
## Execution summary must record
- Step 1: the frozen axis list; the two contract additions found in verification (sequential GC sweep; the Stopped branch re-deleting a not-yet-started instance); per-call timeout facts as verified for each existing provider.
- Step 3: the fact corrections applied (kvm Details keys, kubernetes seam, kvm `isClassified`); matrix rows dropped in favour of the profile table.
- Step 4: the exact link edits.
- Step 5: the verification commands as run with trimmed output.
- Worth noting: CHANGELOG headings keep the repo's own em-dash format; the stale `mock` comments in `provider.go`, `registry.go` and `proxy_types.go` stay listed as gaps and are not fixed here.
+301
View File
@@ -0,0 +1,301 @@
# Plan: kvm2, a clean-room libvirt/KVM provider built from the provider contract
**Created:** 2026-09-16 13:33
## Context
The provider documentation was just split into a generic contract
(`docs/provider-contract.md`), a spec template (`docs/provider-spec-template.md`)
and an appendix on the existing providers (`docs/providers-existing.md`), so that
a new provider can be specified and built from those files alone. Before using
them for Proxmox, the user wants a dry run on a known backend: rebuild the
libvirt/KVM provider from scratch as a second type, `kvm2`, following the
documents, without touching or reading the existing `internal/provider/kvm/`
code. The result is a validated workflow (spec first, then implementation by
fresh agents against the contract) and a v2 provider that can replace v1 once it
passes on the real hypervisor.
User decisions:
- **Placement:** new package `internal/provider/kvm2`, type `kvm2`, config block
`kvm2:`, side by side with `kvm`. The Go config struct reuses
`provider.KVMConfig` (`internal/provider/config_kvm.go` is shared code, not
the kvm package), so an entry ports by changing `type` and the block name.
- **Clean room:** implementers never open, grep or import
`internal/provider/kvm/`. Docs (`docs/kvm-provider.md`) and the operational
lessons from the first build's execution log are allowed.
- **Implementer:** the orchestrator (this session) writes the spec and the step
briefs; each implementation step runs in a fresh general-purpose subagent
given only the three docs, the filled spec, the backend facts and its brief.
The orchestrator reviews each result against the contract, runs the tests and
commits.
- **Profiles:** `create-return` blocking, `provisioning-state` create-blocks,
`child-spans` span-decorator, `quota` detected (pool free space precheck),
`credentials` mounted-files (the existing `kvm-ssh` Secret), `client-kind`
rpc-over-ssh, `gc-mark` written-metadata, `created-at` written-at-create,
`ip-source` discovered (agent, then ARP), `list-partial` fail-whole per host
with unreadable domains skipped, `not-found` error-code, `delete-return`
waits, `secondary-resources` explicit, `timeouts` per-call, `logging` tiered,
`backend-test` integration-tag.
## Backend facts the spec carries (allowed lessons)
- Real host `beelink`: libvirt 11.6, QEMU 10.1, pool `default` at
`/srv/vms/images`, bridge `br0`, `beelink.tail8c69c.ts.net:22`, user `novakj`,
key `~/.ssh/proxy-operator_ed25519`, known_hosts `~/.ssh/kvm_known_hosts`.
The Debian trixie qcow2 is about 435 MB.
- AppArmor confines QEMU from the domain XML: only `<disk type="file">` with a
`<source file>` path gets a rule, so volume paths are resolved with
`StorageVolGetPath` and emitted as file disks.
- Debian 13 hangs in GRUB under BIOS with a serial console and no video: UEFI
by default (`<os firmware="efi">`, secure boot feature disabled), `firmware:
bios` per image; OVMF is a host prerequisite; undefine needs the NVRAM flag.
- libvirt hands the metadata element back without its namespace declaration:
parse by local name.
- Vendor-data must be a shell script, not cloud-config, to survive cloud-init's
merge with user-data; it installs `qemu-guest-agent`. The agent answered after
about 20 s where ARP never did.
- Read-only rootfs: `iso9660.NewWriter` needs a temp dir under `/tmp` (64Mi).
- go-libvirt (`github.com/digitalocean/go-libvirt`, in go.mod): every method
blocks and takes no ctx; `libvirt.Error{Code uint32, Message}` has a value
receiver; needed `ErrorNumber` constants exist (`ErrNoDomain`,
`ErrNoStorageVol`, `ErrNoStoragePool`, `ErrOperationInvalid`,
`ErrNoDomainMetadata`, `ErrStorageVolExist`, `ErrXMLError`, `ErrNoSupport`,
`ErrInvalidArg`, `ErrAuthFailed`, `ErrAccessDenied`). `libvirt.New(net.Conn)`
then `Connect()`. No new dependencies: `x/crypto/ssh`, `kdomanski/iso9660`
are already direct requires; use `slices`, never `sort` (depguard).
## Design decisions recorded in the spec (step 1)
- **Package layout** (`internal/provider/kvm2/`): `kvm2.go` (type, `New`,
logger, per-name keyed lock), `id.go`, `state.go`, `errors.go`, `xml.go`,
`seed.go`, `backend.go` (ctx-first seam over the 20 go-libvirt calls, adapter
with a per-RPC deadline), `ssh.go`, `hosts.go` (one cached connection per
host, lazy dial, dialer seam), `observe.go` (the single decorator: one span
`kvm2.<Call>` per RPC plus the V(1)/V(2) log line), `image.go`, `create.go`,
`get.go`, `delete.go`, `list.go`, `check.go`, plus `fake_test.go`,
`sshserver_test.go`, `kvm2_integration_test.go`. Names are suggestions for
the briefs; the implementer may choose others by responsibility.
- **Seam:** every seam method takes `ctx`; the adapter runs the blocking call in
a goroutine and selects on result, `ctx.Done()` and a timer (dial 10s, RPC
30s, `DomainCreate` 90s, upload/download ctx plus a 20m cap). On abandon it
closes the connection and marks it dead; the next call redials. The image
download runs on a dedicated connection so a cancelled poll cannot cut it off.
- **Decorator wrapped once:** applied inside the real dialer where a connection
is born, so provider code never sees an undecorated backend. Spans `kvm2.dial`
(in the dialer) and `kvm2.download` (in `image.go`) are the two non-RPC spans.
A reflection test asserts every seam method is instrumented.
- **Metadata:** own namespace
`https://gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/kvm2`,
element `<instance>` with `managed`, `uid`, `createdAt`. kvm2 never lists or
adopts v1 domains; a `kvm` and a `kvm2` entry may share a host.
- **Adoption guard:** an existing domain whose metadata `uid` is non-empty and
differs from `req.UID` makes `Create` return `ErrPermanent`.
- **Quota precheck:** before creating volumes, `StoragePoolGetInfo`; needed =
image `Content-Length` (if not cached) + `diskSizeGb` + 16 MiB; below that
returns `ErrQuotaExceeded` naming pool, available and needed.
- **Download** requires `Content-Length` (Permanent otherwise), streams through
`sha256` into `StorageVolUpload`, refreshes the pool, writes the marker
volume last; a failed attempt deletes the partial volume.
- **Classify:** `ErrNoDomain`, `ErrNoStorageVol` NotFound, but Transient inside
`Create`; `ErrNoStoragePool`, `ErrXMLError`, `ErrNoSupport`, `ErrInvalidArg`,
`ErrAuthFailed`, `ErrAccessDenied` Permanent; SSH authentication failures and
host-key mismatches or unknown host keys Permanent; missing key or
known_hosts file Transient (Secret pending), unparsable key Permanent;
cancellation, deadlines, `net.OpError`, `io.EOF` and anything else Transient.
- **Same-name concurrency:** a keyed mutex per instance name serialises Create
and Delete for one name.
- **State:** nostate Provisioning; running or blocked without IP Provisioning,
with IP Running; everything else, including unknown codes, Stopped. IP from
the guest agent first, then ARP, first non-loopback non-link-local IPv4.
- **Checker:** per host concurrently on a fresh dedicated connection (dial,
`ConnectGetLibVersion`, `ConnectGetHostname`, pool lookup and info, close), so
it never touches the pooled connection. Reasons: shared ones plus
`PoolNotFound`, `PoolNotActive`, `CredentialsUnavailable`. Details `user`,
`hostname`, `libvirtVersion`, `pool`, `poolState`, `poolCapacityGiB`,
`poolAvailableGiB`.
- **Rotation:** SSH material is read on every dial, so Secret rotation needs no
restart.
- **Config validation messages:** `validateKVM` is refactored into
`validateKVMBlock(prefix, block string, c *KVMConfig)` so messages say
`kvm2.` for the new block; `validateKVM` stays as a wrapper for `kvm`.
## Steps (one commit each, on `feat/kvm2-provider` off `main`)
PR #7 is merged, so `main` carries the three docs. Step 0 begins with
`git fetch origin && git switch main && git pull --ff-only`.
Every subagent brief opens and closes with: "Do not open, grep, list or import
anything under `internal/provider/kvm/`. If a detail seems to need that code,
stop and report the question instead." Each brief names the files it may create
or edit and nothing else, lists the inputs (the three docs, the spec,
`docs/kvm-provider.md` for backend facts, CLAUDE.md conventions), the
acceptance tests by name and the commands to run, and asks the agent to end
with "files read outside the allowed set: none".
- **Step 0 (orchestrator):** copy this plan to `docs/plans/<ts>-kvm2-provider.md`
with the real timestamp, commit alone, create the branch. Execution summary
`docs/plans-executions/<ts>-kvm2-provider.md` with the checklist after step 1.
- **Step 1 (orchestrator): the spec.** Copy `docs/provider-spec-template.md` to
`docs/kvm2-provider-spec.md` and answer all 24 entries with the profiles and
decisions above; every Evidence line cites the user guide, a go-libvirt symbol,
or "verified on beelink in step 11". Entry 21's checklist lists the concrete
test names from steps 4 to 7. Verify: no blank Answer lines, every axis code
used matches contract §4.20, no reference to `internal/provider/kvm/`.
- **Step 2 (subagent): config wiring, IDs, state, classify.** Edits
`internal/provider/config.go` (`KVM2 *KVMConfig` field, `setBlocks`,
allow-list, dispatch), `config_kvm.go` (`validateKVMBlock`),
`config_test.go` and `config_kvm_test.go` rows. Creates `kvm2.go` (type,
assertions, `New` that never dials and warns on unreadable SSH files, stub
methods), `id.go`, `state.go`, `errors.go` with tests:
`TestFormatProviderID_roundTrip`, `TestParseProviderID_malformed`,
`TestMapState_table`, `TestFirstIPv4_filters`, `TestClassify_table`. Also
registers `"kvm2": kvm2.New` in `cmd/main.go` so the two type lists change in
one commit. Verify: three `"kvm2"` sites in `config.go`, no import of
`provider/kvm`, `go test -race ./internal/provider/... && go vet ./...`.
- **Step 3 (subagent): pure builders.** `xml.go`, `seed.go` and tests:
`TestDomainXML_fileDisksWithResolvedPaths`, `TestDomainXML_uefiDefaultSecureBootOff`,
`TestDomainXML_biosEscape`, `TestDomainXML_metadataElement`,
`TestDomainXML_guestAgentChannel`, `TestParseMetadata_withNamespace`,
`TestParseMetadata_withoutNamespaceDeclaration`,
`TestParseMetadata_absentGivesEmptyUID`, `TestVolumeXML_overlayBackingStore`,
`TestSeedISO_filesAndLabel`, `TestSeedISO_userDataByteForByte`,
`TestSeedISO_tempDirRemoved`, `TestVendorData_isShellScriptNotCloudConfig`,
`TestVendorData_multipartWhenExtraProvided`. Verify: no `type="volume"` in
`xml.go`; temp dir under `/tmp` with a deferred removal.
- **Step 4 (subagent): transport, seam, decorator, fakes.** `backend.go`,
`ssh.go`, `hosts.go`, `observe.go`, `fake_test.go`, `sshserver_test.go` and
tests: `TestDial_forwardsUnixSocket`, `TestDial_unknownHostKeyRejected`,
`TestDial_mismatchedHostKeyRejected`, `TestDial_insecureIgnoreHostKeyAccepts`,
`TestDial_missingKeyFileIsTransient`, `TestDial_unparsableKeyIsPermanent`,
`TestDial_handshakeTimeout`, `TestCall_deadlineClosesConnectionAndReturnsTransient`,
`TestCall_cancelUnblocksRPC`, `TestHosts_oneDialPerHostUnderConcurrency`,
`TestHosts_redialsAfterDrop`, `TestHosts_unconfiguredHostIsPermanent`,
`TestObserve_everySeamMethodInstrumented`, `TestObserve_childSpansUnderParent`,
`TestObserve_logsOneLinePerCallAtV1`, `TestObserve_detailOnlyAtV2`,
`TestObserve_uploadBodyNeverLogged`, `TestSSH_keyMaterialNeverInErrorsOrLogs`.
Verify: no `context.Background()` outside tests and the keepalive goroutine;
`tracing.Start` in the decorator and the dialer; `go test -race -count=2`.
- **Step 5 (subagent): image cache and pool-space precheck.** `image.go` and
tests: `TestEnsureImage_downloadsAndWritesMarkerLast`,
`TestEnsureImage_cachedWithMarkerSkipsDownload`,
`TestEnsureImage_missingMarkerRedownloads`,
`TestEnsureImage_sha256ChangeRedownloads`,
`TestEnsureImage_sha256MismatchIsPermanentAndRemovesVolume`,
`TestEnsureImage_noContentLengthIsPermanent`, `TestEnsureImage_http404IsPermanent`,
`TestEnsureImage_http503IsTransient`, `TestEnsureImage_concurrentCallersFetchOnce`,
`TestEnsureImage_cancelledMidStreamReturnsPromptly`,
`TestEnsureImage_streamsWithoutBuffering`, `TestCheckPoolSpace_belowNeededIsQuota`.
Verify: no `io.ReadAll` or `bytes.Buffer` on the download path; download span.
- **Step 6 (subagent): the four contract methods.** `create.go`, `get.go`,
`delete.go`, `list.go`, `kvm2_test.go`, finishing `kvm2.go`. Tests map 1:1 to
contract §11: `TestCreate_repeatReturnsSameIDAndCreatesNothing`,
`TestCreate_resumesAfterEachPartialStep`, `TestCreate_returnsOnlyWhenRunning`,
`TestCreate_rejectsUnknownPlacement`, `TestCreate_adoptionWithForeignUIDIsPermanent`,
`TestCreate_poolOutOfSpaceIsQuotaExceeded`,
`TestCreate_libvirtNotFoundIsTransientNotNotFound`,
`TestCreate_cloudInitSentinelNeverLoggedOrInErrors`,
`TestGet_afterDeleteReturnsNotFound`, `TestGet_duringDeleteStillReturnsInstance`,
`TestGet_backendBusyIsTransientNeverNotFound`, `TestGet_runningWithoutIPIsProvisioning`,
`TestGet_runningWithIPIsRunning`, `TestGet_agentPreferredOverARP`,
`TestGet_addressErrorIsProvisioning`, `TestGet_readsUIDAndCreatedAtFromBackend`,
`TestGet_malformedIDIsPermanent`, `TestGet_unconfiguredHostIsPermanent`,
`TestDelete_malformedIDIsPermanent`, `TestDelete_twiceSucceeds`,
`TestDelete_removesOverlayAndSeed`, `TestDelete_runningDomainDestroyedFirst`,
`TestDelete_toleratesMissingVolumesAndPool`, `TestDelete_unconfiguredHostIsNoOp`,
`TestDelete_concurrentSameNameSafe`, `TestListByTag_excludesUnmarkedDomains`,
`TestListByTag_emptyUIDStillListed`, `TestListByTag_acrossHostsInConfigOrder`,
`TestListByTag_unreachableHostFailsWhole`, `TestListByTag_unreadableDomainSkipped`,
`TestProvider_cancelledContextReturnsPromptly`,
`TestProvider_everyErrorIsWrappedWithConfiguredName`,
`TestLogging_v0SilentV1OutcomeV2Detail`. Verify: `CloudInit` appears in
`create.go` only as `len(req.CloudInit)`; stub strings gone; `Wrap` always
gets the configured name; `go test -race -count=2 ./internal/provider/kvm2/`.
- **Step 7 (subagent): Checker and the CRD comment.** `check.go`,
`check_test.go`: `TestCheck_oneEntryPerHostInConfigOrder`,
`TestCheck_hostsCheckedConcurrently`, `TestCheck_expiredContextReturnsPromptly`,
`TestCheck_reasonsTable`, `TestCheck_neverUsesPooledConnection`,
`TestCheck_detailsKeys`. Edit `api/v1alpha1/providerhealth_types.go`
(`status.type` comment) and run `make manifests`. Verify: only the CRD
description line changes under `config/`; `go test ./internal/providerhealth/`.
- **Step 8 (subagent): deployment, Makefile, integration test.** Edit
`config/manager/manager.yaml` (comment: the `kvm-ssh` mount serves kvm and
kvm2), `config/manager/providers_config.yaml` and
`config/samples/providers-config.yaml` (commented `kvm2` entry),
`config/samples/kustomization.yaml`, `Makefile` (`test-integration-kvm2`,
`-run TestKVM2`, `-timeout 40m`), `docs/testing.md` (suite rows and a kvm2
integration section). Create `config/samples/proxy_kvm2.yaml` (zone
`beelink`, provider `kvm2-home`) and `kvm2_integration_test.go`
(`KVM2_TEST_HOST`, `_USER`, `_SSH_KEY`, `_KNOWN_HOSTS`, `_BRIDGE`,
`_IMAGE_URL`; optional `_IMAGE_SHA256`, `_POOL`, `_SOCKET`, `_TIMEOUT`,
`_KEEP`, `_LOG_LEVEL`) asserting in order: `New` does not dial; `Check`
Ready with details; `Create` returns `hosts/<host>/domains/<name>`; second
`Create` same ID, domain count unchanged; `Get` reaches Running with an IPv4
and the written UID and CreatedAt; `ListByTag` contains it; `Delete` nil;
`Get` NotFound; `Delete` again nil; pool holds base and marker, no per-VM
volumes; with an in-memory span recorder, `kvm2.DomainDefineXML` and
`kvm2.dial` spans are children of `provider.create`. Verify:
`go vet -tags=integration ./... && go vet -tags=e2e ./...`,
`bin/kustomize build config/default` and `config/samples` parse,
`make -n test-integration-kvm2`.
- **Step 9 (subagent): documentation.** Create `docs/kvm2-provider.md`
(prerequisites incl. OVMF, cluster setup reusing `kvm-ssh`, config reference
pointing at the shared `KVMConfig` table, placement mapping and the
add-a-key-never-edit warning, what lands on the host incl. the metadata
namespace, image cache and quota precheck, health-check reasons, logging and
span names, migration from `kvm`, known gaps). Edit
`docs/providers-existing.md` (fourth column in the three tables and a kvm2
section), `docs/architecture.md` (components row, §5 block, §10 span
topology, §11 row, Decisions bullets), `README.md` row, `docs/providers.md`
guide link. Verify with the link and symbol sweep from the docs-split
execution log; no em-dashes in new prose.
- **Step 10 (orchestrator): milestone review.** `go-operator-reviewer` on the
branch with the spec and contract as inputs, then `/code-review high`,
`make lint`, `make test` (full, with envtest). Fix accepted findings with
regression tests in one commit; record rejected ones in the summary.
- **Step 11 (orchestrator with the user): real-host validation, changelog, MR.**
Hand the user a host checklist (leftover `proxy-*` domains and volumes from
earlier runs, OVMF installed, pool running with free space, `br0` up, the
known_hosts entry) and the `KVM2_TEST_*` export lines; do not run remote
probes unprompted. With the user's go-ahead run `make test-integration-kvm2`
(a permission prompt is expected; the allow-list entry
`make test-integration-kvm *` does not match) and, if wanted, the in-cluster
pass on kind: build and load the image, deploy, a scratch ConfigMap with the
`kvm2-home` entry, apply `proxy_kvm2.yaml`, wait for `Ready`,
`kubectl get ph kvm2-home -o yaml`, a `curl -x` through the proxy, delete
and confirm the domain and volumes are gone; scratch overrides are not
committed. Record cold download duration and time to IP. CHANGELOG entry
after the user confirms; push with the token helper; MR via the Gitea API
with base `main`; never merge.
## Verification
- Per step: the checks listed above, plus `go build ./... && go test -short ./...`
before every commit and `git diff --stat` reviewed for files outside the
step's allowed set.
- Clean-room check on every step's diff: `grep -rn 'provider/kvm"' internal/provider/kvm2/`
empty; the agent's closing line "files read outside the allowed set: none";
identifiers that coincide with v1's names from the appendix (`libvirtAPI`,
`hostPool`, `imageCache`, `tracingAPI`, `lazySSH`) are treated as a smell and
questioned.
- Contract mapping: after step 7, every rule of contract §2 and §8 has a named
test in the package; the §11 list maps 1:1 to test names (recorded in the
spec's entry 21).
- End to end: the integration run's ordered assertions (step 8) on beelink, and
the optional kind pass (step 11).
## Risks
- Subagent scope creep into `internal/provider/kvm/`: instruction-only guard,
mitigated by the closing attestation and the diff greps.
- `.claude/settings.json` is locally modified and uncommitted; it stays out of
feature commits, and the permission prompt for the new make target is
accepted rather than pre-approved.
- `Content-Length` is required for downloads; the Debian and Ubuntu mirrors send
it.
- The cancelled-call policy still drops other in-flight RPCs on that host once;
the dedicated download connection removes the expensive case and the spec
states the rest.
- `go-operator-reviewer` hunts over-abstraction; the seam, the decorator and
the dialer interface each have one production implementation and one fake,
which the contract requires (§4.15, §11).
@@ -0,0 +1,640 @@
# Plan: fix the kvm2 code-review findings
**Created:** 2026-09-17 15:15
## Context
The review of `feat/kvm2-provider` (`review/REPORT.md`) confirmed 52 findings: 4 high,
20 medium, 28 low. We fix them one at a time, highest severity first, each as its own step and
its own commit. This plan is the campaign; the execution file's checklist is the authoritative
progress record. Steps are detailed as we reach them, so the plan is never a fiction about work
not yet designed.
Precedent for this shape: `docs/plans/2026-09-10-2222-kvm-review-fixes.md`.
## Steps
- [x] **Step 0** — Save this plan into the repo, commit alone, before any code.
- [x] **Step 1** — H1: `sshSocket.Close` ran the blocking close before the unblocking one, and
`markDead` killed inside a `sync.Once` before signalling dead. Shipped in `0239fb7`; the
reasoning is in the execution file.
- [ ] **Step 2** — H2: an error during `StorageVolUpload` wedges go-libvirt's stream sender.
- [ ] **Step 3** — H3: `address()` swallows every error and demotes healthy proxies.
- [ ] **Step 4** — H4: the base image cache is shared across provider entries.
- [ ] **Step 5** — Validate H1-H4 in-cluster against the real hypervisor (detailed below).
- [ ] **Step 6** — Route klog into the structured logger (found during Step 5; detailed below).
- [ ] **Step 7+** — the mediums, from `review/REPORT.md`. Detailed when reached.
---
## Step 2 — H2: a source error must not reach go-libvirt's stream sender
### The bug
`uploadImage` (`image.go:255-296`) hands a reader over `resp.Body` to `StorageVolUpload`. I
verified the mechanism in the pinned `go-libvirt@v0.0.0-20260814190004`:
```go
// rpc.go requestStream
abort := make(chan bool) // UNBUFFERED
outErr := make(chan error) // UNBUFFERED
go func() { outErr <- l.socket.SendStream(serial, proc, program, out, abort) }()
resp, err = l.processIncomingStream(c, in)
if err != nil {
abort <- true
return resp, err // outErr is NEVER drained on this path
}
err = <-outErr
```
`SendStream` (`socket/socket.go`) holds a 4 MiB buffer, polls `abort` once per loop, and on a
**non-EOF reader error** sends a `StatusError` packet and returns. It then blocks forever on
`outErr <-`, because the main goroutine is still inside `processIncomingStream`. libvirtd
answers the `StatusError` with an error, so `processIncomingStream` returns an error and the
main goroutine blocks forever on `abort <- true`. **Deadlock: `StorageVolUpload` never returns.**
kvm2's `call` rescues only the caller, after `budgets.transfer` = 20 minutes, and the socket
kill does **not** unblock either goroutine — both are parked on channel sends, not on socket
I/O, which is what the comment at `backend.go:152-156` gets wrong. So every failed upload leaks
kvm2's call goroutine, go-libvirt's sender goroutine and 4 MiB, permanently. The pod limit is
128Mi and the reconciler retries on backoff.
Three shapes, only the first of which is ours to fix:
- **(A) reader error** — a truncated or failing HTTP body. The common case.
- **(B) server-side error mid-upload** — ENOSPC, or the volume deleted underneath. After
`SendStream` has returned there is nothing any reader, ctx or socket close can do: confirmed
unfixable from our side.
- **(C) our own abandonment** — ctx cancel or the 20 m budget kills the socket, which makes
`processIncomingStream` error. Same tail as (B).
### The fix (workaround; the defect stays upstream)
No fork, no `replace`. Two changes that between them close (A) and the realistic half of (C).
**2a. Never hand go-libvirt a reader that errors** — `internal/provider/kvm2/backend.go`, at the
seam rather than in `image.go`, so it covers the seed and marker uploads too and stays out of
the file Step 4 rewrites:
```go
type eofReader struct {
r io.Reader
mu sync.Mutex
err error
}
func (e *eofReader) Read(b []byte) (int, error) {
n, err := e.r.Read(b)
if err == nil || err == io.EOF {
return n, err
}
e.mu.Lock()
if e.err == nil {
e.err = err
}
e.mu.Unlock()
return n, io.EOF
}
func (e *eofReader) sourceErr() error { e.mu.Lock(); defer e.mu.Unlock(); return e.err }
```
`libvirtBackend.StorageVolUpload` wraps `r` in one, and after a nil-error return converts a
recorded `sourceErr()` into the returned error. `io.EOF` is the one outcome that ends the stream
the normal way: libvirtd gets `StatusOK`, replies OK, `processIncomingStream` returns nil, the
main goroutine reaches `err = <-outErr` and both goroutines unwind.
`countingReader` is untouched — it sits under the wrapper, still sees the real error, so
`uploadImage`'s `readErr != nil` branch and `TestEnsureImage_shortReadRemovesVolumeAndIsTransient`
keep working verbatim.
**Unverified, ~85%:** that libvirtd answers a short-but-clean stream finish with OK rather than
an error. `virFDStreamWrite` enforces `length` only on writes *past* it and nothing compares
`offset` to `length` on the close path, which is also why `virsh vol-upload` of a small file
into a large volume works. If it answers with an error instead, we land back in a deadlock by a
different door — but that is exactly today's behaviour, so the change is non-worsening either
way. Probe recipe goes in the commit message; it needs a real libvirtd and a write, so it is
for the user to run, not an agent.
**2b. Make the transfer deadline land on the HTTP source, not on the libvirt socket.**
`p.do` already builds the request with `http.NewRequestWithContext` (`image.go:360`), so the
body is ctx-bound, and `http.DefaultClient` has no timeout of its own by deliberate choice
(`kvm2.go:100`). Give the image GET in `fetchImage` a deadline of `defaultBudgets.transfer`
minus a grace of one minute (precedent for reaching for `defaultBudgets` directly:
`discardVolume`, `image.go:331`).
A stalled mirror then ends by the *body* failing, which 2a turns into `io.EOF` and a clean
unwind, roughly a minute before the libvirt budget would have killed the socket and leaked.
That converts the realistic repeated (C) — a slow or stalling mirror on a 415 MB image — from a
permanent leak into an ordinary Transient error. The socket kill stays as the backstop for
libvirtd itself hanging, which is the residual we cannot close.
**2c. Close the dedicated connection before unlinking the file.** Move the `discardVolume` call
out of `uploadImage` (`image.go:291-294`) up into `fetchImage`'s upload-error branch, where the
deferred `fresh.Close()` has already run, so libvirtd has let go of the stream's fd before the
volume is deleted. `uploadImage` then drops its `be` parameter and its private logger — a net
deletion. Doing it this way rather than adding an explicit `Close` keeps
`image_test.go:573`'s `closeCount() == 1` assertion true.
### Tests, docs, scope
One test: `TestEOFReader_sourceErrorNeverReachesTheStream` in `backend_test.go` — a table with
"error after data", "error with no data", "plain io.EOF passes through", "first error wins".
~35 lines.
**Deliberately not tested:** the abort protocol end to end. `libvirttest` reads a fixed 28-byte
header per packet and implements no stream procedures, so pinning this hermetically would mean
writing a libvirt RPC server stub — 150-250 lines whose only assertion is about *upstream's*
goroutines. That is the bloat we cut for H1.
Docs: a paragraph in `docs/kvm2-provider.md`'s *The image cache* section saying a truncated
response is reported out of band and why, and a *Known gaps* bullet naming shapes (B) and (C)
as an upstream defect with the two-character patch quoted, so a future reader knows it is not
ours and what the escape hatch is:
```go
abort := make(chan bool, 1)
outErr := make(chan error, 1)
```
File that as an upstream issue/PR against `digitalocean/go-libvirt` and link it from the bullet.
---
## Step 3 — H3: an unreadable address must not look like a missing one
### The bug
`address()` (`get.go:104-119`) tries the guest agent then the host's ARP table. Any error that
is not `context.Canceled`/`DeadlineExceeded` is logged at V(2) and skipped; after both it
returns `("", nil)`. `mapState(active, "")` is `StateProvisioning` (`state.go:20-21`), the
controller clears `p.Status.IP` on Provisioning (`proxy_controller.go:208-211`), and the health
engine prunes the proxy's accumulated state and calls `Metrics.ForgetProxy` in one tick once
`EffectiveHost()` is empty.
The trigger that matters: another reconcile's cancel or budget overrun kills the **shared**
pooled connection, and `call` then returns `transientf("... connection is closed: ...")` with no
ctx sentinel, which `get.go:108` does not recognise. `Get` returns a *successful* Instance in
Provisioning with `IP: ""`, and a healthy proxy is pulled out of rotation by an unrelated
reconcile's timeout.
### The fix
A booting VM and a dead link are distinguishable: on beelink the agent source errors while the
ARP source returns success-with-no-addresses (`docs/kvm2-provider-spec.md:146` — the host never
sees the guest's traffic). So *one* source failing is normal; *every* source failing, or a
connection already gone, is not.
```go
func address(ctx context.Context, log logr.Logger, be backend, dom libvirt.Domain) (string, error) {
errs := make([]error, 0, len(addressSources))
for _, s := range addressSources {
ifaces, err := be.DomainInterfaceAddresses(ctx, dom, uint32(s.src), 0)
if err != nil {
if isDead(be) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return "", fmt.Errorf("reading addresses of domain %s from the %s source: %w", dom.Name, s.name, err)
}
log.V(2).Info("no addresses from source", "source", s.name, "error", err.Error())
errs = append(errs, fmt.Errorf("%s source: %w", s.name, err))
continue
}
if ip := firstIPv4(ifaces); ip != "" {
return ip, nil
}
}
if len(errs) == len(addressSources) {
return "", fmt.Errorf("%w: reading addresses of domain %s: every source failed: %w",
provider.ErrTransient, dom.Name, errors.Join(errs...))
}
return "", nil
}
```
The explicit `provider.ErrTransient` wrap is load-bearing, not decoration: without it `classify`
picks the first libvirt code out of the join tree, and `ErrInvalidArg`/`ErrAccessDenied`/
`ErrNoSupport` map to **Permanent** — an address read failing would latch a *running* proxy as
Failed, a worse version of the harm being fixed. `isDead(be)` (`backend.go:50-61`, already
works through the `observed` decorator and the test fake) is not strictly needed — a dead link
fails both sources anyway — but it fails faster, attributes better, and is the same predicate
M15 will need for `ListByTag`.
Also fix the now-wrong comment at `get.go:100-103` ("only ctx ending is an error").
**What this does not fix, on purpose.** A guest agent restarting inside a running VM still
demotes it: the agent errors, ARP returns empty, and the provider cannot tell that from a VM
that is still booting. The discriminator is the *previous* IP, which the provider deliberately
does not hold (`TestGet_readsUIDAndCreatedAtFromBackend` pins "read back, not remembered") and
the controller already has. File as a follow-up finding against
`proxy_controller.go:208-211`: on Provisioning, keep a Proxy's existing IP and `Provisioned`
condition for N polls before clearing, or give the health engine a grace period before pruning.
The one kvm2-local alternative — treating "active, no address, domain older than a provisioning
window" as an error using the mark's `createdAt` — is a design change wearing a bug fix's
clothes, and it invents a provisioning deadline that belongs to the controller. Rejected.
### Tests
- `TestGet_arpFallbackWhenAgentFails` (`get_test.go:135`) passes unchanged — one source of two.
- `TestGet_addressErrorIsProvisioning` (`get_test.go:159`) must be **re-pointed**. It fails both
sources with `ErrAgentUnresponsive`, which is not realistic: code 86 is agent-specific and the
ARP source never produces it. Rewrite as `TestGet_agentFailsAndARPEmptyIsProvisioning` — agent
errors, ARP returns no addresses and no error — which is the beelink boot and the behaviour
the new rule must not break. Nothing covers it today.
- One new test, `TestGet_everyAddressSourceFailingIsTransient`, two subtests: both sources error;
and the pooled link is dead before the address read (the fake implements `liveness`). Assert
Transient, not NotFound, not Permanent.
Docs: `docs/kvm2-provider-spec.md:146-151` says "errors from either source mean 'no address yet'",
now false. The `ErrTransient` row of the error-class table in `docs/kvm2-provider.md`.
---
## Step 4 — H4: the base image cache must not be shared across provider entries
### The bug
`baseVolumeName` (`volume.go:17`) is `base-<key>.<format>`, with a `.sha256` marker beside it.
`internal/provider/kvm/image.go:44-56` builds **byte-identical** names in the same pool — for
URL images too (kvm takes `hex(sum[:8])`, kvm2 takes `hex(sum[:])[:16]`, the same 8 bytes) —
with a completely separate lock table. The shipped config has a live `kvm-home` entry and a
commented-out `kvm2-home` entry on the same host, the same key `debian-13` and the same default
pool, and the comment tells the operator to enable it. Both integration tests use key
`test-image`. On beelink both live overlays back onto `base-debian-13.qcow2`, which kvm created.
Consequences, given `cachedImage` accepts *any* 64-hex marker when no `sha256` is configured and
never re-hashes the base:
- Two entries, one key, different URLs: whichever fetched first owns the file and the other
silently boots the wrong image.
- A concurrent cold fetch: B finds A's base-without-marker, deletes it, recreates and uploads
its own; A finishes into the unlinked inode, writes the marker, and `StorageVolGetPath`
resolves A's handle to B's partial file — a guest boots from a half-written base.
- A `sha256` edit deletes the base in place under live overlays (`cachedImage` branch E), and
libvirt does not refuse to delete a volume that running domains use as a backing store.
### The fix — the provider entry's name in the volume name
`base-<entry>-<key>.<format>`, e.g. `base-kvm2-home-debian-13.qcow2`, with the marker following
automatically. Every production call site already goes through `imageSource.volumeName()`
(`image.go:49`), so this is a small change: give `resolveImage` the provider name and add an
`Entry` field to `imageSource`.
`Provider.name` is already carried (`kvm2.go:104`). But provider names are validated only as
non-empty and unique (`config.go:96-105`) — no charset rule — and a name with a `/` would
produce an unusable volume name, so **`New` must reject an entry name that is not a safe volume
name component**, using the existing `kvmIdentifier` shape. Fail fast at startup, in kvm2 only:
tightening the rule for every provider type is a broader change than this fix.
**What this buys, and what it does not.** It fixes the cross-entry sharing outright — kvm↔kvm2
and kvm2↔kvm2 — which is the wrong-image bug and the concurrent-fetch corruption, and it makes
the existing per-`Provider` `imageLocks` correct rather than a latent bug, since no two entries
can now contend for one name. It does **not** make the name mean the content, so two things
stay open and must be said plainly rather than quietly skipped:
- Editing `images.<key>.url` without changing the key is still a silent no-op. Already
documented behaviour ("add a new key instead"); left as is.
- Cross-*process* fencing still depends on the atomic-publish work (upload to a temp name,
verify, then publish). E4 refuted the rollout and leader-handover triggers for the shipped
manifests, so this is a narrow residual. Its own later commit.
**One companion fix, in this commit**, because the naming alone does not stop it and it is the
remaining path that unlinks a good file under a running domain: `cachedImage` branch E
(`image.go:129-130`, digest differs from the configured `sha256`) currently deletes the marker
and the base. Make it return Permanent instead — "this key is cached with digest X but Y is
configured; add the image under a new key, or delete the Proxies using it and evict the base."
That enforces the rule the contract already states instead of contradicting it. Keep the delete
for the no-marker and non-hex-marker branches, where the base is genuine debris.
### Migration
Nothing to do. The existing `base-debian-13.qcow2` on beelink is kvm's, kvm keeps using it
untouched, and kvm2 downloads its own copy once per host (~415 MB) when `kvm2-home` is enabled.
No window in which anything is replaced under a running guest. The pool needs room for both.
### Tests and docs
Tests are mostly literals becoming helpers: `volume_test.go:122-125`, `image_test.go:184,190,196`
(`wantVolume`), `create_test.go:64-70`, `delete_test.go:67`, `provider_test.go:409`; a small
`fx.baseName()` helper covers the last three. `image_test.go:281,298` should assert the log
field and the `kvm2.volume` span attribute **by prefix**, which keeps pinning that the key stays
readable in logs. `kvm2_integration_test.go:481-484` switches from `baseVolumeName(...)` to
`resolveImage(...).volumeName()`; its assertion that the base survives `Delete` is unchanged.
Two new tests:
- `TestBaseVolumeName_isPerProviderEntry` — two entries, same key, different names; the produced
name never equals the kvm provider's `base-<key>.<format>`.
- `TestNew_rejectsUnsafeEntryName` — an entry name with a `/` fails at construction.
**Not tested:** that libvirt permits deleting an in-use backing file (established from libvirt
source by the review; re-proving it needs a write on a real host), and the cross-process race
(no hermetic way, and its fix is deferred).
Docs: the *What lands on the host* table (`docs/kvm2-provider.md:198-212`), the eviction recipe
(`:305-318`, which currently names `base-debian-13.qcow2` and would delete kvm's file), the
migration section (`:346-370` — kvm2 never reuses a kvm base, one extra download, nothing to
delete), the prerequisites note that the pool needs one base per distro **per entry**, the
naming contract in `docs/kvm2-provider-spec.md` entries 4 and 10, the shared validator's message
at `internal/provider/config_kvm.go:139-141` ("it becomes the cached volume name
`base-<key>.<format>`", now wrong for kvm2), and the kvm2 block's comment in both
`providers-config.yaml` files. Leave `docs/kvm-provider.md` alone; kvm is unchanged.
---
## Step 5 — Validate in-cluster against beelink
### Why, and what this run can and cannot prove
The kvm2 provider has **never run against real libvirt**: Step 11 is unchecked in
`docs/plans-executions/2026-09-16-1333-kvm2-provider.md`, every `base-*` volume on beelink
predates it, and both live `proxy-*` domains carry the kvm v1 metadata namespace. Four fixes
have landed on code no hypervisor has executed. This run is first contact, and it is also the
gate the campaign's `CHANGELOG` entry waits on.
It proves the happy paths: the dial and health probe, a real 415 MB image download through the
reader H2 now wraps, H4's new volume name appearing beside kvm's untouched one, a second Proxy
hitting the cache, H3's address path reaching Running with an IP, and Squid answering a real
CONNECT probe.
It does **not** prove H2's error paths. The unverified ~85% claim — that libvirtd answers a
short stream finish with OK — only arises when a body *fails*, and a healthy download never
finishes short. The 19-minute HTTP deadline will not fire either. A green run means "the
wrapper did not break normal uploads", not "the deadlock is settled". That still needs the
scratch-pool probe recorded in the Step 2 execution entry.
### The cluster this runs in, and the one hazard
`kind-proxy-operator-demo` is **not a throwaway**. It runs the operator that currently owns
four Ready proxies, two of them real VMs on beelink:
```
proxy-beelink-1 Managed kvm-home Ready 192.168.0.166 True
proxy-beelink-2 Managed kvm-home Ready 192.168.0.94 True
proxy-kubernetes-demo-1/2 Managed kubernetes Ready 10.244.0.7/.8 True
```
Validating kvm2 means rolling the operator that owns them. That is low risk — the controller
and the whole `kvm` provider are untouched by H1-H4 — and rollback is one `kubectl set image`.
**Do not run a second operator against beelink with `kvm-home` in its config.** Orphan GC
(`internal/gc/gc.go:86-131`) lists every Proxy in *its own* cluster, and deletes any instance
whose UID is not in that set and is older than `--gc-min-age` (10 m, swept every 10 m). A
second cluster's live set would not contain `proxy-beelink-1/2`'s UIDs, so its GC would destroy
them within ten minutes. Using the existing cluster removes the hazard entirely: its live set
is the one that protects them.
### Steps
**0. Baseline** — record what is there before touching anything, so "did we break it" is
answerable: `kubectl get px -A`, `kubectl get ph`, and a read-only `virsh vol-list default`
plus `virsh list --all` on beelink.
**1. Build and load, under a new tag.**
```bash
make docker-build IMG=egress-proxies-operator:kvm2-dev
kind load docker-image egress-proxies-operator:kvm2-dev --name proxy-operator-demo
```
The tag **must** differ from the running `kvm-dev`. `make deploy` sets the image through
kustomize; an identical tag leaves the Deployment spec unchanged, so no rollout happens, so the
new process never starts, so the new providers config is never read. The ConfigMap carries no
generator hash, so nothing else forces a restart either.
The working tree is dirty (`review/`, `.claude/`), so `GIT_COMMIT` bakes in as `c10f98a-dirty`
and `--version` will say so. Cosmetic; commit or stash `review/` first to avoid it.
**2. Enable kvm2 in the deployed config.** Uncomment the `kvm2-home` block in
`config/manager/providers_config.yaml` (it is the shipped default, commented out). Keep its
`address: beelink.tail8c69c.ts.net:22` — the Tailscale FQDN, which is what
`~/.ssh/kvm_known_hosts` pins. `config/samples/providers-config.yaml` uses the short
`beelink:22`, which **fails host-key verification**; I hit exactly that when validating SSH
earlier in this session.
**3. Deploy.**
```bash
make deploy IMG=egress-proxies-operator:kvm2-dev
kubectl -n egress-proxies-operator-system rollout status deploy/egress-proxies-operator-controller-manager
```
`make deploy` rewrites `config/manager/kustomization.yaml`'s image block as a side effect; it
already carries `kvm-dev` from a previous run.
**4. STOP GATE — provider health, before anything is created.**
```bash
kubectl get ph
kubectl get ph kvm2-home -o yaml
```
`kvm2-home` must be `Ready 1/1` with reason `Reachable` and details carrying `hostname`,
`libvirtVersion`, `poolState: running` and `poolAvailableGiB`. This proves SSH, the host key,
the libvirt handshake and the pool **without creating a thing**, and it exercises the
dedicated-connection dial and close that H1 touched. A failure here is free to diagnose:
`CredentialsUnavailable`, `Unauthorized`, `Forbidden`, `PoolNotActive` each name their own
cause. Report and wait for a go before step 5.
**5. Two Proxies.** Two manifests in the scratchpad (not committed), modelled on
`config/samples/proxy_kvm2.yaml` — `proxy-kvm2-1` and `proxy-kvm2-2` in `default`, matching the
existing `proxy-beelink-*` convention, `provider: kvm2-home`, `zone: beelink`,
`machineType: small`, `image: debian-13`, port 3128 and the sample's Squid cloud-init. Apply
the first, let it finish, then the second — sequencing them is what makes the cache hit
observable.
```bash
kubectl get px -w
kubectl -n egress-proxies-operator-system logs -f deploy/egress-proxies-operator-controller-manager
```
**6. What to check, and which finding it exercises.**
| Check | Finding |
|---|---|
| `fetching base image into host pool` then `base image cached` with size, duration, digest | H2's wrapped reader on a real 415 MB upload |
| `virsh vol-list default` shows `base-kvm2-home-debian-13.qcow2` **and** `base-debian-13.qcow2` still there | H4: per-entry naming, kvm's cache untouched |
| Second Proxy logs no `fetching`, and the server is not hit again | H4 + the image lock: cache hit under the new name |
| `px` goes Provisioning → Running with an IP, without flapping back to Provisioning | H3: the address path, and no spurious demotion |
| `Healthy=True` | Squid answers a real CONNECT probe end to end |
| `virsh list --all` shows two new `proxy-*` domains | Create |
| After deleting both: domains and their `.qcow2`/`-seed.iso` gone, base image still present | Delete, and the cache surviving |
Optionally `curl -x http://<ip>:3128 https://example.com -I` from this box, if it routes to
192.168.0.0/24 — the existing kvm proxies live on that LAN.
**7. Cleanup.** `kubectl delete px proxy-kvm2-1 proxy-kvm2-2` and let the finalizer do the
work, then verify on beelink read-only. **Any leftover domain or volume: I print the exact
`virsh undefine --nvram` / `vol-delete` lines and you run them** — no write reaches the shared
host from me. Then decide whether the `kvm2-home` ConfigMap entry stays (keep it if you want
kvm2 running for real; `git checkout config/manager/providers_config.yaml` otherwise).
**Rollback at any point:**
```bash
kubectl -n egress-proxies-operator-system set image \
deploy/egress-proxies-operator-controller-manager manager=egress-proxies-operator:kvm-dev
```
### Who runs what
I drive steps 0-6 and stop at gate 4 for a go. Every beelink write is yours. Nothing here
touches `proxy-beelink-1/2`, `base-debian-13.qcow2`, your unrelated VMs, or the five
pre-existing orphan volumes the review found.
---
## Step 6 — Route klog into the structured logger
**Not a review finding.** Found while watching the Step 5 validation: some lines ignore the
zap encoder entirely, so no amount of `--zap-encoder=json` makes the stream parseable.
```
I0917 19:50:02.580097 1 leaderelection.go:258] "Attempting to acquire leader lease..." lock="..."
```
That is klog's own text sink. client-go's leader elector does `logger := klog.FromContext(ctx)`
(`leaderelection.go:257`), and controller-runtime hands the elector a context with no logr in it
(`pkg/manager/internal.go:461-473` — `mgr.Start` gets `ctrl.SetupSignalHandler()`, which carries
no logger). `klog.FromContext` falls back to `Background()`, and with no logger registered that
is klog's stderr writer. Nothing in this repo imports or configures klog; it is an indirect
dependency (`k8s.io/klog/v2 v2.140.0`) that ~60 linked packages use, including
`client-go/tools/{leaderelection,cache,record}`, `client-go/transport` and `client-go/rest`.
controller-runtime does not route it either — its only `klog.SetLogger` call is in its own test
suite.
### 6a. `cmd/main.go` — one line and an import
```go
logger := zap.New(zap.UseFlagOptions(&opts))
ctrl.SetLogger(logger)
klog.SetLogger(logger)
```
**Plain `SetLogger`, deliberately not `SetLoggerWithOptions(logger, klog.ContextualLogger(true))`.**
The contextual form is what klog's own docs recommend, because it skips a klogr round trip — but
it also disables klog's verbosity gate ("Such a logger cannot rely on verbosity checking in
klog", `contextual.go:83-86`), handing the decision to zap's level. With `Development: true`
that level is Debug, which enables V(1), so client-go's V(1) chatter that klog currently
suppresses at its default `-v=0` would start appearing. Plain `SetLogger` keeps klog's own check
in front ("klog will do its own verbosity checks before calling logger.V().Info",
`contextual.go:43-44`) and still redirects everything it does emit ("all log lines will be
suppressed from the regular output, and redirected to the logr implementation", `:47-48`).
Same fix, no new volume. Performance is irrelevant at this scale.
`go.mod` promotes `k8s.io/klog/v2` from indirect to direct. It is already in the module graph,
so nothing new is downloaded.
### 6b. `internal/discovery/server.go` — the other stderr bypass
The discovery API's `http.Server` (`:113-119`) sets no `ErrorLog`, so `net/http`'s own errors —
`Accept` failures, handler panics that escape — go to `log.Default()` and out to stderr in
stdlib format. `s.log` is already in scope two lines above:
```go
srv := &http.Server{
Handler: s.handler(),
ErrorLog: slog.NewLogLogger(logr.ToSlogHandler(s.log), slog.LevelError),
ReadHeaderTimeout: readHeadTimeout,
...
}
```
`logr.ToSlogHandler` is the pattern the repo already uses for the same purpose in
`internal/provider/gcp/wirelog.go:31-36`. This server is plain HTTP with no TLS, so `ErrorLog`
carries genuine faults rather than handshake noise from scanners — Error level is right.
### 6c. `config/manager/manager.yaml` — make the deployed stream parseable
```yaml
args:
- --leader-elect
- --health-probe-bind-address=:8081
- --providers-config=/etc/proxy-operator/providers.yaml
- --zap-encoder=json
- --zap-time-encoding=rfc3339
```
Verified that the flag wins over the in-code default: `addDefaults` only fills `NewEncoder`
`if o.NewEncoder == nil` (`zap.go:176-191`), and `BindFlags` sets it from `--zap-encoder`.
`--zap-time-encoding=rfc3339` matters because the JSON encoder defaults to `epoch`, which would
give `1758138422.31` instead of a timestamp anyone can read. Verbosity is left at debug, so the
per-RPC lines stay — that was the choice.
### Deliberately not doing
`opts := zap.Options{Development: true}` (`cmd/main.go:154-155`) stays. Flipping it to a
production default would change the format for every existing deployment, not just this one, and
the flags in 6c get the same result here without that blast radius.
### What stays unformatted regardless, and why that is fine
Flag-parse errors and `--version` (`cmd/main.go:160-163`) print before any logger exists, and
`utilruntime.Must` panics in `init()` run before `main`. controller-runtime's own metrics and
health servers also set no `ErrorLog` (`pkg/internal/httpserver/server.go:11-19`) and expose no
option to set one. All three are outside reach; none can fire during steady-state operation.
### Tests
None. All three changes are wiring: two struct fields and one manifest key. `cmd/main.go` has no
test coverage at all, and pinning `klog.SetLogger` hermetically means mutating global state that
would leak into every other test in the package. Verified by observation instead — see below.
Same call as H1's: a test that costs more than it protects does not earn its place.
### Verification
Rebuild, redeploy, and watch the rollout produce the line that started this:
```bash
kubectl -n egress-proxies-operator-system logs deploy/egress-proxies-operator-controller-manager \
| grep -i 'leader' | head -3
```
It must be JSON with an rfc3339 `ts`, not `I0917 ...`. Then confirm the whole stream parses:
```bash
kubectl -n egress-proxies-operator-system logs deploy/egress-proxies-operator-controller-manager \
| jq -e -s 'length > 0' >/dev/null && echo "every line is JSON"
```
That second command is the real check — it fails if *any* line is not JSON, which is exactly the
property being fixed.
---
## Ordering
**H2 → H3 → H4**, three commits.
H3 is orthogonal: it touches only `get.go` and `get_test.go`.
H2 and H4 both touch `image.go` but at different heights, and the order matters. H2's `image.go`
edit is a deletion — `discardVolume` moves out of `uploadImage` and up into `fetchImage` — while
H4 edits `imageSource.volumeName()`, `resolveImage`, and `cachedImage`'s digest branch ten lines
below. Landing H2 first means H4 rebases cleanly; the reverse means H4 is written against a
failure path H2 is about to restructure. Keeping H2's guard in `backend.go` rather than in
`countingReader` is part of the same separation.
Semantically, H2 makes `fetchImage` delete the base after the transfer connection is closed, and
that delete is only unambiguously safe once names are per-entry — so H2's commit message should
say the discard is still unfenced against a second entry sharing the name and that H4 closes it,
and H4's should point back. The deferred atomic-publish work then lands exactly where H2 put the
discard, on a volume this call created under a name no other entry can produce.
## Verification
```bash
go build ./... && go vet ./...
go test -race -count=1 ./internal/provider/kvm2/
make test
```
Per step, the check that actually matters is that the new test fails against the unfixed code —
stash the source edit, keep the test, watch it fail, restore. For H2 that means asserting
`eofReader` converts before the fix exists; for H3, that the dead-link subtest returns a
successful Provisioning instance today; for H4, that two entries produce one name today.
After H4, run the kvm2 integration test on beelink. **It has never run** — Step 11 is unchecked
in `docs/plans-executions/2026-09-16-1333-kvm2-provider.md`, every `base-*` on the host predates
kvm2, and both live domains carry the kvm v1 metadata namespace. It is the first end-to-end
exercise of the new name and does a real cold download beside kvm's untouched base.
Each step: its own commit on `feat/kvm2-provider` with the execution-file entry and its checklist
line ticked. `CHANGELOG.md` lands with a "validate and record" step once the user confirms the
work, as in the kvm campaign. Trailer:
`Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`.
@@ -0,0 +1,884 @@
# Plan: proxmox, a Proxmox VE provider built from the provider contract
**Created:** 2026-09-18 15:02
## Context
> **Superseded in places by the spec.** Phase 0 recon found ten corrections
> to the design below (listed in the execution log), and Phase 1 folded them
> into [docs/proxmox-provider-spec.md](../proxmox-provider-spec.md). Where a
> phase step here disagrees with the spec (the Create allocation loop, Get's
> check order, resize and seed delete as tasks, no delete before upload,
> `isoStorage` required, `vlanTag`, the agent messages), **the spec wins**.
> Test names in the phases below remain the minimum; spec entry 21 adds the
> ones for the corrections.
The provider contract (`docs/provider-contract.md`), the spec template
(`docs/provider-spec-template.md`) and the kvm2 build proved the workflow:
spec first, then implementation against the contract, one commit per step.
This plan adds a third VM-style backend, `proxmox`, on the Proxmox VE 9.x
REST API at `https://<host>:8006/api2/json`. The user has a real host at
192.168.2.8 running **9.2.20** (Debian 13 Trixie, QEMU 11) and prepares the
API token and a cloud-init-capable template before Phase 0 (checklist
below). Everything below is written against 9.2; where 8.x differed the
difference is called out, because the appendix and the guide must name the
version they were proven on.
The implementation runs in sessions whose context gets compacted, so every
phase is self-contained: it names what to read first, what it creates or
edits, the test names it must add, a `Verify:` clause, a "Done when" line
and a commit subject. A fresh session reading only this plan, the spec
(`docs/proxmox-provider-spec.md`, from Phase 1 on) and the execution log's
checklist (`docs/plans-executions/2026-09-18-1502-proxmox-provider.md`) must be able to
pick up where the last one stopped.
**Resumption protocol, run at the start of every phase.** Read this plan's
Context and the phase entry; read the spec; read the execution log's
checklist; run `git branch --show-current` (must be `feat/proxmox-provider`
from Phase 0 on), `git status --short` and `git log --oneline -8`. The first
unchecked box is the phase to do. If its files already exist partially, its
"Done when" line says whether it counts as done; finish it, never redo it. A
phase is finished only when its "Done when" holds and its execution-log entry
(a few sentences, exact commands, "worth noting", the box ticked) is
committed together with the code. Follow `CLAUDE.md` for the log format.
## Before Phase 0: what the user prepares on 192.168.2.8
Run as root on the Proxmox node. **`VM.Monitor` was removed in PVE 9.0**, so
the agent read rests on `VM.GuestAgent.Audit` alone and the role below must
not mention `VM.Monitor`. Drop any privilege `pveum` still rejects and note
it for F17.
```bash
pveum user add proxy-operator@pve --comment "egress-proxies-operator"
pveum role add ProxyOperator -privs "VM.Allocate VM.Clone VM.Audit VM.Config.CDROM VM.Config.CPU VM.Config.Memory VM.Config.Disk VM.Config.Network VM.Config.Options VM.PowerMgmt VM.GuestAgent.Audit Datastore.Audit Datastore.AllocateSpace Datastore.AllocateTemplate Sys.Audit SDN.Use"
pveum aclmod / -user proxy-operator@pve -role ProxyOperator
pveum user token add proxy-operator@pve operator --privsep 0
# The secret is printed once. Save it on the workstation as one line:
# proxy-operator@pve!operator=<secret>
# in ~/.config/proxmox-token (mode 0600). The same file becomes the
# Kubernetes Secret later.
cp /etc/pve/pve-root-ca.pem /tmp/ # copy to the workstation as ~/.config/proxmox-ca.pem, or use insecureSkipTLSVerify
```
A cloud-init template without a Proxmox cloud-init drive (the provider puts
its own NoCloud ISO on `ide2`). Debian 13's BIOS boot path did not boot
under kvm2, so the template boots UEFI:
```bash
wget https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
qm create 9000 --name debian-13-template --memory 2048 --cores 2 --ostype l26 \
--machine q35 --bios ovmf --efidisk0 local-lvm:1,efitype=4m,pre-enrolled-keys=0 \
--scsihw virtio-scsi-pci --net0 virtio,bridge=vmbr0 \
--serial0 socket --vga serial0 --agent 1
qm set 9000 --scsi0 local-lvm:0,import-from=/root/debian-13-generic-amd64.qcow2
qm set 9000 --boot order=scsi0
qm template 9000
```
Facts the user hands over at Phase 0: the token file path, the CA file path
(or "insecure"), the node name, the template VMID, the ISO-content storage
id (usually `local`), the VM disk storage (`local-lvm`), the bridge, and
whether the operator's cluster can route to that bridge's LAN. Everything
else Phase 0 reads from the API.
## Design decisions made with the user (not re-opened)
- **Backend and client.** Proxmox VE 9.x REST API, JSON, HTTP/1.1 only
(pveproxy offers no HTTP/2 on any release; only Proxmox Backup Server
does).
Thin client on `net/http` (`client-kind: thin-client`), no SDK, no new
dependency. Auth header
`Authorization: PVEAPIToken=<user>@<realm>!<tokenid>=<secret>`, read from a
mounted token file on every request (rotation free). TLS: `caFile` or
`insecureSkipTLSVerify` (warned at startup). Child spans from a parent-gated
otelhttp transport: `tracing.RestConfigWrapper()(base)` returns a plain
`http.RoundTripper` and works as is (`child-spans: transport-wrapper`).
- **Image source.** Clone a prepared template. Config catalogue
`templates: {<key>: {vmid: 9000}}`; `Placement.Image` is a key.
`Placement.Zone` is a node name from `nodes: [..]` (optional with one node).
`Placement.MachineType` is a key of `machineTypes: {<key>: {cores, memoryMiB,
diskGiB}}`; `diskGiB` (optional) grows the boot disk via `PUT .../resize`.
`Placement.Region` is ignored (documented).
- **Identity.** `primary-key: allocated-id`. VMID derived deterministically
from the Proxy UID (FNV-1a 64 into `vmidRange: {min, max}`, default
100000 to 199999) with forward probing on collision; a clone whose `newid`
is taken fails with a 500, which is the allocate-then-verify step. Create
first looks the name up in `GET /cluster/resources?type=vm`; an existing VM
with that name must carry our mark and a matching or empty UID, else
Permanent. providerID is location-free: `vms/<vmid>/<name>` (VMs migrate;
the node is resolved from `/cluster/resources` on every call). Get and
Delete verify the VM at that VMID has that name and our mark; a different
name means our VM is gone and the VMID was reused: Get returns NotFound,
Delete returns nil without touching it.
- **Cloud-init.** NoCloud seed ISO built in-process by reusing kvm2's seed
code, extracted into a shared package `internal/provider/nocloud` (its own
phase). Uploaded to `isoStorage` (default `local`) on the target node via
`POST /nodes/{node}/storage/{storage}/upload` (multipart, `content=iso`,
`filename=<name>-seed.iso`, streamed through `io.Pipe`, returns a UPID),
attached as `ide2: <storage>:iso/<name>-seed.iso,media=cdrom`. The template
must have no Proxmox cloudinit drive on ide2 (documented prerequisite).
Vendor-data installs qemu-guest-agent like kvm2 (`installGuestAgent`,
`vendorData`).
- **GC mark.** `gc-mark: written-metadata` in the VM `description`, which the
clone call accepts (`tags` is not a clone parameter, so tags would be a
mark window). Content, one per line: `proxy-operator-managed=true`,
`proxy-operator-uid=<uid>`, `proxy-operator-created-at=<RFC3339>`.
`created-at: written-at-create`. The config step after the clone also sets
tag `proxy_operator` for UI filtering only. **Underscore, not hyphen:**
current PVE tag validation accepts only lowercase letters, digits and
underscore, and a hyphen is rejected; the underscore form is valid under
both the strict and the permissive reading, so it is used regardless of
what F9 finds. ListByTag: one
`GET /cluster/resources?type=vm`; candidates are entries with tag
`proxy_operator` or name prefix `proxy-`; a `GET /nodes/{node}/qemu/{vmid}/config`
per candidate for the description; unmarked skipped; one unreadable VM
skipped with a V(1) line unless ctx ended; the resources call failing fails
the whole list (`list-partial: fail-whole`).
- **Create** (`create-return: blocking`, `provisioning-state: create-blocks`,
`async-ops: task-handle` and `backend-lock`), every step idempotent, in
this order: validate placement (Permanent before any call); resources
lookup by name (adopt or allocate); `POST /nodes/{tmplnode}/qemu/{tmplvmid}/clone`
`{newid, name, description, full=1, target?, storage?}`; wait the task
(`exitstatus != OK` is Transient with the message; a VM with `lock=clone`
on retry is waited on); upload the seed (always rebuilt, delete then
upload; before the config step because `ide2` may refuse a volume that
does not exist yet); config (`cores`, `memory`, `net0` bridge override if
configured, `agent=1`, `tags`, `ide2` seed); optional resize;
`POST .../status/start` and wait; poll `status/current` until `running`
(capped); return `vms/<vmid>/<name>`. Retry: name found running returns
the ID; found stopped with our mark continues from the seed step; found
locked waits.
- **Get.** Resources lookup by vmid: absent is NotFound; name mismatch or no
mark is NotFound (logged); `lock` set is Transient; `running` reads
`GET /nodes/{node}/qemu/{vmid}/agent/network-get-interfaces`, first
routable IPv4 excluding loopback and link-local gives Running, else
Provisioning (the agent not running is a 500 "QEMU guest agent is not
running", which is Provisioning, not an error); `stopped` is Stopped;
`unknown` is Transient; `qmpstatus=paused` is Stopped; anything else is
Stopped. UID and CreatedAt come from the description on every call, so a
Get is three calls: resources, config, agent.
- **Delete** (`delete-return: waits`, `secondary-resources: explicit`):
resolve by vmid via resources; absent or foreign is nil (logged); running
gets `POST status/stop` and a task wait; then
`DELETE /nodes/{node}/qemu/{vmid}?purge=1&destroy-unreferenced-disks=1` and
a task wait; then delete the seed ISO
`DELETE /nodes/{node}/storage/{storage}/content/{storage}:iso/<name>-seed.iso`
tolerating absence; a locked VM is Transient. Called again mid-delete it
repeats the same tolerant steps. GC blind spot: seed ISOs without a VM
(documented; the ISO name carries the instance name).
- **Errors**, one `classify(op, err)` over `*apiError{Status, Message, Errors}`:
401, 403, 400 and 404 are Permanent (404 means the path never exists;
NotFound comes only from the list-and-miss on resources and from the 500
"does not exist" message in Get and Delete, matched against captured
fixtures); 500 "does not exist" is NotFound (Transient inside Create); 500
"VM is locked" is Transient; 500 "already exists" is handled by the
allocator, otherwise Transient; 500 "not enough free space" or "no space
left" is QuotaExceeded once a real message is captured, else `quota:
absent`; other 5xx, 595, 596, network and ctx errors are Transient;
anything not an apiError is Transient. Error text: Proxmox puts most of it
in the HTTP status reason phrase (`HTTP/1.1 500 Configuration file '...'
does not exist`) and newer releases also in the body's `message`; the
client reads both, body first.
- **Checker.** One target per configured node, in config order, concurrently,
on the same http client: `GET /version` (Details `pveVersion`),
`GET /nodes/{node}/status` (Details `kernel`),
`GET /nodes/{node}/storage/{isoStorage}/status` (Details `isoStorage`,
`isoStorageAvailGiB`), template existence from `GET /cluster/resources?type=vm`
(the `template` flag, any node; Details `templates` as `<found>/<configured>`).
Reasons: the shared ones plus `TemplateNotFound` and `StorageNotFound`.
The `status.type` comment in `api/v1alpha1/providerhealth_types.go` gains
`proxmox`; `make manifests`.
- **Timeouts** (`timeouts: per-call`): request timeout 30s
(`requestTimeoutSeconds`); task-wait caps clone 10m, upload 2m, start and
stop 2m, destroy 5m, running wait 60s; all in a `budgets` struct
overridable in tests. A cancelled ctx cancels that request only; the
connection pool is unaffected. Per-name keyed lock for Create and Delete
(`internal/provider/keyedlock`, extracted from kvm2); Get and ListByTag
lock-free.
- **Logging** (`logging: tiered`): `logf.FromContext(ctx).WithName("proxmox").WithValues("provider", name)`,
`vmid` and `name` on instance lines; V(1) outcomes and one
`proxmox api call` line per seam call from the `observed` decorator; V(2)
request params and response summaries; never the token, never cloud-init,
never the multipart body, never `description` at V(1); no V(5). The
decorator logs, the transport traces: no `proxmox.<Method>` spans, so
nothing is double-counted.
- **Config writes** use `PUT .../config` (synchronous; the VM is stopped at
that point). `resize` is skipped when the boot disk's current `size=` is
at or above the target. Boot disk: first disk key in `boot: order=...`
that is not `net*` and not the ide2 cdrom, else `bootdisk`, else the first
of `scsi0`, `virtio0`, `sata0`, `ide0`; none is Permanent.
- **A UPID names its node** (`UPID:<node>:...`); task polling uses the node
parsed from the UPID.
- **Two VMs with our name** (Proxmox does not enforce unique names) make
Create and Get return Permanent naming both VMIDs; an admin resolves it.
- **Seam.** `api` interface with one method per endpoint (table below),
`httpAPI` production adapter, `fakeAPI` in `fake_test.go` keeping VMs, tasks
and ISOs in memory with lock simulation, task polls, per-method errors and
block channels, and a call log.
- **Integration test** `proxmox_integration_test.go` behind
`//go:build integration`, `PROXMOX_TEST_URL`, `PROXMOX_TEST_TOKEN_FILE`,
`PROXMOX_TEST_NODE`, `PROXMOX_TEST_TEMPLATE_VMID`, `PROXMOX_TEST_ISO_STORAGE`,
optional `_CA_FILE`, `_INSECURE`, `_BRIDGE`, `_STORAGE`, `_VMID_MIN`,
`_VMID_MAX`, `_TIMEOUT`, `_KEEP`, `_LOG_LEVEL`; `make test-integration-proxmox`.
- **Deployment.** Secret `proxmox-token` mounted optional at
`/etc/proxy-operator/proxmox/token`, `make proxmox-token-secret`, commented
entries in `config/manager/providers_config.yaml` and
`config/samples/providers-config.yaml`, `config/samples/proxy_proxmox.yaml`,
`docs/proxmox-provider.md`, `providers-existing.md` column and section,
`architecture.md` rows, README row, `testing.md` section, CHANGELOG after
confirmation. Branch `feat/proxmox-provider`, MR via the Gitea API (no `tea`).
## Backend facts Phase 0 must verify on 192.168.2.8 (numbered for citation)
- F1. Where error text lives: status reason phrase vs. JSON `message`; the
`errors` map shape on a 400.
- F2. Exact 500 message for a missing VM (`GET /nodes/{node}/qemu/999999/config`).
- F3. Exact 500 message for a locked VM (`POST status/start` on a VM with
`lock` set), and whether `PUT config` with `lock=backup` sets the lock and
`delete=lock` clears it through the API, or `qm unlock` is needed.
- F4. Exact 500 message when `newid` is in use on clone.
- F5. Exact 500 message for the agent not running, and for the agent call
on a stopped VM.
- F6. Exact message for no space, if it can be produced cheaply; otherwise
`quota: absent`.
- F7. Whether `PUT .../config` is refused while `lock=clone`, and with which
message.
- F8. `/cluster/resources?type=vm` fields present: `vmid`, `name`, `node`,
`status` values (`running`, `stopped`, `unknown`), `type` (`qemu`, `lxc`),
`lock`, `tags` (separator), `template`; whether `lock` appears there during
a clone.
- F9. Tag rules on 9.2: confirm a hyphen is rejected and `proxy_operator`
accepted, plus case folding, length, and the `tags` separator on
`PUT config` and in `/cluster/resources`. The underscore form stays either
way; this fact only decides what the guide tells users about their own
tags.
- F10. `description` on `POST .../clone`: accepted, survives verbatim
(newlines), max length; whether `name` accepts the 22-character
`proxy-<base32>` form.
- F11. `meta` in the cloned config: is `ctime` the clone time (evidence only;
`written-at-create` stays the profile).
- F12. Upload endpoint: field names (`content`, `filename`, file part name
`filename`), the filename rule (`.iso` suffix, no slashes), the response
(UPID), the privilege it needs, and whether re-uploading an existing name
is refused (then delete-then-upload) or overwrites. On 9.x `content`
accepts `iso`, `vztmpl` or `import` and still refuses `snippets`, which is
why the seed travels as an ISO; confirm `iso` is what an `.iso` needs.
- F13. `DELETE /nodes/{node}/storage/{storage}/content/{volid}`: response
shape (UPID or null) and the message for a missing volume.
- F14. `status/current` on a stopped VM: is `qmpstatus` present; on a paused
VM: `status=running`, `qmpstatus=paused`.
- F15. `POST .../clone`: whether a failed clone destroys the partial VM by
itself, and what a VM interrupted mid-clone looks like (config present,
`lock=clone`).
- F16. Task status shape: `status` (`running`, `stopped`), `exitstatus`
(`OK` or the error string), and how long a full clone of the template
takes.
- F17. Privileges: which of the prepared list the token really needs, in
particular the exact ACL path that satisfies `SDN.Use` for the `net0`
write (the 9.x docs describe it as "access SDN vnets and local network
bridges" without spelling out the path for a plain bridge), and
`VM.GuestAgent.Audit` for the agent read. Also whether the config step
needs `VM.Config.Cloudinit` even though only `ide2` is written.
- F18. HTTP/2 is not offered by pveproxy on any release (documented), so the
client disables it; confirm with `curl --http2 -I` that the connection
falls back to HTTP/1.1.
- F19. The template's disk key (`scsi0` or `virtio0`), its `boot` line, that
ide2 is free and no `cloudinit` drive exists.
- F20. `resize` on a stopped VM: request shape, the message when the target
is smaller or equal, and that it is synchronous.
- F21. Agent response shape: `result[].name`, `result[]["ip-addresses"][]`
with `ip-address`, `ip-address-type`.
- F22. The 401 body and reason phrase on 9.2. PVE 9.2 unified the
authentication-failure message so a failed login no longer leaks whether
the user exists, so any 401 text remembered from 8.x is wrong. Capture it
fresh into `err_unauthorized.json`.
- F23. Where the upload endpoint stages the request body, given that PVE 9
moved the node's `/tmp` to a tmpfs capped at half of RAM and cleans `/tmp`
and `/var/tmp` periodically. A seed ISO is tens of KiB so this cannot
bite in practice, but the observation belongs in the guide next to the
size limit.
## Proxmox endpoint map for the seam
| `api` method | HTTP |
|---|---|
| `Version(ctx)` | `GET /version` |
| `NodeStatus(ctx, node)` | `GET /nodes/{node}/status` |
| `StorageStatus(ctx, node, storage)` | `GET /nodes/{node}/storage/{storage}/status` |
| `ClusterResources(ctx)` | `GET /cluster/resources?type=vm` |
| `VMConfig(ctx, node, vmid)` | `GET /nodes/{node}/qemu/{vmid}/config` |
| `SetVMConfig(ctx, node, vmid, params)` | `PUT /nodes/{node}/qemu/{vmid}/config` |
| `Clone(ctx, node, vmid, cloneParams)` | `POST /nodes/{node}/qemu/{vmid}/clone` |
| `Resize(ctx, node, vmid, disk, size)` | `PUT /nodes/{node}/qemu/{vmid}/resize` |
| `Start(ctx, node, vmid)` | `POST /nodes/{node}/qemu/{vmid}/status/start` |
| `Stop(ctx, node, vmid)` | `POST /nodes/{node}/qemu/{vmid}/status/stop` |
| `VMStatus(ctx, node, vmid)` | `GET /nodes/{node}/qemu/{vmid}/status/current` |
| `Destroy(ctx, node, vmid)` | `DELETE /nodes/{node}/qemu/{vmid}?purge=1&destroy-unreferenced-disks=1` |
| `TaskStatus(ctx, node, upid)` | `GET /nodes/{node}/tasks/{upid}/status` |
| `AgentNetworkInterfaces(ctx, node, vmid)` | `GET /nodes/{node}/qemu/{vmid}/agent/network-get-interfaces` |
| `UploadISO(ctx, node, storage, filename, r, size)` | `POST /nodes/{node}/storage/{storage}/upload` (multipart) |
| `DeleteVolume(ctx, node, storage, volid)` | `DELETE /nodes/{node}/storage/{storage}/content/{volid}` |
## Package layout (`internal/provider/proxmox/`)
`proxmox.go` (`Provider`, `New`, `newProvider`, `withDefaults`, `logger`,
`node`, `wrapErr`, `lockName`, `providerOption`, `budgets`), `api.go` (the
`api` interface and DTOs `vmResource`, `vmConfig`, `vmStatus`, `taskStatus`,
`agentInterface`, `storageStatus`, `nodeStatus`, `versionInfo`,
`cloneParams`), `client.go` (`httpAPI`, `newHTTPAPI`, `do`, `readToken`,
`decodeConfig`), `errors.go` (`apiError`, `classify`, `permanentf`,
`transientf`, `isNotExist`, `isLocked`, `isAlreadyExists`,
`isAgentNotRunning`, `isNoSpace`), `id.go` (`formatProviderID`,
`parseProviderID`, `vmidFor`), `mark.go` (`instanceMark`,
`formatDescription`, `parseDescription`), `state.go` (`mapState`,
`firstIPv4`), `observe.go` (`observed`, `observe`), `task.go` (`parseUPID`,
`waitTask`, `waitUnlocked`, `waitRunning`), `seed.go` (`seedISOName`,
`seedVolID`, `uploadSeed`), `create.go`, `get.go`, `delete.go`, `list.go`,
`check.go`, plus `fake_test.go`, `provider_test.go`,
`proxmox_integration_test.go`, `testdata/*.json`. Shared:
`internal/provider/config_proxmox.go`, `internal/provider/nocloud/`,
`internal/provider/keyedlock/`.
Reused as-is: `provider.NameFromUID`, `provider.Wrap`, the `Err*` sentinels,
`provider.Label*` constants, `provider.Reason*` constants,
`tracing.RestConfigWrapper`, `tracing.WithTracerProvider`, kvm2's test
idioms (`fixture`, `assertProviderError`, `logCapture`).
## Steps (one commit per phase, on `feat/proxmox-provider` off `main`)
- **Phase 0 (orchestrator with the user): plan file, branch, host recon,
fixtures.** Read first: contract §5, §4.9, §7;
`docs/plans-executions/2026-09-16-1333-kvm2-provider.md` Step 0 entry.
Do, in this order: `git switch main && git pull --ff-only`; copy this plan
to `docs/plans/2026-09-18-1502-proxmox-provider.md` with the real timestamp
(`date "+%Y-%m-%d-%H%M"`), commit it alone on `main`
(`docs: plan the proxmox provider`); create `feat/proxmox-provider`. Ask
the user for the hand-over facts listed above. Then, with the user's
go-ahead (each `curl` is a permission prompt; read-only ones first, one at
a time), capture F1 to F21 with
`curl -s --cacert "$CA" -D /tmp/h.txt -o /tmp/b.json -w '%{http_code}\n' -H "Authorization: PVEAPIToken=$(cat "$TOKEN_FILE")" https://192.168.2.8:8006/api2/json/<path>`,
storing each response as `internal/provider/proxmox/testdata/<name>.json`
in the shape `{"status": <code>, "reason": "<status line after the
code>", "body": "<raw body>"}`: `version.json`, `node_status.json`,
`storage_status.json`, `cluster_resources.json`,
`vm_config_template.json`, `vm_config_clone.json`,
`status_current_running.json`, `status_current_stopped.json`,
`agent_network_get_interfaces.json`, `task_status_running.json`,
`task_status_stopped_ok.json`, `task_status_stopped_error.json`,
`upload_response.json`, `content_delete_response.json`,
`err_vm_missing.json`, `err_locked.json`, `err_newid_in_use.json`,
`err_agent_not_running.json`, `err_agent_vm_stopped.json`,
`err_unauthorized.json` (a wrong secret), `err_forbidden.json` (a path
the token lacks), `err_bad_param.json` (`newid=abc`), `err_no_space.json`
(only if F6 is cheap), `err_volume_missing.json` (F13). The write
experiments: one clone of the template into the top of the VMID range
(`newid=199999`, a multi-line `description`, a `name` of the `proxy-`
form), a config read of the clone (F10, F11, F19), a `PUT config` while
it is locked if timing allows (F7), an upload of a 1 KiB
`recon-seed.iso` (F12) and its delete (F13), a resize attempt (F20), a
start, an agent read (F5, F21), a stop, then destroy with
`purge=1&destroy-unreferenced-disks=1`; a final resources read shows
nothing remains. Redact nothing but the token. Write the answers to F1 to
F21 and the privilege outcome into the execution log's Phase 0 entry, one
line per fact; Phase 1 cites them as Evidence. Create the execution log
`docs/plans-executions/2026-09-18-1502-proxmox-provider.md` with the checklist (Phase
0 to Phase 10) and this entry.
Verify: `ls internal/provider/proxmox/testdata | wc -l` is at least 22;
`grep -rl 'PVEAPIToken' internal/provider/proxmox/testdata` is empty;
every fixture parses as JSON; the host shows no VM in the range and no
`recon-seed.iso`.
Done when: the plan commit is on `main`, the branch exists, the fixtures
and the execution log with F1 to F21 are committed. Commit: `proxmox:
captured API fixtures and host facts from 192.168.2.8`.
- **Phase 1 (orchestrator): the spec.** Read first: contract §2, §4, §5,
§6, §11, §12; `docs/provider-spec-template.md` end to end;
`docs/kvm2-provider-spec.md` for the level of detail; this plan's
decisions; the Phase 0 log entry. Create `docs/proxmox-provider-spec.md`
answering all 24 entries: 1 `vms/<vmid>/<name>`, strict parsing, the
node-name and storage-id regexps; 2 `allocated-id`, `vmidFor(uid, rng,
attempt)`, the two-VMs-with-our-name rule, the adoption guard; 3 the
placement table (Region ignored); 4 the template catalogue and the "add a
key, never edit" warning; 5 the seed ISO via `nocloud` and the ide2
prerequisite; 6 `machineTypes` with `diskGiB`; 7 `provisioning-window`,
`discovered` via the guest agent, the IPv4 filter, `Port` unused; 8
`written-metadata` in `description` plus the tag, `written-at-create`, F9
to F11 as Evidence; 9 the state table, `create-blocks`; 10 the ordered
Create steps with what a retry finds at each, `task-handle` plus
`backend-lock`, the budgets; 11 the Delete order, the ISO blind spot,
node-local ISO storage after a migration; 12 `fail-whole`, the candidate
rule, one config read per candidate; 13 the classify table with the F1 to
F6 messages, `not-found: error-code` plus list-and-miss on resources
(allowed because offline nodes' guests appear as `unknown`, not absent);
14 `mounted-files` (Secret `proxmox-token`, key `token`, path
`/etc/proxy-operator/proxmox/token`), `ca-file` or
`insecureSkipTLSVerify`, the privilege list from F17, rotation free; 15
the config table (Phase 2's struct); 16 the Checker; 17 `thin-client`,
`transport-wrapper`, the seam's method list; 18 the budgets table; 19 the
keyed lock per name; 20 the log keys; 21 the §11 list with the test names
from Phases 2 to 8 (copy from this plan); 22 the deployment list; 23 the
docs list; 24 the accepted limits (blocking Create up to the clone budget,
ISO blind spot, VMID range shared with humans, node-local ISO storage, no
adoption of foreign VMs, two-installs GC caveat, `unknown` status while a
node is down).
Verify: `grep -c '^\*\*Answer:\*\*$' docs/proxmox-provider-spec.md` is 0;
every axis code used appears in contract §4.20; Evidence lines cite F
numbers; `grep -c '—' docs/proxmox-provider-spec.md` is 0.
Done when: the spec is committed with all 24 entries and the Phase 1 box
ticked. Commit: `docs: proxmox provider spec`.
- **Phase 2 (implementer): config block, registration, package skeleton.**
Read first: spec entries 1, 2, 3, 6, 8, 9, 13, 15; contract §10, §5, §6;
`internal/provider/config.go`, `config_kvm.go`, `config_test.go`,
`config_kvm_test.go`; `internal/provider/kvm2/{kvm2.go,id.go,metadata.go,state.go,errors.go}`
and their tests as the pattern; `cmd/main.go`; `testdata/err_*.json`.
Create `internal/provider/config_proxmox.go`: `ProxmoxConfig{URL,
TokenFile, CAFile string; InsecureSkipTLSVerify bool; Nodes []string;
ISOStorage, Storage, Bridge string; VMIDRange *ProxmoxVMIDRange; Templates
map[string]ProxmoxTemplate; MachineTypes map[string]ProxmoxMachineType;
RequestTimeoutSeconds int; VendorData string; InstallGuestAgent *bool}`,
`ProxmoxVMIDRange{Min, Max int}`, `ProxmoxTemplate{VMID int}`,
`ProxmoxMachineType{Cores int32; MemoryMiB int64; DiskGiB int64}`,
`validateProxmox(prefix string, c *ProxmoxConfig) error` (URL http(s)
with a host; `tokenFile` non-empty; at least one node, unique, matching
`proxmoxNodeName = ^[a-z0-9][a-z0-9-]*$`; `isoStorage` and `storage`
matching `proxmoxStorageID = ^[A-Za-z][A-Za-z0-9_.-]*$`; `vmidRange` min
at least 100, max at most 999999999, min below max; at least one template
with `vmid` at least 100; at least one machine type with cores and
memoryMiB above zero and diskGiB not negative; `requestTimeoutSeconds`
not negative). Edit `config.go`: `Proxmox *ProxmoxConfig` with tag
`proxmox,omitempty`, `setBlocks`, the allow-list, the dispatch. Edit
`cmd/main.go`: `"proxmox": proxmox.New`. Create in
`internal/provider/proxmox/`: `proxmox.go` (`Provider{name, cfg, api,
now, budgets, nameLocks, tracing}`; until Phase 3 a local `keyedLocks`
copy in `locks.go` that Phase 3 deletes; `New` never dials: builds the
client, `os.Stat`s the token file and warns, warns on
`insecureSkipTLSVerify`; `withDefaults`: `isoStorage` `local`, `vmidRange`
100000 to 199999, `requestTimeoutSeconds` 30, `installGuestAgent` true;
`node(zone)` mirrors kvm2's `host`; the four methods stubbed with
`transientf("not implemented")`), `api.go` (the interface and DTOs;
`vmConfig` is `map[string]string` with scalars rendered as strings by
`decodeConfig`), `id.go`, `mark.go` (keys from `provider.LabelManaged`,
`provider.LabelManagedYes`, `provider.LabelUID` and
`proxy-operator-created-at`; `parseDescription` tolerates extra lines
and CRLF), `state.go`, `errors.go` (`apiError{Status int; Message
string; Errors map[string]string}` whose `Error()` never includes headers
or URLs; `classify` per spec entry 13; matcher helpers keyed on the exact
substrings captured in F2 to F6). Tests: `config_proxmox_test.go`
`TestValidateProxmox_table` (missing url, non-http scheme, missing
tokenFile, no nodes, duplicate node, node with a slash, bad isoStorage
id, vmidRange min below 100, min above max, no templates, template vmid
below 100, no machineTypes, cores zero, negative diskGiB, negative
timeout, valid full, valid minimal) with a `validProxmoxYAML` const;
`config_test.go` rows `proxmox type with kvm block`, `kvm type with
proxmox block`, `proxmox minimal entry loads`. `id_test.go`:
`TestFormatProviderID_roundTrip`, `TestParseProviderID_malformed`,
`TestVMIDFor_deterministicAndInRange`, `TestVMIDFor_probesForwardAndWraps`.
`mark_test.go`: `TestFormatDescription_keys`, `TestParseDescription_table`.
`state_test.go`: `TestMapState_table`, `TestFirstIPv4_filters`.
`errors_test.go`: `TestClassify_table` (every class, every matched
message loaded from `testdata/err_*.json` through `loadFixture(t, name)
*apiError`, `op == "create"` turning NotFound into Transient,
`context.Canceled`, a `*net.OpError`, `errors.New("x")`,
already-classified errors keep their class), `TestAPIError_matchers`,
`TestPermanentfAndTransientf`. `proxmox_test.go`:
`TestNew_doesNotDialAndWarnsOnMissingToken`,
`TestNew_insecureSkipTLSVerifyWarns`, `TestNew_nilBlockDoesNotPanic`,
`TestWithDefaults_table`, `TestNode_table`,
`TestWrapErr_carriesProviderNameOpAndID`.
Verify: `grep -c '"proxmox"' internal/provider/config.go` is 3;
`go build ./... && go vet ./... && go test -race -count=1 ./internal/provider/... ./cmd/... && go test -short ./...`;
the fixture loader fails loudly on a missing file.
Done when: `go test -race ./internal/provider/proxmox/` passes with the
stubs in place and the constructor is registered. Commit: `proxmox:
config block, IDs, marks, state mapping, classify, registration`.
- **Phase 3 (implementer): extract `nocloud` and `keyedlock` from kvm2.**
Read first: `internal/provider/kvm2/{seed.go,seed_test.go,kvm2.go,kvm2_test.go,create.go}`;
contract §1.4, §2.1; spec entries 5 and 19;
`grep -n 'seed.go\|keyedLocks\|buildSeedISO' docs/*.md`. Create
`internal/provider/nocloud/seed.go` exporting `BuildSeedISO(name,
userData, vendorData string, w io.Writer) error` (plain errors, an
`ErrEmptyName` sentinel instead of kvm2's `permanentf`),
`BuildVendorData(configured string, installAgent bool) (string, error)`,
`GuestAgentScript`, `SeedLabel`; move the seed tests to
`nocloud/seed_test.go` under their existing names. Create
`internal/provider/keyedlock/keyedlock.go` exporting `Locks`, `New()
*Locks`, `(*Locks).Acquire(ctx, key) (release func(), err error)`; move
the `TestKeyedLocks_*` tests. Edit kvm2: delete `seed.go`, `seed_test.go`,
the `keyedLocks` type and its tests; `create.go` calls
`nocloud.BuildVendorData` and `nocloud.BuildSeedISO` and wraps their
errors with `permanentf`; `kvm2.go` uses `*keyedlock.Locks`. Edit
`internal/provider/proxmox/proxmox.go` to use `keyedlock.Locks` and delete
`locks.go`. Fix the doc mentions so they name the new packages.
Verify: `go test -race -count=1 ./internal/provider/...`; kvm2's test
count drops by exactly the moved tests and the two new packages carry
them; `grep -rn 'iso9660' internal/provider/kvm2/` is empty;
`go vet -tags=integration ./...` still compiles.
Done when: kvm2 and proxmox import both packages, all tests pass, the docs
name the new packages. Commit: `provider: extract nocloud seed builder
and keyedlock from kvm2`.
- **Phase 4 (implementer): seam, HTTP client, decorator, fake.** Read first:
spec entries 14, 17, 18, 20; contract §4.15, §7, §9;
`internal/tracing/transport.go`; `internal/provider/kvm2/{observe.go,observe_test.go,fake_test.go}`;
`internal/provider/proxmox/{api.go,errors.go}`; `testdata/*.json`. Create
`client.go`: `httpAPI{base *url.URL; http *http.Client; tokenFile
string}`; `newHTTPAPI(cfg provider.ProxmoxConfig, opts ...tracing.Option)
(*httpAPI, error)` builds a transport with `DialContext` timeout 10s,
`TLSHandshakeTimeout` 10s, `ResponseHeaderTimeout` from the request
timeout, `IdleConnTimeout` 90s, `MaxIdleConnsPerHost` 4,
`ForceAttemptHTTP2: false`, an empty `TLSNextProto` map (F18), `RootCAs`
from `caFile` or `InsecureSkipVerify` from the flag, wrapped with
`tracing.RestConfigWrapper(opts...)(transport)`; `http.Client.Timeout` is
the request timeout; `do(ctx, method, path string, query, form
url.Values, out any) error` reads the token file on every call (missing
file `transientf`, a file without `!` and `=` `permanentf`), sets the
header, decodes `{"data": ...}` into `out`, turns any non-2xx into
`*apiError` per F1 with the error body read capped at 64 KiB;
`UploadISO` streams a multipart body through `io.Pipe` with a goroutine
writing `content=iso`, `filename` and the file part. Create `observe.go`:
`observed{inner api; provider string}` writing one V(1) `proxmox api
call` line per seam call with `call`, `node`, `vmid`, `storage`, `upid`,
`duration`, `httpStatus` and `error` on failure, V(2) adding request
params and a response summary; `observe(inner api, providerName string)
api`; `New` wraps `httpAPI` in it. Create `fake_test.go`: `fakeAPI{mu; vms
map[int]*fakeVM; tasks map[string]*fakeTask; isos
map[string]map[string][]byte; hooks; block; calls; log; taskPolls int;
cloneFailsWith, startFailsWith string; agentNotRunning, cloneLeavesLock
bool}`, `fakeVM{node, name, status, qmpstatus, lock string; template
bool; config map[string]string; ifaces []agentInterface}`,
`fakeTask{node string; polls int; exit string; onDone func()}`; `Clone`
creates the VM with `lock=clone` and a task that clears the lock or, per
`cloneFailsWith`, removes the VM (F15); `Start` and `Stop` flip status
through a task; `Destroy` removes the VM through a task; `UploadISO`
records bytes; `DeleteVolume` errors with the F13 message when absent;
`enter(ctx, method)` for blocks and hooks like kvm2; helpers `addVM`,
`addTemplate`, `failWith`, `blockOn`, `callCount`, `callLog`,
`fakeErr(status int, msg string) *apiError`. Tests: `client_test.go` over
`httptest.NewTLSServer`: `TestClient_sendsTokenHeaderFromFileOnEveryRequest`
(rotate the file between two calls), `TestClient_missingTokenFileIsTransient`,
`TestClient_unparsableTokenFileIsPermanent`,
`TestClient_apiErrorFromStatusReasonAndBody` (serve each
`testdata/err_*.json` verbatim), `TestClient_decodesDataEnvelope`,
`TestClient_http1Only`, `TestClient_requestTimeoutIsTransient`,
`TestClient_cancelledContextReturnsPromptly`,
`TestClient_childSpanUnderParent`, `TestClient_noSpanWithoutParent`,
`TestClient_tokenNeverInErrorsOrLogs` (sentinel secret echoed by the
server in a 401 body), `TestClient_caFileTrustsPrivateCA`,
`TestClient_insecureSkipsVerification`, `TestClient_uploadStreamsMultipart`
(the server parses the multipart; the client never `io.ReadAll`s the
reader), `TestClient_destroyQueryFlags`. `observe_test.go`:
`TestObserve_everySeamMethodLogsOneLine` (reflection over `api`),
`TestObserve_detailOnlyAtV2`, `TestObserve_uploadBodyNeverLogged`,
`TestObserve_errorFieldsOnFailure`, `TestObserve_v0Silent`.
Verify: `go test -race -count=2 ./internal/provider/proxmox/`;
`grep -n 'ReadAll' internal/provider/proxmox/client.go` shows only the
bounded error-body read; `grep -n 'context.Background()' internal/provider/proxmox/*.go`
is empty outside tests.
Done when: the client passes its tests against the fixtures and the fake
compiles against the full `api` interface. Commit: `proxmox: thin HTTP
client, seam, log decorator and in-memory fake`.
- **Phase 5 (implementer): Get, ListByTag, task helpers.** Read first: spec
entries 7, 8, 9, 12, 13; contract §2.2, §2.4, §4.7, §4.12;
`internal/provider/kvm2/{get.go,get_test.go,list.go,list_test.go}`; the
resources, config, status, agent and task fixtures. Create `task.go`:
`parseUPID(upid) (node string, err error)` (Permanent when malformed),
`waitTask(ctx, log, api, upid, budget) error` polling `TaskStatus` every
`budgets.taskPoll` (2s; tests shrink it) until `status == "stopped"`,
`exitstatus != "OK"` giving `transientf` with the message, the budget
giving `transientf`, ctx giving Transient; `waitUnlocked(ctx, api, node,
vmid, budget)` polling `VMConfig` until no `lock`. Create `get.go`:
`resolve(ctx, api, vmid) (vmResource, bool, error)` (one resources call,
`Type == "qemu"` and the vmid), the name check, `VMConfig` for the mark,
`lock` Transient, `unknown` Transient, `running` reads the agent
(`isAgentNotRunning` gives no IP, other errors Transient), `mapState`.
Create `list.go` per spec entry 12; `Instance` carries ID, UID, CreatedAt
only. Replace the two stubs. Tests: `task_test.go` `TestParseUPID_table`,
`TestWaitTask_table` (ok, error exitstatus, budget exceeded, ctx
cancelled, poll count), `TestWaitUnlocked_table`. `get_test.go`:
`TestGet_missingVMIsNotFound`, `TestGet_nameMismatchIsNotFound`,
`TestGet_unmarkedIsNotFound`, `TestGet_runningWithoutIPIsProvisioning`,
`TestGet_runningWithIPIsRunning`, `TestGet_agentNotRunningIsProvisioning`,
`TestGet_agentOtherErrorIsTransient`, `TestGet_ipFilterSkipsLoopbackAndLinkLocal`,
`TestGet_stoppedIsStopped`, `TestGet_pausedIsStopped`,
`TestGet_unknownStatusIsTransient`, `TestGet_lockedIsTransientNeverNotFound`,
`TestGet_unknownStateIsStopped`, `TestGet_readsUIDAndCreatedAtFromBackend`,
`TestGet_unmanagedDescriptionHasEmptyUID`, `TestGet_malformedIDIsPermanent`,
`TestGet_resourcesFailureIsTransient`, `TestGet_twoVMsWithOurNameIsPermanent`.
`list_test.go`: `TestListByTag_excludesUnmarkedVMs`,
`TestListByTag_emptyUIDStillListed`, `TestListByTag_candidatesByTagOrNamePrefix`,
`TestListByTag_skipsLXCAndTemplates`, `TestListByTag_resourcesFailureFailsWhole`,
`TestListByTag_unreadableVMSkipped`, `TestListByTag_unreadableVMWithEndedContextFails`,
`TestListByTag_unknownStatusStillAttempted`, `TestListByTag_doesNotReadStatusOrAgent`,
`TestListByTag_idsAcceptedByParse`.
Verify: `go test -race -count=2 ./internal/provider/proxmox/`;
`grep -n 'not implemented' internal/provider/proxmox/*.go` lists only
Create and Delete.
Done when: Get and ListByTag are real and every listed test passes.
Commit: `proxmox: Get, ListByTag and task polling`.
- **Phase 6 (implementer): Create.** Read first: spec entries 2, 3, 4, 5, 6,
10, 19; contract §2.1, §4.2, §4.10, §4.11, §5;
`internal/provider/kvm2/{create.go,create_test.go}`;
`internal/provider/nocloud`; F4, F7, F10, F12, F15, F16, F19, F20 in the
Phase 0 log. Create `seed.go`: `seedISOName(name)` is `<name>-seed.iso`,
`seedVolID(storage, name)` is `<storage>:iso/<name>-seed.iso`,
`uploadSeed(ctx, log, api, node, storage, req, cfg)` builds vendor-data
and the ISO into a `bytes.Buffer` (tens of KiB), deletes an existing
volume tolerating absence, uploads with the buffer's length, waits the
task with `budgets.upload`, logs `cloudInitBytes` only. Create `create.go`
implementing the ordered steps: placement validation (`node`,
`MachineTypes`, `Templates`, all Permanent naming field and value), the
per-name lock, `ClusterResources` once for both the name lookup (adoption
guard, then `waitUnlocked` if locked, then continue from the seed step if
stopped, return if running) and the template's node (absent template is
Permanent); allocation loop `for attempt := 0; attempt < maxProbes (32)`
calling `Clone` with `vmidFor(req.UID, rng, attempt)`, `isAlreadyExists`
moving to the next attempt, exhaustion Transient; `waitTask` with
`budgets.clone`; `uploadSeed`; `SetVMConfig` with `cores`, `memory`,
`agent=1`, `tags=proxy_operator`, `ide2=<volid>,media=cdrom`, and `net0`
rewritten from the cloned config with `bridge=<cfg.Bridge>` only when
configured (MAC kept); `bootDisk(cfg)` and `Resize` when `diskGiB` is
above the current `size=`; `Start` and `waitTask` with `budgets.start`;
`waitRunning` on `VMStatus` with `budgets.running`; return the ID.
`isNotExist` inside Create is Transient (classify with `op == "create"`).
Tests `create_test.go`: `TestCreate_repeatReturnsSameIDAndCreatesNothing`,
`TestCreate_resumesAfterEachPartialStep` (after clone task, after seed
upload, after config, after resize, after start, after running),
`TestCreate_returnsOnlyWhenRunning`, `TestCreate_runningWaitCapIsTransient`,
`TestCreate_rejectsUnknownPlacement`, `TestCreate_zoneOptionalWithOneNode`,
`TestCreate_regionIgnored`, `TestCreate_adoptionWithForeignUIDIsPermanent`,
`TestCreate_adoptsOnlyMarkedVMs`, `TestCreate_duplicateNameIsPermanent`,
`TestCreate_vmidCollisionProbesForward`, `TestCreate_vmidRangeExhaustedIsTransient`,
`TestCreate_waitsForCloneLockOnRetry`, `TestCreate_cloneTaskFailureIsTransient`,
`TestCreate_cloneTaskTimeoutIsTransient`, `TestCreate_markWrittenInCloneCall`,
`TestCreate_tagSetInConfigStep`, `TestCreate_seedAlwaysRebuilt`,
`TestCreate_seedUploadedBeforeConfig`, `TestCreate_configSetsCoresMemoryAgentAndSeed`,
`TestCreate_bridgeOverrideKeepsMAC`, `TestCreate_noBridgeOverrideLeavesNet0`,
`TestCreate_resizeOnlyWhenLarger`, `TestBootDisk_table`,
`TestCreate_startTaskFailureIsTransient`, `TestCreate_templateMissingIsPermanent`,
`TestCreate_notExistInsideCreateIsTransientNotNotFound`,
`TestCreate_cloudInitSentinelNeverLoggedOrInErrors` (V(2) capture, every
error path), `TestCreate_concurrentSameNameSerialised`,
`TestCreate_cancelledMidWaitReturnsPromptly`.
Verify: `grep -n 'CloudInit' internal/provider/proxmox/create.go internal/provider/proxmox/seed.go`
shows only `len(req.CloudInit)` and the pass-through into
`nocloud.BuildSeedISO`; `go test -race -count=2 ./internal/provider/proxmox/`;
`grep -n 'not implemented'` lists only Delete.
Done when: Create is real and every listed test passes. Commit:
`proxmox: Create by cloning a template, seed upload, config, start`.
- **Phase 7 (implementer): Delete, Checker, CRD comment.** Read first: spec
entries 11, 16; contract §2.3, §4.3, §4.13, §8;
`internal/provider/kvm2/{delete.go,delete_test.go,check.go,check_test.go}`;
`internal/provider/check.go`; `api/v1alpha1/providerhealth_types.go`; F13,
F17 in the Phase 0 log. Create `delete.go`: parse the ID (Permanent), the
per-name lock, `resolve`; absent is a logged no-op; name mismatch or
unmarked is a logged no-op; `lock` Transient; `unknown` Transient;
`running` gets `Stop` and `waitTask` (`budgets.stop`); `Destroy` and
`waitTask` (`budgets.destroy`), `isNotExist` from `Destroy` being
success; `DeleteVolume` of the seed on the VM's node tolerating the F13
absent message; nil. Create `check.go`: `var _ provider.Checker =
(*Provider)(nil)`; `reasonTemplateNotFound = "TemplateNotFound"`,
`reasonStorageNotFound = "StorageNotFound"`; `Check` fans out one
goroutine per node in config order (`wg.Go`), each `checkNode` running
`Version`, `NodeStatus`, `StorageStatus` (a 500 "does not exist" is
`StorageNotFound`; a storage whose `content` lacks `iso` is
`StorageNotFound` with a message saying so), `ClusterResources` for the
templates; `checkReason(err)`: ctx ended `Timeout`, 401 `Unauthorized`,
403 `Forbidden`, other `*apiError` `APIError`, network `Unreachable`;
Details `pveVersion`, `kernel`, `isoStorage`, `isoStorageAvailGiB`,
`templates`; `Name` is the node, `Address` the base URL. Edit
`api/v1alpha1/providerhealth_types.go` (`kubernetes, gcp, kvm, kvm2 or
proxmox`) and run `make manifests`. Tests `delete_test.go`:
`TestDelete_malformedIDIsPermanent`, `TestDelete_twiceSucceeds`,
`TestDelete_absentIsNoOp`, `TestDelete_foreignNameIsNoOp`,
`TestDelete_unmarkedIsNoOp`, `TestDelete_runningVMStoppedFirst`,
`TestDelete_stoppedVMSkipsStop`, `TestDelete_lockedIsTransient`,
`TestDelete_unknownStatusIsTransient`, `TestDelete_removesSeedISO`,
`TestDelete_toleratesMissingISO`, `TestDelete_destroyTaskFailureIsTransient`,
`TestDelete_usesPurgeAndDestroyUnreferencedDisks`,
`TestDelete_concurrentSameNameSafe`, `TestDelete_noOpLinesAreSilentAtV0`;
in `get_test.go`: `TestGet_afterDeleteReturnsNotFound`,
`TestGet_duringDeleteStillReturnsInstance` (block the fake's `Destroy`).
`check_test.go`: `TestCheck_oneEntryPerNodeInConfigOrder`,
`TestCheck_nodesCheckedConcurrently`, `TestCheck_expiredContextReturnsPromptly`,
`TestCheck_reasonsTable`, `TestCheck_detailsKeys`, `TestCheck_logsVerdictPerNode`,
`TestCheckReason_table`.
Verify: `go test -race -count=2 ./internal/provider/proxmox/ ./internal/providerhealth/`;
`git diff --stat config/` shows only the CRD description line;
`grep -rn 'not implemented' internal/provider/proxmox/` is empty.
Done when: all four contract methods and `Check` are real. Commit:
`proxmox: Delete with seed cleanup, health Checker, CRD type comment`.
- **Phase 8 (implementer): contract sweep, observability sweep, integration
test, Makefile, testing.md.** Read first: contract §11 and §9; spec
entries 17, 18, 20, 21; `internal/provider/kvm2/{provider_test.go,kvm2_integration_test.go}`;
`internal/provider/tracing_test.go`; `Makefile` integration targets;
`docs/testing.md` kvm2 rows and section. Create `provider_test.go`: the
shared `fixture` (fake, log capture, budgets shrunk), `assertProviderError`,
`TestProvider_cancelledContextReturnsPromptly` (each method blocked on
its first seam call), `TestProvider_everyErrorIsWrappedWithConfiguredName`,
`TestLogging_v0SilentV1OutcomeV2Detail` (V(1) lines `proxmox vm ensured
running`, `proxmox vm fetched`, `proxmox vm deleted`, `proxmox vms
listed`, `proxmox node checked`, every line with `provider`, instance
lines with `vmid` and `name`; V(2) has `cloudInitBytes` and never the
text), `TestTracing_httpSpansUnderOperationSpan` (a real `httpAPI`
against an `httptest` server playing the fixtures for a Get, wrapped in
`provider.WithTracing`, with a span recorder: every HTTP span is a child
of `provider.get` and no attribute carries the token). Create
`proxmox_integration_test.go` (`//go:build integration`):
`loadIntegrationEnv` with the `PROXMOX_TEST_*` variables, `providerConfig()`,
the `integrationLog` pattern from kvm2; `TestProxmox_lifecycle` asserting
in order: `New` returns within 100 ms without a request; `Check` Ready
with `pveVersion`, `isoStorageAvailGiB` and `templates`; `Create` returns
`vms/<vmid>/<name>` with the vmid in range; a second `Create` returns the
same ID and the resources count is unchanged; `Get` reaches Running with
an IPv4 within the budget, UID and CreatedAt as written; `ListByTag`
contains it; a TCP connect to the guest's port is logged only; `Delete`
nil; `Get` NotFound; `Delete` again nil; the ISO storage has no
`<name>-seed.iso` and resources has no VM with the name; an HTTP span is
a child of `provider.create` and no attribute carries the cloud-init
text; `PROXMOX_TEST_KEEP` skips the teardown and prints the `qm destroy`
and ISO delete commands. `TestProxmoxCheck_realHost` creates nothing. Edit
`Makefile`: `test-integration-proxmox` (`go test -race -tags=integration
-count=1 -v -timeout 30m -run TestProxmox ./internal/provider/proxmox/`).
Edit `docs/testing.md`: rows for the package in the suites table and a
"The proxmox integration test" section. Update spec entry 21's tick list
with the final test names.
Verify: `go test -race -count=2 ./internal/provider/proxmox/`;
`go vet -tags=integration ./... && go vet -tags=e2e ./...`;
`make -n test-integration-proxmox`; every §11 item has a test name in
spec entry 21 that exists.
Done when: the package covers every §11 item by name, the integration
test compiles, the target exists. Commit: `proxmox: contract and
observability tests, integration test, testing docs`.
- **Phase 9 (implementer): deployment and documentation.** Read first:
contract §12 items 11 to 13; spec entries 22, 23, 24;
`config/manager/manager.yaml`, `config/manager/providers_config.yaml`,
`config/samples/{providers-config.yaml,proxy_kvm2.yaml,kustomization.yaml}`;
`Makefile` `kvm-ssh-secret`; `docs/kvm2-provider.md` headings;
`docs/providers-existing.md`; `docs/architecture.md` lines mentioning
kvm2; `README.md`; `docs/providers.md`; `CLAUDE.md` Gotchas. Edit
`config/manager/manager.yaml`: an optional Secret mount `proxmox-token` at
`/etc/proxy-operator/proxmox` (key `token`). Edit `Makefile`:
`PROXMOX_TOKEN_FILE ?= $(HOME)/.config/proxmox-token`,
`proxmox-token-secret` creating `proxmox-token` from `--from-file=token=`.
Edit both providers-config files: a commented `proxmox-home` entry (`url:
https://192.168.2.8:8006`, `tokenFile`, `caFile` or
`insecureSkipTLSVerify`, `nodes`, `isoStorage`, `storage`, `bridge`,
`vmidRange`, `templates`, `machineTypes`). Create
`config/samples/proxy_proxmox.yaml` (provider `proxmox-home`, zone the
node, machine type `small`, image the template key, the Squid cloud-init
from `proxy_kvm2.yaml`) and list it in `config/samples/kustomization.yaml`.
Create `docs/proxmox-provider.md` with sections: Proxmox prerequisites
(the template commands from this plan, no cloudinit drive on ide2, the
guest agent or outbound access, the bridge, the privilege list from F17,
reserving the VMID range), Cluster-side setup (the Secret, the config
entry), Config reference (spec entry 15's table), Placement (Region
ignored, catalogue-edit warning), What lands on Proxmox (VMID scheme,
description mark, tag, seed ISO, config keys, `qm unlock` for a stuck
clone), Logging, Tracing, Timeouts, Health check, Error classes, Known
gaps (spec entry 24), Testing. Edit `docs/providers-existing.md`: a fifth
column in the three tables and a proxmox section mirroring kvm2's. Edit
`docs/architecture.md`: components row, a proxmox block in §5, the span
note in §10, a §11 row (and the "All four providers" count), Decisions
bullets. Edit `README.md`, `docs/providers.md`, `CLAUDE.md` Gotchas
(`make test-integration-proxmox`).
Verify: `bin/kustomize build config/default >/dev/null && bin/kustomize build config/samples >/dev/null`;
a test `TestLoadConfig_proxmoxSampleEntry` loads the uncommented block
from `config/samples/providers-config.yaml` through `provider.LoadConfig`;
a link sweep over the edited docs finds no missing target;
`grep -c '—' docs/proxmox-provider.md` is 0.
Done when: manifests build, the sample loads, docs are linked and
link-clean. Commit: `proxmox: deployment files, sample, user guide and
doc rows`.
- **Phase 10 (orchestrator with the user): review, real-host validation,
changelog, MR.** Read first: `docs/plans-executions/2026-09-17-1515-kvm2-review-fixes.md`;
`.claude/agents/go-operator-reviewer.md`; the latest report under
`docs/reviews/` for the shape; the spec; this plan's Risks. Do: run
`go-operator-reviewer` on the branch with the spec and the contract as
inputs, then `/code-review high`, `make lint` (if the local toolchain
panics in staticcheck as it did during kvm2, record it and run
`bin/golangci-lint run ./internal/provider/proxmox/...` with staticcheck
disabled), `make test`. Fix accepted findings with regression tests in
one commit; record rejected ones under `docs/reviews/<date>-proxmox-provider-review/`.
Hand the user the host checklist (template ready, no leftover VMs in the
range, ISO storage has space, the token's privileges per F17) and the
`PROXMOX_TEST_*` export lines; with the go-ahead run
`make test-integration-proxmox` and record the clone duration and time to
IP. Optionally the in-cluster pass on kind: build and load the image,
`make proxmox-token-secret`, a scratch ConfigMap with the `proxmox-home`
entry, apply `proxy_proxmox.yaml`, wait for `Ready`, `kubectl get
providerhealth proxmox-home -o yaml`, a `curl -x` through the proxy,
delete and confirm the VM and ISO are gone; scratch overrides are not
committed. After the user confirms: CHANGELOG entry
(`date "+%Y-%m-%d %H:%M %Z"`), push with `-u` using the token helper from
memory (`~/.config/gitea-token`), open the MR with the Gitea API
(`POST /api/v1/repos/kacerr/egress-proxies-operator/pulls` with `head:
feat/proxmox-provider`, `base: main`, the body ending with the
attribution line; `pr.json` in the scratchpad, never committed). Never
merge.
Verify: the integration run's ordered assertions pass on 192.168.2.8;
`make test` green; the review report lists every finding as accepted or
rejected.
Done when: the MR URL is printed and the execution log's last box is
ticked. Commit: `proxmox: review fixes, real-host validation record,
changelog`.
## Verification
- Per phase: the `Verify:` clause, plus `go build ./... && go test -short ./...`
before every commit and `git diff --stat` reviewed for files outside the
phase's list.
- Contract mapping: after Phase 8 every rule of contract §2 and §8 has a
named test in the package, and spec entry 21 maps the §11 list 1:1 to
existing test names.
- Fixture discipline: every message `classify` or a matcher helper compares
against is loaded from `testdata/` in `TestClassify_table` or
`TestAPIError_matchers`; a string literal in `errors.go` without a fixture
is a review finding.
- Secrets: `TestClient_tokenNeverInErrorsOrLogs` and
`TestCreate_cloudInitSentinelNeverLoggedOrInErrors` run at V(2), and the
integration test asserts no span attribute carries the cloud-init text.
- End to end: the integration run (Phase 8's assertions) on 192.168.2.8 and
the optional kind pass in Phase 10.
## Risks
- **Error text location and exact wording (F1 to F6)** decide the whole
NotFound path; if messages vary by storage type or PVE minor version, the
matchers get one fixture per variant and the guide names the tested
version. This is not hypothetical: 9.2 rewrote the authentication-failure
message (F22), so fixtures captured from an 8.x host would already be
wrong. Every matched string comes from 192.168.2.8 at 9.2.20, and the
guide says so.
- **Clone cleanup (F15).** A failed or interrupted clone may leave a VM with
`lock=clone` that never clears; Create then waits out `budgets.clone` and
returns Transient every time. The guide documents `qm unlock`; the
reconciler keeps retrying rather than latching.
- **`SDN.Use` (F17).** PVE 9 refuses `net0` writes to non-root users without
it; a missing privilege is a 403 Permanent that latches the Proxy until
its spec changes. The guide's privilege list is the mitigation.
- **The ide2 seed and a template with a cloudinit drive** both claim
cloud-init; the Proxy's user-data would be ignored. Documented
prerequisite; the Checker cannot see it.
- **VMID range shared with humans.** A human VM inside the range costs a
probe; a human VM carrying our name is Permanent. The guide says to
reserve the range.
- **Node-local ISO storage after a migration** leaves the seed on the
original node; Delete tolerates its absence and the ISO is a documented
blind spot. Shared ISO storage avoids it.
- **Blocking Create** holds a reconcile worker for up to the clone budget;
the guide sets expectations.
- **The 30s request timeout on the seed upload** is fine for tens of KiB; a
multi-megabyte `vendorData` would trip it. Documented.
- **Two operator installs** on one Proxmox cluster delete each other's VMs
(contract §13); documented in entry 24.
- **kvm2 edits in Phase 3** touch a merged provider; the moved tests are the
guard, and `go vet -tags=integration` keeps its integration test compiling.
+860
View File
@@ -0,0 +1,860 @@
# The provider contract
This document is the specification a provider must meet. It is written for
whoever builds the next one, human or agent, and it deliberately says nothing
about how any existing provider is built. Those details are in
[providers-existing.md](providers-existing.md); the questionnaire a new
provider's design must answer is [provider-spec-template.md](provider-spec-template.md).
Read the three in that order: this contract, then the template, then the
appendix for worked examples.
Rule keywords: **MUST** means breaking the rule breaks a caller, and every MUST
below names the caller and what breaks. **SHOULD** means every current provider
does it and a new one needs a written reason not to. **MAY** means it is
allowed and the choice must be recorded in the provider's spec.
Code references are a file path plus a function or symbol name, never a line
number. Where a rule is enforced by a caller, that caller is one of:
- the **reconciler**, `internal/controller/proxy_controller.go`;
- **orphan GC**, `internal/gc/gc.go`;
- the **provider health monitor**, `internal/providerhealth/monitor.go`;
- the **decorators**, `internal/provider/metrics.go` and
`internal/provider/tracing.go`;
- the **composition root**, `cmd/main.go`.
## 1. What a provider is and who calls it
### 1.1 The types
A provider is a Go type implementing `provider.Provider`
(`internal/provider/provider.go`): four methods over one VM-like instance,
identified by an opaque `providerID` string.
- `Create(ctx, CreateRequest) (providerID, error)`
- `Get(ctx, providerID) (*Instance, error)`
- `Delete(ctx, providerID) error`
- `ListByTag(ctx) ([]Instance, error)`
`CreateRequest` carries `Name` (from `NameFromUID` in `internal/provider/name.go`,
22 characters, `proxy-` plus base32), `UID` (the owning Proxy's UID),
`Namespace`, `ProxyName`, `Placement` (`Region`, `Zone`, `MachineType`,
`Image`), `CloudInit` (already resolved from inline text or a Secret) and
`Port`.
`Instance` carries `ID`, `IP`, `State` (one of `StateProvisioning`,
`StateRunning`, `StateStopped`, `StateTerminated`), `UID` and `CreatedAt`.
The GC marks are the constants `LabelManaged` (value `LabelManagedYes`) and
`LabelUID`, also in `provider.go`.
Providers are named instances of a type: two entries of one type with
different config blocks are two providers. The configured name is what
`Proxy.spec.provider` refers to, and it is immutable, so a Proxy never moves
between providers.
### 1.2 The callers
Nothing above the provider watches backend resources. Everything the operator
knows about an instance comes from polling `Get`.
- **The reconciler** calls `Create`, `Get` and `Delete` from `reconcileManaged`,
`replaceInstance` and `reconcileDelete`, and branches only on the error class
(`providerFailure`, `deletionFailure`) and on `Instance.State`. It maps the
CRD's placement to `Placement` one to one in `placementFrom`. Its poll
intervals are set in `applyDefaults`: `ProvisioningPoll` and `DeletionPoll`
10s, `DriftPoll` 2m, `RequeueNow` 1s, `QuotaRetry` 5m. Up to 3 Proxies
reconcile at once (`MaxConcurrentReconciles` in `SetupWithManager`).
- **Orphan GC** calls `ListByTag` and `Delete` from `Sweeper.sweep`, every
`Interval` (10m), and never deletes an instance younger than `MinAge` (10m).
It loops over providers one after another in a single goroutine.
- **The provider health monitor** calls the optional `Checker` from
`Monitor.checkOnce`, every `--provider-check-interval`, all providers
concurrently under one timeout.
- **The composition root** builds every provider through `registry.Build`
(`internal/provider/registry/registry.go`) before the manager starts, on
every replica, then wraps each one in `WithTracing(WithMetrics(...))`.
**Where the calls come from.** The reconciler, GC, the health engine and the
monitor run on the leader only. The constructors run on every replica at
startup.
**Concurrency.** Up to 3 reconciles, a GC sweep and a health-check round can
call the same provider at the same time, and GC's `Delete` can overlap the
reconciler's `Delete` for the same instance. A provider **MUST be safe for
concurrent use, including two calls for the same instance name**. Nothing
above the provider serialises them.
### 1.3 What the instance is for
The instance is a forward proxy with its own egress path. Two parties connect
to `http://<Instance.IP>:<spec.port>`:
- the health engine, from the operator pod, which sends a real `CONNECT`
through it (`probe` in `internal/health/probe.go`);
- clients, which get `ip` and `port` from the discovery API (`viewOf` in
`internal/discovery/handlers.go`).
So the IP a provider reports **MUST be reachable on that port from the
operator's pod network and from the clients**. Whatever that takes on the
backend (firewall rules, security groups, routing) is either done by the
provider or documented as a prerequisite in the provider's guide.
The provider does not know which proxy software runs. The proxy is installed
and configured by the Proxy's `cloudInit`; building install logic into the
operator is a stated non-goal (`docs/prompts/__initial-prompt.md`, non-goals).
A backend that cannot take user-data (see `readiness` in §4) documents that
it ignores the field and how the proxy gets configured instead.
### 1.4 Where it runs
The manager container (`config/manager/manager.yaml`) runs as non-root with a
read-only root filesystem, a 64Mi `emptyDir` at `/tmp` and a 128Mi memory
limit, from a distroless static image (`Dockerfile`) that carries the public
CA roots and nothing else. So a provider:
- **MUST stream large payloads** rather than buffering them in memory;
- **MUST write scratch files only under `/tmp`**, and delete them;
- gets credentials either as mounted files (the providers ConfigMap at
`/etc/proxy-operator`, optional Secret mounts) or from the environment or
the pod's own identity. See §7.
### 1.5 Time and cancellation
**No caller puts a deadline on the context.** The reconcile and GC contexts
are cancelled on shutdown and on loss of leadership, and otherwise never.
The only bounded context is the health monitor's (`Monitor.checkOnce`, one
timeout per round).
- **MUST honour `ctx`**: return promptly once it is cancelled, on every
method.
- **SHOULD bound every backend call itself**: a dial timeout, a per-request
timeout, a cap on any wait loop. What breaks otherwise: a hung `Create`,
`Get` or `Delete` pins one of the 3 reconcile workers until the manager
stops; a hung `ListByTag` stalls orphan GC for every provider, because
`Sweeper.sweep` runs them sequentially; a `Check` that ignores its context
wedges a monitor round. Choose budgets against the polls: a `Get` that
routinely outlives `ProvisioningPoll` gains nothing.
## 2. The contract, method by method
### 2.1 Create(ctx, CreateRequest) (providerID, error)
- **MUST be idempotent by `req.Name`.** A repeat call for a name that already
exists returns that instance's providerID with a nil error, creates no
duplicate and returns no error.
*Caller:* the reconciler records the ID in status only after `Create`
returns (`reconcileManaged`). A crash between the two, or a lost status
write, runs `Create` again for the same Proxy; `replaceInstance` recovers
from a crash between its two writes the same way.
- **MUST mark the instance as managed**, with `LabelManaged=LabelManagedYes`
and `LabelUID=req.UID` or the backend's equivalent, in the same call that
creates it. When the backend's create call cannot carry the mark, the
window between creation and marking **MUST be far shorter than `MinAge`**
and documented in the provider's spec (`gc-mark` in §4).
*Caller:* orphan GC finds instances only through the mark and never deletes
one whose UID is empty (`Sweeper.sweep`). An instance that exists without
the mark leaks if the operator crashes before marking it.
- **MUST return a providerID that is self-contained.** `Get` and `Delete`
receive only the ID, and the config or spec may have changed since
`Create` (a zone edit during a replacement). Put whatever `Get` needs to
find the instance into the ID, and validate config values that end up in
the ID so they cannot contain the separator. See `primary-key` in §4 for
backends that allocate their own identifier.
- **MUST NOT let `Get` answer NotFound for an ID that `Create` just
returned.** A backend with eventual consistency between create and read
needs a read-after-write guarantee or a retry inside `Create`.
*Caller:* on NotFound the reconciler clears the ID and creates again
(`reconcileManaged`), so a lagging read makes a duplicate.
- **MUST honour `ctx`** (§1.5).
- **MAY return once the request is submitted, or block until the instance is
running** (`create-return` in §4). Submit-and-return is preferred: readiness
is discovered by polling `Get`, and a blocking `Create` holds one of the 3
workers for its whole duration. A provider that blocks MUST still honour
cancellation and SHOULD cap the wait.
- **SHOULD reject a request it can never satisfy** with `ErrPermanent`
before touching the backend: a missing or unknown zone, machine type or
image, or any placement value not in its catalogue. The CRD cannot validate
`placement`, because it is provider-agnostic, so this error is the user's
only feedback. The message SHOULD name the offending field and value.
- **Catalogue changes do not replace instances.** The spec hash
(`specHashInput` in `internal/controller/spechash.go`) covers `placement`,
the resolved `cloudInit` and `port`, not the provider config. When a
placement value names a config entry, changing what that entry means affects
only new instances. The provider's guide MUST say so and tell users to add a
new key rather than edit one in use.
- **MAY ignore `Placement` fields that do not apply.** If a field is ignored,
the provider's guide MUST say so, because the field is still part of the
spec hash: changing it replaces the instance and has no other effect.
- **SHOULD deliver `CloudInit` as user-data**, byte for byte, without parsing
or re-rendering it. A provider that cannot documents that it ignores the
field.
- **MUST NOT log `CloudInit` contents**, put them in span attributes, or
include them in error messages. It can hold secrets. Log its byte count.
Test this with a sentinel string (§11).
### 2.2 Get(ctx, providerID) (*Instance, error)
- **MUST return `ErrNotFound` when, and only when, the instance is gone.**
*Caller:* `ErrNotFound` triggers three actions. In normal reconciling the
reconciler clears the ID and creates again (`reconcileManaged`). During
replacement it is the signal to create the new instance
(`replaceInstance`). During deletion it releases the finalizer
(`reconcileDelete`). A false NotFound during an outage creates a duplicate
or loses the instance. "Unreachable", "wrong credentials" and "cannot tell"
are Transient or Permanent, never NotFound.
- **MUST keep returning the instance, not NotFound, while a delete is in
progress** and the name is still taken.
*Caller:* the replacement instance gets the same name. `replaceInstance`
waits for NotFound before creating it; a NotFound too early makes `Create`
adopt the dying instance.
- **MUST report `StateRunning` only with a non-empty `IP`.** Running without
an address yet is `StateProvisioning`.
*Caller:* the IP from a Running answer goes straight into status and to the
health engine (`reconcileManaged`).
- **MUST report `StateStopped` or `StateTerminated` only when the instance
will not recover by itself.** SHOULD map states it does not recognise to
`StateStopped`.
*Caller:* the reconciler reacts to both states identically: it calls
`Delete` and requeues after `DeletionPoll` without clearing the ID, then
polls `Get` and deletes again until NotFound, then creates
(`reconcileManaged`). Instances are disposable, so this is the safe answer
for anything unknown, but it makes the next rule critical.
- **MUST NOT report a freshly created instance that has not been started yet
as `StateStopped`.** A backend with no provisioning state of its own, where
"defined but not yet started" and "stopped" look the same, MUST resolve this
in one of two ways (`provisioning-state` in §4): block `Create` until the
instance is started, or record enough at creation (written metadata, a task
handle) for `Get` to answer `StateProvisioning`.
*Caller:* the same branch of `reconcileManaged` would delete the instance
right after it was created, forever.
- **During an asynchronous operation or a backend lock** (a clone or a delete
still running, an instance locked by the backend), `Get` MUST return either
the last known state or `ErrTransient`, never NotFound and never
Permanent (`async-ops` in §4).
- **MUST fill `UID` and `CreatedAt` by reading them back from the backend**,
never from memory.
*Caller:* GC compares `UID` with the live Proxies and deletes anything whose
`CreatedAt` is older than `MinAge`. **A zero `CreatedAt` counts as very
old** (`Sweeper.sweep`), so a provider that forgets it makes instances still
being created collectable. An unparsable timestamp giving the zero time is
accepted: it never shields an orphan forever.
- **SHOULD return `ErrPermanent` for an ID it can never resolve**: a malformed
ID, a location no longer in the config.
*Caller:* `reconcileDelete` then tries `Delete` anyway and releases the
finalizer if that works. If the provider returned Transient, deletion would
poll forever.
### 2.3 Delete(ctx, providerID) error
- **MUST be idempotent.** Deleting something already gone returns nil.
*Caller:* GC and the reconciler race, and a crash repeats the call.
- **MUST accept being called again while an earlier delete is still in
progress.** Treat "already deleting" as success, and SHOULD keep that cheap.
*Caller:* while `Get` still returns the instance, `reconcileDelete` and the
Stopped branch of `reconcileManaged` call `Delete` again every
`DeletionPoll` until `Get` returns NotFound.
- **MUST remove everything `Create` made**: disks, volumes, seeds, addresses,
uploaded files. Either delete them explicitly or rely on the backend
cascading (`secondary-resources` in §4).
*Caller:* GC sees only what `ListByTag` returns, so secondary resources
have no other cleanup path.
- **MAY return before the instance is gone** (`delete-return` in §4), as long
as `Get` keeps showing it until it is.
- **SHOULD succeed for an ID whose `Get` fails permanently** when there is
nothing left the provider could reach, and log that it did nothing.
*Caller:* on the deletion path a permanent error is not latched; it retries
forever (`deletionFailure`), and the Proxy is then only released through the
`AnnotationSkipCleanup` annotation (`api/v1alpha1/proxy_types.go`).
### 2.4 ListByTag(ctx) ([]Instance, error)
- **MUST return only instances that carry the managed mark**, each with an
`ID` that `Delete` accepts, plus `UID` and `CreatedAt`. `State` and `IP`
are not used by GC.
*Caller:* GC deletes every listed instance whose UID is not live and whose
age is past `MinAge` (`Sweeper.sweep`). Listing something without the mark
is how an operator deletes an instance it does not own.
- **MUST return an error rather than a list it cannot vouch for.** An error
makes GC skip that provider for this sweep (`Sweeper.sweep`).
- **MAY leave out instances it could not read.** Leaving something out is
always safe, because GC never deletes what is not listed. Pick a policy
(`list-partial` in §4) and document it.
- **SHOULD cover the whole scope the provider can create in**: every
namespace, zone, node or host. Anything outside that scope is never
collected.
- **Scope caveat:** GC protects instances by UID, not by installation. Two
operator installs sharing one backend delete each other's instances (§13).
Two provider entries of one install that share a scope are safe, because
they share the live-UID set.
## 3. What a container-style backend and a VM-style backend have in common
The contract above is the same for both, but the profiles in §4 differ.
A backend that runs a container has an instance that is ready seconds after
`Create`, gets its address from the platform, is deleted in one call and has
no secondary resources; a backend that boots a VM has a provisioning window
of minutes, may discover the address only after the guest reports it, may
need several deletes, and leaves disks and seeds behind unless told
otherwise. Neither is preferred. What matters is that the provider's spec
names its profile on every axis, because the appendix table and the test list
are keyed by those names.
## 4. Behaviour profiles
Each axis below has a stable code. The provider's spec (the template) answers
every axis; the appendix maps each existing provider to its profile on every
axis. A profile is not a permission to skip a MUST in §2; it says which of
the allowed ways of meeting it the provider chose.
### 4.1 `readiness`
*When is an instance usable after `Create`?*
- **immediate**: the platform starts it at once; `Get` reports Running
within a poll or two.
- **provisioning-window**: minutes of booting; `Get` reports Provisioning
for that time.
Callers tolerate both: the reconciler polls at `ProvisioningPoll` either
way. An immediate provider still MUST report Provisioning until the IP is
known.
### 4.2 `create-return`
*Does `Create` return once the request is submitted or once the instance is
running?*
- **submit**: preferred. Readiness comes from `Get`.
- **blocking**: allowed. Costs one of 3 workers for the duration, and any
first-use setup (an image download) runs inside it. MUST honour `ctx`,
SHOULD cap the wait, and MUST keep every step idempotent so a cut-off
attempt resumes.
### 4.3 `delete-return`
*Does `Delete` return before the instance is gone?*
- **returns-early**: `Get` keeps returning the instance until it is gone
(§2.2), and repeated `Delete` calls are cheap.
- **waits**: `Delete` returns nil only when nothing is left. Bound the wait
and honour `ctx`.
### 4.4 `ip-source`
*Where does `Instance.IP` come from?*
- **assigned-at-create**: the backend reports it with the instance.
- **discovered**: an agent in the guest, a DHCP lease, a neighbour table.
Discovery failures mean "no address yet", never an error; `Get` returns
Provisioning. The provider's guide MUST say what the guest needs (an agent
package, outbound access to install it) and what happens without it.
### 4.5 `gc-mark`
*Where do `LabelManaged` and `LabelUID` live?*
- **native-labels**: labels or tags the backend indexes; `ListByTag` can
filter server-side.
- **written-metadata**: a description, an annotation or a metadata element
written by the provider; `ListByTag` lists everything and filters
client-side.
- **mark-window**: the create call cannot carry the mark and a second call
writes it. Allowed only when the window is a few seconds, documented, and
the unmarked instance is cleaned by the same Proxy's next reconcile (§2.1).
### 4.6 `created-at`
*Where does `CreatedAt` come from?*
- **backend-timestamp**: the backend's own creation time.
- **written-at-create**: the provider writes the time into the mark
metadata in the create call. A backend with no timestamp MUST do this.
### 4.7 `list-partial`
*What does `ListByTag` do when part of its scope is unreachable?*
- **fail-whole**: one unreachable part fails the list; GC skips the provider
this sweep. Nothing is collected until every part answers.
- **omit-unreachable**: unreachable parts are left out and the rest is
returned. Orphans in the reachable parts are collected; the provider MUST
log what it left out.
Both are safe (§2.4). A single unreadable instance MAY always be skipped.
### 4.8 `quota`
*Can the provider tell "no capacity" from other failures?*
- **detected**: a recognisable quota, rate-limit or out-of-resources signal
maps to `ErrQuotaExceeded`.
- **absent**: capacity errors map to `ErrTransient`. The guide MUST say so,
and the `Checker` SHOULD expose a capacity figure in `Details` instead.
### 4.9 `not-found`
*How does the provider know an instance is gone?*
- **status-404**: a distinct not-found status or error code from the
backend.
- **error-code**: a code or message the backend uses for "does not exist",
matched exactly and covered by the classify table test with real captured
responses.
- **list-and-miss**: absence from a listing. Allowed only when the listing
can never be partial; otherwise a missing part reads as "gone" and §2.2 is
broken.
### 4.10 `primary-key`
*Is `req.Name` the backend's key?*
- **name-is-key**: the backend addresses instances by name. `Create` looks
the name up, the ID is `<location>/<name>`.
- **allocated-id**: the backend allocates an identifier and the name is a
label. `Create` MUST first find an existing instance by name (idempotency),
then allocate. Allocation MUST be safe against a concurrent `Create` for
another Proxy: a deterministic identifier derived from `req.UID`, or an
allocate-then-verify loop. The ID SHOULD embed the allocated key and the
name; `Get` and `Delete` use the allocated key. If instances can move
between locations (migration), the ID MUST NOT depend on the location.
### 4.11 `provisioning-state`
*Does the backend have a state for "being created"?*
- **native**: the backend reports a provisioning or staging state, mapped to
`StateProvisioning`.
- **create-blocks**: no such state; `Create` blocks until the instance is
started, so `Get` never sees "defined but not started".
- **marker**: no such state and `Create` returns early; the provider records
at creation (metadata or a task handle) what lets `Get` answer
`StateProvisioning` for an instance that was never started, and
`StateStopped` for one that stopped afterwards.
### 4.12 `async-ops`
*Does the backend run create and delete as asynchronous tasks?*
- **none**: calls complete synchronously.
- **task-handle**: the backend returns a task the provider can poll. The
provider SHOULD keep the handle where `Get` can find it (metadata or the
ID) so a failed create can be reported instead of read as NotFound.
- **backend-lock**: the backend locks the instance during a task. `Get`
returns the last known state or Transient while locked (§2.2); `Delete`
on a locked instance returns Transient, not Permanent.
### 4.13 `secondary-resources`
*What else does `Create` make, and who removes it?*
- **none**.
- **cascade**: the backend deletes them with the instance (auto-delete
flags). The provider MUST set those flags at create.
- **explicit**: the provider deletes them by name in `Delete`, tolerating
their absence. Resources that can exist without an instance (a crash
between creating them and creating the instance) are a documented GC blind
spot unless `ListByTag` covers them.
### 4.14 `credentials`
*Where do credentials and trust material come from?*
- **mounted-files**: paths in the config block, pointing into an optional
Secret mount (§7).
- **environment**: standard SDK environment discovery.
- **workload-identity**: the pod's own identity.
TLS trust for an HTTPS backend with a private CA is one of **system-roots**,
**ca-file** (a path in the config block) or **insecure** (a boolean whose
name starts with `insecure`, warned about at startup).
### 4.15 `client-kind`
*What talks to the backend?*
- **vendor-sdk**: put it behind a small interface (the seam) so a fake can
stand in.
- **thin-client**: a `net/http` client written in the provider package.
Preferred when no maintained SDK exists (`CLAUDE.md`: stdlib over
third-party). It MUST set transport timeouts, MUST carry the backend's
status and body in one error type that `classify` reads, and SHOULD wrap
its transport for child spans (§9).
- **rpc-over-ssh** or another non-HTTP transport: one pooled connection per
target, a keepalive, and a documented answer to "what does a cancelled call
do to the shared connection".
### 4.16 `timeouts`
*How is each backend call bounded?* (§1.5)
- **per-call**: every request has its own deadline, independent of `ctx`.
- **ctx-only**: the provider relies on `ctx`. Allowed only for backends
whose client already enforces a timeout; the spec MUST cite it.
### 4.17 `logging`
*Which log tiers does the provider produce?* The logger MUST come from
`logf.FromContext(ctx)` at call time (§9).
- **tiered**: V(1) one line per operation outcome and per failed backend
call; V(2) request and response detail; V(5) raw wire if there is one.
- **outcome-only**: V(1) only.
- **none**: allowed only for a backend whose client logs on its own.
### 4.18 `child-spans`
*How do backend calls appear under the operation span?* (§9)
- **sdk-instrumentation**: the SDK emits spans itself.
- **transport-wrapper**: a parent-gated `otelhttp` transport.
- **span-decorator**: a decorator over the seam that starts one span per
call.
- **none**: a documented gap, with log correlation through `traceID` only.
### 4.19 `backend-test`
*How is the provider proven against the real backend?*
- **cluster-e2e**: the kind end-to-end suite.
- **integration-tag**: a test behind `//go:build integration`, skipped
without its environment variables, with a `make test-integration-<type>`
target and a section in `docs/testing.md`.
- **manual-runbook**: a documented by-hand run. Allowed only when the
backend cannot be reached from CI or a developer machine.
### 4.20 Summary
| Axis | Profiles | Who tolerates the difference | Template entry |
|---|---|---|---|
| `readiness` | immediate, provisioning-window | reconciler polls either way | 7 |
| `create-return` | submit, blocking | reconciler; worker budget | 10 |
| `delete-return` | returns-early, waits | reconciler re-calls Delete | 11 |
| `ip-source` | assigned-at-create, discovered | reconciler gates Running on IP | 7 |
| `gc-mark` | native-labels, written-metadata, mark-window | GC | 8 |
| `created-at` | backend-timestamp, written-at-create | GC `MinAge` | 8 |
| `list-partial` | fail-whole, omit-unreachable | GC | 12 |
| `quota` | detected, absent | reconciler `QuotaRetry` | 13 |
| `not-found` | status-404, error-code, list-and-miss | reconciler | 13 |
| `primary-key` | name-is-key, allocated-id | reconciler idempotency | 2 |
| `provisioning-state` | native, create-blocks, marker | reconciler Stopped branch | 9 |
| `async-ops` | none, task-handle, backend-lock | reconciler | 10 |
| `secondary-resources` | none, cascade, explicit | GC blind spots | 11 |
| `credentials` | mounted-files, environment, workload-identity | deployment | 14 |
| `client-kind` | vendor-sdk, thin-client, rpc-over-ssh | tests, tracing | 17 |
| `timeouts` | per-call, ctx-only | worker and GC budget | 18 |
| `logging` | tiered, outcome-only, none | operators | 20 |
| `child-spans` | sdk-instrumentation, transport-wrapper, span-decorator, none | tracing | 17 |
| `backend-test` | cluster-e2e, integration-tag, manual-runbook | CI | 21 |
## 5. Error taxonomy
Every error a provider method returns **MUST be built with
`provider.Wrap(class, op, providerName, id, err)`** (`internal/provider/errors.go`).
`op` is one of `create`, `get`, `delete` or `list`, and `providerName` is the
**configured name** (`ProviderConfig.Name`), not the type.
*Caller:* the decorators label metrics and spans with the class
(`resultLabel`), and the reconciler branches on `provider.Class`.
`Wrap` keeps both `errors.Is(err, class)` and `errors.As` to the backend's
error type working (`Error.Unwrap` returns both).
| Class | Use it for | During normal reconciling | While deleting |
|---|---|---|---|
| `ErrNotFound` | the instance does not exist | Get: recreate or advance (§2.2); other ops: returned as the error, so exponential backoff | Get: release the finalizer |
| `ErrQuotaExceeded` | quota, rate limit, out of capacity | `Provisioned=False` with reason `QuotaExceeded`, requeue after `QuotaRetry`, not counted as an error (`providerFailure`) | requeue after `QuotaRetry` (`deletionFailure`) |
| `ErrTransient` | network, 5xx, timeouts, cancellation, anything unknown | error returned, so workqueue exponential backoff | same |
| `ErrPermanent` | bad config, bad argument, auth or permission denied, malformed ID | **latched**: `Provisioned=False` with reason `PermanentError`, no more provider calls until `metadata.generation` changes (`reconcileManaged`) | error returned and retried forever; a Permanent `Get` also triggers `Delete` (`reconcileDelete`) |
Pitfalls:
- **Unclassified means Transient.** `provider.Class` returns `ErrTransient`
for any error not built with `Wrap`. That is the safe default, but a
permanent failure that was never classified retries forever, and a quota
error backs off far too fast.
- **Permanent is sticky.** Use it only for errors that editing the Proxy spec
or fixing the config can resolve. A permission-denied status that the
backend also uses for quota is the hard case: read the backend's reason if
it gives one, and if it does not, pick Permanent and document it.
- **Do not return NotFound from `Create`.** The reconciler treats it as
Transient there, but it misleads the metrics and anyone reading logs.
- **NotFound detection** follows the `not-found` profile (§4.9). A backend
that reports "does not exist" as a generic server error needs the exact
message or code matched, with real captured responses in the test table.
- **Classify in one `classify` function** with a table-driven test covering
every class, including "not a backend error at all, so Transient".
## 6. Identity, names, marks, CreatedAt
- **Names:** `NameFromUID(uid)` gives `proxy-` plus 16 lowercase base32
characters. It is a valid DNS label. A backend with stricter rules SHOULD
map it deterministically rather than invent a new scheme, because
idempotency depends on the name being the same every time. A backend that
keys on an allocated identifier follows `primary-key` (§4.10).
- **providerID:** opaque to everyone except the provider. Keep it
`<location>/<name>`-shaped or `<location>/<allocated-key>`-shaped, parse
it strictly, and return `ErrPermanent` for a malformed one (§2.2).
- **Marks:** `LabelManaged` with value `LabelManagedYes`, and `LabelUID`.
Use native labels or tags where the backend has them; otherwise write them
into metadata in the create call (§4.5).
- **`CreatedAt`:** the backend's creation timestamp, or written into the mark
metadata at creation (§4.6).
- **Provider names:** a configured `name` MUST be a lowercase DNS-1123 label
of at most 63 characters (`spec.provider` in
`api/v1alpha1/proxy_types.go`). The `ProviderHealth` object is named after
the provider; `Monitor.Start` warns and `Monitor.publish` skips the object
for a name that is not a valid object name. Config loading does not check
this yet (§13).
## 7. Credentials, TLS and secret hygiene
- **The constructor MUST NOT dial the backend.** `registry.Build` aborts the
whole provider set on a constructor error, and the manager exits: one
unreachable backend must not take the other providers down. Fail in the
constructor only on config errors.
- **Credential files SHOULD be read lazily.** A mount marked `optional: true`
is empty when the Secret is missing; the constructor logs a warning and the
first call fails Transient until the Secret lands. A file that exists but
does not parse is Permanent.
- **Credentials MUST NOT appear in logs, span attributes, error strings or
`Error.Err`.** Backend errors that echo a request URL or header with a
token in it MUST be sanitised before wrapping. The same holds for
`CloudInit` (§2.1). Test both with a sentinel (§11).
- **TLS trust** follows `credentials` (§4.14). An insecure toggle MUST be
named so that it reads as insecure, default off, and MUST be logged as a
warning at startup when on.
- **Rotation.** State in the spec whether rotating the Secret takes effect
without a restart. Reading files on every dial makes rotation free; caching
them after first load needs a restart and must say so.
- **Scratch files** only under `/tmp` (§1.4), removed when the call ends.
## 8. Optional interfaces: `Checker`
`provider.Checker` is `Check(ctx) []TargetStatus` (`internal/provider/check.go`).
`cmd/main.go` type-asserts it on the **unwrapped** provider before adding the
decorators, and the monitor treats a nil Checker as unsupported, not failed
(`Monitor.run`).
- **MUST never fail as a whole.** Every failure is a `TargetStatus` with
`Ready=false`.
*Caller:* `Monitor.checkOnce` has no error path per provider.
- **MUST return promptly once `ctx` ends.** The monitor gives the whole round
one timeout.
- **MUST return one entry per target, in a stable order.** Targets are what
can fail independently: an apiserver, a project, each host or node.
*Caller:* the monitor logs transitions per target (`logTransitions`) and
the gauges are labelled `{provider,target}`.
- **MUST NOT disrupt reconciles.** A check that shares a connection with
provisioning must not tear that connection down when it times out.
- **SHOULD use the shared reasons** (`ReasonReachable`, `ReasonTimeout`,
`ReasonUnreachable`, `ReasonUnauthorized`, `ReasonForbidden`,
`ReasonAPIError`). Add provider-specific reasons only for failures an admin
would fix differently, and document each one in the guide.
- **SHOULD check with the same credentials, verb and scope that `ListByTag`
and `Create` use**, and be cheap: one page, one object.
- **MAY fill `Details`** with facts useful when triaging: versions, capacity,
the account in use.
- The `status.type` field of `ProviderHealth`
(`api/v1alpha1/providerhealth_types.go`) lists the known types in its doc
comment; add the new type there and run `make manifests`.
## 9. Observability
Tracing is OpenTelemetry, set up in `internal/tracing` from the standard
`OTEL_*` environment variables. With no exporter configured, spans are no-ops
and logs are unchanged ([architecture.md §10](architecture.md#10-tracing-internaltracing)).
**Layer 1, operation spans and metrics: nothing to write.** `cmd/main.go`
wraps every provider in `WithTracing(WithMetrics(...))`:
- `WithTracing` opens one client span per call, named `provider.create`,
`provider.get`, `provider.delete` or `provider.list`, with
`provider.name`, `provider.id` and `provider.result` (the error class) as
attributes. `ErrNotFound` keeps the span status Ok, because polling to
NotFound is expected.
- `WithMetrics` counts each call as
`proxy_operator_provider_requests_total{provider,op,result}`
(`internal/metrics/metrics.go`). A `Checker` feeds
`proxy_operator_provider_target_up`,
`proxy_operator_provider_check_duration_seconds` and
`proxy_operator_provider_check_timestamp_seconds`, labelled
`{provider,target}`.
Rules:
- **MUST NOT open its own per-operation span or count its own requests.**
They would duplicate the decorators.
- **MUST pass the caller's `ctx` to every backend call and take the logger
from `logf.FromContext(ctx)` at call time.** `tracing.StartSpan`
(`internal/tracing/logger.go`) puts a logger carrying `traceID` and
`spanID` into `ctx`. A logger cached at construction, or a backend call
made with `context.Background()`, loses the trace.
- **Health checks bypass the decorators.** A `Checker` runs under the
monitor's `providerhealth.check` span, and the same `ctx` rules apply.
**Layer 2, child spans per backend call: SHOULD.** One operation span says
that `Create` took 40 seconds; child spans say which backend call took them.
Follow `child-spans` (§4.18):
- HTTP backends: wrap the transport with `otelhttp`, gated on an existing
parent span as `tracing.RestConfigWrapper` does
(`internal/tracing/transport.go`), so background traffic (token refreshes,
keepalives) does not become root spans. Check first whether the SDK already
does this.
- Non-HTTP backends: start child spans yourself with
`tracing.Start(ctx, "<type>.<call>")` around each call and around long
steps such as a dial or a download, in a decorator over the seam, not at
call sites.
- **MUST NOT put secrets or CloudInit in span attributes.** Put identifiers,
sizes and error codes there.
**Layer 3, logs.** Follow `logging` (§4.17). Log lines join a trace through
the `traceID` key from layer 1.
## 10. Configuration and wiring
The providers file is parsed by `LoadConfig` (`internal/provider/config.go`)
with `UnmarshalStrict`, so unknown keys already fail.
- Add a `<Type>Config` struct and a `ProviderConfig` field for it.
- Add the block to `setBlocks`, so a block that does not match `Type` is
rejected.
- Add the type to the allow-list in `Config.validate`. The same list exists a
second time as the constructor map in `cmd/main.go`; both must change
(§13).
- Add required-field validation in its own `config_<type>.go` when it is
more than a few lines, with rows in `config_test.go`.
- **Validate every config value that is pasted into an identifier**: a
location name that becomes part of the providerID must not contain the
separator; a catalogue key that becomes a file or volume name must be a
safe filename.
- The constructor has the signature `registry.Constructor`:
`func(ctx, provider.ProviderConfig) (provider.Provider, error)`. Register
it in the map in `cmd/main.go`. Flags a provider needs are defined there
too and passed through a `NewWith...` variant, never read from the
environment inside the package.
- Decorators are applied by the composition root, in the order
`WithTracing(WithMetrics(p))`, after the Checker has been collected from
the unwrapped provider.
## 11. Testing obligations
`CLAUDE.md` applies: stdlib `testing`, table-driven, tests next to source,
`t.Parallel()` where safe.
- **A fake behind the seam.** Every unit test runs against an in-memory
fake of the backend, never the network.
- **Contract tests**, each against the fake:
- repeat `Create` returns the same ID and creates nothing;
- `Get` after `Delete` returns `ErrNotFound`;
- `Delete` twice succeeds;
- malformed ID is `ErrPermanent` from `Get` and `Delete`;
- an unknown backend state maps to `StateStopped`;
- `StateRunning` requires an IP, and a running instance without one is
`StateProvisioning`;
- `UID` and `CreatedAt` are read back from the fake, not remembered;
- `ListByTag` excludes unmarked instances and instances with an empty UID
are still listed with `UID == ""`;
- `Get` during an in-progress delete still returns the instance;
- `Get` during an asynchronous operation or lock returns the last state or
Transient (`async-ops`);
- a created but not yet started instance is never `StateStopped`
(`provisioning-state`);
- a cancelled `ctx` makes every method return promptly.
- **Classify**: one table test covering every class, every backend code the
provider matches, and "not a backend error at all, so Transient". Use
captured real responses for message matching.
- **Checker**: a table test for the reasons, a test that `Check` returns
promptly with an expired `ctx`, and a test that it returns one entry per
target in config order.
- **Sentinel tests**: a `CloudInit` containing a sentinel string and a
credential containing another never appear in any log line, span attribute
or error string.
- **Tracing**: with an in-memory span recorder
(`sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(tracetest.NewSpanRecorder()))`,
as in `internal/provider/tracing_test.go`), the child spans are children
of the operation span.
- **Real-backend test** per `backend-test` (§4.19). It runs the whole
lifecycle: create, create again, poll until Running, list, delete, delete
again, confirm it is gone and that no secondary resource remains. Keep it
compiling with `go vet -tags=integration ./...`.
## 12. Checklist for adding a provider
1. **Spec first.** Copy `docs/provider-spec-template.md` to
`docs/<type>-provider-spec.md` and answer every entry. Unanswered entries
block implementation.
2. **Package** `internal/provider/<type>/` with the provider type, a
compile-time assertion for `provider.Provider` and one for
`provider.Checker`, the seam interface, and a logger built from
`logf.FromContext(ctx).WithName("<type>").WithValues("provider", name)`.
3. **Config** per §10, including `config_test.go` rows.
4. **Constructor** `New(ctx, provider.ProviderConfig)` registered in
`cmd/main.go`; no dialing.
5. **Contract** per §2, tested per §11.
6. **Errors** per §5, `classify` plus its table test, `ProviderConfig.Name`
in every `Wrap`.
7. **Checker** per §8, with the `status.type` comment updated and
`make manifests` run.
8. **Tracing and logging** per §9 and §4.17, §4.18.
9. **Real-backend test** per §4.19, with a `make test-integration-<type>`
target and a section in `docs/testing.md`.
10. **RBAC**: if the provider talks to the cluster's own API, add
`+kubebuilder:rbac` markers in their own comment block, never inside a
doc comment, then `make manifests` and check
`git diff config/rbac/role.yaml`.
11. **Deployment**: an `optional: true` credentials mount in
`config/manager/manager.yaml`; a commented entry in
`config/manager/providers_config.yaml` and
`config/samples/providers-config.yaml`; a `config/samples/proxy_<type>.yaml`
whose cloud-init installs a proxy on the sample image; a Makefile helper
for the Secret if there is one; `hack/providers-dev.yaml` only if the
type needs no credentials.
12. **Docs**: `docs/<type>-provider.md` (prerequisites, config reference,
placement mapping, what lands on the backend, the health check, logging,
known gaps); a row and a section in `providers-existing.md`; a row in the
`architecture.md` components table and a block in its §5 and §11
(§10 too if the provider adds spans); a row in the README "Implemented
providers" table; a `CHANGELOG.md` entry once the user has confirmed it
works.
13. **Optional:** a demo script under `docs/demo/`.
## 13. Known limits of the contract
These are facts a new provider has to live with today. None is designed or
scheduled.
- **Adoption is not verified.** `Create` adopts any existing instance with
the right name without checking its `LabelUID`.
- **`StateStopped` and `StateTerminated` are indistinguishable** to the
reconciler; both mean delete and recreate.
- **Config is read once per process.** Adding a provider or a location, or
rotating credentials a provider caches, needs a manager restart.
- **GC has no installation identity.** Two installs sharing one backend
delete each other's instances, because GC only knows its own live UIDs.
- **An unconfigured provider latches the Proxy.** A Proxy naming a provider
missing from the config is latched `PermanentError`; adding the provider
and restarting does not change `metadata.generation`, and `spec.provider`
is immutable, so the Proxy stays latched until it is recreated.
- **Provider names are not validated** as DNS-1123 at config load.
- **The type list exists twice**: `Config.validate` and the constructor map
in `cmd/main.go`.
- **`Delete` is resubmitted on every deletion poll** until `Get` says
NotFound.
- **Callers set no deadline** (§1.5); each provider bounds itself.
- **There is no conformance suite.** The §11 contract tests are written per
provider; `internal/provider/providertest` is a proposal in the appendix.
- **Stale comments** in `internal/provider/provider.go`,
`internal/provider/registry/registry.go` and `api/v1alpha1/proxy_types.go`
still describe a `mock` provider that no longer exists.
## 14. Related documents
- [providers.md](providers.md): the index.
- [provider-spec-template.md](provider-spec-template.md): the questionnaire.
- [providers-existing.md](providers-existing.md): how the existing providers
meet this contract, and their gaps.
- [architecture.md §5](architecture.md#5-what-provider-calls-do-in-the-outside-world),
[§10](architecture.md#10-tracing-internaltracing) and
[§11](architecture.md#11-provider-health-checks-internalproviderhealth).
- [testing.md](testing.md).
+455
View File
@@ -0,0 +1,455 @@
# Provider spec template
Copy this file to `docs/<type>-provider-spec.md`, fill in every **Answer**
line, and hand the result to whoever implements the provider together with
[provider-contract.md](provider-contract.md) and
[providers-existing.md](providers-existing.md). An unanswered entry blocks
implementation: it is a decision the contract leaves to the provider, and the
implementer must not guess it.
Where an entry names an axis in backticks, the allowed answers are the
profiles of that axis in contract §4, and the entry number matches the last
column of the contract's §4.20 table. **Evidence** is where the answer was
confirmed: a link into the backend's API documentation, or the experiment
that was run. "Verify against the target version" is a valid interim
answer only for the Evidence line, never for the Answer.
## Header
- **Type** (the `type:` value in the providers config, lowercase):
- **Example configured name**:
- **Backend and API version this spec was written against**:
- **Client** (`vendor-sdk` with the module path, or `thin-client`):
- **Author and date**:
## 1. Identity and providerID
**Question.** What is the providerID's shape, which location parts does it
embed, and what validates the config values pasted into it?
**Allowed answers.** `<location>/<name>`, `<location>/<allocated-key>`, or a
location-free `<allocated-key>` (contract §6; required when instances can
migrate, see entry 2).
**Obligations.** Strict parsing; `ErrPermanent` for a malformed ID from `Get`
and `Delete`; a regexp on every config value that becomes an ID segment, in
`config_<type>.go`.
**Answer:**
**Evidence:**
## 2. Primary key (`primary-key`)
**Question.** Is `req.Name` the backend's key, or does the backend allocate an
identifier?
**Allowed answers.** `name-is-key`; `allocated-id`.
**Obligations.** `allocated-id`: `Create` finds an existing instance by name
before allocating; allocation is safe against a concurrent `Create` for
another Proxy (deterministic from `req.UID`, or allocate-then-verify); `Get`
and `Delete` use the allocated key; state how a collision with an instance
the operator does not own is detected and reported.
**Answer:**
**Evidence:**
## 3. Placement mapping
**Question.** What do `Placement.Region`, `Zone`, `MachineType` and `Image`
mean for this backend, which are required, which are ignored, and what
happens when a value is unknown?
**Allowed answers.** Per field: a config catalogue key, a backend identifier
passed through, or ignored (contract §2.1, MAY ignore with documentation).
**Obligations.** Unknown values are rejected with `ErrPermanent` naming the
field and value before any backend call; ignored fields are listed in the
provider's guide because they are still in the spec hash; an empty value with
a single-choice catalogue may default, and the guide says so.
**Answer:**
**Evidence:**
## 4. Image source
**Question.** Where does the boot image come from, and what does a catalogue
edit do to running instances?
**Allowed answers.** A catalogue in the config block (key to URL or backend
image ID); a backend template or image referenced directly by
`Placement.Image`; a URL fetched by the provider into the backend on first
use.
**Obligations.** If the provider fetches or converts images: streaming, no
buffering (contract §1.4), a completeness marker so a cut-off fetch is redone,
concurrency control so two Creates fetch once, and a written warning that
replacing an image in use may corrupt instances that depend on it. If the
backend owns the image: the prerequisite is documented in the guide.
**Answer:**
**Evidence:**
## 5. Cloud-init delivery
**Question.** How does `CreateRequest.CloudInit` reach the guest, byte for
byte?
**Allowed answers.** User-data metadata on the instance; a NoCloud seed
built in-process and attached as media; a file placed on the backend and
referenced from the instance config; not delivered (documented, with how the
proxy gets configured instead).
**Obligations.** Size limits of the channel stated; whether vendor-data or a
second document can ride along; the sentinel test (contract §11); the channel
must be fully reachable through the API the provider uses (no step that needs
shell access on the backend unless the spec says the provider has it).
**Answer:**
**Evidence:**
## 6. Machine type mapping
**Question.** How does `Placement.MachineType` become CPU, memory and disk?
**Allowed answers.** A catalogue of named profiles in the config block; a
backend size name passed through.
**Obligations.** A catalogue needs validation of each profile; disk size
default and its config field named.
**Answer:**
**Evidence:**
## 7. Network and IP discovery (`readiness`, `ip-source`)
**Question.** Which network does the instance attach to, where does its
address come from, how long until it is usable, and what makes it reachable
from the operator pod and from clients on `spec.port`?
**Allowed answers.** `readiness`: `immediate`; `provisioning-window`.
`ip-source`: `assigned-at-create`; `discovered` (name the mechanism: guest
agent, DHCP lease, neighbour table, and the order if several).
**Obligations.** `discovered`: what the guest must run, how it gets
installed, what happens without it; discovery failure is Provisioning, not an
error; only routable IPv4 is reported (state the filter). Reachability:
firewall or routing steps the provider performs, or the prerequisite the
guide states. Whether `Port` is used by the provider at all.
**Answer:**
**Evidence:**
## 8. GC marks and CreatedAt (`gc-mark`, `created-at`)
**Question.** Where do `LabelManaged` and `LabelUID` live, where does
`CreatedAt` come from, and which secondary resources are marked?
**Allowed answers.** `gc-mark`: `native-labels`; `written-metadata`;
`mark-window`. `created-at`: `backend-timestamp`; `written-at-create`.
**Obligations.** `mark-window`: the window's length in seconds, why it is
far below `MinAge`, and how the same Proxy's next reconcile cleans an
unmarked instance. Any value-format constraint of the backend's labels
(allowed characters, length) and how a Proxy UID fits it.
**Answer:**
**Evidence:**
## 9. State mapping (`provisioning-state`)
**Question.** How does every backend state map to `StateProvisioning`,
`StateRunning`, `StateStopped`, `StateTerminated`, and how does `Get` tell
"defined but never started" from "stopped"?
**Allowed answers.** A table of backend state (plus lock or task flags) to
provider state, with the unknown-state row mapping to `StateStopped`.
`provisioning-state`: `native`; `create-blocks`; `marker`.
**Obligations.** Running requires an IP; `marker` names what is recorded at
creation and how `Get` reads it; `create-blocks` names the cap on the wait.
**Answer:**
| Backend state | Lock or task | Provider state |
|---|---|---|
| | | |
**Evidence:**
## 10. Create: blocking and task tracking (`create-return`, `async-ops`)
**Question.** What does `Create` wait for before returning, and how are
asynchronous backend tasks tracked?
**Allowed answers.** `create-return`: `submit`; `blocking`. `async-ops`:
`none`; `task-handle`; `backend-lock`.
**Obligations.** `task-handle`: where the handle is kept and how a failed
task surfaces (in `Get`, as which class, with which message). `backend-lock`:
what `Get` and `Delete` return while locked. Every step of `Create` is
idempotent so a cut-off attempt resumes; list the steps and what each finds
on a retry.
**Answer:**
**Evidence:**
## 11. Delete and secondary resources (`delete-return`, `secondary-resources`)
**Question.** In what order does `Delete` tear things down, what does it do
when called again mid-delete, and what does `Get` return meanwhile?
**Allowed answers.** `delete-return`: `returns-early`; `waits`.
`secondary-resources`: `none`; `cascade` (name the flags set at create);
`explicit` (name each resource and how it is found by name).
**Obligations.** Idempotent at every step; "already deleting" is success and
cheap; a location no longer in the config is a logged no-op success; any
resource that can outlive the instance without being listable is a documented
GC blind spot.
**Answer:**
**Evidence:**
## 12. ListByTag scope and partial failure (`list-partial`)
**Question.** What is the full scope `ListByTag` enumerates, and what happens
when part of it is unreachable?
**Allowed answers.** `fail-whole`; `omit-unreachable`.
**Obligations.** The scope covers everything the provider can create in;
server-side filtering on the mark where the backend allows it; a single
unreadable instance may be skipped with a log line; the cost of one list at
fleet size.
**Answer:**
**Evidence:**
## 13. Error classification (`not-found`, `quota`)
**Question.** Which backend responses map to which of the four classes, and
which signal means "does not exist"?
**Allowed answers.** A table of backend status or code (with message pattern
where needed) to class. `not-found`: `status-404`; `error-code`;
`list-and-miss`. `quota`: `detected`; `absent`.
**Obligations.** One `classify`, a table test with captured real responses
for every matched message; the permission-denied-versus-quota case decided;
authentication failures, host-key or certificate failures, and cancellation
each assigned a class with a reason.
**Answer:**
| Backend response | Class | Why |
|---|---|---|
| | | |
**Evidence:**
## 14. Credentials and TLS (`credentials`)
**Question.** What credential does the operator use, where does it come from,
when is it read, and how is the backend's TLS certificate trusted?
**Allowed answers.** `mounted-files` (Secret name, mount path, file names,
`optional: true`); `environment`; `workload-identity`. TLS: `system-roots`;
`ca-file`; `insecure` (the flag's exact name).
**Obligations.** The minimum privilege set the credential needs, listed;
rotation behaviour (free, or restart needed); the sentinel test for the
credential (contract §7); the constructor never dials.
**Answer:**
**Evidence:**
## 15. Config block
**Question.** What are the fields of `<Type>Config`, their defaults, and their
validation?
**Allowed answers.** A table: field, required, default, validation, and which
identifier or path the value ends up in.
**Obligations.** Contract §10: `setBlocks`, the type allow-list in
`Config.validate` and the constructor map in `cmd/main.go`, `config_<type>.go`
with its tests, regexps for values pasted into IDs or file names.
**Answer:**
| Field | Required | Default | Validation | Ends up in |
|---|---|---|---|---|
| | | | | |
**Evidence:**
## 16. Checker
**Question.** What are the check targets, what does one check do, which
reasons can it report, and what goes into `Details`?
**Allowed answers.** One target per thing that fails independently; the
shared reasons from `internal/provider/check.go` first, provider-specific
reasons only with a stated "what the admin does differently"; `Details` keys
listed.
**Obligations.** Same credential, verb and scope as `ListByTag` and `Create`;
cheap (one page, one object); never disrupts a reconcile that shares a
connection; returns promptly on `ctx`; the `status.type` comment in
`api/v1alpha1/providerhealth_types.go` updated.
**Answer:**
**Evidence:**
## 17. Client and child spans (`client-kind`, `child-spans`)
**Question.** What talks to the backend, and how do its calls appear under the
operation span?
**Allowed answers.** `client-kind`: `vendor-sdk` (module path, and whether it
is maintained); `thin-client`; `rpc-over-ssh`. `child-spans`:
`sdk-instrumentation`; `transport-wrapper`; `span-decorator`; `none`.
**Obligations.** The seam interface's name and the fake that implements it;
`thin-client`: one error type carrying status and body, transport timeouts,
parent-gated `otelhttp`; `none` is a documented gap; the span recorder test
(contract §11).
**Answer:**
**Evidence:**
## 18. Timeouts (`timeouts`)
**Question.** What bounds each backend call, the dial, and any long step such
as a download or a wait loop, and what does a cancelled `ctx` do to a shared
connection?
**Allowed answers.** `per-call` (the budgets, per operation); `ctx-only`
(cite the client's own timeout).
**Obligations.** Contract §1.5: no caller sets a deadline; a hung call pins a
worker or stalls GC. Cancellation behaviour on a shared connection stated
(abandon the call, or close the connection).
**Answer:**
**Evidence:**
## 19. Concurrency
**Question.** What is shared between concurrent calls (connections, caches,
locks), and what happens when two calls for the same instance name overlap?
**Allowed answers.** A description of the shared state and its locking.
**Obligations.** Safe for 3 reconciles, a GC sweep and a check round at once;
two `Delete` calls for one instance overlap safely; one fetch per image per
location when several Creates race.
**Answer:**
**Evidence:**
## 20. Logging (`logging`)
**Question.** Which tiers does the provider log, with which keys on every
line?
**Allowed answers.** `tiered`; `outcome-only`; `none` (cite the client's own
logging).
**Obligations.** Logger from `logf.FromContext(ctx)` on every call; never
`CloudInit` or credentials; `provider` and the instance name on every line
that concerns an instance.
**Answer:**
**Evidence:**
## 21. Tests (`backend-test`)
**Question.** What is the fake, which contract tests exist, and how is the
provider proven against the real backend?
**Allowed answers.** `backend-test`: `cluster-e2e`; `integration-tag` (the
`<TYPE>_TEST_*` variables, the `make test-integration-<type>` target); `manual-runbook`.
**Obligations.** Every item of the contract §11 list, ticked here:
- [ ] repeat `Create`
- [ ] `Get` after `Delete`
- [ ] `Delete` twice
- [ ] malformed ID
- [ ] unknown state maps to `StateStopped`
- [ ] `StateRunning` requires an IP
- [ ] `UID` and `CreatedAt` read back
- [ ] `ListByTag` excludes unmarked instances
- [ ] `Get` during an in-progress delete
- [ ] `Get` during an asynchronous operation or lock
- [ ] never started is never `StateStopped`
- [ ] cancelled `ctx` returns promptly
- [ ] `classify` table with captured responses
- [ ] Checker reasons, expired `ctx`, stable order
- [ ] sentinel tests for `CloudInit` and the credential
- [ ] child spans under the operation span
- [ ] real-backend lifecycle test
**Answer:**
**Evidence:**
## 22. Deployment
**Question.** What changes in `config/` and the Makefile?
**Allowed answers.** The list: the `optional: true` mount in
`config/manager/manager.yaml`; the commented entries in
`config/manager/providers_config.yaml` and
`config/samples/providers-config.yaml`; `config/samples/proxy_<type>.yaml`
with a cloud-init that installs a proxy on the sample image; a Makefile
helper for the Secret; RBAC markers if the provider talks to the cluster's
own API; whether `hack/providers-dev.yaml` gets an entry.
**Answer:**
**Evidence:**
## 23. Docs
**Question.** Which documents are written or updated?
**Allowed answers.** `docs/<type>-provider.md` with its section list; the row
and section in `providers-existing.md`; the `architecture.md` rows (components
table, §5, §11, and §10 if spans are added); the README row; the
`docs/testing.md` section; the `CHANGELOG.md` entry after confirmation.
**Answer:**
**Evidence:**
## 24. Known limits accepted at design time
**Question.** Which of the contract's §13 limits bite this provider in
practice, and which new gaps does this design accept?
**Allowed answers.** A list, each with the consequence for an operator and
the workaround.
**Answer:**
**Evidence:**
+793
View File
@@ -0,0 +1,793 @@
# The existing providers
This is the appendix to [provider-contract.md](provider-contract.md): how the
five providers that exist today (kubernetes, gcp, kvm, kvm2, proxmox) meet
the contract, which profile each one chose on every axis, and what is still
missing. It is reference material for whoever writes the next provider, not
a user guide; the user guides are [kvm-provider.md](kvm-provider.md),
[kvm2-provider.md](kvm2-provider.md),
[proxmox-provider.md](proxmox-provider.md),
[gcp-vm-validation.md](gcp-vm-validation.md) and
[gcp-in-specific-project.md](gcp-in-specific-project.md). The README's
"Implemented providers" table stays the short public summary. kvm2 and
proxmox are the two providers built from the contract itself (kvm2
clean-room from [kvm2-provider-spec.md](kvm2-provider-spec.md) without
reading kvm's code, proxmox from
[proxmox-provider-spec.md](proxmox-provider-spec.md)), so their columns are
also a record of what the contract produced.
The per-provider call flows are drawn in
[architecture.md §5](architecture.md#5-what-provider-calls-do-in-the-outside-world),
the health checks are tabulated in
[§11](architecture.md#11-provider-health-checks-internalproviderhealth), and the
judgment calls behind several rows below are recorded under its "Decisions"
list. They are linked, not repeated.
Code references are a file path plus a function or symbol name.
## 1. Profile table
One row per axis of contract §4, in the same order, with the profile name
and the symbol that shows it.
| Axis | kubernetes | gcp | kvm | kvm2 | proxmox |
|---|---|---|---|---|---|
| `readiness` | immediate: a Pod is scheduled within seconds (`stateFromPod`) | provisioning-window: minutes of boot (`mapState`) | provisioning-window: boot plus, on first use, an image download (`imageCache.ensure`) | provisioning-window: boot plus, on first use, an image download (`ensureImage`) | provisioning-window: boot plus the guest agent's install at first boot, about 50 s to an address on the test host (`agentAddress`) |
| `create-return` | submit: returns after `client.Create` | submit: `Insert` is never awaited, `Operation.Wait` is not called | **blocking**: waits through `DomainCreate` (`ensureRunning`), and the first image fetch runs inside (`imageCache.fetch`) | **blocking**: waits through `DomainCreate` and a 15-second poll for running (`ensureRunning`, `waitRunning`), and the first image fetch runs inside (`fetchImage`) | **blocking**: waits through the clone, upload, resize and start tasks and a 60-second poll for running (`provision`, `waitRunning`) |
| `delete-return` | returns-early: `client.Delete`, Pod terminates on its own | returns-early: `Delete` submits an operation each time | waits: destroy, undefine and volume removal complete before return (`removeDomain`, `removeVolumes`) | waits: destroy, undefine and volume removal complete before return (`removeDomain`, `removeVolumes`) | waits: the stop, seed delete and destroy tasks complete before return (`destroy`, `deleteSeed`) |
| `ip-source` | assigned-at-create: `PodIP` (`instanceFromPod`) | assigned-at-create: the external NAT IP of the first NIC (`toInstance`) | discovered: ARP table first, then the guest agent, first routable IPv4 (`address`, `firstIPv4`) | discovered: the guest agent first, then the ARP table, first routable IPv4 (`addressSources`, `address`, `firstIPv4`) | discovered: the guest agent only, first routable IPv4 in the agent's order (`agentAddress`, `firstIPv4`) |
| `gc-mark` | native-labels: Pod labels (`buildPod`) | native-labels: instance labels (`buildInsertRequest`) | written-metadata: a namespaced `<metadata>` element written in `DomainDefineXML` (`metadata.go`) | written-metadata: an element in kvm2's own namespace written in `DomainDefineXML` (`metadataURI`, `newInstanceMetadata`) | written-metadata: three `key=value` lines in the VM's description, passed in the clone call itself (`formatDescription`, `parseDescription`) |
| `created-at` | backend-timestamp: `CreationTimestamp` | backend-timestamp: `CreationTimestamp`, parse error gives zero | written-at-create: `createdAt` in the metadata element (`newInstanceMetadata`) | written-at-create: `createdAt` in the metadata element, parse error gives zero (`newInstanceMetadata`, `createdAt`) | written-at-create: `proxy-operator-created-at` in the description, parse error gives zero (`parseDescription`) |
| `list-partial` | n/a: one call, all namespaces | omit-unreachable: `ReturnPartialSuccess` on `AggregatedList` | fail-whole per host, one unreadable domain skipped (`ListByTag`, `managedInstance`) | fail-whole per host, one unreadable domain skipped unless ctx ended; `ID`, `UID` and `CreatedAt` from the name and the mark only, no state or address RPC (`ListByTag`) | fail-whole for the one cluster-wide `/cluster/resources` call, one unreadable VM skipped unless ctx ended; `ID`, `UID` and `CreatedAt` from the description only, no status or agent call (`ListByTag`) |
| `quota` | absent: a quota 403 is Permanent (`classify`) | detected: 429, and 403 with reason `quotaExceeded` or `rateLimitExceeded` (`classify`, `hasReason`) | absent (`classify`) | detected: a pool-space precheck before any volume is created (`checkPoolSpace`, `neededBytes`) | absent: thin storage overprovisions, so no out-of-space answer exists to match; free space goes to the Checker's details (`classify`) |
| `not-found` | status-404: `apierrors.IsNotFound` | status-404: `isNotFound` | error-code: `ErrNoDomain`, `ErrNoStorageVol` (`hasCode`) | error-code: `ErrNoDomain`, `ErrNoStorageVol`, Transient inside Create (`classifyLibvirt`) | error-code: the 500 `Configuration file '…' does not exist`, plus list-and-miss on `/cluster/resources`; Transient inside Create (`isNotExist`, `classifyAPI`) |
| `primary-key` | name-is-key: `namespace/name` | name-is-key: `zones/<zone>/instances/<name>` | name-is-key: `hosts/<host>/domains/<name>` | name-is-key: `hosts/<host>/domains/<name>`, with a UID guard on adoption (`create`) | **allocated-id**: `vms/<vmid>/<name>`, the VMID from an FNV-1a hash of the UID with forward probing, each taken VMID put through an ownership check with a UID guard (`vmidFor`, `allocate`, `inspect`) |
| `provisioning-state` | native: `PodPending` | native: `PROVISIONING`, `STAGING`, `REPAIRING` | create-blocks: a domain is defined and started in one `Create`; `nostate` maps to Provisioning | create-blocks: defined, started and polled to running in one `Create`; `nostate` maps to Provisioning (`mapState`) | create-blocks: cloned, configured, started and polled to running in one `Create`; `qmpstatus: prelaunch` maps to Provisioning (`mapState`) |
| `async-ops` | none | task-handle, but the handle is discarded: an insert that fails after acceptance is never read | none | none | task-handle and backend-lock: every clone, upload, resize, start, stop, destroy and volume delete returns a UPID polled to its end within the call (`waitTask`); a VM's lock is waited on in Create and Transient in Get and Delete (`waitUnlocked`) |
| `secondary-resources` | none | cascade: `AutoDelete` on the boot disk (`buildInsertRequest`) | explicit: overlay and seed removed by name (`removeVolumes`); base image kept | explicit: overlay and seed removed by name (`removeVolumes`); base image kept | explicit: the seed ISO deleted by name (`deleteSeed`, `deleteSeedEverywhere`); the disks go with the destroy's `purge=1&destroy-unreferenced-disks=1` |
| `credentials` | workload-identity: in-cluster config or kubeconfig (`NewWithTransportWrapper`) | environment: Application Default Credentials | mounted-files: SSH key and known_hosts from the optional `kvm-ssh` Secret, read lazily (`newLazySSH`) | mounted-files: the same `kvm-ssh` Secret, read on every dial and never cached (`loadCredentials`) | mounted-files: the token from the optional `proxmox-token` Secret, read on every request (`readToken`); the CA from the same Secret, loaded once and cached (`httpClient`) |
| `client-kind` | vendor-sdk: controller-runtime `client.Client`, no seam interface | vendor-sdk: `cloud.google.com/go/compute` behind `instancesAPI` | rpc-over-ssh: `go-libvirt` over an SSH-forwarded unix socket, behind `libvirtAPI` (`conn.go`) | rpc-over-ssh: `go-libvirt` over an SSH-forwarded unix socket, behind the ctx-first `backend` seam (`backend.go`) | thin-client: `net/http` and `encoding/json` behind the ctx-first `api` seam, one method per endpoint (`api.go`, `httpAPI`) |
| `timeouts` | ctx-only: no `rest.Config.Timeout` is set | ctx-only: SDK defaults, with SDK retries on Get and List | per-call for the dial only (`defaultHandshakeTimeout` in `dialSSH`); RPCs are ctx-only and a cancelled `call` closes the connection | per-call: a budget around the dial, every RPC, the uploads and the download (`defaultBudgets`, `call`); a cancelled or overlong call kills the pooled connection | per-call: dial and TLS handshake 10 s, each request `requestTimeoutSeconds`, a budget per task wait (`defaultBudgets`); a cancelled request ends only itself |
| `logging` | none | tiered: V(1) outcomes and `logAPIError`, V(2) detail, V(5) wire log (`wirelog.go`) | tiered: V(1) outcomes and one `libvirt call` line per RPC (`trace.go`), V(2) XML and addresses; no wire tier | tiered: V(1) outcomes and one `libvirt call` line per RPC (`observe.go`), V(2) XML and addresses; no wire tier | tiered: V(1) outcomes and one `proxmox api call` line per request (`observe.go`), V(2) parameters, config summaries and addresses; no wire tier |
| `child-spans` | transport-wrapper: `tracing.RestConfigWrapper()` on its own `rest.Config`, parent-gated | sdk-instrumentation: the SDK adds `otelhttp` itself; not parent-gated, but every call site has a parent | **none**: `trace.go` is a log decorator, not spans | span-decorator: `kvm2.<Method>` per RPC (`observe`), plus `kvm2.dial` (`libvirtDialer`) and `kvm2.download` (`fetchImage`) | transport-wrapper: `tracing.RestConfigWrapper()` on the client's transport, parent-gated, one `HTTP <METHOD>` span per request (`newHTTPAPI`) |
| `backend-test` | cluster-e2e: kind (`test/e2e`) | manual-runbook: [gcp-vm-validation.md](gcp-vm-validation.md) | integration-tag: `kvm_integration_test.go`, `make test-integration-kvm` | integration-tag: `kvm2_integration_test.go`, `make test-integration-kvm2` | integration-tag: `proxmox_integration_test.go`, `make test-integration-proxmox`; passed on a PVE 9.2.20 host |
## 2. Other differences
Rows that are not profile axes but that a new provider author compares
against.
| Feature | kubernetes | gcp | kvm | kvm2 | proxmox |
|---|---|---|---|---|---|
| Create verifies the adopted instance's UID | no | no | no | **yes**: a domain without the kvm2 mark or with a different non-empty UID is Permanent; a marked one with a matching or empty UID is adopted (`create`) | **yes**: a VM with the proxy's name and no mark, or with a different non-empty UID, is Permanent; a marked one with a matching or empty UID is adopted (`inspect`) |
| Placement used | none | Zone, MachineType, Image | Zone (host), MachineType, Image | Zone (host), MachineType, Image | Zone (node), MachineType, Image (a template key) |
| CloudInit delivered | **no**: ignored | yes: `user-data` metadata | yes: NoCloud seed ISO built in-process (`buildSeedISO`) | yes: NoCloud seed ISO built in-process (`nocloud.BuildSeedISO`) | yes: NoCloud seed ISO built in-process (`nocloud.BuildSeedISO`) and uploaded through the API (`uploadSeed`) |
| Port used | yes: Squid config and containerPort (`squidConf`) | no: firewall is manual | no | no | no |
| States produced | Provisioning, Running, Terminated | all four | Provisioning, Running, Stopped | Provisioning, Running, Stopped | Provisioning, Running, Stopped |
| Deleting instance still reports its state | yes: a Pod being deleted reports its phase | n/a | n/a | n/a: Delete waits, and a concurrent Get sees the domain until it is undefined | n/a: Delete waits, and a concurrent Get sees the VM (Stopped once stopped) until the destroy removes its config |
| Error.Provider is the configured name | **no**: hardcoded `"kubernetes"` | yes | yes | yes (`wrapErr`) | yes (`wrapErr`) |
| Checker target | `apiserver` | the project | each host, concurrently | each host, concurrently, on a fresh connection | each node, concurrently, on the shared HTTP client |
| Checker Details | none | none | `user`, `pool`, `hostname`, `libvirtVersion`, `poolState`, `poolCapacity`, `poolAvailable` | `user`, `pool`, `hostname`, `libvirtVersion`, `poolState`, `poolCapacityGiB`, `poolAvailableGiB`, filled in as far as the check got | `isoStorage`, `tokenID`, `pveVersion`, `kernel`, `isoStorageAvailGiB`, `templates`, filled in as far as the check got |
| Credential rotation without restart | n/a | via ADC | no: cached after first successful load | yes: both files read on every dial (`loadCredentials`) | the token yes, read on every request (`readToken`); the CA no, cached after first use |
| Connection handling | client-go | SDK client, never closed | one pooled SSH plus libvirt connection per host, 15s keepalive (`hostPool`) | one pooled SSH plus libvirt connection per host, 15s keepalive (`hostTable`); dedicated connections for the image upload and the check (`dialFresh`) | one `net/http` client, 4 idle connections per host, HTTP/1.1 only; a cancelled request ends only itself |
| Unit-test double | controller-runtime fake client | `fakeAPI` behind `instancesAPI`, plus `httptest` for the real SDK client in `check_test.go` | `fakeAPI` behind `libvirtAPI`, plus an in-process `x/crypto/ssh` server in `conn_test.go` | `fakeBackend` behind `backend` (`fake_test.go`), plus an in-process `x/crypto/ssh` server (`sshserver_test.go`) and go-libvirt's own mock for the adapter | `fakeAPI` behind `api` (`fake_test.go`), plus `httptest.NewTLSServer` replaying the Phase 0 captures for the real client (`client_test.go`) |
| Proxy software | generated Squid config (`pod.go`) | from `cloudInit`; the image must run cloud-init | from `cloudInit`, plus a vendor-data script that installs the guest agent (`seed.go`) | from `cloudInit`, plus a vendor-data script that installs the guest agent (`nocloud.GuestAgentScript`) | from `cloudInit`, plus a vendor-data script that installs the guest agent (`nocloud.GuestAgentScript`) |
| IP reachable from the operator | Pod network, so in-cluster only (not from `make run-dev` on a laptop) | external IP, once a firewall rule allows the cluster's egress IP | LAN address on the host's bridge; the operator needs a route to that LAN | LAN address on the host's bridge; the operator needs a route to that LAN | address on the proxy network (bridge or VLAN from the config); the operator needs a route to it |
| Distinct egress IP per proxy | no: the cluster's egress | yes: ephemeral external IP | whatever the host's LAN routes through; not per VM by itself | whatever the host's LAN routes through; not per VM by itself | whatever the proxy network routes through; not per VM by itself |
## 3. Observability status
| Layer | kubernetes | gcp | kvm | kvm2 | proxmox |
|---|---|---|---|---|---|
| 1. Operation spans and metrics | yes: decorators | yes: decorators | yes: decorators | yes: decorators | yes: decorators |
| 2. Child spans per backend call | yes: `tracing.RestConfigWrapper()`, parent-gated (`cmd/main.go`, `NewWithTransportWrapper`) | yes: the SDK's own `otelhttp`; not parent-gated | **no** | yes: `kvm2.<Method>` from the `observe` decorator, `kvm2.dial`, `kvm2.download`; always under the caller's span, since every call carries the caller's ctx | yes: `tracing.RestConfigWrapper()` on the transport, parent-gated, one `HTTP <METHOD>` per request |
| 3. Logs carry `traceID` and `spanID` | n/a: the package does not log | yes, including the V(5) wire log | yes: every `libvirt call` line at V(1) | yes: every `libvirt call` line at V(1), written inside its RPC's span | yes: every `proxmox api call` line at V(1), with the operation span's `spanID` |
| Tested against a real collector | yes: the kind e2e checks `provider.create` and `provider.delete` in Tempo (`test/e2e/tracing_test.go`), operation spans only | no | no | no; the integration test asserts the parent-child topology in an in-memory recorder (`assertLifecycleSpans`) | no; `TestTracing_httpSpansUnderOperationSpan` asserts the parent-child topology in an in-memory recorder |
## 4. kubernetes (`internal/provider/kubernetes`)
- **Package and seam.** `kubernetes.go`, `pod.go`, `check.go`. There is no
seam interface: `newWithClient` injects a controller-runtime
`client.Client`, and tests use the fake client. `New` calls
`NewWithTransportWrapper`, which builds its own uncached client from
`ctrl.GetConfig()` with an optional `transport.WrapperFunc` for tracing.
- **Config.** `KubernetesConfig.Image` only, defaulting to the Squid image
constant in `kubernetes.go`.
- **ID.** `providerID(namespace, name)` gives `namespace/name`;
`parseProviderID` uses `cache.SplitMetaNamespaceKey` and rejects an empty
namespace with `ErrPermanent`. An empty name part is not rejected.
- **Create.** `buildPod` (pure) then `client.Create`; `IsAlreadyExists` is
success. Only `Name`, `Namespace`, `UID` and `Port` are used. The Squid
config is generated by `squidConf` from the port and passed through the
`SQUID_CONF` environment variable, so there is exactly one object per
proxy.
- **Get and state.** `stateFromPod`:
| Pod phase | PodIP | Provider state |
|---|---|---|
| Pending | any | Provisioning |
| Running | empty | Provisioning |
| Running | set | Running |
| Succeeded, Failed, Unknown | any | Terminated |
`instanceFromPod` sets `IP` only when the state is Running. A Pod with a
deletion timestamp still reports its phase. `UID` from the `LabelUID`
label, `CreatedAt` from `CreationTimestamp`.
- **Delete.** `client.Delete`, `IsNotFound` ignored.
- **ListByTag.** One `client.List` across all namespaces with
`MatchingLabels{LabelManaged: LabelManagedYes}`; this is what the
cluster-wide Pod RBAC is for.
- **Errors.** `classify`: `IsNotFound` NotFound; `IsTooManyRequests`,
`IsServerTimeout`, `IsTimeout` Transient; `IsForbidden`, `IsInvalid`,
`IsBadRequest`, `IsUnauthorized` Permanent; default Transient. Nothing is
Quota, because a quota 403 and an RBAC 403 cannot be told apart.
- **Check.** Lists at most one managed Pod cluster-wide (`client.Limit(1)`),
proving read and list access, not create. Target `apiserver`, address the
rest config host. `checkReason`: context errors and timeouts
`ReasonTimeout`; `IsUnauthorized`; `IsForbidden`; any `APIStatus`
`ReasonAPIError`; else `ReasonUnreachable`.
- **Logging and tracing.** No log lines in the package. Child spans come
from the parent-gated transport wrapper.
- **Tests.** `kubernetes_test.go`, `pod_test.go`, `check_test.go` over the
fake client; the kind e2e (`test/e2e`) is the real-backend run.
- **Deployment.** RBAC markers for Pods live next to the reconciler's in
`internal/controller/proxy_controller.go`. `hack/providers-dev.yaml` holds
only this provider, for `make run-dev`.
- **Known gaps.**
- `Error.Provider` is hardcoded to `"kubernetes"`, so errors and logs show
the wrong name when the configured name differs.
- `CloudInit` and all of `Placement` are ignored.
- No quota class: a quota 403 is Permanent.
- A Pod being deleted still reports its phase, so it can read as Running.
- No securityContext, no resources, and `http_access allow all` in
`pod.go`.
- No logging in the package, and no Checker `Details`.
- No `OwnerReference` on Pods: a bypassed finalizer leaves the Pod behind.
- The Pod RBAC is always installed, even when no kubernetes provider is
configured.
## 5. gcp (`internal/provider/gcp`)
- **Package and seam.** `gcp.go`, `insert.go`, `errors.go`, `check.go`,
`wirelog.go`. The seam is `instancesAPI` (`Insert`, `Get`, `Delete`,
`AggregatedList`, `Probe`); `realInstances` adapts the SDK client and
`newWithAPI` is the test constructor. `New` calls `NewWithWireOptions`,
which takes the `--gcp-wire-log-full-payloads` flag from `cmd/main.go`.
- **Config.** `GCPConfig`: `project` required; `network`, `networkTag`,
`diskSizeGb` defaulted in `withDefaults`.
- **ID.** `formatProviderID(zone, name)` gives
`zones/<zone>/instances/<name>`; `parseProviderID` rejects anything else
with `ErrPermanent`. The project comes from the config.
- **Create.** Validates `Zone`, `MachineType` and `Image` up front and
returns `ErrPermanent` naming the missing field. `buildInsertRequest`
(pure) builds one NIC with an ephemeral external IP (`ONE_TO_ONE_NAT`) and
a boot disk with `AutoDelete`, labelled with both marks. `Insert` is
submitted and never awaited; 409 (`isAlreadyExists`) is success.
- **Get and state.** `mapState(status, ip)`:
| Instance status | NAT IP | Provider state |
|---|---|---|
| PROVISIONING, STAGING, REPAIRING | any | Provisioning |
| RUNNING | empty | Provisioning |
| RUNNING | set | Running |
| STOPPING, STOPPED, SUSPENDING, SUSPENDED | any | Stopped |
| TERMINATED | any | Terminated |
| anything else | any | Stopped |
`toInstance` reads the NAT IP of the first access config of the first
NIC, `UID` from the label, and `CreatedAt` from `CreationTimestamp` with
the parse error dropped (zero time on failure).
- **Delete.** Submits a delete operation and returns; 404 (`isNotFound`) is
success. Every deletion poll submits a new operation.
- **ListByTag.** `AggregatedList` with `managedFilter` on the managed label
only and `ReturnPartialSuccess`, so an unreachable zone is left out
silently; the zone is recovered from the URL by `lastPathSegment`.
- **Errors.** `classify`: non-`googleapi` errors Transient; 404 NotFound;
429 Quota; 403 with reason `quotaExceeded` or `rateLimitExceeded`
(`hasReason`) Quota; 400, 401 and other 403s Permanent; other codes
Transient. `logAPIError` records the HTTP status and reasons at V(1)
because `classify` collapses them.
- **Check.** `Probe` makes one `AggregatedList` page with `maxResults=1`
and the managed filter: credentials, the Compute API and
`compute.instances.list` in one call. Target is the project, address
`compute.googleapis.com`. `checkReason`: context errors `ReasonTimeout`;
non-`googleapi` errors `ReasonUnreachable`; 401; 403; else
`ReasonAPIError`.
- **Logging.** `logger(ctx)` from the context, named `gcp` with
`provider`. V(2) for the curated insert request (never the user-data),
V(1) per operation outcome, V(2) per listed instance. The wire log in
`wirelog.go` runs at V(5) with payloads elided unless the flag is set.
- **Tracing.** The SDK adds `otelhttp` itself unless telemetry is disabled;
not parent-gated.
- **Tests.** `insert_test.go` (pure builder), `errors_test.go` (code
table), `gcp_test.go` (IDs, 409, state mapping, list filter and partial
success) over `fakeAPI`; `check_test.go` also drives the real SDK client
against `httptest` to prove exactly one request with `maxResults=1`.
Real backend: the manual runbook in [gcp-vm-validation.md](gcp-vm-validation.md).
- **Deployment.** Credentials by ADC; the runbook mounts a service-account
key and points `GOOGLE_APPLICATION_CREDENTIALS` at it. A commented entry
in both providers configs, `config/samples/proxy_gcp.yaml`, and demo
scripts under `docs/demo/`.
- **Known gaps.**
- Asynchronous insert failures are never read: an insert the API accepts
but that fails later (quota, bad image) is seen only as NotFound on the
next `Get`, and the reconciler creates again, so the reason never
reaches status. [gcp-vm-validation.md](gcp-vm-validation.md#gotchas)
still says such errors do reach status.
- External IP only: a VM without an external NAT IP stays Provisioning
forever.
- No firewall management: the rule for the proxy port is a manual
prerequisite, and `req.Port` is unused.
- The SDK client is never closed, and `Placement.Region` is unused.
- Wrong label value in the docs: `gcp-vm-validation.md` filters on
`labels.proxy-operator-managed=yes`; the value is `true`.
- The sample cannot work: `config/samples/proxy_gcp.yaml` uses a
`debian-cloud` image, which has no cloud-init, so its Squid install never
runs ([gcp-in-specific-project.md](gcp-in-specific-project.md) uses
Ubuntu for that reason).
## 6. kvm (`internal/provider/kvm`)
The full user guide is [kvm-provider.md](kvm-provider.md); this section is
the author's view.
- **Package and seam.** `kvm.go` (the four methods), `conn.go` (SSH dial,
`hostPool`, `libvirtAPI`), `image.go` (`imageCache`), `seed.go` (NoCloud
ISO and vendor-data), `domainxml.go`, `volume.go`, `metadata.go`, `id.go`,
`errors.go`, `check.go`, `trace.go`. The seam is `libvirtAPI`, the twenty
go-libvirt calls the provider uses; `fake_test.go` implements it in memory
and `newWithDialer` injects the dialer and the HTTP client.
- **Config.** `KVMConfig` in `internal/provider/config_kvm.go`:
`sshKeyFile`, `knownHostsFile` or `insecureIgnoreHostKey`, `diskSizeGb`,
`vendorData`, `installGuestAgent`, `images` (`url`, `sha256`, `format`,
`firmware`), `machineTypes` (`vcpus`, `memoryMiB`), `hosts` (`name`,
`address`, `user`, `socket`, `pool`, `bridge`). `validateKVM` restricts
image keys and host names with `kvmIdentifier` because they become a volume
name and an ID segment.
- **ID.** `formatProviderID(host, name)` gives `hosts/<host>/domains/<name>`.
- **Create.** `host` resolves `Placement.Zone` (optional with one host),
the machine type must be a catalogue key, `resolveImage` accepts a
catalogue key or an http(s) URL. Then, inside one `hostPool.call`:
`DomainLookupByName`, and if found `ensureRunning`; otherwise
`imageCache.ensure` (download on first use, streamed through the operator
into the pool with a sha256 marker volume), `ensureOverlay` (reused if it
exists), `writeSeed` (always rebuilt: a cut-off upload must not be
trusted), `defineDomain` with the metadata element, `ensureRunning`.
`Create` returns only when the domain is running.
- **Get and state.** `mapState(state, ip)`:
| libvirt state | IP | Provider state |
|---|---|---|
| nostate | any | Provisioning |
| running, blocked | empty | Provisioning |
| running, blocked | set | Running |
| paused, shutdown, shutoff, crashed, pmsuspended | any | Stopped |
`address` asks the ARP table first, then the guest agent, and
`firstIPv4` drops loopback, link-local and unspecified addresses. Metadata
is read back by `metadata` and parsed by `parseInstanceMetadata`; a domain
without the element reports an empty UID.
- **Delete.** `removeDomain` (destroy, tolerating not running or gone; then
`DomainUndefineFlags` with NVRAM, managed save and snapshot metadata) and
`removeVolumes` (overlay and seed by name, a missing pool is a no-op). A
host no longer in the config is a logged no-op success.
- **ListByTag.** `ConnectListAllDomains` on every host; `managedInstance`
keeps domains whose metadata says managed and skips a domain whose
metadata or state cannot be read. One unreachable host fails the whole
list.
- **Errors.** `classify`: `isClassified` errors keep their class;
`ErrNoDomain`, `ErrNoStorageVol` NotFound; XML, unsupported,
invalid-argument, auth and access errors, and a missing pool, Permanent;
everything else Transient, including SSH handshake and auth failures.
Nothing is Quota. `logAPIError` adds `libvirtErrorFields`.
- **Check.** `checkHost` per host concurrently through `hostPool.probe`,
not `call`, so a check that runs out of time abandons its RPCs instead of
closing the connection a reconcile may be using. Two cheap RPCs
(`ConnectGetLibVersion`, `ConnectGetHostname`) then the pool must exist
and be running. Reasons: the shared ones plus `SSHMaterialUnavailable`,
`SSHHandshakeFailed`, `LibvirtConnectFailed`, `LibvirtError`,
`PoolNotFound`, `PoolNotActive`, chosen by `checkReason` from the
`dialError` stage. Details: `user`, `pool`, `hostname`, `libvirtVersion`,
`poolState`, `poolCapacity`, `poolAvailable`.
- **Connections.** `hostPool` keeps one SSH plus libvirt connection per
host, dialled lazily (`lazySSH.dial`, `sshAuth.dial`, `dialSSH`,
`connectLibvirt`), with a 15s `keepalive`. `hostEntry.acquire` is a
one-slot semaphore so waiting can end with the caller's context. A
cancelled `call` closes the shared connection to unblock the RPC;
`probe` abandons it and leaves the connection open. The SSH material is
read once by `lazySSH.load` and cached.
- **Logging.** Logger from the context, named `kvm`, with `provider`,
`host` and `name`. V(1): one line per operation outcome and one
`libvirt call` line per RPC from the `tracingAPI` decorator in `trace.go`
(rpc name, identifiers, duration, error fields). V(2): domain and volume
XML, metadata, addresses. Upload and download bodies are never logged.
- **Tracing.** No child spans; `trace.go` is a log decorator.
- **Tests.** Pure builders (`domainxml_test.go`, `volume_test.go`,
`seed_test.go`, `id_test.go`, `metadata_test.go`); `errors_test.go`,
`conn_test.go` (pool behaviour and a real in-process SSH server);
`image_test.go`, `kvm_test.go` (Create resumes after every partial step,
every Delete already-gone case, ListByTag policies, logging tiers, a
cloud-init sentinel never logged); `trace_test.go`; `check_test.go`. Real
backend: `kvm_integration_test.go` behind `//go:build integration`, driven
by `KVM_TEST_*` variables and `make test-integration-kvm`, described in
[testing.md](testing.md#the-kvm-integration-test-real-hypervisor).
- **Deployment.** The optional `kvm-ssh` Secret mounted at
`/etc/proxy-operator/kvm` (`config/manager/manager.yaml`),
`make kvm-ssh-secret`, a commented entry in both providers configs,
`config/samples/proxy_kvm.yaml`.
- **Known gaps** (see also [kvm-provider.md § Known gaps](kvm-provider.md#known-gaps)).
- No quota or capacity detection.
- `Create` blocks, including the first image download on each host.
- GC blind spots: volumes without a domain are invisible to GC, and so are
VMs on a host removed from the config, which `Delete` treats as done.
- SSH material is cached after the first successful load, so rotating
keys needs a restart.
- Cancelling one operation cuts off the others on that host: a cancelled
`call` closes the shared per-host connection.
- SSH auth failures retry forever: SSH authentication and host-key
failures are Transient, while libvirt auth errors are Permanent.
- `Create` can return `ErrNotFound` when a volume or domain disappears
during the call.
- No reused-overlay check: an existing overlay is reused without checking
its backing image or size.
- Sample mismatch: `config/samples/proxy_kvm.yaml` places on host `nas1`,
while both providers configs define only `beelink`.
- Doc drift in kvm-provider.md: it says ARP is the fallback, but the code
tries ARP first; it says `host` and `name` are on every log line, but
the check and list paths lack them.
## 7. kvm2 (`internal/provider/kvm2`)
The full user guide is [kvm2-provider.md](kvm2-provider.md) and the design
it was built from is [kvm2-provider-spec.md](kvm2-provider-spec.md); this
section is the author's view. Nothing in the package was taken from
`internal/provider/kvm`; where the two agree it is because both follow the
same libvirt.
- **Package and seam.** `kvm2.go` (`Provider`, `New`, `withDefaults`,
`host`, `wrapErr`, the per-name lock), `create.go`, `get.go`,
`delete.go`, `list.go` (the four methods), `backend.go` (the seam, the
production adapter and the budgets), `ssh.go` (credentials, the forwarded
socket, keepalive), `hosts.go` (the pooled table and the production
dialer), `observe.go` (the span and log decorator), `image.go` (cache,
download, quota precheck), `xml.go`, `volume.go`, `metadata.go`,
`id.go`, `state.go`, `errors.go`, `check.go`; the seed ISO comes from
the shared `internal/provider/nocloud` and both lock sets are
`internal/provider/keyedlock`. The seam is
`backend`: the twenty go-libvirt calls the provider uses, each with `ctx`
first (go-libvirt's own methods take none and block), plus `Close`.
`libvirtBackend` adapts `*libvirt.Libvirt` and runs every call through
`call`, which applies the budget and the abandon policy. The `dialer`
seam separates the SSH transport: `newProvider` injects one for tests,
`newLibvirtDialer` is production. The `observe` decorator is applied
once, in `libvirtDialer.dial`, so provider code never holds an
undecorated backend.
- **Config.** `ProviderConfig.KVM2`, the same `KVMConfig` type as kvm,
validated by `validateKVMBlock` with block name `kvm2` so messages read
`kvm2.hosts`. Defaults (`diskSizeGb` 10, socket, pool, image format and
firmware) are filled by `withDefaults`. `New` never dials; it checks that
the credential files exist and warns when one does not, or when
`insecureIgnoreHostKey` is on.
- **ID.** `formatProviderID(host, name)` gives `hosts/<host>/domains/<name>`;
`parseProviderID` accepts exactly four segments with the literal
`hosts` and `domains` and non-empty host and name, else `ErrPermanent`.
- **Create.** `Create` validates `Zone` (`host`; optional with one host),
`MachineType` (a catalogue key) and `Image` (`resolveImage`: a catalogue
key or an http(s) URL with qcow2 and uefi defaults) before any dial, and
logs the resolved parameters with `cloudInitBytes` at V(2). Then, under
the per-name lock (`lockName`) and on the pooled connection
(`hostTable.get`), `create`: `DomainLookupByName`; found means the
adoption guard (no kvm2 mark, or a non-empty metadata UID that differs
from `req.UID`, is Permanent) and `ensureRunning`. Otherwise
`StoragePoolLookupByName`, `imageSizeIfUncached` (marker lookup, then
`HEAD`, or a closed `GET` when `HEAD` is not 2xx) and
`checkPoolSpace` with `neededBytes` (image if uncached, plus
`diskSizeGb`, plus 16 MiB), then `ensureImage` (`cachedImage` on the
pooled connection, `fetchImage` streaming into `uploadImage` on a
`dialFresh` connection, `StoragePoolRefresh`, `writeMarker` last),
`ensureOverlay` (`ErrStorageVolExist` means reuse), `writeSeed` (delete,
build, upload; always rebuilt), two `StorageVolGetPath`, `buildDomainXML`
with file-type disks and the metadata element, `DomainDefineXML`,
`ensureRunning` (`DomainGetState`, where paused or pmsuspended is
Permanent; `DomainCreate` with `ErrOperationInvalid` as already running;
`waitRunning` polls every 500 ms for up to 15 seconds, Transient on
expiry and at once for any inactive state other than `nostate`).
`Create` returns only when the domain is running.
- **Get and state.** `mapState(state, ip)`:
| libvirt state | IP | Provider state |
|---|---|---|
| nostate | any | Provisioning |
| running, blocked | empty | Provisioning |
| running, blocked | set | Running |
| paused, shutdown, shutoff, crashed, pmsuspended, anything else | any | Stopped |
`get`: `DomainLookupByName` (only `ErrNoDomain` reaches NotFound),
`readMetadata` (`DomainGetMetadata` with `metadataURI`;
`ErrNoDomainMetadata` means unmanaged with an empty UID; an element that
does not parse is Permanent), then `DomainGetState` and, for an active
domain, `address`. `addressSources` asks the guest agent first,
then ARP; a source that fails is skipped at V(2), only ctx ending is an
error, so a cancelled Get is Transient and not a false Provisioning.
`firstIPv4` drops loopback, link-local and unspecified addresses and an
address whose type tag and literal disagree. `UID` and `CreatedAt` are
read back on every call; an unparsable `createdAt` gives the zero time.
- **Delete.** Under the per-name lock: `removeDomain` (`DomainDestroy`,
tolerating not running and gone; `DomainUndefineFlags` with NVRAM,
managed save and snapshot metadata) and `removeVolumes` (overlay and seed
by name through `deleteVolumeIfPresent`; a missing pool is a no-op). A
host no longer in the config is a logged no-op success. Nothing is left
when nil is returned.
- **ListByTag.** `ConnectListAllDomains` (active and inactive) on every
host in config order; `readMetadata` and `managed` keep kvm2's own
domains, and the `Instance` is `ID`, `UID` and `CreatedAt` from the name
and the element, with `State` and `IP` empty: GC reads nothing else
(contract §2.4), so no state or address RPC is made. A domain whose
metadata cannot be read is skipped at V(1), unless ctx ended, which
fails the call. A host that cannot be dialled or listed fails the whole
call.
- **Errors.** `classify(op, err)`: an error already carrying a class keeps
it; libvirt codes through `classifyLibvirt` (`ErrNoDomain` and
`ErrNoStorageVol` NotFound, Transient when `op == "create"`;
`ErrNoStoragePool`, `ErrXMLError`, `ErrNoSupport`, `ErrInvalidArg`,
`ErrAuthFailed`, `ErrAccessDenied` Permanent; the rest Transient);
`knownhosts.KeyError` and the `unable to authenticate` text from
`x/crypto/ssh` Permanent; everything else Transient, including a missing
credential file. `permanentf` and `transientf` classify at the source:
placement, XML builders, a parse failure of the key or known_hosts, an
image 4xx, a missing or zero `Content-Length`, a sha256 mismatch, a pool
that is not running (Permanent); an image 429 or 5xx, a short body, the
running wait expiring (Transient). `checkPoolSpace` is the only source of
`ErrQuotaExceeded`. `logAPIError` writes `kvm2 backend call failed` with
`libvirtErrorFields`.
- **Check.** `Check` runs `checkHost` per host concurrently, each through
`probeHost` on a `dialFresh` connection that is closed when the probe
ends; the pooled table is never consulted. `ConnectGetLibVersion`,
`ConnectGetHostname`, `StoragePoolLookupByName`, `StoragePoolGetInfo`;
a pool that is not running is `poolNotActiveError`. Details are filled
as facts arrive (`user` and `pool` always; `libvirtVersion`, `hostname`,
`poolState`, `poolCapacityGiB`, `poolAvailableGiB`). `checkHost` answers
`Timeout` whenever the round's ctx ended, whatever stage was running and
whatever shape the error has; otherwise `checkReason`: a `dialError` is
judged by its stage (`credentials` gives `CredentialsUnavailable`; `ssh`
with a `knownhosts.KeyError` or the auth text gives `Unauthorized`; any
other stage, including a dial that ran out of its own budget, gives
`Unreachable`); then `context.DeadlineExceeded` or `Canceled` `Timeout`,
`ErrAccessDenied` or `ErrAuthFailed` `Forbidden`, `ErrNoStoragePool`
`PoolNotFound`, any other libvirt code `APIError`, anything else
`Unreachable`; `PoolNotActive` from the pool state.
- **Connections.** `hostTable` keeps one pooled connection per host,
dialled lazily under a per-host one-slot semaphore so waiting ends with
the caller's ctx; a dead backend (`liveness`) is closed and redialled on
the next `get`. `dialSSHSocket` runs credentials, TCP, the SSH handshake
and the `direct-streamlocal` channel to the socket, each failure a
`dialError` with its stage; `libvirtDialer.connect` bounds the whole
sequence plus the libvirt `Connect` with the dial budget and starts the
`keepalive` (15 s, `keepalive@openssh.com`) and a watcher on go-libvirt's
`Disconnected`. `call` runs each blocking go-libvirt call in a goroutine
and waits on the result, ctx and a budget timer; abandoning a call
(`link.markDead`) closes the socket and the SSH client, returns Transient
wrapping the ctx or deadline error, and every other call waiting on that
link fails once with Transient. `loadCredentials` runs on every dial.
- **Logging.** Logger from the context, named `kvm2`, with `provider`,
and `host` and `name` on lines about an instance. V(1): one line per
operation outcome (`kvm2 domain ensured running`, `kvm2 domain fetched`,
`kvm2 domain deleted`, `kvm2 domains listed`, `kvm2 host checked`), one
`libvirt call` line per RPC from `observe` (rpc name, identifiers,
duration, error fields), the connection events `libvirt host connected`
and `libvirt host connect failed` with `stage`, and `kvm2 backend call
failed`. V(2): XML, metadata, addresses, listed names, pool sizes, the
resolved Create parameters. Upload and download bodies, cloud-init and
key material are never logged.
- **Tracing.** `observe.call` starts `kvm2.<Method>` per RPC with
`kvm2.host` and the identifiers (`kvm2.domain`, `kvm2.pool`,
`kvm2.volume`), sets `kvm2.libvirt_code` on failure and writes the
`libvirt call` line inside the span; `libvirtDialer.dial` starts
`kvm2.dial` (`kvm2.address`, `kvm2.user`); `fetchImage` starts
`kvm2.download` (`kvm2.url_host`, `kvm2.sha256_configured`,
`kvm2.bytes`). Both the decorator and the dialer take `tracing.Option`
so tests inject a recorder; `New` leaves them on the global provider.
- **Tests.** Pure builders (`xml_test.go`, `volume_test.go`,
`seed_test.go`, `id_test.go`, `metadata_test.go`, `state_test.go`);
`errors_test.go` (the classify table with real SSH error strings and a
real `knownhosts.KeyError`); `backend_test.go` (the adapter over
go-libvirt's own mock through a `net.Pipe`: deadline kills the
connection, cancel unblocks, killed by another caller); `hosts_test.go`;
`ssh_test.go` over `sshserver_test.go` (every dial stage, host-key
policies, key material never in errors or logs); `image_test.go`
(marker rules, streaming asserted by counting bytes, dedicated upload
connection); `create_test.go`, `get_test.go`, `delete_test.go`,
`list_test.go`, `kvm2_test.go`, `provider_test.go` (the contract §11
list, named test by test in the spec's entry 21); `observe_test.go`
(every seam method spanned and logged exactly once, by reflection);
`check_test.go` (the reason table, never touches the pool). The double is
`fakeBackend` in `fake_test.go`, whose `twin` shares state under its own
call log so a test can tell the pooled connection from a dedicated one.
Real backend: `kvm2_integration_test.go` behind `//go:build integration`,
driven by `KVM2_TEST_*` variables and `make test-integration-kvm2`,
described in
[testing.md](testing.md#the-kvm2-integration-test-real-hypervisor).
- **Deployment.** The `kvm-ssh` Secret and its mount are shared with kvm
(`config/manager/manager.yaml` says so), `make kvm-ssh-secret` is reused,
a commented `kvm2-home` entry in both providers configs,
`config/samples/proxy_kvm2.yaml` (zone `beelink`, matching the shipped
config), the `kvm2` type in the `status.type` comment of
`api/v1alpha1/providerhealth_types.go`. No RBAC.
- **Known gaps** (see also [kvm2-provider.md § Known gaps](kvm2-provider.md#known-gaps)).
- `Create` blocks, including the first image download on each host.
- GC blind spots: volumes without a domain are invisible to GC, and so are
VMs on a host removed from the config, which `Delete` treats as done.
- No adoption of kvm's domains: a switched entry needs its Proxies
deleted first.
- Cancelling one operation cuts off the others on that host once: a
cancelled `call` kills the pooled connection.
- `Content-Length` is required for image downloads.
- A paused or pmsuspended domain is not started by a repeat `Create`:
`ensureRunning` returns Permanent, so the Proxy latches until its spec
changes; the admin resumes the VM on the host.
- Credential parse errors carry the parser's own text, which can quote a
PEM header type or a malformed known_hosts token (never key bytes).
- No reused-overlay check: an existing overlay is reused without checking
its backing image or size.
- The sample cloud-init installs Squid with `http_access allow all`.
## 8. proxmox (`internal/provider/proxmox`)
The full user guide is [proxmox-provider.md](proxmox-provider.md) and the
design it was built from is
[proxmox-provider-spec.md](proxmox-provider-spec.md); this section is the
author's view. It is the second provider built from the contract, and the
first on a REST API of the hypervisor's own: no SSH, no libvirt. Every
error text it matches was captured from a PVE 9.2.20 host before the code
was written.
- **Package and seam.** `proxmox.go` (`Provider`, `New`, `withDefaults`,
`node`, `wrapErr`, the per-name lock, the budgets), `create.go`,
`get.go`, `delete.go`, `list.go` (the four methods), `api.go` (the
seam), `client.go` (the production adapter `httpAPI`, the token and CA
loading), `observe.go` (the logging decorator), `task.go` (task, lock
and running waits), `seed.go`, `mark.go`, `id.go`, `state.go`,
`errors.go`, `check.go`; the seed ISO comes from the shared
`internal/provider/nocloud` and the name lock from
`internal/provider/keyedlock`. The seam is `api`: one method per
endpoint, sixteen in all, each with `ctx` first. `New` wraps `httpAPI` in
`observe` once, so provider code never holds an undecorated seam;
`newProvider` is the test constructor.
- **Config.** `ProviderConfig.Proxmox`, the `ProxmoxConfig` type in
`internal/provider/config_proxmox.go`, validated by `validateProxmox`
(the URL shape, node names and storage ids by regexp because they end
up in request paths, `vlanTag`, `vmidRange`, the two catalogues).
`isoStorage` has no default on purpose: the token needs
`Datastore.Allocate` there. Defaults (`vmidRange` 100000 to 199999,
`requestTimeoutSeconds` 30, `installGuestAgent` true) are filled by
`withDefaults`. `New` never makes a request; it warns when the token
file does not exist, or when `insecureSkipTLSVerify` is on.
- **ID.** `formatProviderID(vmid, name)` gives `vms/<vmid>/<name>`, with no
node, because VMs migrate; the node is resolved from
`/cluster/resources` on every call (`resolve`). `parseProviderID`
accepts exactly three segments, the literal `vms`, a decimal VMID
without leading zeros between 100 and 999999999 and a non-empty name,
else `ErrPermanent`. The name is there so `Get` and `Delete` can tell
our VM from another that later got the same VMID.
- **Create.** `placement` checks `Zone` (`node`; optional with one node),
`MachineType` and `Image` (both catalogue keys) before any request, and
logs the resolved parameters with `cloudInitBytes` at V(2). Then, under
the per-name lock, `create`: one `ClusterResources` read;
`adoptByName` (two VMs with the name are Permanent, one goes to
`inspect`); with none, `template` reads the template's config and
refuses a non-template, a cloud-init drive, a missing `net0` when the
config rewrites it, and an unsized boot disk when `diskGiB` is set, all
before any clone; `allocate` probes `vmidFor(uid, attempt)` up to 32
times, cloning onto a free VMID and running `inspect` on a taken one or
on the clone's `config file already exists` answer. `inspect` waits out
a lock (`waitUnlocked`; a clone in progress is also nameless), then
adopts our name with our mark and a matching or empty UID, probes past
another name, and is Permanent for our name without the mark, with
another UID, or hibernated. An adopted VM gets one `VMStatus`: running
returns at once. Otherwise `provision`: `uploadSeed` (rebuilt and
overwritten every attempt), `SetVMConfig` (`configParams`: cores,
memory, `agent=1`, the tag, `ide2`, and `net0` through `rewriteNet0`
when a bridge or VLAN is configured), the resize task when
`resizeTarget` says the boot disk is smaller than `diskGiB`, the start
task, and `waitRunning` (every second up to 60 s, Transient on expiry
or on a stop). `Create` returns only when the VM runs.
- **Get and state.** `get` checks in order: absent from the resources is
NotFound; a lock other than `suspended` is Transient (checked before the
name, because a mid-clone entry has none); `status: unknown` is
Transient; the config's `does not exist` is NotFound, a config lock
Transient; another name or no mark is NotFound; hibernated is Stopped
without further calls. Then `VMStatus` and `mapState(status,
qmpstatus, ip)`:
| `status` | `qmpstatus` | agent | Provider state |
|---|---|---|---|
| running | running | an IPv4 | Running |
| running | running | 200 without an IPv4, or `QEMU guest agent is not running` | Provisioning |
| running | running | `No QEMU guest agent configured` | Stopped |
| running | prelaunch | not asked | Provisioning |
| running | paused, suspended, io-error, anything else | not asked | Stopped |
| stopped, anything else | any | not asked | Stopped |
Any other agent answer, including `VM <vmid> is not running`, is
Transient. `UID` and `CreatedAt` are read from the description on every
call. A running `Get` is four calls: resources, config, status, agent.
- **Delete.** Under the per-name lock, `destroy`: `resolve`; a lock other
than `suspended` or `status: unknown` is Transient; the config's `does
not exist`, another name or no mark leave the VM alone; our VM
hibernated is destroyed like a stopped one, since the destroy takes it.
Otherwise stop (unless stopped), `deleteSeed` on the
VM's node, where a failure fails the call and leaves the VM for the
next Delete (a 403 for `Datastore.Allocate` is Permanent), then destroy
with `purge=1&destroy-unreferenced-disks=1`. Stop and destroy are tasks
waited on through `runTask`, where `does not exist` means gone. The
seed goes first because a VM already destroyed is NotFound to `Get`,
and nothing would call Delete again. With no VM of ours,
`deleteSeedEverywhere` tries every configured node, where a node that
fails is logged and skipped unless ctx ended or the failure is
Permanent. A missing volume still ends in a successful task, so no
message is matched. Nothing is left when nil is returned.
- **ListByTag.** One `ClusterResources` covers every node, unreachable
ones included (their guests are listed with `status: unknown`).
`isCandidate` keeps QEMU VMs that are not templates and carry the tag
`proxy_operator` or a name starting `proxy-`; a nameless entry (a clone
in progress) is not a candidate. One `VMConfig` per candidate; the mark
in the description decides, and the `Instance` is `ID`, `UID` and
`CreatedAt` only. A candidate whose config cannot be read is skipped at
V(1), unless ctx ended, which fails the call; the resources call failing
fails the whole call.
- **Errors.** `classify(op, err)`: an error already carrying a class keeps
it; then `apiError`s by `classifyAPI` (every 4xx Permanent; the 500
`does not exist` NotFound, Transient when `op == "create"`; every other
status Transient, including the lock, the taken VMID and pveproxy's 595
and 596); TLS verification failures (`tls.CertificateVerificationError`
and the three `x509` types) Permanent; everything else Transient. The
500 matchers (`isNotExist`, `isAlreadyExists`, the two agent ones)
are anchored to the 500 status and each is backed by a captured
response. `permanentf` and `transientf` classify at the
source: placement, the template checks, the ownership check, a
malformed token file or CA file (Permanent); a missing token or CA file,
an expired wait, a failed task (Transient). Nothing is Quota.
- **Check.** `Check` runs `checkNode` per node concurrently on the shared
client. `probeNode` reads the token file for `tokenID` (no request when
it is unusable), then `Version`, `NodeStatus`, `checkStorage` (a 500
whatever its text, inactive, or no `iso` content is `StorageNotFound`)
and `checkTemplates` (each catalogue VMID exists and is a template,
else `TemplateNotFound`). `checkNode` answers `Timeout` whenever the
round's ctx ended; otherwise `checkReason`: the two failures above,
`errCredentials` `CredentialsUnavailable`, `errCAFile` or a TLS
verification failure `CertificateInvalid`, 401 `Unauthorized`, 403
`Forbidden`, 595 and 596 `Unreachable`, any other status `APIError`, a
`net.OpError` `Unreachable` (before the deadline, so a dial out of its
own budget is a node that did not answer), a deadline or cancellation
`Timeout`, a cut connection `Unreachable`.
- **Connections.** One `http.Client`, built on first use once the CA
loads (`httpClient`), shared by every call and the Checker: dial and
TLS handshake 10 s each, `Client.Timeout` `requestTimeoutSeconds`, 4
idle connections per host kept 90 s, HTTP/2 disabled. A cancelled
request ends only itself. The token file is read by `readToken` on
every request.
- **Logging.** Logger from the context, named `proxmox`, with `provider`,
and `vmid`, `name` and `node` on lines about an instance. V(1): one line
per operation outcome (`proxmox vm ensured running`, `proxmox vm
fetched`, `proxmox vm deleted` with `destroyed`, `proxmox vms listed`,
`proxmox node checked`), one `proxmox api call` line per seam call from
`observe` (call, identifiers, UPID, duration, `httpStatus` and `error`),
`proxmox task failed`, and the no-op lines. V(2): request parameters,
config summaries, agent addresses, the resolved Create parameters.
Cloud-init, the token, the CA and the upload body are never logged.
- **Tracing.** No span of its own: `newHTTPAPI` wraps the transport in
`tracing.RestConfigWrapper(opts...)`, so each request is an
`HTTP <METHOD>` client span under the caller's span, and none without
one. `observe` only logs, so nothing is counted twice.
- **Tests.** Pure pieces (`id_test.go`, `mark_test.go`, `state_test.go`,
`seed_test.go`, `errors_test.go` with the classify table over the
captures, `api_test.go`); `client_test.go` (the real client over
`httptest.NewTLSServer`: the token never in errors or logs, x509
failures Permanent, cancellation, the child span); `create_test.go`,
`get_test.go`, `delete_test.go`, `list_test.go`, `task_test.go`,
`proxmox_test.go`, `check_test.go`, `observe_test.go` (every seam
method logs exactly one line, by reflection) and `provider_test.go`
(the contract §11 sweep, named test by test in the spec's entry 21).
The double is `fakeAPI` in `fake_test.go`: VMs, tasks and ISOs in
memory, a clone that leaves a nameless locked entry until its task
ends, per-method errors and blocking, a call log; its error texts are
checked against the captures. Real backend:
`proxmox_integration_test.go` behind `//go:build integration`, driven
by `PROXMOX_TEST_*` variables and `make test-integration-proxmox`,
described in
[testing.md](testing.md#the-proxmox-integration-test-real-proxmox-ve-host);
it passed against PVE 9.2.20 on 2026-09-24.
- **Deployment.** The optional `proxmox-token` Secret (keys `token` and
`ca.pem`) mounted at `/etc/proxy-operator/proxmox`
(`config/manager/manager.yaml`), `make proxmox-token-secret`, a
commented `proxmox-home` entry in both providers configs (loaded by
`TestLoadConfig_proxmoxSampleEntry`), `config/samples/proxy_proxmox.yaml`,
the `proxmox` type in the `status.type` comment of
`api/v1alpha1/providerhealth_types.go`. No RBAC, and no
`hack/providers-dev.yaml` entry, since it needs credentials.
- **Known gaps** (see also [proxmox-provider.md § Known gaps](proxmox-provider.md#known-gaps)).
- `Create` blocks until the VM runs.
- A clone lock that never clears makes every Create for that Proxy wait
out the 10-minute clone budget; `qm unlock` by hand.
- GC blind spot: a seed ISO whose VM was destroyed outside the operator
is invisible to GC, and a seed on node-local storage stays behind
when its VM migrated.
- The VMID range is shared with humans; a hand-made VM with a proxy's
name is Permanent.
- Cross-node clones (`target`) need shared storage and are untested.
- A hibernated VM of ours latches its Proxy when Create's adoption
finds it; a recorded one is replaced.
- The CA is cached after first use; the token is not.
## 9. Gaps shared by the existing providers
- **Adoption is not verified in three of the five.** kubernetes, gcp and
kvm adopt any existing instance with the right name without checking
`LabelUID`; kvm2 and proxmox return `ErrPermanent` when the UID differs.
- **The Create comment does not match kvm, kvm2 and proxmox.** The
interface comment in `internal/provider/provider.go` says `Create`
returns as soon as the request is submitted; the three VM providers on
your own hypervisors block until the VM has started.
- **`Stopped` and `Terminated` are produced inconsistently.** kubernetes
never produces Stopped, kvm, kvm2 and proxmox never produce Terminated,
and the reconciler treats them the same.
- **Different `ListByTag` partial-failure policies.** gcp leaves failed
parts out, kvm and kvm2 fail the whole list, and proxmox fails it when
its one cluster-wide listing fails. Both are safe.
- **Stale `mock` comments** in `internal/provider/provider.go`,
`internal/provider/registry/registry.go` and `api/v1alpha1/proxy_types.go`
(and so the generated CRD's `spec.provider` description).
- **GC does not know about installations.** Two clusters sharing one project
or hypervisor delete each other's instances. This was a merge-blocker in
the 2026-08-10 PR review
([docs/reviews](reviews/2026-08-10-1134-proxy-operator-pr-review.md)) and
is still open.
- **Tracing.** kvm has no child spans, so a kvm trace shows `provider.create`
and nothing under it; correlation works only through `traceID` on its V(1)
log lines. kvm2 has them, but only an in-memory recorder checks their
topology. Only kubernetes is tested against a real collector, and that
test checks only the operation spans; nothing checks that gcp's SDK spans,
the kubernetes API spans, kvm2's spans or proxmox's HTTP spans nest under
`provider.*` in a real collector.
## 10. Proposed features
Listed only. Nothing below is designed or implemented, except where a
bullet says kvm2 already does it.
- **Child spans in kvm:** extend the `tracingAPI` decorator in
`kvm/trace.go`, which already wraps every libvirt call, to also start a
span per RPC, and add spans for the SSH dial and the image download. The
seam's methods take no `ctx` today (`hostPool.call` hands `fn` only the
API), so the span context has to be plumbed in. kvm2's `observe.go` over
its ctx-first seam is the shape this would take.
- **Tempo e2e checks for child spans**, and for gcp, kvm and kvm2 through
their real backend runbooks.
- **A shared conformance suite:** for example `internal/provider/providertest`,
which runs the contract §11 tests against any provider plus a fake
backend, so a new provider gets the contract tests for free.
- **Verify the UID when `Create` adopts an instance.** Return `ErrPermanent`
on a mismatch instead of adopting an instance that belongs to someone else.
kvm2 and proxmox do this; the other three do not.
- **Report asynchronous create failures:** gcp keeps the operation name, and
`Get` reports the failure instead of NotFound.
- **Capacity or quota signals** for kvm, such as pool free space and host
memory, mapped to `ErrQuotaExceeded`. kvm2 has the pool-space precheck;
host memory is still unchecked in both.
- **GC for secondary resources:** mark volumes and similar resources, and
let `ListByTag` or a second listing method cover them.
- **Reload credentials and config without a restart.** kvm2 re-reads its
SSH material on every dial; the providers config is still read once per
process for every type.
- **Checker `Details`** for kubernetes (server version) and gcp (quota
headroom).
- **Internal-IP mode** for gcp, and a firewall rule managed per provider.
- **An installation-identity mark**, set on create and filtered on in
`ListByTag`, so installations sharing a backend do not collect each
other's instances.
- **A permanent-failure latch that sees provider availability**, so adding
a missing provider releases the Proxies waiting on it.
- **Validate provider names at load**, and derive the config type
allow-list from the constructor map.
- **Per-provider RBAC** as an optional kustomize component, and an
`OwnerReference` on kubernetes Pods.
+42
View File
@@ -0,0 +1,42 @@
# Providers
The provider documentation is split into three files so that a new provider
can be specified and built from the generic rules alone, without reading how
the existing ones happen to work.
- **[provider-contract.md](provider-contract.md)**: what every provider MUST,
SHOULD and MAY do, which caller depends on each rule, the behaviour profiles
where providers are allowed to differ, the error taxonomy, credential
hygiene, testing obligations, the checklist for adding a provider, and the
contract's known limits. It never names an existing provider.
- **[provider-spec-template.md](provider-spec-template.md)**: the questionnaire
a new provider's design answers, one entry per decision the contract leaves
open. Copy it to `docs/<type>-provider-spec.md`, fill it in, and hand it to
the implementer with the other two files.
- **[providers-existing.md](providers-existing.md)**: how kubernetes, gcp,
kvm, kvm2 and proxmox meet the contract, a table mapping each of them to every
profile, their per-provider reference notes, shared gaps and proposed
features.
Per-provider user guides: [kvm-provider.md](kvm-provider.md),
[kvm2-provider.md](kvm2-provider.md),
[proxmox-provider.md](proxmox-provider.md),
[gcp-vm-validation.md](gcp-vm-validation.md) and
[gcp-in-specific-project.md](gcp-in-specific-project.md).
Related sections of [architecture.md](architecture.md): the components table,
[§5 what provider calls do](architecture.md#5-what-provider-calls-do-in-the-outside-world),
[§10 tracing](architecture.md#10-tracing-internaltracing),
[§11 provider health checks](architecture.md#11-provider-health-checks-internalproviderhealth)
and the "Decisions" list. The test layout is in [testing.md](testing.md).
## Reading order for a new provider
1. The contract, end to end.
2. The template, filled in for the new backend; every unanswered entry is a
design decision still to make.
3. The appendix, for a worked example of each profile the spec chose.
4. The user guide of the existing provider closest to the new one.
The single-file blueprint that preceded this split is in the git history of
this path.
+961
View File
@@ -0,0 +1,961 @@
# Provider spec: proxmox
Filled from [provider-spec-template.md](provider-spec-template.md) against
[provider-contract.md](provider-contract.md). This is the design the
`proxmox` package is built from. Where an entry names an axis in backticks,
the answer is one of that axis's profiles in contract §4.
Backend facts come from the Phase 0 recon on the user's host, recorded in
[the execution log](plans-executions/2026-09-18-1502-proxmox-provider.md) as
facts F1 to F23, with the raw responses under
`internal/provider/proxmox/testdata/`. An Evidence line citing `F<n>` points
there. Where this spec and the plan
([docs/plans/2026-09-18-1502-proxmox-provider.md](plans/2026-09-18-1502-proxmox-provider.md))
disagree, this spec wins: it folds in the ten corrections Phase 0 found.
## Header
- **Type**: `proxmox`
- **Example configured name**: `proxmox-home`
- **Backend and API version this spec was written against**: Proxmox VE
9.2.20 (`pve-manager/9.2.20/49318c671b82f31e`, kernel `7.0.14-17-pve`,
QEMU 11.0.3) on the single node `pv1-1`, API at
`https://192.168.0.8:8006/api2/json`.
- **Client**: `thin-client`: `net/http` and `encoding/json` from the standard
library, with the transport wrapped by the already-required
`go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`. No new
dependency. The seed ISO uses `github.com/kdomanski/iso9660`, already
required by kvm2.
- **Author and date**: Claude (orchestrator session), 2026-09-23.
## 1. Identity and providerID
**Answer:** `vms/<vmid>/<name>`, where `<vmid>` is the VM's numeric id and
`<name>` is `req.Name`. The ID is location-free: the node is looked up in
`GET /cluster/resources?type=vm` on every call, so a VM migrated between
nodes stays addressable. Parsing is strict: exactly three segments, the
literal `vms`, a decimal VMID without leading zeros between 100 and
999999999, and a non-empty name without `/`. Anything else is `ErrPermanent`
from `Get` and `Delete`. The name is carried so that `Get` and `Delete` can
tell our VM from another VM that later received the same VMID (entry 2). No
config value is pasted into the ID, so no ID-safety regexp is needed; node
names and storage ids are still validated at load (entry 15) because they
end up in request paths.
**Evidence:** contract §6 and §4.10 (the ID must not depend on the location
when instances can migrate); F8 (`node` is a field of every resource entry).
## 2. Primary key (`primary-key`)
**Answer:** `allocated-id`. Proxmox addresses VMs by VMID; `name` is only a
label and is not unique. The VMID is derived from the Proxy UID so a retry
lands on the same number: `vmidFor(uid, attempt)` is
`min + (fnv1a64(uid) + attempt) mod (max - min + 1)` over `vmidRange`
(entry 15). Create, under the per-name lock (entry 19):
1. Reads `/cluster/resources` once. Entries with `type: qemu`, `template: 0`
and `name == req.Name` are the by-name candidates. Two or more is
`ErrPermanent` naming every VMID: an admin must remove the duplicate.
Exactly one goes to the ownership check below and is adopted or refused.
2. With none, probes `attempt = 0, 1, …` up to 32:
- VMID free in the listing: clone onto it. If the clone fails with
`unable to create VM <vmid>: config file already exists` (F4), someone
took it between the listing and the clone; run the ownership check on
it.
- VMID taken: run the ownership check.
- Exhausted: `ErrTransient` naming the range.
3. The **ownership check** on a VMID: if its resource entry carries a
`lock` or has no `name` (a clone in progress lists neither, F8), wait
until it is unlocked (entry 10, clone budget). Then read its config. It
is **ours** when `name == req.Name` and the description carries the mark
with `uid` equal to `req.UID` or empty: adopt it and resume Create at the
seed step (entry 10). It is **foreign** when the name differs: move to
the next attempt. The same name with no mark, or with a different
non-empty `uid`, is `ErrPermanent` naming the VMID and both UIDs; the
operator never takes over a VM it did not create. A `suspended` lock (a
VM hibernated to disk) is not waited on: it is not a task and never
clears by itself. Ownership is judged at once, as `Get` does (entry 9):
a foreign hibernated VM is probed past, and ours is `ErrPermanent`
(resume or destroy it), like kvm2's paused domain. Container and
template entries at a VMID are foreign without a config read, and so
is a VM whose listed name is not `req.Name`, whatever its lock: only a
clone in progress is nameless, and waiting on another VM's `backup`
lock would hold Create for the whole clone budget on every retry
(review finding P2).
The wait in step 3 is what closes the duplicate-VM bug Phase 0 found: after
a crash mid-clone, the retry's by-name lookup misses the nameless VM, but
attempt 0 lands on the same VMID, waits for the clone to finish, and adopts
it instead of probing past it.
`Get` and `Delete` check the name and mark at the VMID in the ID. A VM there
with another name means ours is gone and the VMID was reused: `Get` answers
`ErrNotFound`, `Delete` succeeds without touching it.
**Evidence:** F4 (taken VMID fails synchronously); F8 (a mid-clone entry has
no name); contract §4.10 (allocate-then-verify, deterministic from the UID)
and §13 (adoption not verified: closed here).
## 3. Placement mapping
**Answer:** three fields are catalogue keys checked before any backend call,
one is ignored:
| Field | Meaning | Required | Unknown value |
|---|---|---|---|
| `Zone` | a node from `nodes` | only when more than one node is configured; with one node an empty zone selects it | `ErrPermanent`: `placement.zone "x" is not in proxmox.nodes` |
| `MachineType` | a key of `machineTypes` (`cores`, `memoryMiB`, optional `diskGiB`) | yes | `ErrPermanent`: `placement.machineType "x" is not in proxmox.machineTypes` |
| `Image` | a key of `templates` (a template VMID) | yes | `ErrPermanent`: `placement.image "x" is not in proxmox.templates` |
| `Region` | ignored | | still part of the spec hash: changing it replaces the VM |
The guide documents the ignored field and that catalogue edits affect only
new VMs.
**Evidence:** contract §2.1 (reject early, MAY ignore with documentation).
## 4. Image source
**Answer:** a template VM prepared by the admin, referenced by a catalogue
key: `templates.<key>.vmid`. Create makes a **full clone** (`full=1`), so a
VM owns copies of its disks and keeps nothing linked to the template: the
template may be edited, replaced or deleted without affecting running VMs.
Before cloning, Create reads the template's config and refuses it with
`ErrPermanent` when it is not a template (`template` is not 1) or when any
disk slot holds a Proxmox cloud-init drive (a value containing `cloudinit`),
which would compete with the provider's seed (entry 5). Because the clone
copies the template's config, what the config or resize step would refuse
on the clone is refused on the template too, before any VM exists: no
`net0` while `bridge` or `vlanTag` is set, and no boot disk, or one
without a readable `size=`, while `diskGiB` is set. A Permanent error
after the clone would leave a VM that fixing the template cannot help. The template must
boot from its first disk, run cloud-init with the NoCloud datasource, and
either ship `qemu-guest-agent` or be able to install it (entry 7).
Clone parameters: `newid`, `name`, `description` (the mark, entry 8),
`full=1`, `storage` when configured (empty clones onto the template's own
disk storage), and `target` when the chosen node differs from the template's
node. A cross-node clone needs the template on shared storage; this host has
one node, so that path is untested (entry 24).
Catalogue edit warning for the guide: changing a key's `vmid` changes only
VMs created afterwards. Add a new key rather than repointing one in use, so
the Proxies that name it keep a meaning you can reason about.
**Evidence:** F19 (template 9001: `scsi0`, `boot: order=scsi0`, `ide2`
free, `agent: 1`; template 9000 carries a cloud-init drive on `ide2`); F15
(a clone onto a storage without image support is refused up front); F16 (a
full clone of 9001 took about two seconds).
## 5. Cloud-init delivery
**Answer:** a NoCloud seed ISO built in-process by the shared
`internal/provider/nocloud` package (extracted from kvm2 in Phase 3):
volume label `cidata`, files `user-data` (the Proxy's `CloudInit`, byte for
byte, never parsed), `meta-data` (`instance-id: <name>` and
`local-hostname: <name>`) and `vendor-data` (the guest agent script, joined
with the configured `vendorData` as a MIME multipart when both are set;
identical to kvm2's rules). The builder stages under `os.TempDir()` (`/tmp`,
the manager's emptyDir) and writes into a `bytes.Buffer`; a seed is tens of
KiB, so buffering is within contract §1.4.
The ISO is uploaded with `POST /nodes/{node}/storage/{isoStorage}/upload`
as multipart (`content=iso`, file part `filename` named
`<name>-seed.iso`) and the returned `imgcopy` task is waited on. An upload
**overwrites** an existing file of the same name, so no delete precedes it;
the seed is rebuilt and re-uploaded on every Create attempt, since a
previous attempt may have been cut off. It is attached as
`ide2: <isoStorage>:iso/<name>-seed.iso,media=cdrom`; Proxmox then appends
`ide2` to the boot order on its own, which is harmless because the boot
disk stays first.
Only the Proxmox API is used: the storage upload endpoint accepts `iso`,
`vztmpl` or `import`, never `snippets`, so `cicustom` would need shell
access to the node. Size: the request timeout (entry 18) bounds the upload;
a seed with a multi-megabyte `vendorData` would need a larger
`requestTimeoutSeconds`.
**Evidence:** F12 (upload fields, UPID, overwrite, `.iso` suffix required);
F19 (`ide2` appended to the boot order); F21 (the seed on `ide2` installed
the agent on both the untagged network and VLAN 4); F23 (the upload is
staged in `/var/tmp` on the node, not the tmpfs `/tmp`); contract §2.1 and
§11 (byte for byte, sentinel test).
## 6. Machine type mapping
**Answer:** a catalogue: `machineTypes.<key>` is `{cores, memoryMiB,
diskGiB}`. `cores` and `memoryMiB` are written in the config step (entry
10). `diskGiB` is optional: when set and larger than the cloned boot disk,
the disk is grown with `PUT /nodes/{node}/qemu/{vmid}/resize` to the
absolute size `<diskGiB>G`, and the returned task is waited on; when unset
or not larger, the disk keeps the template's size and no call is made. The
provider never shrinks a disk. Cloud-init's growpart expands the root
filesystem at first boot.
The boot disk is the first entry of the config's `boot: order=…` that is a
disk key (`scsiN`, `virtioN`, `sataN`, `ideN`) and not a CD-ROM, else the
`bootdisk` key, else the first present of `scsi0`, `virtio0`, `sata0`,
`ide0`. Its current size comes from the `size=` option of that disk's value.
No boot disk, when `diskGiB` is set, is `ErrPermanent`.
**Evidence:** F20 (resize is a task; the same size is an `OK` no-op; a
shrink fails in the task with `shrinking disks is not supported`); F19
(`boot: order=scsi0`, becoming `order=scsi0;ide2` once the seed is
attached).
## 7. Network and IP discovery (`readiness`, `ip-source`)
**Answer:** `readiness`: `provisioning-window`. `ip-source`: `discovered`,
from the QEMU guest agent only (`GET /nodes/{node}/qemu/{vmid}/agent/network-get-interfaces`);
the API offers no ARP or DHCP-lease source. The address reported is the first
IPv4 of the first non-loopback interface, in the agent's order, that is not
loopback, link-local or unspecified.
The NIC is the template's `net0`, rewritten in the config step only when the
config sets `bridge` or `vlanTag`: the value's comma-separated options are
kept in order with the model and MAC untouched, `bridge=` and `tag=` are
replaced or appended. With neither set, `net0` is left as cloned.
The agent answers:
| Agent response | Meaning | Get reports |
|---|---|---|
| 200 with a routable IPv4 | booted | Running with that IP |
| 200 without one | network still coming up | Provisioning |
| 500 `QEMU guest agent is not running` | booting, or the agent install has not finished or failed | Provisioning |
| 500 `VM <vmid> is not running` | the VM stopped between the status read and this call | `ErrTransient` |
| 500 `No QEMU guest agent configured` | `agent: 1` was removed from the config; this VM can never report an address | Stopped, so the reconciler replaces it with a VM that has the agent on |
| anything else | cannot tell | `ErrTransient` |
The guest needs the agent. The vendor-data script installs it at first boot
unless `installGuestAgent: false`, and that install needs a package mirror:
**the proxy network must have a route to the internet**, which an egress
proxy needs anyway. Baking `qemu-guest-agent` into the template and setting
`installGuestAgent: false` removes the mirror from IP discovery. `Port` is
not used by the provider. Reachability is a prerequisite in the guide: the
operator's pod network and the clients must route to the proxy network (on
the user's host, VLAN 4, `192.168.4.0/24`).
**Evidence:** F5 (all three agent messages); F21 (agent response shape; an
address about 50 seconds after start on the untagged network and on VLAN 4);
the Phase 0 log's VLAN 4 section (no internet route meant no agent);
contract §1.3 and §2.2 (Running only with an IP).
## 8. GC marks and CreatedAt (`gc-mark`, `created-at`)
**Answer:** `gc-mark`: `written-metadata`. `created-at`: `written-at-create`.
The mark is three lines in the VM's `description`, passed in the clone call
itself, so there is no mark window:
```
proxy-operator-managed=true
proxy-operator-uid=<req.UID>
proxy-operator-created-at=<RFC3339 UTC>
```
The keys are `provider.LabelManaged`, `provider.LabelUID` and
`proxy-operator-created-at`; the managed value is `provider.LabelManagedYes`.
The parser reads `key=value` lines, tolerates CRLF, blank lines and any
other lines an admin adds, and treats the VM as managed only when the
managed value is exactly `true`. A missing `uid` line gives an empty UID. A
`created-at` that does not parse gives the zero time, which GC treats as
old; that is accepted. The clone's own `meta: …,ctime=…` is not usable:
Proxmox copies it from the template.
The config step also sets the tag `proxy_operator`, for filtering in the
Proxmox UI and as a ListByTag candidate hint (entry 12). It is not the mark:
tags cannot be passed to the clone call. Hyphens are accepted in tags on
9.2.20, but the underscore form is kept because it is valid under every
tag rule Proxmox has had. Uppercase is folded to lowercase by Proxmox.
The seed ISO carries no mark; its name `<name>-seed.iso` identifies it
(entry 11).
**Evidence:** F10 (the description survives the clone verbatim, newlines
included); F11 (the clone's `meta` is the template's); F9 (tag rules);
contract §2.1 (mark in the create call) and §4.5.
## 9. State mapping (`provisioning-state`)
**Answer:** `provisioning-state`: `create-blocks`. Create returns only once
`status/current` reports the VM running (entry 10), so a VM the provider
cloned but never started is only ever seen by a Create retry, which adopts
and starts it; the reconciler has no ID for it to `Get`.
`Get` checks in this order; the first match decides:
| Check | Source | Result |
|---|---|---|
| VMID absent | `/cluster/resources` | `ErrNotFound` |
| `lock` set, other than `suspended` | resources entry (a clone in progress also has no name) | `ErrTransient` |
| `lock: suspended` (hibernated to disk) | resources entry | Stopped |
| `status: unknown` (node not reachable, or a VM seconds old the listing has not reported yet: the first `Get` after Create met this on pv1-1) | resources entry | `ErrTransient` |
| config `does not exist` | `GET …/config` | `ErrNotFound` (deleted between the two calls) |
| config `lock` set | config | `ErrTransient` |
| `name` differs from the ID's name | config | `ErrNotFound`, logged: the VMID was reused |
| no mark in the description | config | `ErrNotFound`, logged: not ours |
| `status: stopped` | `status/current` | Stopped |
| `status: running`, `qmpstatus: running` | `status/current` | the agent decides (entry 7) |
| `status: running`, `qmpstatus: prelaunch` | `status/current` | Provisioning |
| `status: running`, any other `qmpstatus` (`paused`, `suspended`, `io-error`, …) | `status/current` | Stopped |
| any other `status` | `status/current` | Stopped |
A `suspended` lock decides the state but not ownership: the config is still
read, and a foreign name or a missing mark is still `ErrNotFound`, so a
hibernated VM that reused our VMID is never reported as ours. Only
`status/current` and the agent are skipped for it.
`status/current` is read, not only the resources entry, because a paused VM
shows as plain `running` in `/cluster/resources`. `UID` and `CreatedAt` come
from the description on every call. A running `Get` makes four calls:
resources, config, status and agent.
**Evidence:** F8 (resources fields; mid-clone entries lack a name, carry
`lock: clone` and `status: unknown`); F14 (stopped and paused as seen by
`status/current` and by resources); F2 (the `does not exist` message);
contract §2.2 and §4.11.
## 10. Create: blocking and task tracking (`create-return`, `async-ops`)
**Answer:** `create-return`: `blocking`. `async-ops`: `task-handle` and
`backend-lock`. Clone, upload, resize, start, stop, destroy and volume
delete each return a UPID; the provider polls
`GET /nodes/{node}/tasks/{upid}/status` every second until `status` is
`stopped`, then reads `exitstatus`: `OK` is success, and so is
`WARNINGS: <n>`, which Proxmox itself counts as a finished task (logged at
V(1)); anything else is `ErrTransient` carrying that text. The node comes
from the UPID (`UPID:<node>:…`). The handle is held within the call only: Create blocks
until the VM runs, so no handle outlives it, and a failure surfaces as
Create's own error with the task's message. Locks: a VM locked by a task is
waited on in Create (the ownership check) and reported as `ErrTransient` by
`Get` and `Delete`. A start issued while a clone holds the lock queues
behind it rather than failing; the provider never relies on that, because it
waits for the clone task first.
Steps, each idempotent, in order, under the per-name lock (entry 19):
1. Validate placement (entry 3). No backend call yet.
2. `GET /cluster/resources` once, then look the name up (entry 2).
3. When the name finds nothing: find the template's node (a missing
template is `ErrPermanent`), read the template's config and refuse it as
entry 4 says, then allocate the VMID (entry 2). The template is checked
only here, because a VM that already exists no longer depends on it
(entry 4): a template deleted after its clone must not fail that
clone's adoption. An adopted VM, found by name or by the probe, gets one
`status/current` read. Running, it returns its ID at once. Stopped, it
continues at step 5.
4. Clone and wait (clone budget).
5. Build the seed and upload it to the VM's node, overwriting; wait
(upload budget).
6. `PUT /nodes/{node}/qemu/{vmid}/config` (synchronous) with `cores`,
`memory`, `agent=1`, `tags=proxy_operator`,
`ide2=<isoStorage>:iso/<name>-seed.iso,media=cdrom` and, when configured,
the rewritten `net0` (entry 7).
7. Resize when `diskGiB` is larger than the boot disk (entry 6); wait
(resize budget).
8. `POST …/status/start`; wait (start budget).
9. Poll `status/current` every second until `status: running` and
`qmpstatus: running`, up to the running budget; expiry is
`ErrTransient`, and so is a `stopped` status after the start.
10. Return `vms/<vmid>/<name>`.
What a retry finds:
| Previous attempt stopped after | The retry |
|---|---|
| nothing | allocates, clones |
| submitting the clone | finds the VMID locked and nameless, waits, adopts, continues at 5 |
| the clone | adopts the stopped VM, continues at 5 |
| any of steps 5 to 7 | adopts, repeats 5 to 7 (upload overwrites, config is a replace, resize is skipped once large enough) |
| the start | adopts the running VM, returns its ID |
A Proxmox `does not exist` inside Create (a VM or volume vanishing between
steps) is `ErrTransient`, never `ErrNotFound`. The log line for a Create
records `cloudInitBytes`, never the content.
**Evidence:** F3 (a start queues behind the clone lock); F7 (a config write
under the clone lock is refused with `VM is locked (clone)`); F16 (task
status fields); F20 (resize is a task); the Phase 0 log's VLAN 4 rerun
(clone to started VM in about five seconds); contract §2.1, §4.2, §4.12 and
§5 ("do not return NotFound from Create").
## 11. Delete and secondary resources (`delete-return`, `secondary-resources`)
**Answer:** `delete-return`: `waits`. `secondary-resources`: `explicit` for
the seed ISO; the VM's disks are removed by the destroy call itself, with
`purge=1&destroy-unreferenced-disks=1`. Under the per-name lock:
1. Parse the ID (`ErrPermanent` if malformed).
2. `/cluster/resources`. VMID absent, or held by a container: skip to
step 6, trying every configured node because the VM's node is no longer
known.
3. `lock` set, other than `suspended`, or `status: unknown`: `ErrTransient`.
A clone or another task still running finishes first; the reconciler
calls Delete again after `DeletionPoll`.
4. Read the config. `does not exist`: skip to step 6 on every node. A
different name, or no mark: the VM is not ours; log it at V(1) and skip
to step 6 on every node without touching it. Our VM with a `lock` other
than `suspended` (taken since the listing): `ErrTransient`. Our VM with
`lock: suspended` (hibernated) is destroyed like a stopped one: the
listing shows it `stopped`, so step 5 is skipped, and the destroy
accepts it and removes its suspend state volume with the disks
(`destroy_hibernated_response.json`, `task_status_destroy_hibernated.json`,
captured in Phase 10). Until then Delete refused it as `ErrPermanent`
on the belief that the destroy would. A hibernated VM that is not ours
is left alone like any other.
5. Status (from the listing) other than `stopped`: `POST …/status/stop`,
wait (stop budget). Stop works on a paused VM too, which the listing
shows as `running`: the task ends `OK` and the VM is `stopped`
(`stop_paused_response.json`, `task_status_stop_paused.json`,
`status_current_after_stop_paused.json`, captured in Phase 10).
`does not exist` means the VM is gone.
6. `DELETE /nodes/{node}/storage/{isoStorage}/content/<isoStorage>:iso/<name>-seed.iso`,
wait (seed-delete budget), on the VM's node when step 5 was reached on
a VM of ours, and on every configured node in config order otherwise
(then step 7 is skipped). A missing volume is still a successful task,
so no message is matched. The seed goes **before** the destroy: once
the VM is gone `Get` is `ErrNotFound`, the reconciler releases the
finalizer and GC no longer lists it, so a seed left by a failed step
after the destroy would never be deleted (review finding CR1). On the
VM's node a failure fails the call and leaves the VM stopped for the
next Delete. On the every-node path a node whose request or task fails
is logged at V(1) and skipped, unless ctx ended or the failure is
`ErrPermanent` (a 403 for `Datastore.Allocate`, which every node
would give), which fail the call, so the missing privilege is visible
on both paths (review finding P1).
7. `DELETE /nodes/{node}/qemu/{vmid}?purge=1&destroy-unreferenced-disks=1`,
wait (destroy budget). `does not exist` is success. The destroy leaves
the seed alone (Phase 0 deleted it after the destroy), which is why
step 6 exists. That Proxmox deletes an ISO a stopped VM's `ide2` still
references was not in the Phase 0 captures; the Phase 10 real-host run
proved it (imgdel `OK` on the stopped VM's seed, then the destroy).
8. Return nil.
Everything is gone when Delete returns nil, so `Get` afterwards is
`ErrNotFound`. A concurrent `Get` during a Delete keeps seeing the VM (as
Stopped once it is stopped) until the destroy task removes its config,
because destroying sets no lock and leaves the resource entry in place until
the end. A second Delete waits on the lock, then finds nothing.
GC blind spot: a VM destroyed outside the operator leaves its seed ISO, which
ListByTag cannot see. The guide says seeds are named `proxy-*-seed.iso` on
`isoStorage` and any without a matching VM may be removed.
**Evidence:** F2 (a second destroy gives `does not exist`); F13 (volume
delete is an `imgdel` task, needs `Datastore.Allocate`, and succeeds on a
missing volume); the Phase 0 capture of resources and config mid-destroy
(`cluster_resources_during_destroy.json`, `config_during_destroy.json`);
contract §2.3.
## 12. ListByTag scope and partial failure (`list-partial`)
**Answer:** `fail-whole` for the one listing, with single unreadable VMs
skipped. Scope: every VM the cluster knows, from one
`GET /cluster/resources?type=vm`, which covers every node, including
unreachable ones, whose guests Proxmox lists with `status: unknown` (entry
13). Candidates are entries
with `type: qemu`, `template: 0`, and either the tag `proxy_operator` (tags
split on `;`, `,` or space) or a name starting with `proxy-`. A nameless
entry (a clone in progress) is not a candidate: its Create is live and it is
younger than `MinAge`. For each candidate the config is read and the
description parsed; managed VMs become an `Instance` with `ID`, `UID` and
`CreatedAt` only, which is all GC reads (contract §2.4); no status or agent
call is made. A candidate whose config cannot be read (a node down, a VM
destroyed mid-sweep) is skipped with a V(1) line, unless ctx ended, which
fails the call. The resources call failing fails the whole call. Filtering
by the mark is client-side; the tag narrows the candidates. Cost: one
resources call plus one config read per candidate per sweep.
**Evidence:** F8 (resources fields, tags separated by `;`, nameless mid-clone
entries); F9 (tags case-folded); contract §2.4 and §4.7.
## 13. Error classification (`not-found`, `quota`)
**Answer:** `not-found`: `error-code`, the 500 message
`Configuration file 'nodes/<node>/qemu-server/<vmid>.conf' does not exist`,
plus `list-and-miss` on `/cluster/resources`. The listing can miss a VM only
when it is gone: it is built from the cluster filesystem, which holds every
guest's config cluster-wide, and Proxmox lists the guests of an unreachable
node with `status: unknown` rather than dropping them. That last point is
Proxmox's documented design, not something the single-node host could show;
Phase 10 or a second node verifies it. `quota`: `absent`. The thin `local-lvm` storage
overprovisions, so no out-of-space response could be produced; capacity
failures are `ErrTransient`, and the Checker reports free space in
`Details` instead (entry 16).
The client carries each failure as `apiError{Status, Message, Errors}`:
`Message` is the body's `message` with the trailing newline trimmed, or the
reason phrase when the body has none (a 401); `Errors` is the 400 body's
per-parameter map. One `classify(op string, err error)`:
| Backend response | Class | Why |
|---|---|---|
| 401 `Authentication failed!`, 401 `No ticket` | Permanent | the token or its secret is wrong; fixed in the Secret |
| 403 `Permission check failed (<path>, <privilege>)` | Permanent | the token lacks a privilege; the message names it |
| 400 `Parameter verification failed.` with `errors` | Permanent | the request is wrong; the message includes the field errors |
| 400 as above with `storage: No such storage.` | Permanent from a call; the Checker's `StorageNotFound` (entry 16) | the storage does not exist in the cluster |
| other 4xx | Permanent | a request Proxmox will never accept |
| 500 `Configuration file '…' does not exist` | NotFound (Transient when `op == "create"`) | the VM is gone |
| 500 `VM is locked (<lock>)` | Transient | a task holds the VM |
| 500 `unable to create VM <vmid>: config file already exists` | handled by the allocator (entry 2); Transient if it escapes | the VMID is taken |
| 500 `QEMU guest agent is not running`, `No QEMU guest agent configured`, `VM <vmid> is not running` | handled by `Get` (entry 7); Transient if they escape | agent state |
| any other 5xx, including pveproxy's 595 and 596 | Transient | unknown means retry |
| task `exitstatus` other than `OK` or `WARNINGS: <n>` | Transient, with the text | the task failed; the next attempt retries |
| TLS verification failure (`x509.UnknownAuthorityError`, `x509.HostnameError`, `x509.CertificateInvalidError`) | Permanent | wrong URL or CA file; the admin must act |
| token file missing (`fs.ErrNotExist`) | Transient | the Secret has not landed yet |
| token file present but not `<user>@<realm>!<id>=<secret>` | Permanent | wrong content in the Secret |
| `context.Canceled`, `context.DeadlineExceeded`, `net.OpError`, `io.EOF`, `io.ErrUnexpectedEOF` | Transient | network or cancellation |
| an error already wrapped with a class | that class | validation and builders classify at the source |
| anything else | Transient | contract default |
Every matched string is backed by a captured response under `testdata/`;
the table test loads them rather than repeating the text. Matching is on
fixed strings anchored to the 500 status: `does not exist`,
`config file already exists`, and the whole messages
`QEMU guest agent is not running` and `No QEMU guest agent configured`,
plus, on a 400, the `storage` field error `No such storage.` (whole; a
clone onto a storage without image support has another `storage` error).
`VM is locked` and the stopped VM's `VM <vmid> is not running` are not
matched by the provider: both are Transient through the default branch.
The tests keep a matcher for each, with the whole stopped-VM message
against `^VM [0-9]+ is not running$` (its tail is a suffix of the
agent-not-running answer), only to hold the fake's wording to the
captures (review finding CR5).
**Evidence:** F1 (error text in the reason phrase and the body; 401 has no
body); F2, F4, F5, F7 (the 500 messages); F6 (no out-of-space response
could be produced on thin storage); F22 (both 401 texts); fixtures
`err_forbidden.json`, `err_bad_param.json`, `err_tag_invalid.json`,
`err_upload_bad_name.json`, and from Phase 10 `storage_status_missing.json`
(a missing storage is a 400, not the 500 the spec had assumed); the Phase 0 TLS finding (`192.168.2.8` not in
the node certificate); contract §5.
## 14. Credentials and TLS (`credentials`)
**Answer:** `mounted-files`. The config names `tokenFile`, one line
`<user>@<realm>!<tokenid>=<secret>`, sent as
`Authorization: PVEAPIToken=<that line>`. It is expected in the optional
Secret `proxmox-token`, key `token`, mounted at
`/etc/proxy-operator/proxmox`. The file is read on **every request**, so
rotating the Secret takes effect without a restart. `New` stats it and logs
a warning when it is missing, but never fails on it and never dials.
TLS: `ca-file`. `caFile` names a PEM bundle, typically the node's
`/etc/pve/pve-root-ca.pem`, carried in the same Secret as key `ca.pem`. It
is loaded on first use and cached, so rotating the CA needs a restart; a
missing CA file is `ErrTransient` until it lands, one that does not parse is
`ErrPermanent`. Without `caFile` the system roots are used. The URL's host
must be a name or address in the node certificate's SANs: on the user's
host that is `192.168.0.8` or `pv1-1.home.lab`, not `192.168.2.8`.
`insecureSkipTLSVerify: true` disables verification and is logged as a
warning by `New`.
Minimum privileges, as verified on 9.2.20, for `proxy-operator@pve` with a
token without privilege separation:
- a role on `/` with `VM.Allocate VM.Clone VM.Audit VM.Config.CDROM
VM.Config.CPU VM.Config.Memory VM.Config.Disk VM.Config.Network
VM.Config.Options VM.PowerMgmt VM.GuestAgent.Audit Datastore.Audit
Datastore.AllocateSpace Datastore.AllocateTemplate Sys.Audit SDN.Use`;
- a role on `/storage/<isoStorage>` with `Datastore.Allocate
Datastore.AllocateTemplate Datastore.AllocateSpace Datastore.Audit`,
because deleting a volume needs `Datastore.Allocate` and a grant on a more
specific path replaces the inherited one. A dedicated ISO-only storage
keeps that privilege away from backups.
`VM.Monitor` no longer exists in PVE 9 and is not needed.
`VM.Config.Cloudinit` is not needed. `SDN.Use` on `/` covers a VLAN tag on
`vmbr0`.
Hygiene: the token never appears in logs, span attributes or error strings.
`apiError.Error()` carries status and message only, never headers or URLs.
Tested with a sentinel secret that a test server echoes in a 401 body.
**Evidence:** F17 (privileges exercised and the one missing); the Phase 0
log's seed storage section; F22 (auth failure texts); contract §7.
## 15. Config block
**Answer:** a new block `proxmox:` with the Go type `provider.ProxmoxConfig`
in `internal/provider/config_proxmox.go`:
| Field | Required | Default | Validation | Ends up in |
|---|---|---|---|---|
| `url` | yes | | `https` URL with a host and no path other than `/` | request base, `…/api2/json` appended |
| `tokenFile` | yes | | non-empty | `Authorization` header |
| `caFile` | | system roots | non-empty when set | TLS roots |
| `insecureSkipTLSVerify` | | false | | TLS, warning at `New` |
| `nodes` | yes, at least one | | unique, `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` | request paths, clone `target` |
| `isoStorage` | yes | | Proxmox storage id `^[a-zA-Z][a-zA-Z0-9_.-]*$` | upload and delete paths, `ide2` |
| `storage` | | the template's disk storage | storage id regexp when set | clone `storage` |
| `bridge` | | as cloned | non-empty when set | `net0` `bridge=` |
| `vlanTag` | | as cloned | 1 to 4094 when set | `net0` `tag=` |
| `vmidRange.min`, `vmidRange.max` | | 100000, 199999 | min at least 100, max at most 999999999, min below max | `vmidFor` |
| `templates.<key>.vmid` | at least one template | | at least 100 | clone source |
| `machineTypes.<key>` | at least one | | `cores` and `memoryMiB` above zero, `diskGiB` not negative | config step, resize |
| `requestTimeoutSeconds` | | 30 | not negative | HTTP client timeout |
| `vendorData` | | | | seed `vendor-data` |
| `installGuestAgent` | | true | | seed `vendor-data` |
`isoStorage` has no default on purpose: the token must be allowed to delete
volumes there (entry 14), so the choice must be explicit. `nodes` holds node
names as Proxmox knows them (`pv1-1`), not addresses; the one URL reaches the
whole cluster.
Wiring: `ProviderConfig.Proxmox *ProxmoxConfig` with tag
`proxmox,omitempty`; `setBlocks` reports `proxmox`; the allow-list in
`Config.validate` and the constructor map in `cmd/main.go` both gain
`proxmox` in the same commit; `validateProxmox(prefix, c)` in
`config_proxmox.go` with `config_proxmox_test.go`.
**Evidence:** contract §10; the Phase 0 decisions (dedicated seed storage,
`vlanTag`, URL at `192.168.0.8`); F13 (why `isoStorage` needs
`Datastore.Allocate`).
## 16. Checker
**Answer:** one target per `nodes` entry, `Name` the node, `Address` the
URL, checked concurrently, returned in config order. The check shares the
provider's HTTP client; each request is independent, so a check that runs
out of time cancels only its own requests. Per node:
1. `GET /version` (`pveVersion`).
2. `GET /nodes/{node}/status` (the node answers; `kernel`).
3. `GET /nodes/{node}/storage/{isoStorage}/status`: the storage exists, is
active, and lists `iso` in its content (`isoStorageAvailGiB`).
4. `GET /cluster/resources?type=vm`: every configured template exists and
is a template (`templates` as `<found>/<configured>`).
Reasons, shared first: `Reachable`; `Timeout` (the round's ctx ended,
whichever request was running); `Unreachable` (TCP connect or TLS handshake
failed for a network reason, or pveproxy answered 595 or 596);
`Unauthorized` (401); `Forbidden` (403, the message names the privilege);
`APIError` (any other failure). Provider-specific, each fixed differently by
an admin:
- `CertificateInvalid`: TLS verification failed, or `caFile` is missing or
holds no certificate. Fix `url` or `caFile`, not the host.
- `CredentialsUnavailable`: the token file is missing or malformed. Fix the
Secret, not Proxmox.
- `StorageNotFound`: `isoStorage` is missing, inactive, or has no `iso`
content. Fix the storage on the node. A storage the cluster does not
know answers `400 Parameter verification failed.` with the field error
`storage: No such storage.` (`storage_status_missing.json`, captured in
Phase 10); that exact field error is matched, and the message carries
it. Any 500 from the storage status call counts as missing too,
whatever its wording: Proxmox knows the storage but cannot use it on
that node (not enabled there, or offline). That answer was not
captured, so no text is matched. Any other 4xx, and a 401, 403, 595 or
596, keeps its shared reason.
- `TemplateNotFound`: a configured template VMID does not exist or is not a
template. Fix the template or the catalogue.
Details: `pveVersion`, `kernel`, `tokenID` (the part before `=`, never the
secret), `isoStorage`, `isoStorageAvailGiB`, `templates`, filled as far as
the check got; `isoStorage` is always there. `templates` counts catalogue
entries. The Checker reads `tokenFile` itself, before the first request,
with the client's `readToken`: the seam stays one method per endpoint, and
a missing or malformed file answers `CredentialsUnavailable` without a
request. Only the part before `=` is kept.
`Timeout` also covers a request that runs out of its own timeout while the
round is still running. A dial or a connection cut mid-response is
`Unreachable`, even when the dial ran out of its own 10 s budget, because
the node did not answer. Cost: four requests per node per round. The `status.type`
comment in `api/v1alpha1/providerhealth_types.go` gains `proxmox` and the
CRD is regenerated.
**Evidence:** contract §8; fixtures `version.json`, `node_status.json`,
`storage_status.json`, `cluster_resources.json`.
## 17. Client and child spans (`client-kind`, `child-spans`)
**Answer:** `client-kind`: `thin-client`. `child-spans`: `transport-wrapper`.
The seam is the interface `api`, one method per endpoint, `ctx` first:
| Method | HTTP | Returns |
|---|---|---|
| `Version` | `GET /version` | version info |
| `NodeStatus` | `GET /nodes/{node}/status` | node status |
| `StorageStatus` | `GET /nodes/{node}/storage/{storage}/status` | storage status |
| `ClusterResources` | `GET /cluster/resources?type=vm` | entries |
| `VMConfig` | `GET /nodes/{node}/qemu/{vmid}/config` | config as `map[string]string` |
| `SetVMConfig` | `PUT /nodes/{node}/qemu/{vmid}/config` | nothing (synchronous) |
| `Clone` | `POST /nodes/{node}/qemu/{vmid}/clone` | UPID |
| `Resize` | `PUT /nodes/{node}/qemu/{vmid}/resize` | UPID |
| `Start` | `POST /nodes/{node}/qemu/{vmid}/status/start` | UPID |
| `Stop` | `POST /nodes/{node}/qemu/{vmid}/status/stop` | UPID |
| `VMStatus` | `GET /nodes/{node}/qemu/{vmid}/status/current` | status and qmpstatus |
| `Destroy` | `DELETE /nodes/{node}/qemu/{vmid}?purge=1&destroy-unreferenced-disks=1` | UPID |
| `TaskStatus` | `GET /nodes/{node}/tasks/{upid}/status` | status and exitstatus |
| `AgentNetworkInterfaces` | `GET /nodes/{node}/qemu/{vmid}/agent/network-get-interfaces` | interfaces |
| `UploadISO` | `POST /nodes/{node}/storage/{storage}/upload` (multipart) | UPID |
| `DeleteVolume` | `DELETE /nodes/{node}/storage/{storage}/content/{volid}` | UPID |
`httpAPI` is the production adapter; `fakeAPI` in `fake_test.go` keeps VMs,
tasks and ISOs in memory, simulates locks (a clone leaves a nameless locked
entry until its task completes), task polls, per-method errors and blocking,
and records a call log. Scalars in a config are rendered as strings by the
adapter (`cores` arrives as a JSON number, `memory` as a string).
The transport is `tracing.RestConfigWrapper(opts...)(base)`, a parent-gated
`otelhttp` transport, so each request is an `HTTP <METHOD>` span under
`provider.create`, `provider.get`, `provider.delete`, `provider.list` or the
monitor's `providerhealth.check`, and background traffic makes no root
spans. The `observed` decorator over `api` logs one line per call and starts
no span, so nothing is counted twice. Span attributes are otelhttp's
standard ones; the token is in a header, which otelhttp does not record.
Tests: a span recorder asserts the HTTP spans are children of the operation
span and carry no token; a reflection test asserts every `api` method logs
exactly one line.
**Evidence:** contract §4.15, §4.18, §9; `RestConfigWrapper` and
`parentGatedTransport` in `internal/tracing/transport.go`; F18 (HTTP/1.1
only).
## 18. Timeouts (`timeouts`)
**Answer:** `per-call`.
| Bound | Budget |
|---|---|
| TCP dial | 10 s |
| TLS handshake | 10 s |
| one request, response included (`http.Client.Timeout`) | `requestTimeoutSeconds`, default 30 s |
| clone task, and the wait for a VM's lock during the ownership check | 10 m |
| upload task | 2 m |
| resize task | 2 m |
| start task | 2 m |
| stop task | 2 m |
| destroy task | 5 m |
| seed delete task | 1 m |
| wait for `running` after the start | 60 s |
| task and status poll interval | 1 s |
The budgets live in one `budgets` struct that tests shrink. A task wait that
runs out is `ErrTransient` naming the UPID, and the task keeps running on
Proxmox; the next attempt finds its result through the ownership check or
the lock. A cancelled `ctx` cancels only that request: the transport keeps
its idle connections (`MaxIdleConnsPerHost` 4, `IdleConnTimeout` 90 s), and
no other call is disturbed. HTTP/2 is disabled explicitly
(`ForceAttemptHTTP2: false`, an empty `TLSNextProto`).
**Evidence:** contract §1.5 and §4.16; F16 (a full clone of the 3 GiB
template took about two seconds; tasks finish within a second or two); F21
(about 50 s from start to an address, which the reconciler's polling covers,
not the running wait).
## 19. Concurrency
**Answer:** shared state is the HTTP client (safe for concurrent use), the
lazily loaded CA pool (loaded once under a `sync.Once`-style guard), and a
lock per instance name from the shared `internal/provider/keyedlock` package
(extracted from kvm2 in Phase 3): a one-slot semaphore per key, waitable
with ctx, removed from the map once unused. Create and Delete for one name
run under that name's lock, so the reconciler's Delete and GC's Delete never
race on stop and destroy, and a Delete cannot interleave with a Create's
steps. Get and ListByTag take no lock. Two Creates for different Proxies
that land on the same VMID are safe: one clone wins, the other gets the
`config file already exists` answer, waits in the ownership check for the
winner's clone to finish, sees a different name, and probes on. Three
reconciles, a GC sweep and a check round at once are safe.
**Evidence:** contract §1.2 (concurrency); F4; entry 2.
## 20. Logging (`logging`)
**Answer:** `tiered`. Logger from `logf.FromContext(ctx)` on every call,
named `proxmox`, with `provider` on every line and `vmid`, `name` and
`node` on every line about an instance.
- V(1): one line per operation outcome (`proxmox vm ensured running`,
`proxmox vm fetched` with state, ip and uid, `proxmox vm deleted` with
`destroyed` (false when no VM of ours was left to destroy),
`proxmox vms listed` with counts, `proxmox node checked` with the reason),
one `proxmox api call` line per seam call from the decorator (`call`,
`node`, `vmid`, `storage`, `upid`, `duration`, plus `newid` and `name`
on a clone, `filename` on an upload and `volid` on a volume delete, and
on failure `httpStatus` and `error`), a `proxmox task failed` line with the UPID and
`exitstatus`, and the no-op lines of Delete and ListByTag (VM already
gone, foreign or unmarked VM left alone, seed delete skipped on a node,
unreadable candidate skipped).
- V(2): request parameters (without `description`'s content at V(1) and
never the upload body), config summaries, agent addresses, the resolved
Create parameters with `cloudInitBytes`.
- No V(5) wire tier.
- Never logged at any level: `CloudInit`, the token, the CA content, the
upload body.
**Evidence:** contract §4.17 and §9.
## 21. Tests (`backend-test`)
**Answer:** `backend-test`: `integration-tag`. Unit tests run over `fakeAPI`
behind the seam, the HTTP client over `httptest.NewTLSServer` serving the
captured fixtures, and `classify` over the fixtures directly. The
integration test is `proxmox_integration_test.go` behind
`//go:build integration`, skipped unless `PROXMOX_TEST_URL`,
`PROXMOX_TEST_TOKEN_FILE`, `PROXMOX_TEST_NODE`,
`PROXMOX_TEST_TEMPLATE_VMID` and `PROXMOX_TEST_ISO_STORAGE` are set;
optional `PROXMOX_TEST_CA_FILE`, `PROXMOX_TEST_INSECURE`,
`PROXMOX_TEST_STORAGE`, `PROXMOX_TEST_BRIDGE`, `PROXMOX_TEST_VLAN_TAG`,
`PROXMOX_TEST_VMID_MIN`, `PROXMOX_TEST_VMID_MAX`, `PROXMOX_TEST_TIMEOUT`
(default 20m), `PROXMOX_TEST_KEEP`, `PROXMOX_TEST_LOG_LEVEL`; run with
`make test-integration-proxmox`. It asserts, in order: `New` returns without
a request; `Check` reports the node Ready with details; `Create` returns
`vms/<vmid>/<name>` with the VMID in range; a second `Create` returns the
same ID and the VM count is unchanged; `Get` reaches Running with an IPv4
within the budget and its `UID` and `CreatedAt` equal what Create wrote;
`ListByTag` contains it; `Delete` returns nil; `Get` is `ErrNotFound`;
`Delete` again returns nil; no VM with the name and no `<name>-seed.iso`
remain; an HTTP span is a child of `provider.create` and no span attribute
carries the cloud-init text.
Contract §11 list, with the test for each item. Every name below exists
(Phase 8 checked each with a grep for `func <name>(`). The two integration
tests passed on pv1-1 on 2026-09-24 (timings in
[testing.md](testing.md#the-proxmox-integration-test-real-proxmox-ve-host)).
- [x] repeat `Create`: `TestCreate_repeatReturnsSameIDAndCreatesNothing`
- [x] `Get` after `Delete`: `TestGet_afterDeleteReturnsNotFound`
- [x] `Delete` twice: `TestDelete_twiceSucceeds`
- [x] malformed ID: `TestParseProviderID_malformed`, `TestGet_malformedIDIsPermanent`, `TestDelete_malformedIDIsPermanent`
- [x] unknown state maps to `StateStopped`: `TestMapState_table`, `TestGet_unknownStateIsStopped`
- [x] `StateRunning` requires an IP: `TestGet_runningWithoutIPIsProvisioning`, `TestGet_runningWithIPIsRunning`
- [x] `UID` and `CreatedAt` read back: `TestGet_readsUIDAndCreatedAtFromBackend`
- [x] `ListByTag` excludes unmarked instances: `TestListByTag_excludesUnmarkedVMs`, `TestListByTag_emptyUIDStillListed`
- [x] `Get` during an in-progress delete: `TestGet_duringDeleteStillReturnsInstance`
- [x] `Get` during an asynchronous operation or lock: `TestGet_lockedIsTransientNeverNotFound`, `TestGet_midCloneNamelessEntryIsTransient`
- [x] never started is never `StateStopped`: `TestCreate_returnsOnlyWhenRunning`
- [x] cancelled `ctx` returns promptly: `TestProvider_cancelledContextReturnsPromptly` (every method and `Check`, blocked on the first seam call, plus Create and Delete blocked mid-operation), `TestClient_cancelledContextReturnsPromptly`
- [x] `classify` table with captured responses: `TestClassify_table`
- [x] Checker reasons, expired `ctx`, stable order: `TestCheck_reasonsTable`, `TestCheck_expiredContextReturnsPromptly`, `TestCheck_oneEntryPerNodeInConfigOrder`
- [x] sentinel tests for `CloudInit` and the credential: `TestCreate_cloudInitSentinelNeverLoggedOrInErrors`, `TestClient_tokenNeverInErrorsOrLogs`, and `TestLogging_v0SilentV1OutcomeV2Detail` over a whole lifecycle at every level
- [x] child spans under the operation span: `TestTracing_httpSpansUnderOperationSpan`, `TestClient_childSpanUnderParent`
- [x] real-backend lifecycle test: `TestProxmox_lifecycle` and `TestProxmoxCheck_realHost` in `proxmox_integration_test.go`, passed on pv1-1
Tests that cover the Phase 0 corrections specifically, in addition to the
plan's per-phase lists:
- `TestCreate_resumesMidCloneVMAtDeterministicVMID`: a crash after the clone
was submitted; the retry waits for the lock and adopts, creating nothing.
- `TestCreate_takenVMIDInspectedBeforeProbing`: a foreign VM at attempt 0 is
skipped; ours at attempt 0 is adopted.
- `TestCreate_cloneAlreadyExistsRaceRunsOwnershipCheck`.
- `TestCreate_templateWithCloudInitDriveIsPermanent`.
- `TestCreate_seedUploadOverwritesWithoutDelete`.
- `TestCreate_resizeWaitsForTask`, `TestCreate_resizeSkippedWhenNotLarger`.
- `TestCreate_vlanTagAppendedKeepsMACAndOptions`.
- `TestBootDisk_table`, with a row for `order=scsi0;ide2`.
- `TestGet_pausedIsStoppedFromStatusCurrent`.
- `TestGet_agentMessagesTable` (the three messages and a 200 without IPv4).
- `TestDelete_seedDeleteIsTaskAndIdempotent`.
- `TestClient_x509FailureIsPermanent`.
**Evidence:** contract §11; docs/testing.md conventions.
## 22. Deployment
**Answer:** a new optional Secret and its mount, two Makefile targets, and
samples:
- `config/manager/manager.yaml`: an optional Secret `proxmox-token` mounted
read-only at `/etc/proxy-operator/proxmox`, keys `token` and `ca.pem`.
`/tmp` is already an emptyDir.
- Makefile: `proxmox-token-secret`, creating or updating `proxmox-token`
from `PROXMOX_TOKEN_FILE` (default `$(HOME)/.config/proxmox-token`) and
`PROXMOX_CA_FILE` (default `$(HOME)/.config/proxmox-ca.pem`), shaped like
`kvm-ssh-secret`; and `test-integration-proxmox` (`go test -race
-tags=integration -count=1 -v -timeout 30m -run TestProxmox
./internal/provider/proxmox/`).
- `config/manager/providers_config.yaml` and
`config/samples/providers-config.yaml`: a commented `proxmox-home` entry
for the user's host: `url: https://192.168.0.8:8006`, the two file paths,
`nodes: [pv1-1]`, `isoStorage: proxy-seeds`, `vlanTag: 4`,
`templates: {debian-13: {vmid: 9001}}`, a `small` machine type.
- `config/samples/proxy_proxmox.yaml`, listed in
`config/samples/kustomization.yaml`: provider `proxmox-home`, zone
`pv1-1`, machine type `small`, image `debian-13`, the Squid cloud-init
from `proxy_kvm2.yaml`.
- RBAC: none; the provider does not talk to the cluster's API.
- `hack/providers-dev.yaml`: no entry (needs credentials).
**Evidence:** contract §12.
## 23. Docs
**Answer:** `docs/proxmox-provider.md` with sections: Proxmox prerequisites
(the template recipe and what disqualifies a template, the dedicated seed
storage, the user, both roles and the token, the proxy network with DHCP and
internet, reserving the VMID range), Cluster-side setup (the Secret, the
config entry, the URL-must-match-the-certificate rule), Config reference
(entry 15), Placement (Region ignored, catalogue-edit warning), What lands on
Proxmox (the VMID scheme, the description mark, the tag, the seed ISO, the
config keys written, `qm unlock` for a stuck clone), Logging, Tracing,
Timeouts, Health check (entry 16), Error classes (entry 13), Known gaps
(entry 24), Testing. A fifth column in the three tables of
`docs/providers-existing.md` and a proxmox section. `docs/architecture.md`:
components row, a `proxmox` block in §5, the span note in §10, a §11 row and
the provider count, Decisions bullets (VMID from the UID hash with the
ownership check, the mark in the description, the seed by API upload, the
node resolved per call). A README row. `docs/providers.md` link.
`docs/testing.md` rows and a proxmox integration section. `CLAUDE.md`
Gotchas: `make test-integration-proxmox`. A `CHANGELOG.md` entry after the
user confirms.
**Evidence:** contract §12.
## 24. Known limits accepted at design time
**Answer:** ten limits, each with its consequence and workaround:
- **Create blocks** until the VM runs, holding one of three reconcile
workers. On the user's host that is about five seconds plus the clone;
large templates on slow storage take longer.
- **A clone lock that never clears** (a node crash mid-clone) makes every
Create for that Proxy wait out the clone budget and return
`ErrTransient`. Clear it with `qm unlock <vmid>` or destroy the VM.
- **Seed ISOs of VMs destroyed outside the operator** are invisible to GC.
Remove `proxy-*-seed.iso` files without a matching VM by hand.
- **The VMID range is shared with humans.** A VM created by hand inside it
costs a probe; a hand-made VM named like a proxy is refused as
`ErrPermanent`. Reserve the range.
- **Cross-node clones need shared storage** and are untested, because the
host has one node.
- **A VM whose agent was switched off is replaced**, not repaired (entry 7).
- **The proxy network needs internet** for the agent install, unless the
template ships the agent.
- **The CA is cached** after first use; rotating it needs a restart. The
token rotates freely.
- **A node that stops answering** makes `Get` and `Delete` for its VMs
`ErrTransient` until it returns, so deleting such a Proxy waits.
- Contract §13 limits that apply unchanged: config read once per process,
no installation identity in GC (two operators sharing a cluster and a
VMID range delete each other's VMs), an unconfigured provider latches the
Proxy.
Accepted after the Phase 10 review
([docs/reviews/2026-09-23-proxmox-provider-review/](reviews/2026-09-23-proxmox-provider-review/REPORT.md),
findings P3 and P4), and listed in the guide's Known gaps:
- **A Create that failed permanently after its clone keeps its VM**, and
the next Create adopts it by name even when `placement` has changed.
Destroy the VM before changing `placement` on such a Proxy.
- **A retry can clone a second VM** when a Create is cut off while
cloning onto a later probe VMID, the VM on the earlier one is removed,
and the retry comes before the clone ends. The next Create names both
VMIDs as `ErrPermanent`.
Changed by the Phase 10 captures of the three answers Phase 0 never saw:
- **A hibernated VM of ours** latches only Create's adoption
(`ErrPermanent`, resume or destroy it; entry 2). `Get` reports it
Stopped, so the reconciler replaces it, and Delete destroys it, since
the destroy takes a hibernated VM (entry 11). Before the capture, Delete
refused it as well.
- **A missing `isoStorage`** is a 400 with `storage: No such storage.`,
which the Checker reports as `StorageNotFound` (entry 16); a missing
storage at upload time is Permanent like any 4xx.
- **A paused VM** stops like a running one (entry 11, step 5), as assumed.
**Evidence:** contract §13; entries 2, 7, 10, 11, 14 above.
+667
View File
@@ -0,0 +1,667 @@
# The proxmox provider
Runs Managed proxies as VMs on a Proxmox VE cluster. It is the third
VM-style provider and the second built from
[provider-contract.md](provider-contract.md), from the design in
[proxmox-provider-spec.md](proxmox-provider-spec.md). The operator talks to
the cluster's REST API only (`https://<host>:8006/api2/json`) with an API
token. It needs no SSH, no shell on the nodes, and no library beyond Go's
standard `net/http`.
Each VM is a **full clone of a template** you prepare once. The operator
builds the Proxy's cloud-init into a NoCloud seed ISO in-process, uploads
it to a storage on the VM's node, attaches it as a CD-ROM, sets the size,
starts the VM and learns its address from the QEMU guest agent. Everything
here was proven against **Proxmox VE 9.2.20** (QEMU 11.0) on the single
node `pv1-1`; every error text the provider matches was captured from that
host.
## Proxmox prerequisites
Once per cluster, as root on a node:
1. **Proxmox VE 9.x with the API reachable** from the operator's pods on
port 8006. The operator's pods and the crawler clients must also route to
the network the proxies land on (see step 5).
2. **A template without a Proxmox cloud-init drive.** The provider brings
its own seed on `ide2`; a Proxmox cloud-init drive would compete with it
and the Proxy's user-data would be ignored. Debian 13's generic cloud
image boots UEFI (under BIOS it hangs in GRUB on a headless VM), so the
template does too:
```sh
wget https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
qm create 9001 --name debian-13-template --memory 2048 --cores 2 --ostype l26 \
--machine q35 --bios ovmf --efidisk0 local-lvm:1,efitype=4m,pre-enrolled-keys=0 \
--scsihw virtio-scsi-pci --net0 virtio,bridge=vmbr0 \
--serial0 socket --vga serial0 --agent 1
qm set 9001 --scsi0 local-lvm:0,import-from=/root/debian-13-generic-amd64.qcow2
qm set 9001 --boot order=scsi0
qm template 9001
```
The template must boot from its first disk, run cloud-init with the
NoCloud datasource (the Debian and Ubuntu cloud images do), and either
ship `qemu-guest-agent` or be able to install it at first boot. Before
the first clone for a Proxy, `Create` reads the template's config and
refuses it with a permanent error when:
- it is not a template (`qm template` was not run);
- any disk slot holds a Proxmox cloud-init drive (a value containing
`cloudinit`), such as the one `qm set <vmid> --ide2 local-lvm:cloudinit`
adds for Proxmox's own cloud-init support;
- it has no `net0` while the config sets `bridge` or `vlanTag`;
- the machine type sets `diskGiB` and the template has no boot disk, or
one without a readable `size=`.
These are checked on the template because the clone copies its config:
a VM cloned from a bad template could not be fixed by fixing the
template. The template is checked only when a VM is about to be cloned;
a VM that already exists no longer depends on it. The health check sees
only whether each template exists and is a template.
3. **A storage for the seed ISOs**, holding `iso` content. A dedicated
directory storage is best, because the token needs `Datastore.Allocate`
on it to delete seeds, and on a dedicated storage that privilege cannot
touch backups or other ISOs:
```sh
pvesm add dir proxy-seeds --path /var/lib/proxy-seeds --content iso
```
A seed is tens of KiB. Proxmox stages uploads under `/var/tmp`, not the
node's tmpfs `/tmp`. On a cluster, a storage that is not shared keeps
each seed on the node the VM was created on (see *Known gaps*).
4. **A user, two roles and a token.** `VM.Monitor` no longer exists in PVE
9 and `pveum` rejects it; the agent read needs `VM.GuestAgent.Audit`
alone. `VM.Config.Cloudinit` is not needed for the seed on `ide2`.
`SDN.Use` on `/` is what lets the config write put a VLAN tag on
`vmbr0`:
```sh
pveum user add proxy-operator@pve --comment "egress-proxies-operator"
pveum role add ProxyOperator -privs "VM.Allocate VM.Clone VM.Audit VM.Config.CDROM VM.Config.CPU VM.Config.Memory VM.Config.Disk VM.Config.Network VM.Config.Options VM.PowerMgmt VM.GuestAgent.Audit Datastore.Audit Datastore.AllocateSpace Datastore.AllocateTemplate Sys.Audit SDN.Use"
pveum aclmod / -user proxy-operator@pve -role ProxyOperator
pveum role add ProxyOperatorSeeds -privs "Datastore.Allocate Datastore.AllocateTemplate Datastore.AllocateSpace Datastore.Audit"
pveum aclmod /storage/proxy-seeds -user proxy-operator@pve -role ProxyOperatorSeeds
pveum user token add proxy-operator@pve operator --privsep 0
```
The second role repeats three privileges of the first on purpose: an
ACL on a more specific path **replaces** what the user inherits from
`/` for that path, so a role with `Datastore.Allocate` alone would take
the upload's `Datastore.AllocateTemplate` away. The role on `/` covers
clone, config, resize, start, stop, the agent read, the upload and the
destroy; what it lacks is `Datastore.Allocate`, without which deleting
a seed is a 403 (`Permission check failed (/storage/<id>,
Datastore.Allocate)`). With `--privsep 0` the token carries the user's
privileges.
The token's secret is printed once. Save it on your workstation as one
line, `proxy-operator@pve!operator=<secret>`, in
`~/.config/proxmox-token` with mode 0600. Copy the node's CA,
`/etc/pve/pve-root-ca.pem`, to `~/.config/proxmox-ca.pem`.
5. **A proxy network with DHCP and a route to the internet.** Each VM
keeps the template's one NIC, optionally moved to another bridge or
VLAN by the config, and takes its address from that network's DHCP. The
network needs internet twice over: the vendor-data script installs
`qemu-guest-agent` from the distro mirrors at first boot, and the agent
is the only way the operator learns the VM's address; and an egress
proxy needs egress anyway. Baking `qemu-guest-agent` into the template
and setting `installGuestAgent: false` removes the mirror from IP
discovery. The operator's pods and the clients must route to that
network: the health engine probes `http://<ip>:<port>` through the
proxy, and clients get the same address from the discovery API. The
provider does not use `spec.port` and configures no Proxmox firewall.
6. **A reserved VMID range.** VMIDs are allocated from `vmidRange`
(default 100000 to 199999). Keep hand-made VMs out of it: one there
costs a probe, and one named like a proxy is refused (see *What lands
on Proxmox*).
Check from the operator's point of view before wiring it in. This sends
the token through a file descriptor, so it never reaches the command line:
```sh
pve() {
curl -s --cacert ~/.config/proxmox-ca.pem \
-H @<(printf 'Authorization: PVEAPIToken=%s\n' "$(tr -d '[:space:]' < ~/.config/proxmox-token)") \
"https://192.168.0.8:8006/api2/json/$1"; echo
}
pve version # the token works, TLS verifies
pve nodes/pv1-1/storage/proxy-seeds/status # active, content includes iso
pve 'cluster/resources?type=vm' # the template is listed with "template":1
```
## Cluster-side setup
1. Create the Secret. It holds two keys, `token` (the one-line token file)
and `ca.pem` (the node's CA):
```sh
make proxmox-token-secret
# or by hand:
kubectl -n egress-proxies-operator-system create secret generic proxmox-token \
--from-file=token=$HOME/.config/proxmox-token \
--from-file=ca.pem=$HOME/.config/proxmox-ca.pem
```
The make target runs that command as an apply, so it also updates an
existing Secret. Override the paths with
`PROXMOX_TOKEN_FILE=... PROXMOX_CA_FILE=...`. Before it applies, it
checks that both files exist, that the token file is exactly one line
of `<user>@<realm>!<tokenid>=<secret>` (without printing it), and that
the CA file holds a PEM certificate. With `insecureSkipTLSVerify`
instead of a CA, create the Secret by hand with the `token` key only.
`config/manager/manager.yaml` mounts the Secret (optional, read-only)
at `/etc/proxy-operator/proxmox`, so the files are
`/etc/proxy-operator/proxmox/token` and
`/etc/proxy-operator/proxmox/ca.pem`. The deployment starts fine
without it, even with a proxmox provider configured: `New` makes no
request, only checks that the token file exists, and logs a `WARNING:`
line when it does not. The provider's proxies then fail transiently and
recover once the Secret lands; the health check reports
`CredentialsUnavailable` meanwhile.
**Rotating the token needs no restart.** The token file is read on
every request, so the next request after the kubelet refreshes the
mount uses the new token. Keep the old token valid until then: a 401
is a permanent error, so a Proxy whose request lands in the gap
latches until its spec changes. Add the new token
(`pveum user token add`), update the Secret, wait a couple of minutes
for the mount to refresh, and only then remove the old token. A token
created with `--expire` needs the same rotation before it expires.
**Rotating the CA does** need a restart: it is loaded on first use and
cached for the life of the process. A missing CA file is
retried until it lands; one without a certificate is a permanent error.
2. Add a provider entry to the providers config
(`config/manager/providers_config.yaml` in-cluster; the full sample,
commented out, is in `config/samples/providers-config.yaml`):
```yaml
- name: proxmox-home
type: proxmox
proxmox:
url: https://192.168.0.8:8006
tokenFile: /etc/proxy-operator/proxmox/token
caFile: /etc/proxy-operator/proxmox/ca.pem
nodes: [pv1-1]
isoStorage: proxy-seeds
vlanTag: 4
templates:
debian-13: {vmid: 9001}
machineTypes:
small: {cores: 1, memoryMiB: 1024}
```
**The URL's host must be in the node certificate.** The node's
self-signed certificate lists its own addresses and names, and TLS
verification checks the URL's host against them. On `pv1-1` it lists
`192.168.0.8`, `pv1-1` and `pv1-1.home.lab`, so
`https://192.168.2.8:8006` reaches the same node and still fails
verification (`CertificateInvalid` in the health check, a permanent
error on every call). See what yours lists with:
```sh
openssl s_client -connect 192.168.0.8:8006 </dev/null 2>/dev/null | openssl x509 -noout -ext subjectAltName
```
If the address the operator must use is not there, either give the
node a certificate that names it (a custom certificate in the node's
Certificates panel) and put that certificate's CA in `caFile`, or set
`insecureSkipTLSVerify: true`, which `New` logs as a `WARNING:` line.
`nodes` holds node names as Proxmox knows them, not addresses: the one
URL reaches the whole cluster. The providers config is read once per
process, so adding a node, a template or a machine type needs a manager
restart.
3. Apply a Proxy (`config/samples/proxy_proxmox.yaml`) and watch it:
```sh
kubectl get px -w
# NAME MODE PROVIDER PHASE IP HEALTHY
# proxy-proxmox-sample Managed proxmox-home Provisioning
# proxy-proxmox-sample Managed proxmox-home Running 192.168.4.147
# proxy-proxmox-sample Managed proxmox-home Ready 192.168.4.147 True
```
(Illustrative.) `Create` returns once the VM runs, a few seconds after
the clone; the address follows when the guest agent is up, about 50
seconds after the start on `pv1-1` with the agent installed at first
boot.
## Config reference
The block is `proxmox:` and its Go type is `ProxmoxConfig` in
`internal/provider/config_proxmox.go`, validated at load by
`validateProxmox`; unknown keys are rejected. Defaults are applied by
`withDefaults` in `internal/provider/proxmox/proxmox.go`.
| Field | Required | Default | Meaning |
|---|---|---|---|
| `url` | yes | | `https://<host>:8006`, with no path (the client appends `/api2/json`). The host must be in the node certificate |
| `tokenFile` | yes | | file holding one line, `<user>@<realm>!<tokenid>=<secret>`, read on every request and sent as `Authorization: PVEAPIToken=<line>` |
| `caFile` | | system roots | PEM bundle trusted for the API's certificate, typically the node's `/etc/pve/pve-root-ca.pem`. Loaded on first use and cached |
| `insecureSkipTLSVerify` | | `false` | skip certificate verification; `New` logs a `WARNING:` line when it is on |
| `nodes` | yes, at least one | | node names `placement.zone` selects from; unique, lowercase letters, digits and dashes. They end up in request paths and as the clone's `target` |
| `isoStorage` | yes | | storage id the seeds are uploaded to and deleted from. Must hold `iso` content; the token needs `Datastore.Allocate` on it. No default on purpose |
| `storage` | | the template's disk storage | storage id the full clone's disks go to |
| `bridge` | | as cloned | replaces `bridge=` in the cloned `net0` |
| `vlanTag` | | as cloned | 1 to 4094; replaces or appends `tag=` in the cloned `net0` |
| `vmidRange.min`, `vmidRange.max` | | `100000`, `199999` | where VMIDs are allocated; at least 100, at most 999999999, min below max |
| `templates.<key>.vmid` | at least one template | | the key names the template in `placement.image`; the VMID is at least 100 |
| `machineTypes.<key>` | at least one | | `{cores, memoryMiB, diskGiB}` size profile. `cores` and `memoryMiB` above zero. `diskGiB` is optional: when larger than the cloned boot disk the disk is grown to it; it is never shrunk |
| `requestTimeoutSeconds` | | `30` | bound on one API request, response included. The seed upload is one request, so a multi-megabyte `vendorData` needs more |
| `vendorData` | | | extra cloud-init vendor-data placed on every seed next to the Proxy's user-data; a MIME multipart part beside the agent script when `installGuestAgent` is on. Cloud-init drops any top-level key of a vendor-data cloud-config that the user-data also defines |
| `installGuestAgent` | | `true` | add the vendor-data script that installs and starts `qemu-guest-agent`. Turn it off for templates that ship the agent |
## Placement
All three catalogue fields are checked before any request. An unknown
value is `ErrPermanent`, which the reconciler latches as `Failed` with the
message below until the spec changes.
| Proxy field | Meaning for proxmox | Error when unknown |
|---|---|---|
| `placement.zone` | a node from `nodes`. May be omitted when exactly one node is configured; otherwise required | `placement.zone "x" is not in proxmox.nodes`; with several nodes and no zone, `placement.zone is required when proxmox.nodes lists more than one node` |
| `placement.machineType` | a key in `machineTypes` | `placement.machineType "x" is not in proxmox.machineTypes` |
| `placement.image` | a key in `templates` | `placement.image "x" is not in proxmox.templates` |
| `placement.region` | ignored. It is still part of the spec hash, so changing it replaces the VM and does nothing else | |
The messages come from `placement` in `internal/provider/proxmox/create.go`
and `node` in `proxmox.go`. A key that exists but names a VMID that is
missing or not a template fails later, before any clone:
`placement.image "x": template VM 9001 does not exist` or `... is not a
template`.
**Catalogue edits affect only new VMs.** The spec hash covers the
placement values, not what the config maps them to, so repointing
`templates.debian-13.vmid` at another template changes the VMs created
afterwards and leaves the running ones alone. Add a new key rather than
repointing one in use, so the Proxies that name a key keep a meaning you
can reason about. A template may be edited, replaced or deleted without
affecting existing VMs: they are full clones and share nothing with it.
## What lands on Proxmox
For a Proxy whose deterministic name is `proxy-abc…`:
| Object | Name | Lifetime |
|---|---|---|
| VM | name `proxy-abc…`, a VMID from `vmidRange` | with the Proxy |
| its disks | the template's disks, fully copied onto `storage` (or the template's storage) | destroyed with the VM (`purge=1&destroy-unreferenced-disks=1`) |
| seed ISO | `<isoStorage>:iso/proxy-abc…-seed.iso` on the VM's node | with the Proxy |
**The VMID** is derived from the Proxy's UID, so a retried Create lands on
the same number: an FNV-1a hash of the UID placed in `vmidRange`, moving
forward one at a time on collision, up to 32 probes (`vmidFor` in
`id.go`). Before allocating, `Create` looks the name up in the cluster's
resources. A VMID that is taken, or a VM that already has the name, goes
through an ownership check (`inspect` in `create.go`): a VM with a lock
(a clone still running, which also has no name yet) is waited on; then
- another name: someone else's VM, so the next VMID is probed;
- our name and our mark with our UID or an empty one: **adopted**, and
Create carries on from where the previous attempt stopped;
- our name without the mark, or with another UID: **permanent error**
naming the VMID and both UIDs. The operator never takes over a VM it did
not create;
- our name, our mark, hibernated (`lock: suspended`): **permanent error**,
`resume or destroy it in Proxmox` (see *Known gaps*). This only happens
when Create has not recorded the VM yet; a recorded VM that gets
hibernated is reported Stopped and replaced.
Two VMs with the proxy's name are a permanent error naming every VMID;
remove all but one.
**The providerID** is `vms/<vmid>/<name>`. It names no node, because VMs
migrate: the node is looked up in `GET /cluster/resources` on every call.
`Get` and `Delete` check that the VM at that VMID still has that name and
the mark. Another name means ours is gone and the VMID was reused: `Get`
answers NotFound, `Delete` succeeds without touching it.
**The mark** is three lines in the VM's description (Notes in the UI),
passed in the clone call itself, so a VM is never unmarked:
```
proxy-operator-managed=true
proxy-operator-uid=0f3a…
proxy-operator-created-at=2026-09-23T17:42:11Z
```
That is how orphan GC recognises its own VMs and how a repeat `Create`
knows whether a VM of the same name belongs to this Proxy. Lines an admin
adds to the description are kept and ignored. The clone's own
`meta: ctime=` is useless for this: Proxmox copies it from the template.
**The config write.** After the clone and the seed upload, one
`PUT /nodes/{node}/qemu/{vmid}/config` sets:
| Key | Value |
|---|---|
| `cores`, `memory` | from the machine type |
| `agent` | `1` |
| `tags` | `proxy_operator` |
| `ide2` | `<isoStorage>:iso/<name>-seed.iso,media=cdrom` |
| `net0` | only when `bridge` or `vlanTag` is set: the cloned value with `bridge=` and `tag=` replaced or appended, the model, MAC and every other option kept |
The write **replaces the template's `agent` option and tags**: a
template's `agent: 1,fstrim_cloned_disks=1` becomes `agent: 1`, and its
tags give way to `proxy_operator`. The tag is for filtering in the
Proxmox UI and narrows GC's candidates; the mark decides. Proxmox appends
`ide2` to the boot order on its own (`order=scsi0;ide2`), which is
harmless because the boot disk stays first. When the machine type's
`diskGiB` is larger than the boot disk (the first disk in `boot: order=`
that is not a CD-ROM), a resize task grows it; cloud-init's growpart
expands the root filesystem at first boot.
**The seed** (`nocloud.BuildSeedISO`, shared with kvm2 in
`internal/provider/nocloud`) is an ISO 9660 image labelled `cidata`.
`user-data` is the Proxy's `cloudInit` **byte for byte**; the operator
never parses or re-renders it. `meta-data` holds `instance-id: <name>` and
`local-hostname: <name>`. `vendor-data` carries the guest-agent script
(a shell script, not a cloud-config, so a Proxy's own `packages:` cannot
shadow it), joined with `vendorData` as a MIME multipart when both are
set. It is staged under `/tmp` (the manager's emptyDir), built in memory
and uploaded with `POST /nodes/{node}/storage/{isoStorage}/upload`. An
upload overwrites a file of the same name, so it is rebuilt and
re-uploaded on every Create attempt with no delete first: a previous
attempt may have been cut off. Only the API is used; the storage upload
endpoint does not accept `snippets`, so Proxmox's own `cicustom` would
need shell access to the node.
**What a retried Create finds.** Every step is safe to repeat. After a
crash mid-clone, the retry lands on the same VMID, waits for the clone's
lock, and adopts the VM. A stopped VM of ours gets the seed, the config
and the resize again (the upload overwrites, the config is a replace, the
resize is skipped once large enough), then the start. A running one is
returned at once.
**A clone lock that never clears.** A node that crashes mid-clone can
leave the VM locked with `lock: clone` for good. Every Create for that
Proxy then waits out the 10 minute clone budget and returns a transient
error, and the reconciler keeps retrying rather than latching. Clear it
on the node:
```sh
qm unlock <vmid> # then let the next Create adopt and finish it
qm destroy <vmid> --purge # or remove it and let Create clone again
```
**Seeds without a VM.** A VM destroyed outside the operator leaves its
seed ISO, which GC cannot see (it lists VMs). Seeds are named
`proxy-*-seed.iso` on `isoStorage`; any without a matching VM may be
removed by hand:
```sh
pvesm list proxy-seeds --content iso
pvesm free proxy-seeds:iso/proxy-abc…-seed.iso
```
**Delete** runs under a per-name lock shared with Create: stop (a paused
VM too), delete the seed on the VM's node, then destroy with
`purge=1&destroy-unreferenced-disks=1`. Each is a task that is waited on.
The seed goes before the VM because once the VM is gone nothing calls
Delete again; a failed seed delete leaves the VM stopped, and the next
Delete finishes both. A VM that is already gone, not ours, or unmarked is
left alone, and the seed is then looked for on every configured node,
where a node that fails is logged and skipped. A refusal every node would
give fails the call instead: **without `Datastore.Allocate` on
`isoStorage` the 403 makes Delete permanent** on either path, and the
Proxy's deletion stays stuck with that message (a replaced Proxy stays
failed, its VM stopped) until the privilege is granted. When Delete
returns nil, nothing of the Proxy is left.
## Logging
The provider logs through controller-runtime's logger, named `proxmox`,
with `provider` (the configured name) on every line, and `vmid`, `name`
and `node` on every line about an instance. Every line inside a traced
operation also carries `traceID` and `spanID`.
- At startup (always on): `WARNING: token file is not readable; every
call fails until it is` with `path` and `reason`, and `WARNING:
insecureSkipTLSVerify is set`.
- `--zap-log-level=debug` (logr V(1)):
- one line per operation outcome: `proxmox vm ensured running`,
`proxmox vm adopted` with `status` and `qmpstatus`,
`proxmox vm fetched` with `state`, `ip`, `uid`, `status` and
`qmpstatus`, `proxmox vm not found` with `reason`,
`proxmox vm deleted` with `destroyed` (false when no VM of ours was
left to destroy), `proxmox vms listed` with `vms`, `candidates`,
`managed` and `skipped` counts, and `proxmox node checked` with
`ready`, `reason` and `latency`;
- **one `proxmox api call` line per API request**, written by the
decorator in `internal/provider/proxmox/observe.go`: `call` (the seam
method, for example `Clone`), the identifiers it was given (`node`,
`vmid`, `storage`), `upid` for a task, `newid` and `name` on a clone,
`filename` on an upload, `volid` on a volume delete, `duration`, and
on failure `httpStatus` and `error`. A running `Get` is four calls; a
fresh Create is a dozen or more, depending on how many polls each
task takes;
- `proxmox task failed` with `upid` and `exitstatus`, and
`proxmox task finished with warnings`;
- the no-op lines: `proxmox vm already gone`,
`proxmox vm at this vmid is not ours, left alone` with `vmName`,
`proxmox vm carries no proxy-operator mark, left alone`,
`proxmox seed delete failed on a node, skipped`,
`skipping unreadable proxmox vm`, and the probe's
`proxmox vmid taken by another vm, probing on`.
- `--zap-log-level=2`: `proxmox create resolved` with the resolved
parameters (node, machine type, cores, memory, disk, template VMID,
storages, bridge, VLAN tag) and `cloudInitBytes`; the `proxmox api
call` lines also carry the config write's parameters, a summary of each
config read, the clone's description, storage and target, the resize
size, VM and task status, the agent's addresses and the upload's byte
count; `proxmox guest agent answered`; `proxmox listed instance` per
listed VM.
- Never logged at any level: the cloud-init content, the token, the CA
content and the upload body. There is no raw wire tier.
## Tracing
With an OTLP exporter configured ([architecture.md
§10](architecture.md#10-tracing-internaltracing)), every API request is a
child span of the operation span the decorators open (`provider.create`,
`provider.get`, `provider.delete`, `provider.list`) or of the monitor's
`providerhealth.check`:
| Span | Around | Attributes |
|---|---|---|
| `HTTP GET`, `HTTP POST`, `HTTP PUT`, `HTTP DELETE` | one API request, from `otelhttp` on the client's transport | otelhttp's standard HTTP client attributes: method, URL, server address and port, response status |
The transport is `tracing.RestConfigWrapper()`, the same parent-gated
wrapper the kubernetes provider uses, so a request without a parent span
starts none. The logging decorator starts no span of its own, so nothing
is counted twice; its `proxmox api call` line carries the operation
span's `spanID`, not the HTTP span's. The token travels in a header,
which otelhttp does not record, and no attribute carries cloud-init.
## Timeouts
No caller puts a deadline on the context, so every request and every wait
carries its own budget (`defaultBudgets` in
`internal/provider/proxmox/proxmox.go`, and the transport in `client.go`):
| Bound | Budget |
|---|---|
| TCP connect | 10 s |
| TLS handshake | 10 s |
| one request, response included | `requestTimeoutSeconds`, default 30 s |
| clone task, and the wait for a VM's lock in the ownership check | 10 m |
| upload task, resize task, start task, stop task | 2 m each |
| destroy task | 5 m |
| seed delete task | 1 m |
| wait for `running` after the start | 60 s |
| task and status poll interval | 1 s |
A task wait that runs out is a transient error naming the UPID; the task
keeps running on Proxmox, and the next attempt finds its result through
the ownership check or the lock. A task that ends with any `exitstatus`
other than `OK` or `WARNINGS: <n>` is transient too, with Proxmox's text.
Requests share one HTTP client and its idle connections (4 per host,
closed after 90 s idle). A cancelled or overlong request ends only
itself: nothing else in flight is disturbed, and there is no connection
to kill as there is for kvm2. HTTP/2 is disabled, because pveproxy speaks
HTTP/1.1 only. Create and Delete for the same name are serialised on a
per-name lock, so the reconciler's Delete and GC's Delete never race on
stop and destroy; Get and ListByTag take no lock.
## Health check
The operator checks every configured node every
`--provider-check-interval` (default 1m) and publishes the result in the
`ProviderHealth` object named after the provider
(`kubectl get ph proxmox-home -o yaml`) and in the
`proxy_operator_provider_target_up{provider,target}` gauge. One target per
`nodes` entry, named after the node with the URL as its address, in
config order, all checked concurrently.
Each check (`checkNode` in `internal/provider/proxmox/check.go`) first
reads the token file for its id, then makes four requests on the shared
client: `GET /version`, `GET /nodes/{node}/status`,
`GET /nodes/{node}/storage/{isoStorage}/status` (the storage must exist,
be active and hold `iso` content), and `GET /cluster/resources?type=vm`
(every configured template must exist and be a template). A check that
runs out of time cancels only its own requests.
Every target's `details` carry `isoStorage`. As facts arrive they gain
`tokenID` (the part of the token before `=`, never the secret),
`pveVersion`, `kernel`, `isoStorageAvailGiB` and `templates`
(`<found>/<configured>`), so a target that fails late still says what the
node answered before. There is no quota signal; the storage's free space
is here instead.
| Reason | Meaning | Look at |
|---|---|---|
| `Reachable` | ready | |
| `CredentialsUnavailable` | the token file is missing or not `<user>@<realm>!<tokenid>=<secret>`; no request was made | the `proxmox-token` Secret; fixed in the cluster, not in Proxmox |
| `CertificateInvalid` | TLS verification failed, or `caFile` is missing or holds no certificate | `url` (its host must be in the node certificate) or `caFile`; not the node |
| `Unreachable` | the TCP connect or TLS handshake failed for a network reason, the connection was cut mid-response, or pveproxy answered 595 or 596 (it cannot reach the node behind it) | routing and firewalls between the cluster and `url`; `pveproxy` running; the node up |
| `Unauthorized` | 401: the token id or secret is wrong | the token file; `pveum user token list proxy-operator@pve` |
| `Forbidden` | 403; the message names the path and the privilege | the roles in *Proxmox prerequisites* |
| `Timeout` | the monitor's `--provider-check-timeout` (default 15s) ran out, whichever request was running, or a request ran out of `requestTimeoutSeconds` while the round still had time | a slow or hung pveproxy, a saturated link |
| `StorageNotFound` | `isoStorage` answered with a 500 on that node (it does not exist, is not enabled there, or is offline), is not active, or holds no `iso` content; the message carries Proxmox's text | `pvesm status`; the storage's nodes and content types |
| `TemplateNotFound` | a configured template VMID does not exist or is not a template | `qm list`; the `templates` catalogue |
| `APIError` | any other failure | the message carries Proxmox's own text |
The check does not look inside the templates: a cloud-init drive, a
missing `net0` or an unreadable disk size surface only when a Create is
about to clone.
## Error classes
Every error is classified once, in `classify` in
`internal/provider/proxmox/errors.go`, with errors that carry a class from
their source (validation, the token and CA files, the ownership check)
keeping it. The reconciler latches `ErrPermanent` as `Failed` until the
spec changes, recreates on `ErrNotFound` from `Get`, and retries
everything else with backoff. The provider never returns
`ErrQuotaExceeded`: the thin `local-lvm` storage overprovisions, so no
out-of-space answer could be produced to match, and capacity failures are
transient.
| Class | What lands there |
|---|---|
| `ErrPermanent` | placement values not in the catalogue; a template VMID that is missing or not a template, has a cloud-init drive, or lacks `net0` or a sized boot disk when the config needs them; a malformed providerID; any 4xx (401 `Authentication failed!` or `No ticket`: the token; 403 `Permission check failed (<path>, <privilege>)`: a missing privilege, including `Datastore.Allocate` for the seed delete; 400 `Parameter verification failed.` with the field errors); a TLS verification failure; a token file that exists but is not `<user>@<realm>!<tokenid>=<secret>`; a CA file with no certificate; a VM with the proxy's name that carries no mark or another UID; two VMs with the proxy's name; our VM hibernated (`lock: suspended`) found by Create's adoption |
| `ErrNotFound` | from `Get`: the VMID absent from the cluster's resources, the 500 `Configuration file 'nodes/<node>/qemu-server/<vmid>.conf' does not exist`, the VM at the VMID now named differently or unmarked. Inside `Create` the `does not exist` answer is `ErrTransient`, because the contract forbids NotFound from Create; `Delete` treats all of these as done |
| `ErrTransient` | everything else: a missing token or CA file (the Secret has not landed yet); network errors and cancellation; 500 `VM is locked (<lock>)` and any lock seen in the resources or config; a VM whose node does not answer, or one a few seconds old the listing has not reported yet (`status: unknown`); the agent's answers that `Get` cannot place, such as `VM <vmid> is not running`; any other 5xx, including pveproxy's 595 and 596; a task that fails or runs out of its budget; no free VMID after 32 probes; the wait for running expiring, or a VM that stops right after its start |
`Get` turns two agent answers into states rather than errors:
`QEMU guest agent is not running` is Provisioning (booting, or the agent
install has not finished), and `No QEMU guest agent configured` is
Stopped (someone removed `agent: 1`; the reconciler replaces the VM).
Error strings name VMIDs, nodes, storages, UPIDs and Proxmox's own text.
They never contain the token, the CA content or cloud-init.
## Known gaps
- **Create blocks** until the VM runs, holding one of the three reconcile
workers for its duration. On `pv1-1` that is about five seconds plus the
clone; large templates on slow storage take longer. Other Proxies keep
reconciling on the remaining workers.
- **A clone lock that never clears** (a node crash mid-clone) makes every
Create for that Proxy wait out the 10 minute clone budget and return a
transient error. Clear it with `qm unlock <vmid>` or destroy the VM (see
*What lands on Proxmox*).
- **Seed ISOs of VMs destroyed outside the operator are invisible to
GC.** Remove `proxy-*-seed.iso` files without a matching VM by hand.
On a storage that is not shared, a VM migrated to another node leaves
its seed on the original node; Delete then looks on the VM's current
node, finds nothing, and the seed stays. Shared ISO storage avoids it.
- **The VMID range is shared with humans.** A VM created by hand inside
it costs a probe; a hand-made VM named like a proxy is refused as
`ErrPermanent`. Reserve the range.
- **Cross-node clones need shared storage and are untested.** When the
zone's node differs from the template's, the clone is sent with
`target`, which Proxmox allows only when the template's disks are on
shared storage. The host this was built on has one node.
- **A VM whose agent was switched off is replaced**, not repaired: with
`agent: 1` removed it can never report an address, so `Get` reports it
Stopped.
- **The proxy network needs internet** for the agent install, unless the
template ships the agent and `installGuestAgent` is `false`. Without it
the VM boots, gets an address, and stays `Provisioning` forever,
because the agent is the only address source. Check
`systemctl status qemu-guest-agent` and
`/var/log/cloud-init-output.log` on the guest (`qm terminal <vmid>` on
the node, or the console in the UI).
- **The CA is cached** after first use; rotating it needs a manager
restart. The token rotates freely.
- **A node that stops answering** makes `Get` and `Delete` for its VMs
transient until it returns, so deleting such a Proxy waits. Proxmox
lists an unreachable node's guests with `status: unknown` rather than
dropping them, so they are never mistaken for gone. That is Proxmox's
documented behaviour; a single-node host could not show it.
- **Contract limits that apply unchanged** ([provider-contract.md
§13](provider-contract.md)): the providers config is read once per
process; GC has no installation identity, so two operators sharing a
Proxmox cluster and a VMID range delete each other's VMs; and a Proxy
naming an unconfigured provider latches until its spec changes.
Beyond the ten limits accepted in the design:
- **A hibernated VM is replaced, or latches a Create.** `Get` reports a
VM of ours with `lock: suspended` (hibernated to disk) as Stopped, so
the reconciler deletes it (the destroy removes its suspend state volume
too) and creates a new one. Only a Create that finds such a VM before it
was ever recorded, by name, fails permanently with
`resume or destroy it in Proxmox`: resume it by starting it
(`qm start <vmid>` restores the hibernated state) or destroy it on the
node, then touch the Proxy's spec.
- **A Create that failed permanently after its clone keeps its VM.**
The stopped VM is adopted by name on the next Create, even when the
Proxy's `placement` has changed since: the Proxy then runs the old
template on the old node. After changing `placement` on such a Proxy,
destroy the VM on the node (`qm destroy <vmid> --purge`) first, or
delete and recreate the Proxy.
- **A retry can clone a second VM** in one rare sequence: a Create is
cut off while cloning onto a later probe VMID (the first was taken by
someone else's VM), that other VM is removed, and the retry comes
while the clone is still running, so its VM is still nameless. The
retry clones onto the now free first VMID. The next Create then fails
permanently with `VMs … are all named …`; destroy either VM.
- **The seed upload is one request**, bounded by `requestTimeoutSeconds`.
A seed is tens of KiB, but a multi-megabyte `vendorData` would need a
larger timeout.
- **The sample cloud-init is an open proxy.**
`config/samples/proxy_proxmox.yaml` installs Squid with
`http_access allow all`; restrict it before exposing the proxy network
to anything but the cluster.
## Testing
Unit tests run over an in-memory fake of the API seam, and the HTTP
client over a TLS test server replaying the responses captured from
`pv1-1` (`internal/provider/proxmox/testdata/`). The optional integration
test against a real host is described in
[testing.md](testing.md#the-proxmox-integration-test-real-proxmox-ve-host),
which also lists the variables it needs; run it with
`make test-integration-proxmox`. `TestProxmoxCheck_realHost` runs the
health check against the same host and creates nothing:
`go test -tags=integration -count=1 -v -run TestProxmoxCheck ./internal/provider/proxmox/`.
Both passed on `pv1-1` (PVE 9.2.20) on 2026-09-24: Create took 6.2 s,
and the guest reported an address 50 s after Create returned.
@@ -0,0 +1,183 @@
# kvm2 provider review: final report
Branch `feat/kvm2-provider` at `2510842`. Scope in `scope.md`; phase 1 detail in `sweep.md`;
phase 2 detail, measurements and unrun probes in `deep.md`.
**What this contains.** Confirmed findings only: the sweep findings (each reported by its
sweeper as verified in code) plus the escalations that came back CONFIRMED. Where a deep dive
refined a sweep finding, the refined version is given. **52 findings: 4 high, 20 medium, 28 low.**
**How far to trust it.** Nothing here was fixed or re-verified by a second reader except where
two or three reviewers reported the same thing independently (marked ×2/×3). Hypervisor access
was read-only, so every claim about what libvirt does on a *write* is from libvirt source or
memory, with the reviewer's stated confidence; the scripts that would settle them are in
`deep.md`. The kvm2 integration test has **not yet run on beelink** (Step 11 is unchecked in
the plan execution file; every `base-*` volume there predates kvm2), so no kvm2 behaviour in
this report has been observed against real libvirt.
Themes: **T1** unknown errors default to Transient and retry forever with no condition on the
Proxy · **T2** the ownership mark is enforced on Create/ListByTag but not Get/Delete ·
**T3** base image cache identity, lifetime and fencing · **T4** any caller can kill the shared
pooled link, and several callers misread the resulting error.
---
## High
### H1 — `markDead` blocks on a silent host and pins every reconcile worker (S1.1 + E1)
`internal/provider/kvm2/backend.go:138-148`, `ssh.go:109-114`
`markDead` runs `kill()` inside `once.Do` and only then closes `l.closed`. `kill` is `sshSocket.Close`, which closes the SSH channel first; that needs `ch.writeMu` and `t.mu`, both held by a writer stuck in a TCP write. Reproduced against x/crypto v0.54.0: with a 256 K send buffer the close was still blocked after 8 s, and a plain `client.Close()` released it instantly. Precondition: a bulk write in flight and a send buffer under the 2 MiB SSH window.
**Scenario.** Three Proxies placed on one host with an uncached image. Worker 1 uploads (`image.go:270`, 20 min budget); workers 2 and 3 park on the image lock (`kvm2.go:224-233`) with no deadline; `MaxConcurrentReconciles` is 3. Host is power-cut. The keepalive's `markDead` (`hosts.go:158`) blocks in the channel close; worker 1's `call` cannot see `done`, `l.closed`, or even a ctx cancel (it queues in `once.Do`). For ~15 min no Proxy on any provider reconciles. No `ReconciliationTimeout` is set, and one alone would not release worker 1.
**Fix.** Close the TCP connection first in `sshSocket.Close` (`s.client.Close()` before, or instead of, `s.Conn.Close()`); in `markDead`, `close(l.closed)` before `kill()` or run `kill` in a goroutine. Regression test with a frozen proxy and small `SO_SNDBUF`. Optionally set `ReconciliationTimeout`.
### H2 — An error during `StorageVolUpload` wedges go-libvirt's stream sender forever (S4.1)
`internal/provider/kvm2/image.go:273` (upstream `rpc.go:290-308`, `socket/socket.go:347-376`)
`abort` and `outErr` are unbuffered. On a reader error `SendStream` blocks forever on `outErr <-`; on a server-side error `requestStream` blocks forever on `abort <- true`. A socket kill does not unblock them (E6 found why: `listen` blocks in `callback`'s send under `cmux`), contrary to the comment at `backend.go:152-156`.
**Scenario.** A mirror truncates the body, or libvirtd hits ENOSPC mid-upload. The caller is rescued only after the 20 min transfer budget; each failed upload leaks 1-2 goroutines plus a 4 MiB buffer, accumulating under reconcile retries; `discardVolume` (`image.go:292`) deletes the base before the deferred `fresh.Close()` (`:265`), so the file is unlinked while the server stream holds the fd. `TestEnsureImage_shortReadRemovesVolumeAndIsTransient` cannot catch it: the fake returns the reader error immediately.
**Fix.** Have `countingReader` convert a read error to `io.EOF` and report it out of band; the existing `readErr` / `read != size` checks (`image.go:281-290`) still catch truncation. Close the dedicated connection before `discardVolume`. Consider an upstream patch for the unbuffered channels.
### H3 — `address()` swallows every error, demoting healthy Running proxies (S3.1)
`internal/provider/kvm2/get.go:104-119`
Every non-`context` error from both address sources becomes `("", nil)`, so `mapState(running, "")` = Provisioning.
**Scenario.** (a) On beelink the ARP source never answers (`kvm2-provider-spec.md:146`), so a `qemu-guest-agent` restart in the guest (`ErrAgentUnresponsive`) empties the address. (b) Another reconcile's cancel kills the shared pooled link between `DomainGetState` and the address read; the "connection is closed" error carries no ctx sentinel, so `Get` *succeeds* with `IP=""`. Then `proxy_controller.go:208-211` clears `status.IP` and sets `Provisioned=False`, and `internal/health/engine.go:200-207` prunes the proxy's health state. One RPC hiccup pulls a working proxy out of rotation. `TestGet_addressErrorIsProvisioning` pins this.
**Fix.** Return Transient from `Get` when the address read fails with a connection-class error (`isDead(be)` or the closed-link error) and return NotFound on `ErrNoDomain`; keep "no address yet" only for agent-unavailable/ARP-empty on a domain that has never reported an address. Consider having the controller keep the last known IP while an instance is Running.
### H4 — The base image cache is shared and unfenced across provider entries (S7.1 ×2, E3-3a/3b, E4)
`internal/provider/kvm2/volume.go:17-25`, `image.go:113-146`, `:235-245`, `kvm2.go:111`; `internal/provider/kvm/image.go:44-57`; `config/manager/providers_config.yaml`, `config/samples/providers-config.yaml:41,73`
The base is `base-<key>.<format>` with a `.sha256` marker, byte-identical in the kvm provider, in the same default pool, under **separate, per-entry locks**. The shipped config puts `kvm-home` and `kvm2-home` on `beelink`, key `debian-13`, pool `default`, and the docs say both types may share a host. On the host both live (kvm-made) overlays back onto that very file. Neither the name nor the marker records the URL.
**Scenarios.**
1. *Wrong image, silently.* The two entries' URLs drift and neither sets `sha256`: whoever fetched first owns the file. Likewise, editing `images.<key>.url` alone is a silent no-op.
2. *Digest mismatch (3a).* One entry pins `sha256: D2` while the cached base is D1: `cachedImage` (`image.go:129` → `:145`) deletes the base under the other provider's live overlays. libvirt refuses a delete only on `in_use`/`building` (`storage_driver.c:1844-1856`, read at v11.6.0). With two different pins, every Create on either side deletes and refetches.
3. *Concurrent cold fetch (3b, by source, unexecuted).* B finds A's base-without-marker and deletes it (`image.go:117-118`), recreates it and uploads. libvirt holds `in_use` only during stream setup (`storage_driver.c:2421-2427`). A's upload completes into the unlinked inode, A writes the marker, and `StorageVolGetPath` resolves to B's partial file: **A boots a guest from a half-written base.** If B is kvm2, its `writeMarker` then fails `ErrStorageVolExist` and `image.go:241-243` discards the complete base under A's running domain. If the process dies during B's upload, a truncated base stays under a valid marker forever, since nothing re-hashes the base.
The rollout/leader-handover variant of this was **refuted** for the shipped manifests (lease held until the old pod dies; SIGKILL at 10 s).
**Fix.** kvm2-specific prefix including the entry name, or one process-wide image lock table; reject two kvm/kvm2 entries with the same address+socket+pool at config load. Upload to `<base>.part-<random>`, verify, then publish the final name; never delete a name this call did not create, in particular drop `discardVolume(base)` on a marker `ErrStorageVolExist`. Record size and URL next to the digest and check the base's allocation before trusting a marker.
---
## Medium
### M1 — Paused domains are destroyed (S2.2 ×2)
`create.go:230-231`, `get.go:56`, `state.go:16-27`. `get.go:56` discards the `DomainGetState` reason; paused/pmsuspended map to Stopped; `reconcileManaged` (`proxy_controller.go:217-225`) then deletes and recreates. The "virsh resume it" guard in `ensureRunning` only fires before an ID is persisted. **Scenario:** the pool fills and QEMU pauses the guest with `PAUSED_IOERROR` (or `virsh suspend`/save/snapshot/migrate); at the next drift poll the VM and its disks are gone and the recreate refills the pool. **Fix:** keep the reason; map paused to a non-destructive state (Provisioning or a new Degraded) except for reasons that are terminal.
### M2 — `Get` and `Delete` do not enforce the kvm2 mark (S2.3 ×2, E3-2) [T2]
`get.go:52-73`, `delete.go:55-98` vs `create.go:80-82`. IDs are byte-identical to the kvm provider's (`kvm/id.go:11-13`). **Scenario:** an entry is re-typed `kvm` → `kvm2` under the same name without first deleting its Proxies (the documented migration, out of order): `status.ProviderID` survives, `Get` returns the v1 domain with `UID: ""`, and anything but Running leads to kvm2 destroying a domain the docs say it can never touch. A tampered `status.providerID` such as `hosts/beelink/domains/docker-29` destroys an unrelated VM. Nothing prevents a re-type (no webhook, `config.go:93-135`). The reverse direction (kvm provider against kvm2 domains) has no guard at all (`kvm/kvm.go:177-181`). **Fix:** `removeDomain` and `get` require `meta.managed()`; pass the expected UID to Delete or compare `inst.UID` with `p.UID` in the controller.
### M3 — Volumes created before `DomainDefineXML` are unreachable when Create returns no ID (S2.1)
`create.go:107-114`. **Scenario:** overlay and seed succeed, define fails Permanent (or the manager dies). No `ProviderID` is set; on Proxy deletion `reconcileDelete` takes the empty-ID shortcut (`proxy_controller.go:270-274`); `ListByTag` lists domains only. `proxy-<x>.qcow2` and `-seed.iso` leak permanently. Five such leftovers already sit in beelink's `default` pool (Appendix C). **Fix:** on a failed define, best-effort remove the two volumes before returning; or have `Delete`/GC derive volume names from the Proxy UID.
### M4 — The pool-space precheck reads a cached, reserve-inflated number (E8)
`create.go:92-100`, `image.go:450-458`, `check.go:106-112`. Measured on beelink: libvirt's `available` is cached per pool (three pools on one filesystem report two values, 1.68 GB apart; frozen over 80 s while `f_bfree` moved) and is `f_bfree`, **24.7 GB above** what unprivileged QEMU can use on the kvm2 filesystem. The only refresh is after an image upload. **Scenario:** with `f_bavail` between 0 and ~10.7 GB the precheck passes for a 10 GiB disk, `ErrQuotaExceeded` never fires, and Create, the upload and the guests hit ENOSPC (feeding H2 and M1). `poolAvailableGiB` in ProviderHealth over-reports the same way. **Fix:** `StoragePoolRefresh` right before `checkPoolSpace` (not in Check); add headroom to `neededBytes` from the discarded `capacity` or a `poolReserveGiB` setting; make the fake's pool info cached-until-refresh.
### M5 — An overlay smaller than the base's virtual size is accepted; the Proxy hangs in Provisioning (S5.6 + E10)
`volume.go:72`, `create.go:107,158`, `internal/provider/config_kvm.go:133`. Measured locally: `qemu-img create` with a capacity below the backing file exits 0; reads past the capacity fail; a GPT base under a short overlay shows only the protective MBR. Bases on the host are 3 GiB virtual. **Scenario:** `diskSizeGb: 2` validates; Create succeeds; the guest has no root device and never reports an address; `Get` says Provisioning forever (no provisioning deadline exists), nothing names `diskSizeGb`, and raising it later does not repair that Proxy. libvirt's pass-through is ~90% from source; the 200 KiB probe is in `deep.md`. **Fix:** add `StorageVolGetInfo` to the backend seam and fail Permanent when the base's capacity exceeds the overlay's; same check on a reused overlay.
### M6 — A peer-close right after ConnectOpen wedges a pooled link forever (E6a)
`backend.go:177-178`, `:229-236`, `hosts.go:75`, `errors.go:55` (upstream race `libvirt.go:297/305/821`, reported by `-race`). **Scenario:** libvirtd or the forwarded socket goes away within ~1 ms of a successful open (daemon restart). `Connect()` returns nil with a `Disconnected()` channel nothing will close; every later RPC fails `EINVAL`, classified Transient; no result marks the link dead; the SSH keepalive keeps succeeding. The host answers "invalid argument" until the operator restarts. Wedged in 546/1000 cycles over real SSH in the reviewer's harness. The hypothesised trigger (kvm2's own kill) is harmless. **Fix:** in `call`, `markDead` on `syscall.EINVAL` / `libvirt.ErrInterrupted`; or wrap the `net.Conn` so a failed `Read` marks the link dead.
### M7 — Permanent host/config rejections retry forever with nothing in status (S5.2 ×2, S3.5, E9b) [T1]
`errors.go:58-71`, `xml.go:26-27,194-206`, `proxy_controller.go:344-345`. `x86_64`/`q35`/`kvm`/host-passthrough/efi are unconditional with no capability gate; code 67 (`ErrConfigUnsupported`) and the bare `libvirt.ErrUnsupported` sentinel are unmapped; `providerFailure`'s Transient arm sets no condition. **Scenario:** an aarch64 or KVM-less host is added; Check says Ready; every Create retries an impossible define forever, rerunning `writeSeed` each time; a missing bridge (38, at start) or missing OVMF (9) behaves the same and Check looks at neither. "Unknown means retry" is a recorded spec decision (`kvm2-provider-spec.md:317`), but it never considered 67. **Fix:** 67 → Permanent on create; `ErrUnsupported` → Permanent; leave 9/38/1/84. Surface a non-latching condition for Transient create failures, or check domain capabilities and the bridge in the health probe.
### M8 — Seed ISO I/O failures latch as Permanent with no requeue (S5.1) [T1, inverted]
`create.go:199-201`, `seed.go:66-74`. `permanentf("building seed ISO …: %v")` covers a missing `/tmp`, ENOSPC, and even a `RemoveAll` failure on an otherwise successful build. **Scenario:** node disk pressure for a minute leaves the Proxy unprovisioned until a spec edit or the ~10 h resync. **Fix:** Permanent only for the empty-name guard; wrap the rest Transient with `%w`.
### M9 — `HEAD` 2xx without a usable Content-Length becomes a latching Permanent (S4.2) [T1]
`image.go:434-439`, `:391-396`. The GET fallback fires only on non-2xx. **Scenario:** a CDN (or Go's own `net/http`) answers HEAD with `Content-Length: 0`; the Proxy never provisions although GET works. **Fix:** fall back to GET whenever HEAD yields no positive length.
### M10 — A completed upload is discarded when the pooled link died meanwhile (S4.3) [T4]
`image.go:235-245`. Refresh and `writeMarker` run on the pooled `be` after a transfer on a dedicated link. **Scenario:** any other reconcile's cancel during a 20 min download kills the pooled link; the verified base is deleted and refetched. **Fix:** run the refresh and marker on the still-open dedicated connection.
### M11 — Precheck uses HEAD's length, the volume uses GET's, and there is no size cap (S4.4)
`image.go:419-443`, `:226-230`. **Scenario:** HEAD says 1 MB, GET says 4 TiB: a sparse 4 TiB volume is created and the stream fills the host pool. **Fix:** re-check space against the GET length; add `maxImageBytes`.
### M12 — `placement.image` is an unvalidated user-supplied URL fetched by the operator (S4.5)
`image.go:59-65`, `internal/provider/config_kvm.go:101-104`. No CRD validation, no host allowlist, `http.DefaultClient` follows redirects, and a URL image can never carry a digest. **Scenario:** anyone who can create a Proxy makes the operator issue requests to cluster-internal addresses and boots arbitrary bytes on the egress bridge. **Fix:** a config switch to allow URL images at all (default off) or a host allowlist; `CheckRedirect` policy; block link-local/private ranges.
### M13 — A `sha256` change replaces the base in place under live overlays (S4.6 ×3)
`image.go:118,129,145`. No in-use check, `V(1)` log; a mistyped digest deletes the base before anything is verified. The kvm2 sample omits the warning the kvm sample carries. Whether guests actually corrupt is Appendix A (E7). **Fix:** on a digest mismatch return Permanent "add the image under a new key"; or put the digest in the volume name.
### M14 — IP detection takes the first IPv4 from any guest interface (S3.2)
`state.go:35-49`. `Hwaddr` is available but unused. **Scenario:** `docker0`/`tailscale0` comes up before the DHCP lease; the Proxy latches `Provisioned=True` with 172.17.0.1 and is never corrected. **Fix:** match the interface by the domain NIC's MAC.
### M15 — A link that dies mid-list yields a silently partial `ListByTag` (S3.3) [T4]
`list.go:38-45`. The guard tests `ctx.Err()`, but a killed link has no ctx sentinel. GC only under-deletes, so nothing wrong is removed, but the sweep covers a fraction of the fleet and says so nowhere. **Fix:** fail the list on any connection-class error (`isDead(be)`).
### M16 — `KVMHost.Address` is not validated as `host:port` (S7.2) [T1]
`internal/provider/config_kvm.go:180-182`. **Scenario:** `address: beelink` loads; every dial fails "missing port in address", classified Transient, and Check reports `Unreachable`. **Fix:** `net.SplitHostPort` at load, or default `:22`.
### M17 — Plain `http://` images with no digest are accepted silently (S7.3)
`internal/provider/config_kvm.go:101-104,142-147`. The root disk of every VM can come from an unauthenticated channel and the marker then pins it. **Fix:** require `sha256` for `http://` catalogue entries, or warn loudly at load.
### M18 — The fake ignores the metadata namespace (S7.4) [T2]
`fake_test.go:362-376`, `:259-282`. No unit test can build a kvm-v1-marked domain and assert `ListByTag` skips it; if that filter regressed, kvm2 would hand kvm's VMs to orphan GC with the suite green. E3/E9 observed that real libvirt 11.6 does scope by namespace. **Fix:** key the fake's metadata on the element's namespace; add the test.
### M19 — The integration target's timeout can skip VM cleanup on the real host (S7.5)
`Makefile:75`. `-timeout 40m` is fixed while two tests each take up to `KVM2_TEST_TIMEOUT` (20 m) plus 5 m cleanup. **Scenario:** a cold download or a raised env timeout trips go test's own timeout before `t.Cleanup` (`kvm2_integration_test.go:373-382`); a domain and two volumes stay on beelink under a random name. **Fix:** derive `-timeout` from the env var with margin; print the name up front; add a `review`-style sweep-by-prefix cleanup target.
### M20 — `sshSocket.keepalive` has no test (S1.2)
`ssh.go:119-147`. It is the only detector of a silently dead pooled link between calls, and `testSSHServer` already services global requests. **Fix:** an end-to-end test that stops the server's request handling and asserts `markDead`.
---
## Low
| ID | Where | Defect → scenario | Fix sketch |
|---|---|---|---|
| L1 (S1.3) | `ssh.go:120` | `NewTicker` panics on interval ≤ 0 in a goroutine → a future `libvirtDialer{}` literal kills the operator on first dial | default the interval when ≤ 0 |
| L2 (S1.4) [T1] | `errors.go:45-51` | `*knownhosts.RevokedError` unrecognised → a revoked host key retries forever | classify it Permanent |
| L3 (S1.5) | `ssh.go:167-172` | `HostKeyAlgorithms` empty → a host pinned by RSA only but serving ed25519 reports a Permanent "mismatch" | on `KeyError` with `Want`, retry with those key types; or document `ssh-keyscan` without `-t` |
| L4 (S2.4) | `create.go:87` | Adoption path starts a defined domain without checking its disks → missing overlay/seed retries forever (E5's EIO residual lands here) | on a storage error in the adoption branch, undefine and fall through to a full create |
| L5 (S2.5) [T1] | `delete.go:15` | `undefineFlags` lacks `CheckpointsMetadata` → an admin/backup checkpoint wedges the finalizer | add the flag |
| L6 (S3.4) | `get.go:104-119` | `ErrNoDomain` swallowed → a vanished domain reads as Provisioning for one poll; comment about ctx-only errors is wrong (a budget expiry never falls back to ARP) | return NotFound; fix the comment |
| L7 (S4.7) | `image.go:62` | URL images assumed qcow2/uefi → a raw URL image fails to boot with a misleading error | sniff the qcow2 magic from the first bytes |
| L8 (S4.8) [T3] | `volume.go:22-25` | 64-bit URL key, namespace shared with catalogue keys → colliding URLs or a 16-hex catalogue key share a base | longer key and a distinct `url-` prefix |
| L9 (S5.3, narrowed by E2) | `metadata.go:44-50` | future-dated mark → a true orphan is never collected. (The zero-time half is benign: `gc.go:118` checks the UID first.) | clamp future timestamps to now |
| L10 (S5.4) | `seed.go:48-53` | `rc-update` fallback unguarded under `set -e` → agent installed but not started on non-systemd, non-OpenRC images | `elif command -v rc-update`, as the kvm provider does |
| L11 (S5.5) | `xml.go:178-193` | `VCPUs`/`MemoryMiB` not validated in the builder (latent) | reject ≤ 0 |
| L12 (S5.7) | `internal/provider/config_kvm.go:186-188` | `bridge`/`pool` only non-empty → opaque libvirt error at Create | apply `kvmIdentifier`-style validation |
| L13 (S5.8) | `seed.go:107-108` | multipart or headerless configured `vendorData` silently dropped with `installGuestAgent` | reject at load what cloud-init will not recognise |
| L14 (S6.1) | `check.go:90`, `backend.go:162,351` | `Close()` can send `ConnectClose` for up to 5 s after the round deadline | `markDead` when `call` refuses on a done ctx, or skip the RPC in Close then |
| L15 (S6.2) | `check.go:57-58` | Latency includes the teardown RPC | stop the clock before Close |
| L16 (S6.3) | `check.go:66` | round-wide `ctx.Err()` masks a per-host reason landing on the deadline | classify the error first; Timeout only if it carries the ctx error |
| L17 (S6.4) | `check.go:135-148`, `internal/providerhealth/monitor.go:113` | a 10 s round timeout collapses Unreachable into Timeout | validate round timeout > dial budget, or shrink the dial budget to fit |
| L18 (S6.5) | `check.go:101` | unbounded remote `hostname` in status `details` → status patch "too large" | truncate; add `maxLength` to the CRD |
| L19 (S6.6) | `check.go:41-45` | unbounded per-host fan-out, key re-read every dial | cap concurrency; cache credentials |
| L20 (S7.6) [T1] | `internal/provider/config_kvm.go:133`, `image.go:466` | no upper bound on `diskSizeGb`; `neededBytes` wraps at 2^34 → precheck passes; huge `memoryMiB` retries forever | bound both at load |
| L21 (S7.7) | `fake_test.go:190-208` | fake accepts calls on a done ctx → `discardVolume`'s `WithoutCancel` is untested | make `enter()` refuse like `call()` |
| L22 (S7.8) | `fake_test.go:579-584` | fake `Close()` leaves the backend live → use-after-close passes in tests | mark dead on Close |
| L23 (S7.9) | `Makefile:60-75`, `.gitea/workflows/build.yaml:27-32` | nothing compiles `-tags=integration` | add `go vet -tags=integration ./...` to the `check` job |
| L24 (S7.10) | `image.go:228,283-289,385-395` | full image URL, including any userinfo, in logs and wrapped errors | use `urlHost`/redacted URL everywhere; reject userinfo at load |
| L25 (S7.11) | `kvm2_integration_test.go:89,307` | `KVM2_TEST_KEEP=0` enables keep; 100 ms wall-clock assertion on `New()` | parse the bool; drop or loosen the timing check |
| L26 (S7.12) | `config/manager/manager.yaml:163-178`, `seed.go:56-74` | cloud-init payloads staged on node-local disk; comment names kvm only | `medium: Memory`; fix the comment |
| L27 (E6 side) | go-libvirt `socket/socket.go:233-234` | packet length < 28 panics the listen goroutine and the operator; a huge length allocates up to 4 GiB → `host.Socket` pointed at a non-libvirt socket (reproduced) | upstream bounds check; until then validate the socket in Check before pooling |
| L28 (E6 side) | go-libvirt `rpc.go:76-84` | duplicate reply deadlocks `callback` under `cmux`, leaking 3 goroutines that survive a socket kill (measured +420/200 cycles); same mechanism keeps H2's leak alive | upstream: send outside the lock |
---
## Appendix A — PLAUSIBLE, with what would settle it
- **E2 — a padded or case-changed `<uid>` in the GC mark makes GC delete a live Proxy's VM.** Reproduced on a verbatim copy of the parser and the `gc.go` switch; there is no second guard before `Delete`. No trigger libvirt produces on its own was found (it re-indents between children only), so it needs a hand edit or a reformatting tool. The kvm provider has the same field, and the two live VMs are kvm v1. *Settle:* the `virsh metadata --set` round trip in `deep.md` on a scratch libvirt. *The fix is three `TrimSpace` lines and worth making regardless.*
- **E7 — in-place base replacement corrupts existing overlays.** The delete is confirmed (M13). Corruption needs a new QEMU process on an old overlay; the controller never does that, but `libvirt-guests` is enabled on beelink and restarts guests after a host reboot. *Settle:* `grep -Ev '^\s*(#|$)' /etc/default/libvirt-guests` on beelink, and the `review-e7-*` inode probe in `deep.md`.
- **Unexecuted links inside confirmed findings.** H4-3b: libvirt allows `vol-delete` during an upload (read in 11.6.0 source; the `probe-race`/`e4probe` scripts). M5: libvirt passes a short capacity through (~90%; `review-e10-short`). H1: the real path's send buffer is under ~2.2 MB (`ss -tmi` during an upload). E9a: duplicate volume → code 90 (~95%; `review-e9-dup`).
## Appendix B — REFUTED
- **E5** overlay left corrupt by an abandoned `StorageVolCreateXML`: libvirtd finishes or unlinks the build regardless of the client (~85%, source memory); creation is 2-11 ms on the host; the residual path fails loudly.
- **E3-1** GC reconciling one provider's list against another's Proxies: the live set is global and the list is namespace-filtered (observed on the host).
- **E3-2** re-type destroying *another* Proxy's instance: the ID always names the Proxy's own UID-derived domain (the remaining hole is M2).
- **E4 rollout/leader handover** deleting a base mid-upload: the lease is held until the old pod dies; SIGKILL at 10 s; the residual window is theoretical and self-healing.
- **E6b** goroutine/fd accumulation from per-round check dials: +0/+0 over 1000 cycles on three transports.
- **E9a** idempotency codes differing from the fake: 42, 49, 50, 80 observed on libvirt 11.6; 90 and 55 sourced at high confidence.
- **E2 (half)** an unparsable `createdAt` deleting a live VM: `gc.go:118` checks the UID first.
- **E7 (half)** marker-missing trigger without an admin edit: marker errors are returned, not treated as unreadable.
- **No XML injection**: three sweepers independently found every value goes through `encoding/xml`.
## Appendix C — Out of scope but noticed
- **Orphan volumes on beelink, pool `default`:** `proxy-7hjzuf3d3w2mjnye.qcow2` + `-seed.iso`, `proxy-qxge3dbm2mnpc3ih.qcow2` + `-seed.iso`, `proxy-sbs36ruo73sxxtqc.qcow2`. 196 KiB each, root-owned, no domain. Reported independently by four deep reviewers; they are M3 in the wild (likely from kvm v1 integration runs). Nothing was deleted.
- **The kvm2 integration test has not run on beelink** (plan execution Step 11 unchecked).
- **The kvm provider shares most of these defects:** untrimmed mark UID (E2), no mark check on Get/Delete and adoption of any same-named domain (`kvm/kvm.go:177-181`), short-overlay hole (`kvm/volume.go:24-32`), same base names.
- **No provisioning deadline in the controller:** any VM that boots but never reports an address stays Provisioning forever with no signal (what makes M5 silent).
- **`providerFailure` sets no condition for Transient errors** (`proxy_controller.go:344-345`), which is what makes all of T1 invisible.
- **Manager wiring:** no `ReconciliationTimeout`, no `LeaderElectionReleaseOnCancel`, default RollingUpdate with one replica, 128Mi memory limit (an OOM mid-upload is one way to H4's truncated base).
- **Host key naming:** `~/.ssh/kvm_known_hosts` pins beelink under its Tailscale FQDN only, while the sample config says `address: beelink:22`; an operator using that file would fail host key verification (seen from this workstation; not verified from inside the operator).
- **Dropped escalations not investigated:** D1 delete-during-open-stream disk-space effect, D2 cloud-init `text/plain` vendor-data dispatch, D3 ProviderHealth status churn, D4 nested `kvm-ssh` volumeMount under the read-only config mount, D5 whether error strings with image URLs reach Proxy status.
@@ -0,0 +1,265 @@
# kvm2 provider review: phase 2 deep dives
Ten `deep-reviewer` runs, one per escalation in `escalations.md`. Hypervisor access was
**read-only** (beelink, libvirt 11.6.0, QEMU 10.1.0); every probe that needs a write was left
unrun and is reproduced under "Probe to settle". Scratch programs live under the session
scratchpad, not in the repo. The orchestrator did not re-read code.
| ID | Verdict | One line |
|---|---|---|
| E1 | **CONFIRMED** (narrowed) | Blocking kill reproduced against x/crypto v0.54.0; pins all 3 reconcile workers ~15 min per incident, trigger is a bulk upload, not the name lock |
| E2 | **PLAUSIBLE** | Padded `<uid>` text does make GC delete a live VM (reproduced on a copy of the parser); no trigger libvirt produces on its own. `createdAt` half REFUTED |
| E3 | **mixed** | GC cross-provider: REFUTED. Re-type beyond S2.3: REFUTED. Shared `base-<key>.<format>` cache between kvm and kvm2: CONFIRMED |
| E4 | **CONFIRMED** (narrowed) | Rollout/leader-handover trigger REFUTED; the unfenced base is reachable in one process via the kvm provider or a second kvm2 entry on the same host+pool |
| E5 | **REFUTED** (~85%) | libvirtd finishes or unlinks a volume build regardless of client disconnect; overlay create is 2-11 ms on the host |
| E6 | (a) **CONFIRMED**, (b) **REFUTED** | Upstream race on `l.disconnected` is real; a peer-close right after ConnectOpen wedges a pooled link forever. No goroutine/fd growth over 1000 cycles |
| E7 | **PLAUSIBLE** | Unguarded in-place delete is real; corruption needs an admin `sha256` edit plus a host-side restart of an old overlay (`libvirt-guests` is enabled on beelink) |
| E8 | **CONFIRMED** | `available` is cached per pool in libvirtd (measured), and it is `f_bfree`, 24.7 GB above what QEMU can use on the kvm2 filesystem |
| E9 | (a) **REFUTED**, (b) **CONFIRMED** | Idempotency codes match libvirt 11.6 (observed 42/49/50/80). Codes 67 and the bare `ErrUnsupported` retry forever, per a recorded spec decision |
| E10 | **CONFIRMED** (libvirt half ~90%) | qemu-img accepts a short overlay (measured locally); guest sees a truncated disk, GPT rejected; Proxy stuck Provisioning forever |
Two facts the deep dives surfaced that correct earlier documents:
- **The kvm2 integration test has not run on beelink.** Step 11 is unchecked in `docs/plans-executions/2026-09-16-1333-kvm2-provider.md`; every `base-*` volume on the host is dated 2026-09-10 (kvm v1's test, same `test-image` key); both live `proxy-*` domains carry the kvm v1 metadata namespace. The "Checked" line of E2 in `escalations.md` (round trip asserted on libvirt 11.6) was a sweeper claim and is not yet true.
- **Sweep finding S5.3 is half wrong.** A zero `createdAt` does not endanger a live VM: `gc.go:118` tests `live[inst.UID]` before the age case, so it only affects true orphans. The future-dated-mark leak stands.
---
## E1 — wedged `markDead` starves the reconcile workers — CONFIRMED (narrowed)
**Premise reproduced** (`scratchpad/e1/main.go`, in-process SSH server behind a freezable TCP proxy, x/crypto v0.54.0). `sshSocket.Close` (`ssh.go:109-114`) calls `s.Conn.Close()` first: `channel.Close` (`channel.go:594`) → `sendMessage` → `writePacket` takes `ch.writeMu`, then `handshakeTransport.writePacket` (`handshake.go:579`) takes `t.mu`. A data writer stuck in a TCP write holds both. `ssh.Client.Close` is a bare TCP close (`connection.go:111`) and takes no locks.
| Scenario | Client send buffer | Writer blocked in | Channel `Close` |
|---|---|---|---|
| Bulk write, loopback default | 2.6 MB | `window.reserve` (no mutex) | 0 s |
| Bulk write, LAN-sized buffer | 256 K | `net.(*conn).Write` under `writeCipherPacket` | still blocked after 8 s |
| Small RPC-sized write only | 2.6 MB | not blocked | 0 s |
Precondition: the writer must be stuck in the TCP write, not SSH flow control (2 MiB window). That needs a send buffer below ~2 MiB, which the reviewer estimates (not measured) is the case on a LAN/Tailscale path during a bulk upload. In the blocked case a separate `client.Close()` released the channel close at once.
**Breaking path.**
1. Three Proxies placed on one kvm2 host with an uncached image. Worker 1 takes the name lock (`create.go:53`) and image lock (`image.go:87`), then `uploadImage` → `dialFresh` (`image.go:261`) → `StorageVolUpload` (`image.go:270`, 20 min budget). Workers 2 and 3 park in `imageLocks.acquire` (`kvm2.go:224-233`); the reconcile ctx has no deadline. `MaxConcurrentReconciles` is 3 (`proxy_controller.go:442`): all workers occupied.
2. Host is power-cut mid-upload. The upload goroutine blocks in the TCP write holding both mutexes.
3. The dedicated link's keepalive (`hosts.go:156-160`) cannot send (needs `t.mu`); after 15 s `hosts.go:158` calls `markDead`, whose `kill()` blocks in `s.Conn.Close()`. `close(l.closed)` (`backend.go:146`) is never reached.
4. Worker 1 sits in `call`'s select (`backend.go:176-187`): `done` cannot fire, `l.closed` is not closed, and a ctx cancel reaches `markDead` at `:180`, which blocks in `once.Do` behind the keepalive goroutine. Even cancellation is uninterruptible.
5. After ~15.5 min (`tcp_retries2=15`) or the 20 min budget everything unwinds Transient. Meanwhile no Proxy on any provider reconciles.
**Unknowns settled.** No `ReconciliationTimeout` is set (`cmd/main.go:288-295`; controller-runtime v0.24.1 applies one only when > 0), and one alone would not release worker 1. The name-lock part of the hypothesis is practically unreachable (controller-runtime never runs one key concurrently; Delete's RPCs are too small to fill the buffer). GC (`gc.go:104-130`, one goroutine, no deadline) normally moves on; it stalls only if the blocked kill is on the *pooled* link, whose only bulk write is the ~50 KiB seed upload (`create.go:210`): marginal. Of the five `markDead` sites, `hosts.go:153` runs under `e.sem` but only handshake bytes have been sent, so it does not block.
**Minimal fix.** (1) In `sshSocket.Close`, close the TCP connection first: `errors.Join(s.client.Close(), s.Conn.Close())`, filtering the expected "use of closed network connection", or drop `Conn.Close()`. (2) In `markDead` (`backend.go:138-148`), `close(l.closed)` before `l.kill()`, or run `kill` in a goroutine. (3) Regression test in `ssh_test.go`: frozen proxy, small client `SO_SNDBUF`, assert `Close` returns within 1 s while a writer is blocked. (4) Optional: set `ReconciliationTimeout` so image-lock waiters requeue.
**Probe to settle** (not run, needs a bulk upload in flight): `ss -tmi dst <beelink-ip>:22` on the operator host during an image upload; `tb` below ~2.2 MB means the blocking case applies.
## E2 — reformatted GC mark makes GC delete a live VM — PLAUSIBLE
Throwaway program (`scratchpad/e2/main.go`) with a verbatim copy of `instanceMetadata`, the same `time.Parse(time.RFC3339, …)` and the `gc.go:112-122` switch; clock 16 days after the mark, MinAge 10 m.
| Input | Parsed UID | zero time | GC |
|---|---|---|---|
| single line (as written) | clean | no | KEEP |
| libvirt's re-indent between children | clean | no | KEEP |
| `<uid> X </uid>` or newline-padded | padded | no | **DELETE** |
| uppercase UID | differs | no | **DELETE** |
| `<uid> </uid>` (whitespace only) | `" "` | no | **DELETE** |
| `<uid></uid>` | `""` | no | KEEP (guard `gc.go:113`) |
| bad/missing `createdAt`, UID clean | clean | yes | KEEP |
| padded `<managed>` | clean | no | KEEP (not listed) |
| future-dated, UID clean | clean | no | KEEP |
`encoding/xml` does not trim chardata. **Path:** padded or case-changed `<uid>` with `<managed>true</managed>` intact → `parseInstanceMetadata` (`metadata.go:66-78`) returns it untrimmed → `ListByTag` (`list.go:46-49`) emits it → `Sweeper.sweep` (`gc.go:113-121`): not empty, not live, older than MinAge → `prov.Delete` (`gc.go:125`) → `delete.go:21-53` destroys the domain and its disks. There is no second guard: no name-to-Proxy lookup, `Delete` does not re-read the mark, and provider scoping does not help because the owning provider lists it.
**Why only PLAUSIBLE.** On the real host libvirt puts newline + two spaces *between* children, strips the xmlns and leaves text bytes intact (`od -c` of the live `…/kvm` marks). Per libxml2, text-bearing elements are never re-indented, so a version change alone would not pad text. The reachable trigger is a hand edit or a tool that pads text nodes via `virsh edit`/`define`: rare, but the result is an unrecoverable delete. The old `internal/provider/kvm/metadata.go` has the same untrimmed field, and the two live VMs are kvm v1 domains.
**Refuted parts.** A bad `createdAt` never deletes a live VM (UID check comes first, consistent with `docs/provider-contract.md:238-241`). A future-dated mark is a leak only, and marks are stamped from the operator's clock (`create.go:127`), so host skew cannot cause it.
**Minimal fix.** At the end of `parseInstanceMetadata`: `TrimSpace` on `Managed`, `UID` (plus `ToLower`), `CreatedAt`. A whitespace-only UID then equals `""`, which `gc.go:113` keeps. Add table rows to `metadata_test.go`. Apply the same to `internal/provider/kvm/metadata.go`. Optional second guard in `gc.go`: skip and log when `provider.NameFromUID(inst.UID)` is not the name part of `inst.ID` (check the other providers' naming first).
**Probe to settle** (not run; use a scratch libvirt, not beelink):
```
virsh -c qemu:///system metadata <scratch-dom> https://gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/kvm2 --key kvm2 --set '<instance><managed>true</managed><uid> 3f1c2a9e-0000-4000-8000-000000000001 </uid><createdAt>2026-09-01T10:00:00Z</createdAt></instance>' --config
virsh -c qemu:///system metadata <scratch-dom> https://gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/kvm2 | od -c
```
## E3 — kvm and kvm2 on the same hypervisor — three paths, three verdicts
**1. GC reconciles provider A's list against provider B's Proxies — REFUTED.** `gc.go:91-101` builds one global `live` set from every Proxy; sharing it only makes GC more conservative. kvm2 `ListByTag` (`list.go:38-48`) is namespace-filtered, and that was checked on the host: both live domains return the mark under `…/kvm` and nothing under `…/kvm2`. What remains is leaks: after a re-type the old type's orphans are never collected, and the scoping still rests on libvirt behaviour no unit test covers (S7.4).
**2. Same UID under both types; reverse direction — REFUTED beyond S2.3.** `NameFromUID` depends only on the UID and `spec.provider` is CEL-immutable (`proxy_types.go:221`), so the same name under both types only comes from re-typing an entry. Nothing prevents that: `config.go:93-135` checks only name uniqueness and block/type agreement, there is no webhook (`cmd/main.go:262`), `status.providerID` records no type, and the controller never compares `inst.UID` with `p.UID`. What gets deleted is still that Proxy's own instance. Exceptions: a tampered `status.providerID` (kvm2 `Delete` checks no mark, so `hosts/beelink/domains/docker-29` would destroy an unrelated VM); the reverse direction has no guard at all (kvm `Create` adopts any same-named domain, `kvm/kvm.go:177-181`; its `Get`/`Delete` check no mark); and after a re-type kvm2 `ensureOverlay` boots a stale overlay the kvm provider left behind (`create.go:171-177`), one Proxy's own disk only.
**3. Shared `base-<key>.<format>` cache — CONFIRMED** (kvm2 code + libvirt 11.6.0 source, `storage_driver.c`). The marker protocols are compatible (both write `<base>.sha256` with 64 hex; both key URL images as `sha256(url)[:16]`), so kvm2 adopts a kvm-cached base. On the host every base and marker dates from 2026-09-10 (kvm-made), both live overlays back onto `/srv/vms/images/base-debian-13.qcow2`, and the sample `kvm2-home` entry's `debian-13` key would share exactly that volume. Both integration tests use key `test-image`.
- **3a Digest mismatch.** `kvm-home` cached `debian-13` from a `latest` URL (digest D1). The operator gives `kvm2-home` `sha256: D2`. First kvm2 Create: `cachedImage` `image.go:129` → `:145` deletes marker and base. libvirt's `storageVolDelete` refuses only on `in_use`/`building` (`storage_driver.c:1844-1856`), not on backing-store use. kvm2 uploads a different image to the same path under the kvm provider's live overlays. With both entries pinned to different digests, every Create on either side deletes and refetches.
- **3b Concurrent cold fetch** (by source, not executed). Image locks are per `Provider` instance (`kvm2.go:111`; `kvm/image.go:62-74`), so this applies kvm-vs-kvm2 and kvm2-vs-kvm2 alike. libvirt holds `in_use` only while the stream is set up (`storage_driver.c:2421-2427`), so the volume is unguarded for the whole transfer. Entry B finds base-without-marker and deletes it (`kvm2/image.go:117-118`; `kvm/image.go:103-108`), recreates it and starts its own upload. A's upload finishes cleanly into the unlinked inode, the digest matches, A writes the marker, and `StorageVolGetPath` resolves A's handle to B's partial file: A boots a guest from a half-written base. If B is kvm2, `writeMarker` then fails `ErrStorageVolExist` and `image.go:241-243` discards the now-complete base under A's running domain.
**Minimal fix.** Give kvm2 its own prefix (`volume.go:17` → `kvm2-base-…`, one extra ~415 MB download per host). Reject two kvm/kvm2 entries with the same address+socket+pool at load, or treat `ErrStorageVolExist` on the marker as success when its digest equals ours. Put the digest in the base name when `sha256` is set. Make `removeDomain` require `meta.managed()` and the expected UID; give `kvm/kvm.go:177` the same guard. Add a fake keyed on the metadata URI (S7.4).
**Probe to settle 3b** (not run):
```sh
truncate -s 4G /tmp/probe-race.bin
virsh -c qemu:///system vol-create-as default probe-race.raw 4G --format raw
virsh -c qemu:///system vol-upload --pool default probe-race.raw /tmp/probe-race.bin &
sleep 1; virsh -c qemu:///system vol-delete --pool default probe-race.raw; echo "delete exit=$?"
wait; echo "upload exit=$?"; virsh -c qemu:///system vol-list default | grep probe-race; rm /tmp/probe-race.bin
```
## E4 — no cross-process fencing on the base volume — CONFIRMED (narrowed)
**Rollout / leader handover: REFUTED for the shipped manifests.** `manager.yaml:64` passes `--leader-elect` (the `false` default at `cmd/main.go:107` applies only to a bare binary or `make run`). No strategy is set, so RollingUpdate with surge 1: old and new pods coexist, but the reconciler is a leader-election runnable, `LeaderElectionReleaseOnCancel` is not set (`cmd/main.go:288-295`), and the old pod holds the lease until it dies. Lease 15 s / renew 10 s / retry 2 s; the kubelet SIGKILLs at 10 s (`manager.yaml:180`). On cancel kvm2 stops the upload at once (`backend.go:179-181` → `markDead`); `discardVolume` runs uncancelled for up to 30 s (`image.go:331`) but is cut by SIGKILL. The residual two-process window needs A's delete RPC to stall ~7 s while B dials, HEADs and creates within ~5 s: theoretical, and self-healing (`image.go:100`, `:135`). Other two-process cases: `make run` without `--leader-elect` against the cluster's hypervisor; `make test-integration-kvm` and `-kvm2` at the same time (same `test-image` key).
**In one process: CONFIRMED.** The kvm provider is an always-present second writer: same names (`kvm/image.go:44-57` vs `kvm2/volume.go:17-25`), same default pool, same marker format, separate locks (`kvm/image.go:77-87` vs `Provider.imageLocks`; `newProvider` builds fresh `keyedLocks` per entry, `kvm2.go:111`, so two kvm2 entries do not share one either), concurrent via `MaxConcurrentReconciles: 3`, documented as supported (`docs/kvm2-provider.md:6-8`) and shipped that way (`config/manager/providers_config.yaml`: both entries on `beelink`, key `debian-13`, default pool). The interleaving is E3-3b. Extra consequence: after A's marker is written, if the process dies during B's upload (rollout SIGKILL, OOM at the 128Mi limit) the pool keeps a **truncated base under a valid marker**, and nothing re-hashes the base (`cachedImage` compares marker text only), so every later Create on that host backs onto it until a manual `vol-delete`.
Caveat: that `StorageVolDelete` succeeds mid-stream came from memory here; the E3 reviewer read it in the 11.6.0 source, which corroborates it. Still unexecuted.
**Minimal fix.** (1) Remove the shared name (kvm2-specific prefix; include the entry name or share one process-wide `keyedLocks`). (2) Make the base appear atomically: upload to `<base>.part-<random>`, verify, then create the final name; treat `ErrStorageVolExist` as "someone else won" and re-check; never delete a name this call did not create, in particular not `discardVolume(base)` on a marker `ErrStorageVolExist` (`image.go:241-243`). (3) Store the size next to the digest and check the base's allocation before trusting the marker. Set `strategy: Recreate` and `LeaderElectionReleaseOnCancel` deliberately.
**Probe to settle** (not run):
```
virsh -c qemu:///system vol-create-as default e4probe.raw 2G --format raw
mkfifo /tmp/e4.fifo; ( head -c 2G /dev/zero | pv -L 20m > /tmp/e4.fifo & )
virsh -c qemu:///system vol-upload --pool default e4probe.raw /tmp/e4.fifo &
sleep 5
virsh -c qemu:///system vol-delete --pool default e4probe.raw # succeeds => confirmed path; "still in use" => narrowed
virsh -c qemu:///system vol-create-as default e4probe.raw 2G --format raw
virsh -c qemu:///system pool-refresh default
wait; virsh -c qemu:///system vol-delete --pool default e4probe.raw; rm /tmp/e4.fifo
```
## E5 — overlay atomicity under an abandoned RPC — REFUTED (~85%, libvirtd side from source memory)
`storageVolCreateXML` rejects a duplicate name, adds the volume with `building = true`, bumps asyncjobs, drops the pool lock, runs `qemu-img create` via `virCommandRun`; on build failure the file is unlinked and the volume removed from the list. It runs on an RPC worker thread holding a client reference, so a disconnect does not cancel it; storage objects have no close callbacks. After a client drop a name is either still building or complete. Pool refresh refuses while asyncjobs > 0, and kvm2's XML has no `<allocation>` and passes `flags = 0`, so the preallocating path is never taken. The spec's "creation is atomic" (`kvm2-provider-spec.md:227-229`) holds.
**Cost on the host.** All six pools are `type='dir'` on local storage. Three leftover overlays show birth-to-mtime gaps of 2-11 ms and are each exactly 196768 bytes, complete. A create over 30 s needs a stalled filesystem.
**Residual path** (loud, never boots a corrupt overlay): Create #1 stalls > 30 s, Create #2 gets `ErrStorageVolExist` while `building` is set; if it reaches `DomainCreate`, QEMU cannot open the file (write lock, or "not in qcow2 format") → Transient → next retry starts cleanly. If `qemu-img` ends in EIO, libvirtd removes the volume and the defined domain points at a missing file; every retry fails until the Proxy is deleted (this is sweep finding S2.4's mechanism).
**Fix options.** None needed for the hypothesis. Minimal: correct the comment at `create.go:151-153` and the spec line to state the real reasons. Hardening: `deleteVolumeIfPresent` before `StorageVolCreateXML` in `ensureOverlay`, as the seed already does (no domain exists at that point under the name lock; cost 2-11 ms; a building volume returns `ErrOperationInvalid`, already Transient).
**Probe to settle** (not run, ~420 MiB in `default`):
```sh
C=qemu:///system; V=review-e5-clone.qcow2
virsh -c $C vol-clone --pool default base-debian-13.qcow2 $V & P=$!
sleep 0.3; kill -9 $P
for i in $(seq 1 40); do virsh -c $C vol-info --pool default $V 2>&1 | tr '\n' ' '; echo; stat -c '%s' /srv/vms/images/$V 2>&1; sleep 0.5; done
virsh -c $C vol-dumpxml --pool default $V
until virsh -c $C vol-delete --pool default $V 2>&1 | grep -qv 'still being allocated'; do sleep 1; done
virsh -c $C vol-list default | grep review-e5- || echo clean
```
## E6 — go-libvirt when the socket is killed under it — (a) CONFIRMED, (b) REFUTED
Scratch module `scratchpad/lvleak/` (same go-libvirt and x/crypto versions; copies kvm2's `link`, `call`, `connect`, `Close`, `sshSocket` in shape).
**(a) Data race on `Libvirt.disconnected`, reported by `-race`** over a real in-process SSH `direct-streamlocal` channel: read in `waitAndDisconnect` (`libvirt.go:821`, goroutine started at `:297`), write in `ConnectToURI` (`:305`), unsynchronised. Interleaving: ConnectOpen reply is routed → socket dies → `listen` closes `s.disconnected` (`socket.go:208`) → `waitAndDisconnect` reads the old channel and returns (`:828`) → the requester installs a fresh channel at `:305` that nothing will ever close, and `Connect()` returns nil.
- **Hypothesised trigger (kvm2's own `markDead`): harmless.** `call` has already taken the timer/ctx branch; the nil result lands in the buffered `done` and is dropped; `hosts.go:153` discards the backend. Measured 5, 1 and 33 times per 1000 cycles, no effect.
- **Peer-close trigger: real defect.** If libvirtd or the forwarded socket goes away within ~1 ms of a successful open (e.g. a daemon restart), `Connect` wins, the watcher (`backend.go:229-236`) holds a channel that never closes, every later RPC fails at `SendPacket` with `syscall.EINVAL` (`socket.go:321-325`), which `errors.go:55` classifies Transient. No call result marks the link dead, `hostTable.get` (`hosts.go:75`) keeps returning it, and the SSH keepalive keeps succeeding if sshd is alive. The host answers "invalid argument" until the operator restarts. Wedged in 953/1000 (unix), 824/1000 (`net.Pipe`), 546/1000 (SSH), 186/500 (SSH, `-race`) when the peer closes right after the open reply. On the Check path the same loser only costs a 5.001 s `Close()`.
**(b) No accumulation.** Six scenarios (peer never answers then kill; RPC hangs; ConnectClose hangs into the close budget; peer closes mid-handshake; clean probe; kill racing the end of the handshake) x three transports, 1000 cycles each (500 under `-race`): goroutines +0, fds +0. Peak in flight 26.
**Side findings (need a misbehaving peer).** (i) `callback` does an unbuffered `c <- res` holding `cmux` (`rpc.go:76-84`): a duplicate reply deadlocks `listen`, leaks three goroutines per hit (+420 over 200 cycles), and a socket kill does not help. On the stream path serials do repeat, which is why the S4.1 leak also survives a kill. (ii) `socket.go:233-234` computes `int(length) - 28` and `make`s it: a length field under 28 panics "makeslice: len out of range" in the listen goroutine and takes the operator down (reproduced); a large bogus length allocates up to 4 GiB. Reachable if `host.Socket` points at something that is not libvirt.
**Minimal fix.** In `call` (`backend.go:177-178`): on `done`, if the error is `syscall.EINVAL` or `libvirt.ErrInterrupted`, `markDead` before returning; add a `backend_test.go` case where the peer closes right after the open reply. Sturdier: give `libvirt.New` a `net.Conn` wrapper whose `Read` calls `link.markDead` on any non-temporary error, dropping the dependence on `Disconnected()` and the 5 s Close stall. Upstream: assign `l.disconnected` before `go l.waitAndDisconnect()`; send outside the lock in `callback`; reject `length < 28` and oversized lengths.
## E7 — in-place base replacement under live overlays — PLAUSIBLE
**Confirmed part.** Admin sets or changes `images.<key>.sha256` and restarts the manager (config is not hot-reloaded). Next Create: `image.go:129` sees the mismatch, `:145` deletes marker and base with no in-use check and a `V(1)` log; `fetchImage` creates a new file at the same path. `TestEnsureImage_sha256ChangeRedownloads` (`image_test.go:394`) asserts exactly this. A mistyped `sha256` is worse: the base is deleted first, then `image.go:289` returns Permanent, leaving every old overlay with a missing backing file (loud). libvirt does not refuse (~95% from the reviewer; the E3 reviewer read it in source).
**Why corruption is only plausible.** Running VMs keep the old inode, and `on_reboot=restart` (`xml.go:217`) keeps the same QEMU process. Corruption needs a *new* QEMU process on an old overlay:
- Controller: safe. Stopped → Delete (`proxy_controller.go:216-225`) removes the overlay (`delete.go:91`).
- **`libvirt-guests` on host reboot.** Enabled on beelink (`multi-user.target.wants/libvirt-guests.service`); `/etc/default/libvirt-guests` could not be read. Stock `ON_BOOT=start` restarts every guest that was running at shutdown regardless of autostart; the reviewer is ~70% sure the distro default is also `ON_SHUTDOWN=suspend`. While the host is down `Get` is Transient, not Stopped, so nothing is deleted; after boot the operator normally sees Running and keeps the VM.
- A manual `virsh start`.
- Create adoption path (`create.go:73-87` calls `ensureRunning` without `ensureImage`): only when `providerID` is empty and the overlay already holds guest writes. Narrow.
The missing/unreadable-marker trigger is **not** reachable within kvm2 without an edit (marker errors are returned, not treated as unreadable, `image.go:119-125`). The second deleter that does exist is the kvm provider (E3/E4). Host state: both live domains running, persistent, autostart disabled, no managed save; the hazard is documented (`kvm2-provider-spec.md:91-93`, `kvm2-provider.md:313-318`), but the code contradicts the "add a new key" rule instead of enforcing it, and the guide's warning names `url` although a `url` change alone never refetches.
**Minimal fix.** At `image.go:129-130` do not delete; return `permanentf("base %s is cached with sha256 %s but %s is configured; add the image under a new key, …")`. Keep deletion for the no-marker and non-hex branches. Alternative: digest in the volume name. Either way log at `V(0)`.
**Probe to settle** (not run):
```
P=default; V="virsh -c qemu:///system"
$V vol-create-as $P review-e7-base.qcow2 16M --format qcow2
$V vol-create-as $P review-e7-overlay.qcow2 16M --format qcow2 --backing-vol review-e7-base.qcow2 --backing-vol-format qcow2
stat -c '%i' /srv/vms/images/review-e7-base.qcow2
$V vol-delete --pool $P review-e7-base.qcow2 # expected: succeeds
$V vol-create-as $P review-e7-base.qcow2 16M --format qcow2
stat -c '%i' /srv/vms/images/review-e7-base.qcow2 # expected: different inode
$V vol-delete --pool $P review-e7-overlay.qcow2; $V vol-delete --pool $P review-e7-base.qcow2
```
Read-only, for the owner: `grep -Ev '^\s*(#|$)' /etc/default/libvirt-guests` (empty output = defaults apply).
## E8 — pool-space precheck freshness — CONFIRMED, and staleness is the smaller of two errors
Measured 2026-09-17 08:28-08:30 UTC, read-only. kvm2's pool is `default` at `/srv/vms/images`.
| pool | filesystem | libvirt Available | `f_bfree` | `f_bavail` | libvirt − bfree | libvirt − bavail | vols |
|---|---|---|---|---|---|---|---|
| default (kvm2) | ubuntu--vg-vms--lv | 266,038,706,176 | 266,038,378,496 | 241,304,276,992 | +327,680 | **+24,734,429,184** | 24 |
| pool | same | 267,718,541,312 | 266,038,378,496 | 241,304,276,992 | +1,680,162,816 | +26,414,264,320 | 15 |
| vms | same | 267,718,541,312 | 266,038,378,496 | 241,304,276,992 | +1,680,162,816 | +26,414,264,320 | 9 |
| boot / images | ubuntu--lv | 73,877,696,512 | 73,918,029,824 | 68,532,543,488 | −40,333,312 | +5,345,153,024 | 0 / 2 |
| vms-2 | nvme1n1p1 | 820,622,606,336 | 820,602,261,504 | 770,575,294,464 | +20,344,832 | +50,047,311,872 | 2 |
- **Cached per pool.** Three pools on one filesystem report two different values; a call-time value could not differ. Pool A's allocation is invisible to pool B. Over 80 s `f_bfree` moved on the `images` filesystem while libvirt stayed frozen, 40 MB stale in the pessimistic direction, so the cache errs both ways. `default` is nearly exact only because libvirt touched it ~1.6 h earlier.
- **The root reserve.** libvirt reports `f_bfree`, which includes ext4's 5% reserve. Overlays are owned by `libvirt-qemu:kvm`, so guest writes stop at `f_bavail = 0` while libvirt still reports ≥ 24.7 GB.
**Failing path.** `create.go:92-100`: pool lookup → `imageSizeIfUncached` → `checkPoolSpace`, no refresh; `image.go:450-458` compares raw `available`; the only refresh is `image.go:235` after an upload, which never runs again once the base is cached. Overlays start at ~196 KB and grow invisibly (live ones at 1.47 and 1.54 GB of 10 GiB), as does `kube-node-33.qcow2` (50.6 GB) in the same directory. Reserve scenario (deterministic here): with `f_bavail` between 0 and ~10.7 GB the precheck passes for a 10 GiB disk, `ErrQuotaExceeded` never fires, and Create, the upload and the guests hit ENOSPC, the condition behind S4.1's wedge and S2.2's paused guests. `check.go:106-112` has the same defect: `poolAvailableGiB` over-reports by ~23 GiB plus drift.
**Minimal fix.** (1) `StoragePoolRefresh` immediately before `checkPoolSpace`, after `imageSizeIfUncached`; failure is Transient; only on the domain-not-defined branch. Cost: readdir + stat per volume (24 today), already paid at `image.go:235`. (2) Do not refresh in Check (a mutation every round on a shared host); document the semantics. (3) Add headroom to `neededBytes`: per-host `poolReserveGiB` or a fraction of `capacity` (already returned and discarded at `image.go:450`); or document `tune2fs -m 0`. (4) Make the fake's `StoragePoolGetInfo` return a cached value that only `StoragePoolRefresh` updates; add `TestCreate_refreshesPoolBeforeSpaceCheck`.
**Probe to settle** (for the owner): `virsh pool-info --bytes vms`, `virsh pool-refresh vms`, `pool-info` again; expect Available to drop ~1.68 GB and still sit ~24.7 GB above `df -B1 --output=avail /srv/vms`.
## E9 — libvirt error-code assumptions — (a) REFUTED, (b) CONFIRMED
**(a) Idempotency codes match libvirt 11.6.** Codes identified from libvirt's fixed per-code message prefix with `LIBVIRT_DEBUG=1`.
| Operation | kvm2 expects | Real | Evidence |
|---|---|---|---|
| `DomainLookupByName`, missing (`create.go:88`, `delete.go:57`, `get.go:34`) | 42 | 42 | observed |
| `StorageVolLookupByName`, missing (`image.go:117,134,176,424`) | 50 | 50 | observed |
| `StoragePoolLookupByName`, missing (`delete.go:84`) | 49 | 49 | observed |
| `DomainGetMetadata`, no kvm2 element (`get.go:81`) | 80 | 80 | observed on `docker-29`, `postgres`, and a kvm-v1-marked proxy domain; namespace scoping holds |
| `StorageVolCreateXML`, duplicate (`create.go:171`) | 90 | 90 | sourced, ~95% |
| `DomainDestroy` inactive (`delete.go:67`) | 55 | 55 | sourced, high |
| `DomainCreate` active (`create.go:236`) | 55 | 55 | sourced, high; `waitRunning` re-checks anyway |
| Destroy/Undefine vanished domain | 42 | 42 | sourced, high |
| `StorageVolDelete` vanished (`image.go:182`) | 50 | 50 | sourced, high |
`TestKVM2_lifecycle` would exercise 42 and 50 but not 90 (the second Create returns from the adoption branch before `ensureOverlay`), not 55 from Destroy, and a wrong 80 would not fail it (`list.go:39-45` logs and continues). Two fake-vs-real differences, neither breaking convergence: an overlay file unknown to libvirt returns 55 not 90; an inactive pool returns 55 from the marker lookup in `imageSizeIfUncached` (`create.go:96`) before `checkPoolSpace` (`create.go:100`), so the Permanent arm at `image.go:454-456` is unreachable through Create (`image_test.go:821-828` tests it by direct call).
**(b) Classification.** `classifyLibvirt` (`errors.go:58-71`) sends every unlisted code to Transient; `providerFailure` (`proxy_controller.go:344-345`) returns the error and sets **no condition**, so the Proxy shows nothing and retries under workqueue backoff, rerunning `writeSeed` each time when the failure is at define.
| Rejection | Where | Code | Evidence |
|---|---|---|---|
| Host has no usable KVM | define | 67 | sourced, high |
| No OVMF firmware | define | 9 | sourced, ~85% |
| Bridge missing | `DomainCreate` | 38 | sourced, high; `check.go` never looks at the bridge |
| host-passthrough unsupported | `DomainCreate` | 67 | sourced |
| Non-x86 host | define | 67 or 1 | observed 67 on the `domcapabilities --arch aarch64` path |
| Unknown machine type | define | 8 (already Permanent) | observed |
beelink rejects none of these. The bare `libvirt.ErrUnsupported` sentinel (`rpc.go:392-394`) bypasses `libvirtCode`; small impact. This follows a recorded decision: spec entry 13 (`kvm2-provider-spec.md:317`) "unknown means retry", and contract §5 makes Permanent sticky until the generation changes, so a host-side fix would not unlatch. The spec never looked at 67, and it already latches `ErrNoStoragePool`, which is also host-fixed.
**Minimal fix.** `ErrConfigUnsupported` → Permanent when `op == "create"`; `errors.Is(err, libvirt.ErrUnsupported)` → Permanent before the `libvirtCode` check; leave 9, 38, 1, 84 alone. Give the Transient arm of `providerFailure` a non-latching `Provisioned=False` condition with the message, or have the health checker read `ConnectGetDomainCapabilities` and check the bridge. In the integration test: destroy+undefine over `dialFresh` then Create again (exercises 90), destroy before step 9's Delete (exercises 55), assert "skipping domain with unreadable metadata" never logs.
**Probe to settle** (not run):
```
virsh -c qemu:///system vol-create-as default review-e9-dup.qcow2 1M --format qcow2 && \
LIBVIRT_DEBUG=1 virsh -c qemu:///system vol-create-as default review-e9-dup.qcow2 1M --format qcow2 2>&1 | grep ' error : ' ; \
virsh -c qemu:///system vol-delete --pool default review-e9-dup.qcow2
```
## E10 — overlay capacity below the base's virtual size — CONFIRMED (libvirt pass-through ~90%, unexecuted)
**Host numbers.** `base-debian-13.qcow2` and `base-test-image.qcow2`: libvirt capacity 3221225472 (3 GiB virtual), allocation 435683328. Live overlays: 10 GiB over that base. So the 10 GiB default has ~7 GiB margin; only `diskSizeGb: 1` or `2` breaks Debian 13 (Ubuntu ~3.5 GiB and Fedora ~5 GiB from memory). libvirt reports the true virtual size because of the refresh at `image.go:235`.
**Local experiment** (qemu-img 10.2.1; host has 10.1.0): `qemu-img create -f qcow2 -b <64M base> -F qcow2 short.qcow2 16384K` exits 0, virtual size 16 MiB, `qemu-img check` clean; reads inside 16 MiB match the base, a read at 16M fails `Input/output error`. A 64M GPT base under a 16M overlay: `sfdisk` sees only the protective MBR, `sgdisk -v` reports the disk smaller than the header says and the backup header in error. The Linux kernel rejects such a GPT, so there is no root device; the guest would load GRUB and the kernel then drop to initramfs (inferred, no VM booted). libvirt's `virStorageBackendCreateQemuImgCmdFromVol` appends the capacity with no size comparison (from source memory).
**Failing path.** `diskSizeGb: 2` passes validation (`config_kvm.go:133` rejects only negatives; `kvm2.go:127-128` changes only 0) → `checkPoolSpace` with the small number (`create.go:100`) → base cached (`:103`) → `ensureOverlay` sends `diskSizeGB << 30` (`create.go:107,158`; `volume.go:72` checks only positive) → domain defined and started, `waitRunning` sees Running, Create succeeds (`:148,260`) → `Get`: active, no address → `StateProvisioning` (`state.go:20-21`) → requeue at `ProvisioningPoll` forever (`proxy_controller.go:208-211`; there is no provisioning deadline). Correction to the hypothesis: the Proxy is stuck Provisioning with "waiting for the instance to run", not reported healthy. Nothing points at `diskSizeGb`, and raising it later does not repair that Proxy (`create.go:75-87`, `:171-177`). The kvm provider has the same hole (`kvm/volume.go:24-32`).
**Minimal fix.** Add `StorageVolGetInfo` to `backend` (libvirtBackend, `observed`, fake already stores `fakeVol.capacity` at `fake_test.go:494`). Between `ensureImage` and `ensureOverlay`, if the base's capacity exceeds `DiskSizeGB << 30` return `permanentf("kvm2.diskSizeGb %d is smaller than image %s's virtual size …")`. One or two RPCs on the first Create only. Run the same check on a reused overlay and recreate it when short. `TestCreate_diskSmallerThanImage`. One sentence in the `diskSizeGb` doc rows. A config-time check is not possible.
**Probe to settle** (not run; one ~200 KiB volume, self-cleaning):
```bash
V=review-e10-short.qcow2; P=default
virsh -c qemu:///system vol-create-as $P $V 1G --format qcow2 \
--backing-vol /srv/vms/images/base-test-image.qcow2 --backing-vol-format qcow2; echo "create exit=$?"
virsh -c qemu:///system vol-dumpxml --pool $P $V | grep -E "capacity|backingStore|<path>"
virsh -c qemu:///system vol-delete --pool $P $V; echo "delete exit=$?"
virsh -c qemu:///system vol-list $P | grep review-e10- || echo "clean"
```
@@ -0,0 +1,102 @@
# kvm2 provider review: escalations for phase 2
18 raw Escalate items from the seven sweepers, merged to 15 root hypotheses, top 10 kept.
Ranked by blast radius x uncertainty. `deep-reviewer` is static (Read, Grep, Glob, Bash; it
can read the Go module cache and run local tests, but not touch the hypervisor), so each item
says whether it can be **settled statically** or is likely to end PLAUSIBLE without a probe on
beelink. Finding IDs (`S…`) refer to `sweep.md`.
| # | Hypothesis in one line | Blast | Uncertainty | Static? |
|---|---|---|---|---|
| E1 | One wedged `markDead` starves every reconcile worker, not just one host | operator-wide stall | medium | yes |
| E2 | A reformatted GC mark makes GC delete a live Proxy's VM | live VM destroyed | medium | mostly |
| E3 | kvm and kvm2 on one hypervisor: cross-provider destruction beyond the Get/Delete hole | other provider's VMs/disks | medium | yes |
| E4 | No cross-process fencing on the base volume: leader handover deletes a base mid-upload | corrupt base under new overlays | medium | yes |
| E5 | An abandoned `StorageVolCreateXML` leaves a corrupt overlay that the next Create adopts | silent guest disk corruption | high | partly |
| E6 | go-libvirt leaks or races when the socket is killed mid-connect; per-round check dials accumulate | slow leak in long-running leader | medium | yes |
| E7 | In-place base replacement corrupts existing overlays on their next boot | guest disk corruption, many VMs | low-medium | partly |
| E8 | Pool-space precheck reads a stale `available`; quota backoff never engages | host pool fills | medium | partly |
| E9 | libvirt error-code assumptions: idempotency codes and define-time rejection codes | retry loops that never converge | medium | partly |
| E10 | Overlay capacity below the base's virtual size is accepted and truncates the disk | silent guest corruption | high | no |
---
## E1 — A wedged `markDead` blocks name locks and starves the reconcile workers
Sources: chunk 1 Escalate 2; builds on finding S1.1.
**Entry points:** `internal/provider/kvm2/kvm2.go:186-243` (`lockName`, `keyedLocks`), `create.go`, `delete.go`, `backend.go:138-188`, `hosts.go:66-95`, `internal/gc/gc.go`, `internal/controller/proxy_controller.go` (+ `SetupWithManager`, `MaxConcurrentReconciles`), `cmd/main.go` manager wiring.
**Hypothesis:** a goroutine blocked inside `markDead` (S1.1) holds the per-name lock taken by Create/Delete, and reconcile and GC contexts carry no deadline, so `keyedLocks.acquire`'s ctx escape never fires. Delete/GC for that name block indefinitely instead of requeueing, and with a small `MaxConcurrentReconciles` a single silent host can pin every worker, stalling all Proxies on all providers.
**Checked:** lock acquisition is ctx-aware and refcounted correctly; only `internal/providerhealth/monitor.go:180` sets a deadline; the health checker and image upload use `dialFresh` and are insulated; `markDead` at `hosts.go:153` runs under `e.sem`.
**Unknown:** whether the controller-runtime work queue tolerates a permanently blocked Reconcile for one key (worker starvation across all Proxies); the actual `MaxConcurrentReconciles` and any reconcile timeout in the manager wiring; whether GC's single goroutine is blocked the same way; how many distinct call sites can enter `markDead` while holding `e.sem` or a name lock.
## E2 — GC mark parse fragility: a reformatted mark makes GC delete a live VM
Sources: chunk 3 Escalate 1; finding S5.3.
**Entry points:** `internal/provider/kvm2/metadata.go:44-78` (`createdAt`, `parseInstanceMetadata`), `list.go:49`, `get.go:79-88`, `internal/gc/gc.go:113-121`.
**Hypothesis:** if libvirt hands `<uid>` or `<createdAt>` back with surrounding whitespace or re-indented content (another libvirt version, or an admin's `virsh edit`/`virsh define` through a reformatting tool), `UID` stops matching `live[inst.UID]` (exact string compare, `gc.go:118`) and/or `createdAt()` returns the zero time, which makes the instance immediately past `MinAge`. GC then deletes a live Proxy's VM. No trim or tolerant parse exists as a backstop.
**Checked:** `newInstanceMetadata` writes a single-line element; `kvm2_integration_test.go:422-449` asserts the UID round-trips and `CreatedAt` is non-zero (sweeper's claim that this ran against real libvirt 11.6 was wrong: phase 2 found the kvm2 integration test has not yet run on beelink, see `deep.md`); libxml2 does not re-indent an element whose children have text content.
**Unknown:** behaviour on other libvirt versions and after `virsh edit`; whether `encoding/xml` unmarshal plus the current struct tags already tolerate whitespace; whether a UID mismatch alone (timestamp intact) is enough for deletion, and what GC does with `UID == ""`; whether GC has any second guard (name-to-Proxy lookup) before `Delete`.
## E3 — kvm and kvm2 on the same hypervisor
Sources: chunk 3 Escalate 2, chunk 7 Escalate 4; builds on findings S2.3, S7.1, S7.4.
**Entry points:** `internal/provider/kvm2/list.go:20-56`, `get.go:52-88`, `delete.go:55-98`, `volume.go:14-18`, `internal/provider/kvm/kvm.go:379`, `internal/provider/kvm/id.go:11-13`, `internal/provider/kvm/volume.go:12`, `internal/provider/kvm/image.go:46`, `internal/gc/gc.go:86-131`, `internal/provider/config.go:108-130`, `internal/controller/proxy_controller.go:197,237,287`, `docs/kvm2-provider.md:346-370`.
**Hypothesis:** the only thing keeping kvm2's `ListByTag` from handing kvm-made domains to GC is libvirt's namespace-scoped `DomainGetMetadata`; both providers also share `<name>.qcow2`, `<name>-seed.iso`, `base-<key>.<format>` names and the providerID format. Beyond the confirmed Get/Delete hole (S2.3), GC's per-provider scoping may let provider A's list be reconciled against provider B's Proxies, and a Proxy moved between a kvm and a kvm2 entry (same UID, same domain name) may have one side delete volumes the other side's domain uses.
**Checked:** domain names are UID-derived so one Proxy cannot exist under both at once; the two metadata URIs differ; `ListByTag` is mark-filtered; `spec.provider` is immutable but an entry's `name` is independent of its `type`; the fake cannot prove the namespace rule (S7.4).
**Unknown:** whether GC builds its live-UID set per provider or globally, and what it deletes when the same UID is visible through two entries; whether anything (config reload guard, admission check on ProviderID, deploy tooling) prevents a re-typed entry from inheriting live `status.ProviderID`s; the reverse direction (kvm v1 `Get`/`Delete`/`ListByTag` against kvm2-made domains).
## E4 — No cross-process fencing on the base volume
Source: chunk 4 Escalate A.
**Entry points:** `internal/provider/kvm2/image.go:85-146` (`ensureImage`, `lockImage`, `cachedImage`), `image.go:200-300` (`fetchImage`, `uploadImage`), `kvm2.go:199-243`, `cmd/main.go:107` (leader election flags), `config/manager/manager.yaml:24`.
**Hypothesis:** process A is inside `fetchImage` (base created, marker not yet written); process B's `cachedImage` sees base-without-marker and deletes it (`image.go:118`), then its `StorageVolCreateXML` either fails `ErrStorageVolExist` or succeeds against a half-uploaded file. A's stream writes into an unlinked inode and A writes a marker for a base that no longer exists. An overlay created in between backs onto a deleted or wrong-content path.
**Checked:** `keyedLocks` is process-local; `replicas: 1`; `--leader-elect` defaults to false; `Delete` never touches base volumes; nothing else in the repo names base or marker volumes.
**Unknown:** whether a leader-election handover or a rolling update (old pod still inside a 20 minute `fetchImage` while the new pod reconciles the same Proxy) reaches this with the shipped manifests; whether the manager cancels in-flight reconciles on lost lease and how long `terminationGracePeriodSeconds: 10` leaves the old process uploading; whether B's marker written over A's bytes can ever validate.
## E5 — Overlay atomicity under an abandoned RPC
Source: chunk 2 Escalate 1.
**Entry points:** `internal/provider/kvm2/create.go:151-181` (`ensureOverlay`), `backend.go:157-188` (`call`, budget, `markDead`), `backend.go:319-321` (`StorageVolCreateXML` under `budgets.rpc` = 30 s).
**Hypothesis:** if `StorageVolCreateXML` for the overlay exceeds the 30 s budget or the caller's ctx ends, `call` kills the SSH transport mid-RPC. If libvirtd still registers the volume, possibly partial, the next Create hits `ErrStorageVolExist`, logs "overlay volume already exists" and defines a domain over a corrupt overlay. The base image has a marker and the seed is rebuilt unconditionally for exactly this reason; the overlay has neither.
**Checked:** the reuse branch distinguishes only `ErrStorageVolExist`; no size, format or backing-path verification anywhere; `Delete` is the only thing that removes an overlay.
**Unknown:** libvirtd's storage-driver behaviour when the client drops mid `virStorageVolCreateXML` (unlink vs leave registered); whether overlay creation can exceed 30 s in normal operation (NFS, LVM, preallocating filesystems) rather than only on cancellation; whether a cheap verification (`StorageVolGetInfo` capacity, or delete-and-recreate when no domain references it) is available through the existing `backend` seam.
## E6 — go-libvirt internals when the socket is killed under it
Sources: chunk 1 Escalate 1, chunk 6 Escalate 1.
**Entry points:** `internal/provider/kvm2/backend.go:225-238` (`libvirtBackend.connect`, the `Disconnected()` watcher), `backend.go:351-367` (`Close`), `hosts.go:91-162` (`dialFresh`, `connect`), `check.go:85-119`; upstream `go-libvirt@v0.0.0-20260814190004-1a83157e1858`: `libvirt.go:286-305` (`ConnectToURI`), `:821/:834` (`waitAndDisconnect`), `socket/`.
**Hypothesis:** (a) when `call(…, "Connect", …)` expires and `markDead` closes the socket just as `ConnectToURI` reaches `l.disconnected = make(chan struct{})`, there is a data race on that field against `waitAndDisconnect`, and the loser leaves a `Libvirt` whose `Disconnected()` never fires; (b) `Check` opens a fresh connection per host per round (~1440/day/host), so any goroutine or fd go-libvirt leaves behind on the killed-socket path (abandoned Connect, or `Close` hitting its 5 s budget mid `ConnectClose`) accumulates linearly in a long-running leader.
**Checked:** kvm2's own goroutines are clean (watcher selects on `link.closed`, keepalive exits on `stop`, `sshSocket.Close` is once-guarded); `Disconnected()` is read only after a successful Connect and the backend is discarded on failure; `go test -race -count=2` of the unit tests is clean.
**Unknown:** whether `waitAndDisconnect`/`listenAndRespond` always terminate when the conn is closed mid-handshake; whether the socket layer leaves an fd; whether a looped dial-and-kill test (N=1000, sampling `runtime.NumGoroutine()`) against the in-process SSH test server shows growth. Note finding S4.1 already confirms one permanent go-libvirt goroutine leak on the stream path; this asks whether the connect/close path has another.
## E7 — In-place base-image replacement under overlays that pin its path
Source: chunk 2 Escalate 2; the code behaviour is finding S4.6 (three sweepers).
**Entry points:** `internal/provider/kvm2/create.go:103,154-165`, `image.go:113-146` (`cachedImage`), `image.go:235` (`StoragePoolRefresh`), `docs/provider-contract.md` ("Catalogue changes do not replace instances").
**Hypothesis:** when `images.<key>.sha256` changes (or the marker is missing or unreadable), the base is deleted and different content is downloaded to the same deterministic path while every earlier overlay records that path as its qcow2 backing file. Any such overlay that is later restarted (adoption path, host reboot, `ensureRunning` on a shutoff domain) reads mismatched backing data: guest filesystem corruption across every VM on that key.
**Checked:** `Delete` never removes the base; the path is stable per key+format; `BackingPath` is resolved once at define time and never re-verified; the contract explicitly allows catalogue edits not to replace instances.
**Unknown:** which restart paths actually exist for an existing overlay in this operator (does anything restart a shutoff domain rather than delete and recreate it, given Stopped → Delete in the controller?); whether the missing/unreadable-marker trigger can fire without any admin edit (e.g. after E4 or a failed `writeMarker`), which would make this reachable in normal operation; whether libvirt refuses `StorageVolDelete` on a volume in use as a backing store.
## E8 — Pool-space precheck freshness
Source: chunk 4 Escalate B.
**Entry points:** `internal/provider/kvm2/image.go:419-467` (`imageSizeIfUncached`, `checkPoolSpace`, `neededBytes`), `create.go:96-106`, `check.go:85-119` (also reads pool info).
**Hypothesis:** for a `dir` pool, `StoragePoolGetInfo.available` is what libvirtd computed at the last pool refresh, adjusted only for creates and deletes it performed; guest writes growing sparse overlays do not update it. `create.go` never issues `StoragePoolRefresh` before the check (the only refresh is after an upload, `image.go:235`), so on a host its VMs have filled, the precheck reports tens of GB free indefinitely, `ErrQuotaExceeded`/`QuotaRetry` never engages, and Create fails later with an ENOSPC-flavoured transient, which is also the case that wedges the upload stream (S4.1) and pauses guests (S2.2).
**Checked:** no refresh anywhere on the pre-create path; `available` is used raw; unit tests set the fake's value directly; the spec describes the precheck but says nothing about freshness.
**Unknown:** libvirt 11.x dir-pool semantics for `available` (statvfs at call time vs cached); whether `Check`'s `poolAvailableGiB` has the same staleness; the cost of a per-Create refresh on the shared pooled connection.
## E9 — libvirt error-code assumptions
Sources: chunk 5 Escalate 2, chunk 7 Escalate 2; findings S3.5, S5.2.
**Entry points:** `internal/provider/kvm2/errors.go:33-97` (`classify`, `classifyLibvirt`, `libvirtCode`, `hasCode`), `create.go:139-142,166-177`, `get.go:79-88`, `delete.go:55-98`, `image.go:174-186`, `fake_test.go:491-493`, `kvm2_integration_test.go`.
**Hypothesis:** two halves of one assumption. (a) Idempotency depends on exact codes: `ensureOverlay` needs `ErrStorageVolExist` for a duplicate volume on a dir pool, `readMetadata` needs `ErrNoDomainMetadata`, the delete paths need `ErrNoDomain`/`ErrNoStorageVol`. The fake returns exactly the codes the code expects, so unit tests cannot refute a mismatch; if real libvirt returns `ErrOperationFailed` or `ErrInternalError` for any of them, a retried Create after a partial failure never converges. (b) Every define-time rejection of the generated XML (arch, machine, firmware feature, host-passthrough) surfaces as `ErrConfigUnsupported` (67) or `ErrOperationUnsupported` (84), which default to Transient: an unbounded retry with no `ReasonPermanentError`.
**Checked:** only `ErrNoStoragePool`, `ErrXMLError`, `ErrNoSupport`, `ErrInvalidArg`, `ErrAuthFailed`, `ErrAccessDenied` map to Permanent; 67 and 84 exist in go-libvirt's const set and are unlisted; bare `libvirt.ErrUnsupported` bypasses `libvirtCode`; transient errors requeue forever.
**Unknown:** which idempotency codes the integration test actually exercises against libvirt 11.6 (a second Create on an existing overlay? Delete twice? Get on a foreign domain?), which settles (a) for those paths; for (b), which code libvirt really returns for each rejection, and whether keeping unknown codes Transient is a deliberate spec decision. Mapping too much to Permanent latches Proxies that would recover.
## E10 — Overlay capacity smaller than the backing image's virtual size
Source: chunk 5 Escalate 1; finding S5.6.
**Entry points:** `internal/provider/kvm2/volume.go:68-89` (`buildVolumeXML`), `create.go:107,154-181`, `image.go:449-467`, `internal/provider/config_kvm.go:133` (`diskSizeGb` validation).
**Hypothesis:** with `kvm2.diskSizeGb` below the base image's virtual size, libvirt runs `qemu-img create -b <base> … <capacity>` and succeeds, producing a truncated virtual disk whose partition table overruns the device. The VM fails to boot or boots with a corrupt root filesystem, and kvm2 reports a healthy running domain that never answers.
**Checked:** nothing in the package compares overlay capacity to the base's virtual size; the validator rejects only `diskSizeGb < 0`; the 10 GiB default exceeds current Debian/Ubuntu/Fedora cloud images.
**Unknown:** whether libvirt's storage backend or qemu-img errors on a short capacity (then this is only a poor error message). Needs a real qemu-img; the deep reviewer can at most establish whether kvm2 has any way to learn the base's virtual size through the existing seam (`StorageVolGetInfo` on the base) and what a guard would cost. **Expect PLAUSIBLE.**
---
## Dropped (not in the top 10)
- **D1** Deleting a volume while a libvirt upload stream is open on it (chunk 4 C): a consequence of confirmed finding S4.1, only changes whether its disk-space effect is real; needs a truncated-source probe on beelink.
- **D2** cloud-init dispatch of a `text/plain` part inside vendor-data (chunk 5 E3): invisible-failure risk but only a boot test on a real image settles it; nothing static to add beyond S5.8.
- **D3** ProviderHealth status churn every round waking watchers (chunk 6 E2): low blast radius, arguably intended per the code comment.
- **D4** Nested volumeMount, `kvm-ssh` Secret inside the read-only `providers-config` mount (chunk 7 E1): pre-existing and shared with kvm; refuted if kvm has ever run in-cluster with the Secret present, otherwise one `kubectl apply` settles it.
- **D5** Whether provider error strings (with the full image URL, S7.10) reach Proxy status conditions (chunk 7 E3): low blast radius; cheap and fully static if you want it added.
@@ -0,0 +1,133 @@
# kvm2 provider review: scope
Branch `feat/kvm2-provider` at `2510842`. Seed: `grep -ril 'kvm2\|libvirt'` excluding vendor,
generated, testdata, docs. The minikube layout hints in the brief do not apply; this repo is
the egress-proxies operator and kvm2 is `internal/provider/kvm2/`, a pure-Go provider
(`digitalocean/go-libvirt` RPC over an SSH-tunnelled socket, no cgo, no driver plugin binary).
Checklist items with no counterpart here, so reviewers can answer them quickly rather than hunt:
- libvirt **network** lifecycle / DHCP / static IP: kvm2 creates no networks; it attaches to a
configured bridge and reads the address via `DomainInterfaceAddresses`. The live questions are
IP detection, timeouts and stale addresses.
- GPU/PCI/NUMA passthrough: none.
- driver plugin RPC boundary: none. Closest analogue is the libvirt RPC link over SSH
(`link`, `call`, budgets, dead-link detection, libvirt version differences).
- cgo/build tags: no cgo. Only tag is `//go:build integration`.
- Handle leaks: go-libvirt handles are plain values (no `Free`); the leakable resources are
SSH connections, the libvirt link, goroutines (keepalive, `await`, `call`), HTTP bodies,
temp dirs and half-written storage volumes.
## CORE (kvm2-owned)
`internal/provider/kvm2/` — 18 source files (~3.2k lines), 22 test files (~7.3k lines).
| File | Lines | What |
|---|---|---|
| kvm2.go | 243 | `Provider`, `New`, options, `withDefaults`, `host`, `wrapErr`, `lockName`, `keyedLocks` |
| ssh.go | 216 | `dialStage`/`dialError`, `loadCredentials`, `sshSocket` + keepalive, `dialSSHSocket`, `await` |
| hosts.go | 162 | `dialer`, `hostTable` (per-host cached backend, `get`/`dialFresh`), `libvirtDialer` |
| backend.go | 369 | `backend` interface, `budgets`, `link` (dead marking), `call`/`run`, `libvirtBackend` |
| observe.go | 263 | `observed` backend decorator: span + log per libvirt call |
| create.go | 280 | `Create`, `create`, `ensureOverlay`, `writeSeed`, `ensureRunning`, `waitRunning` |
| delete.go | 98 | `Delete`, `removeDomain`, `removeVolumes` |
| get.go | 119 | `Get`, `get`, `readMetadata`, `address` |
| list.go | 56 | `ListByTag` |
| state.go | 49 | `mapState`, `isActive`, `firstIPv4` |
| id.go | 28 | `formatProviderID`, `parseProviderID` |
| metadata.go | 78 | GC mark in domain `<metadata>`: marshal/parse, `managed`, `createdAt` |
| errors.go | 97 | `permanentf`/`transientf`, `classify`, `classifyLibvirt`, `libvirtCode`, `hasCode` |
| image.go | 467 | `resolveImage`, `lockImage`, `ensureImage`, `cachedImage`, marker read/write, `fetchImage`, `uploadImage`, `do`, `checkResponse`, pool space checks |
| volume.go | 95 | volume naming, `urlImageKey`, `buildVolumeXML` |
| xml.go | 256 | `domainParams`, `buildDomainXML` and the XML structs |
| seed.go | 124 | `buildSeedISO` (temp dir, iso9660), `buildVendorData` |
| check.go | 189 | `Check`, `checkHost`, `probeHost`, `checkReason`, formatting helpers |
Tests (each reviewed with its source file): `*_test.go` next to every file above, plus
`provider_test.go` (contract tests), `fake_test.go` (fake backend), `sshserver_test.go`
(in-process SSH server), `kvm2_integration_test.go` (`//go:build integration`, `TestKVM2*`).
Other kvm2-owned bits:
- `cmd/main.go:62,206` — import and `"kvm2": kvm2.New` registration
- `Makefile:73-75` — `test-integration-kvm2`
- `config/samples/proxy_kvm2.yaml`, `config/samples/kustomization.yaml:8`
- `config/samples/providers-config.yaml:58-`, `config/manager/providers_config.yaml:54-` (commented kvm2 entries)
Reference only, not under review: `docs/kvm2-provider-spec.md`, `docs/kvm2-provider.md`,
`docs/provider-contract.md`. Reviewers may read them to judge code against stated intent.
## SHARED (on the kvm2 call path; listed symbols only)
Called by kvm2:
- `internal/provider/config_kvm.go` — `KVMConfig`, `KVMImage`, `KVMMachineType`, `KVMHost`,
`KVMFormat*`/`KVMFirmware*` consts, `IsHTTPURL`, `validateKVM`, `validateKVMBlock`
(the validator is what stands between user YAML and kvm2's XML/URL/volume-name building)
- `internal/provider/config.go` — `ProviderConfig.KVM2`, `setBlocks`, the `kvm2` arms of `Config.validate`
- `internal/provider/provider.go` — `Provider` interface, `CreateRequest`, `Placement`, `Instance`,
`InstanceState` + `State*` consts, `LabelManagedYes`
- `internal/provider/errors.go` — `Error`, `Wrap`, `Class`, `ErrTransient`/`ErrPermanent`/`ErrNotFound`/`ErrQuotaExceeded`
- `internal/provider/check.go` — `Checker`, `TargetStatus`, `Reason*` consts
- `internal/provider/name.go` — `NameFromUID` (produces the domain name kvm2 puts in XML and volume names)
- `internal/tracing` — `StartSpan`, `Tracer`, `Option`
Calling into kvm2:
- `internal/controller/proxy_controller.go` — `reconcileManaged` (Create/Get/Delete calls at
156/197/218), `replaceInstance`, `reconcileDelete`, `providerFailure`, `deletionFailure`,
`resolveCloudInit`, `placementFrom`
- `internal/gc/gc.go` — `Sweeper.sweep` (`ListByTag` at 105, `Delete` at 125)
- `internal/providerhealth/monitor.go` — `Monitor.checkOnce`, `Monitor.run` (`Check` at 214; the timeout it passes)
- `internal/provider/registry/registry.go` — `Constructor`, `Build`
- `internal/provider/metrics.go`, `internal/provider/tracing.go` — `WithMetrics`/`WithTracing`
decorators (`instrumented`, `traced`): error passthrough, and that they hide `Checker`
- `cmd/main.go:196-225` — registry build, checker extraction from unwrapped providers, wrapping
- `config/manager/manager.yaml:150-178` — `kvm-ssh` Secret mount, `/tmp` emptyDir (64Mi) that `buildSeedISO` uses
## EXCLUDED but considered
- `internal/provider/kvm/` — the older kvm provider; kvm2 is a clean-room rewrite and imports nothing from it.
- `internal/provider/config_kvm_test.go`, `config_test.go` — reviewed only for kvm2 cases, inside chunk 7; rest is kvm.
- `internal/provider/gcp/`, `internal/provider/kubernetes/` — other providers, not on a kvm2 path.
- `internal/health`, `internal/discovery`, `internal/lease` — consume `Proxy` status, never call the provider.
- `internal/metrics`, `internal/tracing` internals — only the three entry points above are on the path.
- `internal/providerhealth/envtest_test.go` — mentions kvm2 only as a type string in a fixture.
- `api/v1alpha1/providerhealth_types.go:71`, `config/crd/bases/...providerhealths.yaml` — comment-only / generated.
- `go.mod`, `go.sum` — dependency pins (`go-libvirt` 20260814 pseudo-version, `iso9660` v0.4.0, `x/crypto` v0.54.0); noted, not reviewed. Third-party source may be read to understand a call, not reported on.
- `config/samples/proxy_kvm.yaml`, `Makefile:69-71,185-196` (`test-integration-kvm`, `kvm-ssh-secret`) — kvm-only or shared tooling with no kvm2 logic. `kvm-ssh-secret` is mentioned in chunk 7 for context only.
- `README.md`, `CHANGELOG.md`, `AGENTS.md`, `.claude/settings.json`, `docs/` — prose / tooling.
- `.gitea/workflows/build.yaml`, `Dockerfile` — no kvm2-specific content; integration test is not run in CI (worth stating in chunk 7, not a file to review).
## Proposed phase 1 chunking (7 chunks)
1. **Connection layer (SSH + libvirt link)**
Files: `ssh.go`, `hosts.go`, `backend.go`, + `ssh_test.go`, `sshserver_test.go`, `hosts_test.go`, `backend_test.go`.
SHARED in play: `KVMConfig` (SSHKeyFile, KnownHostsFile, InsecureIgnoreHostKey), `KVMHost`.
Focus: connection/goroutine leaks on error paths, dead-link detection and redial races in `hostTable`, budgets vs caller context, keepalive, host key handling.
2. **Domain lifecycle (Create / Delete)**
Files: `kvm2.go`, `create.go`, `delete.go`, `id.go`, + `kvm2_test.go`, `create_test.go`, `delete_test.go`, `id_test.go`, `provider_test.go`.
SHARED in play: `CreateRequest`, `Placement`, `NameFromUID`; `reconcileManaged`, `replaceInstance`, `reconcileDelete` (how Create/Delete are retried).
Focus: partial-create cleanup, idempotency, foreign-domain refusal, half-deleted domains, `keyedLocks` correctness, `waitRunning` timeout/cancellation.
3. **Observation, state and error classification (Get / ListByTag)**
Files: `get.go`, `list.go`, `state.go`, `errors.go`, + `get_test.go`, `list_test.go`, `state_test.go`, `errors_test.go`.
SHARED in play: `Instance`, `InstanceState`, `Error`/`Wrap`/`Class` and the `Err*` classes; `Sweeper.sweep`, `providerFailure`, `deletionFailure`.
Focus: libvirt state mapping (paused/crashed/pmsuspended/shutoff), IP detection and stale addresses, NotFound vs transient, one dead host failing the whole list and what GC does with a partial list.
4. **Base image cache and volumes**
Files: `image.go`, `volume.go`, + `image_test.go`, `volume_test.go`.
SHARED in play: `KVMImage`, `KVMFormat*`, `IsHTTPURL`, `validateKVMBlock` (image key charset).
Focus: download/upload streaming, sha256 verify + marker protocol, cross-replica and cross-Create races on the same base volume, cleanup of partial volumes, URL handling (SSRF, redirects, size caps), pool space check, volume-name injection via URL/key.
5. **XML and cloud-init seed templating**
Files: `xml.go`, `seed.go`, `metadata.go`, (`buildVolumeXML` in `volume.go`), + `xml_test.go`, `seed_test.go`, `metadata_test.go`.
SHARED in play: `KVMMachineType`, `KVMHost` (bridge, pool), `KVMFirmware*`, `CreateRequest.CloudInit`, `NameFromUID`; `resolveCloudInit`; `manager.yaml` `/tmp` emptyDir.
Focus: escaping of config- and user-supplied values, validity across libvirt versions, metadata parse robustness (GC trusts it), temp-dir cleanup and size limit, vendor-data merging.
6. **Health check and observability**
Files: `check.go`, `observe.go`, + `check_test.go`, `observe_test.go`.
SHARED in play: `Checker`, `TargetStatus`, `Reason*`; `Monitor.checkOnce`/`run`; `tracing.StartSpan`/`Tracer`; `WithMetrics`/`WithTracing` decorators; `cmd/main.go:213-225`.
Focus: dedicated check connections closed on every path, check timeout vs dial budget, reason mapping, secrets or cloud-init leaking into logs/spans, span end on every path.
7. **Config, registration, deployment and test gating**
Files: `internal/provider/config.go` + `config_kvm.go` (kvm2 arms), their tests' kvm2 cases, `internal/provider/registry/registry.go`, `cmd/main.go:196-225`, `config/manager/manager.yaml:150-178`, `config/manager/providers_config.yaml`, `config/samples/providers-config.yaml`, `config/samples/proxy_kvm2.yaml`, `Makefile:73-75`, `kvm2_integration_test.go`, `fake_test.go`.
SHARED in play: `validateKVMBlock`, `setBlocks`, `Config.validate`, `registry.Build`, `Constructor`.
Focus: validation gaps that let bad values reach chunks 4/5, `New` behaviour with a nil block or unreadable key, Secret mount perms, integration test gating/cleanup and whether the fake backend diverges from real libvirt in ways that hide bugs.
@@ -0,0 +1,216 @@
# kvm2 provider review: phase 1 sweep
Seven `sweep-reviewer` runs, one per chunk in `scope.md`, against `feat/kvm2-provider` at `2510842`.
Findings below are as the sweepers reported them (they state each was verified in code); the
orchestrator merged duplicates and ranked, and did not re-read code. 54 raw findings, 49 after
merging. IDs are `S<chunk>.<n>`; "also: chunk N" marks a finding reported independently by
another sweeper.
Severity counts after dedupe: **high 4, medium 17, low 28**.
Recurring themes across chunks:
- **T1 Unknown or unmapped errors default to Transient, so permanent misconfigurations retry forever**
(S1.4, S2.5, S3.7, S5.2, S7.2, S7.6b) while one genuinely transient case is made Permanent and latches (S5.1, S4.2).
- **T2 Ownership mark enforced on Create and ListByTag but not on Get/Delete** (S2.3), and the fake cannot test the namespace rule (S7.4).
- **T3 Base image cache identity and lifetime**: keyed by key+format only, deleted in place under live overlays, no cross-process fencing (S4.6, S7.1, S4.8, escalations E4/E7).
- **T4 The shared pooled link**: any caller's cancel or budget expiry kills it for everyone, and several callers misread the resulting "connection is closed" error (S1.1, S3.1, S3.6, S4.3).
## Top of the list (high)
| ID | Where | Defect |
|---|---|---|
| S1.1 | `backend.go:138-148`, `ssh.go:109-114` | `markDead` closes the transport before signalling death; the close can block ~15 min on a silent host, wedging callers and the host's dial semaphore |
| S4.1 | `image.go:273` | A reader or server-side error during `StorageVolUpload` wedges go-libvirt's stream sender forever: goroutine + 4 MiB leak per failed upload, 20 min stall |
| S3.1 | `get.go:104-119` | `address()` swallows every non-ctx error, so a dead link or a guest-agent restart demotes a healthy Running instance to Provisioning and wipes `status.IP` |
| S7.1 | `volume.go:17`, `image.go:113-146` | Base image cache is keyed by key+format, never URL: two provider entries on one host+pool (the shipped sample does this) silently share one image; a URL edit is a silent no-op |
---
## Chunk 1 — Connection layer (`ssh.go`, `hosts.go`, `backend.go`)
**S1.1 — high — `internal/provider/kvm2/backend.go:138-148` (kill at 143-145, `close(l.closed)` at 146), `ssh.go:109-114`**
`link.markDead` runs `l.kill()` synchronously inside `once.Do` and only then closes `l.closed`. `kill` is `sshSocket.Close`, which calls `s.Conn.Close()` (x/crypto `chanConn` → `channel.Close` → a raw TCP write under `writeMu`/`t.mu`) before `s.client.Close()`. That write blocks while the send buffer is full.
Scenario: hypervisor is power-cut or partitioned (no RST/FIN, normal over Tailscale) with data queued. A pooled call hits its 30 s/90 s budget, `call` (`backend.go:183`) invokes `markDead`, which blocks until the kernel gives up (`tcp_retries2`, ~15 min). During that window: the abandoning caller never returns (reconcile ctx has no deadline); `l.closed` is still open so `isDead` reports the backend live and `hostTable.get` keeps handing it out, each new call wedging behind the first in `once.Do`; `sshSocket.closeOnce` memoises the in-progress close so `libvirtBackend.Close`'s 5 s budget cannot bound it; when the blocked `markDead` is the one at `hosts.go:153` it runs while `hostTable.get` holds `e.sem`, blocking every caller for that host.
Fix direction: close `s.client` (plain `net.Conn.Close`) first and/or close `l.closed` before calling `kill`.
**S1.2 — medium — `ssh.go:119-147`**
`sshSocket.keepalive`, the only detector of a silently dead pooled connection between calls, has no test. `testSSHServer` already services global requests, so it is testable end to end. A regression is invisible to CI and shows up only as a stale "live" connection that burns a full 30 s budget. Violates the repo rule that all new Go code has tests.
**S1.3 — low — `ssh.go:120`**
`time.NewTicker(interval)` panics for `interval <= 0`, in a goroutine, which exits the operator. Only `newLibvirtDialer` sets the field today; a future struct literal without it compiles and panics on first successful dial. No guard at either end.
**S1.4 — low — `errors.go:45-51` (dial path from `ssh.go:87`)** [T1]
`classify` recognises only `*knownhosts.KeyError`. A key matched by an `@revoked` line yields `*knownhosts.RevokedError`, which falls through to Transient: a deliberately revoked hypervisor key is retried forever instead of failing Permanent.
**S1.5 — low — `ssh.go:167-172`**
`ClientConfig.HostKeyAlgorithms` is left empty. If `known_hosts` pins only the RSA key of a host that also serves ed25519, the server offers ed25519, the callback returns a `KeyError` with non-empty `Want`, and `classify` maps it to Permanent "host key mismatch" although the pinned entry is genuine.
Verified clean: all dial error paths close the TCP conn and SSH client; `call`/`await` orphan goroutines write into buffered channels; the never-closed pooled table is not a leak (Provider built once, no hot reload).
## Chunk 2 — Domain lifecycle (`kvm2.go`, `create.go`, `delete.go`, `id.go`)
**S2.1 — medium — `create.go:107-114`**
Per-VM volumes created before `DomainDefineXML` are unreachable by both `Delete` and orphan GC when `Create` never returns an ID.
Scenario: `ensureOverlay` and `writeSeed` succeed, `DomainDefineXML` (139) fails Permanent (or the manager dies between). `Create` returns `("", err)`; `reconcileManaged` never sets `status.ProviderID` and latches. On Proxy deletion, `reconcileDelete` takes the `ProviderID == ""` shortcut (`proxy_controller.go:270-274`) without calling `Delete`. `ListByTag` enumerates domains only, so `proxy-<x>.qcow2` (10 GiB capacity) and `proxy-<x>-seed.iso` stay in the pool permanently.
**S2.2 — medium — `create.go:230-231`, `state.go:16-27`, `get.go:56`** (also: chunk 3)
Paused domains are destroyed. `ensureRunning` refuses a paused/pmsuspended domain with a Permanent "virsh resume it" error, but that guard only fires before an ID is persisted. In steady state the reconciler calls `Get`; `get.go:56` discards the `DomainGetState` reason and `mapState` maps paused/pmsuspended to `StateStopped`, which sends `reconcileManaged` (`proxy_controller.go:217-225`) to `prov.Delete`: destroy, undefine, delete disks, recreate.
Scenario: QEMU pauses the guest on an I/O error because the pool is full (`VIR_DOMAIN_PAUSED_IOERROR`), or an admin runs `virsh suspend`/`save`/snapshot/live-migrate. At the next drift poll the VM is killed; in the ENOSPC case the recreate refills the pool or fails the space precheck.
**S2.3 — medium — `delete.go:56-63`, `get.go:52-73`** (also: chunk 3) [T2]
`Get` and `Delete` do not enforce the kvm2 mark that `Create` (`create.go:80-82`) enforces. `readMetadata` returns the zero value for an unmarked domain and `get` builds an Instance anyway (`UID: ""`); `removeDomain`/`removeVolumes` destroy whatever the name resolves to. `internal/provider/kvm/id.go:11-13` emits byte-identical `hosts/<host>/domains/<name>` IDs and both providers use `NameFromUID`.
Scenario (the documented migration path, `docs/kvm2-provider.md:346-370`, done out of order): an admin re-types an entry from `kvm` to `kvm2` keeping its name without first deleting every Proxy. `status.ProviderID` survives; kvm v1's mark is in another namespace, so `Get` returns the v1 domain as this Proxy's instance; if it is anything but Running the controller calls `Delete` and kvm2 destroys a kvm-v1 domain the docs promise it can never touch. Same exposure for a hand-edited `status.ProviderID`. `TestGet_unmanagedDomainHasEmptyUID` pins the current behaviour. `ListByTag` is correctly mark-filtered, so GC is not affected.
**S2.4 — low — `create.go:87`**
The adoption path starts an already-defined domain without checking its disks exist and cannot heal them. If the overlay or seed is gone and no ID was persisted, every retry skips the volume steps, `DomainCreate` fails with a storage error classified Transient, and the reconciler retries forever; the drift path that would heal it is unreachable without an ID.
**S2.5 — low — `delete.go:15`** [T1]
`undefineFlags` omits `DomainUndefineCheckpointsMetadata`. An admin- or backup-tool-created checkpoint makes `DomainUndefineFlags` fail Transient; `deletionFailure` retries forever and the Proxy cannot be deleted without manual `virsh checkpoint-delete`.
Verified clean: `keyedLocks` refcounting, cancel-while-waiting and unlock on every path; `waitRunning` cap and cancellation; providerID round trip.
## Chunk 3 — Observation, state, errors (`get.go`, `list.go`, `state.go`, `errors.go`)
**S3.1 — high — `get.go:104-119`** [T4]
`address()` treats every non-`context` error from both address sources as "skip" and returns `("", nil)`.
Triggers: (a) routine — on beelink the ARP source never answers (`docs/kvm2-provider-spec.md:146`), so a `qemu-guest-agent` restart in the guest returns `ErrAgentUnresponsive` → `""` → `mapState(running, "")` = Provisioning; (b) racy — any concurrent call that exceeds its budget kills the shared pooled link (`backend.go:180-184`), after which `call` returns `transientf("... connection is closed")` with no ctx sentinel, which `get.go:108` does not recognise; if the link dies between `DomainGetState` and the address read, `Get` succeeds with `State=Provisioning, IP=""`.
Downstream: `proxy_controller.go:208-211` clears `status.IP` and sets `Provisioned=False`; `internal/health/engine.go:200-207` prunes the proxy's health state and the Healthy counters restart from zero. One RPC hiccup pulls a working proxy out of rotation. `TestGet_addressErrorIsProvisioning` pins the behaviour.
**S3.2 — medium — `state.go:35-49`**
IP detection has no interface selection. The domain has exactly one bridge NIC and libvirt returns `Hwaddr` per interface, but `firstIPv4` filters only loopback/link-local/unspecified.
Scenario: the image or cloud-init brings up `docker0` (172.17.0.1), `tailscale0` or `wg0` before the DHCP lease lands and the agent reports it first. `Get` returns Running with a non-routable IP, the controller latches `Provisioned=True`, and the value is never corrected because any IPv4 keeps the state Running.
**S3.3 — medium — `list.go:38-45`** [T4]
A pooled link that dies mid-list makes `ListByTag` a silently successful partial list. The fail-whole guard is `ctx.Err() != nil`, but a killed link yields "connection is closed" with no ctx sentinel; the remaining domains each log "skipping domain with unreadable metadata" and the call returns nil error with "managed=5" as if authoritative. GC only under-deletes, so no wrong deletion, but nothing marks the result partial. A Permanent corrupt-mark parse error is likewise skipped forever with only a V(1) line.
**S3.4 — low — `get.go:104-119`**
`address()` also swallows `ErrNoDomain`: a domain that vanishes between `DomainGetState` and the address read is reported as live Provisioning for one poll. The comment "only ctx ending is an error" is also inaccurate: a 30 s budget expiry on the agent source wraps `DeadlineExceeded`, fails `Get`, and never falls back to ARP.
**S3.5 — low — `errors.go:58-71`** [T1] (also: chunks 5, 7)
Version and capability errors escape classification. go-libvirt turns an "unknown procedure" reply into the bare sentinel `libvirt.ErrUnsupported` (not a `libvirt.Error`), so `libvirtCode` misses it and it becomes Transient, while the same condition as `VIR_ERR_NO_SUPPORT` is Permanent. `ErrConfigUnsupported` (67) and `ErrOperationUnsupported` (84) are also unmapped. `Check` records `libvirtVersion` but gates nothing on a minimum. See S5.2 and escalation E9.
## Chunk 4 — Base image cache and volumes (`image.go`, `volume.go`)
**S4.1 — high — `image.go:273`**
`uploadImage` hands a `TeeReader` over `resp.Body` straight to `StorageVolUpload`. In go-libvirt (`rpc.go:290-308`, `socket/socket.go:347-376`) `abort` and `outErr` are unbuffered: on a reader error `SendStream` sends a `StatusError` packet then blocks forever on `outErr <-`, and `requestStream` blocks forever on `abort <- true`. `StorageVolUpload` never returns.
Scenario: mirror truncates the body (or libvirtd errors mid-upload, e.g. ENOSPC). kvm2's `call` rescues the caller only after `budgets.transfer` = 20 min, and `markDead`/socket close does not unblock either goroutine (contrary to the comment at `backend.go:152-156`). Consequences: 1-2 goroutines + 4 MiB buffer leaked per failed upload, accumulating under reconcile retries against a flaky mirror; a truncated download reported after 20 min instead of at once; `discardVolume` (`image.go:292`) deletes the base on the pooled connection before the deferred `fresh.Close()` (`image.go:265`), so the file is unlinked while the server-side stream holds the fd. `TestEnsureImage_shortReadRemovesVolumeAndIsTransient` gives false confidence: the fake returns the reader error immediately and never exercises the abort protocol.
Fix direction: have `countingReader` convert errors to `io.EOF` and report them out of band; the existing `readErr`/`read != size` checks still catch truncation.
**S4.2 — medium — `image.go:434-439`, `391-396`** [T1]
A `HEAD` that answers 2xx with `Content-Length: 0` or none (Go's own `net/http` does this; several CDNs too) becomes `ErrPermanent`, which latches `ReasonPermanentError` for the generation. The GET fallback fires only on non-2xx HEAD, although GET on the same URL would work. The tests never hit it because `serveBytes` sets Content-Length on HEAD.
**S4.3 — medium — `image.go:235-245`** [T4]
A completed upload is discarded when the pooled connection died during it. The base is uploaded on a dedicated connection, but `StoragePoolRefresh` and `writeMarker` then run on the pooled `be`, which any other reconcile's cancel can kill. On failure `discardVolume` deletes the fully downloaded, digest-verified base and the next Create re-downloads. The window is as wide as the download. The dedicated connection is still open at that point and could carry the refresh and marker.
**S4.4 — medium — `image.go:419-443`, `226-230`, `create.go:96-106`**
The pool-space precheck uses the **HEAD** Content-Length; `fetchImage` declares the volume capacity from the **GET** Content-Length and never re-checks. There is no `maxImageBytes` cap.
Scenario: a server answers HEAD with 1 MB and GET with 4 TiB; the precheck passes, a sparse 4 TiB qcow2 is created and the stream fills the host pool until ENOSPC, degrading every VM on the hypervisor. Benign variant: a mirror rotates a build between HEAD and GET.
**S4.5 — medium — `image.go:59-65`**
`placement.image` is an unvalidated user-supplied URL fetched by the operator. `PlacementSpec.Image` has no CRD validation; `IsHTTPURL` checks only scheme and non-empty host; `http.DefaultClient` follows redirects with no policy. Anyone who can create a Proxy in a watched namespace makes the operator issue HEAD/GET from its network position (cluster services, `169.254.169.254`) and makes the hypervisor boot the returned bytes on the egress bridge. URL images can never carry a `sha256`.
**S4.6 — medium — `image.go:118`, `image.go:145`** (also: chunks 2, 7) [T3]
Any Create can evict a base volume that live overlays back onto, with no in-use check and only a V(1) log. `cachedImage` runs on every Create and deletes the base when the marker is missing, unreadable, non-hex, or when `src.SHA256` differs, then `fetchImage` recreates the same path with different content.
Scenario: an admin adds or updates `images.<key>.sha256` while Proxies exist and the mirror has republished. Running qemu processes keep the old inode; every later boot of an existing overlay reads a different base. The spec records this as a doc warning ("add a new key instead"); the kvm sample carries the warning (`config/samples/providers-config.yaml:40`) but the kvm2 block (`:72`) omits it. See escalation E7.
**S4.7 — low — `image.go:62`**
A URL image is always assumed qcow2/uefi. A raw image given as `placement.image` is cached as `base-<key>.qcow2` with `BackingFormat: "qcow2"`; qemu rejects the chain or the VM fails to boot, and the diagnostic points at the domain.
**S4.8 — low — `volume.go:22-25`** [T3]
`urlImageKey` truncates sha256 to 64 bits and shares a namespace with catalogue keys. Two colliding URLs share one base (a ~2^32 birthday search for someone who can create Proxies), and URL images have no digest to catch it. A catalogue key that is 16 lowercase hex chars occupies the same `base-<key>.<format>` name as a URL image.
Verified clean: HTTP bodies and the dedicated upload connection are closed on every path; `cappedWriter` and `countingReader` are correct as written.
## Chunk 5 — XML and cloud-init seed (`xml.go`, `seed.go`, `metadata.go`, `buildVolumeXML`)
**No injection defect found.** Every config- and user-influenced value reaching domain, volume or metadata XML goes through `encoding/xml` struct marshalling; there is no string concatenation into markup; `Name` is `NameFromUID`'s base32 hash. Chunks 2 and 7 reached the same conclusion independently.
**S5.1 — medium — `create.go:199-201`, `seed.go:66-74`** [T1, inverted]
Every I/O failure inside `buildSeedISO` is turned into `ErrPermanent` by `permanentf("building seed ISO for %s: %v", ...)`: `os.MkdirTemp` when `/tmp` is missing, ENOSPC during `AddFile`/`WriteTo`, and via the deferred `errors.Join` even a `Cleanup()`/`RemoveAll` failure on an otherwise successful build. `providerFailure` then returns `ctrl.Result{}` with nil error: no requeue at all until a spec edit or the ~10 h resync. The `%v` also discards the wrapped error. The empty-name guard is correctly permanent.
**S5.2 — medium — `xml.go:26-27`, `194-206`** (also: chunk 7) [T1]
`x86_64`, `q35`, `type="kvm"`, `host-passthrough`, `<os firmware="efi">` (libvirt ≥ 5.2.0) and `<firmware><feature name="secure-boot">` (≥ 7.2.0) are unconditional with no capability or version gate, and no config field or validator mentions architecture.
Scenario: an aarch64 hypervisor or a libvirt 7.0 host is added to `kvm2.hosts`; `Check` reports Ready; every Create there fails at `DomainDefineXML` with code 67 or 84, which `classifyLibvirt` leaves Transient, so the reconciler retries an impossible define indefinitely with no actionable condition.
**S5.3 — low — `metadata.go:44-50`** (see escalation E2)
An unparsable `<createdAt>` returns `time.Time{}`; `gc.go:120` computes `now.Sub(CreatedAt) < MinAge`, so a mangled mark is immediately past the grace period, exactly the case MinAge protects. Mirror case: a mark written under a forward-skewed clock yields a negative age that is always `< MinAge`, so the domain is never collected. Only the first direction is mentioned in the doc comment.
**S5.4 — low — `seed.go:48-53`**
The guest-agent script's non-systemd fallback runs `rc-update` unguarded (the kvm provider guards it with `elif command -v rc-update`). With `set -e`, an image with neither exits 127 after the package install; the agent is installed but never started and discovery silently falls back to ARP only.
**S5.5 — low — `xml.go:178-193`**
`buildDomainXML` validates name, paths and disk format but not `VCPUs`/`MemoryMiB`; it will emit `<vcpu>0</vcpu>`. Unreachable today because `validateKVMBlock` checks them; latent for any future caller.
**S5.6 — low — `volume.go:68-89`** (see escalation E10)
No check that an overlay's capacity is at least the backing image's virtual size. `kvm2.diskSizeGb: 2` against a 3.5 GiB Ubuntu image produces `<capacity>` smaller than `<backingStore>`.
**S5.7 — low — `internal/provider/config_kvm.go:186-188`**
`bridge` and `pool` are checked non-empty only, unlike host names and image keys (`kvmIdentifier`). A value with whitespace or markup is faithfully escaped into the XML and surfaces as an opaque libvirt failure at Create time rather than a config-load error.
**S5.8 — low — `seed.go:107-108`**
A configured `vendorData` that is itself MIME multipart (or starts with any unrecognised line) is nested as `text/plain`, matches no cloud-init prefix, and is silently dropped when `installGuestAgent` is on. No guard in `buildVendorData`.
Verified clean: the iso9660 staging dir is cleaned on every return path; volume XML sets no `<permissions>`, so the seed ISO gets libvirt's 0600 default.
## Chunk 6 — Health check and observability (`check.go`, `observe.go`)
In good shape: the dedicated check connection is released on every path, every seam method is wrapped and forwards correctly (enforced by reflection in `observe_test.go:77`), spans always end, `liveness` survives the decorator, and no cloud-init or key material reaches a log or span at any verbosity. `go vet` and `go test -race` on the chunk were clean.
**S6.1 — low — `check.go:90`, `backend.go:162`, `:351`**
`call()` returns at its pre-flight `ctx.Err()` check without `markDead`, so if the round deadline falls between two probe RPCs the link is still live and the deferred `Close()` sends `ConnectClose` on a ctx-less 5 s budget. `Check` can work past the deadline the Monitor gave it, against `provider.Checker`'s "must return promptly once ctx ends". Bounded at 5 s, narrow window.
**S6.2 — low — `check.go:57-58`**
Reported `Latency` includes the teardown RPC because `defer b.Close()` runs inside `probeHost` before `time.Since(start)`. About one RTT slower than the spec's cost model.
**S6.3 — low — `check.go:66`**
The round-wide `ctx.Err()` test masks an actionable per-host reason. Host A blackholes and eats the round; host B fails at the deadline with "no route to host" and is reported `Timeout`. Same for `PoolNotActive`/`CredentialsUnavailable` landing on the deadline.
**S6.4 — low — `check.go:135-148` vs `internal/providerhealth/monitor.go:113`**
The Unreachable/Timeout distinction only holds while the round timeout exceeds the 10 s dial budget. `ValidateSchedule` accepts `--provider-check-interval=10s` or `--provider-check-timeout=10s`, under which a blackholed host reports `Timeout` instead of `Unreachable`.
**S6.5 — low — `check.go:101` → `api/v1alpha1/providerhealth_types.go:65`**
The remote-controlled `hostname` is copied unbounded into ProviderHealth `details`; `Message` is truncated to 1024 but `details` has no `maxLength`/`maxProperties`. A multi-MB hostname makes every status patch fail "too large". Requires a broken or hostile hypervisor.
**S6.6 — low — `check.go:41-45`**
Unbounded fan-out: one goroutine, key re-read and full SSH handshake per host per round, no cap. Harmless at 2-5 hosts.
## Chunk 7 — Config, registration, deployment, test gating
**S7.1 — high — `internal/provider/kvm2/volume.go:17`, `image.go:113-146`** (also: chunk 4) [T3]
The base image cache is keyed by image key + format only. `baseVolumeName` → `base-<key>.<format>`, and `internal/provider/kvm/image.go:46` produces the identical name. The shipped sample has `kvm-home` (`config/samples/providers-config.yaml:41`) and `kvm2-home` (`:73`) both with key `debian-13`, host `beelink`, pool `default`, and `:58-60` tells the admin to run both while porting.
Scenario: the two entries' URLs drift and neither sets `sha256`; `cachedImage` accepts any 64-hex marker when `src.SHA256 == ""`, so whichever provider downloaded first owns the file and the other boots the wrong image. Same for two `kvm2` entries naming one physical host. Related: changing `images.<key>.url` without changing the key or `sha256` is a silent no-op, since neither the volume name nor the marker records the URL.
**S7.2 — medium — `internal/provider/config_kvm.go:180-182`** [T1]
`KVMHost.Address` is validated only as non-empty although it must be `host:port`; nothing applies a default port. `address: beelink` passes `LoadConfig`; `dialSSHSocket` (`ssh.go:163`) gets "missing port in address", `classify` falls through to Transient, every operation retries forever, and `checkReason` reports `Unreachable`, pointing at the hypervisor rather than the config.
**S7.3 — medium — `internal/provider/config_kvm.go:101-104`, `142-147`**
`IsHTTPURL` accepts plain `http://` and `sha256` stays optional, so a VM root disk can come from an unauthenticated channel; `writeMarker` then pins whatever digest arrived. Nothing warns.
**S7.4 — medium — `internal/provider/kvm2/fake_test.go:362-376`, `:259-282`** [T2]
The fake's `DomainGetMetadata` ignores the namespace of the stored element, returning it whenever `uri[0] == metadataURI`; real libvirt selects strictly by namespace. So no unit test can build a domain carrying the kvm provider's `<instance>` and assert `list.go:38-48` skips it. If that filter regressed, kvm2's `ListByTag` would hand kvm's VMs to orphan GC and the suite would stay green.
**S7.5 — medium — `Makefile:75`**
`-timeout 40m` is not derived from `KVM2_TEST_TIMEOUT`. Two integration tests each take up to 20 m plus a 5 m cleanup budget; with `KVM2_TEST_TIMEOUT=40m` or a cold image download, go test's own timeout panics before `t.Cleanup` (`kvm2_integration_test.go:373-382`) runs, leaving a domain, overlay and seed on the real hypervisor under a random name printed only mid-run.
**S7.6 — low — `internal/provider/config_kvm.go:133`, `internal/provider/kvm2/image.go:466`** [T1]
No upper bound on `diskSizeGb`, and `neededBytes` wraps: `diskSizeGb: 17179869184` (2^34) shifts to 0, so `checkPoolSpace` passes and the failure surfaces later as "capacity must be positive". `machineTypes` vcpus/memoryMiB have no upper bound either (`config_kvm.go:158-162`); `memoryMiB: 1024000` yields an internal libvirt error that is Transient and retried forever.
**S7.7 — low — `internal/provider/kvm2/fake_test.go:190-208`**
The fake's `enter()` accepts calls on an expired ctx and never marks the connection dead, unlike `call()`. So `discardVolume`'s `context.WithoutCancel` (`image.go:331`) is not exercised: deleting it leaves every test green while production skips cleanup of a half-uploaded base on a cancelled Create.
**S7.8 — low — `internal/provider/kvm2/fake_test.go:579-584`**
The fake's `Close()` only bumps a counter; it stays usable and `isDead()` stays false. A use-after-close of a dedicated connection passes in tests and fails in production.
**S7.9 — low — `Makefile:60-75`, `.gitea/workflows/build.yaml:27-32`**
Nothing compiles the integration file: `go vet`/`go build`/`go test -short`/`make test` all run without `-tags=integration`. It compiles today (`go vet -tags=integration ./internal/provider/kvm2/` exits 0), but nothing keeps it that way.
**S7.10 — low — `internal/provider/kvm2/image.go:228`, `283-289`, `385-395`**
The full image URL reaches log lines and error messages while the span deliberately carries only the host (`urlHost`). `IsHTTPURL` accepts userinfo, so `http://user:pass@mirror/...` lands in the error that `provider.Wrap` propagates. Whether it reaches Proxy status is unverified (dropped escalation D5).
**S7.11 — low — `internal/provider/kvm2/kvm2_integration_test.go:89`, `:307`**
`KVM2_TEST_KEEP=0` or `false` enables keep, since the check is `!= ""`. And `if elapsed > 100*time.Millisecond` on `New()` is a wall-clock `t.Errorf` on a possibly loaded machine, a flaky failure at the head of a 20 minute test.
**S7.12 — low/informational — `config/manager/manager.yaml:175-178`, `internal/provider/kvm2/seed.go:56-74`**
The seed ISO staging dir is the `/tmp` emptyDir with no `medium: Memory`, so Proxy cloud-init payloads (which can carry proxy credentials) are written unencrypted to node-local disk for the duration of each Create. The mount comment (`manager.yaml:163-165`) attributes `/tmp` to the kvm provider only.
Verified clean: Secret mount permissions (x/crypto does no permission check); both `Close` defers on dedicated connections.
@@ -0,0 +1,286 @@
# proxmox provider review: final report
Branch `feat/proxmox-provider` at `7f2073f` (Phases 0 to 9), diffed against
`main` (`2167819`): 123 files, 49 of them Go. The core is
`internal/provider/proxmox/`; also in scope were the `nocloud` and
`keyedlock` extraction from kvm2 (`0a6c862`), the config block in
`internal/provider/config*.go`, registration and the deploy files.
**Inputs.** [docs/proxmox-provider-spec.md](../../proxmox-provider-spec.md)
(the spec the code implements) and
[docs/provider-contract.md](../../provider-contract.md) (the MUST/SHOULD
rules). A disagreement with either counts as a finding.
**Reviewers.** Two independent passes, both read-only:
1. The `go-operator-reviewer` agent (`.claude/agents/go-operator-reviewer.md`),
briefed with the spec and the contract, findings **P1 to P6** and
simplifications **S1 to S3**.
2. `/code-review high` on the branch, findings **CR1 to CR10**.
Where both passes found the same thing, the finding is listed once with both
IDs. Every finding is **fixed** (with a regression test when it is code),
**accepted** (real, not fixed here, documented as a known limit), or
**rejected** (with the reason).
**Toolchain.** `make test` green before and after the fixes. `make lint`
panics inside staticcheck on the local Go 1.27 toolchain, as it did during
kvm2, so lint ran without it, over the proxmox package and
`internal/provider`, with and without the integration tag:
```bash
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/
```
Before the fixes both runs were at the Phase 8 and 9 baseline: logcheck 12,
modernize (mapsloop) 2, unparam 3 in `fake_test.go`, gocyclo 1
(`TestAPITypes_decodeCaptures`), goconst on repeated strings, and the three
goconst hits in `internal/provider`. After the fixes the counts are the same
except goconst, one lower (224, 231 with the tag). No hit is new. The first
version of the P2 test did add one: a third `"backup"` literal in the
package's tests, then `"migrate"`. It now uses `"snapshot-delete"`.
## Summary
| ID | Severity | Finding | Disposition |
|---|---|---|---|
| CR1 + P1 | medium-high | A seed delete that fails after the destroy leaks the seed for good, and the class of the error depends on the attempt | **fixed** |
| CR2 | medium | A 403 on the seed delete latches a replaced Proxy whose VM is already gone | **fixed** (same change as CR1) |
| CR3 + P2 | medium | The ownership check waits out any lock, even on a VM whose listed name is someone else's | **fixed** |
| P3 | low-medium | Create adopts a leftover stopped VM whatever the current placement says | **accepted**, documented |
| P4 | low | A crash mid-clone plus a freed earlier VMID can yield a second VM | **accepted**, documented |
| P5 | low | 401/403 are Permanent, which bites during a token rotation | **fixed** in the guide |
| P6 | low | Get and Delete trust the listing's `status`, which lags | **accepted**, confirmed on the host; error message fixed |
| CR5 | low | `isLocked` and `isVMNotRunning` are called only from tests | **fixed** |
| CR8 | low | `TestPermanentfAndTransientf` breaks the `Test<Function>_<scenario>` rule | **fixed** |
| CR9 | low | `get` repeats `mapState`'s "guest running" rule inline | **fixed** |
| CR4 + S2 | low | The Checker repeats the cluster-wide `/version` and `/cluster/resources` per node | **rejected** |
| CR6 | low | `lockName`, `permanentf` and `transientf` are copies of kvm2's | **rejected** |
| CR7 | low | `readToken`, the CA load and seed staging do file I/O without a ctx | **rejected** |
| CR10 | low | `New` runs `withDefaults` twice | **rejected** |
| S1 | simplification | The `observe.go` decorator could be one log line in `send` | **rejected** |
| S3 | simplification | `nocloud` exports `GuestAgentScript` and `SeedLabel` for tests only | **rejected** |
## Fixed
### CR1 + P1: the seed outlives a Delete that fails after the destroy
`internal/provider/proxmox/delete.go`. Delete stopped the VM, destroyed it,
then deleted the seed. If the seed delete failed (a 595, a task over its
1 minute budget, a 403), Delete returned an error, but the VM was already
gone. The reconciler's next `Get` was `ErrNotFound`, so `reconcileDelete`
released the finalizer without calling Delete again, and GC lists only VMs.
The seed stayed on `isoStorage` for good. That breaks contract §2.3 ("MUST
remove everything Create made ... GC sees only what ListByTag returns"). A
destroy task that outlived its budget but finished in the background had
the same effect.
P1 adds the second half. On the finalizer path the next Delete found the
VMID gone and took `deleteSeedEverywhere`, which logged the same 403 at V(1)
and returned nil. The seed leaked with nothing visible.
**Fix.** The seed now goes between the stop and the destroy, so the VM,
which is what `Get` looks at, is the last thing removed: a failed seed
delete leaves the VM stopped, `Get` keeps finding it, and the next Delete
finishes both. `deleteSeedEverywhere` now fails on an `ErrPermanent`
answer (a 401 or 403 every node would give) instead of skipping it. It
still skips a node that is down or lacks the storage. `runTask`'s "gone"
result lost its last reader and was dropped.
That Proxmox deletes an ISO a stopped VM's `ide2` still references is the
one assumption the fake cannot prove. From memory of the PVE source, not
checked against it: the content delete runs `vdisk_free` without looking
for references to `iso` volumes, and the destroy skips CD-ROM drives.
Phase 0 only ever deleted the seed after the destroy. **The Phase 10
real-host run proved it** on 2026-09-24: the imgdel task on the stopped
VM's seed ended `OK`, the destroy followed, and `TestProxmox_lifecycle`
step 10 found neither the VM nor the seed.
**Tests.** `TestDelete_seedDeleteFailureOnVMNodeFails` (403, 596 and a
failed task on the VM's node) now asserts that no Destroy was sent, that
`Get` still finds the VM, that a second Delete gives the same class, and
that a Delete after the fault clears removes both. It failed on the old
code with `Destroy calls = 1` and `Get = instance not found`.
`TestDelete_seedDeleteNodeFailureSkippedWhenNodeUnknown/a refused privilege
fails the call` covers the every-node 403. Updated for the new order:
`TestDelete_stoppedVMSkipsStop`, `TestDelete_runningVMStoppedFirst`,
`TestDelete_seedDeleteIsTaskAndIdempotent`,
`TestDelete_destroyTaskFailureIsTransient` (the seed is now gone and the VM
kept) and `TestDelete_goneMidwayIsSuccess` (a VM gone at the stop still
gets a Destroy call, which answers `does not exist`).
`TestLogging_noOpLinesAtV1`'s skip case now injects a node-level 500,
since a 403 no longer skips.
Docs: spec entry 11 (steps 5 to 7 reordered, the Permanent exception),
the guide's Delete paragraph, `providers-existing.md`'s Delete bullet and
its `delete-return` cell.
### CR2: a 403 on the seed delete latched a Proxy with no VM
Same code, same fix. On the replace path (`reconcileManaged` sees a Stopped
VM and calls Delete), the old order destroyed the VM, got a 403 on the
seed, and returned Permanent. `providerFailure` latched it, so the Proxy
sat Failed pointing at a VM that no longer existed. The next attempt would
have returned nil. Now the 403 comes before the destroy: the VM stays
stopped, and every attempt gives the same Permanent answer naming
`Datastore.Allocate` until the privilege is granted. The guide says so.
### CR3 + P2: the lock wait on a VM that is plainly not ours
`internal/provider/proxmox/create.go`, `allocate`. Any taken qemu VMID went
through `inspect`, which first waits for the VM's lock to clear (clone
budget, 10 minutes) and only then compares names. A VM on one of the
Proxy's probe VMIDs, listed with another name and holding a long lock (a
`vzdump` backup, or a stale lock a failed backup left behind), made every
Create wait 10 minutes and return Transient. `vmidFor` is deterministic,
so every retry landed on the same VMID. Only a clone in progress is
nameless (F8), so a listed name other than ours settles the question.
**Fix.** `allocate` sends a taken entry to `inspect` only when its listed
name is empty or ours; any other name probes on without a config read.
Spec entry 2's step 3 says so.
**Tests.** `TestCreate_takenVMIDInspectedBeforeProbing` gains "foreign VM
under a lock probes on without waiting" (a `snapshot-delete` lock with a
2 s clone budget; the old code spent the 2 s and failed Transient), and
"foreign VM probes on without a read" now expects one config read (the
template's). `TestCreate_vmidRangeExhaustedIsTransient` expects one
VMConfig call instead of five.
### P5: token rotation order
`docs/proxmox-provider.md`, cluster-side setup. A 401 is Permanent (the
same convention as GCP), so a request made with a revoked token before the
kubelet refreshes the Secret latches that Proxy. The guide now gives the
order: add the new token, update the Secret, wait for the mount, then
remove the old one, and the same before an `--expire` date.
### CR5: matchers used only by tests
`isLocked` and `isVMNotRunning` in `errors.go` had no production caller.
Both answers are Transient through `classify`'s default branch. They moved
to `fake_test.go`, where `TestFakeAPI_errorsMatchCaptures`,
`TestAPIError_matchers` and the fake's own checks still use them to hold the
fake's wording to the captures. Spec entry 13's matching sentence and
`providers-existing.md` now name only the matchers the provider uses.
### CR8: test name
`TestPermanentfAndTransientf` became `TestPermanentf_wrapsClassWithMessage`
and `TestTransientf_wrapsClassWithMessage`. kvm2 has the same name; it is
out of scope here.
### CR9: one rule, one place
`get` asked `status == "running" && qmpstatus in {running, ""}` inline to
decide whether to query the agent, the same rule `mapState` applies. Both
now call `guestRunning(status, qmpStatus)` in `state.go`, so a change to it
cannot leave the two out of step. `TestMapState_*` and the Get tests cover
it unchanged (`TestMapState_table`, `TestMapState_fromCaptures`).
## Accepted
### P3: adoption ignores a placement change
`create.go:94-118`. A Create that fails Permanent after the clone (a 403 on
the upload, say) leaves a stopped VM. If the user then changes
`placement.image` or `zone`, the next Create adopts that VM by name, and
the Proxy runs the old template on the old node. The fix the reviewer
proposed (record the template in the mark, re-clone a never-started VM
whose template differs) is a new mark field and a destroy path inside
Create. That is too much for this MR for a two-step, user-driven sequence.
Documented in the guide's Known gaps and spec entry 24: destroy the VM
before changing `placement` on such a Proxy.
### P4: a second VM after a crash mid-clone
The retry misses a nameless in-progress clone on a later probe VMID when
the VM on the earlier VMID was removed meanwhile, and clones again. It
takes a crash or cut-off during a clone of a few seconds, a foreign VM on
the first probe VMID, and that VM's removal in the same window. The
proposed fix (inspect every nameless `lock: clone` entry in the range
first) would make every Create wait on every other Proxy's clone. The
outcome is visible, not silent: the next Create fails Permanent naming
both VMIDs. Documented in the guide and spec entry 24.
### P6: the listing's `status` may lag
`get.go:51`, `delete.go:60`, `delete.go:90`. This is the reviewer's
inference, not observed: `/cluster/resources` comes from pvestatd, which
may lag a state change by seconds. Both cases heal on retry: `Get`
answering Transient for `unknown` is polled through, and a destroy of a VM
that started behind the listing's back fails its task as Transient and the
next Delete stops it first.
**Confirmed on pv1-1.** The first `Get` came 32 ms after the second Create,
about 6 s after the clone, and was Transient: the listing still showed the
VM as `status: unknown`. The next poll 5 s later saw it running. The
behaviour is right, but the message blamed the node ("its node pv1-1 is not
answering"), which was up. Get and Delete now say "its node … is not
answering, or the VM is too new to be reported yet".
`TestGet_unknownStatusIsTransient` asserts the wording, and spec entry 9
and the guide's error table name both causes. The Delete side (a stop
skipped because the listing lags) was not met: the run's Delete came a
minute after the start.
## Rejected
- **CR4 + S2, Checker repeats cluster-wide calls per node.** Spec entry 16
makes each node's target self-contained, so a failure is reported
against the node whose round saw it. The cost is two extra requests per
extra node per round, and the configured cluster
has one node. Worth revisiting with a multi-node config, not before.
- **CR6, helpers copied from kvm2.** `permanentf`/`transientf` are one
line each; `lockName` is five and wraps `keyedlock.Acquire`'s error in
`provider.ErrTransient`. Moving that into `keyedlock` would couple a
generic lock package to the provider sentinels, and putting the helpers
in `internal/provider` would touch every kvm2 call site in a proxmox MR.
- **CR7, file I/O without a ctx.** `readToken` and the CA load read a
mounted Secret with `os.ReadFile`, which cannot be cancelled; a ctx
parameter would be unused. kvm2's `os.ReadFile` of the SSH key is the
precedent. Seed staging is `nocloud`'s, shared with kvm2 and unchanged by
the extraction.
- **CR10, `withDefaults` twice in `New`.** Startup only, idempotent, a few
small maps. `newProvider` applies it because the tests build providers
through it; defaulting once would need a second constructor shape.
- **S1, replace the `observe.go` decorator with one log line in `send`.**
The decorator is spec entries 17 and 20's `span-decorator` answer: each
seam call gets a child span and a V(1)/V(2) line with node, VMID and
UPID, which `send` does not know. It mirrors kvm2's decorator, and the
reflection test guarantees every seam method is covered. Swapping the
design in the review phase is out of proportion.
- **S3, `nocloud` exports used only by tests.** `SeedLabel` is read by
both kvm2's and proxmox's Create tests to recognise a real seed, and
`GuestAgentScript` by proxmox's `TestBuildSeed_table`, which tests
proxmox's own mapping of its config fields onto the vendor-data, not
`nocloud`'s composition. The exports are the price of cross-package
tests.
## Checked and fine
From the reviewer agent's report, verified in code:
- The kvm2 extraction is a pure move. kvm2 still wraps every seed-build
error as Permanent, as before, and `keyedlock` is byte-identical to the
code it replaced.
- Delete stops or destroys only after the listing shows a qemu entry at
the VMID and the config carries the name and the mark; a reused VMID or
an unmarked VM is left alone.
- A missing VM inside Create is Transient, never NotFound; locks and
`status: unknown` are Transient, never NotFound.
- Create resumes at every step: the VMID comes from the UID, a nameless
locked entry is waited on and adopted, upload/config/resize are
repeatable, and the mark is written in the clone call.
- Secrets: `apiError` carries status and message only, the token never
reaches a `url.Error`, the Checker keeps only the token id, and the
cloud-init text appears only as `cloudInitBytes`.
- Every seam call takes the ctx; `pollUntil` honours ctx and budget;
`keyedlock.Acquire` gives up with ctx.
- Spec entries 1, 3, 4, 9, 11, 12, 13, 15 and 16 match the code (P2 was the
one disagreement, and the spec shared the flaw).
- Every §11 test name in spec entry 21 exists.
The agent also saw kvm2's `TestCheck_hostsCheckedConcurrently` fail once
under `-race` (199.9 ms against its 200 ms floor). That file is unchanged
on this branch; the flake is parked, as before.
+357 -4
View File
@@ -53,13 +53,35 @@ manual runs too.
| | `config_test.go` | providers-config parsing + fail-fast validation | pure |
| | `metrics_test.go` | `WithMetrics` decorator: classified result labels, error passthrough | fake recorder + static provider |
| `internal/provider/kubernetes` | `kubernetes_test.go`, `pod_test.go` | Create/Get/Delete/ListByTag over real Pod objects; pure `buildPod`/`squidConf` incl. the `max_filedescriptors` OOM-regression assertion | `client/fake` (real `corev1.Pod`s, no kubelet) |
| | `check_test.go` | health check: a single cluster-wide list with `Limit=1` and the managed-label selector; the reason for every API error class | `client/fake` + interceptor |
| `internal/provider/gcp` | `insert_test.go`, `errors_test.go`, `gcp_test.go` | field-by-field `buildInsertRequest`; HTTP-code classification table; zone-qualified IDs, 409-is-success, 9-row state mapping, ListByTag filter + `ReturnPartialSuccess` | pure builder needs **no fake**; the rest uses the flattened `instancesAPI` seam |
| | `check_test.go` | health check: request shape and the reason per HTTP code or network error; the **real SDK client** makes exactly one request with `maxResults=1`, even when a next page exists | fake seam + `httptest` |
| `internal/provider/kvm` | `domainxml_test.go`, `volume_test.go`, `seed_test.go`, `id_test.go`, `metadata_test.go` | pure builders: domain/volume XML unmarshalled back and checked field by field, seed ISO reopened with the same library, providerID round-trip, metadata in both namespace spellings | pure |
| | `errors_test.go`, `conn_test.go` | libvirt-code classification table; connection pool (reuse, redial, drop on transport error, cancel releases a blocked RPC, one dial per host under concurrency); SSH dial, host-key policy, keepalive | fake dialer; a **real in-process `x/crypto/ssh` server** forwarding a unix socket |
| | `image_test.go`, `kvm_test.go` | image cache (fresh fetch, ready, marker missing, checksum change/mismatch, 4xx/5xx, concurrent callers); Create resume after every partial step, leftover truncated seed, state/IP mapping, every Delete already-gone case, ListByTag across hosts / one down / unreadable domain; logging tiers (V0 silent, V1 one line per RPC, V2 XML) with cloud-init never logged | `httptest` image server + in-memory `fakeAPI` behind the `libvirtAPI` seam |
| | `trace_test.go`, `conn_test.go`, `errors_test.go` | every `libvirtAPI` method traced exactly once (guarded by reflection on the interface), results and errors pass through, libvirt error fields, detail keys only at V2, upload content never logged; pool connect/drop lines, no wrapping below V1 | `fakeAPI` + `funcr` capture |
| | `check_test.go`, `conn_test.go` | health check: details, per-host verdicts in config order, hosts checked concurrently, a timeout that keeps the connection and refuses a second check while the first still waits, no waiting behind a reconcile's dial, the reason table; `probe` abandons without closing, but still drops on a transport error; waiting for a host's slot ends with the caller's deadline; an inactive or missing pool and daemon errors keep the connection; every dial stage tagged through the real SSH server, with classification unchanged | `fakeAPI`, fake dialers, in-process SSH server |
| | `kvm_integration_test.go` (`-tags=integration`) | one real VM: image fetch, Create, idempotent re-Create, Running with IP, ListByTag, Delete, pool state afterwards; `TestKVMCheck_realHost` runs the health check against the host and creates nothing | a **real hypervisor** — see below |
| `internal/provider/kvm2` | `xml_test.go`, `volume_test.go`, `seed_test.go`, `id_test.go`, `metadata_test.go`, `state_test.go` | pure builders: domain XML (file disks with resolved paths, UEFI with secure boot off, BIOS escape, the metadata element, the guest-agent channel), volume XML with and without a backing store, seed ISO reopened (files, label, user-data byte for byte, temp dir removed) and the vendor-data script vs. multipart rules, providerID round-trip and malformed forms, metadata in both namespace spellings, state mapping and the IPv4 filter | pure |
| | `errors_test.go`, `backend_test.go`, `hosts_test.go`, `ssh_test.go` | classification table (libvirt codes, SSH auth text, `knownhosts.KeyError`, missing files); the per-call budget adapter (deadline kills the connection and is Transient, cancel unblocks, killed by another caller, close idempotent) against a mocked libvirt socket; the host table (one dial per host under concurrency, redial after drop, `dialFresh` never cached, waiting ends with ctx); the real dialer end to end: socket forwarding, unknown and mismatched host keys, insecure mode, missing vs. unparsable key and known_hosts, wrong key, handshake timeout, cancel, every stage tagged, key material never in errors or logs | fake dialer; a **real in-process `x/crypto/ssh` server** forwarding a unix socket |
| | `image_test.go`, `create_test.go`, `get_test.go`, `delete_test.go`, `list_test.go`, `kvm2_test.go`, `provider_test.go` | image cache (fetch with the marker written last, cached, marker missing or without a digest, sha256 change or mismatch, 4xx/5xx, no Content-Length, short read, concurrent callers fetch once, cancel mid-stream, streamed without buffering, upload on a dedicated connection) and the pool-space quota precheck; Create resume after every partial step, returns only when running, adoption guard on UID, seed always rebuilt, cloud-init sentinel never logged; Get state and address mapping incl. agent-before-ARP; every Delete already-gone case, NVRAM flag, per-name lock; ListByTag across hosts, one down fails the whole call, unreadable domain skipped; `New` never dials and warns on missing files; every error wrapped with the configured name; logging tiers V0/V1/V2 | `httptest` image server + in-memory `fakeBackend` behind the `backend` seam |
| | `observe_test.go`, `check_test.go` | every seam method traced and logged exactly once (guarded by reflection), child spans under the parent, detail only at V2, upload bodies never logged, error fields; health check: one entry per host in config order, hosts checked concurrently, expired ctx returns promptly, never touches the pooled connection, the reason table, detail keys | `fakeBackend` + `funcr` capture + span recorder |
| | `kvm2_integration_test.go` (`-tags=integration`) | one real VM: `New` without a dial, `Check` Ready with details, Create, idempotent re-Create with an unchanged domain count, Running with IP and the metadata round-trip, ListByTag, Delete, NotFound, pool state afterwards, `kvm2.*` spans under `provider.create`; `TestKVM2Check_realHost` runs the health check against the host and creates nothing | a **real hypervisor** — see below |
| `internal/provider/nocloud` | `seed_test.go` | the NoCloud seed kvm2 and proxmox share: the ISO reopened (files, label, user-data byte for byte, temp dir removed, an empty vendor-data still a file, an empty name refused) and the vendor-data rules (a shell script, not cloud-config; multipart with configured extra vendor-data; the agent install switched off) | pure |
| `internal/provider/keyedlock` | `keyedlock_test.go` | the per-name lock kvm2 and proxmox share: the entry is removed once uncontended, waiters share one lock, a waiter gives up with its ctx | pure |
| `internal/provider/proxmox` | `id_test.go`, `mark_test.go`, `state_test.go`, `seed_test.go`, `errors_test.go`, `api_test.go` | pure pieces: providerID round-trip and malformed forms, the VMID derived from the UID (deterministic, in range, probing forward and wrapping), the description mark (keys, parsed from the captured clone, admin lines kept), state mapping and the IPv4 filter from captures, seed names and building (empty name Permanent without its payload, staging failure Transient); the `classify` table and the 500-message matchers against every captured error; the API types decoded from the captures | pure, over the responses Phase 0 captured from PVE 9.2.20 (`testdata/`) |
| | `client_test.go` | the thin HTTP client: the token read from its file on every request (missing Transient, unparsable Permanent), `apiError` from status, reason phrase and body, the data envelope, method, path and parameters of every seam call, the clone and destroy query shapes, the volid's slash escaped, HTTP/1.1 only, request timeout and cancel Transient and the client unaffected, a child span under a parent and none without, the token never in errors or logs, the CA file trusted and loaded lazily, an x509 failure Permanent, insecure mode, the upload streamed as multipart | `httptest.NewTLSServer` replaying the captures byte for byte |
| | `create_test.go`, `get_test.go`, `delete_test.go`, `list_test.go`, `task_test.go`, `proxmox_test.go`, `provider_test.go`, `fake_test.go` | Create: resume after every partial step and after a crash mid-clone, the VMID probe and ownership check, the template's shape refused before any clone, resize as a task, the seed overwritten without a delete, the net0 bridge and VLAN rewrite keeping the MAC, returns only when running, cloud-init sentinel never logged; Get: lock checked before name, the agent's messages, paused from `status/current`, hibernated, a reused VMID, four calls when running; Delete: every already-gone and not-ours case, the seed deleted before the destroy on the VM's node or on every node, a hibernated VM destroyed, the per-name lock; ListByTag: candidates by tag or name prefix, the mark decides, an unreadable VM skipped unless ctx ended; task and lock waits; `New` makes no request; the contract sweep: every method returns within a second of a cancel (blocked on its first seam call, and mid-operation), every error wrapped with the configured name, op and id, logging tiers V0/V1/V2 with the no-op lines | in-memory `fakeAPI` behind the `api` seam, its error texts checked against the captures |
| | `observe_test.go`, `check_test.go`, `provider_test.go` | the decorator: every seam method logs exactly one line (guarded by reflection), starts no span, identifiers at V1, detail at V2, upload body never logged, error fields; a Get through `provider.WithTracing` over the real client makes four `HTTP GET` spans, all children of `provider.get` and none carrying the token; health check: one entry per node in config order, nodes checked concurrently (a barrier, not a timer), expired ctx returns promptly, the reasons table at V2 with a sentinel secret, the transport reasons from real `net/http` and `crypto/tls` errors, detail keys | `fakeAPI` + `funcr` capture + span recorder; the real client over `httptest` for the tracing and transport-reason rows |
| | `proxmox_integration_test.go` (`-tags=integration`) | one real VM: `New` without a request, `Check` Ready with details, Create, idempotent re-Create with an unchanged VM count, Running with an IPv4 and the mark read back, ListByTag, Delete, NotFound, no VM and no seed left, HTTP spans under `provider.create`; `TestProxmoxCheck_realHost` runs the health check against the host and creates nothing. **Passed on pv1-1 (PVE 9.2.20), 2026-09-24**: Create 6.2 s, address 50 s later, whole run 61 s | a **real Proxmox VE host** — see below |
| `internal/health` | `probe_test.go` | probes through a **real CONNECT-capable proxy stub** to a real TLS server: tunnel success, refused CONNECT, wrong status, dead proxy, plain-http forward | `httptest` + hijacked bidirectional tunnel |
| | `engine_test.go` | thresholds, first-verdict, latency suppression matrix, dropped-event retry, stale-probe UID guard, tick scheduling/seeding/pruning, `Start` end-to-end | fake `client.Reader` + injected `probeFn` |
| `internal/lease` | `store_test.go` | capacity, `MaxLeases=0`, all three selection tie-breaks, target-scoped vs global cooldowns, expiry via fake clock, report-on-retained-lease, idempotent release, **40 concurrent acquires never exceeding capacity** | injectable clock; the concurrency case is why `-race` matters |
| `internal/discovery` | `server_test.go` | auth matrix (incl. `/healthz` exemption), list filtering, grant shape, full 409 arithmetic, invalid TTL/body/result, idempotent release, report→cooldown→409 round trip, `Start`/shutdown | `httptest` over the real handler chain, fake cache reader, **real** `lease.Store` |
| `internal/gc` | `gc_test.go` | the true-orphan matrix (live/deleting/young/unlabelled all kept), broken-provider isolation, list-failure skips sweep, namespace guard, sweep loop | fake reader + canned-list provider |
| `internal/metrics` | `metrics_test.go` | scrape-time collectors (`GatherAndCompare`), per-proxy series cleanup via `ForgetProxy`, label counts, fresh-registry-per-test property | `prometheus/client_golang/testutil` |
| `internal/providerhealth` | `status_test.go`, `monitor_test.go` | `buildStatus` (the Ready condition, per-target transition times, truncation); objects created with the label and patched, the round timeout, no-checker Unknown, invalid names, transition-only logging, cleanup of labelled unconfigured objects, the checks-off path, `ValidateSchedule` | fake client with a cluster-scoped RESTMapper + `funcr` capture |
| | `envtest_test.go` | the generated `ProviderHealth` schema against a real API server: cluster scope, status subresource, required fields, list-map keys, the 1024-character message limit | envtest (skipped under `-short`) |
| `internal/metrics` | `metrics_test.go` | scrape-time collectors (`GatherAndCompare`) incl. the provider-target gauges, per-proxy series cleanup via `ForgetProxy`, label counts, fresh-registry-per-test property | `prometheus/client_golang/testutil` |
Conventions (from `CLAUDE.md`): stdlib `testing`, table-driven by default,
`t.Parallel()` where safe, tests next to source, ginkgo only in the
@@ -83,17 +105,348 @@ envtest suite the scaffold generated.
bug (below).
- **Live GCP**: no test talks to Google. The seam boundary
(`buildInsertRequest` + classification) is tested exhaustively instead.
- **Live libvirt, by default**: the KVM and kvm2 unit tests run over an
in-memory fake of the 20 libvirt calls each provider uses. Whether
libvirt accepts the domain XML, whether a raw qcow2 upload plus pool
refresh yields a usable backing image, and whether the guest actually
boots and reports an address are provable only on a real host — that is
the opt-in integration test below (one per provider).
- **Live Proxmox, by default**: the proxmox unit tests run over an
in-memory fake of the 16 seam calls, and the HTTP client over
`httptest` replaying responses captured from PVE 9.2.20. Whether a
clone, config write, seed upload and start yield a guest that boots and
reports an address is provable only on a real host (the proxmox
integration test below, which has passed on pv1-1). Two kinds of path
have met only the fake:
- **the cross-node paths**: a clone that targets another node, the seed
deleted on every node when the VM's node is unknown, a Check round over
several nodes. The test host is a single node, so the integration test
cannot reach them either
([proxmox-provider-spec.md](proxmox-provider-spec.md) entry 24);
- **an answer never captured**: the storage status for a storage the
cluster knows but cannot use on a node (disabled there, or offline).
Every 500 there is taken as `StorageNotFound`, and what the fake
answers is its own guess. The three answers Phase 0 missed were
captured on pv1-1 on 2026-09-24: a missing storage is a 400 with
`storage: No such storage.`, a hibernated VM is destroyed without
complaint (its suspend state volume with it), and a stop on a paused
VM ends `OK` with the VM stopped.
## The KVM integration test (real hypervisor)
`internal/provider/kvm/kvm_integration_test.go` compiles only under
`-tags=integration` and skips itself unless the environment names a
host. It deviates from the "testcontainers for real infra" convention on
purpose: libvirt needs `/dev/kvm` and a bridge to a DHCP network, and a
container has neither. So it targets whichever hypervisor you point it
at and leaves that host's pool with one more cached base image than it
had.
```sh
export KVM_TEST_HOST=nas1.home.lan:22 # SSH endpoint
export KVM_TEST_USER=virt # must be in the libvirt group
export KVM_TEST_SSH_KEY=$HOME/.ssh/id_ed25519 # private key for that user
export KVM_TEST_KNOWN_HOSTS=$HOME/.ssh/kvm_known_hosts # the hypervisors' host keys
export KVM_TEST_BRIDGE=br0 # bridge to a network with DHCP
export KVM_TEST_IMAGE_URL=https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
make test-integration-kvm
```
`KVM_TEST_KNOWN_HOSTS` is technically optional, but leaving it out turns
host-key verification **off** for the run (the log says so); keep a
`kvm_known_hosts` holding just the hypervisors rather than pointing the
test at your everyday `known_hosts`, so a re-keyed hypervisor is a
one-line fix in a file nothing else uses.
Also optional: `KVM_TEST_IMAGE_SHA256`, `KVM_TEST_POOL`
(default `default`), `KVM_TEST_SOCKET`, `KVM_TEST_TIMEOUT` (default
`20m`, must cover the first-time image download), `KVM_TEST_KEEP`
to leave the VM behind for `virsh console` when something goes wrong,
and `KVM_TEST_LOG_LEVEL` (default `1`) for how much of the provider's
logging the `-v` output shows: at `1` every libvirt RPC is one compact
line (`HH:MM:SS.mmm libvirt virDomainLookupByName domain=... duration=...
error=... libvirtError=ErrNoDomain`), interleaved with the test's own
`t.Log` lines; `2` adds the domain and volume XML, metadata and
addresses; `0` leaves only `t.Log`. The per-run constants (provider,
host, domain name) are left off each line. See the Logging section of
[kvm-provider.md](kvm-provider.md#logging).
What one run asserts, in order: `New` dials the host; `Create` returns a
`hosts/<host>/domains/<name>` ID (downloading the image on a cold host —
the log prints how long it took); a second `Create` returns the same ID;
`Get` reaches `Running` with an IPv4 within the budget, and the UID and
`CreatedAt` survive the metadata round-trip through libvirt; `ListByTag`
lists it; `Delete` removes it, `Get` says `ErrNotFound`, a second
`Delete` is a no-op, and the pool holds the base image and its marker
but no per-VM overlay or seed. It also tries a TCP connect to port 22 on
the guest, but only logs the outcome — the machine running the test may
not route to the bridge network.
`TestKVMCheck_realHost` runs the provider health check
([kvm-provider.md](kvm-provider.md#health-check)) against the same host.
It must find the host reachable and the pool running, and it creates
nothing. It needs the same variables; to run only this test:
```sh
go test -tags=integration -count=1 -v -run TestKVMCheck ./internal/provider/kvm/
```
The test is not part of `make test` and not run in CI. Keep it compiling
with `go vet -tags=integration ./...` when the provider's internals move.
## The kvm2 integration test (real hypervisor)
`internal/provider/kvm2/kvm2_integration_test.go` compiles only under
`-tags=integration` and skips itself unless the environment names a
host. Like the kvm one it deviates from the "testcontainers for real
infra" convention on purpose: libvirt needs `/dev/kvm` and a bridge to a
DHCP network, and a container has neither. It targets whichever
hypervisor you point it at and leaves that host's pool with one more
cached base image (`base-test-image.qcow2` and its `.sha256` marker) than
it had. The kvm and kvm2 tests can share a host: kvm2 marks its domains
in its own metadata namespace and never lists or adopts the other's.
```sh
export KVM2_TEST_HOST=beelink:22 # SSH endpoint
export KVM2_TEST_USER=novakj # must be in the libvirt group
export KVM2_TEST_SSH_KEY=$HOME/.ssh/id_ed25519 # private key for that user
export KVM2_TEST_KNOWN_HOSTS=$HOME/.ssh/kvm_known_hosts # the hypervisors' host keys
export KVM2_TEST_BRIDGE=br0 # bridge to a network with DHCP
export KVM2_TEST_IMAGE_URL=https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
make test-integration-kvm2
```
`KVM2_TEST_KNOWN_HOSTS` is technically optional, but leaving it out turns
host-key verification **off** for the run (the log says so); keep a
`kvm_known_hosts` holding just the hypervisors rather than pointing the
test at your everyday `known_hosts`, so a re-keyed hypervisor is a
one-line fix in a file nothing else uses.
Also optional: `KVM2_TEST_IMAGE_SHA256` (verified after download and
compared with the cached marker), `KVM2_TEST_POOL` (default `default`),
`KVM2_TEST_SOCKET` (default `/var/run/libvirt/libvirt-sock`),
`KVM2_TEST_TIMEOUT` (default `20m`, the budget for the whole lifecycle;
it must cover the first-time image download), `KVM2_TEST_KEEP` to leave
the VM behind for `virsh console` when something goes wrong (the log then
prints the `virsh undefine --nvram` and `virsh vol-delete` lines that
clean it up), and `KVM2_TEST_LOG_LEVEL` (default `1`) for how much of the
provider's logging the `-v` output shows: at `1` every libvirt RPC is one
compact `libvirt call` line (`HH:MM:SS.mmm kvm2 "level"=1 "msg"="libvirt
call" "rpc"="DomainLookupByName" "domain"=... "duration"=...`, plus the
per-operation outcome lines and connection events), interleaved with the
test's own `t.Log` lines; `2` adds the domain and volume XML, metadata
and addresses; `0` leaves only `t.Log`. The per-run constants (provider,
host, domain name) are stated once in the first line and left off every
provider line. See the Logging section of
[kvm2-provider.md](kvm2-provider.md).
What one run asserts, in order, each step logged with its duration:
1. `New` returns within 100 ms and without dialling (no `libvirt host
connected` line before the first call).
2. `Check` reports the one host `Ready` with reason `Reachable` and the
`hostname`, `libvirtVersion`, `poolState` (`running`) and
`poolAvailableGiB` details, which are logged.
3. Every later call goes through `provider.WithTracing` with an in-memory
span recorder that the package's own spans also land in.
4. `Create` returns `hosts/kvm2-test/domains/<name>` (downloading the
image on a cold pool — the log prints how long it took).
5. A second `Create` returns the same ID and the host's domain count,
read over a dedicated connection, is unchanged.
6. `Get`, polled every 5 s, reaches `Running` with an IPv4 within the
budget; its `UID` is the one Create wrote and `CreatedAt` is non-zero
and within a few minutes of now. The time from Create's return to the
first address is logged.
7. `ListByTag` contains the ID with that UID.
8. A TCP connect to port 22 on the guest, logged only — the machine
running the test may not route to the bridge network.
9. `Delete` returns nil, `Get` is `ErrNotFound`, a second `Delete` is a
no-op.
10. The pool, inspected over a dedicated connection, holds the base image
and its marker but no `<name>.qcow2` or `<name>-seed.iso`.
11. Among the recorded spans, a `provider.create` span has at least one
`kvm2.DomainDefineXML` and one `kvm2.dial` child, and no span
attribute carries the cloud-init text.
With `KVM2_TEST_KEEP` steps 9 and 10 are skipped; otherwise a `t.Cleanup`
attempts the Delete even when an earlier assertion failed.
`TestKVM2Check_realHost` runs the provider health check
([kvm2-provider.md](kvm2-provider.md)) against the same host. It must
find the host reachable and the pool running, and it creates nothing. It
needs the same variables; to run only this test:
```sh
go test -tags=integration -count=1 -v -run TestKVM2Check ./internal/provider/kvm2/
```
The test is not part of `make test` and not run in CI. Keep it compiling
with `go vet -tags=integration ./...` when the provider's internals move.
## The proxmox integration test (real Proxmox VE host)
`internal/provider/proxmox/proxmox_integration_test.go` compiles only
under `-tags=integration` and skips itself unless the environment names a
host. It deviates from the testcontainers convention for the same reason
as the kvm tests: Proxmox VE is a hypervisor, not something a container
can stand in for. It needs a node with a cloud-init template without a
Proxmox cloud-init drive and with the guest agent enabled, and a token
with the privileges of [proxmox-provider-spec.md](proxmox-provider-spec.md)
entry 14: the role on `/` from the plan's "Before Phase 0" section
([2026-09-18-1502-proxmox-provider.md](plans/2026-09-18-1502-proxmox-provider.md)),
plus `Datastore.Allocate` on the ISO storage. A passing run leaves
nothing behind: its VM and its seed ISO are gone.
```sh
export PROXMOX_TEST_URL=https://192.168.0.8:8006 # host must be in the node certificate
export PROXMOX_TEST_TOKEN_FILE=$HOME/.config/proxmox-token # one line: user@realm!tokenid=secret
export PROXMOX_TEST_CA_FILE=$HOME/.config/proxmox-ca.pem # the node's pve-root-ca.pem
export PROXMOX_TEST_NODE=pv1-1
export PROXMOX_TEST_TEMPLATE_VMID=9001
export PROXMOX_TEST_ISO_STORAGE=proxy-seeds # holds iso content
export PROXMOX_TEST_VLAN_TAG=4 # template 9001's net0 is untagged; proxies live on VLAN 4
make test-integration-proxmox
```
Required: `PROXMOX_TEST_URL`, `PROXMOX_TEST_TOKEN_FILE`,
`PROXMOX_TEST_NODE`, `PROXMOX_TEST_TEMPLATE_VMID` and
`PROXMOX_TEST_ISO_STORAGE`; the test skips when any is missing. Optional:
`PROXMOX_TEST_CA_FILE` (empty trusts the system roots),
`PROXMOX_TEST_INSECURE` (`true` skips certificate verification; the log
says so), `PROXMOX_TEST_STORAGE` (the clone's target storage; empty keeps
the template's), `PROXMOX_TEST_BRIDGE` and `PROXMOX_TEST_VLAN_TAG` (the
cloned `net0`'s bridge and tag; empty keeps the template's),
`PROXMOX_TEST_VMID_MIN` and `PROXMOX_TEST_VMID_MAX` (default
`100000`..`199999`), `PROXMOX_TEST_TIMEOUT` (default `20m`, the budget for
the whole lifecycle), `PROXMOX_TEST_KEEP` to leave the VM behind, and
`PROXMOX_TEST_LOG_LEVEL` (default `1`): at `1` every request is one
`proxmox api call` line with the call, node, VMID, UPID and duration,
plus the per-operation outcome lines; `2` adds request parameters, config
summaries and agent addresses; `0` leaves only `t.Log`. Provider, node,
name and VMID are stated once and left off every provider line. Without
`Datastore.Allocate` on the ISO storage the seed delete at the end of
Delete is a 403, and Delete fails as Permanent.
What one run asserts, in order, each step logged with its duration:
1. `New` returns within 100 ms and makes no request (no `proxmox api
call` line before the first operation).
2. `Check` reports the node `Ready` with reason `Reachable` and the
`pveVersion`, `kernel`, `tokenID`, `isoStorageAvailGiB` and
`templates` (`1/1`) details, which are logged.
3. Every later call goes through `provider.WithTracing`, with an
in-memory span recorder on the transport as well.
4. `Create` returns `vms/<vmid>/<name>` with the VMID in range. The
machine type is 1 core, 1024 MiB and a 4 GiB disk, so a 3 GiB template
is grown by the resize task.
5. A second `Create` returns the same ID and the cluster's QEMU VM count
is unchanged.
6. `Get`, polled every 5 s, reaches `Running` with an IPv4 within the
budget (a Transient answer while the guest boots is logged and polled
through). Its `UID` and `CreatedAt` equal the mark in the VM's
description, read back from its config, and `CreatedAt` is within a
few minutes of now. The time from Create's return to the first address
is logged.
7. `ListByTag` contains the ID with that UID and CreatedAt.
8. A TCP connect to port 22 on the guest, logged only: the machine
running the test may not route to the VM's network.
9. `Delete` returns nil, `Get` is `ErrNotFound`, a second `Delete` is a
no-op.
10. No VM with the name is left in the cluster's resources, and no
`<isoStorage>:iso/<name>-seed.iso` in the storage's iso content. The
content is read with one extra read-only GET of
`/nodes/{node}/storage/{storage}/content?content=iso` made by the test
itself (`storage_content_iso.json` is its captured shape): the seam
has no method to list storage content, and does not get one for a
test.
11. Among the recorded spans, a `provider.create` span has at least one
`HTTP` child, and no span attribute carries the cloud-init text.
With `PROXMOX_TEST_KEEP` steps 9 and 10 are skipped, and at the end the
log prints what to run as root on the node to clean up:
`qm stop <vmid>`, `qm destroy <vmid> --purge` and
`pvesm free <isoStorage>:iso/<name>-seed.iso`. Otherwise a `t.Cleanup`
runs the Delete even when an earlier assertion failed. It finds the VM by
name, so a Create that failed halfway is cleaned up too.
Cost: one full clone of the template (about 2 s for the 3 GiB template on
pv1-1), one boot to an address (about 50 s), and a few seconds of
destroy; a run takes a couple of minutes. The Makefile target's 30 m
timeout covers the 20 m budget plus the cleanup.
`TestProxmoxCheck_realHost` runs the provider health check against the
same host. It must find the node reachable, the ISO storage usable and
the template present, and it creates nothing. It needs the same
variables; to run only this test:
```sh
go test -tags=integration -count=1 -v -run TestProxmoxCheck ./internal/provider/proxmox/
```
**Last run: 2026-09-24 on pv1-1** (Proxmox VE 9.2.20, kernel
7.0.14-17-pve, template 9001, `proxy-seeds`, VLAN tag 4). Both tests
passed; `go test` reported 60.9 s for the package.
| Step | Time |
|---|---|
| `Check` | 141 ms (125 ms in `TestProxmoxCheck_realHost`) |
| clone task, submit to done | about 3 s (seen done at the third 1 s poll) |
| `Create`, clone to started VM | 6.2 s |
| second `Create` (adoption, nothing created) | 32 ms |
| Create's return to the first address | 50.2 s (the agent install from the internet) |
| `Delete` (stop, seed delete, destroy), `Get`, second `Delete` | 3.1 s |
| `TestProxmox_lifecycle` | 59.7 s |
The first `Get`, 32 ms after the second Create, was Transient: the
listing still showed the new VM with `status: unknown`. The next poll 5 s
later saw it running. The Delete deleted the seed while the stopped VM's
`ide2` still referenced it, then destroyed the VM, and step 10 found
neither left. Like the kvm tests they are not part of `make test` and not
run in CI.
## The scaffolded `make test-e2e` suite
`test/e2e/` is the kubebuilder-generated smoke suite (build image → kind
cluster → deploy → assert the manager pod runs and serves metrics). It
cluster → deploy → assert the manager pod runs, publishes a Ready
`ProviderHealth` for the `kubernetes` provider, and serves metrics that
include that provider's `proxy_operator_provider_target_up` series). It
compiles only under `-tags=e2e`, manages its own kind cluster
(`make test-e2e` / `make cleanup-test-e2e`), and has been kept compiling
(`go vet -tags=e2e ./...` is part of the routine) but is **not part of
`make test` and was not used for the release verification** — the manual
kind run below covers strictly more. Treat it as scaffold to grow into if
CI wants an automated cluster smoke test.
kind run below covers strictly more.
### The OTel tracing spec (Tempo-gated)
`test/e2e/tracing_test.go` proves the full tracing pipeline against a
**real Tempo**: deploy in kind with tracing enabled, create two
kubernetes-provider proxies, delete them, and assert in Tempo that the
traces exist with the documented span topology (`Reconcile Proxy` →
`reconcile.managed` / `provider.create` / `status.patch`, and
`reconcile.delete` / `provider.delete` on the way out).
It **skips unless both env vars are set** (so the rest of the suite runs
anywhere). Homelab invocation:
```bash
TEMPO_URL=http://192.168.0.30:3200 \
OTLP_ENDPOINT=http://192.168.0.30:4318 \
make test-e2e
```
Worth knowing:
- Every span of a run carries the resource attribute
`test.run.id=e2e-<nanos>` (injected via `OTEL_RESOURCE_ATTRIBUTES`, no
code involved); the run ID is printed in the test log, and
`{resource.test.run.id="<id>"}` in Grafana shows exactly that run.
- The spec preflights the OTLP endpoint **from inside the cluster** with a
curl pod and fails fast with a clear message if it's unreachable —
export failures are otherwise only visible at `-zap-log-level=1`.
- Proxy CRs are created in `default`, not the operator namespace: the
squid pods carry no securityContext and the operator namespace enforces
restricted PSS.
## The kind verification run (the real end-to-end)
+18 -14
View File
@@ -4,15 +4,26 @@ go 1.26.0
require (
cloud.google.com/go/compute v1.65.0
github.com/go-logr/logr v1.4.3
github.com/digitalocean/go-libvirt v0.0.0-20260814190004-1a83157e1858
github.com/go-logr/logr v1.4.4
github.com/kdomanski/iso9660 v0.4.0
github.com/onsi/ginkgo/v2 v2.27.4
github.com/onsi/gomega v1.39.0
github.com/prometheus/client_golang v1.23.2
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
go.opentelemetry.io/otel v1.45.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0
go.opentelemetry.io/otel/sdk v1.45.0
go.opentelemetry.io/otel/trace v1.45.0
golang.org/x/crypto v0.54.0
google.golang.org/api v0.292.0
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af
k8s.io/api v0.36.0
k8s.io/apimachinery v0.36.0
k8s.io/client-go v0.36.0
k8s.io/klog/v2 v2.140.0
sigs.k8s.io/controller-runtime v0.24.1
sigs.k8s.io/yaml v1.6.0
)
@@ -31,7 +42,7 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -48,7 +59,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.19 // indirect
github.com/googleapis/gax-go/v2 v2.23.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -66,19 +77,13 @@ require (
github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.57.0 // indirect
@@ -91,7 +96,7 @@ require (
golang.org/x/tools v0.47.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/grpc v1.83.0 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
@@ -100,7 +105,6 @@ require (
k8s.io/apiextensions-apiserver v0.36.0 // indirect
k8s.io/apiserver v0.36.0 // indirect
k8s.io/component-base v0.36.0 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/streaming v0.36.0 // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
+34 -26
View File
@@ -28,14 +28,16 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/digitalocean/go-libvirt v0.0.0-20260814190004-1a83157e1858 h1:8xCFt73OddCD5WXxoYidJBWp3bJMC+CpE7Zwf5tcwk8=
github.com/digitalocean/go-libvirt v0.0.0-20260814190004-1a83157e1858/go.mod h1:qb0Ofa71d3oXARQf633h2tNaeBxLsVxuDp+jcsVO2+4=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
@@ -47,8 +49,8 @@ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6O
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
@@ -86,8 +88,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.19 h1:mMOE7DN2+p76/EdIrm
github.com/googleapis/enterprise-certificate-proxy v0.3.19/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE=
github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@@ -96,6 +98,8 @@ github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kdomanski/iso9660 v0.4.0 h1:BPKKdcINz3m0MdjIMwS0wx1nofsOjxOq8TOr45WGHFg=
github.com/kdomanski/iso9660 v0.4.0/go.mod h1:OxUSupHsO9ceI8lBLPJKWBTphLemjrCQY8LPXM7qSzU=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@@ -170,24 +174,28 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MCxGz8+2VtrN/WgqSpJFctVz24gpxj8CxkKmc8Ww=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0/go.mod h1:BmAYTn+3ysbRe+IU2msxmf5Rx3g6DHvex+tWI3LdhYI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -228,8 +236,8 @@ google.golang.org/api v0.292.0 h1:Ewiwo/GTtiaPZSNAZQUcWLh8AYDEoPmIXyJfeoTSMHU=
google.golang.org/api v0.292.0/go.mod h1:07kjmMnFGm2RQuCza2EZM/5N68G/fVvFb1xKjWqoFA0=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
+46 -9
View File
@@ -41,6 +41,7 @@ import (
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/health"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing"
)
// HealthSnapshotter provides the current probe verdict for a proxy. The
@@ -96,9 +97,14 @@ func (r *ProxyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res
}
base := p.DeepCopy()
defer func() {
// Runs inside the root reconcile span (this defer fires before the
// tracing.NewReconciler wrapper sees the return), and its error is
// folded into err, which that wrapper records.
pctx, span := tracing.Start(ctx, "status.patch")
defer span.End()
// NotFound is expected when this reconcile just removed the last
// finalizer and the object is already gone.
if perr := r.patchStatusIfChanged(ctx, base, &p); perr != nil && !apierrors.IsNotFound(perr) {
if perr := r.patchStatusIfChanged(pctx, base, &p); perr != nil && !apierrors.IsNotFound(perr) {
err = errors.Join(err, perr)
}
}()
@@ -114,6 +120,8 @@ func (r *ProxyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res
}
func (r *ProxyReconciler) reconcileManaged(ctx context.Context, p *crawlv1alpha1.Proxy) (ctrl.Result, error) {
ctx, span := tracing.Start(ctx, "reconcile.managed")
defer span.End()
log := logf.FromContext(ctx)
if controllerutil.AddFinalizer(p, crawlv1alpha1.FinalizerName) {
@@ -224,6 +232,8 @@ func (r *ProxyReconciler) reconcileManaged(ctx context.Context, p *crawlv1alpha1
// exists" — hence: delete, poll to NotFound, only then advance the hash and
// let the create branch run.
func (r *ProxyReconciler) replaceInstance(ctx context.Context, p *crawlv1alpha1.Proxy, prov provider.Provider, hash string) (ctrl.Result, error) {
ctx, span := tracing.Start(ctx, "reconcile.replaceInstance")
defer span.End()
_, err := prov.Get(ctx, p.Status.ProviderID)
if provider.Class(err) == provider.ErrNotFound {
// Old instance is gone. The Update inside setSpecHash refreshes p
@@ -252,14 +262,22 @@ func (r *ProxyReconciler) replaceInstance(ctx context.Context, p *crawlv1alpha1.
}
func (r *ProxyReconciler) reconcileDelete(ctx context.Context, p *crawlv1alpha1.Proxy) (ctrl.Result, error) {
ctx, span := tracing.Start(ctx, "reconcile.delete")
defer span.End()
if !controllerutil.ContainsFinalizer(p, crawlv1alpha1.FinalizerName) {
return ctrl.Result{}, nil
}
if p.Status.ProviderID == "" {
// Nothing was ever recorded as created; orphan GC reaps any stray
// instance a crashed create might have left behind.
controllerutil.RemoveFinalizer(p, crawlv1alpha1.FinalizerName)
return ctrl.Result{}, r.Update(ctx, p)
return r.releaseFinalizer(ctx, p)
}
if p.Annotations[crawlv1alpha1.AnnotationSkipCleanup] == "true" {
// Checked before the provider lookup so it also releases a Proxy whose
// provider is gone from the config. The instance is left running.
logf.FromContext(ctx).Info("skip-cleanup annotation set; abandoning the instance",
"providerID", p.Status.ProviderID, "provider", p.Spec.Provider)
return r.releaseFinalizer(ctx, p)
}
prov, ok := r.Providers[p.Spec.Provider]
if !ok {
@@ -267,11 +285,22 @@ func (r *ProxyReconciler) reconcileDelete(ctx context.Context, p *crawlv1alpha1.
"provider %q is not configured; cannot clean up instance %s", p.Spec.Provider, p.Status.ProviderID)
}
_, err := prov.Get(ctx, p.Status.ProviderID)
if provider.Class(err) == provider.ErrNotFound {
controllerutil.RemoveFinalizer(p, crawlv1alpha1.FinalizerName)
return ctrl.Result{}, r.Update(ctx, p)
}
if err != nil {
switch {
case err == nil:
case provider.Class(err) == provider.ErrNotFound:
return r.releaseFinalizer(ctx, p)
case provider.Class(err) == provider.ErrPermanent:
// Get will never answer for this instance — a host retired from the
// config, a providerID the provider can no longer resolve. Delete is
// idempotent and may still finish, so try it instead of polling a Get
// that cannot converge, and release the object when it does.
if delErr := prov.Delete(ctx, p.Status.ProviderID); delErr != nil {
return r.deletionFailure(errors.Join(err, delErr))
}
logf.FromContext(ctx).Info("instance unreachable but cleanup succeeded; removing the finalizer",
"providerID", p.Status.ProviderID, "error", err.Error())
return r.releaseFinalizer(ctx, p)
default:
return r.deletionFailure(err)
}
if err := prov.Delete(ctx, p.Status.ProviderID); err != nil {
@@ -282,6 +311,11 @@ func (r *ProxyReconciler) reconcileDelete(ctx context.Context, p *crawlv1alpha1.
return ctrl.Result{RequeueAfter: r.DeletionPoll}, nil
}
func (r *ProxyReconciler) releaseFinalizer(ctx context.Context, p *crawlv1alpha1.Proxy) (ctrl.Result, error) {
controllerutil.RemoveFinalizer(p, crawlv1alpha1.FinalizerName)
return ctrl.Result{}, r.Update(ctx, p)
}
func (r *ProxyReconciler) reconcileExternal(_ context.Context, p *crawlv1alpha1.Proxy) (ctrl.Result, error) {
if p.Spec.Endpoint == nil {
// CEL guarantees an endpoint on any object that went through the API
@@ -409,7 +443,10 @@ func (r *ProxyReconciler) SetupWithManager(mgr ctrl.Manager) error {
if r.HealthEvents != nil {
b = b.WatchesRawSource(source.Channel(r.HealthEvents, &handler.EnqueueRequestForObject{}))
}
return b.Complete(r)
// Root span per reconcile; sub-reconcilers and the status flush hang
// their spans off it. Tests calling r.Reconcile directly bypass the
// wrapper and see no-op spans — the global tracer is never set there.
return b.Complete(tracing.NewReconciler("Proxy", r))
}
func (r *ProxyReconciler) applyDefaults() {
+67
View File
@@ -2,6 +2,7 @@ package controller
import (
"context"
"slices"
"testing"
"time"
@@ -78,6 +79,10 @@ func notFoundErr() error {
return provider.Wrap(provider.ErrNotFound, "get", "stub", "some-id", nil)
}
func permanentErr() error {
return provider.Wrap(provider.ErrPermanent, "get", "stub", "some-id", nil)
}
func testScheme(t *testing.T) *runtime.Scheme {
t.Helper()
s := runtime.NewScheme()
@@ -125,6 +130,15 @@ func withSpecHashAnnotation(hash string) func(*crawlv1alpha1.Proxy) {
}
}
func withSkipCleanup() func(*crawlv1alpha1.Proxy) {
return func(p *crawlv1alpha1.Proxy) {
if p.Annotations == nil {
p.Annotations = map[string]string{}
}
p.Annotations[crawlv1alpha1.AnnotationSkipCleanup] = "true"
}
}
func deleting() func(*crawlv1alpha1.Proxy) {
return func(p *crawlv1alpha1.Proxy) {
now := metav1.Now()
@@ -375,6 +389,59 @@ func TestReconcile_actionTable(t *testing.T) {
assertProxyGone(t, r)
},
},
{
// A permanent Get (a retired KVM host, a providerID the provider
// cannot resolve) used to retry forever with the finalizer on.
name: "deletion with an unreachable instance still deletes and releases",
proxy: managedProxy(deleting(), withProviderID("stub-id-1")),
stub: &stubProvider{getErr: permanentErr()},
wantResult: ctrl.Result{},
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
assertProxyGone(t, r)
if stub.deleteCalls != 1 {
t.Errorf("deleteCalls = %d, want 1 — Delete must be tried when Get cannot answer", stub.deleteCalls)
}
},
},
{
name: "deletion keeps the finalizer when cleanup itself fails",
proxy: managedProxy(deleting(), withProviderID("stub-id-1")),
stub: &stubProvider{
getErr: permanentErr(),
deleteErr: provider.Wrap(provider.ErrPermanent, "delete", "stub", "stub-id-1", nil),
},
wantResult: ctrl.Result{},
wantErr: true,
verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) {
// getProxy fails the test if the object is gone, which is what
// finalizer removal would have caused.
if p := getProxy(t, r); !slices.Contains(p.Finalizers, crawlv1alpha1.FinalizerName) {
t.Error("finalizer removed although cleanup failed; the instance would leak silently")
}
},
},
{
name: "skip-cleanup annotation abandons the instance",
proxy: managedProxy(deleting(), withProviderID("stub-id-1"), withSkipCleanup()),
stub: &stubProvider{getInst: &provider.Instance{ID: "stub-id-1", State: provider.StateRunning}},
wantResult: ctrl.Result{},
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
assertProxyGone(t, r)
if stub.getCalls != 0 || stub.deleteCalls != 0 {
t.Errorf("getCalls/deleteCalls = %d/%d, want 0/0 — the provider must not be asked", stub.getCalls, stub.deleteCalls)
}
},
},
{
name: "skip-cleanup annotation releases a proxy whose provider is gone",
proxy: managedProxy(deleting(), withProviderID("stub-id-1"), withSkipCleanup(),
func(p *crawlv1alpha1.Proxy) { p.Spec.Provider = "retired" }),
stub: &stubProvider{},
wantResult: ctrl.Result{},
verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) {
assertProxyGone(t, r)
},
},
{
name: "deletion deletes the instance and polls",
proxy: managedProxy(deleting(), withProviderID("stub-id-1")),
+7 -3
View File
@@ -11,6 +11,7 @@ import (
apimeta "k8s.io/apimachinery/pkg/api/meta"
"sigs.k8s.io/controller-runtime/pkg/client"
logf "sigs.k8s.io/controller-runtime/pkg/log"
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/lease"
@@ -82,7 +83,10 @@ func (s *Server) handleListProxies(w http.ResponseWriter, r *http.Request) {
var list crawlv1alpha1.ProxyList
if err := s.Reader.List(r.Context(), &list); err != nil {
s.log.Error(err, "listing proxies")
// The ctx logger is s.log enriched with this request's
// traceID/spanID by the tracing middleware (logr sinks can't read
// ctx at log time, so per-request values ride on the logger).
logf.FromContext(r.Context()).Error(err, "listing proxies")
writeError(w, http.StatusInternalServerError, "internal", "listing proxies failed")
return
}
@@ -138,7 +142,7 @@ func (s *Server) handleAcquireLease(w http.ResponseWriter, r *http.Request) {
var list crawlv1alpha1.ProxyList
if err := s.Reader.List(r.Context(), &list); err != nil {
s.log.Error(err, "listing proxies for lease")
logf.FromContext(r.Context()).Error(err, "listing proxies for lease")
writeError(w, http.StatusInternalServerError, "internal", "listing proxies failed")
return
}
@@ -231,7 +235,7 @@ func (s *Server) handleReportLease(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "unknown_lease", "no such lease")
return
}
s.log.Error(err, "reporting lease", "leaseID", r.PathValue("id"))
logf.FromContext(r.Context()).Error(err, "reporting lease", "leaseID", r.PathValue("id"))
writeError(w, http.StatusInternalServerError, "internal", "report failed")
return
}
+12 -3
View File
@@ -9,6 +9,7 @@ import (
"context"
"crypto/subtle"
"encoding/json"
"log/slog"
"net"
"net/http"
"strings"
@@ -20,6 +21,7 @@ import (
logf "sigs.k8s.io/controller-runtime/pkg/log"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/lease"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing"
)
// LeaseStore is what the handlers need from a lease backend. Defined here,
@@ -110,7 +112,11 @@ func (s *Server) Start(ctx context.Context) error {
s.mu.Unlock()
srv := &http.Server{
Handler: s.handler(),
Handler: s.handler(),
// Without this, net/http's own faults (a failed Accept, a panic that
// escapes a handler) go to the stdlib logger on stderr, outside the
// structured stream.
ErrorLog: slog.NewLogLogger(logr.ToSlogHandler(s.log), slog.LevelError),
ReadHeaderTimeout: readHeadTimeout,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
@@ -136,7 +142,9 @@ func (s *Server) Start(ctx context.Context) error {
}
// handler assembles the mux and the middleware chain, outermost first:
// recover → request-log → body-size cap → bearer auth.
// recover → tracing (server span + request logger) → request-log →
// body-size cap → bearer auth. Everything inside the tracing layer logs via
// logf.FromContext(r.Context()) and so carries traceID/spanID.
func (s *Server) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
@@ -152,6 +160,7 @@ func (s *Server) handler() http.Handler {
h = s.authMiddleware(h)
h = maxBytesMiddleware(h)
h = s.logMiddleware(h)
h = tracing.HTTPMiddleware("discovery", s.log)(h)
h = s.recoverMiddleware(h)
return h
}
@@ -188,7 +197,7 @@ func (s *Server) logMiddleware(next http.Handler) http.Handler {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
start := time.Now()
next.ServeHTTP(rec, r)
s.log.Info("request",
logf.FromContext(r.Context()).Info("request",
"method", r.Method, "path", r.URL.Path,
"status", rec.status, "duration", time.Since(start).String())
})
+12
View File
@@ -12,8 +12,11 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"go.opentelemetry.io/otel/attribute"
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing"
)
// Sweeper is the manager Runnable running the sweep loop.
@@ -81,6 +84,8 @@ func (s *Sweeper) Start(ctx context.Context) error {
// that deletion, and GC racing it would double-delete. A UID becomes
// orphan-eligible only once the object is fully gone.
func (s *Sweeper) sweep(ctx context.Context) {
ctx, span := tracing.Start(ctx, "gc.sweep")
defer span.End()
log := logf.FromContext(ctx).WithName("orphan-gc")
var list crawlv1alpha1.ProxyList
@@ -95,6 +100,7 @@ func (s *Sweeper) sweep(ctx context.Context) {
live[string(list.Items[i].UID)] = true
}
deleted := 0
for name, prov := range s.Providers {
instances, err := prov.ListByTag(ctx)
if err != nil {
@@ -119,7 +125,13 @@ func (s *Sweeper) sweep(ctx context.Context) {
if err := prov.Delete(ctx, inst.ID); err != nil {
log.Error(err, "deleting orphaned instance",
"provider", name, "providerID", inst.ID, "uid", inst.UID)
continue
}
deleted++
}
}
span.SetAttributes(
attribute.Int("gc.providers", len(s.Providers)),
attribute.Int("gc.deleted", deleted),
)
}
+32 -2
View File
@@ -10,6 +10,9 @@ import (
"sync"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
@@ -18,6 +21,7 @@ import (
logf "sigs.k8s.io/controller-runtime/pkg/log"
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing"
)
// Snapshot is the engine's current verdict for one proxy, read by the
@@ -99,6 +103,10 @@ type Engine struct {
// transition-only by design, so metrics are where high-frequency
// signal (true probe recency, every latency sample) lives.
Metrics ProbeMetrics
// TraceProbes emits one root span per probe (--trace-health-probes).
// Off by default: probes run about once per second per proxy and would
// dominate trace volume.
TraceProbes bool
probeFn func(context.Context, *url.URL, crawlv1alpha1.HealthCheckSpec, *tls.Config) probeResult
@@ -155,8 +163,7 @@ func (e *Engine) Start(ctx context.Context) error {
case <-ctx.Done():
return
case job := <-jobs:
res := e.probeFn(ctx, job.proxyURL, job.hc, e.ProbeTLSConfig)
e.record(job, res, time.Now())
e.record(job, e.runProbe(ctx, job), time.Now())
}
}
})
@@ -264,6 +271,29 @@ func newState(p *crawlv1alpha1.Proxy, now time.Time, interval time.Duration) *st
return st
}
// runProbe executes one probe, inside its own root span when TraceProbes is
// on. The span ends before record folds the result (record stays ctx-free),
// and the probe transport is deliberately uninstrumented — no traceparent
// must ever leak through a proxy toward external targets.
func (e *Engine) runProbe(ctx context.Context, job probeJob) probeResult {
if !e.TraceProbes {
return e.probeFn(ctx, job.proxyURL, job.hc, e.ProbeTLSConfig)
}
ctx, span := tracing.Start(ctx, "health.probe",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(attribute.String("proxy", job.key.String())))
defer span.End()
res := e.probeFn(ctx, job.proxyURL, job.hc, e.ProbeTLSConfig)
span.SetAttributes(
attribute.Bool("probe.ok", res.ok),
attribute.Int64("probe.latency_ms", res.latency.Milliseconds()),
)
if !res.ok {
span.SetStatus(codes.Error, res.err.Error())
}
return res
}
// record folds one probe result into the proxy's threshold state and emits
// an event when the result is status-affecting: a first-ever verdict, a
// threshold-crossing flip, or a material latency change (beyond
+48
View File
@@ -117,3 +117,51 @@ func (c *constCollector) Collect(ch chan<- prometheus.Metric) {
ch <- prometheus.MustNewConstMetric(c.desc, prometheus.GaugeValue, float64(value), label)
}
}
// ProviderTarget is one provider health-check target as the gauges see it.
type ProviderTarget struct {
Provider, Target string
Up bool
Duration time.Duration
CheckedAt time.Time
}
// RegisterProviderTargets registers the provider-check gauges. Like the
// other scrape-time collectors they are read from the source at every
// scrape, so a target that goes away stops being reported rather than
// leaving a stale series behind.
func RegisterProviderTargets(reg prometheus.Registerer, read func() []ProviderTarget) error {
labels := []string{"provider", "target"}
return reg.Register(&providerTargetCollector{
up: prometheus.NewDesc("proxy_operator_provider_target_up",
"Whether the target passed its latest provider health check (1) or not (0).", labels, nil),
duration: prometheus.NewDesc("proxy_operator_provider_check_duration_seconds",
"Duration of the target's latest provider health check.", labels, nil),
checked: prometheus.NewDesc("proxy_operator_provider_check_timestamp_seconds",
"Unix time of the target's latest provider health check.", labels, nil),
read: read,
})
}
type providerTargetCollector struct {
up, duration, checked *prometheus.Desc
read func() []ProviderTarget
}
func (c *providerTargetCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.up
ch <- c.duration
ch <- c.checked
}
func (c *providerTargetCollector) Collect(ch chan<- prometheus.Metric) {
for _, t := range c.read() {
up := 0.0
if t.Up {
up = 1
}
ch <- prometheus.MustNewConstMetric(c.up, prometheus.GaugeValue, up, t.Provider, t.Target)
ch <- prometheus.MustNewConstMetric(c.duration, prometheus.GaugeValue, t.Duration.Seconds(), t.Provider, t.Target)
ch <- prometheus.MustNewConstMetric(c.checked, prometheus.GaugeValue, float64(t.CheckedAt.UnixNano())/1e9, t.Provider, t.Target)
}
}
+42
View File
@@ -111,3 +111,45 @@ func TestRegister_freshRegistryPerTest(t *testing.T) {
register(t, m1, nil, 0)
register(t, m2, nil, 0)
}
func TestRegisterProviderTargets_scrape(t *testing.T) {
t.Parallel()
checked := time.Unix(1757602800, 0)
targets := []ProviderTarget{
{Provider: "kvm-home", Target: "nas1", Up: true, Duration: 84 * time.Millisecond, CheckedAt: checked},
{Provider: "kvm-home", Target: "nas2", Up: false, Duration: 15 * time.Second, CheckedAt: checked},
}
reg := prometheus.NewRegistry()
if err := RegisterProviderTargets(reg, func() []ProviderTarget { return targets }); err != nil {
t.Fatalf("RegisterProviderTargets: %v", err)
}
expected := `
# HELP proxy_operator_provider_check_duration_seconds Duration of the target's latest provider health check.
# TYPE proxy_operator_provider_check_duration_seconds gauge
proxy_operator_provider_check_duration_seconds{provider="kvm-home",target="nas1"} 0.084
proxy_operator_provider_check_duration_seconds{provider="kvm-home",target="nas2"} 15
# HELP proxy_operator_provider_check_timestamp_seconds Unix time of the target's latest provider health check.
# TYPE proxy_operator_provider_check_timestamp_seconds gauge
proxy_operator_provider_check_timestamp_seconds{provider="kvm-home",target="nas1"} 1.7576028e+09
proxy_operator_provider_check_timestamp_seconds{provider="kvm-home",target="nas2"} 1.7576028e+09
# HELP proxy_operator_provider_target_up Whether the target passed its latest provider health check (1) or not (0).
# TYPE proxy_operator_provider_target_up gauge
proxy_operator_provider_target_up{provider="kvm-home",target="nas1"} 1
proxy_operator_provider_target_up{provider="kvm-home",target="nas2"} 0
`
if err := testutil.GatherAndCompare(reg, strings.NewReader(expected)); err != nil {
t.Error(err)
}
}
func TestRegisterProviderTargets_emptySnapshotHasNoSeries(t *testing.T) {
t.Parallel()
reg := prometheus.NewRegistry()
if err := RegisterProviderTargets(reg, func() []ProviderTarget { return nil }); err != nil {
t.Fatalf("RegisterProviderTargets: %v", err)
}
if n, err := testutil.GatherAndCount(reg); err != nil || n != 0 {
t.Errorf("series before the first check = %d (%v), want 0", n, err)
}
}
+46
View File
@@ -0,0 +1,46 @@
package provider
import (
"context"
"time"
)
// Reasons shared by every Checker, so a listing reads the same across
// providers. Providers add their own for failures only they can tell
// apart (KVM's SSHHandshakeFailed or PoolNotActive, say).
const (
ReasonReachable = "Reachable"
ReasonTimeout = "Timeout"
ReasonUnreachable = "Unreachable"
ReasonUnauthorized = "Unauthorized"
ReasonForbidden = "Forbidden"
ReasonAPIError = "APIError"
)
// TargetStatus is one target's verdict from a Checker.
type TargetStatus struct {
// Name identifies the target within its provider: a KVM host name,
// "apiserver", a GCP project.
Name string
// Address is what the check connected to, for humans.
Address string
Ready bool
// Reason is ReasonReachable when Ready, otherwise why not.
Reason string
// Message is human-readable detail, usually the error.
Message string
Latency time.Duration
// Details are provider-specific facts gathered by the check.
Details map[string]string
}
// Checker is the optional interface of a provider that can verify its
// backend is reachable and usable without touching any instance. The
// provider health monitor type-asserts for it on the unwrapped providers,
// so it stays out of the Provider contract.
type Checker interface {
// Check returns one verdict per target, in a stable order. It never
// fails as a whole — a failure is a verdict — and must return promptly
// once ctx ends.
Check(ctx context.Context) []TargetStatus
}
+55 -13
View File
@@ -21,6 +21,34 @@ type ProviderConfig struct {
Type string `json:"type"`
Kubernetes *KubernetesConfig `json:"kubernetes,omitempty"`
GCP *GCPConfig `json:"gcp,omitempty"`
KVM *KVMConfig `json:"kvm,omitempty"`
// KVM2 is the same block shape as KVM, for the kvm2 provider type: one
// or more libvirt/KVM hypervisors reached over SSH.
KVM2 *KVMConfig `json:"kvm2,omitempty"`
// Proxmox is a Proxmox VE cluster reached through its REST API.
Proxmox *ProxmoxConfig `json:"proxmox,omitempty"`
}
// setBlocks names the type-specific blocks that are non-nil, so validate can
// reject a block that doesn't match Type without a pairwise check per type.
func (p ProviderConfig) setBlocks() []string {
var out []string
if p.Kubernetes != nil {
out = append(out, "kubernetes")
}
if p.GCP != nil {
out = append(out, "gcp")
}
if p.KVM != nil {
out = append(out, "kvm")
}
if p.KVM2 != nil {
out = append(out, "kvm2")
}
if p.Proxmox != nil {
out = append(out, "proxmox")
}
return out
}
// KubernetesConfig configures the kubernetes-pod provider, which creates
@@ -81,22 +109,36 @@ func (c *Config) validate() error {
}
seen[p.Name] = true
prefix := fmt.Sprintf("providers[%d] %q", i, p.Name)
switch p.Type {
case "kubernetes":
if p.GCP != nil {
return fmt.Errorf("providers[%d] %q: type is kubernetes but a gcp block is set", i, p.Name)
}
case "gcp":
if p.Kubernetes != nil {
return fmt.Errorf("providers[%d] %q: type is gcp but a kubernetes block is set", i, p.Name)
}
if p.GCP == nil || p.GCP.Project == "" {
return fmt.Errorf("providers[%d] %q: gcp.project is required", i, p.Name)
}
case "kubernetes", "gcp", "kvm", "kvm2", "proxmox":
case "":
return fmt.Errorf("providers[%d] %q: type is required", i, p.Name)
return fmt.Errorf("%s: type is required", prefix)
default:
return fmt.Errorf("providers[%d] %q: unknown provider type %q", i, p.Name, p.Type)
return fmt.Errorf("%s: unknown provider type %q", prefix, p.Type)
}
for _, block := range p.setBlocks() {
if block != p.Type {
return fmt.Errorf("%s: type is %s but a %s block is set", prefix, p.Type, block)
}
}
switch p.Type {
case "gcp":
if p.GCP == nil || p.GCP.Project == "" {
return fmt.Errorf("%s: gcp.project is required", prefix)
}
case "kvm":
if err := validateKVM(prefix, p.KVM); err != nil {
return err
}
case "kvm2":
if err := validateKVMBlock(prefix, "kvm2", p.KVM2); err != nil {
return err
}
case "proxmox":
if err := validateProxmox(prefix, p.Proxmox); err != nil {
return err
}
}
}
return nil
+197
View File
@@ -0,0 +1,197 @@
package provider
import (
"fmt"
"net/url"
"regexp"
)
// KVMConfig configures a kvm provider instance: one or more libvirt/KVM
// hypervisors reached over SSH (the libvirt unix socket is forwarded through
// the SSH connection, so hosts need no libvirtd TCP/TLS listener).
// placement.zone on a Proxy selects a host by name; with a single host it may
// be omitted.
type KVMConfig struct {
// SSHKeyFile is the path to the private key used for every host. Required.
SSHKeyFile string `json:"sshKeyFile"`
// KnownHostsFile is an OpenSSH known_hosts file used to verify host keys.
// Required unless InsecureIgnoreHostKey is set.
KnownHostsFile string `json:"knownHostsFile,omitempty"`
// InsecureIgnoreHostKey skips host-key verification entirely.
InsecureIgnoreHostKey bool `json:"insecureIgnoreHostKey,omitempty"`
// DiskSizeGB is the capacity of each VM's overlay disk. Default 10. The
// guest's cloud-init growpart expands the root filesystem into it.
DiskSizeGB int64 `json:"diskSizeGb,omitempty"`
// VendorData is optional cloud-init vendor-data placed on every seed next
// to the Proxy's own user-data. Note that cloud-init drops any top-level
// key of a vendor-data cloud-config that the user-data also defines.
// When InstallGuestAgent is on, this document and the agent script are
// carried as separate parts of a MIME multipart vendor-data.
VendorData string `json:"vendorData,omitempty"`
// InstallGuestAgent adds a vendor-data script that installs and starts
// qemu-guest-agent, which is how the provider learns a VM's address
// before the host's ARP table has seen traffic from it. Default true.
// Turn it off for images that already ship the agent, or that cannot
// reach a package mirror; discovery then depends on ARP alone.
InstallGuestAgent *bool `json:"installGuestAgent,omitempty"`
// Images are named base images; placement.image names a key here (or is
// an http(s) URL directly). The provider downloads each image into a
// host's storage pool on first use and caches it there.
Images map[string]KVMImage `json:"images,omitempty"`
// MachineTypes are named size profiles; placement.machineType names a
// key here. Required, at least one.
MachineTypes map[string]KVMMachineType `json:"machineTypes"`
// Hosts are the hypervisors. Required, at least one, unique names.
Hosts []KVMHost `json:"hosts"`
}
// KVMImage is one downloadable base image (a distro cloud image with
// cloud-init preinstalled, e.g. Debian's generic qcow2).
type KVMImage struct {
// URL is where to fetch the image from. Required, http or https.
URL string `json:"url"`
// SHA256 is the expected hex digest of the file. Optional; when set it is
// verified after download and a later change triggers a re-download.
SHA256 string `json:"sha256,omitempty"`
// Format is the image's disk format, "qcow2" (default) or "raw".
Format string `json:"format,omitempty"`
// Firmware is how VMs from this image boot: "uefi" (default; OVMF with
// secure boot off) or "bios" (SeaBIOS) for images with no EFI system
// partition. Current Debian, Ubuntu, Fedora and Rocky cloud images all
// boot UEFI, and Debian 13's BIOS path was found not to boot at all.
Firmware string `json:"firmware,omitempty"`
}
// KVMMachineType is a VM size profile.
type KVMMachineType struct {
VCPUs int32 `json:"vcpus"`
MemoryMiB int64 `json:"memoryMiB"`
}
// KVMHost is one hypervisor.
type KVMHost struct {
// Name is the placement.zone value that selects this host. Required.
Name string `json:"name"`
// Address is the SSH endpoint, host:port. Required.
Address string `json:"address"`
// User is the SSH user; it must be allowed to use the libvirt socket
// (typically a member of the libvirt group). Required.
User string `json:"user"`
// Socket is the libvirt unix socket path on the host.
// Default /var/run/libvirt/libvirt-sock.
Socket string `json:"socket,omitempty"`
// Pool is the libvirt storage pool for base images, overlay disks and
// seed ISOs. Default "default".
Pool string `json:"pool,omitempty"`
// Bridge is the host bridge the VM's NIC attaches to; it must lead to a
// network the cluster can reach and that hands out DHCP leases. Required.
Bridge string `json:"bridge"`
}
// Accepted KVMImage.Format and KVMImage.Firmware values.
const (
KVMFormatQcow2 = "qcow2"
KVMFormatRaw = "raw"
KVMFirmwareUEFI = "uefi"
KVMFirmwareBIOS = "bios"
)
// IsHTTPURL reports whether s is an absolute http(s) URL with a host, the
// only kind of image source the kvm provider can fetch.
func IsHTTPURL(s string) bool {
u, err := url.Parse(s)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
var sha256Hex = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
// kvmIdentifier constrains the config names that are pasted into
// identifiers elsewhere: a host name goes into the providerID
// hosts/<name>/domains/<vm>, which is split on "/" to parse, and an image
// key becomes part of the file name of the cached base volume. A slash in
// either produces an ID or a path that never resolves again, so the VM
// leaks with nothing able to address it.
var kvmIdentifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
// IsKVMIdentifier reports whether s is safe to paste into a providerID or a
// volume name. The provider entry's own name reaches a volume name too, and
// that name is validated for every provider type at once (only non-empty
// and unique), so the kvm2 provider checks its own at construction.
func IsKVMIdentifier(s string) bool { return kvmIdentifier.MatchString(s) }
func validateKVM(prefix string, c *KVMConfig) error {
return validateKVMBlock(prefix, "kvm", c)
}
// validateKVMBlock checks a KVMConfig under the given block name, so the
// kvm and kvm2 provider types share one validator and each reports its own
// block in messages.
func validateKVMBlock(prefix, block string, c *KVMConfig) error {
if c == nil {
return fmt.Errorf("%s: %s block is required", prefix, block)
}
if c.SSHKeyFile == "" {
return fmt.Errorf("%s: %s.sshKeyFile is required", prefix, block)
}
if c.KnownHostsFile == "" && !c.InsecureIgnoreHostKey {
return fmt.Errorf("%s: %s.knownHostsFile is required unless %s.insecureIgnoreHostKey is true", prefix, block, block)
}
if c.DiskSizeGB < 0 {
return fmt.Errorf("%s: %s.diskSizeGb must not be negative", prefix, block)
}
for name, img := range c.Images {
p := fmt.Sprintf("%s: %s.images[%q]", prefix, block, name)
if !kvmIdentifier.MatchString(name) {
return fmt.Errorf("%s: key must be letters, digits, dots, dashes or underscores"+
" — it becomes part of the cached base volume's name", p)
}
if !IsHTTPURL(img.URL) {
return fmt.Errorf("%s.url must be an http(s) URL (got %q)", p, img.URL)
}
if img.SHA256 != "" && !sha256Hex.MatchString(img.SHA256) {
return fmt.Errorf("%s.sha256 must be 64 hex characters", p)
}
if img.Format != "" && img.Format != KVMFormatQcow2 && img.Format != KVMFormatRaw {
return fmt.Errorf("%s.format must be %s or %s (got %q)", p, KVMFormatQcow2, KVMFormatRaw, img.Format)
}
if img.Firmware != "" && img.Firmware != KVMFirmwareUEFI && img.Firmware != KVMFirmwareBIOS {
return fmt.Errorf("%s.firmware must be %s or %s (got %q)", p, KVMFirmwareUEFI, KVMFirmwareBIOS, img.Firmware)
}
}
if len(c.MachineTypes) == 0 {
return fmt.Errorf("%s: %s.machineTypes must define at least one profile", prefix, block)
}
for name, mt := range c.MachineTypes {
if mt.VCPUs <= 0 || mt.MemoryMiB <= 0 {
return fmt.Errorf("%s: %s.machineTypes[%q] needs vcpus > 0 and memoryMiB > 0", prefix, block, name)
}
}
if len(c.Hosts) == 0 {
return fmt.Errorf("%s: %s.hosts must list at least one host", prefix, block)
}
seen := make(map[string]bool, len(c.Hosts))
for i, h := range c.Hosts {
p := fmt.Sprintf("%s: %s.hosts[%d]", prefix, block, i)
if h.Name == "" {
return fmt.Errorf("%s: name is required", p)
}
if !kvmIdentifier.MatchString(h.Name) {
return fmt.Errorf("%s: name %q must be letters, digits, dots, dashes or underscores"+
" — it becomes part of the providerID hosts/<name>/domains/<vm>", p, h.Name)
}
if seen[h.Name] {
return fmt.Errorf("%s: duplicate host name %q", p, h.Name)
}
seen[h.Name] = true
if h.Address == "" {
return fmt.Errorf("%s %q: address is required", p, h.Name)
}
if h.User == "" {
return fmt.Errorf("%s %q: user is required", p, h.Name)
}
if h.Bridge == "" {
return fmt.Errorf("%s %q: bridge is required", p, h.Name)
}
}
return nil
}
+320
View File
@@ -0,0 +1,320 @@
package provider
import (
"strings"
"testing"
)
const validKVMYAML = `
providers:
- name: kvm-home
type: kvm
kvm:
sshKeyFile: /etc/proxy-operator/kvm/id_ed25519
knownHostsFile: /etc/proxy-operator/kvm/known_hosts
diskSizeGb: 20
images:
debian-13:
url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
sha256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
ubuntu:
url: http://mirror.local/noble.img
format: raw
machineTypes:
small: {vcpus: 1, memoryMiB: 1024}
medium: {vcpus: 2, memoryMiB: 2048}
hosts:
- name: nas1
address: nas1.home.lan:22
user: virt
bridge: br0
- name: lab2
address: 10.0.0.2:2222
user: root
socket: /run/libvirt/virtqemud-sock
pool: fast
bridge: br1
`
func TestLoadConfig_kvmValid(t *testing.T) {
t.Parallel()
cfg, err := LoadConfig([]byte(validKVMYAML))
if err != nil {
t.Fatalf("LoadConfig() error = %v, want nil", err)
}
k := cfg.Providers[0].KVM
if k == nil {
t.Fatal("providers[0].kvm = nil")
}
if k.SSHKeyFile != "/etc/proxy-operator/kvm/id_ed25519" || k.DiskSizeGB != 20 {
t.Errorf("kvm = %+v, want sshKeyFile and diskSizeGb parsed", k)
}
if got := k.Images["debian-13"].SHA256; !strings.HasPrefix(got, "0123") {
t.Errorf("images[debian-13].sha256 = %q", got)
}
if k.Images["ubuntu"].Format != "raw" {
t.Errorf("images[ubuntu].format = %q, want raw", k.Images["ubuntu"].Format)
}
if mt := k.MachineTypes["medium"]; mt.VCPUs != 2 || mt.MemoryMiB != 2048 {
t.Errorf("machineTypes[medium] = %+v, want {2 2048}", mt)
}
if len(k.Hosts) != 2 || k.Hosts[1].Socket != "/run/libvirt/virtqemud-sock" || k.Hosts[1].Pool != "fast" {
t.Errorf("hosts = %+v, want two hosts with lab2's socket and pool parsed", k.Hosts)
}
}
func TestLoadConfig_kvmInsecureHostKeyNeedsNoKnownHosts(t *testing.T) {
t.Parallel()
yaml := strings.Replace(validKVMYAML,
"knownHostsFile: /etc/proxy-operator/kvm/known_hosts",
"insecureIgnoreHostKey: true", 1)
if _, err := LoadConfig([]byte(yaml)); err != nil {
t.Fatalf("LoadConfig() error = %v, want nil", err)
}
}
func TestLoadConfig_kvmInvalid(t *testing.T) {
t.Parallel()
minimal := func(kvmBody string) string {
return "providers:\n - name: kvm-home\n type: kvm\n kvm:\n" + indent(kvmBody, 6)
}
hostsAndTypes := `
machineTypes:
small: {vcpus: 1, memoryMiB: 1024}
hosts:
- {name: nas1, address: nas1:22, user: virt, bridge: br0}
`
tests := []struct {
name string
yaml string
wantErrSub string
}{
{
name: "missing kvm block",
yaml: "providers:\n - name: kvm-home\n type: kvm\n",
wantErrSub: "kvm block is required",
},
{
name: "missing sshKeyFile",
yaml: minimal("knownHostsFile: /kh\n" + hostsAndTypes),
wantErrSub: "kvm.sshKeyFile is required",
},
{
name: "missing knownHostsFile without insecure flag",
yaml: minimal("sshKeyFile: /key\n" + hostsAndTypes),
wantErrSub: "kvm.knownHostsFile is required unless",
},
{
name: "negative disk size",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\ndiskSizeGb: -1\n" + hostsAndTypes),
wantErrSub: "diskSizeGb must not be negative",
},
{
name: "no machine types",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nhosts:\n - {name: nas1, address: nas1:22, user: virt, bridge: br0}\n"),
wantErrSub: "machineTypes must define at least one",
},
{
name: "machine type with zero memory",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1}\nhosts:\n - {name: nas1, address: nas1:22, user: virt, bridge: br0}\n"),
wantErrSub: `machineTypes["small"] needs vcpus > 0 and memoryMiB > 0`,
},
{
name: "no hosts",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\n"),
wantErrSub: "kvm.hosts must list at least one host",
},
{
name: "duplicate host name",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: nas1, address: a:22, user: virt, bridge: br0}\n - {name: nas1, address: b:22, user: virt, bridge: br0}\n"),
wantErrSub: `duplicate host name "nas1"`,
},
{
// A slash would produce hosts/dc1/rack2/domains/<vm>, which
// parseProviderID can never split back, so the VM would be
// unreachable for Get, Delete and GC alike.
name: "host name with a slash",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: dc1/rack2, address: a:22, user: virt, bridge: br0}\n"),
wantErrSub: `name "dc1/rack2" must be letters, digits, dots, dashes or underscores`,
},
{
name: "image key with a slash",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n ../etc/d: {url: https://x/y.qcow2}\n" + hostsAndTypes),
wantErrSub: `images["../etc/d"]: key must be letters, digits, dots, dashes or underscores`,
},
{
name: "host missing address",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: nas1, user: virt, bridge: br0}\n"),
wantErrSub: "address is required",
},
{
name: "host missing user",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: nas1, address: a:22, bridge: br0}\n"),
wantErrSub: "user is required",
},
{
name: "host missing bridge",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: nas1, address: a:22, user: virt}\n"),
wantErrSub: "bridge is required",
},
{
name: "image with non-http url",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n d: {url: file:///tmp/x.qcow2}\n" + hostsAndTypes),
wantErrSub: `images["d"].url must be an http(s) URL`,
},
{
name: "image with bad sha256",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n d: {url: https://x/y.qcow2, sha256: abc}\n" + hostsAndTypes),
wantErrSub: `images["d"].sha256 must be 64 hex characters`,
},
{
name: "image with unknown firmware",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n d: {url: https://x/y.qcow2, firmware: coreboot}\n" + hostsAndTypes),
wantErrSub: `images["d"].firmware must be uefi or bios`,
},
{
name: "image with unknown format",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n d: {url: https://x/y.vmdk, format: vmdk}\n" + hostsAndTypes),
wantErrSub: `images["d"].format must be qcow2 or raw`,
},
{
name: "kvm type with gcp block",
yaml: `
providers:
- name: p1
type: kvm
gcp:
project: my-project`,
wantErrSub: "type is kvm but a gcp block is set",
},
{
name: "gcp type with kvm block",
yaml: `
providers:
- name: p1
type: gcp
gcp:
project: my-project
kvm:
sshKeyFile: /key`,
wantErrSub: "type is gcp but a kvm block is set",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := LoadConfig([]byte(tc.yaml))
if err == nil {
t.Fatalf("LoadConfig() error = nil, want error containing %q", tc.wantErrSub)
}
if !strings.Contains(err.Error(), tc.wantErrSub) {
t.Errorf("LoadConfig() error = %q, want substring %q", err.Error(), tc.wantErrSub)
}
})
}
}
func TestLoadConfig_kvm2Valid(t *testing.T) {
t.Parallel()
yaml := strings.Replace(validKVMYAML, "type: kvm\n kvm:\n", "type: kvm2\n kvm2:\n", 1)
cfg, err := LoadConfig([]byte(yaml))
if err != nil {
t.Fatalf("LoadConfig() error = %v, want nil", err)
}
if cfg.Providers[0].KVM != nil {
t.Errorf("providers[0].kvm = %+v, want nil", cfg.Providers[0].KVM)
}
k := cfg.Providers[0].KVM2
if k == nil {
t.Fatal("providers[0].kvm2 = nil")
}
if k.SSHKeyFile != "/etc/proxy-operator/kvm/id_ed25519" || k.KnownHostsFile != "/etc/proxy-operator/kvm/known_hosts" || k.DiskSizeGB != 20 {
t.Errorf("kvm2 = %+v, want sshKeyFile, knownHostsFile and diskSizeGb parsed", k)
}
if k.Images["ubuntu"].Format != "raw" {
t.Errorf("images[ubuntu].format = %q, want raw", k.Images["ubuntu"].Format)
}
if mt := k.MachineTypes["small"]; mt.VCPUs != 1 || mt.MemoryMiB != 1024 {
t.Errorf("machineTypes[small] = %+v, want {1 1024}", mt)
}
if len(k.Hosts) != 2 || k.Hosts[0].Name != "nas1" || k.Hosts[1].Pool != "fast" {
t.Errorf("hosts = %+v, want nas1 and lab2 with lab2's pool parsed", k.Hosts)
}
}
func TestLoadConfig_kvm2Invalid(t *testing.T) {
t.Parallel()
minimal := func(body string) string {
return "providers:\n - name: kvm2-home\n type: kvm2\n kvm2:\n" + indent(body, 6)
}
hostsAndTypes := `
machineTypes:
small: {vcpus: 1, memoryMiB: 1024}
hosts:
- {name: nas1, address: nas1:22, user: virt, bridge: br0}
`
tests := []struct {
name string
yaml string
wantErrSub string
}{
{
name: "missing kvm2 block",
yaml: "providers:\n - name: kvm2-home\n type: kvm2\n",
wantErrSub: "kvm2 block is required",
},
{
name: "missing sshKeyFile",
yaml: minimal("knownHostsFile: /kh\n" + hostsAndTypes),
wantErrSub: "kvm2.sshKeyFile is required",
},
{
name: "missing knownHostsFile without insecure flag",
yaml: minimal("sshKeyFile: /key\n" + hostsAndTypes),
wantErrSub: "kvm2.knownHostsFile is required unless kvm2.insecureIgnoreHostKey is true",
},
{
name: "no machine types",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nhosts:\n - {name: nas1, address: nas1:22, user: virt, bridge: br0}\n"),
wantErrSub: "kvm2.machineTypes must define at least one",
},
{
name: "no hosts",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\n"),
wantErrSub: "kvm2.hosts must list at least one host",
},
{
name: "duplicate host name",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: nas1, address: a:22, user: virt, bridge: br0}\n - {name: nas1, address: b:22, user: virt, bridge: br0}\n"),
wantErrSub: `kvm2.hosts[1]: duplicate host name "nas1"`,
},
{
name: "image key with a slash",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n ../etc/d: {url: https://x/y.qcow2}\n" + hostsAndTypes),
wantErrSub: `kvm2.images["../etc/d"]: key must be letters, digits, dots, dashes or underscores`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := LoadConfig([]byte(tc.yaml))
if err == nil {
t.Fatalf("LoadConfig() error = nil, want error containing %q", tc.wantErrSub)
}
if !strings.Contains(err.Error(), tc.wantErrSub) {
t.Errorf("LoadConfig() error = %q, want substring %q", err.Error(), tc.wantErrSub)
}
})
}
}
func indent(s string, n int) string {
pad := strings.Repeat(" ", n)
lines := strings.Split(strings.TrimLeft(s, "\n"), "\n")
for i, l := range lines {
if l != "" {
lines[i] = pad + l
}
}
return strings.Join(lines, "\n")
}
+179
View File
@@ -0,0 +1,179 @@
package provider
import (
"fmt"
"net/url"
"regexp"
)
// ProxmoxConfig configures a proxmox provider instance: one Proxmox VE
// cluster reached through its REST API with an API token. VMs are full
// clones of prepared templates, seeded with a NoCloud ISO uploaded to
// ISOStorage. The design is in docs/proxmox-provider-spec.md.
type ProxmoxConfig struct {
// URL is the API endpoint, https://<host>:8006. The host must be a name
// or address in the node certificate. Required.
URL string `json:"url"`
// TokenFile holds one line, <user>@<realm>!<tokenid>=<secret>, read on
// every request. Required.
TokenFile string `json:"tokenFile"`
// CAFile is a PEM bundle trusted for the API's certificate, typically the
// node's /etc/pve/pve-root-ca.pem. Empty means the system roots.
CAFile string `json:"caFile,omitempty"`
// InsecureSkipTLSVerify disables certificate verification.
InsecureSkipTLSVerify bool `json:"insecureSkipTLSVerify,omitempty"`
// Nodes are the Proxmox node names placement.zone selects from.
// Required, at least one, unique.
Nodes []string `json:"nodes"`
// ISOStorage is the storage seed ISOs are uploaded to and deleted from;
// the token needs Datastore.Allocate on it. Required.
ISOStorage string `json:"isoStorage"`
// Storage is the target storage of the full clone. Empty clones onto the
// template's own disk storage.
Storage string `json:"storage,omitempty"`
// Bridge replaces the bridge of the cloned net0. Empty keeps it.
Bridge string `json:"bridge,omitempty"`
// VLANTag sets the VLAN tag of the cloned net0, 1 to 4094. Zero keeps
// whatever the template has.
VLANTag int `json:"vlanTag,omitempty"`
// VMIDRange is where VMIDs are allocated. Default 100000 to 199999.
VMIDRange *ProxmoxVMIDRange `json:"vmidRange,omitempty"`
// Templates are the clone sources placement.image names. Required, at
// least one.
Templates map[string]ProxmoxTemplate `json:"templates"`
// MachineTypes are the size profiles placement.machineType names.
// Required, at least one.
MachineTypes map[string]ProxmoxMachineType `json:"machineTypes"`
// RequestTimeoutSeconds bounds one API request. Default 30.
RequestTimeoutSeconds int `json:"requestTimeoutSeconds,omitempty"`
// VendorData is optional cloud-init vendor-data placed on every seed next
// to the Proxy's own user-data, as for the kvm providers.
VendorData string `json:"vendorData,omitempty"`
// InstallGuestAgent adds a vendor-data script that installs and starts
// qemu-guest-agent, which is how the provider learns a VM's address.
// Default true. Turn it off for templates that ship the agent.
InstallGuestAgent *bool `json:"installGuestAgent,omitempty"`
}
// ProxmoxVMIDRange bounds the VMIDs the provider allocates, both inclusive.
type ProxmoxVMIDRange struct {
Min int `json:"min"`
Max int `json:"max"`
}
// ProxmoxTemplate is one clone source: a VM converted to a template.
type ProxmoxTemplate struct {
VMID int `json:"vmid"`
}
// ProxmoxMachineType is a VM size profile. DiskGiB grows the cloned boot
// disk when larger than the template's; zero keeps the template's size.
type ProxmoxMachineType struct {
Cores int32 `json:"cores"`
MemoryMiB int64 `json:"memoryMiB"`
DiskGiB int64 `json:"diskGiB,omitempty"`
}
// Proxmox's own limits on VMIDs and VLAN tags.
const (
ProxmoxMinVMID = 100
ProxmoxMaxVMID = 999999999
proxmoxMaxVLANTag = 4094
)
var (
// proxmoxNodeName is a Proxmox node name: a DNS label, as pvecm allows.
proxmoxNodeName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
// proxmoxStorageID is a Proxmox storage identifier.
proxmoxStorageID = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_.-]*$`)
)
func validateProxmox(prefix string, c *ProxmoxConfig) error {
if c == nil {
return fmt.Errorf("%s: proxmox block is required", prefix)
}
if err := validateProxmoxURL(c.URL); err != nil {
return fmt.Errorf("%s: proxmox.url %w", prefix, err)
}
if c.TokenFile == "" {
return fmt.Errorf("%s: proxmox.tokenFile is required", prefix)
}
if len(c.Nodes) == 0 {
return fmt.Errorf("%s: proxmox.nodes must list at least one node", prefix)
}
seen := make(map[string]bool, len(c.Nodes))
for i, n := range c.Nodes {
if !proxmoxNodeName.MatchString(n) {
return fmt.Errorf("%s: proxmox.nodes[%d] %q is not a Proxmox node name (lowercase letters, digits and dashes)", prefix, i, n)
}
if seen[n] {
return fmt.Errorf("%s: proxmox.nodes[%d]: duplicate node %q", prefix, i, n)
}
seen[n] = true
}
if c.ISOStorage == "" {
return fmt.Errorf("%s: proxmox.isoStorage is required: name a storage with iso content where the token holds Datastore.Allocate", prefix)
}
if !proxmoxStorageID.MatchString(c.ISOStorage) {
return fmt.Errorf("%s: proxmox.isoStorage %q is not a Proxmox storage id", prefix, c.ISOStorage)
}
if c.Storage != "" && !proxmoxStorageID.MatchString(c.Storage) {
return fmt.Errorf("%s: proxmox.storage %q is not a Proxmox storage id", prefix, c.Storage)
}
if c.VLANTag < 0 || c.VLANTag > proxmoxMaxVLANTag {
return fmt.Errorf("%s: proxmox.vlanTag must be between 1 and %d (got %d)", prefix, proxmoxMaxVLANTag, c.VLANTag)
}
if r := c.VMIDRange; r != nil {
if r.Min < ProxmoxMinVMID || r.Max > ProxmoxMaxVMID || r.Min >= r.Max {
return fmt.Errorf("%s: proxmox.vmidRange must satisfy %d <= min < max <= %d (got %d to %d)", prefix, ProxmoxMinVMID, ProxmoxMaxVMID, r.Min, r.Max)
}
}
if len(c.Templates) == 0 {
return fmt.Errorf("%s: proxmox.templates must define at least one template", prefix)
}
for key, t := range c.Templates {
if key == "" {
return fmt.Errorf("%s: proxmox.templates has an empty key", prefix)
}
if t.VMID < ProxmoxMinVMID {
return fmt.Errorf("%s: proxmox.templates[%q].vmid must be at least %d (got %d)", prefix, key, ProxmoxMinVMID, t.VMID)
}
}
if len(c.MachineTypes) == 0 {
return fmt.Errorf("%s: proxmox.machineTypes must define at least one profile", prefix)
}
for key, mt := range c.MachineTypes {
if key == "" {
return fmt.Errorf("%s: proxmox.machineTypes has an empty key", prefix)
}
if mt.Cores <= 0 || mt.MemoryMiB <= 0 {
return fmt.Errorf("%s: proxmox.machineTypes[%q] needs cores > 0 and memoryMiB > 0", prefix, key)
}
if mt.DiskGiB < 0 {
return fmt.Errorf("%s: proxmox.machineTypes[%q].diskGiB must not be negative", prefix, key)
}
}
if c.RequestTimeoutSeconds < 0 {
return fmt.Errorf("%s: proxmox.requestTimeoutSeconds must not be negative", prefix)
}
return nil
}
// validateProxmoxURL accepts only an https URL with a host and no path, so
// the client can append /api2/json itself.
func validateProxmoxURL(s string) error {
if s == "" {
return fmt.Errorf("is required")
}
u, err := url.Parse(s)
if err != nil {
return fmt.Errorf("does not parse: %w", err)
}
if u.Scheme != "https" || u.Host == "" {
return fmt.Errorf("must be an https URL with a host (got %q)", s)
}
if u.Path != "" && u.Path != "/" || u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("must have no path, query or fragment; the client appends /api2/json (got %q)", s)
}
return nil
}
+250
View File
@@ -0,0 +1,250 @@
package provider
import (
"os"
"slices"
"strings"
"testing"
"sigs.k8s.io/yaml"
)
const validProxmoxYAML = `
providers:
- name: proxmox-home
type: proxmox
proxmox:
url: https://192.168.0.8:8006
tokenFile: /etc/proxy-operator/proxmox/token
caFile: /etc/proxy-operator/proxmox/ca.pem
nodes: [pv1-1, pv1-2]
isoStorage: proxy-seeds
storage: local-lvm
bridge: vmbr0
vlanTag: 4
vmidRange: {min: 150000, max: 150999}
templates:
debian-13: {vmid: 9001}
machineTypes:
small: {cores: 1, memoryMiB: 1024}
large: {cores: 4, memoryMiB: 4096, diskGiB: 20}
requestTimeoutSeconds: 45
installGuestAgent: false
`
func TestLoadConfig_proxmoxValid(t *testing.T) {
t.Parallel()
cfg, err := LoadConfig([]byte(validProxmoxYAML))
if err != nil {
t.Fatalf("LoadConfig() error = %v, want nil", err)
}
c := cfg.Providers[0].Proxmox
if c == nil {
t.Fatal("providers[0].proxmox = nil")
}
if c.URL != "https://192.168.0.8:8006" || c.ISOStorage != "proxy-seeds" || c.VLANTag != 4 {
t.Errorf("proxmox = %+v, want url, isoStorage and vlanTag parsed", c)
}
if len(c.Nodes) != 2 || c.Nodes[1] != "pv1-2" {
t.Errorf("nodes = %v, want [pv1-1 pv1-2]", c.Nodes)
}
if c.VMIDRange == nil || c.VMIDRange.Min != 150000 || c.VMIDRange.Max != 150999 {
t.Errorf("vmidRange = %+v, want 150000 to 150999", c.VMIDRange)
}
if c.Templates["debian-13"].VMID != 9001 {
t.Errorf("templates[debian-13] = %+v, want vmid 9001", c.Templates["debian-13"])
}
if mt := c.MachineTypes["large"]; mt.Cores != 4 || mt.MemoryMiB != 4096 || mt.DiskGiB != 20 {
t.Errorf("machineTypes[large] = %+v, want {4 4096 20}", mt)
}
if c.InstallGuestAgent == nil || *c.InstallGuestAgent {
t.Errorf("installGuestAgent = %v, want explicit false", c.InstallGuestAgent)
}
}
func TestValidateProxmox_table(t *testing.T) {
t.Parallel()
block := func(body string) string {
return "providers:\n - name: proxmox-home\n type: proxmox\n proxmox:\n" + indent(body, 6)
}
const base = `
url: https://192.168.0.8:8006
tokenFile: /t
nodes: [pv1-1]
isoStorage: proxy-seeds
templates: {debian-13: {vmid: 9001}}
machineTypes: {small: {cores: 1, memoryMiB: 1024}}
`
with := func(old, repl string) string {
if !strings.Contains(base, old) {
t.Fatalf("base has no %q", old)
}
return block(strings.Replace(base, old, repl, 1))
}
tests := []struct {
name string
yaml string
wantErrSub string
}{
{name: "valid minimal", yaml: block(base)},
{name: "valid full", yaml: validProxmoxYAML},
{name: "valid with trailing slash", yaml: with("url: https://192.168.0.8:8006", "url: https://192.168.0.8:8006/")},
{name: "missing block", yaml: "providers:\n - name: proxmox-home\n type: proxmox\n", wantErrSub: "proxmox block is required"},
{name: "missing url", yaml: with("url: https://192.168.0.8:8006\n", ""), wantErrSub: "proxmox.url is required"},
{name: "http url", yaml: with("https://", "http://"), wantErrSub: "must be an https URL"},
{name: "url with api path", yaml: with("8006", "8006/api2/json"), wantErrSub: "must have no path"},
{name: "missing tokenFile", yaml: with("tokenFile: /t\n", ""), wantErrSub: "proxmox.tokenFile is required"},
{name: "no nodes", yaml: with("nodes: [pv1-1]", "nodes: []"), wantErrSub: "proxmox.nodes must list at least one node"},
{name: "duplicate node", yaml: with("nodes: [pv1-1]", "nodes: [pv1-1, pv1-1]"), wantErrSub: `duplicate node "pv1-1"`},
{name: "node with a slash", yaml: with("nodes: [pv1-1]", "nodes: [pv1/1]"), wantErrSub: "is not a Proxmox node name"},
{name: "node in uppercase", yaml: with("nodes: [pv1-1]", "nodes: [PV1]"), wantErrSub: "is not a Proxmox node name"},
{name: "missing isoStorage", yaml: with("isoStorage: proxy-seeds\n", ""), wantErrSub: "proxmox.isoStorage is required"},
{name: "bad isoStorage id", yaml: with("isoStorage: proxy-seeds", "isoStorage: 1seeds"), wantErrSub: "proxmox.isoStorage \"1seeds\" is not a Proxmox storage id"},
{name: "bad storage id", yaml: with("isoStorage: proxy-seeds", "isoStorage: proxy-seeds\nstorage: a/b"), wantErrSub: "proxmox.storage \"a/b\""},
{name: "vlanTag too large", yaml: with("isoStorage: proxy-seeds", "isoStorage: proxy-seeds\nvlanTag: 4095"), wantErrSub: "proxmox.vlanTag must be between 1 and 4094"},
{name: "vlanTag negative", yaml: with("isoStorage: proxy-seeds", "isoStorage: proxy-seeds\nvlanTag: -1"), wantErrSub: "proxmox.vlanTag"},
{name: "vmidRange min below 100", yaml: with("isoStorage: proxy-seeds", "isoStorage: proxy-seeds\nvmidRange: {min: 50, max: 500}"), wantErrSub: "proxmox.vmidRange must satisfy"},
{name: "vmidRange min above max", yaml: with("isoStorage: proxy-seeds", "isoStorage: proxy-seeds\nvmidRange: {min: 2000, max: 1000}"), wantErrSub: "proxmox.vmidRange must satisfy"},
{name: "vmidRange max too large", yaml: with("isoStorage: proxy-seeds", "isoStorage: proxy-seeds\nvmidRange: {min: 100, max: 1000000000}"), wantErrSub: "proxmox.vmidRange must satisfy"},
{name: "no templates", yaml: with("templates: {debian-13: {vmid: 9001}}", "templates: {}"), wantErrSub: "proxmox.templates must define at least one template"},
{name: "template vmid below 100", yaml: with("vmid: 9001", "vmid: 99"), wantErrSub: `proxmox.templates["debian-13"].vmid must be at least 100`},
{name: "no machineTypes", yaml: with("machineTypes: {small: {cores: 1, memoryMiB: 1024}}", "machineTypes: {}"), wantErrSub: "proxmox.machineTypes must define at least one profile"},
{name: "cores zero", yaml: with("cores: 1", "cores: 0"), wantErrSub: `proxmox.machineTypes["small"] needs cores > 0`},
{name: "memory zero", yaml: with("memoryMiB: 1024", "memoryMiB: 0"), wantErrSub: "memoryMiB > 0"},
{name: "negative diskGiB", yaml: with("memoryMiB: 1024", "memoryMiB: 1024, diskGiB: -5"), wantErrSub: "diskGiB must not be negative"},
{name: "negative timeout", yaml: with("isoStorage: proxy-seeds", "isoStorage: proxy-seeds\nrequestTimeoutSeconds: -1"), wantErrSub: "proxmox.requestTimeoutSeconds must not be negative"},
{name: "unknown key is rejected", yaml: with("isoStorage: proxy-seeds", "isoStorage: proxy-seeds\nsshKeyFile: /k"), wantErrSub: "parsing providers config"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := LoadConfig([]byte(tc.yaml))
if tc.wantErrSub == "" {
if err != nil {
t.Fatalf("LoadConfig() error = %v, want nil", err)
}
return
}
if err == nil {
t.Fatalf("LoadConfig() error = nil, want error containing %q", tc.wantErrSub)
}
if !strings.Contains(err.Error(), tc.wantErrSub) {
t.Errorf("LoadConfig() error = %q, want substring %q", err.Error(), tc.wantErrSub)
}
})
}
}
// commentedEntry cuts the commented-out providers entry named name out of a
// config file and strips its "# " at the entry's own indent, so a sample
// that is shipped commented out is still loaded by a test.
func commentedEntry(t *testing.T, path, name string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading %s: %v", path, err)
}
var out []string
var prefix string
for line := range strings.SplitSeq(string(data), "\n") {
if prefix == "" {
if strings.TrimSpace(line) == "# - name: "+name {
prefix = line[:strings.Index(line, "#")]
out = append(out, prefix+"- name: "+name)
}
continue
}
if !strings.HasPrefix(line, prefix+"# ") {
break
}
out = append(out, prefix+strings.TrimPrefix(line, prefix+"# "))
}
if prefix == "" {
t.Fatalf("%s has no commented entry %q", path, name)
}
return strings.Join(out, "\n") + "\n"
}
func TestLoadConfig_proxmoxSampleEntry(t *testing.T) {
t.Parallel()
for name, path := range map[string]string{
"sample": "../../config/samples/providers-config.yaml",
"configmap": "../../config/manager/providers_config.yaml",
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
cfg, err := LoadConfig([]byte("providers:\n" + commentedEntry(t, path, "proxmox-home")))
if err != nil {
t.Fatalf("LoadConfig(proxmox-home from %s) error = %v, want nil", path, err)
}
p := cfg.Providers[0]
// LoadConfig already refused a proxmox block under another type.
if p.Name != "proxmox-home" || p.Proxmox == nil {
t.Fatalf("provider %q has proxmox block %v, want proxmox-home with one", p.Name, p.Proxmox)
}
c := p.Proxmox
if c.URL != "https://192.168.0.8:8006" {
t.Errorf("url = %q, want https://192.168.0.8:8006 (the address in the node certificate)", c.URL)
}
if c.TokenFile != "/etc/proxy-operator/proxmox/token" || c.CAFile != "/etc/proxy-operator/proxmox/ca.pem" {
t.Errorf("tokenFile, caFile = %q, %q, want the proxmox-token Secret's mount paths", c.TokenFile, c.CAFile)
}
if c.InsecureSkipTLSVerify {
t.Error("insecureSkipTLSVerify = true, want it left commented out")
}
if len(c.Nodes) != 1 || c.Nodes[0] != "pv1-1" {
t.Errorf("nodes = %v, want [pv1-1]", c.Nodes)
}
if c.ISOStorage != "proxy-seeds" || c.Storage != "" || c.Bridge != "" || c.VLANTag != 4 {
t.Errorf("isoStorage, storage, bridge, vlanTag = %q, %q, %q, %d, want proxy-seeds, \"\", \"\", 4", c.ISOStorage, c.Storage, c.Bridge, c.VLANTag)
}
if len(c.Templates) != 1 || c.Templates["debian-13"].VMID != 9001 {
t.Errorf("templates = %v, want {debian-13: {vmid: 9001}}", c.Templates)
}
if mt, ok := c.MachineTypes["small"]; !ok || mt.Cores != 1 || mt.MemoryMiB != 1024 || mt.DiskGiB != 0 {
t.Errorf("machineTypes[small] = %+v (present %v), want {1 1024 0}", mt, ok)
}
if c.VMIDRange != nil || c.RequestTimeoutSeconds != 0 || c.InstallGuestAgent != nil || c.VendorData != "" {
t.Errorf("optional fields set: %+v, want vmidRange, requestTimeoutSeconds, installGuestAgent and vendorData left commented out", c)
}
})
}
}
func TestLoadConfig_proxmoxSampleProxyMatchesSampleEntry(t *testing.T) {
t.Parallel()
cfg, err := LoadConfig([]byte("providers:\n" + commentedEntry(t, "../../config/samples/providers-config.yaml", "proxmox-home")))
if err != nil {
t.Fatalf("LoadConfig() error = %v, want nil", err)
}
data, err := os.ReadFile("../../config/samples/proxy_proxmox.yaml")
if err != nil {
t.Fatalf("reading the sample Proxy: %v", err)
}
var px struct {
Spec struct {
Provider string `json:"provider"`
Placement struct {
Zone string `json:"zone"`
MachineType string `json:"machineType"`
Image string `json:"image"`
} `json:"placement"`
} `json:"spec"`
}
if err := yaml.Unmarshal(data, &px); err != nil {
t.Fatalf("parsing the sample Proxy: %v", err)
}
c := cfg.Providers[0].Proxmox
if px.Spec.Provider != cfg.Providers[0].Name {
t.Errorf("spec.provider = %q, want %q", px.Spec.Provider, cfg.Providers[0].Name)
}
if !slices.Contains(c.Nodes, px.Spec.Placement.Zone) {
t.Errorf("placement.zone = %q, not in nodes %v", px.Spec.Placement.Zone, c.Nodes)
}
if _, ok := c.MachineTypes[px.Spec.Placement.MachineType]; !ok {
t.Errorf("placement.machineType = %q, not in machineTypes", px.Spec.Placement.MachineType)
}
if _, ok := c.Templates[px.Spec.Placement.Image]; !ok {
t.Errorf("placement.image = %q, not in templates", px.Spec.Placement.Image)
}
}
+40
View File
@@ -118,6 +118,46 @@ providers:
image: custom-image`,
wantErrSub: "type is gcp but a kubernetes block is set",
},
{
name: "kvm type with kvm2 block",
yaml: `
providers:
- name: p1
type: kvm
kvm2:
sshKeyFile: /key`,
wantErrSub: "type is kvm but a kvm2 block is set",
},
{
name: "kvm2 type with kvm block",
yaml: `
providers:
- name: p1
type: kvm2
kvm:
sshKeyFile: /key`,
wantErrSub: "type is kvm2 but a kvm block is set",
},
{
name: "proxmox type with kvm block",
yaml: `
providers:
- name: p1
type: proxmox
kvm:
sshKeyFile: /key`,
wantErrSub: "type is proxmox but a kvm block is set",
},
{
name: "kvm type with proxmox block",
yaml: `
providers:
- name: p1
type: kvm
proxmox:
url: https://pve:8006`,
wantErrSub: "type is kvm but a proxmox block is set",
},
{
name: "strict mode rejects unknown top-level key",
yaml: `
+78
View File
@@ -0,0 +1,78 @@
package gcp
import (
"context"
"errors"
"net/http"
"time"
"cloud.google.com/go/compute/apiv1/computepb"
"google.golang.org/api/googleapi"
"google.golang.org/api/iterator"
"google.golang.org/protobuf/proto"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
var _ provider.Checker = (*Provider)(nil)
// computeAddress is the endpoint the SDK talks to, shown only to humans.
const computeAddress = "compute.googleapis.com"
// managedFilter selects the instances this operator created.
const managedFilter = "labels." + provider.LabelManaged + " = " + provider.LabelManagedYes
// Check asks the project for one page holding at most one managed
// instance. That exercises the credentials, the Compute API and the
// compute.instances.list permission orphan GC depends on, in a single
// request whatever the project's size. The target is the project: the
// provider config names no zones.
func (p *Provider) Check(ctx context.Context) []provider.TargetStatus {
start := time.Now()
err := p.api.Probe(ctx, &computepb.AggregatedListInstancesRequest{
Project: p.cfg.Project,
Filter: proto.String(managedFilter),
MaxResults: proto.Uint32(1),
ReturnPartialSuccess: proto.Bool(true),
})
ts := provider.TargetStatus{Name: p.cfg.Project, Address: computeAddress, Latency: time.Since(start)}
if err != nil {
logAPIError(p.logger(ctx), "check", err)
ts.Reason = checkReason(ctx, err)
ts.Message = err.Error()
return []provider.TargetStatus{ts}
}
ts.Ready = true
ts.Reason = provider.ReasonReachable
return []provider.TargetStatus{ts}
}
// Probe fetches the first page of an aggregated list and discards it. The
// SDK treats MaxResults as the page size, so AggregatedList, which drains
// the iterator, would still walk every page of the project.
func (r *realInstances) Probe(ctx context.Context, req *computepb.AggregatedListInstancesRequest) error {
_, err := r.client.AggregatedList(ctx, req).Next()
if err == iterator.Done {
return nil
}
return err
}
// checkReason maps a failed probe to a reason. A token that cannot be
// obtained never reaches the API, so it has no status code and lands on
// Unreachable, with the detail in the message.
func checkReason(ctx context.Context, err error) string {
var gerr *googleapi.Error
switch {
case ctx.Err() != nil, errors.Is(err, context.DeadlineExceeded):
return provider.ReasonTimeout
case !errors.As(err, &gerr):
return provider.ReasonUnreachable
case gerr.Code == http.StatusUnauthorized:
return provider.ReasonUnauthorized
case gerr.Code == http.StatusForbidden:
return provider.ReasonForbidden
default:
return provider.ReasonAPIError
}
}
+165
View File
@@ -0,0 +1,165 @@
package gcp
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"syscall"
"testing"
compute "cloud.google.com/go/compute/apiv1"
"cloud.google.com/go/compute/apiv1/computepb"
"google.golang.org/api/googleapi"
"google.golang.org/api/option"
"google.golang.org/protobuf/proto"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
func TestCheck_reachable(t *testing.T) {
t.Parallel()
api := &fakeAPI{}
p := newTestProvider(api)
got := p.Check(context.Background())
if len(got) != 1 {
t.Fatalf("Check() returned %d targets, want 1", len(got))
}
ts := got[0]
if !ts.Ready || ts.Reason != provider.ReasonReachable || ts.Message != "" {
t.Errorf("Check() = %+v, want ready and Reachable", ts)
}
if ts.Name != "my-project" || ts.Address != "compute.googleapis.com" {
t.Errorf("target = %q at %q, want my-project at compute.googleapis.com", ts.Name, ts.Address)
}
want := &computepb.AggregatedListInstancesRequest{
Project: "my-project",
Filter: proto.String("labels.proxy-operator-managed = true"),
MaxResults: proto.Uint32(1),
ReturnPartialSuccess: proto.Bool(true),
}
if !proto.Equal(api.probeReq, want) {
t.Errorf("probe request = %v, want %v", api.probeReq, want)
}
}
func TestCheck_failureReasons(t *testing.T) {
t.Parallel()
ended, cancel := context.WithCancel(context.Background())
cancel()
tests := []struct {
name string
ctx context.Context
err error
want string
}{
{"unauthorized", context.Background(), gerr(401), provider.ReasonUnauthorized},
{"forbidden", context.Background(), gerr(403, "forbidden"), provider.ReasonForbidden},
{"api disabled", context.Background(), gerr(403, "accessNotConfigured"), provider.ReasonForbidden},
{"server error", context.Background(), gerr(500), provider.ReasonAPIError},
{"deadline", context.Background(), &url.Error{
Op: "Get", URL: "https://compute.googleapis.com/", Err: context.DeadlineExceeded,
}, provider.ReasonTimeout},
{"context ended", ended, context.Canceled, provider.ReasonTimeout},
{"connection refused", context.Background(), &url.Error{
Op: "Get", URL: "https://compute.googleapis.com/", Err: syscall.ECONNREFUSED,
}, provider.ReasonUnreachable},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
p := newTestProvider(&fakeAPI{probeErr: tc.err})
got := p.Check(tc.ctx)
if len(got) != 1 {
t.Fatalf("Check() returned %d targets, want 1", len(got))
}
if got[0].Ready || got[0].Reason != tc.want || got[0].Message != tc.err.Error() {
t.Errorf("Check() = %+v, want not ready, reason %s, message %q", got[0], tc.want, tc.err.Error())
}
})
}
}
// TestRealInstances_Probe runs the real SDK client against a local server,
// since the point of Probe is how many requests the SDK makes.
func TestRealInstances_Probe(t *testing.T) {
t.Parallel()
tests := []struct {
name string
status int
body string
wantCode int
}{
{
name: "fetches only the first page",
status: http.StatusOK,
body: `{"items":{"zones/europe-west1-b":{"instances":[{"name":"proxy-a"}]}},"nextPageToken":"page-2"}`,
},
{
name: "empty project",
status: http.StatusOK,
body: `{}`,
},
{
name: "api error keeps its status",
status: http.StatusForbidden,
body: `{"error":{"code":403,"message":"Compute Engine API has not been used","errors":[{"reason":"accessNotConfigured"}]}}`,
wantCode: http.StatusForbidden,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var (
mu sync.Mutex
reqs []*url.URL
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
reqs = append(reqs, r.URL)
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
t.Cleanup(srv.Close)
ctx := context.Background()
c, err := compute.NewInstancesRESTClient(ctx, option.WithEndpoint(srv.URL), option.WithoutAuthentication())
if err != nil {
t.Fatalf("NewInstancesRESTClient: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
err = (&realInstances{client: c}).Probe(ctx, &computepb.AggregatedListInstancesRequest{
Project: "my-project",
MaxResults: proto.Uint32(1),
})
var gerr *googleapi.Error
switch {
case tc.wantCode == 0 && err != nil:
t.Fatalf("Probe() = %v, want nil", err)
case tc.wantCode != 0 && (!errors.As(err, &gerr) || gerr.Code != tc.wantCode):
t.Fatalf("Probe() = %v, want a googleapi error with code %d", err, tc.wantCode)
}
mu.Lock()
defer mu.Unlock()
if len(reqs) != 1 {
t.Fatalf("server saw %d requests, want 1", len(reqs))
}
if got := reqs[0].Query().Get("maxResults"); got != "1" {
t.Errorf("maxResults = %q, want 1", got)
}
if want := "/compute/v1/projects/my-project/aggregated/instances"; reqs[0].Path != want {
t.Errorf("path = %q, want %q", reqs[0].Path, want)
}
})
}
}
+4 -3
View File
@@ -33,6 +33,8 @@ type instancesAPI interface {
Get(ctx context.Context, req *computepb.GetInstanceRequest) (*computepb.Instance, error)
Delete(ctx context.Context, req *computepb.DeleteInstanceRequest) (opName string, err error)
AggregatedList(ctx context.Context, req *computepb.AggregatedListInstancesRequest) ([]*computepb.Instance, error)
// Probe fetches only the first page of an aggregated list; see Check.
Probe(ctx context.Context, req *computepb.AggregatedListInstancesRequest) error
}
// realInstances adapts *compute.InstancesClient to the seam.
@@ -221,17 +223,16 @@ func (p *Provider) Delete(ctx context.Context, providerID string) error {
// entire GC sweep.
func (p *Provider) ListByTag(ctx context.Context) ([]provider.Instance, error) {
log := p.logger(ctx)
filter := fmt.Sprintf("labels.%s = %s", provider.LabelManaged, provider.LabelManagedYes)
instances, err := p.api.AggregatedList(ctx, &computepb.AggregatedListInstancesRequest{
Project: p.cfg.Project,
Filter: proto.String(filter),
Filter: proto.String(managedFilter),
ReturnPartialSuccess: proto.Bool(true),
})
if err != nil {
logAPIError(log, "list", err)
return nil, p.wrapErr("list", "", err)
}
log.V(1).Info("GCP instances listed", "filter", filter, "count", len(instances))
log.V(1).Info("GCP instances listed", "filter", managedFilter, "count", len(instances))
out := make([]provider.Instance, 0, len(instances))
for _, inst := range instances {
conv := toInstance(inst, lastPathSegment(inst.GetZone()))
+8
View File
@@ -31,6 +31,14 @@ type fakeAPI struct {
listReq *computepb.AggregatedListInstancesRequest
listInsts []*computepb.Instance
listErr error
probeReq *computepb.AggregatedListInstancesRequest
probeErr error
}
func (f *fakeAPI) Probe(_ context.Context, req *computepb.AggregatedListInstancesRequest) error {
f.probeReq = req
return f.probeErr
}
func (f *fakeAPI) Insert(_ context.Context, req *computepb.InsertInstanceRequest) (string, error) {
+10
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"github.com/go-logr/logr"
"go.opentelemetry.io/otel/trace"
)
// wireLogMaxFieldBytes is the elision threshold for string fields in wire
@@ -47,6 +48,15 @@ func (h *wireFilterHandler) Handle(ctx context.Context, rec slog.Record) error {
if rec.Level <= slog.LevelDebug && rec.Message != "api request" && rec.Message != "api response" {
return nil
}
// The SDK logs with the request ctx, so wire records can carry the
// surrounding provider span — the one place slog's ctx-aware handlers
// beat logr, and the same keys the logr enrichment uses.
if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
rec = rec.Clone()
rec.AddAttrs(
slog.String("traceID", sc.TraceID().String()),
slog.String("spanID", sc.SpanID().String()))
}
if h.fullPayloads {
return h.inner.Handle(ctx, rec)
}

Some files were not shown because too many files have changed in this diff Show More