πŸ“šBook Signing at KubeCon EU 2026Meet us at Booking.com HQ (Mon 18:30-21:00) & vCluster booth #521 (Tue 24 Mar, 12:30-1:30pm) β€” free book giveaway!RSVP Booking.com Event
Configuration advanced ⏱ 45 minutes K8s 1.28+

Air-Gap OpenShift Upgrade oc-mirror OSUS

Upgrade air-gapped OpenShift with oc-mirror and OSUS. Mirror release payloads and Cincinnati graph, configure IDMS, and drive CVO upgrades.

By Luca Berton β€’ β€’ πŸ“– 8 min read

πŸ’‘ Quick Answer: In an air-gapped OpenShift cluster, the cluster only β€œknows” about a target version (e.g., 4.20.12) after you mirror both the release payload AND the Cincinnati update graph. Use oc mirror with graph: true and shortestPath: true on a connected bastion β€” this pulls release images from Red Hat/Quay AND builds/pushes the graph-data image required by OSUS. Transfer the archive to the disconnected site, push to the local registry, apply the generated cluster-resources/ manifests (mirror rules + UpdateService), and oc adm upgrade --to=4.20.12 drives the upgrade.

The Problem

Air-gapped OpenShift clusters cannot:

  • Reach quay.io to pull release images
  • Query api.openshift.com for the upgrade graph (Cincinnati)
  • Discover which versions exist or which paths are safe
  • See conditional or blocked upgrade advisories

Without both the release payload AND the graph data mirrored locally, oc adm upgrade shows β€œNo updates available” even if images exist in the mirror registry.

The Solution

End-to-End Workflow

graph TD
    subgraph Connected Bastion
        ISC[ImageSetConfiguration<br/>stable-4.20, graph: true<br/>shortestPath: true] --> MIRROR[oc mirror -c config<br/>file:///data/oc-mirror-4.20 --v2]
    end
    
    subgraph Transfer
        MIRROR --> |sneakernet / diode| ARCHIVE[/data/oc-mirror-4.20<br/>on removable media]
    end
    
    subgraph Disconnected Site
        ARCHIVE --> PUSH[oc mirror --from file://<br/>docker://registry:5000 --v2]
        PUSH --> MANIFESTS[Apply cluster-resources/<br/>mirror rules + UpdateService]
        MANIFESTS --> CVO[Patch CVO<br/>spec.upstream β†’ OSUS]
        CVO --> UPGRADE[oc adm upgrade<br/>--to=4.20.12]
    end
    
    style MIRROR fill:#EE0000,color:white
    style PUSH fill:#EE0000,color:white
    style UPGRADE fill:#4CAF50,color:white

Step 1: Create ImageSetConfiguration

On the internet-connected bastion:

# imageset-config.yaml
apiVersion: mirror.openshift.io/v2alpha1
kind: ImageSetConfiguration
mirror:
  platform:
    architectures:
    - amd64
    channels:
    - name: stable-4.20
      type: ocp
      minVersion: 4.20.8
      maxVersion: 4.20.12
      shortestPath: true      # Only mirror the shortest upgrade path
    graph: true                # Builds and pushes the graph-data image for OSUS

Key settings:

FieldPurpose
architectures: [amd64]Target architecture (also arm64, multi)
minVersion: 4.20.8Start from current cluster version
maxVersion: 4.20.12Target upgrade version
shortestPath: trueOnly mirror versions on the shortest upgrade path (saves space)
graph: trueCritical β€” builds and pushes the Cincinnati graph-data image for OSUS

Without graph: true, oc-mirror only mirrors release images β€” OSUS has nothing to serve and CVO sees no upgrade path.

Step 2: Mirror to Archive (Connected Side)

# Mirror to a portable archive
oc mirror -c imageset-config.yaml \
  file:///data/oc-mirror-4.20 \
  --v2

# Output structure:
# /data/oc-mirror-4.20/
# β”œβ”€β”€ mirror_seq1_000000.tar           ← Release image layers
# β”œβ”€β”€ working-dir/
# β”‚   └── cluster-resources/
# β”‚       β”œβ”€β”€ itms-oc-mirror.yaml      ← ImageTagMirrorSet
# β”‚       β”œβ”€β”€ idms-oc-mirror.yaml      ← ImageDigestMirrorSet
# β”‚       β”œβ”€β”€ updateService.yaml       ← Generated OSUS UpdateService
# β”‚       └── release-signatures/
# └── publish/

# Check archive size
du -sh /data/oc-mirror-4.20/
# ~8-15 GB with shortestPath: true (vs 25+ GB without)

graph: true tells oc-mirror to build the Cincinnati graph-data container image containing the upgrade graph for the mirrored version range and push it alongside the release images.

Step 3: Transfer to Disconnected Site

# Copy archive to removable media
cp -r /data/oc-mirror-4.20 /media/usb-drive/

# Or use data diode / secure file transfer
# The archive contains only container image layers β€” no secrets

Step 4: Push to Local Registry (Disconnected Side)

# Push from archive to the internal mirror registry
oc mirror \
  -c imageset-config.yaml \
  --from file:///data/oc-mirror-4.20 \
  docker://registry.example.com:5000 \
  --v2

# This pushes:
# - OCP release payload images β†’ registry.example.com:5000/openshift/release-images
# - Graph-data image β†’ registry.example.com:5000/openshift-update-service/graph-data
# - Generates cluster-resources/ with IDMS, ITMS, and UpdateService manifests

Step 5: Apply Generated Manifests

# Apply ALL generated cluster-resources manifests
# These include mirror rules AND the OSUS UpdateService
oc apply -f /data/oc-mirror-4.20/working-dir/cluster-resources/

# This applies:
# - ImageDigestMirrorSet β€” rewrites release image pullspecs to mirror
# - ImageTagMirrorSet β€” tag-based mirror rules
# - UpdateService β€” OSUS configuration pointing to mirrored graph-data

# Verify
oc get imagedigestmirrorset
oc get imagetagmirrorset
oc get updateservice -n openshift-update-service

The generated UpdateService manifest already has spec.releases and spec.graphDataImage pointing to your mirror registry β€” oc-mirror builds this for you.

Step 6: Configure Registry CA Trust + Pull Secret

# Add mirror registry CA to cluster trust
oc create configmap registry-ca \
  --from-file=registry.example.com..5000=/path/to/ca-bundle.crt \
  -n openshift-config

oc patch image.config.openshift.io/cluster \
  --type merge \
  --patch '{"spec":{"additionalTrustedCA":{"name":"registry-ca"}}}'

# OSUS also needs CA trust
oc create configmap update-service-trusted-ca \
  --from-file=updateservice-registry=/path/to/ca-bundle.crt \
  -n openshift-update-service
# Key MUST be "updateservice-registry" (or "registry.example.com..5000")

# Update pull secret with mirror registry credentials
oc get secret/pull-secret -n openshift-config \
  -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d > pull-secret.json

MIRROR_AUTH=$(echo -n "user:password" | base64)
jq --arg auth "$MIRROR_AUTH" \
  '.auths["registry.example.com:5000"] = {"auth": $auth}' \
  pull-secret.json > pull-secret-updated.json

oc set data secret/pull-secret -n openshift-config \
  --from-file=.dockerconfigjson=pull-secret-updated.json

# Wait for MCP rolling reboot
oc get mcp -w

Step 7: Patch CVO to Use OSUS

# Get the OSUS policy engine URI
OSUS_URL=$(oc get updateservice update-service -n openshift-update-service \
  -o jsonpath='{.status.policyEngineURI}')

# Patch CVO to read upgrade graph from local OSUS
oc patch clusterversion version \
  --type merge \
  --patch "{\"spec\":{\"upstream\":\"${OSUS_URL}/api/upgrades_info/v1/graph\"}}"

# Verify
oc get clusterversion version -o jsonpath='{.spec.upstream}'

Step 8: Verify and Upgrade

# Check available upgrades β€” should list mirrored versions
oc adm upgrade
# Cluster version is 4.20.8
#
# Upgradeable=True
#
# Recommended updates:
#   VERSION    IMAGE
#   4.20.10    registry.example.com:5000/openshift/release-images@sha256:...
#   4.20.12    registry.example.com:5000/openshift/release-images@sha256:...

# Start the upgrade
oc adm upgrade --to=4.20.12

# Monitor CVO-driven upgrade
watch 'oc get clusterversion; echo; oc get co | grep -v "True.*False.*False"; echo; oc get mcp'

What graph: true Actually Does

graph LR
    OC[oc-mirror] --> |pulls| REL[Release Images<br/>from quay.io]
    OC --> |graph: true| BUILD[Builds graph-data<br/>container image]
    BUILD --> |contains| GRAPH[Cincinnati Graph<br/>channels/ + blocked-edges/<br/>+ conditional-edges/]
    OC --> |pushes both| REG[Mirror Registry]
    OC --> |generates| US[UpdateService<br/>manifest]
    
    style BUILD fill:#FF9800,color:white
    style GRAPH fill:#FF9800,color:white

The graph-data image contains the Cincinnati upgrade graph database:

  • channels/ β€” which versions are in stable-4.20, fast-4.20, eus-4.20
  • blocked-edges/ β€” versions Red Hat has blocked (CVEs, regressions)
  • conditional-edges/ β€” β€œupdate with caution” advisories

Without this image, OSUS serves an empty graph β†’ CVO sees no upgrades.

shortestPath vs Full Mirror

ModeVersions MirroredSizeUse Case
shortestPath: trueOnly versions on shortest path (e.g., 4.20.8 β†’ 4.20.12 directly, or 4.20.8 β†’ 4.20.10 β†’ 4.20.12)8-15 GBProduction upgrades with known target
shortestPath: falseALL versions between min and max25-50+ GBWhen you need flexibility to stop at intermediate versions

Verification Script

#!/bin/bash
# verify-airgap-upgrade-readiness.sh
set -euo pipefail

TARGET="${1:-4.20.12}"
REGISTRY="${2:-registry.example.com:5000}"

echo "=== Air-Gap Upgrade Readiness Check ==="

echo "[1/7] Mirror registry reachable..."
curl -sk "https://${REGISTRY}/v2/" > /dev/null 2>&1 && echo "βœ… OK" || echo "❌ Unreachable"

echo "[2/7] Release image mirrored..."
oc image info "${REGISTRY}/openshift/release-images:${TARGET}-x86_64" > /dev/null 2>&1 \
  && echo "βœ… ${TARGET} found" || echo "❌ ${TARGET} NOT found"

echo "[3/7] IDMS/ITMS applied..."
echo "   $(oc get imagedigestmirrorset -o name 2>/dev/null | wc -l) IDMS"
echo "   $(oc get imagetagmirrorset -o name 2>/dev/null | wc -l) ITMS"

echo "[4/7] MCPs ready..."
DEGRADED=$(oc get mcp -o json | jq '[.items[] | select(.status.conditions[] | select(.type=="Degraded" and .status=="True"))] | length')
[ "$DEGRADED" -eq 0 ] && echo "βœ… All MCPs healthy" || echo "❌ ${DEGRADED} degraded MCPs"

echo "[5/7] OSUS running..."
OSUS_READY=$(oc get pods -n openshift-update-service -l app=update-service \
  --field-selector=status.phase=Running -o name 2>/dev/null | wc -l)
echo "   ${OSUS_READY} OSUS pod(s) running"

echo "[6/7] CVO upstream..."
echo "   $(oc get clusterversion version -o jsonpath='{.spec.upstream}')"

echo "[7/7] Available upgrades..."
oc adm upgrade 2>&1 | head -15

echo "=== Check complete ==="

Common Issues

β€œNo updates available” after mirroring everything

Three things must align: release images mirrored, graph-data image mirrored (graph: true), CVO spec.upstream pointing to OSUS. Missing any one = no updates. Also check OSUS pods are Running and serving the graph.

Graph shows version but β€œUnable to apply update”

Release image is in the graph but not mirrored to the registry. If you used shortestPath: true, intermediate versions may be skipped. Re-mirror with the specific version included.

OSUS β€œfailed to pull graph-data image”

CA trust not configured for OSUS namespace. Create ConfigMap with key updateservice-registry in openshift-update-service.

oc-mirror v2 vs v1 archive incompatible

Use --v2 on both connected and disconnected sides. Don’t mix versions.

MCPs stuck after IDMS/pull-secret apply

Both IDMS and pull-secret changes trigger rolling node reboots. Wait for all MCPs to reach UPDATED=True before proceeding. This can take 30-60 minutes for large clusters.

Best Practices

  • Always graph: true β€” without it, oc-mirror only mirrors images, not the upgrade graph
  • shortestPath: true for targeted upgrades β€” saves 50-70% mirror size
  • Set minVersion to current cluster version β€” don’t mirror old releases
  • Apply ALL cluster-resources/ manifests β€” oc-mirror generates IDMS, ITMS, and UpdateService together
  • Test OSUS graph with curl first β€” curl -s "${OSUS_URL}/api/upgrades_info/v1/graph?channel=stable-4.20" | jq '.nodes | length'
  • Keep oc-mirror binary version consistent β€” same binary on both sides
  • Run verification script before upgrading β€” confirm all 7 prerequisites pass

Key Takeaways

  • oc mirror with graph: true pulls release images AND builds/pushes the graph-data image in one operation
  • shortestPath: true mirrors only versions on the shortest upgrade path β€” significantly smaller archives
  • The generated cluster-resources/ directory contains everything: mirror rules (IDMS/ITMS) and the OSUS UpdateService manifest
  • OSUS serves the Cincinnati graph locally so CVO can discover available upgrades
  • CA trust and pull secrets are the #1 and #2 failure points in air-gapped setups
  • The workflow: mirror β†’ transfer β†’ push β†’ apply manifests β†’ CA/secrets β†’ patch CVO β†’ upgrade
#openshift #airgap #disconnected #oc-mirror #osus #upgrades
Luca Berton
Written by Luca Berton

Principal Solutions Architect specializing in Kubernetes, AI/GPU infrastructure, and cloud-native platforms. Author of Kubernetes Recipes and creator of CopyPasteLearn courses.

Kubernetes Recipes book cover

Want More Kubernetes Recipes?

This recipe is from Kubernetes Recipes, our 750-page practical guide with hundreds of production-ready patterns.

Luca Berton Ansible Pilot Ansible by Example Open Empower K8s Recipes Terraform Pilot CopyPasteLearn ProteinLens