436 lines
13 KiB
Bash
Executable File
436 lines
13 KiB
Bash
Executable File
#!/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"
|
|
|
|
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"
|