#!/usr/bin/env bash set -euo pipefail ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) EXPECTED_ROOT=/srv/who_need_help-production EXPECTED_PROJECT=who_need_help_production EXPECTED_ORIGIN=https://whoneedhelp.com STATE_FILE="$ROOT/output/runtime/production-play-physical.env" HOST_MANIFEST="$ROOT/output/runtime/production-play-physical-manifest.json" usage() { cat >&2 <<'EOF' Usage: ./scripts/production-play-physical-fixture.sh plan HELPER_EMAIL --check-only whoneedhelp.com ENV_FILE ./scripts/production-play-physical-fixture.sh prepare HELPER_EMAIL --confirm whoneedhelp.com ENV_FILE ./scripts/production-play-physical-fixture.sh verify-active --confirm whoneedhelp.com ENV_FILE ./scripts/production-play-physical-fixture.sh verify-stopped --confirm whoneedhelp.com ENV_FILE ./scripts/production-play-physical-fixture.sh cleanup --confirm whoneedhelp.com ENV_FILE prepare creates one run-scoped requester, request and accepted assignment. The other actions use the ignored mode-0600 state created by prepare. cleanup is the only supported way to remove the exact fixture after recording. EOF exit 1 } read_env() { local file=$1 local key=$2 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 } ' "$file" } read_state() { local key=$1 read_env "$STATE_FILE" "$key" } encode() { printf %s "$1" | base64 | tr -d '\n' } copy_into_container() { local source=$1 local destination=$2 docker exec -i "$CONTAINER" sh -c \ 'umask 077; cat >"$1"' sh "$destination" <"$source" } copy_from_container() { local source=$1 local destination=$2 docker exec "$CONTAINER" cat "$source" >"$destination" } if [[ $# -lt 4 || $# -gt 5 ]]; then usage fi ACTION=$1 shift case "$ACTION" in plan | prepare) [[ $# -eq 4 ]] || usage HELPER_EMAIL=${1,,} CONFIRMATION=$2 HOST=$3 ENV_FILE=$4 ;; verify-active | verify-stopped | cleanup) [[ $# -eq 3 ]] || usage HELPER_EMAIL= CONFIRMATION=$1 HOST=$2 ENV_FILE=$3 ;; *) usage ;; esac if [[ "$ACTION" == plan ]]; then [[ "$CONFIRMATION" == --check-only ]] || usage else [[ "$CONFIRMATION" == --confirm ]] || usage fi [[ "$HOST" == whoneedhelp.com ]] || usage if [[ "$(realpath --canonicalize-existing "$ROOT")" != "$EXPECTED_ROOT" ]]; then echo "Physical Play fixtures may only run from $EXPECTED_ROOT." >&2 exit 1 fi if [[ "$ENV_FILE" != /* ]]; then ENV_FILE="$ROOT/$ENV_FILE" fi if [[ ! -f "$ENV_FILE" ]]; then echo "Environment file does not exist: $ENV_FILE" >&2 exit 1 fi if [[ "$(read_env "$ENV_FILE" DEPLOYMENT_ENV)" != production ]]; then echo "Physical Play fixtures require DEPLOYMENT_ENV=production." >&2 exit 1 fi if [[ "$(read_env "$ENV_FILE" DEPLOYMENT_TARGET)" != compose ]]; then echo "Physical Play fixtures require DEPLOYMENT_TARGET=compose." >&2 exit 1 fi PROJECT=$(read_env "$ENV_FILE" COMPOSE_PROJECT_NAME) if [[ "$PROJECT" != "$EXPECTED_PROJECT" ]]; then echo "Unexpected production Compose project: $PROJECT" >&2 exit 1 fi if [[ "$(read_env "$ENV_FILE" WNH_BASE_URL)" != "$EXPECTED_ORIGIN" ]]; then echo "Production origin must be $EXPECTED_ORIGIN." >&2 exit 1 fi EXPECTED_DATABASE=$(read_env "$ENV_FILE" POSTGRES_DB) if [[ -z "$EXPECTED_DATABASE" ]]; then echo "POSTGRES_DB must identify the expected production database." >&2 exit 1 fi CONTAINER=$("$ROOT/scripts/compose.sh" "$ENV_FILE" ps -q app | head -n 1) if [[ -z "$CONTAINER" ]]; then CONTAINER=$("$ROOT/scripts/compose.sh" "$ENV_FILE" ps -q web | head -n 1) fi if [[ -z "$CONTAINER" ]]; then echo "No running production app or web container was found for $PROJECT." >&2 exit 1 fi EXPECTED_IMAGE=$(read_env "$ENV_FILE" APP_IMAGE) OBSERVED_IMAGE=$(docker inspect --format '{{.Config.Image}}' "$CONTAINER") CONTAINER_STATE=$(docker inspect --format '{{.State.Status}}' "$CONTAINER") CONTAINER_HEALTH=$(docker inspect \ --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CONTAINER") if [[ "$OBSERVED_IMAGE" != "$EXPECTED_IMAGE" ]]; then echo "Running container image does not match APP_IMAGE." >&2 exit 1 fi if [[ "$CONTAINER_STATE" != running || "$CONTAINER_HEALTH" != healthy ]]; then echo "Production application container is not running and healthy." >&2 exit 1 fi ACTUAL_DATABASE=$(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 "Database identity mismatch: expected $EXPECTED_DATABASE, observed $ACTUAL_DATABASE." >&2 exit 1 fi if [[ "$ACTION" == plan ]]; then if [[ ! "$HELPER_EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+$ ]]; then echo "HELPER_EMAIL is invalid." >&2 exit 1 fi if [[ -e "$STATE_FILE" || -e "$HOST_MANIFEST" ]]; then echo "A physical Play fixture state already exists; inspect and clean it first." >&2 exit 1 fi HELPER=$(encode "$HELPER_EMAIL") docker exec "$CONTAINER" /app/bin/who_need_help rpc \ "require Ecto.Query; email = Base.decode64!(\"$HELPER\"); user = WhoNeedHelp.Repo.get_by(WhoNeedHelp.Accounts.User, email: email); unless match?(%WhoNeedHelp.Accounts.User{confirmed_at: %DateTime{}, moderation_status: :active}, user), do: raise(\"helper is missing, unconfirmed, or inactive\"); active_query = Ecto.Query.from(s in WhoNeedHelp.Tracking.TrackingSession, where: s.user_id == ^user.id and s.active); if WhoNeedHelp.Repo.exists?(active_query), do: raise(\"helper already has an active tracking session\"); IO.puts(\"helper_ready=true\")" printf 'scope=one temporary requester, one matched request, one accepted assignment\n' printf 'database=%s\nimage=%s\ncontainer=%s\n' \ "$ACTUAL_DATABASE" "$OBSERVED_IMAGE" "$CONTAINER" printf 'cleanup=exact run-scoped IDs retained in mode-0600 state\n' exit 0 fi mkdir -p "$ROOT/output/runtime" chmod 700 "$ROOT/output/runtime" umask 077 if [[ "$ACTION" == prepare ]]; then if [[ ! "$HELPER_EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+$ ]]; then echo "HELPER_EMAIL is invalid." >&2 exit 1 fi if [[ -e "$STATE_FILE" || -e "$HOST_MANIFEST" ]]; then echo "A physical Play fixture state already exists; cleanup is required first." >&2 exit 1 fi RUN_ID="$(date -u +%Y%m%d%H%M%S)-$(tr -d - "$STATE_FILE" <&2 exit 1 fi if [[ "$(read_state schema_version)" != 1 ]]; then echo "Unsupported physical Play fixture state version." >&2 exit 1 fi RUN_ID=$(read_state run_id) EXPECTED_DATABASE_FROM_STATE=$(read_state expected_database) HELPER_EMAIL=$(read_state helper_email) CONTAINER_MANIFEST=$(read_state container_manifest) CONTAINER_SCRIPT=$(read_state container_script) STATE_IMAGE=$(read_state image) if [[ "$EXPECTED_DATABASE_FROM_STATE" != "$EXPECTED_DATABASE" || "$STATE_IMAGE" != "$OBSERVED_IMAGE" ]]; then echo "Current production database or image does not match the recorded fixture state." >&2 exit 1 fi fi if ! copy_into_container \ "$ROOT/scripts/production-play-physical-fixture.exs" "$CONTAINER_SCRIPT"; then if [[ "$ACTION" == prepare ]]; then rm -f "$STATE_FILE" fi echo "Could not copy the operator script into the container tmpfs." >&2 exit 1 fi if [[ "$ACTION" != prepare ]]; then if [[ ! -f "$HOST_MANIFEST" ]]; then echo "The mode-0600 host manifest is missing; refusing an unverifiable action." >&2 exit 1 fi copy_into_container "$HOST_MANIFEST" "$CONTAINER_MANIFEST" fi RUN=$(encode "$RUN_ID") DATABASE=$(encode "$EXPECTED_DATABASE") HELPER=$(encode "$HELPER_EMAIL") MANIFEST=$(encode "$CONTAINER_MANIFEST") EXPRESSION="Code.require_file(\"$CONTAINER_SCRIPT\"); WhoNeedHelp.ProductionPlayPhysicalFixture.run(\"$ACTION\", %{run_id: Base.decode64!(\"$RUN\"), expected_database: Base.decode64!(\"$DATABASE\"), helper_email: Base.decode64!(\"$HELPER\"), manifest_path: Base.decode64!(\"$MANIFEST\")})" if ! docker exec "$CONTAINER" /app/bin/who_need_help rpc "$EXPRESSION"; then echo "Fixture action failed. State was retained for inspection and exact cleanup." >&2 exit 1 fi if [[ "$ACTION" == prepare ]]; then copy_from_container "$CONTAINER_MANIFEST" "$HOST_MANIFEST.tmp" chmod 600 "$HOST_MANIFEST.tmp" mv "$HOST_MANIFEST.tmp" "$HOST_MANIFEST" echo "host_state=$STATE_FILE" echo "host_manifest=$HOST_MANIFEST" echo "Record the video now; do not leave the fixture active after recording." elif [[ "$ACTION" == cleanup ]]; then docker exec "$CONTAINER" rm -f "$CONTAINER_SCRIPT" "$CONTAINER_MANIFEST" rm -f "$HOST_MANIFEST" "$STATE_FILE" echo "host_fixture_state_removed=true" fi