who_need_help/scripts/production-play-physical-fixture.sh

302 lines
9.2 KiB
Bash
Executable File

#!/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 - </proc/sys/kernel/random/uuid | cut -c1-12)"
CONTAINER_MANIFEST="/tmp/wnh-play-physical-$RUN_ID.json"
CONTAINER_SCRIPT="/tmp/wnh-play-physical-$RUN_ID.exs"
cat >"$STATE_FILE" <<EOF
schema_version=1
run_id=$RUN_ID
expected_database=$EXPECTED_DATABASE
helper_email=$HELPER_EMAIL
container_manifest=$CONTAINER_MANIFEST
container_script=$CONTAINER_SCRIPT
image=$OBSERVED_IMAGE
EOF
chmod 600 "$STATE_FILE"
else
if [[ ! -f "$STATE_FILE" ]]; then
echo "No physical Play fixture state exists." >&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