diff --git a/CLAUDE.md b/CLAUDE.md index b641108..fdec9f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,10 +15,11 @@ Each skill follows this convention: ## Current Skills - **`github-app-token`** — Generates short-lived GitHub App installation access tokens. Requires `GITHUB_APP_ID`, `GITHUB_APP_INSTALLATION_ID`, and `GITHUB_APP_PEM_FILE` env vars. Use `--raw` flag to get the token value directly (recommended for agents), or omit for legacy `eval`-based `export GH_TOKEN=...` output. +- **`playwright-ephemeral`** — Provisions ephemeral Playwright MCP browser sessions as Kubernetes Jobs for E2E testing. Creates a Job + Service pair in a dedicated namespace, waits for readiness, and returns the MCP endpoint URL. Requires `kubectl` and appropriate RBAC. ## Key Patterns -- Scripts are pure bash with no external dependencies beyond standard Unix tools (`openssl`, `curl`, `jq`). +- Scripts are pure bash with no external dependencies beyond standard Unix tools (`openssl`, `curl`, `jq`, `kubectl`). - The `--raw` output pattern (preferred): scripts with `--raw` print only the value to stdout for easy `$(...)` capture. The legacy `eval` pattern (no flag) prints shell commands like `export VAR="value"` for backward compatibility. - The `die()` function prints errors to stderr and exits non-zero. diff --git a/playwright-ephemeral/SKILL.md b/playwright-ephemeral/SKILL.md new file mode 100644 index 0000000..66fda7b --- /dev/null +++ b/playwright-ephemeral/SKILL.md @@ -0,0 +1,87 @@ +--- +name: playwright-ephemeral +description: Provision and tear down ephemeral Playwright MCP browser sessions as Kubernetes Jobs for E2E testing. +--- + +# Ephemeral Playwright Browser Provisioning + +Provision an ephemeral Playwright MCP browser session running in a Kubernetes Job. Use this when you need to drive a real browser for E2E or integration testing. + +## Prerequisites + +| Requirement | Description | +|---|---| +| `kubectl` | Must be available and configured with cluster access | +| RBAC | Agent service account needs Jobs and Services CRUD in `playwright-sessions` namespace | +| Network | Cluster networking must allow traffic from agent pod to `playwright-sessions` namespace | + +## When to Use + +- You need to drive a real Chromium browser (click, navigate, screenshot, scrape) +- You are running E2E or integration tests against a web application +- You need a Playwright MCP server endpoint to connect your MCP client to + +## Provision a Session + +Run the provision script. It creates a Kubernetes Job + Service pair and waits until the Playwright MCP server is accepting connections. + +```bash +RESULT=$(bash ./playwright-ephemeral/scripts/provision.sh) +``` + +On success, the script prints two lines to stdout: + +``` +SESSION_NAME=playwright-- +MCP_URL=http://playwright--.playwright-sessions.svc.cluster.local:8931/mcp +``` + +Extract the values: + +```bash +SESSION_NAME=$(echo "$RESULT" | grep '^SESSION_NAME=' | cut -d= -f2) +MCP_URL=$(echo "$RESULT" | grep '^MCP_URL=' | cut -d= -f2) +``` + +### Optional Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `PLAYWRIGHT_NAMESPACE` | `playwright-sessions` | Kubernetes namespace for browser pods | +| `PLAYWRIGHT_TIMEOUT` | `120` | Seconds to wait for the MCP server to become ready | +| `PLAYWRIGHT_TTL` | `1800` | TTL in seconds after Job finishes (auto-cleanup) | +| `PLAYWRIGHT_DEADLINE` | `1800` | Hard ceiling in seconds for the Job (kills zombie sessions) | +| `PLAYWRIGHT_MEMORY_REQUEST` | `512Mi` | Memory request for the browser container | +| `PLAYWRIGHT_MEMORY_LIMIT` | `1Gi` | Memory limit for the browser container | + +## Connect to the Session + +Configure your Playwright MCP client with the returned `MCP_URL`. The endpoint speaks HTTP-based MCP transport on port 8931 at the `/mcp` path. + +Example: if `MCP_URL=http://playwright-goose-a1b2c3.playwright-sessions.svc.cluster.local:8931/mcp`, point your MCP client at that URL. + +## Tear Down a Session + +When you are finished with the browser, tear it down: + +```bash +bash ./playwright-ephemeral/scripts/teardown.sh "$SESSION_NAME" +``` + +This deletes both the Job and Service. If you forget, the Job self-cleans after `PLAYWRIGHT_TTL` seconds and hard-terminates after `PLAYWRIGHT_DEADLINE` seconds. + +## Error Handling + +| Scenario | What Happens | +|---|---| +| Pod fails to schedule | Provision script times out and exits non-zero with an error message | +| MCP server not ready in time | Provision script times out and cleans up the Job/Service before exiting | +| kubectl not found | Script exits immediately with an error | +| Namespace does not exist | Script creates the namespace automatically | + +## Security Notes + +- Each session runs in an isolated pod with its own network identity. +- Sessions are ephemeral — the Job TTL and active deadline prevent resource leaks. +- The browser runs with `--no-sandbox` (required in containers) and headless Chromium only. +- No data persists after teardown. diff --git a/playwright-ephemeral/references/job-template.yaml b/playwright-ephemeral/references/job-template.yaml new file mode 100644 index 0000000..0de1f1b --- /dev/null +++ b/playwright-ephemeral/references/job-template.yaml @@ -0,0 +1,51 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: "{{SESSION_NAME}}" + namespace: "{{NAMESPACE}}" + labels: + app: playwright-mcp + session: "{{SESSION_NAME}}" +spec: + ttlSecondsAfterFinished: {{TTL}} + activeDeadlineSeconds: {{DEADLINE}} + backoffLimit: 0 + template: + metadata: + labels: + app: playwright-mcp + session: "{{SESSION_NAME}}" + spec: + shareProcessNamespace: true + restartPolicy: Never + containers: + - name: playwright-mcp + image: mcr.microsoft.com/playwright/mcp + command: ["node"] + args: + - "cli.js" + - "--headless" + - "--browser" + - "chromium" + - "--no-sandbox" + - "--port" + - "8931" + - "--host" + - "0.0.0.0" + ports: + - containerPort: 8931 + protocol: TCP + resources: + requests: + memory: "{{MEMORY_REQUEST}}" + cpu: "250m" + limits: + memory: "{{MEMORY_LIMIT}}" + cpu: "1" + readinessProbe: + httpGet: + path: /mcp + port: 8931 + initialDelaySeconds: 5 + periodSeconds: 3 + failureThreshold: 20 diff --git a/playwright-ephemeral/references/svc-template.yaml b/playwright-ephemeral/references/svc-template.yaml new file mode 100644 index 0000000..045b52e --- /dev/null +++ b/playwright-ephemeral/references/svc-template.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: "{{SESSION_NAME}}" + namespace: "{{NAMESPACE}}" + labels: + app: playwright-mcp + session: "{{SESSION_NAME}}" +spec: + type: ClusterIP + selector: + session: "{{SESSION_NAME}}" + ports: + - port: 8931 + targetPort: 8931 + protocol: TCP diff --git a/playwright-ephemeral/scripts/provision.sh b/playwright-ephemeral/scripts/provision.sh new file mode 100755 index 0000000..8487374 --- /dev/null +++ b/playwright-ephemeral/scripts/provision.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { echo "ERROR: $*" >&2; exit 1; } + +# --- Dependencies --- +command -v kubectl >/dev/null 2>&1 || die "kubectl is not installed or not in PATH" + +# --- Configuration --- +NAMESPACE="${PLAYWRIGHT_NAMESPACE:-playwright-sessions}" +TIMEOUT="${PLAYWRIGHT_TIMEOUT:-120}" +TTL="${PLAYWRIGHT_TTL:-1800}" +DEADLINE="${PLAYWRIGHT_DEADLINE:-1800}" +MEMORY_REQUEST="${PLAYWRIGHT_MEMORY_REQUEST:-512Mi}" +MEMORY_LIMIT="${PLAYWRIGHT_MEMORY_LIMIT:-1Gi}" + +# Generate unique session name +AGENT_NAME="${PAPERCLIP_AGENT_ID:-agent}" +# Use first 8 chars of agent ID + random suffix +AGENT_SHORT="${AGENT_NAME:0:8}" +SHORT_UUID=$(head -c 6 /dev/urandom | od -An -tx1 | tr -d ' \n' | head -c 8) +SESSION_NAME="playwright-${AGENT_SHORT}-${SHORT_UUID}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REF_DIR="${SCRIPT_DIR}/../references" + +# --- Ensure namespace exists --- +if ! kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then + echo "Creating namespace $NAMESPACE..." >&2 + kubectl create namespace "$NAMESPACE" +fi + +# --- Render and apply Job manifest --- +sed \ + -e "s|{{SESSION_NAME}}|${SESSION_NAME}|g" \ + -e "s|{{NAMESPACE}}|${NAMESPACE}|g" \ + -e "s|{{TTL}}|${TTL}|g" \ + -e "s|{{DEADLINE}}|${DEADLINE}|g" \ + -e "s|{{MEMORY_REQUEST}}|${MEMORY_REQUEST}|g" \ + -e "s|{{MEMORY_LIMIT}}|${MEMORY_LIMIT}|g" \ + "$REF_DIR/job-template.yaml" | kubectl apply -f - >&2 + +# --- Render and apply Service manifest --- +sed \ + -e "s|{{SESSION_NAME}}|${SESSION_NAME}|g" \ + -e "s|{{NAMESPACE}}|${NAMESPACE}|g" \ + "$REF_DIR/svc-template.yaml" | kubectl apply -f - >&2 + +# --- Wait for pod to be Ready --- +echo "Waiting for pod to be ready (timeout: ${TIMEOUT}s)..." >&2 +SECONDS=0 +POD_READY=false + +while [ "$SECONDS" -lt "$TIMEOUT" ]; do + POD_NAME=$(kubectl get pods -n "$NAMESPACE" -l "session=${SESSION_NAME}" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) + + if [ -n "$POD_NAME" ]; then + PHASE=$(kubectl get pod -n "$NAMESPACE" "$POD_NAME" -o jsonpath='{.status.phase}' 2>/dev/null || true) + if [ "$PHASE" = "Running" ]; then + READY=$(kubectl get pod -n "$NAMESPACE" "$POD_NAME" -o jsonpath='{.status.containerStatuses[0].ready}' 2>/dev/null || true) + if [ "$READY" = "true" ]; then + POD_READY=true + break + fi + elif [ "$PHASE" = "Failed" ] || [ "$PHASE" = "Succeeded" ]; then + # Cleanup on failure + echo "Pod entered $PHASE state unexpectedly. Cleaning up..." >&2 + kubectl delete job "$SESSION_NAME" -n "$NAMESPACE" --ignore-not-found >&2 + kubectl delete service "$SESSION_NAME" -n "$NAMESPACE" --ignore-not-found >&2 + die "Pod failed to start (phase: $PHASE)" + fi + fi + + sleep 3 +done + +if [ "$POD_READY" != "true" ]; then + echo "Timed out waiting for MCP server. Cleaning up..." >&2 + kubectl delete job "$SESSION_NAME" -n "$NAMESPACE" --ignore-not-found >&2 + kubectl delete service "$SESSION_NAME" -n "$NAMESPACE" --ignore-not-found >&2 + die "Playwright MCP server did not become ready within ${TIMEOUT}s" +fi + +MCP_URL="http://${SESSION_NAME}.${NAMESPACE}.svc.cluster.local:8931/mcp" + +echo "Playwright MCP session ready." >&2 +echo "SESSION_NAME=${SESSION_NAME}" +echo "MCP_URL=${MCP_URL}" diff --git a/playwright-ephemeral/scripts/teardown.sh b/playwright-ephemeral/scripts/teardown.sh new file mode 100755 index 0000000..f093c4c --- /dev/null +++ b/playwright-ephemeral/scripts/teardown.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { echo "ERROR: $*" >&2; exit 1; } + +# --- Dependencies --- +command -v kubectl >/dev/null 2>&1 || die "kubectl is not installed or not in PATH" + +# --- Arguments --- +SESSION_NAME="${1:-}" +[ -n "$SESSION_NAME" ] || die "Usage: teardown.sh " + +NAMESPACE="${PLAYWRIGHT_NAMESPACE:-playwright-sessions}" + +# --- Delete Job and Service --- +echo "Tearing down session: ${SESSION_NAME} in namespace: ${NAMESPACE}" >&2 + +kubectl delete job "$SESSION_NAME" -n "$NAMESPACE" --ignore-not-found >&2 +echo "Job deleted." >&2 + +kubectl delete service "$SESSION_NAME" -n "$NAMESPACE" --ignore-not-found >&2 +echo "Service deleted." >&2 + +echo "Session ${SESSION_NAME} torn down successfully." >&2