From eb5e0f896dbe944e9c21fe312221a8ab98259630 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Tue, 21 Jul 2026 17:46:34 +0300 Subject: [PATCH] Isolate test and production deployments --- .env.e2e.example | 3 + .env.edge.example | 10 -- .env.example | 17 ++ .env.load.example | 3 + .gitignore | 1 - README.md | 18 +-- compose.edge.yaml | 6 +- compose.yaml | 14 +- config/runtime.exs | 24 +-- config/test.exs | 1 + deploy/caddy/Caddyfile | 4 +- docs/operations.md | 152 +++++++++--------- lib/who_need_help_web/security_headers.ex | 9 +- scripts/compose.sh | 4 +- scripts/edge-up.sh | 2 +- scripts/init-edge-env.sh | 58 ------- scripts/init-production-env.sh | 43 ++++- .../{init-staging-env.sh => init-test-env.sh} | 83 +++++----- scripts/provision-host-postgres.sh | 54 +++---- scripts/quality.sh | 108 +++++++------ scripts/validate-deployment-isolation.sh | 136 ++++++++++++++++ scripts/validate-edge-env.sh | 20 +-- scripts/validate-production-env.sh | 61 ++++++- scripts/validate-test-env.sh | 121 ++++++++++++++ .../controllers/page_controller_test.exs | 4 + 25 files changed, 647 insertions(+), 309 deletions(-) delete mode 100644 .env.edge.example delete mode 100755 scripts/init-edge-env.sh rename scripts/{init-staging-env.sh => init-test-env.sh} (71%) create mode 100755 scripts/validate-deployment-isolation.sh create mode 100755 scripts/validate-test-env.sh diff --git a/.env.e2e.example b/.env.e2e.example index aa9cbee..6b0151e 100644 --- a/.env.e2e.example +++ b/.env.e2e.example @@ -19,6 +19,9 @@ MAP_TILE_URL=https://proxy/__e2e__/map-tile.png?z={z}&x={x}&y={y} DEPLOYMENT_TARGET=compose DEPLOYMENT_ENV=development COMPOSE_PROJECT_NAME=who_need_help_e2e +APP_IMAGE=who-need-help:e2e +SOCKET_PROXY_IMAGE=who-need-help:socket-proxy-e2e +POSTGIS_IMAGE=who-need-help:postgis-e2e APP_TOPOLOGY=split DATABASE_MODE=container diff --git a/.env.edge.example b/.env.edge.example deleted file mode 100644 index 794e1e6..0000000 --- a/.env.edge.example +++ /dev/null @@ -1,10 +0,0 @@ -# Copy to an ignored mode-0600 .env.edge on the public server. -COMPOSE_PROJECT_NAME=who_need_help_edge -PUBLIC_EDGE_NETWORK=who_need_help_public_edge -EDGE_BIND_ADDRESS=0.0.0.0 -EDGE_HTTP_PORT=80 -EDGE_HTTPS_PORT=443 -PRIMARY_DOMAIN=whoneedhelp.com -PRIMARY_UPSTREAM=who-need-help-production:4000 -STAGING_DOMAIN=staging.whoneedhelp.com -STAGING_UPSTREAM=who-need-help-staging:4000 diff --git a/.env.example b/.env.example index daa8a84..1233776 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,11 @@ DEPLOYMENT_TARGET=compose DEPLOYMENT_ENV=development COMPOSE_PROJECT_NAME=who_need_help +# Every independently deployable checkout must use its own image tags. This +# prevents a test build from replacing the image used by production. +APP_IMAGE=who-need-help:local +SOCKET_PROXY_IMAGE=who-need-help:socket-proxy-local +POSTGIS_IMAGE=who-need-help:postgis-local # split runs independently scalable web and worker replicas behind Traefik. # compact runs one combined Phoenix + Oban VM directly on HTTP_PORT. APP_TOPOLOGY=split @@ -27,6 +32,18 @@ HTTP_BIND_ADDRESS=0.0.0.0 PUBLIC_EDGE_ENABLED=false PUBLIC_EDGE_NETWORK=who_need_help_public_edge PUBLIC_UPSTREAM_NAME=who-need-help-local +# The public Caddy edge is managed from the production checkout with the same +# production .env. The test checkout joins PUBLIC_EDGE_NETWORK but never owns +# or restarts Caddy. +EDGE_COMPOSE_PROJECT_NAME=who_need_help_edge +CADDY_IMAGE=who-need-help:caddy-local +EDGE_BIND_ADDRESS=0.0.0.0 +EDGE_HTTP_PORT=80 +EDGE_HTTPS_PORT=443 +PRIMARY_DOMAIN=whoneedhelp.com +PRIMARY_UPSTREAM=who-need-help-production:4000 +TEST_DOMAIN=test.whoneedhelp.com +TEST_UPSTREAM=who-need-help-test:4000 MAILPIT_PORT=8027 MAILPIT_BIND_ADDRESS=127.0.0.1 DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID diff --git a/.env.load.example b/.env.load.example index 2d17180..28e154b 100644 --- a/.env.load.example +++ b/.env.load.example @@ -5,6 +5,9 @@ DEPLOYMENT_TARGET=compose DEPLOYMENT_ENV=development COMPOSE_PROJECT_NAME=who_need_help_load +APP_IMAGE=who-need-help:load +SOCKET_PROXY_IMAGE=who-need-help:socket-proxy-load +POSTGIS_IMAGE=who-need-help:postgis-load APP_TOPOLOGY=split DATABASE_MODE=container diff --git a/.gitignore b/.gitignore index 654bc4c..cedad00 100644 --- a/.gitignore +++ b/.gitignore @@ -51,7 +51,6 @@ __pycache__/ /.env /.env.* !/.env.example -!/.env.edge.example !/.env.load.example !/.env.e2e.example diff --git a/README.md b/README.md index e4bcce3..6ff8fb3 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,10 @@ policies are deliberately not claimed as complete. ## Fast start with Docker Compose The public single-server path uses the compact application topology plus a -separately managed Caddy edge. Production and staging can run as isolated +separately managed Caddy edge. Production and test can run as isolated Compose projects with distinct PostGIS volumes and secrets while sharing only a Docker network used for HTTPS reverse proxying. See the -[operations runbook](docs/operations.md#public-https-edge-and-isolated-staging) +[operations runbook](docs/operations.md#two-independent-checkouts-and-one-env-in-each) for the verified order of operations. Redis is not a project dependency. Prerequisite: Docker with the Compose plugin. @@ -206,7 +206,7 @@ secrets without printing them, writes mode `0600`, and refuses to replace an existing file: ```bash -./scripts/init-production-env.sh whoneedhelp.com .env.production +./scripts/init-production-env.sh whoneedhelp.com ``` Configure the verified reverse-proxy source IP/CIDR and transactional email @@ -222,8 +222,8 @@ database queues continue to work when it is empty. See Then validate the file structure and the production Compose render: ```bash -./scripts/validate-production-env.sh .env.production whoneedhelp.com -./scripts/deploy-up.sh .env.production +./scripts/validate-production-env.sh .env whoneedhelp.com +./scripts/deploy-up.sh .env ``` To generate a split deployment against an already provisioned PostgreSQL 18 + @@ -233,7 +233,7 @@ PostGIS database, supply the mode and URL to the initializer: PRODUCTION_APP_TOPOLOGY=split \ PRODUCTION_DATABASE_MODE=external \ PRODUCTION_DATABASE_URL='ecto://USER:PASSWORD@DB_HOST/DB_NAME?ssl=true' \ - ./scripts/init-production-env.sh whoneedhelp.com .env.production + ./scripts/init-production-env.sh whoneedhelp.com ``` Use `ssl=true` when the database provider requires TLS; the database check @@ -244,7 +244,7 @@ configured from that provider's verified documentation. For PostgreSQL installed on the same Linux host, keep its TCP listener private and connect through its Unix socket. The root-only bootstrap refuses existing project roles/databases, backs up `pg_hba.conf`, adds two exact SCRAM rules, -creates independent production/staging roles and empty databases, preloads +creates independent production/test roles and empty databases, preloads `citext` and PostGIS, verifies both logins, and writes mode-`0600` initializer fragments without printing their passwords: @@ -253,12 +253,12 @@ sudo ./scripts/provision-host-postgres.sh "$USER" set -a . "$HOME/.config/who_need_help/database-production.env" set +a -./scripts/init-production-env.sh whoneedhelp.com .env.production +./scripts/init-production-env.sh whoneedhelp.com unset PRODUCTION_DATABASE_MODE PRODUCTION_DATABASE_URL \ PRODUCTION_DATABASE_SOCKET_DIR ``` -Use `database-staging.env` for the isolated staging environment. Compose mounts +Use `database-test.env` for the isolated test environment. Compose mounts only the configured socket directory read-only; Ecto migrations remain the source of application schema. Inspect the exact host PostgreSQL state and the script's documented impact before the sudo invocation. diff --git a/compose.edge.yaml b/compose.edge.yaml index 357aa80..f36f690 100644 --- a/compose.edge.yaml +++ b/compose.edge.yaml @@ -1,4 +1,4 @@ -name: who_need_help_edge +name: ${EDGE_COMPOSE_PROJECT_NAME:?Set EDGE_COMPOSE_PROJECT_NAME in .env} services: edge: @@ -10,8 +10,8 @@ services: environment: PRIMARY_DOMAIN: ${PRIMARY_DOMAIN:?Set PRIMARY_DOMAIN in the edge environment} PRIMARY_UPSTREAM: ${PRIMARY_UPSTREAM:?Set PRIMARY_UPSTREAM in the edge environment} - STAGING_DOMAIN: ${STAGING_DOMAIN:?Set STAGING_DOMAIN in the edge environment} - STAGING_UPSTREAM: ${STAGING_UPSTREAM:?Set STAGING_UPSTREAM in the edge environment} + TEST_DOMAIN: ${TEST_DOMAIN:?Set TEST_DOMAIN in .env} + TEST_UPSTREAM: ${TEST_UPSTREAM:?Set TEST_UPSTREAM in .env} ports: - "${EDGE_BIND_ADDRESS:-0.0.0.0}:${EDGE_HTTP_PORT:-80}:80" - "${EDGE_BIND_ADDRESS:-0.0.0.0}:${EDGE_HTTPS_PORT:-443}:443" diff --git a/compose.yaml b/compose.yaml index 8dcd926..9ac50f3 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,5 +1,3 @@ -name: who_need_help - x-app-environment: &app-environment APP_ROLE: web DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env} @@ -61,7 +59,7 @@ x-app-environment: &app-environment services: docker-api-proxy: - image: who-need-help:socket-proxy-local + image: ${SOCKET_PROXY_IMAGE:?Set SOCKET_PROXY_IMAGE in .env} build: context: . dockerfile: Dockerfile.socket-proxy @@ -113,7 +111,7 @@ services: restart: unless-stopped db: - image: who-need-help:postgis-local + image: ${POSTGIS_IMAGE:?Set POSTGIS_IMAGE in .env} build: context: . dockerfile: Dockerfile.postgis @@ -156,7 +154,7 @@ services: restart: unless-stopped migrate: - image: who-need-help:local + image: ${APP_IMAGE:?Set APP_IMAGE in .env} build: context: . environment: @@ -185,7 +183,7 @@ services: app: profiles: [compact] - image: who-need-help:local + image: ${APP_IMAGE:?Set APP_IMAGE in .env} environment: <<: *app-environment APP_ROLE: combined @@ -219,7 +217,7 @@ services: restart: unless-stopped web: - image: who-need-help:local + image: ${APP_IMAGE:?Set APP_IMAGE in .env} environment: <<: *app-environment APP_ROLE: web @@ -264,7 +262,7 @@ services: restart: unless-stopped worker: - image: who-need-help:local + image: ${APP_IMAGE:?Set APP_IMAGE in .env} environment: <<: *app-environment APP_ROLE: worker diff --git a/config/runtime.exs b/config/runtime.exs index 46deac0..68c10ad 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -189,7 +189,7 @@ github_oauth = config :who_need_help, :social_oauth, github_oauth -google_auth = +{google_auth, google_oauth_base_url} = case { System.get_env("GOOGLE_OAUTH_CLIENT_ID"), System.get_env("GOOGLE_OAUTH_CLIENT_SECRET") @@ -197,16 +197,21 @@ google_auth = {client_id, client_secret} when is_binary(client_id) and client_id != "" and is_binary(client_secret) and client_secret != "" -> - [ - client_id: client_id, - client_secret: client_secret, - base_url: oauth_endpoint.("GOOGLE_OAUTH_BASE_URL", "https://accounts.google.com/"), - authorization_params: [scope: "email profile"], - http_adapter: {Assent.HTTPAdapter.Req, oauth_http_options.("GOOGLE")} - ] + base_url = oauth_endpoint.("GOOGLE_OAUTH_BASE_URL", "https://accounts.google.com/") + + { + [ + client_id: client_id, + client_secret: client_secret, + base_url: base_url, + authorization_params: [scope: "email profile"], + http_adapter: {Assent.HTTPAdapter.Req, oauth_http_options.("GOOGLE")} + ], + base_url + } {client_id, client_secret} when client_id in [nil, ""] and client_secret in [nil, ""] -> - [] + {[], Application.get_env(:who_need_help, :google_oauth_base_url)} _partial_configuration -> raise """ @@ -215,6 +220,7 @@ google_auth = end config :who_need_help, :google_auth, google_auth +config :who_need_help, :google_oauth_base_url, google_oauth_base_url push_configuration = case { diff --git a/config/test.exs b/config/test.exs index 8046bd8..6b0fa68 100644 --- a/config/test.exs +++ b/config/test.exs @@ -30,6 +30,7 @@ config :who_need_help, WhoNeedHelpWeb.Endpoint, config :who_need_help, WhoNeedHelp.Mailer, adapter: Swoosh.Adapters.Test config :who_need_help, :social_oauth_adapter, WhoNeedHelp.SocialOAuthFake config :who_need_help, :google_auth_adapter, WhoNeedHelp.GoogleAuthFake +config :who_need_help, :google_oauth_base_url, "https://accounts.google.example/" config :who_need_help, :metrics_token, "test-metrics-token" # Disable swoosh api client as it is only required for production adapters diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 22084e0..d87de8b 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -16,12 +16,12 @@ www.{$PRIMARY_DOMAIN} { redir https://{$PRIMARY_DOMAIN}{uri} permanent } -{$STAGING_DOMAIN} { +{$TEST_DOMAIN} { encode zstd gzip header { -Server } - reverse_proxy {$STAGING_UPSTREAM} + reverse_proxy {$TEST_UPSTREAM} } diff --git a/docs/operations.md b/docs/operations.md index 94f1028..e319bf8 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -48,75 +48,79 @@ The deployment environment selects topology and database ownership: There is no Redis dependency. Queues, rate-limit counters, Oban leadership, and durable application state use PostgreSQL. -### Public HTTPS edge and isolated staging +### Two independent checkouts and one `.env` in each -The first single-server deployment uses one separately managed Caddy container -for ports 80/443 and two independent application projects. Caddy 2.11.4 is -built as a static non-root binary with pinned Go 1.26.5 and a scratch runtime. -Its persistent volume holds ACME account and -certificate state; do not remove that volume during an ordinary application -deploy. Production and staging each have their own application secret file, -PostGIS volume, migrations, and Docker DNS alias. They share only the named -`who_need_help_public_edge` network, so load and browser records created in -staging cannot enter the production database. +The server uses exactly these independent Git clones: -Generate and start the edge before either application project: +```text +/srv/who_need_help-test/.env +/srv/who_need_help-production/.env +``` + +No `.env.test`, `.env.staging`, `.env.production`, or `.env.edge` is used. +Each checkout can remain on a different commit. Test and production have +different Compose projects, application/infrastructure image tags, database +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. + +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 +`who_need_help_public_edge` Docker network. Caddy routes `whoneedhelp.com` to +`who-need-help-production:4000` and `test.whoneedhelp.com` to +`who-need-help-test:4000`. + +Create the test configuration inside the test checkout: ```bash -./scripts/init-edge-env.sh \ - whoneedhelp.com staging.whoneedhelp.com .env.edge -./scripts/edge-up.sh .env.edge +cd /srv/who_need_help-test +TEST_CODEX_SESSION_ID=YOUR_MAIN_CODEX_SESSION_ID \ +TEST_GOOGLE_OAUTH_CLIENT_ID=YOUR_TEST_CLIENT_ID \ +TEST_GOOGLE_OAUTH_CLIENT_SECRET=YOUR_TEST_CLIENT_SECRET \ + ./scripts/init-test-env.sh test.whoneedhelp.com +./scripts/validate-test-env.sh .env test.whoneedhelp.com +./scripts/deploy-up.sh .env ``` +Test always uses its own `who_need_help_test` PostGIS container/volume and +Mailpit. Its messages cannot be sent by the production UniSender account. + +Create the production configuration inside the production checkout after +loading only the production database/provider credentials into the process: + +```bash +cd /srv/who_need_help-production +PRODUCTION_DATABASE_MODE=external \ +PRODUCTION_DATABASE_URL='ecto://PRODUCTION_ROLE:PASSWORD@localhost/who_need_help_production' \ +PRODUCTION_DATABASE_SOCKET_DIR=/var/run/postgresql \ +PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \ +PRODUCTION_UNISENDER_GO_API_KEY=YOUR_PRODUCTION_KEY \ +PRODUCTION_GOOGLE_OAUTH_CLIENT_ID=YOUR_PRODUCTION_CLIENT_ID \ +PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET=YOUR_PRODUCTION_CLIENT_SECRET \ +PRODUCTION_CODEX_SESSION_ID=YOUR_MAIN_CODEX_SESSION_ID \ + ./scripts/init-production-env.sh whoneedhelp.com +./scripts/validate-production-env.sh .env whoneedhelp.com +``` + +Before either public switch, prove cross-environment isolation without printing +credentials: + +```bash +/srv/who_need_help-test/scripts/validate-deployment-isolation.sh \ + /srv/who_need_help-test /srv/who_need_help-production +``` + +Start/update test first and run the complete browser/API/Android verification. +Only then check out that exact tested SHA in production. Start the shared edge +from the production checkout with `./scripts/edge-up.sh .env`; start the +production application separately with `./scripts/deploy-up.sh .env`. + The authoritative A records for `whoneedhelp.com`, `www.whoneedhelp.com`, and -`staging.whoneedhelp.com` must point to the observed server address, and inbound -TCP 80/443 plus UDP 443 must be permitted before public certificate issuance. -Caddy obtains and renews public certificates and redirects HTTP to HTTPS. The -Phoenix release remains plain HTTP on the internal shared Docker network; -Caddy replaces incoming forwarding headers and WebSocket proxying is automatic. -The compact app's optional host-published HTTP port stays bound to loopback for -operator health checks and is not a public entry point. - -Create staging with independent names and secrets: - -```bash -PRODUCTION_COMPOSE_PROJECT_NAME=who_need_help_staging \ -PRODUCTION_PUBLIC_UPSTREAM_NAME=who-need-help-staging \ -PRODUCTION_HTTP_PORT=4011 \ -PRODUCTION_CODEX_SESSION_ID=YOUR_MAIN_CODEX_SESSION_ID \ -./scripts/init-production-env.sh \ - staging.whoneedhelp.com .env.staging -``` - -Create production separately: - -```bash -PRODUCTION_COMPOSE_PROJECT_NAME=who_need_help_production \ -PRODUCTION_PUBLIC_UPSTREAM_NAME=who-need-help-production \ -PRODUCTION_HTTP_PORT=4010 \ -PRODUCTION_CODEX_SESSION_ID=YOUR_MAIN_CODEX_SESSION_ID \ -./scripts/init-production-env.sh \ - whoneedhelp.com .env.production -``` - -Configure and validate email delivery/OAuth independently in each ignored file. Start -staging first, run database/load/browser/Android verification there, and then -start the clean production project. Stopping staging does not stop Caddy or -production: - -```bash -./scripts/compose.sh .env.staging down -``` - -Do not add `--volumes` unless the exact staging database has been inspected and -its deletion is the intended operation. - -Run the initializer on the target Docker host after its final public hostname -is known: - -```bash -./scripts/init-production-env.sh whoneedhelp.com .env.production -``` +`test.whoneedhelp.com` must point to the verified server address before Caddy +can obtain their certificates. Do not remove the edge volumes during an +ordinary application deploy. Do not add `--volumes` to a stop command unless +the exact database has been inspected and deletion is intended. The initializer reads `.env.example`, derives the numeric group of that host's Docker socket, generates independent random values for PostgreSQL, @@ -148,8 +152,8 @@ enabled, also set both Google Web client credentials and register URI. Leave both credentials empty to keep the feature disabled. Then run: ```bash -./scripts/validate-production-env.sh .env.production whoneedhelp.com -./scripts/deploy-up.sh .env.production +./scripts/validate-production-env.sh .env whoneedhelp.com +./scripts/deploy-up.sh .env ``` The production override keeps Mailpit behind its inactive `local-mail` profile, @@ -189,10 +193,10 @@ Its exact mutation scope is: - prepend two database-and-role-specific `local ... scram-sha-256` rules to the active `pg_hba.conf`, retaining a mode-`0600` copy under `/var/backups/who_need_help/`, and reload that cluster; -- create login roles `wnh_production` and `wnh_staging` without superuser, +- create login roles `wnh_production` and `wnh_test` without superuser, database-creation, role-creation, replication, or row-security bypass rights; - create empty owner databases `who_need_help_production` and - `who_need_help_staging`, revoke public connect, and preload `citext` and + `who_need_help_test`, revoke public connect, and preload `citext` and PostGIS so the non-superuser Ecto migrations can run; - verify both credentials through the observed Unix socket and write separate mode-`0600` initializer fragments under @@ -204,20 +208,20 @@ rules before reload, and restores its HBA backup plus removes only objects it created if provisioning fails. It does not inspect or migrate application tables; the release migration runner remains authoritative for schema. -Generate staging or production after loading only the matching trusted +Generate test or production after loading only the matching trusted fragment: ```bash set -a . "$HOME/.config/who_need_help/database-production.env" set +a -./scripts/init-production-env.sh whoneedhelp.com .env.production +./scripts/init-production-env.sh whoneedhelp.com unset PRODUCTION_DATABASE_MODE PRODUCTION_DATABASE_URL \ PRODUCTION_DATABASE_SOCKET_DIR ``` -For staging, load `database-staging.env` and pass the staging domain, project, -upstream alias, and HTTP port described above. Do not load both fragments into +For test external-database drills, load `database-test.env`; the public test +deployment normally uses its own Compose database. Do not load both fragments into one shell. Provision the database and role first, then generate the environment without @@ -227,7 +231,7 @@ placing its credentials on a command line that is retained in shell history: export PRODUCTION_DATABASE_MODE=external read -rsp 'External DATABASE_URL: ' PRODUCTION_DATABASE_URL && echo export PRODUCTION_DATABASE_URL -./scripts/init-production-env.sh whoneedhelp.com .env.production +./scripts/init-production-env.sh whoneedhelp.com unset PRODUCTION_DATABASE_URL ``` @@ -238,10 +242,10 @@ or application startup unless PostgreSQL and `PostGIS_Version()` are reachable. The check also reports the observed TLS flag. The connection URL must follow the database provider's verified TLS/CA requirements. -`scripts/compose.sh .env.production config --services` is the canonical +`scripts/compose.sh .env config --services` is the canonical read-only render check. In external mode its output must not contain `db`. -Use `scripts/compose.sh .env.production ps -a` and -`scripts/compose.sh .env.production logs web worker` for the selected mode. +Use `scripts/compose.sh .env ps -a` and +`scripts/compose.sh .env logs web worker` for the selected mode. The repository's Compose backup, restore, rotation, and rollback-based staging scripts deliberately refuse `DATABASE_MODE=external`; they are scoped to the diff --git a/lib/who_need_help_web/security_headers.ex b/lib/who_need_help_web/security_headers.ex index 41b997f..8ff28fc 100644 --- a/lib/who_need_help_web/security_headers.ex +++ b/lib/who_need_help_web/security_headers.ex @@ -19,6 +19,13 @@ defmodule WhoNeedHelpWeb.SecurityHeaders do connect_sources = sources(["'self'", tile_origin, websocket_origin]) image_sources = sources(["'self'", "data:", "blob:", tile_origin]) + google_oauth_origin = + :who_need_help + |> Application.get_env(:google_oauth_base_url) + |> origin() + + form_sources = sources(["'self'", google_oauth_origin]) + policy = [ "default-src 'self'", @@ -31,7 +38,7 @@ defmodule WhoNeedHelpWeb.SecurityHeaders do "connect-src #{connect_sources}", "worker-src 'self' blob:", "manifest-src 'self'", - "form-action 'self'" + "form-action #{form_sources}" ] |> Enum.join("; ") diff --git a/scripts/compose.sh b/scripts/compose.sh index 3efed3d..2b3a478 100755 --- a/scripts/compose.sh +++ b/scripts/compose.sh @@ -45,8 +45,8 @@ project=$(read_env_value COMPOSE_PROJECT_NAME 2>/dev/null || printf who_need_hel } case "$deployment_environment" in - development | production) ;; - *) echo "DEPLOYMENT_ENV must be development or production." >&2; exit 1 ;; + development | test | production) ;; + *) echo "DEPLOYMENT_ENV must be development, test, or production." >&2; exit 1 ;; esac case "$database_mode" in diff --git a/scripts/edge-up.sh b/scripts/edge-up.sh index 0aa9f01..e7089f4 100755 --- a/scripts/edge-up.sh +++ b/scripts/edge-up.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -env_file=${1:-"$ROOT/.env.edge"} +env_file=${1:-"$ROOT/.env"} if [[ "$env_file" != /* ]]; then env_file="$ROOT/$env_file" diff --git a/scripts/init-edge-env.sh b/scripts/init-edge-env.sh deleted file mode 100755 index c386e93..0000000 --- a/scripts/init-edge-env.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -umask 077 - -ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -primary_domain=${1:-} -staging_domain=${2:-} -target=${3:-"$ROOT/.env.edge"} - -if [[ -z "$primary_domain" || -z "$staging_domain" ]]; then - echo "Usage: $0 PRIMARY_DOMAIN STAGING_DOMAIN [OUTPUT_FILE]" >&2 - exit 1 -fi - -if [[ "$target" != /* ]]; then - target="$ROOT/$target" -fi - -if [[ -e "$target" ]]; then - echo "Refusing to overwrite existing edge environment: $target" >&2 - exit 1 -fi - -target_dir=$(dirname -- "$target") -if [[ ! -d "$target_dir" ]]; then - echo "Output directory does not exist: $target_dir" >&2 - exit 1 -fi - -tmp=$(mktemp "$target_dir/.edge-env.XXXXXX") -trap 'rm -f "$tmp"' EXIT HUP INT TERM -chmod 600 "$tmp" - -PRIMARY_DOMAIN_VALUE=$primary_domain \ -STAGING_DOMAIN_VALUE=$staging_domain \ - awk ' - BEGIN { - replacement["PRIMARY_DOMAIN"] = ENVIRON["PRIMARY_DOMAIN_VALUE"] - replacement["STAGING_DOMAIN"] = ENVIRON["STAGING_DOMAIN_VALUE"] - } - { - separator = index($0, "=") - key = separator > 1 ? substr($0, 1, separator - 1) : "" - - if (key in replacement) { - print key "=" replacement[key] - } else { - print - } - } - ' "$ROOT/.env.edge.example" >"$tmp" - -mv "$tmp" "$target" -chmod 600 "$target" -trap - EXIT HUP INT TERM - -"$ROOT/scripts/validate-edge-env.sh" "$target" -echo "Created mode-0600 edge environment: $target" diff --git a/scripts/init-production-env.sh b/scripts/init-production-env.sh index 27a0297..a903711 100755 --- a/scripts/init-production-env.sh +++ b/scripts/init-production-env.sh @@ -4,7 +4,7 @@ umask 077 ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) domain=${1:-} -target=${2:-"$ROOT/.env.production"} +target=${2:-"$ROOT/.env"} usage() { echo "Usage: $0 DOMAIN [OUTPUT_FILE]" >&2 @@ -21,7 +21,7 @@ if ! printf '%s\n' "$domain" | exit 1 fi -for command in awk docker grep mktemp openssl stat; do +for command in awk docker git grep mktemp openssl stat; do if ! command -v "$command" >/dev/null 2>&1; then echo "Required command is unavailable: $command" >&2 exit 1 @@ -46,17 +46,29 @@ fi database_mode=${PRODUCTION_DATABASE_MODE:-container} app_topology=${PRODUCTION_APP_TOPOLOGY:-compact} -compose_project_name=${PRODUCTION_COMPOSE_PROJECT_NAME:-who_need_help} +compose_project_name=${PRODUCTION_COMPOSE_PROJECT_NAME:-who_need_help_production} public_edge_enabled=${PRODUCTION_PUBLIC_EDGE_ENABLED:-true} public_edge_network=${PRODUCTION_PUBLIC_EDGE_NETWORK:-who_need_help_public_edge} 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:-} +test_domain=${PRODUCTION_TEST_DOMAIN:-"test.$domain"} +test_upstream=${PRODUCTION_TEST_UPSTREAM:-who-need-help-test:4000} +edge_compose_project_name=${PRODUCTION_EDGE_COMPOSE_PROJECT_NAME:-who_need_help_edge} +git_sha=$(git -C "$ROOT" rev-parse --short=12 HEAD) if [ -z "$codex_session_id" ]; then echo "PRODUCTION_CODEX_SESSION_ID is required for the Build Week feedback page." >&2 exit 1 fi +if { [ -n "$google_oauth_client_id" ] || [ -n "$google_oauth_client_secret" ]; } && + { [ -z "$google_oauth_client_id" ] || [ -z "$google_oauth_client_secret" ]; }; then + echo "Production Google OAuth client ID and secret must either both be set or both be empty." >&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 @@ -67,7 +79,7 @@ esac case "$database_mode" in container) postgres_password=$(openssl rand -hex 32) - database_url="ecto://postgres:$postgres_password@db/who_need_help" + database_url="ecto://postgres:$postgres_password@db/who_need_help_production" ;; external) postgres_password= @@ -188,11 +200,21 @@ EMAIL_HTTP_RECEIVE_TIMEOUT_MS_VALUE=$email_http_receive_timeout_ms \ EMAIL_FROM_ADDRESS_VALUE=$email_from_address \ SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \ 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 \ +EDGE_COMPOSE_PROJECT_NAME_VALUE=$edge_compose_project_name \ +PRIMARY_UPSTREAM_VALUE="$public_upstream_name:4000" \ +TEST_DOMAIN_VALUE=$test_domain \ +TEST_UPSTREAM_VALUE=$test_upstream \ awk ' BEGIN { replacement["DEPLOYMENT_TARGET"] = "compose" replacement["DEPLOYMENT_ENV"] = "production" replacement["COMPOSE_PROJECT_NAME"] = ENVIRON["COMPOSE_PROJECT_NAME_VALUE"] + replacement["APP_IMAGE"] = "who-need-help:production-" ENVIRON["GIT_SHA_VALUE"] + replacement["SOCKET_PROXY_IMAGE"] = "who-need-help:socket-proxy-production-" ENVIRON["GIT_SHA_VALUE"] + replacement["POSTGIS_IMAGE"] = "who-need-help:postgis-production-" ENVIRON["GIT_SHA_VALUE"] replacement["APP_TOPOLOGY"] = ENVIRON["APP_TOPOLOGY_VALUE"] replacement["DATABASE_MODE"] = ENVIRON["DATABASE_MODE_VALUE"] replacement["HTTP_BIND_ADDRESS"] = ENVIRON["HTTP_BIND_ADDRESS_VALUE"] @@ -200,6 +222,12 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ replacement["PUBLIC_EDGE_ENABLED"] = ENVIRON["PUBLIC_EDGE_ENABLED_VALUE"] replacement["PUBLIC_EDGE_NETWORK"] = ENVIRON["PUBLIC_EDGE_NETWORK_VALUE"] replacement["PUBLIC_UPSTREAM_NAME"] = ENVIRON["PUBLIC_UPSTREAM_NAME_VALUE"] + replacement["EDGE_COMPOSE_PROJECT_NAME"] = ENVIRON["EDGE_COMPOSE_PROJECT_NAME_VALUE"] + replacement["CADDY_IMAGE"] = "who-need-help:caddy-production-" ENVIRON["GIT_SHA_VALUE"] + replacement["PRIMARY_DOMAIN"] = ENVIRON["DOMAIN"] + replacement["PRIMARY_UPSTREAM"] = ENVIRON["PRIMARY_UPSTREAM_VALUE"] + replacement["TEST_DOMAIN"] = ENVIRON["TEST_DOMAIN_VALUE"] + replacement["TEST_UPSTREAM"] = ENVIRON["TEST_UPSTREAM_VALUE"] replacement["DOCKER_SOCKET_GID"] = ENVIRON["DOCKER_SOCKET_GID_VALUE"] replacement["TRAEFIK_TRUSTED_IPS"] = ENVIRON["TRUSTED_PROXY_IPS_VALUE"] replacement["TRAEFIK_PROJECT_CONSTRAINT"] = ENVIRON["COMPOSE_PROJECT_NAME_VALUE"] @@ -209,6 +237,8 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ replacement["PHX_URL_PORT"] = "443" replacement["WNH_DEBUG_BASE_URL"] = "https://" ENVIRON["DOMAIN"] replacement["WNH_BASE_URL"] = "https://" ENVIRON["DOMAIN"] + replacement["POSTGRES_DB"] = "who_need_help_production" + replacement["POSTGRES_USER"] = "postgres" replacement["POSTGRES_PASSWORD"] = ENVIRON["POSTGRES_PASSWORD_VALUE"] replacement["DATABASE_URL"] = ENVIRON["DATABASE_URL_VALUE"] replacement["DATABASE_SOCKET_DIR"] = ENVIRON["DATABASE_SOCKET_DIR_VALUE"] @@ -231,6 +261,8 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ replacement["EMAIL_HTTP_RECEIVE_TIMEOUT_MS"] = ENVIRON["EMAIL_HTTP_RECEIVE_TIMEOUT_MS_VALUE"] replacement["EMAIL_FROM_ADDRESS"] = ENVIRON["EMAIL_FROM_ADDRESS_VALUE"] 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["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"] } { @@ -251,7 +283,8 @@ trap - EXIT HUP INT TERM unset postgres_password secret_key_base handover_secret release_cookie metrics_token unset smtp_password unisender_go_api_key +unset google_oauth_client_secret echo "Generated independent deployment secrets without printing them." -echo "Created mode-0600 environment: $target" +echo "Created the single mode-0600 production configuration: $target" echo "Run scripts/validate-production-env.sh '$target' '$domain' after configuring the verified proxy and email-provider values." diff --git a/scripts/init-staging-env.sh b/scripts/init-test-env.sh similarity index 71% rename from scripts/init-staging-env.sh rename to scripts/init-test-env.sh index 85a0c6c..317b4cb 100755 --- a/scripts/init-staging-env.sh +++ b/scripts/init-test-env.sh @@ -4,7 +4,7 @@ umask 077 ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) domain=${1:-} -target=${2:-"$ROOT/.env.staging"} +target=${2:-"$ROOT/.env"} if [[ -z "$domain" ]]; then echo "Usage: $0 DOMAIN [OUTPUT_FILE]" >&2 @@ -20,60 +20,61 @@ if [[ "$target" != /* ]]; then target="$ROOT/$target" fi -for command in awk docker mktemp openssl stat; do - if ! command -v "$command" >/dev/null 2>&1; then +for command in awk docker git mktemp openssl stat; do + command -v "$command" >/dev/null 2>&1 || { echo "Required command is unavailable: $command" >&2 exit 1 - fi + } done -if [[ ! -S /var/run/docker.sock ]]; then +[[ -S /var/run/docker.sock ]] || { echo "/var/run/docker.sock is unavailable; run this on the target Docker host." >&2 exit 1 -fi +} -if [[ -e "$target" ]]; then - echo "Refusing to overwrite existing staging environment: $target" >&2 +[[ ! -e "$target" ]] || { + echo "Refusing to overwrite existing test environment: $target" >&2 exit 1 -fi +} target_dir=$(dirname -- "$target") -if [[ ! -d "$target_dir" ]]; then +[[ -d "$target_dir" ]] || { echo "Output directory does not exist: $target_dir" >&2 exit 1 -fi +} -compose_project_name=${STAGING_COMPOSE_PROJECT_NAME:-who_need_help_staging} -public_edge_network=${STAGING_PUBLIC_EDGE_NETWORK:-who_need_help_public_edge} -public_upstream_name=${STAGING_PUBLIC_UPSTREAM_NAME:-who-need-help-staging} -http_bind_address=${STAGING_HTTP_BIND_ADDRESS:-127.0.0.1} -http_port=${STAGING_HTTP_PORT:-4011} -mailpit_bind_address=${STAGING_MAILPIT_BIND_ADDRESS:-127.0.0.1} -mailpit_port=${STAGING_MAILPIT_PORT:-8027} -codex_session_id=${STAGING_CODEX_SESSION_ID:-} -google_oauth_client_id=${STAGING_GOOGLE_OAUTH_CLIENT_ID:-} -google_oauth_client_secret=${STAGING_GOOGLE_OAUTH_CLIENT_SECRET:-} -support_inbox_address=${STAGING_SUPPORT_INBOX_ADDRESS:-} +compose_project_name=${TEST_COMPOSE_PROJECT_NAME:-who_need_help_test} +public_edge_network=${TEST_PUBLIC_EDGE_NETWORK:-who_need_help_public_edge} +public_upstream_name=${TEST_PUBLIC_UPSTREAM_NAME:-who-need-help-test} +http_bind_address=${TEST_HTTP_BIND_ADDRESS:-127.0.0.1} +http_port=${TEST_HTTP_PORT:-4011} +mailpit_bind_address=${TEST_MAILPIT_BIND_ADDRESS:-127.0.0.1} +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:-} +support_inbox_address=${TEST_SUPPORT_INBOX_ADDRESS:-} +git_sha=$(git -C "$ROOT" rev-parse --short=12 HEAD) [[ "$compose_project_name" =~ ^[a-zA-Z0-9_-]+$ ]] || { - echo "STAGING_COMPOSE_PROJECT_NAME contains unsupported characters." >&2 + echo "TEST_COMPOSE_PROJECT_NAME contains unsupported characters." >&2 exit 1 } [[ "$public_edge_network" =~ ^[a-zA-Z0-9_-]+$ ]] || { - echo "STAGING_PUBLIC_EDGE_NETWORK contains unsupported characters." >&2 + echo "TEST_PUBLIC_EDGE_NETWORK contains unsupported characters." >&2 exit 1 } [[ "$public_upstream_name" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || { - echo "STAGING_PUBLIC_UPSTREAM_NAME must be a lowercase Docker DNS alias." >&2 + echo "TEST_PUBLIC_UPSTREAM_NAME must be a lowercase Docker DNS alias." >&2 exit 1 } [[ -n "$codex_session_id" ]] || { - echo "STAGING_CODEX_SESSION_ID is required for the Build Week feedback page." >&2 + echo "TEST_CODEX_SESSION_ID is required for the Build Week feedback page." >&2 exit 1 } if [[ -n "$google_oauth_client_id" || -n "$google_oauth_client_secret" ]]; then [[ -n "$google_oauth_client_id" && -n "$google_oauth_client_secret" ]] || { - echo "Staging Google OAuth client ID and secret must either both be set or both be empty." >&2 + echo "Test Google OAuth client ID and secret must either both be set or both be empty." >&2 exit 1 } fi @@ -86,7 +87,7 @@ for pair in "HTTP:$http_port" "MAILPIT:$mailpit_port"; do fi done [[ "$http_port" != "$mailpit_port" ]] || { - echo "Staging HTTP and Mailpit ports must be different." >&2 + echo "Test HTTP and Mailpit ports must be different." >&2 exit 1 } @@ -96,14 +97,15 @@ handover_secret=$(openssl rand -hex 64) release_cookie=$(openssl rand -hex 64) metrics_token=$(openssl rand -hex 32) docker_socket_gid=$(stat -c '%g' /var/run/docker.sock) -postgres_db=who_need_help_staging_e2e +postgres_db=who_need_help_test database_url="ecto://postgres:$postgres_password@db/$postgres_db" -tmp=$(mktemp "$target_dir/.staging-env.XXXXXX") +tmp=$(mktemp "$target_dir/.test-env.XXXXXX") trap 'rm -f "$tmp"' EXIT HUP INT TERM chmod 600 "$tmp" DOMAIN=$domain \ +GIT_SHA_VALUE=$git_sha \ COMPOSE_PROJECT_NAME_VALUE=$compose_project_name \ PUBLIC_EDGE_NETWORK_VALUE=$public_edge_network \ PUBLIC_UPSTREAM_NAME_VALUE=$public_upstream_name \ @@ -126,8 +128,11 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ awk ' BEGIN { replacement["DEPLOYMENT_TARGET"] = "compose" - replacement["DEPLOYMENT_ENV"] = "development" + replacement["DEPLOYMENT_ENV"] = "test" replacement["COMPOSE_PROJECT_NAME"] = ENVIRON["COMPOSE_PROJECT_NAME_VALUE"] + replacement["APP_IMAGE"] = "who-need-help:test-" ENVIRON["GIT_SHA_VALUE"] + replacement["SOCKET_PROXY_IMAGE"] = "who-need-help:socket-proxy-test-" ENVIRON["GIT_SHA_VALUE"] + replacement["POSTGIS_IMAGE"] = "who-need-help:postgis-test-" ENVIRON["GIT_SHA_VALUE"] replacement["APP_TOPOLOGY"] = "compact" replacement["DATABASE_MODE"] = "container" replacement["HTTP_BIND_ADDRESS"] = ENVIRON["HTTP_BIND_ADDRESS_VALUE"] @@ -140,7 +145,7 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ replacement["DOCKER_SOCKET_GID"] = ENVIRON["DOCKER_SOCKET_GID_VALUE"] replacement["TRAEFIK_TRUSTED_IPS"] = "127.0.0.1/32" replacement["TRAEFIK_PROJECT_CONSTRAINT"] = ENVIRON["COMPOSE_PROJECT_NAME_VALUE"] - replacement["TRAEFIK_APP_NAME"] = "who-need-help-staging" + replacement["TRAEFIK_APP_NAME"] = "who-need-help-test" replacement["TRAEFIK_DOCKER_NETWORK"] = ENVIRON["COMPOSE_PROJECT_NAME_VALUE"] "_ingress" replacement["PHX_HOST"] = ENVIRON["DOMAIN"] replacement["PHX_SCHEME"] = "https" @@ -164,8 +169,8 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ replacement["SMTP_AUTH"] = "never" replacement["SMTP_TLS"] = "never" replacement["SMTP_SSL"] = "false" - replacement["EMAIL_FROM_NAME"] = "\"Who Need Help Staging\"" - replacement["EMAIL_FROM_ADDRESS"] = "staging@" ENVIRON["DOMAIN"] + replacement["EMAIL_FROM_NAME"] = "\"Who Need Help Test\"" + replacement["EMAIL_FROM_ADDRESS"] = "test@" ENVIRON["DOMAIN"] 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"] @@ -174,11 +179,7 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ { separator = index($0, "=") key = separator > 1 ? substr($0, 1, separator - 1) : "" - if (key in replacement) { - print key "=" replacement[key] - } else { - print - } + print (key in replacement) ? key "=" replacement[key] : $0 } ' "$ROOT/.env.example" >"$tmp" @@ -190,5 +191,5 @@ unset postgres_password secret_key_base handover_secret release_cookie metrics_t unset google_oauth_client_secret "$ROOT/scripts/compose.sh" "$target" config --quiet -echo "Generated independent staging secrets without printing them." -echo "Created mode-0600 staging environment: $target" +echo "Generated independent test secrets without printing them." +echo "Created the single mode-0600 test configuration: $target" diff --git a/scripts/provision-host-postgres.sh b/scripts/provision-host-postgres.sh index 5167d2a..5e2bc0a 100755 --- a/scripts/provision-host-postgres.sh +++ b/scripts/provision-host-postgres.sh @@ -26,8 +26,8 @@ socket_dir=${POSTGRES_SOCKET_DIR:-/var/run/postgresql} production_role=wnh_production production_database=who_need_help_production -staging_role=wnh_staging -staging_database=who_need_help_staging +test_role=wnh_test +test_database=who_need_help_test hba_marker="# BEGIN Who Need Help managed local socket authentication" for command in awk cat chgrp chmod chown cut date getent grep id install mktemp \ @@ -96,10 +96,10 @@ fi existing_objects=$( "${postgres_psql[@]}" --dbname postgres --command " SELECT 'role:' || rolname FROM pg_roles - WHERE rolname IN ('$production_role', '$staging_role') + WHERE rolname IN ('$production_role', '$test_role') UNION ALL SELECT 'database:' || datname FROM pg_database - WHERE datname IN ('$production_database', '$staging_database') + WHERE datname IN ('$production_database', '$test_database') ORDER BY 1; " ) @@ -113,15 +113,15 @@ if [[ -n "$existing_objects" ]]; then fi production_fragment="$output_dir/database-production.env" -staging_fragment="$output_dir/database-staging.env" -if [[ -e "$production_fragment" || -e "$staging_fragment" ]]; then +test_fragment="$output_dir/database-test.env" +if [[ -e "$production_fragment" || -e "$test_fragment" ]]; then echo "Database credential fragments already exist; refusing to overwrite them." >&2 exit 1 fi umask 077 production_password=$(openssl rand -hex 32) -staging_password=$(openssl rand -hex 32) +test_password=$(openssl rand -hex 32) work_dir=$(mktemp -d) chgrp postgres "$work_dir" chmod 750 "$work_dir" @@ -138,14 +138,14 @@ cleanup() { if [[ "$status" != 0 && "$provision_started" == true && "$provision_finished" == false ]]; then "${postgres_psql[@]}" --dbname postgres --command \ - "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IN ('$production_database', '$staging_database') AND pid <> pg_backend_pid();" \ + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IN ('$production_database', '$test_database') AND pid <> pg_backend_pid();" \ >/dev/null 2>&1 || true "${postgres_psql[@]}" --dbname postgres --command \ - "DROP DATABASE IF EXISTS $staging_database;" >/dev/null 2>&1 || true + "DROP DATABASE IF EXISTS $test_database;" >/dev/null 2>&1 || true "${postgres_psql[@]}" --dbname postgres --command \ "DROP DATABASE IF EXISTS $production_database;" >/dev/null 2>&1 || true "${postgres_psql[@]}" --dbname postgres --command \ - "DROP ROLE IF EXISTS $staging_role; DROP ROLE IF EXISTS $production_role;" \ + "DROP ROLE IF EXISTS $test_role; DROP ROLE IF EXISTS $production_role;" \ >/dev/null 2>&1 || true if [[ -f "$hba_backup" ]]; then @@ -153,11 +153,11 @@ cleanup() { pg_ctlcluster "$postgres_version" "$postgres_cluster" reload || true fi - rm -f "$production_fragment" "$staging_fragment" + rm -f "$production_fragment" "$test_fragment" fi rm -rf "$work_dir" - unset production_password staging_password + unset production_password test_password exit "$status" } trap cleanup EXIT HUP INT TERM @@ -166,7 +166,7 @@ install -m 600 -o root -g root "$hba_file" "$hba_backup" { printf '%s\n' "$hba_marker" printf 'local %s %s scram-sha-256\n' "$production_database" "$production_role" - printf 'local %s %s scram-sha-256\n' "$staging_database" "$staging_role" + printf 'local %s %s scram-sha-256\n' "$test_database" "$test_role" printf '%s\n' '# END Who Need Help managed local socket authentication' cat "$hba_backup" } >"$hba_candidate" @@ -190,18 +190,18 @@ cat >"$sql_file" </dev/null -PGPASSWORD="$staging_password" psql --no-psqlrc --set ON_ERROR_STOP=1 \ - --host "$socket_dir" --username "$staging_role" \ - --dbname "$staging_database" --quiet --tuples-only --no-align \ +PGPASSWORD="$test_password" psql --no-psqlrc --set ON_ERROR_STOP=1 \ + --host "$socket_dir" --username "$test_role" \ + --dbname "$test_database" --quiet --tuples-only --no-align \ --command 'SELECT current_user, current_database(), PostGIS_Version()' >/dev/null install -d -m 700 -o "$operator" -g "$operator_group" "$output_dir" production_tmp="$work_dir/database-production.env" -staging_tmp="$work_dir/database-staging.env" +test_tmp="$work_dir/database-test.env" printf '%s\n' \ 'PRODUCTION_DATABASE_MODE=external' \ "PRODUCTION_DATABASE_URL=ecto://$production_role:$production_password@localhost/$production_database" \ "PRODUCTION_DATABASE_SOCKET_DIR=$socket_dir" >"$production_tmp" printf '%s\n' \ 'PRODUCTION_DATABASE_MODE=external' \ - "PRODUCTION_DATABASE_URL=ecto://$staging_role:$staging_password@localhost/$staging_database" \ - "PRODUCTION_DATABASE_SOCKET_DIR=$socket_dir" >"$staging_tmp" + "PRODUCTION_DATABASE_URL=ecto://$test_role:$test_password@localhost/$test_database" \ + "PRODUCTION_DATABASE_SOCKET_DIR=$socket_dir" >"$test_tmp" install -m 600 -o "$operator" -g "$operator_group" "$production_tmp" "$production_fragment" -install -m 600 -o "$operator" -g "$operator_group" "$staging_tmp" "$staging_fragment" +install -m 600 -o "$operator" -g "$operator_group" "$test_tmp" "$test_fragment" provision_finished=true -echo "Provisioned isolated production and staging PostgreSQL roles and databases." +echo "Provisioned isolated production and test PostgreSQL roles and databases." echo "Verified SCRAM authentication, citext, and PostGIS through $socket_dir." echo "Credential fragments (mode 0600):" echo " $production_fragment" -echo " $staging_fragment" +echo " $test_fragment" echo "Original HBA backup (mode 0600): $backup_file" diff --git a/scripts/quality.sh b/scripts/quality.sh index ea5cbf6..36787cd 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -91,54 +91,48 @@ grep -Fx '/.runner' .dockerignore >/dev/null grep -Fx '/act_runner' .dockerignore >/dev/null grep -Fx '/act_runner-data/' .dockerignore >/dev/null -echo "Checking production environment initialization and validation" -edge_env="$scan_dir/.env.edge" -./scripts/init-edge-env.sh help.test staging.help.test "$edge_env" >/dev/null -test "$(stat -c '%a' "$edge_env")" = 600 -./scripts/validate-edge-env.sh "$edge_env" >/dev/null -if ./scripts/init-edge-env.sh help.test staging.help.test "$edge_env" >/dev/null 2>&1; then - echo "Edge environment initializer overwrote an existing file." >&2 +echo "Checking independent test and production environment initialization" +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 + echo "Test environment initializer accepted a missing Codex session ID." >&2 exit 1 fi -staging_env="$scan_dir/.env.staging" -if ./scripts/init-staging-env.sh staging.help.test \ - "$scan_dir/.env.staging.missing-codex" >/dev/null 2>&1; then - echo "Staging environment initializer accepted a missing Codex session ID." >&2 +TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ +TEST_GOOGLE_OAUTH_CLIENT_ID=quality-test-client \ +TEST_GOOGLE_OAUTH_CLIENT_SECRET=quality-test-secret \ + ./scripts/init-test-env.sh test.help.test "$test_env" >/dev/null +test "$(stat -c '%a' "$test_env")" = 600 +grep -Fx 'DEPLOYMENT_ENV=test' "$test_env" >/dev/null +grep -Fx 'COMPOSE_PROJECT_NAME=who_need_help_test' "$test_env" >/dev/null +grep -E '^APP_IMAGE=who-need-help:test-[0-9a-f]{12}$' "$test_env" >/dev/null +grep -Fx 'APP_TOPOLOGY=compact' "$test_env" >/dev/null +grep -Fx 'DATABASE_MODE=container' "$test_env" >/dev/null +grep -Fx 'POSTGRES_DB=who_need_help_test' "$test_env" >/dev/null +grep -Fx 'PUBLIC_EDGE_ENABLED=true' "$test_env" >/dev/null +grep -Fx 'PUBLIC_UPSTREAM_NAME=who-need-help-test' "$test_env" >/dev/null +grep -Fx 'PHX_HOST=test.help.test' "$test_env" >/dev/null +grep -Fx 'PHX_SCHEME=https' "$test_env" >/dev/null +grep -Fx 'PHX_URL_PORT=443' "$test_env" >/dev/null +grep -Fx 'EMAIL_DELIVERY_PROVIDER=smtp' "$test_env" >/dev/null +grep -Fx 'SMTP_RELAY=mailpit' "$test_env" >/dev/null +grep -Fx 'EMAIL_FROM_NAME="Who Need Help Test"' "$test_env" >/dev/null +grep -Fx 'GOOGLE_OAUTH_CLIENT_ID=quality-test-client' "$test_env" >/dev/null +./scripts/validate-test-env.sh "$test_env" test.help.test >/dev/null +if TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + TEST_GOOGLE_OAUTH_CLIENT_ID=quality-test-client \ + ./scripts/init-test-env.sh test.help.test \ + "$scan_dir/test.partial-google.env" >/dev/null 2>&1; then + echo "Test environment initializer accepted partial Google OAuth credentials." >&2 exit 1 fi -STAGING_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ -STAGING_GOOGLE_OAUTH_CLIENT_ID=quality-staging-client \ -STAGING_GOOGLE_OAUTH_CLIENT_SECRET=quality-staging-secret \ - ./scripts/init-staging-env.sh staging.help.test "$staging_env" >/dev/null -test "$(stat -c '%a' "$staging_env")" = 600 -grep -Fx 'DEPLOYMENT_ENV=development' "$staging_env" >/dev/null -grep -Fx 'COMPOSE_PROJECT_NAME=who_need_help_staging' "$staging_env" >/dev/null -grep -Fx 'APP_TOPOLOGY=compact' "$staging_env" >/dev/null -grep -Fx 'DATABASE_MODE=container' "$staging_env" >/dev/null -grep -Fx 'PUBLIC_EDGE_ENABLED=true' "$staging_env" >/dev/null -grep -Fx 'PUBLIC_UPSTREAM_NAME=who-need-help-staging' "$staging_env" >/dev/null -grep -Fx 'PHX_HOST=staging.help.test' "$staging_env" >/dev/null -grep -Fx 'PHX_SCHEME=https' "$staging_env" >/dev/null -grep -Fx 'PHX_URL_PORT=443' "$staging_env" >/dev/null -grep -Fx 'EMAIL_DELIVERY_PROVIDER=smtp' "$staging_env" >/dev/null -grep -Fx 'SMTP_RELAY=mailpit' "$staging_env" >/dev/null -grep -Fx 'EMAIL_FROM_NAME="Who Need Help Staging"' "$staging_env" >/dev/null -grep -Fx 'GOOGLE_OAUTH_CLIENT_ID=quality-staging-client' "$staging_env" >/dev/null -"$ROOT/scripts/compose.sh" "$staging_env" config --quiet -if STAGING_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ - STAGING_GOOGLE_OAUTH_CLIENT_ID=quality-staging-client \ - ./scripts/init-staging-env.sh staging.help.test \ - "$scan_dir/.env.staging.partial-google" >/dev/null 2>&1; then - echo "Staging environment initializer accepted partial Google OAuth credentials." >&2 +if TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + ./scripts/init-test-env.sh test.help.test "$test_env" >/dev/null 2>&1; then + echo "Test environment initializer overwrote an existing file." >&2 exit 1 fi -if STAGING_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ - ./scripts/init-staging-env.sh staging.help.test "$staging_env" >/dev/null 2>&1; then - echo "Staging environment initializer overwrote an existing file." >&2 - exit 1 -fi -production_env="$scan_dir/.env.production" -missing_codex_env="$scan_dir/.env.production.missing-codex" +production_env="$scan_dir/production.env" +missing_codex_env="$scan_dir/production.missing-codex.env" if PRODUCTION_TRAEFIK_TRUSTED_IPS=172.20.0.1/32 \ PRODUCTION_SMTP_RELAY=smtp.help.test \ PRODUCTION_SMTP_PORT=587 \ @@ -162,9 +156,28 @@ PRODUCTION_SMTP_TLS=always \ PRODUCTION_SMTP_SSL=false \ 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 \ ./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 +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 +grep -Fx 'PRIMARY_DOMAIN=help.test' "$production_env" >/dev/null +grep -Fx 'TEST_DOMAIN=test.help.test' "$production_env" >/dev/null +./scripts/validate-edge-env.sh "$production_env" >/dev/null + +test_checkout="$scan_dir/test-checkout" +production_checkout="$scan_dir/production-checkout" +mkdir "$test_checkout" "$production_checkout" +git -C "$test_checkout" init --quiet +git -C "$production_checkout" init --quiet +cp "$test_env" "$test_checkout/.env" +cp "$production_env" "$production_checkout/.env" +chmod 600 "$test_checkout/.env" "$production_checkout/.env" +./scripts/validate-deployment-isolation.sh \ + "$test_checkout" "$production_checkout" >/dev/null api_production_env="$scan_dir/.env.production.unisender-go" PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \ PRODUCTION_UNISENDER_GO_API_KEY=quality-unisender-go-api-key \ @@ -289,6 +302,8 @@ cp "$production_env" "$partial_google_env" chmod 600 "$partial_google_env" sed -i 's/^GOOGLE_OAUTH_CLIENT_ID=.*/GOOGLE_OAUTH_CLIENT_ID=quality-client/' \ "$partial_google_env" +sed -i 's/^GOOGLE_OAUTH_CLIENT_SECRET=.*/GOOGLE_OAUTH_CLIENT_SECRET=/' \ + "$partial_google_env" if ./scripts/validate-production-env.sh \ "$partial_google_env" help.test >/dev/null 2>&1; then echo "Production environment validator accepted partial Google OAuth credentials." >&2 @@ -308,11 +323,10 @@ if ./scripts/validate-production-env.sh \ fi echo "Rendering every Docker Compose profile" -CADDY_IMAGE=who-need-help:caddy-local \ -docker compose --project-directory "$ROOT" --env-file "$edge_env" \ +docker compose --project-directory "$ROOT" --env-file "$production_env" \ --file compose.edge.yaml config --format json | jq --exit-status ' - .services.edge.image == "who-need-help:caddy-local" and + (.services.edge.image | startswith("who-need-help:caddy-production-")) and .services.edge.user == "1000:1000" and .services.edge.read_only == true and .services.edge.cap_drop == ["ALL"] and @@ -335,12 +349,12 @@ docker compose --project-directory "$ROOT" --env-file "$edge_env" \ ./scripts/compose.sh "$external_production_env" config --quiet ./scripts/compose.sh "$external_socket_production_env" config --quiet ./scripts/compose.sh "$external_split_production_env" config --quiet -./scripts/compose.sh "$staging_env" config --format json | +./scripts/compose.sh "$test_env" config --format json | jq --exit-status ' .services.app.networks.internal.interface_name == "eth0" and .services.app.networks.egress.interface_name == "eth1" and .services.app.networks.public_edge.interface_name == "eth2" and - .services.app.networks.public_edge.aliases == ["who-need-help-staging"] + .services.app.networks.public_edge.aliases == ["who-need-help-test"] ' >/dev/null ./scripts/compose.sh .env.example config --format json | jq --exit-status ' diff --git a/scripts/validate-deployment-isolation.sh b/scripts/validate-deployment-isolation.sh new file mode 100755 index 0000000..228c89d --- /dev/null +++ b/scripts/validate-deployment-isolation.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +test_dir=${1:-} +production_dir=${2:-} + +if [[ -z "$test_dir" || -z "$production_dir" ]]; then + echo "Usage: $0 TEST_CHECKOUT PRODUCTION_CHECKOUT" >&2 + exit 1 +fi + +test_dir=$(realpath "$test_dir") +production_dir=$(realpath "$production_dir") +[[ "$test_dir" != "$production_dir" ]] || { + echo "Test and production must be different directories." >&2 + exit 1 +} + +for dir in "$test_dir" "$production_dir"; do + [[ -d "$dir/.git" ]] || { + echo "Independent Git metadata is missing in $dir." >&2 + exit 1 + } + [[ -f "$dir/.env" ]] || { + echo "The single runtime configuration is missing: $dir/.env" >&2 + exit 1 + } + [[ "$(stat -c '%a' "$dir/.env")" == 600 ]] || { + echo "$dir/.env must have mode 0600." >&2 + exit 1 + } + if find "$dir" -maxdepth 1 -type f \ + \( -name '.env.edge' -o -name '.env.test' -o -name '.env.staging' -o -name '.env.production' \) \ + -print -quit | grep -q .; then + echo "$dir contains a second runtime environment file." >&2 + exit 1 + fi +done + +test_git=$(git -C "$test_dir" rev-parse --absolute-git-dir) +production_git=$(git -C "$production_dir" rev-parse --absolute-git-dir) +[[ "$test_git" != "$production_git" ]] || { + echo "Test and production must not share a Git worktree." >&2 + exit 1 +} + +read_env() { + local file=$1 key=$2 + awk -v key="$key" ' + index($0, key "=") == 1 { + print substr($0, length(key) + 2) + found = 1 + exit + } + END { if (!found) exit 1 } + ' "$file" +} + +test_env="$test_dir/.env" +production_env="$production_dir/.env" + +require_different() { + local key=$1 test_value production_value + test_value=$(read_env "$test_env" "$key") + production_value=$(read_env "$production_env" "$key") + [[ -n "$test_value" && -n "$production_value" && "$test_value" != "$production_value" ]] || { + echo "$key must be present and different between test and production." >&2 + exit 1 + } +} + +[[ "$(read_env "$test_env" DEPLOYMENT_ENV)" == test ]] || { + echo "Test .env must declare DEPLOYMENT_ENV=test." >&2 + exit 1 +} +[[ "$(read_env "$production_env" DEPLOYMENT_ENV)" == production ]] || { + echo "Production .env must declare DEPLOYMENT_ENV=production." >&2 + exit 1 +} + +for key in COMPOSE_PROJECT_NAME APP_IMAGE SOCKET_PROXY_IMAGE POSTGIS_IMAGE \ + PHX_HOST WNH_BASE_URL PUBLIC_UPSTREAM_NAME DATABASE_URL EMAIL_FROM_ADDRESS \ + SECRET_KEY_BASE HANDOVER_SECRET RELEASE_COOKIE METRICS_TOKEN; do + require_different "$key" +done + +test_google_id=$(read_env "$test_env" GOOGLE_OAUTH_CLIENT_ID 2>/dev/null || true) +production_google_id=$(read_env "$production_env" GOOGLE_OAUTH_CLIENT_ID 2>/dev/null || true) +if [[ -n "$test_google_id" || -n "$production_google_id" ]]; then + [[ -n "$test_google_id" && -n "$production_google_id" && + "$test_google_id" != "$production_google_id" ]] || { + echo "Configured test and production Google OAuth clients must be different." >&2 + exit 1 + } +fi + +[[ "$(read_env "$test_env" EMAIL_DELIVERY_PROVIDER)" == smtp && + "$(read_env "$test_env" SMTP_RELAY)" == mailpit ]] || { + echo "Test email must be isolated in Mailpit." >&2 + exit 1 +} +[[ "$(read_env "$production_env" EMAIL_DELIVERY_PROVIDER)" != smtp || + "$(read_env "$production_env" SMTP_RELAY)" != mailpit ]] || { + echo "Production email must not target test Mailpit." >&2 + exit 1 +} + +test_edge_network=$(read_env "$test_env" PUBLIC_EDGE_NETWORK) +production_edge_network=$(read_env "$production_env" PUBLIC_EDGE_NETWORK) +[[ "$test_edge_network" == "$production_edge_network" ]] || { + echo "Both deployments must intentionally join the same public edge network." >&2 + exit 1 +} +test_upstream=$(read_env "$test_env" PUBLIC_UPSTREAM_NAME) +production_edge_test_upstream=$(read_env "$production_env" TEST_UPSTREAM) +[[ "$production_edge_test_upstream" == "$test_upstream:4000" ]] || { + echo "Production edge TEST_UPSTREAM does not point to the test alias." >&2 + exit 1 +} +production_upstream=$(read_env "$production_env" PUBLIC_UPSTREAM_NAME) +production_edge_primary_upstream=$(read_env "$production_env" PRIMARY_UPSTREAM) +[[ "$production_edge_primary_upstream" == "$production_upstream:4000" ]] || { + echo "Production edge PRIMARY_UPSTREAM does not point to production." >&2 + exit 1 +} + +edge_project=$(read_env "$production_env" EDGE_COMPOSE_PROJECT_NAME) +test_project=$(read_env "$test_env" COMPOSE_PROJECT_NAME) +production_project=$(read_env "$production_env" COMPOSE_PROJECT_NAME) +[[ "$edge_project" != "$test_project" && "$edge_project" != "$production_project" ]] || { + echo "The edge Compose project must be independent from both application projects." >&2 + exit 1 +} + +echo "Verified two independent Git checkouts and one isolated .env per deployment." +echo "Verified separate Compose projects, images, databases, OAuth clients, email paths, and secrets." diff --git a/scripts/validate-edge-env.sh b/scripts/validate-edge-env.sh index e0120ee..49f502f 100755 --- a/scripts/validate-edge-env.sh +++ b/scripts/validate-edge-env.sh @@ -64,18 +64,18 @@ validate_upstream() { fi } -compose_project_name=$(require_value COMPOSE_PROJECT_NAME) +compose_project_name=$(require_value EDGE_COMPOSE_PROJECT_NAME) public_edge_network=$(require_value PUBLIC_EDGE_NETWORK) edge_bind_address=$(require_value EDGE_BIND_ADDRESS) edge_http_port=$(require_value EDGE_HTTP_PORT) edge_https_port=$(require_value EDGE_HTTPS_PORT) primary_domain=$(require_value PRIMARY_DOMAIN) primary_upstream=$(require_value PRIMARY_UPSTREAM) -staging_domain=$(require_value STAGING_DOMAIN) -staging_upstream=$(require_value STAGING_UPSTREAM) +test_domain=$(require_value TEST_DOMAIN) +test_upstream=$(require_value TEST_UPSTREAM) [[ "$compose_project_name" =~ ^[a-zA-Z0-9_-]+$ ]] || { - echo "COMPOSE_PROJECT_NAME contains unsupported characters." >&2 + echo "EDGE_COMPOSE_PROJECT_NAME contains unsupported characters." >&2 exit 1 } [[ "$public_edge_network" =~ ^[a-zA-Z0-9_-]+$ ]] || { @@ -99,16 +99,16 @@ done } validate_hostname PRIMARY_DOMAIN "$primary_domain" -validate_hostname STAGING_DOMAIN "$staging_domain" +validate_hostname TEST_DOMAIN "$test_domain" validate_upstream PRIMARY_UPSTREAM "$primary_upstream" -validate_upstream STAGING_UPSTREAM "$staging_upstream" +validate_upstream TEST_UPSTREAM "$test_upstream" -[[ "$primary_domain" != "$staging_domain" ]] || { - echo "PRIMARY_DOMAIN and STAGING_DOMAIN must be different." >&2 +[[ "$primary_domain" != "$test_domain" ]] || { + echo "PRIMARY_DOMAIN and TEST_DOMAIN must be different." >&2 exit 1 } -[[ "$primary_upstream" != "$staging_upstream" ]] || { - echo "PRIMARY_UPSTREAM and STAGING_UPSTREAM must be different." >&2 +[[ "$primary_upstream" != "$test_upstream" ]] || { + echo "PRIMARY_UPSTREAM and TEST_UPSTREAM must be different." >&2 exit 1 } diff --git a/scripts/validate-production-env.sh b/scripts/validate-production-env.sh index 275aff4..6186d7b 100755 --- a/scripts/validate-production-env.sh +++ b/scripts/validate-production-env.sh @@ -75,6 +75,9 @@ phx_host=$(require_value PHX_HOST) deployment_target=$(require_value DEPLOYMENT_TARGET) deployment_environment=$(require_value DEPLOYMENT_ENV) compose_project_name=$(require_value COMPOSE_PROJECT_NAME) +app_image=$(require_value APP_IMAGE) +socket_proxy_image=$(require_value SOCKET_PROXY_IMAGE) +postgis_image=$(require_value POSTGIS_IMAGE) app_topology=$(require_value APP_TOPOLOGY) database_mode=$(require_value DATABASE_MODE) phx_scheme=$(require_value PHX_SCHEME) @@ -87,6 +90,8 @@ public_edge_network=$(require_value PUBLIC_EDGE_NETWORK) public_upstream_name=$(require_value PUBLIC_UPSTREAM_NAME) trusted_proxy_ips=$(optional_value TRAEFIK_TRUSTED_IPS) postgres_password=$(optional_value POSTGRES_PASSWORD) +postgres_db=$(optional_value POSTGRES_DB) +postgres_user=$(optional_value POSTGRES_USER) database_url=$(require_value DATABASE_URL) database_socket_dir=$(optional_value DATABASE_SOCKET_DIR) secret_key_base=$(require_value SECRET_KEY_BASE) @@ -111,6 +116,12 @@ 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) codex_session_id=$(require_value CODEX_SESSION_ID) +edge_compose_project_name=$(require_value EDGE_COMPOSE_PROJECT_NAME) +caddy_image=$(require_value CADDY_IMAGE) +primary_domain=$(require_value PRIMARY_DOMAIN) +primary_upstream=$(require_value PRIMARY_UPSTREAM) +test_domain=$(require_value TEST_DOMAIN) +test_upstream=$(require_value TEST_UPSTREAM) [[ "$deployment_target" == compose ]] || { echo "Production Compose validation requires DEPLOYMENT_TARGET=compose." >&2 @@ -124,6 +135,22 @@ codex_session_id=$(require_value CODEX_SESSION_ID) echo "COMPOSE_PROJECT_NAME contains unsupported characters." >&2 exit 1 } +[[ "$compose_project_name" == who_need_help_production ]] || { + echo "Production must use COMPOSE_PROJECT_NAME=who_need_help_production." >&2 + exit 1 +} +[[ "$app_image" == who-need-help:production-* ]] || { + echo "Production must use a production-specific APP_IMAGE." >&2 + exit 1 +} +[[ "$socket_proxy_image" == who-need-help:socket-proxy-production-* ]] || { + echo "Production must use a production-specific socket-proxy image." >&2 + exit 1 +} +[[ "$postgis_image" == who-need-help:postgis-production-* ]] || { + echo "Production must use a production-specific PostGIS image." >&2 + exit 1 +} [[ "$app_topology" =~ ^(compact|split)$ ]] || { echo "APP_TOPOLOGY must be compact or split." >&2 exit 1 @@ -190,13 +217,17 @@ case "$database_url" in esac if [[ "$database_mode" == container ]]; then + [[ -n "$postgres_db" && -n "$postgres_user" ]] || { + echo "POSTGRES_DB and POSTGRES_USER are required for DATABASE_MODE=container." >&2 + exit 1 + } [[ -n "$postgres_password" ]] || { echo "POSTGRES_PASSWORD is required for DATABASE_MODE=container." >&2 exit 1 } reject_marker POSTGRES_PASSWORD "$postgres_password" - expected_database_url="ecto://postgres:$postgres_password@db/who_need_help" + expected_database_url="ecto://$postgres_user:$postgres_password@db/$postgres_db" [[ "$database_url" == "$expected_database_url" ]] || { echo "Container DATABASE_URL does not match the generated PostgreSQL role/password/database." >&2 exit 1 @@ -325,6 +356,31 @@ case "$codex_session_id" in esac reject_marker CODEX_SESSION_ID "$codex_session_id" +[[ "$edge_compose_project_name" =~ ^[a-zA-Z0-9_-]+$ ]] || { + echo "EDGE_COMPOSE_PROJECT_NAME contains unsupported characters." >&2 + exit 1 +} +[[ "$edge_compose_project_name" != "$compose_project_name" ]] || { + echo "The edge and production application must use different Compose project names." >&2 + exit 1 +} +[[ "$caddy_image" == who-need-help:caddy-production-* ]] || { + echo "Production must use a production-specific Caddy image." >&2 + exit 1 +} +[[ "$primary_domain" == "$expected_domain" ]] || { + echo "PRIMARY_DOMAIN does not match EXPECTED_DOMAIN." >&2 + exit 1 +} +[[ "$primary_upstream" == "$(require_value PUBLIC_UPSTREAM_NAME):4000" ]] || { + echo "PRIMARY_UPSTREAM does not target the production application alias." >&2 + exit 1 +} +[[ "$test_domain" != "$primary_domain" && "$test_upstream" != "$primary_upstream" ]] || { + echo "Test and production edge routes must be different." >&2 + exit 1 +} + secrets=( "$secret_key_base" "$handover_secret" @@ -346,6 +402,9 @@ for ((left = 0; left < ${#secrets[@]}; left++)); do done "$ROOT/scripts/compose.sh" "$env_file" config --quiet +if [[ "$public_edge_enabled" == true ]]; then + "$ROOT/scripts/validate-edge-env.sh" "$env_file" >/dev/null +fi echo "Production environment structure passed validation without printing secrets." echo "This does not test DNS, TLS, email-provider availability/delivery, proxy source IPs, or server capacity." diff --git a/scripts/validate-test-env.sh b/scripts/validate-test-env.sh new file mode 100755 index 0000000..166cca0 --- /dev/null +++ b/scripts/validate-test-env.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +env_file=${1:-} +expected_domain=${2:-} + +if [[ -z "$env_file" || -z "$expected_domain" || ! -f "$env_file" ]]; then + echo "Usage: $0 ENV_FILE EXPECTED_DOMAIN" >&2 + exit 1 +fi + +[[ "$(stat -c '%a' "$env_file")" == 600 ]] || { + echo "Test environment must have mode 0600: $env_file" >&2 + exit 1 +} +[[ "$(stat -c '%u' "$env_file")" == "$(id -u)" ]] || { + echo "Test environment must be owned by the current operator." >&2 + exit 1 +} + +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" +} + +require_value() { + local key=$1 value + value=$(read_value "$key") || true + [[ -n "$value" ]] || { + echo "$key is missing or empty in $env_file." >&2 + exit 1 + } + printf '%s' "$value" +} + +[[ "$(require_value DEPLOYMENT_ENV)" == test ]] || { + echo "Test validation requires DEPLOYMENT_ENV=test." >&2 + exit 1 +} +[[ "$(require_value DEPLOYMENT_TARGET)" == compose ]] || { + echo "Test validation requires DEPLOYMENT_TARGET=compose." >&2 + exit 1 +} +[[ "$(require_value COMPOSE_PROJECT_NAME)" == who_need_help_test ]] || { + echo "The test checkout must use COMPOSE_PROJECT_NAME=who_need_help_test." >&2 + exit 1 +} +[[ "$(require_value APP_IMAGE)" == who-need-help:test-* ]] || { + echo "The test checkout must use a test-specific APP_IMAGE." >&2 + exit 1 +} +[[ "$(require_value SOCKET_PROXY_IMAGE)" == who-need-help:socket-proxy-test-* ]] || { + echo "The test checkout must use a test-specific socket-proxy image." >&2 + exit 1 +} +[[ "$(require_value POSTGIS_IMAGE)" == who-need-help:postgis-test-* ]] || { + echo "The test checkout must use a test-specific PostGIS image." >&2 + exit 1 +} +[[ "$(require_value DATABASE_MODE)" == container ]] || { + echo "The test checkout must use its project-owned database container." >&2 + exit 1 +} +[[ "$(require_value POSTGRES_DB)" == who_need_help_test ]] || { + echo "The test database must be named who_need_help_test." >&2 + exit 1 +} +[[ "$(require_value DATABASE_URL)" == ecto://*"@db/who_need_help_test" ]] || { + echo "The test DATABASE_URL must target its own Compose database." >&2 + exit 1 +} +[[ "$(require_value EMAIL_DELIVERY_PROVIDER)" == smtp && + "$(require_value SMTP_RELAY)" == mailpit ]] || { + echo "The test checkout must deliver email only to its Mailpit service." >&2 + exit 1 +} +[[ "$(require_value PHX_HOST)" == "$expected_domain" && + "$(require_value WNH_BASE_URL)" == "https://$expected_domain" ]] || { + echo "The test public origin does not match EXPECTED_DOMAIN." >&2 + exit 1 +} +[[ "$(require_value PUBLIC_UPSTREAM_NAME)" == who-need-help-test ]] || { + echo "The test public upstream alias must be who-need-help-test." >&2 + exit 1 +} + +google_id=$(read_value GOOGLE_OAUTH_CLIENT_ID 2>/dev/null || true) +google_secret=$(read_value GOOGLE_OAUTH_CLIENT_SECRET 2>/dev/null || true) +if [[ -n "$google_id" || -n "$google_secret" ]]; then + [[ -n "$google_id" && -n "$google_secret" ]] || { + echo "Test Google OAuth ID and secret must be configured together." >&2 + exit 1 + } +fi + +secrets=( + "$(require_value POSTGRES_PASSWORD)" + "$(require_value SECRET_KEY_BASE)" + "$(require_value HANDOVER_SECRET)" + "$(require_value RELEASE_COOKIE)" + "$(require_value METRICS_TOKEN)" +) +for ((left = 0; left < ${#secrets[@]}; left++)); do + for ((right = left + 1; right < ${#secrets[@]}; right++)); do + [[ "${secrets[$left]}" != "${secrets[$right]}" ]] || { + echo "Test secrets must be independent." >&2 + exit 1 + } + done +done + +"$ROOT/scripts/compose.sh" "$env_file" config --quiet +echo "Test environment isolation and Compose structure passed validation." diff --git a/test/who_need_help_web/controllers/page_controller_test.exs b/test/who_need_help_web/controllers/page_controller_test.exs index a3dbd7b..8d10cf8 100644 --- a/test/who_need_help_web/controllers/page_controller_test.exs +++ b/test/who_need_help_web/controllers/page_controller_test.exs @@ -14,6 +14,10 @@ defmodule WhoNeedHelpWeb.PageControllerTest do assert content_security_policy =~ "frame-ancestors 'none'" assert content_security_policy =~ "https://tile.openstreetmap.org" assert content_security_policy =~ "ws://www.example.com" + + assert content_security_policy =~ + "form-action 'self' https://accounts.google.example" + refute content_security_policy =~ "wss://www.example.com" assert get_resp_header(conn, "permissions-policy") == [