ci: add ConfigMap + init container E2E plugin deploy (CI-only) #57
@@ -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
|
||||
Executable
+145
@@ -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 <<EOF
|
||||
{
|
||||
"spec": {
|
||||
"template": {
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"e2e-plugin-deploy-ts": "${DEPLOY_TIMESTAMP}"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"initContainers": [
|
||||
{
|
||||
"name": "install-e2e-plugin",
|
||||
"image": "busybox:latest",
|
||||
"command": [
|
||||
"sh", "-c",
|
||||
"mkdir -p /plugins/${PLUGIN_NAME} && cd /plugins/${PLUGIN_NAME} && tar xzf /plugin-src/plugin.tar.gz && echo 'Plugin extracted successfully' && ls -la"
|
||||
],
|
||||
"volumeMounts": [
|
||||
{"name": "static-plugins", "mountPath": "/plugins"},
|
||||
{"name": "e2e-plugin-src", "mountPath": "/plugin-src", "readOnly": true}
|
||||
]
|
||||
}
|
||||
],
|
||||
"volumes": [
|
||||
{
|
||||
"name": "e2e-plugin-src",
|
||||
"configMap": {
|
||||
"name": "${CONFIGMAP_NAME}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)"
|
||||
|
||||
# --- Step 4: Wait for rollout ---
|
||||
echo "Waiting for rollout..."
|
||||
kubectl rollout status deployment/"$HEADLAMP_DEPLOY" -n "$HEADLAMP_NS" --timeout=180s
|
||||
|
||||
# --- Step 5: Verify Headlamp is ready ---
|
||||
echo "Verifying Headlamp readiness..."
|
||||
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" = "200" ]; then
|
||||
echo "Headlamp is ready (HTTP 200)"
|
||||
|
||||
# Verify plugin is loaded
|
||||
PLUGIN_CHECK=$(curl -sf "$HEADLAMP_URL/plugins" 2>/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
|
||||
Executable
+141
@@ -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
|
||||
Reference in New Issue
Block a user