diff --git a/deployment/e2e-runner-rbac.yaml b/deployment/e2e-runner-rbac.yaml new file mode 100644 index 0000000..b26334b --- /dev/null +++ b/deployment/e2e-runner-rbac.yaml @@ -0,0 +1,61 @@ +--- +# ============================================================================ +# CI-ONLY TEST FIXTURE — NOT part of production deployment. +# +# RBAC for the self-hosted GitHub Actions runner ServiceAccount to deploy +# plugins to Headlamp via ConfigMap + deployment patch in E2E CI. +# +# Approved under CTO decision PRI-200 (narrowly-scoped CI exception). +# Production plugin distribution remains ArtifactHub-only. +# ============================================================================ +# +# Grants ONLY the permissions needed by scripts/deploy-plugin-to-headlamp.sh: +# - configmaps: create/get/update (store the plugin tarball) +# - deployments: get/patch (add the init container that extracts the plugin) +# - replicasets: get/list (for kubectl rollout status) +# - pods: get/list (for rollout readiness check) +# +# No pod exec or pod cp access is required. +# +# Apply with: +# kubectl apply -f deployment/e2e-runner-rbac.yaml +# +# The runner SA name comes from the ARC (Actions Runner Controller) deployment. +# Adjust the serviceaccount name/namespace if your runner uses a different identity. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: e2e-plugin-deployer + namespace: kube-system +rules: + # Store plugin tarball in a ConfigMap + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["create", "get", "update", "patch"] + # Patch the Headlamp deployment to add the init container + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "patch"] + # Required for kubectl rollout status + - apiGroups: ["apps"] + resources: ["replicasets"] + verbs: ["get", "list"] + # Required for rollout status pod readiness check + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: e2e-plugin-deployer + namespace: kube-system +subjects: + - kind: ServiceAccount + name: local-ubuntu-latest-gha-rs-no-permission + namespace: arc-runners +roleRef: + kind: Role + name: e2e-plugin-deployer + apiGroup: rbac.authorization.k8s.io diff --git a/scripts/deploy-plugin-to-headlamp.sh b/scripts/deploy-plugin-to-headlamp.sh new file mode 100755 index 0000000..e116e91 --- /dev/null +++ b/scripts/deploy-plugin-to-headlamp.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# ============================================================================ +# CI-ONLY TEST FIXTURE — NOT a user installation method. +# +# Production plugin distribution is ArtifactHub-only via Headlamp's native +# plugin installer. This script exists solely for E2E CI to deploy freshly- +# built plugin artifacts to a test Headlamp instance. +# +# Approved under CTO decision PRI-200 (narrowly-scoped CI exception). +# ============================================================================ +# +# Deploy the built plugin to a live Headlamp instance via ConfigMap + init +# container. No kubectl exec or kubectl cp is used — only standard Kubernetes +# API operations (create configmap, patch deployment, rollout status). +# +# The script: +# 1. Packages dist/ + package.json as a tarball +# 2. Stores the tarball in a Kubernetes ConfigMap +# 3. Patches the Headlamp deployment with an init container that extracts +# the plugin into the static-plugins volume before Headlamp starts +# 4. Waits for rollout and verifies readiness +# +# Prerequisites: +# - kubectl configured with access to the Headlamp namespace +# - Plugin already built (npm run build → dist/) +# - Headlamp deployment uses a "static-plugins" volume (emptyDir) +# +# Environment variables (all optional, with defaults): +# HEADLAMP_URL — Headlamp URL for readiness check +# HEADLAMP_NS — Kubernetes namespace (default: kube-system) +# HEADLAMP_DEPLOY — Headlamp deployment name (default: headlamp) +# PLUGIN_NAME — Plugin directory name (default: polaris) +# +# Usage: +# npm run build +# ./scripts/deploy-plugin-to-headlamp.sh + +set -euo pipefail + +HEADLAMP_URL="${HEADLAMP_URL:-http://headlamp.kube-system.svc.cluster.local}" +HEADLAMP_NS="${HEADLAMP_NS:-kube-system}" +HEADLAMP_DEPLOY="${HEADLAMP_DEPLOY:-headlamp}" +PLUGIN_NAME="${PLUGIN_NAME:-polaris}" +CONFIGMAP_NAME="headlamp-e2e-plugin-${PLUGIN_NAME}" + +if [ ! -d "dist" ]; then + echo "Error: dist/ not found. Run 'npm run build' first." >&2 + exit 1 +fi + +# --- Step 1: Package plugin as tarball --- +echo "Packaging plugin..." +TARBALL=$(mktemp /tmp/${PLUGIN_NAME}-plugin-XXXXXX.tar.gz) +tar czf "$TARBALL" dist/ package.json +echo " tarball size: $(du -h "$TARBALL" | cut -f1)" + +# ConfigMap binary data limit is ~1MB +TARBALL_SIZE=$(stat -c%s "$TARBALL" 2>/dev/null || stat -f%z "$TARBALL") +if [ "$TARBALL_SIZE" -gt 1000000 ]; then + echo "Error: Plugin tarball (${TARBALL_SIZE} bytes) exceeds ConfigMap 1MB limit." >&2 + rm -f "$TARBALL" + exit 1 +fi + +# --- Step 2: Store tarball in a ConfigMap --- +echo "Creating ConfigMap ${CONFIGMAP_NAME}..." +kubectl create configmap "$CONFIGMAP_NAME" \ + --from-file="plugin.tar.gz=${TARBALL}" \ + -n "$HEADLAMP_NS" \ + --dry-run=client -o yaml | kubectl apply -f - +rm -f "$TARBALL" + +# --- Step 3: Patch the Headlamp deployment --- +# Adds an init container that extracts the plugin tarball into the static-plugins +# volume. Uses strategic merge — init containers merge by name, so re-running +# this script updates the existing init container rather than adding duplicates. +# A timestamp annotation forces a rollout even if the patch shape is unchanged. +echo "Patching Headlamp deployment..." +DEPLOY_TIMESTAMP=$(date -u +%Y%m%dT%H%M%SZ) + +kubectl patch deployment "$HEADLAMP_DEPLOY" -n "$HEADLAMP_NS" --type=strategic -p "$(cat </dev/null || echo "[]") + if echo "$PLUGIN_CHECK" | grep -q "$PLUGIN_NAME"; then + echo "Plugin '${PLUGIN_NAME}' is loaded" + else + echo "::warning::Plugin '${PLUGIN_NAME}' not found in /plugins response" + fi + exit 0 + fi + echo " attempt $i/30 — HTTP $HTTP_CODE" + sleep 5 +done + +echo "Error: Headlamp did not recover after plugin deploy" >&2 +exit 1 diff --git a/scripts/deploy-plugin-to-headlamp.test.sh b/scripts/deploy-plugin-to-headlamp.test.sh new file mode 100755 index 0000000..1e24ba5 --- /dev/null +++ b/scripts/deploy-plugin-to-headlamp.test.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# ============================================================================ +# CI-ONLY TEST FIXTURE — tests for scripts/deploy-plugin-to-headlamp.sh +# +# Validates the deploy script's precondition checks without requiring a +# live Kubernetes cluster. Run from the repo root: +# +# bash scripts/deploy-plugin-to-headlamp.test.sh +# ============================================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="$SCRIPT_DIR/deploy-plugin-to-headlamp.sh" +PASS=0 +FAIL=0 + +assert_exit_code() { + local description="$1" + local expected="$2" + local actual="$3" + if [ "$actual" -eq "$expected" ]; then + echo " PASS: $description" + PASS=$((PASS + 1)) + else + echo " FAIL: $description (expected exit $expected, got $actual)" + FAIL=$((FAIL + 1)) + fi +} + +echo "=== Deploy script precondition tests ===" + +# Test 1: Script fails when dist/ does not exist +echo "" +echo "Test 1: Should fail when dist/ directory is missing" +TMPDIR=$(mktemp -d) +cd "$TMPDIR" +set +e +bash "$SCRIPT" >/dev/null 2>&1 +EXIT_CODE=$? +set -e +assert_exit_code "Exits with error when dist/ is missing" 1 "$EXIT_CODE" +rm -rf "$TMPDIR" + +# Test 2: Script is executable +echo "" +echo "Test 2: Script should be executable" +if [ -x "$SCRIPT" ]; then + echo " PASS: Script is executable" + PASS=$((PASS + 1)) +else + echo " FAIL: Script is not executable" + FAIL=$((FAIL + 1)) +fi + +# Test 3: Script has CI-only header comment +echo "" +echo "Test 3: Script should have CI-only fixture header" +if grep -q "CI-ONLY TEST FIXTURE" "$SCRIPT"; then + echo " PASS: CI-only header present" + PASS=$((PASS + 1)) +else + echo " FAIL: Missing CI-only header" + FAIL=$((FAIL + 1)) +fi + +# Test 4: Script does NOT use kubectl exec or kubectl cp +echo "" +echo "Test 4: Script must not use kubectl exec or kubectl cp" +if grep -v '^\s*#' "$SCRIPT" | grep -qE 'kubectl\s+(exec|cp)'; then + echo " FAIL: Script contains kubectl exec/cp" + FAIL=$((FAIL + 1)) +else + echo " PASS: No kubectl exec/cp found" + PASS=$((PASS + 1)) +fi + +# Test 5: Script uses kubectl create configmap +echo "" +echo "Test 5: Script should use kubectl create configmap" +if grep -q 'kubectl create configmap' "$SCRIPT"; then + echo " PASS: Uses kubectl create configmap" + PASS=$((PASS + 1)) +else + echo " FAIL: Missing kubectl create configmap" + FAIL=$((FAIL + 1)) +fi + +# Test 6: Script uses kubectl patch deployment +echo "" +echo "Test 6: Script should use kubectl patch deployment" +if grep -q 'kubectl patch deployment' "$SCRIPT"; then + echo " PASS: Uses kubectl patch deployment" + PASS=$((PASS + 1)) +else + echo " FAIL: Missing kubectl patch deployment" + FAIL=$((FAIL + 1)) +fi + +# Test 7: ConfigMap size guard exists +echo "" +echo "Test 7: Script should guard against ConfigMap size limit" +if grep -q '1000000' "$SCRIPT"; then + echo " PASS: ConfigMap size guard present" + PASS=$((PASS + 1)) +else + echo " FAIL: Missing ConfigMap size guard" + FAIL=$((FAIL + 1)) +fi + +# Test 8: RBAC manifest does not grant exec access +echo "" +echo "Test 8: RBAC manifest must not grant exec access" +RBAC_FILE="$SCRIPT_DIR/../deployment/e2e-runner-rbac.yaml" +if [ -f "$RBAC_FILE" ]; then + if grep -qE '"(exec|cp)"' "$RBAC_FILE" || grep -qE "'(exec|cp)'" "$RBAC_FILE"; then + echo " FAIL: RBAC manifest grants exec/cp access" + FAIL=$((FAIL + 1)) + else + echo " PASS: No exec/cp in RBAC manifest" + PASS=$((PASS + 1)) + fi + + # Also check the verbs explicitly + if grep -q 'pods/exec' "$RBAC_FILE"; then + echo " FAIL: RBAC manifest grants pods/exec" + FAIL=$((FAIL + 1)) + else + echo " PASS: No pods/exec in RBAC manifest" + PASS=$((PASS + 1)) + fi +else + echo " SKIP: RBAC manifest not found at $RBAC_FILE" +fi + +echo "" +echo "=== Results: $PASS passed, $FAIL failed ===" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi