230 lines
7.6 KiB
Bash
Executable File
230 lines
7.6 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 prepare USER_EMAIL --confirm whoneedhelp.com
|
|
./scripts/production-web-push-smoke.sh verify --from-state --confirm whoneedhelp.com
|
|
./scripts/production-web-push-smoke.sh cleanup --from-state --confirm whoneedhelp.com
|
|
|
|
prepare sends one browser-only production Web Push notification to the newest
|
|
active Web Push device for USER_EMAIL. verify proves the exact Oban delivery
|
|
completed on its first attempt. cleanup removes only the run-scoped notification,
|
|
job, manifest, and temporary script. The separate phases leave time to inspect
|
|
and click the operating-system notification. No email worker, Android FCM device,
|
|
Test project, Caddy, or public Git is used.
|
|
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'
|
|
}
|
|
|
|
read_state_value() {
|
|
local key=$1
|
|
|
|
awk -F= -v key="$key" '
|
|
$1 == key {
|
|
if (found) exit 2
|
|
print substr($0, index($0, "=") + 1)
|
|
found = 1
|
|
}
|
|
END {if (found != 1) exit 1}
|
|
' "$STATE_FILE"
|
|
}
|
|
|
|
if [[ $# -ne 4 ]]; then
|
|
usage
|
|
fi
|
|
|
|
ACTION=$1
|
|
USER_EMAIL=${2,,}
|
|
CONFIRMATION=$3
|
|
HOST=$4
|
|
|
|
case "$ACTION" in
|
|
plan) [[ "$CONFIRMATION" == --check-only ]] || usage ;;
|
|
prepare) [[ "$CONFIRMATION" == --confirm ]] || usage ;;
|
|
verify | cleanup)
|
|
[[ "$USER_EMAIL" == --from-state && "$CONFIRMATION" == --confirm ]] || usage
|
|
;;
|
|
*) usage ;;
|
|
esac
|
|
|
|
[[ "$HOST" == whoneedhelp.com ]] || usage
|
|
|
|
if [[ "$ACTION" == plan || "$ACTION" == prepare ]]; then
|
|
if [[ ! "$USER_EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+$ ]]; then
|
|
echo "USER_EMAIL is invalid." >&2
|
|
exit 1
|
|
fi
|
|
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, Test project, Caddy, public Git\n'
|
|
printf 'cleanup=exact run-scoped notification, job, manifest, and temporary script\n'
|
|
exit 0
|
|
fi
|
|
|
|
mkdir -p "$ROOT/output/runtime"
|
|
chmod 700 "$ROOT/output/runtime"
|
|
umask 077
|
|
|
|
if [[ "$ACTION" == prepare ]]; then
|
|
if [[ -e "$STATE_FILE" ]]; then
|
|
echo "A prior Web Push smoke state exists; inspect it before starting another run." >&2
|
|
exit 1
|
|
fi
|
|
|
|
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
|
|
user_email=$USER_EMAIL
|
|
remote_script=$REMOTE_SCRIPT
|
|
remote_manifest=$REMOTE_MANIFEST
|
|
EOF
|
|
chmod 600 "$STATE_FILE"
|
|
|
|
ssh -o BatchMode=yes "$SSH_TARGET" \
|
|
"docker exec -i '$CONTAINER' sh -c 'umask 077; cat >\"$REMOTE_SCRIPT\"'" <"$LOCAL_SCRIPT"
|
|
else
|
|
if [[ ! -f "$STATE_FILE" ]]; then
|
|
echo "No Web Push smoke state exists." >&2
|
|
exit 1
|
|
fi
|
|
|
|
schema_version=$(read_state_value schema_version)
|
|
run_id=$(read_state_value run_id)
|
|
ssh_target=$(read_state_value ssh_target)
|
|
container=$(read_state_value container)
|
|
user_email=$(read_state_value user_email)
|
|
remote_script=$(read_state_value remote_script)
|
|
remote_manifest=$(read_state_value remote_manifest)
|
|
|
|
expected_remote_script="/tmp/wnh-production-web-push-$run_id.exs"
|
|
expected_remote_manifest="/tmp/wnh-production-web-push-$run_id.json"
|
|
|
|
if [[ "${schema_version:-}" != 1 || "${ssh_target:-}" != "$SSH_TARGET" ||
|
|
"${container:-}" != "$CONTAINER" ||
|
|
! "${run_id:-}" =~ ^[0-9]{14}-[a-f0-9]{12}$ ||
|
|
! "${user_email:-}" =~ ^[^[:space:]@]+@[^[:space:]@]+$ ||
|
|
"${remote_script:-}" != "$expected_remote_script" ||
|
|
"${remote_manifest:-}" != "$expected_remote_manifest" ]]; then
|
|
echo "Web Push smoke state does not match the current production target." >&2
|
|
exit 1
|
|
fi
|
|
|
|
RUN_ID=$run_id
|
|
USER_EMAIL=$user_email
|
|
REMOTE_SCRIPT=$remote_script
|
|
REMOTE_MANIFEST=$remote_manifest
|
|
fi
|
|
|
|
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'"
|
|
}
|
|
|
|
case "$ACTION" in
|
|
prepare)
|
|
if ! rpc_action prepare; then
|
|
printf 'Preparation failed; state is preserved for exact inspection: %s\n' "$STATE_FILE" >&2
|
|
exit 1
|
|
fi
|
|
printf 'state=%s\nnext=inspect and click the OS notification, then run verify and cleanup\n' "$STATE_FILE"
|
|
;;
|
|
verify)
|
|
rpc_action verify
|
|
;;
|
|
cleanup)
|
|
rpc_action cleanup
|
|
ssh -o BatchMode=yes "$SSH_TARGET" \
|
|
"docker exec '$CONTAINER' rm -f '$REMOTE_SCRIPT' '$REMOTE_MANIFEST'"
|
|
rm -f "$STATE_FILE"
|
|
echo "production_web_push_smoke_cleanup_complete=true"
|
|
;;
|
|
esac
|