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