Compare commits

..

10 Commits

Author SHA1 Message Date
Chris Farhood d202ca42d6 fix(e2e): reference @main workflow after .github merge
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-05 17:43:44 +00:00
Chris Farhood 019366ff01 fix(e2e): use LoadBalancer IP for HEADLAMP_URL
Previous approaches (port-forward to Service/Pod) failed with 'connection
refused' — the runner cannot tunnel to pod IPs through the API server.

Switch to LoadBalancer service type:
- After rollout, poll kubectl get svc for status.loadBalancer.ingress[0].ip
- Once assigned, poll http://<lb-ip>:80 until reachable
- Write HEADLAMP_URL=http://<lb-ip>:80 to .env.e2e

The runner pod (in the cluster) can reach LoadBalancer IPs assigned
by the cloud controller or metallb.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-05 17:15:33 +00:00
Chris Farhood 9cc1ca7b91 fix(e2e): use NodePort instead of cluster-internal DNS for HEADLAMP_URL
Previous attempt used kubectl port-forward to a Service, which failed
with 'connection refused' — the API server could not reach pod IPs.

Switch to NodePort (30080) service type and use the node's InternalIP
for HEADLAMP_URL, reachable from the GitHub Actions runner pod.

- Change Service type from ClusterIP to NodePort with nodePort: 30080
- After rollout, get node InternalIP via kubectl get nodes
- Poll http://<node-ip>:30080 until reachable
- Write HEADLAMP_URL=http://<node-ip>:30080 to .env.e2e
- Remove port-forward leftover cleanup from teardown script

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-05 17:10:54 +00:00
Chris Farhood f1dd09c155 fix(e2e): use localhost via kubectl port-forward for HEADLAMP_URL
The browser runs outside the cluster and cannot resolve
headlamp-e2e.${E2E_NAMESPACE}.svc.cluster.local DNS names.

- Start kubectl port-forward in background after service rollout
- Poll until localhost:4466 is reachable before writing .env.e2e
- Write HEADLAMP_URL=http://localhost:4466 so Playwright browser can connect
- teardown: kill port-forward processes with pkill

Fixes PRI-752.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-05 17:03:13 +00:00
Chris Farhood 8b90535ec7 Merge branch 'gandalf/e2e-fix-kube-vip' into gandalf/e2e-fix-kube-vip-local 2026-05-05 14:07:33 +00:00
Chris Farhood 00df4a829f fix(e2e): add e2e script to package.json
Missing script caused ERR_PNPM_NO_SCRIPT in CI E2E step.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-05 14:06:49 +00:00
Chris Farhood 869d1c7225 fix(e2e): use .first() to handle strict mode violations with multiple headings (PRI-700)
The kube-vip page has both 'kube-vip — Overview' (h1) and 'kube-vip Not Detected' (h2) headings.
getByRole('heading', { name: /kube.vip/i }) resolves to both in strict mode. Using .first()
to match the first one (the overview heading) instead.
2026-05-05 13:55:54 +00:00
Chris Farhood 87798ecbe1 fix(e2e): add e2e npm script for reusable workflow (PRI-700)
The plugin-e2e.yaml reusable workflow runs 'npm run e2e' to execute
Playwright tests. This script was missing from the kube-vip plugin.
2026-05-05 13:49:56 +00:00
Chris Farhood 097ac48ecf feat(e2e): add @playwright/test to devDependencies
Required by PRI-700 / PRI-699: E2E test infra needs @playwright/test
as a direct devDependency.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-05 13:33:10 +00:00
Chris Farhood ced7d57895 feat(e2e): consolidate E2E test infrastructure + add waitForSidebar (PRI-700)
- Adds e2e/auth.setup.ts, e2e/kube-vip.spec.ts with waitForSidebar helper
- Adds playwright.config.ts, scripts/deploy-e2e-headlamp.sh, scripts/teardown-e2e-headlamp.sh
- Adds .github/workflows/e2e.yaml
- Fixes plugin settings test to wait for list before searching
2026-05-05 13:07:55 +00:00
9 changed files with 335 additions and 353 deletions
-1
View File
@@ -14,7 +14,6 @@ on:
jobs:
dual-approval:
if: github.event.pull_request != null
uses: privilegedescalation/.github/.github/workflows/dual-approval-check.yaml@main
secrets: inherit
with:
-1
View File
@@ -21,4 +21,3 @@ jobs:
node-version: '22'
headlamp-version: v0.40.1
e2e-namespace: headlamp-dev
plugin-name: headlamp-kube-vip
-6
View File
@@ -5,9 +5,3 @@ dist/
.env
.env.local
.eslintcache
# E2E
e2e/.auth/
.env.e2e
playwright-report/
test-results/
-25
View File
@@ -22,28 +22,3 @@ All data is fetched through Headlamp's built-in API proxy, which respects the us
## Reporting a Vulnerability
Please report security vulnerabilities by opening a private issue or emailing the maintainers directly.
## Known Low-Severity Vulnerabilities
### GHSA-848j-6mx2-7j84 (elliptic)
**Severity:** High (but not exploitable in this plugin's context)
**Affected component:** `elliptic` (transitive, via `vite-plugin-node-polyfills``node-stdlib-browser``crypto-browserify``browserify-sign`)
**Description:** The elliptic library used in this plugin's development dependencies contains a prototype pollution vulnerability. This plugin is a **read-only** Headlamp plugin that never executes any cryptographic operations at runtime. The vulnerable code path requires:
- Use of `elliptic` curve operations on untrusted input, AND
- Ability for an attacker to influence the `elliptic` curve key generation input
Neither condition is met in this plugin's runtime context.
**Remediation:** No patched version of `elliptic` exists on npm. The current override in `package.json` (`"elliptic": ">=6.6.1"`) is a placeholder — no resolvable version satisfies this constraint.
**Risk acceptance rationale:**
1. Plugin has no write operations against the cluster
2. All data flows through Headlamp's API proxy with standard RBAC enforcement
3. The vulnerable dependency is only in the development/build toolchain, not runtime
4. No untrusted input can reach `elliptic` curve operations through this plugin
**Review date:** 2026-05-05
**Reviewed by:** Hugh Hackman (VP Engineering Operations)
+15 -6
View File
@@ -1,30 +1,37 @@
import { test, expect } from '@playwright/test';
async function waitForSidebar(page: import('@playwright/test').Page) {
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
await expect(sidebar).toBeVisible({ timeout: 15_000 });
await page.waitForLoadState('networkidle');
return sidebar;
}
test.describe('kube-vip plugin smoke tests', () => {
test('sidebar contains kube-vip entry', async ({ page }) => {
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
await expect(sidebar).toBeVisible({ timeout: 15_000 });
const sidebar = await waitForSidebar(page);
await expect(sidebar.getByRole('button', { name: /kube.vip/i })).toBeVisible();
});
test('kube-vip sidebar entry navigates to kube-vip view', async ({ page }) => {
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
await expect(sidebar).toBeVisible({ timeout: 15_000 });
const sidebar = await waitForSidebar(page);
const entry = sidebar.getByRole('button', { name: /kube.vip/i });
await expect(entry).toBeVisible();
await entry.click();
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/kube-vip/);
await expect(page.getByRole('heading', { name: /kube.vip/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /kube.vip/i }).first()).toBeVisible();
});
test('kube-vip page renders content', async ({ page }) => {
await page.goto('/c/main/kube-vip');
await waitForSidebar(page);
await expect(page.getByRole('heading', { name: /kube.vip/i })).toBeVisible({
await expect(page.getByRole('heading', { name: /kube.vip/i }).first()).toBeVisible({
timeout: 15_000,
});
@@ -35,6 +42,8 @@ test.describe('kube-vip plugin smoke tests', () => {
test('plugin settings page shows kube-vip plugin entry', async ({ page }) => {
await page.goto('/settings/plugins');
await page.waitForLoadState('networkidle');
await page.waitForSelector('table, [class*="PluginList"], [class*="plugin"]', { timeout: 10_000 }).catch(() => {});
const pluginEntry = page.locator('text=/kube.vip/i').first();
await expect(pluginEntry).toBeVisible({ timeout: 30_000 });
+4 -6
View File
@@ -24,8 +24,7 @@
"format:check": "prettier --check src/",
"test": "vitest run",
"test:watch": "vitest",
"e2e": "playwright test",
"e2e:headed": "playwright test --headed"
"e2e": "playwright test"
},
"peerDependencies": {
"react": "^18.0.0",
@@ -35,13 +34,13 @@
"tar": "^7.5.11",
"undici": "^7.24.3",
"lodash": ">=4.18.0",
"vite": ">=6.4.2",
"elliptic": ">=6.6.1"
"vite": ">=6.4.2"
},
"devDependencies": {
"@headlamp-k8s/eslint-config": "^0.6.0",
"@kinvolk/headlamp-plugin": "^0.13.0",
"@mui/material": "^5.15.14",
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
@@ -55,7 +54,6 @@
"react-dom": "^18.3.1",
"react-router-dom": "^5.3.0",
"typescript": "~5.6.2",
"vitest": "^3.2.4",
"@playwright/test": "^1.58.2"
"vitest": "^3.2.4"
}
}
+269 -269
View File
File diff suppressed because it is too large Load Diff
+46 -38
View File
@@ -1,20 +1,4 @@
#!/usr/bin/env bash
# deploy-e2e-headlamp.sh
#
# Deploys a stock Headlamp instance with the kube-vip plugin loaded via
# a ConfigMap volume mount.
#
# E2E resources are deployed to the `headlamp-dev` namespace. Nothing
# persists beyond the test run — teardown cleans up all created resources.
#
# Prerequisites:
# - Plugin built (dist/ exists with plugin-main.js + package.json)
# - kubectl configured with cluster access
#
# Environment:
# E2E_NAMESPACE — namespace for E2E Headlamp (default: headlamp-dev)
# E2E_RELEASE — release/resource name prefix (default: headlamp-e2e)
# HEADLAMP_VERSION — Headlamp image tag (default: latest)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@@ -43,13 +27,9 @@ echo " Release: $E2E_RELEASE"
echo ""
echo "Creating ConfigMap with plugin files..."
kubectl delete configmap headlamp-kube-vip-plugin \
-n "$E2E_NAMESPACE" --ignore-not-found
kubectl delete configmap headlamp-kube-vip-plugin -n "$E2E_NAMESPACE" --ignore-not-found
kubectl create configmap headlamp-kube-vip-plugin \
-n "$E2E_NAMESPACE" \
--from-file="$DIST_DIR" \
--from-file=package.json="$REPO_ROOT/package.json"
kubectl create configmap headlamp-kube-vip-plugin -n "$E2E_NAMESPACE" --from-file="$DIST_DIR" --from-file=package.json="$REPO_ROOT/package.json"
echo ""
echo "Removing any existing E2E deployment (clean-start)..."
@@ -60,7 +40,7 @@ kubectl delete serviceaccount "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-
echo ""
echo "Deploying Headlamp E2E instance..."
if ! kubectl apply -f - <<EOF
kubectl apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
@@ -121,11 +101,11 @@ spec:
initialDelaySeconds: 10
periodSeconds: 10
volumeMounts:
- name: headlamp-kube-vip-plugin
- name: kube-vip-plugin
mountPath: /headlamp/plugins/headlamp-kube-vip
readOnly: true
volumes:
- name: headlamp-kube-vip-plugin
- name: kube-vip-plugin
configMap:
name: headlamp-kube-vip-plugin
---
@@ -138,7 +118,7 @@ metadata:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
spec:
type: ClusterIP
type: LoadBalancer
selector:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
@@ -148,16 +128,9 @@ spec:
targetPort: http
protocol: TCP
EOF
then
echo "ERROR: kubectl apply failed. Dumping cluster state..." >&2
kubectl get all -n "$E2E_NAMESPACE" 2>&1 || true
kubectl get events -n "$E2E_NAMESPACE" --sort-by='.lastTimestamp' 2>&1 | tail -30 || true
exit 1
fi
echo "Waiting for rollout..."
kubectl rollout status "deployment/${E2E_RELEASE}" \
-n "$E2E_NAMESPACE" --timeout=120s
kubectl rollout status "deployment/${E2E_RELEASE}" -n "$E2E_NAMESPACE" --timeout=120s
SVC_URL="http://${E2E_RELEASE}.${E2E_NAMESPACE}.svc.cluster.local"
@@ -177,16 +150,51 @@ done
echo ""
echo "E2E Headlamp is ready at: ${SVC_URL}"
echo ""
echo "Getting LoadBalancer IP for Headlamp service..."
LB_IP=""
ATTEMPTS=0
MAX_ATTEMPTS=24
while [ -z "${LB_IP}" ] || [ "${LB_IP}" = "<pending>" ]; do
ATTEMPTS=$((ATTEMPTS + 1))
if [ "$ATTEMPTS" -ge "$MAX_ATTEMPTS" ]; then
echo "ERROR: LoadBalancer IP not assigned after $((MAX_ATTEMPTS * 5))s" >&2
exit 1
fi
LB_IP=$(kubectl get svc "${E2E_RELEASE}" -n "$E2E_NAMESPACE" -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || echo "")
if [ -z "${LB_IP}" ] || [ "${LB_IP}" = "<pending>" ]; then
LB_IP=""
echo " [${ATTEMPTS}/${MAX_ATTEMPTS}] LoadBalancer IP not yet assigned, retrying in 5s..."
sleep 5
fi
done
echo " LoadBalancer IP: ${LB_IP}"
echo ""
echo "Waiting for Headlamp at http://${LB_IP}:80 to be reachable..."
ATTEMPTS=0
MAX_ATTEMPTS=24
until curl -sf --max-time 5 "http://${LB_IP}:80" -o /dev/null 2>/dev/null; do
ATTEMPTS=$((ATTEMPTS + 1))
if [ "$ATTEMPTS" -ge "$MAX_ATTEMPTS" ]; then
echo "ERROR: http://${LB_IP}:80 not reachable after $((MAX_ATTEMPTS * 5))s" >&2
exit 1
fi
echo " [${ATTEMPTS}/${MAX_ATTEMPTS}] LoadBalancer not yet reachable, retrying in 5s..."
sleep 5
done
echo ""
echo "Headlamp is ready at http://${LB_IP}:80"
echo ""
echo "Creating service account token for E2E auth..."
kubectl create serviceaccount headlamp-e2e-test \
-n "$E2E_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
kubectl create serviceaccount headlamp-e2e-test -n "$E2E_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
TOKEN=$(kubectl create token headlamp-e2e-test -n "$E2E_NAMESPACE" --duration=1h 2>/dev/null || echo "")
if [ -n "$TOKEN" ]; then
echo "HEADLAMP_URL=${SVC_URL}" > "$REPO_ROOT/.env.e2e"
echo "HEADLAMP_URL=http://${LB_IP}:80" > "$REPO_ROOT/.env.e2e"
echo "HEADLAMP_TOKEN=${TOKEN}" >> "$REPO_ROOT/.env.e2e"
echo "Wrote .env.e2e with HEADLAMP_URL and HEADLAMP_TOKEN"
echo "Wrote .env.e2e with HEADLAMP_URL=http://${LB_IP}:80 and HEADLAMP_TOKEN"
else
echo " WARNING: Could not generate token."
fi
+1 -1
View File
@@ -22,7 +22,7 @@ echo "Cleaning up test service account..."
kubectl delete serviceaccount headlamp-e2e-test -n "$E2E_NAMESPACE" --ignore-not-found
if [ -f "$REPO_ROOT/.env.e2e" ]; then
rm "$REPO_ROOT/.env.e2e"
rm -f "$REPO_ROOT/.env.e2e"
echo "Removed .env.e2e"
fi