Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot] 515317dcb2 release: v0.7.2 2026-03-19 21:16:38 +00:00
48 changed files with 20152 additions and 2047 deletions
+3 -214
View File
@@ -2,223 +2,12 @@ name: CI
on:
push:
branches: ['**']
branches: [main]
pull_request:
branches: [main, dev, uat]
branches: [main]
workflow_dispatch:
inputs:
node-version:
description: 'Node.js version to use'
required: false
type: string
default: '22'
workflow_call:
inputs:
node-version:
description: 'Node.js version to use'
required: false
type: string
default: '22'
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
timeout-minutes: 10
container: node:22-slim
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install Python
run: apt-get update && apt-get install -y --no-install-recommends python3 python3-yaml
- name: Validate artifacthub-pkg.yml
run: |
python3 - <<'EOF'
import sys, re
try:
import yaml
except ImportError:
print("::warning::PyYAML not available, skipping artifacthub-pkg.yml validation")
sys.exit(0)
try:
with open("artifacthub-pkg.yml") as f:
pkg = yaml.safe_load(f)
except FileNotFoundError:
print("::error::artifacthub-pkg.yml not found")
sys.exit(1)
except yaml.YAMLError as e:
print(f"::error::artifacthub-pkg.yml is invalid YAML: {e}")
sys.exit(1)
errors = []
for field in ["version", "name", "description", "homeURL"]:
if not pkg.get(field):
errors.append(f"Missing required field: {field}")
version = pkg.get("version", "")
if version and not re.match(r'^\d+\.\d+\.\d+$', str(version)):
errors.append(f"version '{version}' is not SemVer (expected X.Y.Z)")
annotations = pkg.get("annotations", {}) or {}
archive_url = annotations.get("headlamp/plugin/archive-url", "")
archive_checksum = annotations.get("headlamp/plugin/archive-checksum", "")
if not archive_url:
errors.append("Missing annotation: headlamp/plugin/archive-url")
if not archive_checksum:
errors.append("Missing annotation: headlamp/plugin/archive-checksum")
elif not re.match(r'^sha256:[0-9a-f]{64}$', str(archive_checksum)):
errors.append(f"archive-checksum has unexpected format: '{archive_checksum}' (expected sha256:<64 hex chars>)")
if errors:
for e in errors:
print(f"::error::{e}")
sys.exit(1)
print(f"artifacthub-pkg.yml valid: name={pkg['name']} version={pkg['version']}")
EOF
- name: Detect package manager
id: pkg-manager
run: |
if [ -f "pnpm-lock.yaml" ]; then
echo "manager=pnpm" >> $GITHUB_OUTPUT
PM=$(python3 -c "import json,sys; d=json.load(open('package.json')); print('true' if d.get('packageManager','').startswith('pnpm@') else 'false')" 2>/dev/null || echo "false")
echo "has_package_manager=$PM" >> $GITHUB_OUTPUT
else
echo "manager=npm" >> $GITHUB_OUTPUT
echo "has_package_manager=false" >> $GITHUB_OUTPUT
fi
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version || '22' }}
cache: ${{ steps.pkg-manager.outputs.manager == 'npm' && 'npm' || '' }}
- name: Setup pnpm (via Corepack, reads version from packageManager field)
if: steps.pkg-manager.outputs.manager == 'pnpm' && steps.pkg-manager.outputs.has_package_manager == 'true'
run: |
npm install -g corepack
corepack enable pnpm
corepack install
- name: Setup pnpm (version latest)
if: steps.pkg-manager.outputs.manager == 'pnpm' && steps.pkg-manager.outputs.has_package_manager == 'false'
uses: pnpm/action-setup@v5
with:
run_install: false
version: latest
- name: Get pnpm store directory
id: pnpm-store
if: steps.pkg-manager.outputs.manager == 'pnpm'
run: echo "dir=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
if: steps.pkg-manager.outputs.manager == 'pnpm'
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-store.outputs.dir }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Validate pnpm lockfile freshness
if: steps.pkg-manager.outputs.manager == 'pnpm'
run: |
if [ ! -f "pnpm-lock.yaml" ]; then
echo "No pnpm-lock.yaml found, skipping lockfile freshness check"
exit 0
fi
if ! grep -q 'overrides:' pnpm-lock.yaml 2>/dev/null; then
echo "No overrides section in pnpm-lock.yaml, skipping lockfile freshness check"
exit 0
fi
echo "Detected pnpm-lock.yaml with overrides section. Checking lockfile freshness..."
ERR_FILE=$(mktemp)
if pnpm install --frozen-lockfile 2>&1 | tee "$ERR_FILE"; then
echo "Lockfile is fresh."
else
if grep -q "CONFIG_MISMATCH\|EBADLOCKFILE\|ERR_PNPM_LOCKFILE" "$ERR_FILE"; then
echo ""
echo "::error::pnpm-lock.yaml is out of sync with package.json overrides."
echo "::error::Run 'pnpm install' to regenerate the lockfile and commit the updated pnpm-lock.yaml."
rm -f "$ERR_FILE"
exit 1
fi
rm -f "$ERR_FILE"
echo "::warning::Install failed with a different error. Will retry in the Install dependencies step."
fi
- name: Install dependencies
run: |
max_attempts=3
attempt=1
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt of $max_attempts"
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
pnpm install --frozen-lockfile && break
else
npm ci && break
fi
if [ $attempt -lt $max_attempts ]; then
echo "::warning::Install step failed on attempt $attempt. Retrying in 5 seconds..."
sleep 5
fi
attempt=$((attempt + 1))
done
if [ $attempt -gt $max_attempts ]; then
echo "::error::Install step failed after $max_attempts attempts."
exit 1
fi
- name: Build plugin
run: npx @kinvolk/headlamp-plugin build
- name: Lint
run: |
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
pnpm run lint
else
npm run lint
fi
- name: Type-check
run: |
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
pnpm run tsc
else
npm run tsc
fi
- name: Format check
run: |
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
pnpm run format:check
else
npm run format:check
fi
- name: Run tests
run: |
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
pnpm test
else
npm test
fi
- name: Security audit
run: |
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
npx audit-ci --pnpm --audit-level=high --config ./audit-ci.jsonc
else
npx audit-ci --npm --audit-level=high --config ./audit-ci.jsonc
fi
uses: privilegedescalation/.github/.github/workflows/plugin-ci.yaml@main
-113
View File
@@ -1,113 +0,0 @@
name: Promotion Gate
# dev PRs: no gate (engineer self-merges).
# uat PRs: QA approval required.
# main PRs: UAT approval required (uat→main promotions).
on:
pull_request_review:
types: [submitted, dismissed]
pull_request:
branches: [uat, main]
types: [opened, reopened, synchronize]
jobs:
promotion-gate:
name: Promotion Gate
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check promotion approval
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
BASE_REF: ${{ github.base_ref }}
run: |
if [ -z "${PR_NUMBER}" ] || [ "${PR_NUMBER}" = "null" ]; then
echo "::notice::No PR number in context. Skipping promotion gate."
exit 0
fi
echo "Checking promotion gate for PR #${PR_NUMBER} targeting ${BASE_REF} in ${REPO}"
if [ -z "${BASE_REF}" ] && [ -n "${PR_NUMBER}" ] && [ "${PR_NUMBER}" != "null" ]; then
BASE_REF=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Accept: application/json" \
"https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}" | jq -r '.base.ref')
echo "BASE_REF was empty; resolved from PR #${PR_NUMBER} API: ${BASE_REF}"
fi
# Determine required reviewer based on target branch
case "${BASE_REF}" in
dev)
echo "Target is dev — no review required. Engineers self-merge."
exit 0
;;
uat)
REQUIRED_REVIEWER="pe_regina"
GATE_NAME="QA"
;;
main)
REQUIRED_REVIEWER="pe_regina"
GATE_NAME="QA"
# For plugin repos (Pipeline A), UAT approval is needed for uat→main
# Check if the source branch is uat
SOURCE_REF=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Accept: application/json" \
"https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}" | jq -r '.head.ref')
if [ "${SOURCE_REF}" = "uat" ]; then
REQUIRED_REVIEWER="pe_patty"
GATE_NAME="UAT"
fi
;;
*)
echo "::notice::Target branch '${BASE_REF}' has no promotion gate configured."
exit 0
;;
esac
echo "Required reviewer: ${REQUIRED_REVIEWER} (${GATE_NAME})"
# For uat→main promotions, pe_patty may not be able to review (bot account).
# Accept pe_nancy (CTO) as a valid alternative reviewer.
ALT_REVIEWER=""
if [ "${REQUIRED_REVIEWER}" = "pe_patty" ]; then
ALT_REVIEWER="pe_nancy"
fi
REVIEWS=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Accept: application/json" \
"https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}/reviews" \
| python3 -c 'import sys,json; json.dump(json.load(sys.stdin),sys.stdout)')
if [ -z "${REVIEWS}" ] || [ "${REVIEWS}" = "null" ]; then
echo "::warning::Could not fetch reviews for PR #${PR_NUMBER}."
exit 1
fi
REVIEWER_APPROVED=$(echo "${REVIEWS}" | jq -r --arg user "${REQUIRED_REVIEWER}" \
'[.[] | select(.user.login == $user)] | last | if .state then .state == "APPROVED" else false end')
echo "${GATE_NAME} (${REQUIRED_REVIEWER}) approved: ${REVIEWER_APPROVED}"
# Fallback: check if CTO approved as alternative for uat→main
if [ "${REVIEWER_APPROVED}" != "true" ] && [ -n "${ALT_REVIEWER}" ]; then
REVIEWER_APPROVED=$(echo "${REVIEWS}" | jq -r --arg user "${ALT_REVIEWER}" \
'[.[] | select(.user.login == $user)] | last | if .state then .state == "APPROVED" else false end')
if [ "${REVIEWER_APPROVED}" = "true" ]; then
echo "CTO (${ALT_REVIEWER}) approved as fallback for UAT gate."
fi
fi
if [ "${REVIEWER_APPROVED}" = "true" ]; then
echo "Promotion gate passed: ${GATE_NAME} has approved."
else
echo "Promotion gate failed: waiting for ${GATE_NAME} approval from ${REQUIRED_REVIEWER}."
exit 1
fi
+155
View File
@@ -0,0 +1,155 @@
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
env:
HEADLAMP_NAMESPACE: kube-system
HEADLAMP_DEPLOY: headlamp
jobs:
e2e:
runs-on: local-ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npm run build
- name: Setup kubectl
uses: azure/setup-kubectl@v4
- name: Ensure PVC exists
run: kubectl apply -f deployment/headlamp-plugins-pvc.yaml
- name: Patch Headlamp deployment with shared volume mount
run: |
NS="$HEADLAMP_NAMESPACE"
DEPLOY="$HEADLAMP_DEPLOY"
# Check if the plugins volume and mount already exist (by name or mountPath)
DEPLOY_JSON=$(kubectl get deploy "$DEPLOY" -n "$NS" -o json)
HAS_VOL=$(echo "$DEPLOY_JSON" | \
python3 -c "import sys,json; d=json.load(sys.stdin); vols=d['spec']['template']['spec'].get('volumes',[]); print('yes' if any(v.get('persistentVolumeClaim',{}).get('claimName')=='headlamp-plugins' or v.get('name')=='plugins' for v in vols) else '')")
HAS_MOUNT=$(echo "$DEPLOY_JSON" | \
python3 -c "import sys,json; d=json.load(sys.stdin); mounts=d['spec']['template']['spec']['containers'][0].get('volumeMounts',[]); print('yes' if any(m.get('mountPath')=='/headlamp/plugins' or m.get('name')=='plugins' for m in mounts) else '')")
NEEDS_PATCH=false
if [ -z "$HAS_VOL" ]; then
echo "Adding plugins PVC volume..."
kubectl patch deploy "$DEPLOY" -n "$NS" --type=json -p '[
{"op":"add","path":"/spec/template/spec/volumes/-","value":{
"name":"plugins",
"persistentVolumeClaim":{"claimName":"headlamp-plugins"}
}}
]'
NEEDS_PATCH=true
else
echo "Plugins volume already present, skipping."
fi
if [ -z "$HAS_MOUNT" ]; then
echo "Adding plugins volume mount..."
kubectl patch deploy "$DEPLOY" -n "$NS" --type=json -p '[
{"op":"add","path":"/spec/template/spec/containers/0/volumeMounts/-","value":{
"name":"plugins",
"mountPath":"/headlamp/plugins",
"readOnly":true
}}
]'
NEEDS_PATCH=true
else
echo "Plugins volume mount already present, skipping."
fi
# Set the plugins directory via env var
kubectl set env deploy/"$DEPLOY" -n "$NS" \
HEADLAMP_CONFIG_PLUGIN_DIR=/headlamp/plugins
# Wait for rollout
kubectl rollout status deploy/"$DEPLOY" -n "$NS" --timeout=120s
- name: Deploy plugin via shared volume
run: scripts/deploy-plugin-via-volume.sh
- name: Preflight — verify Headlamp and plugin availability
env:
HEADLAMP_URL: ${{ secrets.HEADLAMP_URL || 'http://headlamp.kube-system.svc.cluster.local' }}
run: |
PLUGIN_NAME=$(node -p "require('./package.json').name")
EXPECTED=$(node -p "require('./package.json').version")
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: ${{ 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@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results
path: test-results/
retention-days: 7
+6 -195
View File
@@ -4,206 +4,17 @@ on:
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g. 1.0.1)'
description: 'Release version (e.g. 1.0.0)'
required: true
type: string
permissions:
contents: write
pull-requests: write
jobs:
check-secrets:
runs-on: runners-privilegedescalation
outputs:
ready: ${{ steps.check.outputs.ready }}
steps:
- name: Verify GITEA_RELEASE_TOKEN is configured
id: check
env:
GITEA_RELEASE_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }}
run: |
if [ -z "$GITEA_RELEASE_TOKEN" ]; then
echo "::notice::GITEA_RELEASE_TOKEN org secret is not configured (see PRI-1533). Release skipped — no artifacts will be created."
echo "ready=false" >> $GITHUB_OUTPUT
else
echo "ready=true" >> $GITHUB_OUTPUT
fi
ci:
needs: check-secrets
if: needs.check-secrets.outputs.ready == 'true'
uses: ./.github/workflows/ci.yaml
with:
node-version: '20'
check-token-permissions:
needs: check-secrets
if: needs.check-secrets.outputs.ready == 'true'
runs-on: runners-privilegedescalation
outputs:
has_write: ${{ steps.check.outputs.has_write }}
steps:
- name: Check write permissions via API
id: check
env:
GITEA_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }}
REPO: ${{ github.repository }}
run: |
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Accept: application/json" \
"https://git.farh.net/api/v1/repos/${REPO}/git/refs" \
-d '{"ref":"refs/heads/_release_check","sha":"${{ github.sha }}"}')
if [ "$HTTP_CODE" = "201" ]; then
echo "::notice::Token has write permission — cleaning up test ref."
curl -sf -o /dev/null -w "%{http_code}" \
-X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"https://git.farh.net/api/v1/repos/${REPO}/git/refs/heads/_release_check"
echo "has_write=true" >> $GITHUB_OUTPUT
elif [ "$HTTP_CODE" = "403" ]; then
echo "::error::Token lacks write permission. Release cannot push tags or branches."
echo "has_write=false" >> $GITHUB_OUTPUT
exit 1
else
echo "::warning::Unexpected response ($HTTP_CODE) when checking write permission."
echo "has_write=false" >> $GITHUB_OUTPUT
exit 1
fi
check-tag:
needs: check-secrets
if: needs.check-secrets.outputs.ready == 'true'
runs-on: runners-privilegedescalation
outputs:
skip: ${{ steps.check.outputs.skip }}
steps:
- name: Check if tag already exists
id: check
env:
GITEA_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }}
REPO: ${{ github.repository }}
run: |
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
-H "Authorization: token ${GITEA_TOKEN}" \
"https://git.farh.net/api/v1/repos/${REPO}/git/refs/tags/v${{ inputs.version }}")
if [ "$HTTP_CODE" = "200" ]; then
echo "::notice::Tag v${{ inputs.version }} already exists. Release skipped (not an error)."
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
release:
needs: [ci, check-tag, check-secrets, check-token-permissions]
if: needs.check-secrets.outputs.ready == 'true' && needs.check-tag.outputs.skip != 'true' && needs.check-token-permissions.outputs.has_write == 'true'
runs-on: runners-privilegedescalation
timeout-minutes: 10
steps:
- name: Validate version format
run: |
if [[ ! "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Version must be in X.Y.Z format"
exit 1
fi
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'pnpm'
- name: Install pnpm
run: npm install -g corepack && corepack enable pnpm && corepack install
- name: Configure Git
env:
GITEA_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }}
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git remote set-url origin "https://x-access-token:${GITEA_TOKEN}@git.farh.net/${{ github.repository }}.git"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Get tarball path
id: tarball
env:
VERSION: ${{ inputs.version }}
run: |
output=$(pnpm run package 2>&1)
echo "output=$output"
tarball_name=$(echo "$output" | grep -oP 'headlamp-polaris-\d+\.\d+\.\d+\.tar\.gz' | tail -1)
echo "tarball_name=$tarball_name" >> $GITHUB_OUTPUT
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Compute checksum
run: |
CHECKSUM=$(sha256sum "${{ steps.tarball.outputs.tarball_name }}" | awk '{print $1}')
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_ENV
echo "Tarball checksum: $CHECKSUM"
- name: Commit and tag
env:
GITEA_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }}
run: |
VERSION="${{ inputs.version }}"
BRANCH="release/v${VERSION}"
if git ls-remote --exit-code origin "refs/heads/$BRANCH" 2>/dev/null; then
echo "::notice::Branch $BRANCH already exists — deleting for clean re-trigger."
git push origin --delete "$BRANCH"
fi
git checkout -b "$BRANCH"
git add package.json pnpm-lock.yaml
git commit -m "release: v${VERSION}"
git tag "v${VERSION}"
git push origin "$BRANCH"
git push origin "refs/tags/v${VERSION}"
- name: Create Gitea Release
env:
GITEA_URL: https://git.farh.net
GITEA_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }}
REPO: ${{ github.repository }}
run: |
VERSION="${{ inputs.version }}"
ASSET_NAME="headlamp-polaris-${VERSION}.tar.gz"
TARBALL="${{ steps.tarball.outputs.tarball_name }}"
RELEASE_RESPONSE=$(
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" \
-d "{
\"tag_name\": \"v${VERSION}\",
\"name\": \"v${VERSION}\",
\"draft\": false,
\"prerelease\": false
}"
)
echo "Release response: ${RELEASE_RESPONSE}"
RELEASE_ID=$(echo "${RELEASE_RESPONSE}" | python3 -c "import sys, json; print(json.load(sys.stdin).get('id', ''))")
if [ -z "$RELEASE_ID" ]; then
echo "Failed to create release"
exit 1
fi
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
-T "$TARBALL" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${ASSET_NAME}"
uses: privilegedescalation/.github/.github/workflows/plugin-release.yaml@main
with:
version: ${{ inputs.version }}
upstream-repo: 'FairwindsOps/polaris'
+3 -1
View File
@@ -2,7 +2,9 @@ node_modules/
dist/
.headlamp-plugin/
*.tar.gz
e2e/.auth/
test-results/
.playwright-mcp/
.env
.env.local
.eslintcache
package-lock.json
-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
+2
View File
@@ -25,6 +25,8 @@ npm run format:check # Prettier check
npm test # vitest run
npm run test:watch # vitest watch mode
npx vitest run src/api/polaris.test.ts # run a single test file
npm run e2e # Playwright E2E tests
npm run e2e:headed # Playwright headed mode
```
All tests and `tsc` must pass before committing.
+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
---
+2 -2
View File
@@ -97,7 +97,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp # adjust to match your Headlamp service account
namespace: <your-namespace>
namespace: kube-system # adjust to match the namespace Headlamp runs in
roleRef:
kind: Role
name: polaris-proxy-reader
@@ -197,7 +197,7 @@ npm test
npm run test:watch
# E2E tests (Playwright)
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n <your-namespace> --duration=24h)
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n kube-system --duration=24h)
npm run e2e
npm run e2e:headed # see browser
```
+4 -4
View File
@@ -71,7 +71,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
namespace: kube-system
roleRef:
kind: Role
name: polaris-proxy-reader
@@ -149,7 +149,7 @@ spec:
### Service Account (Default)
Headlamp runs with a dedicated service account (`headlamp` in the namespace where Headlamp is installed). All users share the same permissions defined by this service account's RBAC bindings.
Headlamp runs with a dedicated service account (`headlamp` in `kube-system`). All users share the same permissions defined by this service account's RBAC bindings.
**Security Considerations:**
- All users have identical access to the plugin
@@ -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
@@ -317,7 +317,7 @@ All service proxy requests are logged in Kubernetes API audit logs (if enabled):
"verb": "get",
"requestURI": "/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json",
"user": {
"username": "system:serviceaccount:<your-namespace>:headlamp",
"username": "system:serviceaccount:kube-system:headlamp",
"groups": ["system:serviceaccounts", "system:authenticated"]
}
}
-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.
+26 -73
View File
@@ -1,81 +1,34 @@
version: 1.0.1
version: "0.7.2"
name: headlamp-polaris
displayName: Polaris
createdAt: '2026-05-20T00:00:00Z'
description: Surfaces Fairwinds Polaris audit results inside the Headlamp UI. Shows
cluster score, check summary, and per-namespace drill-downs with per-resource pass/warning/danger
breakdowns. Data is fetched read-only via the Kubernetes service proxy to the Polaris
dashboard. Requires a Role granting `get` on `services/proxy` for the `polaris-dashboard`
service in the `polaris` namespace.
createdAt: "2026-02-05T19:00:00Z"
description: >-
Surfaces Fairwinds Polaris audit results inside the Headlamp UI.
Shows cluster score, check summary, and per-namespace drill-downs
with per-resource pass/warning/danger breakdowns. Data is fetched
read-only via the Kubernetes service proxy to the Polaris dashboard.
Requires a Role granting `get` on `services/proxy` for the
`polaris-dashboard` service in the `polaris` namespace.
license: Apache-2.0
homeURL: https://github.com/privilegedescalation/headlamp-polaris-plugin
appVersion: 10.1.6
homeURL: "https://github.com/privilegedescalation/headlamp-polaris-plugin"
category: security
keywords:
- polaris
- fairwinds
- security
- audit
- headlamp
- kubernetes
- polaris
- fairwinds
- security
- audit
- headlamp
- kubernetes
links:
- name: Source
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
- name: Source
url: "https://github.com/privilegedescalation/headlamp-polaris-plugin"
- name: Polaris
url: "https://polaris.docs.fairwinds.com/"
maintainers:
- name: privilegedescalation
email: chris@farhood.org
- name: privilegedescalation
email: "chris@farhood.org"
annotations:
headlamp/plugin/archive-url: https://git.farh.net/privilegedescalation/headlamp-polaris-plugin/releases/download/v1.0.1/headlamp-polaris-1.0.1.tar.gz
headlamp/plugin/version-compat: '>=0.26'
headlamp/plugin/archive-checksum: sha256:1e05d079c7032cf55ebde85e116cb65b686d207f4b6a3b0f716f0af93f933e7e
headlamp/plugin/distro-compat: in-cluster,web,desktop
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:ce75449a05d3d3dd3c546db36a2257fae3e4601e466108182e64310a1a4f6d71
headlamp/plugin/distro-compat: in-cluster
-20
View File
@@ -1,20 +0,0 @@
{
// Allowlist for inherited dev-dependency CVEs from @kinvolk/headlamp-plugin
// CTO decision (PRI-854): these high-severity vulns are dev/build-time only,
// trace to @kinvolk/headlamp-plugin transitive deps (Picomatch, Vite, lodash),
// and do NOT ship in production plugin artifacts.
"allowlist": [
{
"id": "GHSA-hhpm-516h-p3p6",
"reason": "Picomatch ReDoS: devDependency only, does not ship in production plugin bundle"
},
{
"id": "GHSA-36xf-7xpp-53w5",
"reason": "Vite arbitrary file read: devDependency only, does not ship in production plugin bundle"
},
{
"id": "GHSA-jf8v-p3pp-93qh",
"reason": "lodash code injection via _.template: devDependency only, does not ship in production plugin bundle"
}
]
}
+58
View File
@@ -0,0 +1,58 @@
# Headlamp Plugin Loading Issue - Root Cause and Fix
## Problem
Headlamp v0.39.0 was not loading plugins installed via the plugin manager. Plugins appeared in Settings → Plugins but:
- No sidebar entries appeared
- No plugin settings were available
- Plugin JavaScript was not being executed in the browser
## Root Cause
When `config.watchPlugins: true` (the default), Headlamp treats catalog-managed plugins in `/headlamp/plugins/` as "development directory" plugins. This causes:
- Backend serves plugin metadata correctly
- Backend logs show "Treating catalog-installed plugin in development directory as user plugin"
- **Frontend does NOT execute the plugin JavaScript**
- Plugin registrations (`registerSidebarEntry`, `registerRoute`, etc.) never happen
## Solution
Set `config.watchPlugins: false` in the Headlamp HelmRelease values:
```yaml
spec:
values:
config:
watchPlugins: false
pluginsManager:
enabled: true
configContent: |
plugins:
- name: polaris
source: https://artifacthub.io/packages/headlamp/polaris/headlamp-polaris-plugin
# ... other plugins
```
## Why This Works
With `watchPlugins: false`:
- Headlamp no longer treats catalog-managed plugins as "development" plugins
- Frontend properly loads and executes plugin JavaScript on startup
- Plugin registrations happen correctly
- All plugin features (sidebar, routes, settings, etc.) work as expected
## Testing
After applying this fix:
1. Verify plugins are installed: `kubectl logs -n kube-system <headlamp-pod> -c headlamp-plugin`
2. Verify watchPlugins is false: `kubectl logs -n kube-system <headlamp-pod> -c headlamp | grep "Watch Plugins"`
3. Hard refresh browser (Cmd+Shift+R / Ctrl+Shift+F5) to clear cached JavaScript
4. Verify plugin sidebar entries appear
5. Verify plugin functionality works
## Additional Notes
- This appears to be a bug/limitation in Headlamp v0.39.0
- The `watchPlugins` feature is intended for development scenarios where plugins are being actively modified
- For production deployments with catalog-managed plugins, `watchPlugins: false` is the correct configuration
- Once plugins are loaded, subsequent restarts or updates work correctly as long as `watchPlugins` remains false
## References
- Headlamp Helm Chart: https://github.com/headlamp-k8s/headlamp/tree/main/charts/headlamp
- Plugin Manager: https://github.com/headlamp-k8s/headlamp/tree/main/plugins/headlamp-plugin
- Issue discovered: 2026-02-11
- Fix applied: 2026-02-12
+59
View File
@@ -0,0 +1,59 @@
---
# RBAC for the GitHub Actions CI runner to perform E2E test setup.
# CI-only test fixture — NOT for production use.
#
# Grants the ARC runner service account namespace-scoped permissions in
# kube-system to patch the Headlamp deployment (add shared volume mount),
# manage PVCs, run temporary pods, and restart deployments.
#
# No cluster-scoped permissions needed — the E2E workflow uses kubectl patch
# instead of helm upgrade, avoiding the need to read ClusterRole/ClusterRoleBinding.
#
# Apply with: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: e2e-ci-runner
namespace: kube-system
rules:
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "create", "delete", "watch"]
- apiGroups: [""]
resources: ["pods/attach"]
verbs: ["create", "get"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "patch", "watch"]
- apiGroups: ["apps"]
resources: ["deployments/scale"]
verbs: ["patch"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["serviceaccounts"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: e2e-ci-runner-binding
namespace: kube-system
subjects:
- kind: ServiceAccount
name: local-ubuntu-latest-gha-rs-no-permission
namespace: arc-runners
roleRef:
kind: Role
name: e2e-ci-runner
apiGroup: rbac.authorization.k8s.io
+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
+28
View File
@@ -0,0 +1,28 @@
# RBAC to allow authenticated users to proxy to the Polaris dashboard service.
# The polaris plugin reads audit data via the Kubernetes service proxy:
# /api/v1/namespaces/polaris/services/http:polaris-dashboard:80/proxy/results.json
# Without this Role + RoleBinding, users get a 403 when Headlamp proxies the request.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: polaris-dashboard-proxy-reader
namespace: polaris
rules:
- apiGroups: [""]
resources: ["services/proxy"]
resourceNames: ["polaris-dashboard", "http:polaris-dashboard:80"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: polaris-dashboard-proxy-reader
namespace: polaris
subjects:
- kind: Group
name: system:authenticated
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: polaris-dashboard-proxy-reader
apiGroup: rbac.authorization.k8s.io
+2 -2
View File
@@ -33,7 +33,7 @@ kubectl -n polaris get svc polaris-dashboard
kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json | jq .PolarisOutputVersion
# Verify Headlamp is deployed
kubectl -n <your-namespace> get pods -l app.kubernetes.io/name=headlamp
kubectl -n kube-system get pods -l app.kubernetes.io/name=headlamp
```
## Installation Methods
@@ -59,7 +59,7 @@ kubectl -n <your-namespace> get pods -l app.kubernetes.io/name=headlamp
```bash
helm upgrade --install headlamp headlamp/headlamp \
--namespace <your-namespace> \
--namespace kube-system \
--values headlamp-values.yaml
```
+3 -2
View File
@@ -268,9 +268,10 @@ npm run e2e
```bash
# Create token
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n <your-namespace> --duration=24h)
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n kube-system --duration=24h)
kubectl port-forward -n <your-namespace> svc/headlamp 4466:80
# Port-forward for local testing
kubectl port-forward -n kube-system svc/headlamp 4466:80
# Run tests
HEADLAMP_URL=http://localhost:4466 npm run e2e
+16 -16
View File
@@ -33,7 +33,7 @@ This guide covers common issues encountered when using the Headlamp Polaris Plug
```bash
# View Headlamp pod logs (plugin sidecar)
kubectl logs -n <your-namespace> deployment/headlamp -c headlamp-plugin
kubectl logs -n kube-system deployment/headlamp -c headlamp-plugin
# Expected output:
# Installing plugin from https://github.com/.../headlamp-polaris-plugin-X.Y.Z.tar.gz
@@ -43,7 +43,7 @@ kubectl logs -n <your-namespace> deployment/headlamp -c headlamp-plugin
**Verify plugin files exist**:
```bash
kubectl exec -n <your-namespace> deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/
kubectl exec -n kube-system deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/
# Should show: headlamp-polaris-plugin/
```
@@ -118,7 +118,7 @@ Expected subjects:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
namespace: kube-system
```
For OIDC mode:
@@ -154,7 +154,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
namespace: kube-system
roleRef:
kind: Role
name: polaris-proxy-reader
@@ -169,7 +169,7 @@ Service account mode:
```bash
# Impersonate Headlamp service account
kubectl auth can-i get services/proxy \
--as=system:serviceaccount:<your-namespace>:headlamp \
--as=system:serviceaccount:kube-system:headlamp \
--resource-name=polaris-dashboard \
-n polaris
# Expected: yes
@@ -189,7 +189,7 @@ kubectl auth can-i get services/proxy \
After applying RBAC changes:
```bash
kubectl rollout restart deployment headlamp -n <your-namespace>
kubectl rollout restart deployment headlamp -n kube-system
```
---
@@ -490,7 +490,7 @@ Run this script to test all RBAC components:
#!/bin/bash
NS="polaris"
SA="headlamp"
SA_NS="<your-namespace>"
SA_NS="kube-system"
echo "=== Testing RBAC for Polaris Plugin ==="
@@ -529,8 +529,8 @@ echo "=== Test complete ==="
Test connectivity from Headlamp to Polaris:
```bash
# Create debug pod in headlamp namespace
kubectl run netdebug -n <your-namespace> --rm -it --image=nicolaka/netshoot -- bash
# Create debug pod in kube-system namespace
kubectl run netdebug -n kube-system --rm -it --image=nicolaka/netshoot -- bash
# Inside pod, test DNS and HTTP
nslookup polaris-dashboard.polaris.svc.cluster.local
@@ -545,11 +545,11 @@ If you have audit logging enabled, check for denied requests:
```bash
# View recent audit logs (location varies by cluster)
kubectl logs -n <your-namespace> kube-apiserver-* | grep polaris-dashboard
kubectl logs -n kube-system kube-apiserver-* | grep polaris-dashboard
# Look for lines with:
# "reason": "Forbidden"
# "user": "system:serviceaccount:<your-namespace>:headlamp"
# "user": "system:serviceaccount:kube-system:headlamp"
```
---
@@ -567,7 +567,7 @@ kubectl logs -n <your-namespace> kube-apiserver-* | grep polaris-dashboard
**Check sidecar logs**:
```bash
kubectl logs -n <your-namespace> deployment/headlamp -c headlamp-plugin
kubectl logs -n kube-system deployment/headlamp -c headlamp-plugin
```
**Common errors**:
@@ -591,7 +591,7 @@ Error: 404 Not Found
**Solution**: Verify `archive-url` in plugin config matches GitHub release:
```bash
kubectl get configmap headlamp-plugin-config -n <your-namespace> -o yaml
kubectl get configmap headlamp-plugin-config -n kube-system -o yaml
```
Expected format:
@@ -677,13 +677,13 @@ If none of these solutions work, gather debugging information and open an issue:
1. **Version Information**:
```bash
kubectl get pods -n <your-namespace> -l app.kubernetes.io/name=headlamp -o yaml | grep image:
kubectl get pods -n kube-system -l app.kubernetes.io/name=headlamp -o yaml | grep image:
```
2. **Plugin Version**:
- Check Settings → Plugins in Headlamp UI
- Or: `kubectl exec -n <your-namespace> deployment/headlamp -c headlamp -- cat /headlamp/plugins/headlamp-polaris-plugin/package.json`
- Or: `kubectl exec -n kube-system deployment/headlamp -c headlamp -- cat /headlamp/plugins/headlamp-polaris-plugin/package.json`
3. **Browser Console Output**:
@@ -698,7 +698,7 @@ If none of these solutions work, gather debugging information and open an issue:
5. **Pod Logs**:
```bash
kubectl logs -n <your-namespace> deployment/headlamp -c headlamp --tail=100
kubectl logs -n kube-system deployment/headlamp -c headlamp --tail=100
kubectl logs -n polaris deployment/polaris-dashboard --tail=100
```
+20 -20
View File
@@ -41,11 +41,11 @@ pluginsManager:
```bash
# Install Headlamp
helm install headlamp headlamp/headlamp \
--namespace <your-namespace> \
--namespace kube-system \
--values headlamp-values.yaml
# Wait for deployment
kubectl -n <your-namespace> wait --for=condition=available deployment/headlamp --timeout=300s
kubectl -n kube-system wait --for=condition=available deployment/headlamp --timeout=300s
```
After installation, install the plugin via Headlamp UI (**Settings → Plugins → Catalog**).
@@ -131,7 +131,7 @@ Deploy:
```bash
helm upgrade --install headlamp headlamp/headlamp \
--namespace <your-namespace> \
--namespace kube-system \
--values headlamp-values.yaml \
--wait \
--timeout 5m
@@ -177,7 +177,7 @@ apiVersion: v1
kind: ConfigMap
metadata:
name: headlamp-plugin-config
namespace: <your-namespace>
namespace: kube-system
data:
plugin.yml: |
- name: headlamp-polaris-plugin
@@ -191,7 +191,7 @@ Apply ConfigMap then deploy Headlamp:
kubectl apply -f headlamp-plugin-config.yaml
helm upgrade --install headlamp headlamp/headlamp \
--namespace <your-namespace> \
--namespace kube-system \
--values headlamp-values.yaml
```
@@ -221,7 +221,7 @@ apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: headlamp
namespace: <your-namespace>
namespace: kube-system
spec:
interval: 30m
chart:
@@ -300,7 +300,7 @@ kubectl apply -f helmrepository.yaml
kubectl apply -f helmrelease.yaml
# Watch deployment
flux get helmreleases -n <your-namespace> --watch
flux get helmreleases -n kube-system --watch
```
## RBAC Configuration
@@ -329,7 +329,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
namespace: kube-system
roleRef:
kind: Role
name: polaris-proxy-reader
@@ -349,7 +349,7 @@ helm repo update
# Upgrade Headlamp (preserves plugin configuration)
helm upgrade headlamp headlamp/headlamp \
--namespace <your-namespace> \
--namespace kube-system \
--values headlamp-values.yaml \
--wait
```
@@ -365,15 +365,15 @@ helm upgrade headlamp headlamp/headlamp \
```bash
# Update ConfigMap with new version
kubectl -n <your-namespace> edit configmap headlamp-plugin-config
kubectl -n kube-system edit configmap headlamp-plugin-config
# Update version and URL:
# version: 0.3.6
# url: https://github.com/.../v0.3.6/polaris-0.3.10.tar.gz
# Restart deployment to trigger init container
kubectl -n <your-namespace> rollout restart deployment/headlamp
kubectl -n <your-namespace> rollout status deployment/headlamp
kubectl -n kube-system rollout restart deployment/headlamp
kubectl -n kube-system rollout status deployment/headlamp
```
## Troubleshooting
@@ -382,25 +382,25 @@ kubectl -n <your-namespace> rollout status deployment/headlamp
```bash
# Check Headlamp values
helm get values headlamp -n <your-namespace>
helm get values headlamp -n kube-system
# Verify plugin files exist
kubectl -n <your-namespace> exec deployment/headlamp -c headlamp -- \
kubectl -n kube-system exec deployment/headlamp -c headlamp -- \
ls -la /headlamp/plugins/headlamp-polaris-plugin/
# If missing, reinstall plugin via UI or check init container logs
kubectl -n <your-namespace> logs deployment/headlamp -c install-polaris-plugin
kubectl -n kube-system logs deployment/headlamp -c install-polaris-plugin
```
### Helm Release Stuck
```bash
# Check Helm release status
helm list -n <your-namespace>
helm list -n kube-system
# If stuck, force upgrade
helm upgrade headlamp headlamp/headlamp \
--namespace <your-namespace> \
--namespace kube-system \
--values headlamp-values.yaml \
--force \
--wait
@@ -410,13 +410,13 @@ helm upgrade headlamp headlamp/headlamp \
```bash
# Check HelmRelease status
flux get helmreleases -n <your-namespace>
flux get helmreleases -n kube-system
# Check events
kubectl -n <your-namespace> describe helmrelease headlamp
kubectl -n kube-system describe helmrelease headlamp
# Force reconciliation
flux reconcile helmrelease headlamp -n <your-namespace>
flux reconcile helmrelease headlamp -n kube-system
```
## Next Steps
+21 -21
View File
@@ -47,7 +47,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
namespace: kube-system
roleRef:
kind: Role
name: polaris-proxy-reader
@@ -71,7 +71,7 @@ kubectl -n polaris get rolebinding headlamp-polaris-proxy
# Test permission
kubectl auth can-i get services/proxy \
--as=system:serviceaccount:<your-namespace>:headlamp \
--as=system:serviceaccount:kube-system:headlamp \
-n polaris \
--resource-name=polaris-dashboard
@@ -90,7 +90,7 @@ apiVersion: v1
kind: ConfigMap
metadata:
name: headlamp-plugin-config
namespace: <your-namespace>
namespace: kube-system
labels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/component: plugin-config
@@ -109,7 +109,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: headlamp
namespace: <your-namespace>
namespace: kube-system
labels:
app.kubernetes.io/name: headlamp
spec:
@@ -194,7 +194,7 @@ apiVersion: v1
kind: ServiceAccount
metadata:
name: headlamp
namespace: <your-namespace>
namespace: kube-system
labels:
app.kubernetes.io/name: headlamp
@@ -204,7 +204,7 @@ apiVersion: v1
kind: Service
metadata:
name: headlamp
namespace: <your-namespace>
namespace: kube-system
labels:
app.kubernetes.io/name: headlamp
spec:
@@ -235,27 +235,27 @@ kubectl apply -f headlamp-service.yaml
kubectl apply -f headlamp-serviceaccount.yaml
# Wait for deployment to be ready
kubectl -n <your-namespace> wait --for=condition=available deployment/headlamp --timeout=300s
kubectl -n kube-system wait --for=condition=available deployment/headlamp --timeout=300s
```
### 2. Verify Deployment
```bash
# Check pods are running
kubectl -n <your-namespace> get pods -l app.kubernetes.io/name=headlamp
kubectl -n kube-system get pods -l app.kubernetes.io/name=headlamp
# Expected output:
# NAME READY STATUS RESTARTS AGE
# headlamp-xxxxxxxxxx-xxxxx 1/1 Running 0 2m
# Check init container logs
kubectl -n <your-namespace> logs deployment/headlamp -c install-plugins
kubectl -n kube-system logs deployment/headlamp -c install-plugins
# Expected output:
# Plugin installation complete
# Verify plugin files exist
kubectl -n <your-namespace> exec deployment/headlamp -c headlamp -- \
kubectl -n kube-system exec deployment/headlamp -c headlamp -- \
ls -la /headlamp/plugins/headlamp-polaris-plugin/
# Expected output:
@@ -273,7 +273,7 @@ kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy
```bash
# Port-forward to access locally
kubectl -n <your-namespace> port-forward service/headlamp 8080:80
kubectl -n kube-system port-forward service/headlamp 8080:80
# Open browser to http://localhost:8080
```
@@ -309,7 +309,7 @@ k8s/
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: <your-namespace>
namespace: kube-system
commonLabels:
app.kubernetes.io/name: headlamp
@@ -401,7 +401,7 @@ spec:
- apiVersion: apps/v1
kind: Deployment
name: headlamp
namespace: <your-namespace>
namespace: kube-system
```
## Upgrading the Plugin
@@ -410,24 +410,24 @@ spec:
```bash
# Edit ConfigMap with new version
kubectl -n <your-namespace> edit configmap headlamp-plugin-config
kubectl -n kube-system edit configmap headlamp-plugin-config
# Update version and URL:
# version: 0.3.6
# url: https://github.com/.../v0.3.6/polaris-0.3.10.tar.gz
# Restart deployment to trigger init container
kubectl -n <your-namespace> rollout restart deployment/headlamp
kubectl -n kube-system rollout restart deployment/headlamp
# Wait for rollout to complete
kubectl -n <your-namespace> rollout status deployment/headlamp
kubectl -n kube-system rollout status deployment/headlamp
```
### Verify Upgrade
```bash
# Check init container logs
kubectl -n <your-namespace> logs deployment/headlamp -c install-plugins
kubectl -n kube-system logs deployment/headlamp -c install-plugins
# Verify new version in UI
# Navigate to Settings → Plugins in Headlamp
@@ -439,7 +439,7 @@ kubectl -n <your-namespace> logs deployment/headlamp -c install-plugins
```bash
# Check init container logs
kubectl -n <your-namespace> logs deployment/headlamp -c install-plugins
kubectl -n kube-system logs deployment/headlamp -c install-plugins
# Common issues:
# 1. Network connectivity to GitHub
@@ -451,14 +451,14 @@ kubectl -n <your-namespace> logs deployment/headlamp -c install-plugins
```bash
# Verify HEADLAMP_CONFIG_WATCH_PLUGINS is false
kubectl -n <your-namespace> get deployment headlamp -o yaml | grep WATCH_PLUGINS
kubectl -n kube-system get deployment headlamp -o yaml | grep WATCH_PLUGINS
# Expected output:
# - name: HEADLAMP_CONFIG_WATCH_PLUGINS
# value: "false"
# If not set or "true", update deployment
kubectl -n <your-namespace> edit deployment headlamp
kubectl -n kube-system edit deployment headlamp
```
### RBAC Permissions Denied
@@ -466,7 +466,7 @@ kubectl -n <your-namespace> edit deployment headlamp
```bash
# Test RBAC
kubectl auth can-i get services/proxy \
--as=system:serviceaccount:<your-namespace>:headlamp \
--as=system:serviceaccount:kube-system:headlamp \
-n polaris \
--resource-name=polaris-dashboard
+15 -15
View File
@@ -37,8 +37,8 @@ kubectl -n polaris get svc polaris-dashboard
kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json | jq .PolarisOutputVersion
# Verify Headlamp
kubectl -n <your-namespace> get deployment headlamp
kubectl -n <your-namespace> get svc headlamp
kubectl -n kube-system get deployment headlamp
kubectl -n kube-system get svc headlamp
```
## Production Checklist
@@ -60,17 +60,17 @@ kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy
# 2. Verify RBAC permissions
kubectl auth can-i get services/proxy \
--as=system:serviceaccount:<your-namespace>:headlamp \
--as=system:serviceaccount:kube-system:headlamp \
-n polaris \
--resource-name=polaris-dashboard
# Expected: yes
# 3. Check Headlamp logs for plugin loading
kubectl -n <your-namespace> logs deployment/headlamp | grep -i polaris
kubectl -n kube-system logs deployment/headlamp | grep -i polaris
# Expected: No errors related to plugin loading
# 4. Verify plugin files exist
kubectl -n <your-namespace> exec deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/headlamp-polaris-plugin/
kubectl -n kube-system exec deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/headlamp-polaris-plugin/
# Expected: dist/, package.json present
```
@@ -241,7 +241,7 @@ apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: headlamp-pdb
namespace: <your-namespace>
namespace: kube-system
spec:
minAvailable: 1
selector:
@@ -295,7 +295,7 @@ apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: headlamp
namespace: <your-namespace>
namespace: kube-system
spec:
selector:
matchLabels:
@@ -312,10 +312,10 @@ spec:
```bash
# View logs
kubectl -n <your-namespace> logs deployment/headlamp -f
kubectl -n kube-system logs deployment/headlamp -f
# Filter for plugin-related logs
kubectl -n <your-namespace> logs deployment/headlamp | grep -i polaris
kubectl -n kube-system logs deployment/headlamp | grep -i polaris
```
**Polaris Dashboard Logs:**
@@ -341,14 +341,14 @@ apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: headlamp-alerts
namespace: <your-namespace>
namespace: kube-system
spec:
groups:
- name: headlamp
interval: 30s
rules:
- alert: HeadlampPodNotReady
expr: kube_pod_status_ready{namespace="<your-namespace>", pod=~"headlamp-.*"} == 0
expr: kube_pod_status_ready{namespace="kube-system", pod=~"headlamp-.*"} == 0
for: 5m
labels:
severity: warning
@@ -422,9 +422,9 @@ If Headlamp or plugin becomes unavailable:
2. **Redeploy Headlamp:**
```bash
helm upgrade --install headlamp headlamp/headlamp \
--namespace <your-namespace> \
--values headlamp-values.yaml
helm upgrade --install headlamp headlamp/headlamp \
--namespace kube-system \
--values headlamp-values.yaml
```
3. **Reapply RBAC:**
@@ -436,7 +436,7 @@ helm upgrade --install headlamp headlamp/headlamp \
4. **Verify plugin files:**
```bash
kubectl -n <your-namespace> exec deployment/headlamp -- \
kubectl -n kube-system exec deployment/headlamp -- \
ls /headlamp/plugins/headlamp-polaris-plugin/
```
+3 -2
View File
@@ -268,9 +268,10 @@ npm run e2e
```bash
# Create token
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n <your-namespace> --duration=24h)
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n kube-system --duration=24h)
kubectl port-forward -n <your-namespace> svc/headlamp 4466:80
# Port-forward for local testing
kubectl port-forward -n kube-system svc/headlamp 4466:80
# Run tests
HEADLAMP_URL=http://localhost:4466 npm run e2e
+13 -13
View File
@@ -72,7 +72,7 @@ Deploy or update Headlamp:
```bash
helm upgrade --install headlamp headlamp/headlamp \
--namespace <your-namespace> \
--namespace kube-system \
--values headlamp-values.yaml
```
@@ -122,7 +122,7 @@ apiVersion: v1
kind: ConfigMap
metadata:
name: headlamp-plugin-config
namespace: <your-namespace>
namespace: kube-system
data:
plugin.yml: |
- name: headlamp-polaris-plugin
@@ -138,14 +138,14 @@ kubectl apply -f headlamp-plugin-config.yaml
# Deploy/update Headlamp with sidecar
helm upgrade --install headlamp headlamp/headlamp \
--namespace <your-namespace> \
--namespace kube-system \
--values headlamp-values.yaml
# Wait for pod to be ready
kubectl -n <your-namespace> wait --for=condition=ready pod -l app.kubernetes.io/name=headlamp --timeout=300s
kubectl -n kube-system wait --for=condition=ready pod -l app.kubernetes.io/name=headlamp --timeout=300s
# Verify plugin files
kubectl -n <your-namespace> exec -it deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/headlamp-polaris-plugin/
kubectl -n kube-system exec -it deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/headlamp-polaris-plugin/
# Expected output:
# drwxr-xr-x dist/
@@ -270,7 +270,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
namespace: kube-system
roleRef:
kind: Role
name: polaris-proxy-reader
@@ -284,10 +284,10 @@ See [RBAC Permissions](../user-guide/rbac-permissions.md) for detailed RBAC conf
```bash
# If you updated Helm values or ConfigMaps
kubectl -n <your-namespace> rollout restart deployment/headlamp
kubectl -n kube-system rollout restart deployment/headlamp
# Wait for pod to be ready
kubectl -n <your-namespace> wait --for=condition=ready pod -l app.kubernetes.io/name=headlamp --timeout=300s
kubectl -n kube-system wait --for=condition=ready pod -l app.kubernetes.io/name=headlamp --timeout=300s
```
### 3. Clear Browser Cache
@@ -312,14 +312,14 @@ kubectl -n <your-namespace> wait --for=condition=ready pod -l app.kubernetes.io/
```bash
# Verify plugin files exist
kubectl -n <your-namespace> exec -it deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/headlamp-polaris-plugin/
kubectl -n kube-system exec -it deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/headlamp-polaris-plugin/
# Expected output:
# drwxr-xr-x dist/
# -rw-r--r-- package.json
# Check Headlamp logs for errors
kubectl -n <your-namespace> logs deployment/headlamp | grep -i polaris
kubectl -n kube-system logs deployment/headlamp | grep -i polaris
# Expected: No errors related to plugin loading
@@ -345,13 +345,13 @@ kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy
```bash
# 1. Verify plugin files exist
kubectl -n <your-namespace> exec deployment/headlamp -c headlamp -- \
kubectl -n kube-system exec deployment/headlamp -c headlamp -- \
ls -la /headlamp/plugins/headlamp-polaris-plugin/
# Expected: dist/, package.json present
# 2. Check Headlamp logs for plugin errors
kubectl -n <your-namespace> logs deployment/headlamp | grep -i polaris
kubectl -n kube-system logs deployment/headlamp | grep -i polaris
# 3. Hard refresh browser (Cmd+Shift+R or Ctrl+Shift+R)
@@ -404,7 +404,7 @@ helm install polaris fairwinds-stable/polaris \
```bash
# Wait 30 minutes for ArtifactHub sync
# Or manually force Headlamp restart:
kubectl -n <your-namespace> rollout restart deployment/headlamp
kubectl -n kube-system rollout restart deployment/headlamp
```
## Next Steps
+5 -5
View File
@@ -67,14 +67,14 @@ kubectl -n polaris wait --for=condition=ready pod -l app.kubernetes.io/name=pola
```bash
# Check Headlamp is deployed
kubectl -n <your-namespace> get pods -l app.kubernetes.io/name=headlamp
kubectl -n kube-system get pods -l app.kubernetes.io/name=headlamp
# Expected output:
# NAME READY STATUS RESTARTS AGE
# headlamp-xxxxxxxxxx-xxxxx 1/1 Running 0 1h
# Check Headlamp version (must be v0.26+)
kubectl -n <your-namespace> get deployment headlamp -o jsonpath='{.spec.template.spec.containers[0].image}'
kubectl -n kube-system get deployment headlamp -o jsonpath='{.spec.template.spec.containers[0].image}'
# Expected output:
# ghcr.io/headlamp-k8s/headlamp:v0.39.0 (or similar)
@@ -89,12 +89,12 @@ helm repo update
# Install Headlamp
helm install headlamp headlamp/headlamp \
--namespace <your-namespace> \
--namespace kube-system \
--set config.pluginsDir="/headlamp/plugins" \
--set pluginsManager.enabled=true
# Wait for pod to be ready
kubectl -n <your-namespace> wait --for=condition=ready pod -l app.kubernetes.io/name=headlamp --timeout=300s
kubectl -n kube-system wait --for=condition=ready pod -l app.kubernetes.io/name=headlamp --timeout=300s
```
## RBAC Requirements
@@ -112,7 +112,7 @@ The plugin requires permissions to access the Polaris dashboard via Kubernetes s
```bash
# Test if Headlamp service account has permission
kubectl auth can-i get services/proxy \
--as=system:serviceaccount:<your-namespace>:headlamp \
--as=system:serviceaccount:kube-system:headlamp \
-n polaris \
--resource-name=polaris-dashboard
+5 -5
View File
@@ -38,7 +38,7 @@ EOF
# Update Headlamp
helm upgrade --install headlamp headlamp/headlamp \
--namespace <your-namespace> \
--namespace kube-system \
--values headlamp-values.yaml
```
@@ -70,7 +70,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
namespace: kube-system
roleRef:
kind: Role
name: polaris-proxy-reader
@@ -111,7 +111,7 @@ EOF
```bash
# Verify plugin files exist
kubectl -n <your-namespace> exec -it deployment/headlamp -c headlamp -- \
kubectl -n kube-system exec -it deployment/headlamp -c headlamp -- \
ls /headlamp/plugins/headlamp-polaris-plugin/dist/
# Expected output:
@@ -119,7 +119,7 @@ kubectl -n <your-namespace> exec -it deployment/headlamp -c headlamp -- \
# Verify RBAC is correct
kubectl auth can-i get services/proxy \
--as=system:serviceaccount:<your-namespace>:headlamp \
--as=system:serviceaccount:kube-system:headlamp \
-n polaris \
--resource-name=polaris-dashboard
@@ -185,7 +185,7 @@ Cluster score badge in top navigation:
```bash
# Verify plugin files exist
kubectl -n <your-namespace> exec -it deployment/headlamp -c headlamp -- \
kubectl -n kube-system exec -it deployment/headlamp -c headlamp -- \
ls /headlamp/plugins/headlamp-polaris-plugin/
# If missing, reinstall via Headlamp UI or sidecar method
+5 -5
View File
@@ -38,17 +38,17 @@ kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy
# 3. Verify RBAC permissions
kubectl auth can-i get services/proxy \
--as=system:serviceaccount:<your-namespace>:headlamp \
--as=system:serviceaccount:kube-system:headlamp \
-n polaris \
--resource-name=polaris-dashboard
# Expected output: yes
# 4. Check Headlamp pod is running
kubectl -n <your-namespace> get pods -l app.kubernetes.io/name=headlamp
kubectl -n kube-system get pods -l app.kubernetes.io/name=headlamp
# 5. Check Headlamp logs for plugin errors
kubectl -n <your-namespace> logs deployment/headlamp | grep -i polaris
kubectl -n kube-system logs deployment/headlamp | grep -i polaris
# Expected: No errors
```
@@ -57,7 +57,7 @@ kubectl -n <your-namespace> logs deployment/headlamp | grep -i polaris
```bash
# Verify plugin files exist
kubectl -n <your-namespace> exec deployment/headlamp -c headlamp -- \
kubectl -n kube-system exec deployment/headlamp -c headlamp -- \
ls -la /headlamp/plugins/headlamp-polaris-plugin/
# Expected output:
@@ -76,7 +76,7 @@ kubectl -n polaris get rolebinding headlamp-polaris-proxy
# Test permission (service account mode)
kubectl auth can-i get services/proxy \
--as=system:serviceaccount:<your-namespace>:headlamp \
--as=system:serviceaccount:kube-system:headlamp \
-n polaris \
--resource-name=polaris-dashboard
+16 -16
View File
@@ -33,7 +33,7 @@ This guide covers common issues encountered when using the Headlamp Polaris Plug
```bash
# View Headlamp pod logs (plugin sidecar)
kubectl logs -n <your-namespace> deployment/headlamp -c headlamp-plugin
kubectl logs -n kube-system deployment/headlamp -c headlamp-plugin
# Expected output:
# Installing plugin from https://github.com/.../headlamp-polaris-plugin-X.Y.Z.tar.gz
@@ -43,7 +43,7 @@ kubectl logs -n <your-namespace> deployment/headlamp -c headlamp-plugin
**Verify plugin files exist**:
```bash
kubectl exec -n <your-namespace> deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/
kubectl exec -n kube-system deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/
# Should show: headlamp-polaris-plugin/
```
@@ -118,7 +118,7 @@ Expected subjects:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
namespace: kube-system
```
For OIDC mode:
@@ -154,7 +154,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
namespace: kube-system
roleRef:
kind: Role
name: polaris-proxy-reader
@@ -169,7 +169,7 @@ Service account mode:
```bash
# Impersonate Headlamp service account
kubectl auth can-i get services/proxy \
--as=system:serviceaccount:<your-namespace>:headlamp \
--as=system:serviceaccount:kube-system:headlamp \
--resource-name=polaris-dashboard \
-n polaris
# Expected: yes
@@ -189,7 +189,7 @@ kubectl auth can-i get services/proxy \
After applying RBAC changes:
```bash
kubectl rollout restart deployment headlamp -n <your-namespace>
kubectl rollout restart deployment headlamp -n kube-system
```
---
@@ -490,7 +490,7 @@ Run this script to test all RBAC components:
#!/bin/bash
NS="polaris"
SA="headlamp"
SA_NS="<your-namespace>"
SA_NS="kube-system"
echo "=== Testing RBAC for Polaris Plugin ==="
@@ -529,8 +529,8 @@ echo "=== Test complete ==="
Test connectivity from Headlamp to Polaris:
```bash
# Create debug pod in the namespace where Headlamp is installed
kubectl run netdebug -n <your-namespace> --rm -it --image=nicolaka/netshoot -- bash
# Create debug pod in kube-system namespace
kubectl run netdebug -n kube-system --rm -it --image=nicolaka/netshoot -- bash
# Inside pod, test DNS and HTTP
nslookup polaris-dashboard.polaris.svc.cluster.local
@@ -545,11 +545,11 @@ If you have audit logging enabled, check for denied requests:
```bash
# View recent audit logs (location varies by cluster)
kubectl logs -n <your-namespace> kube-apiserver-* | grep polaris-dashboard
kubectl logs -n kube-system kube-apiserver-* | grep polaris-dashboard
# Look for lines with:
# "reason": "Forbidden"
# "user": "system:serviceaccount:<your-namespace>:headlamp"
# "user": "system:serviceaccount:kube-system:headlamp"
```
---
@@ -567,7 +567,7 @@ kubectl logs -n <your-namespace> kube-apiserver-* | grep polaris-dashboard
**Check sidecar logs**:
```bash
kubectl logs -n <your-namespace> deployment/headlamp -c headlamp-plugin
kubectl logs -n kube-system deployment/headlamp -c headlamp-plugin
```
**Common errors**:
@@ -591,7 +591,7 @@ Error: 404 Not Found
**Solution**: Verify `archive-url` in plugin config matches GitHub release:
```bash
kubectl get configmap headlamp-plugin-config -n <your-namespace> -o yaml
kubectl get configmap headlamp-plugin-config -n kube-system -o yaml
```
Expected format:
@@ -677,13 +677,13 @@ If none of these solutions work, gather debugging information and open an issue:
1. **Version Information**:
```bash
kubectl get pods -n <your-namespace> -l app.kubernetes.io/name=headlamp -o yaml | grep image:
kubectl get pods -n kube-system -l app.kubernetes.io/name=headlamp -o yaml | grep image:
```
2. **Plugin Version**:
- Check Settings → Plugins in Headlamp UI
- Or: `kubectl exec -n <your-namespace> deployment/headlamp -c headlamp -- cat /headlamp/plugins/headlamp-polaris-plugin/package.json`
- Or: `kubectl exec -n kube-system deployment/headlamp -c headlamp -- cat /headlamp/plugins/headlamp-polaris-plugin/package.json`
3. **Browser Console Output**:
@@ -698,7 +698,7 @@ If none of these solutions work, gather debugging information and open an issue:
5. **Pod Logs**:
```bash
kubectl logs -n <your-namespace> deployment/headlamp -c headlamp --tail=100
kubectl logs -n kube-system deployment/headlamp -c headlamp --tail=100
kubectl logs -n polaris deployment/polaris-dashboard --tail=100
```
+2 -2
View File
@@ -43,7 +43,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
namespace: kube-system
roleRef:
kind: Role
name: polaris-proxy-reader
@@ -83,7 +83,7 @@ roleRef:
```bash
# Test service account (in-cluster mode)
kubectl auth can-i get services/proxy \
--as=system:serviceaccount:<your-namespace>:headlamp \
--as=system:serviceaccount:kube-system:headlamp \
-n polaris \
--resource-name=polaris-dashboard
+1 -1
View File
@@ -317,7 +317,7 @@ kubectl -n polaris get rolebinding headlamp-polaris-proxy
# Test permission
kubectl auth can-i get services/proxy \
--as=system:serviceaccount:<your-namespace>:headlamp \
--as=system:serviceaccount:kube-system:headlamp \
-n polaris \
--resource-name=polaris-dashboard
```
+8 -8
View File
@@ -65,7 +65,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp # Adjust to your Headlamp SA name
namespace: <your-namespace>
namespace: kube-system # Adjust to Headlamp's namespace
roleRef:
kind: Role
name: polaris-proxy-reader
@@ -75,7 +75,7 @@ roleRef:
**Adjust for your environment:**
- `subjects[0].name` - Your Headlamp service account name (often `headlamp`)
- `subjects[0].namespace` - Namespace where Headlamp is installed
- `subjects[0].namespace` - Namespace where Headlamp runs (often `kube-system`)
### Step 3: Apply and Verify
@@ -91,7 +91,7 @@ kubectl -n polaris get rolebinding headlamp-polaris-proxy
# Test permission
kubectl auth can-i get services/proxy \
--as=system:serviceaccount:<your-namespace>:headlamp \
--as=system:serviceaccount:kube-system:headlamp \
-n polaris \
--resource-name=polaris-dashboard
@@ -109,7 +109,7 @@ In token-auth mode, **each user's own identity** is used for Kubernetes API requ
With service account mode:
- Single RoleBinding grants access to all Headlamp users
- Kubernetes sees all requests as `system:serviceaccount:<your-namespace>:headlamp`
- Kubernetes sees all requests as `system:serviceaccount:kube-system:headlamp`
With token-auth mode:
@@ -267,7 +267,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
namespace: kube-system
roleRef:
kind: Role
name: polaris-proxy-reader
@@ -281,7 +281,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
namespace: kube-system
roleRef:
kind: Role
name: polaris-proxy-reader
@@ -411,7 +411,7 @@ Every plugin data fetch creates a Kubernetes API audit log entry.
"level": "Metadata",
"verb": "get",
"user": {
"username": "system:serviceaccount:<your-namespace>:headlamp"
"username": "system:serviceaccount:kube-system:headlamp"
},
"sourceIPs": ["10.96.0.1"],
"objectRef": {
@@ -494,7 +494,7 @@ If using a log aggregator (e.g., Elasticsearch), create filters to exclude or do
```bash
# Service account mode
kubectl auth can-i get services/proxy \
--as=system:serviceaccount:<your-namespace>:headlamp \
--as=system:serviceaccount:kube-system:headlamp \
-n polaris \
--resource-name=polaris-dashboard
+294
View File
@@ -0,0 +1,294 @@
# E2E Smoke Tests
Playwright-based smoke tests that validate the Polaris plugin against a live Headlamp deployment.
## CI
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
Configure these in GitHub repository settings (Settings → Secrets and variables → Actions):
| 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) |
Set either `AUTHENTIK_USERNAME` + `AUTHENTIK_PASSWORD` **or** `HEADLAMP_TOKEN`. OIDC takes priority if both are set.
## Running Locally
### Option 1: OIDC via Authentik (same as CI)
```bash
AUTHENTIK_USERNAME=you@example.com AUTHENTIK_PASSWORD=... npm run e2e
```
The default base URL is `https://headlamp.animaniacs.farh.net`. Override with `HEADLAMP_URL` if needed.
### Option 2: K8s bearer token (port-forward)
```bash
kubectl port-forward -n kube-system svc/headlamp 4466:80
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n kube-system)
HEADLAMP_URL=http://localhost:4466 npm run e2e
```
Or in headed mode (opens a browser window):
```bash
HEADLAMP_URL=http://localhost:4466 npm run e2e:headed
```
## Environment Variables
| 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 (fallback auth) |
Set either `AUTHENTIK_USERNAME` + `AUTHENTIK_PASSWORD` or `HEADLAMP_TOKEN`. OIDC takes priority if both are set.
## What the Tests Validate
- **Sidebar entry** — The Polaris sidebar item appears after login
- **Overview page** — Cluster score and check distribution render correctly
- **Namespaces page** — Table of namespaces loads with clickable links
- **Namespace detail** — Clicking a namespace shows its score and resource table
These are smoke tests against real cluster data. They verify the plugin loads and renders without errors, not specific data values.
## Test Coverage
### Current Tests (`polaris.spec.ts`)
1. **`sidebar contains Polaris entry`**
- Verifies Polaris appears in the navigation sidebar
- Ensures plugin successfully registered sidebar entry
2. **`overview page renders cluster score`**
- Navigates to `/c/main/polaris`
- Checks for "Polaris — Overview" heading
- Verifies cluster score percentage is displayed
- Validates data fetching and rendering
3. **`namespaces page renders table with namespace buttons`**
- Navigates to `/c/main/polaris/namespaces`
- Checks for "Polaris — Namespaces" heading
- Verifies table is visible with at least one row
- Ensures namespace buttons are clickable
4. **`namespace detail drawer opens from table button`**
- Clicks first namespace button in table
- Verifies drawer opens with namespace name in heading
- Checks "Namespace Score" section is visible
- Confirms "Resources" table is displayed
- Validates URL hash is updated with namespace name
5. **`namespace detail drawer closes with Escape key`**
- Opens namespace drawer
- Presses Escape key
- Verifies drawer closes
- Checks URL hash is cleared
6. **`namespace detail drawer opens from URL hash`**
- Navigates directly to `/c/main/polaris/namespaces#<namespace>`
- Verifies drawer automatically opens
- Checks namespace details are displayed
## Prerequisites
### Cluster Requirements
1. **Polaris Deployment**
```bash
# Verify Polaris is running
kubectl -n polaris get pods
kubectl -n polaris get svc polaris-dashboard
```
2. **Polaris Audit Data**
```bash
# Check if Polaris has generated audit results
kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json | jq '.AuditTime'
```
3. **RBAC Permissions**
- Headlamp service account (or test user) needs `get` on `services/proxy` for `polaris-dashboard`
- See main README for RBAC setup
### Local Setup
```bash
# 1. Install dependencies
npm install
npx playwright install chromium
# 2. Create .env file (optional, for persistent config)
cp .env.example .env
# 3. Set environment variables
export HEADLAMP_URL=https://your-headlamp-instance.com
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n kube-system)
# 4. Run tests
npm run e2e
```
## Debugging
### Run in Headed Mode
See the browser UI while tests run:
```bash
npm run e2e:headed
```
### Enable Debug Mode
Step through tests with Playwright Inspector:
```bash
npx playwright test --debug
```
### Generate Trace
Record full trace for failed tests:
```bash
npx playwright test --trace on
npx playwright show-trace test-results/<test-name>/trace.zip
```
### Screenshot on Failure
Tests automatically capture screenshots on failure in `test-results/`
### Common Issues
**Auth fails with "Sign In button not found":**
- Check HEADLAMP_URL is correct
- Verify Headlamp is accessible
- Ensure OIDC is configured if using Authentik
**Polaris sidebar entry not found:**
- Plugin may not be installed: Check Settings → Plugins in Headlamp
- Plugin may have failed to load: Check browser console
- Clear browser cache and hard refresh
**Cluster score not displayed:**
- Polaris may not have audit data yet
- Check Polaris is running: `kubectl -n polaris get pods`
- Verify service proxy: `kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json`
**Namespace table empty:**
- Polaris hasn't run audit yet (wait a few minutes)
- Check Polaris logs: `kubectl -n polaris logs -l app.kubernetes.io/name=polaris`
## Writing New Tests
### Example: Testing Plugin Settings
```typescript
test('plugin settings page shows Polaris configuration', async ({ page }) => {
await page.goto('/c/main/settings/plugins');
// Find and click Polaris plugin
await page.getByText('headlamp-polaris-plugin').click();
// Check settings are visible
await expect(page.getByText('Polaris Settings')).toBeVisible();
await expect(page.getByText('Refresh Interval')).toBeVisible();
await expect(page.getByText('Dashboard URL')).toBeVisible();
});
```
### Example: Testing App Bar Badge
```typescript
test('app bar displays Polaris score badge', async ({ page }) => {
await page.goto('/c/main');
// Badge should be visible in app bar
const badge = page.getByRole('button', { name: /Polaris: \d+%/ });
await expect(badge).toBeVisible();
// Clicking should navigate to overview
await badge.click();
await expect(page).toHaveURL(/\/c\/main\/polaris$/);
});
```
### Example: Testing Dark Mode
```typescript
test('plugin UI adapts to dark mode', async ({ page }) => {
await page.goto('/c/main/polaris');
// Toggle dark mode
await page.getByRole('button', { name: /theme/i }).click();
// Check background color changes
const body = page.locator('body');
await expect(body).toHaveCSS('background-color', 'rgb(18, 18, 18)');
// Plugin components should adapt
const sectionBox = page.locator('[class*="MuiPaper"]').first();
await expect(sectionBox).not.toHaveCSS('background-color', 'rgb(255, 255, 255)');
});
```
## CI/CD Integration
Tests run automatically in GitHub Actions on pushes to `main` and pull requests. See `.github/workflows/e2e.yaml` for workflow configuration.
### Required Secrets
Configure these in GitHub repository settings (Settings → Secrets and variables → Actions):
- `HEADLAMP_URL` (optional): Headlamp instance URL
- `AUTHENTIK_USERNAME` + `AUTHENTIK_PASSWORD` (for OIDC auth)
- OR `HEADLAMP_TOKEN` (for token-based auth)
### Workflow Overview
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
You can manually trigger E2E tests from GitHub Actions:
1. Go to Actions → E2E Tests
2. Click "Run workflow"
3. Select branch and run
## Best Practices
1. **Use semantic selectors**: `getByRole`, `getByText` over CSS selectors
2. **Wait for visibility**: Use `await expect(...).toBeVisible()` instead of `waitForTimeout`
3. **Keep tests independent**: Each test should work in isolation
4. **Test user flows**: Complete journeys, not just page loads
5. **Clean up state**: Close drawers/modals after tests
6. **Use storage state**: Reuse auth across tests (already configured)
7. **Parallelize carefully**: Currently disabled due to shared state
## Resources
- [Playwright Documentation](https://playwright.dev/)
- [Playwright Best Practices](https://playwright.dev/docs/best-practices)
- [Headlamp Plugin Development](https://headlamp.dev/docs/latest/development/plugins/)
- [Project Main README](../README.md)
+90
View File
@@ -0,0 +1,90 @@
import { test, expect } from '@playwright/test';
test.describe('Polaris app bar badge', () => {
test('badge displays cluster score in app bar', async ({ page }) => {
await page.goto('/c/main');
// Wait for page to load
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible();
// Badge should be visible in app bar with score percentage
const badge = page.getByRole('button', { name: /Polaris: \d+%/ });
await expect(badge).toBeVisible({ timeout: 15_000 });
// Badge should show shield emoji
await expect(badge).toContainText('🛡️');
});
test('clicking badge navigates to overview page', async ({ page }) => {
await page.goto('/c/main');
// Find and click the badge
const badge = page.getByRole('button', { name: /Polaris: \d+%/ });
await expect(badge).toBeVisible({ timeout: 15_000 });
await badge.click();
// Should navigate to Polaris overview
await expect(page).toHaveURL(/\/c\/main\/polaris$/);
await expect(page.getByRole('heading', { name: 'Polaris — Overview' })).toBeVisible();
});
test('badge color reflects score level', async ({ page }) => {
await page.goto('/c/main');
// Get the badge
const badge = page.getByRole('button', { name: /Polaris: \d+%/ });
await expect(badge).toBeVisible({ timeout: 15_000 });
// Extract score from button text
const badgeText = await badge.textContent();
const scoreMatch = badgeText?.match(/(\d+)%/);
expect(scoreMatch).toBeTruthy();
const score = parseInt(scoreMatch![1]);
// Check background color matches score level
const bgColor = await badge.evaluate(el =>
window.getComputedStyle(el).backgroundColor
);
// Verify that the badge has a non-default background color applied
// (theme-dependent RGB values vary across Headlamp versions, so we
// only assert that a real color is set rather than transparent/default)
expect(bgColor).not.toBe('rgba(0, 0, 0, 0)');
expect(bgColor).not.toBe('transparent');
expect(bgColor).toMatch(/^rgb/);
});
test('badge updates when navigating between clusters', async ({ page }) => {
// This test assumes multi-cluster setup; skip if only one cluster
await page.goto('/c/main');
// Get initial badge score
const badge = page.getByRole('button', { name: /Polaris: \d+%/ });
await expect(badge).toBeVisible({ timeout: 15_000 });
const initialScore = await badge.textContent();
// Try to switch clusters (if available)
const clusterSelector = page.getByRole('button', { name: /cluster/i });
if (await clusterSelector.isVisible()) {
// Note: This part will only work in multi-cluster setups
// For single-cluster, this test will just verify badge persists
await clusterSelector.click();
// Select different cluster if available
const clusterOptions = page.getByRole('menuitem');
const count = await clusterOptions.count();
if (count > 1) {
await clusterOptions.nth(1).click();
// Badge should update or disappear (if new cluster doesn't have Polaris)
// This is just verifying no crash occurs
await page.waitForTimeout(2000);
}
}
// Badge should still be functional
await expect(badge).toBeEnabled();
});
});
+76
View File
@@ -0,0 +1,76 @@
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> {
// Navigate to login — Headlamp redirects / to /c/main/login
await page.goto('/');
await page.waitForURL('**/login');
// Click "Sign In" and capture the Authentik popup
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: /sign in/i }).click();
const popup = await popupPromise;
// Wait for the Authentik popup to fully load before interacting
await popup.waitForLoadState('domcontentloaded');
await popup.waitForLoadState('networkidle');
// Authentik step 1: fill username — wait for the form to render
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();
// Authentik step 2: fill password — wait for the next step to load
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();
// Wait for the popup to close (Authentik redirects back, Headlamp processes callback)
await popup.waitForEvent('close', { timeout: 15_000 });
// Original page should now be authenticated — wait for sidebar
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({
timeout: 15_000,
});
}
async function authenticateWithToken(page: Page, token: string): Promise<void> {
// Navigate to login — Headlamp redirects / to /c/main/login
await page.goto('/');
await page.waitForURL('**/login');
// 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);
await page.getByRole('button', { name: /authenticate/i }).click();
// Wait for the main UI to load
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 });
});
+110
View File
@@ -0,0 +1,110 @@
import { test, expect } from '@playwright/test';
test.describe('Polaris plugin smoke tests', () => {
test('sidebar contains Polaris entry', async ({ page }) => {
await page.goto('/');
// The sidebar is the "Navigation" nav element (not "Appbar Tools")
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
await expect(sidebar).toBeVisible({ timeout: 15_000 });
await expect(sidebar.getByRole('button', { name: 'Polaris' })).toBeVisible();
});
test('overview page renders cluster score', async ({ page }) => {
await page.goto('/c/main/polaris');
// SectionHeader renders a heading
await expect(page.getByRole('heading', { name: 'Polaris \u2014 Overview' })).toBeVisible();
// "Cluster Score" section exists with a percentage
await expect(page.getByText('Cluster Score')).toBeVisible();
await expect(page.locator('main').getByText(/%/).first()).toBeVisible();
});
test('namespaces page renders table with namespace buttons', async ({ page }) => {
await page.goto('/c/main/polaris/namespaces');
await expect(page.getByRole('heading', { name: 'Polaris \u2014 Namespaces' })).toBeVisible();
// Table should have at least one row with a namespace button
const table = page.locator('table');
await expect(table).toBeVisible();
const rows = table.locator('tbody tr');
await expect(rows.first()).toBeVisible();
// Each namespace row should contain a button (now buttons instead of links for drawer)
const firstButton = rows.first().locator('button');
await expect(firstButton).toBeVisible();
});
test('namespace detail drawer opens from table button', async ({ page }) => {
await page.goto('/c/main/polaris/namespaces');
// Click the first namespace button in the table
const table = page.locator('table');
await expect(table).toBeVisible();
const firstButton = table.locator('tbody tr').first().locator('button');
const namespaceName = await firstButton.textContent();
await firstButton.click();
// Drawer should open and show the namespace name in the heading
await expect(
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
).toBeVisible();
// "Namespace Score" section should be present in drawer
await expect(page.getByText('Namespace Score')).toBeVisible();
// Resources table should exist in drawer
await expect(page.getByRole('heading', { name: 'Resources' })).toBeVisible();
// URL hash should be updated with namespace name
await expect(page).toHaveURL(/\/polaris\/namespaces#/);
});
test('namespace detail drawer closes with Escape key', async ({ page }) => {
await page.goto('/c/main/polaris/namespaces');
// Open the drawer by clicking a namespace button
const table = page.locator('table');
await expect(table).toBeVisible();
const firstButton = table.locator('tbody tr').first().locator('button');
const namespaceName = await firstButton.textContent();
await firstButton.click();
// Verify drawer is open
await expect(
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
).toBeVisible();
// Press Escape key
await page.keyboard.press('Escape');
// Drawer should close (heading should not be visible anymore)
await expect(
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
).not.toBeVisible();
// URL hash should be cleared
await expect(page).toHaveURL(/\/polaris\/namespaces$/);
});
test('namespace detail drawer opens from URL hash', async ({ page }) => {
// Get a namespace name first
await page.goto('/c/main/polaris/namespaces');
const table = page.locator('table');
await expect(table).toBeVisible();
const firstButton = table.locator('tbody tr').first().locator('button');
const namespaceName = await firstButton.textContent();
// Navigate directly to URL with hash
await page.goto(`/c/main/polaris/namespaces#${namespaceName}`);
// Drawer should automatically open with the namespace details
await expect(
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
).toBeVisible();
// "Namespace Score" section should be present
await expect(page.getByText('Namespace Score')).toBeVisible();
});
});
+90
View File
@@ -0,0 +1,90 @@
import { test, expect, Page } from '@playwright/test';
/** Navigate to the Polaris plugin settings page and wait for settings to render. */
async function goToPolarisSettings(page: Page) {
// Headlamp's plugin settings page is a HOME-context route at /settings/plugins,
// not an in-cluster route (/c/main/settings/plugins would 404). Headlamp loads
// plugin scripts asynchronously on SPA init. When registerPluginSettings() fires,
// it dispatches a Redux action — PluginSettings uses useTypedSelector so it
// re-renders automatically once the plugin registers. No preloading needed.
await page.goto('/settings/plugins');
// Wait for the plugin to appear in the settings list. The timeout covers
// async plugin script loading + registration.
const pluginEntry = page.locator('text=headlamp-polaris').first();
await expect(pluginEntry).toBeVisible({ timeout: 30_000 });
await pluginEntry.click();
// Wait for the PolarisSettings component to render
await expect(page.getByText('Polaris Settings')).toBeVisible({ timeout: 15_000 });
}
test.describe('Polaris plugin settings', () => {
test('settings page shows configuration options', async ({ page }) => {
await goToPolarisSettings(page);
// SectionBox title should be visible
await expect(page.getByText('Polaris Settings')).toBeVisible();
});
test('refresh interval setting is configurable', async ({ page }) => {
await goToPolarisSettings(page);
// Find the refresh interval dropdown
const intervalSelect = page.locator('select').filter({ hasText: /minute|second/ });
await expect(intervalSelect).toBeVisible();
// Get current value
const currentValue = await intervalSelect.inputValue();
// Change to a different value
const newValue = currentValue === '300' ? '600' : '300';
await intervalSelect.selectOption(newValue);
// Value should be updated
await expect(intervalSelect).toHaveValue(newValue);
});
test('dashboard URL setting is configurable', async ({ page }) => {
await goToPolarisSettings(page);
// Find the dashboard URL input
const urlInput = page.getByPlaceholder(/polaris-dashboard/);
await expect(urlInput).toBeVisible();
// Input should have the default proxy URL or custom URL
const currentUrl = await urlInput.inputValue();
expect(currentUrl).toBeTruthy();
// Examples text should be visible
await expect(page.getByText('Examples:')).toBeVisible();
await expect(page.getByText(/K8s proxy:/)).toBeVisible();
});
test('connection test button is available', async ({ page }) => {
await goToPolarisSettings(page);
// Find and verify test connection button
const testButton = page.getByRole('button', { name: /test connection/i });
await expect(testButton).toBeVisible();
await expect(testButton).toBeEnabled();
});
test('connection test works with valid URL', async ({ page }) => {
await goToPolarisSettings(page);
// Click test connection
const testButton = page.getByRole('button', { name: /test connection/i });
await testButton.click();
// Wait for either success or error message
// Note: This will succeed if Polaris is accessible, fail otherwise
await page.waitForSelector('text=/Connected successfully|Connection failed/', {
timeout: 15_000,
});
// Either success or failure is acceptable (depends on environment)
const result = await page.textContent('body');
expect(result).toMatch(/(Connected successfully|Connection failed)/);
});
});
+18553
View File
File diff suppressed because it is too large Load Diff
+9 -20
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",
@@ -23,42 +22,32 @@
"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"
},
"peerDependencies": {
"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",
"fast-uri": ">=3.1.2"
}
"overrides": {
"tar": "^7.5.11",
"undici": "^7.24.3"
},
"devDependencies": {
"@kinvolk/headlamp-plugin": "^0.14.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",
"@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"
}
+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 || 'https://headlamp.animaniacs.farh.net',
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'],
},
],
});
+233 -647
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"
}
]
}
+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."
-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,
},
},
},
});