From 777bd779efe128a5e088c47b52304bf18672b56d Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Thu, 23 Jul 2026 20:06:00 +0300 Subject: [PATCH] Prepare isolated environment release workflow --- README.md | 13 +- android/README.md | 22 +- docs/operations.md | 103 +++++++- docs/performance.md | 12 +- scripts/android-instrumentation-test.sh | 4 +- scripts/backup-external-postgres.sh | 245 +++++++++++++++++ scripts/backup-s3-drill.sh | 4 +- scripts/check-environment-readiness.sh | 213 +++++++++++++++ scripts/clean-deploy-verify.sh | 3 +- scripts/e2e-run.sh | 3 +- scripts/e2e-stop.sh | 8 +- scripts/ensure-local-android-test-env.sh | 11 +- scripts/ensure-local-e2e-env.sh | 10 +- scripts/ensure-local-load-env.sh | 14 +- scripts/init-production-env.sh | 57 ++++ scripts/init-test-env.sh | 57 ++++ scripts/load-cycle.sh | 2 +- scripts/load-resilience-run.sh | 4 +- scripts/load-run.sh | 6 +- scripts/load-stack-stop.sh | 4 +- scripts/load-stack-up.sh | 10 +- scripts/oban-burst-run.sh | 4 +- scripts/observability-run.sh | 6 +- scripts/observability-stop.sh | 4 +- scripts/prepare-production-release.sh | 53 ++++ scripts/production-release-remote.sh | 276 ++++++++++++++++++++ scripts/production-release.sh | 124 +++++++++ scripts/quality.sh | 22 ++ scripts/rotate-load-metrics-token.sh | 2 +- scripts/upgrade-rehearsal-compose.sh | 6 +- scripts/validate-production-env.sh | 81 ++++++ scripts/validate-test-env.sh | 52 ++++ test/who_need_help/mutual_aid_flow_test.exs | 13 +- 33 files changed, 1375 insertions(+), 73 deletions(-) create mode 100755 scripts/backup-external-postgres.sh create mode 100755 scripts/check-environment-readiness.sh create mode 100755 scripts/prepare-production-release.sh create mode 100755 scripts/production-release-remote.sh create mode 100755 scripts/production-release.sh diff --git a/README.md b/README.md index 1b4a1d5..018c2e1 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ load project and then run: ``` The command generates MinIO and Restic secrets only in ignored mode-`0600` -`.env.load`, streams `pg_dump` directly into an encrypted Restic repository, +`output/runtime/load.env`, streams `pg_dump` directly into an encrypted Restic repository, restores it into a new temporary database, checks corruption and interruption failure paths, removes those temporary buckets, and retains the successful encrypted bucket in local MinIO. It never writes a plaintext dump to the host. @@ -460,8 +460,8 @@ two worker replicas: ./scripts/e2e-run.sh ``` -On its first run it generates `.env.e2e` with independent random local secrets -and mode `0600`. Browser traffic uses the isolated Traefik HTTPS entrypoint; +On its first run it generates `output/runtime/e2e.env` with independent random +local secrets and mode `0600`. Browser traffic uses the isolated Traefik HTTPS entrypoint; Playwright accepts only that one-run proxy's generated certificate. E2E-only fixture and failure-injection routes are enabled by a compile-time flag that is disabled in the ordinary production image. The suite registers users through @@ -499,8 +499,8 @@ probe or the complete minimum/current API 24/30/34/37 matrix: ./scripts/android-matrix-test.sh ``` -On first run it generates the ignored `.env.android-test` with a randomized -device-loopback origin and mode `0600`. The suite covers denied and granted +On first run it generates ignored `output/runtime/android-test.env` with a +randomized device-loopback origin and mode `0600`. The suite covers denied and granted location permission, same-origin deep links, Activity recreation, foreground location upload while the Activity is backgrounded and destroyed, the persistent notification Stop action, a disconnected Stop request with visible @@ -628,7 +628,8 @@ Grafana datasource and dashboard, discovers each current web container as a separate target, and exercises a firing/resolved alert by stopping and recovering exactly one verified load replica. It prints the loopback-only random ports and retains non-secret evidence below `output/observability/`. -The generated Grafana password remains only in mode-`0600` `.env.load`. +The generated Grafana password remains only in mode-`0600` +`output/runtime/load.env`. Stop only the monitoring services while leaving their local metric volumes and the load application running: diff --git a/android/README.md b/android/README.md index 24ae4c1..ff3e58e 100644 --- a/android/README.md +++ b/android/README.md @@ -77,7 +77,7 @@ source in the Phoenix environment; never put that private JSON in the Android build. ```sh -WNH_ENV_FILE=.env.production ./scripts/android-release-build.sh +./scripts/android-release-build.sh ``` The build passes the private files with Docker BuildKit secret mounts, runs @@ -109,11 +109,12 @@ from the existing `PHX_HOST`, `PHX_SCHEME`, and `PHX_URL_PORT`, then build: sha256sum android/dist-staging/who-need-help-staging.apk ``` -This variant uses Android's generic debug signing key so it can be installed -for staging verification. It is not a production-signed artifact and must not -be published as a release. The manifest accepts same-origin HTTPS deep links, -but verified Android App Links additionally require the final signing -certificate fingerprint in the deployment's `/.well-known/assetlinks.json`. +This variant uses the dedicated stable staging key below +`~/.config/who_need_help/android-staging/`. It is not the production upload +identity and must not be published as a production release. The manifest +accepts same-origin HTTPS deep links, while verified Android App Links require +this staging certificate fingerprint in the dev deployment's +`/.well-known/assetlinks.json`. With the temporary public origin reachable, run the API 37 emulator smoke test: @@ -151,10 +152,11 @@ repository root: ./scripts/android-matrix-test.sh ``` -The command requires `/dev/kvm`. It generates an ignored -`.env.android-test` once with a randomized `http://127.0.0.1:PORT` origin and -mode `0600`; the application and its in-process fixture server both derive the -origin from that file. It then builds both APKs, boots a fresh selected emulator +The command requires `/dev/kvm`. It generates ignored +`output/runtime/android-test.env` once with a randomized +`http://127.0.0.1:PORT` origin and mode `0600`; the application and its +in-process fixture server both derive the origin from that file. It then +builds both APKs, boots a fresh selected emulator container without external networking, injects emulator coordinates, and runs `AndroidJUnitRunner`. `WNH_ANDROID_TEST_API` selects one supported API, while `WNH_ANDROID_TEST_API_MATRIX` controls the matrix command. diff --git a/docs/operations.md b/docs/operations.md index 692e01c..18dd0ed 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -64,6 +64,20 @@ connections, Docker volumes, public aliases, Google OAuth clients, email delivery paths, and generated secrets. Oban queues are isolated by those different PostgreSQL databases. There is no Redis dependency. +Generated harness state is not a deployment environment. E2E, load, and +Android instrumentation scripts keep their random local inputs below the +ignored mode-`0700` `output/runtime/` directory: + +```text +output/runtime/e2e.env +output/runtime/load.env +output/runtime/android-test.env +``` + +Those files are generated automatically, never copied to a server, and do not +represent dev, test, or production. The one ignored `.env` at each checkout +root remains the only application/deployment configuration. + The shared Caddy edge is owned only by the production checkout and reads the same production `.env`; it is not a third project directory or a second secret file. Both applications intentionally share only the external @@ -116,6 +130,41 @@ to the comma-separated App Links value; the upload certificate alone does not describe Play-delivered APKs. The production environment validator accepts multiple SHA-256 fingerprints and rejects partial or malformed configuration. +### Release capability inputs + +Each environment owns distinct external-provider credentials. Seed a new +production `.env` with `scripts/init-production-env.sh` and the corresponding +`PRODUCTION_*` process variables; use `TEST_*` only when creating the separate +test checkout. The generated file uses the ordinary runtime names: + +| Capability | Values kept in that checkout's `.env` | +| --- | --- | +| Google sign-in | `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET` | +| Browser push | three `WEB_PUSH_VAPID_*` values | +| Android Firebase client | four public `WNH_FIREBASE_*` values | +| Android delivery | `FCM_PROJECT_ID` and one private service-account source | +| Verified Android links | `ANDROID_APP_LINKS_PACKAGE_NAME`, `ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS` | +| Transactional email | `SMTP_*`, sender, and `SUPPORT_INBOX_ADDRESS` | + +Do not reuse a Google client, VAPID private key, Firebase project/service +account, SMTP credential, or Android signing key between dev and production. +The public Firebase Android values are build configuration; the Base64 FCM +service-account JSON is a server secret and must never be passed into the +Android build. + +Check an environment without printing its secret values: + +```bash +./scripts/check-environment-readiness.sh .env +./scripts/check-environment-readiness.sh .env --require-release +``` + +The first command reports incomplete or local-only capabilities. The second is +a blocking release preflight and exits nonzero until Google sign-in, external +SMTP, browser Web Push, Android Firebase/FCM, App Links, support routing, +Android version/signing aliases, and the core application configuration are +all complete. + Create the test configuration inside the test checkout: ```bash @@ -349,7 +398,7 @@ The isolated load project can run a complete encrypted Restic/MinIO drill: The script refuses the staging Compose project and validates the project and service labels of every pre-existing container in its scope. On first use, `scripts/ensure-local-load-env.sh` generates independent random MinIO and -Restic credentials in ignored `.env.load` and restricts that file to mode +Restic credentials in ignored `output/runtime/load.env` and restricts that file to mode `0600`. MinIO publishes Docker-assigned ports only on `127.0.0.1`; the observed API and console URLs are printed after a successful run. @@ -493,7 +542,7 @@ against an isolated restored copy: The rehearsal validates the checksum and archive catalog, reads the public origin configuration from ignored `.env`, and uses only the independently -generated credentials in ignored mode-`0600` `.env.e2e`. It builds a uniquely +generated credentials in ignored mode-`0600` `output/runtime/e2e.env`. It builds a uniquely tagged production release, creates a uniquely named Compose project and database from `template0`, restores the archive, records application-table counts, and then: @@ -720,7 +769,7 @@ rewrite while preserving the target's own `instance` label. Prometheus, Alertmanager, and Grafana are pinned by tag and digest. Their host ports default to Docker-assigned values bound only to `127.0.0.1`; the run prints the observed URLs. Grafana uses the random admin password generated in -ignored `.env.load`, disables anonymous signup, update checks, suggested plugin +ignored `output/runtime/load.env`, disables anonymous signup, update checks, suggested plugin installation, and its unused built-in alert engine. The Prometheus datasource and ten-panel dashboard are provisioned from tracked files. The dashboard separates all discovered web and worker replicas and includes HTTP @@ -747,6 +796,54 @@ destinations, production availability, and measured alert policies remain deployment decisions. In Kubernetes, put the metrics token in `existingSecret`; configure the external scraper to send it as a Bearer token. +## Production release without pushing the frozen repository + +The post-submission workflow keeps the public Git repository and +`test.whoneedhelp.com` untouched. A clean local commit is packaged as a +verified Git bundle and transferred directly over SSH to only +`/srv/who_need_help-production`: + +```bash +./scripts/production-release.sh plan whoneedhelp +``` + +The default `plan` action is read-only. It verifies the exact local and remote +commits, requires a fast-forward history, checks the production checkout, +Compose scope and healthy containers, checks public readiness, opens a +read-only PostgreSQL connection, and runs the complete environment capability +preflight. It neither uploads a bundle nor creates a backup. + +After reviewing the exact commit printed by the plan, execution additionally +requires an explicit per-commit confirmation: + +```bash +WNH_PRODUCTION_RELEASE_CONFIRM=whoneedhelp.com:FULL_COMMIT \ + ./scripts/production-release.sh apply whoneedhelp +``` + +The apply path refuses tracked local or remote modifications. It then: + +1. creates and verifies a full Git bundle for exactly that clean commit; +2. uploads only that bundle to the production checkout's ignored + `output/releases/`; +3. creates a custom-format PostgreSQL 18 backup without exposing the database + password in process arguments; +4. verifies its archive catalog and SHA-256, then copies and verifies the + backup again under local ignored `output/production-backups/`; +5. fast-forwards the production checkout without accessing or changing the + test checkout or public remote; +6. selects immutable per-commit image tags, applies migrations, starts the + application and edge, and verifies the public readiness and Android App + Links endpoints. + +If application startup fails after the new image tags are selected, the script +restores the previous immutable application and Caddy image tags and attempts +to recover public readiness. It deliberately does not reverse Git source or +Ecto migrations automatically. The per-release rollback manifest and backup +paths are recorded below the production checkout's ignored `output/releases/`. +The copied backup is separate from the production host, but a long-term +encrypted off-site backup destination remains an operational requirement. + ## Rollback boundary The release image is immutable and migrations run as a separate one-shot role. diff --git a/docs/performance.md b/docs/performance.md index ad760f4..e77004f 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -104,19 +104,19 @@ wrapper, it deliberately keeps the isolated database volume between commands. ./scripts/load-stack-up.sh ``` -The generated `.env.load` is ignored, restricted to mode 600, and contains -independent PostgreSQL and application secrets. Edit its `LOAD_*` inputs to +The generated `output/runtime/load.env` is ignored, restricted to mode 600, +and contains independent PostgreSQL and application secrets. Edit its `LOAD_*` inputs to define a specific experiment. Values in `.env.load.example` are reproducible measurement points, not recommendations. -For a one-run duration that must not rewrite `.env.load`, pass an explicit +For a one-run duration that must not rewrite `output/runtime/load.env`, pass an explicit experiment override: ```sh LOAD_DURATION_OVERRIDE=10m ./scripts/load-run.sh local-soak-YYYYMMDD ``` -The effective value and whether it came from `.env.load` or the override are +The effective value and whether it came from `output/runtime/load.env` or the override are recorded in that run's `environment.txt`. ## Run and compare replica counts @@ -124,7 +124,7 @@ recorded in that run's `environment.txt`. ```sh ./scripts/load-run.sh two-web ./scripts/load-stack-up.sh 3 -# Set LOAD_WEB_REPLICAS=3 in .env.load so the recorded expected topology +# Set LOAD_WEB_REPLICAS=3 in output/runtime/load.env so the recorded expected topology # matches the running topology, then: ./scripts/load-run.sh three-web ``` @@ -162,7 +162,7 @@ contexts. The pair count equals the maximum simultaneous VU count across all scenarios, because k6 may reuse its global VU pool between parallel scenarios; mapping each global VU identifier directly to its own pair prevents concurrent users from sharing a tracking assignment. The random fixture password exists -only in ignored mode-600 `.env.load` and is never written to the manifest or +only in ignored mode-600 `output/runtime/load.env` and is never written to the manifest or console. On success and on trapped failure, cleanup deletes only the UUIDs recorded in diff --git a/scripts/android-instrumentation-test.sh b/scripts/android-instrumentation-test.sh index ce50901..bf64264 100755 --- a/scripts/android-instrumentation-test.sh +++ b/scripts/android-instrumentation-test.sh @@ -3,7 +3,7 @@ set -eu ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ANDROID_ENV="$ROOT/.env" -TEST_ENV="$ROOT/.env.android-test" +TEST_ENV=${WNH_ANDROID_TEST_ENV_FILE:-"$ROOT/output/runtime/android-test.env"} run_id=$(date -u +%Y%m%d%H%M%S)-$$ android_api=${WNH_ANDROID_TEST_API:-37.0} # 1G is measured against the API 30/34/37 suite and is also exercised by the @@ -63,7 +63,7 @@ set -a . "$TEST_ENV" set +a -: "${WNH_ANDROID_TEST_BASE_URL:?Set WNH_ANDROID_TEST_BASE_URL in .env.android-test}" +: "${WNH_ANDROID_TEST_BASE_URL:?Generate Android test runtime state with scripts/ensure-local-android-test-env.sh}" : "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS in .env}" : "${WNH_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in .env}" diff --git a/scripts/backup-external-postgres.sh b/scripts/backup-external-postgres.sh new file mode 100755 index 0000000..9b6ecf6 --- /dev/null +++ b/scripts/backup-external-postgres.sh @@ -0,0 +1,245 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +env_file=${1:-"$ROOT/.env"} +target=${2:-} +expected_environment=${3:-} + +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 2 +fi + +if [[ "$(stat -c '%a' "$env_file")" != 600 ]]; then + echo "Environment file must have mode 0600: $env_file" >&2 + exit 2 +fi + +read_value() { + local key=$1 + awk -v key="$key" ' + index($0, key "=") == 1 { + print substr($0, length(key) + 2) + found = 1 + exit + } + END { if (!found) exit 1 } + ' "$env_file" +} + +deployment_environment=$(read_value DEPLOYMENT_ENV) +compose_project=$(read_value COMPOSE_PROJECT_NAME) +database_mode=$(read_value DATABASE_MODE) + +if [[ "$database_mode" != "external" ]]; then + echo "backup-external-postgres.sh requires DATABASE_MODE=external." >&2 + exit 2 +fi + +if [[ -n "$expected_environment" && + "$deployment_environment" != "$expected_environment" ]]; then + echo "Environment mismatch: expected $expected_environment." >&2 + exit 2 +fi + +for command in awk pg_dump pg_restore psql python3 sha256sum stat; do + command -v "$command" >/dev/null 2>&1 || { + echo "Required command is unavailable: $command" >&2 + exit 2 + } +done + +mapfile -d '' -t connection_parts < <( + python3 - "$env_file" <<'PY' +import sys +from pathlib import Path +from urllib.parse import unquote, urlsplit + +values = {} +for line in Path(sys.argv[1]).read_text().splitlines(): + if "=" in line and not line.startswith("#"): + key, value = line.split("=", 1) + values.setdefault(key, value) + +raw_url = values.get("DATABASE_URL", "") +if raw_url.startswith("ecto://"): + raw_url = "postgresql://" + raw_url[len("ecto://"):] + +parsed = urlsplit(raw_url) +required = { + "host": parsed.hostname, + "database": parsed.path.lstrip("/"), + "username": parsed.username, + "password": parsed.password, +} +missing = [name for name, value in required.items() if not value] +if missing: + raise SystemExit("DATABASE_URL is missing: " + ", ".join(missing)) + +parts = ( + parsed.hostname, + str(parsed.port or 5432), + unquote(parsed.path.lstrip("/")), + unquote(parsed.username), + unquote(parsed.password), + values.get("DATABASE_SOCKET_DIR", ""), +) +sys.stdout.buffer.write(b"\0".join(part.encode() for part in parts) + b"\0") +PY +) + +if [[ ${#connection_parts[@]} -ne 6 ]]; then + echo "Could not parse the external PostgreSQL connection without exposing it." >&2 + exit 2 +fi + +database_host=${connection_parts[0]} +database_port=${connection_parts[1]} +database_name=${connection_parts[2]} +database_user=${connection_parts[3]} +database_password=${connection_parts[4]} +database_socket_dir=${connection_parts[5]} +connection_host=${database_socket_dir:-$database_host} + +escape_pgpass() { + local escaped=$1 + escaped=${escaped//\\/\\\\} + escaped=${escaped//:/\\:} + printf '%s' "$escaped" +} + +pgpass_file=$(mktemp "${TMPDIR:-/tmp}/who-need-help-pgpass.XXXXXX") +partial= +checksum_partial= +metadata_partial= + +cleanup() { + rm -f "$pgpass_file" + [[ -z "$partial" ]] || rm -f "$partial" + [[ -z "$checksum_partial" ]] || rm -f "$checksum_partial" + [[ -z "$metadata_partial" ]] || rm -f "$metadata_partial" +} +trap cleanup EXIT HUP INT TERM + +chmod 600 "$pgpass_file" +printf '%s:%s:%s:%s:%s\n' \ + "$(escape_pgpass "$database_host")" \ + "$(escape_pgpass "$database_port")" \ + "$(escape_pgpass "$database_name")" \ + "$(escape_pgpass "$database_user")" \ + "$(escape_pgpass "$database_password")" >"$pgpass_file" + +psql_args=( + --host "$connection_host" + --port "$database_port" + --username "$database_user" + --dbname "$database_name" + --no-password + --no-psqlrc + --tuples-only + --no-align + --set ON_ERROR_STOP=1 +) + +server_version=$( + PGPASSFILE="$pgpass_file" PGOPTIONS="-c default_transaction_read_only=on" \ + psql "${psql_args[@]}" --command="show server_version" +) +server_database=$( + PGPASSFILE="$pgpass_file" PGOPTIONS="-c default_transaction_read_only=on" \ + psql "${psql_args[@]}" --command="select current_database()" +) + +if [[ "$server_database" != "$database_name" ]]; then + echo "PostgreSQL connected to an unexpected database." >&2 + exit 2 +fi + +printf 'Environment: %s\n' "$deployment_environment" +printf 'Compose project: %s\n' "$compose_project" +printf 'Database mode: external\n' +printf 'Database endpoint: %s:%s/%s\n' "$connection_host" "$database_port" "$database_name" +printf 'PostgreSQL server: %s\n' "$server_version" +printf 'PostgreSQL client: %s\n' "$(pg_dump --version)" + +if [[ "$target" == "--check-only" ]]; then + echo "Read-only external PostgreSQL connection check passed." + exit 0 +fi + +if [[ -z "$target" ]]; then + echo "Usage: $0 ENV_FILE OUTPUT_DUMP [EXPECTED_ENVIRONMENT]" >&2 + echo " $0 ENV_FILE --check-only [EXPECTED_ENVIRONMENT]" >&2 + exit 2 +fi + +if [[ "$target" != /* ]]; then + target="$ROOT/$target" +fi + +target_dir=$(dirname -- "$target") +target_name=$(basename -- "$target") +checksum="$target.sha256" +metadata="$target.metadata" + +if [[ -e "$target" || -e "$checksum" || -e "$metadata" ]]; then + echo "Refusing to overwrite an existing backup, checksum, or metadata file." >&2 + exit 2 +fi + +mkdir -p "$target_dir" +chmod 700 "$target_dir" + +partial="$target.partial.$$" +checksum_partial="$checksum.partial.$$" +metadata_partial="$metadata.partial.$$" + +printf 'Backup target: %s\n' "$target" +echo "The source database is read only; the command will create one custom-format dump plus checksum and non-secret metadata." + +PGPASSFILE="$pgpass_file" pg_dump \ + --host "$connection_host" \ + --port "$database_port" \ + --username "$database_user" \ + --dbname "$database_name" \ + --no-password \ + --format custom \ + --no-owner \ + --no-acl \ + --file "$partial" + +[[ -s "$partial" ]] || { + echo "PostgreSQL produced an empty backup." >&2 + exit 1 +} + +pg_restore --list "$partial" >/dev/null +hash=$(sha256sum "$partial" | awk '{print $1}') +printf '%s %s\n' "$hash" "$target_name" >"$checksum_partial" +{ + printf 'deployment_environment=%s\n' "$deployment_environment" + printf 'compose_project=%s\n' "$compose_project" + printf 'database_name=%s\n' "$database_name" + printf 'server_version=%s\n' "$server_version" + printf 'client_version=%s\n' "$(pg_dump --version)" + printf 'created_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'sha256=%s\n' "$hash" +} >"$metadata_partial" + +chmod 600 "$partial" "$checksum_partial" "$metadata_partial" +sync -f "$partial" "$checksum_partial" "$metadata_partial" +mv "$partial" "$target" +mv "$checksum_partial" "$checksum" +mv "$metadata_partial" "$metadata" +partial= +checksum_partial= +metadata_partial= + +echo "External PostgreSQL backup catalog and checksum passed verification." +printf 'Backup: %s\nChecksum: %s\nMetadata: %s\n' "$target" "$checksum" "$metadata" diff --git a/scripts/backup-s3-drill.sh b/scripts/backup-s3-drill.sh index 249223a..252cf07 100755 --- a/scripts/backup-s3-drill.sh +++ b/scripts/backup-s3-drill.sh @@ -3,7 +3,7 @@ set -euo pipefail umask 077 ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -ENV_FILE="$ROOT/.env.load" +ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"} LABEL=${1:-"backup-$(date -u +%Y%m%d%H%M%S)"} if [[ ! -f "$ENV_FILE" ]]; then @@ -22,7 +22,7 @@ for name in LOAD_PROJECT POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD DATABASE_UR BACKUP_TIMEOUT_SECONDS BACKUP_INTERRUPTION_CHUNKS \ BACKUP_INTERRUPTION_CHUNK_BYTES BACKUP_INTERRUPTION_INTERVAL_SECONDS; do if [[ -z "${!name:-}" ]]; then - echo "$name is missing from .env.load" >&2 + echo "$name is missing from the generated load runtime environment" >&2 exit 1 fi done diff --git a/scripts/check-environment-readiness.sh b/scripts/check-environment-readiness.sh new file mode 100755 index 0000000..6043bfd --- /dev/null +++ b/scripts/check-environment-readiness.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +env_file=${1:-"$ROOT/.env"} +mode=${2:-} + +if [[ "$env_file" != /* ]]; then + env_file="$ROOT/$env_file" +fi + +if [[ "$mode" != "" && "$mode" != "--require-release" ]]; then + echo "Usage: $0 [ENV_FILE] [--require-release]" >&2 + exit 2 +fi + +if [[ ! -f "$env_file" ]]; then + echo "Environment file does not exist: $env_file" >&2 + exit 2 +fi + +if [[ "$(stat -c '%a' "$env_file")" != 600 ]]; then + echo "Environment file must have mode 0600: $env_file" >&2 + exit 2 +fi + +read_value() { + local key=$1 + + awk -v key="$key" ' + index($0, key "=") == 1 { + value = substr($0, length(key) + 2) + if (value ~ /^".*"$/ || value ~ /^'\''.*'\''$/) { + value = substr(value, 2, length(value) - 2) + } + print value + found = 1 + exit + } + END { if (!found) exit 1 } + ' "$env_file" +} + +value() { + read_value "$1" 2>/dev/null || true +} + +is_set() { + [[ -n "$(value "$1")" ]] +} + +all_set() { + local key + for key in "$@"; do + is_set "$key" || return 1 + done +} + +all_empty() { + local key + for key in "$@"; do + is_set "$key" && return 1 + done + return 0 +} + +contains_template_marker() { + local observed=$1 + [[ "$observed" == *REPLACE* || "$observed" == *GENERATE* || + "$observed" == *example.com* || "$observed" == *example.invalid* ]] +} + +failures=0 +warnings=0 + +ready() { + printf 'READY %-24s %s\n' "$1" "$2" +} + +local_only() { + printf 'LOCAL_ONLY %-24s %s\n' "$1" "$2" + warnings=$((warnings + 1)) +} + +missing() { + printf 'MISSING %-24s %s\n' "$1" "$2" + failures=$((failures + 1)) +} + +invalid() { + printf 'INVALID %-24s %s\n' "$1" "$2" + failures=$((failures + 1)) +} + +partial() { + printf 'PARTIAL %-24s %s\n' "$1" "$2" + failures=$((failures + 1)) +} + +deployment_env=$(value DEPLOYMENT_ENV) +phx_host=$(value PHX_HOST) +phx_scheme=$(value PHX_SCHEME) +phx_port=$(value PHX_URL_PORT) +base_url=$(value WNH_BASE_URL) +debug_base_url=$(value WNH_DEBUG_BASE_URL) + +if all_set DEPLOYMENT_ENV PHX_HOST PHX_SCHEME PHX_URL_PORT WNH_BASE_URL WNH_DEBUG_BASE_URL && + [[ "$base_url" == "$debug_base_url" ]] && + [[ "$base_url" == "$phx_scheme://$phx_host" || + "$base_url" == "$phx_scheme://$phx_host:$phx_port" ]] && + ! contains_template_marker "$base_url"; then + ready "public origin" "deployment=$deployment_env; one canonical Android/web origin" +else + invalid "public origin" "DEPLOYMENT_ENV/PHX_*/WNH_*_BASE_URL are incomplete or inconsistent" +fi + +if all_set SECRET_KEY_BASE HANDOVER_SECRET RELEASE_COOKIE METRICS_TOKEN; then + ready "application secrets" "four required independent values are present" +else + missing "application secrets" "SECRET_KEY_BASE, HANDOVER_SECRET, RELEASE_COOKIE, METRICS_TOKEN" +fi + +smtp_relay=$(value SMTP_RELAY) +email_delivery_provider=$(value EMAIL_DELIVERY_PROVIDER) +email_delivery_provider=${email_delivery_provider:-smtp} +if [[ "$email_delivery_provider" != "smtp" ]]; then + invalid "transactional email" "EMAIL_DELIVERY_PROVIDER must be smtp" +elif ! all_set SMTP_RELAY SMTP_PORT SMTP_AUTH SMTP_TLS SMTP_SSL EMAIL_FROM_ADDRESS; then + missing "transactional email" "SMTP transport and sender fields" +elif [[ "$smtp_relay" == "mailpit" ]]; then + local_only "transactional email" "Mailpit captures messages locally; it cannot deliver public email" +elif [[ "$(value SMTP_AUTH)" == "always" ]] && ! all_set SMTP_USERNAME SMTP_PASSWORD; then + partial "transactional email" "authenticated SMTP requires both username and password" +else + ready "transactional email" "external SMTP transport is configured" +fi + +if is_set SUPPORT_INBOX_ADDRESS; then + ready "support inbox" "operator destination is configured" +else + missing "support inbox" "SUPPORT_INBOX_ADDRESS" +fi + +if all_empty GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET; then + missing "Google sign-in" "GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET" +elif all_set GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET; then + ready "Google sign-in" "client ID and secret are both configured" +else + partial "Google sign-in" "client ID and secret must be configured together" +fi + +if all_empty WEB_PUSH_VAPID_PUBLIC_KEY WEB_PUSH_VAPID_PRIVATE_KEY WEB_PUSH_VAPID_SUBJECT; then + missing "browser Web Push" "VAPID public/private keys and subject" +elif all_set WEB_PUSH_VAPID_PUBLIC_KEY WEB_PUSH_VAPID_PRIVATE_KEY WEB_PUSH_VAPID_SUBJECT; then + case "$(value WEB_PUSH_VAPID_SUBJECT)" in + mailto:* | https://*) ready "browser Web Push" "complete VAPID configuration" ;; + *) invalid "browser Web Push" "WEB_PUSH_VAPID_SUBJECT must use mailto: or https://" ;; + esac +else + partial "browser Web Push" "all three VAPID values are required together" +fi + +if all_empty WNH_FIREBASE_APPLICATION_ID WNH_FIREBASE_API_KEY \ + WNH_FIREBASE_PROJECT_ID WNH_FIREBASE_GCM_SENDER_ID; then + missing "Android Firebase client" "four WNH_FIREBASE_* Android client values" +elif all_set WNH_FIREBASE_APPLICATION_ID WNH_FIREBASE_API_KEY \ + WNH_FIREBASE_PROJECT_ID WNH_FIREBASE_GCM_SENDER_ID; then + ready "Android Firebase client" "complete client configuration" +else + partial "Android Firebase client" "all four WNH_FIREBASE_* values are required together" +fi + +fcm_file=$(value FCM_SERVICE_ACCOUNT_FILE) +fcm_base64=$(value FCM_SERVICE_ACCOUNT_JSON_BASE64) +if [[ -z "$(value FCM_PROJECT_ID)" && -z "$fcm_file" && -z "$fcm_base64" ]]; then + missing "Android FCM delivery" "FCM project ID and one service-account source" +elif [[ -z "$(value FCM_PROJECT_ID)" || (-n "$fcm_file" && -n "$fcm_base64") || + (-z "$fcm_file" && -z "$fcm_base64") ]]; then + partial "Android FCM delivery" "project ID and exactly one credential source are required" +elif [[ -n "$fcm_file" ]]; then + if [[ "$fcm_file" == /* && -r "$fcm_file" ]]; then + ready "Android FCM delivery" "readable service-account file is configured" + else + invalid "Android FCM delivery" "FCM_SERVICE_ACCOUNT_FILE must be an absolute readable file" + fi +elif printf '%s' "$fcm_base64" | base64 --decode 2>/dev/null | + jq -e '.type == "service_account" and (.project_id | type == "string")' >/dev/null 2>&1; then + ready "Android FCM delivery" "valid Base64 service-account document is configured" +else + invalid "Android FCM delivery" "Base64 credential is not a service-account JSON document" +fi + +if all_empty ANDROID_APP_LINKS_PACKAGE_NAME ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS; then + missing "Android App Links" "package name and signing certificate fingerprint" +elif all_set ANDROID_APP_LINKS_PACKAGE_NAME ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS; then + ready "Android App Links" "package and signing fingerprints are configured" +else + partial "Android App Links" "package and signing fingerprints must be configured together" +fi + +if all_set WNH_ANDROID_VERSION_CODE WNH_ANDROID_VERSION_NAME \ + WNH_ANDROID_SIGNING_KEY_ALIAS WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS; then + ready "Android release inputs" "version and separate production/staging signing aliases are present" +else + missing "Android release inputs" "version code/name and both signing aliases" +fi + +printf '\nEnvironment readiness: %d blocking item(s), %d local-only warning(s).\n' \ + "$failures" "$warnings" + +if [[ "$mode" == "--require-release" && ($failures -ne 0 || $warnings -ne 0) ]]; then + exit 1 +fi diff --git a/scripts/clean-deploy-verify.sh b/scripts/clean-deploy-verify.sh index 33387b3..9c51fd3 100755 --- a/scripts/clean-deploy-verify.sh +++ b/scripts/clean-deploy-verify.sh @@ -123,8 +123,7 @@ fi source_commit=$(git rev-parse --verify HEAD) git archive --format=tar "$source_commit" | tar -xf - -C "$workspace" -if [[ -e "$workspace/.git" || -e "$workspace/output" || -e "$workspace/.env.load" || - -e "$workspace/.env.e2e" ]]; then +if [[ -e "$workspace/.git" || -e "$workspace/output" ]]; then echo "The tracked archive unexpectedly contains local state." >&2 exit 1 fi diff --git a/scripts/e2e-run.sh b/scripts/e2e-run.sh index 64724e8..2ffe2b2 100755 --- a/scripts/e2e-run.sh +++ b/scripts/e2e-run.sh @@ -5,6 +5,7 @@ ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) cd "$ROOT" "$ROOT/scripts/ensure-local-e2e-env.sh" +E2E_ENV=${WNH_E2E_ENV_FILE:-"$ROOT/output/runtime/e2e.env"} run_id="$(date -u +%Y%m%d%H%M%S)-$$" project="who_need_help_e2e_$run_id" @@ -44,7 +45,7 @@ export MAP_TILE_URL="/__e2e__/map-tile.png?z={z}&x={x}&y={y}" compose() { docker compose \ --project-name "$project" \ - --env-file "$ROOT/.env.e2e" \ + --env-file "$E2E_ENV" \ --file "$ROOT/compose.yaml" \ --file "$ROOT/compose.e2e.yaml" \ "$@" diff --git a/scripts/e2e-stop.sh b/scripts/e2e-stop.sh index b1d7e54..71465f9 100755 --- a/scripts/e2e-stop.sh +++ b/scripts/e2e-stop.sh @@ -17,8 +17,14 @@ case "$project" in esac ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +E2E_ENV=${WNH_E2E_ENV_FILE:-"$ROOT/output/runtime/e2e.env"} run_id=${project#who_need_help_e2e_} +if [ ! -f "$E2E_ENV" ]; then + echo "Missing generated E2E runtime state: $E2E_ENV" >&2 + exit 1 +fi + # Compose still resolves required interpolation values for `down`. These values # mirror the exact per-run tags. The project label passed above remains the # Compose resource scope. @@ -38,7 +44,7 @@ export MAP_TILE_URL="/__e2e__/map-tile.png?z={z}&x={x}&y={y}" docker compose \ --project-name "$project" \ - --env-file "$ROOT/.env.e2e" \ + --env-file "$E2E_ENV" \ --file "$ROOT/compose.yaml" \ --file "$ROOT/compose.e2e.yaml" \ down --remove-orphans diff --git a/scripts/ensure-local-android-test-env.sh b/scripts/ensure-local-android-test-env.sh index 37c109c..ca840fc 100755 --- a/scripts/ensure-local-android-test-env.sh +++ b/scripts/ensure-local-android-test-env.sh @@ -2,10 +2,15 @@ set -eu ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -target="$ROOT/.env.android-test" +runtime_dir=${WNH_RUNTIME_ENV_DIR:-"$ROOT/output/runtime"} +target=${WNH_ANDROID_TEST_ENV_FILE:-"$runtime_dir/android-test.env"} + +mkdir -p "$runtime_dir" +chmod 700 "$runtime_dir" if [ -f "$target" ]; then - echo ".env.android-test already exists; no setting was changed." + chmod 600 "$target" + echo "The generated Android test runtime environment already exists; no setting was changed." exit 0 fi @@ -16,4 +21,4 @@ umask 077 } > "$target" chmod 600 "$target" -echo "Generated .env.android-test with an isolated device-loopback origin and mode 0600." +echo "Generated isolated Android test runtime state in output/runtime/android-test.env with mode 0600." diff --git a/scripts/ensure-local-e2e-env.sh b/scripts/ensure-local-e2e-env.sh index d127dfc..297cfe8 100755 --- a/scripts/ensure-local-e2e-env.sh +++ b/scripts/ensure-local-e2e-env.sh @@ -3,7 +3,11 @@ set -eu umask 077 ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -target="$ROOT/.env.e2e" +runtime_dir=${WNH_RUNTIME_ENV_DIR:-"$ROOT/output/runtime"} +target=${WNH_E2E_ENV_FILE:-"$runtime_dir/e2e.env"} + +mkdir -p "$runtime_dir" +chmod 700 "$runtime_dir" if [ -f "$target" ]; then chmod 600 "$target" @@ -36,7 +40,7 @@ if [ -f "$target" ]; then if [ "$updated" = true ]; then echo "Updated non-secret E2E transport inputs; existing secrets were preserved." else - echo ".env.e2e already exists; no secret or experiment input was changed." + echo "The generated E2E runtime environment already exists; no secret or experiment input was changed." fi exit 0 @@ -105,4 +109,4 @@ E2E_OUTPUT_DIR=$ROOT/output/e2e/generated-per-run EOF chmod 600 "$target" -echo "Generated .env.e2e with independent local secrets and mode 0600." +echo "Generated isolated E2E runtime state in output/runtime/e2e.env with mode 0600." diff --git a/scripts/ensure-local-load-env.sh b/scripts/ensure-local-load-env.sh index daf358f..66126d5 100755 --- a/scripts/ensure-local-load-env.sh +++ b/scripts/ensure-local-load-env.sh @@ -3,7 +3,11 @@ set -eu ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) TEMPLATE=${WNH_LOAD_ENV_TEMPLATE:-"$ROOT/.env.load.example"} -ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"} +runtime_dir=${WNH_RUNTIME_ENV_DIR:-"$ROOT/output/runtime"} +ENV_FILE=${WNH_LOAD_ENV_FILE:-"$runtime_dir/load.env"} + +mkdir -p "$runtime_dir" +chmod 700 "$runtime_dir" for command in openssl perl; do if ! command -v "$command" >/dev/null 2>&1; then @@ -156,7 +160,7 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS [ "$needs_backup_interruption_chunks" = false ] && [ "$needs_backup_interruption_chunk_bytes" = false ] && [ "$needs_backup_interruption_interval" = false ]; then - echo ".env.load already exists; no secret or experiment input was changed." + echo "The generated load runtime environment already exists; no secret or experiment input was changed." exit 0 fi @@ -356,7 +360,7 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS needs_backup_bucket_prefix needs_backup_timeout \ needs_backup_interruption_chunks needs_backup_interruption_chunk_bytes \ needs_backup_interruption_interval missing_template_keys template_upgrade_keys - echo "Added missing deployment/queue/load/resilience/observability/backup inputs to ignored .env.load." + echo "Added missing deployment/queue/load/resilience/observability/backup inputs to the generated load runtime environment." exit 0 fi @@ -404,7 +408,7 @@ DOCKER_SOCKET_GID_VALUE=$docker_socket_gid \ ' "$TEMPLATE" >"$temporary" if grep -Eq '^[A-Z0-9_]+=GENERATE_' "$temporary"; then - echo "A secret marker was not replaced; refusing to publish .env.load." >&2 + echo "A secret marker was not replaced; refusing to publish the generated load runtime environment." >&2 exit 1 fi @@ -416,4 +420,4 @@ unset postgres_password secret_key_base handover_secret release_cookie metrics_t backup_minio_root_user backup_minio_root_password backup_restic_password \ database_url -echo "Generated independent load-profile secrets in ignored .env.load." +echo "Generated independent load-profile runtime state in output/runtime/load.env with mode 0600." diff --git a/scripts/init-production-env.sh b/scripts/init-production-env.sh index b5878ee..7cba8c4 100755 --- a/scripts/init-production-env.sh +++ b/scripts/init-production-env.sh @@ -53,6 +53,15 @@ public_upstream_name=${PRODUCTION_PUBLIC_UPSTREAM_NAME:-who-need-help-production codex_session_id=${PRODUCTION_CODEX_SESSION_ID:-} google_oauth_client_id=${PRODUCTION_GOOGLE_OAUTH_CLIENT_ID:-} google_oauth_client_secret=${PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET:-} +web_push_vapid_public_key=${PRODUCTION_WEB_PUSH_VAPID_PUBLIC_KEY:-} +web_push_vapid_private_key=${PRODUCTION_WEB_PUSH_VAPID_PRIVATE_KEY:-} +web_push_vapid_subject=${PRODUCTION_WEB_PUSH_VAPID_SUBJECT:-} +firebase_application_id=${PRODUCTION_WNH_FIREBASE_APPLICATION_ID:-} +firebase_api_key=${PRODUCTION_WNH_FIREBASE_API_KEY:-} +firebase_project_id=${PRODUCTION_WNH_FIREBASE_PROJECT_ID:-} +firebase_sender_id=${PRODUCTION_WNH_FIREBASE_GCM_SENDER_ID:-} +fcm_project_id=${PRODUCTION_FCM_PROJECT_ID:-} +fcm_service_account_json_base64=${PRODUCTION_FCM_SERVICE_ACCOUNT_JSON_BASE64:-} android_app_links_package_name=${PRODUCTION_ANDROID_APP_LINKS_PACKAGE_NAME:-} android_app_links_fingerprints=${PRODUCTION_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS:-} test_domain=${PRODUCTION_TEST_DOMAIN:-"test.$domain"} @@ -77,6 +86,34 @@ if { [ -n "$android_app_links_package_name" ] || [ -n "$android_app_links_finger exit 1 fi +firebase_values="$firebase_application_id $firebase_api_key $firebase_project_id $firebase_sender_id" +if printf '%s\n' "$firebase_values" | grep -q '[^[:space:]]'; then + for value in "$firebase_application_id" "$firebase_api_key" \ + "$firebase_project_id" "$firebase_sender_id"; do + [ -n "$value" ] || { + echo "All four production WNH_FIREBASE_* values must be configured together." >&2 + exit 1 + } + done +fi + +vapid_values="$web_push_vapid_public_key $web_push_vapid_private_key $web_push_vapid_subject" +if printf '%s\n' "$vapid_values" | grep -q '[^[:space:]]'; then + for value in "$web_push_vapid_public_key" "$web_push_vapid_private_key" \ + "$web_push_vapid_subject"; do + [ -n "$value" ] || { + echo "All three production WEB_PUSH_VAPID_* values must be configured together." >&2 + exit 1 + } + done +fi + +if { [ -n "$fcm_project_id" ] || [ -n "$fcm_service_account_json_base64" ]; } && + { [ -z "$fcm_project_id" ] || [ -z "$fcm_service_account_json_base64" ]; }; then + echo "Production FCM project ID and Base64 service account must be configured together." >&2 + exit 1 +fi + case "$compose_project_name" in *[!a-zA-Z0-9_-]* | '') echo "PRODUCTION_COMPOSE_PROJECT_NAME must contain only letters, numbers, underscores, or hyphens." >&2 @@ -187,6 +224,15 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ GIT_SHA_VALUE=$git_sha \ GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \ GOOGLE_OAUTH_CLIENT_SECRET_VALUE=$google_oauth_client_secret \ +WEB_PUSH_VAPID_PUBLIC_KEY_VALUE=$web_push_vapid_public_key \ +WEB_PUSH_VAPID_PRIVATE_KEY_VALUE=$web_push_vapid_private_key \ +WEB_PUSH_VAPID_SUBJECT_VALUE=$web_push_vapid_subject \ +FIREBASE_APPLICATION_ID_VALUE=$firebase_application_id \ +FIREBASE_API_KEY_VALUE=$firebase_api_key \ +FIREBASE_PROJECT_ID_VALUE=$firebase_project_id \ +FIREBASE_SENDER_ID_VALUE=$firebase_sender_id \ +FCM_PROJECT_ID_VALUE=$fcm_project_id \ +FCM_SERVICE_ACCOUNT_JSON_BASE64_VALUE=$fcm_service_account_json_base64 \ ANDROID_APP_LINKS_PACKAGE_NAME_VALUE=$android_app_links_package_name \ ANDROID_APP_LINKS_FINGERPRINTS_VALUE=$android_app_links_fingerprints \ EDGE_COMPOSE_PROJECT_NAME_VALUE=$edge_compose_project_name \ @@ -244,6 +290,16 @@ TEST_UPSTREAM_VALUE=$test_upstream \ replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_SECRET"] = ENVIRON["GOOGLE_OAUTH_CLIENT_SECRET_VALUE"] + replacement["WEB_PUSH_VAPID_PUBLIC_KEY"] = ENVIRON["WEB_PUSH_VAPID_PUBLIC_KEY_VALUE"] + replacement["WEB_PUSH_VAPID_PRIVATE_KEY"] = ENVIRON["WEB_PUSH_VAPID_PRIVATE_KEY_VALUE"] + replacement["WEB_PUSH_VAPID_SUBJECT"] = ENVIRON["WEB_PUSH_VAPID_SUBJECT_VALUE"] + replacement["WNH_FIREBASE_APPLICATION_ID"] = ENVIRON["FIREBASE_APPLICATION_ID_VALUE"] + replacement["WNH_FIREBASE_API_KEY"] = ENVIRON["FIREBASE_API_KEY_VALUE"] + replacement["WNH_FIREBASE_PROJECT_ID"] = ENVIRON["FIREBASE_PROJECT_ID_VALUE"] + replacement["WNH_FIREBASE_GCM_SENDER_ID"] = ENVIRON["FIREBASE_SENDER_ID_VALUE"] + replacement["FCM_PROJECT_ID"] = ENVIRON["FCM_PROJECT_ID_VALUE"] + replacement["FCM_SERVICE_ACCOUNT_FILE"] = "" + replacement["FCM_SERVICE_ACCOUNT_JSON_BASE64"] = ENVIRON["FCM_SERVICE_ACCOUNT_JSON_BASE64_VALUE"] replacement["ANDROID_APP_LINKS_PACKAGE_NAME"] = ENVIRON["ANDROID_APP_LINKS_PACKAGE_NAME_VALUE"] replacement["ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS"] = ENVIRON["ANDROID_APP_LINKS_FINGERPRINTS_VALUE"] replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"] @@ -267,6 +323,7 @@ trap - EXIT HUP INT TERM unset postgres_password secret_key_base handover_secret release_cookie metrics_token unset smtp_password unset google_oauth_client_secret +unset web_push_vapid_private_key firebase_api_key fcm_service_account_json_base64 unset android_app_links_fingerprints echo "Generated independent deployment secrets without printing them." diff --git a/scripts/init-test-env.sh b/scripts/init-test-env.sh index 5b76b0e..6e697f1 100755 --- a/scripts/init-test-env.sh +++ b/scripts/init-test-env.sh @@ -53,6 +53,15 @@ mailpit_port=${TEST_MAILPIT_PORT:-8027} codex_session_id=${TEST_CODEX_SESSION_ID:-} google_oauth_client_id=${TEST_GOOGLE_OAUTH_CLIENT_ID:-} google_oauth_client_secret=${TEST_GOOGLE_OAUTH_CLIENT_SECRET:-} +web_push_vapid_public_key=${TEST_WEB_PUSH_VAPID_PUBLIC_KEY:-} +web_push_vapid_private_key=${TEST_WEB_PUSH_VAPID_PRIVATE_KEY:-} +web_push_vapid_subject=${TEST_WEB_PUSH_VAPID_SUBJECT:-} +firebase_application_id=${TEST_WNH_FIREBASE_APPLICATION_ID:-} +firebase_api_key=${TEST_WNH_FIREBASE_API_KEY:-} +firebase_project_id=${TEST_WNH_FIREBASE_PROJECT_ID:-} +firebase_sender_id=${TEST_WNH_FIREBASE_GCM_SENDER_ID:-} +fcm_project_id=${TEST_FCM_PROJECT_ID:-} +fcm_service_account_json_base64=${TEST_FCM_SERVICE_ACCOUNT_JSON_BASE64:-} android_app_links_package_name=${TEST_ANDROID_APP_LINKS_PACKAGE_NAME:-} android_app_links_fingerprints=${TEST_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS:-} support_inbox_address=${TEST_SUPPORT_INBOX_ADDRESS:-} @@ -88,6 +97,34 @@ if [[ -n "$android_app_links_package_name" || -n "$android_app_links_fingerprint } fi +firebase_values="$firebase_application_id $firebase_api_key $firebase_project_id $firebase_sender_id" +if grep -q '[^[:space:]]' <<<"$firebase_values"; then + for value in "$firebase_application_id" "$firebase_api_key" \ + "$firebase_project_id" "$firebase_sender_id"; do + [[ -n "$value" ]] || { + echo "All four test WNH_FIREBASE_* values must be configured together." >&2 + exit 1 + } + done +fi + +vapid_values="$web_push_vapid_public_key $web_push_vapid_private_key $web_push_vapid_subject" +if grep -q '[^[:space:]]' <<<"$vapid_values"; then + for value in "$web_push_vapid_public_key" "$web_push_vapid_private_key" \ + "$web_push_vapid_subject"; do + [[ -n "$value" ]] || { + echo "All three test WEB_PUSH_VAPID_* values must be configured together." >&2 + exit 1 + } + done +fi + +if [[ (-n "$fcm_project_id" || -n "$fcm_service_account_json_base64") && + (-z "$fcm_project_id" || -z "$fcm_service_account_json_base64") ]]; then + echo "Test FCM project ID and Base64 service account must be configured together." >&2 + exit 1 +fi + for pair in "HTTP:$http_port" "MAILPIT:$mailpit_port"; do value=${pair#*:} if [[ ! "$value" =~ ^[0-9]+$ ]] || ((value < 1 || value > 65535)); then @@ -132,6 +169,15 @@ RELEASE_COOKIE_VALUE=$release_cookie \ METRICS_TOKEN_VALUE=$metrics_token \ GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \ GOOGLE_OAUTH_CLIENT_SECRET_VALUE=$google_oauth_client_secret \ +WEB_PUSH_VAPID_PUBLIC_KEY_VALUE=$web_push_vapid_public_key \ +WEB_PUSH_VAPID_PRIVATE_KEY_VALUE=$web_push_vapid_private_key \ +WEB_PUSH_VAPID_SUBJECT_VALUE=$web_push_vapid_subject \ +FIREBASE_APPLICATION_ID_VALUE=$firebase_application_id \ +FIREBASE_API_KEY_VALUE=$firebase_api_key \ +FIREBASE_PROJECT_ID_VALUE=$firebase_project_id \ +FIREBASE_SENDER_ID_VALUE=$firebase_sender_id \ +FCM_PROJECT_ID_VALUE=$fcm_project_id \ +FCM_SERVICE_ACCOUNT_JSON_BASE64_VALUE=$fcm_service_account_json_base64 \ ANDROID_APP_LINKS_PACKAGE_NAME_VALUE=$android_app_links_package_name \ ANDROID_APP_LINKS_FINGERPRINTS_VALUE=$android_app_links_fingerprints \ SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \ @@ -185,6 +231,16 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_SECRET"] = ENVIRON["GOOGLE_OAUTH_CLIENT_SECRET_VALUE"] + replacement["WEB_PUSH_VAPID_PUBLIC_KEY"] = ENVIRON["WEB_PUSH_VAPID_PUBLIC_KEY_VALUE"] + replacement["WEB_PUSH_VAPID_PRIVATE_KEY"] = ENVIRON["WEB_PUSH_VAPID_PRIVATE_KEY_VALUE"] + replacement["WEB_PUSH_VAPID_SUBJECT"] = ENVIRON["WEB_PUSH_VAPID_SUBJECT_VALUE"] + replacement["WNH_FIREBASE_APPLICATION_ID"] = ENVIRON["FIREBASE_APPLICATION_ID_VALUE"] + replacement["WNH_FIREBASE_API_KEY"] = ENVIRON["FIREBASE_API_KEY_VALUE"] + replacement["WNH_FIREBASE_PROJECT_ID"] = ENVIRON["FIREBASE_PROJECT_ID_VALUE"] + replacement["WNH_FIREBASE_GCM_SENDER_ID"] = ENVIRON["FIREBASE_SENDER_ID_VALUE"] + replacement["FCM_PROJECT_ID"] = ENVIRON["FCM_PROJECT_ID_VALUE"] + replacement["FCM_SERVICE_ACCOUNT_FILE"] = "" + replacement["FCM_SERVICE_ACCOUNT_JSON_BASE64"] = ENVIRON["FCM_SERVICE_ACCOUNT_JSON_BASE64_VALUE"] replacement["ANDROID_APP_LINKS_PACKAGE_NAME"] = ENVIRON["ANDROID_APP_LINKS_PACKAGE_NAME_VALUE"] replacement["ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS"] = ENVIRON["ANDROID_APP_LINKS_FINGERPRINTS_VALUE"] replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"] @@ -202,6 +258,7 @@ trap - EXIT HUP INT TERM unset postgres_password secret_key_base handover_secret release_cookie metrics_token unset google_oauth_client_secret +unset web_push_vapid_private_key firebase_api_key fcm_service_account_json_base64 unset android_app_links_fingerprints "$ROOT/scripts/compose.sh" "$target" config --quiet diff --git a/scripts/load-cycle.sh b/scripts/load-cycle.sh index f162a65..09474cd 100755 --- a/scripts/load-cycle.sh +++ b/scripts/load-cycle.sh @@ -3,7 +3,7 @@ set -euo pipefail umask 077 ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -BASE_ENV=${WNH_LOAD_BASE_ENV_FILE:-"$ROOT/.env.load"} +BASE_ENV=${WNH_LOAD_BASE_ENV_FILE:-"$ROOT/output/runtime/load.env"} LABEL=${1:-"cycle-$(date -u +%Y%m%dT%H%M%SZ)"} MODE=${2:-load} diff --git a/scripts/load-resilience-run.sh b/scripts/load-resilience-run.sh index db17711..20e7bff 100755 --- a/scripts/load-resilience-run.sh +++ b/scripts/load-resilience-run.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"} +ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"} LABEL=${1:-"resilience-$(date -u +%Y%m%dT%H%M%SZ)"} if [[ ! -f "$ENV_FILE" ]]; then @@ -21,7 +21,7 @@ for name in LOAD_PROJECT LOAD_HOST LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS \ LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS TRAEFIK_RETRY_ATTEMPTS \ TRAEFIK_API_INSECURE HTTP_PORT POSTGRES_DB METRICS_TOKEN; do if [[ -z "${!name:-}" ]]; then - echo "$name is missing from .env.load" >&2 + echo "$name is missing from the generated load runtime environment" >&2 exit 1 fi done diff --git a/scripts/load-run.sh b/scripts/load-run.sh index e13a118..81af27e 100755 --- a/scripts/load-run.sh +++ b/scripts/load-run.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"} +ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"} LOAD_TOOLS_IMAGE=${WNH_LOAD_TOOLS_IMAGE:-who-need-help:load-tools} K6_IMAGE="grafana/k6@sha256:65c920dc067d5e2e00befbf982af6ad6ad0117034e8b1c65817c7975c52d4669" PYTHON_IMAGE="python:3.14.6-alpine3.23@sha256:b165067c5afc37fa5608a3c05609cc3d51aafd808a30fbfd822ee594fef55ad4" @@ -22,7 +22,7 @@ set +a WEB_POOL_SIZE=${WEB_POOL_SIZE:-${POOL_SIZE:-}} WORKER_POOL_SIZE=${WORKER_POOL_SIZE:-${POOL_SIZE:-}} -duration_source=".env.load" +duration_source="output/runtime/load.env" if [[ -n "$duration_override" ]]; then LOAD_DURATION=$duration_override duration_source="LOAD_DURATION_OVERRIDE" @@ -35,7 +35,7 @@ for name in LOAD_PROJECT LOAD_HOST LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS \ LOAD_AUTH_THINK_SECONDS LOAD_FIXTURE_PASSWORD POSTGRES_DB HTTP_PORT \ WEB_POOL_SIZE WORKER_POOL_SIZE; do if [[ -z "${!name:-}" ]]; then - echo "$name is missing from .env.load" >&2 + echo "$name is missing from the generated load runtime environment" >&2 exit 1 fi done diff --git a/scripts/load-stack-stop.sh b/scripts/load-stack-stop.sh index b06819d..d66ae8f 100755 --- a/scripts/load-stack-stop.sh +++ b/scripts/load-stack-stop.sh @@ -2,7 +2,7 @@ set -eu ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"} +ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"} LOAD_TOOLS_IMAGE=${WNH_LOAD_TOOLS_IMAGE:-who-need-help:load-tools} if [ ! -f "$ENV_FILE" ]; then @@ -15,7 +15,7 @@ set -a . "$ENV_FILE" set +a -: "${LOAD_PROJECT:?LOAD_PROJECT is missing from .env.load}" +: "${LOAD_PROJECT:?LOAD_PROJECT is missing from the generated load runtime environment}" if [ "$LOAD_PROJECT" = who_need_help ]; then echo "Refusing to stop the staging Compose project." >&2 diff --git a/scripts/load-stack-up.sh b/scripts/load-stack-up.sh index ef0b037..ad9a572 100755 --- a/scripts/load-stack-up.sh +++ b/scripts/load-stack-up.sh @@ -2,7 +2,7 @@ set -eu ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"} +ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"} LOAD_TOOLS_IMAGE=${WNH_LOAD_TOOLS_IMAGE:-who-need-help:load-tools} REPLICAS=${1:-} @@ -13,10 +13,10 @@ set -a . "$ENV_FILE" set +a -: "${LOAD_PROJECT:?LOAD_PROJECT is missing from .env.load}" -: "${LOAD_HOST:?LOAD_HOST is missing from .env.load}" -: "${LOAD_WEB_REPLICAS:?LOAD_WEB_REPLICAS is missing from .env.load}" -: "${LOAD_WORKER_REPLICAS:?LOAD_WORKER_REPLICAS is missing from .env.load}" +: "${LOAD_PROJECT:?LOAD_PROJECT is missing from the generated load runtime environment}" +: "${LOAD_HOST:?LOAD_HOST is missing from the generated load runtime environment}" +: "${LOAD_WEB_REPLICAS:?LOAD_WEB_REPLICAS is missing from the generated load runtime environment}" +: "${LOAD_WORKER_REPLICAS:?LOAD_WORKER_REPLICAS is missing from the generated load runtime environment}" if [ "$LOAD_PROJECT" = who_need_help ]; then echo "The load profile must not use the staging Compose project." >&2 diff --git a/scripts/oban-burst-run.sh b/scripts/oban-burst-run.sh index 72c6d8e..96b475f 100755 --- a/scripts/oban-burst-run.sh +++ b/scripts/oban-burst-run.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -ENV_FILE="$ROOT/.env.load" +ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"} if [[ $# -ne 3 ]]; then echo "Usage: $0 LABEL JOB_COUNT TIMEOUT_SECONDS" >&2 @@ -40,7 +40,7 @@ set +a for name in LOAD_PROJECT LOAD_WORKER_REPLICAS POSTGRES_DB; do if [[ -z "${!name:-}" ]]; then - echo "$name is missing from .env.load." >&2 + echo "$name is missing from the generated load runtime environment." >&2 exit 1 fi done diff --git a/scripts/observability-run.sh b/scripts/observability-run.sh index c50981e..71f4165 100755 --- a/scripts/observability-run.sh +++ b/scripts/observability-run.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -ENV_FILE="$ROOT/.env.load" +ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"} LABEL=${1:-"observability-$(date -u +%Y%m%dT%H%M%SZ)"} if [[ ! -f "$ENV_FILE" ]]; then @@ -21,7 +21,7 @@ for name in LOAD_PROJECT LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS POSTGRES_DB METR OBSERVABILITY_EVALUATION_INTERVAL OBSERVABILITY_TIMEOUT_SECONDS \ OBSERVABILITY_GRAFANA_ADMIN_USER OBSERVABILITY_GRAFANA_ADMIN_PASSWORD; do if [[ -z "${!name:-}" ]]; then - echo "$name is missing from .env.load" >&2 + echo "$name is missing from the generated load runtime environment" >&2 exit 1 fi done @@ -658,5 +658,5 @@ trap - EXIT HUP INT TERM printf 'Observability evidence: %s\n' "$output_dir" printf 'Prometheus: %s\nAlertmanager: %s\nGrafana: %s/d/wnh-overview/overview\n' \ "$prometheus_url" "$alertmanager_url" "$grafana_url" -printf 'Grafana user: %s; its random password remains only in ignored .env.load.\n' \ +printf 'Grafana user: %s; its random password remains only in ignored output/runtime/load.env.\n' \ "$OBSERVABILITY_GRAFANA_ADMIN_USER" diff --git a/scripts/observability-stop.sh b/scripts/observability-stop.sh index dcbd0f1..836a813 100755 --- a/scripts/observability-stop.sh +++ b/scripts/observability-stop.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -ENV_FILE="$ROOT/.env.load" +ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"} if [[ ! -f "$ENV_FILE" ]]; then echo "Missing $ENV_FILE; no observability project was selected." >&2 @@ -14,7 +14,7 @@ set -a . "$ENV_FILE" set +a -: "${LOAD_PROJECT:?LOAD_PROJECT is missing from .env.load}" +: "${LOAD_PROJECT:?LOAD_PROJECT is missing from the generated load runtime environment}" if [[ "$LOAD_PROJECT" == "who_need_help" ]]; then echo "Refusing to stop services in the staging Compose project." >&2 diff --git a/scripts/prepare-production-release.sh b/scripts/prepare-production-release.sh new file mode 100755 index 0000000..033333e --- /dev/null +++ b/scripts/prepare-production-release.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) + +if [[ -n "$(git -C "$ROOT" status --porcelain --untracked-files=no)" ]]; then + echo "Refusing to package a release from a dirty tracked checkout." >&2 + exit 1 +fi + +commit=$(git -C "$ROOT" rev-parse --verify HEAD) +short_commit=${commit:0:12} +release_dir="$ROOT/output/releases/$commit" +bundle="$release_dir/who_need_help-$commit.bundle" +checksum="$bundle.sha256" +manifest="$release_dir/manifest.txt" + +mkdir -p "$release_dir" +chmod 700 "$ROOT/output" "$ROOT/output/releases" "$release_dir" + +if [[ -e "$bundle" || -e "$checksum" || -e "$manifest" ]]; then + echo "Release package already exists; verifying it instead of overwriting it." +else + git -C "$ROOT" bundle create "$bundle" HEAD + chmod 600 "$bundle" + hash=$(sha256sum "$bundle" | awk '{print $1}') + printf '%s %s\n' "$hash" "$(basename -- "$bundle")" >"$checksum" + { + printf 'commit=%s\n' "$commit" + printf 'short_commit=%s\n' "$short_commit" + printf 'created_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'bundle_sha256=%s\n' "$hash" + } >"$manifest" + chmod 600 "$checksum" "$manifest" +fi + +( + cd "$release_dir" + sha256sum --check "$(basename -- "$checksum")" >/dev/null +) +git -C "$ROOT" bundle verify "$bundle" >/dev/null + +bundle_head=$(git -C "$ROOT" bundle list-heads "$bundle" | awk '$2 == "HEAD" {print $1}') +if [[ "$bundle_head" != "$commit" ]]; then + echo "Release bundle HEAD does not match the current commit." >&2 + exit 1 +fi + +printf 'Release commit: %s\n' "$commit" +printf 'Bundle: %s\n' "$bundle" +printf 'Checksum: %s\n' "$checksum" +printf 'Manifest: %s\n' "$manifest" diff --git a/scripts/production-release-remote.sh b/scripts/production-release-remote.sh new file mode 100755 index 0000000..493889a --- /dev/null +++ b/scripts/production-release-remote.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +action=${1:-} +root=${2:-/srv/who_need_help-production} +expected_domain=${3:-whoneedhelp.com} +bundle=${4:-} +target_commit=${5:-} +backup=${6:-} + +usage() { + echo "Usage: $0 plan /srv/who_need_help-production whoneedhelp.com" >&2 + echo " $0 apply /srv/who_need_help-production whoneedhelp.com BUNDLE COMMIT BACKUP" >&2 +} + +case "$action" in + plan | apply) ;; + *) usage; exit 2 ;; +esac + +root=$(realpath --canonicalize-existing "$root") +if [[ "$root" != "/srv/who_need_help-production" ]]; then + echo "Refusing a production release outside /srv/who_need_help-production." >&2 + exit 2 +fi + +env_file="$root/.env" +if [[ ! -f "$env_file" || "$(stat -c '%a' "$env_file")" != 600 ]]; then + echo "Production .env is missing or does not have mode 0600." >&2 + exit 2 +fi + +read_value() { + local key=$1 + awk -v key="$key" ' + index($0, key "=") == 1 { + print substr($0, length(key) + 2) + found = 1 + exit + } + END { if (!found) exit 1 } + ' "$env_file" +} + +deployment_environment=$(read_value DEPLOYMENT_ENV) +compose_project=$(read_value COMPOSE_PROJECT_NAME) +database_mode=$(read_value DATABASE_MODE) +app_topology=$(read_value APP_TOPOLOGY) +phx_host=$(read_value PHX_HOST) +public_origin=$(read_value WNH_BASE_URL) +branch=$(git -C "$root" symbolic-ref --quiet --short HEAD || true) +current_commit=$(git -C "$root" rev-parse --verify HEAD) + +[[ "$deployment_environment" == "production" ]] || { + echo "DEPLOYMENT_ENV is not production." >&2 + exit 2 +} +[[ "$compose_project" == "who_need_help_production" ]] || { + echo "Unexpected production Compose project." >&2 + exit 2 +} +[[ "$database_mode" == "external" ]] || { + echo "The verified single-server production workflow expects DATABASE_MODE=external." >&2 + exit 2 +} +[[ "$phx_host" == "$expected_domain" && + "$public_origin" == "https://$expected_domain" ]] || { + echo "Production origin does not match the expected domain." >&2 + exit 2 +} +[[ "$branch" == "main" || -z "$branch" ]] || { + echo "Production checkout must be on main or detached at the deployed commit." >&2 + exit 2 +} +[[ -z "$(git -C "$root" status --porcelain --untracked-files=no)" ]] || { + echo "Production checkout has tracked modifications." >&2 + exit 2 +} + +"$root/scripts/validate-production-env.sh" "$env_file" "$expected_domain" >/dev/null +"$root/scripts/compose.sh" "$env_file" config --quiet + +case "$app_topology" in + compact) expected_services=(app) ;; + split) expected_services=(web worker) ;; + *) echo "Unexpected APP_TOPOLOGY." >&2; exit 2 ;; +esac + +for service in "${expected_services[@]}"; do + mapfile -t containers < <("$root/scripts/compose.sh" "$env_file" ps -q "$service") + [[ ${#containers[@]} -gt 0 ]] || { + echo "Production service is not running: $service" >&2 + exit 2 + } + + for container in "${containers[@]}"; do + state=$(docker inspect --format '{{.State.Status}}' "$container") + health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container") + [[ "$state" == "running" && "$health" == "healthy" ]] || { + echo "Production container is not healthy: $service" >&2 + exit 2 + } + done +done + +curl --fail --silent --show-error --max-time 15 \ + "https://$expected_domain/healthz/ready" >/dev/null + +printf 'Production checkout: %s\n' "$root" +printf 'Current commit: %s\n' "$current_commit" +printf 'Branch: %s\n' "${branch:-detached}" +printf 'Compose project: %s\n' "$compose_project" +printf 'Topology: %s\n' "$app_topology" +printf 'Database mode: %s\n' "$database_mode" +printf 'Public readiness: passed\n' +df -h "$root" /var/lib/docker 2>/dev/null || df -h "$root" + +if [[ "$action" == "plan" ]]; then + echo "Read-only production release scope check passed." + exit 0 +fi + +if [[ -z "$bundle" || -z "$target_commit" || -z "$backup" ]]; then + usage + exit 2 +fi + +expected_confirmation="$expected_domain:$target_commit" +if [[ "${WNH_PRODUCTION_RELEASE_CONFIRM:-}" != "$expected_confirmation" ]]; then + echo "Set WNH_PRODUCTION_RELEASE_CONFIRM=$expected_confirmation for the approved release." >&2 + exit 2 +fi + +for required_file in "$bundle" "$bundle.sha256" "$backup" "$backup.sha256" "$backup.metadata"; do + [[ -f "$required_file" ]] || { + echo "Required release evidence is missing: $required_file" >&2 + exit 2 + } +done + +( + cd "$(dirname -- "$bundle")" + sha256sum --check "$(basename -- "$bundle.sha256")" >/dev/null +) +( + cd "$(dirname -- "$backup")" + sha256sum --check "$(basename -- "$backup.sha256")" >/dev/null +) +pg_restore --list "$backup" >/dev/null +git -C "$root" bundle verify "$bundle" >/dev/null + +bundle_head=$(git -C "$root" bundle list-heads "$bundle" | awk '$2 == "HEAD" {print $1}') +[[ "$bundle_head" == "$target_commit" ]] || { + echo "Bundle HEAD does not match the approved target commit." >&2 + exit 2 +} + +release_id="$(date -u +%Y%m%dT%H%M%SZ)-${target_commit:0:12}" +release_dir="$root/output/releases/$release_id" +mkdir -p "$release_dir" +chmod 700 "$root/output" "$root/output/releases" "$release_dir" +rollback_manifest="$release_dir/rollback-manifest.txt" + +{ + printf 'previous_commit=%s\n' "$current_commit" + printf 'target_commit=%s\n' "$target_commit" + printf 'APP_IMAGE=%s\n' "$(read_value APP_IMAGE)" + printf 'SOCKET_PROXY_IMAGE=%s\n' "$(read_value SOCKET_PROXY_IMAGE)" + printf 'POSTGIS_IMAGE=%s\n' "$(read_value POSTGIS_IMAGE)" + printf 'CADDY_IMAGE=%s\n' "$(read_value CADDY_IMAGE)" + printf 'database_backup=%s\n' "$backup" + printf 'started_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'status=started\n' +} >"$rollback_manifest" +chmod 600 "$rollback_manifest" + +revision_changed=false + +restore_image_revision() { + local temporary + temporary=$(mktemp "$root/.env.release-rollback.XXXXXX") + chmod 600 "$temporary" + + APP_IMAGE_VALUE=$(awk -F= '$1 == "APP_IMAGE" {print substr($0,index($0,"=")+1)}' "$rollback_manifest") \ + SOCKET_PROXY_IMAGE_VALUE=$(awk -F= '$1 == "SOCKET_PROXY_IMAGE" {print substr($0,index($0,"=")+1)}' "$rollback_manifest") \ + POSTGIS_IMAGE_VALUE=$(awk -F= '$1 == "POSTGIS_IMAGE" {print substr($0,index($0,"=")+1)}' "$rollback_manifest") \ + CADDY_IMAGE_VALUE=$(awk -F= '$1 == "CADDY_IMAGE" {print substr($0,index($0,"=")+1)}' "$rollback_manifest") \ + awk ' + BEGIN { + replacement["APP_IMAGE"] = ENVIRON["APP_IMAGE_VALUE"] + replacement["SOCKET_PROXY_IMAGE"] = ENVIRON["SOCKET_PROXY_IMAGE_VALUE"] + replacement["POSTGIS_IMAGE"] = ENVIRON["POSTGIS_IMAGE_VALUE"] + replacement["CADDY_IMAGE"] = ENVIRON["CADDY_IMAGE_VALUE"] + } + { + separator = index($0, "=") + key = separator > 1 ? substr($0, 1, separator - 1) : "" + print (key in replacement) ? key "=" replacement[key] : $0 + } + ' "$env_file" >"$temporary" + + mv "$temporary" "$env_file" + chmod 600 "$env_file" +} + +rollback_runtime() { + local status=$? + trap - EXIT HUP INT TERM + + if [[ "$status" -ne 0 && "$revision_changed" == true ]]; then + echo "Release failed after selecting new images; restoring the previous immutable image tags." >&2 + restore_image_revision + "$root/scripts/compose.sh" "$env_file" \ + up -d --no-build --wait --remove-orphans || true + + edge_project=$(read_value EDGE_COMPOSE_PROJECT_NAME) + docker compose \ + --project-name "$edge_project" \ + --project-directory "$root" \ + --env-file "$env_file" \ + --file "$root/compose.edge.yaml" \ + up -d --no-build --wait --remove-orphans || true + + curl --fail --silent --show-error --max-time 15 \ + "https://$expected_domain/healthz/ready" >/dev/null || true + { + printf 'status=runtime-rolled-back\n' + printf 'failed_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'database_note=migrations_may_have_applied_and_were_not_reversed\n' + } >>"$rollback_manifest" + echo "The Git checkout and any applied migrations were intentionally not reversed automatically." >&2 + fi + + exit "$status" +} +trap rollback_runtime EXIT HUP INT TERM + +release_ref="refs/wnh/releases/$target_commit" +git -C "$root" fetch "$bundle" "HEAD:$release_ref" +[[ "$(git -C "$root" rev-parse "$release_ref^{commit}")" == "$target_commit" ]] || { + echo "Fetched release ref does not match the approved commit." >&2 + exit 1 +} +git -C "$root" merge-base --is-ancestor "$current_commit" "$target_commit" || { + echo "Production updates must be a fast-forward from the deployed commit." >&2 + exit 1 +} +if [[ "$branch" == "main" ]]; then + git -C "$root" merge --ff-only "$release_ref" +else + git -C "$root" checkout --detach "$release_ref" +fi + +"$root/scripts/set-deployment-revision.sh" "$env_file" +revision_changed=true +"$root/scripts/validate-production-env.sh" "$env_file" "$expected_domain" +"$root/scripts/check-environment-readiness.sh" "$env_file" --require-release + +"$root/scripts/deploy-up.sh" "$env_file" +"$root/scripts/edge-up.sh" "$env_file" +curl --fail --silent --show-error --max-time 30 \ + "https://$expected_domain/healthz/ready" >/dev/null +curl --fail --silent --show-error --max-time 30 \ + "https://$expected_domain/.well-known/assetlinks.json" >/dev/null + +{ + printf 'status=success\n' + printf 'completed_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} >>"$rollback_manifest" +revision_changed=false +trap - EXIT HUP INT TERM + +printf 'Production release completed: %s\n' "$target_commit" +printf 'Rollback/runtime evidence: %s\n' "$rollback_manifest" +printf 'Database backup: %s\n' "$backup" diff --git a/scripts/production-release.sh b/scripts/production-release.sh new file mode 100755 index 0000000..99bed50 --- /dev/null +++ b/scripts/production-release.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +action=${1:-plan} +ssh_target=${2:-whoneedhelp} +remote_root=${WNH_PRODUCTION_REMOTE_ROOT:-/srv/who_need_help-production} +expected_domain=${WNH_PRODUCTION_DOMAIN:-whoneedhelp.com} + +case "$action" in + plan | apply) ;; + *) + echo "Usage: $0 [plan|apply] [SSH_TARGET]" >&2 + exit 2 + ;; +esac + +for command in git pg_restore scp sha256sum ssh; do + command -v "$command" >/dev/null 2>&1 || { + echo "Required command is unavailable: $command" >&2 + exit 2 + } +done + +local_commit=$(git -C "$ROOT" rev-parse --verify HEAD) +remote_commit=$( + ssh -o BatchMode=yes "$ssh_target" \ + "git -C '$remote_root' rev-parse --verify HEAD" +) + +printf 'Local candidate commit: %s\n' "$local_commit" +printf 'Current production commit: %s\n' "$remote_commit" + +if git -C "$ROOT" cat-file -e "$remote_commit^{commit}" 2>/dev/null; then + git -C "$ROOT" merge-base --is-ancestor "$remote_commit" "$local_commit" || { + echo "The local candidate is not a fast-forward from the production commit." >&2 + exit 2 + } + printf 'Pending commits: %s\n' \ + "$(git -C "$ROOT" rev-list --count "$remote_commit..$local_commit")" +else + echo "The production commit is not present in the local object database." >&2 + exit 2 +fi + +plan_failed=0 + +if ! ssh -o BatchMode=yes "$ssh_target" \ + "bash -s -- plan '$remote_root' '$expected_domain'" \ + <"$ROOT/scripts/production-release-remote.sh"; then + plan_failed=1 +fi + +if ! ssh -o BatchMode=yes "$ssh_target" \ + "bash -s -- '$remote_root/.env' --check-only production" \ + <"$ROOT/scripts/backup-external-postgres.sh"; then + plan_failed=1 +fi + +if ! ssh -o BatchMode=yes "$ssh_target" \ + "bash -s -- '$remote_root/.env' --require-release" \ + <"$ROOT/scripts/check-environment-readiness.sh"; then + plan_failed=1 +fi + +if [[ "$plan_failed" -ne 0 ]]; then + echo "Production release plan has blocking checks; no remote state was changed." >&2 + exit 1 +fi + +if [[ "$action" == "plan" ]]; then + echo "Production release plan passed; no remote state was changed." + exit 0 +fi + +if [[ -n "$(git -C "$ROOT" status --porcelain --untracked-files=no)" ]]; then + echo "Refusing to release a dirty tracked checkout." >&2 + exit 2 +fi + +confirmation="$expected_domain:$local_commit" +if [[ "${WNH_PRODUCTION_RELEASE_CONFIRM:-}" != "$confirmation" ]]; then + echo "Release execution requires explicit approval in this exact process:" >&2 + echo "WNH_PRODUCTION_RELEASE_CONFIRM=$confirmation $0 apply $ssh_target" >&2 + exit 2 +fi + +"$ROOT/scripts/prepare-production-release.sh" +release_dir="$ROOT/output/releases/$local_commit" +bundle="$release_dir/who_need_help-$local_commit.bundle" +remote_release_dir="$remote_root/output/releases/incoming" +remote_bundle="$remote_release_dir/$(basename -- "$bundle")" +timestamp=$(date -u +%Y%m%dT%H%M%SZ) +remote_backup="$remote_root/output/backups/production/pre-$timestamp-${local_commit:0:12}.dump" + +ssh -o BatchMode=yes "$ssh_target" \ + "install -d -m 700 '$remote_release_dir'" +scp -p "$bundle" "$bundle.sha256" "$ssh_target:$remote_release_dir/" + +ssh -o BatchMode=yes "$ssh_target" \ + "bash -s -- '$remote_root/.env' '$remote_backup' production" \ + <"$ROOT/scripts/backup-external-postgres.sh" + +local_backup_dir="$ROOT/output/production-backups/$timestamp-${local_commit:0:12}" +mkdir -p "$local_backup_dir" +chmod 700 "$ROOT/output" "$ROOT/output/production-backups" "$local_backup_dir" +scp -p \ + "$ssh_target:$remote_backup" \ + "$ssh_target:$remote_backup.sha256" \ + "$ssh_target:$remote_backup.metadata" \ + "$local_backup_dir/" +( + cd "$local_backup_dir" + sha256sum --check "$(basename -- "$remote_backup.sha256")" >/dev/null +) +pg_restore --list "$local_backup_dir/$(basename -- "$remote_backup")" >/dev/null +echo "Copied and independently verified the pre-release backup outside the production server." + +quoted_confirmation=$(printf '%q' "$confirmation") +ssh -o BatchMode=yes "$ssh_target" \ + "WNH_PRODUCTION_RELEASE_CONFIRM=$quoted_confirmation bash -s -- apply '$remote_root' '$expected_domain' '$remote_bundle' '$local_commit' '$remote_backup'" \ + <"$ROOT/scripts/production-release-remote.sh" + +echo "Production release and public health verification completed." diff --git a/scripts/quality.sh b/scripts/quality.sh index 455346c..306d8fe 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -116,6 +116,10 @@ WNH_LOAD_ENV_FILE="$legacy_load_env" \ test "$(sha256sum "$legacy_load_env" | awk '{print $1}')" = "$legacy_load_hash" echo "Checking independent test and production environment initialization" +quality_fcm_base64=$( + printf '%s' '{"type":"service_account","project_id":"quality-production"}' | + base64 -w 0 +) test_env="$scan_dir/test.env" if ./scripts/init-test-env.sh test.help.test \ "$scan_dir/test.missing-codex.env" >/dev/null 2>&1; then @@ -186,11 +190,22 @@ PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \ PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ PRODUCTION_GOOGLE_OAUTH_CLIENT_ID=quality-production-client \ PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET=quality-production-secret \ +PRODUCTION_WEB_PUSH_VAPID_PUBLIC_KEY=quality-vapid-public \ +PRODUCTION_WEB_PUSH_VAPID_PRIVATE_KEY=quality-vapid-private \ +PRODUCTION_WEB_PUSH_VAPID_SUBJECT=mailto:contact@help.test \ +PRODUCTION_WNH_FIREBASE_APPLICATION_ID=1:123456789:android:quality \ +PRODUCTION_WNH_FIREBASE_API_KEY=quality-public-client-key \ +PRODUCTION_WNH_FIREBASE_PROJECT_ID=quality-production \ +PRODUCTION_WNH_FIREBASE_GCM_SENDER_ID=123456789 \ +PRODUCTION_FCM_PROJECT_ID=quality-production \ +PRODUCTION_FCM_SERVICE_ACCOUNT_JSON_BASE64="$quality_fcm_base64" \ PRODUCTION_ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile \ PRODUCTION_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=A5:74:2B:AE:70:C6:D0:34:E3:75:44:B6:2E:37:A3:75:C0:E0:05:64:74:50:F4:0F:29:B2:A9:84:F9:FD:B8:FB \ +PRODUCTION_SUPPORT_INBOX_ADDRESS=support@help.test \ ./scripts/init-production-env.sh help.test "$production_env" >/dev/null test "$(stat -c '%a' "$production_env")" = 600 ./scripts/validate-production-env.sh "$production_env" help.test >/dev/null +./scripts/check-environment-readiness.sh "$production_env" --require-release >/dev/null grep -Fx 'COMPOSE_PROJECT_NAME=who_need_help_production' "$production_env" >/dev/null grep -E '^APP_IMAGE=who-need-help:production-[0-9a-f]{12}$' "$production_env" >/dev/null grep -Fx 'EDGE_COMPOSE_PROJECT_NAME=who_need_help_edge' "$production_env" >/dev/null @@ -333,6 +348,13 @@ if ./scripts/init-production-env.sh help.test "$production_env" >/dev/null 2>&1; echo "Production environment initializer overwrote an existing file." >&2 exit 1 fi +if PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + PRODUCTION_WNH_FIREBASE_PROJECT_ID=partial-firebase \ + ./scripts/init-production-env.sh \ + help.test "$scan_dir/.env.production.partial-firebase" >/dev/null 2>&1; then + echo "Production initializer accepted partial Firebase Android configuration." >&2 + exit 1 +fi incomplete_production_env="$scan_dir/.env.production.incomplete" PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ ./scripts/init-production-env.sh help.test "$incomplete_production_env" >/dev/null diff --git a/scripts/rotate-load-metrics-token.sh b/scripts/rotate-load-metrics-token.sh index 5883a2a..653c92c 100755 --- a/scripts/rotate-load-metrics-token.sh +++ b/scripts/rotate-load-metrics-token.sh @@ -2,7 +2,7 @@ set -eu ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -ENV_FILE="$ROOT/.env.load" +ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"} if [ ! -f "$ENV_FILE" ]; then echo "Missing $ENV_FILE. Run scripts/ensure-local-load-env.sh first." >&2 diff --git a/scripts/upgrade-rehearsal-compose.sh b/scripts/upgrade-rehearsal-compose.sh index 42238f4..5009295 100755 --- a/scripts/upgrade-rehearsal-compose.sh +++ b/scripts/upgrade-rehearsal-compose.sh @@ -26,7 +26,7 @@ fi "$ROOT/scripts/ensure-local-e2e-env.sh" >/dev/null PUBLIC_ENV_FILE="$ROOT/.env" -ENV_FILE="$ROOT/.env.e2e" +ENV_FILE=${WNH_E2E_ENV_FILE:-"$ROOT/output/runtime/e2e.env"} if [ ! -f "$PUBLIC_ENV_FILE" ]; then echo "Missing $PUBLIC_ENV_FILE." >&2 @@ -65,8 +65,8 @@ set -a . "$ENV_FILE" set +a -: "${POSTGRES_USER:?POSTGRES_USER is missing from .env.e2e}" -: "${DATABASE_URL:?DATABASE_URL is missing from .env.e2e}" +: "${POSTGRES_USER:?POSTGRES_USER is missing from the generated E2E runtime environment}" +: "${DATABASE_URL:?DATABASE_URL is missing from the generated E2E runtime environment}" dump_dir=$(CDPATH='' cd -- "$(dirname -- "$dump")" && pwd) dump_name=$(basename -- "$dump") diff --git a/scripts/validate-production-env.sh b/scripts/validate-production-env.sh index 6cd5868..1229cdd 100755 --- a/scripts/validate-production-env.sh +++ b/scripts/validate-production-env.sh @@ -110,6 +110,16 @@ email_from_address=$(require_value EMAIL_FROM_ADDRESS) support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS) google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID) google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET) +web_push_vapid_public_key=$(optional_value WEB_PUSH_VAPID_PUBLIC_KEY) +web_push_vapid_private_key=$(optional_value WEB_PUSH_VAPID_PRIVATE_KEY) +web_push_vapid_subject=$(optional_value WEB_PUSH_VAPID_SUBJECT) +firebase_application_id=$(optional_value WNH_FIREBASE_APPLICATION_ID) +firebase_api_key=$(optional_value WNH_FIREBASE_API_KEY) +firebase_project_id=$(optional_value WNH_FIREBASE_PROJECT_ID) +firebase_sender_id=$(optional_value WNH_FIREBASE_GCM_SENDER_ID) +fcm_project_id=$(optional_value FCM_PROJECT_ID) +fcm_service_account_file=$(optional_value FCM_SERVICE_ACCOUNT_FILE) +fcm_service_account_json_base64=$(optional_value FCM_SERVICE_ACCOUNT_JSON_BASE64) android_app_links_package_name=$(optional_value ANDROID_APP_LINKS_PACKAGE_NAME) android_app_links_fingerprints=$(optional_value ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS) codex_session_id=$(require_value CODEX_SESSION_ID) @@ -318,6 +328,77 @@ if [[ -n "$google_oauth_client_id" || -n "$google_oauth_client_secret" ]]; then reject_marker GOOGLE_OAUTH_CLIENT_SECRET "$google_oauth_client_secret" fi +firebase_values=( + "$firebase_application_id" + "$firebase_api_key" + "$firebase_project_id" + "$firebase_sender_id" +) +firebase_nonempty=0 +for candidate in "${firebase_values[@]}"; do + [[ -z "$candidate" ]] || firebase_nonempty=$((firebase_nonempty + 1)) +done +if ((firebase_nonempty != 0 && firebase_nonempty != ${#firebase_values[@]})); then + echo "All four WNH_FIREBASE_* Android client values must be configured together." >&2 + exit 1 +fi + +vapid_values=( + "$web_push_vapid_public_key" + "$web_push_vapid_private_key" + "$web_push_vapid_subject" +) +vapid_nonempty=0 +for candidate in "${vapid_values[@]}"; do + [[ -z "$candidate" ]] || vapid_nonempty=$((vapid_nonempty + 1)) +done +if ((vapid_nonempty != 0 && vapid_nonempty != ${#vapid_values[@]})); then + echo "All three WEB_PUSH_VAPID_* values must be configured together." >&2 + exit 1 +fi +if ((vapid_nonempty == ${#vapid_values[@]})) && + [[ ! "$web_push_vapid_subject" =~ ^(mailto:|https://) ]]; then + echo "WEB_PUSH_VAPID_SUBJECT must start with mailto: or https://." >&2 + exit 1 +fi + +if [[ -n "$fcm_service_account_file" && -n "$fcm_service_account_json_base64" ]]; then + echo "Set only one FCM service-account credential source." >&2 + exit 1 +fi +if [[ -n "$fcm_project_id" || -n "$fcm_service_account_file" || + -n "$fcm_service_account_json_base64" ]]; then + [[ -n "$fcm_project_id" ]] || { + echo "FCM_PROJECT_ID is required with FCM credentials." >&2 + exit 1 + } + [[ -n "$fcm_service_account_file" || -n "$fcm_service_account_json_base64" ]] || { + echo "One FCM service-account credential source is required with FCM_PROJECT_ID." >&2 + exit 1 + } + + if [[ -n "$fcm_service_account_file" ]]; then + [[ "$fcm_service_account_file" == /* && -r "$fcm_service_account_file" ]] || { + echo "FCM_SERVICE_ACCOUNT_FILE must be an absolute readable file." >&2 + exit 1 + } + else + for command in base64 jq; do + command -v "$command" >/dev/null 2>&1 || { + echo "Required command is unavailable for FCM validation: $command" >&2 + exit 1 + } + done + printf '%s' "$fcm_service_account_json_base64" | + base64 --decode 2>/dev/null | + jq -e '.type == "service_account" and (.project_id | type == "string")' \ + >/dev/null 2>&1 || { + echo "FCM_SERVICE_ACCOUNT_JSON_BASE64 is not a service-account JSON document." >&2 + exit 1 + } + fi +fi + if [[ -n "$android_app_links_package_name" || -n "$android_app_links_fingerprints" ]]; then [[ -n "$android_app_links_package_name" && -n "$android_app_links_fingerprints" ]] || { echo "Android App Links package and fingerprints must either both be set or both be empty." >&2 diff --git a/scripts/validate-test-env.sh b/scripts/validate-test-env.sh index 058d3b5..7e91615 100755 --- a/scripts/validate-test-env.sh +++ b/scripts/validate-test-env.sh @@ -124,6 +124,58 @@ if [[ -n "$google_id" || -n "$google_secret" ]]; then } fi +firebase_values=( + "$(read_value WNH_FIREBASE_APPLICATION_ID 2>/dev/null || true)" + "$(read_value WNH_FIREBASE_API_KEY 2>/dev/null || true)" + "$(read_value WNH_FIREBASE_PROJECT_ID 2>/dev/null || true)" + "$(read_value WNH_FIREBASE_GCM_SENDER_ID 2>/dev/null || true)" +) +firebase_nonempty=0 +for candidate in "${firebase_values[@]}"; do + [[ -z "$candidate" ]] || firebase_nonempty=$((firebase_nonempty + 1)) +done +if ((firebase_nonempty != 0 && firebase_nonempty != ${#firebase_values[@]})); then + echo "All four test WNH_FIREBASE_* Android client values must be configured together." >&2 + exit 1 +fi + +vapid_values=( + "$(read_value WEB_PUSH_VAPID_PUBLIC_KEY 2>/dev/null || true)" + "$(read_value WEB_PUSH_VAPID_PRIVATE_KEY 2>/dev/null || true)" + "$(read_value WEB_PUSH_VAPID_SUBJECT 2>/dev/null || true)" +) +vapid_nonempty=0 +for candidate in "${vapid_values[@]}"; do + [[ -z "$candidate" ]] || vapid_nonempty=$((vapid_nonempty + 1)) +done +if ((vapid_nonempty != 0 && vapid_nonempty != ${#vapid_values[@]})); then + echo "All three test WEB_PUSH_VAPID_* values must be configured together." >&2 + exit 1 +fi +if ((vapid_nonempty == ${#vapid_values[@]})) && + [[ ! "${vapid_values[2]}" =~ ^(mailto:|https://) ]]; then + echo "WEB_PUSH_VAPID_SUBJECT must start with mailto: or https://." >&2 + exit 1 +fi + +fcm_project_id=$(read_value FCM_PROJECT_ID 2>/dev/null || true) +fcm_service_account_file=$(read_value FCM_SERVICE_ACCOUNT_FILE 2>/dev/null || true) +fcm_service_account_json_base64=$( + read_value FCM_SERVICE_ACCOUNT_JSON_BASE64 2>/dev/null || true +) +if [[ -n "$fcm_service_account_file" && -n "$fcm_service_account_json_base64" ]]; then + echo "Set only one test FCM service-account credential source." >&2 + exit 1 +fi +if [[ -n "$fcm_project_id" || -n "$fcm_service_account_file" || + -n "$fcm_service_account_json_base64" ]]; then + [[ -n "$fcm_project_id" && + (-n "$fcm_service_account_file" || -n "$fcm_service_account_json_base64") ]] || { + echo "Test FCM project ID and exactly one credential source are required together." >&2 + exit 1 + } +fi + android_package=$(read_value ANDROID_APP_LINKS_PACKAGE_NAME 2>/dev/null || true) android_fingerprints=$(read_value ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS 2>/dev/null || true) if [[ -n "$android_package" || -n "$android_fingerprints" ]]; then diff --git a/test/who_need_help/mutual_aid_flow_test.exs b/test/who_need_help/mutual_aid_flow_test.exs index e8a51e7..955857e 100644 --- a/test/who_need_help/mutual_aid_flow_test.exs +++ b/test/who_need_help/mutual_aid_flow_test.exs @@ -138,13 +138,16 @@ defmodule WhoNeedHelp.MutualAidFlowTest do assert {:ok, replacement_assignment} = Help.accept_request(replacement_scope, request.id) assert replacement_assignment.id != assignment.id - assert [old_assignment, active_assignment] = - Assignment - |> where([current], current.request_id == ^request.id) - |> order_by([current], asc: current.inserted_at) - |> Repo.all() + assert Repo.aggregate( + from(current in Assignment, where: current.request_id == ^request.id), + :count + ) == 2 + + old_assignment = Repo.get!(Assignment, assignment.id) + active_assignment = Repo.get!(Assignment, replacement_assignment.id) refute old_assignment.active + assert old_assignment.status == :cancelled assert active_assignment.active assert active_assignment.helper_id == replacement.id end