302 lines
9.2 KiB
Bash
Executable File
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
|