Disconnected OSUS on OpenShift
Manifests to reproduce this lab: osus-lab.
Configuring the OpenShift Update Service (OSUS) for a cluster that pulls images through an Artifactory mirror, with the OSUS graph scrape served from a small dedicated repo. Built and validated on a Single-Node OpenShift (SNO) lab.
Core of this is OSUS + manifest-only mirroring (§0, §2-§10). §1 (DNS) is only needed if Artifactory doesn’t already resolve in your environment — skip it if yours does.
All hostnames, IPs and org names below are anonymized/placeholder — swap in your own per the table below.
Placeholders used throughout — replace with your values:
artifactory.example.com→ your Artifactory host (artifactory.lab.example.com)apps.<cluster>→ your ingress wildcard domain (apps.sno-lab.example.com)artifactory.example.com/ocp-release-images/graph-data→ your Artifactory path for the graph-data image4.21.31/4.22.12→ your bridge + target versions<current-4.20.z>→ the version the cluster is on now
0. Architecture — two consumers, two paths
The single most important idea in this whole setup. There are two different consumers of images, with opposite needs, and conflating them causes most of the failures:
| Consumer | What it does | Points at | Access pattern |
|---|---|---|---|
| Node CRI-O (via IDMS/ITMS) | Pulls the actual upgrade payload | Artifactory proxy (existing) | By digest, no enumeration — breadth is fine |
| OSUS graph-builder | Reads release manifests to build the update graph | Dedicated Artifactory local repo (release images + graph-data) | Enumerates tags + fetches manifest by digest — must stay small |
Key insight: OSUS only reads release-image manifests to build graph nodes. It never pulls the ~190-image component payload. So the OSUS repo needs ~100 MiB per version, not ~21 GiB. The payload lives once, in Artifactory, where nodes pull it at upgrade time via IDMS.
Consequences that drive every later step:
- OSUS
releasesmust point at a repo whose tag list you control (a local Artifactory repo you fill deliberately — never a pull-through proxy, which enumerates the full upstream tag history on scrape). - Use
oc image mirror(manifest-only copy), notoc adm release mirror(which always mirrors the full payload). - OSUS bypasses IDMS/ITMS — it reads
releasesdirectly, so that value must be a path OSUS itself can resolve.
1. (Optional) DNS — make Artifactory resolve for nodes and pods
Skip this section if Artifactory already resolves cluster-wide. Only needed in a lab/disconnected env with no real DNS entry for it.
Two distinct resolution paths. Fixing one does not fix the other.
- Node / CRI-O uses the node’s
/etc/hosts+/etc/resolv.conf. - Pods (OSUS) resolve through cluster CoreDNS — not the node’s
/etc/hosts.
1a. Test what actually resolves
host/dig/nslookup query DNS directly and ignore /etc/hosts — a timeout
there is misleading. Test the way real clients resolve:
getent hosts artifactory.example.com # respects /etc/hosts
curl -vI https://artifactory.example.com/v2/1b. Node-level entry (covers CRI-O image pulls)
Quick, non-persistent test on a node:
oc debug node/<node>
chroot /host
echo "192.0.2.10 artifactory.example.com" >> /etc/hostsPersistent (all nodes, survives reboot) via MachineConfig — idempotent systemd oneshot, appends without clobbering the file:
apiVersion: machineconfiguration.openshift.io/v1
kind: MachineConfig
metadata:
labels:
machineconfiguration.openshift.io/role: worker # duplicate with 'master' for control-plane / SNO
name: 99-worker-artifactory-hosts
spec:
config:
ignition:
version: 3.4.0
systemd:
units:
- name: add-artifactory-host.service
enabled: true
contents: |
[Unit]
Description=Append Artifactory host entry to /etc/hosts
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/bash -c "grep -q 'artifactory.example.com' /etc/hosts || echo '192.0.2.10 artifactory.example.com' >> /etc/hosts"
[Install]
WantedBy=multi-user.targetApplying a MachineConfig drains + reboots the pool. On SNO that’s your one node.
1c. Pod-level resolution (covers OSUS) — minimal in-cluster resolver
If external DNS is dead, pods still can’t resolve Artifactory. Stand up a tiny CoreDNS resolver inside the cluster and forward only the Artifactory zone to it.
apiVersion: v1
kind: Namespace
metadata:
name: lab-dns
---
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns-lab
namespace: lab-dns
data:
Corefile: |
lab.example.com:5353 {
hosts {
192.0.2.10 artifactory.example.com
fallthrough
}
errors
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: lab-dns
namespace: lab-dns
spec:
replicas: 2
selector:
matchLabels: { app: lab-dns }
template:
metadata:
labels: { app: lab-dns }
spec:
containers:
- name: coredns
image: registry.redhat.io/openshift4/ose-coredns:latest
args: ["-conf", "/etc/coredns/Corefile"]
ports:
- { containerPort: 5353, protocol: UDP }
- { containerPort: 5353, protocol: TCP }
volumeMounts:
- { name: config, mountPath: /etc/coredns }
volumes:
- name: config
configMap: { name: coredns-lab }
---
apiVersion: v1
kind: Service
metadata:
name: lab-dns
namespace: lab-dns
spec:
selector: { app: lab-dns }
ports:
- { name: dns-udp, port: 53, targetPort: 5353, protocol: UDP }
- { name: dns-tcp, port: 53, targetPort: 5353, protocol: TCP }Why 5353: the restricted SCC won’t let the pod bind <1024, so CoreDNS listens
on 5353 and the Service remaps 53→5353. The listen port must be set in the
Corefile (zone:5353 { ... }), not just the containerPort — mismatch gives
bind: permission denied.
Wire cluster DNS to it — forward only the Artifactory zone, to the Service ClusterIP (not pod IP, not a name — avoid the resolver-resolving-itself loop):
oc get svc lab-dns -n lab-dns -o jsonpath='{.spec.clusterIP}{"\n"}'apiVersion: operator.openshift.io/v1
kind: DNS
metadata:
name: default
spec:
servers:
- name: lab-artifactory
zones:
- lab.example.com # this zone ONLY — never a catch-all
forwardPlugin:
upstreams:
- <lab-dns-ClusterIP> # no :port — forward assumes 53, Service serves 53Verify from a pod:
oc run dnstest --image=registry.redhat.io/ubi9/ubi --rm -it --restart=Never -- \
getent hosts artifactory.example.com2. Artifactory repos
Keep the existing proxy setup for node pulls; add one local repo for OSUS.
- Existing remote/proxy repos (
quay,registryreedhatio,nvcr,dockerio) behind a virtual repo → node pulls via IDMS/ITMS. Leave as-is. - New local (hosted) Docker repo
ocp-release-images→ OSUS scrape target. “Local” is Artifactory’s word for a repo you push into (vs. a proxy). It lives on the same Artifactory; the difference is it holds only what you push, so its tag list stays small and controlled.
A proxy repo cannot serve as the OSUS
releasestarget: on scrape it forwards the tag-list query upstream and caches the entireocp-releasehistory, recreating the memory-blowup you’re avoiding.
Also confirm proxies exist for registry.redhat.io (OSUS operator) and
registry.access.redhat.com (ubi9 base, if building graph-data from Dockerfile).
3. Mirror the release images (manifest-only)
Use oc image mirror, not oc adm release mirror.
oc adm release mirrormirrors the full payload (~21 GiB, 190+ component images) —--to-release-imageis additive, it does not restrict.oc image mirrorcopies just the release image (its manifest + ~6 own blobs, ~100 MiB) and does not walk into the referenced components.
# authfile needs BOTH quay.io read (source) and Artifactory write (dest)
oc image mirror -a /tmp/pull-new.json \
quay.io/openshift-release-dev/ocp-release:4.21.31-x86_64 \
artifactory.example.com/ocp-release-images/openshift-release-dev/ocp-release:4.21.31-x86_64
oc image mirror -a /tmp/pull-new.json \
quay.io/openshift-release-dev/ocp-release:4.22.12-x86_64 \
artifactory.example.com/ocp-release-images/openshift-release-dev/ocp-release:4.22.12-x86_64Expected output signature (good): manifests=1, ~116 MiB, single phase.
If you see phase 1 ... manifests=190 and ~21 GiB, you used the wrong tool.
Adding a later z-stream (e.g. once 4.22.23 ships)
Same operation, run again when a new patch becomes available — no need to redo earlier versions, they’re already in the local repo:
oc image mirror -a /tmp/pull-new.json \
quay.io/openshift-release-dev/ocp-release:4.22.23-x86_64 \
artifactory.example.com/ocp-release-images/openshift-release-dev/ocp-release:4.22.23-x86_64
oc image info artifactory.example.com/ocp-release-images/openshift-release-dev/ocp-release:4.22.23-x86_64Refresh graph-data (§4) after adding a version — new tag alone doesn’t update
channel membership/edges. Bump the graphDataImage tag on the UpdateService CR
(§7) to pick it up.
Which versions: mirror the path, not a range — your current
<current-4.20.z>is represented as a graph node by the graph-data image (no payload needed), and you mirror the bridge (4.21.z) + target (4.22.z) payloads. Minor jumps require stepping through each minor. Let the graph tell you the exact z-streams (see Appendix A).
Verify by digest — the test that matters
OSUS fetches the manifest by digest. This must resolve, not just the tag:
oc image info artifactory.example.com/ocp-release-images/openshift-release-dev/ocp-release:4.21.31-x86_64
oc image info artifactory.example.com/ocp-release-images/openshift-release-dev/ocp-release:4.22.12-x86_64Both must print manifest info (not manifest unknown / 404).
4. Graph-data image (on Artifactory)
The graph-data image holds channel membership + edges. Build once, refresh before each update cycle. Not per-release. Push it into the same local repo as the release images (§2) — OSUS already trusts and authenticates to that repo, so no extra CA/credential wiring is needed.
Dockerfile:
FROM registry.access.redhat.com/ubi9/ubi:latest
RUN curl -L -o cincinnati-graph-data.tar.gz https://api.openshift.com/api/upgrades_info/graph-data
RUN mkdir -p /var/lib/cincinnati-graph-data && \
tar xvzf cincinnati-graph-data.tar.gz -C /var/lib/cincinnati-graph-data/ --no-overwrite-dir --no-same-owner
CMD ["/bin/bash", "-c", "exec cp -rp /var/lib/cincinnati-graph-data/* /var/lib/cincinnati/graph-data"]podman build -f ./Dockerfile -t artifactory.example.com/ocp-release-images/graph-data:latest .
podman push artifactory.example.com/ocp-release-images/graph-data:latestTag each refresh (e.g.
:2026-09-15) and bump the CR, rather than reusing:latest— the OSUS init container loads graph-data at pod start and won’t pick up a re-pushed:lateston its own.
5. Trust — the Artifactory CA chain
Artifactory presents a leaf-only handshake signed by an intermediate, whose
issuer is a private root. OSUS trusts neither by default. Build a bundle with
intermediate + root under the key OSUS requires: updateservice-registry.
Diagnose what the server sends and its chain:
openssl s_client -connect artifactory.example.com:443 -showcerts </dev/null 2>/dev/null \
| openssl x509 -noout -issuer -subject
# subject = leaf (the host), issuer = the intermediate → you need intermediate + its rootConfigMap (in openshift-config), both certs under the one key:
apiVersion: v1
kind: ConfigMap
metadata:
name: artifactory-registry-ca
namespace: openshift-config
data:
updateservice-registry: |
-----BEGIN CERTIFICATE-----
<intermediate: 2023 Certificate Authority RHCSv2>
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
<root: Internal Root CA (self-signed)>
-----END CERTIFICATE-----Validate the chain locally before applying (fast feedback vs. pod logs):
# leaf.crt = server cert; bundle.pem = intermediate + root
openssl s_client -connect artifactory.example.com:443 </dev/null 2>/dev/null \
| openssl x509 -out leaf.crt
openssl verify -CAfile bundle.pem leaf.crt # want: leaf.crt: OKWire it into the cluster image trust (OSUS operator reads
updateservice-registry from here):
oc patch image.config.openshift.io/cluster --type=merge \
-p '{"spec":{"additionalTrustedCA":{"name":"artifactory-registry-ca"}}}'The benign log line
unable to process certificate ca-bundle.trust.crt ... Expecting: CERTIFICATEis noise — ignore it. The real errors arecertificate verify failed(missing CA in bundle) orunable to get issuer certificate(missing root above the intermediate).
6. Credentials
OSUS mounts the global cluster pull secret at
/var/lib/cincinnati/registry-credentials/.dockerconfigjson. It needs an
Artifactory entry — which your nodes already use, so it likely exists. Confirm:
oc get secret pull-secret -n openshift-config \
-o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | jq '.auths | keys'
# expect artifactory.example.com in the listIf missing, add it:
oc get secret pull-secret -n openshift-config \
-o jsonpath='{.data.\.dockerconfigjson}' | base64 -d > /tmp/pull.json
oc registry login --registry=artifactory.example.com \
--auth-basic="<user>:<pass>" --to=/tmp/pull.json
oc set data secret/pull-secret -n openshift-config \
--from-file=.dockerconfigjson=/tmp/pull.jsonUpdating the global pull secret triggers a node rollout (SNO reboots). Wait for
oc get mcpto settle.
7. Install OSUS + create the UpdateService
Install the OpenShift Update Service operator into
openshift-update-service (OperatorHub, or Subscription; package
cincinnati-operator). Then:
apiVersion: updateservice.operator.openshift.io/v1
kind: UpdateService
metadata:
name: service
namespace: openshift-update-service
spec:
replicas: 2
releases: artifactory.example.com/ocp-release-images/openshift-release-dev/ocp-release
graphDataImage: artifactory.example.com/ocp-release-images/graph-data:latestRestart the graph-builder and watch for success:
oc rollout restart deployment/service -n openshift-update-service
oc logs -f deployment/service -n openshift-update-service -c graph-builder | grep -E 'valid releases|error'Target line: graph update completed, N valid releases.
N will be larger than the number you mirrored (e.g. 54): graph-data knows all channel versions as nodes. Only the versions you actually mirrored are installable — edges to unmirrored versions will stall at pull time.
Confirm the graph serves your versions:
PE=$(oc -n openshift-update-service get updateservice service -o jsonpath='{.status.policyEngineURI}')
curl -sk "$PE/api/upgrades_info/v1/graph?channel=stable-4.22" | jq '.nodes[].version'8. Point the CVO at OSUS (via the Route, not the service)
The CVO is host-networked and resolves via the node’s resolver — it cannot
resolve .svc.cluster.local. Use the OSUS Route URI:
PE=$(oc -n openshift-update-service get updateservice service -o jsonpath='{.status.policyEngineURI}')
echo "$PE" # must be https://...apps.<cluster>...
oc adm upgrade channel stable-4.22
oc patch clusterversion version --type merge \
-p "{\"spec\":{\"upstream\":\"${PE}/api/upgrades_info/v1/graph\"}}"Ensure *.apps.<cluster> resolves from the node (same DNS work as §1), or the
CVO gets no such host.
9. Trust the OSUS Route cert (CVO → Route)
Console error Unable to retrieve available updates: ... certificate signed by unknown authority means the CVO doesn’t trust the Route’s TLS cert. On SNO the
Route uses the ingress operator’s self-signed wildcard CA.
Identify the issuer:
oc get secret router-certs-default -n openshift-ingress \
-o jsonpath='{.data.tls\.crt}' | base64 -d \
| openssl x509 -noout -issuer -subject
# issuer=CN=ingress-operator@<ts>, subject=CN=*.apps.<cluster> → self-signed ingress CAGrab that CA from the managed configmap and add it to the cluster-wide proxy trust bundle (where the CVO looks):
oc get configmap default-ingress-cert -n openshift-config-managed \
-o jsonpath='{.data.ca-bundle\.crt}' > /tmp/ingress-ca.crt
oc create configmap osus-route-ca -n openshift-config \
--from-file=ca-bundle.crt=/tmp/ingress-ca.crt # key MUST be ca-bundle.crt
oc patch proxy/cluster --type merge \
-p '{"spec":{"trustedCA":{"name":"osus-route-ca"}}}'Triggers a node reconcile — wait for
oc get coto settle. The ingress CA CN carries a timestamp and rotates on expiry; if the cluster is long-lived, replace the default wildcard with a cert from a stable CA instead of pinning this one.
10. Verify end to end
oc adm upgrade
oc get clusterversion -o jsonpath='{.status.conditions}' | jq # no RemoteFailedYou should see the path toward 4.22.12 sourced from your own OSUS.
Before an actual upgrade, confirm your node-side Artifactory IDMS covers both release paths the CVO pulls — this is the silent failure where OSUS recommends an upgrade the cluster then can’t pull:
quay.io/openshift-release-dev/ocp-releasequay.io/openshift-release-dev/ocp-v4.0-art-dev← the components; easy to miss
oc get idms -o yaml | grep -A2 'ocp-v4.0-art-dev' # must be presentAppendix A — Read the upgrade path off the graph
Before mirroring, find the exact z-streams on your path (don’t guess):
# current version
oc get clusterversion -o jsonpath='{.status.desired.version}{"\n"}'
# reachable versions in the target channel
curl -s "https://api.openshift.com/api/upgrades_info/v1/graph?channel=stable-4.22" \
| jq -r '.nodes[].version' | sort -V | grep -E '^4\.(21|22)\.'Optionally let oc-mirror compute the path with shortestPath: true in an
ImageSetConfiguration (min = current, max = target, channel = target).
Appendix B — Extract live manifests from your own cluster
Where a template is fiddlier than reading your actual state, pull the real object:
# current UpdateService CR
oc get updateservice service -n openshift-update-service -o yaml
# DNS operator config (check zone scoping + forward upstreams)
oc get dns.operator/default -o yaml
# image trust reference
oc get image.config.openshift.io/cluster -o yaml
# proxy trust bundle reference
oc get proxy/cluster -o yaml
# what release-images tags exist
oc get istag -n openshift 2>/dev/null | grep -E 'release' # if you used internal registry
# or query Artifactory directly:
curl -s https://artifactory.example.com/artifactory/api/docker/ocp-release-images/v2/openshift-release-dev/ocp-release/tags/list | jq
# confirm OSUS SA can read a namespace's images (if cross-namespace pull)
oc auth can-i get imagestreams/layers -n openshift \
--as=system:serviceaccount:openshift-update-service:default