nuthatch/tests/kind/recovery-run.sh
ms2k 15eb0038d2 Replace planning-era names and squash initial schema
Use descriptive test and executable names so implementation surfaces do not depend on journal organization. Consolidate the unreleased migration history into the authoritative initial schema because no deployed database requires an upgrade path.
2026-07-19 15:21:24 +09:00

408 lines
12 KiB
Shell
Executable file

#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
source "$ROOT/tests/kind/lib.sh"
USER_KUBECONFIG_BEFORE="$(user_kubeconfig_digest)"
SUCCEEDED=false
OWNS_FIXTURE=false
APP_IMAGE=nuthatch-recovery:local
cleanup() {
local status=$?
trap - EXIT INT TERM
if [[ "${NUTHATCH_TESTBED_RETAIN:-0}" = 1 ]] && [[ "$SUCCEEDED" != true ]]; then
printf 'retained kubeconfig: %s\n' "$KUBECONFIG_PATH"
printf 'retained context: %s\n' "$CONTEXT"
exit "$status"
fi
if [[ "$OWNS_FIXTURE" = true ]]; then
destroy_fixture || status=1
fi
if [[ "$(user_kubeconfig_digest)" != "$USER_KUBECONFIG_BEFORE" ]]; then
printf 'user kubeconfig changed unexpectedly\n' >&2
status=1
fi
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
pg_value() {
local database="$1"
local query="$2"
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/postgres -- \
env PGPASSWORD=synthetic-postgres psql \
--username nuthatch \
--dbname "$database" \
--tuples-only \
--no-align \
--set ON_ERROR_STOP=1 \
--command "$query" |
tr -d '[:space:]'
}
wait_for_value() {
local database="$1"
local query="$2"
local expected="$3"
local description="$4"
for _ in $(seq 1 120); do
if [[ "$(pg_value "$database" "$query" 2>/dev/null || true)" = "$expected" ]]; then
return
fi
sleep 0.25
done
printf 'timed out waiting for %s\n' "$description" >&2
return 1
}
render_application() {
local lane="$1"
local profile_file="$2"
local pg_conn
local redis_url
local s3_endpoint
local s3_region
local s3_bucket
local s3_access_key
local s3_secret_key
pg_conn="$(sed -n 's/^NUTHATCH_PG_CONN=//p' "$profile_file")"
redis_url="$(sed -n 's/^NUTHATCH_REDIS_URL=//p' "$profile_file")"
s3_endpoint="$(sed -n 's/^NUTHATCH_S3_ENDPOINT=//p' "$profile_file")"
s3_region="$(sed -n 's/^NUTHATCH_S3_REGION=//p' "$profile_file")"
s3_bucket="$(sed -n 's/^NUTHATCH_S3_BUCKET=//p' "$profile_file")"
s3_access_key="$(sed -n 's/^NUTHATCH_S3_ACCESS_KEY=//p' "$profile_file")"
s3_secret_key="$(sed -n 's/^NUTHATCH_S3_SECRET_KEY=//p' "$profile_file")"
sed \
-e "s|@@LANE@@|$lane|g" \
-e "s|@@APP_IMAGE@@|$APP_IMAGE|g" \
-e "s|@@PG_CONN@@|$pg_conn|g" \
-e "s|@@REDIS_URL@@|$redis_url|g" \
-e "s|@@OIDC_ISSUER@@|https://issuer.invalid|g" \
-e "s|@@OIDC_CLIENT_ID@@|recovery|g" \
-e "s|@@OIDC_ADDITIONAL_AUDIENCES@@||g" \
-e "s|@@OIDC_CLIENT_SECRET@@|synthetic-recovery-oidc-client-secret|g" \
-e "s|@@S3_ENDPOINT@@|$s3_endpoint|g" \
-e "s|@@S3_REGION@@|$s3_region|g" \
-e "s|@@S3_BUCKET@@|$s3_bucket|g" \
-e "s|@@S3_ACCESS_KEY@@|$s3_access_key|g" \
-e "s|@@S3_SECRET_KEY@@|$s3_secret_key|g" \
"$ROOT/deploy/kubernetes/nuthatch.yaml.in" >"$RUNTIME/recovery-app-$lane.yaml"
sed -i \
's/NUTHATCH_OBJECT_REQUEST_TIMEOUT_SEC: "300"/NUTHATCH_OBJECT_REQUEST_TIMEOUT_SEC: "2"/' \
"$RUNTIME/recovery-app-$lane.yaml"
sed -i \
'/NUTHATCH_OBJECT_REQUEST_TIMEOUT_SEC/a\ NUTHATCH_RECOVERY_GRACE_SEC: "1"' \
"$RUNTIME/recovery-app-$lane.yaml"
sed -i 's/nameservers: \[10.96.0.53\]/nameservers: [10.96.0.10]/' \
"$RUNTIME/recovery-app-$lane.yaml"
sed -i 's/ replicas: 1/ replicas: 2/' "$RUNTIME/recovery-app-$lane.yaml"
}
deploy_recovery() {
local lane="$1"
local profile="$2"
render_application "$lane" "$RUNTIME/$profile.env"
kube apply --filename "$RUNTIME/recovery-app-$lane.yaml" >/dev/null
kube --namespace "$FIXTURE_NAMESPACE" rollout status \
"deployment/nuthatch-$lane" --timeout=180s
}
cleanup_recovery() {
local lane="$1"
kube --namespace "$FIXTURE_NAMESPACE" delete \
"deployment/nuthatch-$lane" \
"service/nuthatch-$lane" \
"configmap/nuthatch-$lane" \
"secret/nuthatch-$lane" \
--ignore-not-found >/dev/null
kube --namespace "$FIXTURE_NAMESPACE" wait \
--for=delete pod \
--selector="app=nuthatch-$lane" \
--timeout=60s >/dev/null
rm -f "$RUNTIME/recovery-app-$lane.yaml"
}
start_phase() {
local lane="$1"
local profile="$2"
local phase="$3"
local job="recovery-$phase-$lane"
render_lane_job \
"$job" \
"$TESTBED/recovery-phase-job.yaml.in" \
"$APP_IMAGE" \
"$profile"
sed -i "s|@@PHASE@@|$phase|g" "$RUNTIME/$job.yaml"
kube apply --filename "$RUNTIME/$job.yaml" >/dev/null
}
kill_phase() {
local lane="$1"
local phase="$2"
local job="recovery-$phase-$lane"
kube --namespace "$FIXTURE_NAMESPACE" delete pod \
--selector="job-name=$job" \
--grace-period=0 \
--force >/dev/null
kube --namespace "$FIXTURE_NAMESPACE" delete "job/$job" \
--ignore-not-found >/dev/null
}
wait_for_phase_checkpoint() {
local lane="$1"
local phase="$2"
local operation="recovery-$phase"
local job="recovery-$phase-$lane"
for _ in $(seq 1 120); do
if kube --namespace "$FIXTURE_NAMESPACE" logs "job/$job" 2>/dev/null |
grep -F "recovery checkpoint $operation" >/dev/null; then
return
fi
sleep 0.25
done
printf 'timed out waiting for %s checkpoint\n' "$phase" >&2
return 1
}
assert_terminal_authority() {
local database="$1"
local operation="$2"
local result="$3"
wait_for_value \
"$database" \
"SELECT state FROM mutations WHERE operation_id = '$operation'" \
"$result" \
"$operation terminal result"
test "$(
pg_value "$database" \
"SELECT count(*) FROM key_locks WHERE operation_id = '$operation'"
)" = 0
test "$(
pg_value "$database" \
"SELECT reserved_bytes FROM pools WHERE pool_id = 'shared'"
)" = 0
}
set_service_available() {
local service="$1"
local available="$2"
local selector="$service"
if [[ "$available" = false ]]; then
selector=recovery-unavailable
fi
kube --namespace "$FIXTURE_NAMESPACE" patch service "$service" \
--type merge \
--patch "{\"spec\":{\"selector\":{\"app\":\"$selector\"}}}" >/dev/null
}
terminate_database_connections() {
local database="$1"
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/postgres -- \
env PGPASSWORD=synthetic-postgres psql \
--username nuthatch \
--dbname postgres \
--set ON_ERROR_STOP=1 \
--command \
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$database' AND pid <> pg_backend_pid()" \
>/dev/null
}
assert_object_absent() {
local target="$1"
local bucket="$2"
local key="$3"
local endpoint
endpoint="$(target_loopback_endpoint "$target")"
if kube --namespace "$FIXTURE_NAMESPACE" exec "deployment/$target" \
--container s3-readiness -- \
aws --endpoint-url "$endpoint" s3api head-object \
--bucket "$bucket" \
--key "$key" >/dev/null 2>&1; then
return 1
fi
}
assert_object_present() {
local target="$1"
local bucket="$2"
local key="$3"
local endpoint
endpoint="$(target_loopback_endpoint "$target")"
kube --namespace "$FIXTURE_NAMESPACE" exec "deployment/$target" \
--container s3-readiness -- \
aws --endpoint-url "$endpoint" s3api head-object \
--bucket "$bucket" \
--key "$key" >/dev/null
}
assert_cache_invalidated() {
local lane="$1"
local value
value="$(
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/redis -- \
redis-cli --raw -n "$((10#$lane))" \
GET nuthatch:list:recovery-user:shared:: |
tr -d '[:space:]'
)"
test -z "$value"
}
run_phase_recovery() {
local lane="$1"
local profile="$2"
local database="$3"
local target="$4"
local phase="$5"
local durable_state="$6"
local terminal_state="$7"
local operation="recovery-$phase"
start_phase "$lane" "$profile" "$phase"
wait_for_value \
"$database" \
"SELECT state || ':' || external_state FROM mutations WHERE operation_id = '$operation'" \
"$durable_state" \
"$phase durable checkpoint"
if [[ "$phase" = during-put ]]; then
sleep 1
fi
kill_phase "$lane" "$phase"
deploy_recovery "$lane" "$profile"
assert_terminal_authority "$database" "$operation" "$terminal_state"
cleanup_recovery "$lane"
if [[ "$terminal_state" = succeeded ]]; then
assert_cache_invalidated "$lane"
fi
}
acquire_fixture_lock
OWNS_FIXTURE=true
setup_fixture durable coordination target
source "$RUNTIME/fixture.env"
"$NUTHATCH_REAL_NERDCTL" --namespace "$NERDCTL_NAMESPACE" build \
--file "$ROOT/Dockerfile" \
--target test-runtime \
--tag "$APP_IMAGE" \
"$ROOT"
load_local_image "$APP_IMAGE" "$NERDCTL_NAMESPACE"
while IFS=$'\t' read -r lane coordination oidc target; do
if [[ -n "${NUTHATCH_RECOVERY_TARGET:-}" ]] &&
[[ "$target" != "$NUTHATCH_RECOVERY_TARGET" ]]; then
continue
fi
profile="$(write_lane_profile "$lane" redis - "$target")"
database="nuthatch_lane_$lane"
bucket="nuthatch-lane-$lane"
printf 'Running recovery lane %s: target=%s\n' "$lane" "$target"
run_phase_recovery \
"$lane" "$profile" "$database" "$target" \
before-dispatch prepared:idle failed
run_phase_recovery \
"$lane" "$profile" "$database" "$target" \
during-put applying:pending failed
sleep 5
assert_object_absent "$target" "$bucket" \
shared/recovery-during-put.bin
start_phase "$lane" "$profile" live-timeout
wait_for_phase_checkpoint "$lane" live-timeout
test "$(
pg_value "$database" \
"SELECT count(*) FROM key_locks WHERE operation_id = 'recovery-live-timeout'"
)" = 1
test "$(
pg_value "$database" \
"SELECT reserved_bytes FROM pools WHERE pool_id = 'shared'"
)" = "$((20 * 1024 * 1024))"
kill_phase "$lane" live-timeout
deploy_recovery "$lane" "$profile"
assert_terminal_authority "$database" recovery-live-timeout failed
cleanup_recovery "$lane"
sleep 5
assert_object_absent "$target" "$bucket" \
shared/recovery-live-timeout.bin
start_phase "$lane" "$profile" after-dispatch
wait_for_value \
"$database" \
"SELECT state || ':' || external_state FROM mutations WHERE operation_id = 'recovery-after-dispatch'" \
applying:pending \
"after-dispatch checkpoint"
kill_phase "$lane" after-dispatch
pg_value \
"$database" \
"UPDATE mutations SET request_deadline_at = now() + interval '60 seconds', worker_lease_until = now() + interval '60 seconds' WHERE operation_id = 'recovery-after-dispatch'; SELECT 1" \
>/dev/null
deploy_recovery "$lane" "$profile"
before_pg_restarts="$(
kube --namespace "$FIXTURE_NAMESPACE" get pods \
--selector="app=nuthatch-$lane" \
--output json |
jq '[.items[].status.containerStatuses[].restartCount] | add'
)"
set_service_available postgres false
terminate_database_connections "$database"
sleep 6
test "$(
kube --namespace "$FIXTURE_NAMESPACE" get pods \
--selector="app=nuthatch-$lane" \
--output json |
jq '[.items[].status.containerStatuses[].restartCount] | add'
)" = "$before_pg_restarts"
set_service_available postgres true
wait_for_value "$database" "SELECT 1" 1 "PostgreSQL recovery"
pg_value \
"$database" \
"UPDATE mutations SET request_deadline_at = now() - interval '1 second', worker_lease_until = now() - interval '1 second' WHERE operation_id = 'recovery-after-dispatch'; SELECT 1" \
>/dev/null
assert_terminal_authority "$database" recovery-after-dispatch succeeded
cleanup_recovery "$lane"
assert_cache_invalidated "$lane"
assert_object_present "$target" "$bucket" \
shared/recovery-after-dispatch.bin
start_phase "$lane" "$profile" after-settlement
wait_for_value \
"$database" \
"SELECT state || ':' || external_state FROM mutations WHERE operation_id = 'recovery-after-settlement'" \
applying:settled \
"after-settlement checkpoint"
kill_phase "$lane" after-settlement
set_service_available "$target" false
deploy_recovery "$lane" "$profile"
sleep 3
kube --namespace "$FIXTURE_NAMESPACE" logs \
--selector="app=nuthatch-$lane" \
--prefix=true |
grep -F '"result":"exhausted"'
set_service_available "$target" true
assert_terminal_authority "$database" recovery-after-settlement succeeded
cleanup_recovery "$lane"
assert_cache_invalidated "$lane"
run_phase_recovery \
"$lane" "$profile" "$database" "$target" \
delete-settled applying:settled succeeded
assert_object_absent "$target" "$bucket" \
shared/recovery-delete-settled.bin
cleanup_lane_state "$lane" redis - "$target"
for phase in \
before-dispatch \
during-put \
live-timeout \
after-dispatch \
after-settlement \
delete-settled; do
cleanup_lane_resources "recovery-$phase-$lane" "$profile"
done
done < <(expand_fixture_matrix target)
SUCCEEDED=true
printf 'Nuthatch recovery gate passed\n'