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 (+ pcsclient time if QE ID mismatch) |
| 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,
podmanandopensslinstalled, andpython3-passliborhttpd-toolsforhtpasswd.
# ── 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"
# 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.
This step is ALWAYS required — even when QE IDs match and cached PCK cert files exist.
pcsclient.py cachedoes two things: (1) provisions the PCK certificate secret so QGS can generate quotes, and (2) populates the local cache with QeIdentity and TcbInfo thatcollect-dcap-collateral.shreads in E-4. Skipping this step leavesqeidentityempty inplatform_collaterals.json, causing KBS to fail attestation with"collateral JSON error: invalid type: string "", expected struct QeIdentity".This step must be run interactively —
pcsclient.pyprompts for the Intel PCS API key viagetpassand cannot be automated via SSH pipe (seeUPSTREAM-ISSUES-INTEL-DCAP.mdIssue 3).
echo "=== E-3: PCK Registration (INTERACTIVE — run in tmux) ===" 2>&1 | tee -a "$LOG"
cd ~/pck-registration
# Step 1: Generate platform_list.json from cluster platform-data secrets
echo "Generating platform_list.json..." 2>&1 | tee -a "$LOG"
oc get secrets -o json -n intel-dcap-operator-system -l 'type=platform-data' \
| jq '[.items[] | .data | map_values(@base64d)]' > platform_list.json
echo "platform_list.json: $(wc -l < platform_list.json) lines" 2>&1 | tee -a "$LOG"
# Step 2: Compare QE IDs — cache reuse check (informational only)
CLUSTER_QE_ID=$(oc get secrets -n intel-dcap-operator-system -l type=platform-data \
--no-headers -o custom-columns=NAME:.metadata.name | head -1)
CACHED_QE_ID=$(ls cache/*_0000 2>/dev/null | head -1 | xargs basename 2>/dev/null | sed 's/_0000//')
echo "Cluster QE ID: $CLUSTER_QE_ID" 2>&1 | tee -a "$LOG"
echo "Cached QE ID: $CACHED_QE_ID" 2>&1 | tee -a "$LOG"
if [ "$CLUSTER_QE_ID" = "$CACHED_QE_ID" ] && [ -n "$CACHED_QE_ID" ]; then
echo "QE ID MATCH — PCK cert cache reusable. Still must run pcsclient.py cache for QeIdentity." \
2>&1 | tee -a "$LOG"
else
echo "QE ID MISMATCH — record as DEV-2 in DEVIATIONS.md" 2>&1 | tee -a "$LOG"
fi
# Step 3: Run pcsclient.py cache INTERACTIVELY
# Will prompt: "Please input ApiKey for Intel PCS:" — paste your key.
# Populates cache/ with PCK certs AND QeIdentity/TcbInfo for E-4.
echo "STOP: Run the following command interactively in this tmux window:" 2>&1 | tee -a "$LOG"
echo ""
echo " python3 ~/confidential-computing.tee.dcap/tools/PcsClientTool/pcsclient.py cache \\"
echo " -i platform_list.json \\"
echo " -e 8760 \\"
echo " -t early"
echo ""
echo "Enter API key when prompted, then press Enter."
echo "When complete, continue to Step 4 below."
# Step 4: Apply PCK cert secret to cluster and restart QGS
# (Run after pcsclient.py cache completes)
for f in cache/*_0000; do
qe_id=$(basename "$f" _0000)
echo "Applying PCK cert for QE ID: $qe_id" 2>&1 | tee -a "$LOG"
oc create secret generic "${qe_id}-pck" --from-file=certificate="$f" \
-n intel-dcap-operator-system --dry-run=client -o yaml | oc apply -f - \
2>&1 | tee -a "$LOG"
done
oc delete pod -n intel-dcap-operator-system -l app=intel-tdx-qgs \
2>&1 | tee -a "$LOG"
echo "E-3: PCK registration complete at $(date)" 2>&1 | tee -a "$LOG"Must run AFTER E-3 —
collect-dcap-collateral.shcallspcsclient.py fetchwhich reads from the local cache populated bypcsclient.py cachein E-3. Running E-4 before E-3 producesplatform_collaterals.jsonwithqeidentity: ""— KBS will fail attestation.
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', '')
print('qeidentity length:', len(qi), '— PASS' if len(qi) > 100 else '— FAIL: re-run E-3 first')
" 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.