From b77808c34e84d9dadacc99e998a9d0b2f1695195 Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Mon, 9 Nov 2026 19:03:57 +0100 Subject: [PATCH 1/2] feat(java-on-amazon-eks): Own deploy scripts, copied from java-performance java-on-amazon-eks no longer runs the java-performance scripts: the ten one-line wrappers are full copies, and reset-app.sh, verify.sh and nodepool-pin.sh get the current java-performance versions (load stop, port-forward service, Kyverno policy cleanup, consolidation WhenEmpty). The two deploy folders differ only in the workshop name, so each workshop can change on its own. The apps (sensor, skills, profiler, unicorn store) stay shared. sync-branch.sh takes --workshop=NAME (default java-performance) for --skills and --sensor. --- .../deploy/java-on-amazon-eks/app-deploy.sh | 71 +++++++- .../deploy/java-on-amazon-eks/app-prepare.sh | 78 ++++++++- .../deploy/java-on-amazon-eks/claude-code.sh | 124 +++++++++++++- .../deploy/java-on-amazon-eks/ide-layout.sh | 47 +++++- .../deploy/java-on-amazon-eks/nodepool-pin.sh | 10 +- .../java-on-amazon-eks/perf-profiler.sh | 96 ++++++++++- .../deploy/java-on-amazon-eks/perf-sensor.sh | 159 +++++++++++++++++- .../java-on-amazon-eks/platform-placement.sh | 110 +++++++++++- .../java-on-amazon-eks/prebuild-images.sh | 91 +++++++++- .../deploy/java-on-amazon-eks/reset-app.sh | 37 +++- .../java-on-amazon-eks/startup-cpu-boost.sh | 47 +++++- .../deploy/java-on-amazon-eks/verify.sh | 12 +- .../java-performance/platform-placement.sh | 2 +- infra/scripts/ws-test/README.md | 5 +- infra/scripts/ws-test/remote/sync-branch.sh | 25 +-- 15 files changed, 856 insertions(+), 58 deletions(-) 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/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 < Date: Mon, 9 Nov 2026 19:19:29 +0100 Subject: [PATCH 2/2] fix(java-performance): Post-deploy success line names java-performance --- infra/scripts/templates/java-performance.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"