diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 9685c69..e8b3034 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,104 @@ 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: Ensure PVC exists + run: kubectl apply -f deployment/headlamp-plugins-pvc.yaml + + - name: Patch Headlamp deployment with shared volume mount + run: | + NS="$HEADLAMP_NAMESPACE" + DEPLOY="$HEADLAMP_DEPLOY" + + # 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 + + 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", + "mountPath":"/headlamp/plugins", + "readOnly":true + }} + ]' + NEEDS_PATCH=true + else + echo "Plugins volume mount already present, skipping." + 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 + + - 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 diff --git a/deployment/e2e-ci-runner-rbac.yaml b/deployment/e2e-ci-runner-rbac.yaml new file mode 100644 index 0000000..40e3bdf --- /dev/null +++ b/deployment/e2e-ci-runner-rbac.yaml @@ -0,0 +1,59 @@ +--- +# 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 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 +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", "watch"] + - apiGroups: ["apps"] + resources: ["deployments/scale"] + verbs: ["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 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/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 diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 430683b..333a0b4 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -2,11 +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) { - await page.goto('/c/main/settings/plugins'); + // 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'); - // Find and click the Polaris plugin entry to open its settings - const pluginEntry = page.locator('text=polaris').first(); - await expect(pluginEntry).toBeVisible({ timeout: 15_000 }); + // 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: 30_000 }); await pluginEntry.click(); // Wait for the PolarisSettings component to render diff --git a/scripts/deploy-plugin-via-volume.sh b/scripts/deploy-plugin-via-volume.sh new file mode 100755 index 0000000..8cba0fb --- /dev/null +++ b/scripts/deploy-plugin-via-volume.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# deploy-plugin-via-volume.sh +# +# Copies the built plugin into the shared PVC so Headlamp picks it up. +# Uses a temporary Kubernetes Job to write to the PVC — the CI runner +# does NOT need the PVC mounted locally. +# +# Usage: +# scripts/deploy-plugin-via-volume.sh +# +# Environment: +# 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)" +HEADLAMP_NAMESPACE="${HEADLAMP_NAMESPACE:-kube-system}" +HEADLAMP_DEPLOY="${HEADLAMP_DEPLOY:-headlamp}" + +# 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 + echo "ERROR: dist/ not found. Run 'npm run build' first." >&2 + exit 1 +fi + +echo "Deploying plugin to shared volume via temporary job..." +echo " Source: $DIST_DIR" +echo " PVC: headlamp-plugins" +echo " Plugin: $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))" + +# Find the node where Headlamp is running — the PVC is ReadWriteOnce so +# 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) +if [ -z "$HEADLAMP_NODE" ]; then + 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 job there)" +fi + +# Clean up any previous deploy resources +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 + +# 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" + +# Build the Pod manifest as a temp file to avoid heredoc YAML escaping issues +POD_FILE=$(mktemp /tmp/plugin-deploy-pod-XXXXXX.yaml) + +cat > "$POD_FILE" <<'YAMLDOC' +apiVersion: v1 +kind: Pod +metadata: + name: plugin-deploy +spec: + restartPolicy: Never + containers: + - name: deploy + image: busybox:1.36 + command: ["sh", "-c"] + args: + - | + echo "Cleaning up stale plugin directories..." + rm -rf /plugins/polaris /plugins/headlamp-polaris + echo "Extracting plugin to shared volume..." + 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: + - 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" "$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}" "$POD_FILE" +fi + +echo "Starting deploy pod..." +kubectl apply -n "$HEADLAMP_NAMESPACE" -f "$POD_FILE" +rm -f "$POD_FILE" + +# 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 plugin-deploy -n "$HEADLAMP_NAMESPACE" 2>/dev/null || true + +# Clean up +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" + +# 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." 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 }) => {