OpenShift Virtualization full stack¶
Two GitHub checkouts, one cluster. This installer provisions ARO HCP, GitOps, and a virt-ready node pool. Sibling validated-pattern-openshift-virt provisions Azure NetApp Files, Trident CSI, OpenShift Virtualization, Azure Route Server, and the CUDN BGP operator (in-cluster build of bgp-cloud-connector, pinned to a commit SHA in the sibling GitOps overlay).
Do not create ANF, Trident CRs, CNV, or Azure Route Server in this repository. Do not install a second Argo CD.
Agents (done-when, extra-hop, tmux): clusters/aro-virt/AGENTS.md. This page is the operator command path.
What you get¶
| Layer | Repo | Result |
|---|---|---|
| Cluster | this repo, clusters/aro-virt |
Public API/ingress, Fedora jump (10.0.2.0/28, set jump_ssh_source_prefix), np-1 (Standard_D4s_v6 × 2) + np-virt (Standard_D8s_v6 × 2, labels workload=virtualization and bgp_router=true, Azure zone 1) |
| GitOps baseline | this repo make cluster.aro-virt.bootstrap |
OpenShift GitOps, Web Terminal, Compliance, External Secrets |
| Platform contract | make cluster.aro-virt.platform |
Gitignored clusters/aro-virt/platform.json |
| ANF + Trident + CNV + Route Server | sibling make cluster.aro-virt.apply then .bootstrap |
Delegated subnet 10.0.3.0/24, RouteServerSubnet 10.0.4.0/26, ANF account/pool, StorageClass anf-virt, HyperConverged, bgp-cloud-connector |
ANF NFS is VNet-native RFC1918 (delegated subnet), not a Private Endpoint — see Network privacy. Cluster default StorageClass stays managed-csi. CNV uses anf-virt via storageclass.kubevirt.io/is-default-virt-class.
Microsoft supports OpenShift Virtualization on ARO only on Dsv5 / Dsv6 with 8+ cores (Azure Boost). Keep np-1 for platform pods. Do not taint np-virt unless HyperConverged and virt-handler have matching tolerations.
This example shares np-virt as BGP speakers (bgp_router=true): those nodes run FRR and peer with Azure Route Server. That is enough for a two-node demo (Route Server has two BGP endpoints). Production can keep a small speaker pool (Azure Route Server 16 BGP peers max) and run CUDN/VMs on other workers if those NICs have enableIPForwarding. OVN extra-hops VNet ingress from a speaker to the VM’s node; the reply egresses the VM node with the CUDN source, which Azure drops unless forwarding is on. Do not label virt nodes bgp_router=true just to get that flag.
bgp-cloud-connector only enables forwarding on routerNodeSelector today (RFE #121). Until that ships, the sibling GitOps DaemonSet azure-nic-ip-forwarding patches all worker NICs using cluster-api-azure (tracking #9).
Speaker (and workaround) NIC IP forwarding uses the installer cluster-api-azure identity (federated to the bgp-cloud-connector ServiceAccount). The sibling BGP MI only manages Route Server BGP connections in the customer RG. Worker NICs are in the managed RG (RP deny assignment). That token-exchange lets the operator act as full CAPI there — tracked for a tighter identity in installer #20. platform.json publishes cluster_api_azure_client_id for the sibling Job (spec.azure.networkInterfaceClientID).
Prerequisites¶
Complete Account prerequisites and sibling prerequisites in addition to a public cluster:
| Extra | Notes |
|---|---|
| Two checkouts | This repo and validated-pattern-openshift-virt (or gitignored references/validated-pattern-openshift-virt) |
| Dsv6 quota | +16 vCPU Standard Dsv6 in location (np-virt × 2 × 8 cores). Jump adds +2 vCPU Standard_D2s_v6 |
Microsoft.NetApp |
Registered; ANF capacity quota. Sibling pool default is 1 TiB Flexible (billable) |
| Free CIDR | Installer netapp_subnet_prefix default 10.0.3.0/24 and route_server_subnet_prefix default 10.0.4.0/26 must not overlap worker, integration, jump (10.0.2.0/28), or each other |
| Tools | Same as the installer, plus sibling Terraform >= 1.9 |
Permissions: installer full-stack by step. Sibling apply needs Contributor + User Access Administrator on the customer RG (Trident custom role). Sibling bootstrap needs the installer kubeconfig (cluster-admin).
0. Unset TF_VAR_*¶
Leftover TF_VAR_* (ROSA tests, tags, cluster_name, location) still reach Terraform for keys not in the cluster tfvars. Unset them in the same shell as every make cluster.* in both repos:
env | grep '^TF_VAR_' || true
# If any are set, unset them or align the cluster tfvars. Do not apply until they match.
while IFS= read -r k; do unset "$k"; done < <(env | awk -F= '/^TF_VAR_/ {print $1}')
unset TF_DATA_DIR
-var-file wins for keys in clusters/aro-virt/terraform.tfvars. Env leftovers that are not in that file still apply.
1. Installer (this repo)¶
cp -r clusters/aro-virt clusters/my-virt # or deploy clusters/aro-virt in place
# Edit location, cluster_name, versions. Example pull_secret_path = "../tmp/pull-secret.txt"
mkdir -p tmp
cp /path/to/pull-secret.txt tmp/pull-secret.txt
make setup
make cluster.aro-virt.jump-key # clusters/aro-virt/jump + jump.pub
# Set jump_ssh_source_prefix in terraform.tfvars to your public /32
make cluster.aro-virt.plan
make cluster.aro-virt.apply # ~30–60 min; both node pools + jump
make cluster.aro-virt.kubeconfig # 24h admin → .kube/config
make cluster.aro-virt.external-auth # console is 503 until this finishes
GitOps + optional org overlay (Entra group cluster-admin):
# Tenant-neutral baseline (this repo):
make cluster.aro-virt.bootstrap
# Or a cluster-config repo (example: MOBB overlay named aro-virt):
GITOPS_REPO=https://github.com/rh-mobb/validated-pattern-aro-hcp-cluster-config.git \
GITOPS_SOURCE_ROOT=overlays \
make cluster.aro-virt.bootstrap
Copying clusters/aro-virt to clusters/my-virt still bootstraps overlay aro-virt when that directory exists under gitops/overlays/ (or GITOPS_OVERLAY). Cluster-config uses overlays/aro-virt the same way.
Publish the contract after GitOps is up:
Workers Ready is not a finished install. Console URL HTTP 200 and clusterversion Available need external-auth.
2. Sibling (ANF + Trident + CNV + Route Server)¶
From the sibling checkout. Point ingest at the installer profile. Point oc at the installer kubeconfig (sibling defaults to its own .kube/config, which is empty):
export ARO_HCP_ROOT=/path/to/validated-pattern-aro-hcp
export ARO_HCP_PROFILE=aro-virt
export KUBECONFIG_PATH="${ARO_HCP_ROOT}/.kube/config"
export KUBECONFIG="${KUBECONFIG_PATH}"
# Unset TF_VAR_* again in this shell.
cp -r clusters/aro-virt clusters/my-virt # or use clusters/aro-virt in place
ARO_HCP_ROOT="${ARO_HCP_ROOT}" ARO_HCP_PROFILE=aro-virt \
make cluster.aro-virt.plan
ARO_HCP_ROOT="${ARO_HCP_ROOT}" ARO_HCP_PROFILE=aro-virt \
make cluster.aro-virt.apply # ANF subnet, RouteServerSubnet, ANF pool, Trident + BGP identities
make cluster.aro-virt.bootstrap # Argo Application virt-stack + anf-platform-metadata + bgp-platform-metadata
One Argo CD instance (openshift-gitops). cluster-config (installer) and virt-stack (sibling) are two Applications on the same application controller.
The sibling overlay binds OpenShift cluster-admin to openshift-gitops-argocd-application-controller so Argo can create Trident ServiceAccounts, VolumeSnapshotClass, TridentOrchestrator, and HyperConverged. The installer baseline does not grant that (ESO ignores ServiceAccount drift instead). Tightening that binding: virt issue #6.
3. Verify¶
Installer:
export KUBECONFIG=/path/to/validated-pattern-aro-hcp/.kube/config
az aro hcp cluster show -g aro-virt-rg -n aro-virt --query provisioningState
oc get nodes -L workload,bgp_router,topology.kubernetes.io/zone
oc get clusterversion
oc get co console
# Console route should return HTTP 200 after external-auth
Expect four workers: two unlabeled np-1, two workload=virtualization / bgp_router=true on np-virt. Kubernetes zone labels look like uksouth-1 even when create input was "1".
Sibling / GitOps:
oc -n openshift-gitops get applications.argoproj.io
# cluster-config and virt-stack: Synced / Healthy
oc get sc
# managed-csi (default) disk.csi.azure.com
# anf-virt csi.trident.netapp.io virt-class annotation
oc -n trident get tbc anf-backend
# PHASE Bound
oc -n openshift-cnv get hyperconverged kubevirt-hyperconverged
# systemHealthStatus healthy
# CDI clone/upload memory (default ~600M OOMs on large images without this)
oc get hyperconverged kubevirt-hyperconverged -n openshift-cnv \
-o jsonpath='{.spec.resourceRequirements.storageWorkloads.limits.memory}{"\n"}'
# Expected: 4Gi
oc get cdiconfig config -o jsonpath='{.status.defaultPodResourceRequirements.limits.memory}{"\n"}'
# Expected: 4Gi
oc -n openshift-cnv get pods -l kubevirt.io=virt-handler -o wide
oc get bgpcloudconfiguration cluster -o yaml
# spec.platform Azure; status shows Route Server neighbors
oc -n openshift-bgp-cloud-connector get pods
oc -n openshift-bgp-cloud-connector get builds
oc get bgprouting virt
# Ready; operator created ClusterUDN cluster-udn-virt (do not apply a CUDN yourself)
oc get clusteruserdefinednetwork cluster-udn-virt
oc get ns virt --show-labels
# cluster-udn=virt, k8s.ovn.org/primary-user-defined-network=
Workloads that should be reachable from the jump / VNet go in namespace virt (primary CUDN 192.168.100.0/24). Overlay IPs (10.128.0.0/14) are not advertised. Jump ping/HTTP to CUDN on speakers and np-1 (extra-hop): agent playbook clusters/aro-virt/AGENTS.md. OpenShift 4.21.8+ OVN (wrong-node egress) does not replace Azure enableIPForwarding on the VM/pod node.
Smoke RWX (optional; ANF first volume often takes 5–15 minutes, CSI may DeadlineExceeded then bind on retry):
oc create ns e2e-anf-test
oc apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: rwx-smoke
namespace: e2e-anf-test
spec:
accessModes: ["ReadWriteMany"]
storageClassName: anf-virt
resources:
requests:
storage: 100Gi
EOF
oc -n e2e-anf-test get pvc rwx-smoke -w
# Bound, then: oc delete ns e2e-anf-test
CNV golden images in openshift-virtualization-os-images also provision on anf-virt.
4. Destroy¶
Sibling first. trident-cleanup.sh (via make cluster.aro-virt.destroy) deletes the sibling virt-stack Argo CD Application first so GitOps does not recreate BGP CRs, then drains BGP CRs (while the operator can still remove Azure peerings), then anf-virt PVCs (timeout 180s each). ANF volume delete is slower than that; leftover volumes make terraform destroy fail on the capacity pool and NetApp subnet.
# sibling checkout
export KUBECONFIG_PATH=/path/to/validated-pattern-aro-hcp/.kube/config
# unset TF_VAR_*
make cluster.aro-virt.destroy # cleanup + terraform destroy
If destroy errors CannotDeleteResource / InUseSubnetCannotBeDeleted, list and delete remaining volumes, wait until the list is empty, then re-run destroy:
az netappfiles volume list -g aro-virt-rg --account-name aro-virt-anf --pool-name aro-virt-anf-pool -o table
az netappfiles volume delete -g aro-virt-rg --account-name aro-virt-anf --pool-name aro-virt-anf-pool --name <pvc-…> --yes
Then the installer:
# installer checkout; unset TF_VAR_*
make cluster.aro-virt.destroy # state-rm all Terraform nodePools, then terraform destroy
This installer destroy does not call the sibling. It also does not delete a leftover Entra app if Graph still shows aro-virt-cluster-app after state is empty — an Application Administrator (or app owner) must remove it.
Troubleshooting¶
| Symptom | Cause | What to do |
|---|---|---|
virt-stack Forbidden on ServiceAccounts / VolumeSnapshotClass / TridentOrchestrator / HyperConverged |
Default GitOps ClusterRole is get/list/watch | Sibling bootstrap pre-applies virt-stack-gitops-controller before the Application. If missing: oc apply -f …/gitops/base/gitops-controller-rbac.yaml, then sync virt-stack. |
azure-nic-ip-forwarding CreateContainerConfigError (configmap "azure-nic-ip-forwarding" not found) |
bgp-from-metadata Job did not finish before the DS (old hook: Sync ordering) |
Upgrade sibling GitOps (Job is sync-wave 4, DS wave 6). oc apply -f …/from-metadata-job.yaml once, or delete the DS and sync virt-stack. |
trident-from-metadata Job hangs on oc get tridentorchestrator |
Namespaced Role cannot get cluster-scoped CRs | Sibling ClusterRole trident-from-metadata (orchestrator + CRD get). |
PVC Pending, Azure volume Creating |
ANF create is slow | Wait; do not treat the first CSI timeout as failure. |
| Sibling destroy 409 on the pool | Volumes still exist | Delete ANF volumes, wait, destroy again. |
Sibling bootstrap oc whoami fails |
Empty sibling .kube/config |
KUBECONFIG_PATH / KUBECONFIG = installer .kube/config. |
| Tags / region / name wrong | Leftover TF_VAR_* |
Unset in the same shell; see step 0. |
az aro hcp cluster request-credential hangs; activity log Started/Accepted only |
RP requestAdminCredential LRO never terminal (CLI waits on Location HTTP 202) |
Wait or open an RP issue. Do not start a second request (REST or CLI) or revoke until you choose that. |
Jump → all CUDN IPs fail; ping TTL exceeded from a speaker (10.0.0.x); curl times out |
OVN-K did not install br-ex ingress openflow for 192.168.100.0/24 (race when CUDN patch port is created) |
BGP/Azure can still look fine. On a speaker: ovs-ofctl dump-flows br-ex must show priority=300,in_port=1,nw_dst=192.168.100.0/24. If missing: oc -n openshift-ovn-kubernetes delete pod -l app=ovnkube-node, wait for rollout, re-test jump. Agent playbook: clusters/aro-virt/AGENTS.md#if-jump-to-any-cudn-fails-ttl-exceeded--all-speakers-too. Do not set CNO ipForwarding: Global. |
Jump → CUDN on np-1 fails; speakers work |
NIC enableIPForwarding false (not the 4.21.8 OVN wrong-node-egress bug) |
Sibling DS azure-nic-ip-forwarding / CAPI; do not label np-1 bgp_router=true. Agent steps: clusters/aro-virt/AGENTS.md |
| DataVolume clone stuck ~65%, CDI pod OOMKilled | CDI default ~600M memory limit | Sibling GitOps sets storageWorkloads on HyperConverged; verify cdiconfig → 4Gi. See CDI storage workloads. |
disk.img: file exists on clone retry |
Partial clone after OOM | Delete DV, tmp PVCs in openshift-virtualization-os-images, related pods; retry after cdiconfig shows 4Gi. |