Files
egress-proxies-operator/review/sweep.md
T
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

217 lines
27 KiB
Markdown

# 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.