#!/usr/bin/env bash set -euo pipefail ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) KUBECTL="$ROOT/.tools/bin/kubectl" CONTEXT=kind-who-need-help CLUSTER_CONTAINER=who-need-help-control-plane NAMESPACE=who-need-help WEB_DEPLOYMENT=who-need-help-who-need-help-web WORKER_DEPLOYMENT=who-need-help-who-need-help-worker SERVICE=who-need-help-who-need-help OWNERSHIP_MARKER="$ROOT/.tools/who-need-help.owned" ROLLOUT_TIMEOUT=${KIND_ROLLOUT_TIMEOUT:-180s} PROBE_INTERVAL=${KIND_ROLLOUT_PROBE_INTERVAL_SECONDS:-0.05} PROBE_TIMEOUT=${KIND_ROLLOUT_PROBE_TIMEOUT_SECONDS:-2} PROBE_RETRIES=${KIND_ROLLOUT_PROBE_RETRIES:-2} CLUSTER_JOIN_TIMEOUT=${KIND_CLUSTER_JOIN_TIMEOUT_SECONDS:-120} LABEL=${1:-"kind-rollout-$(date -u +%Y%m%dT%H%M%SZ)"} if [[ ! -x "$KUBECTL" ]]; then echo "The project-owned kubectl is missing. Run scripts/bootstrap-kubernetes-tools.sh." >&2 exit 1 fi if [[ ! -f "$OWNERSHIP_MARKER" ]]; then echo "The kind ownership marker is missing; refusing to mutate the cluster." >&2 exit 1 fi if [[ ! "$LABEL" =~ ^[A-Za-z0-9._-]+$ ]]; then echo "Run label may contain only letters, numbers, dot, underscore, and dash." >&2 exit 1 fi if [[ ! "$ROLLOUT_TIMEOUT" =~ ^[1-9][0-9]*[smh]$ ]]; then echo "KIND_ROLLOUT_TIMEOUT must be a positive Kubernetes duration in s, m, or h." >&2 exit 1 fi if ! awk -v value="$PROBE_INTERVAL" \ 'BEGIN {exit !(value ~ /^[0-9]+([.][0-9]+)?$/ && value > 0)}'; then echo "KIND_ROLLOUT_PROBE_INTERVAL_SECONDS must be greater than zero." >&2 exit 1 fi if [[ ! "$PROBE_TIMEOUT" =~ ^[1-9][0-9]*$ ]]; then echo "KIND_ROLLOUT_PROBE_TIMEOUT_SECONDS must be a positive integer." >&2 exit 1 fi if [[ ! "$PROBE_RETRIES" =~ ^[0-9]+$ ]]; then echo "KIND_ROLLOUT_PROBE_RETRIES must be a non-negative integer." >&2 exit 1 fi if [[ ! "$CLUSTER_JOIN_TIMEOUT" =~ ^[1-9][0-9]*$ ]]; then echo "KIND_CLUSTER_JOIN_TIMEOUT_SECONDS must be a positive integer." >&2 exit 1 fi kube=("$KUBECTL" --context "$CONTEXT" --namespace "$NAMESPACE") if ! "$KUBECTL" config get-contexts -o name | grep -Fxq "$CONTEXT"; then echo "The expected local kind context does not exist." >&2 exit 1 fi if [[ "$(docker inspect --format '{{index .Config.Labels "io.x-k8s.kind.cluster"}}' \ "$CLUSTER_CONTAINER")" != "who-need-help" ]]; then echo "The kind control-plane container does not belong to this project cluster." >&2 exit 1 fi output_dir="$ROOT/output/resilience/$LABEL" mkdir -p "$output_dir" chmod 700 "$ROOT/output" "$ROOT/output/resilience" "$output_dir" probe_marker="$output_dir/.probe-running" probe_log="$output_dir/readiness.jsonl" run_started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) touch "$probe_marker" deployment_snapshot() { "${kube[@]}" get deployment "$WEB_DEPLOYMENT" "$WORKER_DEPLOYMENT" -o json | jq '[ .items[] | { name: .metadata.name, generation: .metadata.generation, revision: .metadata.annotations["deployment.kubernetes.io/revision"], replicas: .spec.replicas, ready: .status.readyReplicas, available: .status.availableReplicas, updated: .status.updatedReplicas, strategy: .spec.strategy, image: .spec.template.spec.containers[0].image } ]' >"$1" } database_snapshot() { # Variables are intentionally expanded inside the PostGIS container. # shellcheck disable=SC2016 "${kube[@]}" exec -i postgis-0 -- sh -c \ 'psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \ --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' >"$1" <<'SQL' BEGIN READ ONLY; SELECT 'users' AS table_name, count(*) AS row_count FROM users UNION ALL SELECT 'users_tokens', count(*) FROM users_tokens UNION ALL SELECT 'help_requests', count(*) FROM help_requests UNION ALL SELECT 'messages', count(*) FROM messages UNION ALL SELECT 'categories', count(*) FROM categories UNION ALL SELECT 'help_assignments', count(*) FROM help_assignments UNION ALL SELECT 'activities', count(*) FROM activities UNION ALL SELECT 'reports', count(*) FROM reports UNION ALL SELECT 'social_identities', count(*) FROM social_identities UNION ALL SELECT 'tracking_sessions', count(*) FROM tracking_sessions UNION ALL SELECT 'tracking_positions', count(*) FROM tracking_positions UNION ALL SELECT 'schema_migrations', count(*) FROM schema_migrations ORDER BY table_name; COMMIT; SQL } pod_snapshot() { "${kube[@]}" get pods \ -l app.kubernetes.io/instance=who-need-help \ -o json | jq '[ .items[] | select( .metadata.labels["app.kubernetes.io/component"] == "web" or .metadata.labels["app.kubernetes.io/component"] == "worker" ) | { name: .metadata.name, uid: .metadata.uid, component: .metadata.labels["app.kubernetes.io/component"], ready: ([.status.containerStatuses[]?.ready] | all), restarts: ([.status.containerStatuses[]?.restartCount] | add // 0) } ] | sort_by(.component, .name)' >"$1" } stop_probe() { unlink "$probe_marker" 2>/dev/null || true if [[ -n "${probe_pid:-}" ]]; then wait "$probe_pid" 2>/dev/null || true fi } cleanup() { local status=$? trap - EXIT HUP INT TERM stop_probe exit "$status" } trap cleanup EXIT HUP INT TERM deployment_snapshot "$output_dir/deployments-before.json" if ! jq -e ' length == 2 and all( .replicas >= 2 and .ready == .replicas and .available == .replicas and .updated == .replicas and .strategy.type == "RollingUpdate" and .strategy.rollingUpdate.maxUnavailable == 0 and .strategy.rollingUpdate.maxSurge == 1 ) ' "$output_dir/deployments-before.json" >/dev/null; then echo "The local deployments are not ready for the recorded rolling strategy." >&2 exit 1 fi database_snapshot "$output_dir/database-before.txt" pod_snapshot "$output_dir/pods-before.json" node_port=$( "${kube[@]}" get service "$SERVICE" \ -o jsonpath='{.spec.ports[?(@.name=="http")].nodePort}' ) published=$( docker port "$CLUSTER_CONTAINER" "${node_port}/tcp" | head -n 1 ) host_port=${published##*:} if [[ ! "$host_port" =~ ^[1-9][0-9]*$ ]]; then echo "The kind HTTP NodePort has no observed Docker host mapping." >&2 exit 1 fi base_url="http://127.0.0.1:$host_port" sample_readiness() { while [[ -e "$probe_marker" ]]; do observed_at=$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ) body_file="$output_dir/kind-readiness-body.$$" error_file="$output_dir/kind-readiness-error.$$" set +e result=$( curl --silent --show-error \ --max-time "$PROBE_TIMEOUT" \ --retry "$PROBE_RETRIES" \ --retry-all-errors \ --retry-connrefused \ --retry-delay 0 \ --output "$body_file" \ --write-out '%{http_code} %{num_retries}' \ "$base_url/healthz/ready" 2>"$error_file" ) curl_status=$? set -e read -r status retries <<<"$result" if [[ "$curl_status" -ne 0 ]]; then status=000 fi if [[ -f "$body_file" ]]; then body=$(tr -d '\n' <"$body_file") else body= fi error=$(tr -d '\n' <"$error_file") unlink "$body_file" 2>/dev/null || true unlink "$error_file" 2>/dev/null || true jq -cn \ --arg observed_at "$observed_at" \ --arg status "$status" \ --arg body "$body" \ --arg error "$error" \ --argjson retries "${retries:-0}" \ '{ observed_at: $observed_at, status: $status, retries: $retries, body: $body, error: $error }' >>"$probe_log" sleep "$PROBE_INTERVAL" done } { printf 'observed_at=%s\n' "$run_started_at" printf 'context=%s\n' "$CONTEXT" printf 'namespace=%s\n' "$NAMESPACE" printf 'rollout_timeout=%s\n' "$ROLLOUT_TIMEOUT" printf 'probe_interval_seconds=%s\n' "$PROBE_INTERVAL" printf 'probe_timeout_seconds=%s\n' "$PROBE_TIMEOUT" printf 'probe_retries=%s\n' "$PROBE_RETRIES" printf 'cluster_join_timeout_seconds=%s\n' "$CLUSTER_JOIN_TIMEOUT" printf 'node_port=%s\n' "$node_port" printf 'observed_host_port=%s\n' "$host_port" "$KUBECTL" version --client } >"$output_dir/environment.txt" sample_readiness & probe_pid=$! "${kube[@]}" rollout restart "deployment/$WEB_DEPLOYMENT" \ >"$output_dir/web-rollout-restart.txt" "${kube[@]}" rollout status "deployment/$WEB_DEPLOYMENT" \ "--timeout=$ROLLOUT_TIMEOUT" >"$output_dir/web-rollout-status.txt" "${kube[@]}" rollout restart "deployment/$WORKER_DEPLOYMENT" \ >"$output_dir/worker-rollout-restart.txt" "${kube[@]}" rollout status "deployment/$WORKER_DEPLOYMENT" \ "--timeout=$ROLLOUT_TIMEOUT" >"$output_dir/worker-rollout-status.txt" old_pod_deadline=$((SECONDS + CLUSTER_JOIN_TIMEOUT)) while ((SECONDS < old_pod_deadline)); do "${kube[@]}" get pods \ -l app.kubernetes.io/instance=who-need-help \ -o json >"$output_dir/pods-current.json" retained_count=$( jq \ --slurpfile before "$output_dir/pods-before.json" \ '[ .items[].metadata.uid as $uid | select($before[0] | any(.uid == $uid)) ] | length' "$output_dir/pods-current.json" ) if [[ "$retained_count" == "0" ]]; then break fi sleep 1 done unlink "$output_dir/pods-current.json" 2>/dev/null || true if [[ "$retained_count" != "0" ]]; then echo "Old application pods did not terminate after the rollout." >&2 exit 1 fi mapfile -t current_application_pods < <( "${kube[@]}" get pods \ -l app.kubernetes.io/instance=who-need-help \ -o json | jq -r ' .items[] | select( .metadata.labels["app.kubernetes.io/component"] == "web" or .metadata.labels["app.kubernetes.io/component"] == "worker" ) | "pod/" + .metadata.name ' ) if [[ "${#current_application_pods[@]}" -ne 4 ]]; then echo "Expected four replacement application pods; observed ${#current_application_pods[@]}." >&2 exit 1 fi "${kube[@]}" wait \ --for=condition=Ready \ --timeout="$ROLLOUT_TIMEOUT" \ "${current_application_pods[@]}" \ >"$output_dir/pods-ready.txt" deployment_snapshot "$output_dir/deployments-after.json" pod_snapshot "$output_dir/pods-after.json" database_snapshot "$output_dir/database-after.txt" if ! diff -u "$output_dir/database-before.txt" "$output_dir/database-after.txt" \ >"$output_dir/database-diff.txt"; then echo "The rolling verification changed tracked database counts." >&2 exit 1 fi jq -n \ --slurpfile before "$output_dir/pods-before.json" \ --slurpfile after "$output_dir/pods-after.json" \ '{ before_count: ($before[0] | length), after_count: ($after[0] | length), retained_uids: ( [$before[0][].uid] as $old | [$after[0][].uid | select(. as $uid | $old | index($uid))] ), after_all_ready: ($after[0] | all(.ready and .restarts == 0)) }' >"$output_dir/pod-replacement-summary.json" if ! jq -e ' .before_count == 4 and .after_count == 4 and .retained_uids == [] and .after_all_ready ' "$output_dir/pod-replacement-summary.json" >/dev/null; then echo "The rollout did not replace all four application pods cleanly." >&2 exit 1 fi web_pod=$( "${kube[@]}" get pod \ -l app.kubernetes.io/component=web \ --field-selector=status.phase=Running \ -o jsonpath='{.items[0].metadata.name}' ) expected_peers=$( jq '[.[].replicas] | add - 1' "$output_dir/deployments-after.json" ) cluster_deadline=$((SECONDS + CLUSTER_JOIN_TIMEOUT)) while ((SECONDS < cluster_deadline)); do peer_count=$( "${kube[@]}" exec "$web_pod" -- /app/bin/who_need_help rpc \ 'IO.puts(length(Node.list()))' 2>/dev/null | tail -n 1 ) if [[ "$peer_count" == "$expected_peers" ]]; then break fi sleep 1 done if [[ "$peer_count" != "$expected_peers" ]]; then echo "All rolled application pods did not join the BEAM cluster." >&2 exit 1 fi "${kube[@]}" exec "$web_pod" -- /app/bin/who_need_help rpc \ 'IO.inspect(%{node: node(), peers: Node.list(), peer_count: length(Node.list())})' \ >"$output_dir/cluster-after.txt" "$ROOT/scripts/verify-realtime-cluster.sh" kind \ >"$output_dir/pubsub-after.txt" "$ROOT/scripts/verify-beam-runtime.sh" kind \ "$output_dir/beam-runtime-after.json" >/dev/null stop_probe probe_pid= jq -s '{ samples: length, failures: (map(select(.status != "200")) | length), retried_samples: (map(select(.retries > 0)) | length), total_retries: (map(.retries) | add), nodes: (map(.body | fromjson? | .node) | map(select(. != null)) | unique) }' "$probe_log" >"$output_dir/readiness-summary.json" "${kube[@]}" get pods \ -l app.kubernetes.io/component=web \ -o json | jq '[.items[].status.podIP | "who_need_help@" + .] | sort' \ >"$output_dir/current-web-nodes.json" if ! jq -e \ --slurpfile current "$output_dir/current-web-nodes.json" ' .samples > 0 and .failures == 0 and ($current[0] | length) == 2 and (($current[0] - .nodes) | length) == 0 ' "$output_dir/readiness-summary.json" >/dev/null; then echo "Kind readiness was unavailable or did not reach both web replicas." >&2 exit 1 fi "${kube[@]}" logs \ --selector app.kubernetes.io/instance=who-need-help \ --all-containers \ --since-time "$run_started_at" \ --prefix >"$output_dir/application.log" 2>&1 trap - EXIT HUP INT TERM printf 'Kind rollout evidence: %s\n' "$output_dir"