From f6c7af3262145ddb75f1ed05064a46aac696a9d5 Mon Sep 17 00:00:00 2001 From: Scott J Dickerson Date: Wed, 20 May 2026 20:38:00 -0400 Subject: [PATCH 1/2] :sparkles: Add report images action Adds a GitHub Actions workflow that will report the runtime configuration and images for a Konveyor operator. This change be useful in CI/CD pipelines to ensure that the operator is configured as expected for the current workflow run. The report is a bash script that can be called from the command line. Assisted-by: Cursor:claude-4.6-opus Signed-off-by: Scott J Dickerson --- .github/actions/report-images/action.yml | 81 ++++++ hack/report-images-map.json | 107 +++++++ hack/report-images.sh | 355 +++++++++++++++++++++++ 3 files changed, 543 insertions(+) create mode 100644 .github/actions/report-images/action.yml create mode 100644 hack/report-images-map.json create mode 100755 hack/report-images.sh diff --git a/.github/actions/report-images/action.yml b/.github/actions/report-images/action.yml new file mode 100644 index 0000000..a5bf93a --- /dev/null +++ b/.github/actions/report-images/action.yml @@ -0,0 +1,81 @@ +name: Report Runtime Images +description: | + Queries the live cluster to report all container images configured and running + for the Konveyor operator stack. Writes a markdown report to the GitHub Step + Summary and optionally uploads a JSON artifact. + +inputs: + namespace: + description: "Namespace where Konveyor is installed" + required: false + default: "konveyor-tackle" + upload_artifact: + description: "Upload the JSON report as a workflow artifact" + required: false + default: "false" + artifact_name: + description: "Name for the uploaded artifact" + required: false + default: "operator-runtime-image-report" + artifact_retention_days: + description: "Number of days to retain the artifact" + required: false + default: "90" + +outputs: + json: + description: "The full image report as a JSON string" + value: ${{ steps.report.outputs.json }} + +runs: + using: "composite" + steps: + - name: Ensure jq is available + shell: bash + run: | + if command -v jq &>/dev/null; then + echo "jq is already installed" + exit 0 + fi + echo "::error::jq is required but not found in PATH" + exit 1 + + - name: Verify cluster access + shell: bash + run: | + KUBECTL="kubectl" + if ! command -v kubectl &>/dev/null; then + if command -v oc &>/dev/null; then + KUBECTL="oc" + else + echo "::error::Neither kubectl nor oc found in PATH" + exit 1 + fi + fi + $KUBECTL get namespace "${{ inputs.namespace }}" >/dev/null + + - name: Generate image report + id: report + shell: bash + working-directory: ${{ github.action_path }}/../../.. + run: | + bash hack/report-images.sh -n "${{ inputs.namespace }}" >> "$GITHUB_STEP_SUMMARY" + + JSON=$(bash hack/report-images.sh -n "${{ inputs.namespace }}" --json) + echo "$JSON" > "$RUNNER_TEMP/report-images.json" + + # Make JSON available as an output (delimiter-based to handle multiline) + EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) + { + echo "json<<$EOF" + echo "$JSON" + echo "$EOF" + } >> "$GITHUB_OUTPUT" + + - name: Upload JSON report + if: ${{ inputs.upload_artifact == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact_name }} + path: ${{ runner.temp }}/report-images.json + retention-days: ${{ inputs.artifact_retention_days }} diff --git a/hack/report-images-map.json b/hack/report-images-map.json new file mode 100644 index 0000000..3f4d361 --- /dev/null +++ b/hack/report-images-map.json @@ -0,0 +1,107 @@ +{ + "_comment": "Component map for hack/report-images.sh. Maps RELATED_IMAGE_* env vars to human-readable component names, activation conditions (feature flags), and deployment name prefixes for drift detection. Update this file when images are added/removed from helm/templates/deployment.yaml.", + "components": { + "RELATED_IMAGE_TACKLE_HUB": { + "component": "Hub API Server", + "condition": "always", + "deployment_prefix": "tackle-hub" + }, + "RELATED_IMAGE_TACKLE_UI": { + "component": "UI", + "condition": "always", + "deployment_prefix": "tackle-ui" + }, + "RELATED_IMAGE_KEYCLOAK_SSO": { + "component": "Keycloak SSO", + "condition": "feature_auth_required", + "deployment_prefix": "tackle-keycloak-sso" + }, + "RELATED_IMAGE_TACKLE_POSTGRES": { + "component": "Keycloak PostgreSQL", + "condition": "feature_auth_required", + "deployment_prefix": "tackle-keycloak-postgresql" + }, + "RELATED_IMAGE_OAUTH_PROXY": { + "component": "OAuth Proxy", + "condition": "openshift_cluster", + "deployment_prefix": null + }, + "RELATED_IMAGE_ADDON_ANALYZER": { + "component": "Analyzer Addon", + "condition": "always", + "deployment_prefix": null + }, + "RELATED_IMAGE_ADDON_DISCOVERY": { + "component": "Language Discovery Addon", + "condition": "feature_discovery", + "deployment_prefix": null + }, + "RELATED_IMAGE_ADDON_PLATFORM": { + "component": "Platform Addon", + "condition": "always", + "deployment_prefix": null + }, + "RELATED_IMAGE_PROVIDER_JAVA": { + "component": "Java Provider", + "condition": "always", + "deployment_prefix": null + }, + "RELATED_IMAGE_PROVIDER_GO": { + "component": "Go Provider", + "condition": "always", + "deployment_prefix": null + }, + "RELATED_IMAGE_PROVIDER_PYTHON": { + "component": "Python Provider", + "condition": "always", + "deployment_prefix": null + }, + "RELATED_IMAGE_PROVIDER_NODEJS": { + "component": "Node.js Provider", + "condition": "always", + "deployment_prefix": null + }, + "RELATED_IMAGE_PROVIDER_C_SHARP": { + "component": "C# Provider", + "condition": "always", + "deployment_prefix": null + }, + "RELATED_IMAGE_KANTRA": { + "component": "Kantra CLI", + "condition": "always", + "deployment_prefix": null + }, + "RELATED_IMAGE_KAI": { + "component": "KAI Solution Server", + "condition": "kai_solution_server_enabled", + "deployment_prefix": "kai" + }, + "RELATED_IMAGE_LIGHTSPEED_STACK": { + "component": "LLM Proxy", + "condition": "kai_llm_proxy_enabled", + "deployment_prefix": "llm-proxy" + } + }, + "feature_flags": { + "feature_auth_required": { + "default": true, + "description": "Keycloak + PostgreSQL + OAuth Proxy" + }, + "feature_discovery": { + "default": true, + "description": "Language Discovery Addon" + }, + "kai_solution_server_enabled": { + "default": false, + "description": "KAI Solution Server" + }, + "kai_llm_proxy_enabled": { + "default": false, + "description": "LLM Proxy (Lightspeed Stack)" + }, + "openshift_cluster": { + "default": false, + "description": "OAuth Proxy sidecar" + } + } +} diff --git a/hack/report-images.sh b/hack/report-images.sh new file mode 100755 index 0000000..27673a8 --- /dev/null +++ b/hack/report-images.sh @@ -0,0 +1,355 @@ +#!/bin/bash +# +# hack/report-images.sh - Runtime image introspection for konveyor-operator +# +# Queries a live cluster to report all container images configured and running +# for the Konveyor operator stack. +# +# Usage: +# hack/report-images.sh # markdown output (default) +# hack/report-images.sh --json # JSON output +# hack/report-images.sh -n # custom namespace +# hack/report-images.sh -n --json # both +# +# The component map is loaded from hack/report-images-map.json. Any new +# RELATED_IMAGE_* env vars discovered on the operator that aren't in the map +# will still be reported (as "Unknown" components assumed always active). +# +# Requires: kubectl (or oc), jq + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NAMESPACE="konveyor-tackle" +OUTPUT_FORMAT="markdown" +OPERATOR_DEPLOYMENT="tackle-operator" +MAP_FILE="${SCRIPT_DIR}/report-images-map.json" + +usage() { + cat <&2; exit 1 ;; + esac +done + +# --- Dependency checks --- + +KUBECTL="kubectl" +if ! command -v kubectl &>/dev/null; then + if command -v oc &>/dev/null; then + KUBECTL="oc" + else + echo "Error: neither kubectl nor oc found in PATH" >&2 + exit 1 + fi +fi + +if ! command -v jq &>/dev/null; then + echo "Error: jq is required but not found in PATH" >&2 + echo "Install: https://jqlang.github.io/jq/download/" >&2 + exit 1 +fi + +if [[ ! -f "$MAP_FILE" ]]; then + echo "Error: component map not found at '$MAP_FILE'" >&2 + echo "Expected alongside this script at hack/report-images-map.json" >&2 + exit 1 +fi + +# Load and validate the component map +COMPONENT_MAP=$(jq '.components' "$MAP_FILE") +FLAG_DEFINITIONS=$(jq '.feature_flags' "$MAP_FILE") + +# Verify cluster connectivity +if ! $KUBECTL get namespace "$NAMESPACE" &>/dev/null; then + echo "Error: cannot access namespace '$NAMESPACE'. Check cluster connectivity and namespace name." >&2 + exit 1 +fi + +# --- Data Collection (all as raw JSON) --- + +OPERATOR_JSON=$($KUBECTL get deployment "$OPERATOR_DEPLOYMENT" -n "$NAMESPACE" -o json 2>/dev/null || echo '{}') +TACKLE_CR_JSON=$($KUBECTL get tackles.tackle.konveyor.io -n "$NAMESPACE" -o json 2>/dev/null || echo '{"items":[]}') +DEPLOYMENTS_JSON=$($KUBECTL get deployments -n "$NAMESPACE" -o json 2>/dev/null || echo '{"items":[]}') +ADDONS_JSON=$($KUBECTL get addons.tackle.konveyor.io -n "$NAMESPACE" -o json 2>/dev/null || echo '{"items":[]}') +EXTENSIONS_JSON=$($KUBECTL get extensions.tackle.konveyor.io -n "$NAMESPACE" -o json 2>/dev/null || echo '{"items":[]}') + +# CSV may not exist (Helm-only installs) +CSV_JSON=$($KUBECTL get csv -n "$NAMESPACE" -o json 2>/dev/null || echo '{"items":[]}') +CSV_JSON=$(echo "$CSV_JSON" | jq '[.items[] | select(.metadata.name | test("konveyor"))] | if length > 0 then .[0] else null end') + +# --- Structured data extraction via jq --- + +# Operator metadata and full image catalog from deployment env vars +OPERATOR_DATA=$(echo "$OPERATOR_JSON" | jq '{ + image: .spec.template.spec.containers[0].image, + app_name: ([.spec.template.spec.containers[0].env[] | select(.name == "APP_NAME")] | first | .value // "unknown"), + version: ([.spec.template.spec.containers[0].env[] | select(.name == "VERSION")] | first | .value // "unknown"), + profile: ([.spec.template.spec.containers[0].env[] | select(.name == "PROFILE")] | first | .value // "unknown"), + image_catalog: ([.spec.template.spec.containers[0].env[] | select(.name | startswith("RELATED_IMAGE_")) | {(.name): .value}] | add // {}) +}') + +# Tackle CR spec (first item) +TACKLE_SPEC_JSON=$(echo "$TACKLE_CR_JSON" | jq ' + if .items and (.items | length) > 0 then .items[0].spec // {} + else {} + end +') + +# Feature flags: read from CR spec, apply defaults from the map file +FEATURE_FLAGS=$(jq -n \ + --argjson spec "$TACKLE_SPEC_JSON" \ + --argjson defs "$FLAG_DEFINITIONS" \ + ' + $defs | to_entries | map({ + key: .key, + value: ( + $spec[.key] as $val | + .value.default as $default | + if $val == null then $default + else ($val | tostring | ascii_downcase == "true") + end + ) + }) | from_entries + ') + +# CR image overrides (any spec field ending in _fqin) +CR_OVERRIDES=$(echo "$TACKLE_SPEC_JSON" | jq '[to_entries[] | select(.key | test("_fqin$")) | {(.key): .value}] | add // {}') + +# Running containers from all deployments in namespace +RUNNING_CONTAINERS=$(echo "$DEPLOYMENTS_JSON" | jq '[.items[] | .metadata.name as $deploy | .spec.template.spec.containers[] | {deployment: $deploy, container: .name, image: .image}]') + +# Addon images +ADDON_IMAGES=$(echo "$ADDONS_JSON" | jq '[.items[] | {(.metadata.name): .spec.container.image}] | add // {}') + +# Extension images +EXTENSION_IMAGES=$(echo "$EXTENSIONS_JSON" | jq '[.items[] | {(.metadata.name): .spec.container.image}] | add // {}') + +# CSV related images (null if no CSV) +CSV_RELATED=$(echo "$CSV_JSON" | jq 'if . != null then ([.spec.relatedImages[]? | {(.name): .image}] | add // {}) else null end') + +# --- Build enriched image catalog --- +# Merges the external component map with dynamically discovered env vars. +# Unknown env vars (not in map) are reported as "Discovered" with condition "always". +IMAGE_CATALOG=$(jq -n \ + --argjson operator "$OPERATOR_DATA" \ + --argjson flags "$FEATURE_FLAGS" \ + --argjson map "$COMPONENT_MAP" \ + ' + [$operator.image_catalog | to_entries[] | { + env_var: .key, + image: .value, + component: ($map[.key].component // null), + condition: ($map[.key].condition // "always"), + deployment_prefix: ($map[.key].deployment_prefix // null), + in_map: ($map[.key] != null), + active: ( + ($map[.key].condition // "always") as $cond | + if $cond == "always" then true + elif $flags[$cond] == true then true + else false + end + ) + }] + ') + +# --- Output --- + +if [[ "$OUTPUT_FORMAT" == "json" ]]; then + jq -n \ + --arg ns "$NAMESPACE" \ + --argjson operator "$OPERATOR_DATA" \ + --argjson feature_flags "$FEATURE_FLAGS" \ + --argjson image_catalog "$IMAGE_CATALOG" \ + --argjson cr_overrides "$CR_OVERRIDES" \ + --argjson running_containers "$RUNNING_CONTAINERS" \ + --argjson addon_images "$ADDON_IMAGES" \ + --argjson extension_images "$EXTENSION_IMAGES" \ + --argjson csv_related_images "$CSV_RELATED" \ + '{ + namespace: $ns, + operator: { + image: $operator.image, + app_name: $operator.app_name, + version: $operator.version, + profile: $operator.profile + }, + feature_flags: $feature_flags, + image_catalog: $image_catalog, + cr_overrides: $cr_overrides, + running_containers: $running_containers, + addon_images: $addon_images, + extension_images: $extension_images, + csv_related_images: $csv_related_images + }' +else + # --- Markdown output --- + + echo "# Konveyor Operator - Runtime Image Report" + echo "" + echo "**Namespace:** \`$NAMESPACE\`" + echo "**Report generated:** $(date -u '+%Y-%m-%d %H:%M:%S UTC')" + echo "" + + # Section 1: Operator metadata + echo "## Operator" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "$OPERATOR_DATA" | jq -r ' + "| Deployment | `'"$OPERATOR_DEPLOYMENT"'` |", + "| Image | `\(.image)` |", + "| Version | `\(.version)` |", + "| Profile | `\(.profile)` |" + ' + echo "" + + # Section 2: Feature Flags (driven by map file) + echo "## Feature Flags" + echo "" + echo "These flags determine which optional components are deployed." + echo "" + echo "| Flag | Value | Effect |" + echo "|------|-------|--------|" + jq -n --argjson flags "$FEATURE_FLAGS" --argjson defs "$FLAG_DEFINITIONS" \ + '$defs | to_entries[] | "| `\(.key)` | `\($flags[.key])` | \(.value.description) |"' -r + echo "" + + # Section 3: Image Catalog with active/inactive status + echo "## Image Catalog (from Operator Deployment env)" + echo "" + echo "All images the operator is configured to deploy. Status reflects current feature flags." + echo "" + echo "| Status | Component | Env Var | Image |" + echo "|--------|-----------|---------|-------|" + echo "$IMAGE_CATALOG" | jq -r '.[] | + (if .active then "✅ ACTIVE" else "⬚ INACTIVE" end) as $status | + (if .in_map then .component else "⚠️ \(.env_var | ltrimstr("RELATED_IMAGE_") | gsub("_"; " ") | ascii_downcase) (unmapped)" end) as $name | + "| \($status) | \($name) | `\(.env_var)` | `\(.image)` |" + ' + echo "" + + # Warn about unmapped images + UNMAPPED_COUNT=$(echo "$IMAGE_CATALOG" | jq '[.[] | select(.in_map == false)] | length') + if [[ "$UNMAPPED_COUNT" -gt 0 ]]; then + echo "> **Note:** $UNMAPPED_COUNT image(s) found on the operator deployment are not in the component map." + echo "> Update \`hack/report-images-map.json\` to add component names and activation conditions." + echo "" + fi + + # Section 4: Tackle CR Overrides + echo "## Tackle CR Overrides" + echo "" + OVERRIDE_COUNT=$(echo "$CR_OVERRIDES" | jq 'length') + if [[ "$OVERRIDE_COUNT" -gt 0 ]]; then + echo "The Tackle CR spec overrides the following images (these take precedence over the operator env vars):" + echo "" + echo "| CR Field | Image |" + echo "|----------|-------|" + echo "$CR_OVERRIDES" | jq -r 'to_entries[] | "| `\(.key)` | `\(.value)` |"' + else + echo "_No image overrides in Tackle CR spec._" + fi + echo "" + + # Section 5: Running Containers + echo "## Running Containers" + echo "" + echo "Actual container images currently deployed in the namespace." + echo "" + echo "| Deployment | Container | Image |" + echo "|------------|-----------|-------|" + echo "$RUNNING_CONTAINERS" | jq -r '.[] | "| `\(.deployment)` | `\(.container)` | `\(.image)` |"' + echo "" + + # Section 6: Addon CRs + echo "## Addon CRs" + echo "" + ADDON_COUNT=$(echo "$ADDON_IMAGES" | jq 'length') + if [[ "$ADDON_COUNT" -gt 0 ]]; then + echo "| Addon Name | Image |" + echo "|------------|-------|" + echo "$ADDON_IMAGES" | jq -r 'to_entries[] | "| `\(.key)` | `\(.value)` |"' + else + echo "_No Addon CRs found._" + fi + echo "" + + # Section 7: Extension CRs + echo "## Extension CRs" + echo "" + EXTENSION_COUNT=$(echo "$EXTENSION_IMAGES" | jq 'length') + if [[ "$EXTENSION_COUNT" -gt 0 ]]; then + echo "| Extension Name | Image |" + echo "|----------------|-------|" + echo "$EXTENSION_IMAGES" | jq -r 'to_entries[] | "| `\(.key)` | `\(.value)` |"' + else + echo "_No Extension CRs found._" + fi + echo "" + + # Section 8: CSV (if OLM install) + CSV_IS_NULL=$(echo "$CSV_RELATED" | jq 'if . == null then "yes" else "no" end' -r) + if [[ "$CSV_IS_NULL" == "no" ]]; then + echo "## OLM ClusterServiceVersion - Related Images" + echo "" + echo "Images declared in the CSV \`relatedImages\` (used for disconnected/air-gapped installs)." + echo "" + echo "| Name | Image |" + echo "|------|-------|" + echo "$CSV_RELATED" | jq -r 'to_entries[] | "| `\(.key)` | `\(.value)` |"' + echo "" + fi + + # Section 9: Image drift detection + echo "## Image Drift Detection" + echo "" + echo "Compares operator-configured images (env vars) against what is actually running." + echo "" + + # Use deployment_prefix from the map to match catalog entries to running deployments + DRIFT=$(jq -n \ + --argjson catalog "$IMAGE_CATALOG" \ + --argjson running "$RUNNING_CONTAINERS" \ + ' + [ + $catalog[] | select(.active and .deployment_prefix != null) | + . as $entry | + ($running[] | select(.deployment | startswith($entry.deployment_prefix)) | .image) as $running_img | + if $running_img != $entry.image then + {component: ($entry.component // $entry.env_var), configured: $entry.image, running: $running_img, env_var: $entry.env_var} + else empty + end + ] | unique_by(.env_var) + ') + + DRIFT_COUNT=$(echo "$DRIFT" | jq 'length') + if [[ "$DRIFT_COUNT" -gt 0 ]]; then + echo "| Component | Configured | Running | Env Var |" + echo "|-----------|------------|---------|---------|" + echo "$DRIFT" | jq -r '.[] | "| \(.component) | `\(.configured)` | `\(.running)` | `\(.env_var)` |"' + else + echo "_No drift detected. All running images match operator configuration._" + fi + echo "" +fi From 202d961a3d90ee438c33a5d3bdf2838f6ac44fdd Mon Sep 17 00:00:00 2001 From: Scott J Dickerson Date: Tue, 2 Jun 2026 10:09:49 -0400 Subject: [PATCH 2/2] Convert script to nodejs, add README Signed-off-by: Scott J Dickerson --- .github/actions/report-images/action.yml | 24 +- hack/report-images.sh | 355 --------- hack/runtime-configuration/README.md | 95 +++ .../known-components-flags-map.json} | 0 hack/runtime-configuration/report.js | 682 ++++++++++++++++++ 5 files changed, 784 insertions(+), 372 deletions(-) delete mode 100755 hack/report-images.sh create mode 100644 hack/runtime-configuration/README.md rename hack/{report-images-map.json => runtime-configuration/known-components-flags-map.json} (100%) create mode 100755 hack/runtime-configuration/report.js diff --git a/.github/actions/report-images/action.yml b/.github/actions/report-images/action.yml index a5bf93a..4b321e3 100644 --- a/.github/actions/report-images/action.yml +++ b/.github/actions/report-images/action.yml @@ -1,7 +1,7 @@ -name: Report Runtime Images +name: Report Runtime Configuration description: | - Queries the live cluster to report all container images configured and running - for the Konveyor operator stack. Writes a markdown report to the GitHub Step + Queries the live cluster to report all container images and feature flags configured + and running for the Konveyor operator stack. Writes a markdown report to the GitHub Step Summary and optionally uploads a JSON artifact. inputs: @@ -30,16 +30,6 @@ outputs: runs: using: "composite" steps: - - name: Ensure jq is available - shell: bash - run: | - if command -v jq &>/dev/null; then - echo "jq is already installed" - exit 0 - fi - echo "::error::jq is required but not found in PATH" - exit 1 - - name: Verify cluster access shell: bash run: | @@ -59,10 +49,10 @@ runs: shell: bash working-directory: ${{ github.action_path }}/../../.. run: | - bash hack/report-images.sh -n "${{ inputs.namespace }}" >> "$GITHUB_STEP_SUMMARY" + hack/runtime-configuration/report.js -n "${{ inputs.namespace }}" >> "$GITHUB_STEP_SUMMARY" - JSON=$(bash hack/report-images.sh -n "${{ inputs.namespace }}" --json) - echo "$JSON" > "$RUNNER_TEMP/report-images.json" + JSON=$(hack/runtime-configuration/report.js -n "${{ inputs.namespace }}" --json) + echo "$JSON" > "$RUNNER_TEMP/report.json" # Make JSON available as an output (delimiter-based to handle multiline) EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) @@ -77,5 +67,5 @@ runs: uses: actions/upload-artifact@v4 with: name: ${{ inputs.artifact_name }} - path: ${{ runner.temp }}/report-images.json + path: ${{ runner.temp }}/report.json retention-days: ${{ inputs.artifact_retention_days }} diff --git a/hack/report-images.sh b/hack/report-images.sh deleted file mode 100755 index 27673a8..0000000 --- a/hack/report-images.sh +++ /dev/null @@ -1,355 +0,0 @@ -#!/bin/bash -# -# hack/report-images.sh - Runtime image introspection for konveyor-operator -# -# Queries a live cluster to report all container images configured and running -# for the Konveyor operator stack. -# -# Usage: -# hack/report-images.sh # markdown output (default) -# hack/report-images.sh --json # JSON output -# hack/report-images.sh -n # custom namespace -# hack/report-images.sh -n --json # both -# -# The component map is loaded from hack/report-images-map.json. Any new -# RELATED_IMAGE_* env vars discovered on the operator that aren't in the map -# will still be reported (as "Unknown" components assumed always active). -# -# Requires: kubectl (or oc), jq - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -NAMESPACE="konveyor-tackle" -OUTPUT_FORMAT="markdown" -OPERATOR_DEPLOYMENT="tackle-operator" -MAP_FILE="${SCRIPT_DIR}/report-images-map.json" - -usage() { - cat <&2; exit 1 ;; - esac -done - -# --- Dependency checks --- - -KUBECTL="kubectl" -if ! command -v kubectl &>/dev/null; then - if command -v oc &>/dev/null; then - KUBECTL="oc" - else - echo "Error: neither kubectl nor oc found in PATH" >&2 - exit 1 - fi -fi - -if ! command -v jq &>/dev/null; then - echo "Error: jq is required but not found in PATH" >&2 - echo "Install: https://jqlang.github.io/jq/download/" >&2 - exit 1 -fi - -if [[ ! -f "$MAP_FILE" ]]; then - echo "Error: component map not found at '$MAP_FILE'" >&2 - echo "Expected alongside this script at hack/report-images-map.json" >&2 - exit 1 -fi - -# Load and validate the component map -COMPONENT_MAP=$(jq '.components' "$MAP_FILE") -FLAG_DEFINITIONS=$(jq '.feature_flags' "$MAP_FILE") - -# Verify cluster connectivity -if ! $KUBECTL get namespace "$NAMESPACE" &>/dev/null; then - echo "Error: cannot access namespace '$NAMESPACE'. Check cluster connectivity and namespace name." >&2 - exit 1 -fi - -# --- Data Collection (all as raw JSON) --- - -OPERATOR_JSON=$($KUBECTL get deployment "$OPERATOR_DEPLOYMENT" -n "$NAMESPACE" -o json 2>/dev/null || echo '{}') -TACKLE_CR_JSON=$($KUBECTL get tackles.tackle.konveyor.io -n "$NAMESPACE" -o json 2>/dev/null || echo '{"items":[]}') -DEPLOYMENTS_JSON=$($KUBECTL get deployments -n "$NAMESPACE" -o json 2>/dev/null || echo '{"items":[]}') -ADDONS_JSON=$($KUBECTL get addons.tackle.konveyor.io -n "$NAMESPACE" -o json 2>/dev/null || echo '{"items":[]}') -EXTENSIONS_JSON=$($KUBECTL get extensions.tackle.konveyor.io -n "$NAMESPACE" -o json 2>/dev/null || echo '{"items":[]}') - -# CSV may not exist (Helm-only installs) -CSV_JSON=$($KUBECTL get csv -n "$NAMESPACE" -o json 2>/dev/null || echo '{"items":[]}') -CSV_JSON=$(echo "$CSV_JSON" | jq '[.items[] | select(.metadata.name | test("konveyor"))] | if length > 0 then .[0] else null end') - -# --- Structured data extraction via jq --- - -# Operator metadata and full image catalog from deployment env vars -OPERATOR_DATA=$(echo "$OPERATOR_JSON" | jq '{ - image: .spec.template.spec.containers[0].image, - app_name: ([.spec.template.spec.containers[0].env[] | select(.name == "APP_NAME")] | first | .value // "unknown"), - version: ([.spec.template.spec.containers[0].env[] | select(.name == "VERSION")] | first | .value // "unknown"), - profile: ([.spec.template.spec.containers[0].env[] | select(.name == "PROFILE")] | first | .value // "unknown"), - image_catalog: ([.spec.template.spec.containers[0].env[] | select(.name | startswith("RELATED_IMAGE_")) | {(.name): .value}] | add // {}) -}') - -# Tackle CR spec (first item) -TACKLE_SPEC_JSON=$(echo "$TACKLE_CR_JSON" | jq ' - if .items and (.items | length) > 0 then .items[0].spec // {} - else {} - end -') - -# Feature flags: read from CR spec, apply defaults from the map file -FEATURE_FLAGS=$(jq -n \ - --argjson spec "$TACKLE_SPEC_JSON" \ - --argjson defs "$FLAG_DEFINITIONS" \ - ' - $defs | to_entries | map({ - key: .key, - value: ( - $spec[.key] as $val | - .value.default as $default | - if $val == null then $default - else ($val | tostring | ascii_downcase == "true") - end - ) - }) | from_entries - ') - -# CR image overrides (any spec field ending in _fqin) -CR_OVERRIDES=$(echo "$TACKLE_SPEC_JSON" | jq '[to_entries[] | select(.key | test("_fqin$")) | {(.key): .value}] | add // {}') - -# Running containers from all deployments in namespace -RUNNING_CONTAINERS=$(echo "$DEPLOYMENTS_JSON" | jq '[.items[] | .metadata.name as $deploy | .spec.template.spec.containers[] | {deployment: $deploy, container: .name, image: .image}]') - -# Addon images -ADDON_IMAGES=$(echo "$ADDONS_JSON" | jq '[.items[] | {(.metadata.name): .spec.container.image}] | add // {}') - -# Extension images -EXTENSION_IMAGES=$(echo "$EXTENSIONS_JSON" | jq '[.items[] | {(.metadata.name): .spec.container.image}] | add // {}') - -# CSV related images (null if no CSV) -CSV_RELATED=$(echo "$CSV_JSON" | jq 'if . != null then ([.spec.relatedImages[]? | {(.name): .image}] | add // {}) else null end') - -# --- Build enriched image catalog --- -# Merges the external component map with dynamically discovered env vars. -# Unknown env vars (not in map) are reported as "Discovered" with condition "always". -IMAGE_CATALOG=$(jq -n \ - --argjson operator "$OPERATOR_DATA" \ - --argjson flags "$FEATURE_FLAGS" \ - --argjson map "$COMPONENT_MAP" \ - ' - [$operator.image_catalog | to_entries[] | { - env_var: .key, - image: .value, - component: ($map[.key].component // null), - condition: ($map[.key].condition // "always"), - deployment_prefix: ($map[.key].deployment_prefix // null), - in_map: ($map[.key] != null), - active: ( - ($map[.key].condition // "always") as $cond | - if $cond == "always" then true - elif $flags[$cond] == true then true - else false - end - ) - }] - ') - -# --- Output --- - -if [[ "$OUTPUT_FORMAT" == "json" ]]; then - jq -n \ - --arg ns "$NAMESPACE" \ - --argjson operator "$OPERATOR_DATA" \ - --argjson feature_flags "$FEATURE_FLAGS" \ - --argjson image_catalog "$IMAGE_CATALOG" \ - --argjson cr_overrides "$CR_OVERRIDES" \ - --argjson running_containers "$RUNNING_CONTAINERS" \ - --argjson addon_images "$ADDON_IMAGES" \ - --argjson extension_images "$EXTENSION_IMAGES" \ - --argjson csv_related_images "$CSV_RELATED" \ - '{ - namespace: $ns, - operator: { - image: $operator.image, - app_name: $operator.app_name, - version: $operator.version, - profile: $operator.profile - }, - feature_flags: $feature_flags, - image_catalog: $image_catalog, - cr_overrides: $cr_overrides, - running_containers: $running_containers, - addon_images: $addon_images, - extension_images: $extension_images, - csv_related_images: $csv_related_images - }' -else - # --- Markdown output --- - - echo "# Konveyor Operator - Runtime Image Report" - echo "" - echo "**Namespace:** \`$NAMESPACE\`" - echo "**Report generated:** $(date -u '+%Y-%m-%d %H:%M:%S UTC')" - echo "" - - # Section 1: Operator metadata - echo "## Operator" - echo "" - echo "| Field | Value |" - echo "|-------|-------|" - echo "$OPERATOR_DATA" | jq -r ' - "| Deployment | `'"$OPERATOR_DEPLOYMENT"'` |", - "| Image | `\(.image)` |", - "| Version | `\(.version)` |", - "| Profile | `\(.profile)` |" - ' - echo "" - - # Section 2: Feature Flags (driven by map file) - echo "## Feature Flags" - echo "" - echo "These flags determine which optional components are deployed." - echo "" - echo "| Flag | Value | Effect |" - echo "|------|-------|--------|" - jq -n --argjson flags "$FEATURE_FLAGS" --argjson defs "$FLAG_DEFINITIONS" \ - '$defs | to_entries[] | "| `\(.key)` | `\($flags[.key])` | \(.value.description) |"' -r - echo "" - - # Section 3: Image Catalog with active/inactive status - echo "## Image Catalog (from Operator Deployment env)" - echo "" - echo "All images the operator is configured to deploy. Status reflects current feature flags." - echo "" - echo "| Status | Component | Env Var | Image |" - echo "|--------|-----------|---------|-------|" - echo "$IMAGE_CATALOG" | jq -r '.[] | - (if .active then "✅ ACTIVE" else "⬚ INACTIVE" end) as $status | - (if .in_map then .component else "⚠️ \(.env_var | ltrimstr("RELATED_IMAGE_") | gsub("_"; " ") | ascii_downcase) (unmapped)" end) as $name | - "| \($status) | \($name) | `\(.env_var)` | `\(.image)` |" - ' - echo "" - - # Warn about unmapped images - UNMAPPED_COUNT=$(echo "$IMAGE_CATALOG" | jq '[.[] | select(.in_map == false)] | length') - if [[ "$UNMAPPED_COUNT" -gt 0 ]]; then - echo "> **Note:** $UNMAPPED_COUNT image(s) found on the operator deployment are not in the component map." - echo "> Update \`hack/report-images-map.json\` to add component names and activation conditions." - echo "" - fi - - # Section 4: Tackle CR Overrides - echo "## Tackle CR Overrides" - echo "" - OVERRIDE_COUNT=$(echo "$CR_OVERRIDES" | jq 'length') - if [[ "$OVERRIDE_COUNT" -gt 0 ]]; then - echo "The Tackle CR spec overrides the following images (these take precedence over the operator env vars):" - echo "" - echo "| CR Field | Image |" - echo "|----------|-------|" - echo "$CR_OVERRIDES" | jq -r 'to_entries[] | "| `\(.key)` | `\(.value)` |"' - else - echo "_No image overrides in Tackle CR spec._" - fi - echo "" - - # Section 5: Running Containers - echo "## Running Containers" - echo "" - echo "Actual container images currently deployed in the namespace." - echo "" - echo "| Deployment | Container | Image |" - echo "|------------|-----------|-------|" - echo "$RUNNING_CONTAINERS" | jq -r '.[] | "| `\(.deployment)` | `\(.container)` | `\(.image)` |"' - echo "" - - # Section 6: Addon CRs - echo "## Addon CRs" - echo "" - ADDON_COUNT=$(echo "$ADDON_IMAGES" | jq 'length') - if [[ "$ADDON_COUNT" -gt 0 ]]; then - echo "| Addon Name | Image |" - echo "|------------|-------|" - echo "$ADDON_IMAGES" | jq -r 'to_entries[] | "| `\(.key)` | `\(.value)` |"' - else - echo "_No Addon CRs found._" - fi - echo "" - - # Section 7: Extension CRs - echo "## Extension CRs" - echo "" - EXTENSION_COUNT=$(echo "$EXTENSION_IMAGES" | jq 'length') - if [[ "$EXTENSION_COUNT" -gt 0 ]]; then - echo "| Extension Name | Image |" - echo "|----------------|-------|" - echo "$EXTENSION_IMAGES" | jq -r 'to_entries[] | "| `\(.key)` | `\(.value)` |"' - else - echo "_No Extension CRs found._" - fi - echo "" - - # Section 8: CSV (if OLM install) - CSV_IS_NULL=$(echo "$CSV_RELATED" | jq 'if . == null then "yes" else "no" end' -r) - if [[ "$CSV_IS_NULL" == "no" ]]; then - echo "## OLM ClusterServiceVersion - Related Images" - echo "" - echo "Images declared in the CSV \`relatedImages\` (used for disconnected/air-gapped installs)." - echo "" - echo "| Name | Image |" - echo "|------|-------|" - echo "$CSV_RELATED" | jq -r 'to_entries[] | "| `\(.key)` | `\(.value)` |"' - echo "" - fi - - # Section 9: Image drift detection - echo "## Image Drift Detection" - echo "" - echo "Compares operator-configured images (env vars) against what is actually running." - echo "" - - # Use deployment_prefix from the map to match catalog entries to running deployments - DRIFT=$(jq -n \ - --argjson catalog "$IMAGE_CATALOG" \ - --argjson running "$RUNNING_CONTAINERS" \ - ' - [ - $catalog[] | select(.active and .deployment_prefix != null) | - . as $entry | - ($running[] | select(.deployment | startswith($entry.deployment_prefix)) | .image) as $running_img | - if $running_img != $entry.image then - {component: ($entry.component // $entry.env_var), configured: $entry.image, running: $running_img, env_var: $entry.env_var} - else empty - end - ] | unique_by(.env_var) - ') - - DRIFT_COUNT=$(echo "$DRIFT" | jq 'length') - if [[ "$DRIFT_COUNT" -gt 0 ]]; then - echo "| Component | Configured | Running | Env Var |" - echo "|-----------|------------|---------|---------|" - echo "$DRIFT" | jq -r '.[] | "| \(.component) | `\(.configured)` | `\(.running)` | `\(.env_var)` |"' - else - echo "_No drift detected. All running images match operator configuration._" - fi - echo "" -fi diff --git a/hack/runtime-configuration/README.md b/hack/runtime-configuration/README.md new file mode 100644 index 0000000..2ee598e --- /dev/null +++ b/hack/runtime-configuration/README.md @@ -0,0 +1,95 @@ +# Runtime Configuration Report + +Introspects a live cluster running the Konveyor operator and produces a full +report of what is deployed, what images are configured, and how the CRD +instances relate to each other. + +## What it looks at + +The report queries the following resources in the operator's namespace: + +| Resource | What we extract | +|----------|-----------------| +| `Deployment/tackle-operator` | Operator image, version, profile, and the full `RELATED_IMAGE_*` env var catalog | +| `tackles.tackle.konveyor.io` | CR spec (feature flags, image overrides via `*_fqin` fields), CR status conditions | +| `addons.tackle.konveyor.io` | Addon name, container image, task-matching regex | +| `extensions.tackle.konveyor.io` | Extension name, container image (or null → uses generic provider), addon-matching regex, selector | +| `tasks.tackle.konveyor.io` | Task name, priority, dependency list | +| `schemas.tackle.konveyor.io` | Schema name, domain, subject, variant | +| All `Deployments` in namespace | Running container images for drift detection | +| `ClusterServiceVersion` (if OLM) | `relatedImages` declared for disconnected installs | + +## What it reports + +1. **Operator metadata** — image, version, profile +2. **Tackle CR status** — reconciliation conditions (Ready, Failure, etc.) +3. **Feature flags** — current values vs. defaults, showing which optional + components are active +4. **Image catalog** — every `RELATED_IMAGE_*` env var, enriched with component + names from `known-components-flags-map.json`, marked ACTIVE or INACTIVE + based on feature flags +5. **CR overrides** — any `*_fqin` fields in the Tackle CR spec that override + the operator's default images +6. **Running containers** — actual images deployed in the namespace +7. **Addon CRs** — images and the task regex they serve +8. **Extension CRs** — images (or generic provider fallback), addon binding, + and application selectors +9. **Task CRs** — execution priority and dependency graph +10. **Schema CRs** — registered schema definitions +11. **Task → Addon → Extension relationship graph** — how tasks dispatch to + addons, and which extensions (language providers) attach as sidecars +12. **OLM CSV related images** — if installed via OLM +13. **Image drift detection** — compares configured images against what is + actually running + +## Usage + +```bash +# Markdown output (default) +node hack/runtime-configuration/report.js + +# JSON output +node hack/runtime-configuration/report.js --json + +# Custom namespace +node hack/runtime-configuration/report.js -n my-namespace +``` + +Requires `kubectl` (or `oc`) in PATH and Node.js >= 22. No npm dependencies. + +## Files + +| File | Purpose | +|------|---------| +| `report.js` | Main script (Node.js, zero dependencies) | +| `report.sh` | Bash equivalent (uses jq) | +| `known-components-flags-map.json` | Maps `RELATED_IMAGE_*` env vars to human-readable names, activation conditions, and deployment prefixes | + +## Extending for new CRDs + +When a new CRD is added to the operator (e.g. a hypothetical +`pipelines.tackle.konveyor.io`): + +1. **Add a collector** in the "CRD Collectors" section of `report.js` — a + function that calls `kubectl(...)` and returns `{ kind, items }`. +2. **Register it** in the `CRD_COLLECTORS` object. +3. **Add a renderer** in the "Markdown Renderers" section — a function that + returns a markdown string for that section. +4. **Register it** in the `SECTION_RENDERERS` array. + +If the new CRD carries container images governed by feature flags, also add +its `RELATED_IMAGE_*` entry to `known-components-flags-map.json`. + +## Updating the component map + +When images are added or removed from `helm/templates/deployment.yaml`, update +`known-components-flags-map.json`: + +- `components..component` — human-readable name +- `components..condition` — `"always"` or a feature flag key +- `components..deployment_prefix` — prefix of the Deployment name + this image ends up in (used for drift detection), or `null` if it runs as a + task sidecar rather than a long-lived deployment + +Any `RELATED_IMAGE_*` env var found on the operator that isn't in the map will +still be reported, flagged as "unmapped" so it's obvious what needs updating. diff --git a/hack/report-images-map.json b/hack/runtime-configuration/known-components-flags-map.json similarity index 100% rename from hack/report-images-map.json rename to hack/runtime-configuration/known-components-flags-map.json diff --git a/hack/runtime-configuration/report.js b/hack/runtime-configuration/report.js new file mode 100755 index 0000000..b267137 --- /dev/null +++ b/hack/runtime-configuration/report.js @@ -0,0 +1,682 @@ +#!/usr/bin/env node +// hack/runtime-deployment.js - Runtime deployment introspection for konveyor-operator +// +// Queries a live cluster to report all container images, CRD instances, and +// deployment topology for the Konveyor operator stack. +// +// Usage: +// node hack/runtime-deployment.js # markdown (default) +// node hack/runtime-deployment.js --json # JSON output +// node hack/runtime-deployment.js -n # custom namespace +// +// Requires: kubectl (or oc) in PATH, Node.js >= 22 +// +// To support new CRDs: +// 1. Add a collector function in the "CRD Collectors" section +// 2. Register it in the CRD_COLLECTORS array +// 3. Add a renderer function in the "Markdown Renderers" section +// 4. Register it in the SECTION_RENDERERS array +// +// No external dependencies - uses child_process and Node.js built-ins only. + +import { execSync } from "node:child_process"; +import { parseArgs } from "node:util"; + +import componentMap from "./known-components-flags-map.json" with { type: "json" }; + +// ───────────────────────────────────────────────────────────────────────────── +// CLI Argument Parsing +// ───────────────────────────────────────────────────────────────────────────── + +const { values: args } = parseArgs({ + options: { + namespace: { type: "string", short: "n", default: "konveyor-tackle" }, + json: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + strict: true, +}); + +if (args.help) { + console.log(`Usage: node hack/runtime-deployment.js [OPTIONS] + +Query a live cluster to report all container images and CRD instances +for the Konveyor operator stack. + +Options: + -n, --namespace NS Namespace (default: konveyor-tackle) + --json Output as JSON + -h, --help Show this help + +Requires: kubectl (or oc) in PATH`); + process.exit(0); +} + +const NAMESPACE = args.namespace; + +// ───────────────────────────────────────────────────────────────────────────── +// Kubectl Execution +// ───────────────────────────────────────────────────────────────────────────── + +const KUBECTL = resolveKubectl(); + +function resolveKubectl() { + try { + execSync("which kubectl", { stdio: "pipe" }); + return "kubectl"; + } catch { + try { + execSync("which oc", { stdio: "pipe" }); + return "oc"; + } catch { + fatal("neither kubectl nor oc found in PATH"); + } + } +} + +function kubectl(resource, opts = {}) { + const ns = opts.allNamespaces ? "" : `-n ${NAMESPACE}`; + const output = opts.outputType || "json"; + const cmd = `${KUBECTL} get ${resource} ${ns} -o ${output}`; + try { + const result = execSync(cmd, { stdio: "pipe", encoding: "utf-8" }); + return output === "json" ? JSON.parse(result) : result; + } catch { + return opts.fallback ?? null; + } +} + +function kubectlRaw(cmd) { + try { + return execSync(`${KUBECTL} ${cmd}`, { stdio: "pipe", encoding: "utf-8" }); + } catch { + return null; + } +} + +function fatal(msg) { + console.error(`Error: ${msg}`); + process.exit(1); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Verify Cluster Access +// ───────────────────────────────────────────────────────────────────────────── + +if (!kubectlRaw(`get namespace ${NAMESPACE}`)) { + fatal( + `cannot access namespace '${NAMESPACE}'. Check cluster connectivity and namespace name.` + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Core Data Collectors +// ───────────────────────────────────────────────────────────────────────────── + +function collectOperator() { + const deploy = kubectl(`deployment/tackle-operator`, { + fallback: null, + }); + if (!deploy) return null; + + const container = deploy.spec.template.spec.containers[0]; + const envMap = Object.fromEntries( + (container.env || []).map((e) => [e.name, e.value]) + ); + + const relatedImages = Object.fromEntries( + Object.entries(envMap).filter(([k]) => k.startsWith("RELATED_IMAGE_")) + ); + + return { + image: container.image, + appName: envMap.APP_NAME || "unknown", + version: envMap.VERSION || "unknown", + profile: envMap.PROFILE || "unknown", + relatedImages, + }; +} + +function collectFeatureFlags(tackleSpec) { + const defs = componentMap.feature_flags || {}; + const flags = {}; + for (const [flag, def] of Object.entries(defs)) { + const specVal = tackleSpec[flag]; + if (specVal == null) { + flags[flag] = def.default; + } else { + flags[flag] = String(specVal).toLowerCase() === "true" || specVal === true; + } + } + return flags; +} + +function buildImageCatalog(relatedImages, featureFlags) { + const components = componentMap.components || {}; + return Object.entries(relatedImages).map(([envVar, image]) => { + const mapping = components[envVar]; + const condition = mapping?.condition || "always"; + let active; + if (condition === "always") { + active = true; + } else { + active = featureFlags[condition] === true; + } + return { + envVar, + image, + component: mapping?.component || null, + condition, + deploymentPrefix: mapping?.deployment_prefix || null, + inMap: !!mapping, + active, + }; + }); +} + +function collectRunningContainers() { + const deployments = kubectl("deployments", { fallback: { items: [] } }); + const containers = []; + for (const deploy of deployments.items || []) { + const name = deploy.metadata.name; + for (const c of deploy.spec.template.spec.containers || []) { + containers.push({ + deployment: name, + container: c.name, + image: c.image, + }); + } + } + return containers; +} + +function collectCrOverrides(tackleSpec) { + const overrides = {}; + for (const [key, val] of Object.entries(tackleSpec)) { + if (key.endsWith("_fqin")) { + overrides[key] = val; + } + } + return overrides; +} + +// ───────────────────────────────────────────────────────────────────────────── +// CRD Collectors +// ───────────────────────────────────────────────────────────────────────────── +// Each collector returns { kind, items[] } where items have a consistent shape. +// To support a new CRD, add a function here and register it in CRD_COLLECTORS. + +function collectTackleCR() { + const result = kubectl("tackles.tackle.konveyor.io", { + fallback: { items: [] }, + }); + const items = (result.items || []).map((item) => ({ + name: item.metadata.name, + spec: item.spec || {}, + status: item.status || {}, + conditions: item.status?.conditions || [], + })); + return { kind: "Tackle", items }; +} + +function collectAddons() { + const result = kubectl("addons.tackle.konveyor.io", { + fallback: { items: [] }, + }); + const items = (result.items || []).map((item) => ({ + name: item.metadata.name, + image: item.spec?.container?.image || item.spec?.image || null, + task: item.spec?.task || null, + selector: item.spec?.selector || null, + })); + return { kind: "Addon", items }; +} + +function collectExtensions() { + const result = kubectl("extensions.tackle.konveyor.io", { + fallback: { items: [] }, + }); + const items = (result.items || []).map((item) => ({ + name: item.metadata.name, + image: item.spec?.container?.image || null, + addon: item.spec?.addon || null, + selector: item.spec?.selector || null, + metadata: item.spec?.metadata || {}, + })); + return { kind: "Extension", items }; +} + +function collectTasks() { + const result = kubectl("tasks.tackle.konveyor.io", { + fallback: { items: [] }, + }); + const items = (result.items || []).map((item) => ({ + name: item.metadata.name, + dependencies: item.spec?.dependencies || [], + priority: item.spec?.priority ?? null, + data: item.spec?.data || {}, + })); + return { kind: "Task", items }; +} + +function collectSchemas() { + const result = kubectl("schemas.tackle.konveyor.io", { + fallback: { items: [] }, + }); + const items = (result.items || []).map((item) => ({ + name: item.metadata.name, + domain: item.spec?.domain || null, + subject: item.spec?.subject || null, + variant: item.spec?.variant || null, + })); + return { kind: "Schema", items }; +} + +function collectCSV() { + const result = kubectl("csv", { fallback: { items: [] } }); + const csv = (result.items || []).find((i) => + i.metadata.name.includes("konveyor") + ); + if (!csv) return null; + const related = (csv.spec?.relatedImages || []).map((r) => ({ + name: r.name, + image: r.image, + })); + return { name: csv.metadata.name, relatedImages: related }; +} + +/** + * Registry of CRD collectors. + * To add support for a new CRD: + * 1. Write a collect function above + * 2. Add it here with a key matching the CRD's Kind + */ +const CRD_COLLECTORS = { + Tackle: collectTackleCR, + Addon: collectAddons, + Extension: collectExtensions, + Task: collectTasks, + Schema: collectSchemas, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Drift Detection +// ───────────────────────────────────────────────────────────────────────────── + +function detectDrift(catalog, runningContainers) { + const drifts = []; + for (const entry of catalog) { + if (!entry.active || !entry.deploymentPrefix) continue; + const running = runningContainers.find((c) => + c.deployment.startsWith(entry.deploymentPrefix) + ); + if (running && running.image !== entry.image) { + drifts.push({ + component: entry.component || entry.envVar, + configured: entry.image, + running: running.image, + envVar: entry.envVar, + }); + } + } + return drifts; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Relationship Graph Builder +// ───────────────────────────────────────────────────────────────────────────── + +function buildRelationshipGraph(crds) { + const tasks = crds.Task?.items || []; + const addons = crds.Addon?.items || []; + const extensions = crds.Extension?.items || []; + + const graph = tasks.map((task) => { + const matchingAddons = addons.filter((a) => { + if (!a.task) return false; + try { + return new RegExp(a.task).test(task.name); + } catch { + return a.task === task.name; + } + }); + + const addonEntries = matchingAddons.map((addon) => { + const matchingExtensions = extensions.filter((ext) => { + if (!ext.addon) return false; + try { + return new RegExp(ext.addon).test(addon.name); + } catch { + return ext.addon === addon.name; + } + }); + return { ...addon, extensions: matchingExtensions }; + }); + + return { + task: task.name, + priority: task.priority, + dependencies: task.dependencies, + addons: addonEntries, + }; + }); + + return graph; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main Collection +// ───────────────────────────────────────────────────────────────────────────── + +const operator = collectOperator(); +if (!operator) fatal("cannot find tackle-operator deployment"); + +const crds = {}; +for (const [kind, collector] of Object.entries(CRD_COLLECTORS)) { + crds[kind] = collector(); +} + +const tackleSpec = crds.Tackle.items[0]?.spec || {}; +const tackleConditions = crds.Tackle.items[0]?.conditions || []; +const featureFlags = collectFeatureFlags(tackleSpec); +const imageCatalog = buildImageCatalog(operator.relatedImages, featureFlags); +const crOverrides = collectCrOverrides(tackleSpec); +const runningContainers = collectRunningContainers(); +const drift = detectDrift(imageCatalog, runningContainers); +const csv = collectCSV(); +const relationshipGraph = buildRelationshipGraph(crds); + +// ───────────────────────────────────────────────────────────────────────────── +// JSON Output +// ───────────────────────────────────────────────────────────────────────────── + +if (args.json) { + const report = { + namespace: NAMESPACE, + generatedAt: new Date().toISOString(), + operator: { + image: operator.image, + appName: operator.appName, + version: operator.version, + profile: operator.profile, + }, + featureFlags, + imageCatalog, + crOverrides, + runningContainers, + crds: { + addons: crds.Addon.items, + extensions: crds.Extension.items, + tasks: crds.Task.items, + schemas: crds.Schema.items, + }, + relationshipGraph, + csv: csv + ? { name: csv.name, relatedImages: csv.relatedImages } + : null, + tackleStatus: tackleConditions, + drift, + }; + console.log(JSON.stringify(report, null, 2)); + process.exit(0); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Markdown Renderers +// ───────────────────────────────────────────────────────────────────────────── +// Each renderer is a function that returns a string (or empty string to skip). +// To add a new section, write a renderer and register it in SECTION_RENDERERS. + +function renderHeader() { + return `# Konveyor Operator - Runtime Deployment Report + +**Namespace:** \`${NAMESPACE}\` +**Generated:** ${new Date().toISOString()}`; +} + +function renderOperator() { + return `## Operator + +| Field | Value | +|-------|-------| +| Deployment | \`tackle-operator\` | +| Image | \`${operator.image}\` | +| Version | \`${operator.version}\` | +| Profile | \`${operator.profile}\` |`; +} + +function renderTackleStatus() { + if (tackleConditions.length === 0) return ""; + const rows = tackleConditions.map( + (c) => + `| \`${c.type}\` | ${c.status === "True" ? "✅" : "❌"} ${c.status} | ${c.reason || "-"} | ${c.message || "-"} |` + ); + return `## Tackle CR Status + +| Condition | Status | Reason | Message | +|-----------|--------|--------|---------| +${rows.join("\n")}`; +} + +function renderFeatureFlags() { + const defs = componentMap.feature_flags || {}; + const rows = Object.entries(featureFlags).map( + ([flag, val]) => + `| \`${flag}\` | \`${val}\` | ${defs[flag]?.description || "-"} |` + ); + return `## Feature Flags + +These flags determine which optional components are deployed. + +| Flag | Value | Effect | +|------|-------|--------| +${rows.join("\n")}`; +} + +function renderImageCatalog() { + const rows = imageCatalog.map((entry) => { + const status = entry.active ? "🟢 ACTIVE" : "⚫ INACTIVE"; + let name; + if (entry.inMap) { + name = entry.component; + } else { + const readable = entry.envVar + .replace("RELATED_IMAGE_", "") + .replaceAll("_", " ") + .toLowerCase(); + name = `⚠️ ${readable} (unmapped)`; + } + return `| ${status} | ${name} | \`${entry.envVar}\` | \`${entry.image}\` |`; + }); + + const unmapped = imageCatalog.filter((e) => !e.inMap); + let note = ""; + if (unmapped.length > 0) { + note = `\n> **Note:** ${unmapped.length} image(s) not in component map. Update \`hack/report-images-map.json\`.`; + } + + return `## Image Catalog (from Operator Deployment env) + +All images the operator is configured to deploy. Status reflects current feature flags. + +| Status | Component | Env Var | Image | +|--------|-----------|---------|-------| +${rows.join("\n")}${note}`; +} + +function renderCrOverrides() { + const entries = Object.entries(crOverrides); + if (entries.length === 0) { + return `## Tackle CR Overrides + +_No image overrides in Tackle CR spec._`; + } + const rows = entries.map( + ([field, img]) => `| \`${field}\` | \`${img}\` |` + ); + return `## Tackle CR Overrides + +| CR Field | Image | +|----------|-------| +${rows.join("\n")}`; +} + +function renderRunningContainers() { + const rows = runningContainers.map( + (c) => `| \`${c.deployment}\` | \`${c.container}\` | \`${c.image}\` |` + ); + return `## Running Containers + +| Deployment | Container | Image | +|------------|-----------|-------| +${rows.join("\n")}`; +} + +function renderAddons() { + const items = crds.Addon.items; + if (items.length === 0) return `## Addon CRs\n\n_No Addon CRs found._`; + const rows = items.map( + (a) => `| \`${a.name}\` | \`${a.image || "n/a"}\` | \`${a.task || "-"}\` |` + ); + return `## Addon CRs + +| Name | Image | Task Pattern | +|------|-------|--------------| +${rows.join("\n")}`; +} + +function renderExtensions() { + const items = crds.Extension.items; + if (items.length === 0) + return `## Extension CRs\n\n_No Extension CRs found._`; + const rows = items.map((e) => { + const img = e.image || "(uses generic provider)"; + return `| \`${e.name}\` | \`${img}\` | \`${e.addon || "-"}\` | \`${e.selector || "-"}\` |`; + }); + return `## Extension CRs + +| Name | Image | Addon Pattern | Selector | +|------|-------|---------------|----------| +${rows.join("\n")}`; +} + +function renderTasks() { + const items = crds.Task.items; + if (items.length === 0) return `## Task CRs\n\n_No Task CRs found._`; + const rows = items.map((t) => { + const deps = t.dependencies.length > 0 ? t.dependencies.join(", ") : "-"; + return `| \`${t.name}\` | ${t.priority ?? "-"} | ${deps} |`; + }); + return `## Task CRs + +| Name | Priority | Dependencies | +|------|----------|--------------| +${rows.join("\n")}`; +} + +function renderSchemas() { + const items = crds.Schema.items; + if (items.length === 0) return ""; + const rows = items.map( + (s) => + `| \`${s.name}\` | ${s.domain || "-"} | ${s.subject || "-"} | ${s.variant || "-"} |` + ); + return `## Schema CRs + +| Name | Domain | Subject | Variant | +|------|--------|---------|---------| +${rows.join("\n")}`; +} + +function renderRelationshipGraph() { + if (relationshipGraph.length === 0) return ""; + + const lines = ["## Task → Addon → Extension Graph", ""]; + lines.push( + "Shows which addons serve each task, and which extensions attach to those addons.", + "" + ); + + for (const entry of relationshipGraph) { + const deps = + entry.dependencies.length > 0 + ? ` (depends on: ${entry.dependencies.join(", ")})` + : ""; + lines.push( + `- **Task: \`${entry.task}\`** [priority: ${entry.priority ?? "default"}]${deps}` + ); + if (entry.addons.length === 0) { + lines.push(" - _(no matching addon)_"); + } + for (const addon of entry.addons) { + lines.push( + ` - Addon: \`${addon.name}\` → image: \`${addon.image || "n/a"}\`` + ); + for (const ext of addon.extensions || []) { + lines.push( + ` - Extension: \`${ext.name}\` → image: \`${ext.image || "(generic)"}\` [selector: ${ext.selector || "-"}]` + ); + } + } + } + return lines.join("\n"); +} + +function renderCSV() { + if (!csv) return ""; + const rows = csv.relatedImages.map( + (r) => `| \`${r.name}\` | \`${r.image}\` |` + ); + return `## OLM ClusterServiceVersion - Related Images + +CSV: \`${csv.name}\` + +| Name | Image | +|------|-------| +${rows.join("\n")}`; +} + +function renderDrift() { + if (drift.length === 0) { + return `## Image Drift Detection + +_No drift detected. All running images match operator configuration._`; + } + const rows = drift.map( + (d) => + `| ${d.component} | \`${d.configured}\` | \`${d.running}\` | \`${d.envVar}\` |` + ); + return `## Image Drift Detection + +| Component | Configured | Running | Env Var | +|-----------|------------|---------|---------| +${rows.join("\n")}`; +} + +/** + * Ordered list of section renderers. + * To add a new report section, write a render function and add it here. + */ +const SECTION_RENDERERS = [ + renderHeader, + renderOperator, + renderTackleStatus, + renderFeatureFlags, + renderImageCatalog, + renderCrOverrides, + renderRunningContainers, + renderAddons, + renderExtensions, + renderTasks, + renderSchemas, + renderRelationshipGraph, + renderCSV, + renderDrift, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// Render Markdown +// ───────────────────────────────────────────────────────────────────────────── + +const sections = SECTION_RENDERERS.map((fn) => fn()).filter(Boolean); +console.log(sections.join("\n\n"));