This Kubernetes operator manages the lifecycle of guest clusters. The guest clusters use two topologies: CRC (CodeReady Containers / OpenShift Local) and HyperShift. The guest clusters run inside a management OpenShift cluster. CI systems lease, use, and recycle these disposable clusters on a shared hypervisor. The operator removes the need for manual bookkeeping.
- CRC covers SNO test suites and Machine Config Operator / platform- alteration test suites.
- HyperShift covers multi-node test suites and EaaS-parity test suites.
- Both topologies need provisioning. Each job must reuse them, isolate them from other jobs, and tear them down cleanly between runs. No person needs to track which VM or hosted cluster is free.
This operator works as a small, self-hosted cluster broker. CI
acquires a lease on a cluster of a given topology. The lease returns a
kubeconfig Secret. CI uses the cluster, then releases the lease. The
operator recycles the underlying compute. The next job gets a clean slate.
The operator has one binary, two controllers, and three CRDs. It also has a small, out-of-band crc-agent process:
┌─────────────────┐ watches/owns ┌───────────────────┐
CI ── apply ──▶ │ ClusterLease │ ◀────────────────────────│ ClusterPool │
│ (demand side) │ │ (supply side) │
└────────┬─────────┘ └─────────┬──────────┘
│ binds to a free, Ready instance │ tops up
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ ClusterInstance │
│ (state machine: Provisioning → Ready → deleted; │
│ "claimed by a lease" is derived, not a phase -- │
│ see "Binding model" below) │
└───────────────┬───────────────────────┬────────────────┘
│ type: crc │ type: hcp
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────────┐
│ KubeVirt VirtualMachine │ │ HyperShift HostedCluster │
│ + CDI DataVolume (boots │ │ + NodePool (KubeVirt workers) │
│ crc.qcow2, "parked") │ │ │
└─────────────┬─────────────┘ └───────────────┬─────────────────┘
│ VMI has IP → crc-agent Job created│ HC status.kubeconfig
▼ │
┌────────────────────┐ │
│ crc-agent Job │ │
│ (SSH as "core", │ │
│ runs native Go │ │
│ post-boot fixups, │ │
│ publishes raw │ │
│ kubeconfig Secret) │ │
└─────────┬───────────┘ │
└───────────────┬──────────────────────┘
▼
canonical per-instance kubeconfig Secret
│
▼
per-lease kubeconfig Secret (copy)
│
▼
CI reads it, runs `oc get nodes`
| Kind | Short name | Role |
|---|---|---|
ClusterPool |
cpool |
Supply side. This CRD declares a topology (crc or hcp), a template for provisioning, and a budget (minSize, warmSpares, maxSize). The operator tops up ClusterInstances continuously. This satisfies minSize, warmSpares, and any Pending ClusterLease demand. The operator never exceeds maxSize. |
ClusterInstance |
cinst |
This CRD represents one concrete guest cluster: a CRC VM, or a HostedCluster with a NodePool. It owns the actual KubeVirt/HyperShift objects. It drives provisioning to the ready state. It publishes a per-instance kubeconfig Secret. A lease claim is not one of its own phases (see "Binding model" below). There is no in-place reset. On release, the operator deletes a claimed instance. It does not recycle the instance back to Ready. |
ClusterLease |
clease |
Demand side. CI creates this CRD. The operator matches it to a free Ready ClusterInstance of the requested type. It claims the instance with a single atomic write (status.instanceRef plus Phase: Bound). It copies the instance's kubeconfig into a lease-owned Secret. It deletes the claimed instance on release, on deletion, or on TTL expiry. |
Two controllers implement this:
-
ClusterPoolReconcilerhandles supply only. It never binds a lease itself;ClusterLeaseReconcilerdoes that job. It lists theClusterInstances it owns, using theopdev.io/poollabel. It lists theClusterLeases that target it. It computesstatus.totalInstances,status.availableInstances, andstatus.leasedInstances. It maintains three independent floors.maxSizealways bounds these floors:minSize: a stable, total-count floor. This floor counts any non-terminal phase. It works like a cluster autoscaler's minimum node-group size. It stays independent of current lease demand.warmSpares: a spare-capacity floor. The operator measures this floor against Ready, unclaimed instances (autoscaler-style over-provisioning). It keeps this floor ahead of demand. A lease can then bind instantly instead of waiting for a full provision.- Pending
ClusterLeasedemand: the operator provisions instances on demand when leases outnumber whatminSizeandwarmSparesalone would produce. This makesminSize: 0, warmSpares: 0a valid, pure on-demand configuration.ClusterLeaseReconcilernever provisions instances. Without this floor, the pool would never create an instance for a Pending lease.
The controller creates or deletes one
ClusterInstanceper reconcile. This avoids a thundering herd. It targets whichever floor or demand has the largest deficit or surplus. It reports aCapacityAvailablestatus condition:CapacitySufficient,AtCapacity, orMaxSizeTooSmall. This condition shows whether the floors are reachable givenmaxSize, and whether the pool is saturated. For example, aClusterLeasestuck atPendingbecause the pool is atmaxSizenow showsAtCapacity. It does not wait forever in silence. -
ClusterInstanceReconcilerowns the actual backing resources. It dispatches onspec.type:crc: The controller creates a CDIDataVolume. This volume boots fromtemplate.releaseImage, the extractedcrc.qcow2disk image from an official.crcbundle(see CRC bundle setup below). The controller also creates a KubeVirtVirtualMachinewired to that volume. Once the VM'sVirtualMachineInstancereports an IP, the controller creates a run-to-completion crc-agent Job (<instance>-crc-agent-<vmi-hash>). This Job connects to the VM over SSH as usercore, usingtemplate.bundleSSHKeyRef. It runs every post-boot fixup natively, with no external orchestration binary (see crc-agent below). The controller waits for the Job to publish the raw kubeconfig Secret (<instance>-crc-raw-kubeconfig-<vmi-uid>). It verifies the external API before it marks the instanceReady, and retains the result for recovery. KubeVirt is the only hypervisor involved. No nested virtualization is required.hcp: The controller creates a HyperShiftHostedClusterwithplatform: KubeVirt. It setscontrollerAvailabilityPolicyfromtemplate.controllerAvailabilityPolicy(default:SingleReplica). It also creates aNodePool, sized totemplate.nodePoolReplicas(default: 1). It waits forHostedClusterto reportAvailable=True. It waits for theNodePoolto reach its desired replica count. Then it reads the admin kubeconfig Secret referenced bystatus.kubeConfig.
In both cases, once the instance is ready, the controller writes the canonical per-instance kubeconfig Secret (
<instance>-kubeconfig, with keyskubeconfigandocpVersion). It setsstatus.phase,status.ocpVersion,status.topology,status.apiEndpoint, andstatus.kubeconfigSecretRef. If the observed OCP version differs fromspec.template.ocpVersion, it also sets a non-fatalVersionMismatchcondition. CI decides whether a version mismatch is a hard fail or a documented skip. -
ClusterLeaseReconcilerperforms demand-side matchmaking only. It never provisions instances. It listsReadyinstances that belong to the requested pool and are not claimed by any other lease. It claims one instance with a single atomicStatus().Updateon the lease itself (see "Binding model" below). It never writes to theClusterInstanceside. If no instance is free, it sets aWaitingForCapacitycondition and requeues.ClusterPoolReconciler's independent top-up loop eventually produces a free instance. An instance watch wakes aPendinglease as soon as an instance appears. The lease does not wait for the requeue backstop.On release, the lease controller deletes the claimed
ClusterInstanceoutright. Release happens on TTL expiry or explicit deletion of theClusterLease.ClusterPoolReconciler's normal top-up loop then provisions a fresh replacement. It treats this the same as any other capacity shortfall.
The operator records the lease-to-instance relationship in exactly one
place: ClusterLease.Status.InstanceRef. This design deliberately
follows how the Kubernetes scheduler binds a Pod to a Node. The scheduler
writes one authoritative pointer, Pod.Spec.NodeName, once, on the demand
object:
ClusterLeasemaps to Pod.ClusterInstancemaps to Node.Lease.Status.InstanceRefis the only authoritative record of the binding.ClusterInstancehas no "Leased" phase. It carries no authoritative claim state. An instance's own lifecycle stays exactly Provisioning, then Ready, then deleted. This lifecycle does not depend on whether a lease currently claims the instance, just as a KubernetesNodehas no "occupied" phase.Status.LeaseRefonClusterInstanceis a read-only, derived projection.ClusterInstanceReconcilermaintains this field by watchingClusterLeases. This letskubectl get clusterinstanceshow which lease, if any, currently claims the instance. No component uses this field to make a scheduling or lifecycle decision.- Binding is a single write.
ClusterLeaseReconcilercommits a claim with oneStatus().Updateon the lease: it setsInstanceRefandPhase: Bound. It never touches the instance. This design eliminates a whole class of races that existed in earlier iterations of this operator. In one such race, an instance got claimed while its lease's own status had not caught up yet; this caused the operator to provision a second, phantom instance. In another race, a lease retry after a partial write re-claimed a second instance and orphaned the first. With a single write, a failure writes nothing. No partial state needs reconciling. - Claims are "assumed" immediately. The operator treats an instance as
in-use the moment any lease's
InstanceRefnames it. This holds regardless of the instance's own phase, mirroring how the scheduler's cache assumes a binding before the kubelet confirms it. For this reason,ClusterPoolReconciler's supply accounting (minSize,warmSpares, and demand; see above) sources entirely fromClusterLease.Status.InstanceRef.
There is no in-place "recycle" phase. The operator never resets an
instance back to Ready in place. Instead, releasing a lease deletes the
whole ClusterInstance object. This deletion goes through the same
finalizer-driven teardown as any other deletion. If still needed,
ClusterPoolReconciler then provisions a new instance to top back up to
minSize or warmSpares. All leasable instances are pool-owned,
anonymous, and interchangeable. Nothing depends on a stable identity or on
a Service/Route hostname across lease cycles: the operator copies a
lease's kubeconfig Secret and status.apiEndpoint fresh at bind time. So
a plain delete-and-recreate is simpler than a dedicated
teardown-and-recreate-in-place step:
- CRC: The operator deletes the crc-agent
Job, the KubeVirtVirtualMachine, the CDIDataVolume, the raw crc-agent kubeconfig Secret, and the APIServiceandRoute. It also destroys the VM's disk (DataVolume). So no operator or CSV installed by the previous test run can survive. The replacement instance's VM boots a pristine CRC bundle image. - HyperShift: The operator deletes the
HostedCluster, which cascades to the control plane. It also deletes theNodePoolexplicitly, as a backup step. This is a full destroy. No residual workload state remains.
One residual is documented: out-of-band storage provisioned by a
StorageClass with a Retain reclaim policy. This storage stays outside
the operator's control. If your environment needs strictly zero residual,
use a default StorageClass with a Delete policy.
A .crcbundle, as distributed for CodeReady Containers / OpenShift Local,
is a zstd-compressed tar archive. It contains a fully pre-installed
single-node OpenShift cluster disk image (crc.qcow2), a kubeconfig, an
id_ecdsa_crc SSH private key for user core, and metadata. The
distributor ships the bundle in a "parked" state on purpose. Its kubelet
is disabled. Its pull secret is an empty {}. Its certificates stay valid
for only ~30 days from the bundle's build date.
You can supply a bundle to a crc-topology ClusterPool or standalone
ClusterInstance in two ways: the turnkey path (recommended) and a
manual fallback.
Set only two fields on the pool's template. The operator handles
everything else automatically:
template:
crcVersion: "4.16.0" # required: which CRC bundle to use
# crcArch: "amd64" # optional, default "amd64" (also supports "arm64")
pullSecretRef:
name: crc-pull-secretWhen ClusterPoolReconciler sees template.crcVersion set, it creates a
cluster-scoped CRCBundle object named crc-<version>-<arch> (see
internal/resources.CRCBundleName). If one already exists, it reuses
that object instead. CRCBundleReconciler then:
- Creates a golden
PersistentVolumeClaim(default size:35Gi, configurable viaCRCBundle.spec.goldenVolumeSize) and a transient scratchPersistentVolumeClaim(40Gi, fixed size). Both live in the operator's own namespace (seeOPERATOR_NAMESPACEbelow). - Runs a one-time, run-to-completion bundle-prep Job. This Job reuses
the crc-agent container image (see
crc-agent) but overrides its command and
args to run an embedded shell script instead. The Job downloads the
official
.crcbundlefrommirror.openshift.com. It verifies the download against thesha256sum.txtchecksum, extractscrc.qcow2into the scratch PVC, then converts it to raw format at/disk.imgin the golden PVC. The scratch PVC holds both the compressed and extracted files during conversion. - Publishes the bundle's
id_ecdsa_crcSSH key as aSecret. It also publishes a smallConfigMapwith the derived OpenShift version and checksum. Both live in the operator's namespace. TheCRCBundleowns both objects, for garbage collection. - Once ready, sets
CRCBundle.status.phase: Ready. This status includes references to the golden PVC and the SSH key Secret.
Every ClusterInstance created against that pool then clones the
golden PVC, per instance, using CDI's DataVolumeSourcePVC. This clone is
native, cross-namespace, and host-assisted. It needs no re-download and no
shared mutable disk. The instance uses this clone instead of an HTTP
DataVolume import. It also resolves its crc-agent SSH key directly from
the CRCBundle's Secret, not from template.bundleSSHKeyRef.
The CRCBundle object is cluster-scoped and shared. Any number of
ClusterPools or namespaces can reference the same crcVersion and
crcArch. They then reuse the same golden PVC and pay the
mirror-download cost only once. Expect ~10-20GB of egress from the
operator's namespace to mirror.openshift.com the first time you use a
given version. Expect also ~35Gi of golden PVC storage and ~40Gi of
transient scratch PVC storage per distinct version. The operator deletes
the scratch PVC automatically, on a best-effort basis, once the bundle
reaches Ready.
You can also apply a CRCBundle manually, ahead of time (see
config/samples/guestcluster_v1alpha1_crcbundle.yaml). Use this to
pre-warm the golden PVC before any pool references it. Use this also to
override the mirror URL, the sha256sum.txt URL, or the storage class. A
ClusterPool reuses whatever CRCBundle already exists with a matching
name. It does not create a new one in that case.
If you leave template.crcVersion unset, the operator falls back to the
original, fully supported manual path. Use this path for offline or
air-gapped mirrors, for a custom bundle build, or for any source other
than the public mirror.openshift.com layout:
curl -L -o crc.crcbundle <official-or-custom-bundle-download-url>
tar --zstd -xf crc.crcbundle
# upload the extracted crc.qcow2 somewhere reachable over HTTP by CDI
# (an internal registry/webserver on the mgmt cluster's network is fine)Then, once per ClusterPool (not once per lease):
- Set
template.releaseImageto the URL of that extractedcrc.qcow2. - Create a
Secretfrom the bundle'sid_ecdsa_crcand reference it viatemplate.bundleSSHKeyRef:kubectl create secret generic crc-bundle-ssh-key --from-file=id_ecdsa=./id_ecdsa_crc
template.pullSecretRefstays optional, on both paths. See Pull secret below.
If you set both, template.crcVersion takes priority. The operator
ignores releaseImage and bundleSSHKeyRef in that case.
template.pullSecretRef is optional. If you leave it unset, the
operator defaults to a copy of the management cluster's own global pull
secret. This is the pull-secret Secret in the openshift-config
namespace. Every OpenShift cluster has this Secret already, scoped to
quay.io/openshift-release-dev. So provisioning works out of the box. You
do not need to supply a credential the cluster already has. This default
requires the RBAC that make deploy installs (see
Prerequisites). If you deploy manually, apply
config/openshift-config-rbac too.
Set template.pullSecretRef explicitly to override this default. For
example, a disconnected or mirrored registry may need a narrower or
different credential:
kubectl create secret generic crc-pull-secret --from-file=.dockerconfigjson=./pull-secret.json --type=kubernetes.io/dockerconfigjsonthen reference it via template.pullSecretRef.name: crc-pull-secret.
The affected ClusterInstance fails fast in two cases. First, if the
pull secret is missing or malformed: neither an explicit pullSecretRef
nor the cluster's default pull secret is usable. Second, if the bundle
SSH key is missing or malformed, on the manual path only (the turnkey
path derives its key from the CRCBundle and never checks
bundleSSHKeyRef). In both cases, the operator sets status.phase: Failed and a Ready=False condition. The reason field reads
InvalidPullSecret or MissingBundleSSHKey. This failure happens before
the operator creates any VM or Job. You get a clear failure, not a late,
opaque SSH error.
Bundle certificates expire after ~30 days. For this reason, recycle always re-provisions a fresh VM and re-runs the crc-agent Job from scratch (see Recycle semantics). This also refreshes the certificates on every reuse. Both the turnkey and manual paths work this way.
The turnkey path's golden PVC, scratch PVC, SSH-key Secret, metadata
ConfigMap, and bundle-prep Job all live in "the operator's own
namespace." internal/resources.OperatorNamespace() resolves this
namespace in priority order: first the OPERATOR_NAMESPACE environment
variable, then the in-cluster service account namespace file
(/var/run/secrets/kubernetes.io/serviceaccount/namespace), then a fixed
value of "default". config/manager/manager.yaml already sets
OPERATOR_NAMESPACE through the downward API (fieldRef: metadata.namespace). So deployments through make deploy need no extra
configuration. If you run the manager locally (make run), or through
some other method where neither the env var nor the service-account
namespace file exists, export OPERATOR_NAMESPACE yourself. Otherwise
the turnkey path defaults to the default namespace.
You can only observe CRC's own health, version, and kubeconfig from
inside the guest. A freshly booted bundle VM also needs several mandatory
fixups before use. These fixups start the disabled kubelet, swap in a
fresh SSH key, regenerate the admin CA and client cert, approve pending
kubelet CSRs, inject the real pull secret, set credentials, and make the
kubeconfig's server URL routable. crc-agent implements this checklist
natively in Go. An SSH runner (golang.org/x/crypto/ssh, ported from
patterns in the upstream crc CLI) drives the steps that must run as
root on the guest itself: dnsmasq, guest DNS configuration, starting the
kubelet, swapping the SSH key, and the one-time CA bootstrap (see
cmd/crc-agent/guest.go). Typed k8s.io/client-go and dynamic clients
drive everything else, tunneled to the guest's API server over the same
SSH connection: CSR approval, pull-secret and htpasswd patches, and the
external API serving-cert plus apiserver patch (see
cmd/crc-agent/cluster.go). This design replaces an earlier one that
shelled out to the unmaintained
crc-cloud upi provider,
itself built on Pulumi's Automation API. The operator has removed that
dependency, and Pulumi, entirely.
The guest CRC VM's own pod-network IP is not routable from outside the
management cluster: it lives in the cluster's internal pod CIDR. So the
operator provides external access to the guest API through a
management-cluster-side passthrough Route (see "External API
access" below), not through a DNS name that points directly at the VM.
For each ClusterInstance of topology crc, once its
VirtualMachineInstance reports an IP, ClusterInstanceReconciler
ensures a Service and passthrough Route that expose the guest API
externally (see below). It then creates a run-to-completion Kubernetes
Job (<instance>-crc-agent-<vmi-hash>, see internal/resources.BuildCRCAgentJob)
that runs this binary. The binary:
- Waits for the VM's SSH endpoint (port 22) to accept connections. It
then connects as user
core, using the bundle's SSH key. - Runs the guest-side fixups over that connection. It detects the
guest's own internal IP, which differs from the externally reachable
VMI IP on masquerade-networked KubeVirt VMs. It configures dnsmasq and
guest DNS resolution: through NetworkManager on OVN bundles, matching
upstream
crc's own approach, with a direct/etc/resolv.conffallback for older bundles. It starts the kubelet. It generates a fresh SSH keypair and swaps it in. It bootstraps a new admin CA and client certificate, by patchingadmin-kubeconfig-client-cathroughoc, run on the guest itself. This is the one deliberate exception to the "no more shelling out to CLIs" rule. - Reconnects with the new SSH key. It opens a tunnel to the guest's
api.crc.testing:6443. It builds typed OpenShift and Kubernetes clients over that tunnel, using the freshly minted client certificate. It verifies this certificate against the bundle's own server CA, a different trust root than the one generated for client-cert auth (seeguestResult.ServerCAPEM's doc comment). - Runs the remaining cluster-side fixups through those typed clients. It
approves pending kubelet CSRs. It injects the real pull secret. It
sets
kubeadminanddevelopercredentials, bcrypt-hashed in-process. It mints a self-signed serving certificate for the externally routable API hostname,CRC_API_HOSTNAME, the Route host the controller already provisioned. It patches the apiserver'snamedCertificatesso the API server presents this certificate for that hostname. - Reads the resulting kubeconfig. It rewrites the server URL to
https://<CRC_API_HOSTNAME>:443. It embeds the same self-signed certificate as the trusted CA. It derives the cluster's OpenShift version from the typedClusterVersionobject. - Publishes both values and the configured VMI UID into
<instance>-crc-raw-kubeconfig-<vmi-uid>, with keyskubeconfig,ocpVersion, andvmiUID. This Secret forms the only contract between crc-agent andClusterInstanceReconciler.ClusterInstanceReconcilerreads this Secret and never connects over SSH itself.
BuildCRCAgentJob sets configuration through environment variables:
INSTANCE_NAME, INSTANCE_NAMESPACE, CRC_SSH_HOST (the VM's IP),
CRC_VMI_UID (the VMI identity),
CRC_API_HOSTNAME (the externally routable Route host), and
CRC_SSH_KEY_PATH and PULL_SECRET_PATH (mounted Secret file paths).
The VMI's pod-network IP is reachable only from inside the management
cluster. So ensureCRCAPIRoute (in
internal/controller/clusterinstance_crc.go) provisions, per instance:
- A
ClusterIPService(<instance>-crc-api, seeresources.BuildCRCAPIService). This Service selects the VM's virt-launcher pod directly, through KubeVirt's ownvm.kubevirt.io/namelabel. It exposes port6443. - A passthrough
Route(<instance>-crc-api, seeresources.BuildCRCAPIRoute), at hostapi-<instance>.<mgmt-ingress-domain>. The operator reads the domain from the management cluster's owningresses.config.openshift.io/cluster. The management cluster's own router fronts this Route.
The Route requires passthrough termination, not edge or reencrypt
termination. The Kubernetes API needs mTLS client-certificate
authentication and SPDY/websocket upgrades, for exec, logs, and
port-forward. These features work only if the router forwards the raw
TLS stream to the guest API server untouched. This exposure method does
not weaken security. The router performs pure L4 SNI forwarding. It
never terminates or inspects the TLS session. So the same end-to-end mTLS
and API-server authentication/authorization that protects every
OpenShift cluster's API applies here too. Reachability does not equal
access.
The Service and Route persist for the ClusterInstance's whole lifetime.
So an already-distributed kubeconfig's hostname stays valid for the
duration of a bound lease. The operator deletes them only when it tears
down the ClusterInstance itself, which happens outright on lease
release (see
Recycle semantics).
status.apiEndpoint holds this Route's URL (see "Known limitations"
below; earlier versions left this field empty for the crc topology).
The Job runs to completion once per boot. On failure, the Job's
backoffLimit (2) governs retries. A fresh Job always accompanies a
fresh VM.
Dockerfile.crc-agent builds the crc-agent container image. This image
layers this repo's cmd/crc-agent binary and the oc CLI, fetched from
the stable-4 mirror channel, onto quay.io/centos/centos:stream9. The
same image serves, unmodified, the CRCBundle bundle-prep Job
described above: its curl, tar, zstd, jq, and oc tooling
matches exactly what the prep script needs. The prep Job simply
overrides the image's Command and Args at the Pod-spec level, instead
of using its default ENTRYPOINT. Build and push it with:
make docker-build-crc-agent CRC_AGENT_IMG=<registry>/guestcluster-operator-crc-agent:<tag>
make docker-push-crc-agent CRC_AGENT_IMG=<registry>/guestcluster-operator-crc-agent:<tag>CRC_AGENT_IMG is a Makefile-only variable (default:
opdev.io/guestcluster-operator-crc-agent:latest). It controls the tag
that docker-build-crc-agent and docker-push-crc-agent produce. The
running manager does not read this variable automatically. You must
separately set the runtime CRC_AGENT_IMAGE environment variable on
the manager Deployment, to match whatever tag you actually pushed. If
unset, this defaults to internal/resources.DefaultCRCAgentImage, the
same string as CRC_AGENT_IMG's default. The two variables stay
independent, at two different times: one is a build-time Makefile
variable, the other a runtime environment variable, read through
os.Getenv by both ClusterInstanceReconciler and CRCBundleReconciler.
For the crc-agent Job, the image must run as the crc-agent
ServiceAccount (config/rbac/crc_agent_*.yaml), scoped to secrets and
its VirtualMachineInstance in the operator's namespace. For the bundle-prep Job, it must
run as the bundle-prep ServiceAccount, scoped to secrets and
configmaps access in the operator's namespace only.
The image fetches the bundled oc CLI from the stable-4 mirror channel
at build time. This CLI is not pinned to any specific bundle's exact
OpenShift version. See Known limitations.
On the management OpenShift cluster:
- OpenShift 4.14+.
- OpenShift Virtualization
installed: KubeVirt plus CDI. Both the CRC-as-VM path and HyperShift's
KubeVirt worker platform need this. KubeVirt itself acts as the
hypervisor for CRC VMs; it boots the CRC bundle's
crc.qcow2directly. So no nested virtualization is required. - HyperShift operator installed.
Required for the
hcptopology. - OVNKubernetes CNI, wildcard DNS routes enabled on the default
IngressController, a defaultStorageClass, and aLoadBalancerimplementation, for example MetalLB. These are standard HyperShift-on-KubeVirt prerequisites. - A valid pull secret for
quay.io/openshift-release-dev.template.pullSecretRefis optional. It defaults to a copy of the management cluster's own global pull secret,openshift-config/pull-secret(see Pull secret). This default requires the operator to havegetaccess to that Secret. The RBAC inconfig/openshift-config-rbacgrants this access;make deployapplies it automatically. To override the default, settemplate.pullSecretRefexplicitly, pointing at an opaqueSecretin the same namespace as the pool or instances. - For
crcpools specifically: an extracted CRC bundlecrc.qcow2, hosted at an HTTP-reachable URL, and aSecretholding itsid_ecdsa_crcSSH key (template.bundleSSHKeyRef). See CRC bundle setup below. - A crc-agent container image, with
ocinstalled, pushed somewhere your cluster can pull from. Reference this image through the manager'sCRC_AGENT_IMAGEenvironment variable. See crc-agent below. - Go 1.26 or later, Docker or Podman,
kubectloroc. If you plan to modify this repo, also installoperator-sdkv1.42 or later.
Install the CRDs and RBAC:
make installRun the manager, either locally against your current kubeconfig context, or deployed in-cluster:
# local dev
make run
# or build+push an image and deploy
make docker-build docker-push IMG=<registry>/guestcluster-operator:tag
make deploy IMG=<registry>/guestcluster-operator:tagCreate pools for the topologies you need. See config/samples/ for
ready-to-edit examples: guestcluster_v1alpha1_clusterpool.yaml for
crc, and guestcluster_v1alpha1_clusterpool_hcp.yaml for hcp:
kubectl apply -f config/samples/guestcluster_v1alpha1_clusterpool.yaml
kubectl apply -f config/samples/guestcluster_v1alpha1_clusterpool_hcp.yamlEach pool starts topping up instances to satisfy spec.minSize and
spec.warmSpares. spec.maxSize bounds both values.
-
CI creates a
ClusterLeasethat names the pool it wants. The pool'sspec.typedetermines the topology; for example,crc-poolbelow names acrcpool:apiVersion: guestcluster.opdev.io/v1alpha1 kind: ClusterLease metadata: name: ci-job-1234-lease spec: poolRef: name: crc-pool ttl: 2h # optional safety-net auto-release requestedBy: ci-job-1234
kubectl apply -f lease.yaml
-
CI polls the lease until it's bound:
kubectl wait --for=jsonpath='{.status.phase}'=Bound clusterlease/ci-job-1234-lease --timeout=20m
-
CI reads the explicit outputs off the lease status and the kubeconfig Secret it references:
kubectl get clusterlease ci-job-1234-lease -o jsonpath='{.status.topology}' # crc | hcp kubectl get clusterlease ci-job-1234-lease -o jsonpath='{.status.ocpVersion}' # e.g. 4.16.0 SECRET=$(kubectl get clusterlease ci-job-1234-lease -o jsonpath='{.status.kubeconfigSecretRef.name}') kubectl get secret "$SECRET" -o jsonpath='{.data.kubeconfig}' | base64 -d > kubeconfig KUBECONFIG=./kubeconfig oc get nodes
Compare
status.ocpVersionandstatus.topologyagainst the artifact's documented OCP line. A mismatch is a hard fail or a documented skip, per your CI policy. The operator surfaces the mismatch as a non-fatalVersionMismatchcondition on the underlyingClusterInstance. It does not decide pass or fail for you. -
When the job is done, CI deletes the lease to release the slot:
kubectl delete clusterlease ci-job-1234-lease
This step is safe even without an explicit "release" step. Deleting the lease always deletes the bound instance, through a finalizer.
ClusterPoolReconcilerthen provisions a fresh replacement, if needed, to top back up tominSizeorwarmSpares.
Two jobs that request leases from the same pool at the same time bind to
two different ClusterInstances. Each instance gets its own unique
VM name and network endpoint for CRC, or its own HostedCluster and
NodePool names for HyperShift. This holds as long as the pool has
enough capacity, or can grow to enough capacity within maxSize. Binding
stays race-safe through Kubernetes optimistic concurrency:
resourceVersion conflicts on ClusterInstance.status retry naturally.
No manual locking is required. Each pool's spec.maxSize alone enforces
the hypervisor budget. Size your pools to your hypervisor's real capacity.
kubectl delete -k config/samples/
make uninstall
make undeployapi/v1alpha1/ ClusterPool, ClusterInstance, ClusterLease, CRCBundle types
internal/resources/ Builder helpers for KubeVirt/CDI/HyperShift/CRCBundle objects
internal/controller/ The four reconcilers
cmd/main.go Manager entrypoint (registers all schemes)
cmd/crc-agent/main.go Standalone CRC-VM kubeconfig publisher (also reused, via
command/args override, as the CRCBundle bundle-prep Job)
Dockerfile.crc-agent Multi-stage image for cmd/crc-agent + oc
config/crd, config/rbac, ... Generated manifests (make manifests / make generate)
config/samples/ Example CRs for crc (turnkey + manual), hcp, CRCBundle
- The operator observes the crc-agent Job's failure only indirectly. If
the post-boot fixups permanently fail, once the Job's
backoffLimitis exhausted, the raw kubeconfig Secret never appears. TheClusterInstancethen staysProvisioning, with periodic requeue, instead of moving to a hardFailedstate. A follow-up should watch the Job's status directly and surface a clear failure condition. - The operator intentionally fixes the
<instance>-crc-apiRoute's hostname at creation time, from the management cluster's ingress domain at that moment. If the management cluster's owningresses.config.openshift.iodomain later changes, existing instances keep their original Route host. They do not migrate. - IDMS/ICSP mirror configuration (
template.idmsRef) is wired through toHostedCluster.spec.imageContentSourcesas a structural stub only. Parsing an actualImageDigestMirrorSetorImageContentSourcePolicyobject into that field is a follow-up. - Controllers currently poll rather than use
OwnsorWatcheson the KubeVirt/HyperShift backing objects: every 20s forClusterInstance, every 10s or 30s forClusterLease. Given multi-minute provisioning times, this polling is acceptable. Event-driven watches would still reduce convergence latency further. - The turnkey CRC bundle path's cross-namespace PVC clone relies on a
single, cluster-wide
datavolumes/source: createRBAC grant, for the manager'sServiceAccount(see the+kubebuilder:rbacmarker onClusterInstanceReconciler). This follows CDI's documented cross-namespace-clone authorization pattern. This project has not independently re-verified the pattern against CDI's source code. If clones fail unexpectedly with a permission error, confirm this pattern against your CDI/OpenShift Virtualization version. Dockerfile.crc-agentfetches theocCLI from thestable-4mirror channel at image-build time. This CLI is not pinned to any particular CRC bundle's exact OpenShift version. This should remain compatible with the simpleoc get clusterversion,oc apply, andoc patchoperations that crc-agent and the bundle-prep Job perform. This compatibility is not guaranteed across all future OpenShift version skew. If this becomes a problem, rebuild the image periodically, or pinOC_CHANNEL, or vendor your ownocbinary.
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.