nuthatch/tests/kind/recovery-run.sh

397 lines
12 KiB
Shell
Raw Permalink Normal View History

2026-07-18 22:49:19 +09:00
#!/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
2026-07-18 22:49:19 +09:00
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:]'
}
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" \
2026-07-18 22:49:19 +09:00
-e "s|@@OIDC_ADDITIONAL_AUDIENCES@@||g" \
-e "s|@@OIDC_CLIENT_SECRET@@|synthetic-recovery-oidc-client-secret|g" \
2026-07-18 22:49:19 +09:00
-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"
2026-07-18 22:49:19 +09:00
sed -i \
's/NUTHATCH_OBJECT_REQUEST_TIMEOUT_SEC: "300"/NUTHATCH_OBJECT_REQUEST_TIMEOUT_SEC: "2"/' \
"$RUNTIME/recovery-app-$lane.yaml"
2026-07-18 22:49:19 +09:00
sed -i \
'/NUTHATCH_OBJECT_REQUEST_TIMEOUT_SEC/a\ NUTHATCH_RECOVERY_GRACE_SEC: "1"' \
"$RUNTIME/recovery-app-$lane.yaml"
2026-07-18 22:49:19 +09:00
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"
2026-07-18 22:49:19 +09:00
}
deploy_recovery() {
local lane="$1"
local profile="$2"
render_application "$lane" "$RUNTIME/$profile.env"
kube apply --filename "$RUNTIME/recovery-app-$lane.yaml" >/dev/null
2026-07-18 22:49:19 +09:00
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"
2026-07-18 22:49:19 +09:00
}
start_phase() {
local lane="$1"
local profile="$2"
local phase="$3"
local job="recovery-$phase-$lane"
2026-07-18 22:49:19 +09:00
render_lane_job \
"$job" \
"$TESTBED/recovery-phase-job.yaml.in" \
2026-07-18 22:49:19 +09:00
"$APP_IMAGE" \
"$profile"
sed -i \
-e "s|@@PHASE@@|$phase|g" \
-e 's|@@PROBE_ARGS@@||g' \
"$RUNTIME/$job.yaml"
2026-07-18 22:49:19 +09:00
kube apply --filename "$RUNTIME/$job.yaml" >/dev/null
}
run_recovery_probe() {
local lane="$1"
local profile="$2"
local suffix="$3"
shift 3
local probe_args
probe_args="$(
printf '"%s",' "$@"
)"
probe_args="${probe_args%,}"
local job="recovery-check-$suffix-$lane"
render_lane_job \
"$job" \
"$TESTBED/recovery-phase-job.yaml.in" \
"$APP_IMAGE" \
"$profile"
sed -i \
-e 's|@@PHASE@@|observer|g' \
-e "s|@@PROBE_ARGS@@|$probe_args|g" \
"$RUNTIME/$job.yaml"
run_job_manifest "$job" "$RUNTIME/$job.yaml"
kube --namespace "$FIXTURE_NAMESPACE" delete \
"job/$job" \
--ignore-not-found >/dev/null
rm -f "$RUNTIME/$job.yaml"
}
run_recovery_log_probe() {
local lane="$1"
local profile="$2"
local log_file="$RUNTIME/recovery-app-$lane.log"
local log_config_map="recovery-log-$lane"
local job="recovery-check-log-$lane"
kube --namespace "$FIXTURE_NAMESPACE" logs \
--selector="app=nuthatch-$lane" \
--prefix=true >"$log_file"
kube --namespace "$FIXTURE_NAMESPACE" create configmap "$log_config_map" \
--from-file="app.log=$log_file" \
--dry-run=client \
--output yaml |
kube apply --filename - >/dev/null
render_lane_job \
"$job" \
"$TESTBED/recovery-log-job.yaml.in" \
"$APP_IMAGE" \
"$profile"
sed -i \
-e "s|@@LOG_CONFIG_MAP@@|$log_config_map|g" \
"$RUNTIME/$job.yaml"
run_job_manifest "$job" "$RUNTIME/$job.yaml"
kube --namespace "$FIXTURE_NAMESPACE" delete \
"job/$job" \
"configmap/$log_config_map" \
--ignore-not-found >/dev/null
rm -f "$RUNTIME/$job.yaml" "$log_file"
}
2026-07-18 22:49:19 +09:00
kill_phase() {
local lane="$1"
local phase="$2"
local job="recovery-$phase-$lane"
2026-07-18 22:49:19 +09:00
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"
2026-07-18 22:49:19 +09:00
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
}
set_service_available() {
local service="$1"
local available="$2"
local selector="$service"
if [[ "$available" = false ]]; then
selector=recovery-unavailable
2026-07-18 22:49:19 +09:00
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
}
run_phase_recovery() {
local lane="$1"
local profile="$2"
local phase="$3"
local durable_state="$4"
local terminal_state="$5"
2026-07-18 22:49:19 +09:00
local operation="recovery-$phase"
start_phase "$lane" "$profile" "$phase"
run_recovery_probe \
"$lane" "$profile" "phase-$phase" \
wait-phase "$operation" "$durable_state"
2026-07-18 22:49:19 +09:00
if [[ "$phase" = during-put ]]; then
sleep 1
fi
kill_phase "$lane" "$phase"
deploy_recovery "$lane" "$profile"
run_recovery_probe \
"$lane" "$profile" "terminal-$phase" \
terminal "$operation" "$terminal_state"
2026-07-18 22:49:19 +09:00
cleanup_recovery "$lane"
if [[ "$terminal_state" = succeeded ]]; then
run_recovery_probe \
"$lane" "$profile" "cache-$phase" \
cache-invalidated
2026-07-18 22:49:19 +09:00
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
2026-07-18 22:49:19 +09:00
continue
fi
profile="$(write_lane_profile "$lane" redis - "$target")"
database="nuthatch_lane_$lane"
printf 'Running recovery lane %s: target=%s\n' "$lane" "$target"
2026-07-18 22:49:19 +09:00
run_phase_recovery \
"$lane" "$profile" \
2026-07-18 22:49:19 +09:00
before-dispatch prepared:idle failed
run_phase_recovery \
"$lane" "$profile" \
2026-07-18 22:49:19 +09:00
during-put applying:pending failed
sleep 5
run_recovery_probe \
"$lane" "$profile" during-put-object \
object absent shared/recovery-during-put.bin
2026-07-18 22:49:19 +09:00
start_phase "$lane" "$profile" live-timeout
wait_for_phase_checkpoint "$lane" live-timeout
run_recovery_probe \
"$lane" "$profile" live-timeout-active \
active recovery-live-timeout "$((20 * 1024 * 1024))"
2026-07-18 22:49:19 +09:00
kill_phase "$lane" live-timeout
deploy_recovery "$lane" "$profile"
run_recovery_probe \
"$lane" "$profile" live-timeout-terminal \
terminal recovery-live-timeout failed
2026-07-18 22:49:19 +09:00
cleanup_recovery "$lane"
sleep 5
run_recovery_probe \
"$lane" "$profile" live-timeout-object \
object absent shared/recovery-live-timeout.bin
2026-07-18 22:49:19 +09:00
start_phase "$lane" "$profile" after-dispatch
run_recovery_probe \
"$lane" "$profile" after-dispatch-phase \
wait-phase recovery-after-dispatch applying:pending
2026-07-18 22:49:19 +09:00
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'
)"
2026-07-18 22:49:19 +09:00
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"
2026-07-18 22:49:19 +09:00
set_service_available postgres true
run_recovery_probe \
"$lane" "$profile" database-available \
database-available
2026-07-18 22:49:19 +09:00
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
run_recovery_probe \
"$lane" "$profile" after-dispatch-terminal \
terminal recovery-after-dispatch succeeded
2026-07-18 22:49:19 +09:00
cleanup_recovery "$lane"
run_recovery_probe \
"$lane" "$profile" after-dispatch-cache \
cache-invalidated
run_recovery_probe \
"$lane" "$profile" after-dispatch-object \
object present shared/recovery-after-dispatch.bin
2026-07-18 22:49:19 +09:00
start_phase "$lane" "$profile" after-settlement
run_recovery_probe \
"$lane" "$profile" after-settlement-phase \
wait-phase recovery-after-settlement applying:settled
2026-07-18 22:49:19 +09:00
kill_phase "$lane" after-settlement
set_service_available "$target" false
deploy_recovery "$lane" "$profile"
sleep 3
run_recovery_log_probe "$lane" "$profile"
2026-07-18 22:49:19 +09:00
set_service_available "$target" true
run_recovery_probe \
"$lane" "$profile" after-settlement-terminal \
terminal recovery-after-settlement succeeded
2026-07-18 22:49:19 +09:00
cleanup_recovery "$lane"
run_recovery_probe \
"$lane" "$profile" after-settlement-cache \
cache-invalidated
2026-07-18 22:49:19 +09:00
run_phase_recovery \
"$lane" "$profile" \
2026-07-18 22:49:19 +09:00
delete-settled applying:settled succeeded
run_recovery_probe \
"$lane" "$profile" delete-settled-object \
object absent shared/recovery-delete-settled.bin
2026-07-18 22:49:19 +09:00
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"
2026-07-18 22:49:19 +09:00
done
done < <(expand_fixture_matrix target)
SUCCEEDED=true
printf 'Nuthatch recovery gate passed\n'