Permanent home:
coco-pattern/airgap/DEPLOY-RUNBOOK.mdThis runbook covers the full lifecycle of an airgap deploy — from one-time jump host infrastructure setup through verified TDX attestation on an air-gapped OCP SNO cluster.
| Phase | Description | Duration |
|---|---|---|
| 0 | One-Time Infrastructure Setup | ~30 min (skip on repeat runs) |
| A | Prerequisites and Mirror Wipe | ~15 min |
| B | Full Re-Mirror (oc-mirror v2) | ~3-4 hours |
| C | Cluster Install | ~35 min |
| D | Bootstrap and Pattern Deploy | ~25 min |
| E | DCAP and TDX Attestation | ~15 min (+ connected low-side PCK generation) |
| F | Verification and Pass/Fail | ~10 min |
| Total | ~5-6 hours (first-time add ~30 min) |
Skip this phase on repeat runs. These steps configure the jump host infrastructure (mirror registry, git server) that persists across deployments. Run once per jump host.
Prerequisites: Internet access on the jump host, Python 3.10+,
podmanandopensslinstalled, andpython3-passliborhttpd-toolsforhtpasswd.
Install the shared Python dependencies before starting the smart Git HTTP server or collecting reference values:
python3 -m pip install -r requirements.txt# ── Update these for your environment ──────────────────────────────────────
export MIRROR_REGISTRY=MIRROR_REGISTRY_HOST:8443 # host:port of your mirror registry
export GIT_SERVER_PORT=8080 # port for local git HTTP server
# ───────────────────────────────────────────────────────────────────────────
export MREG_HOST="${MIRROR_REGISTRY%%:*}"
export MREG_PORT="${MIRROR_REGISTRY##*:}"The mirror registry is a docker.io/library/registry:2 container with TLS and htpasswd auth.
echo "=== 0-1: Mirror Registry Init ==="
# Create directory structure
mkdir -p ~/mirror-registry-certs ~/mirror-registry-config ~/local-registry
mkdir -p ~/.coco-pattern ~/.config/containers/certs.d/${MIRROR_REGISTRY}
# a) Generate CA key + self-signed CA cert (10-year validity)
openssl genrsa -out ~/mirror-registry-certs/ca.key 4096
openssl req -new -x509 -days 3650 \
-key ~/mirror-registry-certs/ca.key \
-out ~/mirror-registry-certs/ca.crt \
-subj "/CN=mirror-registry-ca"
echo "CA cert generated: $(openssl x509 -noout -subject -in ~/mirror-registry-certs/ca.crt)"
# b) Generate server TLS cert signed by the CA (SAN = host IP)
openssl genrsa -out ~/mirror-registry-certs/server.key 4096
openssl req -new \
-key ~/mirror-registry-certs/server.key \
-out ~/mirror-registry-certs/server.csr \
-subj "/CN=${MREG_HOST}"
openssl x509 -req -days 3650 \
-in ~/mirror-registry-certs/server.csr \
-CA ~/mirror-registry-certs/ca.crt \
-CAkey ~/mirror-registry-certs/ca.key \
-CAcreateserial \
-out ~/mirror-registry-certs/server.crt \
-extfile <(printf "[v3_req]\nsubjectAltName=IP:${MREG_HOST}\n") \
-extensions v3_req
echo "Server cert signed by CA"
# c) Generate registry password
# Use printf (NOT echo) — echo appends a newline which breaks Helm registry login (401)
MREG_PASS=$(openssl rand -base64 18 | tr -d '=+/' | head -c 24)
printf '%s' "$MREG_PASS" > ~/.coco-pattern/mirror-registry-password
chmod 600 ~/.coco-pattern/mirror-registry-password
echo "Password saved to ~/.coco-pattern/mirror-registry-password (${#MREG_PASS} bytes, no newline)"
# d) Create htpasswd auth file (bcrypt)
htpasswd -Bbc ~/mirror-registry-config/htpasswd init "$MREG_PASS"
# SELinux: container_file_t label required so registry:2 container can read the file
chcon -t container_file_t ~/mirror-registry-config/htpasswd
echo "htpasswd created for user: init"
# e) Create registry config.yml
cat > ~/mirror-registry-config/config.yml << 'EOF'
version: 0.1
log:
level: warn
storage:
filesystem:
rootdirectory: /var/lib/registry
delete:
enabled: true
http:
addr: :8443
tls:
certificate: /certs/server.crt
key: /certs/server.key
auth:
htpasswd:
realm: basic-realm
path: /auth/htpasswd
EOF
# f) Create and start podman container
# config.yml → /etc/docker/registry/config.yml (where registry:2 reads it)
# htpasswd → /auth/htpasswd (where config.yml references it)
# certs → /certs/ (where config.yml references server.crt/server.key)
podman create \
--name local-registry \
-p "${MREG_PORT}:8443" \
-v ~/local-registry:/var/lib/registry:z \
-v ~/mirror-registry-config/config.yml:/etc/docker/registry/config.yml:z \
-v ~/mirror-registry-config/htpasswd:/auth/htpasswd:z \
-v ~/mirror-registry-certs:/certs:z \
docker.io/library/registry:2
podman start local-registry
echo "local-registry container started"
# g) Persist as systemd user service (survives reboots and logout)
mkdir -p ~/.config/systemd/user
podman generate systemd --name local-registry --restart-policy always \
> ~/.config/systemd/user/local-registry.service
systemctl --user daemon-reload
systemctl --user enable local-registry.service
loginctl enable-linger "$USER"
echo "local-registry systemd user service enabled"
# h) Trust the CA for container tools (per-user, no sudo required)
# podman/skopeo/oc-mirror read from this directory automatically.
# curl: pass --cacert ~/mirror-registry-certs/ca.crt
# oc image mirror: pass --insecure=true
cp ~/mirror-registry-certs/ca.crt ~/.config/containers/certs.d/${MIRROR_REGISTRY}/ca.crt
echo "CA trusted for container tools"
# h2) Skip sigstore attachment lookups for certified vendors
# Intel, HashiCorp, and NVIDIA images on registry.connect.redhat.com lack cosign
# .sig manifests. Without this, oc-mirror fails with "name unknown: Image not found".
mkdir -p ~/.config/containers/registries.d
cat > ~/.config/containers/registries.d/no-sigstore-certified.yaml << 'REGCFG'
docker:
registry.connect.redhat.com/intel:
use-sigstore-attachments: false
registry.connect.redhat.com/hashicorp:
use-sigstore-attachments: false
registry.connect.redhat.com/nvidia:
use-sigstore-attachments: false
REGCFG
echo "Sigstore attachment lookups disabled for certified vendors"
# i) Build combined-ca-bundle.pem (used by labctl --additional-trust-bundle)
# The mirror-registry CA is the only CA required — Quay is not used in this deployment.
cp ~/mirror-registry-certs/ca.crt ~/combined-ca-bundle.pem
echo "combined-ca-bundle.pem created: $(grep -c 'BEGIN CERTIFICATE' ~/combined-ca-bundle.pem) cert(s)"
# j) Verify
CATALOG=$(curl -s -u "init:$MREG_PASS" \
--cacert ~/mirror-registry-certs/ca.crt \
https://${MIRROR_REGISTRY}/v2/_catalog)
echo "Registry catalog check: $CATALOG"
[ "$CATALOG" = '{"repositories":[]}' ] && echo "PASS: registry healthy and empty" || echo "WARN: unexpected response"The Red Hat pull-secret must be downloaded from console.redhat.com before running this step.
echo "=== 0-2: Seed pull-secret.json ==="
# Download pull-secret.json from https://console.redhat.com → OpenShift → Downloads
# Place at ~/pull-secret.json before proceeding.
MREG_PASS=$(cat ~/.coco-pattern/mirror-registry-password)
python3 - << 'EOF'
import json, base64, os
ps_path = os.path.expanduser("~/pull-secret.json")
mreg = os.environ["MIRROR_REGISTRY"]
mreg_pass = open(os.path.expanduser("~/.coco-pattern/mirror-registry-password")).read()
mreg_auth = base64.b64encode(f"init:{mreg_pass}".encode()).decode()
with open(ps_path) as f:
ps = json.load(f)
ps["auths"][mreg] = {
"auth": mreg_auth,
"username": "init",
"password": mreg_pass
}
with open(ps_path, "w") as f:
json.dump(ps, f, indent=2)
print("Added mirror-registry auth to pull-secret.json")
print("Registries:", list(ps["auths"].keys()))
EOFWhen to re-run: Only if
~/.coco-pattern/mirror-registry-passwordis regenerated. The htpasswd auth survives mirror storage wipes — only image data is cleared in Phase A-4.
The local Git HTTP server makes pattern repos available to the cluster's patterns-operator
during the deploy. scripts/airgap-post-install.sh --sync-repos-only creates bare repos
and starts the service; subsequent runs auto-sync via the D-1 step.
echo "=== 0-3: Git Server Init ==="
# a) Clone the pattern repositories (internet required — run before air-gapping)
git clone -b dev/airgap-testing \
https://github.com/butler54/coco-pattern.git ~/coco-pattern
git clone -b dev/phase1-testing \
https://github.com/butler54/trustee-chart.git ~/trustee-chart
git clone -b dev/phase1-testing \
https://github.com/butler54/sandboxed-containers-chart.git ~/sandboxed-containers-chart
git clone -b dev/phase1-testing \
https://github.com/butler54/sandboxed-policies-chart.git ~/sandboxed-policies-chart
echo "Repos cloned:"
for d in coco-pattern trustee-chart sandboxed-containers-chart sandboxed-policies-chart; do
echo " ~/$d: $(git -C ~/$d rev-parse --short HEAD 2>/dev/null)"
done
# b) Create bare repos in ~/public_html/git/ and start git-http.service
cd ~/coco-pattern
export MIRROR_REGISTRY # must be set from 0-0
scripts/airgap-post-install.sh --sync-repos-only
# This:
# - Creates ~/public_html/git/*.git bare repos
# - Creates and enables systemd user service: git-http.service
# - Reports Git HTTP URLs on the local network
# c) Verify
sleep 3
curl -s "http://localhost:${GIT_SERVER_PORT}/coco-pattern.git/info/refs?service=git-upload-pack" | head -c 80
echo ""
[ $? -eq 0 ] && echo "PASS: git HTTP server responding" || echo "FAIL: check git-http.service status"On subsequent runs: bare repo sync happens automatically in D-1 via
scripts/airgap-post-install.sh. Phase 0-3 is one-time only.
This is the only SSH session you need for the entire run. Start tmux immediately after connecting — all steps, including long-running ones, run as named windows within the single
deploysession. To disconnect safely at any point:Ctrl-b d. To reconnect to the running session:ssh jumpthentmux attach -t deploy.
ssh chbutler@user-jump.int.apac-tech-lab.net
tmux new-session -s deployEverything below runs inside the deploy session. Long-running steps open a new named window
(Ctrl-b c or via tmux new-window -n <name>) and run in the foreground — no nohup needed.
Switch between windows with Ctrl-b <number> or Ctrl-b n/p.
# ── Site-specific variables — update these for each deployment environment ──
export MIRROR_REGISTRY=MIRROR_REGISTRY_HOST:8443 # mirror-registry host:port
export GIT_SERVER=http://JUMP_HOST_IP:8080 # local git HTTP server
# Persist to all tmux windows (restore in each window with: eval "$(tmux showenv -g)")
tmux set-environment -g MIRROR_REGISTRY "$MIRROR_REGISTRY"
tmux set-environment -g GIT_SERVER "$GIT_SERVER"
export LOG=~/coco-pattern/logs/run-25-$(date +%Y%m%d).log
mkdir -p ~/coco-pattern/logs
touch "$LOG"
echo "=== Phase 25 Run Start: $(date) ===" | tee -a "$LOG"
echo "LOG=$LOG MIRROR_REGISTRY=$MIRROR_REGISTRY GIT_SERVER=$GIT_SERVER" | tee -a "$LOG"
tmux set-environment -g LOG "$LOG"
# local-registry credentials (htpasswd auth, username: init)
export MREG_USER=init
export MREG_PASS=$(cat ~/.coco-pattern/mirror-registry-password)
echo "local-registry credentials loaded (user: $MREG_USER)" | tee -a "$LOG"
# Ensure local-registry CA is trusted by container tools (idempotent)
MREG_HOST="${MIRROR_REGISTRY%%:*}"
MREG_PORT="${MIRROR_REGISTRY##*:}"
mkdir -p ~/.config/containers/certs.d/${MIRROR_REGISTRY}
cp ~/mirror-registry-certs/ca.crt ~/.config/containers/certs.d/${MIRROR_REGISTRY}/ca.crt
echo "local-registry CA trusted for container tools" | tee -a "$LOG"
# Per-user cert dir handles podman/skopeo/oc-mirror TLS.
# curl: pass --cacert ~/mirror-registry-certs/ca.crt
# oc image mirror: pass --insecure=true
# Verify sigstore skip config is in place (created in Phase 0-1 step h2)
ls ~/.config/containers/registries.d/no-sigstore-certified.yaml && \
echo "Sigstore skip config present" | tee -a "$LOG" || \
echo "WARN: no-sigstore-certified.yaml missing — Intel/HashiCorp/NVIDIA mirrors may fail" | tee -a "$LOG"At run end, Claude will SSH in and read this log file for findings analysis.
Critical: PATH order issues can cause the wrong binary to be used silently.
~/.local/binmay contain old versions that override~/bin/symlinks. Every check below must show the expected version. Stop and fix any mismatch.
echo "=== A-0.5: Tool Version Verification ===" 2>&1 | tee -a "$LOG"
OCP_MINOR="4.22" # Expected OCP minor version for this run
# 1. openshift-install — version string must contain expected OCP minor version
OI_VERSION=$(openshift-install version 2>/dev/null | head -1)
echo "openshift-install: $OI_VERSION" | tee -a "$LOG"
if ! echo "$OI_VERSION" | grep -q "^openshift-install ${OCP_MINOR}"; then
echo "FAIL: openshift-install reports wrong version (expected ${OCP_MINOR}.x, got: $OI_VERSION)" | tee -a "$LOG"
echo "Fix: run 'labctl generate ...' first to symlink the correct binary, or check PATH order" | tee -a "$LOG"
exit 1
fi
echo "PASS: openshift-install is ${OCP_MINOR}.x" | tee -a "$LOG"
# 2. oc-mirror — version string must contain expected OCP minor version
OM_VERSION=$(oc-mirror version 2>&1 | grep -i 'gitversion' | grep -o '[0-9]\+\.[0-9]\+\.[0-9]*' | head -1)
echo "oc-mirror version: $OM_VERSION" | tee -a "$LOG"
if ! echo "$OM_VERSION" | grep -q "^${OCP_MINOR}"; then
echo "FAIL: oc-mirror reports wrong version (expected ${OCP_MINOR}.x, got: $OM_VERSION)" | tee -a "$LOG"
echo "Fix: ln -sf ~/ocp_42208_bin/oc-mirror ~/bin/oc-mirror" | tee -a "$LOG"
exit 1
fi
echo "PASS: oc-mirror is ${OCP_MINOR}.x" | tee -a "$LOG"
# 3. labctl — verify installed
LABCTL_VERSION=$(labctl --version 2>/dev/null)
echo "labctl: $LABCTL_VERSION" | tee -a "$LOG"
# 4. Release image digest alignment (informational — only meaningful after B-2)
OI_DIGEST=$(openshift-install version 2>/dev/null | grep 'release image' | grep -o 'sha256:[0-9a-f]*')
OM_DIGEST=$(grep -o 'sha256-[0-9a-f]*' \
~/oc-mirror-workspace/working-dir/cluster-resources/signature-configmap.json 2>/dev/null | \
head -1 | sed 's/sha256-/sha256:/')
echo "openshift-install release digest: $OI_DIGEST" | tee -a "$LOG"
echo "oc-mirror release digest: ${OM_DIGEST:-not yet run}" | tee -a "$LOG"
[ -n "$OM_DIGEST" ] && [ "$OI_DIGEST" = "$OM_DIGEST" ] && \
echo "PASS: release image digests match" | tee -a "$LOG" || \
echo "INFO: digest mismatch or oc-mirror not yet run (check again after B-2)" | tee -a "$LOG"
echo "A-0.5: Version verification complete at $(date)" | tee -a "$LOG"echo "=== A-1: State Verification ===" 2>&1 | tee -a "$LOG"
cd ~/coco-pattern
git remote get-url origin 2>&1 | tee -a "$LOG"
# EXPECTED: ${GIT_SERVER}/coco-pattern.git
git branch --show-current 2>&1 | tee -a "$LOG"
# EXPECTED: dev/airgap-testing
du -sh ~/oc-mirror-workspace 2>&1 | tee -a "$LOG"
# Check local-registry state (htpasswd auth required)
echo "local-registry catalog:" 2>&1 | tee -a "$LOG"
curl -sk -u "$MREG_USER:$MREG_PASS" https://${MIRROR_REGISTRY}/v2/_catalog 2>&1 | tee -a "$LOG"
grep -c "BEGIN CERTIFICATE" ~/combined-ca-bundle.pem 2>&1 | tee -a "$LOG"
# EXPECTED: 3 (Quay 2-chain + mirror-registry 1 cert)
# Verify pull-secret.json has local-registry credentials (required for cluster node pulls via IDMS)
echo "pull-secret registries:" 2>&1 | tee -a "$LOG"
python3 -c "import json; [print(k) for k in json.load(open('pull-secret.json'))['auths']]" 2>&1 | tee -a "$LOG"
# MUST include: ${MIRROR_REGISTRY}
# If missing, run from appendix A.6 before proceeding
echo "A-1: State verification complete at $(date)" 2>&1 | tee -a "$LOG"DESTRUCTIVE — confirm A-1 shows correct git branch and mirror-registry state before proceeding.
echo "=== A-3: oc-mirror Workspace Wipe ===" 2>&1 | tee -a "$LOG"
ls -la ~/oc-mirror-workspace 2>&1 | tee -a "$LOG"
rm -rf ~/oc-mirror-workspace
echo "oc-mirror-workspace removed" 2>&1 | tee -a "$LOG"
# Also clear any cached oc-mirror state
rm -rf ~/.oc-mirror 2>/dev/null || true
echo "A-3: oc-mirror wipe complete at $(date)" 2>&1 | tee -a "$LOG"DESTRUCTIVE: stop container, wipe storage, restart empty. Container name:
local-registry. Storage:~/local-registry(owned by chbutler — no sudo). No auth required for registry API.
echo "=== A-4: local-registry Decommission ===" 2>&1 | tee -a "$LOG"
# Confirm state before wiping
echo "Container status:" 2>&1 | tee -a "$LOG"
podman ps --format "{{.Names}} {{.Status}}" | grep -i registry 2>&1 | tee -a "$LOG"
echo "Storage size:" 2>&1 | tee -a "$LOG"
du -sh ~/local-registry/ 2>&1 | tee -a "$LOG"
# Stop container
podman stop local-registry 2>&1 | tee -a "$LOG"
echo "local-registry stopped" 2>&1 | tee -a "$LOG"
# Wipe storage (chbutler owns ~/local-registry — no sudo needed)
rm -rf ~/local-registry/*
echo "Storage wiped" 2>&1 | tee -a "$LOG"
# Restart container (reinitializes on empty storage)
podman start local-registry 2>&1 | tee -a "$LOG"
sleep 10
# Verify empty catalog (htpasswd auth required)
curl -sk -u "$MREG_USER:$MREG_PASS" https://${MIRROR_REGISTRY}/v2/_catalog 2>&1 | tee -a "$LOG"
# EXPECTED: {"repositories":[]}
echo "A-4: local-registry decommission complete at $(date)" 2>&1 | tee -a "$LOG"echo "=== A-5: Verify Empty State ===" 2>&1 | tee -a "$LOG"
# local-registry catalog must be empty (htpasswd auth required)
echo "local-registry catalog after wipe:" 2>&1 | tee -a "$LOG"
curl -sk -u "$MREG_USER:$MREG_PASS" https://${MIRROR_REGISTRY}/v2/_catalog 2>&1 | tee -a "$LOG"
# EXPECTED: {"repositories":[]}
# oc-mirror workspace must not exist
if [ -d ~/oc-mirror-workspace ]; then
echo "WARNING: oc-mirror-workspace still exists" 2>&1 | tee -a "$LOG"
else
echo "PASS: oc-mirror-workspace absent" 2>&1 | tee -a "$LOG"
fi
echo "A-5: Empty state verification complete at $(date)" 2>&1 | tee -a "$LOG"Skip Phase B if mirror-registry already has all required content. Run B-0 to verify. If all checks pass, go directly to Phase C.
CRITICAL: oc-mirror must be version-matched to the OCP release being mirrored. Cross-version oc-mirror rebuilds manifests with different SHA256 digests than openshift-install expects. This causes every component image pull to return 404 from mirror-registry, with the node falling back to internet quay.io (blocked by fake gateway) and stalling.
labctl generatesymlinks~/bin/oc-mirrorfrom the OCP version bin dir automatically.
echo "=== B-0: Mirror Content Verification ===" 2>&1 | tee -a "$LOG"
MREG_PASS=$(cat ~/.coco-pattern/mirror-registry-password)
echo "--- oc-mirror version check (MUST match OCP version) ---" 2>&1 | tee -a "$LOG"
oc-mirror version 2>&1 | grep -i 'version\|GitVersion' | tee -a "$LOG"
# MUST contain "4.22" — if not, update symlink: ln -sf ~/ocp_42208_bin/oc-mirror ~/bin/oc-mirror
echo "--- VP OCI Helm charts ---" 2>&1 | tee -a "$LOG"
for chart in clustergroup hashicorp-vault acm openshift-external-secrets \
sandboxed-containers sandboxed-policies trustee; do
result=$(curl -sk -u "init:$MREG_PASS" \
"https://${MIRROR_REGISTRY}/v2/validatedpatterns/${chart}/tags/list" 2>/dev/null)
tags=$(echo "$result" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('tags','MISSING'))" 2>/dev/null)
echo " validatedpatterns/${chart}: ${tags}" 2>&1 | tee -a "$LOG"
done
echo "--- Operator catalog indexes ---" 2>&1 | tee -a "$LOG"
for idx in redhat/redhat-operator-index redhat/certified-operator-index redhat/community-operator-index; do
result=$(curl -sk -u "init:$MREG_PASS" \
"https://${MIRROR_REGISTRY}/v2/${idx}/tags/list" 2>/dev/null)
tags=$(echo "$result" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('tags','MISSING'))" 2>/dev/null)
echo " ${idx}: ${tags}" 2>&1 | tee -a "$LOG"
done
echo "--- OCP release image digest check ---" 2>&1 | tee -a "$LOG"
# The release image MUST be accessible at the digest openshift-install has hardcoded.
# This digest is set by the version-matched oc-mirror run — do NOT patch manually.
RELEASE_DIGEST=$(~/ocp_42208_bin/openshift-install version 2>/dev/null | grep 'release image' | awk '{print $NF}' | sed 's/.*@//')
if [ -n "$RELEASE_DIGEST" ]; then
HTTP=$(curl -sk -u "init:$MREG_PASS" -o /dev/null -w '%{http_code}' \
"https://${MIRROR_REGISTRY}/v2/openshift/release-images/manifests/sha256:${RELEASE_DIGEST}")
[ "$HTTP" = "200" ] && echo "PASS: release image digest present" | tee -a "$LOG" || \
echo "FAIL: release image digest missing (HTTP $HTTP) — must run B-2 with version-matched oc-mirror" | tee -a "$LOG"
fi
echo "--- oc-mirror cluster-resources ---" 2>&1 | tee -a "$LOG"
ls ~/oc-mirror-workspace/working-dir/cluster-resources/ 2>/dev/null | tee -a "$LOG"
echo "B-0: Mirror verification complete at $(date)" 2>&1 | tee -a "$LOG"If all VP charts show tags, catalog indexes show v4.22, oc-mirror version matches, and release digest is present → skip to Phase C. If anything is missing or wrong → run Phase B (full re-mirror).
echo "=== B-1: Sync Repos ===" 2>&1 | tee -a "$LOG"
# Sync git mirror (bare repo) from GitHub
cd ~/public_html/git/coco-pattern.git
git fetch https://github.com/butler54/coco-pattern.git dev/airgap-testing:dev/airgap-testing --force \
2>&1 | tee -a "$LOG"
# Pull into working copy
cd ~/coco-pattern
git fetch origin 2>&1 | tee -a "$LOG"
git reset --hard origin/dev/airgap-testing 2>&1 | tee -a "$LOG"
# Verify remote and branch
git remote get-url origin 2>&1 | tee -a "$LOG"
# MUST: ${GIT_SERVER}/coco-pattern.git
git branch --show-current 2>&1 | tee -a "$LOG"
# MUST: dev/airgap-testing
# Verify clustergroup chart version in imageset-config
echo "Checking imageset-config clustergroup version:" 2>&1 | tee -a "$LOG"
grep "clustergroup" ~/coco-pattern/airgap/imageset-config-4.22.yaml 2>&1 | tee -a "$LOG"
# EXPECTED: clustergroup:0.9.58
# Verify values-baremetal-airgap.yaml is 2-line shape
echo "Checking values-baremetal-airgap.yaml shape:" 2>&1 | tee -a "$LOG"
cat ~/coco-pattern/values-baremetal-airgap.yaml 2>&1 | tee -a "$LOG"
# EXPECTED: 2 lines with global.catalogSource and global.catalogSourceNamespace
# Verify deprecated scripts are quarantined
echo "Checking scripts/deprecated/:" 2>&1 | tee -a "$LOG"
ls ~/coco-pattern/scripts/deprecated/ 2>&1 | tee -a "$LOG"
# EXPECTED: exactly 3 files (fix-patterns-operator-images.sh, rebuild-patterns-operator-bundle.sh, deploy-pattern-without-operator.sh)
echo "B-1: Repo sync complete at $(date)" 2>&1 | tee -a "$LOG"Long-running (~3-4 hours). Open a new window in the
deploysession for this step.oc-mirror version must match OCP version.
labctl generate(C-1) automatically symlinks~/bin/oc-mirrorfrom~/ocp_42208_bin/oc-mirror. Verify with B-0 before proceeding. A version mismatch causes oc-mirror to rebuild manifests with different SHA256 digests than openshift-install expects, resulting in 404s on every component image during node boot.
tmux new-window -n "oc-mirror" # opens within the current session; switches you into it
eval "$(tmux showenv -g LOG)"; eval "$(tmux showenv -g MIRROR_REGISTRY)"; eval "$(tmux showenv -g GIT_SERVER)"
echo "=== B-2: oc-mirror v2 Full Re-Mirror ===" 2>&1 | tee -a "$LOG"
echo "Started at $(date)" 2>&1 | tee -a "$LOG"
# Verify oc-mirror version before running (must match OCP 4.22)
oc-mirror version 2>&1 | grep -i 'version\|GitVersion' | tee -a "$LOG"
# MUST show 4.22.x — if not, stop and fix: ln -sf ~/ocp_42208_bin/oc-mirror ~/bin/oc-mirror
# Authenticate oc-mirror against local-registry (CA trusted via certs.d from A-0)
podman login ${MIRROR_REGISTRY} \
--username "$MREG_USER" \
--password "$MREG_PASS" \
2>&1 | tee -a "$LOG"
# EXPECTED: Login Succeeded!
# Run oc-mirror v2 — full mirror to mirror-registry (sole airgap target, Phase 25.2)
# Note: --v2 (double dash) is mandatory; -v2 (single dash) is not recognised
# CA trust comes from ~/.config/containers/certs.d/${MIRROR_REGISTRY}/ca.crt (set in A-0)
oc-mirror \
--config ~/coco-pattern/airgap/imageset-config-4.22.yaml \
docker://${MIRROR_REGISTRY} \
--dest-tls-verify=true \
--workspace file://$HOME/oc-mirror-workspace \
--v2 \
2>&1 | tee -a "$LOG"
echo "B-2: oc-mirror complete at $(date)" 2>&1 | tee -a "$LOG"Wait for oc-mirror to complete before proceeding to B-3. Output is visible live in this window. Switch windows with
Ctrl-b <number>while it runs. Expected completion: 3-4 hours. oc-mirror v2 is resumable — re-running the same command skips already-mirrored content.Cosign signature failures: Intel, HashiCorp, and NVIDIA images on
registry.connect.redhat.comlack cosign.sigmanifests. Theno-sigstore-certified.yamlconfig (created in Phase 0-1 step h2) disables sigstore attachment lookups for these vendors, so oc-mirror handles them normally.Transient failures (fixed by re-running):
multicluster-engine/hive-rhel9is 600MB+ and reliably times out on the first oc-mirror run (context deadline exceededduring blob upload). When hive-rhel9 times out, oc-mirror also skipsmce-operator-bundleentirely — this cascades to: MCE CSV not installed → MCH stuck at Installing → ACM policy controller never starts →credentialsecret never created → trustee KbsConfig blocks forever. If B-3 shows hive-rhel9 or mce-operator-bundle MISSING: re-run the B-2 oc-mirror command. oc-mirror resumes from the workspace cache and retries only what failed — do NOT skip to Phase C.
echo "=== B-3: Post-Mirror Verification ===" 2>&1 | tee -a "$LOG"
# Count cluster-resources YAML files (expect ~9: IDMS, ITMS, CatalogSources, signatures)
ls ~/oc-mirror-workspace/working-dir/cluster-resources/*.yaml 2>&1 | tee -a "$LOG"
COUNT=$(ls ~/oc-mirror-workspace/working-dir/cluster-resources/*.yaml | wc -l)
echo "cluster-resources count: $COUNT (expected ~10)" 2>&1 | tee -a "$LOG"
# Paginate through full registry catalog — v2/_catalog returns max 100 repos per page
# CRITICAL: do NOT use a bare curl; with 270+ repos the first page misses everything past repo 100
echo "Spot-checking key repos in local-registry (paginated):" 2>&1 | tee -a "$LOG"
python3 - << 'PYEOF' 2>&1 | tee -a "$LOG"
import json, urllib.request, urllib.error, base64, os
creds = base64.b64encode(b"init:" + open(os.path.expanduser("~/.coco-pattern/mirror-registry-password"), "rb").read().strip()).decode()
headers = {"Authorization": f"Basic {creds}"}
repos = []
last = ""
while True:
url = f"https://${MIRROR_REGISTRY}/v2/_catalog?n=100{f'&last={last}' if last else ''}"
req = urllib.request.Request(url, headers=headers)
ctx = __import__("ssl").create_default_context()
ctx.load_verify_locations(os.path.expanduser("~/mirror-registry-certs/ca.crt"))
try:
with urllib.request.urlopen(req, context=ctx) as r:
page = json.loads(r.read()).get("repositories", [])
except Exception as e:
print(f"ERROR: {e}"); break
repos.extend(page)
if len(page) < 100: break
last = page[-1]
print(f"Total repos: {len(repos)}")
checks = [
("openshift/release-images", "OCP release images"),
("openshift/release", "OCP release components"),
("validatedpatterns/clustergroup", "clustergroup chart"),
("community-operator-pipeline-prod/patterns-operator", "patterns-operator"),
("hashicorp/vault", "Vault"),
("intel/intel-tdx-dcap-operator", "Intel TDX DCAP"),
("multicluster-engine/hive-rhel9", "MCE hive (large — timeout risk; re-run oc-mirror if missing)"),
("multicluster-engine/mce-operator-bundle", "MCE bundle (skipped by oc-mirror if hive timed out)"),
("build-of-trustee/trustee-rhel9", "Trustee"),
("multicluster-engine/hive-rhel9", "MCE Hive"),
("openshift-sandboxed-containers/osc-cloud-api-adaptor-rhel9", "CoCo cloud-api-adaptor"),
]
for repo, label in checks:
status = "OK" if repo in repos else "MISSING"
print(f" {status} {label} ({repo})")
PYEOF
# Verify IDMS files cover expected namespaces
echo "IDMS namespace coverage:" 2>&1 | tee -a "$LOG"
grep "${MIRROR_REGISTRY}" ~/oc-mirror-workspace/working-dir/cluster-resources/idms-oc-mirror.yaml | wc -l | xargs -I{} echo " idms-oc-mirror.yaml: {} mirror entries" | tee -a "$LOG"
# idms-manual-mirrors.yaml no longer needed — oc-mirror generates IDMS for Intel/Hashicorp
# when registries.d/no-sigstore-certified.yaml is in place
echo "B-3: Post-mirror verification complete at $(date)" 2>&1 | tee -a "$LOG"No longer needed. The
no-sigstore-certified.yamlregistries.d config (Phase 0-1 step h2) disables sigstore attachment lookups for Intel, HashiCorp, and NVIDIA onregistry.connect.redhat.com. oc-mirror now mirrors all images successfully and generates IDMS entries for them automatically.ITMS (tag-based mirrors) for
registry.connect.redhat.com/inteland/hashicorpare still needed — oc-mirror only generates IDMS (digest-based). The supplementary ITMS lives atairgap/itms-manual-mirrors.yamland is applied automatically byairgap-post-install.shin D-1.
echo "=== B-3a: Verify supplementary ITMS in repo ===" | tee -a "$LOG"
cat ~/coco-pattern/airgap/itms-manual-mirrors.yaml 2>&1 | tee -a "$LOG"
# EXPECTED: ImageTagMirrorSet covering registry.connect.redhat.com/intel and /hashicorplabctl now generates the fake gateway directly (172.25.53.254) instead of
route-type: blackhole, and embeds wipe-disks.ign into the ISO viacoreos-installer iso ignition embed. No manual patching or separate openshift-install call needed.
echo "=== C-1: Generate Configs and Build ISO ===" 2>&1 | tee -a "$LOG"
source ~/.envrc
# ── labctl (APAC lab-specific) ───────────────────────────────────────────────
# labctl is an internal APAC lab tool that reads node configuration from an
# XLSX file and generates OCP agent-based install configs with the correct
# NIC, iDRAC, and network settings for this lab environment.
#
# For other environments, replace labctl generate with:
# openshift-install agent create image --dir <output-dir>
# and mount the ISO via your BMC virtual media interface manually.
# ─────────────────────────────────────────────────────────────────────────────
labctl generate \
--node node-02 \
--xlsx "$HOME/APAC Technology Lab Details.xlsx" \
--ocp-version 42208 \
--output-dir ~/node-02-airgap-output \
--mirror-resources ~/oc-mirror-workspace/working-dir/cluster-resources \
--additional-trust-bundle ~/combined-ca-bundle.pem \
--http-serve-path $HOME/public_html/ \
2>&1 | tee -a "$LOG"
# Verify: fake gateway present, no blackhole, ISO built and served
grep "172.25.53.254" ~/node-02-airgap-output/agent-config.yaml 2>&1 | tee -a "$LOG" && \
! grep "blackhole" ~/node-02-airgap-output/agent-config.yaml && \
echo "PASS: fake gateway in agent-config, no blackhole" 2>&1 | tee -a "$LOG"
ls -lh ~/public_html/node-02-42208.iso 2>&1 | tee -a "$LOG" && \
echo "PASS: ISO present at http serve path" 2>&1 | tee -a "$LOG"
echo "C-1: Config generation and ISO build complete at $(date)" 2>&1 | tee -a "$LOG"echo "=== C-3: Mount ISO via iDRAC ===" 2>&1 | tee -a "$LOG"
source ~/.envrc
# ── labctl (APAC lab-specific) ───────────────────────────────────────────────
# labctl is an internal APAC lab tool that reads node configuration from an
# XLSX file and generates OCP agent-based install configs with the correct
# NIC, iDRAC, and network settings for this lab environment.
#
# For other environments, replace labctl generate with:
# openshift-install agent create image --dir <output-dir>
# and mount the ISO via your BMC virtual media interface manually.
# ─────────────────────────────────────────────────────────────────────────────
labctl idrac mount-iso \
--node node-02 \
--xlsx "$HOME/APAC Technology Lab Details.xlsx" \
--iso-url "http://user-jump.int.apac-tech-lab.net/~chbutler/node-02-42208.iso" \
2>&1 | tee -a "$LOG"
echo "C-3: ISO mounted at $(date)" 2>&1 | tee -a "$LOG"tmux new-window -n "cluster-install" # new window within the deploy session
eval "$(tmux showenv -g LOG)"; eval "$(tmux showenv -g MIRROR_REGISTRY)"; eval "$(tmux showenv -g GIT_SERVER)"
echo "=== C-4: Wait for Install ===" 2>&1 | tee -a "$LOG"
cd ~/node-02-airgap-output/42208_build
~/ocp_42208_bin/openshift-install agent wait-for install-complete \
--dir . \
--log-level info \
2>&1 | tee -a "$LOG"
echo "C-4: Install complete at $(date)" 2>&1 | tee -a "$LOG"Wait for install to complete before proceeding to C-5. Expected log line:
Install complete!andAccess the OpenShift web-console here:Switch windows withCtrl-b <number>while waiting.
echo "=== C-5: Health Check ===" 2>&1 | tee -a "$LOG"
# Find the build directory (changes per run)
BUILD_DIR=$(ls -td ~/node-02-airgap-output/4*_build* 2>/dev/null | head -1)
echo "Build dir: $BUILD_DIR" 2>&1 | tee -a "$LOG"
export KUBECONFIG="${BUILD_DIR}/auth/kubeconfig"
echo "KUBECONFIG=$KUBECONFIG" | tee -a "$LOG"
oc get clusterversion 2>&1 | tee -a "$LOG"
# EXPECTED: VERSION 4.22.8, STATUS Available
oc get nodes 2>&1 | tee -a "$LOG"
# EXPECTED: node-02 Ready
echo "C-5: Health check complete at $(date)" 2>&1 | tee -a "$LOG"echo "=== C-6: Set KUBECONFIG ===" 2>&1 | tee -a "$LOG"
# The build directory name changes per run — set it from the actual output
BUILD_DIR=$(ls -td ~/node-02-airgap-output/4*_build* 2>/dev/null | head -1)
export KUBECONFIG="${BUILD_DIR}/auth/kubeconfig"
echo "export KUBECONFIG=${BUILD_DIR}/auth/kubeconfig" >> ~/.envrc
echo "KUBECONFIG set to: $KUBECONFIG" 2>&1 | tee -a "$LOG"
# Verify
oc whoami 2>&1 | tee -a "$LOG"
# EXPECTED: system:admin
echo "C-6: KUBECONFIG configured at $(date)" 2>&1 | tee -a "$LOG"
airgap-post-install.shhandles all of the following in one pass:
- Disables OperatorHub default catalogs
- Applies CatalogSources from oc-mirror cluster-resources (deleting stale ones)
- Applies ALL oc-mirror cluster-resources: IDMS, ITMS, ClusterCatalog (OLM v1), signature ConfigMap
- Applies supplementary IDMS (
idms-manual-mirrors.yaml— intel/hashicorp, created in B-3a)- Applies supplementary ITMS (
airgap/itms-manual-mirrors.yaml— intel/hashicorp, from repo)- Patches
mirrorSourcePolicy: NeverContactSourceon all IDMS entries (prevents internet fallback)- Configures the git HTTP server and patterns-operator-config ConfigMap
echo "=== D-1: airgap-post-install.sh ===" 2>&1 | tee -a "$LOG"
cd ~/coco-pattern
# MIRROR_REGISTRY was set in A-0; confirm it is still set
echo "MIRROR_REGISTRY=$MIRROR_REGISTRY" | tee -a "$LOG"
scripts/airgap-post-install.sh 2>&1 | tee -a "$LOG"
# Verify catalog pods are Running before proceeding to D-2
echo "--- CatalogSource pods ---" 2>&1 | tee -a "$LOG"
oc get pods -n openshift-marketplace 2>&1 | tee -a "$LOG"
# EXPECTED: 3 pods Running (redhat, certified, community operator indexes)
echo "D-1: airgap-post-install complete at $(date)" 2>&1 | tee -a "$LOG"This step is now automated.
airgap-post-install.sh(step 3b/3c) patchesNeverContactSourceon all IDMS and ITMS objects — including the bootstrapimage-digest-mirrorIDMS created bylabctlat cluster install time. If any objects were patched, MCO re-rendersregistries.conf. On SNO this triggers a node reboot (~5 min). Check the script output for"MCO will re-render registries.conf"and wait for the node to recover before proceeding.
# If airgap-post-install.sh printed a reboot warning, wait here:
echo "=== D-1.5: Wait for node if MCO re-rendered ===" 2>&1 | tee -a "$LOG"
until oc get node -o jsonpath='{.items[0].status.conditions[?(@.type=="Ready")].status}' \
2>/dev/null | grep -q True; do
echo " Node not Ready yet — $(date)" | tee -a "$LOG"
sleep 15
done
echo "Node Ready at $(date)" 2>&1 | tee -a "$LOG"
oc get nodes 2>&1 | tee -a "$LOG"echo "=== D-2: Enable routingViaHost ===" 2>&1 | tee -a "$LOG"
oc patch network.operator.openshift.io cluster --type merge \
-p '{"spec":{"defaultNetwork":{"ovnKubernetesConfig":{"gatewayConfig":{"routingViaHost":true}}}}}' \
2>&1 | tee -a "$LOG"
echo "Waiting for MCO to complete (expect 5-10 min including reboot)..." 2>&1 | tee -a "$LOG"
while true; do
STATUS=$(oc get mcp master -o jsonpath='{.status.conditions[?(@.type=="Updated")].status}' 2>/dev/null)
UPDATING=$(oc get mcp master -o jsonpath='{.status.conditions[?(@.type=="Updating")].status}' 2>/dev/null)
echo "STATUS: $(date) — MCO Updated=$STATUS Updating=$UPDATING" 2>&1 | tee -a "$LOG"
[ "$STATUS" = "True" ] && [ "$UPDATING" = "False" ] && echo "MCO complete" 2>&1 | tee -a "$LOG" && break
sleep 30
done
echo "D-2: routingViaHost enabled at $(date)" 2>&1 | tee -a "$LOG"echo "=== D-3: Prepare Secrets ===" 2>&1 | tee -a "$LOG"
cd ~/coco-pattern
make cache-keys 2>&1 | tee -a "$LOG"
make cache-registry-ca 2>&1 | tee -a "$LOG"
./scripts/gen-secrets.sh 2>&1 | tee -a "$LOG"
# registryCaCert and bootstrap_secrets (mirror-registry-helm-oci) are
# commented out by default in values-secret.yaml.template (they only apply
# to airgap deployments). Uncomment both blocks in the generated
# values-secret file now that cache-registry-ca has populated the CA cert.
make enable-airgap-secrets 2>&1 | tee -a "$LOG"
# Strip trailing newline from mirror-registry-password.
# load-bootstrap reads this file verbatim via the values-secret.yaml path: field and
# the Ansible role does not strip whitespace. A trailing \n causes Helm to send
# "password\n" to the registry, which never matches the htpasswd hash → 401.
printf '%s' "$(cat ~/.coco-pattern/mirror-registry-password)" \
> ~/.coco-pattern/mirror-registry-password
echo "mirror-registry-password length: $(wc -c < ~/.coco-pattern/mirror-registry-password) bytes (expected 24, no newline)" \
2>&1 | tee -a "$LOG"
echo "D-3: Secrets prepared at $(date)" 2>&1 | tee -a "$LOG"tmux new-window -n "pattern-deploy" # new window within the deploy session
eval "$(tmux showenv -g LOG)"; eval "$(tmux showenv -g MIRROR_REGISTRY)"; eval "$(tmux showenv -g GIT_SERVER)"
echo "=== D-4: Pattern Deploy ===" 2>&1 | tee -a "$LOG"
cd ~/coco-pattern
# Verify git remote and branch before deploy
git remote get-url origin 2>&1 | tee -a "$LOG"
# MUST: ${GIT_SERVER}/coco-pattern.git
git branch --show-current 2>&1 | tee -a "$LOG"
# MUST: dev/airgap-testing
./pattern.sh make install \
2>&1 | tee -a "$LOG"
echo "D-4: pattern.sh complete at $(date)" 2>&1 | tee -a "$LOG"Immediately switch to your main window and start D-5.
Ctrl-b <number>to switch. D-5 runs in parallel — it loops waiting for vp-gitops to appear, then fires automatically. Do not wait — if ArgoCD comes up before D-5 completes it will try to pull charts without credentials. MCO reboots during kata deployment are normal — not a failure signal.
Start this immediately after launching D-4 from the main tmux window. D-5 internally waits for
vp-gitopsto exist — start it now so it fires the moment ArgoCD is ready. If you delay, ArgoCD may come up and fail to pull VP OCI Helm charts before credentials are loaded.Steps D-5 performs once vp-gitops appears:
- Inject mirror-registry private CA into ArgoCD's TLS trust store
make load-bootstrap— pre-seeds ArgoCD with mirror-registry OCI Helm credentials (from thebootstrap_secretsblock in values-secret.yaml; no vault required)- Hard-refresh ArgoCD so it pulls chart manifests from mirror-registry immediately
echo "=== D-5: ArgoCD CA + Bootstrap Secrets ===" 2>&1 | tee -a "$LOG"
cd ~/coco-pattern
# Wait for ArgoCD (vp-gitops namespace)
echo "Waiting for vp-gitops namespace..." 2>&1 | tee -a "$LOG"
until oc get ns vp-gitops &>/dev/null; do sleep 10; done
echo "vp-gitops active at $(date)" 2>&1 | tee -a "$LOG"
# Wait for repo-server deployment to exist before patching
until oc get deployment vp-gitops-repo-server -n vp-gitops &>/dev/null; do sleep 10; done
# Inject mirror-registry CA into ArgoCD TLS trust.
# mirror-registry uses the lab private CA — provide the chain so ArgoCD verifies properly.
# Note: CA cert contains literal newlines which must be escaped for JSON; use Python to build patch.
echo "Injecting mirror-registry CA into argocd-tls-certs-cm..." 2>&1 | tee -a "$LOG"
python3 -c "
import json, subprocess, sys
ca = open('$HOME/mirror-registry-certs/ca.crt').read()
import os; mreg_host = os.environ.get('MIRROR_REGISTRY','MIRROR_REGISTRY_HOST:8443').split(':')[0]
patch = json.dumps({'data': {mreg_host: ca}})
r = subprocess.run(['oc','patch','configmap','argocd-tls-certs-cm','-n','vp-gitops','--type','merge','-p',patch], capture_output=True, text=True)
print(r.stdout or r.stderr)
sys.exit(r.returncode)
" 2>&1 | tee -a "$LOG"
# Restart ALL vp-gitops pods to pick up the new CA and any secret changes.
# repo-server uses the CA for Helm registry login; other components may cache certs.
echo "Rolling all vp-gitops deployments..." 2>&1 | tee -a "$LOG"
oc get deployments -n vp-gitops --no-headers -o name 2>/dev/null | \
while read dep; do oc rollout restart "$dep" -n vp-gitops 2>&1 | tee -a "$LOG"; done
# Wait for repo-server specifically before loading secrets (it handles Helm OCI auth)
oc rollout status deployment/vp-gitops-repo-server -n vp-gitops --timeout=120s \
2>&1 | tee -a "$LOG"
# Load bootstrap secrets — creates ArgoCD OCI Helm repo secret for mirror-registry.
# This uses values-secret.yaml bootstrap_secrets section; runs before vault init.
# IMPORTANT: mirror-registry-password must have no trailing newline (fixed in D-3).
# A trailing \n causes Helm to send "password\n" → registry rejects with 401.
echo "Loading bootstrap secrets (ArgoCD OCI Helm repo auth)..." 2>&1 | tee -a "$LOG"
make load-bootstrap 2>&1 | tee -a "$LOG"
# Verify the secret was created and password has correct length (no trailing newline)
oc get secret mirror-registry-helm-oci -n vp-gitops 2>&1 | tee -a "$LOG"
# EXPECTED: mirror-registry-helm-oci Opaque ...
PASS_LEN=$(oc get secret mirror-registry-helm-oci -n vp-gitops \
-o jsonpath='{.data.password}' 2>/dev/null | base64 -d | wc -c)
echo "mirror-registry-helm-oci password length: ${PASS_LEN} bytes (expected: same as mirror-registry-password file)" \
2>&1 | tee -a "$LOG"
if [ "${PASS_LEN}" -gt 24 ]; then
echo "WARN: password may have trailing newline — patch the secret:" 2>&1 | tee -a "$LOG"
echo " MREG_PASS=\$(cat ~/.coco-pattern/mirror-registry-password | tr -d '\\n')" 2>&1 | tee -a "$LOG"
echo " oc create secret generic mirror-registry-helm-oci -n vp-gitops \\" 2>&1 | tee -a "$LOG"
echo " --from-literal=type=helm --from-literal=enableOCI=true \\" 2>&1 | tee -a "$LOG"
echo " --from-literal=url=${MIRROR_REGISTRY}/validatedpatterns \\" 2>&1 | tee -a "$LOG"
echo " --from-literal=name=mirror-registry-charts \\" 2>&1 | tee -a "$LOG"
echo " --from-literal=username=init --from-literal=password=\"\$MREG_PASS\" \\" 2>&1 | tee -a "$LOG"
echo " --dry-run=client -o yaml | oc apply -f -" 2>&1 | tee -a "$LOG"
fi
# Trigger hard refresh — ArgoCD now has CA trust + credentials for mirror-registry
oc annotate applications.argoproj.io coco-pattern-baremetal -n vp-gitops \
argocd.argoproj.io/refresh=hard --overwrite 2>&1 | tee -a "$LOG"
sleep 30
# Verify child apps are appearing
echo "--- ArgoCD child apps ---" 2>&1 | tee -a "$LOG"
oc get applications.argoproj.io -A --no-headers 2>/dev/null | \
awk '{print $2, $4, $5}' | column -t 2>&1 | tee -a "$LOG"
# EXPECTED: vault, acm, kyverno, openshift-external-secrets, storage, etc. are listed
echo "D-5: ArgoCD configured at $(date)" 2>&1 | tee -a "$LOG"echo "=== D-6: Wait for Vault + Load Secrets ===" 2>&1 | tee -a "$LOG"
cd ~/coco-pattern
while true; do
INIT=$(oc exec -n vault vault-0 -- vault status -format=json 2>/dev/null | \
python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('initialized',''))" 2>/dev/null || echo "")
SEALED=$(oc exec -n vault vault-0 -- vault status -format=json 2>/dev/null | \
python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('sealed',''))" 2>/dev/null || echo "")
echo "STATUS: $(date) — Vault init=$INIT sealed=$SEALED" 2>&1 | tee -a "$LOG"
[ "$INIT" = "True" ] && [ "$SEALED" = "False" ] && echo "Vault ready" 2>&1 | tee -a "$LOG" && break
sleep 15
done
./pattern.sh make load-secrets 2>&1 | tee -a "$LOG"
# EXPECTED: 10 secrets injected
echo "D-6: Secrets loaded at $(date)" 2>&1 | tee -a "$LOG"echo "=== E-1: DCAP Readiness ===" 2>&1 | tee -a "$LOG"
oc get pods -n intel-dcap-operator-system -l app=intel-tdx-qgs --no-headers \
2>&1 | tee -a "$LOG"
# EXPECTED: NAME 2/2 Running
# NOTE: QGS DaemonSet runs in intel-dcap-operator-system, not openshift-operators
oc get secrets -n intel-dcap-operator-system -l type=platform-data --no-headers \
2>&1 | tee -a "$LOG"
# EXPECTED: 1 secret (name is the QE ID hex string)
echo "E-1: DCAP readiness verified at $(date)" 2>&1 | tee -a "$LOG"
charts/all/baremetal/templates/vsock-mco.yamldeploys a MachineConfig drop-in at/etc/kata-containers/kata-tdx/config.d/96-kata-kernel-configthat setssocket_port=0. Verify that the drop-in exists and the effective port is 0. The baseconfiguration.tomlmay still show 4050 — that is overridden by the drop-in, which kata reads last.
echo "=== E-2: QGS Socket Port Verification ===" 2>&1 | tee -a "$LOG"
NODE=$(oc get nodes -o name | head -1)
echo "Checking node: $NODE" 2>&1 | tee -a "$LOG"
# Check the drop-in file (authoritative — overrides configuration.toml)
DROP_IN=$(oc debug $NODE -- chroot /host bash -c \
"cat /etc/kata-containers/kata-tdx/config.d/96-kata-kernel-config 2>/dev/null" \
2>/dev/null)
echo "Drop-in content: $DROP_IN" 2>&1 | tee -a "$LOG"
if echo "$DROP_IN" | grep -q "socket_port=0"; then
echo "PASS: socket_port=0 in drop-in — QGS will use unix socket" 2>&1 | tee -a "$LOG"
else
echo "FAIL: drop-in missing or socket_port not 0 — record as DEV-1 in DEVIATIONS.md" 2>&1 | tee -a "$LOG"
echo " See Appendix A.3 for the fallback sed command (apply ONLY after recording DEV-1)" 2>&1 | tee -a "$LOG"
fi
echo "E-2: QGS socket port verification complete at $(date)" 2>&1 | tee -a "$LOG"If verification fails (DEV-1): The MachineConfig drop-in was not applied — check that
oc get mc | grep kata-tdxshows a MachineConfig with the drop-in content. See Appendix Section A.3 for the temporary sed fallback. Record in DEVIATIONS.md.
PCK certificates are specific to each QGS platform. The Make targets create checksummed request and response bundles, validate the complete QE-ID/PCE-ID set, and restart QGS only after all matching PCK Secrets are applied.
The importer discovers the QGS DaemonSet from the pck-certs-watcher pod owner reference, avoiding a version-specific DaemonSet label or name. Set DCAP_QGS_DAEMONSET only if the namespace has more than one candidate.
If this bastion can reach both the disconnected cluster and Intel PCS, run the combined workflow. The API key is read from INTEL_PCS_API_KEY if exported, otherwise Make prompts without echoing it. Never put the key on the Make command line.
echo "=== E-3: Combined PCK provisioning ===" 2>&1 | tee -a "$LOG"
cd ~/coco-pattern
make dcap-pck-provision 2>&1 | tee -a "$LOG"If the high-side cluster cannot reach the connected low-side host, use the split workflow:
# High side: create ~/.coco-pattern/dcap-pck/platform-request and transfer it high -> low.
make dcap-platform-export
# Low side: copy platform-request to ~/.coco-pattern/dcap-pck/, then run these commands.
make dcap-tools dcap-pck-generate
# Transfer ~/.coco-pattern/dcap-pck/pck-response low -> high, then import it on the high side.
make dcap-pck-importcollect-dcap-collateral is independent of PCK provisioning. It collects public Intel TDX verification collateral on the connected low side and does not require an Intel PCS API key. Transfer ~/.coco-pattern/dcap-offline/platform_collaterals.json to the high side before E-5.
echo "=== E-4: Collect DCAP Collateral + Firmware Refvals ===" 2>&1 | tee -a "$LOG"
cd ~/coco-pattern
make collect-dcap-collateral 2>&1 | tee -a "$LOG"
# Verify QeIdentity is populated (must be >100 chars)
python3 -c "
import json, os
c = json.load(open(os.path.expanduser('~/.coco-pattern/dcap-offline/platform_collaterals.json')))
qi = c.get('collaterals', {}).get('qeidentity_early') or c.get('collaterals', {}).get('qeidentity')
print('qeidentity present:', isinstance(qi, dict), '— PASS' if isinstance(qi, dict) else '— FAIL: recollect collateral')
" 2>&1 | tee -a "$LOG"
make collect-firmware-refvals 2>&1 | tee -a "$LOG"
echo "E-4: DCAP collateral and refvals collected at $(date)" 2>&1 | tee -a "$LOG"echo "=== E-5: Reload Secrets ===" 2>&1 | tee -a "$LOG"
cd ~/coco-pattern
./pattern.sh make load-secrets 2>&1 | tee -a "$LOG"
# EXPECTED: 10 secrets injected (including DCAP collateral and firmware refvals)
echo "E-5: Secrets reloaded at $(date)" 2>&1 | tee -a "$LOG"echo "=== F-1: Wait for initdata ConfigMaps ===" 2>&1 | tee -a "$LOG"
while true; do
COUNT=$(oc get configmap -n imperative -l coco.io/type=initdata --no-headers 2>/dev/null | wc -l)
echo "STATUS: $(date) — initdata ConfigMaps: $COUNT/2" 2>&1 | tee -a "$LOG"
[ "$COUNT" -ge 2 ] && echo "Initdata ready" 2>&1 | tee -a "$LOG" && break
sleep 30
done
echo "F-1: initdata ConfigMaps ready at $(date)" 2>&1 | tee -a "$LOG"echo "=== F-2: Bounce Workload Pods ===" 2>&1 | tee -a "$LOG"
oc delete pods --all -n hello-openshift --force --grace-period=0 \
2>&1 | tee -a "$LOG"
oc delete pods --all -n kbs-access --force --grace-period=0 \
2>&1 | tee -a "$LOG"
echo "Waiting 90 seconds for pods to restart..." 2>&1 | tee -a "$LOG"
sleep 90
echo "F-2: Pods bounced at $(date)" 2>&1 | tee -a "$LOG"echo "=== F-3: Verify Pods Running ===" 2>&1 | tee -a "$LOG"
echo "--- hello-openshift ---" 2>&1 | tee -a "$LOG"
oc get pods -n hello-openshift 2>&1 | tee -a "$LOG"
# EXPECTED: 3/3 Running (standard, insecure-policy, secure)
echo "--- kbs-access ---" 2>&1 | tee -a "$LOG"
oc get pods -n kbs-access 2>&1 | tee -a "$LOG"
# EXPECTED: kbs-access-curl 1/1, kbs-access-sealed 1/1 (or similar)
echo "F-3: Pod status captured at $(date)" 2>&1 | tee -a "$LOG"echo "=== F-4: Verify KBS Attestation ===" 2>&1 | tee -a "$LOG"
oc logs deployment/trustee-deployment -n trustee-operator-system -c kbs --tail=20 \
2>&1 | tee -a "$LOG"
# EXPECTED: POST /attest 200, GET /resource 200
echo "F-4: KBS attestation verified at $(date)" 2>&1 | tee -a "$LOG"echo "=== F-5: Verify Image Sources (all from mirror-registry) ===" 2>&1 | tee -a "$LOG"
for ns in hello-openshift kbs-access trustee-operator-system; do
echo "--- $ns ---" 2>&1 | tee -a "$LOG"
oc get pods -n $ns -o jsonpath='{range .items[*]}{.metadata.name}{": "}{.spec.containers[*].image}{"\n"}{end}' \
2>&1 | tee -a "$LOG"
done
# Flag any image not from mirror-registry or the internal OCP registry
echo "Upstream refs (should be empty):" 2>&1 | tee -a "$LOG"
oc get pods -A -o jsonpath='{range .items[*]}{.spec.containers[*].image}{"\n"}{end}' 2>/dev/null \
| grep -v "${MIRROR_REGISTRY}\|image-registry.openshift-image-registry" \
| sort | uniq \
2>&1 | tee -a "$LOG" || echo "All images from expected registries" | tee -a "$LOG"
echo "F-5: Image sources verified at $(date)" 2>&1 | tee -a "$LOG"echo "=== F-6: Verify ArgoCD Apps ===" 2>&1 | tee -a "$LOG"
oc get applications.argoproj.io -n vp-gitops \
2>&1 | tee -a "$LOG"
# EXPECTED: All apps Synced+Healthy
APP_COUNT=$(oc get applications.argoproj.io -n vp-gitops --no-headers 2>/dev/null | wc -l)
HEALTHY_COUNT=$(oc get applications.argoproj.io -n vp-gitops --no-headers 2>/dev/null \
| grep -c "Synced.*Healthy" || echo "0")
echo "ArgoCD: $HEALTHY_COUNT/$APP_COUNT apps Synced+Healthy" 2>&1 | tee -a "$LOG"
echo "F-6: ArgoCD status captured at $(date)" 2>&1 | tee -a "$LOG"These checks validate that Phase 24 cleanup decisions did not break the deploy. Record results in DEVIATIONS.md if any check fails.
echo "=== F-7: Phase 24 Validation Checks (D-07) ===" 2>&1 | tee -a "$LOG"
# Check 1: scripts/deprecated/ contains exactly 3 scripts, none were invoked during deploy
echo "--- Check 1: scripts/deprecated/ contents ---" 2>&1 | tee -a "$LOG"
ls -la ~/coco-pattern/scripts/deprecated/ 2>&1 | tee -a "$LOG"
DEPRECATED_COUNT=$(ls ~/coco-pattern/scripts/deprecated/ | wc -l)
echo "Deprecated script count: $DEPRECATED_COUNT (expected: 3)" 2>&1 | tee -a "$LOG"
if [ "$DEPRECATED_COUNT" = "3" ]; then
echo "PASS: Exactly 3 deprecated scripts present (none removed, none invoked)" 2>&1 | tee -a "$LOG"
else
echo "DEVIATION: Record deprecated count mismatch in DEVIATIONS.md" 2>&1 | tee -a "$LOG"
fi
# Check 2: values-baremetal-airgap.yaml is the 2-line global overlay (D-05 / Phase 24 C-05)
echo "--- Check 2: values-baremetal-airgap.yaml shape ---" 2>&1 | tee -a "$LOG"
cat ~/coco-pattern/values-baremetal-airgap.yaml 2>&1 | tee -a "$LOG"
OVERLAY_LINES=$(grep -c "global\." ~/coco-pattern/values-baremetal-airgap.yaml 2>/dev/null || echo "0")
echo "global.* lines in overlay: $OVERLAY_LINES (expected: 2)" 2>&1 | tee -a "$LOG"
grep -q "global.catalogSource" ~/coco-pattern/values-baremetal-airgap.yaml && \
grep -q "global.catalogSourceNamespace" ~/coco-pattern/values-baremetal-airgap.yaml && \
echo "PASS: 2-line global catalogSource overlay active" 2>&1 | tee -a "$LOG" || \
echo "DEVIATION: values-baremetal-airgap.yaml does not match expected 2-line shape" 2>&1 | tee -a "$LOG"
# Check 3: mirror-registry is still present and serving
echo "--- Check 3: mirror-registry health ---" 2>&1 | tee -a "$LOG"
curl -sk --cacert ~/mirror-registry-certs/ca.crt \
https://${MIRROR_REGISTRY}/v2/_catalog \
2>&1 | tee -a "$LOG"
MIRROR_STATUS=$?
if [ "$MIRROR_STATUS" = "0" ]; then
echo "PASS: mirror-registry accessible at ${MIRROR_REGISTRY}" 2>&1 | tee -a "$LOG"
else
echo "DEVIATION: mirror-registry not accessible — record in DEVIATIONS.md" 2>&1 | tee -a "$LOG"
fi
# Check 4: Record whether VP hybrid images required mirror-registry or pulled from Quay alone
echo "--- Check 4: VP hybrid image source ---" 2>&1 | tee -a "$LOG"
echo "Review B-4 log output: did airgap-post-install.sh WARN about VP images?" 2>&1 | tee -a "$LOG"
grep "WARN.*VP\|WARN.*patterns-operator\|mirror-registry" "$LOG" | head -5 2>&1 | tee -a "$LOG"
echo "If WARNs appeared → mirror-registry still needed (record in DEVIATIONS.md as Phase 26 prerequisite confirmed)" 2>&1 | tee -a "$LOG"
echo "If no WARNs → VP images served from Quay alone (record as Phase 26 milestone: mirror-registry removable)" 2>&1 | tee -a "$LOG"
echo "F-7: Phase 24 validation checks complete at $(date)" 2>&1 | tee -a "$LOG"Fill in the ACTUAL column after running the above steps. Record any failures in DEVIATIONS.md.
echo "=== F-8: Pass/Fail Summary ===" 2>&1 | tee -a "$LOG"
# Collect key status values for the summary table
echo "patterns-operator CSV:" 2>&1 | tee -a "$LOG"
oc get csv -n openshift-operators -l operators.coreos.com/patterns-operator.openshift-operators \
--no-headers 2>&1 | tee -a "$LOG"
echo "Vault status:" 2>&1 | tee -a "$LOG"
oc exec -n vault vault-0 -- vault status 2>&1 | tee -a "$LOG"
echo "MCH status:" 2>&1 | tee -a "$LOG"
oc get mch -A 2>&1 | tee -a "$LOG"
echo "=== Run Complete: $(date) ===" 2>&1 | tee -a "$LOG"Pass/Fail Criteria Reference Table:
| Criterion | Expected | Actual |
|---|---|---|
| patterns-operator CSV Succeeded | Via OLM from mirror-registry community catalog | |
| Pattern CR reconciles | targetRepo = HTTP Git URL (not GitHub) | |
| Vault init without manual SA creation | Sync-wave fix still working | |
| 15/15 (or current count) ArgoCD apps Synced+Healthy | All green | |
| KBS attestation affirming | POST /attest 200 | |
| KBS resource delivery | GET /resource 200 | |
| hello-openshift 3/3 Running | standard + insecure-policy + secure | |
| kbs-access-curl Running | Uses privileged SCC SA | |
| kbs-access-sealed Running | Uses Secret volume | |
| secret.txt contains KBS resource | Fetched via CDH from KBS | |
| All images from mirror-registry | No upstream refs | |
| MCH Running | ACM + MCE healthy | |
| QGS socket_port=0 WITHOUT manual sed | D-06 — MCO fix working (new row) | |
| No mirrorSourcePolicy conflicts after D-1.5 | PASS: all IDMS/ITMS have NeverContactSource (new row) | |
| scripts/deprecated/ untouched — 3 files, none invoked | D-07 — no deprecated scripts called (new row) | |
| 2-line global catalogSource overlay active in values-baremetal-airgap.yaml | D-07 — Phase 24 C-05 migration active (new row) | |
| mirror-registry serving VP hybrid images | D-07 — confirms two-registry still needed OR single-registry milestone |
Known Warnings (not failures):
| Warning | Explanation |
|---|---|
| VP hybrid images WARN in airgap-post-install.sh | Images pre-pushed to mirror-registry. Script cannot re-push (already there). |
| insights operator unavailable | Expected in airgap — cannot reach console.redhat.com |
| clusterversion Error reconciling | Caused by insights operator. Cosmetic. |
| MCO reboots during kata deployment | Normal behavior — not a failure signal |
Copy this template to ~/coco-pattern/DEVIATIONS.md at the start of the run.
Add a new ## DEV-N section for each deviation discovered during execution.
# Run 25 Deviations
**Run date:** YYYY-MM-DD
**Operator:** chbutler
**Log file:** ~/coco-pattern/logs/run-25-YYYYMMDD.log
## Format
Each deviation entry:
- What happened (observation)
- What was done (action taken)
- Impact on Phase 26 cleanup decisions
---
## DEV-1: [Title — fill in if QGS socket_port fix not automatic]
**Phase step:** E-2
**What happened:** [e.g., socket_port was 4050 instead of 0 in kata-tdx/configuration.toml]
**What was done:** [e.g., applied fallback sed — see Appendix A.3]
**Impact on Phase 26:** [e.g., MCO-layer QGS fix not working; Phase 26 must investigate and automate]
---
## DEV-2: [Title — fill in if QE ID mismatch required pcsclient.py]
**Phase step:** E-3
**What happened:** [e.g., cluster QE ID c0560e9b019a720a6a09149ec518bbe1 did not match cached ID]
**What was done:** [e.g., ran pcsclient.py interactively to generate new PCK material]
**Impact on Phase 26:** [e.g., hypothesis disproved — SGX platform reset required; investigate trigger]
---
## DEV-N: [Add more entries as needed]
**Phase step:**
**What happened:**
**What was done:**
**Impact on Phase 26:**Container name: local-registry (podman). Image: docker.io/library/registry:2.
Storage: ~/local-registry (host bind mount, owned by chbutler).
Config: ~/mirror-registry-config/config.yml. Certs: ~/mirror-registry-certs/.
Auth: htpasswd (~/mirror-registry-config/htpasswd). Username: init.
Password file: ~/.coco-pattern/mirror-registry-password (600, git-ignored).
# List all repos
curl -sk -u "$MREG_USER:$MREG_PASS" https://${MIRROR_REGISTRY}/v2/_catalog
# List tags for a repo
curl -sk -u "$MREG_USER:$MREG_PASS" https://${MIRROR_REGISTRY}/v2/<repo>/tags/listTLS: self-signed cert — use -sk (skip TLS verify) or --cacert ~/mirror-registry-certs/ca.crt
Decommission: podman stop local-registry && rm -rf ~/local-registry/* && podman start local-registry
SELinux note: htpasswd file requires container_file_t label. If recreating, run:
chcon -t container_file_t ~/mirror-registry-config/htpasswd
CA trust for container tools (podman, oc-mirror, skopeo):
mkdir -p ~/.config/containers/certs.d/${MIRROR_REGISTRY}
cp ~/mirror-registry-certs/ca.crt ~/.config/containers/certs.d/${MIRROR_REGISTRY}/ca.crtThis is idempotent and is run automatically in A-0. It enables --dest-tls-verify=true in oc-mirror
and removes the need for --tls-verify=false in podman login.
Apply ONLY if E-2 verification fails and DEV-1 is recorded in DEVIATIONS.md. This is a deviation fallback — not a normal execution step.
# FALLBACK ONLY (if E-2 fails) — record as DEV-1 first
echo "Applying QGS socket port fallback sed (DEV-1 deviation)..." 2>&1 | tee -a "$LOG"
NODE=$(oc get nodes -o name | head -1)
oc debug $NODE -- chroot /host bash -c \
"sed -i 's/tdx_quote_generation_service_socket_port = 4050/tdx_quote_generation_service_socket_port = 0/' \
/etc/kata-containers/kata-tdx/configuration.toml" \
2>&1 | tee -a "$LOG"
echo "Fallback sed applied — record in DEVIATIONS.md as DEV-1" 2>&1 | tee -a "$LOG"After the run completes, Claude SSHes in to analyze the log:
# Claude retrieves log from jump host
ssh chbutler@user-jump.int.apac-tech-lab.net "cat ~/coco-pattern/logs/run-25-*.log"The operator does not need to gzip, commit, or serve the log file. Claude reads it directly via SSH. After analysis, commit DEVIATIONS.md to coco-gsd:
# On jump host — commit DEVIATIONS.md back
cd ~/coco-pattern
git add DEVIATIONS.md
git commit -m "docs(run-25): record run deviations"
git push origin dev/airgap-testing
# Then in coco-gsd
cd ~/coco-gsd
cp ../coco-pattern/DEVIATIONS.md .planning/phases/25-manual-logged-jump-host-deploy-runbook/RUN-25-DEVIATIONS.md
git add .planning/phases/25-manual-logged-jump-host-deploy-runbook/RUN-25-DEVIATIONS.md
git commit -m "docs(25): import run-25 deviations log"
git push origin mainSet these before starting the run:
export LOG=~/coco-pattern/logs/run-25-YYYYMMDD.log
export MREG_USER=init
export MREG_PASS=$(cat ~/.coco-pattern/mirror-registry-password)Auto-set during run:
export KUBECONFIG=<set in C-6 from build dir>
export MIRROR_REGISTRY=MIRROR_REGISTRY_HOST:8443Persistent in ~/.envrc:
export LABCTL_IDRAC_USER=chbutler
export LABCTL_IDRAC_PASSWORD="..."The cluster's global pull-secret (embedded in install-config.yaml by labctl) must include
credentials for MIRROR_REGISTRY_HOST:8443. Without this, cluster nodes cannot pull images redirected
by the IDMS files generated by oc-mirror.
Run once after initial auth setup, or if the registry password is regenerated:
MREG_PASS=$(cat ~/.coco-pattern/mirror-registry-password)
MREG_AUTH=$(echo -n "init:$MREG_PASS" | base64 -w 0)
python3 - <<'EOF'
import json, os
ps_path = os.path.expanduser("~/pull-secret.json")
with open(ps_path) as f:
ps = json.load(f)
import base64
mreg_pass = open(os.path.expanduser("~/.coco-pattern/mirror-registry-password")).read().strip()
mreg_auth = base64.b64encode(f"init:{mreg_pass}".encode()).decode()
ps["auths"]["${MIRROR_REGISTRY}"] = {
"auth": mreg_auth,
"username": "init",
"password": mreg_pass
}
with open(ps_path, "w") as f:
json.dump(ps, f, indent=2)
print("Done. Registries in pull-secret.json:")
for k in ps["auths"]:
print(f" {k}")
EOFWhen to re-run: Only if
~/.coco-pattern/mirror-registry-passwordchanges (password regeneration). The htpasswd auth survivesrm -rf ~/local-registry/*(A-4) — only image data is wiped, not auth config.