Files
egress-proxies-operator/docs/reviews/2026-09-17-kvm2-provider-review/sweep.md
T
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

27 KiB

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.