From cfef181fe829f5afff7d47f3062e7dc0c763d99e Mon Sep 17 00:00:00 2001 From: Suleyman Akbas Date: Thu, 16 Jul 2026 14:29:16 +0200 Subject: [PATCH 1/3] feat(microshift-ci): add --local mode to PCP dashboard generator Enable generate-dashboard.sh to work with local scenario-info directories (via --local flag) in addition to Prow URLs (--url). This allows CI post steps to generate PCP dashboards directly from artifacts on the hypervisor without downloading from GCS. New flags: --local, --build-id, --output, --title. Also adds --title support to create-pcp-dashboard.py for custom HTML titles, and fixes symlink traversal in find/os.walk for local mode. Co-Authored-By: Claude Opus 4.6 --- .../pcp-graphs/create-pcp-dashboard.py | 13 ++- .../scripts/pcp-graphs/extract_scenarios.py | 2 +- .../scripts/pcp-graphs/generate-dashboard.sh | 83 ++++++++++++++----- 3 files changed, 73 insertions(+), 25 deletions(-) diff --git a/plugins/microshift-ci/scripts/pcp-graphs/create-pcp-dashboard.py b/plugins/microshift-ci/scripts/pcp-graphs/create-pcp-dashboard.py index ee02ed6e..1f27d8fd 100755 --- a/plugins/microshift-ci/scripts/pcp-graphs/create-pcp-dashboard.py +++ b/plugins/microshift-ci/scripts/pcp-graphs/create-pcp-dashboard.py @@ -315,25 +315,27 @@ def escape_json_for_script(json_str): return json_str.replace(" -PCP Performance Dashboard +{safe_title} @@ -368,6 +370,8 @@ def main(): help="Timezone label for display (default: UTC)") parser.add_argument("--output", help="Output HTML file (default: /pcp-dashboard.html)") + parser.add_argument("--title", default="PCP Performance Dashboard", + help="HTML for the dashboard") args = parser.parse_args() dashboard_dir = os.path.join(args.workdir, "pcp-dashboard") @@ -385,7 +389,8 @@ def main(): data_json = json.dumps(data, separators=(",", ":")) scenarios_json = json.dumps(scenarios, separators=(",", ":")) - html = build_html(chartjs_src, pcp_charts_src, data_json, scenarios_json, args.timezone) + html = build_html(chartjs_src, pcp_charts_src, data_json, scenarios_json, args.timezone, + title=args.title) with open(output, "w") as f: f.write(html) diff --git a/plugins/microshift-ci/scripts/pcp-graphs/extract_scenarios.py b/plugins/microshift-ci/scripts/pcp-graphs/extract_scenarios.py index 436237e6..5321b374 100755 --- a/plugins/microshift-ci/scripts/pcp-graphs/extract_scenarios.py +++ b/plugins/microshift-ci/scripts/pcp-graphs/extract_scenarios.py @@ -23,7 +23,7 @@ def find_scenario_dirs(artifacts_root): build_dir = os.path.join(artifacts_root, build_id) if not os.path.isdir(build_dir): continue - for root, dirs, _files in os.walk(build_dir): + for root, dirs, _files in os.walk(build_dir, followlinks=True): if os.path.basename(root) != "scenario-info": continue for scenario in sorted(os.listdir(root)): diff --git a/plugins/microshift-ci/scripts/pcp-graphs/generate-dashboard.sh b/plugins/microshift-ci/scripts/pcp-graphs/generate-dashboard.sh index 4c1785cf..57daf101 100755 --- a/plugins/microshift-ci/scripts/pcp-graphs/generate-dashboard.sh +++ b/plugins/microshift-ci/scripts/pcp-graphs/generate-dashboard.sh @@ -1,12 +1,15 @@ #!/usr/bin/bash -# Generate an interactive PCP performance dashboard from a Prow job URL. +# Generate an interactive PCP performance dashboard. # -# Downloads artifacts from GCS, extracts per-VM PCP archives, and produces -# an interactive HTML dashboard. +# Two modes: +# --url <prow-url> Download artifacts from GCS (default) +# --local <path> Use a local directory containing scenario-info/ # -# Usage: generate-dashboard.sh --url <prow-url> [--parallel N] [--timezone TZ] +# Usage: +# generate-dashboard.sh --url <prow-url> [--parallel N] [--timezone TZ] +# generate-dashboard.sh --local <path> [--build-id ID] [--output FILE] [--title TITLE] [--timezone TZ] # -# Prerequisites: gsutil, python3, and one of: +# Prerequisites: python3, and one of: # - pcp-export-pcp2json (native) # - podman (container fallback) @@ -16,6 +19,10 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SHARED_SCRIPTS="$(cd "${SCRIPT_DIR}/../../../shared/scripts" && pwd)" URL="" +LOCAL_PATH="" +BUILD_ID="" +OUTPUT="" +TITLE="" PARALLEL=6 TIMEZONE="UTC" PCP2JSON_MODE="" # "native" or "container" @@ -23,8 +30,15 @@ CONTAINER_RT="" # "podman" CONTAINER_IMAGE="pcp2json-tool" usage() { - echo "Usage: ${0} --url <prow-url> [--parallel N] [--timezone TZ]" >&2 - echo " --url URL : Prow job URL (required)" >&2 + echo "Usage:" >&2 + echo " ${0} --url <prow-url> [--parallel N] [--timezone TZ]" >&2 + echo " ${0} --local <path> [--build-id ID] [--output FILE] [--title TITLE] [--timezone TZ]" >&2 + echo "" >&2 + echo " --url URL : Prow job URL" >&2 + echo " --local PATH : local directory with scenario-info/" >&2 + echo " --build-id ID : build label (default: local)" >&2 + echo " --output FILE : output HTML file path" >&2 + echo " --title TITLE : HTML <title> for the dashboard" >&2 echo " --parallel N : number of parallel extraction jobs (default: 6)" >&2 echo " --timezone TZ : IANA timezone for timestamps (default: UTC)" >&2 exit 1 @@ -35,6 +49,18 @@ while [[ $# -gt 0 ]]; do --url) [[ $# -lt 2 ]] && { echo "Error: --url requires a URL" >&2; usage; } URL="$2"; shift 2 ;; + --local) + [[ $# -lt 2 ]] && { echo "Error: --local requires a path" >&2; usage; } + LOCAL_PATH="$2"; shift 2 ;; + --build-id) + [[ $# -lt 2 ]] && { echo "Error: --build-id requires a value" >&2; usage; } + BUILD_ID="$2"; shift 2 ;; + --output) + [[ $# -lt 2 ]] && { echo "Error: --output requires a file path" >&2; usage; } + OUTPUT="$2"; shift 2 ;; + --title) + [[ $# -lt 2 ]] && { echo "Error: --title requires a value" >&2; usage; } + TITLE="$2"; shift 2 ;; --parallel) [[ $# -lt 2 ]] && { echo "Error: --parallel requires a number" >&2; usage; } [[ "$2" =~ ^[1-9][0-9]*$ ]] || { echo "Error: --parallel must be a positive integer" >&2; usage; } @@ -47,20 +73,34 @@ while [[ $# -gt 0 ]]; do esac done -if [[ -z "${URL}" ]]; then - echo "Error: --url is required" >&2 +if [[ -n "${URL}" && -n "${LOCAL_PATH}" ]]; then + echo "Error: --url and --local are mutually exclusive" >&2 + usage +fi + +if [[ -z "${URL}" && -z "${LOCAL_PATH}" ]]; then + echo "Error: --url or --local is required" >&2 usage fi # --------------------------------------------------------------------------- -# URL parsing and artifact download (delegates to shared download-jobs.sh) +# Set up WORKDIR depending on mode # --------------------------------------------------------------------------- -# Extract build ID (last path segment of the URL) to compute workdir -BUILD_ID=$(basename "${URL%/}") -WORKDIR="/tmp/microshift-job-pcp-dashboard.${BUILD_ID}" - -bash "${SHARED_SCRIPTS}/download-jobs.sh" --workdir "${WORKDIR}" --url "${URL}" +if [[ -n "${LOCAL_PATH}" ]]; then + # Local mode: symlink the local scenario-info into a temp workdir + LOCAL_PATH="$(cd "${LOCAL_PATH}" && pwd)" + BUILD_ID="${BUILD_ID:-local}" + WORKDIR=$(mktemp -d "/tmp/microshift-pcp-local.XXXXXX") + mkdir -p "${WORKDIR}/artifacts/${BUILD_ID}" + ln -s "${LOCAL_PATH}" "${WORKDIR}/artifacts/${BUILD_ID}/scenario-info" + echo "Local mode: ${LOCAL_PATH} -> ${WORKDIR}/artifacts/${BUILD_ID}/scenario-info" >&2 +else + # URL mode: download artifacts via shared script + BUILD_ID=$(basename "${URL%/}") + WORKDIR="/tmp/microshift-job-pcp-dashboard.${BUILD_ID}" + bash "${SHARED_SCRIPTS}/download-jobs.sh" --workdir "${WORKDIR}" --url "${URL}" +fi # --------------------------------------------------------------------------- # pcp2json detection: native or container fallback @@ -121,7 +161,7 @@ run_pcp2json() { # --------------------------------------------------------------------------- find_pcp_tarballs() { - find "${WORKDIR}/artifacts" -name "pcp-archives.tar" -path "*/vms/*/pcp/*" \ + find -L "${WORKDIR}/artifacts" -name "pcp-archives.tar" -path "*/vms/*/pcp/*" \ 2>/dev/null | sort } @@ -223,7 +263,7 @@ process_tarball() { # --------------------------------------------------------------------------- find_hypervisor_pcp_dirs() { - find "${WORKDIR}/artifacts" -name "Latest" -path "*pmlogs*" \ + find -L "${WORKDIR}/artifacts" -name "Latest" -path "*pmlogs*" \ -exec dirname {} \; 2>/dev/null | sort } @@ -327,10 +367,13 @@ python3 "${SCRIPT_DIR}/extract_scenarios.py" --workdir "${WORKDIR}" # Generate the HTML dashboard echo "Generating HTML dashboard..." >&2 -python3 "${SCRIPT_DIR}/create-pcp-dashboard.py" \ - --workdir "${WORKDIR}" --timezone "${TIMEZONE}" +dashboard_args=(--workdir "${WORKDIR}" --timezone "${TIMEZONE}") +[[ -n "${OUTPUT}" ]] && dashboard_args+=(--output "${OUTPUT}") +[[ -n "${TITLE}" ]] && dashboard_args+=(--title "${TITLE}") + +python3 "${SCRIPT_DIR}/create-pcp-dashboard.py" "${dashboard_args[@]}" -output="${WORKDIR}/pcp-dashboard.html" +output="${OUTPUT:-${WORKDIR}/pcp-dashboard.html}" if [[ -f "${output}" ]]; then echo "Dashboard: ${output}" >&2 else From 50f6c08a2084b4f0ba485efc10e1fd3ac9d72bf9 Mon Sep 17 00:00:00 2001 From: Suleyman Akbas <sakbas@redhat.com> Date: Thu, 16 Jul 2026 14:34:32 +0200 Subject: [PATCH 2/3] chore(microshift-ci): bump plugin version to 1.5.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .claude-plugin/marketplace.json | 2 +- plugins/microshift-ci/.claude-plugin/plugin.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0b63d4cf..21f73ea5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -70,7 +70,7 @@ "name": "microshift-ci", "source": "./plugins/microshift-ci", "description": "MicroShift CI Automation", - "version": "1.4.2" + "version": "1.5.0" }, { "name": "microshift-dev", diff --git a/plugins/microshift-ci/.claude-plugin/plugin.json b/plugins/microshift-ci/.claude-plugin/plugin.json index 5261da1d..9c3301f7 100644 --- a/plugins/microshift-ci/.claude-plugin/plugin.json +++ b/plugins/microshift-ci/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "microshift-ci", "description": "MicroShift CI Automation", - "version": "1.4.2", + "version": "1.5.0", "author": { "name": "ggiguash" }, From 6ef682bb77718b477fb935a63f12f8967c99af08 Mon Sep 17 00:00:00 2001 From: Suleyman Akbas <sakbas@redhat.com> Date: Thu, 16 Jul 2026 14:38:14 +0200 Subject: [PATCH 3/3] fix(microshift-ci): address review findings for PCP dashboard - Prevent symlink cycles in extract_scenarios.py by tracking visited (st_dev, st_ino) pairs during os.walk with followlinks=True - Validate --build-id against allow-list [a-zA-Z0-9._-] to reject path traversal sequences - Fix --local help text to clarify it expects the scenario-info/ directory path itself Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .../microshift-ci/scripts/pcp-graphs/extract_scenarios.py | 7 +++++++ .../microshift-ci/scripts/pcp-graphs/generate-dashboard.sh | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/plugins/microshift-ci/scripts/pcp-graphs/extract_scenarios.py b/plugins/microshift-ci/scripts/pcp-graphs/extract_scenarios.py index 5321b374..e27d889b 100755 --- a/plugins/microshift-ci/scripts/pcp-graphs/extract_scenarios.py +++ b/plugins/microshift-ci/scripts/pcp-graphs/extract_scenarios.py @@ -19,11 +19,18 @@ def find_scenario_dirs(artifacts_root): """Yield (build_id, scenario_name, scenario_dir) tuples.""" if not os.path.isdir(artifacts_root): return + seen = set() for build_id in os.listdir(artifacts_root): build_dir = os.path.join(artifacts_root, build_id) if not os.path.isdir(build_dir): continue for root, dirs, _files in os.walk(build_dir, followlinks=True): + st = os.stat(root) + ident = (st.st_dev, st.st_ino) + if ident in seen: + dirs.clear() + continue + seen.add(ident) if os.path.basename(root) != "scenario-info": continue for scenario in sorted(os.listdir(root)): diff --git a/plugins/microshift-ci/scripts/pcp-graphs/generate-dashboard.sh b/plugins/microshift-ci/scripts/pcp-graphs/generate-dashboard.sh index 57daf101..3429110d 100755 --- a/plugins/microshift-ci/scripts/pcp-graphs/generate-dashboard.sh +++ b/plugins/microshift-ci/scripts/pcp-graphs/generate-dashboard.sh @@ -3,7 +3,7 @@ # # Two modes: # --url <prow-url> Download artifacts from GCS (default) -# --local <path> Use a local directory containing scenario-info/ +# --local <path> Use a local scenario-info/ directory # # Usage: # generate-dashboard.sh --url <prow-url> [--parallel N] [--timezone TZ] @@ -35,7 +35,7 @@ usage() { echo " ${0} --local <path> [--build-id ID] [--output FILE] [--title TITLE] [--timezone TZ]" >&2 echo "" >&2 echo " --url URL : Prow job URL" >&2 - echo " --local PATH : local directory with scenario-info/" >&2 + echo " --local PATH : path to scenario-info/ directory" >&2 echo " --build-id ID : build label (default: local)" >&2 echo " --output FILE : output HTML file path" >&2 echo " --title TITLE : HTML <title> for the dashboard" >&2 @@ -54,6 +54,7 @@ while [[ $# -gt 0 ]]; do LOCAL_PATH="$2"; shift 2 ;; --build-id) [[ $# -lt 2 ]] && { echo "Error: --build-id requires a value" >&2; usage; } + [[ "$2" =~ ^[a-zA-Z0-9._-]+$ ]] || { echo "Error: --build-id contains invalid characters" >&2; usage; } BUILD_ID="$2"; shift 2 ;; --output) [[ $# -lt 2 ]] && { echo "Error: --output requires a file path" >&2; usage; }