From ced7d578959dd450be6e4e7fb0a4cad58d32acbe Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 5 May 2026 13:07:55 +0000 Subject: [PATCH 1/9] 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 --- .github/workflows/e2e.yaml | 23 +++++ e2e/auth.setup.ts | 69 +++++++++++++ e2e/kube-vip.spec.ts | 51 ++++++++++ playwright.config.ts | 27 +++++ scripts/deploy-e2e-headlamp.sh | 167 +++++++++++++++++++++++++++++++ scripts/teardown-e2e-headlamp.sh | 30 ++++++ 6 files changed, 367 insertions(+) create mode 100644 .github/workflows/e2e.yaml create mode 100644 e2e/auth.setup.ts create mode 100644 e2e/kube-vip.spec.ts create mode 100644 playwright.config.ts create mode 100755 scripts/deploy-e2e-headlamp.sh create mode 100755 scripts/teardown-e2e-headlamp.sh diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml new file mode 100644 index 0000000..4ee85a4 --- /dev/null +++ b/.github/workflows/e2e.yaml @@ -0,0 +1,23 @@ +name: E2E Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: e2e-${{ github.repository }} + cancel-in-progress: false + +jobs: + e2e: + uses: privilegedescalation/.github/.github/workflows/plugin-e2e.yaml@hugh/add-pnpm-support-plugin-e2e + with: + node-version: '22' + headlamp-version: v0.40.1 + e2e-namespace: headlamp-dev diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts new file mode 100644 index 0000000..fcd9f7f --- /dev/null +++ b/e2e/auth.setup.ts @@ -0,0 +1,69 @@ +import { test as setup, expect, Page } from '@playwright/test'; + +const AUTH_STATE_PATH = 'e2e/.auth/state.json'; + +async function authenticateWithOIDC(page: Page, username: string, password: string): Promise { + await page.goto('/'); + await page.waitForURL('**/login'); + + const popupPromise = page.waitForEvent('popup'); + await page.getByRole('button', { name: /sign in/i }).click(); + const popup = await popupPromise; + + await popup.waitForLoadState('domcontentloaded'); + await popup.waitForLoadState('networkidle'); + + const usernameField = popup.getByRole('textbox', { name: /email or username/i }); + await usernameField.waitFor({ state: 'visible', timeout: 15_000 }); + await usernameField.fill(username); + await popup.getByRole('button', { name: /log in/i }).click(); + + await popup.waitForLoadState('networkidle'); + const passwordField = popup.getByRole('textbox', { name: /password/i }); + await passwordField.waitFor({ state: 'visible', timeout: 15_000 }); + await passwordField.fill(password); + await popup.getByRole('button', { name: /continue|log in/i }).click(); + + await popup.waitForEvent('close', { timeout: 15_000 }); + + await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({ + timeout: 15_000, + }); +} + +async function authenticateWithToken(page: Page, token: string): Promise { + await page.goto('/'); + await page.waitForURL(/\/(login|token)$/); + + if (page.url().includes('/login')) { + const useTokenBtn = page.getByRole('button', { name: /use a token/i }); + await useTokenBtn.waitFor({ state: 'visible', timeout: 15_000 }); + await useTokenBtn.click(); + await page.waitForURL('**/token'); + } + + await page.getByRole('textbox', { name: /id token/i }).fill(token); + await page.getByRole('button', { name: /authenticate/i }).click(); + + await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({ + timeout: 15_000, + }); +} + +setup('authenticate with Headlamp', async ({ page }) => { + const username = process.env.AUTHENTIK_USERNAME; + const password = process.env.AUTHENTIK_PASSWORD; + const token = process.env.HEADLAMP_TOKEN; + + if (username && password) { + await authenticateWithOIDC(page, username, password); + } else if (token) { + await authenticateWithToken(page, token); + } else { + throw new Error( + 'Set AUTHENTIK_USERNAME + AUTHENTIK_PASSWORD for OIDC auth, or HEADLAMP_TOKEN for token auth' + ); + } + + await page.context().storageState({ path: AUTH_STATE_PATH }); +}); diff --git a/e2e/kube-vip.spec.ts b/e2e/kube-vip.spec.ts new file mode 100644 index 0000000..8d4d6f9 --- /dev/null +++ b/e2e/kube-vip.spec.ts @@ -0,0 +1,51 @@ +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 = 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 = 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(); + }); + + 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({ + timeout: 15_000, + }); + + const hasTable = await page.locator('table').first().isVisible().catch(() => false); + const hasContent = await page.locator('[class*="Mui"]').first().isVisible().catch(() => false); + expect(hasTable || hasContent).toBe(true); + }); + + test('plugin settings page shows kube-vip plugin entry', async ({ page }) => { + await page.goto('/settings/plugins'); + await page.waitForLoadState('networkidle'); + await page.waitForSelector('[class*="PluginList"], [class*="plugins"], table, list', { timeout: 10_000 }).catch(() => {}); + + const pluginEntry = page.locator('text=/kube.vip/i').first(); + await expect(pluginEntry).toBeVisible({ timeout: 30_000 }); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..b916edf --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + expect: { timeout: 10_000 }, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: 'list', + use: { + baseURL: process.env.HEADLAMP_URL || (() => { throw new Error('HEADLAMP_URL is required — run scripts/deploy-e2e-headlamp.sh first'); })(), + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { name: 'setup', testMatch: /auth\.setup\.ts/, timeout: 60_000 }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'e2e/.auth/state.json', + }, + dependencies: ['setup'], + }, + ], +}); diff --git a/scripts/deploy-e2e-headlamp.sh b/scripts/deploy-e2e-headlamp.sh new file mode 100755 index 0000000..0bb3add --- /dev/null +++ b/scripts/deploy-e2e-headlamp.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DIST_DIR="$REPO_ROOT/dist" + +E2E_NAMESPACE="${E2E_NAMESPACE:-headlamp-dev}" +E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}" +HEADLAMP_VERSION="${HEADLAMP_VERSION:-latest}" + +if [ ! -d "$DIST_DIR" ]; then + echo "ERROR: dist/ not found. Run 'pnpm build' first." >&2 + exit 1 +fi + +echo "Checking RBAC permissions in namespace '${E2E_NAMESPACE}'..." +if ! kubectl auth can-i delete configmaps -n "$E2E_NAMESPACE" --quiet 2>/dev/null; then + echo "ERROR: Missing RBAC — cannot delete configmaps in namespace '${E2E_NAMESPACE}'." >&2 + exit 1 +fi + +echo "=== E2E Headlamp Deployment ===" +echo " Image: ghcr.io/headlamp-k8s/headlamp:${HEADLAMP_VERSION}" +echo " Namespace: $E2E_NAMESPACE" +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 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)..." +kubectl delete deployment "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait +kubectl delete service "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait +kubectl delete serviceaccount "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait + +echo "" +echo "Deploying Headlamp E2E instance..." + +kubectl apply -f - </dev/null; do + ATTEMPTS=$((ATTEMPTS + 1)) + if [ "$ATTEMPTS" -ge "$MAX_ATTEMPTS" ]; then + echo "ERROR: ${SVC_URL} not reachable after $((MAX_ATTEMPTS * 5))s" >&2 + exit 1 + fi + echo " [${ATTEMPTS}/${MAX_ATTEMPTS}] not yet reachable, retrying in 5s..." + sleep 5 +done +echo "" +echo "E2E Headlamp is ready at: ${SVC_URL}" + +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 - + +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_TOKEN=${TOKEN}" >> "$REPO_ROOT/.env.e2e" + echo "Wrote .env.e2e with HEADLAMP_URL and HEADLAMP_TOKEN" +else + echo " WARNING: Could not generate token." +fi + +echo "" +echo "E2E deployment complete." diff --git a/scripts/teardown-e2e-headlamp.sh b/scripts/teardown-e2e-headlamp.sh new file mode 100755 index 0000000..eda69c0 --- /dev/null +++ b/scripts/teardown-e2e-headlamp.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +E2E_NAMESPACE="${E2E_NAMESPACE:-headlamp-dev}" +E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}" + +echo "=== E2E Headlamp Teardown ===" +echo " Namespace: $E2E_NAMESPACE" +echo " Release: $E2E_RELEASE" + +echo "Removing Headlamp Deployment, Service, and ServiceAccount..." +kubectl delete deployment "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found +kubectl delete service "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found +kubectl delete serviceaccount "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found + +echo "Cleaning up ConfigMap..." +kubectl delete configmap headlamp-kube-vip-plugin -n "$E2E_NAMESPACE" --ignore-not-found + +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" + echo "Removed .env.e2e" +fi + +echo "" +echo "E2E teardown complete." -- 2.52.0 From 097ac48ecfaa9584b93e15314ae20e2c4766413a Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 5 May 2026 13:33:10 +0000 Subject: [PATCH 2/9] 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 --- package.json | 1 + pnpm-lock.yaml | 42 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d588bc2..b9ecb03 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39e4fd1..8e9b1cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@mui/material': specifier: ^5.15.14 version: 5.18.0(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 '@testing-library/jest-dom': specifier: ^6.4.8 version: 6.9.1 @@ -988,6 +991,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -3049,6 +3057,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4185,6 +4198,16 @@ packages: resolution: {integrity: sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==} engines: {node: '>=10'} + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -6189,7 +6212,7 @@ snapshots: material-react-table: 2.13.3(93149b7a28d7dcf9399e2d03ebc8c990) monaco-editor: 0.52.2 msw: 2.4.9(typescript@5.6.2) - msw-storybook-addon: 2.0.3(msw@2.4.9(typescript@5.6.3)) + msw-storybook-addon: 2.0.3(msw@2.4.9(typescript@5.6.2)) notistack: 3.0.2(csstype@3.2.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) path-browserify: 1.0.1 prettier: 2.8.8 @@ -6522,6 +6545,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + '@popperjs/core@2.11.8': {} '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1)': @@ -9033,6 +9060,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -10083,7 +10113,7 @@ snapshots: ms@2.1.3: {} - msw-storybook-addon@2.0.3(msw@2.4.9(typescript@5.6.3)): + msw-storybook-addon@2.0.3(msw@2.4.9(typescript@5.6.2)): dependencies: is-node-process: 1.2.0 msw: 2.4.9(typescript@5.6.2) @@ -10413,6 +10443,14 @@ snapshots: dependencies: find-up: 5.0.0 + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-modules-extract-imports@3.1.0(postcss@8.5.13): -- 2.52.0 From 87798ecbe10e41ef379b24aca11234753f2dee3e Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 5 May 2026 13:49:56 +0000 Subject: [PATCH 3/9] 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. --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b9ecb03..eabc2d0 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "format": "prettier --write src/", "format:check": "prettier --check src/", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "e2e": "playwright test" }, "peerDependencies": { "react": "^18.0.0", -- 2.52.0 From 869d1c7225a71a3baa2f292569c6360417671b95 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 5 May 2026 13:55:54 +0000 Subject: [PATCH 4/9] fix(e2e): use .first() to handle strict mode violations with multiple headings (PRI-700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- e2e/kube-vip.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e/kube-vip.spec.ts b/e2e/kube-vip.spec.ts index 8d4d6f9..b2fc4f9 100644 --- a/e2e/kube-vip.spec.ts +++ b/e2e/kube-vip.spec.ts @@ -24,14 +24,14 @@ test.describe('kube-vip plugin smoke tests', () => { 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, }); @@ -43,7 +43,7 @@ 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('[class*="PluginList"], [class*="plugins"], table, list', { timeout: 10_000 }).catch(() => {}); + 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 }); -- 2.52.0 From 00df4a829ff76daf1596269286461108c31c3fdf Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 5 May 2026 14:06:49 +0000 Subject: [PATCH 5/9] fix(e2e): add e2e script to package.json Missing script caused ERR_PNPM_NO_SCRIPT in CI E2E step. Co-Authored-By: Paperclip --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b9ecb03..eabc2d0 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "format": "prettier --write src/", "format:check": "prettier --check src/", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "e2e": "playwright test" }, "peerDependencies": { "react": "^18.0.0", -- 2.52.0 From f1dd09c1554fd7147f72dfc4f9ea98dfd0a2edeb Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 5 May 2026 17:03:13 +0000 Subject: [PATCH 6/9] 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 --- scripts/deploy-e2e-headlamp.sh | 31 ++++++++++++++++++++++++++++--- scripts/teardown-e2e-headlamp.sh | 5 +++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/scripts/deploy-e2e-headlamp.sh b/scripts/deploy-e2e-headlamp.sh index 0bb3add..707c944 100755 --- a/scripts/deploy-e2e-headlamp.sh +++ b/scripts/deploy-e2e-headlamp.sh @@ -150,18 +150,43 @@ done echo "" echo "E2E Headlamp is ready at: ${SVC_URL}" +PF_PORT=4466 +echo "" +echo "Starting kubectl port-forward to ${SVC_URL} on localhost:${PF_PORT}..." +nohup kubectl port-forward -n "$E2E_NAMESPACE" "svc/${E2E_RELEASE}" "${PF_PORT}:80" > "$REPO_ROOT/.port-forward.log" 2>&1 & +PF_PID=$! +echo " port-forward PID: ${PF_PID}" + +echo "" +echo "Waiting for localhost:${PF_PORT} to be reachable via port-forward..." +ATTEMPTS=0 +MAX_ATTEMPTS=24 +until curl -sf --max-time 5 "http://localhost:${PF_PORT}" -o /dev/null 2>/dev/null; do + ATTEMPTS=$((ATTEMPTS + 1)) + if [ "$ATTEMPTS" -ge "$MAX_ATTEMPTS" ]; then + echo "ERROR: localhost:${PF_PORT} not reachable after $((MAX_ATTEMPTS * 5))s" >&2 + cat "$REPO_ROOT/.port-forward.log" >&2 + kill "${PF_PID}" 2>/dev/null || true + exit 1 + fi + echo " [${ATTEMPTS}/${MAX_ATTEMPTS}] port-forward not yet reachable, retrying in 5s..." + sleep 5 +done +echo "" +echo "Port-forward is ready at http://localhost:${PF_PORT}" + 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 - 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://localhost:${PF_PORT}" > "$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://localhost:${PF_PORT} and HEADLAMP_TOKEN" else echo " WARNING: Could not generate token." fi echo "" -echo "E2E deployment complete." +echo "E2E deployment complete. port-forward PID ${PF_PID} is running in background." diff --git a/scripts/teardown-e2e-headlamp.sh b/scripts/teardown-e2e-headlamp.sh index eda69c0..15594fa 100755 --- a/scripts/teardown-e2e-headlamp.sh +++ b/scripts/teardown-e2e-headlamp.sh @@ -26,5 +26,10 @@ if [ -f "$REPO_ROOT/.env.e2e" ]; then echo "Removed .env.e2e" fi +echo "Killing any kubectl port-forward processes for ${E2E_RELEASE}..." +pkill -f "kubectl port-forward.*${E2E_RELEASE}" 2>/dev/null || true + +rm -f "$REPO_ROOT/.port-forward.log" + echo "" echo "E2E teardown complete." -- 2.52.0 From 9cc1ca7b9158cb5813832ad4551d728b5bcb047a Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 5 May 2026 17:10:54 +0000 Subject: [PATCH 7/9] fix(e2e): use NodePort instead of cluster-internal DNS for HEADLAMP_URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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://:30080 until reachable - Write HEADLAMP_URL=http://:30080 to .env.e2e - Remove port-forward leftover cleanup from teardown script Co-Authored-By: Paperclip --- scripts/deploy-e2e-headlamp.sh | 32 +++++++++++++++++--------------- scripts/teardown-e2e-headlamp.sh | 7 +------ 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/scripts/deploy-e2e-headlamp.sh b/scripts/deploy-e2e-headlamp.sh index 707c944..13d6da7 100755 --- a/scripts/deploy-e2e-headlamp.sh +++ b/scripts/deploy-e2e-headlamp.sh @@ -118,7 +118,7 @@ metadata: app.kubernetes.io/name: headlamp app.kubernetes.io/instance: ${E2E_RELEASE} spec: - type: ClusterIP + type: NodePort selector: app.kubernetes.io/name: headlamp app.kubernetes.io/instance: ${E2E_RELEASE} @@ -126,6 +126,7 @@ spec: - name: http port: 80 targetPort: http + nodePort: 30080 protocol: TCP EOF @@ -152,28 +153,29 @@ echo "E2E Headlamp is ready at: ${SVC_URL}" PF_PORT=4466 echo "" -echo "Starting kubectl port-forward to ${SVC_URL} on localhost:${PF_PORT}..." -nohup kubectl port-forward -n "$E2E_NAMESPACE" "svc/${E2E_RELEASE}" "${PF_PORT}:80" > "$REPO_ROOT/.port-forward.log" 2>&1 & -PF_PID=$! -echo " port-forward PID: ${PF_PID}" +echo "Getting a node internal IP for NodePort access..." +NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}' 2>/dev/null) +if [ -z "${NODE_IP}" ]; then + echo "ERROR: Could not determine node InternalIP" >&2 + exit 1 +fi +echo " node IP: ${NODE_IP}" echo "" -echo "Waiting for localhost:${PF_PORT} to be reachable via port-forward..." +echo "Waiting for NodePort ${NODE_IP}:30080 to be reachable..." ATTEMPTS=0 MAX_ATTEMPTS=24 -until curl -sf --max-time 5 "http://localhost:${PF_PORT}" -o /dev/null 2>/dev/null; do +until curl -sf --max-time 5 "http://${NODE_IP}:30080" -o /dev/null 2>/dev/null; do ATTEMPTS=$((ATTEMPTS + 1)) if [ "$ATTEMPTS" -ge "$MAX_ATTEMPTS" ]; then - echo "ERROR: localhost:${PF_PORT} not reachable after $((MAX_ATTEMPTS * 5))s" >&2 - cat "$REPO_ROOT/.port-forward.log" >&2 - kill "${PF_PID}" 2>/dev/null || true + echo "ERROR: ${NODE_IP}:30080 not reachable after $((MAX_ATTEMPTS * 5))s" >&2 exit 1 fi - echo " [${ATTEMPTS}/${MAX_ATTEMPTS}] port-forward not yet reachable, retrying in 5s..." + echo " [${ATTEMPTS}/${MAX_ATTEMPTS}] NodePort not yet reachable, retrying in 5s..." sleep 5 done echo "" -echo "Port-forward is ready at http://localhost:${PF_PORT}" +echo "Headlamp is ready at http://${NODE_IP}:30080" echo "" echo "Creating service account token for E2E auth..." @@ -181,12 +183,12 @@ kubectl create serviceaccount headlamp-e2e-test -n "$E2E_NAMESPACE" --dry-run= TOKEN=$(kubectl create token headlamp-e2e-test -n "$E2E_NAMESPACE" --duration=1h 2>/dev/null || echo "") if [ -n "$TOKEN" ]; then - echo "HEADLAMP_URL=http://localhost:${PF_PORT}" > "$REPO_ROOT/.env.e2e" + echo "HEADLAMP_URL=http://${NODE_IP}:30080" > "$REPO_ROOT/.env.e2e" echo "HEADLAMP_TOKEN=${TOKEN}" >> "$REPO_ROOT/.env.e2e" - echo "Wrote .env.e2e with HEADLAMP_URL=http://localhost:${PF_PORT} and HEADLAMP_TOKEN" + echo "Wrote .env.e2e with HEADLAMP_URL=http://${NODE_IP}:30080 and HEADLAMP_TOKEN" else echo " WARNING: Could not generate token." fi echo "" -echo "E2E deployment complete. port-forward PID ${PF_PID} is running in background." +echo "E2E deployment complete." diff --git a/scripts/teardown-e2e-headlamp.sh b/scripts/teardown-e2e-headlamp.sh index 15594fa..1466196 100755 --- a/scripts/teardown-e2e-headlamp.sh +++ b/scripts/teardown-e2e-headlamp.sh @@ -22,14 +22,9 @@ 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 -echo "Killing any kubectl port-forward processes for ${E2E_RELEASE}..." -pkill -f "kubectl port-forward.*${E2E_RELEASE}" 2>/dev/null || true - -rm -f "$REPO_ROOT/.port-forward.log" - echo "" echo "E2E teardown complete." -- 2.52.0 From 019366ff01109be95d8dbf0c56094cba1bec4414 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 5 May 2026 17:15:33 +0000 Subject: [PATCH 8/9] fix(e2e): use LoadBalancer IP for HEADLAMP_URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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://:80 until reachable - Write HEADLAMP_URL=http://: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 --- scripts/deploy-e2e-headlamp.sh | 47 ++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/scripts/deploy-e2e-headlamp.sh b/scripts/deploy-e2e-headlamp.sh index 13d6da7..eb42a35 100755 --- a/scripts/deploy-e2e-headlamp.sh +++ b/scripts/deploy-e2e-headlamp.sh @@ -118,7 +118,7 @@ metadata: app.kubernetes.io/name: headlamp app.kubernetes.io/instance: ${E2E_RELEASE} spec: - type: NodePort + type: LoadBalancer selector: app.kubernetes.io/name: headlamp app.kubernetes.io/instance: ${E2E_RELEASE} @@ -126,7 +126,6 @@ spec: - name: http port: 80 targetPort: http - nodePort: 30080 protocol: TCP EOF @@ -151,31 +150,41 @@ done echo "" echo "E2E Headlamp is ready at: ${SVC_URL}" -PF_PORT=4466 echo "" -echo "Getting a node internal IP for NodePort access..." -NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}' 2>/dev/null) -if [ -z "${NODE_IP}" ]; then - echo "ERROR: Could not determine node InternalIP" >&2 - exit 1 -fi -echo " node IP: ${NODE_IP}" - -echo "" -echo "Waiting for NodePort ${NODE_IP}:30080 to be reachable..." +echo "Getting LoadBalancer IP for Headlamp service..." +LB_IP="" ATTEMPTS=0 MAX_ATTEMPTS=24 -until curl -sf --max-time 5 "http://${NODE_IP}:30080" -o /dev/null 2>/dev/null; do +while [ -z "${LB_IP}" ] || [ "${LB_IP}" = "" ]; do ATTEMPTS=$((ATTEMPTS + 1)) if [ "$ATTEMPTS" -ge "$MAX_ATTEMPTS" ]; then - echo "ERROR: ${NODE_IP}:30080 not reachable after $((MAX_ATTEMPTS * 5))s" >&2 + echo "ERROR: LoadBalancer IP not assigned after $((MAX_ATTEMPTS * 5))s" >&2 exit 1 fi - echo " [${ATTEMPTS}/${MAX_ATTEMPTS}] NodePort not yet reachable, retrying in 5s..." + 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}" = "" ]; 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://${NODE_IP}:30080" +echo "Headlamp is ready at http://${LB_IP}:80" echo "" echo "Creating service account token for E2E auth..." @@ -183,9 +192,9 @@ kubectl create serviceaccount headlamp-e2e-test -n "$E2E_NAMESPACE" --dry-run= TOKEN=$(kubectl create token headlamp-e2e-test -n "$E2E_NAMESPACE" --duration=1h 2>/dev/null || echo "") if [ -n "$TOKEN" ]; then - echo "HEADLAMP_URL=http://${NODE_IP}:30080" > "$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=http://${NODE_IP}:30080 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 -- 2.52.0 From d202ca42d6319bc629acd88cf48a1d029504c0e9 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 5 May 2026 17:43:44 +0000 Subject: [PATCH 9/9] fix(e2e): reference @main workflow after .github merge 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 4ee85a4..7157fd1 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -16,7 +16,7 @@ concurrency: jobs: e2e: - uses: privilegedescalation/.github/.github/workflows/plugin-e2e.yaml@hugh/add-pnpm-support-plugin-e2e + uses: privilegedescalation/.github/.github/workflows/plugin-e2e.yaml@main with: node-version: '22' headlamp-version: v0.40.1 -- 2.52.0