Add scoped Android FCM production smoke
This commit is contained in:
parent
15c976213a
commit
4f81c38717
204
scripts/production-android-fcm-smoke.exs
Normal file
204
scripts/production-android-fcm-smoke.exs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
defmodule WhoNeedHelp.ProductionAndroidFCMSmoke do
|
||||
import Ecto.Query
|
||||
|
||||
alias Oban.Job
|
||||
alias WhoNeedHelp.Notifications.{Notification, PushDevice}
|
||||
alias WhoNeedHelp.Push.DeviceDeliveryWorker
|
||||
alias WhoNeedHelp.Repo
|
||||
|
||||
@allowed_actions ~w(prepare verify cleanup)
|
||||
|
||||
def run(action, options) when action in @allowed_actions and is_map(options) do
|
||||
context = verified_context(options)
|
||||
|
||||
case action do
|
||||
"prepare" -> prepare(context)
|
||||
"verify" -> verify(context)
|
||||
"cleanup" -> cleanup(context)
|
||||
end
|
||||
end
|
||||
|
||||
def run(_action, _options), do: raise("unsupported Android FCM smoke action")
|
||||
|
||||
defp verified_context(options) do
|
||||
run_id = required_option!(options, :run_id)
|
||||
expected_database = required_option!(options, :expected_database)
|
||||
device_id = required_option!(options, :device_id)
|
||||
manifest_path = required_option!(options, :manifest_path)
|
||||
|
||||
unless Regex.match?(~r/^[a-z0-9-]+$/, run_id), do: raise("invalid run id")
|
||||
unless uuid?(device_id), do: raise("invalid device id")
|
||||
|
||||
unless String.starts_with?(manifest_path, "/tmp/wnh-production-android-fcm-") do
|
||||
raise "invalid manifest path"
|
||||
end
|
||||
|
||||
%Postgrex.Result{rows: [[actual_database]]} =
|
||||
Repo.query!("SELECT current_database()", [], log: false)
|
||||
|
||||
unless actual_database == expected_database, do: raise("database identity mismatch")
|
||||
|
||||
%{
|
||||
run_id: run_id,
|
||||
database: actual_database,
|
||||
device_id: device_id,
|
||||
manifest_path: manifest_path,
|
||||
idempotency_key: "production-android-fcm-smoke:#{run_id}"
|
||||
}
|
||||
end
|
||||
|
||||
defp prepare(context) do
|
||||
if File.exists?(context.manifest_path), do: raise("manifest already exists")
|
||||
|
||||
device = Repo.get(PushDevice, context.device_id)
|
||||
|
||||
unless match?(
|
||||
%PushDevice{platform: :android, provider: :fcm, disabled_at: nil},
|
||||
device
|
||||
) do
|
||||
raise "target is not an active Android FCM device"
|
||||
end
|
||||
|
||||
if Repo.exists?(
|
||||
from(notification in Notification,
|
||||
where: notification.idempotency_key == ^context.idempotency_key
|
||||
)
|
||||
) do
|
||||
raise "run-scoped notification already exists"
|
||||
end
|
||||
|
||||
{:ok, fixture} =
|
||||
Repo.transaction(fn ->
|
||||
notification =
|
||||
%Notification{}
|
||||
|> Notification.changeset(%{
|
||||
user_id: device.user_id,
|
||||
kind: :support_update,
|
||||
title: "Who Need Help notification check",
|
||||
body: "Android notifications are working.",
|
||||
path: "/notifications",
|
||||
data: %{"run_id" => context.run_id, "synthetic" => true},
|
||||
idempotency_key: context.idempotency_key
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
job =
|
||||
%{"notification_id" => notification.id, "device_id" => device.id}
|
||||
|> DeviceDeliveryWorker.new()
|
||||
|> Oban.insert!()
|
||||
|
||||
%{notification: notification, job: job}
|
||||
end)
|
||||
|
||||
manifest = %{
|
||||
"schema_version" => 1,
|
||||
"run_id" => context.run_id,
|
||||
"database" => context.database,
|
||||
"idempotency_key" => context.idempotency_key,
|
||||
"notification_id" => fixture.notification.id,
|
||||
"device_id" => device.id,
|
||||
"job_id" => fixture.job.id
|
||||
}
|
||||
|
||||
File.write!(context.manifest_path, Jason.encode_to_iodata!(manifest, pretty: true))
|
||||
File.chmod!(context.manifest_path, 0o600)
|
||||
|
||||
IO.puts("android_fcm_smoke_prepared=true")
|
||||
IO.puts("job_id=#{fixture.job.id}")
|
||||
IO.puts("delivery_scope=one exact active Android FCM device")
|
||||
IO.puts("email_enqueued=false")
|
||||
end
|
||||
|
||||
defp verify(context) do
|
||||
fixture = load_and_validate_manifest!(context)
|
||||
notification = Repo.get(Notification, fixture["notification_id"])
|
||||
device = Repo.get(PushDevice, fixture["device_id"])
|
||||
job = Repo.get(Job, fixture["job_id"])
|
||||
|
||||
unless match?(%Notification{}, notification) and
|
||||
match?(%PushDevice{platform: :android, provider: :fcm, disabled_at: nil}, device) and
|
||||
notification.user_id == device.user_id and
|
||||
notification.idempotency_key == context.idempotency_key and
|
||||
match?(%Job{state: "completed", attempt: 1}, job) and
|
||||
job.args["notification_id"] == notification.id and
|
||||
job.args["device_id"] == device.id do
|
||||
raise "Android FCM smoke has not completed successfully on the exact target"
|
||||
end
|
||||
|
||||
IO.puts("android_fcm_provider_delivery_verified=true")
|
||||
IO.puts("job_state=#{job.state}")
|
||||
IO.puts("job_attempt=#{job.attempt}")
|
||||
IO.puts("device_still_active=true")
|
||||
end
|
||||
|
||||
defp cleanup(context) do
|
||||
fixture = load_and_validate_manifest!(context)
|
||||
job = Repo.get(Job, fixture["job_id"])
|
||||
notification = Repo.get(Notification, fixture["notification_id"])
|
||||
|
||||
unless match?(%Job{}, job) and match?(%Notification{}, notification) and
|
||||
notification.idempotency_key == context.idempotency_key and
|
||||
job.args["notification_id"] == notification.id and
|
||||
job.args["device_id"] == fixture["device_id"] do
|
||||
raise "run-scoped records no longer match the manifest"
|
||||
end
|
||||
|
||||
{:ok, deleted} =
|
||||
Repo.transaction(fn ->
|
||||
{jobs, _} = Repo.delete_all(from(candidate in Job, where: candidate.id == ^job.id))
|
||||
|
||||
{notifications, _} =
|
||||
Repo.delete_all(
|
||||
from(candidate in Notification,
|
||||
where:
|
||||
candidate.id == ^notification.id and
|
||||
candidate.idempotency_key == ^context.idempotency_key
|
||||
)
|
||||
)
|
||||
|
||||
%{jobs: jobs, notifications: notifications}
|
||||
end)
|
||||
|
||||
unless deleted == %{jobs: 1, notifications: 1} do
|
||||
raise "exact Android FCM smoke cleanup failed"
|
||||
end
|
||||
|
||||
File.rm!(context.manifest_path)
|
||||
|
||||
if Repo.exists?(
|
||||
from(candidate in Notification,
|
||||
where: candidate.idempotency_key == ^context.idempotency_key
|
||||
)
|
||||
) do
|
||||
raise "run-scoped notification remains after cleanup"
|
||||
end
|
||||
|
||||
IO.puts("android_fcm_smoke_cleanup_verified=true")
|
||||
IO.puts("deleted_jobs=1")
|
||||
IO.puts("deleted_notifications=1")
|
||||
end
|
||||
|
||||
defp load_and_validate_manifest!(context) do
|
||||
manifest = context.manifest_path |> File.read!() |> Jason.decode!()
|
||||
|
||||
valid? =
|
||||
manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and
|
||||
manifest["database"] == context.database and
|
||||
manifest["device_id"] == context.device_id and
|
||||
manifest["idempotency_key"] == context.idempotency_key and
|
||||
uuid?(manifest["notification_id"]) and is_integer(manifest["job_id"])
|
||||
|
||||
unless valid?, do: raise("manifest does not match this exact run")
|
||||
manifest
|
||||
end
|
||||
|
||||
defp required_option!(options, name) do
|
||||
case Map.get(options, name) do
|
||||
value when is_binary(value) and value != "" -> value
|
||||
_missing -> raise("#{name} is required")
|
||||
end
|
||||
end
|
||||
|
||||
defp uuid?(value) when is_binary(value), do: match?({:ok, _}, Ecto.UUID.cast(value))
|
||||
defp uuid?(_value), do: false
|
||||
end
|
||||
201
scripts/production-android-fcm-smoke.sh
Executable file
201
scripts/production-android-fcm-smoke.sh
Executable file
|
|
@ -0,0 +1,201 @@
|
|||
#!/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-android-fcm-smoke.env"
|
||||
LOCAL_SCRIPT="$ROOT/scripts/production-android-fcm-smoke.exs"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage:
|
||||
./scripts/production-android-fcm-smoke.sh plan DEVICE_ID --check-only whoneedhelp.com
|
||||
./scripts/production-android-fcm-smoke.sh prepare DEVICE_ID --confirm whoneedhelp.com
|
||||
./scripts/production-android-fcm-smoke.sh verify --from-state --confirm whoneedhelp.com
|
||||
./scripts/production-android-fcm-smoke.sh cleanup --from-state --confirm whoneedhelp.com
|
||||
|
||||
prepare sends one privacy-safe production notification to the exact active Android
|
||||
FCM device. 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 the notification on the phone.
|
||||
No email worker, Web Push device, frozen 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'
|
||||
}
|
||||
|
||||
if [[ $# -ne 4 ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
ACTION=$1
|
||||
DEVICE_ID=$2
|
||||
CONFIRMATION=$3
|
||||
HOST=$4
|
||||
|
||||
case "$ACTION" in
|
||||
plan) [[ "$CONFIRMATION" == --check-only ]] || usage ;;
|
||||
prepare) [[ "$CONFIRMATION" == --confirm ]] || usage ;;
|
||||
verify | cleanup)
|
||||
[[ "$DEVICE_ID" == --from-state && "$CONFIRMATION" == --confirm ]] || usage
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
[[ "$HOST" == whoneedhelp.com ]] || usage
|
||||
|
||||
if [[ "$ACTION" == plan || "$ACTION" == prepare ]]; then
|
||||
[[ "$DEVICE_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || usage
|
||||
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 exact Android FCM delivery job\n'
|
||||
printf 'device_id=%s\ndatabase=%s\nimage=%s\ncontainer=%s\n' \
|
||||
"$DEVICE_ID" "$ACTUAL_DATABASE" "$OBSERVED_IMAGE" "$CONTAINER"
|
||||
printf 'excluded=email delivery, Web Push, frozen 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 Android FCM 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-android-fcm-$RUN_ID.exs"
|
||||
REMOTE_MANIFEST="/tmp/wnh-production-android-fcm-$RUN_ID.json"
|
||||
|
||||
cat >"$STATE_FILE" <<EOF
|
||||
schema_version=1
|
||||
run_id=$RUN_ID
|
||||
ssh_target=$SSH_TARGET
|
||||
container=$CONTAINER
|
||||
device_id=$DEVICE_ID
|
||||
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 Android FCM smoke state exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$STATE_FILE"
|
||||
|
||||
if [[ "${schema_version:-}" != 1 || "${ssh_target:-}" != "$SSH_TARGET" ||
|
||||
"${container:-}" != "$CONTAINER" || -z "${run_id:-}" ||
|
||||
-z "${device_id:-}" || -z "${remote_script:-}" || -z "${remote_manifest:-}" ]]; then
|
||||
echo "Android FCM smoke state does not match the current production target." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RUN_ID=$run_id
|
||||
DEVICE_ID=$device_id
|
||||
REMOTE_SCRIPT=$remote_script
|
||||
REMOTE_MANIFEST=$remote_manifest
|
||||
fi
|
||||
|
||||
RUN=$(encode "$RUN_ID")
|
||||
DATABASE=$(encode "$EXPECTED_DATABASE")
|
||||
DEVICE=$(encode "$DEVICE_ID")
|
||||
MANIFEST=$(encode "$REMOTE_MANIFEST")
|
||||
|
||||
rpc_action() {
|
||||
local action=$1
|
||||
local expression
|
||||
expression="Code.require_file(\"$REMOTE_SCRIPT\"); WhoNeedHelp.ProductionAndroidFCMSmoke.run(\"$action\", %{run_id: Base.decode64!(\"$RUN\"), expected_database: Base.decode64!(\"$DATABASE\"), device_id: Base.decode64!(\"$DEVICE\"), 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=verify notification on the phone, 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_android_fcm_smoke_cleanup_complete=true"
|
||||
;;
|
||||
esac
|
||||
Loading…
Reference in New Issue
Block a user