diff --git a/.cursor/rules/010-tekton-pipelines.mdc b/.cursor/rules/010-tekton-pipelines.mdc new file mode 100644 index 0000000..23695d2 --- /dev/null +++ b/.cursor/rules/010-tekton-pipelines.mdc @@ -0,0 +1,140 @@ +--- +description: Guidelines for Tekton pipeline development with resilient execution patterns +alwaysApply: true +--- + +# Tekton Tasks Repository Guidelines + +## Repository Overview + +This repository contains Tekton pipelines and tasks for OpenShift Virtualization CI/CD workflows. All pipelines follow the **Resilient Execution Layer** pattern for observability and debugging. + +## Key Patterns + +### Resilient Execution Layer + +Every pipeline step MUST include: + +1. **Execution Layer Header** - Initialize tracking at script start: +```bash +SCRIPT_START=$(date +%s) +declare -a ACTION_LOG=() +FINAL_EXIT_CODE=0 +``` + +2. **exec_action() wrapper** - Wrap external commands with timeouts and structured logging: +```bash +exec_action "Action name" "" +``` + +3. **execution_summary() trap** - Guarantee summary output on ANY exit: +```bash +trap execution_summary EXIT +``` + +4. **Fail-fast run logic** - Keep `set -eo pipefail` for business logic after the execution layer. + +### Timeout Guidelines + +| Command Type | Recommended Timeout | +|-------------|---------------------| +| kubectl/oc API calls | 60s | +| git clone | 120s | +| git pull/push | 60s | +| helm pull | 120s | +| helm push | 120s | +| helm lint/package | 30s | +| Registry login | 30s | +| Validation checks | 30s | + +### Validation Requirements + +Before executing any command, validate: +- Required parameters are not empty +- Environment variables (tokens, credentials) exist +- Files exist before reading them +- JSON/YAML is valid before parsing + +### Output Formatting + +Use box-drawing characters for structured output: +- `┌─────` Action start +- `│` Output prefix +- `├─────` Status separator +- `└─────` Action end +- `╔═════` Summary header +- `║` Summary lines +- `╚═════` Summary footer + +### Credential Patterns + +#### Registry Auth (Quay OCI) + +For steps that pull/push OCI artifacts (Helm charts, images): +1. Mount the docker config secret as a **volume** (provides `.dockerconfigjson` file) +2. Extract username/password via `jq` from the mounted file +3. Run `helm registry login ` **inline** (not through `exec_action`) -- passwords cannot be safely piped through `bash -c` +4. A single `helm registry login quay.io` covers ALL quay.io repos the robot has access to + +#### Secret Injection + +- Use `envFrom` for tokens needed as env vars (e.g., `GITLAB_TOKEN`) +- Use `volumeMounts` for structured credentials (e.g., `.dockerconfigjson`) +- Never hardcode credentials in pipeline YAML + +### Helm OCI Chart Lifecycle + +When pipelines produce Helm charts via OCI: + +1. **Pull** a skeleton (template) chart from OCI registry +2. **Hydrate** Chart.yaml fields (name, version, appVersion) from build metadata +3. **Hydrate** values.yaml with runtime configuration +4. **Lint** the hydrated chart (`helm lint .`) +5. **Package** (`helm package .`) -- filename is `{Chart.yaml name}-{Chart.yaml version}.tgz` +6. **Push** to target OCI registry (`helm push oci://`) + +`helm package` derives the .tgz filename from `Chart.yaml`, not from shell variables. Always verify `PACKAGE_FILE` matches what `helm package` actually produces. + +### Cross-Step Data Sharing + +Steps within the same Tekton task share `/tekton/results/`: +- Step N writes: `echo -n "$VALUE" > /tekton/results/` +- Step N+1 reads: `cat /tekton/results/` +- Size limit: ~4KB per result, ~12KB total. Monitor for large JSON payloads. + +## File Structure + +``` +tekton-tasks/ +├── pipelines/ # Full pipeline definitions +│ └── *.yaml +├── fbc/ # FBC-specific tasks +│ └── *.yaml +└── README.md +``` + +## Pipeline Parameters + +Always include a `debug` parameter for verbose output: +```yaml +- name: debug + type: string + default: "false" + description: Enable debug mode with verbose output (set -x) +``` + +## Testing Changes + +Before committing: +1. Validate YAML syntax: `python3 -c "import yaml; yaml.safe_load(open('file.yaml'))"` +2. Validate bash syntax: Extract script and run `bash -n script.sh` +3. Test in a non-production namespace first + +## Commit Messages + +Format: `(): ` + +Examples: +- `feat(pipeline): add new release automation step` +- `fix(update-bundle): handle empty snapshot gracefully` +- `refactor(pipeline): improve error handling and observability` diff --git a/.cursor/rules/020-execution-layer-reference.mdc b/.cursor/rules/020-execution-layer-reference.mdc new file mode 100644 index 0000000..523fde6 --- /dev/null +++ b/.cursor/rules/020-execution-layer-reference.mdc @@ -0,0 +1,149 @@ +--- +description: Reference implementation for the resilient execution layer pattern +globs: "**/*.yaml" +alwaysApply: false +--- + +# Execution Layer Reference Implementation + +## Full Template + +Copy this template when creating new pipeline steps: + +```bash +#!/usr/bin/env bash + +# ============================================================ +# EXECUTION LAYER - Resilient wrapper for observability +# ============================================================ +SCRIPT_START=$(date +%s) +declare -a ACTION_LOG=() +FINAL_EXIT_CODE=0 + +# Enable debug mode if requested +if [[ "$(params.DEBUG)" == "true" ]]; then + set -x +fi + +exec_action() { + local action_name="$1" + local timeout_secs="${2:-60}" + shift 2 + local cmd="$*" + + local start_ts=$(date +%s) + echo "" + echo "┌─────────────────────────────────────────────────────────────" + echo "│ ACTION: $action_name" + echo "│ CMD: $cmd" + echo "│ TIMEOUT: ${timeout_secs}s" + echo "├─────────────────────────────────────────────────────────────" + + local output exit_code + output=$(timeout "$timeout_secs" bash -c "$cmd" 2>&1) + exit_code=$? + + local end_ts=$(date +%s) + local duration=$((end_ts - start_ts)) + + if [[ -n "$output" ]]; then + echo "$output" | sed 's/^/│ /' + fi + + local status + if [[ $exit_code -eq 0 ]]; then + status="OK" + elif [[ $exit_code -eq 124 ]]; then + status="TIMEOUT" + else + status="FAILED" + fi + + echo "├─────────────────────────────────────────────────────────────" + echo "│ STATUS: $status (exit: $exit_code, duration: ${duration}s)" + echo "└─────────────────────────────────────────────────────────────" + + ACTION_LOG+=("$status|$action_name|exit=$exit_code|${duration}s") + return $exit_code +} + +execution_summary() { + local script_exit_code=$? + local script_end=$(date +%s) + local total_duration=$((script_end - SCRIPT_START)) + + echo "" + echo "╔═════════════════════════════════════════════════════════════" + echo "║ EXECUTION SUMMARY - " + echo "║ Total Duration: ${total_duration}s" + echo "║ Final Exit Code: ${FINAL_EXIT_CODE:-$script_exit_code}" + echo "╠═════════════════════════════════════════════════════════════" + + local ok_count=0 fail_count=0 timeout_count=0 + + for entry in "${ACTION_LOG[@]}"; do + local status=$(echo "$entry" | cut -d'|' -f1) + local action=$(echo "$entry" | cut -d'|' -f2) + local details=$(echo "$entry" | cut -d'|' -f3-) + + case "$status" in + OK) ok_count=$((ok_count + 1)); echo "║ ✓ $action ($details)" ;; + FAILED) fail_count=$((fail_count + 1)); echo "║ ✗ $action ($details)" ;; + TIMEOUT) timeout_count=$((timeout_count + 1)); echo "║ ⏱ $action ($details)" ;; + esac + done + + echo "╠═════════════════════════════════════════════════════════════" + echo "║ OK: $ok_count | FAILED: $fail_count | TIMEOUT: $timeout_count" + echo "╚═════════════════════════════════════════════════════════════" + + exit ${FINAL_EXIT_CODE:-$script_exit_code} +} + +trap execution_summary EXIT + +# ============================================================ +# RUN LOGIC - Business logic with fail-fast behavior +# ============================================================ +set -eo pipefail + +# Your business logic here... +``` + +## Usage Patterns + +### Wrapping Commands + +```bash +# With exec_action (preferred for external calls) +if ! exec_action "Fetch data" 60 "kubectl get pod -ojson"; then + echo "ERROR: Failed to fetch data" + FINAL_EXIT_CODE=1 + exit 1 +fi + +# Quick validation (no external call) +if [[ -z "$REQUIRED_VAR" ]]; then + echo "ERROR: REQUIRED_VAR is empty" + FINAL_EXIT_CODE=1 + exit 1 +fi +ACTION_LOG+=("OK|Validate REQUIRED_VAR|exit=0|0s") +``` + +### Early Exit (Success) + +```bash +if [[ "$CONDITION" == "skip" ]]; then + ACTION_LOG+=("OK|Skip - condition met|exit=0|0s") + FINAL_EXIT_CODE=0 + exit 0 +fi +``` + +## Key Principles + +1. **Always set FINAL_EXIT_CODE before exit** - Ensures trap reports correct status +2. **Log manual validations** - Add to ACTION_LOG for visibility +3. **Timeout all external calls** - Prevent indefinite hangs +4. **Validate before use** - Check files/vars exist before operations diff --git a/.cursor/rules/030-update-bundle-pipeline.mdc b/.cursor/rules/030-update-bundle-pipeline.mdc new file mode 100644 index 0000000..46ada6a --- /dev/null +++ b/.cursor/rules/030-update-bundle-pipeline.mdc @@ -0,0 +1,138 @@ +--- +description: Architecture and data flow for the update-bundle pipeline (OCI chart lifecycle, cross-step communication, credential management) +globs: "pipelines/update-bundle.yaml" +alwaysApply: false +--- + +# update-bundle Pipeline Architecture + +## Pipeline Overview + +The `update-bundle` pipeline processes HCO bundle registry snapshots from Konflux. It has **two steps** in a single Tekton task, sharing data via `/tekton/results/`. + +## Step Architecture + +``` +Step 1: update-bundle Step 2: push-release-chart +┌──────────────────────────┐ ┌──────────────────────────────┐ +│ Phase 1: Fetch snapshot │ │ Phase 1: Check snapshot type │ +│ Phase 2: Clone repo │ │ Phase 2: Read results │ +│ Phase 3: Validate commit │ │ Phase 3: Load configuration │ +│ Phase 4: Update images │ │ Phase 4: Registry login │ +│ Phase 5: Commit & push │ │ Phase 5: Pull skeleton chart │ +│ Phase 6: Prepare release │──results──▶ │ Phase 6: Hydrate Chart.yaml │ +│ data │ │ Phase 7: Hydrate values.yaml │ +└──────────────────────────┘ │ Phase 8: Inject snapshots │ + │ Phase 9: Lint chart │ + Results written: │ Phase 10: Package chart │ + - bundle-version │ Phase 11: Push to OCI │ + - snapshots-to-release └──────────────────────────────┘ +``` + +## Cross-Step Data Flow + +Steps share data via Tekton results at `/tekton/results/`: + +| Result | Producer | Consumer | Format | Size Risk | +|--------|----------|----------|--------|-----------| +| `bundle-version` | Step 1 Phase 6 | Step 2 Phase 2 | Plain text (`4.20.8-rhel9.47`) | None | +| `snapshots-to-release` | Step 1 Phase 6 | Step 2 Phase 8 | JSON object | Monitor: Tekton limits results to ~4KB each | + +Step 2 gates on snapshot type at Phase 1: if the snapshot name does not contain `hco-bundle-registry`, the step exits 0 immediately. + +## Credential Management + +### Secrets + +| Secret Name | Type | Used By | Purpose | +|-------------|------|---------|---------| +| `hco-updater-gitlab-token` | envFrom | Both steps | GitLab API token (`GITLAB_TOKEN` env var) | +| `quay-builds-creds` | envFrom + volumeMount | Step 2 | Quay robot account credentials | + +### Quay Auth Pattern (Step 2) + +The `quay-builds-creds` secret is both: +1. **Volume-mounted** at `/tmp/quay-builds-creds` (provides `.dockerconfigjson` file) +2. **Injected via envFrom** (keys become env vars; `.dockerconfigjson` is skipped as invalid env name) + +The script reads credentials from the volume mount, NOT the env vars: +``` +Volume mount → read .dockerconfigjson → jq extract username/password → helm registry login quay.io +``` + +The login is done **inline** (not through `exec_action`) because the password cannot be safely piped through `bash -c`. A single `helm registry login quay.io` covers ALL quay.io repositories. + +### Robot Account + +- Name: `openshift-virtualization+konflux_builds_bot` +- Has write access to: `quay.io/openshift-virtualization/konflux-builds/v{XY}/*` +- Has read access to: `quay.io/openshift-virtualization/release-chart/*` + +## OCI Chart Lifecycle (Skeleton → Hydrate → Push) + +### Design Principle + +The **skeleton chart** is OS-agnostic (`name: hco-bundle-registry`). The **hydrated chart** inherits the OS suffix from the bundle version, producing OS-specific artifacts. + +### Skeleton Chart + +- Registry: `quay.io/openshift-virtualization/release-chart/hco-bundle-registry` +- Version: `0.0.0-skeleton` (configurable via pipeline param) +- Contains: Chart.yaml, values.yaml, templates/ with placeholder values + +### Chart Name Hydration + +The chart name is derived from `BUNDLE_VERSION`: + +``` +BUNDLE_VERSION OS_SUFFIX HYDRATED_CHART_NAME +4.20.8-rhel9.47 → rhel9 → hco-bundle-registry-rhel9 +4.22.1-rhel10.5 → rhel10 → hco-bundle-registry-rhel10 +4.12.36 (legacy) → (none) → hco-bundle-registry +``` + +Extraction: `cut -d'-' -f2 | cut -d'.' -f1` with defensive guards for null/empty. + +### Hydration Fields + +| Chart.yaml Field | Source | Example | +|------------------|--------|---------| +| `name` | `CHART_NAME` + OS suffix | `hco-bundle-registry-rhel9` | +| `version` | `SEMVER-releases.RELEASE_NUM` | `4.20.8-releases.47` | +| `appVersion` | `v` + BUNDLE_VERSION | `v4.20.8-rhel9.47` | + +### Push Destination + +``` +oci://quay.io/openshift-virtualization/konflux-builds/v{XY}/{HYDRATED_CHART_NAME}:{CHART_VERSION} +``` + +Example: `oci://quay.io/openshift-virtualization/konflux-builds/v4-20/hco-bundle-registry-rhel9:4.20.8-releases.47` + +## Variable Derivation Map + +``` +BUNDLE_VERSION = "4.20.8-rhel9.47" (from build-bundle.json → Tekton result) + │ + ├──▶ SEMVER = "4.20.8" (cut -d'-' -f1) + ├──▶ RELEASE_NUM = "47" (grep -oE '[0-9]+$') + ├──▶ OS_SUFFIX = "rhel9" (cut -d'-' -f2 | cut -d'.' -f1) + ├──▶ XY = "4-20" (cut -d'.' -f1,2 | tr '.' '-') + │ + ├──▶ CHART_VERSION = "4.20.8-releases.47" + ├──▶ HYDRATED_CHART_NAME = "hco-bundle-registry-rhel9" + ├──▶ APP_VERSION = "v4.20.8-rhel9.47" + └──▶ TARGET_REGISTRY = "quay.io/openshift-virtualization/konflux-builds/v4-20" +``` + +## Known Gotchas + +1. **Double kubectl fetch (Step 1, Phase 1)**: The snapshot is fetched once via `exec_action` (for logging) and once directly (for data capture). `exec_action` runs in a subshell so its output cannot be captured by the caller. Future fix: write output to a temp file. + +2. **Token in git clone log (Step 1, Phase 2)**: `REPO_URL_WITH_AUTH` embeds the GitLab token and is logged by `exec_action`'s CMD line. Consider sanitizing the display URL. + +3. **jq null OS field**: The jq expression at Step 1 line 345 always produces a dash (`X.Y.Z-{os}.{release}`). If `.os` is null in `build-bundle.json`, BUNDLE_VERSION becomes `4.12.36-null.36`. The hydration code guards against `OS_SUFFIX == "null"`. + +4. **Tekton result size limit**: Individual results are capped at ~4KB. If `snapshots-to-release` JSON grows large (many components), it may exceed this. Monitor snapshot counts. + +5. **exec_action subshell environment**: `exec_action` runs commands via `timeout ... bash -c "..."`. The child process inherits `HOME` and all exported vars, but does NOT have access to unexported shell functions. Use `export -f` when passing functions. diff --git a/.cursor/skills/add-tekton-task/SKILL.md b/.cursor/skills/add-tekton-task/SKILL.md new file mode 100644 index 0000000..a2e0498 --- /dev/null +++ b/.cursor/skills/add-tekton-task/SKILL.md @@ -0,0 +1,125 @@ +--- +name: add-tekton-task +description: Guides adding a new Tekton pipeline task step using the Resilient Execution Layer template. Includes timeout table, validation checklist, and exec_action patterns. Use when adding tasks, creating pipeline steps, or implementing new automation. +--- + +# Add Tekton Task + +## Prerequisites + +Before adding a task, determine: + +1. **Target pipeline** -- `pipelines/update-bundle.yaml`, `fbc/fbc-post-release.yaml`, or new file? +2. **Position** -- Which task does it run after? +3. **External calls** -- What commands does the step invoke (git, helm, kubectl, curl)? + +## Step-by-Step + +### 1. Add the task entry to the pipeline + +```yaml +- name: my-new-task + runAfter: + - + taskSpec: + params: + - name: PARAM_NAME + description: What this param provides + results: + - name: MY_RESULT + description: What this result contains + steps: + - name: run-logic + image: @sha256: + script: | + #!/usr/bin/env bash + # Paste the execution layer template below +``` + +### 2. Apply the execution layer template + +Copy from `.cursor/rules/020-execution-layer-reference.mdc`: + +```bash +#!/usr/bin/env bash + +# ============================================================ +# EXECUTION LAYER +# ============================================================ +SCRIPT_START=$(date +%s) +declare -a ACTION_LOG=() +FINAL_EXIT_CODE=0 + +if [[ "$(params.DEBUG)" == "true" ]]; then set -x; fi + +# (exec_action and execution_summary functions from template) + +trap execution_summary EXIT + +# ============================================================ +# RUN LOGIC +# ============================================================ +set -eo pipefail + +# Your business logic here, using exec_action for external calls +``` + +### 3. Wrap external calls with exec_action + +Use the timeout table for appropriate values: + +| Command Type | Timeout | +|-------------|---------| +| kubectl/oc API calls | 60s | +| git clone | 120s | +| git pull/push | 60s | +| helm pull/push | 120s | +| helm lint/package | 30s | +| Registry login | 30s | +| Validation checks | 30s | + +```bash +if ! exec_action "Clone repository" 120 "git clone \$REPO_URL /workspace/repo"; then + echo "ERROR: Clone failed" + FINAL_EXIT_CODE=1 + exit 1 +fi +``` + +### 4. Write results before exit + +```bash +echo -n "$VALUE" > $(results.MY_RESULT.path) +FINAL_EXIT_CODE=0 +``` + +### 5. Add debug parameter + +Every pipeline MUST have a `debug` param: + +```yaml +params: + - name: debug + type: string + default: "false" + description: Enable debug mode with verbose output (set -x) +``` + +## Validation After Adding + +1. YAML syntax: `python3 -c "import yaml; yaml.safe_load(open('file.yaml'))"` +2. Bash syntax: Extract script, run `bash -n script.sh` +3. Dry-run: `kubectl apply --dry-run=client -f file.yaml` +4. Verify `runAfter` ordering +5. Verify result references use correct task/param names + +## Checklist + +- [ ] Execution layer header (SCRIPT_START, ACTION_LOG, FINAL_EXIT_CODE) +- [ ] exec_action() for all external calls +- [ ] execution_summary() trap set +- [ ] set -eo pipefail after execution layer +- [ ] All params validated (non-empty, files exist) +- [ ] Results written before every exit path +- [ ] Debug param wired through +- [ ] Image pinned with @sha256: digest diff --git a/.cursor/skills/validate-tekton-pipeline/SKILL.md b/.cursor/skills/validate-tekton-pipeline/SKILL.md new file mode 100644 index 0000000..81b7ff6 --- /dev/null +++ b/.cursor/skills/validate-tekton-pipeline/SKILL.md @@ -0,0 +1,123 @@ +--- +name: validate-tekton-pipeline +description: Validates Tekton pipeline and task YAML for syntax errors, bash script correctness, and dry-run apply. Use when editing pipelines, adding tasks, modifying scripts, or before committing. +--- + +# Validate Tekton Pipeline + +## When to Use + +- After editing any `pipelines/**/*.yaml` or `fbc/**/*.yaml` file +- After modifying embedded bash scripts in `script:` blocks +- Before committing pipeline changes + +## Quick Validation + +### 1. YAML Syntax Check + +```bash +cd /home/thason/Git/Openshift-virtulization/GitHub/tekton-tasks +python3 -c " +import yaml, sys, pathlib +for f in list(pathlib.Path('pipelines').rglob('*.yaml')) + list(pathlib.Path('fbc').rglob('*.yaml')): + try: + list(yaml.safe_load_all(f.read_text())) + except Exception as e: + print(f'INVALID: {f}: {e}') + sys.exit(1) +print('All YAML files valid') +" +``` + +### 2. Bash Script Syntax Check + +Extract the `script:` block from a YAML file and validate bash syntax: + +```bash +python3 -c " +import yaml, sys +doc = yaml.safe_load(open(sys.argv[1])) +for task in doc.get('spec', {}).get('tasks', []): + ts = task.get('taskSpec', {}) + for step in ts.get('steps', []): + script = step.get('script', '') + if script: + fname = f'/tmp/{step[\"name\"]}.sh' + with open(fname, 'w') as f: + f.write(script) + import subprocess + r = subprocess.run(['bash', '-n', fname], capture_output=True, text=True) + if r.returncode != 0: + print(f'SYNTAX ERROR in step {step[\"name\"]}: {r.stderr}') + sys.exit(1) + print(f'OK: {step[\"name\"]}') +" pipelines/.yaml +``` + +### 3. Dry-Run Apply + +```bash +kubectl apply --dry-run=client -f pipelines/update-bundle.yaml +kubectl apply --dry-run=client -f fbc/fbc-post-release.yaml +``` + +## Execution Layer Checks + +For steps using the Resilient Execution Layer, verify: + +1. `SCRIPT_START`, `ACTION_LOG`, `FINAL_EXIT_CODE` initialized at top +2. `exec_action()` function defined +3. `execution_summary()` function defined +4. `trap execution_summary EXIT` set before business logic +5. `set -eo pipefail` set after execution layer, before business logic + +## Helm OCI Chart Lifecycle Checks + +For steps that hydrate and push Helm charts (e.g., `push-release-chart`): + +### Variable Derivation Chain + +Verify the derivation from `BUNDLE_VERSION` is consistent: + +``` +BUNDLE_VERSION → SEMVER (cut -d'-' -f1) + → RELEASE_NUM (grep -oE '[0-9]+$') + → OS_SUFFIX (cut -d'-' -f2 | cut -d'.' -f1) + → XY (cut -d'.' -f1,2 | tr '.' '-') +``` + +### Chart Name Hydration Contract + +1. `HYDRATED_CHART_NAME` must match what `yq` writes to `.name` in Chart.yaml +2. `CHART_VERSION` must match what `yq` writes to `.version` in Chart.yaml +3. `PACKAGE_FILE` must equal `{HYDRATED_CHART_NAME}-{CHART_VERSION}.tgz` +4. `helm package .` produces the .tgz using `.name` and `.version` from Chart.yaml +5. `helm push` reads the chart name from inside the .tgz for the OCI repo path + +If any of these are misaligned, the package file check (`! -f "${PACKAGE_FILE}"`) will fail, or the push will target the wrong OCI repo. + +### Verification Checklist + +- [ ] Chart.yaml `.name` is hydrated (not left as skeleton default) +- [ ] Chart.yaml `.version` is hydrated (not `0.0.0-skeleton`) +- [ ] Chart.yaml `.appVersion` is hydrated +- [ ] Verification reads back `.name` and `.version` and compares +- [ ] `PACKAGE_FILE` uses `HYDRATED_CHART_NAME`, not `CHART_NAME` +- [ ] Published message uses `HYDRATED_CHART_NAME` +- [ ] OS suffix guard handles: normal (`rhel9`), future (`rhel10`), null, empty, legacy (no dash) + +### Cross-Step Result Checks + +- [ ] Step 1 writes `bundle-version` and `snapshots-to-release` to `/tekton/results/` +- [ ] Step 2 validates both files exist before reading +- [ ] Step 2 validates `bundle-version` is non-empty +- [ ] `snapshots-to-release` JSON is validated with `jq -e .` before injection + +## Pre-Commit Workflow + +1. Edit the pipeline/task YAML +2. Run YAML syntax check +3. Run bash syntax check on modified scripts +4. Run `kubectl apply --dry-run=client` +5. Verify chart hydration contract (if modifying push-release-chart step) +6. Commit with format: `(): ` diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..395229e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.cursor/plans/* \ No newline at end of file diff --git a/Docs/fix_helm_push_bug-Code_Changes_Review_2026-02-13_1530.md b/Docs/fix_helm_push_bug-Code_Changes_Review_2026-02-13_1530.md new file mode 100644 index 0000000..2a24dd2 --- /dev/null +++ b/Docs/fix_helm_push_bug-Code_Changes_Review_2026-02-13_1530.md @@ -0,0 +1,103 @@ +# Code Changes Review: Fix Helm Chart Push Bug + +**Plan:** `fix_helm_push_bug_d166770e.plan.md` +**Date:** 2026-02-13 +**File:** `pipelines/update-bundle.yaml` +**Diff:** 4 hunks, +15 / -2 lines (net +13) + +--- + +## 1. Developer + Technical Impact Summary + +* **Risk Level:** Low +* **Breaking Changes:** None. The pipeline's external interface is unchanged: + * **Params:** `snapshot`, `skeleton-chart-version`, `debug` -- no change + * **Results:** `snapshots-to-release`, `bundle-version` -- no change + * **Step contract:** Shared `/tekton/results/` volume between steps -- no change + * **Secrets:** `hco-updater-gitlab-token`, `quay-builds-creds` -- no change +* **Behavioral Change (intended):** The OCI push now uses the correct versioned `.tgz` file instead of the stale skeleton archive. Downstream Kargo Warehouses will see correct version tags (e.g., `4.21.1-releases.23`) instead of `0.0.0-skeleton`. + +--- + +## 2. Downstream Impact Analysis + +| Consumer | Location | Impact | Risk | +| :--- | :--- | :--- | :--- | +| `fbc-post-release.yaml` | `fbc/fbc-post-release.yaml` | None -- independent pipeline, does not consume `update-bundle` results | None | +| Kargo Warehouses (OCI watch) | External (Konflux cluster) | **Positive** -- will now detect correct version tags from Quay OCI registry | None (fix restores intended behavior) | +| ArgoCD Sync | External (Konflux cluster) | **Positive** -- promotions triggered by Kargo will use the hydrated chart with correct values | None | +| `pipelines/README.md` | Same repo | No update needed -- documents params/results which are unchanged | None | +| Execution summary output | Pipeline logs | Two new ACTION_LOG entries appear: `Cleanup stale tgz archives`, `Verify Chart.yaml hydration`. Does not affect parsing -- log consumers read exit codes, not action names. | None | + +**Existing tests:** No automated test suite exists for this pipeline (Tekton pipelines are validated via `kubectl apply --dry-run=client` per README). No risk of test failure. + +--- + +## 3. Findings and Fixes + +| File | Line | Severity | Issue Type | Description and Fix | +| :--- | :--- | :--- | :--- | :--- | +| `update-bundle.yaml` | 651 | LOW | Robustness | The `yq` read-back runs under `set -eo pipefail`. If `yq` fails (unlikely since it just wrote to the same file), `set -e` aborts the script but `FINAL_EXIT_CODE` remains `0`, causing the execution summary trap to exit `0`. **This is a pre-existing pattern issue**, not introduced by this change. To harden, wrap with explicit error handling: `ACTUAL_VERSION=$(yq '.version' Chart.yaml \| tr -d '"') \|\| { echo "ERROR: ..."; FINAL_EXIT_CODE=1; exit 1; }`. **Verdict: acceptable as-is** -- the preceding `exec_action` just used `yq -i` on the same file, so a read failure here would indicate a catastrophic environment problem. | +| `update-bundle.yaml` | 708 | LOW | Dead code | The `-z "${PACKAGE_FILE}"` check is now redundant since the variable is constructed from two previously-validated non-empty strings (`CHART_NAME` at line 530 is a constant, `CHART_VERSION` at line 638 is validated at lines 632-636). **Verdict: keep it** -- it costs nothing and provides defense-in-depth if the variable construction logic is ever refactored. | +| `update-bundle.yaml` | 616 | INFO | Style | The `ACTION_LOG` entry for stale `.tgz` cleanup always reports `OK` regardless of whether files were actually removed. This is acceptable since `rm -f` is idempotent and the action's purpose is cleanup, not validation. | + +**No HIGH or CRITICAL issues found.** + +--- + +## 4. Verification Plan + +### Pre-merge (local) + +1. **YAML syntax validation:** + +```bash +python3 -c "import yaml; yaml.safe_load(open('pipelines/update-bundle.yaml'))" +``` + +2. **Bash syntax validation** -- extract the `push-release-chart` script and check: + +```bash +# Extract script block and validate syntax +yq '.spec.tasks[0].taskSpec.steps[1].script' pipelines/update-bundle.yaml > /tmp/push-release-chart.sh +bash -n /tmp/push-release-chart.sh +``` + +3. **Dry-run apply:** + +```bash +kubectl apply --dry-run=client -f pipelines/update-bundle.yaml +``` + +### Post-merge (pipeline run) + +4. **Trigger a test run** against a non-production `hco-bundle-registry` snapshot. In the pipeline log, verify the five-line consistency check: + +``` +ACTION: Package chart --> ...hco-bundle-registry-rhel9-X.Y.Z-releases.N.tgz +OK: Packaged as --> ...hco-bundle-registry-rhel9-X.Y.Z-releases.N.tgz +CMD: helm push --> ...hco-bundle-registry-rhel9-X.Y.Z-releases.N.tgz +Pushed: --> ...hco-bundle-registry-rhel9:X.Y.Z-releases.N +Published --> ...hco-bundle-registry-rhel9:X.Y.Z-releases.N (file: ...) +``` + +All five lines MUST show the same version string. The previously broken run showed `0.0.0-skeleton` on lines 2-5 while line 1 had the correct version. + +5. **Verify Quay OCI tag** -- after the pipeline completes, confirm the chart exists at the expected tag: + +```bash +helm show chart oci://quay.io/openshift-virtualization/konflux-builds/v4-21/hco-bundle-registry-rhel9 --version 4.21.1-releases.23 +``` + +6. **Verify new ACTION_LOG entries** in the execution summary: + +``` +Cleanup stale tgz archives +Verify Chart.yaml hydration +``` + +Both should appear as `OK` in the summary. + +### Regression check + +7. **Non-hco-bundle-registry snapshot:** Run the pipeline with a non-bundle snapshot (e.g., a `kubevirt` component). Verify the `push-release-chart` step correctly skips at Phase 1 with `SKIP: Snapshot does not contain 'hco-bundle-registry'`. This flow is not touched by the changes but confirms no collateral damage. diff --git a/fbc/fbc-post-release.yaml b/fbc/fbc-post-release.yaml new file mode 100644 index 0000000..1659e2c --- /dev/null +++ b/fbc/fbc-post-release.yaml @@ -0,0 +1,340 @@ +--- +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: fbc-post-release +spec: + description: This tasks gets all the required data of an FBC build and commits it to a payload repository to be used in a GitOps environment. + params: + - name: snapshot + type: string + - name: cnv_version + type: string + - name: releaseplan_id + type: string + - name: environment + type: string + tasks: + - name: get-info + params: + - name: SNAPSHOT + value: "$(params.snapshot)" + - name: CNV_VERSION + value: "$(params.cnv_version)" + - name: RELEASEPLAN_ID + value: "$(params.releaseplan_id)" + - name: ENVIRONMENT + value: "$(params.environment)" + taskSpec: + params: + - name: SNAPSHOT + type: string + - name: CNV_VERSION + type: string + - name: RELEASEPLAN_ID + type: string + - name: ENVIRONMENT + type: string + results: + - name: channel + description: Release channel (candidate or stable) + - name: environment + description: Release environment (stage or production) + - name: hco-bundle-version + description: Full bundle version from updated_image.yaml + steps: + - name: get-info + image: quay.io/konflux-ci/release-service-utils:105474114b10c0bd52eda96bc67490447131b784 + envFrom: + - secretRef: + name: cnv-ci-release-token + script: | + #!/usr/bin/env bash + set -eo pipefail + + snapshot_id=$(echo "$(params.SNAPSHOT)" | awk -F'/' '{print $2}') + release_id=$(kubectl get release --sort-by=.metadata.creationTimestamp -o json | \ + jq -r --arg snapshot "${snapshot_id}" --arg releaseplan "$(params.RELEASEPLAN_ID)" \ + '.items[] | select(.spec.snapshot == $snapshot and .spec.releasePlan == $releaseplan) | .metadata.name' | tail -n 1) + + released_status=$(kubectl get release "${release_id}" -oyaml | yq '.status.conditions[] | select(.type == "ManagedPipelineProcessed") | .status') + if [ "$released_status" != "True" ]; then + echo "Release failed. Not continuing with post-release." + exit 1 + fi + + cnv_version="$(params.CNV_VERSION)" + snapshot_data=$(kubectl get snapshot "${snapshot_id}" -oyaml) + fbc_fragment=$(echo "${snapshot_data}" | yq '.spec.components[0].containerImage') + index_image=$(kubectl get release "${release_id}" -oyaml | yq ".status.artifacts.index_image.\"${cnv_version}\".index_image") + from_index="registry-proxy.engineering.redhat.com/rh-osbs/iib-pub-pending:${cnv_version}" + + git_commit=$(echo "${snapshot_data}" | yq '.spec.components[0].source.git.revision') + git_repo=$(echo "${snapshot_data}" | yq '.spec.components[0].source.git.url') + gitfbcdir=$(mktemp -d) && cd "${gitfbcdir}" + git init + git remote add origin "${git_repo}.git" + git fetch origin "${git_commit}" --depth 1 + git checkout "${git_commit}" + image_sha=$(yq '.image' ${cnv_version}/updated_image.yaml) + channel=$(yq '.channel' ${cnv_version}/updated_image.yaml) + hco_bundle_version=$(yq '.hco-bundle-version' ${cnv_version}/updated_image.yaml) + hco_bundle_url=$(yq '.hco-bundle-url' ${cnv_version}/updated_image.yaml) + + if [[ $hco_bundle_version == v4.99* ]]; then + cnv_version="v4.99" + fi + + gitpayloaddir=$(mktemp -d) && cd "${gitpayloaddir}" + # github_token and github_username are injected from cnv-ci-release-token secret + git clone https://${github_token}@github.com/openshift-cnv/cnv-fbc-payloads-new.git && cd cnv-fbc-payloads-new + git config --global user.name "${github_username}" + git config --global user.email "no-reply@redhat.com" + + case $(params.ENVIRONMENT) in + "production") + NEXT_STAGE="verify-production-release" + ;; + "stage") + NEXT_STAGE="stage" + ;; + *) + echo "Unknown environment: $(params.ENVIRONMENT)" + exit 1 + ;; + esac + save_folder="${cnv_version//./-}/lanes/${channel}/${NEXT_STAGE}" + mkdir -p "${save_folder}" + echo "" > "${save_folder}/payload.yaml" + yq e ".index_image = \"${index_image}\"" -i ${save_folder}/payload.yaml + yq e ".snapshot_id = \"${snapshot_id}\"" -i ${save_folder}/payload.yaml + yq e ".from_index = \"${from_index}\"" -i ${save_folder}/payload.yaml + yq e ".fbc_fragment = \"${fbc_fragment}\"" -i ${save_folder}/payload.yaml + yq e ".channel = \"${channel}\"" -i ${save_folder}/payload.yaml + yq e ".hco_bundle_registry_by_sha = \"${image_sha}\"" -i ${save_folder}/payload.yaml + image_base=$(echo "${image_sha}" | cut -d'@' -f1) + yq e ".hco_bundle_registry_by_tag = \"${image_base}:${hco_bundle_version}\"" -i ${save_folder}/payload.yaml + yq e ".hco_bundle_version = \"${hco_bundle_version}\"" -i ${save_folder}/payload.yaml + yq e ".hco_bundle_url = \"${hco_bundle_url}\"" -i ${save_folder}/payload.yaml + # Add the minor version number from the bundle for Kargo + yq e '.minorVersion = (.hco_bundle_version | split("-") | .[0])' -i ${save_folder}/payload.yaml + yq e '.rhelVersion = (.minorVersion | split(".") | .[3])' -i ${save_folder}/payload.yaml + yq e ".version = \"${cnv_version//./-}\"" -i ${save_folder}/payload.yaml + yq e ".releasePlan = \"${NEXT_STAGE}\"" -i ${save_folder}/payload.yaml + yq e ".freightName = \"${snapshot_id}\"" -i ${save_folder}/payload.yaml + yq e ".freightID = null" -i ${save_folder}/payload.yaml + yq e ".prNumber = null" -i ${save_folder}/payload.yaml + # add a firstRelease flag to the payload + patch_version=$(echo "$hco_bundle_version" | grep -oP 'v\d+\.\d+\.\K\d+') + if [ "$patch_version" -eq 0 ]; then + yq -i '.firstRelease = true' ${save_folder}/payload.yaml + else + yq -i '.firstRelease = false' ${save_folder}/payload.yaml + fi + + + git add . + git commit -m "Update payload $hco_bundle_version" + + MAX_RETRIES=5 + for attempt in $(seq 1 $MAX_RETRIES); do + if git push origin main; then + echo "Push succeeded on attempt ${attempt}" + break + fi + if [ "$attempt" -eq "$MAX_RETRIES" ]; then + echo "Push failed after ${MAX_RETRIES} attempts" + exit 1 + fi + echo "Push rejected (attempt ${attempt}/${MAX_RETRIES}), rebasing..." + git pull --rebase origin main + done + + echo -n "${channel}" > /tekton/results/channel + echo -n "$(params.ENVIRONMENT)" > /tekton/results/environment + echo -n "${hco_bundle_version}" > /tekton/results/hco-bundle-version + - name: update-smartsheet + image: quay.io/konflux-ci/release-service-utils@sha256:dd7fa6a8d8ade6fbc3ad30d974bb72cdefbdf7fd6a9eafa19c09d0146ece67c1 + env: + - name: SMARTSHEET_TOKEN + valueFrom: + secretKeyRef: + name: smartsheets + key: token + optional: true + - name: SHEET_ID + valueFrom: + secretKeyRef: + name: smartsheets + key: SheetId + optional: true + script: | + #!/usr/bin/env bash + set -u + + ENVIRONMENT=$(cat /tekton/results/environment) + if [[ "$ENVIRONMENT" != "production" ]]; then + echo "Skipping Smartsheet update: environment is '${ENVIRONMENT}', not 'production'" + exit 0 + fi + + CHANNEL=$(cat /tekton/results/channel) + HCO_BUNDLE_VERSION=$(cat /tekton/results/hco-bundle-version) + + echo "=== FBC Prod Release Smartsheet Update ===" + echo "Environment: $ENVIRONMENT" + echo "Channel: $CHANNEL" + echo "Bundle: $HCO_BUNDLE_VERSION" + echo "" + + fail_safe() { + echo "" + echo "╔═════════════════════════════════════════" + echo "║ WARN: Smartsheet update failed" + echo "║ Reason: $1" + echo "║ Channel: ${CHANNEL:-unknown}" + echo "║ Bundle: ${HCO_BUNDLE_VERSION:-unknown}" + echo "║ Payload git push was NOT affected" + echo "╚═════════════════════════════════════════" + exit 0 + } + + if [[ -z "${SMARTSHEET_TOKEN:-}" || -z "${SHEET_ID:-}" ]]; then + fail_safe "smartsheets secret not configured (missing token or SheetId)" + fi + + VERSION=$(echo "$HCO_BUNDLE_VERSION" | grep -oP 'v?\d+\.\d+' | head -1) + if [[ -z "$VERSION" ]]; then + fail_safe "Could not derive version from $HCO_BUNDLE_VERSION" + fi + + BUNDLE_ID_VALUE=$(echo "$HCO_BUNDLE_VERSION" | sed 's/-\(rhel[0-9]*\)\./.\1-/') + echo "Version row: $VERSION" + echo "BundleID: $BUNDLE_ID_VALUE" + echo "" + + smartsheet_api_call() { + local METHOD="$1" ENDPOINT="$2" DATA="${3:-}" + local MAX_RETRIES=3 RETRY_DELAY=2 + + for ((i=1; i<=MAX_RETRIES; i++)); do + local CURL_ARGS=(-s -w "\n%{http_code}" -X "$METHOD" + "https://api.smartsheet.com/2.0${ENDPOINT}" + -H "Authorization: Bearer ${SMARTSHEET_TOKEN}" + -H "Content-Type: application/json") + [[ -n "$DATA" ]] && CURL_ARGS+=(-d "$DATA") + + RESPONSE=$(curl "${CURL_ARGS[@]}" 2>/dev/null) + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') + + case "$HTTP_CODE" in + 200|201) echo "$BODY"; return 0 ;; + 429) + echo "WARN: Rate limited, waiting ${RETRY_DELAY}s (attempt $i/$MAX_RETRIES)" >&2 + sleep "$RETRY_DELAY"; RETRY_DELAY=$((RETRY_DELAY * 2)) ;; + 401|403) + echo "ERROR: Auth failed (HTTP $HTTP_CODE)" >&2; return 1 ;; + *) + echo "WARN: API error $HTTP_CODE (attempt $i/$MAX_RETRIES)" >&2 + sleep "$RETRY_DELAY"; RETRY_DELAY=$((RETRY_DELAY * 2)) ;; + esac + done + echo "ERROR: Max retries exceeded" >&2; return 1 + } + + echo "Discovering Smartsheet columns..." + COLUMNS_JSON=$(smartsheet_api_call "GET" "/sheets/${SHEET_ID}/columns" "") \ + || fail_safe "Column discovery failed" + + COL_NAME=$(echo "$COLUMNS_JSON" | jq -r '.data[] | select(.title=="Name") | .id') + COL_BUNDLEID=$(echo "$COLUMNS_JSON" | jq -r '.data[] | select(.title=="BundleID") | .id') + COL_CANDIDATE_PROD=$(echo "$COLUMNS_JSON" | jq -r '.data[] | select(.title=="Candidate Prod") | .id') + COL_CANDIDATE_DATE=$(echo "$COLUMNS_JSON" | jq -r '.data[] | select(.title=="Candidate Prod Date") | .id') + COL_STABLE_PROD=$(echo "$COLUMNS_JSON" | jq -r '.data[] | select(.title=="Stable Prod") | .id') + COL_STABLE_DATE=$(echo "$COLUMNS_JSON" | jq -r '.data[] | select(.title=="Stable Prod Date") | .id') + + if [[ -z "$COL_NAME" || "$COL_NAME" == "null" ]]; then + fail_safe "Primary column 'Name' not found" + fi + if [[ -z "$COL_BUNDLEID" || "$COL_BUNDLEID" == "null" ]]; then + fail_safe "Column 'BundleID' not found" + fi + + case "$CHANNEL" in + "candidate") + COL_PROD="$COL_CANDIDATE_PROD" + COL_DATE="$COL_CANDIDATE_DATE" + ;; + "stable") + COL_PROD="$COL_STABLE_PROD" + COL_DATE="$COL_STABLE_DATE" + ;; + *) + fail_safe "Unknown channel: $CHANNEL" + ;; + esac + + if [[ -z "$COL_PROD" || "$COL_PROD" == "null" ]]; then + fail_safe "Column '${CHANNEL^} Prod' not found -- add it to the Smartsheet first" + fi + if [[ -z "$COL_DATE" || "$COL_DATE" == "null" ]]; then + fail_safe "Column '${CHANNEL^} Prod Date' not found -- add it to the Smartsheet first" + fi + echo "Column IDs discovered" + + SHEET_DATA=$(smartsheet_api_call "GET" "/sheets/${SHEET_ID}" "") \ + || fail_safe "Failed to fetch sheet data" + + VERSION_ROW_ID=$(echo "$SHEET_DATA" | jq -r --arg v "$VERSION" --argjson c "$COL_NAME" \ + '[.rows[]? | select(.parentId == null) | + select(.cells[]? | select(.columnId == $c and .value == $v))] | first | .id // empty') + + if [[ -z "$VERSION_ROW_ID" || "$VERSION_ROW_ID" == "null" ]]; then + fail_safe "Version row '$VERSION' not found in build sheet" + fi + echo "Found L0 version row: $VERSION_ROW_ID" + + APP_ROW_ID=$(echo "$SHEET_DATA" | jq -r --arg app "hco-bundle-registry" --argjson c "$COL_NAME" --argjson pid "$VERSION_ROW_ID" \ + '[.rows[]? | select(.parentId == $pid) | + select(.cells[]? | select(.columnId == $c and .value == $app))] | first | .id // empty') + + if [[ -z "$APP_ROW_ID" || "$APP_ROW_ID" == "null" ]]; then + fail_safe "Application row 'hco-bundle-registry' not found under '$VERSION'" + fi + echo "Found L1 application row: $APP_ROW_ID" + + COMPONENT_ROW_ID=$(echo "$SHEET_DATA" | jq -r \ + --arg bid "$BUNDLE_ID_VALUE" \ + --argjson cb "$COL_BUNDLEID" \ + --argjson pid "$APP_ROW_ID" \ + '[.rows[]? | select(.parentId == $pid) | + select(.cells[]? | select(.columnId == $cb and .value == $bid))] | first | .id // empty') + + if [[ -z "$COMPONENT_ROW_ID" || "$COMPONENT_ROW_ID" == "null" ]]; then + fail_safe "No build row with BundleID '$BUNDLE_ID_VALUE' found under hco-bundle-registry" + fi + echo "Found L2 component row: $COMPONENT_ROW_ID (BundleID: $BUNDLE_ID_VALUE)" + + TIMESTAMP=$(date -Iseconds) + UPDATE_DATA=$(jq -n \ + --argjson rid "$COMPONENT_ROW_ID" \ + --argjson cp "$COL_PROD" \ + --argjson cd "$COL_DATE" \ + --arg date "$TIMESTAMP" \ + '[{id: $rid, cells: [ + {columnId: $cp, value: true}, + {columnId: $cd, value: $date} + ]}]') + + smartsheet_api_call "PUT" "/sheets/${SHEET_ID}/rows" "$UPDATE_DATA" >/dev/null \ + || fail_safe "Failed to update Smartsheet row" + + echo "" + echo "╔═════════════════════════════════════════" + echo "║ FBC Prod Release marked in Smartsheet" + echo "║ Version: $VERSION" + echo "║ Bundle: $HCO_BUNDLE_VERSION" + echo "║ Channel: $CHANNEL" + echo "║ Column: ${CHANNEL^} Prod = true" + echo "╚═════════════════════════════════════════" diff --git a/fbc/report-fbc-build-status.yaml b/fbc/report-fbc-build-status.yaml new file mode 100644 index 0000000..f3b3761 --- /dev/null +++ b/fbc/report-fbc-build-status.yaml @@ -0,0 +1,407 @@ +# fbc/report-fbc-build-status.yaml +# @ai-rules: +# 1. [Pattern]: Resilient Execution Layer — exec_action, execution_summary, trap. +# 2. [Constraint]: FINAL_EXIT_CODE=0 always — this is a fail-safe task, never fail the build. +# 3. [Pattern]: Secret is `smartsheets` (keys: token, SheetId), same as fbc-post-release.yaml. +# 4. [Gotcha]: BundleID in commit message is already in sheet format — no sed transform needed. +# 5. [Gotcha]: L0 version rows use `v4.21` format (keep `v` prefix). +apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: report-fbc-build-status + annotations: + description: | + Reports FBC catalog build status to Smartsheet by updating existing hco-bundle-registry + L2 rows with FBC Stage/Prod status, PipelineRun name, and channel. + Referenced via git resolver from fbc-pipeline finally block. +spec: + params: + - name: git-url + type: string + - name: revision + type: string + - name: pipelinerun-name + type: string + - name: pipelinerun-namespace + type: string + - name: PIPELINE_AGGREGATE_STATUS + type: string + - name: DEBUG + type: string + default: "false" + steps: + - name: post-fbc-status + image: quay.io/konflux-ci/release-service-utils@sha256:dd7fa6a8d8ade6fbc3ad30d974bb72cdefbdf7fd6a9eafa19c09d0146ece67c1 + env: + - name: SMARTSHEET_TOKEN + valueFrom: + secretKeyRef: + name: smartsheets + key: token + optional: true + - name: SHEET_ID + valueFrom: + secretKeyRef: + name: smartsheets + key: SheetId + optional: true + script: | + #!/usr/bin/env bash + + # ============================================================ + # EXECUTION LAYER - Resilient wrapper for observability + # ============================================================ + SCRIPT_START=$(date +%s) + declare -a ACTION_LOG=() + FINAL_EXIT_CODE=0 + + if [[ "$(params.DEBUG)" == "true" ]]; then set -x; fi + + exec_action() { + local action_name="$1" + local timeout_secs="${2:-60}" + shift 2 + local cmd="$*" + + local start_ts=$(date +%s) + echo "" + echo "┌─────────────────────────────────────────────────────────────" + echo "│ ACTION: $action_name" + echo "│ TIMEOUT: ${timeout_secs}s" + echo "├─────────────────────────────────────────────────────────────" + + local output exit_code + output=$(timeout "$timeout_secs" bash -c "$cmd" 2>&1) + exit_code=$? + + local end_ts=$(date +%s) + local duration=$((end_ts - start_ts)) + + if [[ -n "$output" ]]; then + echo "$output" | sed 's/^/│ /' + fi + + local status + if [[ $exit_code -eq 0 ]]; then + status="OK" + elif [[ $exit_code -eq 124 ]]; then + status="TIMEOUT" + else + status="FAILED" + fi + + echo "├─────────────────────────────────────────────────────────────" + echo "│ STATUS: $status (exit: $exit_code, duration: ${duration}s)" + echo "└─────────────────────────────────────────────────────────────" + + ACTION_LOG+=("$status|$action_name|exit=$exit_code|${duration}s") + return $exit_code + } + + execution_summary() { + local script_end=$(date +%s) + local total_duration=$((script_end - SCRIPT_START)) + + echo "" + echo "╔═════════════════════════════════════════════════════════════" + echo "║ EXECUTION SUMMARY - report-fbc-build-status" + echo "║ Total Duration: ${total_duration}s" + echo "║ Final Exit Code: ${FINAL_EXIT_CODE}" + echo "╠═════════════════════════════════════════════════════════════" + + local ok_count=0 fail_count=0 timeout_count=0 + + for entry in "${ACTION_LOG[@]}"; do + local status=$(echo "$entry" | cut -d'|' -f1) + local action=$(echo "$entry" | cut -d'|' -f2) + local details=$(echo "$entry" | cut -d'|' -f3-) + + case "$status" in + OK) ok_count=$((ok_count + 1)); echo "║ ✓ $action ($details)" ;; + FAILED) fail_count=$((fail_count + 1)); echo "║ ✗ $action ($details)" ;; + TIMEOUT) timeout_count=$((timeout_count + 1)); echo "║ ⏱ $action ($details)" ;; + esac + done + + echo "╠═════════════════════════════════════════════════════════════" + echo "║ OK: $ok_count | FAILED: $fail_count | TIMEOUT: $timeout_count" + echo "╚═════════════════════════════════════════════════════════════" + + exit ${FINAL_EXIT_CODE} + } + + trap execution_summary EXIT + + # ============================================================ + # RUN LOGIC + # ============================================================ + set -uo pipefail + + GIT_URL="$(params.git-url)" + REVISION="$(params.revision)" + PR_NAME="$(params.pipelinerun-name)" + PR_NS="$(params.pipelinerun-namespace)" + PIPELINE_STATUS="$(params.PIPELINE_AGGREGATE_STATUS)" + + # ── Read PipelineRun metadata ── + PR_JSON=$(kubectl get pipelinerun "$PR_NAME" -n "$PR_NS" -o json 2>/dev/null) + if [[ $? -ne 0 || -z "$PR_JSON" ]]; then + ACTION_LOG+=("FAILED|Read PipelineRun|exit=1|0s") + echo "Could not read PipelineRun $PR_NAME in $PR_NS (RBAC?)" + exit 0 + fi + ACTION_LOG+=("OK|Read PipelineRun|exit=0|0s") + + # ── Guard: only push events ── + EVENT_TYPE=$(echo "$PR_JSON" | jq -r \ + '.metadata.labels["pipelinesascode.tekton.dev/event-type"] // empty') + if [[ "$EVENT_TYPE" != "push" ]]; then + echo "Skipping: event-type is '${EVENT_TYPE:-unknown}', not 'push'" + ACTION_LOG+=("OK|Skip non-push event|exit=0|0s") + exit 0 + fi + + # ── Parse application label -> version + environment ── + APPLICATION=$(echo "$PR_JSON" | jq -r \ + '.metadata.labels["appstudio.openshift.io/application"] // empty') + if [[ -z "$APPLICATION" ]]; then + ACTION_LOG+=("FAILED|Parse application label|exit=1|0s") + echo "Missing appstudio.openshift.io/application label" + exit 0 + fi + + APP_SUFFIX="${APPLICATION#cnv-fbc-}" + if [[ "$APP_SUFFIX" == *-prod ]]; then + ENVIRONMENT="prod" + VERSION_RAW="${APP_SUFFIX%-prod}" + else + ENVIRONMENT="stage" + VERSION_RAW="$APP_SUFFIX" + fi + VERSION="${VERSION_RAW//-/.}" + ACTION_LOG+=("OK|Parse labels: version=$VERSION env=$ENVIRONMENT|exit=0|0s") + + echo "┌─────────────────────────────────────" + echo "│ FBC Build Status Report" + echo "├─────────────────────────────────────" + echo "│ Version: $VERSION" + echo "│ Environment: $ENVIRONMENT" + echo "│ Status: $PIPELINE_STATUS" + echo "│ Revision: ${REVISION:0:8}" + echo "└─────────────────────────────────────" + + # ── Fetch commit message from GitHub API ── + REPO_PATH=$(echo "$GIT_URL" | sed -n 's|.*github\.com[:/]\(.*\)\.git$|\1|p; s|.*github\.com[:/]\(.*\)$|\1|p') + if [[ -z "$REPO_PATH" ]]; then + ACTION_LOG+=("FAILED|Parse GitHub repo from git-url|exit=1|0s") + echo "Could not parse GitHub repo from: $GIT_URL" + exit 0 + fi + + COMMIT_JSON=$(curl -sf --max-time 30 \ + "https://api.github.com/repos/${REPO_PATH}/commits/${REVISION}" 2>/dev/null) + if [[ $? -ne 0 || -z "$COMMIT_JSON" ]]; then + ACTION_LOG+=("FAILED|Fetch commit message|exit=1|0s") + echo "GitHub API failed for ${REVISION:0:8} — skipping" + exit 0 + fi + COMMIT_MSG=$(echo "$COMMIT_JSON" | jq -r '.commit.message' | head -1) + ACTION_LOG+=("OK|Fetch commit message|exit=0|0s") + + # ── Extract BundleID and channel ── + BUNDLE_ID=$(echo "$COMMIT_MSG" | sed -n 's/.*hco-bundle-registry:\([^ ]*\).*/\1/p') + CHANNEL=$(echo "$COMMIT_MSG" | sed -n 's/.*on \(.*\) channel/\1/p') + + if [[ -z "$BUNDLE_ID" ]]; then + echo "Not a bundle update commit, skipping" + echo "Commit: $COMMIT_MSG" + ACTION_LOG+=("OK|Skip non-bundle commit|exit=0|0s") + exit 0 + fi + ACTION_LOG+=("OK|Extract BundleID=$BUNDLE_ID channel=${CHANNEL:-unknown}|exit=0|0s") + + # ── Validate secret ── + if [[ -z "${SMARTSHEET_TOKEN:-}" || -z "${SHEET_ID:-}" ]]; then + ACTION_LOG+=("FAILED|Validate smartsheets secret|exit=1|0s") + echo "smartsheets secret not configured (missing token or SheetId)" + exit 0 + fi + ACTION_LOG+=("OK|Validate smartsheets secret|exit=0|0s") + + # ── Smartsheet API with retry + exponential backoff ── + smartsheet_api_call() { + local METHOD="$1" ENDPOINT="$2" DATA="${3:-}" + local MAX_RETRIES=3 RETRY_DELAY=2 + + for ((i=1; i<=MAX_RETRIES; i++)); do + local CURL_ARGS=(-s -w "\n%{http_code}" --max-time 30 -X "$METHOD" + "https://api.smartsheet.com/2.0${ENDPOINT}" + -H "Authorization: Bearer ${SMARTSHEET_TOKEN}" + -H "Content-Type: application/json") + [[ -n "$DATA" ]] && CURL_ARGS+=(-d "$DATA") + + RESPONSE=$(curl "${CURL_ARGS[@]}" 2>/dev/null) + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') + + case "$HTTP_CODE" in + 200|201) echo "$BODY"; return 0 ;; + 429) + echo "WARN: Rate limited, waiting ${RETRY_DELAY}s (attempt $i/$MAX_RETRIES)" >&2 + sleep "$RETRY_DELAY"; RETRY_DELAY=$((RETRY_DELAY * 2)) ;; + 401|403) + echo "ERROR: Auth failed (HTTP $HTTP_CODE)" >&2; return 1 ;; + *) + echo "WARN: API error $HTTP_CODE (attempt $i/$MAX_RETRIES)" >&2 + sleep "$RETRY_DELAY"; RETRY_DELAY=$((RETRY_DELAY * 2)) ;; + esac + done + echo "ERROR: Max retries exceeded" >&2; return 1 + } + + # ── Discover column IDs ── + echo "Discovering Smartsheet columns..." + COLUMNS_JSON=$(smartsheet_api_call "GET" "/sheets/${SHEET_ID}/columns" "") + if [[ $? -ne 0 || -z "$COLUMNS_JSON" ]]; then + ACTION_LOG+=("FAILED|Discover columns|exit=1|0s") + echo "Column discovery failed" + exit 0 + fi + + col_id() { echo "$COLUMNS_JSON" | jq -r --arg t "$1" '.data[] | select(.title==$t) | .id'; } + + COL_NAME=$(col_id "Name") + COL_BUNDLEID=$(col_id "BundleID") + COL_FBC_STAGE=$(col_id "FBC Stage") + COL_FBC_STAGE_RUN=$(col_id "FBC Stage Run") + COL_FBC_STAGE_CHANNEL=$(col_id "FBC Stage Channel") + COL_FBC_PROD=$(col_id "FBC Prod") + COL_FBC_PROD_RUN=$(col_id "FBC Prod Run") + COL_FBC_PROD_CHANNEL=$(col_id "FBC Prod Channel") + + if [[ -z "$COL_NAME" || "$COL_NAME" == "null" ]]; then + ACTION_LOG+=("FAILED|Validate columns|exit=1|0s") + echo "Primary column 'Name' not found" + exit 0 + fi + if [[ -z "$COL_BUNDLEID" || "$COL_BUNDLEID" == "null" ]]; then + ACTION_LOG+=("FAILED|Validate columns|exit=1|0s") + echo "Column 'BundleID' not found" + exit 0 + fi + ACTION_LOG+=("OK|Discover columns|exit=0|0s") + + # ── Select target columns based on environment ── + if [[ "$ENVIRONMENT" == "prod" ]]; then + COL_STATUS="$COL_FBC_PROD" + COL_RUN="$COL_FBC_PROD_RUN" + COL_CHANNEL="$COL_FBC_PROD_CHANNEL" + else + COL_STATUS="$COL_FBC_STAGE" + COL_RUN="$COL_FBC_STAGE_RUN" + COL_CHANNEL="$COL_FBC_STAGE_CHANNEL" + fi + + if [[ -z "$COL_STATUS" || "$COL_STATUS" == "null" ]]; then + ACTION_LOG+=("FAILED|Validate FBC columns|exit=1|0s") + echo "FBC columns not found — add them to the Smartsheet first" + exit 0 + fi + ACTION_LOG+=("OK|Select ${ENVIRONMENT} columns|exit=0|0s") + + # ── Fetch sheet data and find rows ── + SHEET_DATA=$(smartsheet_api_call "GET" "/sheets/${SHEET_ID}" "") + if [[ $? -ne 0 || -z "$SHEET_DATA" ]]; then + ACTION_LOG+=("FAILED|Fetch sheet data|exit=1|0s") + echo "Failed to fetch sheet data" + exit 0 + fi + ACTION_LOG+=("OK|Fetch sheet data|exit=0|0s") + + # L0: version row + VERSION_ROW_ID=$(echo "$SHEET_DATA" | jq -r --arg v "$VERSION" --argjson c "$COL_NAME" \ + '[.rows[]? | select(.parentId == null) | + select(.cells[]? | select(.columnId == $c and .value == $v))] | first | .id // empty') + + if [[ -z "$VERSION_ROW_ID" || "$VERSION_ROW_ID" == "null" ]]; then + ACTION_LOG+=("FAILED|Find L0 version row|exit=1|0s") + echo "Version row '$VERSION' not found" + exit 0 + fi + echo "Found L0 version row: $VERSION_ROW_ID" + + # L1: hco-bundle-registry application row + APP_ROW_ID=$(echo "$SHEET_DATA" | jq -r \ + --arg app "hco-bundle-registry" --argjson c "$COL_NAME" --argjson pid "$VERSION_ROW_ID" \ + '[.rows[]? | select(.parentId == $pid) | + select(.cells[]? | select(.columnId == $c and .value == $app))] | first | .id // empty') + + if [[ -z "$APP_ROW_ID" || "$APP_ROW_ID" == "null" ]]; then + ACTION_LOG+=("FAILED|Find L1 hco-bundle-registry row|exit=1|0s") + echo "hco-bundle-registry row not found under '$VERSION'" + exit 0 + fi + echo "Found L1 application row: $APP_ROW_ID" + ACTION_LOG+=("OK|Find L0=$VERSION_ROW_ID L1=$APP_ROW_ID|exit=0|0s") + + # L2: match by BundleID (last match for duplicates) + COMPONENT_ROW_ID=$(echo "$SHEET_DATA" | jq -r \ + --arg bid "$BUNDLE_ID" --argjson cb "$COL_BUNDLEID" --argjson pid "$APP_ROW_ID" \ + '[.rows[]? | select(.parentId == $pid) | + select(.cells[]? | select(.columnId == $cb and .value == $bid))] | last | .id // empty') + + if [[ -z "$COMPONENT_ROW_ID" || "$COMPONENT_ROW_ID" == "null" ]]; then + echo "WARN: No row with BundleID '$BUNDLE_ID' — falling back to latest L2 child" + COMPONENT_ROW_ID=$(echo "$SHEET_DATA" | jq -r --argjson pid "$APP_ROW_ID" \ + '[.rows[]? | select(.parentId == $pid)] | last | .id // empty') + if [[ -z "$COMPONENT_ROW_ID" || "$COMPONENT_ROW_ID" == "null" ]]; then + ACTION_LOG+=("FAILED|Find L2 component row|exit=1|0s") + echo "No L2 rows under hco-bundle-registry for '$VERSION'" + exit 0 + fi + echo "Using fallback L2 row: $COMPONENT_ROW_ID" + else + echo "Found L2 row by BundleID: $COMPONENT_ROW_ID" + fi + ACTION_LOG+=("OK|Find L2 row=$COMPONENT_ROW_ID|exit=0|0s") + + # ── Map status ── + case "$PIPELINE_STATUS" in + "Succeeded"|"Completed") DISPLAY_STATUS="Succeeded" ;; + "Failed") DISPLAY_STATUS="Failed" ;; + *) DISPLAY_STATUS="$PIPELINE_STATUS" ;; + esac + + # ── Build cell update payload ── + CELLS=$(jq -n \ + --argjson cs "$COL_STATUS" --arg status "$DISPLAY_STATUS" \ + --argjson cr "$COL_RUN" --arg run "$PR_NAME" \ + '[{columnId: $cs, value: $status}, {columnId: $cr, value: $run}]') + + if [[ -n "$COL_CHANNEL" && "$COL_CHANNEL" != "null" && -n "$CHANNEL" ]]; then + CELLS=$(echo "$CELLS" | jq --argjson cc "$COL_CHANNEL" --arg ch "$CHANNEL" \ + '. + [{columnId: $cc, value: $ch}]') + fi + + UPDATE_DATA=$(jq -n --argjson rid "$COMPONENT_ROW_ID" --argjson cells "$CELLS" \ + '[{id: $rid, cells: $cells}]') + + # ── Update row ── + smartsheet_api_call "PUT" "/sheets/${SHEET_ID}/rows" "$UPDATE_DATA" >/dev/null + if [[ $? -ne 0 ]]; then + ACTION_LOG+=("FAILED|Update Smartsheet row|exit=1|0s") + echo "Failed to update row $COMPONENT_ROW_ID" + exit 0 + fi + ACTION_LOG+=("OK|Update Smartsheet row|exit=0|0s") + + echo "" + echo "╔═════════════════════════════════════" + echo "║ FBC build status reported" + echo "║ Version: $VERSION" + echo "║ Environment: $ENVIRONMENT" + echo "║ BundleID: $BUNDLE_ID" + echo "║ Channel: ${CHANNEL:-unknown}" + echo "║ Status: $DISPLAY_STATUS" + echo "║ PipelineRun: $PR_NAME" + echo "╚═════════════════════════════════════" diff --git a/pipelines/README.md b/pipelines/README.md new file mode 100644 index 0000000..11b3354 --- /dev/null +++ b/pipelines/README.md @@ -0,0 +1,69 @@ +# CNV Tekton Pipelines + +Tekton pipelines for CNV component builds and releases in Konflux. + +## Pipelines + +### update-bundle + +Updates the HCO bundle registry with component snapshots and publishes the release orchestrator chart. + +**Trigger:** Runs when a component snapshot is created for `hco-bundle-registry`. + +**Parameters:** + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `snapshot` | string | Yes | — | Snapshot reference (e.g., `namespace/snapshot-id`) | +| `skeleton-chart-version` | string | No | `0.0.0-skeleton` | Version of skeleton chart to pull from OCI registry | + +**Steps:** + +1. **update-bundle**: Updates `hco-bundle-registry` GitLab repo with component image references +2. **push-release-chart**: Pulls skeleton chart, hydrates with snapshots, and pushes versioned chart + +**Secrets Required:** + +| Secret | Keys | Purpose | +|--------|------|---------| +| `hco-updater-gitlab-token` | `GITLAB_TOKEN` | GitLab repo access | +| `quay-builds-creds` | `.dockerconfigjson` | OCI registry push | + +**Outputs:** + +- `bundle-version`: Version string (e.g., `4.99.0-rhel9.2555`) +- `snapshots-to-release`: JSON map of component → snapshot ID + +**Version Format:** + +``` +Input: build-bundle.json { XY: "v4.99", Z: "0", os: "rhel9", release: "2555" } +Output: 4.99.0-rhel9.2555 +``` + +**Dual ReleasePlan:** + +The pipeline sets two ReleasePlans in the chart: +- `releasePlan`: For regular components (kubevirt, cdi, etc.) +- `bundleReleasePlan`: For `hco-bundle-registry` (separate Konflux pipeline) + +--- + +## Development + +### Testing Locally + +The pipelines require Konflux cluster access. For local validation: + +```bash +# Validate YAML syntax +kubectl apply --dry-run=client -f pipelines/update-bundle.yaml +``` + +### Required Tools in Pipeline Images + +- `kubectl` - Kubernetes API access +- `jq` - JSON processing +- `yq` - YAML processing +- `helm` (v3.x) - Chart operations +- `git` - Repository operations diff --git a/pipelines/update-bundle.yaml b/pipelines/update-bundle.yaml new file mode 100644 index 0000000..eaa84d8 --- /dev/null +++ b/pipelines/update-bundle.yaml @@ -0,0 +1,786 @@ +--- +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: update-bundle +spec: + description: This tasks gets all the required data of an FBC build and commits it to a payload repository to be used in a GitOps environment. + params: + - name: snapshot + type: string + - name: skeleton-chart-version + type: string + default: "0.0.0-skeleton" + - name: debug + type: string + default: "false" + description: Enable debug mode with verbose output (set -x) + tasks: + - name: update-bundle + params: + - name: SNAPSHOT + value: "$(params.snapshot)" + - name: SKELETON_CHART_VERSION + value: "$(params.skeleton-chart-version)" + - name: DEBUG + value: "$(params.debug)" + taskSpec: + params: + - name: SNAPSHOT + type: string + - name: SKELETON_CHART_VERSION + type: string + - name: DEBUG + type: string + results: + - name: snapshots-to-release + description: Contents of the snapshots.json file + - name: bundle-version + description: NVR of the bundle + volumes: + - name: quay-creds + secret: + secretName: quay-builds-creds + - name: shared-data + emptyDir: {} + steps: + - name: update-bundle + image: quay.io/konflux-ci/release-service-utils@sha256:2f9e6863e82bbc9ddce5a290f3fd0e87657c475e3de8a832b2ef7f8d0671e7d3 + envFrom: + - secretRef: + name: hco-updater-gitlab-token + volumeMounts: + - name: shared-data + mountPath: /var/shared + script: | + #!/usr/bin/env bash + + # ============================================================ + # EXECUTION LAYER - Resilient wrapper for observability + # ============================================================ + SCRIPT_START=$(date +%s) + declare -a ACTION_LOG=() + FINAL_EXIT_CODE=0 + + # Enable debug mode if requested + if [[ "$(params.DEBUG)" == "true" ]]; then + set -x + fi + + exec_action() { + local action_name="$1" + local timeout_secs="${2:-60}" + shift 2 + local cmd="$*" + + local start_ts=$(date +%s) + echo "" + echo "┌─────────────────────────────────────────────────────────────" + echo "│ ACTION: $action_name" + echo "│ CMD: $cmd" + echo "│ TIMEOUT: ${timeout_secs}s" + echo "├─────────────────────────────────────────────────────────────" + + local output + local exit_code + output=$(timeout "$timeout_secs" bash -c "$cmd" 2>&1) + exit_code=$? + + local end_ts=$(date +%s) + local duration=$((end_ts - start_ts)) + + if [[ -n "$output" ]]; then + echo "$output" | sed 's/^/│ /' + fi + + local status + if [[ $exit_code -eq 0 ]]; then + status="OK" + elif [[ $exit_code -eq 124 ]]; then + status="TIMEOUT" + else + status="FAILED" + fi + + echo "├─────────────────────────────────────────────────────────────" + echo "│ STATUS: $status (exit: $exit_code, duration: ${duration}s)" + echo "└─────────────────────────────────────────────────────────────" + + ACTION_LOG+=("$status|$action_name|exit=$exit_code|${duration}s") + return $exit_code + } + + execution_summary() { + local script_exit_code=$? + local script_end=$(date +%s) + local total_duration=$((script_end - SCRIPT_START)) + + echo "" + echo "╔═════════════════════════════════════════════════════════════" + echo "║ EXECUTION SUMMARY - update-bundle" + echo "║ Total Duration: ${total_duration}s" + echo "║ Final Exit Code: ${FINAL_EXIT_CODE:-$script_exit_code}" + echo "╠═════════════════════════════════════════════════════════════" + + local ok_count=0 fail_count=0 timeout_count=0 + + for entry in "${ACTION_LOG[@]}"; do + local status=$(echo "$entry" | cut -d'|' -f1) + local action=$(echo "$entry" | cut -d'|' -f2) + local details=$(echo "$entry" | cut -d'|' -f3-) + + case "$status" in + OK) ok_count=$((ok_count + 1)); echo "║ ✓ $action ($details)" ;; + FAILED) fail_count=$((fail_count + 1)); echo "║ ✗ $action ($details)" ;; + TIMEOUT) timeout_count=$((timeout_count + 1)); echo "║ ⏱ $action ($details)" ;; + esac + done + + echo "╠═════════════════════════════════════════════════════════════" + echo "║ OK: $ok_count | FAILED: $fail_count | TIMEOUT: $timeout_count" + echo "╚═════════════════════════════════════════════════════════════" + + exit ${FINAL_EXIT_CODE:-$script_exit_code} + } + + trap execution_summary EXIT + + # ============================================================ + # RUN LOGIC - Business logic with fail-fast behavior + # ============================================================ + set -eo pipefail + + echo "=== PHASE 1: Fetching snapshot data ===" + + # Validate SNAPSHOT parameter + if [[ -z "$(params.SNAPSHOT)" ]]; then + echo "ERROR: SNAPSHOT parameter is empty" + FINAL_EXIT_CODE=1 + exit 1 + fi + ACTION_LOG+=("OK|Validate SNAPSHOT parameter|exit=0|0s") + + SNAPSHOT_ID=$(echo "$(params.SNAPSHOT)" | awk -F'/' '{print $2}') + if [[ -z "$SNAPSHOT_ID" ]]; then + echo "ERROR: Could not extract SNAPSHOT_ID from: $(params.SNAPSHOT)" + FINAL_EXIT_CODE=1 + exit 1 + fi + ACTION_LOG+=("OK|Extract SNAPSHOT_ID|exit=0|0s") + echo "│ Snapshot ID: $SNAPSHOT_ID" + + if ! exec_action "Fetch snapshot from Kubernetes" 60 "kubectl get snapshot $SNAPSHOT_ID -ojson"; then + echo "ERROR: Failed to fetch snapshot $SNAPSHOT_ID" + FINAL_EXIT_CODE=1 + exit 1 + fi + SNAPSHOT_DATA=$(kubectl get snapshot $SNAPSHOT_ID -ojson) + + # Validate JSON response + if ! echo "$SNAPSHOT_DATA" | jq -e . > /dev/null 2>&1; then + echo "ERROR: Invalid JSON response from kubectl" + FINAL_EXIT_CODE=1 + exit 1 + fi + ACTION_LOG+=("OK|Validate snapshot JSON|exit=0|0s") + + NAMESPACE=$(jq -r '.metadata.namespace' <<< "$SNAPSHOT_DATA") + APPLICATION=$(jq -r '.spec.application' <<< "$SNAPSHOT_DATA") + VERSION=$(echo "$NAMESPACE" | cut -d'-' -f1,2) + echo "│ Namespace: $NAMESPACE" + echo "│ Application: $APPLICATION" + echo "│ Version: $VERSION" + + echo "=== PHASE 2: Cloning hco-bundle-registry ===" + + # Validate GITLAB_TOKEN + if [[ -z "${GITLAB_TOKEN:-}" ]]; then + echo "ERROR: GITLAB_TOKEN environment variable is not set" + FINAL_EXIT_CODE=1 + exit 1 + fi + ACTION_LOG+=("OK|Validate GITLAB_TOKEN|exit=0|0s") + + REPO_URL="https://gitlab.cee.redhat.com/openshift-virtualization/konflux-builds/${VERSION}/hco-bundle-registry.git" + REPO_URL_WITH_AUTH=$(echo "$REPO_URL" | sed "s|https://|https://oauth2:${GITLAB_TOKEN}@|") + + if ! exec_action "Clone hco-bundle-registry" 120 "git clone $REPO_URL_WITH_AUTH --depth 50 hco-bundle-registry"; then + echo "ERROR: Failed to clone repository" + FINAL_EXIT_CODE=1 + exit 1 + fi + + cd hco-bundle-registry + git remote set-url origin "$REPO_URL_WITH_AUTH" + git config user.name "submodule-sync" + git config user.email "no-reply@redhat.com" + ACTION_LOG+=("OK|Configure git identity|exit=0|0s") + + echo "=== PHASE 3: Validating commit consistency ===" + IMAGES=$(jq -r '.spec.components[].containerImage' <<< "$SNAPSHOT_DATA") + + # Check commit consistency directly -- large JSON can't be passed safely through exec_action + echo "" + echo "┌─────────────────────────────────────────────────────────────" + echo "│ ACTION: Validate commit consistency" + echo "│ CMD: jq '[.spec.components[].source.git.revision] | unique | length' /tmp/snapshot.json" + echo "│ TIMEOUT: 30s" + echo "├─────────────────────────────────────────────────────────────" + + echo "$SNAPSHOT_DATA" > /tmp/snapshot.json + consistency_exit=0 + UNIQUE_COUNT=$(jq '[.spec.components[].source.git.revision] | unique | length' /tmp/snapshot.json 2>&1) || consistency_exit=$? + + if [[ $consistency_exit -ne 0 ]]; then + echo "│ ERROR: jq failed to parse snapshot data" | sed 's/^/│ /' + echo "├─────────────────────────────────────────────────────────────" + echo "│ STATUS: FAILED (exit: $consistency_exit, duration: 0s)" + echo "└─────────────────────────────────────────────────────────────" + ACTION_LOG+=("FAILED|Validate commit consistency|exit=$consistency_exit|0s") + FINAL_EXIT_CODE=1 + exit 1 + fi + + COMPONENT_COUNT=$(jq '.spec.components | length' /tmp/snapshot.json) + + if [[ "$UNIQUE_COUNT" -eq 0 ]]; then + echo "│ WARN: Snapshot contains no components" + echo "├─────────────────────────────────────────────────────────────" + echo "│ STATUS: FAILED (exit: 1, duration: 0s)" + echo "└─────────────────────────────────────────────────────────────" + ACTION_LOG+=("FAILED|Validate commit consistency|exit=1|0s") + FINAL_EXIT_CODE=1 + exit 1 + elif [[ "$UNIQUE_COUNT" -eq 1 ]]; then + echo "│ OK: All $COMPONENT_COUNT components point to the same commit" + echo "├─────────────────────────────────────────────────────────────" + echo "│ STATUS: OK (exit: 0, duration: 0s)" + echo "└─────────────────────────────────────────────────────────────" + ACTION_LOG+=("OK|Validate commit consistency|exit=0|0s") + else + echo "│ WARN: $UNIQUE_COUNT unique revisions across $COMPONENT_COUNT components" + echo "│ Component revisions:" + jq -r '.spec.components[] | "│ \(.name): \(.source.git.revision)"' /tmp/snapshot.json + echo "│ Unique revisions:" + jq -r '[.spec.components[].source.git.revision] | unique | .[] | "│ \(.)"' /tmp/snapshot.json + echo "├─────────────────────────────────────────────────────────────" + echo "│ STATUS: WARN (exit: 0, duration: 0s)" + echo "└─────────────────────────────────────────────────────────────" + ACTION_LOG+=("WARN|Validate commit consistency|exit=0|0s") + FINAL_EXIT_CODE=0 + exit 0 + fi + + rm -f /tmp/snapshot.json + + # Save Snapshot object for GC resilience (stripped, with velero label) + if [[ "$APPLICATION" != *"-test-"* ]]; then + snapshot_objects_dir="components/hco-bundle-registry/snapshot-objects" + mkdir -p "$snapshot_objects_dir" + if ! exec_action "Save Snapshot object for ${APPLICATION}" 30 "set -o pipefail; kubectl get snapshot '$SNAPSHOT_ID' -oyaml | yq 'del(.metadata.ownerReferences) | .metadata.labels[\"velero.io/restore-name\"] = \"snapshot-persistence\"' > '${snapshot_objects_dir}/${APPLICATION}.yaml'"; then + echo "WARN: Failed to save Snapshot object for ${APPLICATION} (non-fatal)" + fi + fi + + # Define snapshots_file outside function so it's available in main script + snapshots_file="components/hco-bundle-registry/snapshots.json" + + function update_images_snapshots() { + for img in $IMAGES; do + image_name_sha=$(echo "${img##*/}") + image_name=$(echo "$image_name_sha" | cut -d'@' -f1) + + # Virt-platform-autopilot should also be in the core-params + if [[ ("$image_name" == *"-operator"* || "$image_name" == *"virt-platform-autopilot"*) && "$image_name" != *"-test-"* ]]; then + json_file="components/hco-bundle-registry/core-params.json" + else + json_file="components/hco-bundle-registry/extra-params.json" + fi + jq -S ".\"${image_name}-image\" = \"${image_name_sha}\"" "$json_file" > "${json_file}.tmp" && mv "${json_file}.tmp" "$json_file" + done + + touch ${snapshots_file} + if [[ ! "$APPLICATION" == *"-test-"* ]]; then + jq -n \ + --slurpfile f "$snapshots_file" \ + --arg app "$APPLICATION" \ + --arg snap "$SNAPSHOT_ID" \ + '$f[0] // {} | .[$app] = $snap' \ + > "$snapshots_file.tmp" \ + && mv "$snapshots_file.tmp" "$snapshots_file" + else + echo "Test application, skipping snapshot being added to the bundle, as it won't be released" + fi + } + + echo "=== PHASE 4: Updating image references ===" + # Export function AND variables needed by function when run in subshell via exec_action + export -f update_images_snapshots + export IMAGES APPLICATION SNAPSHOT_ID snapshots_file + if ! exec_action "Update image references" 60 "update_images_snapshots"; then + echo "ERROR: Failed to update image references" + FINAL_EXIT_CODE=1 + exit 1 + fi + + echo "=== PHASE 5: Committing and pushing changes ===" + if [[ -n "$(git status --porcelain)" ]]; then + git add . + git commit -m "Update HCO bundle registry with new images" + ACTION_LOG+=("OK|Commit changes|exit=0|0s") + + if ! exec_action "Pull with rebase" 60 "git pull --rebase origin main"; then + echo "ERROR: Failed to pull with rebase" + FINAL_EXIT_CODE=1 + exit 1 + fi + + if ! exec_action "Push to origin" 60 "git push origin main"; then + echo "ERROR: Failed to push to origin" + FINAL_EXIT_CODE=1 + exit 1 + fi + echo "│ OK: Changes pushed to GitLab" + else + ACTION_LOG+=("OK|Skip - No changes detected|exit=0|0s") + echo "│ SKIP: No changes detected in the bundle registry" + fi + + # Copy Snapshot objects to shared volume for push-release-chart step + mkdir -p /var/shared/snapshot-objects + cp components/hco-bundle-registry/snapshot-objects/*.yaml /var/shared/snapshot-objects/ 2>/dev/null || true + + if [[ "$(params.SNAPSHOT)" == *"hco-bundle-registry"* ]]; then + echo "=== PHASE 6: Preparing release data for hco-bundle-registry ===" + REPO_REVISION=$(jq -r '.spec.components[0].source.git.revision' <<< "$SNAPSHOT_DATA") + echo "│ Repo revision: $REPO_REVISION" + + if [[ ! -f "build-bundle.json" ]]; then + echo "ERROR: build-bundle.json not found" + FINAL_EXIT_CODE=1 + exit 1 + fi + + BUNDLE_VERSION=$(cat build-bundle.json | jq -r '.metadata.Version | "\(.XY).\(.Z)-\(.os).\(.release)" | sub("^v"; "")') + if [[ -z "$BUNDLE_VERSION" || "$BUNDLE_VERSION" == "null" ]]; then + echo "ERROR: Failed to extract bundle version" + FINAL_EXIT_CODE=1 + exit 1 + fi + echo "│ Bundle version: $BUNDLE_VERSION" + ACTION_LOG+=("OK|Extract bundle version|exit=0|0s") + + echo -n "$BUNDLE_VERSION" > /tekton/results/bundle-version + if [[ ! -f "/tekton/results/bundle-version" ]]; then + echo "ERROR: Failed to write bundle-version result" + FINAL_EXIT_CODE=1 + exit 1 + fi + ACTION_LOG+=("OK|Write bundle-version result|exit=0|0s") + + if ! exec_action "Checkout release revision" 30 "git checkout $REPO_REVISION"; then + echo "ERROR: Failed to checkout revision $REPO_REVISION" + FINAL_EXIT_CODE=1 + exit 1 + fi + + update_images_snapshots + ACTION_LOG+=("OK|Update images for release|exit=0|0s") + + SNAPSHOTS_TO_RELEASE=$(cat $snapshots_file) + echo -n "$SNAPSHOTS_TO_RELEASE" > /tekton/results/snapshots-to-release + if [[ ! -f "/tekton/results/snapshots-to-release" ]]; then + echo "ERROR: Failed to write snapshots-to-release result" + FINAL_EXIT_CODE=1 + exit 1 + fi + ACTION_LOG+=("OK|Write snapshots-to-release result|exit=0|0s") + echo "│ OK: Release data written to results" + fi + + echo "=== update-bundle step completed ===" + FINAL_EXIT_CODE=0 + - name: push-release-chart + image: quay.io/redhat-user-workloads/ocp-virt-images-tenant/pipeline-tools@sha256:18f9f819e9393891ce7ae19414a5672786e3efb120bdeaa2c254d8ba5d8e2493 + env: + - name: HOME + value: /tmp + envFrom: + - secretRef: + name: hco-updater-gitlab-token + - secretRef: + name: quay-builds-creds + volumeMounts: + - name: quay-creds + mountPath: /tmp/quay-builds-creds + readOnly: true + - name: shared-data + mountPath: /var/shared + script: | + #!/usr/bin/env bash + + # ============================================================ + # EXECUTION LAYER - Resilient wrapper for observability + # ============================================================ + SCRIPT_START=$(date +%s) + declare -a ACTION_LOG=() + FINAL_EXIT_CODE=0 + + # Enable debug mode if requested + if [[ "$(params.DEBUG)" == "true" ]]; then + set -x + fi + + exec_action() { + local action_name="$1" + local timeout_secs="${2:-60}" + shift 2 + local cmd="$*" + + local start_ts=$(date +%s) + echo "" + echo "┌─────────────────────────────────────────────────────────────" + echo "│ ACTION: $action_name" + echo "│ CMD: $cmd" + echo "│ TIMEOUT: ${timeout_secs}s" + echo "├─────────────────────────────────────────────────────────────" + + local output + local exit_code + output=$(timeout "$timeout_secs" bash -c "$cmd" 2>&1) + exit_code=$? + + local end_ts=$(date +%s) + local duration=$((end_ts - start_ts)) + + if [[ -n "$output" ]]; then + echo "$output" | sed 's/^/│ /' + fi + + local status + if [[ $exit_code -eq 0 ]]; then + status="OK" + elif [[ $exit_code -eq 124 ]]; then + status="TIMEOUT" + else + status="FAILED" + fi + + echo "├─────────────────────────────────────────────────────────────" + echo "│ STATUS: $status (exit: $exit_code, duration: ${duration}s)" + echo "└─────────────────────────────────────────────────────────────" + + ACTION_LOG+=("$status|$action_name|exit=$exit_code|${duration}s") + return $exit_code + } + + execution_summary() { + local script_exit_code=$? + local script_end=$(date +%s) + local total_duration=$((script_end - SCRIPT_START)) + + echo "" + echo "╔═════════════════════════════════════════════════════════════" + echo "║ EXECUTION SUMMARY - push-release-chart" + echo "║ Total Duration: ${total_duration}s" + echo "║ Final Exit Code: ${FINAL_EXIT_CODE:-$script_exit_code}" + echo "╠═════════════════════════════════════════════════════════════" + + local ok_count=0 fail_count=0 timeout_count=0 + + for entry in "${ACTION_LOG[@]}"; do + local status=$(echo "$entry" | cut -d'|' -f1) + local action=$(echo "$entry" | cut -d'|' -f2) + local details=$(echo "$entry" | cut -d'|' -f3-) + + case "$status" in + OK) ok_count=$((ok_count + 1)); echo "║ ✓ $action ($details)" ;; + FAILED) fail_count=$((fail_count + 1)); echo "║ ✗ $action ($details)" ;; + TIMEOUT) timeout_count=$((timeout_count + 1)); echo "║ ⏱ $action ($details)" ;; + esac + done + + echo "╠═════════════════════════════════════════════════════════════" + echo "║ OK: $ok_count | FAILED: $fail_count | TIMEOUT: $timeout_count" + echo "╚═════════════════════════════════════════════════════════════" + + exit ${FINAL_EXIT_CODE:-$script_exit_code} + } + + trap execution_summary EXIT + + # ============================================================ + # RUN LOGIC - Business logic with fail-fast behavior + # ============================================================ + set -eo pipefail + + echo "=== PHASE 1: Checking if hco-bundle-registry release ===" + if [[ "$(params.SNAPSHOT)" != *"hco-bundle-registry"* ]]; then + ACTION_LOG+=("OK|Skip - Not hco-bundle-registry|exit=0|0s") + echo "│ SKIP: Snapshot does not contain 'hco-bundle-registry'" + FINAL_EXIT_CODE=0 + exit 0 + fi + ACTION_LOG+=("OK|Validate snapshot type|exit=0|0s") + echo "│ OK: This is an hco-bundle-registry release" + + echo "=== PHASE 2: Reading results from previous step ===" + + # Validate result files exist + if [[ ! -f "/tekton/results/snapshots-to-release" ]]; then + echo "ERROR: snapshots-to-release result file not found" + FINAL_EXIT_CODE=1 + exit 1 + fi + if [[ ! -f "/tekton/results/bundle-version" ]]; then + echo "ERROR: bundle-version result file not found" + FINAL_EXIT_CODE=1 + exit 1 + fi + ACTION_LOG+=("OK|Validate result files exist|exit=0|0s") + + SNAPSHOTS_TO_RELEASE=$(cat /tekton/results/snapshots-to-release) + BUNDLE_VERSION=$(cat /tekton/results/bundle-version) + + if [[ -z "$BUNDLE_VERSION" ]]; then + echo "ERROR: bundle-version is empty" + FINAL_EXIT_CODE=1 + exit 1 + fi + ACTION_LOG+=("OK|Read previous step results|exit=0|0s") + + XY=$(echo "$BUNDLE_VERSION" | cut -d'.' -f1,2 | tr '.' '-') + echo "│ Bundle Version: $BUNDLE_VERSION" + echo "│ XY Version: $XY" + + echo "=== PHASE 3: Loading configuration ===" + CHART_NAME="hco-bundle-registry" + SKELETON_REGISTRY="quay.io/openshift-virtualization/release-chart" + TARGET_REGISTRY="quay.io/openshift-virtualization/konflux-builds/v${XY}" + SKELETON_VERSION="$(params.SKELETON_CHART_VERSION)" + echo "│ Chart Name: $CHART_NAME" + echo "│ Skeleton Registry: $SKELETON_REGISTRY" + echo "│ Target Registry: $TARGET_REGISTRY" + echo "│ Skeleton Version: $SKELETON_VERSION" + ACTION_LOG+=("OK|Load configuration|exit=0|0s") + + TMPDIR=$(mktemp -d) + cd "$TMPDIR" + + echo "=== PHASE 4: Registry login ===" + + # Validate credentials file exists + if [[ ! -f "/tmp/quay-builds-creds/.dockerconfigjson" ]]; then + echo "ERROR: Quay credentials file not found at /tmp/quay-builds-creds/.dockerconfigjson" + FINAL_EXIT_CODE=1 + exit 1 + fi + + DOCKER_CONFIG=$(cat /tmp/quay-builds-creds/.dockerconfigjson) + + # Validate JSON + if ! echo "$DOCKER_CONFIG" | jq -e . > /dev/null 2>&1; then + echo "ERROR: Invalid JSON in docker config" + FINAL_EXIT_CODE=1 + exit 1 + fi + ACTION_LOG+=("OK|Validate credentials file|exit=0|0s") + + QUAY_USERNAME=$(echo "${DOCKER_CONFIG}" | jq -r '[.auths | to_entries[] | select(.key | contains("quay.io"))] | first | .value.username') + QUAY_PASSWORD=$(echo "${DOCKER_CONFIG}" | jq -r '[.auths | to_entries[] | select(.key | contains("quay.io"))] | first | .value.password') + + if [[ -z "${QUAY_USERNAME}" || "${QUAY_USERNAME}" == "null" ]]; then + echo "ERROR: Could not extract quay.io credentials from secret" + FINAL_EXIT_CODE=1 + exit 1 + fi + ACTION_LOG+=("OK|Extract Quay credentials|exit=0|0s") + echo "│ OK: Extracted credentials for user: $QUAY_USERNAME" + + # Login directly -- password can't be piped through exec_action safely + echo "" + echo "┌─────────────────────────────────────────────────────────────" + echo "│ ACTION: Login to Quay registry" + echo "│ CMD: echo '***' | helm registry login quay.io --username '${QUAY_USERNAME}' --password-stdin" + echo "│ TIMEOUT: 30s" + echo "├─────────────────────────────────────────────────────────────" + login_exit=0 + login_output=$(echo "${QUAY_PASSWORD}" | timeout 30 helm registry login quay.io --username "${QUAY_USERNAME}" --password-stdin 2>&1) || login_exit=$? + if [[ -n "$login_output" ]]; then + echo "$login_output" | sed 's/^/│ /' + fi + echo "├─────────────────────────────────────────────────────────────" + if [[ $login_exit -eq 0 ]]; then + echo "│ STATUS: OK (exit: 0, duration: 0s)" + echo "└─────────────────────────────────────────────────────────────" + ACTION_LOG+=("OK|Login to Quay registry|exit=0|0s") + else + echo "│ STATUS: FAILED (exit: $login_exit, duration: 0s)" + echo "└─────────────────────────────────────────────────────────────" + ACTION_LOG+=("FAILED|Login to Quay registry|exit=$login_exit|0s") + echo "ERROR: Failed to login to Quay registry" + FINAL_EXIT_CODE=1 + exit 1 + fi + echo "│ OK: Logged in to quay.io" + + echo "=== PHASE 5: Pulling skeleton chart ===" + echo "│ Pulling: oci://${SKELETON_REGISTRY}/${CHART_NAME} --version ${SKELETON_VERSION}" + + if ! exec_action "Pull skeleton chart" 120 "helm pull 'oci://${SKELETON_REGISTRY}/${CHART_NAME}' --version '${SKELETON_VERSION}' --untar"; then + echo "ERROR: Failed to pull skeleton chart" + FINAL_EXIT_CODE=1 + exit 1 + fi + + if [[ ! -d "${CHART_NAME}" ]]; then + echo "ERROR: Chart directory ${CHART_NAME} not found after pull" + FINAL_EXIT_CODE=1 + exit 1 + fi + cd "${CHART_NAME}" + # Defense-in-depth: remove stale .tgz left by helm pull --untar + rm -f "../${CHART_NAME}"-*.tgz "${CHART_NAME}"-*.tgz 2>/dev/null || true + ACTION_LOG+=("OK|Cleanup stale tgz archives|exit=0|0s") + echo "│ OK: Skeleton chart extracted" + + echo "=== PHASE 6: Hydrating Chart.yaml ===" + + if [[ ! -f "Chart.yaml" ]]; then + echo "ERROR: Chart.yaml not found" + FINAL_EXIT_CODE=1 + exit 1 + fi + + # Transform 4.99.0-rhel9.2559 to 4.99.0-releases.2559 for clearer chart versioning + SEMVER=$(echo "$BUNDLE_VERSION" | cut -d'-' -f1) + RELEASE_NUM=$(echo "$BUNDLE_VERSION" | grep -oE '[0-9]+$') + + if [[ -z "$SEMVER" || -z "$RELEASE_NUM" ]]; then + echo "ERROR: Failed to parse bundle version: $BUNDLE_VERSION" + FINAL_EXIT_CODE=1 + exit 1 + fi + + CHART_VERSION="${SEMVER}-releases.${RELEASE_NUM}" + + # Derive OS suffix from bundle version for chart name hydration + # Current builds: 4.20.8-rhel9.47 → rhel9; future: rhel10 + # Defensive: legacy/null OS falls back to base CHART_NAME + if [[ "$BUNDLE_VERSION" == *"-"* ]]; then + OS_SUFFIX=$(echo "$BUNDLE_VERSION" | cut -d'-' -f2 | cut -d'.' -f1) + if [[ -n "$OS_SUFFIX" && "$OS_SUFFIX" != "null" ]]; then + HYDRATED_CHART_NAME="${CHART_NAME}-${OS_SUFFIX}" + else + HYDRATED_CHART_NAME="${CHART_NAME}" + fi + else + HYDRATED_CHART_NAME="${CHART_NAME}" + fi + + APP_VERSION="v${BUNDLE_VERSION}" + + if ! exec_action "Hydrate Chart.yaml" 30 "yq -i '.name = \"${HYDRATED_CHART_NAME}\"' Chart.yaml && yq -i '.version = \"${CHART_VERSION}\"' Chart.yaml && yq -i '.appVersion = \"${APP_VERSION}\"' Chart.yaml"; then + echo "ERROR: Failed to update Chart.yaml" + FINAL_EXIT_CODE=1 + exit 1 + fi + echo "│ OK: Chart.yaml updated" + echo "│ name: $HYDRATED_CHART_NAME" + echo "│ version (OCI tag): $CHART_VERSION" + echo "│ appVersion: $APP_VERSION" + + # Feedback loop: verify Chart.yaml was actually hydrated + ACTUAL_NAME=$(yq '.name' Chart.yaml | tr -d '"') + ACTUAL_VERSION=$(yq '.version' Chart.yaml | tr -d '"') + if [[ "$ACTUAL_NAME" != "$HYDRATED_CHART_NAME" ]]; then + echo "ERROR: Chart.yaml name hydration failed. Expected: $HYDRATED_CHART_NAME, Got: $ACTUAL_NAME" + FINAL_EXIT_CODE=1 + exit 1 + fi + if [[ "$ACTUAL_VERSION" != "$CHART_VERSION" ]]; then + echo "ERROR: Chart.yaml version hydration failed. Expected: $CHART_VERSION, Got: $ACTUAL_VERSION" + FINAL_EXIT_CODE=1 + exit 1 + fi + ACTION_LOG+=("OK|Verify Chart.yaml hydration|exit=0|0s") + + echo "=== PHASE 7: Hydrating values.yaml ===" + + if [[ ! -f "values.yaml" ]]; then + echo "ERROR: values.yaml not found" + FINAL_EXIT_CODE=1 + exit 1 + fi + + if ! exec_action "Hydrate values.yaml" 30 "yq -i '.global.targetNamespace = \"v${XY}-openshift-virtualization-tenant\"' values.yaml && yq -i '.global.releasePlanSuffix = \"-releaseplan-prod\"' values.yaml"; then + echo "ERROR: Failed to update values.yaml" + FINAL_EXIT_CODE=1 + exit 1 + fi + echo "│ OK: Global values set" + + echo "=== PHASE 8: Injecting snapshots ===" + + # Validate snapshots JSON + if ! echo "$SNAPSHOTS_TO_RELEASE" | jq -e . > /dev/null 2>&1; then + echo "ERROR: Invalid JSON in snapshots-to-release" + FINAL_EXIT_CODE=1 + exit 1 + fi + ACTION_LOG+=("OK|Validate snapshots JSON|exit=0|0s") + + if ! exec_action "Inject snapshots" 30 "echo '$SNAPSHOTS_TO_RELEASE' | jq -r '{snapshots: .}' | yq eval -P '.' > /tmp/snapshots_with_key.yaml && yq eval-all '. as \$item ireduce ({}; . * \$item)' values.yaml /tmp/snapshots_with_key.yaml -i"; then + echo "ERROR: Failed to inject snapshots" + FINAL_EXIT_CODE=1 + exit 1 + fi + echo "│ OK: Snapshots injected into values.yaml" + + echo "=== PHASE 8b: Injecting Snapshot objects into chart ===" + SNAPSHOT_OBJECTS_SRC="/var/shared/snapshot-objects" + if ls "${SNAPSHOT_OBJECTS_SRC}"/*.yaml 1>/dev/null 2>&1; then + cp "${SNAPSHOT_OBJECTS_SRC}"/*.yaml templates/ + SNAP_COUNT=$(ls "${SNAPSHOT_OBJECTS_SRC}"/*.yaml 2>/dev/null | wc -l) + echo "│ Copied $SNAP_COUNT Snapshot files into chart templates/" + ACTION_LOG+=("OK|Inject Snapshot objects ($SNAP_COUNT files)|exit=0|0s") + else + echo "│ WARN: No Snapshot object files found" + ACTION_LOG+=("OK|Skip Snapshot objects (none found)|exit=0|0s") + fi + + echo "=== PHASE 9: Validating chart ===" + if ! exec_action "Lint chart" 30 "helm lint ."; then + echo "ERROR: Chart validation failed" + FINAL_EXIT_CODE=1 + exit 1 + fi + echo "│ OK: Chart passed validation" + + echo "=== PHASE 10: Packaging chart ===" + if ! exec_action "Package chart" 30 "helm package ."; then + echo "ERROR: Failed to package chart" + FINAL_EXIT_CODE=1 + exit 1 + fi + + PACKAGE_FILE="${HYDRATED_CHART_NAME}-${CHART_VERSION}.tgz" + + if [[ -z "${PACKAGE_FILE}" || ! -f "${PACKAGE_FILE}" ]]; then + echo "ERROR: Failed to find packaged chart file" + FINAL_EXIT_CODE=1 + exit 1 + fi + ACTION_LOG+=("OK|Validate package file|exit=0|0s") + echo "│ OK: Packaged as $PACKAGE_FILE" + + echo "=== PHASE 11: Pushing to OCI registry ===" + if ! exec_action "Push to OCI registry" 120 "helm push '${PACKAGE_FILE}' 'oci://${TARGET_REGISTRY}'"; then + echo "ERROR: Failed to push chart to registry" + FINAL_EXIT_CODE=1 + exit 1 + fi + + echo "=== push-release-chart step completed ===" + echo "│ Published ${HYDRATED_CHART_NAME}:${CHART_VERSION} to oci://${TARGET_REGISTRY} (file: ${PACKAGE_FILE})" + FINAL_EXIT_CODE=0