From ad52f3a3b62d56d36ce87d003b802926a62ed403 Mon Sep 17 00:00:00 2001 From: Pablo Fontanilla Date: Wed, 15 Jul 2026 13:57:48 +0200 Subject: [PATCH 1/5] Add /prepare skill for installation config generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two helper scripts + Claude skill that automate the manual process of turning "TNF agent cluster on latest 4.21 nightly on ARM" into correctly written dev-scripts config files. - helpers/resolve-release-image.sh: resolves version specs (4.21-nightly, 4.20, 4.22-ec) to concrete pullspecs via release controller or quay.io tag API, with optional digest pinning and access validation - helpers/prepare-config.sh: generates config from example templates with constraint matrix enforcement (aarch64 IPv6 block, -multi tag block), CI_TOKEN/OPENSHIFT_CI handling, Metal3 overrides, and self-check against the Ansible role's expected patterns - hack/test-prepare-config.sh: offline test matrix (238 assertions) covering all topology/method/arch/stack combinations plus constraint and inventory fork handling - .claude/commands/prepare.md: thin orchestrator skill that chains resolve → config → doctor → print deploy command (never deploys) Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/commands/prepare.md | 150 ++++++++ .claude/commands/prepare/README.md | 72 ++++ CLAUDE.md | 5 + hack/test-prepare-config.sh | 311 +++++++++++++++ helpers/README.md | 79 ++++ helpers/prepare-config.sh | 396 +++++++++++++++++++ helpers/resolve-release-image.sh | 598 +++++++++++++++++++++++++++++ 7 files changed, 1611 insertions(+) create mode 100644 .claude/commands/prepare.md create mode 100644 .claude/commands/prepare/README.md create mode 100755 hack/test-prepare-config.sh create mode 100755 helpers/prepare-config.sh create mode 100755 helpers/resolve-release-image.sh diff --git a/.claude/commands/prepare.md b/.claude/commands/prepare.md new file mode 100644 index 00000000..dc4a8b50 --- /dev/null +++ b/.claude/commands/prepare.md @@ -0,0 +1,150 @@ +--- +description: Prepare a dev-scripts cluster config with a resolved OpenShift release image +--- + +You are preparing an OpenShift cluster configuration for deployment via dev-scripts. This skill resolves a release image, generates a config file, and validates the environment — but never deploys. + +## Arguments + +`/prepare [key=value...]` + +**Positional (required):** +- **topology**: `tna` / `arbiter`, `tnf` / `fencing`, or `sno` +- **method**: `ipi` or `agent` +- **medium**: `aws` or `external` +- **version**: free text mapped to a resolver spec (see below) + +**Optional key=value overrides:** +- `ip-stack=v4|v6|v4v6` (default: v4) +- `arch=x86_64|aarch64` (override auto-detection) +- `force=true` (overwrite existing config) +- `ds-repo=URL` (dev-scripts fork URL) +- `ds-branch=BRANCH` (dev-scripts fork branch) + +## Version Mapping + +| User says | Resolver spec | +|-----------|---------------| +| `4.21 nightly` | `4.21-nightly` | +| `latest 4.22 EC/RC` | `4.22-prerelease` | +| `4.22 EC` | `4.22-ec` | +| `4.20` or `4.20 GA` | `4.20` | +| `4.20.5` | `4.20.5` | +| Contains `/` or `@sha256:` | `--pullspec` (explicit) | + +## Non-Interactive Contract + +When all positional arguments are present, **never prompt open-endedly**. If prerequisites are missing, emit one consolidated failure block listing each issue with its fix, then stop: + +``` +Missing prerequisites: +- pull-secret.json: run /setup dev-scripts +- CI Token: run /setup, CI Token section +- inventory.ini: run /setup external (for external medium) +``` + +Interactive prompting is only for humans invoking with missing arguments. + +## Scope + +**Dev-scripts only** (v1). No kcli, assisted, or baremetal-adopt. If the user asks for kcli or assisted, tell them it's out of scope and point to the relevant make targets. + +## CI Token Sourcing + +1. Check existing `config/config_*.sh` files for an active `export CI_TOKEN="..."` line (any topology) +2. If found and not a placeholder (` \ + --arch \ + $([ "" = "agent" ] && echo "--digest") \ + --pull-secret config/pull-secret.json \ + --validate-access \ + $([ -n "" ] && echo "--ci-token ") +``` + +Capture stdout (single-line pullspec). On failure: +- Exit 2: invalid version spec → show valid formats +- Exit 3: no matching release → suggest checking version number +- Exit 5: access denied → point to `/setup` for credentials + +### Step 2: Generate config + +```bash +helpers/prepare-config.sh \ + --topology \ + --method \ + --release-image \ + --ci-token \ + --ip-stack \ + --arch \ + $([ "" = "true" ] && echo "--force") \ + $([ -n "" ] && echo "--ds-repo ") \ + $([ -n "" ] && echo "--ds-branch ") +``` + +On failure: +- Exit 3: constraint violation → explain the specific constraint +- Exit 4: config exists → ask if they want `force=true` (human) or tell agent callers to pass `force=true` +- Exit 5: self-check failed → likely template drift, report verbatim + +### Step 3: Run doctor + +```bash +cd deploy && make doctor - +``` + +Relay FAIL lines verbatim. Doctor is read-only — it won't break anything. + +### Step 4: Print deployment commands (never run them) + +**medium=aws:** +``` +cd deploy && make deploy - +``` +Note: if an instance is already running (`make status`), `make -` reuses it. + +**medium=external:** +``` +ansible-playbook deploy/openshift-clusters/setup.yml \ + -e "topology=" -e "interactive_mode=false" \ + $([ "" = "agent" ] && echo '-e "method=agent"') \ + -i deploy/openshift-clusters/inventory.ini +``` +Note for first-time external hosts: run `ansible-playbook deploy/openshift-clusters/init-host.yml -i deploy/openshift-clusters/inventory.ini` first. + +## After Completion + +Report what was done: +- Resolved image (tag or digest) +- Config file path written +- Doctor result (pass/fail) +- Deployment command to run + +Do **not** offer to run the deployment. The user decides when to deploy. diff --git a/.claude/commands/prepare/README.md b/.claude/commands/prepare/README.md new file mode 100644 index 00000000..0f7cccf1 --- /dev/null +++ b/.claude/commands/prepare/README.md @@ -0,0 +1,72 @@ +# /prepare — Installation Preparation + +Resolves an OpenShift release image, generates a dev-scripts config file, validates the environment, and prints the deployment command — without deploying. + +## Usage + +``` +/prepare [options] +``` + +### Examples + +``` +# TNF fencing cluster, IPI on AWS, latest 4.21 GA +/prepare tnf ipi aws 4.21 + +# Arbiter cluster, agent method on external host, latest nightly +/prepare tna agent external 4.22 nightly + +# SNO with specific version, forced overwrite +/prepare sno ipi aws 4.20.5 force=true + +# Fencing on aarch64 Graviton instance +/prepare tnf ipi aws 4.21 arch=aarch64 + +# Using a dev-scripts fork for testing +/prepare tnf ipi aws 4.21 nightly ds-repo=https://github.com/user/dev-scripts ds-branch=fix/my-change +``` + +### Parameters + +| Parameter | Values | Default | +|-----------|--------|---------| +| topology | `tna`/`arbiter`, `tnf`/`fencing`, `sno` | required | +| method | `ipi`, `agent` | required | +| medium | `aws`, `external` | required | +| version | `4.21`, `4.21 nightly`, `4.22 EC`, etc. | required | +| ip-stack | `v4`, `v6`, `v4v6` | `v4` | +| arch | `x86_64`, `aarch64` | auto-detected | +| force | `true` | `false` | +| ds-repo | fork URL | none | +| ds-branch | fork branch | none | + +## What It Does + +1. **Resolves** the version spec to a concrete release image (nightly from CI registry, or GA/EC/RC from quay.io) +2. **Pins by digest** when method=agent (agent installs require digest-pinned images) +3. **Generates** `config/config_.sh` from the example template with correct vars +4. **Validates** CI token, pull secret auth, and architecture constraints +5. **Runs** `make doctor -` as a final gate +6. **Prints** the deployment command — but never runs it + +## What It Does NOT Do + +- Deploy a cluster (that's `make deploy` or `ansible-playbook`) +- Set up credentials or pull secrets (that's `/setup`) +- Manage kcli, assisted, or baremetal-adopt configs (dev-scripts only in v1) + +## Helper Scripts + +For scripted or CI use, call the helpers directly: + +```bash +# Resolve a release image +helpers/resolve-release-image.sh --version 4.21-nightly --arch x86_64 + +# Generate config +helpers/prepare-config.sh --topology fencing --method ipi \ + --release-image --ci-token +``` + +Run each script with `--help` for full usage. diff --git a/CLAUDE.md b/CLAUDE.md index 3f8c0e12..2ae3cae5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,11 @@ make status # Show status dashboard (instance, cluster VMs, proxy, cl make inventory # Update inventory.ini with current instance IP make doctor # Validate configuration and prerequisites (read-only) make doctor # Validate strictly for a specific cluster type (e.g. fencing-kcli) + +# Installation preparation (resolve image + generate config) +# /prepare tnf ipi aws 4.21 # Claude skill — interactive or agent-driven +helpers/resolve-release-image.sh --version 4.21 # Resolve version → pullspec +helpers/prepare-config.sh --topology fencing --method ipi --release-image --ci-token ``` ### Ansible Deployment Methods diff --git a/hack/test-prepare-config.sh b/hack/test-prepare-config.sh new file mode 100755 index 00000000..73b1cfbc --- /dev/null +++ b/hack/test-prepare-config.sh @@ -0,0 +1,311 @@ +#!/usr/bin/bash +# Offline test matrix for prepare-config.sh and resolve-release-image.sh. +# Runs without network access — validates config transforms against real examples. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +readonly PREPARE="${REPO_ROOT}/helpers/prepare-config.sh" +readonly RESOLVE="${REPO_ROOT}/helpers/resolve-release-image.sh" + +PASS=0 +FAIL=0 +TMPDIR_BASE="" + +readonly COLOR_RED='\033[0;31m' +readonly COLOR_GREEN='\033[0;32m' +readonly COLOR_CLEAR='\033[0m' + +pass() { echo -e "${COLOR_GREEN} PASS${COLOR_CLEAR} $1"; PASS=$((PASS + 1)); } +fail() { echo -e "${COLOR_RED} FAIL${COLOR_CLEAR} $1"; FAIL=$((FAIL + 1)); } + +setup() { + TMPDIR_BASE=$(mktemp -d) + trap 'rm -rf "$TMPDIR_BASE"' EXIT +} + +# --------------------------------------------------------------------------- +# Assert helpers +# --------------------------------------------------------------------------- + +assert_exit() { + local expected="$1" actual="$2" label="$3" + if [[ "$expected" == "$actual" ]]; then + pass "$label (exit $expected)" + else + fail "$label (expected exit $expected, got $actual)" + fi +} + +assert_grep() { + local file="$1" pattern="$2" label="$3" + if grep -qE "$pattern" "$file"; then + pass "$label" + else + fail "$label — pattern not found: $pattern" + fi +} + +assert_not_grep() { + local file="$1" pattern="$2" label="$3" + if ! grep -qE "$pattern" "$file"; then + pass "$label" + else + fail "$label — pattern should not match: $pattern" + fi +} + +# --------------------------------------------------------------------------- +# Run prepare-config.sh and capture result +# --------------------------------------------------------------------------- + +run_prepare() { + local label="$1"; shift + local outfile="${TMPDIR_BASE}/${label}.sh" + local rc=0 + "$PREPARE" --output "$outfile" "$@" >/dev/null 2>&1 || rc=$? + echo "$outfile:$rc" +} + +# --------------------------------------------------------------------------- +# Test: config transform matrix +# --------------------------------------------------------------------------- + +test_transform_matrix() { + echo "=== Config transform matrix ===" + + local topologies=(arbiter fencing sno) + local methods=(ipi agent) + local arches=(x86_64 aarch64) + local stacks=(v4 v6 v4v6) + + # shellcheck disable=SC2034 + local prefix_map_arbiter="TNA" + # shellcheck disable=SC2034 + local prefix_map_fencing="TNF" + # shellcheck disable=SC2034 + local prefix_map_sno="SNO" + + local ci_token="sha256~test_matrix_token_abc123" + + for topo in "${topologies[@]}"; do + local prefix_var="prefix_map_${topo}" + local expected_prefix="${!prefix_var}" + + for method in "${methods[@]}"; do + for arch in "${arches[@]}"; do + local image + if [[ "$arch" == "aarch64" ]]; then + image="quay.io/openshift-release-dev/ocp-release:4.21.0-aarch64" + else + image="quay.io/openshift-release-dev/ocp-release:4.21.0-multi" + fi + + for stack in "${stacks[@]}"; do + local label="${topo}_${method}_${arch}_${stack}" + + # Constraint: aarch64 + non-v4 → exit 3 + if [[ "$arch" == "aarch64" && "$stack" != "v4" ]]; then + local rc=0 + "$PREPARE" --topology "$topo" --method "$method" \ + --release-image "$image" --ci-token "$ci_token" \ + --arch "$arch" --ip-stack "$stack" \ + --output "${TMPDIR_BASE}/${label}.sh" >/dev/null 2>&1 || rc=$? + assert_exit 3 "$rc" "$label (aarch64+${stack} blocked)" + continue + fi + + local result + result=$(run_prepare "$label" \ + --topology "$topo" --method "$method" \ + --release-image "$image" --ci-token "$ci_token" \ + --arch "$arch" --ip-stack "$stack") + local outfile="${result%%:*}" + local rc="${result##*:}" + + assert_exit 0 "$rc" "$label" + [[ "$rc" -eq 0 ]] || continue + + # Scenario prefix matches topology + local ip_suffix + case "$stack" in + v4) ip_suffix="IPV4" ;; + v6) ip_suffix="IPV6" ;; + v4v6) ip_suffix="IPV4V6" ;; + esac + assert_grep "$outfile" "^export AGENT_E2E_TEST_SCENARIO=\"${expected_prefix}_${ip_suffix}\"" \ + "$label scenario=${expected_prefix}_${ip_suffix}" + + # No /dev/null; then + pass "$label sourceable" + else + fail "$label sourceable" + fi + + # aarch64: Metal3 images uncommented + if [[ "$arch" == "aarch64" ]]; then + assert_grep "$outfile" '^[[:space:]]*export IRONIC_IMAGE=' "$label IRONIC_IMAGE active" + assert_grep "$outfile" '^[[:space:]]*export VBMC_IMAGE=' "$label VBMC_IMAGE active" + assert_grep "$outfile" '^[[:space:]]*export SUSHY_TOOLS_IMAGE=' "$label SUSHY_TOOLS active" + fi + + # OPENSHIFT_CI not active + assert_not_grep "$outfile" '^export OPENSHIFT_CI=' "$label OPENSHIFT_CI commented" + + # CI_TOKEN active + assert_grep "$outfile" '^export CI_TOKEN="sha256~' "$label CI_TOKEN active" + + # Release image set + assert_grep "$outfile" "^export OPENSHIFT_RELEASE_IMAGE=${image}" "$label release image" + + # IP_STACK set + assert_grep "$outfile" "^export IP_STACK=\"${stack}\"" "$label IP_STACK" + done + done + done + done +} + +# --------------------------------------------------------------------------- +# Test: constraint blocks and usage errors +# --------------------------------------------------------------------------- + +test_constraints() { + echo "" + echo "=== Constraint and usage tests ===" + + local ci="sha256~constraint_test" + local img="quay.io/openshift-release-dev/ocp-release:4.21.0-multi" + + # aarch64 + -multi image → exit 3 + local rc=0 + "$PREPARE" --topology fencing --method ipi --release-image "$img" \ + --ci-token "$ci" --arch aarch64 \ + --output "${TMPDIR_BASE}/blocked_multi.sh" >/dev/null 2>&1 || rc=$? + assert_exit 3 "$rc" "aarch64 + -multi blocked" + + # Missing --ci-token → exit 2 + rc=0 + "$PREPARE" --topology fencing --method ipi --release-image "$img" \ + --output "${TMPDIR_BASE}/no_token.sh" >/dev/null 2>&1 || rc=$? + assert_exit 2 "$rc" "missing --ci-token" + + # Existing output without --force → exit 4 + touch "${TMPDIR_BASE}/exists.sh" + rc=0 + "$PREPARE" --topology fencing --method ipi --release-image "$img" \ + --ci-token "$ci" --output "${TMPDIR_BASE}/exists.sh" >/dev/null 2>&1 || rc=$? + assert_exit 4 "$rc" "existing output without --force" + + # --force overwrites and creates backup + rc=0 + "$PREPARE" --topology fencing --method ipi --release-image "$img" \ + --ci-token "$ci" --output "${TMPDIR_BASE}/exists.sh" --force >/dev/null 2>&1 || rc=$? + assert_exit 0 "$rc" "--force overwrites" + if [[ -f "${TMPDIR_BASE}/exists.sh.bak" ]]; then + pass "--force creates .bak" + else + fail "--force creates .bak" + fi + + # --help exits 0 (prepare-config) + rc=0 + "$PREPARE" --help >/dev/null 2>&1 || rc=$? + assert_exit 0 "$rc" "prepare-config --help" + + # --help exits 0 (resolve-release-image) + rc=0 + "$RESOLVE" --help >/dev/null 2>&1 || rc=$? + assert_exit 0 "$rc" "resolve-release-image --help" + + # Invalid version spec → exit 2 + rc=0 + "$RESOLVE" --version garbage >/dev/null 2>&1 || rc=$? + assert_exit 2 "$rc" "resolver rejects garbage spec" + + # --ds-branch without --ds-repo → exit 2 + rc=0 + "$PREPARE" --topology fencing --method ipi --release-image "$img" \ + --ci-token "$ci" --ds-branch mybranch \ + --output "${TMPDIR_BASE}/ds_branch_only.sh" >/dev/null 2>&1 || rc=$? + assert_exit 2 "$rc" "--ds-branch without --ds-repo" +} + +# --------------------------------------------------------------------------- +# Test: inventory.ini fork handling +# --------------------------------------------------------------------------- + +test_inventory() { + echo "" + echo "=== Inventory fork handling ===" + + local ci="sha256~inv_test" + local img="quay.io/openshift-release-dev/ocp-release:4.21.0-multi" + local inv="${TMPDIR_BASE}/inventory.ini" + + # Create test inventory + cat > "$inv" <<'INV' +[metal_machine] +user@host ansible_ssh_extra_args='-o ServerAliveInterval=30' + +[metal_machine:vars] +ansible_become_password="" +INV + + # --ds-repo + --ds-branch → upserts both + "$PREPARE" --topology fencing --method ipi --release-image "$img" \ + --ci-token "$ci" --inventory "$inv" \ + --ds-repo https://github.com/user/dev-scripts --ds-branch fix/my-change \ + --output "${TMPDIR_BASE}/inv1.sh" >/dev/null 2>&1 + assert_grep "$inv" '^dev_scripts_src_repo=https://github.com/user/dev-scripts' "upsert repo" + assert_grep "$inv" '^dev_scripts_branch=fix/my-change' "upsert branch" + + # --ds-repo only → upserts repo + default branch=master + "$PREPARE" --topology fencing --method ipi --release-image "$img" \ + --ci-token "$ci" --inventory "$inv" \ + --ds-repo https://github.com/other/dev-scripts \ + --output "${TMPDIR_BASE}/inv2.sh" --force >/dev/null 2>&1 + assert_grep "$inv" '^dev_scripts_src_repo=https://github.com/other/dev-scripts' "repo-only upsert" + assert_grep "$inv" '^dev_scripts_branch=master' "repo-only defaults branch=master" + + # Neither → purges existing lines + "$PREPARE" --topology fencing --method ipi --release-image "$img" \ + --ci-token "$ci" --inventory "$inv" \ + --output "${TMPDIR_BASE}/inv3.sh" --force >/dev/null 2>&1 + assert_not_grep "$inv" '^dev_scripts_src_repo=' "purged repo" + assert_not_grep "$inv" '^dev_scripts_branch=' "purged branch" + + # Missing inventory.ini + --ds-repo → error + local rc=0 + "$PREPARE" --topology fencing --method ipi --release-image "$img" \ + --ci-token "$ci" --inventory "${TMPDIR_BASE}/nonexistent.ini" \ + --ds-repo https://github.com/user/dev-scripts \ + --output "${TMPDIR_BASE}/inv4.sh" >/dev/null 2>&1 || rc=$? + if [[ "$rc" -ne 0 ]]; then + pass "missing inventory + --ds-repo errors" + else + fail "missing inventory + --ds-repo should error" + fi +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +setup +test_transform_matrix +test_constraints +test_inventory + +echo "" +echo "==============================" +echo -e "Results: ${COLOR_GREEN}${PASS} passed${COLOR_CLEAR}, ${COLOR_RED}${FAIL} failed${COLOR_CLEAR}" +echo "==============================" + +[[ "$FAIL" -eq 0 ]] diff --git a/helpers/README.md b/helpers/README.md index 63e3476c..b4ee6b85 100644 --- a/helpers/README.md +++ b/helpers/README.md @@ -6,6 +6,8 @@ Utilities for OpenShift cluster operations including package management and clus This directory contains multiple helper tools for various OpenShift cluster operations: +- **Release Image Resolver**: Resolves an OCP version spec (e.g. `4.21-nightly`, `4.20`) to a concrete pullspec via the release controller or quay.io +- **Config Preparer**: Generates a dev-scripts config file from the example template with architecture-aware defaults and constraint enforcement - **Resource Agent Patching**: Scripts and playbooks (recommended usage) for installing RPM packages on cluster nodes using rpm-ostree's override functionality - **Fencing Validation**: Tools for validating two-node cluster fencing configuration and health - **arm64 Metal3 Image Builder**: Builds arm64 variants of Metal3 images for aarch64 hypervisors (Graviton) @@ -28,6 +30,83 @@ This directory contains multiple helper tools for various OpenShift cluster oper ## Available Tools +### Release Image Resolver + +Resolves an OCP version spec to a concrete release image pullspec. Supports nightly builds (from the CI release controller) and GA/EC/RC releases (from quay.io). + +**Usage:** + +```bash +# Latest 4.21 GA for x86_64 +./resolve-release-image.sh --version 4.21 + +# Latest 4.22 nightly on aarch64 +./resolve-release-image.sh --version 4.22-nightly --arch aarch64 + +# Pin by digest (for agent installs) +./resolve-release-image.sh --version 4.21 --digest --pull-secret ../config/pull-secret.json + +# Validate access to a CI nightly +./resolve-release-image.sh --version 4.21-nightly --validate-access --ci-token "$CI_TOKEN" +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--version SPEC` | `X.Y`, `X.Y-KIND` (nightly/ec/rc/prerelease/ga), or `X.Y.Z` | +| `--pullspec IMAGE` | Explicit image passthrough | +| `--arch ARCH` | `x86_64` or `aarch64` (default: `x86_64`) | +| `--digest` | Output `repo@sha256:...` instead of tagged ref | +| `--pull-secret PATH` | Pull secret JSON (default: `config/pull-secret.json`) | +| `--validate-access` | Verify the resolved image is pullable | +| `--ci-token TOKEN` | Bearer token for `registry.ci.openshift.org` | + +**Exit codes:** 0 success, 2 usage error, 3 resolution failed, 4 digest failed, 5 access denied. + +**Prerequisites:** `curl`, `jq` or `python3`. Optional: `oc` (preferred for digest resolution). + +### Config Preparer + +Generates a dev-scripts config file from the example template, enforcing the architecture constraint matrix and validating the result against the Ansible role's expected patterns. + +**Usage:** + +```bash +# Fencing IPI with a resolved image +./prepare-config.sh --topology fencing --method ipi \ + --release-image quay.io/.../ocp-release:4.21.3-multi \ + --ci-token "$CI_TOKEN" + +# SNO agent on aarch64 with custom Metal3 tag +./prepare-config.sh --topology sno --method agent \ + --release-image quay.io/.../ocp-release@sha256:abc123 \ + --ci-token "$CI_TOKEN" --arch aarch64 --metal3-tag 2026-07 + +# With dev-scripts fork override +./prepare-config.sh --topology tnf --method ipi \ + --release-image quay.io/.../ocp-release:4.21.3-multi \ + --ci-token "$CI_TOKEN" \ + --ds-repo https://github.com/user/dev-scripts --ds-branch fix/my-change +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--topology TOPO` | `arbiter`/`tna`, `fencing`/`tnf`, or `sno` | +| `--method METHOD` | `ipi` or `agent` | +| `--release-image IMAGE` | Resolved pullspec | +| `--ci-token TOKEN` | CI bearer token | +| `--ip-stack STACK` | `v4`, `v6`, or `v4v6` (default: `v4`) | +| `--arch ARCH` | `x86_64` or `aarch64` (default: `x86_64`) | +| `--metal3-tag TAG` | aarch64 Metal3 image tag (default: `2026-06`) | +| `--ds-repo URL` | Dev-scripts fork URL | +| `--ds-branch BRANCH` | Dev-scripts fork branch | +| `--force` | Overwrite existing config (backs up to `.bak`) | + +**Exit codes:** 0 success, 2 usage error, 3 constraint violation, 4 output exists, 5 self-check failed. + ### Force New Cluster Automates etcd cluster recovery by configuring CIB (Cluster Information Base) attributes to force a new etcd cluster formation. This is useful when etcd quorum is lost and manual intervention is required to restore cluster functionality. diff --git a/helpers/prepare-config.sh b/helpers/prepare-config.sh new file mode 100755 index 00000000..6b1d532e --- /dev/null +++ b/helpers/prepare-config.sh @@ -0,0 +1,396 @@ +#!/usr/bin/bash +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +readonly REPO_ROOT + +readonly DEFAULT_METAL3_TAG="2026-06" + +readonly COLOR_RED='\033[0;31m' +readonly COLOR_YELLOW='\033[0;33m' +readonly COLOR_GREEN='\033[0;32m' +readonly COLOR_BLUE='\033[0;34m' +readonly COLOR_CLEAR='\033[0m' + +msg_err() { echo -e "${COLOR_RED}ERROR: ${1}${COLOR_CLEAR}" >&2; } +msg_warn() { echo -e "${COLOR_YELLOW}WARN: ${1}${COLOR_CLEAR}" >&2; } +msg_ok() { echo -e "${COLOR_GREEN}OK: ${1}${COLOR_CLEAR}" >&2; } +msg_info() { echo -e "${COLOR_BLUE}INFO: ${1}${COLOR_CLEAR}" >&2; } + +valreq() { [[ -n "${2-}" && "$2" != -* ]]; } + +# --------------------------------------------------------------------------- +# Config transform +# --------------------------------------------------------------------------- + +transform_config() { + local tmpfile="$1" release_image="$2" ip_stack="$3" ci_token="$4" + local scenario="$5" arch="$6" metal3_tag="$7" + + sed -i "s|^export OPENSHIFT_RELEASE_IMAGE=.*|export OPENSHIFT_RELEASE_IMAGE=${release_image}|" "$tmpfile" + + sed -i "s|^export IP_STACK=\".*\"|export IP_STACK=\"${ip_stack}\"|" "$tmpfile" + + if grep -q '^export AGENT_E2E_TEST_SCENARIO=' "$tmpfile"; then + sed -i "s|^export AGENT_E2E_TEST_SCENARIO=.*|export AGENT_E2E_TEST_SCENARIO=\"${scenario}\"|" "$tmpfile" + elif grep -q '^# *export AGENT_E2E_TEST_SCENARIO=' "$tmpfile"; then + sed -i "s|^# *export AGENT_E2E_TEST_SCENARIO=.*|export AGENT_E2E_TEST_SCENARIO=\"${scenario}\"|" "$tmpfile" + fi + + if grep -q '^export CI_TOKEN=' "$tmpfile"; then + sed -i "s|^export CI_TOKEN=.*|export CI_TOKEN=\"${ci_token}\"|" "$tmpfile" + else + sed -i "/^export OPENSHIFT_RELEASE_IMAGE=/a export CI_TOKEN=\"${ci_token}\"" "$tmpfile" + fi + + sed -i 's|^export OPENSHIFT_CI="true"|# export OPENSHIFT_CI="true"|' "$tmpfile" + + if [[ "$arch" == "aarch64" ]]; then + # shellcheck disable=SC2016 + if grep -q '^# *if \[ "\$(uname -m)" = "aarch64" \]' "$tmpfile"; then + # shellcheck disable=SC2016 + sed -i '/^# *if \[ "\$(uname -m)" = "aarch64" \]/,/^# *fi/{s/^# //}' "$tmpfile" + else + cat >> "$tmpfile" <&2 <.sh) + --force Overwrite existing output (backs up to .bak) + -h, --help Show this help + +Constraints: + aarch64 + ip-stack != v4 Blocked (IPv6 unsupported on ARM) + aarch64 + -multi image Blocked (explicit aarch64 payload required) + --ds-branch without --ds-repo Error + +Exit codes: + 0 Config written (path on stdout) + 2 Usage error + 3 Constraint violation + 4 Output exists without --force + 5 Self-check failed +EOF + exit "${1:-0}" +} + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + +TOPOLOGY="" +METHOD="" +RELEASE_IMAGE="" +CI_TOKEN_VALUE="" +IP_STACK="v4" +ARCH="x86_64" +METAL3_TAG="${DEFAULT_METAL3_TAG}" +DS_REPO="" +DS_BRANCH="" +INVENTORY="" +OUTPUT="" +FORCE="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --topology) + valreq "$1" "${2-}" || { msg_err "--topology requires a value"; exit 2; } + TOPOLOGY="$2"; shift 2 ;; + --method) + valreq "$1" "${2-}" || { msg_err "--method requires a value"; exit 2; } + METHOD="$2"; shift 2 ;; + --release-image) + valreq "$1" "${2-}" || { msg_err "--release-image requires a value"; exit 2; } + RELEASE_IMAGE="$2"; shift 2 ;; + --ci-token) + valreq "$1" "${2-}" || { msg_err "--ci-token requires a value"; exit 2; } + CI_TOKEN_VALUE="$2"; shift 2 ;; + --ip-stack) + valreq "$1" "${2-}" || { msg_err "--ip-stack requires a value"; exit 2; } + IP_STACK="$2"; shift 2 ;; + --arch) + valreq "$1" "${2-}" || { msg_err "--arch requires a value"; exit 2; } + ARCH="$2"; shift 2 ;; + --metal3-tag) + valreq "$1" "${2-}" || { msg_err "--metal3-tag requires a value"; exit 2; } + METAL3_TAG="$2"; shift 2 ;; + --ds-repo) + valreq "$1" "${2-}" || { msg_err "--ds-repo requires a value"; exit 2; } + DS_REPO="$2"; shift 2 ;; + --ds-branch) + valreq "$1" "${2-}" || { msg_err "--ds-branch requires a value"; exit 2; } + DS_BRANCH="$2"; shift 2 ;; + --inventory) + valreq "$1" "${2-}" || { msg_err "--inventory requires a value"; exit 2; } + INVENTORY="$2"; shift 2 ;; + --output) + valreq "$1" "${2-}" || { msg_err "--output requires a value"; exit 2; } + OUTPUT="$2"; shift 2 ;; + --force) + FORCE="true"; shift ;; + -h|--help) + usage 0 ;; + *) + msg_err "Unknown option: $1"; usage 2 ;; + esac +done + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +case "$TOPOLOGY" in + tna) TOPOLOGY="arbiter" ;; + tnf) TOPOLOGY="fencing" ;; + arbiter|fencing|sno) ;; + "") msg_err "--topology is required"; exit 2 ;; + *) msg_err "Invalid topology: ${TOPOLOGY} (expected: arbiter|tna, fencing|tnf, sno)"; exit 2 ;; +esac + +[[ -n "$METHOD" ]] || { msg_err "--method is required"; exit 2; } +[[ -n "$RELEASE_IMAGE" ]] || { msg_err "--release-image is required"; exit 2; } +[[ -n "$CI_TOKEN_VALUE" ]] || { msg_err "--ci-token is required"; exit 2; } + +case "$METHOD" in + ipi|agent) ;; + *) msg_err "Invalid method: ${METHOD} (expected: ipi, agent)"; exit 2 ;; +esac +case "$IP_STACK" in + v4|v6|v4v6) ;; + *) msg_err "Invalid ip-stack: ${IP_STACK} (expected: v4, v6, v4v6)"; exit 2 ;; +esac +case "$ARCH" in + x86_64|aarch64) ;; + *) msg_err "Invalid arch: ${ARCH} (expected: x86_64, aarch64)"; exit 2 ;; +esac + +if [[ "$CI_TOKEN_VALUE" == *"/dev/null || true) + cp "$OUTPUT" "${OUTPUT}.bak" + msg_info "Backed up existing config to ${OUTPUT}.bak" + [[ -z "${old_image:-}" ]] || msg_info "Previous release image: ${old_image}" +fi + +# --------------------------------------------------------------------------- +# Generate config +# --------------------------------------------------------------------------- + +TMPFILE=$(mktemp) +trap 'rm -f "$TMPFILE"' EXIT + +cp "$TEMPLATE" "$TMPFILE" +msg_info "Generating ${TOPOLOGY} config (${METHOD}, ${ARCH}, IP_STACK=${IP_STACK})" + +transform_config "$TMPFILE" "$RELEASE_IMAGE" "$IP_STACK" "$CI_TOKEN_VALUE" \ + "$SCENARIO" "$ARCH" "$METAL3_TAG" + +if ! self_check "$TMPFILE" "$TOPOLOGY"; then + msg_err "Self-check failed; config not written" + msg_err " This likely indicates the example template has drifted from expected format" + exit 5 +fi + +cp "$TMPFILE" "$OUTPUT" +msg_ok "Config written: ${OUTPUT}" +msg_info "Deploy targets auto-sync via make sync-config" + +handle_inventory "$INVENTORY" "$DS_REPO" "$DS_BRANCH" || exit $? + +echo "$OUTPUT" diff --git a/helpers/resolve-release-image.sh b/helpers/resolve-release-image.sh new file mode 100755 index 00000000..981fc91f --- /dev/null +++ b/helpers/resolve-release-image.sh @@ -0,0 +1,598 @@ +#!/usr/bin/bash +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +readonly REPO_ROOT + +readonly QUAY_REPO="openshift-release-dev/ocp-release" +readonly QUAY_TAG_API="https://quay.io/api/v1/repository/${QUAY_REPO}/tag/" +readonly RC_AMD64="https://amd64.ocp.releases.ci.openshift.org/api/v1/releasestream" +readonly RC_ARM64="https://arm64.ocp.releases.ci.openshift.org/api/v1/releasestream" +readonly CI_REGISTRY="registry.ci.openshift.org" + +readonly COLOR_RED='\033[0;31m' +readonly COLOR_YELLOW='\033[0;33m' +readonly COLOR_GREEN='\033[0;32m' +readonly COLOR_BLUE='\033[0;34m' +readonly COLOR_CLEAR='\033[0m' + +msg_err() { echo -e "${COLOR_RED}ERROR: ${1}${COLOR_CLEAR}" >&2; } +msg_warn() { echo -e "${COLOR_YELLOW}WARN: ${1}${COLOR_CLEAR}" >&2; } +msg_ok() { echo -e "${COLOR_GREEN}OK: ${1}${COLOR_CLEAR}" >&2; } +msg_info() { echo -e "${COLOR_BLUE}INFO: ${1}${COLOR_CLEAR}" >&2; } + +QUIET="false" +info() { [[ "$QUIET" == "true" ]] || msg_info "$1"; } +ok() { [[ "$QUIET" == "true" ]] || msg_ok "$1"; } + +valreq() { [[ -n "${2-}" && "$2" != -* ]]; } + +fetch_url() { curl -fsS --connect-timeout 30 "$@"; } + +require_json_tool() { + command -v jq >/dev/null 2>&1 || command -v python3 >/dev/null 2>&1 || { + msg_err "jq or python3 required to parse JSON API responses" + exit 2 + } +} + +# --------------------------------------------------------------------------- +# Image reference helpers +# --------------------------------------------------------------------------- + +IMG_REGISTRY="" IMG_REPO="" IMG_TAG="" IMG_DIGEST="" + +parse_image_ref() { + local ref="$1" + IMG_REGISTRY="" IMG_REPO="" IMG_TAG="" IMG_DIGEST="" + + if [[ "$ref" == *"@"* ]]; then + IMG_DIGEST="${ref#*@}" + ref="${ref%%@*}" + fi + if [[ "$ref" == *":"* ]]; then + local after="${ref##*:}" + if [[ "$after" != *"/"* ]]; then + IMG_TAG="$after" + ref="${ref%:*}" + fi + fi + local first="${ref%%/*}" + if [[ "$first" == *"."* || "$first" == *":"* ]]; then + IMG_REGISTRY="$first" + IMG_REPO="${ref#*/}" + else + IMG_REGISTRY="docker.io" + IMG_REPO="$ref" + fi +} + +image_repo_sans_tag() { + local r="${1%%@*}" + if [[ "$r" == *":"* ]]; then + local after="${r##*:}" + [[ "$after" != *"/"* ]] && r="${r%:*}" + fi + echo "$r" +} + +# --------------------------------------------------------------------------- +# Registry auth +# --------------------------------------------------------------------------- + +extract_registry_auth() { + local registry="$1" pull_secret="$2" + [[ -f "$pull_secret" ]] || return 1 + if command -v jq >/dev/null 2>&1; then + jq -r ".auths[\"${registry}\"].auth // empty" "$pull_secret" 2>/dev/null + elif command -v python3 >/dev/null 2>&1; then + python3 -c " +import json, sys +with open(sys.argv[1]) as f: + d = json.load(f) +a = d.get('auths',{}).get(sys.argv[2],{}).get('auth','') +if a: print(a, end='') +" "$pull_secret" "$registry" 2>/dev/null + fi +} + +get_bearer_token() { + local registry="$1" repo="$2" pull_secret="${3:-}" ci_token="${4:-}" + + if [[ "$registry" == "$CI_REGISTRY" ]]; then + if [[ -n "$ci_token" ]]; then + echo "$ci_token" + return 0 + fi + local auth_b64 + auth_b64="$(extract_registry_auth "$registry" "$pull_secret")" || true + if [[ -n "$auth_b64" ]]; then + local decoded + decoded=$(echo "$auth_b64" | base64 -d 2>/dev/null) || true + [[ -n "$decoded" ]] && { echo "${decoded#*:}"; return 0; } + fi + return 1 + fi + + local headers realm service + headers=$(curl -sI --connect-timeout 10 "https://${registry}/v2/" 2>/dev/null) || true + realm=$(echo "$headers" | grep -ioP 'realm="\K[^"]+' | head -1) || true + service=$(echo "$headers" | grep -ioP 'service="\K[^"]+' | head -1) || true + [[ -n "$realm" ]] || return 1 + + local auth_args=() + local auth_b64 + auth_b64="$(extract_registry_auth "$registry" "$pull_secret")" || true + [[ -n "$auth_b64" ]] && auth_args=(-H "Authorization: Basic ${auth_b64}") + + local token_json + token_json=$(curl -fsS --connect-timeout 10 "${auth_args[@]}" \ + "${realm}?service=${service}&scope=repository:${repo}:pull" 2>/dev/null) || return 1 + + if command -v jq >/dev/null 2>&1; then + jq -r '.token // empty' <<< "$token_json" + else + python3 -c "import json,sys;t=json.load(sys.stdin).get('token','');print(t,end='') if t else None" <<< "$token_json" + fi +} + +registry_head_digest() { + local registry="$1" repo="$2" reference="$3" bearer_token="${4:-}" + + local auth_args=() + [[ -n "$bearer_token" ]] && auth_args=(-H "Authorization: Bearer ${bearer_token}") + + local tmpfile + tmpfile=$(mktemp) + local http_code + http_code=$(curl -sI --connect-timeout 30 \ + -H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json" \ + "${auth_args[@]}" \ + -o /dev/null -D "$tmpfile" -w "%{http_code}" \ + "https://${registry}/v2/${repo}/manifests/${reference}" 2>/dev/null) || http_code="000" + + local digest="" + [[ "$http_code" == "200" ]] && \ + digest=$(grep -i '^docker-content-digest:' "$tmpfile" | awk '{print $2}' | tr -d '\r\n') + rm -f "$tmpfile" + + case "$http_code" in + 200) echo "$digest"; return 0 ;; + 401|403) return 1 ;; + *) return 1 ;; + esac +} + +access_hint() { + local registry="$1" + if [[ "$registry" == "$CI_REGISTRY" ]]; then + msg_err " Refresh CI token: https://console-openshift-console.apps.ci.l2s4.p1.openshiftapps.com" + msg_err " Then run /setup or pass --ci-token" + else + msg_err " Check pull-secret.json has auth for ${registry}" + msg_err " Update credentials via /setup" + fi +} + +# --------------------------------------------------------------------------- +# Version spec parsing +# --------------------------------------------------------------------------- + +MAJOR_MINOR="" KIND="" EXPLICIT_VERSION="" + +parse_version_spec() { + local spec="$1" + MAJOR_MINOR="" KIND="" EXPLICIT_VERSION="" + + if [[ "$spec" =~ ^([0-9]+\.[0-9]+)\.[0-9]+$ ]]; then + MAJOR_MINOR="${BASH_REMATCH[1]}" + EXPLICIT_VERSION="$spec" + KIND="explicit" + return 0 + fi + if [[ "$spec" =~ ^([0-9]+\.[0-9]+)-(.+)$ ]]; then + MAJOR_MINOR="${BASH_REMATCH[1]}" + KIND="${BASH_REMATCH[2]}" + case "$KIND" in + nightly|ec|rc|prerelease|ga) return 0 ;; + *) msg_err "Invalid kind '${KIND}' in '${spec}' (expected: nightly, ec, rc, prerelease, ga)"; return 1 ;; + esac + fi + if [[ "$spec" =~ ^[0-9]+\.[0-9]+$ ]]; then + MAJOR_MINOR="$spec" + KIND="ga" + return 0 + fi + + msg_err "Invalid version spec: '${spec}'" + msg_err " Expected: X.Y, X.Y-KIND (nightly|ec|rc|prerelease|ga), or X.Y.Z" + return 1 +} + +# --------------------------------------------------------------------------- +# Resolution: nightly (release controller) +# --------------------------------------------------------------------------- + +resolve_nightly() { + local major_minor="$1" arch="$2" + local stream rc_base + + if [[ "$arch" == "x86_64" ]]; then + stream="${major_minor}.0-0.nightly" + rc_base="$RC_AMD64" + else + stream="${major_minor}.0-0.nightly-arm64" + rc_base="$RC_ARM64" + fi + + info "Querying release controller: ${stream}" + + local json + json="$(fetch_url "${rc_base}/${stream}/tags")" || { + msg_err "Failed to reach release controller for ${stream}" + return 1 + } + + local pullspec + if command -v jq >/dev/null 2>&1; then + pullspec=$(jq -r '(.tags // []) | map(select(.phase == "Accepted")) | .[0].pullSpec // empty' <<< "$json") + else + pullspec=$(python3 -c ' +import json, sys +data = json.load(sys.stdin) +for t in data.get("tags") or []: + if t.get("phase") == "Accepted" and t.get("pullSpec"): + print(t["pullSpec"], end="") + break +' <<< "$json") + fi + + [[ -n "$pullspec" ]] || { msg_err "No Accepted nightly found for ${stream}"; return 1; } + + info "Found: ${pullspec}" + echo "$pullspec" +} + +# --------------------------------------------------------------------------- +# Resolution: stable (quay.io ga/ec/rc/prerelease) +# --------------------------------------------------------------------------- + +fetch_quay_tags() { + local filter="$1" + local page=1 all_tags="" + + while true; do + local response + response="$(fetch_url "${QUAY_TAG_API}?page=${page}&limit=100&filter_tag_name=like:${filter}")" || { + msg_err "Failed to fetch tags from quay.io (page ${page})" + return 1 + } + + local page_tags has_more + if command -v jq >/dev/null 2>&1; then + page_tags=$(jq -r '(.tags // [])[] | .name' <<< "$response") + has_more=$(jq -r '.has_additional // false' <<< "$response") + else + page_tags=$(python3 -c ' +import json, sys +for t in json.load(sys.stdin).get("tags") or []: + n = t.get("name","") + if n: print(n) +' <<< "$response") + has_more=$(python3 -c ' +import json, sys +print("true" if json.load(sys.stdin).get("has_additional") else "false") +' <<< "$response") + fi + + [[ -n "$page_tags" ]] && all_tags+="${page_tags}"$'\n' + [[ "$has_more" == "true" ]] || break + ((page++)) + done + + echo "$all_tags" +} + +build_version_filter() { + local mm_esc="$1" kind="$2" suffix="$3" + case "$kind" in + ga) echo "^${mm_esc}\.[0-9]+-${suffix}$" ;; + ec) echo "^${mm_esc}\.[0-9]+-ec\.[0-9]+-${suffix}$" ;; + rc) echo "^${mm_esc}\.[0-9]+-rc\.[0-9]+-${suffix}$" ;; + prerelease) echo "^${mm_esc}\.[0-9]+-(ec|rc)\.[0-9]+-${suffix}$" ;; + esac +} + +resolve_stable() { + local major_minor="$1" kind="$2" arch="$3" + local arch_suffix + [[ "$arch" == "aarch64" ]] && arch_suffix="aarch64" || arch_suffix="multi" + + info "Querying quay.io for ${major_minor} ${kind} (${arch})" + + local all_tags + all_tags="$(fetch_quay_tags "${major_minor}.")" || return 1 + [[ -n "$all_tags" ]] || { msg_err "No tags found for ${major_minor} on quay.io"; return 1; } + + local mm_esc="${major_minor//./\\.}" + local filter_regex versions + + filter_regex="$(build_version_filter "$mm_esc" "$kind" "$arch_suffix")" + versions=$(grep -E "$filter_regex" <<< "$all_tags" | sed "s/-${arch_suffix}$//" || true) + + if [[ -z "$versions" && "$arch" == "x86_64" && "$arch_suffix" == "multi" ]]; then + msg_warn "No -multi tags for ${major_minor} ${kind}; trying -x86_64" + arch_suffix="x86_64" + filter_regex="$(build_version_filter "$mm_esc" "$kind" "$arch_suffix")" + versions=$(grep -E "$filter_regex" <<< "$all_tags" | sed "s/-${arch_suffix}$//" || true) + fi + + [[ -n "$versions" ]] || { msg_err "No ${kind} release found for ${major_minor} on quay.io"; return 1; } + + local latest + latest=$(sort -V <<< "$versions" | tail -1) + local tag="${latest}-${arch_suffix}" + + info "Resolved: ${latest} -> quay.io/${QUAY_REPO}:${tag}" + echo "quay.io/${QUAY_REPO}:${tag}" +} + +# --------------------------------------------------------------------------- +# Resolution: explicit version (X.Y.Z) +# --------------------------------------------------------------------------- + +check_quay_tag() { + local tag="$1" + local response + response="$(fetch_url "${QUAY_TAG_API}?specificTag=${tag}")" || return 1 + if command -v jq >/dev/null 2>&1; then + jq -r '(.tags // []) | length' <<< "$response" + else + python3 -c 'import json,sys;print(len(json.load(sys.stdin).get("tags") or []))' <<< "$response" + fi +} + +resolve_explicit() { + local version="$1" arch="$2" + local arch_suffix + [[ "$arch" == "aarch64" ]] && arch_suffix="aarch64" || arch_suffix="multi" + + local tag="${version}-${arch_suffix}" + info "Checking quay.io for ${tag}" + + local found + found="$(check_quay_tag "$tag")" || { msg_err "Failed to query quay.io"; return 1; } + + if [[ "$found" -gt 0 ]]; then + echo "quay.io/${QUAY_REPO}:${tag}" + return 0 + fi + + if [[ "$arch" == "x86_64" && "$arch_suffix" == "multi" ]]; then + msg_warn "Tag ${tag} not found; trying -x86_64" + tag="${version}-x86_64" + found="$(check_quay_tag "$tag")" || return 1 + if [[ "$found" -gt 0 ]]; then + echo "quay.io/${QUAY_REPO}:${tag}" + return 0 + fi + fi + + msg_err "Version ${version} not found on quay.io" + return 1 +} + +# --------------------------------------------------------------------------- +# Digest resolution +# --------------------------------------------------------------------------- + +resolve_digest() { + local pullspec="$1" pull_secret="$2" ci_token="$3" + + [[ "$pullspec" != *"@sha256:"* ]] || { echo "$pullspec"; return 0; } + + if command -v oc >/dev/null 2>&1 && [[ -f "$pull_secret" ]]; then + info "Resolving digest via oc adm release info" + local digest + if digest="$(oc adm release info --registry-config "$pull_secret" "$pullspec" \ + -o 'jsonpath={.digest}' 2>/dev/null)" && [[ -n "$digest" ]]; then + echo "$(image_repo_sans_tag "$pullspec")@${digest}" + return 0 + fi + msg_warn "oc adm release info failed; trying registry API" + fi + + parse_image_ref "$pullspec" + local reference="${IMG_TAG:-${IMG_DIGEST}}" + [[ -n "$reference" ]] || { msg_err "Cannot determine tag/digest from: ${pullspec}"; return 4; } + + info "Resolving digest via registry API" + local token + token="$(get_bearer_token "$IMG_REGISTRY" "$IMG_REPO" "$pull_secret" "$ci_token" 2>/dev/null)" || true + + local digest + if ! digest="$(registry_head_digest "$IMG_REGISTRY" "$IMG_REPO" "$reference" "$token")"; then + msg_err "Access denied resolving digest for ${pullspec}" + access_hint "$IMG_REGISTRY" + return 5 + fi + + [[ -n "$digest" ]] || { msg_err "Could not resolve digest for ${pullspec}"; return 4; } + echo "$(image_repo_sans_tag "$pullspec")@${digest}" +} + +# --------------------------------------------------------------------------- +# Access validation +# --------------------------------------------------------------------------- + +validate_access() { + local pullspec="$1" pull_secret="$2" ci_token="$3" + + if command -v oc >/dev/null 2>&1 && [[ -f "$pull_secret" ]]; then + info "Validating access via oc adm release info" + if oc adm release info --registry-config "$pull_secret" "$pullspec" >/dev/null 2>&1; then + return 0 + fi + msg_warn "oc validation failed; trying registry API" + fi + + parse_image_ref "$pullspec" + local reference="${IMG_TAG:-${IMG_DIGEST}}" + [[ -n "$reference" ]] || { msg_err "Cannot determine tag/digest from: ${pullspec}"; return 1; } + + local token + token="$(get_bearer_token "$IMG_REGISTRY" "$IMG_REPO" "$pull_secret" "$ci_token" 2>/dev/null)" || true + + if ! registry_head_digest "$IMG_REGISTRY" "$IMG_REPO" "$reference" "$token" >/dev/null; then + msg_err "Access denied: ${pullspec}" + access_hint "$IMG_REGISTRY" + return 1 + fi +} + +# --------------------------------------------------------------------------- +# Usage +# --------------------------------------------------------------------------- + +usage() { + cat >&2 < Date: Wed, 15 Jul 2026 14:29:48 +0200 Subject: [PATCH 2/5] fix: add explicit return 0 to validate_access success path Co-Authored-By: Claude Opus 4.6 (1M context) --- helpers/resolve-release-image.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/helpers/resolve-release-image.sh b/helpers/resolve-release-image.sh index 981fc91f..692bac46 100755 --- a/helpers/resolve-release-image.sh +++ b/helpers/resolve-release-image.sh @@ -452,6 +452,8 @@ validate_access() { access_hint "$IMG_REGISTRY" return 1 fi + + return 0 } # --------------------------------------------------------------------------- From 390e91256c51ae11a1acd8fd9ed5a28ae7726458 Mon Sep 17 00:00:00 2001 From: Pablo Fontanilla Date: Wed, 15 Jul 2026 16:16:38 +0200 Subject: [PATCH 3/5] Apply CodeRabbit suggestions from PR #95 Auto-applied: - helpers/resolve-release-image.sh:34: add --max-time 120 to fetch_url Accepted after review: - .claude/commands/prepare.md:117: normalize topology aliases before Make commands - helpers/prepare-config.sh:366,390: harden permissions on token-bearing config files Co-Authored-By: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/commands/prepare.md | 2 ++ helpers/prepare-config.sh | 3 ++- helpers/resolve-release-image.sh | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.claude/commands/prepare.md b/.claude/commands/prepare.md index dc4a8b50..0383d577 100644 --- a/.claude/commands/prepare.md +++ b/.claude/commands/prepare.md @@ -116,6 +116,8 @@ On failure: ### Step 3: Run doctor +Before running Make, normalize the topology: if the user said `tnf`, use `fencing`; if `tna`, use `arbiter`. The `` below must be `arbiter`, `fencing`, or `sno`. + ```bash cd deploy && make doctor - ``` diff --git a/helpers/prepare-config.sh b/helpers/prepare-config.sh index 6b1d532e..40695e2d 100755 --- a/helpers/prepare-config.sh +++ b/helpers/prepare-config.sh @@ -364,6 +364,7 @@ if [[ -f "$OUTPUT" ]]; then fi old_image=$(grep -oP '^export OPENSHIFT_RELEASE_IMAGE=\K.*' "$OUTPUT" 2>/dev/null || true) cp "$OUTPUT" "${OUTPUT}.bak" + chmod 600 "${OUTPUT}.bak" msg_info "Backed up existing config to ${OUTPUT}.bak" [[ -z "${old_image:-}" ]] || msg_info "Previous release image: ${old_image}" fi @@ -387,7 +388,7 @@ if ! self_check "$TMPFILE" "$TOPOLOGY"; then exit 5 fi -cp "$TMPFILE" "$OUTPUT" +install -m 600 "$TMPFILE" "$OUTPUT" msg_ok "Config written: ${OUTPUT}" msg_info "Deploy targets auto-sync via make sync-config" diff --git a/helpers/resolve-release-image.sh b/helpers/resolve-release-image.sh index 692bac46..3afc1d73 100755 --- a/helpers/resolve-release-image.sh +++ b/helpers/resolve-release-image.sh @@ -31,7 +31,7 @@ ok() { [[ "$QUIET" == "true" ]] || msg_ok "$1"; } valreq() { [[ -n "${2-}" && "$2" != -* ]]; } -fetch_url() { curl -fsS --connect-timeout 30 "$@"; } +fetch_url() { curl -fsS --connect-timeout 30 --max-time 120 "$@"; } require_json_tool() { command -v jq >/dev/null 2>&1 || command -v python3 >/dev/null 2>&1 || { From 652baf0b13909521327f594bbfaf90abcd58d768 Mon Sep 17 00:00:00 2001 From: Pablo Fontanilla Date: Wed, 15 Jul 2026 17:37:29 +0200 Subject: [PATCH 4/5] Add online validation script for /prepare skill Two-tier test script (hack/test-prepare-online.sh) that validates resolve-release-image.sh and prepare-config.sh against live registries. Tier 1 runs on any dev machine with pull-secret; Tier 2 requires a configured AWS instance. Not wired into make verify (stays offline/CI-safe). Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 3 + hack/test-prepare-online.sh | 393 ++++++++++++++++++++++++++++++++++++ 2 files changed, 396 insertions(+) create mode 100755 hack/test-prepare-online.sh diff --git a/Makefile b/Makefile index 9d038198..bbcc9de5 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,9 @@ ansible-lint: test-resource-agents: @./helpers/resource-agents-build/local-build-test.sh $(ARGS) +test-prepare-online: + @./hack/test-prepare-online.sh $(ARGS) + verify: VALIDATE_ONLY=true $(MAKE) shellcheck VALIDATE_ONLY=true $(MAKE) yamlfmt diff --git a/hack/test-prepare-online.sh b/hack/test-prepare-online.sh new file mode 100755 index 00000000..40ce7443 --- /dev/null +++ b/hack/test-prepare-online.sh @@ -0,0 +1,393 @@ +#!/usr/bin/bash +# Online validation for resolve-release-image.sh and prepare-config.sh. +# Tier 1: resolver against live registries (any dev machine with pull-secret). +# Tier 2: end-to-end config generation on configured instances. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +readonly RESOLVE="${REPO_ROOT}/helpers/resolve-release-image.sh" +readonly PREPARE="${REPO_ROOT}/helpers/prepare-config.sh" +readonly PULL_SECRET="${REPO_ROOT}/config/pull-secret.json" +readonly INVENTORY="${REPO_ROOT}/inventory.ini" + +PASS=0 +FAIL=0 +SKIP=0 +TMPDIR_BASE="" + +readonly COLOR_RED='\033[0;31m' +readonly COLOR_GREEN='\033[0;32m' +readonly COLOR_YELLOW='\033[0;33m' +readonly COLOR_CLEAR='\033[0m' + +pass() { echo -e "${COLOR_GREEN} PASS${COLOR_CLEAR} $1"; PASS=$((PASS + 1)); } +fail() { echo -e "${COLOR_RED} FAIL${COLOR_CLEAR} $1"; FAIL=$((FAIL + 1)); } +skip() { echo -e "${COLOR_YELLOW} SKIP${COLOR_CLEAR} $1"; SKIP=$((SKIP + 1)); } + +setup() { + TMPDIR_BASE=$(mktemp -d) + trap 'rm -rf "$TMPDIR_BASE"' EXIT +} + +# --------------------------------------------------------------------------- +# Assert helpers +# --------------------------------------------------------------------------- + +assert_exit() { + local expected="$1" actual="$2" label="$3" + if [[ "$expected" == "$actual" ]]; then + pass "$label (exit $expected)" + else + fail "$label (expected exit $expected, got $actual)" + fi +} + +assert_grep() { + local file="$1" pattern="$2" label="$3" + if grep -qE "$pattern" "$file"; then + pass "$label" + else + fail "$label — pattern not found: $pattern" + fi +} + +assert_not_grep() { + local file="$1" pattern="$2" label="$3" + if ! grep -qE "$pattern" "$file"; then + pass "$label" + else + fail "$label — pattern should not match: $pattern" + fi +} + +assert_stdout_match() { + local stdout="$1" pattern="$2" label="$3" + if echo "$stdout" | grep -qE -- "$pattern"; then + pass "$label" + else + fail "$label — stdout '${stdout}' didn't match: $pattern" + fi +} + +assert_stdout_not_match() { + local stdout="$1" pattern="$2" label="$3" + if ! echo "$stdout" | grep -qE -- "$pattern"; then + pass "$label" + else + fail "$label — stdout '${stdout}' should not match: $pattern" + fi +} + +# --------------------------------------------------------------------------- +# Skip detection +# --------------------------------------------------------------------------- + +HAS_PULL_SECRET="false" +HAS_CI_TOKEN="false" +HAS_E2E_CONFIG="false" + +detect_capabilities() { + if [[ -f "$PULL_SECRET" ]]; then + HAS_PULL_SECRET="true" + else + echo "Skipped: pull-secret.json not found (run /setup first)" + exit 0 + fi + + if [[ -n "${CI_TOKEN:-}" ]]; then + HAS_CI_TOKEN="true" + fi + + if [[ -f "$INVENTORY" ]] && \ + [[ -f "${REPO_ROOT}/config/config_fencing_example.sh" ]] && \ + [[ -f "${REPO_ROOT}/config/config_sno_example.sh" ]]; then + HAS_E2E_CONFIG="true" + fi +} + +# --------------------------------------------------------------------------- +# Tier 1: Resolver online tests +# --------------------------------------------------------------------------- + +test_resolver_ga() { + echo "=== Tier 1: GA/EC/RC (quay.io) ===" + + # Test 1: --version 4.22 → quay multi tag + local stdout rc=0 + stdout=$("$RESOLVE" --version 4.22 --quiet 2>/dev/null) || rc=$? + assert_exit 0 "$rc" "T1.1 resolve 4.22 GA" + [[ "$rc" -eq 0 ]] && \ + assert_stdout_match "$stdout" 'quay\.io/openshift-release-dev/ocp-release:4\.22\.[0-9]+-multi' \ + "T1.1 pullspec is quay multi" + + # Test 2: --version 4.22 --arch aarch64 → aarch64 tag, never -multi + rc=0 + stdout=$("$RESOLVE" --version 4.22 --arch aarch64 --quiet 2>/dev/null) || rc=$? + assert_exit 0 "$rc" "T1.2 resolve 4.22 aarch64" + if [[ "$rc" -eq 0 ]]; then + assert_stdout_match "$stdout" ':4\.22\.[0-9]+-aarch64' "T1.2 tag is aarch64" + assert_stdout_not_match "$stdout" '-multi' "T1.2 not multi" + fi + + # Test 3: --version 4.22 --digest → sha256 digest + rc=0 + stdout=$("$RESOLVE" --version 4.22 --digest --quiet 2>/dev/null) || rc=$? + assert_exit 0 "$rc" "T1.3 resolve 4.22 digest" + [[ "$rc" -eq 0 ]] && \ + assert_stdout_match "$stdout" 'ocp-release@sha256:[a-f0-9]{64}' \ + "T1.3 digest format" + + # Test 4: --version 4.22 --validate-access → exit 0 + rc=0 + "$RESOLVE" --version 4.22 --validate-access --quiet >/dev/null 2>&1 || rc=$? + assert_exit 0 "$rc" "T1.4 validate-access 4.22" + + # Test 5: --version 99.99 → exit 3 + rc=0 + "$RESOLVE" --version 99.99 --quiet >/dev/null 2>&1 || rc=$? + assert_exit 3 "$rc" "T1.5 nonexistent version" +} + +test_resolver_prerelease() { + echo "" + echo "=== Tier 1: Prerelease (quay.io) ===" + + # Test 6: --version 4.22-prerelease → ec/rc tag or exit 3 + local stdout rc=0 + stdout=$("$RESOLVE" --version 4.22-prerelease --quiet 2>/dev/null) || rc=$? + if [[ "$rc" -eq 0 ]]; then + assert_stdout_match "$stdout" '-(ec|rc)\.' "T1.6 prerelease tag" + elif [[ "$rc" -eq 3 ]]; then + skip "T1.6 no prerelease for 4.22 (exit 3 expected)" + else + fail "T1.6 prerelease (unexpected exit $rc)" + fi +} + +test_resolver_nightly() { + echo "" + echo "=== Tier 1: Nightly (release controller) ===" + + if [[ "$HAS_CI_TOKEN" != "true" ]]; then + skip "T1.7-10 nightly tests (CI_TOKEN not set)" + return + fi + + # Test 7: --version 4.22-nightly → registry.ci pullspec + local stdout rc=0 + stdout=$("$RESOLVE" --version 4.22-nightly --quiet 2>/dev/null) || rc=$? + assert_exit 0 "$rc" "T1.7 resolve 4.22-nightly" + [[ "$rc" -eq 0 ]] && \ + assert_stdout_match "$stdout" 'registry\.ci\.openshift\.org/ocp/release:4\.22' \ + "T1.7 pullspec is CI registry" + + # Test 8: --version 4.22-nightly --arch aarch64 → arm64 registry + rc=0 + stdout=$("$RESOLVE" --version 4.22-nightly --arch aarch64 --quiet 2>/dev/null) || rc=$? + assert_exit 0 "$rc" "T1.8 resolve 4.22-nightly aarch64" + [[ "$rc" -eq 0 ]] && \ + assert_stdout_match "$stdout" 'registry\.ci\.openshift\.org/ocp-arm64/release-arm64:' \ + "T1.8 pullspec is arm64 CI registry" + + # Test 9: --validate-access with real CI_TOKEN → exit 0 + rc=0 + "$RESOLVE" --version 4.22-nightly --validate-access --ci-token "$CI_TOKEN" --quiet >/dev/null 2>&1 || rc=$? + assert_exit 0 "$rc" "T1.9 validate-access nightly with CI_TOKEN" + + # Test 10: --validate-access with dummy CI token → exit 5 + rc=0 + "$RESOLVE" --version 4.22-nightly --validate-access --ci-token "sha256~bogus_token_12345" --quiet >/dev/null 2>&1 || rc=$? + assert_exit 5 "$rc" "T1.10 validate-access nightly with bad token" +} + +test_resolver_digest_fallback() { + echo "" + echo "=== Tier 1: Digest fallback ===" + + # Test 11: --digest with oc on PATH + local stdout rc=0 + stdout=$("$RESOLVE" --version 4.22 --digest --quiet 2>/dev/null) || rc=$? + assert_exit 0 "$rc" "T1.11 digest with oc" + [[ "$rc" -eq 0 ]] && \ + assert_stdout_match "$stdout" '@sha256:' "T1.11 contains @sha256:" + + # Test 11b: --digest with oc hidden (curl fallback) + rc=0 + local restricted_path + restricted_path=$(echo "$PATH" | tr ':' '\n' | grep -v -E '(openshift|oc|ocp|crc|usr/local/bin|\.local)' | tr '\n' ':') + restricted_path="${restricted_path%:}" + stdout=$(PATH="$restricted_path" "$RESOLVE" --version 4.22 --digest --quiet 2>/dev/null) || rc=$? + if [[ "$rc" -eq 0 ]]; then + assert_stdout_match "$stdout" '@sha256:' "T1.11b digest via curl fallback" + else + skip "T1.11b curl fallback (exit $rc — may need auth in pull-secret)" + fi +} + +test_resolver_passthrough() { + echo "" + echo "=== Tier 1: Explicit passthrough ===" + + # Test 12: --pullspec echoes input unchanged + local input="quay.io/openshift-release-dev/ocp-release:4.22.0-multi" + local stdout rc=0 + stdout=$("$RESOLVE" --pullspec "$input" --quiet 2>/dev/null) || rc=$? + assert_exit 0 "$rc" "T1.12 passthrough" + if [[ "$rc" -eq 0 && "$stdout" == "$input" ]]; then + pass "T1.12 output matches input" + elif [[ "$rc" -eq 0 ]]; then + fail "T1.12 output mismatch: got '$stdout'" + fi +} + +# --------------------------------------------------------------------------- +# Tier 2: End-to-end validation +# --------------------------------------------------------------------------- + +test_e2e_fencing_ipi() { + echo "" + echo "=== Tier 2: Fencing IPI x86_64 (E2E-1) ===" + + local resolved rc=0 + resolved=$("$RESOLVE" --version 4.22 --quiet 2>/dev/null) || rc=$? + if [[ "$rc" -ne 0 ]]; then + fail "E2E-1 setup: resolve 4.22 failed (exit $rc)" + return + fi + + local outfile="${TMPDIR_BASE}/config_fencing.sh" + rc=0 + "$PREPARE" --topology fencing --method ipi \ + --release-image "$resolved" --ci-token "${CI_TOKEN:-placeholder}" \ + --output "$outfile" --force >/dev/null 2>&1 || rc=$? + assert_exit 0 "$rc" "E2E-1 prepare-config fencing-ipi" + [[ "$rc" -eq 0 ]] || return + + if [[ -f "$outfile" ]]; then + pass "E2E-1 output file exists" + else + fail "E2E-1 output file exists" + fi + assert_grep "$outfile" "^export OPENSHIFT_RELEASE_IMAGE=" "E2E-1 OPENSHIFT_RELEASE_IMAGE set" + assert_grep "$outfile" '^export CI_TOKEN="' "E2E-1 CI_TOKEN active" + assert_not_grep "$outfile" '^export OPENSHIFT_CI=' "E2E-1 OPENSHIFT_CI commented" + assert_grep "$outfile" '^export AGENT_E2E_TEST_SCENARIO="TNF_' "E2E-1 scenario prefix TNF" +} + +test_e2e_sno_agent() { + echo "" + echo "=== Tier 2: SNO agent x86_64 (E2E-2) ===" + + local resolved rc=0 + resolved=$("$RESOLVE" --version 4.22 --digest --quiet 2>/dev/null) || rc=$? + if [[ "$rc" -ne 0 ]]; then + fail "E2E-2 setup: resolve 4.22 --digest failed (exit $rc)" + return + fi + + local outfile="${TMPDIR_BASE}/config_sno.sh" + rc=0 + "$PREPARE" --topology sno --method agent \ + --release-image "$resolved" --ci-token "${CI_TOKEN:-placeholder}" \ + --output "$outfile" --force >/dev/null 2>&1 || rc=$? + assert_exit 0 "$rc" "E2E-2 prepare-config sno-agent" + [[ "$rc" -eq 0 ]] || return + + assert_grep "$outfile" '@sha256:' "E2E-2 release image is digest-pinned" + assert_grep "$outfile" '^export AGENT_E2E_TEST_SCENARIO="SNO_' "E2E-2 scenario prefix SNO" +} + +test_e2e_doctor() { + echo "" + echo "=== Tier 2: Make doctor gate (E2E-3) ===" + + local rc=0 + make -C "${REPO_ROOT}/deploy" doctor fencing-ipi 2>&1 || rc=$? + if [[ "$rc" -eq 0 ]]; then + pass "E2E-3 make doctor fencing-ipi" + else + echo -e "${COLOR_YELLOW} NOTE${COLOR_CLEAR} E2E-3 make doctor exited $rc (instance-level issue, not counted as failure)" + fi +} + +test_e2e_inventory_fork() { + echo "" + echo "=== Tier 2: Inventory fork override (E2E-4) ===" + + local inv_backup="${TMPDIR_BASE}/inventory.ini.bak" + cp "$INVENTORY" "$inv_backup" + + local img rc + img=$("$RESOLVE" --version 4.22 --quiet 2>/dev/null) || { + fail "E2E-4 setup: resolve failed" + cp "$inv_backup" "$INVENTORY" + return + } + + # E2E-4a: --ds-repo + --ds-branch → upserts both + rc=0 + "$PREPARE" --topology fencing --method ipi \ + --release-image "$img" --ci-token "${CI_TOKEN:-placeholder}" \ + --inventory "$INVENTORY" \ + --ds-repo https://github.com/example/dev-scripts --ds-branch test-branch \ + --output "${TMPDIR_BASE}/inv_fork1.sh" --force >/dev/null 2>&1 || rc=$? + assert_exit 0 "$rc" "E2E-4a fork upsert" + [[ "$rc" -eq 0 ]] && { + assert_grep "$INVENTORY" '^dev_scripts_src_repo=https://github.com/example/dev-scripts' "E2E-4a repo upserted" + assert_grep "$INVENTORY" '^dev_scripts_branch=test-branch' "E2E-4a branch upserted" + } + + # E2E-4b: without fork args → purges + rc=0 + "$PREPARE" --topology fencing --method ipi \ + --release-image "$img" --ci-token "${CI_TOKEN:-placeholder}" \ + --inventory "$INVENTORY" \ + --output "${TMPDIR_BASE}/inv_fork2.sh" --force >/dev/null 2>&1 || rc=$? + assert_exit 0 "$rc" "E2E-4b fork purge" + [[ "$rc" -eq 0 ]] && { + assert_not_grep "$INVENTORY" '^dev_scripts_src_repo=' "E2E-4b repo purged" + assert_not_grep "$INVENTORY" '^dev_scripts_branch=' "E2E-4b branch purged" + } + + cp "$inv_backup" "$INVENTORY" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +setup +detect_capabilities + +echo "=== Online validation: resolve-release-image.sh + prepare-config.sh ===" +echo " pull-secret: ${HAS_PULL_SECRET}" +echo " CI_TOKEN: ${HAS_CI_TOKEN}" +echo " e2e config: ${HAS_E2E_CONFIG}" +echo "" + +# Tier 1 +test_resolver_ga +test_resolver_prerelease +test_resolver_nightly +test_resolver_digest_fallback +test_resolver_passthrough + +# Tier 2 +if [[ "$HAS_E2E_CONFIG" == "true" ]]; then + test_e2e_fencing_ipi + test_e2e_sno_agent + test_e2e_doctor + test_e2e_inventory_fork +else + echo "" + skip "Tier 2 — instance not configured for e2e (need inventory.ini + example configs)" +fi + +echo "" +echo "==============================" +echo -e "Results: ${COLOR_GREEN}${PASS} passed${COLOR_CLEAR}, ${COLOR_RED}${FAIL} failed${COLOR_CLEAR}, ${COLOR_YELLOW}${SKIP} skipped${COLOR_CLEAR}" +echo "==============================" + +[[ "$FAIL" -eq 0 ]] From ef9d5a411e8578fd5668ffe1aad0b475dc880885 Mon Sep 17 00:00:00 2001 From: Pablo Fontanilla Date: Thu, 16 Jul 2026 18:31:36 +0200 Subject: [PATCH 5/5] fix: require instance.env for AWS deployments in /prepare and doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /prepare skill accepted medium=aws but never ensured config/instance.env existed — config generation succeeded but deployment would fail without AWS hypervisor settings (REGION, AWS_PROFILE, EC2_INSTANCE_TYPE). Three fixes: - doctor.sh: add --aws flag with NEED_AWS severity promotion (NOTE→FAIL) - Makefile: default AWS=1 so make doctor requires instance.env (AWS=0 opts out) - prepare.md: add worktree config bootstrap (Step 1), instance.env template copy (Step 3), sync-config before doctor (Step 5), fix EC2_INSTANCE_TYPE variable name New helpers/sync-worktree-config.sh copies essential config files from the main checkout to a worktree's config/ folder, picking the newer of config/ and canonical locations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/commands/prepare.md | 55 ++++++++++++-- deploy/Makefile | 5 +- deploy/openshift-clusters/scripts/doctor.sh | 25 ++++++- helpers/sync-worktree-config.sh | 81 +++++++++++++++++++++ 4 files changed, 155 insertions(+), 11 deletions(-) create mode 100755 helpers/sync-worktree-config.sh diff --git a/.claude/commands/prepare.md b/.claude/commands/prepare.md index 0383d577..d859f1ad 100644 --- a/.claude/commands/prepare.md +++ b/.claude/commands/prepare.md @@ -59,7 +59,7 @@ Interactive prompting is only for humans invoking with missing arguments. ## Architecture Determination 1. `arch=` key wins if provided -2. medium=aws: read `INSTANCE_TYPE` from `config/instance.env` — Graviton families (`c7g`, `m7g`, `r7g`, `c6g`, `m6g`, `r6g`, etc.) → `aarch64`; otherwise `x86_64` +2. medium=aws: read `EC2_INSTANCE_TYPE` from `config/instance.env` — Graviton families (`c7g`, `m7g`, `r7g`, `c6g`, `m6g`, `r6g`, etc.) → `aarch64`; otherwise `x86_64` 3. medium=external: default `x86_64` with a note that `arch=aarch64` can override ## Constraint Matrix @@ -77,7 +77,17 @@ These are enforced in the helper scripts. If the user hits one, explain why and Run each step as a separate Bash call. Branch on exit codes. -### Step 1: Resolve release image +### Step 1: Bootstrap worktree config (worktrees only) + +```bash +helpers/sync-worktree-config.sh +``` + +The script auto-detects whether it is running in a worktree. If so, it copies essential config files (`pull-secret.json`, `instance.env`, `config_*.sh`) from the main checkout into the worktree's `config/` folder — picking the newer of `config/` and the canonical sync destination when both exist. If not a worktree, it exits silently. + +After this step, `config/pull-secret.json` must exist. If it is still missing, fail with the standard prerequisites block. + +### Step 2: Resolve release image ```bash helpers/resolve-release-image.sh \ @@ -94,13 +104,36 @@ Capture stdout (single-line pullspec). On failure: - Exit 3: no matching release → suggest checking version number - Exit 5: access denied → point to `/setup` for credentials -### Step 2: Generate config +### Step 3: Ensure instance.env exists (medium=aws only) + +Skip this step when medium is not `aws`. + +Check if `config/instance.env` exists: +- **If present**: proceed silently (do not re-copy the template over user edits) +- **If missing**: copy the template and warn about defaults + +```bash +cp config/instance.env.template config/instance.env +``` + +After copying, emit a warning (not a failure — the defaults are usable): +``` +NOTE: Created config/instance.env from template with defaults: + - EC2_INSTANCE_TYPE=c5n.metal (x86_64 Intel) + - REGION=us-west-2 + - AWS_PROFILE=microshift-dev +Review and edit config/instance.env if these defaults are not suitable. +``` + +Do NOT prompt for edits when all positional args are present (non-interactive contract). Just warn and continue. + +### Step 4: Generate config ```bash helpers/prepare-config.sh \ --topology \ --method \ - --release-image \ + --release-image \ --ci-token \ --ip-stack \ --arch \ @@ -114,17 +147,25 @@ On failure: - Exit 4: config exists → ask if they want `force=true` (human) or tell agent callers to pass `force=true` - Exit 5: self-check failed → likely template drift, report verbatim -### Step 3: Run doctor +### Step 5: Run doctor Before running Make, normalize the topology: if the user said `tnf`, use `fencing`; if `tna`, use `arbiter`. The `` below must be `arbiter`, `fencing`, or `sno`. +**medium=aws** (default — no AWS= override needed): ```bash -cd deploy && make doctor - +cd deploy && make sync-config && make doctor - ``` +**medium=external:** +```bash +cd deploy && make doctor AWS=0 - +``` + +The `sync-config` call propagates `config/instance.env` to the canonical location (`deploy/aws-hypervisor/instance.env`) that doctor reads. `AWS=0` opts out of the instance.env requirement for external hosts. + Relay FAIL lines verbatim. Doctor is read-only — it won't break anything. -### Step 4: Print deployment commands (never run them) +### Step 6: Print deployment commands (never run them) **medium=aws:** ``` diff --git a/deploy/Makefile b/deploy/Makefile index 306f6a99..93ec39ef 100644 --- a/deploy/Makefile +++ b/deploy/Makefile @@ -1,6 +1,9 @@ # Valid cluster types for 'make deploy ' VALID_CLUSTER_TYPES := fencing-ipi fencing-agent fencing-assisted arbiter-ipi arbiter-agent arbiter-kcli fencing-kcli sno-ipi sno-agent +# AWS hypervisor checks are on by default for doctor; opt out with AWS=0 +AWS ?= 1 + # Handle 'make deploy ' pattern # When 'deploy' is first, validate any following arguments are valid cluster types ifeq (deploy,$(firstword $(MAKECMDGOALS))) @@ -91,7 +94,7 @@ sync-config: fi' doctor: - @./openshift-clusters/scripts/doctor.sh $(DOCTOR_ARGS) + @./openshift-clusters/scripts/doctor.sh $(if $(filter 1,$(AWS)),--aws) $(DOCTOR_ARGS) ifeq (doctor,$(firstword $(MAKECMDGOALS))) # Cluster types after 'doctor' are arguments to doctor.sh, not deployments diff --git a/deploy/openshift-clusters/scripts/doctor.sh b/deploy/openshift-clusters/scripts/doctor.sh index 3e85657e..d89e32fe 100755 --- a/deploy/openshift-clusters/scripts/doctor.sh +++ b/deploy/openshift-clusters/scripts/doctor.sh @@ -66,12 +66,15 @@ section() { } usage() { - echo "Usage: $0 [cluster-type...]" + echo "Usage: $0 [--aws] [cluster-type...]" echo "" echo "Read-only configuration preflight. With no arguments, runs common checks" echo "plus every auto-detected section. Naming a cluster type makes its" echo "deployment method's checks mandatory." echo "" + echo "Options:" + echo " --aws Require instance.env (promotes missing instance.env to FAIL)" + echo "" echo "Valid cluster types: fencing-ipi fencing-agent fencing-assisted" echo " fencing-kcli arbiter-ipi arbiter-agent arbiter-kcli" echo " sno-ipi sno-agent" @@ -83,7 +86,9 @@ TARGETED=0 NEED_DEVSCRIPTS=0 NEED_KCLI=0 NEED_ASSISTED=0 +NEED_AWS=0 REQUIRED_TOPOLOGIES=" " +TARGETED_TYPES="" for arg in "$@"; do case "${arg}" in @@ -91,21 +96,27 @@ for arg in "$@"; do usage exit 0 ;; + --aws) + NEED_AWS=1 + ;; fencing-assisted) # assisted deploys the hub via fencing-ipi, so it needs the dev-scripts config TARGETED=1 NEED_DEVSCRIPTS=1 NEED_ASSISTED=1 REQUIRED_TOPOLOGIES+="fencing " + TARGETED_TYPES+=" ${arg}" ;; arbiter-ipi|arbiter-agent|fencing-ipi|fencing-agent|sno-ipi|sno-agent) TARGETED=1 NEED_DEVSCRIPTS=1 REQUIRED_TOPOLOGIES+="${arg%-*} " + TARGETED_TYPES+=" ${arg}" ;; arbiter-kcli|fencing-kcli) TARGETED=1 NEED_KCLI=1 + TARGETED_TYPES+=" ${arg}" ;; *) echo "Error: Unknown cluster type: '${arg}'" @@ -130,7 +141,10 @@ tool_hint() { echo "two-node-toolbox configuration preflight (read-only)" if [[ "${TARGETED}" -eq 1 ]]; then - check_note "validating strictly for: $*" + check_note "validating strictly for:${TARGETED_TYPES}" +fi +if [[ "${NEED_AWS}" -eq 1 ]]; then + check_note "AWS mode: instance.env is required" fi # --- Required tools --- @@ -235,7 +249,12 @@ fi section "AWS hypervisor (instance.env)" if [[ ! -f "${INSTANCE_ENV}" ]]; then - check_note "section skipped: instance.env not found (only needed for the AWS hypervisor flow) - to use it: 'cp ${CONFIG_DIR}/instance.env.template ${CONFIG_DIR}/instance.env' and edit" + if [[ "${NEED_AWS}" -eq 1 ]]; then + check_fail "instance.env missing (required for AWS deployment)" \ + "cp ${CONFIG_DIR}/instance.env.template ${CONFIG_DIR}/instance.env && edit it, then run 'make sync-config'" + else + check_note "section skipped: instance.env not found (only needed for the AWS hypervisor flow) - to use it: 'cp ${CONFIG_DIR}/instance.env.template ${CONFIG_DIR}/instance.env' and edit" + fi else # set +u: the deployment scripts never source instance.env under nounset, # so the probe must not be stricter than real usage diff --git a/helpers/sync-worktree-config.sh b/helpers/sync-worktree-config.sh new file mode 100755 index 00000000..360f7743 --- /dev/null +++ b/helpers/sync-worktree-config.sh @@ -0,0 +1,81 @@ +#!/usr/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +readonly COLOR_RED='\033[0;31m' +readonly COLOR_YELLOW='\033[0;33m' +readonly COLOR_GREEN='\033[0;32m' +readonly COLOR_BLUE='\033[0;34m' +readonly COLOR_CLEAR='\033[0m' + +msg_err() { echo -e "${COLOR_RED}ERROR: ${1}${COLOR_CLEAR}" >&2; } +msg_ok() { echo -e "${COLOR_GREEN}OK: ${1}${COLOR_CLEAR}" >&2; } +msg_info() { echo -e "${COLOR_BLUE}INFO: ${1}${COLOR_CLEAR}" >&2; } +msg_warn() { echo -e "${COLOR_YELLOW}WARN: ${1}${COLOR_CLEAR}" >&2; } + +# Files to sync and their canonical (non-config/) locations in the main +# checkout. For each file the script picks the newer of config/ and +# the canonical path, then copies it into the worktree's config/ folder. +# +# Format: config-filename:canonical-path (relative to repo root) +WORKTREE_SYNC_FILES=( + "pull-secret.json:deploy/openshift-clusters/roles/dev-scripts/install-dev/files/pull-secret.json" + "instance.env:deploy/aws-hypervisor/instance.env" + "config_arbiter.sh:deploy/openshift-clusters/roles/dev-scripts/install-dev/files/config_arbiter.sh" + "config_fencing.sh:deploy/openshift-clusters/roles/dev-scripts/install-dev/files/config_fencing.sh" + "config_sno.sh:deploy/openshift-clusters/roles/dev-scripts/install-dev/files/config_sno.sh" +) + +# --------------------------------------------------------------------------- + +GIT_DIR="$(git -C "${REPO_ROOT}" rev-parse --git-dir 2>/dev/null)" +GIT_COMMON="$(git -C "${REPO_ROOT}" rev-parse --git-common-dir 2>/dev/null)" + +if [[ "${GIT_DIR}" == "${GIT_COMMON}" ]]; then + msg_info "Not a worktree — nothing to sync" + exit 0 +fi + +MAIN_CHECKOUT="$(git -C "${REPO_ROOT}" worktree list --porcelain | head -1 | sed 's/^worktree //')" + +if [[ ! -d "${MAIN_CHECKOUT}/config" ]]; then + msg_err "Main checkout config/ not found at ${MAIN_CHECKOUT}/config" + exit 1 +fi + +# Pick the newer of two files. Prints the path of the winner, or nothing if +# neither exists. +newer_of() { + local a="$1" b="$2" + if [[ -f "$a" && -f "$b" ]]; then + if [[ "$a" -nt "$b" ]]; then echo "$a"; else echo "$b"; fi + elif [[ -f "$a" ]]; then + echo "$a" + elif [[ -f "$b" ]]; then + echo "$b" + fi +} + +COPIED=() + +for entry in "${WORKTREE_SYNC_FILES[@]}"; do + name="${entry%%:*}" + canonical="${entry#*:}" + dest="${REPO_ROOT}/config/${name}" + + [[ -f "$dest" ]] && continue + + src="$(newer_of "${MAIN_CHECKOUT}/config/${name}" "${MAIN_CHECKOUT}/${canonical}")" + [[ -n "$src" ]] || continue + + cp -p "$src" "$dest" + COPIED+=("$name") +done + +if [[ ${#COPIED[@]} -gt 0 ]]; then + IFS=', ' msg_ok "Copied from main checkout: ${COPIED[*]}" +else + msg_info "Worktree config/ already up to date" +fi