19 KiB
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, typekvm2, config blockkvm2:, side by side withkvm. The Go config struct reusesprovider.KVMConfig(internal/provider/config_kvm.gois shared code, not the kvm package), so an entry ports by changingtypeand 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-returnblocking,provisioning-statecreate-blocks,child-spansspan-decorator,quotadetected (pool free space precheck),credentialsmounted-files (the existingkvm-sshSecret),client-kindrpc-over-ssh,gc-markwritten-metadata,created-atwritten-at-create,ip-sourcediscovered (agent, then ARP),list-partialfail-whole per host with unreadable domains skipped,not-founderror-code,delete-returnwaits,secondary-resourcesexplicit,timeoutsper-call,loggingtiered,backend-testintegration-tag.
Backend facts the spec carries (allowed lessons)
- Real host
beelink: libvirt 11.6, QEMU 10.1, pooldefaultat/srv/vms/images, bridgebr0,beelink.tail8c69c.ts.net:22, usernovakj, 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 withStorageVolGetPathand 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: biosper 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.NewWriterneeds 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; neededErrorNumberconstants exist (ErrNoDomain,ErrNoStorageVol,ErrNoStoragePool,ErrOperationInvalid,ErrNoDomainMetadata,ErrStorageVolExist,ErrXMLError,ErrNoSupport,ErrInvalidArg,ErrAuthFailed,ErrAccessDenied).libvirt.New(net.Conn)thenConnect(). No new dependencies:x/crypto/ssh,kdomanski/iso9660are already direct requires; useslices, neversort(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 spankvm2.<Call>per RPC plus the V(1)/V(2) log line),image.go,create.go,get.go,delete.go,list.go,check.go, plusfake_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,DomainCreate90s, 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) andkvm2.download(inimage.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>withmanaged,uid,createdAt. kvm2 never lists or adopts v1 domains; akvmand akvm2entry may share a host. - Adoption guard: an existing domain whose metadata
uidis non-empty and differs fromreq.UIDmakesCreatereturnErrPermanent. - Quota precheck: before creating volumes,
StoragePoolGetInfo; needed = imageContent-Length(if not cached) +diskSizeGb+ 16 MiB; below that returnsErrQuotaExceedednaming pool, available and needed. - Download requires
Content-Length(Permanent otherwise), streams throughsha256intoStorageVolUpload, refreshes the pool, writes the marker volume last; a failed attempt deletes the partial volume. - Classify:
ErrNoDomain,ErrNoStorageVolNotFound, but Transient insideCreate;ErrNoStoragePool,ErrXMLError,ErrNoSupport,ErrInvalidArg,ErrAuthFailed,ErrAccessDeniedPermanent; 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.EOFand 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 plusPoolNotFound,PoolNotActive,CredentialsUnavailable. Detailsuser,hostname,libvirtVersion,pool,poolState,poolCapacityGiB,poolAvailableGiB. - Rotation: SSH material is read on every dial, so Secret rotation needs no restart.
- Config validation messages:
validateKVMis refactored intovalidateKVMBlock(prefix, block string, c *KVMConfig)so messages saykvm2.for the new block;validateKVMstays as a wrapper forkvm.
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.mdwith the real timestamp, commit alone, create the branch. Execution summarydocs/plans-executions/<ts>-kvm2-provider.mdwith the checklist after step 1. - Step 1 (orchestrator): the spec. Copy
docs/provider-spec-template.mdtodocs/kvm2-provider-spec.mdand 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 tointernal/provider/kvm/. - Step 2 (subagent): config wiring, IDs, state, classify. Edits
internal/provider/config.go(KVM2 *KVMConfigfield,setBlocks, allow-list, dispatch),config_kvm.go(validateKVMBlock),config_test.goandconfig_kvm_test.gorows. Createskvm2.go(type, assertions,Newthat never dials and warns on unreadable SSH files, stub methods),id.go,state.go,errors.gowith tests:TestFormatProviderID_roundTrip,TestParseProviderID_malformed,TestMapState_table,TestFirstIPv4_filters,TestClassify_table. Also registers"kvm2": kvm2.Newincmd/main.goso the two type lists change in one commit. Verify: three"kvm2"sites inconfig.go, no import ofprovider/kvm,go test -race ./internal/provider/... && go vet ./.... - Step 3 (subagent): pure builders.
xml.go,seed.goand 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: notype="volume"inxml.go; temp dir under/tmpwith a deferred removal. - Step 4 (subagent): transport, seam, decorator, fakes.
backend.go,ssh.go,hosts.go,observe.go,fake_test.go,sshserver_test.goand 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: nocontext.Background()outside tests and the keepalive goroutine;tracing.Startin the decorator and the dialer;go test -race -count=2. - Step 5 (subagent): image cache and pool-space precheck.
image.goand 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: noio.ReadAllorbytes.Bufferon the download path; download span. - Step 6 (subagent): the four contract methods.
create.go,get.go,delete.go,list.go,kvm2_test.go, finishingkvm2.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:CloudInitappears increate.goonly aslen(req.CloudInit); stub strings gone;Wrapalways 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. Editapi/v1alpha1/providerhealth_types.go(status.typecomment) and runmake manifests. Verify: only the CRD description line changes underconfig/;go test ./internal/providerhealth/. - Step 8 (subagent): deployment, Makefile, integration test. Edit
config/manager/manager.yaml(comment: thekvm-sshmount serves kvm and kvm2),config/manager/providers_config.yamlandconfig/samples/providers-config.yaml(commentedkvm2entry),config/samples/kustomization.yaml,Makefile(test-integration-kvm2,-run TestKVM2,-timeout 40m),docs/testing.md(suite rows and a kvm2 integration section). Createconfig/samples/proxy_kvm2.yaml(zonebeelink, providerkvm2-home) andkvm2_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:Newdoes not dial;CheckReady with details;Createreturnshosts/<host>/domains/<name>; secondCreatesame ID, domain count unchanged;Getreaches Running with an IPv4 and the written UID and CreatedAt;ListByTagcontains it;Deletenil;GetNotFound;Deleteagain nil; pool holds base and marker, no per-VM volumes; with an in-memory span recorder,kvm2.DomainDefineXMLandkvm2.dialspans are children ofprovider.create. Verify:go vet -tags=integration ./... && go vet -tags=e2e ./...,bin/kustomize build config/defaultandconfig/samplesparse,make -n test-integration-kvm2. - Step 9 (subagent): documentation. Create
docs/kvm2-provider.md(prerequisites incl. OVMF, cluster setup reusingkvm-ssh, config reference pointing at the sharedKVMConfigtable, 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 fromkvm, known gaps). Editdocs/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.mdrow,docs/providers.mdguide 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-revieweron 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,br0up, the known_hosts entry) and theKVM2_TEST_*export lines; do not run remote probes unprompted. With the user's go-ahead runmake test-integration-kvm2(a permission prompt is expected; the allow-list entrymake 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 thekvm2-homeentry, applyproxy_kvm2.yaml, wait forReady,kubectl get ph kvm2-home -o yaml, acurl -xthrough 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 basemain; never merge.
Verification
- Per step: the checks listed above, plus
go build ./... && go test -short ./...before every commit andgit diff --statreviewed 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.jsonis 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-Lengthis 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-reviewerhunts 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).