diff --git a/infra/scripts/deploy/java-on-amazon-eks/app-deploy.sh b/infra/scripts/deploy/java-on-amazon-eks/app-deploy.sh index de36e9f5..6df8b12d 100755 --- a/infra/scripts/deploy/java-on-amazon-eks/app-deploy.sh +++ b/infra/scripts/deploy/java-on-amazon-eks/app-deploy.sh @@ -1,5 +1,68 @@ #!/bin/bash -# java-on-amazon-eks runs the java-performance implementation of this step: the two workshops -# share the app, the sensor and the profiler, and one copy keeps a fix in one place. Replace -# this wrapper with an own script when the workshops diverge. -exec bash "$(dirname "${BASH_SOURCE[0]}")/../java-performance/$(basename "${BASH_SOURCE[0]}")" "$@" +# ============================================================================= +# app-deploy.sh — deploy the participant's starting point to the cluster. +# +# Applies what ~/environment/unicorn-store-spring/k8s holds (written by app-prepare.sh): +# namespace + ServiceAccount, Pod Identity association to unicornstore-eks-pod-role, +# SecretProviderClass, Deployment, Service, Ingress. Waits for the Deployment to be +# Available. The ALB takes ~2 more minutes to provision; verify.sh checks it, so the +# bootstrap can install the platform pieces meanwhile. +# +# Needs kubectl against the cluster and the :latest image (app-prepare.sh). Idempotent. +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/../../lib/common.sh" +[ -f /etc/profile.d/workshop.sh ] && source /etc/profile.d/workshop.sh + +APP_NAME="unicorn-store-spring" +NS="${APP_NAME}" +K8S="${HOME}/environment/${APP_NAME}/k8s" +CLUSTER_NAME="${PREFIX:-workshop}-eks" +POD_ROLE="unicornstore-eks-pod-role" + +[ -f "${K8S}/deployment.yaml" ] || { log_error "${K8S}/deployment.yaml missing — run app-prepare.sh first"; exit 1; } + +log_info "Namespace + ServiceAccount ${NS}..." +kubectl create namespace "${NS}" --dry-run=client -o yaml | kubectl apply -f - >/dev/null +kubectl -n "${NS}" create serviceaccount "${APP_NAME}" --dry-run=client -o yaml | kubectl apply -f - >/dev/null + +log_info "Pod Identity: ${NS}/${APP_NAME} -> ${POD_ROLE}..." +if ! aws eks list-pod-identity-associations --cluster-name "${CLUSTER_NAME}" \ + --query "associations[?serviceAccount=='${APP_NAME}' && namespace=='${NS}']" --output text --no-cli-pager | grep -q .; then + aws eks create-pod-identity-association --cluster-name "${CLUSTER_NAME}" \ + --namespace "${NS}" --service-account "${APP_NAME}" \ + --role-arn "arn:aws:iam::${ACCOUNT_ID}:role/${POD_ROLE}" --no-cli-pager >/dev/null + log_success "Pod Identity association created" + # The Pod Identity webhook only mutates pods created after the association has propagated; + # a pod created earlier gets no AWS_* env and crash-loops ("Unable to load region"). + sleep 15 +else + log_info "Pod Identity association already exists" +fi + +log_info "Applying manifests from ${K8S}..." +kubectl apply -f "${K8S}/secret-provider-class.yaml" >/dev/null +kubectl apply -f "${K8S}/deployment.yaml" -f "${K8S}/service.yaml" -f "${K8S}/ingress.yaml" >/dev/null + +# Self-heal the same race if propagation took longer than the sleep: once a pod exists, +# check that the webhook injected the Pod Identity env; if not, recreate the pod once. +pod_env="" +for _ in $(seq 1 30); do + pod_env=$(kubectl -n "${NS}" get pods -l app="${APP_NAME}" \ + -o jsonpath="{.items[*].spec.containers[?(@.name=='${APP_NAME}')].env[*].name}" 2>/dev/null || true) + kubectl -n "${NS}" get pods -l app="${APP_NAME}" -o name 2>/dev/null | grep -q . && break + sleep 2 +done +if ! grep -q AWS_CONTAINER_CREDENTIALS_FULL_URI <<<"${pod_env}"; then + log_warning "Pod has no Pod Identity env (association not yet propagated) — restarting the Deployment once" + sleep 15 + kubectl -n "${NS}" rollout restart deployment "${APP_NAME}" >/dev/null +fi + +kubectl -n "${NS}" wait deployment "${APP_NAME}" --for condition=Available=True --timeout=300s >/dev/null \ + || { log_error "Deployment not Available:"; kubectl -n "${NS}" get pods -o wide; kubectl -n "${NS}" describe pod -l app="${APP_NAME}" | tail -30; exit 1; } +log_success "Deployment ${NS}/${APP_NAME} Available (ALB provisioning continues in the background)" + +echo "✅ Success: unicorn-store-spring deployed to EKS" diff --git a/infra/scripts/deploy/java-on-amazon-eks/app-prepare.sh b/infra/scripts/deploy/java-on-amazon-eks/app-prepare.sh index de36e9f5..80ca45ca 100755 --- a/infra/scripts/deploy/java-on-amazon-eks/app-prepare.sh +++ b/infra/scripts/deploy/java-on-amazon-eks/app-prepare.sh @@ -1,5 +1,75 @@ #!/bin/bash -# java-on-amazon-eks runs the java-performance implementation of this step: the two workshops -# share the app, the sensor and the profiler, and one copy keeps a fix in one place. Replace -# this wrapper with an own script when the workshops diverge. -exec bash "$(dirname "${BASH_SOURCE[0]}")/../java-performance/$(basename "${BASH_SOURCE[0]}")" "$@" +# ============================================================================= +# app-prepare.sh — the participant's starting point, without a cluster. +# +# Produces ~/environment/unicorn-store-spring at the commit +# "Starting point: containerized + deployed to EKS" and the :latest image in ECR: +# 1. copy apps/unicorn-store-spring, drop src/test, de-spoil (remove the org.crac +# dependency and the finished CRaC hook UnicornPublisher.crac — the CRaC step is +# discovered in the session, not handed over) +# 2. lay the end state of the immersion-day containerize + deploy pages over it +# (app/Dockerfile, app/k8s/*.yaml with the account's ECR URI filled in) +# 3. build and push the image (:latest) +# 4. git init, one "Initial commit" (sources), one "Starting point" commit +# (Dockerfile + manifests) — the same two commits the replayed labs produced +# +# Needs Docker, Maven, ECR and the IDE role; no kubectl. Runs in the bootstrap while +# the EKS cluster is still being created. Idempotent: an existing copy is replaced. +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/../../lib/common.sh" +[ -f /etc/profile.d/workshop.sh ] && source /etc/profile.d/workshop.sh +LOG="${WORKSHOP_LOG_DIR}/app-prepare.log" + +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" +APP_NAME="unicorn-store-spring" +SRC="${REPO_ROOT}/apps/${APP_NAME}" +OVERLAY="${SCRIPT_DIR}/app" +DST="${HOME}/environment/${APP_NAME}" +ECR_REGISTRY="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" +ECR_URI="${ECR_REGISTRY}/${APP_NAME}" +START_MSG="Starting point: containerized + deployed to EKS" + +git config --global user.name >/dev/null 2>&1 || git config --global user.name "Workshop User" +git config --global user.email >/dev/null 2>&1 || git config --global user.email "user@sample.com" + +# 1. Sources +log_info "Preparing ${DST} from ${SRC}..." +rm -rf "${DST}" +mkdir -p "${HOME}/environment" +cp -r "${SRC}" "${DST}" +rm -rf "${DST}/src/test" "${DST}/target" +# De-spoil: the whole block that carries org.crac (only crac lives there). +perl -0777 -i -pe 's{\s*\s*\s*org\.crac.*?}{}s' "${DST}/pom.xml" +find "${DST}/src" -name '*.crac' -delete +( cd "${DST}" && git init -q -b main && git add -A && git commit -q -m "Initial commit" ) +log_success "Sources copied, de-spoiled, initial commit made" + +# 2. Containerize + deploy end state +cp "${OVERLAY}/Dockerfile" "${DST}/Dockerfile" +mkdir -p "${DST}/k8s" +for f in "${OVERLAY}"/k8s/*.yaml; do + sed "s|__ECR_URI__|${ECR_URI}|g" "$f" > "${DST}/k8s/$(basename "$f")" +done +log_success "Dockerfile + k8s manifests in place (image ${ECR_URI}:latest)" + +# 3. Image +log_info "Ensuring ECR repository ${APP_NAME} exists..." +aws ecr describe-repositories --repository-names "${APP_NAME}" --region "${AWS_REGION}" >/dev/null 2>&1 \ + || aws ecr create-repository --repository-name "${APP_NAME}" --region "${AWS_REGION}" >/dev/null +aws ecr get-login-password --region "${AWS_REGION}" \ + | docker login --username AWS --password-stdin "${ECR_REGISTRY}" >/dev/null 2>&1 +log_info "Building ${APP_NAME}:latest (full log: ${LOG})..." +( cd "${DST}" && docker build -t "${ECR_URI}:latest" . ) >>"${LOG}" 2>&1 \ + || { log_error "docker build failed — last 40 lines of ${LOG}:"; tail -n 40 "${LOG}"; exit 1; } +docker push "${ECR_URI}:latest" >>"${LOG}" 2>&1 \ + || { log_error "docker push failed — last 40 lines of ${LOG}:"; tail -n 40 "${LOG}"; exit 1; } +log_success "Pushed ${ECR_URI}:latest" + +# 4. Starting-point commit +( cd "${DST}" && git add -A && git commit -q -m "${START_MSG}" ) +log_success "Committed '${START_MSG}'" + +echo "✅ Success: unicorn-store-spring starting point (sources, image, manifests)" diff --git a/infra/scripts/deploy/java-on-amazon-eks/claude-code.sh b/infra/scripts/deploy/java-on-amazon-eks/claude-code.sh index de36e9f5..10f95d08 100755 --- a/infra/scripts/deploy/java-on-amazon-eks/claude-code.sh +++ b/infra/scripts/deploy/java-on-amazon-eks/claude-code.sh @@ -1,5 +1,121 @@ #!/bin/bash -# java-on-amazon-eks runs the java-performance implementation of this step: the two workshops -# share the app, the sensor and the profiler, and one copy keeps a fix in one place. Replace -# this wrapper with an own script when the workshops diverge. -exec bash "$(dirname "${BASH_SOURCE[0]}")/../java-performance/$(basename "${BASH_SOURCE[0]}")" "$@" +# ============================================================================= +# claude-code.sh — configure Claude Code on the IDE for this workshop: the skill pack, +# the workspace MCP config (perf-sensor + EKS MCP Server) and the project permissions, +# so Claude Code started from ~/environment finds all three. No cluster needed. +# +# Copies the two skills to ~/environment/.claude/skills/, writes ~/environment/.mcp.json +# (perf-sensor over streamable-http on localhost:8090, the IDE's port-forward service; +# eks-mcp read-only) and the project permissions. Idempotent. +# +# EKS MCP Server: the awslabs eks-mcp-server over local stdio, pinned to a version, +# WITHOUT --allow-write (the server refuses mutations). Its tool list still shows the +# write tools (apply_yaml, manage_k8s_resource, ...), so the permissions below allow +# only the read tools by name; anything else prompts. +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" +SKILLS_SRC="${REPO_ROOT}/apps/perf-sensor/skills" +ENV_DIR="${ENVIRONMENT_DIR:-${HOME}/environment}" +SKILLS_DST="${ENV_DIR}/.claude/skills" +REGION="${AWS_REGION:-us-east-1}" +NS="monitoring" +EKS_MCP_VERSION="${EKS_MCP_VERSION:-0.2.1}" # pinned; bump deliberately (ide/tools.sh prewarms the same version) + +# 1. Skills -> ~/environment/.claude/skills/ +mkdir -p "${SKILLS_DST}" +for skill in java-on-eks-optimization java-on-eks-checklist; do + rm -rf "${SKILLS_DST:?}/${skill}" + cp -R "${SKILLS_SRC}/${skill}" "${SKILLS_DST}/${skill}" + echo "installed skill: ${SKILLS_DST}/${skill}" +done + +# 2. ~/environment/.mcp.json +cat > "${ENV_DIR}/.mcp.json" < explain -> apply loop runs without +# prompts. permissions.deny closes the ways the agent could widen its own scope: Bash +# (the participant rolls out; the load runs in its own terminal), edits to the Claude +# configuration itself (.claude/**, .mcp.json) or to .git/** — Edit(path) rules cover the +# Write tool too — and Agent: a subagent answers out of the skill's sight, and its +# hand-off text ("I'll be notified when the agent completes") ended up in an answer. +# Everything not listed prompts. +python3 - "${ENV_DIR}/.claude/settings.json" <<'PY' +import json, os, sys +path = sys.argv[1] +data = {} +if os.path.exists(path): + try: + with open(path) as f: + data = json.load(f) + except Exception: + data = {} +servers = set(data.get("enabledMcpjsonServers", [])) +servers.update(["perf-sensor", "eks-mcp"]) +data["enabledMcpjsonServers"] = sorted(servers) +perms = data.setdefault("permissions", {}) +allow = [r for r in perms.get("allow", []) if r != "mcp__eks-mcp"] # replace the blanket eks-mcp grant +for rule in ["mcp__perf-sensor", + "mcp__eks-mcp__list_k8s_resources", "mcp__eks-mcp__get_pod_logs", "mcp__eks-mcp__get_k8s_events", + "mcp__eks-mcp__get_eks_insights", "mcp__eks-mcp__get_eks_metrics_guidance", + "mcp__eks-mcp__get_cloudwatch_logs", "mcp__eks-mcp__get_cloudwatch_metrics", + "mcp__eks-mcp__search_eks_troubleshoot_guide", "mcp__eks-mcp__list_api_versions", + "Read", "Grep", "Glob", "Edit", "Write"]: + if rule not in allow: + allow.append(rule) +perms["allow"] = allow +# Path deny rules are Edit(path) only: they cover every file-editing tool, and Claude Code +# rejects Write(path) rules at startup. Drop any Write(...) entries left by an earlier run. +deny = [r for r in perms.get("deny", []) if not r.startswith("Write(")] +for rule in ["Bash", "Agent", + "Edit(.claude/**)", "Edit(.mcp.json)", "Edit(**/.git/**)", + "mcp__eks-mcp__apply_yaml", "mcp__eks-mcp__manage_k8s_resource", "mcp__eks-mcp__manage_eks_stacks", + "mcp__eks-mcp__add_inline_policy", "mcp__eks-mcp__generate_app_manifest"]: + if rule not in deny: + deny.append(rule) +perms["deny"] = deny +os.makedirs(os.path.dirname(path), exist_ok=True) +with open(path, "w") as f: + json.dump(data, f, indent=2) +PY +echo "wrote ${ENV_DIR}/.claude/settings.json (MCP servers enabled; allow: sensor, eks-mcp read tools, read, edit; deny: Bash, Agent, config edits, eks-mcp write tools)" + +# No workspace CLAUDE.md: the app repo documents itself (unicorn-store-spring/CLAUDE.md). +# --allow-sensitive-data-access is required for get_pod_logs / get_k8s_events (read-only; +# --allow-write is NOT passed, so the server refuses mutations even if a write tool is +# called). Auth uses the IDE role (default iam mode). uv/uvx and the eks-mcp package are +# installed and prewarmed by ide/tools.sh (install_uv) during base bootstrap. + +echo +echo "Next:" +echo " # the sensor is on localhost:8090 (systemd perf-sensor-port-forward, from perf-sensor.sh install)" +echo " cd ${ENV_DIR} && claude # skills + .mcp.json are picked up from here" diff --git a/infra/scripts/deploy/java-on-amazon-eks/ide-layout.sh b/infra/scripts/deploy/java-on-amazon-eks/ide-layout.sh index de36e9f5..95272273 100755 --- a/infra/scripts/deploy/java-on-amazon-eks/ide-layout.sh +++ b/infra/scripts/deploy/java-on-amazon-eks/ide-layout.sh @@ -1,5 +1,44 @@ #!/bin/bash -# java-on-amazon-eks runs the java-performance implementation of this step: the two workshops -# share the app, the sensor and the profiler, and one copy keeps a fix in one place. Replace -# this wrapper with an own script when the workshops diverge. -exec bash "$(dirname "${BASH_SOURCE[0]}")/../java-performance/$(basename "${BASH_SOURCE[0]}")" "$@" +# ============================================================================= +# ide-layout.sh — the two terminals of the session, set at bootstrap, nothing in +# ~/environment: the terminal in the editor area (Code Editor's startup editor, a zsh in +# ~/environment) is where Claude Code runs; a user-level task opens the Shell terminal in +# the bottom panel each time the IDE opens. +# +# Writes ~/.code-editor-server/data/User/tasks.json (task "Shell", runOn folderOpen) and +# sets "task.allowAutomaticTasks": "on" in the user settings (without it Code Editor asks +# before running the task) and "terminal.integrated.environmentChangesIndicator": "off" (the +# Claude Code extension asks to relaunch every terminal Claude runs in, which otherwise marks +# the tab with a warning badge for the rest of the session). Runs as ec2-user after +# ide/code-editor.sh. Idempotent. +# ============================================================================= +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/../../lib/common.sh" + +USER_DIR="${HOME}/.code-editor-server/data/User" +SETTINGS="${USER_DIR}/settings.json" +[ -f "${SETTINGS}" ] || { log_error "no Code Editor user settings at ${SETTINGS} (ide/code-editor.sh)"; exit 1; } + +tmp="$(mktemp)" +jq '. + {"workbench.startupEditor": "terminal", "task.allowAutomaticTasks": "on", + "terminal.integrated.environmentChangesIndicator": "off"}' "${SETTINGS}" > "${tmp}" +mv "${tmp}" "${SETTINGS}" + +cat > "${USER_DIR}/tasks.json" <<'JSON' +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Shell", + "type": "shell", + "command": "exec zsh -l", + "isBackground": true, + "problemMatcher": [], + "presentation": {"reveal": "always", "panel": "dedicated", "focus": false, "showReuseMessage": false, "echo": false}, + "runOptions": {"runOn": "folderOpen"} + } + ] +} +JSON +log_success "IDE layout: Claude Code terminal in the editor area, Shell terminal in the panel" diff --git a/infra/scripts/deploy/java-on-amazon-eks/nodepool-pin.sh b/infra/scripts/deploy/java-on-amazon-eks/nodepool-pin.sh index ed99e72c..e0de56ce 100755 --- a/infra/scripts/deploy/java-on-amazon-eks/nodepool-pin.sh +++ b/infra/scripts/deploy/java-on-amazon-eks/nodepool-pin.sh @@ -9,6 +9,11 @@ # (e.g. m8azn at 5 GHz) and startup "improves" from hardware, not from the change. # Family, not type, keeps capacity flexible across sizes. # +# Consolidation: WhenEmpty only. The pool carries just the app (platform-placement.sh), and +# one m6a.xlarge fits every state of the session, rollout surge and boosted start included. +# WhenEmptyOrUnderutilized (the eks.sh default) would replace the node when the app shrinks +# and move the pod mid-measurement; WhenEmpty only removes a node nothing runs on. +# # Replaces the three scheduling requirements (cpu-manufacturer, instance-category, # instance-generation) with one (instance-family); capacity-type, arch, os, cpu and # memory requirements stay. Idempotent. Runs after setup/eks.sh. @@ -38,7 +43,8 @@ PATCH="$(kubectl get nodepool "${NODEPOOL}" -o json | jq -c --arg fam "${INSTANC "eks.amazonaws.com/instance-generation", "eks.amazonaws.com/instance-family") | not)) + [{"key":"eks.amazonaws.com/instance-family","operator":"In","values":[$fam]}] - | {spec:{template:{spec:{requirements:.}}}}')" + | {spec:{template:{spec:{requirements:.}}, + disruption:{consolidationPolicy:"WhenEmpty",consolidateAfter:"5m"}}}')" kubectl patch nodepool "${NODEPOOL}" --type merge -p "${PATCH}" >/dev/null \ - && log_success "NodePool ${NODEPOOL} pinned to ${INSTANCE_FAMILY} (existing nodes drain on consolidation; new nodes are ${INSTANCE_FAMILY})" \ + && log_success "NodePool ${NODEPOOL} pinned to ${INSTANCE_FAMILY}, consolidation WhenEmpty (existing nodes drain on consolidation; new nodes are ${INSTANCE_FAMILY})" \ || { log_error "NodePool patch failed"; exit 1; } diff --git a/infra/scripts/deploy/java-on-amazon-eks/perf-profiler.sh b/infra/scripts/deploy/java-on-amazon-eks/perf-profiler.sh index de36e9f5..dafb42a7 100755 --- a/infra/scripts/deploy/java-on-amazon-eks/perf-profiler.sh +++ b/infra/scripts/deploy/java-on-amazon-eks/perf-profiler.sh @@ -1,5 +1,93 @@ #!/bin/bash -# java-on-amazon-eks runs the java-performance implementation of this step: the two workshops -# share the app, the sensor and the profiler, and one copy keeps a fix in one place. Replace -# this wrapper with an own script when the workshops diverge. -exec bash "$(dirname "${BASH_SOURCE[0]}")/../java-performance/$(basename "${BASH_SOURCE[0]}")" "$@" +# ============================================================================= +# perf-profiler.sh [build|install] — privilege-free JVM profiler sidecar +# +# build build + push the baked async-profiler image, record its digest +# (no cluster needed; runs while EKS is still being created) +# install install Kyverno and apply the sidecar-injection MutatingPolicy with the +# image pinned BY DIGEST (the tag is mutable; the digest is what was pushed) +# (none) both, in order +# +# Runs on the IDE instance as ec2-user with the IDE role. Not app-specific: the +# workload opts in with a label; APP_UID must match the UID the app image runs as +# (JVM dynamic attach needs the same UID; the workshop app uses 1000). +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/../../lib/common.sh" +LOG="${WORKSHOP_LOG_DIR}/perf-profiler.log" +[ -f /etc/profile.d/workshop.sh ] && source /etc/profile.d/workshop.sh + +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" +APP_DIR="${REPO_ROOT}/apps/perf-profiler" +ECR_REGISTRY="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" +ECR_URI="${ECR_REGISTRY}/perf-profiler" +STATE_DIR="${WORKSHOP_STATE_DIR:-${HOME}/.workshop}" +IMAGE_FILE="${STATE_DIR}/perf-profiler.image" # build writes, install reads +CLUSTER_NAME="${PREFIX:-workshop}-eks" # stamped into the sidecar as a Pyroscope label +APP_UID="${APP_UID:-1000}" # UID the profiled app runs as +PYROSCOPE_URL="${PYROSCOPE_URL:-http://pyroscope.monitoring:4040}" +KYVERNO_CHART_VERSION="3.9.1" # Kyverno v1.19.1; MutatingPolicy v1beta1 needs >= this + +build() { + log_info "Building and pushing perf-profiler image..." + aws ecr describe-repositories --repository-names perf-profiler --region "${AWS_REGION}" >/dev/null 2>&1 \ + || aws ecr create-repository --repository-name perf-profiler --region "${AWS_REGION}" >/dev/null + aws ecr get-login-password --region "${AWS_REGION}" \ + | docker login --username AWS --password-stdin "${ECR_REGISTRY}" >/dev/null 2>&1 + # Pin linux/amd64: the EKS nodes are amd64, but this build may run on an arm64 host + # (e.g. an Apple-Silicon Mac), where a native `docker build` would push arm64 and the + # nodes fail the pull with "no match for platform". + docker build --platform linux/amd64 -t "${ECR_URI}:latest" "${APP_DIR}" >>"$LOG" 2>&1 \ + || { log_error "perf-profiler docker build failed — last 40 lines of $LOG:"; tail -n 40 "$LOG"; exit 1; } + docker push "${ECR_URI}:latest" >>"$LOG" 2>&1 \ + || { log_error "perf-profiler docker push failed — last 40 lines of $LOG:"; tail -n 40 "$LOG"; exit 1; } + local digest + digest="$(aws ecr describe-images --repository-name perf-profiler --image-ids imageTag=latest \ + --query 'imageDetails[0].imageDigest' --output text --region "${AWS_REGION}")" + [ -n "${digest}" ] && [ "${digest}" != "None" ] || { log_error "could not resolve the pushed image digest"; exit 1; } + mkdir -p "${STATE_DIR}" + echo "${ECR_URI}@${digest}" > "${IMAGE_FILE}" + log_success "perf-profiler image pushed: $(cat "${IMAGE_FILE}")" +} + +install() { + local image_ref + image_ref="$(cat "${IMAGE_FILE}" 2>/dev/null || true)" + if [ -z "${image_ref}" ]; then + image_ref="${ECR_URI}@$(aws ecr describe-images --repository-name perf-profiler --image-ids imageTag=latest \ + --query 'imageDetails[0].imageDigest' --output text --region "${AWS_REGION}")" + fi + case "${image_ref}" in *@sha256:*) ;; *) log_error "no perf-profiler image found — run '$0 build' first"; exit 1;; esac + + log_info "Installing Kyverno ${KYVERNO_CHART_VERSION}..." + helm repo add kyverno https://kyverno.github.io/kyverno/ >/dev/null 2>&1 || true + helm repo update >/dev/null + helm upgrade --install kyverno kyverno/kyverno \ + --namespace kyverno --create-namespace --version "${KYVERNO_CHART_VERSION}" \ + --wait --timeout 10m >>"$LOG" 2>&1 || { log_error "kyverno helm install failed — last 40 lines of $LOG:"; tail -n 40 "$LOG"; exit 1; } + log_success "Kyverno installed" + + log_info "Applying inject-perf-profiler MutatingPolicy (image ${image_ref}, app UID ${APP_UID})..." + sed -e "s|__PERF_PROFILER_IMAGE__|${image_ref}|g" \ + -e "s|__CLUSTER_NAME__|${CLUSTER_NAME}|g" \ + -e "s|__APP_UID__|${APP_UID}|g" \ + -e "s|__PYROSCOPE_URL__|${PYROSCOPE_URL}|g" \ + "${APP_DIR}/k8s/sidecar-inject-policy.yaml" | kubectl apply -f - >/dev/null + log_success "MutatingPolicy inject-perf-profiler applied" + # Rejects opt-ins the sidecar cannot serve (app container first, named like the app + # label, runAsUser APP_UID), at the apply of the Deployment. + sed -e "s|__APP_UID__|${APP_UID}|g" "${APP_DIR}/k8s/sidecar-optin-policy.yaml" | kubectl apply -f - >/dev/null + log_success "ValidatingPolicy perf-profiler-opt-in applied" + + echo "✅ Success: perf-profiler (image ${image_ref}; Kyverno + inject and opt-in policies)" + log_info "Opt a workload in: add 'perf-profile/sidecar: \"true\"' under spec.template.metadata.labels and kubectl apply the Deployment" +} + +case "${1:-all}" in + build) build ;; + install) install ;; + all) build; install ;; + *) echo "usage: $0 [build|install]" >&2; exit 2 ;; +esac diff --git a/infra/scripts/deploy/java-on-amazon-eks/perf-sensor.sh b/infra/scripts/deploy/java-on-amazon-eks/perf-sensor.sh index de36e9f5..833ebb8c 100755 --- a/infra/scripts/deploy/java-on-amazon-eks/perf-sensor.sh +++ b/infra/scripts/deploy/java-on-amazon-eks/perf-sensor.sh @@ -1,5 +1,156 @@ #!/bin/bash -# java-on-amazon-eks runs the java-performance implementation of this step: the two workshops -# share the app, the sensor and the profiler, and one copy keeps a fix in one place. Replace -# this wrapper with an own script when the workshops diverge. -exec bash "$(dirname "${BASH_SOURCE[0]}")/../java-performance/$(basename "${BASH_SOURCE[0]}")" "$@" +# ============================================================================= +# perf-sensor.sh [build|install] — deterministic performance sensors for Java +# workloads on EKS, exposed over MCP (streamable-http) + REST. No Bedrock, no Spring AI +# chat, no AWS SDK, no Pod Identity (the sensor makes no AWS calls). +# +# build jib build + push to ECR, record the digest (no cluster needed) +# install ServiceAccount + read-only ClusterRole, the checklist rules ConfigMap, +# Deployment (image by digest) + Service in the monitoring namespace, the +# Grafana dashboard, and the IDE's port-forward localhost:8090 -> the sensor as +# the systemd service perf-sensor-port-forward (Restart=always, as ec2-user) +# (none) both, in order +# +# Not app-specific: APP_NS names the workload the dashboard is for; REQUEST_PACKAGE +# names the app's request-path package (the sensor refuses to start without it). Runs +# on the IDE instance as ec2-user with the IDE role. +# ============================================================================= +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[ -f "${SCRIPT_DIR}/../../lib/common.sh" ] && source "${SCRIPT_DIR}/../../lib/common.sh" +LOG="${WORKSHOP_LOG_DIR:-/tmp}/perf-sensor-build.log" +[ -f /etc/profile.d/workshop.sh ] && source /etc/profile.d/workshop.sh +command -v log_info >/dev/null 2>&1 || { + log_info() { echo "ℹ️ $*"; } + log_success() { echo "✅ $*"; } + log_warning() { echo "⚠️ $*"; } + log_error() { echo "❌ $*" >&2; } +} + +REGION="${AWS_REGION:-${AWS_DEFAULT_REGION:-us-east-1}}" +ACCOUNT_ID="${ACCOUNT_ID:-$(aws sts get-caller-identity --query Account --output text)}" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" +APP_DIR="${REPO_ROOT}/apps/perf-sensor" +K8S_DIR="${APP_DIR}/k8s" +ECR_BASE="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com" +REPO="${ECR_BASE}/perf-sensor" +STATE_DIR="${WORKSHOP_STATE_DIR:-${HOME}/.workshop}" +IMAGE_FILE="${STATE_DIR}/perf-sensor.image" +NS="monitoring" +APP_NS="${APP_NS:-unicorn-store-spring}" # namespace == Deployment == container of the workload +REQUEST_PACKAGE="${REQUEST_PACKAGE:-com.unicorn.store}" # the app's request-path package +SENSOR_SERVICES="${SENSOR_SERVICES:-${APP_NS}}" # workloads the sensor answers for (request package above) + +build() { + # The ECR repo is normally created on first push by the account's creation template, + # but a stack deployed before perf-sensor was added to that template has no repo and + # jib fails with 404 NAME_UNKNOWN — so create it idempotently first. + log_info "Ensuring ECR repository perf-sensor exists..." + aws ecr describe-repositories --repository-names perf-sensor --region "${REGION}" >/dev/null 2>&1 \ + || aws ecr create-repository --repository-name perf-sensor --region "${REGION}" >/dev/null \ + || { log_error "could not create ECR repo perf-sensor"; exit 1; } + log_info "Building and pushing perf-sensor image (full log: ${LOG})..." + aws ecr get-login-password --region "${REGION}" \ + | docker login --username AWS --password-stdin "${ECR_BASE}" >/dev/null 2>&1 + ( cd "${APP_DIR}" && mvn -q clean compile jib:build -Dimage="${REPO}:latest" ) >>"$LOG" 2>&1 \ + || { log_error "perf-sensor build failed — last 40 lines of $LOG:"; tail -n 40 "$LOG"; exit 1; } + local digest + digest="$(cat "${APP_DIR}/target/jib-image.digest" 2>/dev/null || true)" + [ -n "${digest}" ] || digest="$(aws ecr describe-images --repository-name perf-sensor --image-ids imageTag=latest \ + --query 'imageDetails[0].imageDigest' --output text --region "${REGION}")" + [ -n "${digest}" ] && [ "${digest}" != "None" ] || { log_error "could not resolve the pushed image digest"; exit 1; } + mkdir -p "${STATE_DIR}" + echo "${REPO}@${digest}" > "${IMAGE_FILE}" + log_success "perf-sensor image pushed: $(cat "${IMAGE_FILE}")" +} + +install() { + local image_ref + image_ref="$(cat "${IMAGE_FILE}" 2>/dev/null || true)" + if [ -z "${image_ref}" ]; then + image_ref="${REPO}@$(aws ecr describe-images --repository-name perf-sensor --image-ids imageTag=latest \ + --query 'imageDetails[0].imageDigest' --output text --region "${REGION}")" + fi + case "${image_ref}" in *@sha256:*) ;; *) log_error "no perf-sensor image found — run '$0 build' first"; exit 1;; esac + + log_info "Applying perf-sensor SA + read-only ClusterRole..." + sed "s|__NS__|${NS}|g" "${K8S_DIR}/rbac.yaml" | kubectl apply -f - >/dev/null + log_success "RBAC applied (get/list on deployments, pods, pods/log)" + + # The checklist rules (CEL) as a ConfigMap the sensor reads at startup, so anyone can + # see what is scored: kubectl -n monitoring get cm perf-sensor-checklist-rules -o yaml + local rules="${APP_DIR}/src/main/resources/checklist-rules.yaml" rules_sha + log_info "Applying checklist rules ConfigMap..." + kubectl create configmap perf-sensor-checklist-rules --from-file=checklist-rules.yaml="${rules}" -n "${NS}" \ + --dry-run=client -o yaml | kubectl apply -f - >/dev/null \ + || { log_error "could not apply ConfigMap perf-sensor-checklist-rules"; exit 1; } + rules_sha="$(sha256sum "${rules}" | cut -c1-16)" + + log_info "Deploying perf-sensor to ${NS} (REQUEST_PACKAGE=${REQUEST_PACKAGE})..." + sed -e "s|__IMAGE__|${image_ref}|g" -e "s|__NS__|${NS}|g" -e "s|__REQUEST_PACKAGE__|${REQUEST_PACKAGE}|g" -e "s|__SENSOR_SERVICES__|${SENSOR_SERVICES}|g" \ + -e "s|__RULES_SHA__|${rules_sha}|g" "${K8S_DIR}/deployment.yaml" | kubectl apply -f - >/dev/null + # The image is pinned by digest and the rules by hash, so a changed image or rules file + # is a changed pod template: no rollout restart needed. + kubectl -n "${NS}" rollout status deploy/perf-sensor --timeout=200s >/dev/null || { + log_error "rollout not ready; recent logs:"; kubectl -n "${NS}" logs -l app=perf-sensor --tail=60; exit 1; } + log_success "perf-sensor running (${image_ref})" + + # Grafana dashboard (k8s/dashboard.json, __APP_NS__ substituted) shipped as a ConfigMap + # the Grafana sidecar imports (label grafana_dashboard=1, folder from the annotation). + log_info "Provisioning 'Java on EKS — Sensor' dashboard..." + local dash + dash=$(mktemp) + sed "s|__APP_NS__|${APP_NS}|g" "${K8S_DIR}/dashboard.json" > "${dash}" + kubectl create configmap perf-sensor-dashboard --from-file=perf-sensor.json="${dash}" -n "${NS}" \ + --dry-run=client -o yaml | kubectl apply -f - >/dev/null 2>&1 \ + && kubectl label configmap perf-sensor-dashboard -n "${NS}" grafana_dashboard=1 --overwrite >/dev/null 2>&1 \ + && kubectl annotate configmap perf-sensor-dashboard -n "${NS}" grafana_folder="Workshop Dashboards" --overwrite >/dev/null 2>&1 \ + && log_success "Dashboard 'Java on EKS — Sensor' provisioned" \ + || log_warning "dashboard provisioning skipped (Grafana sidecar / monitoring not present?)" + rm -f "${dash}" + + port_forward_service + + echo "✅ Success: perf-sensor (image ${image_ref})" + log_info "Reach it from the IDE: curl -s localhost:8090/api/v1/measure/${APP_NS} | jq . (systemd: ${PF_UNIT})" +} + +# localhost:8090 on the IDE -> svc/perf-sensor:8080, for Claude Code (.mcp.json) and curl. +# kubectl port-forward ends whenever the sensor pod is replaced; systemd starts it again, +# so nobody keeps a terminal open for it. A login shell gives kubectl the user's PATH, +# kubeconfig and /etc/profile.d/workshop.sh (region for `aws eks get-token`). +PF_UNIT="perf-sensor-port-forward" +port_forward_service() { + log_info "Installing the IDE port-forward (systemd ${PF_UNIT}, localhost:8090)..." + sudo tee "/etc/systemd/system/${PF_UNIT}.service" >/dev/null </dev/null 2>&1 + sudo systemctl restart "${PF_UNIT}" + for _ in $(seq 1 15); do + curl -sf localhost:8090/actuator/health >/dev/null 2>&1 && { log_success "port-forward up: localhost:8090"; return 0; } + sleep 2 + done + log_warning "localhost:8090 not answering yet (journalctl -u ${PF_UNIT})" +} + +case "${1:-all}" in + build) build ;; + install) install ;; + all) build; install ;; + *) echo "usage: $0 [build|install]" >&2; exit 2 ;; +esac diff --git a/infra/scripts/deploy/java-on-amazon-eks/platform-placement.sh b/infra/scripts/deploy/java-on-amazon-eks/platform-placement.sh index de36e9f5..6afe34bf 100755 --- a/infra/scripts/deploy/java-on-amazon-eks/platform-placement.sh +++ b/infra/scripts/deploy/java-on-amazon-eks/platform-placement.sh @@ -1,5 +1,107 @@ #!/bin/bash -# java-on-amazon-eks runs the java-performance implementation of this step: the two workshops -# share the app, the sensor and the profiler, and one copy keeps a fix in one place. Replace -# this wrapper with an own script when the workshops diverge. -exec bash "$(dirname "${BASH_SOURCE[0]}")/../java-performance/$(basename "${BASH_SOURCE[0]}")" "$@" +# ============================================================================= +# platform-placement.sh — keep the platform off the application's node (java-on-amazon-eks). +# +# The app selects the `workshop` NodePool (karpenter.sh/nodepool: workshop). The platform +# workloads have no selector, so Karpenter places them wherever there is room, usually on +# the app's 4-vCPU node. There the app shares physical cores (SMT) with Prometheus, +# Pyroscope and Kyverno, and the session's measurements move with that load (startup and +# steady CPU 10–60 % higher in a test run). This script creates a `platform` NodePool and +# pins every platform Deployment and StatefulSet to it, so the `workshop` node carries only +# the app and the DaemonSets. The app manifest is unchanged. +# +# The pool admits only instances with at least 4 vCPU and 8 GiB (c6a.xlarge is the cheapest +# match): the platform requests about 1.5 vCPU and 4.4 GiB, so one node holds all of it. On +# the built-in general-purpose pool Karpenter started a c6a.large per batch of pending pods +# and kept two (do-not-disrupt blocks the merge), at more cost than one c6a.xlarge. +# +# Namespaces: monitoring (Prometheus, Grafana, Pyroscope, Alloy, sensor), kyverno, +# kube-startup-cpu-boost-system, and metrics-server in kube-system. Pods move once, at +# bootstrap; Prometheus, Grafana and Pyroscope keep their data on their PVCs. The pods are +# marked do-not-disrupt and the pool consolidates only when empty, so nothing moves them later. +# Idempotent. `platform-placement.sh pool` creates only the NodePool (template stage B, so +# monitoring.sh installs onto it via MONITORING_NODEPOOL); without an argument it also pins +# the workloads (after stage E). A later `helm upgrade` +# of one of these releases drops the selector: run this script again afterwards. +# ============================================================================= +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/../../lib/common.sh" + +POOL="${PLATFORM_NODEPOOL:-platform}" + +kubectl apply -f - </dev/null || { log_error "NodePool ${POOL} could not be created"; exit 1; } +apiVersion: karpenter.sh/v1 +kind: NodePool +metadata: + name: ${POOL} +spec: + template: + spec: + nodeClassRef: + group: eks.amazonaws.com + kind: NodeClass + name: default + requirements: + - key: karpenter.sh/capacity-type + operator: In + values: ["on-demand"] + - key: kubernetes.io/arch + operator: In + values: ["amd64"] + - key: kubernetes.io/os + operator: In + values: ["linux"] + - key: eks.amazonaws.com/instance-category + operator: In + values: ["c", "m"] + - key: eks.amazonaws.com/instance-generation + operator: Gt + values: ["5"] + - key: eks.amazonaws.com/instance-cpu + operator: Gt + values: ["3"] + - key: eks.amazonaws.com/instance-memory + operator: Gt + values: ["8000"] + limits: + cpu: "16" + disruption: + consolidationPolicy: WhenEmpty + consolidateAfter: 5m +EOF +log_info "NodePool ${POOL}: at least 4 vCPU and 8 GiB, consolidation WhenEmpty" +# `pool`: only the NodePool (template stage B, before monitoring.sh installs onto it). +[ "${1:-}" = pool ] && exit 0 + +# do-not-disrupt: a consolidation during the session would move the sensor or Kyverno in +# the middle of a checklist or an admission; the annotation keeps their node until the pods go. +PATCH="{\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"karpenter.sh/do-not-disrupt\":\"true\"}},\"spec\":{\"nodeSelector\":{\"karpenter.sh/nodepool\":\"${POOL}\"}}}}}" +RC=0 +pin() { # pin NAMESPACE KIND/NAME... + local ns="$1"; shift + for w in "$@"; do + if kubectl -n "${ns}" patch "${w}" --type merge -p "${PATCH}" >/dev/null; then + log_info "${ns}/${w} → ${POOL}" + else + log_error "patch failed: ${ns}/${w}"; RC=1 + fi + done +} + +for ns in monitoring kyverno kube-startup-cpu-boost-system; do + kubectl get namespace "${ns}" >/dev/null 2>&1 || { log_warning "namespace ${ns} not found, skipped"; continue; } + mapfile -t W < <(kubectl -n "${ns}" get deployment,statefulset -o name) + [ "${#W[@]}" -gt 0 ] && pin "${ns}" "${W[@]}" +done +kubectl -n kube-system get deployment metrics-server >/dev/null 2>&1 && pin kube-system deployment/metrics-server + +for ns in monitoring kyverno kube-startup-cpu-boost-system; do + kubectl get namespace "${ns}" >/dev/null 2>&1 || continue + for w in $(kubectl -n "${ns}" get deployment,statefulset -o name); do + kubectl -n "${ns}" rollout status "${w}" --timeout=600s >/dev/null || { log_error "${ns}/${w} not ready on ${POOL}"; RC=1; } + done +done + +[ "${RC}" -eq 0 ] && log_success "Platform workloads on NodePool ${POOL}; the workshop node carries the app only" || exit 1 diff --git a/infra/scripts/deploy/java-on-amazon-eks/prebuild-images.sh b/infra/scripts/deploy/java-on-amazon-eks/prebuild-images.sh index de36e9f5..9b7a734c 100755 --- a/infra/scripts/deploy/java-on-amazon-eks/prebuild-images.sh +++ b/infra/scripts/deploy/java-on-amazon-eks/prebuild-images.sh @@ -1,5 +1,88 @@ #!/bin/bash -# java-on-amazon-eks runs the java-performance implementation of this step: the two workshops -# share the app, the sensor and the profiler, and one copy keeps a fix in one place. Replace -# this wrapper with an own script when the workshops diverge. -exec bash "$(dirname "${BASH_SOURCE[0]}")/../java-performance/$(basename "${BASH_SOURCE[0]}")" "$@" +# ============================================================================= +# Phase 3a — prebuild the optimized unicorn-store-spring images (:aot-prebuilt, :crac-prebuilt) +# +# Safety net for the builders' session: a CRaC/AOT build (Maven + training/checkpoint run +# against Aurora) takes 1-3 minutes on the IDE and can fail on a bad day. Participants build +# and push :aot / :crac themselves (that IS the lesson); the prebuilt tags are separate so +# a participant's push never overwrites the fallback. If a build fails, the runbook points +# the Deployment at the -prebuilt tag and the session continues. +# +# Builds from a throwaway copy of the participant's app dir so the workshop starting point +# (~/environment/unicorn-store-spring) stays untouched. Uses the generic Dockerfiles the +# optimization skill hands to participants +# (apps/perf-sensor/skills/java-on-eks-optimization/references/Dockerfile.{crac,aot}), with +# their ARGs filled via build-args — same Dockerfile, same ARG values module 4 expects. The +# CRaC checkpoint bakes GC, heap bounds and the processor count in, so JAVA_HEAP_OPTS is +# derived from the memory limit module 1 lands on (PREBUILD_MEM_LIMIT_MI, 768 on the +# reference run: peak ~490 x 1.4 rounded up to 128) at the sensor's -Xmx/-Xms shares +# (75 % / 50 %, SensorService.heapOpts), and JAVA_CPU_OPTS from the CPU limit (1). Swaps in the CRaC Resource +# hook (UnicornPublisher.crac) for the checkpoint build. +# +# Runs on the IDE instance (amd64, inside the VPC so the DB is reachable). +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/../../lib/common.sh" +source /etc/profile.d/workshop.sh + +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" +APP_SRC="${APP_SRC:-${HOME}/environment/unicorn-store-spring}" # override to build from another copy (reset-app.sh --prebuild) +SKILL_REFS="${REPO_ROOT}/apps/perf-sensor/skills/java-on-eks-optimization/references" +BUILD_DIR="$(mktemp -d "${TMPDIR:-/tmp}/unicorn-prebuild.XXXXXXXX")" +trap 'rm -rf "${BUILD_DIR}"' EXIT + +# ARG values for the generic Dockerfiles (see EXTRA_BUILD_ARGS in scripts/build.sh). +export JAR_FILE="store-spring-1.0.0-exec.jar" +export MAIN_CLASS="com.unicorn.store.StoreApplication" +# Heap flags baked into the checkpoint: 75 % / 50 % of the memory limit module 1 reaches +# (as the sensor's jvmFlags.javaHeapOpts). One number to change if the reference run moves. +PREBUILD_MEM_LIMIT_MI="${PREBUILD_MEM_LIMIT_MI:-768}" +export JAVA_HEAP_OPTS="-Xmx$((PREBUILD_MEM_LIMIT_MI * 3 / 4))m -Xms$((PREBUILD_MEM_LIMIT_MI / 2))m" +export JAVA_CPU_OPTS="-XX:ActiveProcessorCount=1" # ceil(limits.cpu) = 1 +PREBUILT_SUFFIX="-prebuilt" # tags participants never push to +# Warm-up request for the CRaC checkpoint: the app's write path. +export WARMUP_CMD="curl -s -o /dev/null -X POST -H 'Content-Type: application/json' -d '{\"name\":\"warmup\",\"age\":\"1\",\"type\":\"warmup\",\"size\":\"s\"}' http://localhost:8080/unicorns" + +[ -d "${APP_SRC}" ] || { log_error "App dir not found: ${APP_SRC} (run Phase 3 first)"; exit 1; } +[ -x "${APP_SRC}/scripts/build.sh" ] || { log_error "scripts/build.sh missing in ${APP_SRC}"; exit 1; } +[ -f "${SKILL_REFS}/Dockerfile.crac" ] && [ -f "${SKILL_REFS}/Dockerfile.aot" ] \ + || { log_error "Skill Dockerfiles not found in ${SKILL_REFS}"; exit 1; } + +log_info "Copying app to ${BUILD_DIR} for the prebuild..." +cp -r "${APP_SRC}/." "${BUILD_DIR}/" +rm -rf "${BUILD_DIR}/.git" "${BUILD_DIR}/target" + +# --- CRaC: skill Dockerfile + Resource hook swapped in ------------------------- +CRAC_LOG="${WORKSHOP_LOG_DIR}/prebuild-crac.log" +log_info "Prebuilding unicorn-store-spring:crac${PREBUILT_SUFFIX} (limit ${PREBUILD_MEM_LIMIT_MI}Mi -> ${JAVA_HEAP_OPTS}; full log: ${CRAC_LOG})..." +cp "${SKILL_REFS}/Dockerfile.crac" "${BUILD_DIR}/Dockerfile.crac" +PUBLISHER_DIR="${BUILD_DIR}/src/main/java/com/unicorn/store/data" +if [ -f "${PUBLISHER_DIR}/UnicornPublisher.crac" ]; then + cp "${PUBLISHER_DIR}/UnicornPublisher.crac" "${PUBLISHER_DIR}/UnicornPublisher.java" +else + # Hard-fail here rather than let the checkpoint fail silently: without the hook + # (and the org.crac dep it implies) the CRaC checkpoint never writes /opt/crac-files, + # and the build dies 20 lines later with a cryptic `COPY "/opt/crac-files": not found`. + # A missing hook means the source was de-spoiled before this ran — fix the phase order. + log_error "UnicornPublisher.crac not found in ${PUBLISHER_DIR} — source is de-spoiled; prebuild must run BEFORE de-spoil" + exit 1 +fi +( cd "${BUILD_DIR}" && EXTRA_BUILD_ARGS="JAR_FILE JAVA_HEAP_OPTS JAVA_CPU_OPTS WARMUP_CMD" ./scripts/build.sh "crac${PREBUILT_SUFFIX}" Dockerfile.crac ) >"${CRAC_LOG}" 2>&1 \ + && log_success "unicorn-store-spring:crac${PREBUILT_SUFFIX} pushed" \ + || { log_error "CRaC prebuild failed — last 40 lines of ${CRAC_LOG}:"; tail -n 40 "${CRAC_LOG}"; exit 1; } + +# --- AOT: skill Dockerfile on the unmodified source --------------------------- +AOT_LOG="${WORKSHOP_LOG_DIR}/prebuild-aot.log" +log_info "Prebuilding unicorn-store-spring:aot${PREBUILT_SUFFIX} (full log: ${AOT_LOG})..." +rm -rf "${BUILD_DIR}" +mkdir -p "${BUILD_DIR}" +cp -r "${APP_SRC}/." "${BUILD_DIR}/" +rm -rf "${BUILD_DIR}/.git" "${BUILD_DIR}/target" +cp "${SKILL_REFS}/Dockerfile.aot" "${BUILD_DIR}/Dockerfile.aot" +( cd "${BUILD_DIR}" && EXTRA_BUILD_ARGS="JAR_FILE MAIN_CLASS" ./scripts/build.sh "aot${PREBUILT_SUFFIX}" Dockerfile.aot ) >"${AOT_LOG}" 2>&1 \ + && log_success "unicorn-store-spring:aot${PREBUILT_SUFFIX} pushed" \ + || { log_error "AOT prebuild failed — last 40 lines of ${AOT_LOG}:"; tail -n 40 "${AOT_LOG}"; exit 1; } + +log_success "Prebuilt fallback images ready: unicorn-store-spring:{crac,aot}${PREBUILT_SUFFIX}" diff --git a/infra/scripts/deploy/java-on-amazon-eks/reset-app.sh b/infra/scripts/deploy/java-on-amazon-eks/reset-app.sh index 91682409..69b6483d 100755 --- a/infra/scripts/deploy/java-on-amazon-eks/reset-app.sh +++ b/infra/scripts/deploy/java-on-amazon-eks/reset-app.sh @@ -6,6 +6,10 @@ # Everything a run changes in the APP is reverted; the platform (monitoring, # profiler policy, sensor, boost controller, skills) is left as bootstrapped. # +# 0. The load a run started is stopped: the loops and the artillery process of the +# current run (it outlives its loop for up to 600 s, and a second load on top doubles +# the request rate the next run measures). The sensor port-forward service is +# restarted, and a hand-started port-forward from an older run is stopped. # 1. ~/environment/unicorn-store-spring: hard reset to the commit # "Starting point: containerized + deployed to EKS" (made by app-prepare.sh), untracked files removed # (Dockerfile.aot/.crac, startup-cpu-boost.yaml, listener classes ...); then @@ -13,7 +17,8 @@ # source (apps/unicorn-store-spring) and folded into that commit, so the # starting point always carries the current versions. # 2. StartupCPUBoost CRs in the app namespace deleted (created in module 2; -# the CR outlives the manifest reset). +# the CR outlives the manifest reset), and every Kyverno NamespacedValidatingPolicy +# in the app namespace (module 5; the baseline manifest fails it). # 3. Baseline manifest applied (:latest, 1 vCPU / 2Gi, no sidecar label) and # rolled out; image, resources and container list verified. # 4. ~/environment/baseline.txt removed. @@ -52,6 +57,21 @@ PREBUILD=false [ -d "${APP_DIR}/.git" ] || { log_error "No git repo at ${APP_DIR}"; exit 1; } +# 0. Stop the previous run's load and port-forward (loops first, then what they started). +# common.sh sets -e: pkill exits 1 when nothing matches, which is the normal case here. +pkill -f 'scripts/load.sh' 2>/dev/null || true; pkill -f 'artillery run' 2>/dev/null || true +# A hand-started forward (loop first, then its kubectl) would hold 8090; the service's own +# kubectl has --address in its command line and does not match. +pkill -f 'while true; do kubectl -n monitoring port-forward' 2>/dev/null || true +pkill -f 'kubectl -n monitoring port-forward svc/perf-sensor' 2>/dev/null || true +sleep 2 +pgrep -f 'artillery run|scripts/load.sh' >/dev/null \ + && log_warning "load still running: $(pgrep -af 'artillery run|scripts/load.sh' | head -3)" \ + || log_success "load stopped" +sudo systemctl restart perf-sensor-port-forward 2>/dev/null \ + && log_success "port-forward service restarted (localhost:8090)" \ + || log_warning "no perf-sensor-port-forward service (perf-sensor.sh install creates it)" + # 1. App repo back to the starting point. START_SHA="$(git -C "${APP_DIR}" log --format=%H --grep="${START_MSG}" -n 1)" [ -n "${START_SHA}" ] || { log_error "Commit '${START_MSG}' not found in ${APP_DIR}"; exit 1; } @@ -85,6 +105,14 @@ kubectl -n "${APP_NS}" delete startupcpuboost --all --ignore-not-found >/dev/nul && log_success "StartupCPUBoost CRs removed" \ || log_warning "could not delete StartupCPUBoost CRs (CRD absent?)" +# Module 5's Kyverno policy rejects the baseline manifest: remove every namespaced +# validating policy in the app namespace. +if kubectl -n "${APP_NS}" get namespacedvalidatingpolicies -o name 2>/dev/null | grep -q .; then + kubectl -n "${APP_NS}" delete namespacedvalidatingpolicies --all >/dev/null \ + && log_success "Kyverno policies in ${APP_NS} removed" + sleep 5 # give Kyverno a moment to drop the deleted policy +fi + # 3. Baseline deployment. MANIFEST="${APP_DIR}/k8s/deployment.yaml" [ -f "${MANIFEST}" ] || { log_error "Manifest not found: ${MANIFEST}"; exit 1; } @@ -92,6 +120,13 @@ log_info "Applying baseline manifest and waiting for rollout..." kubectl -n "${APP_NS}" apply -f "${MANIFEST}" >/dev/null \ && kubectl -n "${APP_NS}" rollout status deploy/unicorn-store-spring --timeout=300s >/dev/null \ || { log_error "baseline rollout failed"; exit 1; } +# Wait for the previous pods to finish terminating (grace period up to 40 s), so the next +# run starts from one pod, as a fresh environment does. +for _ in $(seq 1 30); do + [ "$(kubectl -n "${APP_NS}" get pod -l app=unicorn-store-spring -o json \ + | jq '[.items[] | select(.metadata.deletionTimestamp != null)] | length')" = 0 ] && break + sleep 3 +done # Read the NEW pod: right after rollout status the old one is still Terminating and # sorts first, so filter on phase=Running and no deletionTimestamp, newest first. diff --git a/infra/scripts/deploy/java-on-amazon-eks/startup-cpu-boost.sh b/infra/scripts/deploy/java-on-amazon-eks/startup-cpu-boost.sh index de36e9f5..f566c950 100755 --- a/infra/scripts/deploy/java-on-amazon-eks/startup-cpu-boost.sh +++ b/infra/scripts/deploy/java-on-amazon-eks/startup-cpu-boost.sh @@ -1,5 +1,44 @@ #!/bin/bash -# java-on-amazon-eks runs the java-performance implementation of this step: the two workshops -# share the app, the sensor and the profiler, and one copy keeps a fix in one place. Replace -# this wrapper with an own script when the workshops diverge. -exec bash "$(dirname "${BASH_SOURCE[0]}")/../java-performance/$(basename "${BASH_SOURCE[0]}")" "$@" +# ============================================================================= +# Kube Startup CPU Boost — cluster-wide (platform) install. +# +# This is the PRODUCTION path for the optimization skill's "start faster without +# changing the image" answer. It installs a controller + mutating webhook + CRD +# (cluster-admin, ONCE) so that a developer only writes a namespaced +# `StartupCPUBoost` CR: the controller then boosts CPU at pod admission and resizes +# it back DOWN in place once the pod is Ready — automatically, for every pod, every +# rollout, every scale-up. No per-pod `kubectl patch` (that manual path is only for +# teaching the underlying in-place-resize primitive). +# +# Cluster-scoped objects (CRD, ClusterRole, webhooks) require cluster-admin, which is +# why this lives in the bootstrap (platform layer), not in participant/dev steps. +# +# Upstream: https://github.com/google/kube-startup-cpu-boost +# Version is pinned; bump BOOST_VERSION to adopt a newer release. +# ============================================================================= +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[ -f "${SCRIPT_DIR}/../../lib/common.sh" ] && source "${SCRIPT_DIR}/../../lib/common.sh" +[ -f /etc/profile.d/workshop.sh ] && source /etc/profile.d/workshop.sh +command -v log_info >/dev/null 2>&1 || { + log_info() { echo "ℹ️ $*"; } + log_success() { echo "✅ $*"; } + log_warning() { echo "⚠️ $*"; } + log_error() { echo "❌ $*" >&2; } +} + +BOOST_VERSION="v0.21.1" +MANIFEST="https://github.com/google/kube-startup-cpu-boost/releases/download/${BOOST_VERSION}/manifests.yaml" +NS="kube-startup-cpu-boost-system" + +log_info "Installing Kube Startup CPU Boost ${BOOST_VERSION} (cluster-wide controller + webhook)..." +# --server-side: the bundled CRD is large; client-side apply can exceed the +# last-applied annotation size limit. +if kubectl apply --server-side --force-conflicts -f "${MANIFEST}"; then + kubectl -n "${NS}" rollout status deploy/kube-startup-cpu-boost-controller-manager --timeout=180s \ + && log_success "Kube Startup CPU Boost ${BOOST_VERSION} installed (controller Ready)" \ + || log_warning "Boost controller applied but not Ready yet (webhook may lag; the CR still admits later)" +else + log_warning "Kube Startup CPU Boost install failed — the skill's manual in-place-resize path still works" +fi diff --git a/infra/scripts/deploy/java-on-amazon-eks/verify.sh b/infra/scripts/deploy/java-on-amazon-eks/verify.sh index ad78ae9e..1c28657d 100755 --- a/infra/scripts/deploy/java-on-amazon-eks/verify.sh +++ b/infra/scripts/deploy/java-on-amazon-eks/verify.sh @@ -53,12 +53,12 @@ kubectl -n kube-startup-cpu-boost-system get deploy kube-startup-cpu-boost-contr && log_success "Startup CPU Boost controller present" || log_warning "Startup CPU Boost controller missing (manual resize path still works)" kubectl -n monitoring rollout status deploy/perf-sensor --timeout=60s >/dev/null 2>&1 && log_success "perf-sensor Ready" || fail "perf-sensor not Ready" -# 5. Sensor answers with facts for the app (measure over a port-forward). -kubectl -n monitoring port-forward svc/perf-sensor 18090:8080 >/dev/null 2>&1 & -PF=$! -sleep 4 -M="$(curl -s --max-time 60 "localhost:18090/api/v1/measure/${APP_NS}" || true)" -kill "${PF}" 2>/dev/null || true +# 5. Sensor answers with facts for the app, over the IDE's port-forward service (what +# Claude Code and the content use). +systemctl is-active --quiet perf-sensor-port-forward && log_success "port-forward service active" \ + || fail "systemd service perf-sensor-port-forward not active (perf-sensor.sh install)" +for _ in $(seq 1 15); do curl -sf localhost:8090/actuator/health >/dev/null 2>&1 && break; sleep 2; done +M="$(curl -s --max-time 60 "localhost:8090/api/v1/measure/${APP_NS}" || true)" echo "${M}" | jq -e '.workload.memLimitMi != null and .runtime.uptimeSeconds != null' >/dev/null 2>&1 \ && log_success "perf-sensor measures ${APP_NS} (limit $(echo "${M}" | jq -r .workload.memLimitMi) Mi, uptime $(echo "${M}" | jq -r .runtime.uptimeSeconds) s)" \ || fail "perf-sensor returned no workload/runtime facts for ${APP_NS}: $(echo "${M}" | head -c 300)" diff --git a/infra/scripts/deploy/java-performance/platform-placement.sh b/infra/scripts/deploy/java-performance/platform-placement.sh index 545a8cce..3c5f9069 100755 --- a/infra/scripts/deploy/java-performance/platform-placement.sh +++ b/infra/scripts/deploy/java-performance/platform-placement.sh @@ -1,6 +1,6 @@ #!/bin/bash # ============================================================================= -# platform-placement.sh — keep the platform off the application's node (java-performance, java-on-amazon-eks). +# platform-placement.sh — keep the platform off the application's node (java-performance). # # The app selects the `workshop` NodePool (karpenter.sh/nodepool: workshop). The platform # workloads have no selector, so Karpenter places them wherever there is room, usually on diff --git a/infra/scripts/templates/java-performance.sh b/infra/scripts/templates/java-performance.sh index f6dbf75d..4c5aac48 100755 --- a/infra/scripts/templates/java-performance.sh +++ b/infra/scripts/templates/java-performance.sh @@ -112,4 +112,4 @@ run "Environment verified" "$DEPLOY/verify.sh" log_success "$(stamp)java-performance post-deploy completed" # Emit for bootstrap summary -echo "✅ Success: Java-on-Amazon-EKS workshop template" +echo "✅ Success: Java-Performance workshop template" diff --git a/infra/scripts/ws-test/README.md b/infra/scripts/ws-test/README.md index 93ca3b96..b23101cb 100644 --- a/infra/scripts/ws-test/README.md +++ b/infra/scripts/ws-test/README.md @@ -43,7 +43,7 @@ export WS_ENV_FILE=~/path/to/.env # git-ignored: export AWS_DEFAULT_REGION=. cd infra/scripts/ws-test node generate.mjs java-performance && git commit ... # content changed: regenerate, commit -remote/sync-branch.sh [--skills] [--sensor] # IDE ~/java-on-aws → this branch +remote/sync-branch.sh [--skills] [--sensor] # IDE ~/java-on-aws → this branch (--workshop=NAME) remote/run-ws-test.sh java-performance # reset-app.sh, then the run (~20 min) REPORT=$(remote/fetch-report.sh java-performance) # → ~/.cache/ws-test/reports/... samples/refresh.py java-performance "$REPORT" ../../../../java-performance/content @@ -51,7 +51,8 @@ samples/refresh.py java-performance "$REPORT" ../../../../java-performance/conte - `sync-branch.sh` ships only commits. `--skills` reinstalls the Claude Code skills and settings (`claude-code.sh`; reset-app does not), `--sensor` rebuilds and reinstalls the - sensor (`perf-sensor.sh`). + sensor (`perf-sensor.sh`). Both run the scripts of `--workshop=NAME` + (`infra/scripts/deploy/NAME`, default java-performance). - `run-ws-test.sh` runs detached on the IDE and polls every 30 s; `--no-wait` returns at once, `--status` shows the last lines later, `--no-reset` skips `reset-app.sh`, arguments after `--` go to the test script. Never use an interactive Claude on the IDE during a run: diff --git a/infra/scripts/ws-test/remote/sync-branch.sh b/infra/scripts/ws-test/remote/sync-branch.sh index 0bfb83a4..64c00edc 100755 --- a/infra/scripts/ws-test/remote/sync-branch.sh +++ b/infra/scripts/ws-test/remote/sync-branch.sh @@ -1,13 +1,15 @@ #!/bin/bash # ============================================================================= -# sync-branch.sh [--skills] [--sensor] [BRANCH] — move the IDE's ~/java-on-aws to a -# local branch that is not pushed: bundle origin/main..BRANCH (default: the current -# branch), upload it to S3, fetch it on the IDE and `checkout -B` the branch there. -# Only commits travel: commit first. +# sync-branch.sh [--workshop=NAME] [--skills] [--sensor] [BRANCH] — move the IDE's +# ~/java-on-aws to a local branch that is not pushed: bundle origin/main..BRANCH (default: +# the current branch), upload it to S3, fetch it on the IDE and `checkout -B` the branch +# there. Only commits travel: commit first. # -# --skills then reinstall the Claude Code skills and settings (claude-code.sh); -# reset-app.sh does not -# --sensor then rebuild and reinstall the sensor (perf-sensor.sh) +# --workshop=NAME the deploy scripts --skills and --sensor run (infra/scripts/deploy/NAME, +# default java-performance) +# --skills then reinstall the Claude Code skills and settings (claude-code.sh); +# reset-app.sh does not +# --sensor then rebuild and reinstall the sensor (perf-sensor.sh) # # The IDE clones java-on-aws main at bootstrap, so a fresh environment needs this # before every run of a branch. @@ -16,12 +18,13 @@ set -euo pipefail DIR="$(dirname "${BASH_SOURCE[0]}")" source "$DIR/env.sh" -SKILLS=0; SENSOR=0; BRANCH="" +SKILLS=0; SENSOR=0; BRANCH=""; WORKSHOP=java-performance for a in "$@"; do case "$a" in + --workshop=?*) WORKSHOP="${a#--workshop=}" ;; --skills) SKILLS=1 ;; --sensor) SENSOR=1 ;; - -*) sed -n 3,13p "$0" | sed 's/^# \{0,1\}//'; exit 2 ;; + -*) sed -n 3,15p "$0" | sed 's/^# \{0,1\}//'; exit 2 ;; *) BRANCH="$a" ;; esac done @@ -33,7 +36,9 @@ BUNDLE="$WS_LOCAL/java-on-aws.bundle" git -C "$WS_REPO" bundle create -q "$BUNDLE" "origin/main..$BRANCH" aws s3 cp --quiet "$BUNDLE" "$WS_S3/java-on-aws.bundle" -DEPLOY='~/java-on-aws/infra/scripts/deploy/java-performance' +[ -d "$WS_REPO/infra/scripts/deploy/$WORKSHOP" ] \ + || { echo "No deploy scripts for workshop $WORKSHOP (infra/scripts/deploy/$WORKSHOP)" >&2; exit 2; } +DEPLOY="~/java-on-aws/infra/scripts/deploy/$WORKSHOP" { cat <