Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot] 515317dcb2 release: v0.7.2 2026-03-19 21:16:38 +00:00
26 changed files with 522 additions and 1922 deletions
-20
View File
@@ -1,20 +0,0 @@
name: Dual Approval (CTO + QA)
# Calls the shared dual-approval-check workflow.
# Passes when both privilegedescalation-cto and privilegedescalation-qa
# have approved the PR. Add "Dual Approval (CTO + QA)" to required_status_checks
# in branch protection to enforce this gate.
on:
pull_request_review:
types: [submitted, dismissed]
pull_request:
branches: [main]
types: [opened, reopened, synchronize]
jobs:
dual-approval:
uses: privilegedescalation/.github/.github/workflows/dual-approval-check.yaml@main
secrets: inherit
with:
pr_number: ${{ github.event.pull_request.number }}
+100 -146
View File
@@ -7,29 +7,13 @@ on:
branches: [main]
workflow_dispatch:
permissions:
contents: read
# Only one E2E run at a time: the shared E2E_RELEASE (headlamp-e2e) in
# headlamp-dev cannot be shared across concurrent runs.
# cancel-in-progress: false (queue, don't cancel) — cancelling in-flight
# runs may skip the if:always() teardown, leaving dangling cluster resources.
concurrency:
group: e2e-${{ github.repository }}
cancel-in-progress: false
env:
E2E_NAMESPACE: headlamp-dev
E2E_RELEASE: headlamp-e2e
# Pin to a known-good Headlamp version. Using :latest is risky because
# the tag can change between CI runs, causing flaky failures when a newer
# image is pulled on some nodes but not others (IfNotPresent pull policy).
# Update this when Headlamp is upgraded in production (kube-system).
HEADLAMP_VERSION: v0.40.1
HEADLAMP_NAMESPACE: kube-system
HEADLAMP_DEPLOY: headlamp
jobs:
e2e:
runs-on: runners-privilegedescalation
runs-on: local-ubuntu-latest
timeout-minutes: 15
steps:
@@ -37,155 +21,125 @@ jobs:
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Setup kubectl
uses: azure/setup-kubectl@v4
- name: Get kubeconfig
run: |
set -euo pipefail
echo "=== Runner environment diagnostic ==="
echo "HOME=${HOME:-}"
echo "KUBECONFIG=${KUBECONFIG:-}"
echo "ACTIONS_KUBECONFIG=${ACTIONS_KUBECONFIG:-}"
echo "RUNNER_CONFIG=${RUNNER_CONFIG:-}"
echo "RUNNER_CONFIG_DIR=${RUNNER_CONFIG_DIR:-}"
echo ""
echo "=== Checking known kubeconfig locations ==="
for path in /runner/config /home/runner/.kube/config "${HOME:-}/.kube/config" "${HOME:-}/.kube"; do
if [ -f "$path" ]; then
echo "FOUND kubeconfig at: $path"
elif [ -d "$path" ]; then
echo "DIR exists at: $path, contents:"
ls -la "$path" 2>&1 || echo " (cannot list)"
else
echo "NOT FOUND: $path"
fi
done
echo ""
echo "=== In-cluster service account check ==="
in_cluster=false
if [ -f /var/run/secrets/kubernetes.io/serviceaccount/token ]; then
echo "Service account token present — in-cluster mode available"
echo "KUBERNETES_SERVICE_HOST=${KUBERNETES_SERVICE_HOST:-}"
echo "KUBERNETES_SERVICE_PORT=${KUBERNETES_SERVICE_PORT:-}"
in_cluster=true
else
echo "No service account token at /var/run/secrets/kubernetes.io/serviceaccount/"
fi
echo ""
if [ -f /runner/config ]; then
echo "KUBECONFIG=/runner/config" >> "$GITHUB_ENV"
echo "Using kubeconfig from /runner/config"
elif [ -f /home/runner/.kube/config ]; then
echo "KUBECONFIG=/home/runner/.kube/config" >> "$GITHUB_ENV"
echo "Using kubeconfig from /home/runner/.kube/config"
elif [ -f "${HOME:-}/.kube/config" ]; then
echo "KUBECONFIG=${HOME:-}/.kube/config" >> "$GITHUB_ENV"
echo "Using kubeconfig from HOME"
elif [ "$in_cluster" = true ]; then
echo "No static kubeconfig found — generating in-cluster kubeconfig"
KUBECFG_DIR="${HOME:-}/.kube"
mkdir -p "$KUBECFG_DIR"
kubectl config set-cluster in-cluster \
--server="https://${KUBERNETES_SERVICE_HOST:-kubernetes.default.svc}:${KUBERNETES_SERVICE_PORT:-443}" \
--certificate-authority=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
--embed-certs=true \
--kubeconfig="$KUBECFG_DIR/config" 2>&1
kubectl config set-credentials in-cluster \
--token="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \
--kubeconfig="$KUBECFG_DIR/config" 2>&1
kubectl config set-context in-cluster \
--cluster=in-cluster \
--user=in-cluster \
--kubeconfig="$KUBECFG_DIR/config" 2>&1
kubectl config use-context in-cluster \
--kubeconfig="$KUBECFG_DIR/config" 2>&1
echo "KUBECONFIG=$KUBECFG_DIR/config" >> "$GITHUB_ENV"
echo "Generated in-cluster kubeconfig at $KUBECFG_DIR/config"
else
echo "::error::No kubeconfig found in /runner/config, /home/runner/.kube/config, HOME, or in-cluster service account"
exit 1
fi
- name: Apply RBAC for E2E pipeline
run: |
set -x
kubectl apply -f deployment/e2e-ci-runner-rbac.yaml --dry-run=server 2>&1 || true
kubectl apply -f deployment/e2e-ci-runner-rbac.yaml 2>&1
echo "exit code: $?"
echo "Waiting for RBAC propagation..."
sleep 5
echo "Verifying RBAC resources were created..."
kubectl get role e2e-ci-runner -n headlamp-dev 2>&1 | tail -3
kubectl get role e2e-ci-runner-polaris -n headlamp-dev 2>&1 | tail -3
kubectl get rolebinding e2e-ci-runner-binding -n headlamp-dev 2>&1 | tail -3
set +x
- name: Apply Polaris dashboard RBAC
run: kubectl apply -f deployment/polaris-rbac.yaml
- name: RBAC pre-flight check
run: |
echo "Checking RBAC resources..."
MISSING=0
kubectl get role polaris-dashboard-proxy-reader -n polaris -o name >/dev/null 2>&1 || MISSING=1
kubectl get rolebinding polaris-dashboard-proxy-reader -n polaris -o name >/dev/null 2>&1 || MISSING=1
kubectl auth can-i delete configmaps -n "$E2E_NAMESPACE" 2>/dev/null || MISSING=1
if [ "$MISSING" -eq 0 ]; then
echo "RBAC pre-flight check passed."
else
echo "::error::RBAC pre-flight check failed. Missing required permissions."
exit 1
fi
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npx @kinvolk/headlamp-plugin build
run: npm run build
- name: Deploy E2E Headlamp instance
run: scripts/deploy-e2e-headlamp.sh
- name: Setup kubectl
uses: azure/setup-kubectl@v4
- name: Load E2E environment
- name: Ensure PVC exists
run: kubectl apply -f deployment/headlamp-plugins-pvc.yaml
- name: Patch Headlamp deployment with shared volume mount
run: |
if [ -f .env.e2e ]; then
cat .env.e2e >> "$GITHUB_ENV"
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 "::error::deploy-e2e-headlamp.sh did not produce .env.e2e"
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")
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
if [ "$HTTP_CODE" = "000" ]; then
echo "::error::Cannot reach Headlamp at $HEADLAMP_URL after 60s"
exit 1
fi
# Verify plugin is visible
PLUGIN_JSON=$(curl -sf --connect-timeout 10 "$HEADLAMP_URL/plugins" 2>/dev/null || echo "[]")
node -e "
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'));
if (!ours) {
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"
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npm run e2e
env:
HEADLAMP_URL: ${{ env.HEADLAMP_URL }}
HEADLAMP_TOKEN: ${{ env.HEADLAMP_TOKEN }}
- name: Collect deployment diagnostics on failure
if: failure()
run: |
echo "=== Pod state ==="
kubectl get pods -n "$E2E_NAMESPACE" -l "app.kubernetes.io/instance=$E2E_RELEASE" 2>&1 || true
echo "=== Pod describe ==="
kubectl describe pods -n "$E2E_NAMESPACE" -l "app.kubernetes.io/instance=$E2E_RELEASE" 2>&1 || true
echo "=== Recent namespace events ==="
kubectl get events -n "$E2E_NAMESPACE" --sort-by='.lastTimestamp' 2>&1 | tail -20 || true
- name: Teardown E2E instance
if: always()
run: scripts/teardown-e2e-headlamp.sh
HEADLAMP_URL: ${{ secrets.HEADLAMP_URL || 'http://headlamp.kube-system.svc.cluster.local' }}
HEADLAMP_TOKEN: ${{ secrets.HEADLAMP_TOKEN }}
AUTHENTIK_USERNAME: ${{ secrets.AUTHENTIK_USERNAME }}
AUTHENTIK_PASSWORD: ${{ secrets.AUTHENTIK_PASSWORD }}
- name: Upload Playwright report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
@@ -193,7 +147,7 @@ jobs:
retention-days: 7
- name: Upload test results
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results
-3
View File
@@ -15,9 +15,6 @@ permissions:
jobs:
release:
uses: privilegedescalation/.github/.github/workflows/plugin-release.yaml@main
secrets:
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
with:
version: ${{ inputs.version }}
upstream-repo: 'FairwindsOps/polaris'
-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:
configurationFile: renovate.json
renovate-json5: true
-1
View File
@@ -6,6 +6,5 @@ e2e/.auth/
test-results/
.playwright-mcp/
.env
.env.e2e
.env.local
.eslintcache
-53
View File
@@ -1,53 +0,0 @@
{
"config": {
// Line length — not enforced for docs with code examples
"MD013": false,
// First line heading — files use YAML frontmatter, not headings
"MD041": false,
// Emphasis as heading — common pattern for Option 1/2/3 sections
"MD036": false,
// No duplicate heading — changelog files repeat section names intentionally
"MD024": false,
// Fenced code language — not always applicable for diagram blocks
"MD040": false,
// Table column style — table alignment is visual, not semantic
"MD060": false,
// Ordered list item prefix — number resets are intentional in documents
"MD029": false,
// No inline HTML — each elements are valid in valid Markdown
"MD033": false,
// List marker space — spacing after list markers varies by editor
"MD030": false,
// Blanks around headings — not always needed in compact docs
"MD022": false,
// Blanks around lists — not always needed in compact docs
"MD032": false,
// Blanks around fences — not always needed between adjacent blocks
"MD031": false,
// Multiple blanks — editor artifacts, not semantic
"MD012": false,
// Single title — files may have multiple H1 sections
"MD025": false,
// Trailing spaces — editor artifacts
"MD009": false,
// Bare URLs — URL shortening not always needed
"MD034": false,
// Single trailing newline — editor artifacts
"MD047": false,
// Trailing punctuation — heading punctuation is intentional
"MD026": false,
// Space in emphasis — double-asterisk bold spacing varies by renderer
"MD037": false,
// No hard tabs — some generated docs use tabs for indentation
"MD010": false,
// Code block style — generated docs may use inconsistent styles
"MD046": false,
// Comment style — generated docs have no comments
"MD048": false,
// Commands show output — shell examples intentionally show only commands
"MD014": false
},
"ignores": [
"docs/api-reference/generated/**"
]
}
-1
View File
@@ -1 +0,0 @@
docs/api-reference/generated/**
+1 -27
View File
@@ -7,31 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0] - 2026-03-22
First stable release. The plugin API (routes, sidebar entries, settings schema, and app bar action) is
now frozen — no breaking changes without a new major version.
### Security
- Patched 8 of 9 npm audit vulnerabilities via `pnpm.overrides` (#92)
### Added
- **Dual-approval CI check**: PRs now require approval from both CTO and QA before merging (#98, #76)
- **ExemptionManager test suite**: Full coverage of annotation-based exemption flows, exemption creation, and inline feedback (#82)
- **RBAC preflight check**: `deploy-e2e-headlamp.sh` now verifies runner RBAC before attempting E2E deploy (#80)
### Fixed
- **E2E infrastructure overhaul**: Replaced Dockerfile.e2e with ConfigMap volume mount for plugin loading; tests now run in the `privilegedescalation-dev` namespace (#73, #89, #94)
- **E2E token auth**: Workflow uses GitHub App token auth and handles the `/token` redirect correctly (#97)
- **E2E HTTP readiness**: `deploy-e2e-headlamp.sh` waits for HTTP reachability after rollout before running tests (#104)
- **E2E runner label**: Updated to `runners-privilegedescalation` for self-hosted ARC runners (#71)
- **Direct devDependencies**: Added `typescript`, `eslint`, `prettier`, and `@headlamp-k8s/eslint-config` as explicit direct devDependencies to prevent phantom-dep failures in clean installs (#95, #102)
### Changed
- **pnpm version pinned**: `packageManager` field in `package.json` pins the pnpm version used in CI (#103)
- **GitHub Actions SHA pinning**: Renovate `pinDigests` enabled to SHA-pin all GitHub Actions (#105)
- **ArtifactHub metadata polish**: Improved `install` instructions and `changes` section formatting (#82)
## [0.6.0] - 2026-03-04
### Fixed
@@ -295,8 +270,7 @@ now frozen — no breaking changes without a new major version.
- Automated release workflow
- Basic CI/CD pipeline
[Unreleased]: https://github.com/privilegedescalation/headlamp-polaris-plugin/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/privilegedescalation/headlamp-polaris-plugin/compare/v0.7.2...v1.0.0
[Unreleased]: https://github.com/privilegedescalation/headlamp-polaris-plugin/compare/v0.6.0...HEAD
[0.6.0]: https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/tag/v0.6.0
[0.3.5]: https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/tag/v0.3.5
[0.3.4]: https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/tag/v0.3.4
+1 -1
View File
@@ -229,7 +229,7 @@ Headlamp v0.39.0 with default `watchPlugins: true` treats catalog-managed plugin
**Action Items:**
- [ ] Parallelize test execution
- [ ] Add npm cache to GitHub Actions
- [x] Renovate is configured org-wide via `github>privilegedescalation/.github:renovate-config`
- [ ] Integrate Dependabot
- [ ] Add semantic-release
---
+1 -1
View File
@@ -212,7 +212,7 @@ If you discover a security vulnerability in this plugin, please report it via:
The project uses:
- **npm audit**: Runs automatically during `npm install`
- **Renovate**: Automated dependency updates via Mend Renovate (org-wide configured)
- **Dependabot**: GitHub Dependabot monitors dependencies and creates PRs for updates
- **GitHub Actions**: CI workflow runs `npm audit` on every commit
### Updating Dependencies
-98
View File
@@ -1,98 +0,0 @@
# PRI-324 Spec: Make E2E Workflow Self-Sufficient with RBAC
## Context
PR #123 introduced an RBAC pre-flight check to the E2E workflow. QA (Nancy, acting as QA) verified the "fails fast without RBAC" path works, but found that the "with RBAC passes" path had no green CI evidence — the workflow did not apply RBAC before the pre-flight check.
PR #131 attempted to fix this by adding `kubectl apply` steps and extending the CI runner RBAC, but its merge commit (739db6fe) was reverted by the next commit on main (aa1db921) due to a vulnerability fix PR (#128).
The current E2E workflow on `main` lacks the RBAC apply steps and CI runner permissions needed to make the pre-flight check meaningful.
## Required Changes
### 1. `.github/workflows/e2e.yaml`
Add between the "Setup kubectl" and "Install dependencies" steps:
```yaml
- name: Apply RBAC for E2E pipeline
run: |
set -x
kubectl apply -f deployment/e2e-ci-runner-rbac.yaml --dry-run=server 2>&1 || true
kubectl apply -f deployment/e2e-ci-runner-rbac.yaml 2>&1
echo "exit code: $?"
echo "Waiting for RBAC propagation..."
sleep 5
echo "Verifying CI runner permissions..."
kubectl auth can-i create roles -n headlamp-dev --as="system:serviceaccount:arc-runners:runners-privilegedescalation-gha-rs-no-permission" 2>&1 || { echo "::error::CI runner still lacks roles permission after propagation wait"; exit 1; }
set +x
- name: Apply Polaris dashboard RBAC
run: kubectl apply -f deployment/polaris-rbac.yaml
- name: RBAC pre-flight check
run: |
echo "Checking RBAC resources..."
MISSING=0
kubectl get role polaris-dashboard-proxy-reader -n polaris -o name >/dev/null 2>&1 || MISSING=1
kubectl get rolebinding polaris-dashboard-proxy-reader -n polaris -o name >/dev/null 2>&1 || MISSING=1
kubectl auth can-i delete configmaps -n "$E2E_NAMESPACE" --quiet 2>/dev/null || MISSING=1
if [ "$MISSING" -eq 0 ]; then
echo "RBAC pre-flight check passed."
else
echo "::error::RBAC pre-flight check failed. Missing required permissions."
exit 1
fi
```
### 2. `deployment/e2e-ci-runner-rbac.yaml`
Add a new Role + RoleBinding for the `polaris` namespace (from PR #131):
```yaml
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: e2e-ci-runner-polaris
namespace: polaris
rules:
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["roles", "rolebindings"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: e2e-ci-runner-polaris
namespace: polaris
subjects:
- kind: ServiceAccount
name: runners-privilegedescalation-gha-rs-no-permission
namespace: arc-runners
roleRef:
kind: Role
name: e2e-ci-runner-polaris
apiGroup: rbac.authorization.k8s.io
```
And add to the existing `e2e-ci-runner` Role in the `headlamp-dev` namespace:
```yaml
# Apply Polaris dashboard RBAC in the polaris namespace
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["roles", "rolebindings"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
```
## Acceptance Criteria
- [ ] Workflow applies `deployment/e2e-ci-runner-rbac.yaml` before the pre-flight check
- [ ] Workflow applies `deployment/polaris-rbac.yaml` before the pre-flight check
- [ ] CI runner has RBAC to apply the manifests (added via new Role+RoleBinding in polaris namespace)
- [ ] E2E pipeline passes on the PR branch (proof of green path)
- [ ] `kubectl get … --quiet` flag removed (QA nit)
- [ ] `MISSING_ROLE`/`MISSING_ROLEBINDING` collapsed to single `MISSING` flag (QA nit)
## Definition of Done
PR #123 QA changes-requested are addressed: the workflow is self-sufficient (applies its own RBAC), the green path is demonstrated, and QA review is re-requested.
+4 -46
View File
@@ -1,4 +1,4 @@
version: "1.0.0"
version: "0.7.2"
name: headlamp-polaris
displayName: Polaris
createdAt: "2026-02-05T19:00:00Z"
@@ -11,7 +11,6 @@ description: >-
`polaris-dashboard` service in the `polaris` namespace.
license: Apache-2.0
homeURL: "https://github.com/privilegedescalation/headlamp-polaris-plugin"
appVersion: "10.1.6"
category: security
keywords:
- polaris
@@ -25,52 +24,11 @@ links:
url: "https://github.com/privilegedescalation/headlamp-polaris-plugin"
- name: Polaris
url: "https://polaris.docs.fairwinds.com/"
install: |
## Installation
### Prerequisites
1. [Headlamp](https://headlamp.dev) v0.26.0 or later
2. [Fairwinds Polaris](https://polaris.docs.fairwinds.com/) installed and the dashboard running in your cluster
### Install via Headlamp Plugin Catalog
1. Open Headlamp and navigate to **Settings → Plugin Catalog**
2. Search for **"Polaris"**
3. Click **Install** and restart Headlamp when prompted
The plugin is sourced directly from [ArtifactHub](https://artifacthub.io/packages/headlamp/headlamp/headlamp-polaris).
## Usage
After installation, the Polaris plugin adds:
- A **cluster score badge** in the Headlamp app bar
- A **Polaris** section in the sidebar with the full dashboard and namespace drill-downs
- An **inline audit panel** on Deployment, StatefulSet, DaemonSet, Job, and CronJob detail pages
For more information, see the [README](https://github.com/privilegedescalation/headlamp-polaris-plugin/blob/main/README.md).
changes:
- kind: security
description: Patched 8 npm audit vulnerabilities via pnpm.overrides
- kind: added
description: Dual-approval required CI check — PRs must be approved by both CTO and QA before merge
- kind: added
description: ExemptionManager test suite — full coverage of annotation-based exemption flows
- kind: fixed
description: E2E infrastructure overhauled — ConfigMap volume mount replaces Dockerfile-based approach, tests run in privilegedescalation-dev namespace
- kind: fixed
description: E2E workflow uses token auth and waits for HTTP reachability before running tests
- kind: fixed
description: Added explicit direct devDependencies (typescript, eslint, prettier, @headlamp-k8s/eslint-config) to prevent phantom dep failures
- kind: changed
description: pnpm version pinned via packageManager field; GitHub Actions SHA-pinned via Renovate pinDigests
- kind: changed
description: v1.0.0 stable release — plugin API (routes, sidebar, settings schema, app bar action) is stable and will not change without a major version bump
maintainers:
- name: privilegedescalation
email: "chris@farhood.org"
annotations:
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/download/v1.0.0/headlamp-polaris-1.0.0.tar.gz"
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/download/v0.7.2/headlamp-polaris-0.7.2.tar.gz"
headlamp/plugin/version-compat: ">=0.26"
headlamp/plugin/archive-checksum: sha256:a165e871b40f11a44950aa9f10eb7f7883276f749026ae7a4f886278ecd9bd7d
headlamp/plugin/distro-compat: "in-cluster,web,desktop"
headlamp/plugin/archive-checksum: sha256:ce75449a05d3d3dd3c546db36a2257fae3e4601e466108182e64310a1a4f6d71
headlamp/plugin/distro-compat: in-cluster
+31 -46
View File
@@ -1,72 +1,57 @@
---
# RBAC for the GitHub Actions CI runner to manage the E2E Headlamp instance.
# 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 the headlamp-dev
# namespace to deploy and tear down a dedicated Headlamp instance via Helm.
# E2E resources run in `headlamp-dev` — nothing persists beyond a test run.
# 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.
#
# Plugin is loaded via ConfigMap volume mount — no custom Docker images.
# No cluster-scoped permissions needed — the E2E workflow uses kubectl patch
# instead of helm upgrade, avoiding the need to read ClusterRole/ClusterRoleBinding.
#
# Note: This RBAC is mirrored in privilegedescalation/infra (base/rbac/)
# and managed by Flux GitOps. The infra repo is the source of truth.
# 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: headlamp-dev
namespace: kube-system
rules:
# Helm needs to manage these resources for the Headlamp chart
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "create", "update", "patch", "delete", "watch"]
- apiGroups: [""]
resources: ["services", "serviceaccounts", "configmaps", "secrets", "events"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
resources: ["persistentvolumeclaims"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
# Token creation for E2E test auth
verbs: ["get", "list", "create", "delete", "watch"]
- apiGroups: [""]
resources: ["serviceaccounts/token"]
verbs: ["create"]
# Apply Polaris dashboard RBAC in the polaris namespace
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["roles", "rolebindings"]
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"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: e2e-ci-runner-polaris
namespace: polaris
rules:
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["roles", "rolebindings"]
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: e2e-ci-runner-polaris
namespace: polaris
subjects:
- kind: ServiceAccount
name: runners-privilegedescalation-gha-rs-no-permission
namespace: arc-runners
roleRef:
kind: Role
name: e2e-ci-runner-polaris
apiGroup: rbac.authorization.k8s.io
- 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: headlamp-dev
namespace: kube-system
subjects:
- kind: ServiceAccount
name: runners-privilegedescalation-gha-rs-no-permission
name: local-ubuntu-latest-gha-rs-no-permission
namespace: arc-runners
roleRef:
kind: Role
+22
View File
@@ -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
+14
View File
@@ -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
+24 -33
View File
@@ -4,16 +4,7 @@ Playwright-based smoke tests that validate the Polaris plugin against a live Hea
## CI
E2E tests run automatically in GitHub Actions on pushes to `main` and pull requests. The workflow (`.github/workflows/e2e.yaml`):
1. Builds the plugin (`npm run build`)
2. Creates a ConfigMap from the built `dist/` output
3. Deploys a stock Headlamp instance via Helm with the plugin mounted as a ConfigMap volume
4. Generates a ServiceAccount token for test auth
5. Runs Playwright tests against the E2E instance
6. Tears down the E2E instance
This approach uses the stock `ghcr.io/headlamp-k8s/headlamp` image with no custom Docker builds. The plugin is loaded via `HEADLAMP_PLUGINS_DIR` volume mount.
E2E tests run automatically in GitHub Actions on pushes to `main` and pull requests. The workflow (`.github/workflows/e2e.yaml`) uses either Authentik OIDC or token-based authentication via repository secrets.
### Required GitHub Secrets
@@ -21,12 +12,12 @@ Configure these in GitHub repository settings (Settings → Secrets and variable
| Secret | Required | Description |
| -------------------- | -------- | -------------------------------------------------------------- |
| `HEADLAMP_URL` | Optional | Headlamp instance URL (defaults to `https://headlamp.animaniacs.farh.net`) |
| `AUTHENTIK_USERNAME` | OIDC | Authentik email or username for a CI user with Headlamp access |
| `AUTHENTIK_PASSWORD` | OIDC | Password for that user |
| `HEADLAMP_TOKEN` | Token | Kubernetes service account token (alternative to OIDC) |
Token-based auth is auto-generated by the deploy script. OIDC secrets are only needed if testing against the shared Headlamp instance.
No `GHCR_TOKEN` or Docker registry secrets are needed — the stock Headlamp image is public.
Set either `AUTHENTIK_USERNAME` + `AUTHENTIK_PASSWORD` **or** `HEADLAMP_TOKEN`. OIDC takes priority if both are set.
## Running Locally
@@ -56,12 +47,12 @@ HEADLAMP_URL=http://localhost:4466 npm run e2e:headed
| Variable | Required | Default | Description |
| -------------------- | -------- | -------------------------------------- | --------------------------------------- |
| `HEADLAMP_URL` | No | `https://headlamp.animaniacs.farh.net` | Base URL of the Headlamp instance |
| `AUTHENTIK_USERNAME` | OIDC | — | Authentik email/username |
| `AUTHENTIK_PASSWORD` | OIDC | — | Authentik password |
| `HEADLAMP_TOKEN` | Token | — | Kubernetes bearer token (auto-generated in CI) |
| `HEADLAMP_URL` | No | `https://headlamp.animaniacs.farh.net` | Base URL of the Headlamp instance |
| `AUTHENTIK_USERNAME` | OIDC | — | Authentik email/username |
| `AUTHENTIK_PASSWORD` | OIDC | — | Authentik password |
| `HEADLAMP_TOKEN` | Token | — | Kubernetes bearer token (fallback auth) |
In CI, `HEADLAMP_URL` and `HEADLAMP_TOKEN` are set automatically by the deploy script. For local runs, set either OIDC credentials or a token manually.
Set either `AUTHENTIK_USERNAME` + `AUTHENTIK_PASSWORD` or `HEADLAMP_TOKEN`. OIDC takes priority if both are set.
## What the Tests Validate
@@ -258,25 +249,25 @@ test('plugin UI adapts to dark mode', async ({ page }) => {
Tests run automatically in GitHub Actions on pushes to `main` and pull requests. See `.github/workflows/e2e.yaml` for workflow configuration.
### Architecture
### Required Secrets
The E2E workflow deploys a **dedicated Headlamp instance** for each test run:
Configure these in GitHub repository settings (Settings → Secrets and variables → Actions):
1. Build plugin (`npm run build`)
2. Create ConfigMap from `dist/` output (`scripts/deploy-e2e-headlamp.sh`)
3. Deploy stock Headlamp via Helm with ConfigMap volume mount
4. Run Playwright tests against the E2E instance
5. Tear down (`scripts/teardown-e2e-headlamp.sh`)
- `HEADLAMP_URL` (optional): Headlamp instance URL
- `AUTHENTIK_USERNAME` + `AUTHENTIK_PASSWORD` (for OIDC auth)
- OR `HEADLAMP_TOKEN` (for token-based auth)
No custom Docker images, no PVCs, no kubectl exec/cp, no patching of existing deployments. The plugin is mounted from a ConfigMap into the stock Headlamp image.
### Workflow Overview
### Cluster Prerequisites
One-time setup by a cluster admin:
```bash
kubectl apply -f deployment/e2e-ci-runner-rbac.yaml
```
1. Checkout code
2. Setup Node.js 20 with npm cache
3. Install dependencies (`npm ci`)
4. Install Playwright browsers (`chromium` only)
5. Run auth setup (creates session in `e2e/.auth/state.json`)
6. Run all E2E tests
7. Upload artifacts on failure:
- `playwright-report/` - HTML test report
- `test-results/` - Screenshots, traces, videos
### Manual Trigger
+5 -12
View File
@@ -39,20 +39,13 @@ async function authenticateWithOIDC(page: Page, username: string, password: stri
}
async function authenticateWithToken(page: Page, token: string): Promise<void> {
// Navigate to login — Headlamp redirects / to /c/main/login
await page.goto('/');
// Headlamp goes to /token directly when no OIDC is configured,
// or through /login when OIDC is configured
await page.waitForURL(/\/(login|token)$/);
await page.waitForURL('**/login');
if (page.url().includes('/login')) {
// OIDC login page — click "use a token" to reach token auth.
// Wait explicitly before clicking so failures surface at 15 s
// with a clear message rather than silently timing out at 60 s.
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');
}
// Click the token auth option
await page.getByRole('button', { name: /use a token/i }).click();
await page.waitForURL('**/token');
// Fill the "ID token" field and submit
await page.getByRole('textbox', { name: /id token/i }).fill(token);
+2 -91
View File
@@ -1,12 +1,12 @@
{
"name": "headlamp-polaris",
"version": "1.0.0",
"version": "0.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "headlamp-polaris",
"version": "1.0.0",
"version": "0.7.2",
"license": "Apache-2.0",
"devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0",
@@ -17,13 +17,11 @@
"@testing-library/user-event": "^14.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitest/coverage-v8": "^3.2.4",
"jsdom": "^24.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^5.3.0",
"tar": "^7.5.11",
"typescript": "~5.6.2",
"undici": "^7.24.3",
"vitest": "^3.0.5"
},
@@ -39,20 +37,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "11.7.2",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.2.tgz",
@@ -447,16 +431,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@bundled-es-modules/cookie": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz",
@@ -4872,40 +4846,6 @@
"vitest": "3.2.4"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
"integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"@bcoe/v8-coverage": "^1.0.2",
"ast-v8-to-istanbul": "^0.3.3",
"debug": "^4.4.1",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.6",
"istanbul-reports": "^3.1.7",
"magic-string": "^0.30.17",
"magicast": "^0.3.5",
"std-env": "^3.9.0",
"test-exclude": "^7.0.1",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "3.2.4",
"vitest": "3.2.4"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -5711,35 +5651,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/ast-v8-to-istanbul": {
"version": "0.3.12",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
"integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+4 -17
View File
@@ -1,6 +1,6 @@
{
"name": "headlamp-polaris",
"version": "1.0.0",
"version": "0.7.2",
"description": "Headlamp plugin for Fairwinds Polaris audit results",
"repository": {
"type": "git",
@@ -12,7 +12,6 @@
"homepage": "https://github.com/privilegedescalation/headlamp-polaris-plugin#readme",
"author": "privilegedescalation",
"license": "Apache-2.0",
"packageManager": "pnpm@10.32.1",
"scripts": {
"start": "headlamp-plugin start",
"build": "headlamp-plugin build",
@@ -31,16 +30,9 @@
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"pnpm": {
"overrides": {
"tar": "^7.5.11",
"undici": "^7.24.3",
"flatted": "^3.4.2",
"lodash": ">=4.18.0",
"picomatch": ">=4.0.4",
"vite": ">=6.4.2",
"elliptic": ">=6.6.1"
}
"overrides": {
"tar": "^7.5.11",
"undici": "^7.24.3"
},
"devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0",
@@ -51,16 +43,11 @@
"@testing-library/user-event": "^14.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitest/coverage-v8": "^3.2.4",
"@headlamp-k8s/eslint-config": "^0.6.0",
"eslint": "^8.57.0",
"jsdom": "^24.0.0",
"prettier": "^2.8.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^5.3.0",
"tar": "^7.5.11",
"typescript": "~5.6.2",
"undici": "^7.24.3",
"vitest": "^3.0.5"
}
+162 -623
View File
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -1,5 +1,19 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>privilegedescalation/.github:renovate-config"]
"extends": ["config:recommended"],
"baseBranches": ["main"],
"schedule": ["every weekend"],
"prConcurrentLimit": 10,
"packageRules": [
{
"matchManagers": ["npm"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "npm minor and patch"
},
{
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "github-actions minor and patch"
}
]
}
-210
View File
@@ -1,210 +0,0 @@
#!/usr/bin/env bash
# deploy-e2e-headlamp.sh
#
# Deploys a stock Headlamp instance with the polaris plugin loaded via
# a ConfigMap volume mount. No custom Docker images — the plugin is built
# in CI and injected as a ConfigMap.
#
# E2E resources are deployed to the `headlamp-dev` namespace. Nothing
# persists beyond a test run — teardown cleans up all created resources.
#
# Prerequisites:
# - Plugin built (dist/ exists with plugin-main.js + package.json)
# - kubectl configured with cluster access
# - RBAC applied (managed by Flux GitOps in privilegedescalation/infra)
#
# 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: v0.40.1, pinned to match production)
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:-v0.40.1}"
if [ ! -d "$DIST_DIR" ]; then
echo "ERROR: dist/ not found. Run 'npm run build' first." >&2
exit 1
fi
# --- Preflight: verify RBAC before touching the cluster ---
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
echo " Apply RBAC first: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml" >&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"
# --- Create ConfigMap from built plugin ---
echo ""
echo "Creating ConfigMap with plugin files..."
# Delete existing ConfigMap if present (idempotent redeploy)
kubectl delete configmap headlamp-polaris-plugin \
-n "$E2E_NAMESPACE" --ignore-not-found
# Create ConfigMap from dist/ contents and package.json
kubectl create configmap headlamp-polaris-plugin \
-n "$E2E_NAMESPACE" \
--from-file="$DIST_DIR" \
--from-file=package.json="$REPO_ROOT/package.json"
# --- Tear down any existing E2E deployment for a clean start ---
# kubectl apply without prior deletion only patches in-place: if the pod spec is
# unchanged between runs, no new rollout is triggered and a degraded pod keeps
# serving. Delete first to guarantee a fresh pod regardless of prior state.
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
# --- Deploy Headlamp via kubectl apply ---
echo ""
echo "Deploying Headlamp E2E instance..."
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: polaris-plugin
mountPath: /headlamp/plugins/headlamp-polaris
readOnly: true
volumes:
- name: polaris-plugin
configMap:
name: headlamp-polaris-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
echo "Waiting for rollout..."
kubectl rollout status "deployment/${E2E_RELEASE}" \
-n "$E2E_NAMESPACE" --timeout=120s
# --- Generate a service URL for tests ---
SVC_URL="http://${E2E_RELEASE}.${E2E_NAMESPACE}.svc.cluster.local"
# --- Wait for DNS and HTTP reachability ---
# rollout status only confirms the pod is ready per readinessProbe.
# Kubernetes Service DNS may still be propagating to the runner pod.
# Poll until the service is reachable over HTTP before handing off.
echo ""
echo "Waiting for ${SVC_URL} to be reachable..."
ATTEMPTS=0
MAX_ATTEMPTS=24 # 24 × 5s = 120s max
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 " export HEADLAMP_URL=${SVC_URL}"
# --- Generate a token for test auth ---
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 " export HEADLAMP_TOKEN=<generated>"
echo ""
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. Set HEADLAMP_TOKEN manually or use OIDC."
fi
echo ""
echo "E2E deployment complete."
+135
View File
@@ -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."
-34
View File
@@ -1,34 +0,0 @@
#!/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-polaris-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
# Clean up local env file
rm -f "$REPO_ROOT/.env.e2e"
echo "Teardown complete."
-432
View File
@@ -1,432 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { makeResult } from '../test-utils';
const { mockApiRequest } = vi.hoisted(() => ({ mockApiRequest: vi.fn() }));
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: { request: mockApiRequest },
}));
vi.mock('@mui/material/styles', () => ({
useTheme: () => ({
palette: {
primary: { main: '#1976d2', contrastText: '#fff' },
action: { disabledBackground: '#e0e0e0', disabled: '#9e9e9e' },
divider: '#e0e0e0',
},
}),
}));
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
<div data-testid="section-box" data-title={title}>
{children}
</div>
),
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
<span data-testid="status-label" data-status={status}>
{children}
</span>
),
Dialog: ({
open,
children,
title,
}: {
open: boolean;
onClose?: () => void;
title?: string;
children?: React.ReactNode;
}) =>
open ? (
<div data-testid="dialog" data-title={title}>
{children}
</div>
) : null,
}));
import ExemptionManager from './ExemptionManager';
const defaultProps = {
workloadResult: makeResult(),
namespace: 'default',
kind: 'Deployment',
name: 'my-deploy',
};
const resultWithPodFailures = makeResult({
PodResult: {
Name: 'pod',
Results: {
hostIPCSet: {
ID: 'hostIPCSet',
Message: 'Host IPC is set',
Details: [],
Success: false,
Severity: 'danger',
Category: 'Security',
},
hostPIDSet: {
ID: 'hostPIDSet',
Message: 'Host PID is set',
Details: [],
Success: false,
Severity: 'danger',
Category: 'Security',
},
},
ContainerResults: [],
},
});
const resultWithContainerFailures = makeResult({
PodResult: {
Name: 'pod',
Results: {},
ContainerResults: [
{
Name: 'container-1',
Results: {
cpuRequestsMissing: {
ID: 'cpuRequestsMissing',
Message: 'CPU requests missing',
Details: [],
Success: false,
Severity: 'warning',
Category: 'Efficiency',
},
},
},
],
},
});
const resultWithIgnoredFailures = makeResult({
PodResult: {
Name: 'pod',
Results: {
hostIPCSet: {
ID: 'hostIPCSet',
Message: '',
Details: [],
Success: false,
Severity: 'ignore',
Category: 'Security',
},
},
ContainerResults: [],
},
});
describe('ExemptionManager', () => {
describe('rendering failing checks', () => {
it('shows disabled Add Exemption button when no failing checks', () => {
render(<ExemptionManager {...defaultProps} />);
const btn = screen.getByRole('button', { name: /add exemption/i });
expect(btn).toBeDisabled();
});
it('shows enabled Add Exemption button when there are failing checks', () => {
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
const btn = screen.getByRole('button', { name: /add exemption/i });
expect(btn).not.toBeDisabled();
});
it('does not include ignored-severity checks as failing', () => {
render(<ExemptionManager {...defaultProps} workloadResult={resultWithIgnoredFailures} />);
const btn = screen.getByRole('button', { name: /add exemption/i });
expect(btn).toBeDisabled();
});
it('collects failing checks from pod-level results', () => {
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
expect(screen.getByText('Host IPC')).toBeInTheDocument();
expect(screen.getByText('Host PID')).toBeInTheDocument();
});
it('collects failing checks from container-level results', () => {
render(<ExemptionManager {...defaultProps} workloadResult={resultWithContainerFailures} />);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
expect(screen.getByText('CPU Requests')).toBeInTheDocument();
});
it('deduplicates checks that appear in multiple containers', () => {
const resultWithDuplicate = makeResult({
PodResult: {
Name: 'pod',
Results: {},
ContainerResults: [
{
Name: 'container-1',
Results: {
cpuRequestsMissing: {
ID: 'cpuRequestsMissing',
Message: '',
Details: [],
Success: false,
Severity: 'warning',
Category: 'Efficiency',
},
},
},
{
Name: 'container-2',
Results: {
cpuRequestsMissing: {
ID: 'cpuRequestsMissing',
Message: '',
Details: [],
Success: false,
Severity: 'warning',
Category: 'Efficiency',
},
},
},
],
},
});
render(<ExemptionManager {...defaultProps} workloadResult={resultWithDuplicate} />);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
const items = screen.getAllByText('CPU Requests');
expect(items).toHaveLength(1);
});
});
describe('dialog interactions', () => {
it('opens dialog when Add Exemption button is clicked', () => {
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
expect(screen.getByTestId('dialog')).toBeInTheDocument();
});
it('closes dialog when Cancel button is clicked', () => {
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
expect(screen.getByTestId('dialog')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument();
});
it('toggles individual check selection', () => {
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
// Find the checkbox next to "Host IPC"
const checkboxes = screen.getAllByRole('checkbox');
// First checkbox is "Exempt from all checks", rest are individual checks
const hostIPCCheckbox = checkboxes[1];
expect(hostIPCCheckbox).not.toBeChecked();
fireEvent.click(hostIPCCheckbox);
expect(hostIPCCheckbox).toBeChecked();
fireEvent.click(hostIPCCheckbox);
expect(hostIPCCheckbox).not.toBeChecked();
});
it('hides individual checks list when exempt-all is toggled', () => {
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
expect(screen.getByText('Host IPC')).toBeInTheDocument();
const exemptAllCheckbox = screen.getByRole('checkbox', { name: /exempt from all checks/i });
fireEvent.click(exemptAllCheckbox);
expect(screen.queryByText('Host IPC')).not.toBeInTheDocument();
});
it('Apply button is disabled when no checks selected and exemptAll is false', () => {
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
expect(screen.getByRole('button', { name: /apply/i })).toBeDisabled();
});
it('Apply button is enabled when exemptAll is checked', () => {
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
const exemptAllCheckbox = screen.getByRole('checkbox', { name: /exempt from all checks/i });
fireEvent.click(exemptAllCheckbox);
expect(screen.getByRole('button', { name: /apply/i })).not.toBeDisabled();
});
it('Apply button is enabled when at least one individual check is selected', () => {
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
const checkboxes = screen.getAllByRole('checkbox');
fireEvent.click(checkboxes[1]); // select first individual check
expect(screen.getByRole('button', { name: /apply/i })).not.toBeDisabled();
});
});
describe('ApiProxy.request calls', () => {
it('patches with exempt-all annotation when exemptAll is selected', async () => {
mockApiRequest.mockResolvedValue({});
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
await waitFor(() => {
expect(mockApiRequest).toHaveBeenCalledWith(
'/apis/apps/v1/namespaces/default/deployments/my-deploy',
expect.objectContaining({
method: 'PATCH',
headers: { 'Content-Type': 'application/strategic-merge-patch+json' },
body: JSON.stringify({
metadata: {
annotations: { 'polaris.fairwinds.com/exempt': 'true' },
},
}),
})
);
});
});
it('patches with per-check annotations when individual checks selected', async () => {
mockApiRequest.mockResolvedValue({});
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
// Select first check (hostIPCSet)
fireEvent.click(screen.getAllByRole('checkbox')[1]);
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
await waitFor(() => {
expect(mockApiRequest).toHaveBeenCalledWith(
'/apis/apps/v1/namespaces/default/deployments/my-deploy',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({
metadata: {
annotations: { 'polaris.fairwinds.com/hostIPCSet-exempt': 'true' },
},
}),
})
);
});
});
it('uses core API path for Pod kind (no api group)', async () => {
mockApiRequest.mockResolvedValue({});
render(
<ExemptionManager {...defaultProps} kind="Pod" workloadResult={resultWithPodFailures} />
);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
await waitFor(() => {
expect(mockApiRequest).toHaveBeenCalledWith(
'/api/v1/namespaces/default/pods/my-deploy',
expect.anything()
);
});
});
it('uses batch API group for Job kind', async () => {
mockApiRequest.mockResolvedValue({});
render(
<ExemptionManager {...defaultProps} kind="Job" workloadResult={resultWithPodFailures} />
);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
await waitFor(() => {
expect(mockApiRequest).toHaveBeenCalledWith(
'/apis/batch/v1/namespaces/default/jobs/my-deploy',
expect.anything()
);
});
});
it('uses batch API group for CronJob kind', async () => {
mockApiRequest.mockResolvedValue({});
render(
<ExemptionManager {...defaultProps} kind="CronJob" workloadResult={resultWithPodFailures} />
);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
await waitFor(() => {
expect(mockApiRequest).toHaveBeenCalledWith(
'/apis/batch/v1/namespaces/default/cronjobs/my-deploy',
expect.anything()
);
});
});
it('uses apps API group for StatefulSet kind', async () => {
mockApiRequest.mockResolvedValue({});
render(
<ExemptionManager
{...defaultProps}
kind="StatefulSet"
workloadResult={resultWithPodFailures}
/>
);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
await waitFor(() => {
expect(mockApiRequest).toHaveBeenCalledWith(
'/apis/apps/v1/namespaces/default/statefulsets/my-deploy',
expect.anything()
);
});
});
});
describe('feedback states', () => {
it('shows success feedback and closes dialog after successful apply', async () => {
mockApiRequest.mockResolvedValue({});
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
await waitFor(() => {
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument();
const label = screen.getByTestId('status-label');
expect(label).toHaveAttribute('data-status', 'success');
expect(label).toHaveTextContent('Exemptions applied successfully');
});
});
it('shows error feedback and keeps dialog closed after failed apply', async () => {
mockApiRequest.mockRejectedValue(new Error('403 Forbidden'));
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
await waitFor(() => {
const label = screen.getByTestId('status-label');
expect(label).toHaveAttribute('data-status', 'error');
expect(label).toHaveTextContent(/failed to apply exemptions/i);
});
});
it('shows "Applying..." text on Apply button while in-flight', async () => {
let resolveRequest!: () => void;
mockApiRequest.mockReturnValue(
new Promise<void>(res => {
resolveRequest = res;
})
);
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
expect(screen.getByRole('button', { name: /applying/i })).toBeInTheDocument();
resolveRequest();
await waitFor(() => {
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument();
});
});
});
});
-11
View File
@@ -9,16 +9,5 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
exclude: ['e2e/**', 'node_modules/**'],
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.test.{ts,tsx}', 'src/test-utils.tsx', 'src/index.tsx'],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});