Compare commits

..

7 Commits

Author SHA1 Message Date
Chris Farhood fe37b4d424 fix(e2e): correct indentation of failureThreshold in readinessProbe YAML
YAML indentation was 2 levels deeper than siblings (14 spaces vs 12),
causing the key to be nested under httpGet instead of readinessProbe.
kubectl apply would silently accept this mis-structured manifest but
the probe configuration would be ignored at runtime.

PRI-907

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-06 21:07:06 +00:00
Chris Farhood 6c6cfc88f4 fix(e2e): add cluster diagnostics to deploy step for faster triage
Add pre-deployment node/namespace/resource diagnostics and wrap
kubectl apply in explicit error handling with cluster state dump on
failure. This gives us actionable output in the GitHub Actions logs
when the Deploy E2E step fails, instead of a silent exit code.

PRI-956

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-06 18:30:43 +00:00
Chris Farhood 540f0a7890 fix e2e: add missing plugin-name input to plugin-e2e workflow
The rook plugin E2E workflow was calling the reusable plugin-e2e workflow
without the required plugin-name input. This caused the ConfigMap naming
and mount path to fall back to the headlamp-kube-vip defaults, breaking
E2E runs for the rook plugin.

Fix: pass plugin-name: rook to the reusable workflow.
2026-05-06 12:36:08 +00:00
Chris Farhood 3f93e71f28 fix(e2e): reference @main workflow after .github merge
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-05 17:43:54 +00:00
Chris Farhood 0d9f9d859a fix(e2e): use .first() to handle strict mode violations (PRI-699)
Similar to the kube-vip fix, /overview/i and /storage class/i can
match multiple headings. Using .first() to avoid strict mode violations.
2026-05-05 13:56:35 +00:00
Chris Farhood 61df61c691 fix(e2e): expand storage classes sidebar nav and relax plugin settings locator
The 'Storage Classes' link is nested under the Rook sidebar button, not
at the top level. Expand the Rook section before asserting visibility.
Also uses /rook/i case-insensitive regex and waits for the plugins list
to render before searching.
2026-05-05 13:04:45 +00:00
Chris Farhood 15d161c312 fix(e2e): use pnpm-capable workflow branch with namespace param 2026-05-05 12:21:22 +00:00
10 changed files with 1091 additions and 717 deletions
+24
View File
@@ -0,0 +1,24 @@
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@main
with:
node-version: "22"
headlamp-version: v0.40.1
e2e-namespace: headlamp-dev
plugin-name: rook
-14
View File
@@ -1,14 +0,0 @@
name: Renovate
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
jobs:
renovate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: renovatebot/github-action@v40.3.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
configurationFile: renovate.json
+1 -1
View File
@@ -90,7 +90,7 @@ roleRef:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
namespace: headlamp
```
## Troubleshooting
+69
View File
@@ -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<void> {
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<void> {
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 });
});
+68
View File
@@ -0,0 +1,68 @@
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('Rook plugin smoke tests', () => {
test('sidebar contains Rook entry', async ({ page }) => {
await page.goto('/');
const sidebar = await waitForSidebar(page);
await expect(sidebar.getByRole('button', { name: /rook/i })).toBeVisible();
});
test('Rook sidebar entry navigates to overview', async ({ page }) => {
await page.goto('/');
const sidebar = await waitForSidebar(page);
const rookEntry = sidebar.getByRole('button', { name: /rook/i });
await expect(rookEntry).toBeVisible();
await rookEntry.click();
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/rook-ceph/);
await expect(page.getByRole('heading', { name: /overview/i }).first()).toBeVisible();
});
test('overview page renders content', async ({ page }) => {
await page.goto('/c/main/rook-ceph');
await waitForSidebar(page);
await expect(page.getByRole('heading', { name: /overview/i }).first()).toBeVisible({
timeout: 15_000,
});
const hasContent = await page.locator('text=/cluster|ceph|status/i').first().isVisible().catch(() => false);
const hasDashboard = await page.locator('[class*="Mui"]').first().isVisible().catch(() => false);
expect(hasContent || hasDashboard).toBe(true);
});
test('navigation to storage classes view works', async ({ page }) => {
await page.goto('/c/main/rook-ceph');
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
const rookBtn = sidebar.getByRole('button', { name: /rook/i });
await rookBtn.click();
await page.waitForLoadState('networkidle');
const storageClassesLink = sidebar.getByRole('link', { name: /storage classes/i });
await expect(storageClassesLink).toBeVisible({ timeout: 10_000 });
await storageClassesLink.click();
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/rook-ceph\/storage-classes/);
await expect(page.getByRole('heading', { name: /storage class/i }).first()).toBeVisible({ timeout: 15_000 });
});
test('plugin settings page shows rook 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=/rook/i').first();
await expect(pluginEntry).toBeVisible({ timeout: 30_000 });
});
});
+5 -11
View File
@@ -22,12 +22,15 @@
"format": "prettier --write src/",
"format:check": "prettier --check src/",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"e2e": "playwright test",
"e2e:headed": "playwright test --headed"
},
"devDependencies": {
"@headlamp-k8s/eslint-config": "^0.6.0",
"@kinvolk/headlamp-plugin": "^0.13.0",
"@mui/material": "^5.15.14",
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
@@ -47,15 +50,6 @@
"tar": "^7.5.11",
"undici": "^7.24.3",
"vite": ">=6.4.2",
"lodash": ">=4.18.0",
"elliptic": ">=6.6.1"
},
"packageManager": "pnpm@9.15.4",
"pnpm": {
"onlyBuiltDependencies": [
"@swc/core",
"esbuild",
"msw"
]
"lodash": ">=4.18.0"
}
}
+27
View File
@@ -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'],
},
],
});
+654 -691
View File
File diff suppressed because it is too large Load Diff
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env bash
# deploy-e2e-headlamp.sh
#
# Deploys a stock Headlamp instance with the rook 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)"
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 ""
echo "=== Pre-deployment cluster diagnostics ==="
echo "Nodes:"
kubectl get nodes -o wide 2>&1 || true
echo ""
echo "headlamp-dev namespace state:"
kubectl get ns headlamp-dev -o yaml 2>&1 || true
echo ""
echo "Existing E2E resources in namespace:"
kubectl get all -n "$E2E_NAMESPACE" -l "app.kubernetes.io/instance=$E2E_RELEASE" 2>&1 || true
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-rook-plugin \
-n "$E2E_NAMESPACE" --ignore-not-found
kubectl create configmap headlamp-rook-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..."
if ! kubectl apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
name: ${E2E_RELEASE}
namespace: ${E2E_NAMESPACE}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${E2E_RELEASE}
namespace: ${E2E_NAMESPACE}
labels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
template:
metadata:
labels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
spec:
serviceAccountName: ${E2E_RELEASE}
automountServiceAccountToken: true
securityContext: {}
containers:
- name: headlamp
image: ghcr.io/headlamp-k8s/headlamp:${HEADLAMP_VERSION}
imagePullPolicy: IfNotPresent
securityContext:
runAsNonRoot: true
privileged: false
runAsUser: 100
runAsGroup: 101
args:
- "-in-cluster"
- "-in-cluster-context-name=main"
- "-plugins-dir=/headlamp/plugins"
ports:
- name: http
containerPort: 4466
protocol: TCP
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 6
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
volumeMounts:
- name: rook-plugin
mountPath: /headlamp/plugins/headlamp-rook
readOnly: true
volumes:
- name: rook-plugin
configMap:
name: headlamp-rook-plugin
---
apiVersion: v1
kind: Service
metadata:
name: ${E2E_RELEASE}
namespace: ${E2E_NAMESPACE}
labels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
ports:
- name: http
port: 80
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
SVC_URL="http://${E2E_RELEASE}.${E2E_NAMESPACE}.svc.cluster.local"
echo ""
echo "Waiting for ${SVC_URL} to be reachable..."
ATTEMPTS=0
MAX_ATTEMPTS=24
until curl -sf --max-time 5 "${SVC_URL}" -o /dev/null 2>/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."
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# teardown-e2e-headlamp.sh
#
# Tears down the dedicated E2E Headlamp instance deployed by deploy-e2e-headlamp.sh.
#
# Environment:
# E2E_NAMESPACE — namespace to clean up (default: headlamp-dev)
# E2E_RELEASE — release/resource name prefix (default: headlamp-e2e)
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-rook-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."