From 40a3dbf42c943fdebfe82ee65cfaaea361fc106f Mon Sep 17 00:00:00 2001 From: Gandalf the Greybeard Date: Mon, 16 Mar 2026 15:15:28 +0000 Subject: [PATCH 01/26] e2e: shared volume plugin deployment replacing init container approach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the init container plugin installation with a shared PVC volume between the CI runner and Headlamp pod. The runner builds the plugin and copies it to the shared mount; Headlamp reads from the same volume. - Add deployment/headlamp-e2e-values.yaml (PVC-backed shared volume) - Add deployment/headlamp-plugins-pvc.yaml (PVC manifest) - Add scripts/deploy-plugin-via-volume.sh (build + copy + restart) - Remove deployment/headlamp-static-plugin-values.yaml (init container) This is CI-only test infrastructure — ArtifactHub remains the sole user-facing distribution channel. Co-Authored-By: Paperclip --- deployment/headlamp-e2e-values.yaml | 22 +++++ deployment/headlamp-plugins-pvc.yaml | 14 ++++ deployment/headlamp-static-plugin-values.yaml | 83 ------------------- scripts/deploy-plugin-via-volume.sh | 49 +++++++++++ 4 files changed, 85 insertions(+), 83 deletions(-) create mode 100644 deployment/headlamp-e2e-values.yaml create mode 100644 deployment/headlamp-plugins-pvc.yaml delete mode 100644 deployment/headlamp-static-plugin-values.yaml create mode 100755 scripts/deploy-plugin-via-volume.sh diff --git a/deployment/headlamp-e2e-values.yaml b/deployment/headlamp-e2e-values.yaml new file mode 100644 index 0000000..36ab498 --- /dev/null +++ b/deployment/headlamp-e2e-values.yaml @@ -0,0 +1,22 @@ +--- +# Headlamp Helm values for E2E testing with shared volume plugin deployment. +# +# The CI runner and Headlamp pod share a PVC so that the runner can copy +# built plugin artifacts directly into Headlamp's plugins directory. +# This is a CI-only mechanism — production plugin distribution uses ArtifactHub. + +# Point Headlamp at the shared plugins mount +config: + pluginsDir: /headlamp/plugins + +# PVC-backed volume shared with the CI runner +volumes: + - name: plugins + persistentVolumeClaim: + claimName: headlamp-plugins + +# Mount into the Headlamp container +volumeMounts: + - name: plugins + mountPath: /headlamp/plugins + readOnly: true diff --git a/deployment/headlamp-plugins-pvc.yaml b/deployment/headlamp-plugins-pvc.yaml new file mode 100644 index 0000000..f103a21 --- /dev/null +++ b/deployment/headlamp-plugins-pvc.yaml @@ -0,0 +1,14 @@ +--- +# PVC for sharing built plugin artifacts between the CI runner and Headlamp. +# Used only in E2E test environments — not for production. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: headlamp-plugins + namespace: kube-system +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 128Mi diff --git a/deployment/headlamp-static-plugin-values.yaml b/deployment/headlamp-static-plugin-values.yaml deleted file mode 100644 index 59618f3..0000000 --- a/deployment/headlamp-static-plugin-values.yaml +++ /dev/null @@ -1,83 +0,0 @@ ---- -# Custom Headlamp values for static plugin installation -# This disables the plugin manager and uses an init container instead - -# Disable the plugin manager sidecar -pluginsManager: - enabled: false - -# Use an init container to install plugins to /headlamp/static-plugins -initContainers: - - name: install-plugins - image: node:lts-alpine - command: - - /bin/sh - - -c - - | - set -e - echo "Installing plugins to /headlamp/static-plugins..." - - # Create plugins directory - mkdir -p /headlamp/static-plugins - - # Set up npm cache - export NPM_CONFIG_CACHE=/tmp/npm-cache - export NPM_CONFIG_USERCONFIG=/tmp/npm-userconfig - mkdir -p /tmp/npm-cache /tmp/npm-userconfig - - # Install polaris plugin - echo "Installing polaris plugin..." - cd /headlamp/static-plugins - npm pack headlamp-polaris-plugin@0.3.0 - tar -xzf headlamp-polaris-plugin-0.3.0.tgz - mv package headlamp-polaris-plugin - rm headlamp-polaris-plugin-0.3.0.tgz - - # Install other plugins - npx --yes @headlamp-k8s/plugin@latest install \ - --source https://artifacthub.io/packages/headlamp/headlamp-plugins/headlamp_flux \ - --folderName /headlamp/static-plugins - - npx --yes @headlamp-k8s/plugin@latest install \ - --source https://artifacthub.io/packages/headlamp/headlamp-trivy/headlamp_trivy \ - --folderName /headlamp/static-plugins - - npx --yes @headlamp-k8s/plugin@latest install \ - --source https://artifacthub.io/packages/headlamp/headlamp-plugins/headlamp_cert-manager \ - --folderName /headlamp/static-plugins - - npx --yes @headlamp-k8s/plugin@latest install \ - --source https://artifacthub.io/packages/headlamp/headlamp-plugins/headlamp_ai_assistant \ - --folderName /headlamp/static-plugins - - echo "All plugins installed successfully" - ls -la /headlamp/static-plugins - securityContext: - runAsUser: 100 - runAsGroup: 101 - runAsNonRoot: true - privileged: false - resources: - requests: - cpu: 100m - memory: 256Mi - limits: - memory: 512Mi - volumeMounts: - - name: static-plugins - mountPath: /headlamp/static-plugins - -# Configure headlamp to use static plugins -config: - pluginsDir: /headlamp/static-plugins - -# Add volume for static plugins -volumes: - - name: static-plugins - emptyDir: {} - -# Add volume mount to main container -volumeMounts: - - name: static-plugins - mountPath: /headlamp/static-plugins - readOnly: true diff --git a/scripts/deploy-plugin-via-volume.sh b/scripts/deploy-plugin-via-volume.sh new file mode 100755 index 0000000..c89c719 --- /dev/null +++ b/scripts/deploy-plugin-via-volume.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# deploy-plugin-via-volume.sh +# +# Copies the built plugin into the shared PVC so Headlamp picks it up. +# The PVC must already be mounted on the CI runner at PLUGIN_VOLUME_PATH. +# +# Usage: +# scripts/deploy-plugin-via-volume.sh [plugin-volume-path] +# +# Environment: +# PLUGIN_VOLUME_PATH — mount point of the shared PVC (default: /mnt/headlamp-plugins) +# HEADLAMP_NAMESPACE — namespace where Headlamp runs (default: kube-system) +# HEADLAMP_DEPLOY — Headlamp deployment name (default: headlamp) +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PLUGIN_VOLUME_PATH="${1:-${PLUGIN_VOLUME_PATH:-/mnt/headlamp-plugins}}" +HEADLAMP_NAMESPACE="${HEADLAMP_NAMESPACE:-kube-system}" +HEADLAMP_DEPLOY="${HEADLAMP_DEPLOY:-headlamp}" + +# The deployed directory name must match the plugin's registered name. +# PR #56 aligns registerPluginSettings to "polaris"; the directory must match. +PLUGIN_DIR_NAME="polaris" +DIST_DIR="$REPO_ROOT/dist" + +if [ ! -d "$DIST_DIR" ]; then + echo "ERROR: dist/ not found. Run 'npm run build' first." >&2 + exit 1 +fi + +echo "Deploying plugin to shared volume..." +echo " Source: $DIST_DIR" +echo " Destination: $PLUGIN_VOLUME_PATH/$PLUGIN_DIR_NAME" + +# Clean any previous version and copy fresh build +rm -rf "${PLUGIN_VOLUME_PATH:?}/${PLUGIN_DIR_NAME}" +mkdir -p "$PLUGIN_VOLUME_PATH/$PLUGIN_DIR_NAME" +cp -a "$DIST_DIR"/. "$PLUGIN_VOLUME_PATH/$PLUGIN_DIR_NAME/" +cp "$REPO_ROOT/package.json" "$PLUGIN_VOLUME_PATH/$PLUGIN_DIR_NAME/" + +echo "Plugin files deployed:" +ls -la "$PLUGIN_VOLUME_PATH/$PLUGIN_DIR_NAME/" + +# Restart Headlamp to pick up the new plugin +echo "Restarting Headlamp deployment to load plugin..." +kubectl rollout restart "deployment/$HEADLAMP_DEPLOY" -n "$HEADLAMP_NAMESPACE" +kubectl rollout status "deployment/$HEADLAMP_DEPLOY" -n "$HEADLAMP_NAMESPACE" --timeout=120s + +echo "Plugin deployed successfully." -- 2.52.0 From 8501f06a56075dac65ab400cf192df03039da707 Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Mon, 16 Mar 2026 15:15:43 +0000 Subject: [PATCH 02/26] ci: update e2e workflow for shared volume plugin deployment Replace the old preflight-only approach with a build-and-deploy flow that uses a shared volume (hostPath) between the CI runner and the Headlamp pod. The workflow now builds the plugin from source, copies the artifact to a shared volume path, and optionally calls Gandalf's deploy script for Headlamp rollout coordination. Removes kubectl exec/cp references and version-match preflight in favor of deploying the PR's actual build artifact. Refs: PRI-216, PRI-195 Co-Authored-By: Paperclip --- .github/workflows/e2e.yaml | 78 ++++++++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 21 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 9685c69..659e50f 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -7,6 +7,11 @@ on: branches: [main] workflow_dispatch: +env: + SHARED_VOLUME_PATH: /shared/headlamp-plugins + HEADLAMP_NAMESPACE: kube-system + HEADLAMP_RELEASE: headlamp + jobs: e2e: runs-on: local-ubuntu-latest @@ -25,46 +30,77 @@ jobs: - name: Install dependencies run: npm ci - - name: Preflight — verify Headlamp and plugin version + - name: Build plugin + run: npm run build + + - name: Deploy plugin via shared volume env: HEADLAMP_URL: ${{ secrets.HEADLAMP_URL || 'http://headlamp.kube-system.svc.cluster.local' }} run: | - EXPECTED=$(node -p "require('./package.json').version") - PLUGIN_NAME=$(node -p "require('./package.json').artifacthub?.name || require('./package.json').name") - echo "Expected: $PLUGIN_NAME@$EXPECTED" + PLUGIN_NAME=$(node -p "require('./package.json').name") + PLUGIN_DIR="$SHARED_VOLUME_PATH/$PLUGIN_NAME" + + echo "Deploying $PLUGIN_NAME to shared volume at $SHARED_VOLUME_PATH" + + # Ensure shared volume directory exists + mkdir -p "$SHARED_VOLUME_PATH" + + # Remove stale plugin if present, then copy fresh build + rm -rf "$PLUGIN_DIR" + cp -r dist "$PLUGIN_DIR" + + # Copy package.json for version identification + cp package.json "$PLUGIN_DIR/package.json" + + echo "Plugin deployed to $PLUGIN_DIR" + ls -la "$PLUGIN_DIR" + + # If Gandalf's deploy script exists, use it for Headlamp rollout + if [ -x scripts/deploy-plugin-via-volume.sh ]; then + echo "Running deploy script..." + scripts/deploy-plugin-via-volume.sh + else + echo "::warning::deploy-plugin-via-volume.sh not found — relying on pre-configured shared volume mount" + fi + + - name: Preflight — verify Headlamp and plugin availability + env: + HEADLAMP_URL: ${{ secrets.HEADLAMP_URL || 'http://headlamp.kube-system.svc.cluster.local' }} + run: | + PLUGIN_NAME=$(node -p "require('./package.json').name") + EXPECTED=$(node -p "require('./package.json').version") + echo "Expecting: $PLUGIN_NAME@$EXPECTED" + + # Wait for Headlamp to be reachable + for i in $(seq 1 30); do + HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 "$HEADLAMP_URL" || true) + if [ "$HTTP_CODE" != "000" ]; then + echo "Headlamp responded HTTP $HTTP_CODE" + break + fi + echo "Waiting for Headlamp... ($i/30)" + sleep 2 + done - # Check Headlamp connectivity - HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 10 "$HEADLAMP_URL" || true) if [ "$HTTP_CODE" = "000" ]; then - echo "::error::Cannot reach Headlamp at $HEADLAMP_URL" + echo "::error::Cannot reach Headlamp at $HEADLAMP_URL after 60s" exit 1 fi - echo "Headlamp responded HTTP $HTTP_CODE" - # Check installed plugins and version match + # Verify plugin is visible PLUGIN_JSON=$(curl -sf --connect-timeout 10 "$HEADLAMP_URL/plugins" 2>/dev/null || echo "[]") node -e " - const expected = '$EXPECTED'; - const pluginName = '$PLUGIN_NAME'; const plugins = JSON.parse(process.argv[1]); console.log('Installed plugins:'); for (const p of plugins) console.log(' ' + p.name + '@' + (p.version||'unknown')); - const ours = plugins.find(p => p.name === pluginName || p.name === 'polaris' || p.name.includes('polaris')); + const ours = plugins.find(p => p.name === '$PLUGIN_NAME' || p.name === 'polaris' || p.name.includes('polaris')); if (!ours) { - console.log('::warning::Plugin ' + pluginName + ' not found in Headlamp — data-dependent tests will fail'); + console.log('::warning::Plugin $PLUGIN_NAME not yet visible — Headlamp may need a restart'); } else { console.log('Found plugin: ' + ours.name + ' at path ' + ours.path); } " "$PLUGIN_JSON" - # Fetch deployed plugin version from package.json - DEPLOYED_VERSION=$(curl -sf --connect-timeout 10 "$HEADLAMP_URL/plugins/$PLUGIN_NAME/package.json" 2>/dev/null \ - | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version" 2>/dev/null || echo "unknown") - echo "Deployed version: $DEPLOYED_VERSION" - if [ "$DEPLOYED_VERSION" != "$EXPECTED" ] && [ "$DEPLOYED_VERSION" != "unknown" ]; then - echo "::warning::Version mismatch — repo has $EXPECTED but Headlamp runs $DEPLOYED_VERSION. Tests may fail due to stale plugin." - fi - - name: Install Playwright browsers run: npx playwright install --with-deps chromium -- 2.52.0 From 06386e445b4400913007fbb24a69ea9cfa5ea27a Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Mon, 16 Mar 2026 19:21:45 +0000 Subject: [PATCH 03/26] ci: align e2e workflow with Gandalf's deploy script interface Simplify deploy step to call scripts/deploy-plugin-via-volume.sh directly instead of duplicating copy logic. Align env var names (PLUGIN_VOLUME_PATH, HEADLAMP_DEPLOY) with the deploy script's expected interface from PR #59. Co-Authored-By: Paperclip --- .github/workflows/e2e.yaml | 33 +++------------------------------ 1 file changed, 3 insertions(+), 30 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 659e50f..271cd0c 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -8,9 +8,9 @@ on: workflow_dispatch: env: - SHARED_VOLUME_PATH: /shared/headlamp-plugins + PLUGIN_VOLUME_PATH: /mnt/headlamp-plugins HEADLAMP_NAMESPACE: kube-system - HEADLAMP_RELEASE: headlamp + HEADLAMP_DEPLOY: headlamp jobs: e2e: @@ -34,34 +34,7 @@ jobs: run: npm run build - name: Deploy plugin via shared volume - env: - HEADLAMP_URL: ${{ secrets.HEADLAMP_URL || 'http://headlamp.kube-system.svc.cluster.local' }} - run: | - PLUGIN_NAME=$(node -p "require('./package.json').name") - PLUGIN_DIR="$SHARED_VOLUME_PATH/$PLUGIN_NAME" - - echo "Deploying $PLUGIN_NAME to shared volume at $SHARED_VOLUME_PATH" - - # Ensure shared volume directory exists - mkdir -p "$SHARED_VOLUME_PATH" - - # Remove stale plugin if present, then copy fresh build - rm -rf "$PLUGIN_DIR" - cp -r dist "$PLUGIN_DIR" - - # Copy package.json for version identification - cp package.json "$PLUGIN_DIR/package.json" - - echo "Plugin deployed to $PLUGIN_DIR" - ls -la "$PLUGIN_DIR" - - # If Gandalf's deploy script exists, use it for Headlamp rollout - if [ -x scripts/deploy-plugin-via-volume.sh ]; then - echo "Running deploy script..." - scripts/deploy-plugin-via-volume.sh - else - echo "::warning::deploy-plugin-via-volume.sh not found — relying on pre-configured shared volume mount" - fi + run: scripts/deploy-plugin-via-volume.sh - name: Preflight — verify Headlamp and plugin availability env: -- 2.52.0 From 813a9e1fcdd8068ebfc3d137c182b8eee9dd582b Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 09:10:09 +0000 Subject: [PATCH 04/26] fix: deploy plugin via temporary pod instead of assuming local PVC mount The deploy script assumed the PVC was mounted on the CI runner at /mnt/headlamp-plugins, but the runner pod doesn't have that mount. Fix by using a temporary pod (kubectl run) that mounts the PVC, receives the plugin tarball via stdin, and extracts it. Also adds missing workflow steps to create the PVC and upgrade Headlamp with the shared volume helm values before deploying. Co-Authored-By: Paperclip --- .github/workflows/e2e.yaml | 12 +++++- scripts/deploy-plugin-via-volume.sh | 60 ++++++++++++++++++++++------- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 271cd0c..86aa19f 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -8,7 +8,6 @@ on: workflow_dispatch: env: - PLUGIN_VOLUME_PATH: /mnt/headlamp-plugins HEADLAMP_NAMESPACE: kube-system HEADLAMP_DEPLOY: headlamp @@ -33,6 +32,17 @@ jobs: - name: Build plugin run: npm run build + - name: Ensure PVC exists + run: kubectl apply -f deployment/headlamp-plugins-pvc.yaml + + - name: Upgrade Headlamp with shared volume mount + run: | + helm upgrade headlamp headlamp/headlamp \ + --namespace "$HEADLAMP_NAMESPACE" \ + --reuse-values \ + -f deployment/headlamp-e2e-values.yaml \ + --wait --timeout 120s + - name: Deploy plugin via shared volume run: scripts/deploy-plugin-via-volume.sh diff --git a/scripts/deploy-plugin-via-volume.sh b/scripts/deploy-plugin-via-volume.sh index c89c719..881cb62 100755 --- a/scripts/deploy-plugin-via-volume.sh +++ b/scripts/deploy-plugin-via-volume.sh @@ -2,19 +2,18 @@ # deploy-plugin-via-volume.sh # # Copies the built plugin into the shared PVC so Headlamp picks it up. -# The PVC must already be mounted on the CI runner at PLUGIN_VOLUME_PATH. +# Uses a temporary Kubernetes pod to write to the PVC — the CI runner +# does NOT need the PVC mounted locally. # # Usage: -# scripts/deploy-plugin-via-volume.sh [plugin-volume-path] +# scripts/deploy-plugin-via-volume.sh # # Environment: -# PLUGIN_VOLUME_PATH — mount point of the shared PVC (default: /mnt/headlamp-plugins) # HEADLAMP_NAMESPACE — namespace where Headlamp runs (default: kube-system) # HEADLAMP_DEPLOY — Headlamp deployment name (default: headlamp) set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -PLUGIN_VOLUME_PATH="${1:-${PLUGIN_VOLUME_PATH:-/mnt/headlamp-plugins}}" HEADLAMP_NAMESPACE="${HEADLAMP_NAMESPACE:-kube-system}" HEADLAMP_DEPLOY="${HEADLAMP_DEPLOY:-headlamp}" @@ -28,18 +27,51 @@ if [ ! -d "$DIST_DIR" ]; then exit 1 fi -echo "Deploying plugin to shared volume..." -echo " Source: $DIST_DIR" -echo " Destination: $PLUGIN_VOLUME_PATH/$PLUGIN_DIR_NAME" +echo "Deploying plugin to shared volume via temporary pod..." +echo " Source: $DIST_DIR" +echo " PVC: headlamp-plugins" +echo " Plugin: $PLUGIN_DIR_NAME" -# Clean any previous version and copy fresh build -rm -rf "${PLUGIN_VOLUME_PATH:?}/${PLUGIN_DIR_NAME}" -mkdir -p "$PLUGIN_VOLUME_PATH/$PLUGIN_DIR_NAME" -cp -a "$DIST_DIR"/. "$PLUGIN_VOLUME_PATH/$PLUGIN_DIR_NAME/" -cp "$REPO_ROOT/package.json" "$PLUGIN_VOLUME_PATH/$PLUGIN_DIR_NAME/" +# Create tarball of plugin dist + package.json +TAR_FILE=$(mktemp /tmp/plugin-XXXXXX.tar.gz) +tar -czf "$TAR_FILE" -C "$DIST_DIR" . -C "$REPO_ROOT" package.json +echo " Tarball: $TAR_FILE ($(du -h "$TAR_FILE" | cut -f1))" -echo "Plugin files deployed:" -ls -la "$PLUGIN_VOLUME_PATH/$PLUGIN_DIR_NAME/" +# Clean up any previous deploy pod +kubectl delete pod plugin-deploy -n "$HEADLAMP_NAMESPACE" --ignore-not-found --wait=false 2>/dev/null || true +sleep 2 + +# Run a temporary pod that mounts the PVC and receives the tarball via stdin +echo "Starting deploy pod..." +kubectl run plugin-deploy \ + --rm -i \ + --restart=Never \ + --image=busybox:1.36 \ + --namespace="$HEADLAMP_NAMESPACE" \ + --overrides="{ + \"spec\": { + \"containers\": [{ + \"name\": \"plugin-deploy\", + \"image\": \"busybox:1.36\", + \"stdin\": true, + \"command\": [\"sh\", \"-c\", + \"rm -rf /plugins/${PLUGIN_DIR_NAME} && mkdir -p /plugins/${PLUGIN_DIR_NAME} && tar -xzf - -C /plugins/${PLUGIN_DIR_NAME} && echo Files deployed: && ls -la /plugins/${PLUGIN_DIR_NAME}/\" + ], + \"volumeMounts\": [{ + \"name\": \"plugins\", + \"mountPath\": \"/plugins\" + }] + }], + \"volumes\": [{ + \"name\": \"plugins\", + \"persistentVolumeClaim\": { + \"claimName\": \"headlamp-plugins\" + } + }] + } + }" < "$TAR_FILE" + +rm -f "$TAR_FILE" # Restart Headlamp to pick up the new plugin echo "Restarting Headlamp deployment to load plugin..." -- 2.52.0 From 8e51a20b48d72738a1d8b12b55d260becc1ff61c Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 09:11:53 +0000 Subject: [PATCH 05/26] fix: add kubectl, helm, and helm repo setup steps to e2e workflow The self-hosted runner doesn't have kubectl or helm pre-installed. Add setup steps using azure/setup-kubectl and azure/setup-helm actions, and add the Headlamp helm repo before the upgrade step. Co-Authored-By: Paperclip --- .github/workflows/e2e.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 86aa19f..e65370d 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -32,6 +32,15 @@ jobs: - name: Build plugin run: npm run build + - name: Setup kubectl + uses: azure/setup-kubectl@v4 + + - name: Setup Helm + uses: azure/setup-helm@v4 + + - name: Add Headlamp Helm repo + run: helm repo add headlamp https://headlamp-k8s.github.io/headlamp/ && helm repo update + - name: Ensure PVC exists run: kubectl apply -f deployment/headlamp-plugins-pvc.yaml -- 2.52.0 From d4af8c035f415902edd65d3ccf29e4957b2637b0 Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 12:13:31 +0000 Subject: [PATCH 06/26] fix: update Headlamp Helm repo URL from headlamp-k8s to kubernetes-sigs The Headlamp project moved to the kubernetes-sigs org. The old Helm chart repository URL (headlamp-k8s.github.io) returns 404, causing E2E workflow failure at the `helm repo add` step. Co-Authored-By: Paperclip --- .github/workflows/e2e.yaml | 2 +- docs/deployment/helm.md | 4 ++-- docs/getting-started/prerequisites.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index e65370d..f9c2e17 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -39,7 +39,7 @@ jobs: uses: azure/setup-helm@v4 - name: Add Headlamp Helm repo - run: helm repo add headlamp https://headlamp-k8s.github.io/headlamp/ && helm repo update + run: helm repo add headlamp https://kubernetes-sigs.github.io/headlamp/ && helm repo update - name: Ensure PVC exists run: kubectl apply -f deployment/headlamp-plugins-pvc.yaml diff --git a/docs/deployment/helm.md b/docs/deployment/helm.md index 702bfcc..26c6f1f 100644 --- a/docs/deployment/helm.md +++ b/docs/deployment/helm.md @@ -19,7 +19,7 @@ Helm provides the easiest way to deploy and manage the plugin in production. Thi ```bash # Add Headlamp Helm repository -helm repo add headlamp https://headlamp-k8s.github.io/headlamp/ +helm repo add headlamp https://kubernetes-sigs.github.io/headlamp/ helm repo update ``` @@ -210,7 +210,7 @@ metadata: namespace: flux-system spec: interval: 1h - url: https://headlamp-k8s.github.io/headlamp/ + url: https://kubernetes-sigs.github.io/headlamp/ ``` ### HelmRelease diff --git a/docs/getting-started/prerequisites.md b/docs/getting-started/prerequisites.md index 50bd721..2838ee2 100644 --- a/docs/getting-started/prerequisites.md +++ b/docs/getting-started/prerequisites.md @@ -84,7 +84,7 @@ kubectl -n kube-system get deployment headlamp -o jsonpath='{.spec.template.spec ```bash # Add Headlamp Helm repository -helm repo add headlamp https://headlamp-k8s.github.io/headlamp/ +helm repo add headlamp https://kubernetes-sigs.github.io/headlamp/ helm repo update # Install Headlamp -- 2.52.0 From e9f3fdc971c835d0bf05d04e5199cfe5a07010b6 Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 12:18:36 +0000 Subject: [PATCH 07/26] chore: add RBAC manifest for E2E CI runner Documents the Role and RoleBinding applied to the cluster for the ARC runner service account. Grants permissions in kube-system needed for shared volume plugin deployment (PVCs, pods, Helm resources). Co-Authored-By: Paperclip --- deployment/e2e-ci-runner-rbac.yaml | 53 ++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 deployment/e2e-ci-runner-rbac.yaml diff --git a/deployment/e2e-ci-runner-rbac.yaml b/deployment/e2e-ci-runner-rbac.yaml new file mode 100644 index 0000000..90ee671 --- /dev/null +++ b/deployment/e2e-ci-runner-rbac.yaml @@ -0,0 +1,53 @@ +--- +# RBAC for the GitHub Actions CI runner to perform E2E test setup. +# CI-only test fixture — NOT for production use. +# +# Grants the ARC runner service account permissions in kube-system to: +# - Create/manage PVCs (shared plugin volume) +# - Run temporary pods (plugin deploy helper) +# - Manage Helm release resources (secrets, configmaps, services) +# - Restart deployments (Headlamp rollout after plugin deploy) +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: e2e-ci-runner + namespace: kube-system +rules: + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: ["get", "list", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "create", "delete", "watch"] + - apiGroups: [""] + resources: ["pods/attach"] + verbs: ["create", "get"] + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "patch"] + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["services"] + verbs: ["get", "list", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["serviceaccounts"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: e2e-ci-runner-binding + namespace: kube-system +subjects: + - kind: ServiceAccount + name: local-ubuntu-latest-gha-rs-no-permission + namespace: arc-runners +roleRef: + kind: Role + name: e2e-ci-runner + apiGroup: rbac.authorization.k8s.io -- 2.52.0 From 4d34f1da86dffe0a2aa59539180316e020f00a2a Mon Sep 17 00:00:00 2001 From: Gandalf the Greybeard Date: Tue, 17 Mar 2026 12:19:58 +0000 Subject: [PATCH 08/26] fix: remove .github/workflows/e2e.yaml changes from PR The workflow changes should be handled separately by Hugh Hackman per PRI-215. This PR should only contain deployment manifests and scripts, not CI workflow modifications. Co-Authored-By: Paperclip --- .github/workflows/e2e.yaml | 68 +++++++++++--------------------------- 1 file changed, 20 insertions(+), 48 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index f9c2e17..9685c69 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -7,10 +7,6 @@ on: branches: [main] workflow_dispatch: -env: - HEADLAMP_NAMESPACE: kube-system - HEADLAMP_DEPLOY: headlamp - jobs: e2e: runs-on: local-ubuntu-latest @@ -29,70 +25,46 @@ jobs: - name: Install dependencies run: npm ci - - name: Build plugin - run: npm run build - - - name: Setup kubectl - uses: azure/setup-kubectl@v4 - - - name: Setup Helm - uses: azure/setup-helm@v4 - - - name: Add Headlamp Helm repo - run: helm repo add headlamp https://kubernetes-sigs.github.io/headlamp/ && helm repo update - - - name: Ensure PVC exists - run: kubectl apply -f deployment/headlamp-plugins-pvc.yaml - - - name: Upgrade Headlamp with shared volume mount - run: | - helm upgrade headlamp headlamp/headlamp \ - --namespace "$HEADLAMP_NAMESPACE" \ - --reuse-values \ - -f deployment/headlamp-e2e-values.yaml \ - --wait --timeout 120s - - - name: Deploy plugin via shared volume - run: scripts/deploy-plugin-via-volume.sh - - - name: Preflight — verify Headlamp and plugin availability + - name: Preflight — verify Headlamp and plugin version env: HEADLAMP_URL: ${{ secrets.HEADLAMP_URL || 'http://headlamp.kube-system.svc.cluster.local' }} run: | - PLUGIN_NAME=$(node -p "require('./package.json').name") EXPECTED=$(node -p "require('./package.json').version") - echo "Expecting: $PLUGIN_NAME@$EXPECTED" - - # Wait for Headlamp to be reachable - for i in $(seq 1 30); do - HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 "$HEADLAMP_URL" || true) - if [ "$HTTP_CODE" != "000" ]; then - echo "Headlamp responded HTTP $HTTP_CODE" - break - fi - echo "Waiting for Headlamp... ($i/30)" - sleep 2 - done + PLUGIN_NAME=$(node -p "require('./package.json').artifacthub?.name || require('./package.json').name") + echo "Expected: $PLUGIN_NAME@$EXPECTED" + # Check Headlamp connectivity + HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 10 "$HEADLAMP_URL" || true) if [ "$HTTP_CODE" = "000" ]; then - echo "::error::Cannot reach Headlamp at $HEADLAMP_URL after 60s" + echo "::error::Cannot reach Headlamp at $HEADLAMP_URL" exit 1 fi + echo "Headlamp responded HTTP $HTTP_CODE" - # Verify plugin is visible + # Check installed plugins and version match PLUGIN_JSON=$(curl -sf --connect-timeout 10 "$HEADLAMP_URL/plugins" 2>/dev/null || echo "[]") node -e " + const expected = '$EXPECTED'; + const pluginName = '$PLUGIN_NAME'; const plugins = JSON.parse(process.argv[1]); console.log('Installed plugins:'); for (const p of plugins) console.log(' ' + p.name + '@' + (p.version||'unknown')); - const ours = plugins.find(p => p.name === '$PLUGIN_NAME' || p.name === 'polaris' || p.name.includes('polaris')); + const ours = plugins.find(p => p.name === pluginName || p.name === 'polaris' || p.name.includes('polaris')); if (!ours) { - console.log('::warning::Plugin $PLUGIN_NAME not yet visible — Headlamp may need a restart'); + console.log('::warning::Plugin ' + pluginName + ' not found in Headlamp — data-dependent tests will fail'); } else { console.log('Found plugin: ' + ours.name + ' at path ' + ours.path); } " "$PLUGIN_JSON" + # Fetch deployed plugin version from package.json + DEPLOYED_VERSION=$(curl -sf --connect-timeout 10 "$HEADLAMP_URL/plugins/$PLUGIN_NAME/package.json" 2>/dev/null \ + | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version" 2>/dev/null || echo "unknown") + echo "Deployed version: $DEPLOYED_VERSION" + if [ "$DEPLOYED_VERSION" != "$EXPECTED" ] && [ "$DEPLOYED_VERSION" != "unknown" ]; then + echo "::warning::Version mismatch — repo has $EXPECTED but Headlamp runs $DEPLOYED_VERSION. Tests may fail due to stale plugin." + fi + - name: Install Playwright browsers run: npx playwright install --with-deps chromium -- 2.52.0 From b1b3d84f57e1a2daf14c04f6ea10355593eb1508 Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 12:21:20 +0000 Subject: [PATCH 09/26] ci: add shared volume plugin deployment to E2E workflow Adds the build, Helm, PVC, and plugin deploy steps needed for the shared volume E2E approach. Uses the correct kubernetes-sigs Helm repo URL and overrides config.sessionTTL=0 to avoid schema validation error. This is the workflow counterpart to the deployment manifests and scripts already in this PR (PVC, values overlay, deploy script). Co-Authored-By: Paperclip --- .github/workflows/e2e.yaml | 69 +++++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 9685c69..ac1524e 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -7,6 +7,10 @@ on: branches: [main] workflow_dispatch: +env: + HEADLAMP_NAMESPACE: kube-system + HEADLAMP_DEPLOY: headlamp + jobs: e2e: runs-on: local-ubuntu-latest @@ -25,46 +29,71 @@ jobs: - name: Install dependencies run: npm ci - - name: Preflight — verify Headlamp and plugin version + - name: Build plugin + run: npm run build + + - name: Setup kubectl + uses: azure/setup-kubectl@v4 + + - name: Setup Helm + uses: azure/setup-helm@v4 + + - name: Add Headlamp Helm repo + run: helm repo add headlamp https://kubernetes-sigs.github.io/headlamp/ && helm repo update + + - name: Ensure PVC exists + run: kubectl apply -f deployment/headlamp-plugins-pvc.yaml + + - name: Upgrade Headlamp with shared volume mount + run: | + helm upgrade headlamp headlamp/headlamp \ + --namespace "$HEADLAMP_NAMESPACE" \ + --reuse-values \ + -f deployment/headlamp-e2e-values.yaml \ + --set config.sessionTTL=0 \ + --wait --timeout 120s + + - name: Deploy plugin via shared volume + run: scripts/deploy-plugin-via-volume.sh + + - name: Preflight — verify Headlamp and plugin availability env: HEADLAMP_URL: ${{ secrets.HEADLAMP_URL || 'http://headlamp.kube-system.svc.cluster.local' }} run: | + PLUGIN_NAME=$(node -p "require('./package.json').name") EXPECTED=$(node -p "require('./package.json').version") - PLUGIN_NAME=$(node -p "require('./package.json').artifacthub?.name || require('./package.json').name") - echo "Expected: $PLUGIN_NAME@$EXPECTED" + echo "Expecting: $PLUGIN_NAME@$EXPECTED" + + # Wait for Headlamp to be reachable + for i in $(seq 1 30); do + HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 "$HEADLAMP_URL" || true) + if [ "$HTTP_CODE" != "000" ]; then + echo "Headlamp responded HTTP $HTTP_CODE" + break + fi + echo "Waiting for Headlamp... ($i/30)" + sleep 2 + done - # Check Headlamp connectivity - HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 10 "$HEADLAMP_URL" || true) if [ "$HTTP_CODE" = "000" ]; then - echo "::error::Cannot reach Headlamp at $HEADLAMP_URL" + echo "::error::Cannot reach Headlamp at $HEADLAMP_URL after 60s" exit 1 fi - echo "Headlamp responded HTTP $HTTP_CODE" - # Check installed plugins and version match + # Verify plugin is visible PLUGIN_JSON=$(curl -sf --connect-timeout 10 "$HEADLAMP_URL/plugins" 2>/dev/null || echo "[]") node -e " - const expected = '$EXPECTED'; - const pluginName = '$PLUGIN_NAME'; const plugins = JSON.parse(process.argv[1]); console.log('Installed plugins:'); for (const p of plugins) console.log(' ' + p.name + '@' + (p.version||'unknown')); - const ours = plugins.find(p => p.name === pluginName || p.name === 'polaris' || p.name.includes('polaris')); + const ours = plugins.find(p => p.name === '$PLUGIN_NAME' || p.name === 'polaris' || p.name.includes('polaris')); if (!ours) { - console.log('::warning::Plugin ' + pluginName + ' not found in Headlamp — data-dependent tests will fail'); + console.log('::warning::Plugin $PLUGIN_NAME not yet visible — Headlamp may need a restart'); } else { console.log('Found plugin: ' + ours.name + ' at path ' + ours.path); } " "$PLUGIN_JSON" - # Fetch deployed plugin version from package.json - DEPLOYED_VERSION=$(curl -sf --connect-timeout 10 "$HEADLAMP_URL/plugins/$PLUGIN_NAME/package.json" 2>/dev/null \ - | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version" 2>/dev/null || echo "unknown") - echo "Deployed version: $DEPLOYED_VERSION" - if [ "$DEPLOYED_VERSION" != "$EXPECTED" ] && [ "$DEPLOYED_VERSION" != "unknown" ]; then - echo "::warning::Version mismatch — repo has $EXPECTED but Headlamp runs $DEPLOYED_VERSION. Tests may fail due to stale plugin." - fi - - name: Install Playwright browsers run: npx playwright install --with-deps chromium -- 2.52.0 From f62a6a49536c83aeb7d9a12db94b572efbce21ed Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 12:39:13 +0000 Subject: [PATCH 10/26] fix(e2e): set sessionTTL=1 to satisfy Helm schema minimum The Headlamp Helm chart schema enforces a minimum of 1 for config.sessionTTL. Setting it to 0 caused helm upgrade to fail with a schema validation error. Co-Authored-By: Paperclip --- .github/workflows/e2e.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index ac1524e..2eca76e 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -50,7 +50,7 @@ jobs: --namespace "$HEADLAMP_NAMESPACE" \ --reuse-values \ -f deployment/headlamp-e2e-values.yaml \ - --set config.sessionTTL=0 \ + --set config.sessionTTL=1 \ --wait --timeout 120s - name: Deploy plugin via shared volume -- 2.52.0 From d5d16b2fe374a1ae9338321e1ad7f8dbc5076f16 Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 12:41:24 +0000 Subject: [PATCH 11/26] fix(e2e): add cluster-scoped RBAC for CI runner The Headlamp Helm chart manages ClusterRole and ClusterRoleBinding resources. The CI runner SA needs cluster-level permissions to get/update these during helm upgrade. Added ClusterRole and ClusterRoleBinding alongside the existing namespace-scoped Role. Co-Authored-By: Paperclip --- deployment/e2e-ci-runner-rbac.yaml | 36 +++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/deployment/e2e-ci-runner-rbac.yaml b/deployment/e2e-ci-runner-rbac.yaml index 90ee671..ee1fe3f 100644 --- a/deployment/e2e-ci-runner-rbac.yaml +++ b/deployment/e2e-ci-runner-rbac.yaml @@ -2,11 +2,37 @@ # RBAC for the GitHub Actions CI runner to perform E2E test setup. # CI-only test fixture — NOT for production use. # -# Grants the ARC runner service account permissions in kube-system to: -# - Create/manage PVCs (shared plugin volume) -# - Run temporary pods (plugin deploy helper) -# - Manage Helm release resources (secrets, configmaps, services) -# - Restart deployments (Headlamp rollout after plugin deploy) +# The Headlamp Helm chart manages cluster-scoped resources (ClusterRole, +# ClusterRoleBinding). The CI runner SA needs cluster-level read/write on +# these resources for `helm upgrade` to succeed, plus namespace-scoped +# permissions in kube-system for PVCs, pods, Helm secrets, etc. +# +# Apply with: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml + +# --- Cluster-scoped permissions (for Headlamp Helm chart resources) --- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: e2e-ci-runner +rules: + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["clusterroles", "clusterrolebindings"] + verbs: ["get", "list", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: e2e-ci-runner-binding +subjects: + - kind: ServiceAccount + name: local-ubuntu-latest-gha-rs-no-permission + namespace: arc-runners +roleRef: + kind: ClusterRole + name: e2e-ci-runner + apiGroup: rbac.authorization.k8s.io +--- +# --- Namespace-scoped permissions (kube-system) --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: -- 2.52.0 From e679216660ca3fa3b3ca0eaefab8ed18f1942604 Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 17:07:38 +0000 Subject: [PATCH 12/26] fix(e2e): replace helm upgrade with kubectl patch to avoid cluster RBAC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI runner SA cannot access cluster-scoped resources (ClusterRole, ClusterRoleBinding) needed by helm upgrade's 3-way merge. Replace the helm upgrade step with kubectl patch commands that add the shared volume mount directly to the Headlamp deployment. This eliminates the need for cluster-admin intervention: - kubectl patch adds PVC volume + volumeMount to the deployment - kubectl set env configures the plugins directory - kubectl rollout status waits for the update Also removes the now-unnecessary ClusterRole/ClusterRoleBinding from the RBAC manifest — only namespace-scoped Role/RoleBinding is needed. Co-Authored-By: Paperclip --- .github/workflows/e2e.yaml | 48 ++++++++++++++++++++++-------- deployment/e2e-ci-runner-rbac.yaml | 40 +++++++------------------ 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 2eca76e..e335c90 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -35,23 +35,45 @@ jobs: - name: Setup kubectl uses: azure/setup-kubectl@v4 - - name: Setup Helm - uses: azure/setup-helm@v4 - - - name: Add Headlamp Helm repo - run: helm repo add headlamp https://kubernetes-sigs.github.io/headlamp/ && helm repo update - - name: Ensure PVC exists run: kubectl apply -f deployment/headlamp-plugins-pvc.yaml - - name: Upgrade Headlamp with shared volume mount + - name: Patch Headlamp deployment with shared volume mount run: | - helm upgrade headlamp headlamp/headlamp \ - --namespace "$HEADLAMP_NAMESPACE" \ - --reuse-values \ - -f deployment/headlamp-e2e-values.yaml \ - --set config.sessionTTL=1 \ - --wait --timeout 120s + NS="$HEADLAMP_NAMESPACE" + DEPLOY="$HEADLAMP_DEPLOY" + + # Add the PVC volume if not already present + HAS_VOL=$(kubectl get deploy "$DEPLOY" -n "$NS" \ + -o jsonpath='{.spec.template.spec.volumes[?(@.name=="plugins")].name}') + if [ -z "$HAS_VOL" ]; then + kubectl patch deploy "$DEPLOY" -n "$NS" --type=json -p '[ + {"op":"add","path":"/spec/template/spec/volumes/-","value":{ + "name":"plugins", + "persistentVolumeClaim":{"claimName":"headlamp-plugins"} + }} + ]' + fi + + # Add the volume mount to the first container if not already present + HAS_MOUNT=$(kubectl get deploy "$DEPLOY" -n "$NS" \ + -o jsonpath='{.spec.template.spec.containers[0].volumeMounts[?(@.name=="plugins")].name}') + if [ -z "$HAS_MOUNT" ]; then + kubectl patch deploy "$DEPLOY" -n "$NS" --type=json -p '[ + {"op":"add","path":"/spec/template/spec/containers/0/volumeMounts/-","value":{ + "name":"plugins", + "mountPath":"/headlamp/plugins", + "readOnly":true + }} + ]' + fi + + # Set the plugins directory via env var + kubectl set env deploy/"$DEPLOY" -n "$NS" \ + HEADLAMP_CONFIG_PLUGIN_DIR=/headlamp/plugins + + # Wait for rollout + kubectl rollout status deploy/"$DEPLOY" -n "$NS" --timeout=120s - name: Deploy plugin via shared volume run: scripts/deploy-plugin-via-volume.sh diff --git a/deployment/e2e-ci-runner-rbac.yaml b/deployment/e2e-ci-runner-rbac.yaml index ee1fe3f..40e3bdf 100644 --- a/deployment/e2e-ci-runner-rbac.yaml +++ b/deployment/e2e-ci-runner-rbac.yaml @@ -2,37 +2,14 @@ # RBAC for the GitHub Actions CI runner to perform E2E test setup. # CI-only test fixture — NOT for production use. # -# The Headlamp Helm chart manages cluster-scoped resources (ClusterRole, -# ClusterRoleBinding). The CI runner SA needs cluster-level read/write on -# these resources for `helm upgrade` to succeed, plus namespace-scoped -# permissions in kube-system for PVCs, pods, Helm secrets, etc. +# Grants the ARC runner service account namespace-scoped permissions in +# kube-system to patch the Headlamp deployment (add shared volume mount), +# manage PVCs, run temporary pods, and restart deployments. +# +# No cluster-scoped permissions needed — the E2E workflow uses kubectl patch +# instead of helm upgrade, avoiding the need to read ClusterRole/ClusterRoleBinding. # # Apply with: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml - -# --- Cluster-scoped permissions (for Headlamp Helm chart resources) --- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: e2e-ci-runner -rules: - - apiGroups: ["rbac.authorization.k8s.io"] - resources: ["clusterroles", "clusterrolebindings"] - verbs: ["get", "list", "create", "update", "patch", "delete"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: e2e-ci-runner-binding -subjects: - - kind: ServiceAccount - name: local-ubuntu-latest-gha-rs-no-permission - namespace: arc-runners -roleRef: - kind: ClusterRole - name: e2e-ci-runner - apiGroup: rbac.authorization.k8s.io ---- -# --- Namespace-scoped permissions (kube-system) --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: @@ -50,7 +27,10 @@ rules: verbs: ["create", "get"] - apiGroups: ["apps"] resources: ["deployments"] - verbs: ["get", "list", "patch"] + verbs: ["get", "list", "patch", "watch"] + - apiGroups: ["apps"] + resources: ["deployments/scale"] + verbs: ["patch"] - apiGroups: [""] resources: ["secrets"] verbs: ["get", "list", "create", "update", "patch", "delete"] -- 2.52.0 From 58e9b02698452338bba0a42dd4c5a4ec0d81202b Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 17:09:26 +0000 Subject: [PATCH 13/26] fix(e2e): improve volume mount idempotency check Check for existing volume mount by mountPath and PVC claimName, not just by volume name. A prior helm upgrade may have created mounts with different names but the same path, causing kubectl patch to fail with "mountPath must be unique". Co-Authored-By: Paperclip --- .github/workflows/e2e.yaml | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index e335c90..e8b3034 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -43,22 +43,30 @@ jobs: NS="$HEADLAMP_NAMESPACE" DEPLOY="$HEADLAMP_DEPLOY" - # Add the PVC volume if not already present - HAS_VOL=$(kubectl get deploy "$DEPLOY" -n "$NS" \ - -o jsonpath='{.spec.template.spec.volumes[?(@.name=="plugins")].name}') + # Check if the plugins volume and mount already exist (by name or mountPath) + DEPLOY_JSON=$(kubectl get deploy "$DEPLOY" -n "$NS" -o json) + HAS_VOL=$(echo "$DEPLOY_JSON" | \ + python3 -c "import sys,json; d=json.load(sys.stdin); vols=d['spec']['template']['spec'].get('volumes',[]); print('yes' if any(v.get('persistentVolumeClaim',{}).get('claimName')=='headlamp-plugins' or v.get('name')=='plugins' for v in vols) else '')") + HAS_MOUNT=$(echo "$DEPLOY_JSON" | \ + python3 -c "import sys,json; d=json.load(sys.stdin); mounts=d['spec']['template']['spec']['containers'][0].get('volumeMounts',[]); print('yes' if any(m.get('mountPath')=='/headlamp/plugins' or m.get('name')=='plugins' for m in mounts) else '')") + + NEEDS_PATCH=false + if [ -z "$HAS_VOL" ]; then + echo "Adding plugins PVC volume..." kubectl patch deploy "$DEPLOY" -n "$NS" --type=json -p '[ {"op":"add","path":"/spec/template/spec/volumes/-","value":{ "name":"plugins", "persistentVolumeClaim":{"claimName":"headlamp-plugins"} }} ]' + NEEDS_PATCH=true + else + echo "Plugins volume already present, skipping." fi - # Add the volume mount to the first container if not already present - HAS_MOUNT=$(kubectl get deploy "$DEPLOY" -n "$NS" \ - -o jsonpath='{.spec.template.spec.containers[0].volumeMounts[?(@.name=="plugins")].name}') if [ -z "$HAS_MOUNT" ]; then + echo "Adding plugins volume mount..." kubectl patch deploy "$DEPLOY" -n "$NS" --type=json -p '[ {"op":"add","path":"/spec/template/spec/containers/0/volumeMounts/-","value":{ "name":"plugins", @@ -66,6 +74,9 @@ jobs: "readOnly":true }} ]' + NEEDS_PATCH=true + else + echo "Plugins volume mount already present, skipping." fi # Set the plugins directory via env var -- 2.52.0 From 77d87db614ad44e301bb23ae66ffaf894a5b6778 Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 17:12:24 +0000 Subject: [PATCH 14/26] fix(e2e): schedule deploy pod on same node as Headlamp The headlamp-plugins PVC is ReadWriteOnce, so the temporary deploy pod must run on the same node as the Headlamp pod to mount it. Look up the Headlamp pod's node and set nodeName in the pod spec. Co-Authored-By: Paperclip --- scripts/deploy-plugin-via-volume.sh | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/scripts/deploy-plugin-via-volume.sh b/scripts/deploy-plugin-via-volume.sh index 881cb62..a5903ce 100755 --- a/scripts/deploy-plugin-via-volume.sh +++ b/scripts/deploy-plugin-via-volume.sh @@ -37,6 +37,25 @@ TAR_FILE=$(mktemp /tmp/plugin-XXXXXX.tar.gz) tar -czf "$TAR_FILE" -C "$DIST_DIR" . -C "$REPO_ROOT" package.json echo " Tarball: $TAR_FILE ($(du -h "$TAR_FILE" | cut -f1))" +# Find the node where Headlamp is running — the PVC is ReadWriteOnce so +# the deploy pod must land on the same node to mount it. +HEADLAMP_NODE=$(kubectl get pods -n "$HEADLAMP_NAMESPACE" \ + -l "app.kubernetes.io/name=headlamp" \ + -o jsonpath='{.items[0].spec.nodeName}' 2>/dev/null || true) +if [ -z "$HEADLAMP_NODE" ]; then + # Fallback: try by deployment label + HEADLAMP_NODE=$(kubectl get pods -n "$HEADLAMP_NAMESPACE" \ + -l "app.kubernetes.io/instance=headlamp" \ + -o jsonpath='{.items[0].spec.nodeName}' 2>/dev/null || true) +fi +if [ -n "$HEADLAMP_NODE" ]; then + echo " Headlamp node: $HEADLAMP_NODE (scheduling deploy pod there)" + NODE_OVERRIDE="\"nodeName\": \"$HEADLAMP_NODE\"," +else + echo " WARNING: Could not determine Headlamp node, deploy pod may fail if PVC is ReadWriteOnce" + NODE_OVERRIDE="" +fi + # Clean up any previous deploy pod kubectl delete pod plugin-deploy -n "$HEADLAMP_NAMESPACE" --ignore-not-found --wait=false 2>/dev/null || true sleep 2 @@ -50,6 +69,7 @@ kubectl run plugin-deploy \ --namespace="$HEADLAMP_NAMESPACE" \ --overrides="{ \"spec\": { + ${NODE_OVERRIDE} \"containers\": [{ \"name\": \"plugin-deploy\", \"image\": \"busybox:1.36\", -- 2.52.0 From 2fb60c60f3f107b972debb96917b47a823a4e0c6 Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 17:15:27 +0000 Subject: [PATCH 15/26] fix(e2e): use Job with base64 tarball instead of kubectl run stdin The kubectl run --rm -i stdin pipe times out in the ARC runner environment. Replace with a Kubernetes Job that receives the plugin tarball as base64-encoded data in the container command. This avoids the unreliable attach/stdin mechanism entirely. Co-Authored-By: Paperclip --- scripts/deploy-plugin-via-volume.sh | 96 ++++++++++++++++++----------- 1 file changed, 59 insertions(+), 37 deletions(-) diff --git a/scripts/deploy-plugin-via-volume.sh b/scripts/deploy-plugin-via-volume.sh index a5903ce..d9fb5e6 100755 --- a/scripts/deploy-plugin-via-volume.sh +++ b/scripts/deploy-plugin-via-volume.sh @@ -43,53 +43,75 @@ HEADLAMP_NODE=$(kubectl get pods -n "$HEADLAMP_NAMESPACE" \ -l "app.kubernetes.io/name=headlamp" \ -o jsonpath='{.items[0].spec.nodeName}' 2>/dev/null || true) if [ -z "$HEADLAMP_NODE" ]; then - # Fallback: try by deployment label HEADLAMP_NODE=$(kubectl get pods -n "$HEADLAMP_NAMESPACE" \ -l "app.kubernetes.io/instance=headlamp" \ -o jsonpath='{.items[0].spec.nodeName}' 2>/dev/null || true) fi if [ -n "$HEADLAMP_NODE" ]; then - echo " Headlamp node: $HEADLAMP_NODE (scheduling deploy pod there)" - NODE_OVERRIDE="\"nodeName\": \"$HEADLAMP_NODE\"," + echo " Headlamp node: $HEADLAMP_NODE (scheduling deploy job there)" + NODE_SELECTOR="\"nodeName\": \"$HEADLAMP_NODE\"," else - echo " WARNING: Could not determine Headlamp node, deploy pod may fail if PVC is ReadWriteOnce" - NODE_OVERRIDE="" + echo " WARNING: Could not determine Headlamp node" + NODE_SELECTOR="" fi -# Clean up any previous deploy pod -kubectl delete pod plugin-deploy -n "$HEADLAMP_NAMESPACE" --ignore-not-found --wait=false 2>/dev/null || true +# Base64-encode the tarball so we can embed it in the pod command +# (avoids unreliable kubectl run --rm -i stdin piping) +TARBALL_B64=$(base64 -w0 "$TAR_FILE") +echo " Encoded size: $(echo -n "$TARBALL_B64" | wc -c) bytes" + +# Clean up any previous deploy job/pod +kubectl delete job plugin-deploy -n "$HEADLAMP_NAMESPACE" --ignore-not-found 2>/dev/null || true +kubectl delete pod plugin-deploy -n "$HEADLAMP_NAMESPACE" --ignore-not-found 2>/dev/null || true sleep 2 -# Run a temporary pod that mounts the PVC and receives the tarball via stdin -echo "Starting deploy pod..." -kubectl run plugin-deploy \ - --rm -i \ - --restart=Never \ - --image=busybox:1.36 \ - --namespace="$HEADLAMP_NAMESPACE" \ - --overrides="{ - \"spec\": { - ${NODE_OVERRIDE} - \"containers\": [{ - \"name\": \"plugin-deploy\", - \"image\": \"busybox:1.36\", - \"stdin\": true, - \"command\": [\"sh\", \"-c\", - \"rm -rf /plugins/${PLUGIN_DIR_NAME} && mkdir -p /plugins/${PLUGIN_DIR_NAME} && tar -xzf - -C /plugins/${PLUGIN_DIR_NAME} && echo Files deployed: && ls -la /plugins/${PLUGIN_DIR_NAME}/\" - ], - \"volumeMounts\": [{ - \"name\": \"plugins\", - \"mountPath\": \"/plugins\" - }] - }], - \"volumes\": [{ - \"name\": \"plugins\", - \"persistentVolumeClaim\": { - \"claimName\": \"headlamp-plugins\" - } - }] - } - }" < "$TAR_FILE" +# Create a Job that decodes the tarball and extracts to the PVC +echo "Starting deploy job..." +cat < /tmp/plugin.tar.gz + rm -rf /plugins/${PLUGIN_DIR_NAME} + mkdir -p /plugins/${PLUGIN_DIR_NAME} + tar -xzf /tmp/plugin.tar.gz -C /plugins/${PLUGIN_DIR_NAME} + echo "Files deployed:" + ls -la /plugins/${PLUGIN_DIR_NAME}/ + volumeMounts: + - name: plugins + mountPath: /plugins + volumes: + - name: plugins + persistentVolumeClaim: + claimName: headlamp-plugins +JOBEOF + +# Wait for the job to complete +echo "Waiting for deploy job to complete..." +kubectl wait --for=condition=complete job/plugin-deploy \ + -n "$HEADLAMP_NAMESPACE" --timeout=120s + +# Show logs +kubectl logs job/plugin-deploy -n "$HEADLAMP_NAMESPACE" 2>/dev/null || true + +# Clean up +kubectl delete job plugin-deploy -n "$HEADLAMP_NAMESPACE" --ignore-not-found 2>/dev/null || true rm -f "$TAR_FILE" -- 2.52.0 From d4909ab17c56523707240734acf6d0348008a803 Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 17:17:06 +0000 Subject: [PATCH 16/26] fix(e2e): use ConfigMap for tarball instead of inline base64 Embedding base64 data in the YAML spec broke parsing. Store the plugin tarball in a ConfigMap via --from-file and mount it in the deploy Job. This avoids both the stdin pipe issue and the YAML escaping issue. Co-Authored-By: Paperclip --- scripts/deploy-plugin-via-volume.sh | 36 +++++++++++++++++------------ 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/scripts/deploy-plugin-via-volume.sh b/scripts/deploy-plugin-via-volume.sh index d9fb5e6..e09ef44 100755 --- a/scripts/deploy-plugin-via-volume.sh +++ b/scripts/deploy-plugin-via-volume.sh @@ -55,19 +55,20 @@ else NODE_SELECTOR="" fi -# Base64-encode the tarball so we can embed it in the pod command -# (avoids unreliable kubectl run --rm -i stdin piping) -TARBALL_B64=$(base64 -w0 "$TAR_FILE") -echo " Encoded size: $(echo -n "$TARBALL_B64" | wc -c) bytes" - -# Clean up any previous deploy job/pod +# Clean up any previous deploy resources kubectl delete job plugin-deploy -n "$HEADLAMP_NAMESPACE" --ignore-not-found 2>/dev/null || true -kubectl delete pod plugin-deploy -n "$HEADLAMP_NAMESPACE" --ignore-not-found 2>/dev/null || true +kubectl delete configmap plugin-tarball -n "$HEADLAMP_NAMESPACE" --ignore-not-found 2>/dev/null || true sleep 2 -# Create a Job that decodes the tarball and extracts to the PVC +# Store the tarball in a ConfigMap (binary-safe via --from-file) +echo "Creating ConfigMap with plugin tarball..." +kubectl create configmap plugin-tarball \ + -n "$HEADLAMP_NAMESPACE" \ + --from-file=plugin.tar.gz="$TAR_FILE" + +# Create a Job that extracts the tarball from the ConfigMap to the PVC echo "Starting deploy job..." -cat < /tmp/plugin.tar.gz + echo "Extracting plugin to shared volume..." rm -rf /plugins/${PLUGIN_DIR_NAME} mkdir -p /plugins/${PLUGIN_DIR_NAME} - tar -xzf /tmp/plugin.tar.gz -C /plugins/${PLUGIN_DIR_NAME} + tar -xzf /tarball/plugin.tar.gz -C /plugins/${PLUGIN_DIR_NAME} echo "Files deployed:" ls -la /plugins/${PLUGIN_DIR_NAME}/ volumeMounts: - name: plugins mountPath: /plugins + - name: tarball + mountPath: /tarball + readOnly: true volumes: - name: plugins persistentVolumeClaim: claimName: headlamp-plugins + - name: tarball + configMap: + name: plugin-tarball JOBEOF # Wait for the job to complete @@ -112,6 +117,7 @@ kubectl logs job/plugin-deploy -n "$HEADLAMP_NAMESPACE" 2>/dev/null || true # Clean up kubectl delete job plugin-deploy -n "$HEADLAMP_NAMESPACE" --ignore-not-found 2>/dev/null || true +kubectl delete configmap plugin-tarball -n "$HEADLAMP_NAMESPACE" --ignore-not-found 2>/dev/null || true rm -f "$TAR_FILE" -- 2.52.0 From 8aaa82663c6f0ceae07e3254aa199ee9a1bbdf69 Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 17:18:47 +0000 Subject: [PATCH 17/26] fix(e2e): use temp file for Job YAML to avoid heredoc escaping Variable expansion inside heredocs breaks YAML parsing when values contain colons and quotes (like nodeName). Write the Job manifest to a temp file with literal YAML, then sed-substitute the dynamic values. Co-Authored-By: Paperclip --- scripts/deploy-plugin-via-volume.sh | 40 +++++++++++++++++------------ 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/scripts/deploy-plugin-via-volume.sh b/scripts/deploy-plugin-via-volume.sh index e09ef44..927b6e9 100755 --- a/scripts/deploy-plugin-via-volume.sh +++ b/scripts/deploy-plugin-via-volume.sh @@ -2,7 +2,7 @@ # deploy-plugin-via-volume.sh # # Copies the built plugin into the shared PVC so Headlamp picks it up. -# Uses a temporary Kubernetes pod to write to the PVC — the CI runner +# Uses a temporary Kubernetes Job to write to the PVC — the CI runner # does NOT need the PVC mounted locally. # # Usage: @@ -27,7 +27,7 @@ if [ ! -d "$DIST_DIR" ]; then exit 1 fi -echo "Deploying plugin to shared volume via temporary pod..." +echo "Deploying plugin to shared volume via temporary job..." echo " Source: $DIST_DIR" echo " PVC: headlamp-plugins" echo " Plugin: $PLUGIN_DIR_NAME" @@ -38,7 +38,7 @@ tar -czf "$TAR_FILE" -C "$DIST_DIR" . -C "$REPO_ROOT" package.json echo " Tarball: $TAR_FILE ($(du -h "$TAR_FILE" | cut -f1))" # Find the node where Headlamp is running — the PVC is ReadWriteOnce so -# the deploy pod must land on the same node to mount it. +# the deploy job must land on the same node to mount it. HEADLAMP_NODE=$(kubectl get pods -n "$HEADLAMP_NAMESPACE" \ -l "app.kubernetes.io/name=headlamp" \ -o jsonpath='{.items[0].spec.nodeName}' 2>/dev/null || true) @@ -49,10 +49,6 @@ if [ -z "$HEADLAMP_NODE" ]; then fi if [ -n "$HEADLAMP_NODE" ]; then echo " Headlamp node: $HEADLAMP_NODE (scheduling deploy job there)" - NODE_SELECTOR="\"nodeName\": \"$HEADLAMP_NODE\"," -else - echo " WARNING: Could not determine Headlamp node" - NODE_SELECTOR="" fi # Clean up any previous deploy resources @@ -66,9 +62,10 @@ kubectl create configmap plugin-tarball \ -n "$HEADLAMP_NAMESPACE" \ --from-file=plugin.tar.gz="$TAR_FILE" -# Create a Job that extracts the tarball from the ConfigMap to the PVC -echo "Starting deploy job..." -kubectl apply -n "$HEADLAMP_NAMESPACE" -f - < "$JOB_FILE" <<'YAMLDOC' apiVersion: batch/v1 kind: Job metadata: @@ -78,7 +75,6 @@ spec: ttlSecondsAfterFinished: 60 template: spec: - ${NODE_SELECTOR} restartPolicy: Never containers: - name: deploy @@ -87,11 +83,11 @@ spec: args: - | echo "Extracting plugin to shared volume..." - rm -rf /plugins/${PLUGIN_DIR_NAME} - mkdir -p /plugins/${PLUGIN_DIR_NAME} - tar -xzf /tarball/plugin.tar.gz -C /plugins/${PLUGIN_DIR_NAME} + rm -rf /plugins/PLUGIN_DIR_PLACEHOLDER + mkdir -p /plugins/PLUGIN_DIR_PLACEHOLDER + tar -xzf /tarball/plugin.tar.gz -C /plugins/PLUGIN_DIR_PLACEHOLDER echo "Files deployed:" - ls -la /plugins/${PLUGIN_DIR_NAME}/ + ls -la /plugins/PLUGIN_DIR_PLACEHOLDER/ volumeMounts: - name: plugins mountPath: /plugins @@ -105,7 +101,19 @@ spec: - name: tarball configMap: name: plugin-tarball -JOBEOF +YAMLDOC + +# Substitute plugin dir name +sed -i "s/PLUGIN_DIR_PLACEHOLDER/${PLUGIN_DIR_NAME}/g" "$JOB_FILE" + +# Add nodeName if we know which node Headlamp is on +if [ -n "$HEADLAMP_NODE" ]; then + sed -i "/restartPolicy: Never/i\\ nodeName: ${HEADLAMP_NODE}" "$JOB_FILE" +fi + +echo "Starting deploy job..." +kubectl apply -n "$HEADLAMP_NAMESPACE" -f "$JOB_FILE" +rm -f "$JOB_FILE" # Wait for the job to complete echo "Waiting for deploy job to complete..." -- 2.52.0 From f4fac60d401e6b391ac0fc48ae98061eae9e0748 Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 17:20:37 +0000 Subject: [PATCH 18/26] fix(e2e): use Pod instead of Job for plugin deploy The CI runner SA has permission to create Pods but not Jobs in kube-system. Switch from a Job to a plain Pod with restartPolicy:Never. Use ConfigMap mount for tarball data (no stdin piping needed). Co-Authored-By: Paperclip --- scripts/deploy-plugin-via-volume.sh | 84 ++++++++++++++--------------- 1 file changed, 40 insertions(+), 44 deletions(-) diff --git a/scripts/deploy-plugin-via-volume.sh b/scripts/deploy-plugin-via-volume.sh index 927b6e9..600c81d 100755 --- a/scripts/deploy-plugin-via-volume.sh +++ b/scripts/deploy-plugin-via-volume.sh @@ -52,7 +52,7 @@ if [ -n "$HEADLAMP_NODE" ]; then fi # Clean up any previous deploy resources -kubectl delete job plugin-deploy -n "$HEADLAMP_NAMESPACE" --ignore-not-found 2>/dev/null || true +kubectl delete pod plugin-deploy -n "$HEADLAMP_NAMESPACE" --ignore-not-found --wait=true 2>/dev/null || true kubectl delete configmap plugin-tarball -n "$HEADLAMP_NAMESPACE" --ignore-not-found 2>/dev/null || true sleep 2 @@ -62,69 +62,65 @@ kubectl create configmap plugin-tarball \ -n "$HEADLAMP_NAMESPACE" \ --from-file=plugin.tar.gz="$TAR_FILE" -# Build the Job manifest as a temp file to avoid heredoc YAML escaping issues -JOB_FILE=$(mktemp /tmp/plugin-deploy-job-XXXXXX.yaml) +# Build the Pod manifest as a temp file to avoid heredoc YAML escaping issues +POD_FILE=$(mktemp /tmp/plugin-deploy-pod-XXXXXX.yaml) -cat > "$JOB_FILE" <<'YAMLDOC' -apiVersion: batch/v1 -kind: Job +cat > "$POD_FILE" <<'YAMLDOC' +apiVersion: v1 +kind: Pod metadata: name: plugin-deploy spec: - backoffLimit: 0 - ttlSecondsAfterFinished: 60 - template: - spec: - restartPolicy: Never - containers: - - name: deploy - image: busybox:1.36 - command: ["sh", "-c"] - args: - - | - echo "Extracting plugin to shared volume..." - rm -rf /plugins/PLUGIN_DIR_PLACEHOLDER - mkdir -p /plugins/PLUGIN_DIR_PLACEHOLDER - tar -xzf /tarball/plugin.tar.gz -C /plugins/PLUGIN_DIR_PLACEHOLDER - echo "Files deployed:" - ls -la /plugins/PLUGIN_DIR_PLACEHOLDER/ - volumeMounts: - - name: plugins - mountPath: /plugins - - name: tarball - mountPath: /tarball - readOnly: true - volumes: + restartPolicy: Never + containers: + - name: deploy + image: busybox:1.36 + command: ["sh", "-c"] + args: + - | + echo "Extracting plugin to shared volume..." + rm -rf /plugins/PLUGIN_DIR_PLACEHOLDER + mkdir -p /plugins/PLUGIN_DIR_PLACEHOLDER + tar -xzf /tarball/plugin.tar.gz -C /plugins/PLUGIN_DIR_PLACEHOLDER + echo "Files deployed:" + ls -la /plugins/PLUGIN_DIR_PLACEHOLDER/ + volumeMounts: - name: plugins - persistentVolumeClaim: - claimName: headlamp-plugins + mountPath: /plugins - name: tarball - configMap: - name: plugin-tarball + mountPath: /tarball + readOnly: true + volumes: + - name: plugins + persistentVolumeClaim: + claimName: headlamp-plugins + - name: tarball + configMap: + name: plugin-tarball YAMLDOC # Substitute plugin dir name -sed -i "s/PLUGIN_DIR_PLACEHOLDER/${PLUGIN_DIR_NAME}/g" "$JOB_FILE" +sed -i "s/PLUGIN_DIR_PLACEHOLDER/${PLUGIN_DIR_NAME}/g" "$POD_FILE" # Add nodeName if we know which node Headlamp is on if [ -n "$HEADLAMP_NODE" ]; then - sed -i "/restartPolicy: Never/i\\ nodeName: ${HEADLAMP_NODE}" "$JOB_FILE" + sed -i "/restartPolicy: Never/i\\ nodeName: ${HEADLAMP_NODE}" "$POD_FILE" fi -echo "Starting deploy job..." -kubectl apply -n "$HEADLAMP_NAMESPACE" -f "$JOB_FILE" -rm -f "$JOB_FILE" +echo "Starting deploy pod..." +kubectl apply -n "$HEADLAMP_NAMESPACE" -f "$POD_FILE" +rm -f "$POD_FILE" -# Wait for the job to complete -echo "Waiting for deploy job to complete..." -kubectl wait --for=condition=complete job/plugin-deploy \ +# Wait for the pod to complete (Succeeded phase) +echo "Waiting for deploy pod to complete..." +kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/plugin-deploy \ -n "$HEADLAMP_NAMESPACE" --timeout=120s # Show logs -kubectl logs job/plugin-deploy -n "$HEADLAMP_NAMESPACE" 2>/dev/null || true +kubectl logs plugin-deploy -n "$HEADLAMP_NAMESPACE" 2>/dev/null || true # Clean up -kubectl delete job plugin-deploy -n "$HEADLAMP_NAMESPACE" --ignore-not-found 2>/dev/null || true +kubectl delete pod plugin-deploy -n "$HEADLAMP_NAMESPACE" --ignore-not-found 2>/dev/null || true kubectl delete configmap plugin-tarball -n "$HEADLAMP_NAMESPACE" --ignore-not-found 2>/dev/null || true rm -f "$TAR_FILE" -- 2.52.0 From 39af8ad7e9906f2a3e5398b8343e8264b3a56b99 Mon Sep 17 00:00:00 2001 From: Gandalf the Greybeard Date: Tue, 17 Mar 2026 17:29:37 +0000 Subject: [PATCH 19/26] fix: align registerPluginSettings name with deployed plugin directory The plugin is deployed to the 'polaris' directory but was registered with 'headlamp-polaris', causing Headlamp to not match the settings component with the loaded plugin. This fixes all 5 failing E2E settings tests. Co-Authored-By: Paperclip --- src/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 5a90bbe..e5d068f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -99,7 +99,7 @@ registerRoute({ }); // Register plugin settings -registerPluginSettings('headlamp-polaris', PolarisSettings, true); +registerPluginSettings('polaris', PolarisSettings, true); // Register details view section for supported controller types registerDetailsViewSection(({ resource }) => { -- 2.52.0 From b9d718b1fe303de64309e96c26ca63edeaf52103 Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 17:48:51 +0000 Subject: [PATCH 20/26] fix: use package name for registerPluginSettings, not directory name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headlamp identifies plugins by their package.json name (headlamp-polaris), not the deploy directory name (polaris). The previous commit incorrectly changed this to 'polaris', causing the settings component to never render in the plugin settings page — breaking all 5 E2E settings tests. Co-Authored-By: Paperclip --- src/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index e5d068f..5a90bbe 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -99,7 +99,7 @@ registerRoute({ }); // Register plugin settings -registerPluginSettings('polaris', PolarisSettings, true); +registerPluginSettings('headlamp-polaris', PolarisSettings, true); // Register details view section for supported controller types registerDetailsViewSection(({ resource }) => { -- 2.52.0 From a58f61b21352c3e1105cda75f70b71d5c78a1031 Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 18:04:56 +0000 Subject: [PATCH 21/26] fix: align registerPluginSettings name with deploy directory 'polaris' The shared volume deploy script places the plugin at /headlamp/plugins/polaris/, so Headlamp matches settings by directory name 'polaris', not the package.json name 'headlamp-polaris'. This reverts commit b9d718b which incorrectly changed the registration name back to 'headlamp-polaris'. Co-Authored-By: Paperclip --- src/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 5a90bbe..e5d068f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -99,7 +99,7 @@ registerRoute({ }); // Register plugin settings -registerPluginSettings('headlamp-polaris', PolarisSettings, true); +registerPluginSettings('polaris', PolarisSettings, true); // Register details view section for supported controller types registerDetailsViewSection(({ resource }) => { -- 2.52.0 From bf5614f268c444f842ce95056f661e7b82ff3780 Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Tue, 17 Mar 2026 18:13:03 +0000 Subject: [PATCH 22/26] fix: align plugin deploy dir with package.json name, clean stale dirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PVC had a stale headlamp-polaris directory from a previous install. Headlamp loads plugins by scanning the plugins dir and reading package.json from each subdirectory — it was loading the old build from headlamp-polaris/ while the deploy script was writing to polaris/. The settings registration name needs to match the plugin name Headlamp identifies. Changes: - Deploy script now uses headlamp-polaris as the directory name (matching package.json name field) - Deploy pod cleans up both polaris/ and headlamp-polaris/ before deploying to ensure no stale copies remain - registerPluginSettings uses headlamp-polaris to match Headlamp's plugin identifier Co-Authored-By: Paperclip --- scripts/deploy-plugin-via-volume.sh | 10 ++++++---- src/index.tsx | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/deploy-plugin-via-volume.sh b/scripts/deploy-plugin-via-volume.sh index 600c81d..8cba0fb 100755 --- a/scripts/deploy-plugin-via-volume.sh +++ b/scripts/deploy-plugin-via-volume.sh @@ -17,9 +17,10 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" HEADLAMP_NAMESPACE="${HEADLAMP_NAMESPACE:-kube-system}" HEADLAMP_DEPLOY="${HEADLAMP_DEPLOY:-headlamp}" -# The deployed directory name must match the plugin's registered name. -# PR #56 aligns registerPluginSettings to "polaris"; the directory must match. -PLUGIN_DIR_NAME="polaris" +# The deployed directory name must match the package.json name and +# the registerPluginSettings name. Headlamp identifies plugins by +# reading package.json from each subdirectory of the plugins dir. +PLUGIN_DIR_NAME="headlamp-polaris" DIST_DIR="$REPO_ROOT/dist" if [ ! -d "$DIST_DIR" ]; then @@ -78,8 +79,9 @@ spec: command: ["sh", "-c"] args: - | + echo "Cleaning up stale plugin directories..." + rm -rf /plugins/polaris /plugins/headlamp-polaris echo "Extracting plugin to shared volume..." - rm -rf /plugins/PLUGIN_DIR_PLACEHOLDER mkdir -p /plugins/PLUGIN_DIR_PLACEHOLDER tar -xzf /tarball/plugin.tar.gz -C /plugins/PLUGIN_DIR_PLACEHOLDER echo "Files deployed:" diff --git a/src/index.tsx b/src/index.tsx index e5d068f..5a90bbe 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -99,7 +99,7 @@ registerRoute({ }); // Register plugin settings -registerPluginSettings('polaris', PolarisSettings, true); +registerPluginSettings('headlamp-polaris', PolarisSettings, true); // Register details view section for supported controller types registerDetailsViewSection(({ resource }) => { -- 2.52.0 From 2ca66e7d7b872d59462f8697b17ca79f64429d13 Mon Sep 17 00:00:00 2001 From: Gandalf the Greybeard Date: Tue, 17 Mar 2026 21:35:18 +0000 Subject: [PATCH 23/26] fix: align registerPluginSettings and E2E test with package.json name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headlamp identifies plugins by reading package.json from the plugin directory. Since package.json name is 'headlamp-polaris', both the registerPluginSettings call and the E2E settings test must use 'headlamp-polaris', not 'polaris'. - registerPluginSettings('polaris') → registerPluginSettings('headlamp-polaris') - E2E test locator: text=polaris → text=headlamp-polaris Co-Authored-By: Paperclip --- e2e/settings.spec.ts | 2 +- src/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 430683b..a64a714 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -5,7 +5,7 @@ async function goToPolarisSettings(page: Page) { await page.goto('/c/main/settings/plugins'); // Find and click the Polaris plugin entry to open its settings - const pluginEntry = page.locator('text=polaris').first(); + const pluginEntry = page.locator('text=headlamp-polaris').first(); await expect(pluginEntry).toBeVisible({ timeout: 15_000 }); await pluginEntry.click(); diff --git a/src/index.tsx b/src/index.tsx index e5d068f..5a90bbe 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -99,7 +99,7 @@ registerRoute({ }); // Register plugin settings -registerPluginSettings('polaris', PolarisSettings, true); +registerPluginSettings('headlamp-polaris', PolarisSettings, true); // Register details view section for supported controller types registerDetailsViewSection(({ resource }) => { -- 2.52.0 From 4844e9667d82e9a7e162e6f4e534c8e1db9b92f9 Mon Sep 17 00:00:00 2001 From: Gandalf the Greybeard Date: Tue, 17 Mar 2026 22:20:18 +0000 Subject: [PATCH 24/26] fix(e2e): load main page before settings to ensure plugin list is populated Headlamp's PluginSettings component initializes its state from localStorage on mount and never syncs when props.plugins updates later. If the settings page loads before fetchAndExecutePlugins completes, the plugin list stays empty and the test can't find "headlamp-polaris". Fix: navigate to the main page first, wait for the Polaris sidebar entry to confirm the plugin is loaded (which populates localStorage), then navigate to the settings page. Co-Authored-By: Paperclip --- e2e/settings.spec.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index a64a714..7a51e87 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -2,6 +2,18 @@ import { test, expect, Page } from '@playwright/test'; /** Navigate to the Polaris plugin settings page and wait for settings to render. */ async function goToPolarisSettings(page: Page) { + // Load the main page first so Headlamp fetches the plugin list and stores + // it in localStorage (headlampPluginSettings). The PluginSettings component + // initializes its state from localStorage on mount, so the data must already + // be there before we navigate to the settings page. + await page.goto('/'); + const sidebar = page.getByRole('navigation', { name: 'Navigation' }); + await expect(sidebar).toBeVisible({ timeout: 15_000 }); + await expect(sidebar.getByRole('button', { name: 'Polaris' })).toBeVisible({ + timeout: 15_000, + }); + + // Now navigate to plugin settings — localStorage has the plugin list await page.goto('/c/main/settings/plugins'); // Find and click the Polaris plugin entry to open its settings -- 2.52.0 From 1161fbcc4da29fbc9d0564d981f26a7355a38e8c Mon Sep 17 00:00:00 2001 From: Gandalf the Greybeard Date: Tue, 17 Mar 2026 22:42:41 +0000 Subject: [PATCH 25/26] fix(e2e): use client-side routing for settings navigation The PluginSettings component reads the plugin registry once on mount and never re-renders when new plugins register. Using page.goto() for the settings URL re-initializes the SPA, causing PluginSettings to mount before async plugin scripts finish calling registerPluginSettings(). Replace page.goto() with pushState + popstate to do client-side routing. This preserves the already-loaded plugin registrations from the main page, so PluginSettings sees the plugin immediately on mount. Co-Authored-By: Paperclip --- e2e/settings.spec.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 7a51e87..6840aad 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -2,10 +2,12 @@ import { test, expect, Page } from '@playwright/test'; /** Navigate to the Polaris plugin settings page and wait for settings to render. */ async function goToPolarisSettings(page: Page) { - // Load the main page first so Headlamp fetches the plugin list and stores - // it in localStorage (headlampPluginSettings). The PluginSettings component - // initializes its state from localStorage on mount, so the data must already - // be there before we navigate to the settings page. + // Load the main page first so all plugin scripts execute and call + // registerPluginSettings(). Headlamp loads plugins asynchronously — the + // PluginSettings component reads the plugin registry once on mount and + // never re-renders when new plugins register. A full page.goto() to the + // settings URL would re-initialize the SPA, causing PluginSettings to + // mount before plugin scripts finish executing. await page.goto('/'); const sidebar = page.getByRole('navigation', { name: 'Navigation' }); await expect(sidebar).toBeVisible({ timeout: 15_000 }); @@ -13,8 +15,14 @@ async function goToPolarisSettings(page: Page) { timeout: 15_000, }); - // Now navigate to plugin settings — localStorage has the plugin list - await page.goto('/c/main/settings/plugins'); + // Navigate to plugin settings via client-side routing (pushState + popstate) + // instead of page.goto(). This preserves the already-loaded plugin scripts + // and their registerPluginSettings() registrations. React Router's history + // listener picks up the popstate event and re-renders with the new route. + await page.evaluate(() => { + window.history.pushState({}, '', '/c/main/settings/plugins'); + window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); + }); // Find and click the Polaris plugin entry to open its settings const pluginEntry = page.locator('text=headlamp-polaris').first(); -- 2.52.0 From 7000a1f77f5e65aba8ce14aa3dfc1038f9f21cfb Mon Sep 17 00:00:00 2001 From: Gandalf the Greybeard Date: Tue, 17 Mar 2026 23:02:33 +0000 Subject: [PATCH 26/26] fix(e2e): use correct HOME-context URL for plugin settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings page is at /settings/plugins (HOME sidebar context), not /c/main/settings/plugins (in-cluster context). The in-cluster URL doesn't match any route, so PluginSettings never mounted and the plugin entry was never visible. With the correct URL, no preloading or client-side routing hacks are needed — PluginSettings uses useTypedSelector on the Redux plugin store, so it re-renders automatically when registerPluginSettings() fires. Co-Authored-By: Paperclip --- e2e/settings.spec.ts | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 6840aad..333a0b4 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -2,31 +2,17 @@ import { test, expect, Page } from '@playwright/test'; /** Navigate to the Polaris plugin settings page and wait for settings to render. */ async function goToPolarisSettings(page: Page) { - // Load the main page first so all plugin scripts execute and call - // registerPluginSettings(). Headlamp loads plugins asynchronously — the - // PluginSettings component reads the plugin registry once on mount and - // never re-renders when new plugins register. A full page.goto() to the - // settings URL would re-initialize the SPA, causing PluginSettings to - // mount before plugin scripts finish executing. - await page.goto('/'); - const sidebar = page.getByRole('navigation', { name: 'Navigation' }); - await expect(sidebar).toBeVisible({ timeout: 15_000 }); - await expect(sidebar.getByRole('button', { name: 'Polaris' })).toBeVisible({ - timeout: 15_000, - }); + // Headlamp's plugin settings page is a HOME-context route at /settings/plugins, + // not an in-cluster route (/c/main/settings/plugins would 404). Headlamp loads + // plugin scripts asynchronously on SPA init. When registerPluginSettings() fires, + // it dispatches a Redux action — PluginSettings uses useTypedSelector so it + // re-renders automatically once the plugin registers. No preloading needed. + await page.goto('/settings/plugins'); - // Navigate to plugin settings via client-side routing (pushState + popstate) - // instead of page.goto(). This preserves the already-loaded plugin scripts - // and their registerPluginSettings() registrations. React Router's history - // listener picks up the popstate event and re-renders with the new route. - await page.evaluate(() => { - window.history.pushState({}, '', '/c/main/settings/plugins'); - window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); - }); - - // Find and click the Polaris plugin entry to open its settings + // Wait for the plugin to appear in the settings list. The timeout covers + // async plugin script loading + registration. const pluginEntry = page.locator('text=headlamp-polaris').first(); - await expect(pluginEntry).toBeVisible({ timeout: 15_000 }); + await expect(pluginEntry).toBeVisible({ timeout: 30_000 }); await pluginEntry.click(); // Wait for the PolarisSettings component to render -- 2.52.0