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>
59 KiB
The existing providers
This is the appendix to 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, kvm2-provider.md, proxmox-provider.md, gcp-vm-validation.md and 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 without reading kvm's code, proxmox from 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, the health checks are tabulated in §11, 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, destroy and seed delete 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 | 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; compiled, not yet run against a 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:newWithClientinjects a controller-runtimeclient.Client, and tests use the fake client.NewcallsNewWithTransportWrapper, which builds its own uncached client fromctrl.GetConfig()with an optionaltransport.WrapperFuncfor tracing. -
Config.
KubernetesConfig.Imageonly, defaulting to the Squid image constant inkubernetes.go. -
ID.
providerID(namespace, name)givesnamespace/name;parseProviderIDusescache.SplitMetaNamespaceKeyand rejects an empty namespace withErrPermanent. An empty name part is not rejected. -
Create.
buildPod(pure) thenclient.Create;IsAlreadyExistsis success. OnlyName,Namespace,UIDandPortare used. The Squid config is generated bysquidConffrom the port and passed through theSQUID_CONFenvironment 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 instanceFromPodsetsIPonly when the state is Running. A Pod with a deletion timestamp still reports its phase.UIDfrom theLabelUIDlabel,CreatedAtfromCreationTimestamp. -
Delete.
client.Delete,IsNotFoundignored. -
ListByTag. One
client.Listacross all namespaces withMatchingLabels{LabelManaged: LabelManagedYes}; this is what the cluster-wide Pod RBAC is for. -
Errors.
classify:IsNotFoundNotFound;IsTooManyRequests,IsServerTimeout,IsTimeoutTransient;IsForbidden,IsInvalid,IsBadRequest,IsUnauthorizedPermanent; 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. Targetapiserver, address the rest config host.checkReason: context errors and timeoutsReasonTimeout;IsUnauthorized;IsForbidden; anyAPIStatusReasonAPIError; elseReasonUnreachable. -
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.goover 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.yamlholds only this provider, formake run-dev. -
Known gaps.
Error.Provideris hardcoded to"kubernetes", so errors and logs show the wrong name when the configured name differs.CloudInitand all ofPlacementare 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 allinpod.go. - No logging in the package, and no Checker
Details. - No
OwnerReferenceon 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 isinstancesAPI(Insert,Get,Delete,AggregatedList,Probe);realInstancesadapts the SDK client andnewWithAPIis the test constructor.NewcallsNewWithWireOptions, which takes the--gcp-wire-log-full-payloadsflag fromcmd/main.go. -
Config.
GCPConfig:projectrequired;network,networkTag,diskSizeGbdefaulted inwithDefaults. -
ID.
formatProviderID(zone, name)giveszones/<zone>/instances/<name>;parseProviderIDrejects anything else withErrPermanent. The project comes from the config. -
Create. Validates
Zone,MachineTypeandImageup front and returnsErrPermanentnaming the missing field.buildInsertRequest(pure) builds one NIC with an ephemeral external IP (ONE_TO_ONE_NAT) and a boot disk withAutoDelete, labelled with both marks.Insertis 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 toInstancereads the NAT IP of the first access config of the first NIC,UIDfrom the label, andCreatedAtfromCreationTimestampwith 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.
AggregatedListwithmanagedFilteron the managed label only andReturnPartialSuccess, so an unreachable zone is left out silently; the zone is recovered from the URL bylastPathSegment. -
Errors.
classify: non-googleapierrors Transient; 404 NotFound; 429 Quota; 403 with reasonquotaExceededorrateLimitExceeded(hasReason) Quota; 400, 401 and other 403s Permanent; other codes Transient.logAPIErrorrecords the HTTP status and reasons at V(1) becauseclassifycollapses them. -
Check.
Probemakes oneAggregatedListpage withmaxResults=1and the managed filter: credentials, the Compute API andcompute.instances.listin one call. Target is the project, addresscompute.googleapis.com.checkReason: context errorsReasonTimeout; non-googleapierrorsReasonUnreachable; 401; 403; elseReasonAPIError. -
Logging.
logger(ctx)from the context, namedgcpwithprovider. V(2) for the curated insert request (never the user-data), V(1) per operation outcome, V(2) per listed instance. The wire log inwirelog.goruns at V(5) with payloads elided unless the flag is set. -
Tracing. The SDK adds
otelhttpitself 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) overfakeAPI;check_test.goalso drives the real SDK client againsthttptestto prove exactly one request withmaxResults=1. Real backend: the manual runbook in gcp-vm-validation.md. -
Deployment. Credentials by ADC; the runbook mounts a service-account key and points
GOOGLE_APPLICATION_CREDENTIALSat it. A commented entry in both providers configs,config/samples/proxy_gcp.yaml, and demo scripts underdocs/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 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.Portis unused. - The SDK client is never closed, and
Placement.Regionis unused. - Wrong label value in the docs:
gcp-vm-validation.mdfilters onlabels.proxy-operator-managed=yes; the value istrue. - The sample cannot work:
config/samples/proxy_gcp.yamluses adebian-cloudimage, which has no cloud-init, so its Squid install never runs (gcp-in-specific-project.md uses Ubuntu for that reason).
- 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
6. kvm (internal/provider/kvm)
The full user guide is 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 islibvirtAPI, the twenty go-libvirt calls the provider uses;fake_test.goimplements it in memory andnewWithDialerinjects the dialer and the HTTP client. -
Config.
KVMConfigininternal/provider/config_kvm.go:sshKeyFile,knownHostsFileorinsecureIgnoreHostKey,diskSizeGb,vendorData,installGuestAgent,images(url,sha256,format,firmware),machineTypes(vcpus,memoryMiB),hosts(name,address,user,socket,pool,bridge).validateKVMrestricts image keys and host names withkvmIdentifierbecause they become a volume name and an ID segment. -
ID.
formatProviderID(host, name)giveshosts/<host>/domains/<name>. -
Create.
hostresolvesPlacement.Zone(optional with one host), the machine type must be a catalogue key,resolveImageaccepts a catalogue key or an http(s) URL. Then, inside onehostPool.call:DomainLookupByName, and if foundensureRunning; otherwiseimageCache.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),defineDomainwith the metadata element,ensureRunning.Createreturns 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 addressasks the ARP table first, then the guest agent, andfirstIPv4drops loopback, link-local and unspecified addresses. Metadata is read back bymetadataand parsed byparseInstanceMetadata; a domain without the element reports an empty UID. -
Delete.
removeDomain(destroy, tolerating not running or gone; thenDomainUndefineFlagswith NVRAM, managed save and snapshot metadata) andremoveVolumes(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.
ConnectListAllDomainson every host;managedInstancekeeps 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:isClassifiederrors keep their class;ErrNoDomain,ErrNoStorageVolNotFound; 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.logAPIErroraddslibvirtErrorFields. -
Check.
checkHostper host concurrently throughhostPool.probe, notcall, 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 plusSSHMaterialUnavailable,SSHHandshakeFailed,LibvirtConnectFailed,LibvirtError,PoolNotFound,PoolNotActive, chosen bycheckReasonfrom thedialErrorstage. Details:user,pool,hostname,libvirtVersion,poolState,poolCapacity,poolAvailable. -
Connections.
hostPoolkeeps one SSH plus libvirt connection per host, dialled lazily (lazySSH.dial,sshAuth.dial,dialSSH,connectLibvirt), with a 15skeepalive.hostEntry.acquireis a one-slot semaphore so waiting can end with the caller's context. A cancelledcallcloses the shared connection to unblock the RPC;probeabandons it and leaves the connection open. The SSH material is read once bylazySSH.loadand cached. -
Logging. Logger from the context, named
kvm, withprovider,hostandname. V(1): one line per operation outcome and onelibvirt callline per RPC from thetracingAPIdecorator intrace.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.gois 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.gobehind//go:build integration, driven byKVM_TEST_*variables andmake test-integration-kvm, described in testing.md. -
Deployment. The optional
kvm-sshSecret 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).
- No quota or capacity detection.
Createblocks, 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
Deletetreats 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
callcloses the shared per-host connection. - SSH auth failures retry forever: SSH authentication and host-key failures are Transient, while libvirt auth errors are Permanent.
Createcan returnErrNotFoundwhen 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.yamlplaces on hostnas1, while both providers configs define onlybeelink. - Doc drift in kvm-provider.md: it says ARP is the fallback, but the code
tries ARP first; it says
hostandnameare 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 and the design
it was built from is 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 sharedinternal/provider/nocloudand both lock sets areinternal/provider/keyedlock. The seam isbackend: the twenty go-libvirt calls the provider uses, each withctxfirst (go-libvirt's own methods take none and block), plusClose.libvirtBackendadapts*libvirt.Libvirtand runs every call throughcall, which applies the budget and the abandon policy. Thedialerseam separates the SSH transport:newProviderinjects one for tests,newLibvirtDialeris production. Theobservedecorator is applied once, inlibvirtDialer.dial, so provider code never holds an undecorated backend. -
Config.
ProviderConfig.KVM2, the sameKVMConfigtype as kvm, validated byvalidateKVMBlockwith block namekvm2so messages readkvm2.hosts. Defaults (diskSizeGb10, socket, pool, image format and firmware) are filled bywithDefaults.Newnever dials; it checks that the credential files exist and warns when one does not, or wheninsecureIgnoreHostKeyis on. -
ID.
formatProviderID(host, name)giveshosts/<host>/domains/<name>;parseProviderIDaccepts exactly four segments with the literalhostsanddomainsand non-empty host and name, elseErrPermanent. -
Create.
CreatevalidatesZone(host; optional with one host),MachineType(a catalogue key) andImage(resolveImage: a catalogue key or an http(s) URL with qcow2 and uefi defaults) before any dial, and logs the resolved parameters withcloudInitBytesat 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 fromreq.UID, is Permanent) andensureRunning. OtherwiseStoragePoolLookupByName,imageSizeIfUncached(marker lookup, thenHEAD, or a closedGETwhenHEADis not 2xx) andcheckPoolSpacewithneededBytes(image if uncached, plusdiskSizeGb, plus 16 MiB), thenensureImage(cachedImageon the pooled connection,fetchImagestreaming intouploadImageon adialFreshconnection,StoragePoolRefresh,writeMarkerlast),ensureOverlay(ErrStorageVolExistmeans reuse),writeSeed(delete, build, upload; always rebuilt), twoStorageVolGetPath,buildDomainXMLwith file-type disks and the metadata element,DomainDefineXML,ensureRunning(DomainGetState, where paused or pmsuspended is Permanent;DomainCreatewithErrOperationInvalidas already running;waitRunningpolls every 500 ms for up to 15 seconds, Transient on expiry and at once for any inactive state other thannostate).Createreturns 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(onlyErrNoDomainreaches NotFound),readMetadata(DomainGetMetadatawithmetadataURI;ErrNoDomainMetadatameans unmanaged with an empty UID; an element that does not parse is Permanent), thenDomainGetStateand, for an active domain,address.addressSourcesasks 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.firstIPv4drops loopback, link-local and unspecified addresses and an address whose type tag and literal disagree.UIDandCreatedAtare read back on every call; an unparsablecreatedAtgives the zero time. -
Delete. Under the per-name lock:
removeDomain(DomainDestroy, tolerating not running and gone;DomainUndefineFlagswith NVRAM, managed save and snapshot metadata) andremoveVolumes(overlay and seed by name throughdeleteVolumeIfPresent; 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;readMetadataandmanagedkeep kvm2's own domains, and theInstanceisID,UIDandCreatedAtfrom the name and the element, withStateandIPempty: 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 throughclassifyLibvirt(ErrNoDomainandErrNoStorageVolNotFound, Transient whenop == "create";ErrNoStoragePool,ErrXMLError,ErrNoSupport,ErrInvalidArg,ErrAuthFailed,ErrAccessDeniedPermanent; the rest Transient);knownhosts.KeyErrorand theunable to authenticatetext fromx/crypto/sshPermanent; everything else Transient, including a missing credential file.permanentfandtransientfclassify at the source: placement, XML builders, a parse failure of the key or known_hosts, an image 4xx, a missing or zeroContent-Length, a sha256 mismatch, a pool that is not running (Permanent); an image 429 or 5xx, a short body, the running wait expiring (Transient).checkPoolSpaceis the only source ofErrQuotaExceeded.logAPIErrorwriteskvm2 backend call failedwithlibvirtErrorFields. -
Check.
CheckrunscheckHostper host concurrently, each throughprobeHoston adialFreshconnection that is closed when the probe ends; the pooled table is never consulted.ConnectGetLibVersion,ConnectGetHostname,StoragePoolLookupByName,StoragePoolGetInfo; a pool that is not running ispoolNotActiveError. Details are filled as facts arrive (userandpoolalways;libvirtVersion,hostname,poolState,poolCapacityGiB,poolAvailableGiB).checkHostanswersTimeoutwhenever the round's ctx ended, whatever stage was running and whatever shape the error has; otherwisecheckReason: adialErroris judged by its stage (credentialsgivesCredentialsUnavailable;sshwith aknownhosts.KeyErroror the auth text givesUnauthorized; any other stage, including a dial that ran out of its own budget, givesUnreachable); thencontext.DeadlineExceededorCanceledTimeout,ErrAccessDeniedorErrAuthFailedForbidden,ErrNoStoragePoolPoolNotFound, any other libvirt codeAPIError, anything elseUnreachable;PoolNotActivefrom the pool state. -
Connections.
hostTablekeeps 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 nextget.dialSSHSocketruns credentials, TCP, the SSH handshake and thedirect-streamlocalchannel to the socket, each failure adialErrorwith its stage;libvirtDialer.connectbounds the whole sequence plus the libvirtConnectwith the dial budget and starts thekeepalive(15 s,keepalive@openssh.com) and a watcher on go-libvirt'sDisconnected.callruns 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.loadCredentialsruns on every dial. -
Logging. Logger from the context, named
kvm2, withprovider, andhostandnameon 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), onelibvirt callline per RPC fromobserve(rpc name, identifiers, duration, error fields), the connection eventslibvirt host connectedandlibvirt host connect failedwithstage, andkvm2 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.callstartskvm2.<Method>per RPC withkvm2.hostand the identifiers (kvm2.domain,kvm2.pool,kvm2.volume), setskvm2.libvirt_codeon failure and writes thelibvirt callline inside the span;libvirtDialer.dialstartskvm2.dial(kvm2.address,kvm2.user);fetchImagestartskvm2.download(kvm2.url_host,kvm2.sha256_configured,kvm2.bytes). Both the decorator and the dialer taketracing.Optionso tests inject a recorder;Newleaves 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 realknownhosts.KeyError);backend_test.go(the adapter over go-libvirt's own mock through anet.Pipe: deadline kills the connection, cancel unblocks, killed by another caller);hosts_test.go;ssh_test.gooversshserver_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 isfakeBackendinfake_test.go, whosetwinshares state under its own call log so a test can tell the pooled connection from a dedicated one. Real backend:kvm2_integration_test.gobehind//go:build integration, driven byKVM2_TEST_*variables andmake test-integration-kvm2, described in testing.md. -
Deployment. The
kvm-sshSecret and its mount are shared with kvm (config/manager/manager.yamlsays so),make kvm-ssh-secretis reused, a commentedkvm2-homeentry in both providers configs,config/samples/proxy_kvm2.yaml(zonebeelink, matching the shipped config), thekvm2type in thestatus.typecomment ofapi/v1alpha1/providerhealth_types.go. No RBAC. -
Known gaps (see also kvm2-provider.md § Known gaps).
Createblocks, 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
Deletetreats 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
callkills the pooled connection. Content-Lengthis required for image downloads.- A paused or pmsuspended domain is not started by a repeat
Create:ensureRunningreturns 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 and the design it was built from is 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 adapterhttpAPI, 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 sharedinternal/provider/nocloudand the name lock frominternal/provider/keyedlock. The seam isapi: one method per endpoint, sixteen in all, each withctxfirst.NewwrapshttpAPIinobserveonce, so provider code never holds an undecorated seam;newProvideris the test constructor. -
Config.
ProviderConfig.Proxmox, theProxmoxConfigtype ininternal/provider/config_proxmox.go, validated byvalidateProxmox(the URL shape, node names and storage ids by regexp because they end up in request paths,vlanTag,vmidRange, the two catalogues).isoStoragehas no default on purpose: the token needsDatastore.Allocatethere. Defaults (vmidRange100000 to 199999,requestTimeoutSeconds30,installGuestAgenttrue) are filled bywithDefaults.Newnever makes a request; it warns when the token file does not exist, or wheninsecureSkipTLSVerifyis on. -
ID.
formatProviderID(vmid, name)givesvms/<vmid>/<name>, with no node, because VMs migrate; the node is resolved from/cluster/resourceson every call (resolve).parseProviderIDaccepts exactly three segments, the literalvms, a decimal VMID without leading zeros between 100 and 999999999 and a non-empty name, elseErrPermanent. The name is there soGetandDeletecan tell our VM from another that later got the same VMID. -
Create.
placementchecksZone(node; optional with one node),MachineTypeandImage(both catalogue keys) before any request, and logs the resolved parameters withcloudInitBytesat V(2). Then, under the per-name lock,create: oneClusterResourcesread;adoptByName(two VMs with the name are Permanent, one goes toinspect); with none,templatereads the template's config and refuses a non-template, a cloud-init drive, a missingnet0when the config rewrites it, and an unsized boot disk whendiskGiBis set, all before any clone;allocateprobesvmidFor(uid, attempt)up to 32 times, cloning onto a free VMID and runninginspecton a taken one or on the clone'sconfig file already existsanswer.inspectwaits 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 oneVMStatus: running returns at once. Otherwiseprovision:uploadSeed(rebuilt and overwritten every attempt),SetVMConfig(configParams: cores, memory,agent=1, the tag,ide2, andnet0throughrewriteNet0when a bridge or VLAN is configured), the resize task whenresizeTargetsays the boot disk is smaller thandiskGiB, the start task, andwaitRunning(every second up to 60 s, Transient on expiry or on a stop).Createreturns only when the VM runs. -
Get and state.
getchecks in order: absent from the resources is NotFound; a lock other thansuspendedis Transient (checked before the name, because a mid-clone entry has none);status: unknownis Transient; the config'sdoes not existis NotFound, a config lock Transient; another name or no mark is NotFound; hibernated is Stopped without further calls. ThenVMStatusandmapState(status, qmpstatus, ip):statusqmpstatusagent Provider state running running an IPv4 Running running running 200 without an IPv4, or QEMU guest agent is not runningProvisioning running running No QEMU guest agent configuredStopped 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.UIDandCreatedAtare read from the description on every call. A runningGetis four calls: resources, config, status, agent. -
Delete. Under the per-name lock,
destroy:resolve; a lock other thansuspendedorstatus: unknownis Transient; the config'sdoes not exist, another name or no mark leave the VM alone; our VM hibernated is Permanent, because the destroy refuses it and the lock never clears. Otherwise stop (unless stopped) and destroy withpurge=1&destroy-unreferenced-disks=1, each a task waited on throughrunTask, wheredoes not existmeans gone. ThendeleteSeedon the VM's node, where a failure fails the call (a 403 forDatastore.Allocateis Permanent), or, with no VM of ours destroyed,deleteSeedEverywhereover every configured node, where a node that fails is logged and skipped unless ctx ended. A missing volume still ends in a successful task, so no message is matched. Nothing is left when nil is returned. -
ListByTag. One
ClusterResourcescovers every node, unreachable ones included (their guests are listed withstatus: unknown).isCandidatekeeps QEMU VMs that are not templates and carry the tagproxy_operatoror a name startingproxy-; a nameless entry (a clone in progress) is not a candidate. OneVMConfigper candidate; the mark in the description decides, and theInstanceisID,UIDandCreatedAtonly. 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; thenapiErrors byclassifyAPI(every 4xx Permanent; the 500does not existNotFound, Transient whenop == "create"; every other status Transient, including the lock, the taken VMID and pveproxy's 595 and 596); TLS verification failures (tls.CertificateVerificationErrorand the threex509types) Permanent; everything else Transient. The 500 matchers (isNotExist,isLocked,isAlreadyExists, the three agent ones) are anchored to the 500 status and each is backed by a captured response.permanentfandtransientfclassify 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.
CheckrunscheckNodeper node concurrently on the shared client.probeNodereads the token file fortokenID(no request when it is unusable), thenVersion,NodeStatus,checkStorage(a 500 whatever its text, inactive, or noisocontent isStorageNotFound) andcheckTemplates(each catalogue VMID exists and is a template, elseTemplateNotFound).checkNodeanswersTimeoutwhenever the round's ctx ended; otherwisecheckReason: the two failures above,errCredentialsCredentialsUnavailable,errCAFileor a TLS verification failureCertificateInvalid, 401Unauthorized, 403Forbidden, 595 and 596Unreachable, any other statusAPIError, anet.OpErrorUnreachable(before the deadline, so a dial out of its own budget is a node that did not answer), a deadline or cancellationTimeout, a cut connectionUnreachable. -
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.TimeoutrequestTimeoutSeconds, 4 idle connections per host kept 90 s, HTTP/2 disabled. A cancelled request ends only itself. The token file is read byreadTokenon every request. -
Logging. Logger from the context, named
proxmox, withprovider, andvmid,nameandnodeon lines about an instance. V(1): one line per operation outcome (proxmox vm ensured running,proxmox vm fetched,proxmox vm deletedwithdestroyed,proxmox vms listed,proxmox node checked), oneproxmox api callline per seam call fromobserve(call, identifiers, UPID, duration,httpStatusanderror),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:
newHTTPAPIwraps the transport intracing.RestConfigWrapper(opts...), so each request is anHTTP <METHOD>client span under the caller's span, and none without one.observeonly logs, so nothing is counted twice. -
Tests. Pure pieces (
id_test.go,mark_test.go,state_test.go,seed_test.go,errors_test.gowith the classify table over the captures,api_test.go);client_test.go(the real client overhttptest.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) andprovider_test.go(the contract §11 sweep, named test by test in the spec's entry 21). The double isfakeAPIinfake_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.gobehind//go:build integration, driven byPROXMOX_TEST_*variables andmake test-integration-proxmox, described in testing.md; it compiles but has not run against a host yet. -
Deployment. The optional
proxmox-tokenSecret (keystokenandca.pem) mounted at/etc/proxy-operator/proxmox(config/manager/manager.yaml),make proxmox-token-secret, a commentedproxmox-homeentry in both providers configs (loaded byTestLoadConfig_proxmoxSampleEntry),config/samples/proxy_proxmox.yaml, theproxmoxtype in thestatus.typecomment ofapi/v1alpha1/providerhealth_types.go. No RBAC, and nohack/providers-dev.yamlentry, since it needs credentials. -
Known gaps (see also proxmox-provider.md § Known gaps).
Createblocks until the VM runs.- A clone lock that never clears makes every Create for that Proxy wait
out the 10-minute clone budget;
qm unlockby 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 in Create and in Delete.
- The CA is cached after first use; the token is not.
- Never run against a real host yet; three answers were never captured (the storage status for a missing storage, a destroy on a hibernated VM, a stop on a paused VM).
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 returnErrPermanentwhen the UID differs. - The Create comment does not match kvm, kvm2 and proxmox. The
interface comment in
internal/provider/provider.gosaysCreatereturns as soon as the request is submitted; the three VM providers on your own hypervisors block until the VM has started. StoppedandTerminatedare produced inconsistently. kubernetes never produces Stopped, kvm, kvm2 and proxmox never produce Terminated, and the reconciler treats them the same.- Different
ListByTagpartial-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
mockcomments ininternal/provider/provider.go,internal/provider/registry/registry.goandapi/v1alpha1/proxy_types.go(and so the generated CRD'sspec.providerdescription). - 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) and is still open.
- Tracing. kvm has no child spans, so a kvm trace shows
provider.createand nothing under it; correlation works only throughtraceIDon 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 underprovider.*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
tracingAPIdecorator inkvm/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 noctxtoday (hostPool.callhandsfnonly the API), so the span context has to be plumbed in. kvm2'sobserve.goover 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
Createadopts an instance. ReturnErrPermanenton 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
Getreports 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
ListByTagor 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
Detailsfor 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
OwnerReferenceon kubernetes Pods.