who_need_help/scripts/production-web-push-smoke.sh

208 lines
6.4 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
SSH_TARGET=${PRODUCTION_SSH_TARGET:-whoneedhelp}
REMOTE_ROOT=/srv/who_need_help-production
REMOTE_ENV=$REMOTE_ROOT/.env
EXPECTED_PROJECT=who_need_help_production
EXPECTED_ORIGIN=https://whoneedhelp.com
STATE_FILE="$ROOT/output/runtime/production-web-push-smoke.env"
LOCAL_SCRIPT="$ROOT/scripts/production-web-push-smoke.exs"
usage() {
cat >&2 <<'EOF'
Usage:
./scripts/production-web-push-smoke.sh plan USER_EMAIL --check-only whoneedhelp.com
./scripts/production-web-push-smoke.sh run USER_EMAIL --confirm whoneedhelp.com
run sends one browser-only production Web Push notification to the newest active
Web Push device for USER_EMAIL, verifies the exact Oban delivery completed on its
first attempt, then removes the run-scoped notification, job, and temporary files.
It never invokes the notification email worker or the Android FCM device.
EOF
exit 1
}
read_remote_env() {
local key=$1
ssh -o BatchMode=yes "$SSH_TARGET" \
"awk -F= -v key='$key' '\$1 == key {value = substr(\$0, index(\$0, \"=\") + 1); sub(/\\r\$/, \"\", value); if ((value ~ /^\".*\"\$/) || (value ~ /^\\047.*\\047\$/)) value = substr(value, 2, length(value) - 2); count++} END {if (count == 1) print value; else exit 1}' '$REMOTE_ENV'"
}
encode() {
printf %s "$1" | base64 | tr -d '\n'
}
if [[ $# -ne 4 ]]; then
usage
fi
ACTION=$1
USER_EMAIL=${2,,}
CONFIRMATION=$3
HOST=$4
case "$ACTION" in
plan) [[ "$CONFIRMATION" == --check-only ]] || usage ;;
run) [[ "$CONFIRMATION" == --confirm ]] || usage ;;
*) usage ;;
esac
[[ "$HOST" == whoneedhelp.com ]] || usage
if [[ ! "$USER_EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+$ ]]; then
echo "USER_EMAIL is invalid." >&2
exit 1
fi
if [[ ! -f "$LOCAL_SCRIPT" ]]; then
echo "Local smoke script is missing: $LOCAL_SCRIPT" >&2
exit 1
fi
DEPLOYMENT_ENV=$(read_remote_env DEPLOYMENT_ENV)
DEPLOYMENT_TARGET=$(read_remote_env DEPLOYMENT_TARGET)
PROJECT=$(read_remote_env COMPOSE_PROJECT_NAME)
ORIGIN=$(read_remote_env WNH_BASE_URL)
EXPECTED_DATABASE=$(read_remote_env POSTGRES_DB)
EXPECTED_IMAGE=$(read_remote_env APP_IMAGE)
if [[ "$DEPLOYMENT_ENV" != production || "$DEPLOYMENT_TARGET" != compose ||
"$PROJECT" != "$EXPECTED_PROJECT" || "$ORIGIN" != "$EXPECTED_ORIGIN" ||
-z "$EXPECTED_DATABASE" ]]; then
echo "Remote deployment identity does not match the production target." >&2
exit 1
fi
CONTAINER=$(ssh -o BatchMode=yes "$SSH_TARGET" \
"cd '$REMOTE_ROOT' && ./scripts/compose.sh '$REMOTE_ENV' ps -q app | head -n 1")
if [[ -z "$CONTAINER" ]]; then
echo "No running production app container was found." >&2
exit 1
fi
read -r OBSERVED_IMAGE CONTAINER_STATE CONTAINER_HEALTH < <(
ssh -o BatchMode=yes "$SSH_TARGET" \
"docker inspect --format '{{.Config.Image}} {{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' '$CONTAINER'"
)
if [[ "$OBSERVED_IMAGE" != "$EXPECTED_IMAGE" || "$CONTAINER_STATE" != running ||
"$CONTAINER_HEALTH" != healthy ]]; then
echo "Production container identity or health does not match the environment." >&2
exit 1
fi
ACTUAL_DATABASE=$(ssh -o BatchMode=yes "$SSH_TARGET" \
"docker exec '$CONTAINER' /app/bin/who_need_help rpc '%Postgrex.Result{rows: [[database]]} = WhoNeedHelp.Repo.query!(\"SELECT current_database()\", [], log: false); IO.puts(database)'" | tail -n 1)
if [[ "$ACTUAL_DATABASE" != "$EXPECTED_DATABASE" ]]; then
echo "Production database identity mismatch." >&2
exit 1
fi
if [[ "$ACTION" == plan ]]; then
printf 'scope=one production notification and one browser-only Web Push delivery job\n'
printf 'target=%s\ndatabase=%s\nimage=%s\ncontainer=%s\n' \
"$USER_EMAIL" "$ACTUAL_DATABASE" "$OBSERVED_IMAGE" "$CONTAINER"
printf 'excluded=email delivery, Android FCM, frozen test project, Caddy, public Git\n'
printf 'cleanup=exact run-scoped notification, job, manifest, and temporary script\n'
exit 0
fi
if [[ -e "$STATE_FILE" ]]; then
echo "A prior Web Push smoke state exists; inspect it before starting another run." >&2
exit 1
fi
mkdir -p "$ROOT/output/runtime"
chmod 700 "$ROOT/output/runtime"
umask 077
RUN_ID="$(date -u +%Y%m%d%H%M%S)-$(tr -d - </proc/sys/kernel/random/uuid | cut -c1-12)"
REMOTE_SCRIPT="/tmp/wnh-production-web-push-$RUN_ID.exs"
REMOTE_MANIFEST="/tmp/wnh-production-web-push-$RUN_ID.json"
cat >"$STATE_FILE" <<EOF
schema_version=1
run_id=$RUN_ID
ssh_target=$SSH_TARGET
container=$CONTAINER
remote_script=$REMOTE_SCRIPT
remote_manifest=$REMOTE_MANIFEST
EOF
chmod 600 "$STATE_FILE"
cleanup_complete=false
remove_temporary_files() {
ssh -o BatchMode=yes "$SSH_TARGET" \
"docker exec '$CONTAINER' rm -f '$REMOTE_SCRIPT' '$REMOTE_MANIFEST'" >/dev/null 2>&1 || true
rm -f "$STATE_FILE"
}
preserve_failed_run() {
local status=$1
trap - EXIT INT TERM
if [[ "$cleanup_complete" == true ]]; then
remove_temporary_files
else
printf '%s\n' \
"Web Push smoke did not complete exact record cleanup." \
"Run-scoped state was preserved for inspection:" \
" local state: $STATE_FILE" \
" remote manifest: $REMOTE_MANIFEST" \
" remote script: $REMOTE_SCRIPT" >&2
fi
exit "$status"
}
trap 'preserve_failed_run $?' EXIT
trap 'preserve_failed_run 130' INT
trap 'preserve_failed_run 143' TERM
ssh -o BatchMode=yes "$SSH_TARGET" \
"docker exec -i '$CONTAINER' sh -c 'umask 077; cat >\"$REMOTE_SCRIPT\"'" <"$LOCAL_SCRIPT"
RUN=$(encode "$RUN_ID")
DATABASE=$(encode "$EXPECTED_DATABASE")
EMAIL=$(encode "$USER_EMAIL")
MANIFEST=$(encode "$REMOTE_MANIFEST")
rpc_action() {
local action=$1
local expression
expression="Code.require_file(\"$REMOTE_SCRIPT\"); WhoNeedHelp.ProductionWebPushSmoke.run(\"$action\", %{run_id: Base.decode64!(\"$RUN\"), expected_database: Base.decode64!(\"$DATABASE\"), user_email: Base.decode64!(\"$EMAIL\"), manifest_path: Base.decode64!(\"$MANIFEST\")})"
ssh -o BatchMode=yes "$SSH_TARGET" \
"docker exec '$CONTAINER' /app/bin/who_need_help rpc '$expression'"
}
rpc_action prepare
verified=false
for _attempt in $(seq 1 20); do
if rpc_action verify; then
verified=true
break
fi
sleep 1
done
if [[ "$verified" != true ]]; then
echo "Web Push provider delivery did not verify within 20 seconds." >&2
echo "Run-scoped state remains in production for inspection; automatic record deletion was not attempted." >&2
exit 1
fi
rpc_action cleanup
cleanup_complete=true
remove_temporary_files
trap - EXIT INT TERM
echo "production_web_push_smoke_complete=true"