Isolate test and production deployments
Some checks are pending
Quality / full-local-gates (push) Waiting to run

This commit is contained in:
SimpleTest 2026-07-21 17:46:34 +03:00
parent 562a1f5cdf
commit eb5e0f896d
25 changed files with 647 additions and 309 deletions

View File

@ -19,6 +19,9 @@ MAP_TILE_URL=https://proxy/__e2e__/map-tile.png?z={z}&x={x}&y={y}
DEPLOYMENT_TARGET=compose DEPLOYMENT_TARGET=compose
DEPLOYMENT_ENV=development DEPLOYMENT_ENV=development
COMPOSE_PROJECT_NAME=who_need_help_e2e 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 APP_TOPOLOGY=split
DATABASE_MODE=container DATABASE_MODE=container

View File

@ -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

View File

@ -5,6 +5,11 @@
DEPLOYMENT_TARGET=compose DEPLOYMENT_TARGET=compose
DEPLOYMENT_ENV=development DEPLOYMENT_ENV=development
COMPOSE_PROJECT_NAME=who_need_help 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. # split runs independently scalable web and worker replicas behind Traefik.
# compact runs one combined Phoenix + Oban VM directly on HTTP_PORT. # compact runs one combined Phoenix + Oban VM directly on HTTP_PORT.
APP_TOPOLOGY=split APP_TOPOLOGY=split
@ -27,6 +32,18 @@ HTTP_BIND_ADDRESS=0.0.0.0
PUBLIC_EDGE_ENABLED=false PUBLIC_EDGE_ENABLED=false
PUBLIC_EDGE_NETWORK=who_need_help_public_edge PUBLIC_EDGE_NETWORK=who_need_help_public_edge
PUBLIC_UPSTREAM_NAME=who-need-help-local 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_PORT=8027
MAILPIT_BIND_ADDRESS=127.0.0.1 MAILPIT_BIND_ADDRESS=127.0.0.1
DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID

View File

@ -5,6 +5,9 @@
DEPLOYMENT_TARGET=compose DEPLOYMENT_TARGET=compose
DEPLOYMENT_ENV=development DEPLOYMENT_ENV=development
COMPOSE_PROJECT_NAME=who_need_help_load 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 APP_TOPOLOGY=split
DATABASE_MODE=container DATABASE_MODE=container

1
.gitignore vendored
View File

@ -51,7 +51,6 @@ __pycache__/
/.env /.env
/.env.* /.env.*
!/.env.example !/.env.example
!/.env.edge.example
!/.env.load.example !/.env.load.example
!/.env.e2e.example !/.env.e2e.example

View File

@ -66,10 +66,10 @@ policies are deliberately not claimed as complete.
## Fast start with Docker Compose ## Fast start with Docker Compose
The public single-server path uses the compact application topology plus a 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 Compose projects with distinct PostGIS volumes and secrets while sharing only a
Docker network used for HTTPS reverse proxying. See the 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. for the verified order of operations. Redis is not a project dependency.
Prerequisite: Docker with the Compose plugin. Prerequisite: Docker with the Compose plugin.
@ -206,7 +206,7 @@ secrets without printing them, writes mode `0600`, and refuses to replace an
existing file: existing file:
```bash ```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 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: Then validate the file structure and the production Compose render:
```bash ```bash
./scripts/validate-production-env.sh .env.production whoneedhelp.com ./scripts/validate-production-env.sh .env whoneedhelp.com
./scripts/deploy-up.sh .env.production ./scripts/deploy-up.sh .env
``` ```
To generate a split deployment against an already provisioned PostgreSQL 18 + 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_APP_TOPOLOGY=split \
PRODUCTION_DATABASE_MODE=external \ PRODUCTION_DATABASE_MODE=external \
PRODUCTION_DATABASE_URL='ecto://USER:PASSWORD@DB_HOST/DB_NAME?ssl=true' \ 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 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 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 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, 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 `citext` and PostGIS, verifies both logins, and writes mode-`0600` initializer
fragments without printing their passwords: fragments without printing their passwords:
@ -253,12 +253,12 @@ sudo ./scripts/provision-host-postgres.sh "$USER"
set -a set -a
. "$HOME/.config/who_need_help/database-production.env" . "$HOME/.config/who_need_help/database-production.env"
set +a 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 \ unset PRODUCTION_DATABASE_MODE PRODUCTION_DATABASE_URL \
PRODUCTION_DATABASE_SOCKET_DIR 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 only the configured socket directory read-only; Ecto migrations remain the
source of application schema. Inspect the exact host PostgreSQL state and the source of application schema. Inspect the exact host PostgreSQL state and the
script's documented impact before the sudo invocation. script's documented impact before the sudo invocation.

View File

@ -1,4 +1,4 @@
name: who_need_help_edge name: ${EDGE_COMPOSE_PROJECT_NAME:?Set EDGE_COMPOSE_PROJECT_NAME in .env}
services: services:
edge: edge:
@ -10,8 +10,8 @@ services:
environment: environment:
PRIMARY_DOMAIN: ${PRIMARY_DOMAIN:?Set PRIMARY_DOMAIN in the edge environment} PRIMARY_DOMAIN: ${PRIMARY_DOMAIN:?Set PRIMARY_DOMAIN in the edge environment}
PRIMARY_UPSTREAM: ${PRIMARY_UPSTREAM:?Set PRIMARY_UPSTREAM 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} TEST_DOMAIN: ${TEST_DOMAIN:?Set TEST_DOMAIN in .env}
STAGING_UPSTREAM: ${STAGING_UPSTREAM:?Set STAGING_UPSTREAM in the edge environment} TEST_UPSTREAM: ${TEST_UPSTREAM:?Set TEST_UPSTREAM in .env}
ports: ports:
- "${EDGE_BIND_ADDRESS:-0.0.0.0}:${EDGE_HTTP_PORT:-80}:80" - "${EDGE_BIND_ADDRESS:-0.0.0.0}:${EDGE_HTTP_PORT:-80}:80"
- "${EDGE_BIND_ADDRESS:-0.0.0.0}:${EDGE_HTTPS_PORT:-443}:443" - "${EDGE_BIND_ADDRESS:-0.0.0.0}:${EDGE_HTTPS_PORT:-443}:443"

View File

@ -1,5 +1,3 @@
name: who_need_help
x-app-environment: &app-environment x-app-environment: &app-environment
APP_ROLE: web APP_ROLE: web
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env} DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env}
@ -61,7 +59,7 @@ x-app-environment: &app-environment
services: services:
docker-api-proxy: docker-api-proxy:
image: who-need-help:socket-proxy-local image: ${SOCKET_PROXY_IMAGE:?Set SOCKET_PROXY_IMAGE in .env}
build: build:
context: . context: .
dockerfile: Dockerfile.socket-proxy dockerfile: Dockerfile.socket-proxy
@ -113,7 +111,7 @@ services:
restart: unless-stopped restart: unless-stopped
db: db:
image: who-need-help:postgis-local image: ${POSTGIS_IMAGE:?Set POSTGIS_IMAGE in .env}
build: build:
context: . context: .
dockerfile: Dockerfile.postgis dockerfile: Dockerfile.postgis
@ -156,7 +154,7 @@ services:
restart: unless-stopped restart: unless-stopped
migrate: migrate:
image: who-need-help:local image: ${APP_IMAGE:?Set APP_IMAGE in .env}
build: build:
context: . context: .
environment: environment:
@ -185,7 +183,7 @@ services:
app: app:
profiles: [compact] profiles: [compact]
image: who-need-help:local image: ${APP_IMAGE:?Set APP_IMAGE in .env}
environment: environment:
<<: *app-environment <<: *app-environment
APP_ROLE: combined APP_ROLE: combined
@ -219,7 +217,7 @@ services:
restart: unless-stopped restart: unless-stopped
web: web:
image: who-need-help:local image: ${APP_IMAGE:?Set APP_IMAGE in .env}
environment: environment:
<<: *app-environment <<: *app-environment
APP_ROLE: web APP_ROLE: web
@ -264,7 +262,7 @@ services:
restart: unless-stopped restart: unless-stopped
worker: worker:
image: who-need-help:local image: ${APP_IMAGE:?Set APP_IMAGE in .env}
environment: environment:
<<: *app-environment <<: *app-environment
APP_ROLE: worker APP_ROLE: worker

View File

@ -189,7 +189,7 @@ github_oauth =
config :who_need_help, :social_oauth, github_oauth config :who_need_help, :social_oauth, github_oauth
google_auth = {google_auth, google_oauth_base_url} =
case { case {
System.get_env("GOOGLE_OAUTH_CLIENT_ID"), System.get_env("GOOGLE_OAUTH_CLIENT_ID"),
System.get_env("GOOGLE_OAUTH_CLIENT_SECRET") System.get_env("GOOGLE_OAUTH_CLIENT_SECRET")
@ -197,16 +197,21 @@ google_auth =
{client_id, client_secret} {client_id, client_secret}
when is_binary(client_id) and client_id != "" and is_binary(client_secret) and when is_binary(client_id) and client_id != "" and is_binary(client_secret) and
client_secret != "" -> client_secret != "" ->
[ base_url = oauth_endpoint.("GOOGLE_OAUTH_BASE_URL", "https://accounts.google.com/")
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"], client_id: client_id,
http_adapter: {Assent.HTTPAdapter.Req, oauth_http_options.("GOOGLE")} 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, ""] -> {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 -> _partial_configuration ->
raise """ raise """
@ -215,6 +220,7 @@ google_auth =
end end
config :who_need_help, :google_auth, google_auth config :who_need_help, :google_auth, google_auth
config :who_need_help, :google_oauth_base_url, google_oauth_base_url
push_configuration = push_configuration =
case { case {

View File

@ -30,6 +30,7 @@ config :who_need_help, WhoNeedHelpWeb.Endpoint,
config :who_need_help, WhoNeedHelp.Mailer, adapter: Swoosh.Adapters.Test config :who_need_help, WhoNeedHelp.Mailer, adapter: Swoosh.Adapters.Test
config :who_need_help, :social_oauth_adapter, WhoNeedHelp.SocialOAuthFake config :who_need_help, :social_oauth_adapter, WhoNeedHelp.SocialOAuthFake
config :who_need_help, :google_auth_adapter, WhoNeedHelp.GoogleAuthFake 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" config :who_need_help, :metrics_token, "test-metrics-token"
# Disable swoosh api client as it is only required for production adapters # Disable swoosh api client as it is only required for production adapters

View File

@ -16,12 +16,12 @@ www.{$PRIMARY_DOMAIN} {
redir https://{$PRIMARY_DOMAIN}{uri} permanent redir https://{$PRIMARY_DOMAIN}{uri} permanent
} }
{$STAGING_DOMAIN} { {$TEST_DOMAIN} {
encode zstd gzip encode zstd gzip
header { header {
-Server -Server
} }
reverse_proxy {$STAGING_UPSTREAM} reverse_proxy {$TEST_UPSTREAM}
} }

View File

@ -48,75 +48,79 @@ The deployment environment selects topology and database ownership:
There is no Redis dependency. Queues, rate-limit counters, Oban leadership, There is no Redis dependency. Queues, rate-limit counters, Oban leadership,
and durable application state use PostgreSQL. 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 The server uses exactly these independent Git clones:
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.
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 ```bash
./scripts/init-edge-env.sh \ cd /srv/who_need_help-test
whoneedhelp.com staging.whoneedhelp.com .env.edge TEST_CODEX_SESSION_ID=YOUR_MAIN_CODEX_SESSION_ID \
./scripts/edge-up.sh .env.edge 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 The authoritative A records for `whoneedhelp.com`, `www.whoneedhelp.com`, and
`staging.whoneedhelp.com` must point to the observed server address, and inbound `test.whoneedhelp.com` must point to the verified server address before Caddy
TCP 80/443 plus UDP 443 must be permitted before public certificate issuance. can obtain their certificates. Do not remove the edge volumes during an
Caddy obtains and renews public certificates and redirects HTTP to HTTPS. The ordinary application deploy. Do not add `--volumes` to a stop command unless
Phoenix release remains plain HTTP on the internal shared Docker network; the exact database has been inspected and deletion is intended.
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
```
The initializer reads `.env.example`, derives the numeric group of that host's The initializer reads `.env.example`, derives the numeric group of that host's
Docker socket, generates independent random values for PostgreSQL, 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: URI. Leave both credentials empty to keep the feature disabled. Then run:
```bash ```bash
./scripts/validate-production-env.sh .env.production whoneedhelp.com ./scripts/validate-production-env.sh .env whoneedhelp.com
./scripts/deploy-up.sh .env.production ./scripts/deploy-up.sh .env
``` ```
The production override keeps Mailpit behind its inactive `local-mail` profile, 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 - prepend two database-and-role-specific `local ... scram-sha-256` rules to the
active `pg_hba.conf`, retaining a mode-`0600` copy under active `pg_hba.conf`, retaining a mode-`0600` copy under
`/var/backups/who_need_help/`, and reload that cluster; `/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; database-creation, role-creation, replication, or row-security bypass rights;
- create empty owner databases `who_need_help_production` and - 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; PostGIS so the non-superuser Ecto migrations can run;
- verify both credentials through the observed Unix socket and write separate - verify both credentials through the observed Unix socket and write separate
mode-`0600` initializer fragments under 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 created if provisioning fails. It does not inspect or migrate application
tables; the release migration runner remains authoritative for schema. 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: fragment:
```bash ```bash
set -a set -a
. "$HOME/.config/who_need_help/database-production.env" . "$HOME/.config/who_need_help/database-production.env"
set +a 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 \ unset PRODUCTION_DATABASE_MODE PRODUCTION_DATABASE_URL \
PRODUCTION_DATABASE_SOCKET_DIR PRODUCTION_DATABASE_SOCKET_DIR
``` ```
For staging, load `database-staging.env` and pass the staging domain, project, For test external-database drills, load `database-test.env`; the public test
upstream alias, and HTTP port described above. Do not load both fragments into deployment normally uses its own Compose database. Do not load both fragments into
one shell. one shell.
Provision the database and role first, then generate the environment without 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 export PRODUCTION_DATABASE_MODE=external
read -rsp 'External DATABASE_URL: ' PRODUCTION_DATABASE_URL && echo read -rsp 'External DATABASE_URL: ' PRODUCTION_DATABASE_URL && echo
export PRODUCTION_DATABASE_URL export PRODUCTION_DATABASE_URL
./scripts/init-production-env.sh whoneedhelp.com .env.production ./scripts/init-production-env.sh whoneedhelp.com
unset PRODUCTION_DATABASE_URL 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 check also reports the observed TLS flag. The connection URL must follow
the database provider's verified TLS/CA requirements. 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`. read-only render check. In external mode its output must not contain `db`.
Use `scripts/compose.sh .env.production ps -a` and Use `scripts/compose.sh .env ps -a` and
`scripts/compose.sh .env.production logs web worker` for the selected mode. `scripts/compose.sh .env logs web worker` for the selected mode.
The repository's Compose backup, restore, rotation, and rollback-based staging The repository's Compose backup, restore, rotation, and rollback-based staging
scripts deliberately refuse `DATABASE_MODE=external`; they are scoped to the scripts deliberately refuse `DATABASE_MODE=external`; they are scoped to the

View File

@ -19,6 +19,13 @@ defmodule WhoNeedHelpWeb.SecurityHeaders do
connect_sources = sources(["'self'", tile_origin, websocket_origin]) connect_sources = sources(["'self'", tile_origin, websocket_origin])
image_sources = sources(["'self'", "data:", "blob:", tile_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 = policy =
[ [
"default-src 'self'", "default-src 'self'",
@ -31,7 +38,7 @@ defmodule WhoNeedHelpWeb.SecurityHeaders do
"connect-src #{connect_sources}", "connect-src #{connect_sources}",
"worker-src 'self' blob:", "worker-src 'self' blob:",
"manifest-src 'self'", "manifest-src 'self'",
"form-action 'self'" "form-action #{form_sources}"
] ]
|> Enum.join("; ") |> Enum.join("; ")

View File

@ -45,8 +45,8 @@ project=$(read_env_value COMPOSE_PROJECT_NAME 2>/dev/null || printf who_need_hel
} }
case "$deployment_environment" in case "$deployment_environment" in
development | production) ;; development | test | production) ;;
*) echo "DEPLOYMENT_ENV must be development or production." >&2; exit 1 ;; *) echo "DEPLOYMENT_ENV must be development, test, or production." >&2; exit 1 ;;
esac esac
case "$database_mode" in case "$database_mode" in

View File

@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
env_file=${1:-"$ROOT/.env.edge"} env_file=${1:-"$ROOT/.env"}
if [[ "$env_file" != /* ]]; then if [[ "$env_file" != /* ]]; then
env_file="$ROOT/$env_file" env_file="$ROOT/$env_file"

View File

@ -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"

View File

@ -4,7 +4,7 @@ umask 077
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
domain=${1:-} domain=${1:-}
target=${2:-"$ROOT/.env.production"} target=${2:-"$ROOT/.env"}
usage() { usage() {
echo "Usage: $0 DOMAIN [OUTPUT_FILE]" >&2 echo "Usage: $0 DOMAIN [OUTPUT_FILE]" >&2
@ -21,7 +21,7 @@ if ! printf '%s\n' "$domain" |
exit 1 exit 1
fi 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 if ! command -v "$command" >/dev/null 2>&1; then
echo "Required command is unavailable: $command" >&2 echo "Required command is unavailable: $command" >&2
exit 1 exit 1
@ -46,17 +46,29 @@ fi
database_mode=${PRODUCTION_DATABASE_MODE:-container} database_mode=${PRODUCTION_DATABASE_MODE:-container}
app_topology=${PRODUCTION_APP_TOPOLOGY:-compact} 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_enabled=${PRODUCTION_PUBLIC_EDGE_ENABLED:-true}
public_edge_network=${PRODUCTION_PUBLIC_EDGE_NETWORK:-who_need_help_public_edge} public_edge_network=${PRODUCTION_PUBLIC_EDGE_NETWORK:-who_need_help_public_edge}
public_upstream_name=${PRODUCTION_PUBLIC_UPSTREAM_NAME:-who-need-help-production} public_upstream_name=${PRODUCTION_PUBLIC_UPSTREAM_NAME:-who-need-help-production}
codex_session_id=${PRODUCTION_CODEX_SESSION_ID:-} 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 if [ -z "$codex_session_id" ]; then
echo "PRODUCTION_CODEX_SESSION_ID is required for the Build Week feedback page." >&2 echo "PRODUCTION_CODEX_SESSION_ID is required for the Build Week feedback page." >&2
exit 1 exit 1
fi 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 case "$compose_project_name" in
*[!a-zA-Z0-9_-]* | '') *[!a-zA-Z0-9_-]* | '')
echo "PRODUCTION_COMPOSE_PROJECT_NAME must contain only letters, numbers, underscores, or hyphens." >&2 echo "PRODUCTION_COMPOSE_PROJECT_NAME must contain only letters, numbers, underscores, or hyphens." >&2
@ -67,7 +79,7 @@ esac
case "$database_mode" in case "$database_mode" in
container) container)
postgres_password=$(openssl rand -hex 32) 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) external)
postgres_password= postgres_password=
@ -188,11 +200,21 @@ EMAIL_HTTP_RECEIVE_TIMEOUT_MS_VALUE=$email_http_receive_timeout_ms \
EMAIL_FROM_ADDRESS_VALUE=$email_from_address \ EMAIL_FROM_ADDRESS_VALUE=$email_from_address \
SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \ SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \
CODEX_SESSION_ID_VALUE=$codex_session_id \ 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 ' awk '
BEGIN { BEGIN {
replacement["DEPLOYMENT_TARGET"] = "compose" replacement["DEPLOYMENT_TARGET"] = "compose"
replacement["DEPLOYMENT_ENV"] = "production" replacement["DEPLOYMENT_ENV"] = "production"
replacement["COMPOSE_PROJECT_NAME"] = ENVIRON["COMPOSE_PROJECT_NAME_VALUE"] 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["APP_TOPOLOGY"] = ENVIRON["APP_TOPOLOGY_VALUE"]
replacement["DATABASE_MODE"] = ENVIRON["DATABASE_MODE_VALUE"] replacement["DATABASE_MODE"] = ENVIRON["DATABASE_MODE_VALUE"]
replacement["HTTP_BIND_ADDRESS"] = ENVIRON["HTTP_BIND_ADDRESS_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_ENABLED"] = ENVIRON["PUBLIC_EDGE_ENABLED_VALUE"]
replacement["PUBLIC_EDGE_NETWORK"] = ENVIRON["PUBLIC_EDGE_NETWORK_VALUE"] replacement["PUBLIC_EDGE_NETWORK"] = ENVIRON["PUBLIC_EDGE_NETWORK_VALUE"]
replacement["PUBLIC_UPSTREAM_NAME"] = ENVIRON["PUBLIC_UPSTREAM_NAME_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["DOCKER_SOCKET_GID"] = ENVIRON["DOCKER_SOCKET_GID_VALUE"]
replacement["TRAEFIK_TRUSTED_IPS"] = ENVIRON["TRUSTED_PROXY_IPS_VALUE"] replacement["TRAEFIK_TRUSTED_IPS"] = ENVIRON["TRUSTED_PROXY_IPS_VALUE"]
replacement["TRAEFIK_PROJECT_CONSTRAINT"] = ENVIRON["COMPOSE_PROJECT_NAME_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["PHX_URL_PORT"] = "443"
replacement["WNH_DEBUG_BASE_URL"] = "https://" ENVIRON["DOMAIN"] replacement["WNH_DEBUG_BASE_URL"] = "https://" ENVIRON["DOMAIN"]
replacement["WNH_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["POSTGRES_PASSWORD"] = ENVIRON["POSTGRES_PASSWORD_VALUE"]
replacement["DATABASE_URL"] = ENVIRON["DATABASE_URL_VALUE"] replacement["DATABASE_URL"] = ENVIRON["DATABASE_URL_VALUE"]
replacement["DATABASE_SOCKET_DIR"] = ENVIRON["DATABASE_SOCKET_DIR_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_HTTP_RECEIVE_TIMEOUT_MS"] = ENVIRON["EMAIL_HTTP_RECEIVE_TIMEOUT_MS_VALUE"]
replacement["EMAIL_FROM_ADDRESS"] = ENVIRON["EMAIL_FROM_ADDRESS_VALUE"] replacement["EMAIL_FROM_ADDRESS"] = ENVIRON["EMAIL_FROM_ADDRESS_VALUE"]
replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_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"] 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 postgres_password secret_key_base handover_secret release_cookie metrics_token
unset smtp_password unisender_go_api_key unset smtp_password unisender_go_api_key
unset google_oauth_client_secret
echo "Generated independent deployment secrets without printing them." 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." echo "Run scripts/validate-production-env.sh '$target' '$domain' after configuring the verified proxy and email-provider values."

View File

@ -4,7 +4,7 @@ umask 077
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
domain=${1:-} domain=${1:-}
target=${2:-"$ROOT/.env.staging"} target=${2:-"$ROOT/.env"}
if [[ -z "$domain" ]]; then if [[ -z "$domain" ]]; then
echo "Usage: $0 DOMAIN [OUTPUT_FILE]" >&2 echo "Usage: $0 DOMAIN [OUTPUT_FILE]" >&2
@ -20,60 +20,61 @@ if [[ "$target" != /* ]]; then
target="$ROOT/$target" target="$ROOT/$target"
fi fi
for command in awk docker mktemp openssl stat; do for command in awk docker git mktemp openssl stat; do
if ! command -v "$command" >/dev/null 2>&1; then command -v "$command" >/dev/null 2>&1 || {
echo "Required command is unavailable: $command" >&2 echo "Required command is unavailable: $command" >&2
exit 1 exit 1
fi }
done 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 echo "/var/run/docker.sock is unavailable; run this on the target Docker host." >&2
exit 1 exit 1
fi }
if [[ -e "$target" ]]; then [[ ! -e "$target" ]] || {
echo "Refusing to overwrite existing staging environment: $target" >&2 echo "Refusing to overwrite existing test environment: $target" >&2
exit 1 exit 1
fi }
target_dir=$(dirname -- "$target") target_dir=$(dirname -- "$target")
if [[ ! -d "$target_dir" ]]; then [[ -d "$target_dir" ]] || {
echo "Output directory does not exist: $target_dir" >&2 echo "Output directory does not exist: $target_dir" >&2
exit 1 exit 1
fi }
compose_project_name=${STAGING_COMPOSE_PROJECT_NAME:-who_need_help_staging} compose_project_name=${TEST_COMPOSE_PROJECT_NAME:-who_need_help_test}
public_edge_network=${STAGING_PUBLIC_EDGE_NETWORK:-who_need_help_public_edge} public_edge_network=${TEST_PUBLIC_EDGE_NETWORK:-who_need_help_public_edge}
public_upstream_name=${STAGING_PUBLIC_UPSTREAM_NAME:-who-need-help-staging} public_upstream_name=${TEST_PUBLIC_UPSTREAM_NAME:-who-need-help-test}
http_bind_address=${STAGING_HTTP_BIND_ADDRESS:-127.0.0.1} http_bind_address=${TEST_HTTP_BIND_ADDRESS:-127.0.0.1}
http_port=${STAGING_HTTP_PORT:-4011} http_port=${TEST_HTTP_PORT:-4011}
mailpit_bind_address=${STAGING_MAILPIT_BIND_ADDRESS:-127.0.0.1} mailpit_bind_address=${TEST_MAILPIT_BIND_ADDRESS:-127.0.0.1}
mailpit_port=${STAGING_MAILPIT_PORT:-8027} mailpit_port=${TEST_MAILPIT_PORT:-8027}
codex_session_id=${STAGING_CODEX_SESSION_ID:-} codex_session_id=${TEST_CODEX_SESSION_ID:-}
google_oauth_client_id=${STAGING_GOOGLE_OAUTH_CLIENT_ID:-} google_oauth_client_id=${TEST_GOOGLE_OAUTH_CLIENT_ID:-}
google_oauth_client_secret=${STAGING_GOOGLE_OAUTH_CLIENT_SECRET:-} google_oauth_client_secret=${TEST_GOOGLE_OAUTH_CLIENT_SECRET:-}
support_inbox_address=${STAGING_SUPPORT_INBOX_ADDRESS:-} support_inbox_address=${TEST_SUPPORT_INBOX_ADDRESS:-}
git_sha=$(git -C "$ROOT" rev-parse --short=12 HEAD)
[[ "$compose_project_name" =~ ^[a-zA-Z0-9_-]+$ ]] || { [[ "$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 exit 1
} }
[[ "$public_edge_network" =~ ^[a-zA-Z0-9_-]+$ ]] || { [[ "$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 exit 1
} }
[[ "$public_upstream_name" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || { [[ "$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 exit 1
} }
[[ -n "$codex_session_id" ]] || { [[ -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 exit 1
} }
if [[ -n "$google_oauth_client_id" || -n "$google_oauth_client_secret" ]]; then if [[ -n "$google_oauth_client_id" || -n "$google_oauth_client_secret" ]]; then
[[ -n "$google_oauth_client_id" && -n "$google_oauth_client_secret" ]] || { [[ -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 exit 1
} }
fi fi
@ -86,7 +87,7 @@ for pair in "HTTP:$http_port" "MAILPIT:$mailpit_port"; do
fi fi
done done
[[ "$http_port" != "$mailpit_port" ]] || { [[ "$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 exit 1
} }
@ -96,14 +97,15 @@ handover_secret=$(openssl rand -hex 64)
release_cookie=$(openssl rand -hex 64) release_cookie=$(openssl rand -hex 64)
metrics_token=$(openssl rand -hex 32) metrics_token=$(openssl rand -hex 32)
docker_socket_gid=$(stat -c '%g' /var/run/docker.sock) 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" 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 trap 'rm -f "$tmp"' EXIT HUP INT TERM
chmod 600 "$tmp" chmod 600 "$tmp"
DOMAIN=$domain \ DOMAIN=$domain \
GIT_SHA_VALUE=$git_sha \
COMPOSE_PROJECT_NAME_VALUE=$compose_project_name \ COMPOSE_PROJECT_NAME_VALUE=$compose_project_name \
PUBLIC_EDGE_NETWORK_VALUE=$public_edge_network \ PUBLIC_EDGE_NETWORK_VALUE=$public_edge_network \
PUBLIC_UPSTREAM_NAME_VALUE=$public_upstream_name \ PUBLIC_UPSTREAM_NAME_VALUE=$public_upstream_name \
@ -126,8 +128,11 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \
awk ' awk '
BEGIN { BEGIN {
replacement["DEPLOYMENT_TARGET"] = "compose" replacement["DEPLOYMENT_TARGET"] = "compose"
replacement["DEPLOYMENT_ENV"] = "development" replacement["DEPLOYMENT_ENV"] = "test"
replacement["COMPOSE_PROJECT_NAME"] = ENVIRON["COMPOSE_PROJECT_NAME_VALUE"] 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["APP_TOPOLOGY"] = "compact"
replacement["DATABASE_MODE"] = "container" replacement["DATABASE_MODE"] = "container"
replacement["HTTP_BIND_ADDRESS"] = ENVIRON["HTTP_BIND_ADDRESS_VALUE"] 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["DOCKER_SOCKET_GID"] = ENVIRON["DOCKER_SOCKET_GID_VALUE"]
replacement["TRAEFIK_TRUSTED_IPS"] = "127.0.0.1/32" replacement["TRAEFIK_TRUSTED_IPS"] = "127.0.0.1/32"
replacement["TRAEFIK_PROJECT_CONSTRAINT"] = ENVIRON["COMPOSE_PROJECT_NAME_VALUE"] 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["TRAEFIK_DOCKER_NETWORK"] = ENVIRON["COMPOSE_PROJECT_NAME_VALUE"] "_ingress"
replacement["PHX_HOST"] = ENVIRON["DOMAIN"] replacement["PHX_HOST"] = ENVIRON["DOMAIN"]
replacement["PHX_SCHEME"] = "https" replacement["PHX_SCHEME"] = "https"
@ -164,8 +169,8 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \
replacement["SMTP_AUTH"] = "never" replacement["SMTP_AUTH"] = "never"
replacement["SMTP_TLS"] = "never" replacement["SMTP_TLS"] = "never"
replacement["SMTP_SSL"] = "false" replacement["SMTP_SSL"] = "false"
replacement["EMAIL_FROM_NAME"] = "\"Who Need Help Staging\"" replacement["EMAIL_FROM_NAME"] = "\"Who Need Help Test\""
replacement["EMAIL_FROM_ADDRESS"] = "staging@" ENVIRON["DOMAIN"] replacement["EMAIL_FROM_ADDRESS"] = "test@" ENVIRON["DOMAIN"]
replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_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_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"]
replacement["GOOGLE_OAUTH_CLIENT_SECRET"] = ENVIRON["GOOGLE_OAUTH_CLIENT_SECRET_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, "=") separator = index($0, "=")
key = separator > 1 ? substr($0, 1, separator - 1) : "" key = separator > 1 ? substr($0, 1, separator - 1) : ""
if (key in replacement) { print (key in replacement) ? key "=" replacement[key] : $0
print key "=" replacement[key]
} else {
print
}
} }
' "$ROOT/.env.example" >"$tmp" ' "$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 unset google_oauth_client_secret
"$ROOT/scripts/compose.sh" "$target" config --quiet "$ROOT/scripts/compose.sh" "$target" config --quiet
echo "Generated independent staging secrets without printing them." echo "Generated independent test secrets without printing them."
echo "Created mode-0600 staging environment: $target" echo "Created the single mode-0600 test configuration: $target"

View File

@ -26,8 +26,8 @@ socket_dir=${POSTGRES_SOCKET_DIR:-/var/run/postgresql}
production_role=wnh_production production_role=wnh_production
production_database=who_need_help_production production_database=who_need_help_production
staging_role=wnh_staging test_role=wnh_test
staging_database=who_need_help_staging test_database=who_need_help_test
hba_marker="# BEGIN Who Need Help managed local socket authentication" 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 \ for command in awk cat chgrp chmod chown cut date getent grep id install mktemp \
@ -96,10 +96,10 @@ fi
existing_objects=$( existing_objects=$(
"${postgres_psql[@]}" --dbname postgres --command " "${postgres_psql[@]}" --dbname postgres --command "
SELECT 'role:' || rolname FROM pg_roles SELECT 'role:' || rolname FROM pg_roles
WHERE rolname IN ('$production_role', '$staging_role') WHERE rolname IN ('$production_role', '$test_role')
UNION ALL UNION ALL
SELECT 'database:' || datname FROM pg_database SELECT 'database:' || datname FROM pg_database
WHERE datname IN ('$production_database', '$staging_database') WHERE datname IN ('$production_database', '$test_database')
ORDER BY 1; ORDER BY 1;
" "
) )
@ -113,15 +113,15 @@ if [[ -n "$existing_objects" ]]; then
fi fi
production_fragment="$output_dir/database-production.env" production_fragment="$output_dir/database-production.env"
staging_fragment="$output_dir/database-staging.env" test_fragment="$output_dir/database-test.env"
if [[ -e "$production_fragment" || -e "$staging_fragment" ]]; then if [[ -e "$production_fragment" || -e "$test_fragment" ]]; then
echo "Database credential fragments already exist; refusing to overwrite them." >&2 echo "Database credential fragments already exist; refusing to overwrite them." >&2
exit 1 exit 1
fi fi
umask 077 umask 077
production_password=$(openssl rand -hex 32) production_password=$(openssl rand -hex 32)
staging_password=$(openssl rand -hex 32) test_password=$(openssl rand -hex 32)
work_dir=$(mktemp -d) work_dir=$(mktemp -d)
chgrp postgres "$work_dir" chgrp postgres "$work_dir"
chmod 750 "$work_dir" chmod 750 "$work_dir"
@ -138,14 +138,14 @@ cleanup() {
if [[ "$status" != 0 && "$provision_started" == true && "$provision_finished" == false ]]; then if [[ "$status" != 0 && "$provision_started" == true && "$provision_finished" == false ]]; then
"${postgres_psql[@]}" --dbname postgres --command \ "${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 >/dev/null 2>&1 || true
"${postgres_psql[@]}" --dbname postgres --command \ "${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 \ "${postgres_psql[@]}" --dbname postgres --command \
"DROP DATABASE IF EXISTS $production_database;" >/dev/null 2>&1 || true "DROP DATABASE IF EXISTS $production_database;" >/dev/null 2>&1 || true
"${postgres_psql[@]}" --dbname postgres --command \ "${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 >/dev/null 2>&1 || true
if [[ -f "$hba_backup" ]]; then if [[ -f "$hba_backup" ]]; then
@ -153,11 +153,11 @@ cleanup() {
pg_ctlcluster "$postgres_version" "$postgres_cluster" reload || true pg_ctlcluster "$postgres_version" "$postgres_cluster" reload || true
fi fi
rm -f "$production_fragment" "$staging_fragment" rm -f "$production_fragment" "$test_fragment"
fi fi
rm -rf "$work_dir" rm -rf "$work_dir"
unset production_password staging_password unset production_password test_password
exit "$status" exit "$status"
} }
trap cleanup EXIT HUP INT TERM 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 '%s\n' "$hba_marker"
printf 'local %s %s scram-sha-256\n' "$production_database" "$production_role" 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' printf '%s\n' '# END Who Need Help managed local socket authentication'
cat "$hba_backup" cat "$hba_backup"
} >"$hba_candidate" } >"$hba_candidate"
@ -190,18 +190,18 @@ cat >"$sql_file" <<SQL
SET password_encryption = 'scram-sha-256'; SET password_encryption = 'scram-sha-256';
CREATE ROLE $production_role LOGIN PASSWORD '$production_password' CREATE ROLE $production_role LOGIN PASSWORD '$production_password'
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS; NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
CREATE ROLE $staging_role LOGIN PASSWORD '$staging_password' CREATE ROLE $test_role LOGIN PASSWORD '$test_password'
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS; NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
CREATE DATABASE $production_database OWNER $production_role TEMPLATE template0; CREATE DATABASE $production_database OWNER $production_role TEMPLATE template0;
CREATE DATABASE $staging_database OWNER $staging_role TEMPLATE template0; CREATE DATABASE $test_database OWNER $test_role TEMPLATE template0;
REVOKE CONNECT ON DATABASE $production_database FROM PUBLIC; REVOKE CONNECT ON DATABASE $production_database FROM PUBLIC;
REVOKE CONNECT ON DATABASE $staging_database FROM PUBLIC; REVOKE CONNECT ON DATABASE $test_database FROM PUBLIC;
GRANT CONNECT ON DATABASE $production_database TO $production_role; GRANT CONNECT ON DATABASE $production_database TO $production_role;
GRANT CONNECT ON DATABASE $staging_database TO $staging_role; GRANT CONNECT ON DATABASE $test_database TO $test_role;
\connect $production_database \connect $production_database
CREATE EXTENSION IF NOT EXISTS citext; CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS postgis; CREATE EXTENSION IF NOT EXISTS postgis;
\connect $staging_database \connect $test_database
CREATE EXTENSION IF NOT EXISTS citext; CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS postgis; CREATE EXTENSION IF NOT EXISTS postgis;
SQL SQL
@ -213,30 +213,30 @@ PGPASSWORD="$production_password" psql --no-psqlrc --set ON_ERROR_STOP=1 \
--host "$socket_dir" --username "$production_role" \ --host "$socket_dir" --username "$production_role" \
--dbname "$production_database" --quiet --tuples-only --no-align \ --dbname "$production_database" --quiet --tuples-only --no-align \
--command 'SELECT current_user, current_database(), PostGIS_Version()' >/dev/null --command 'SELECT current_user, current_database(), PostGIS_Version()' >/dev/null
PGPASSWORD="$staging_password" psql --no-psqlrc --set ON_ERROR_STOP=1 \ PGPASSWORD="$test_password" psql --no-psqlrc --set ON_ERROR_STOP=1 \
--host "$socket_dir" --username "$staging_role" \ --host "$socket_dir" --username "$test_role" \
--dbname "$staging_database" --quiet --tuples-only --no-align \ --dbname "$test_database" --quiet --tuples-only --no-align \
--command 'SELECT current_user, current_database(), PostGIS_Version()' >/dev/null --command 'SELECT current_user, current_database(), PostGIS_Version()' >/dev/null
install -d -m 700 -o "$operator" -g "$operator_group" "$output_dir" install -d -m 700 -o "$operator" -g "$operator_group" "$output_dir"
production_tmp="$work_dir/database-production.env" production_tmp="$work_dir/database-production.env"
staging_tmp="$work_dir/database-staging.env" test_tmp="$work_dir/database-test.env"
printf '%s\n' \ printf '%s\n' \
'PRODUCTION_DATABASE_MODE=external' \ 'PRODUCTION_DATABASE_MODE=external' \
"PRODUCTION_DATABASE_URL=ecto://$production_role:$production_password@localhost/$production_database" \ "PRODUCTION_DATABASE_URL=ecto://$production_role:$production_password@localhost/$production_database" \
"PRODUCTION_DATABASE_SOCKET_DIR=$socket_dir" >"$production_tmp" "PRODUCTION_DATABASE_SOCKET_DIR=$socket_dir" >"$production_tmp"
printf '%s\n' \ printf '%s\n' \
'PRODUCTION_DATABASE_MODE=external' \ 'PRODUCTION_DATABASE_MODE=external' \
"PRODUCTION_DATABASE_URL=ecto://$staging_role:$staging_password@localhost/$staging_database" \ "PRODUCTION_DATABASE_URL=ecto://$test_role:$test_password@localhost/$test_database" \
"PRODUCTION_DATABASE_SOCKET_DIR=$socket_dir" >"$staging_tmp" "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" "$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 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 "Verified SCRAM authentication, citext, and PostGIS through $socket_dir."
echo "Credential fragments (mode 0600):" echo "Credential fragments (mode 0600):"
echo " $production_fragment" echo " $production_fragment"
echo " $staging_fragment" echo " $test_fragment"
echo "Original HBA backup (mode 0600): $backup_file" echo "Original HBA backup (mode 0600): $backup_file"

View File

@ -91,54 +91,48 @@ grep -Fx '/.runner' .dockerignore >/dev/null
grep -Fx '/act_runner' .dockerignore >/dev/null grep -Fx '/act_runner' .dockerignore >/dev/null
grep -Fx '/act_runner-data/' .dockerignore >/dev/null grep -Fx '/act_runner-data/' .dockerignore >/dev/null
echo "Checking production environment initialization and validation" echo "Checking independent test and production environment initialization"
edge_env="$scan_dir/.env.edge" test_env="$scan_dir/test.env"
./scripts/init-edge-env.sh help.test staging.help.test "$edge_env" >/dev/null if ./scripts/init-test-env.sh test.help.test \
test "$(stat -c '%a' "$edge_env")" = 600 "$scan_dir/test.missing-codex.env" >/dev/null 2>&1; then
./scripts/validate-edge-env.sh "$edge_env" >/dev/null echo "Test environment initializer accepted a missing Codex session ID." >&2
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
exit 1 exit 1
fi fi
staging_env="$scan_dir/.env.staging" TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
if ./scripts/init-staging-env.sh staging.help.test \ TEST_GOOGLE_OAUTH_CLIENT_ID=quality-test-client \
"$scan_dir/.env.staging.missing-codex" >/dev/null 2>&1; then TEST_GOOGLE_OAUTH_CLIENT_SECRET=quality-test-secret \
echo "Staging environment initializer accepted a missing Codex session ID." >&2 ./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 exit 1
fi fi
STAGING_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ if TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
STAGING_GOOGLE_OAUTH_CLIENT_ID=quality-staging-client \ ./scripts/init-test-env.sh test.help.test "$test_env" >/dev/null 2>&1; then
STAGING_GOOGLE_OAUTH_CLIENT_SECRET=quality-staging-secret \ echo "Test environment initializer overwrote an existing file." >&2
./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
exit 1 exit 1
fi fi
if STAGING_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ production_env="$scan_dir/production.env"
./scripts/init-staging-env.sh staging.help.test "$staging_env" >/dev/null 2>&1; then missing_codex_env="$scan_dir/production.missing-codex.env"
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"
if PRODUCTION_TRAEFIK_TRUSTED_IPS=172.20.0.1/32 \ if PRODUCTION_TRAEFIK_TRUSTED_IPS=172.20.0.1/32 \
PRODUCTION_SMTP_RELAY=smtp.help.test \ PRODUCTION_SMTP_RELAY=smtp.help.test \
PRODUCTION_SMTP_PORT=587 \ PRODUCTION_SMTP_PORT=587 \
@ -162,9 +156,28 @@ PRODUCTION_SMTP_TLS=always \
PRODUCTION_SMTP_SSL=false \ PRODUCTION_SMTP_SSL=false \
PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \ PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \
PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ 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 ./scripts/init-production-env.sh help.test "$production_env" >/dev/null
test "$(stat -c '%a' "$production_env")" = 600 test "$(stat -c '%a' "$production_env")" = 600
./scripts/validate-production-env.sh "$production_env" help.test >/dev/null ./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" api_production_env="$scan_dir/.env.production.unisender-go"
PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \ PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \
PRODUCTION_UNISENDER_GO_API_KEY=quality-unisender-go-api-key \ 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" chmod 600 "$partial_google_env"
sed -i 's/^GOOGLE_OAUTH_CLIENT_ID=.*/GOOGLE_OAUTH_CLIENT_ID=quality-client/' \ sed -i 's/^GOOGLE_OAUTH_CLIENT_ID=.*/GOOGLE_OAUTH_CLIENT_ID=quality-client/' \
"$partial_google_env" "$partial_google_env"
sed -i 's/^GOOGLE_OAUTH_CLIENT_SECRET=.*/GOOGLE_OAUTH_CLIENT_SECRET=/' \
"$partial_google_env"
if ./scripts/validate-production-env.sh \ if ./scripts/validate-production-env.sh \
"$partial_google_env" help.test >/dev/null 2>&1; then "$partial_google_env" help.test >/dev/null 2>&1; then
echo "Production environment validator accepted partial Google OAuth credentials." >&2 echo "Production environment validator accepted partial Google OAuth credentials." >&2
@ -308,11 +323,10 @@ if ./scripts/validate-production-env.sh \
fi fi
echo "Rendering every Docker Compose profile" echo "Rendering every Docker Compose profile"
CADDY_IMAGE=who-need-help:caddy-local \ docker compose --project-directory "$ROOT" --env-file "$production_env" \
docker compose --project-directory "$ROOT" --env-file "$edge_env" \
--file compose.edge.yaml config --format json | --file compose.edge.yaml config --format json |
jq --exit-status ' 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.user == "1000:1000" and
.services.edge.read_only == true and .services.edge.read_only == true and
.services.edge.cap_drop == ["ALL"] 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_production_env" config --quiet
./scripts/compose.sh "$external_socket_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 "$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 ' jq --exit-status '
.services.app.networks.internal.interface_name == "eth0" and .services.app.networks.internal.interface_name == "eth0" and
.services.app.networks.egress.interface_name == "eth1" and .services.app.networks.egress.interface_name == "eth1" and
.services.app.networks.public_edge.interface_name == "eth2" 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 ' >/dev/null
./scripts/compose.sh .env.example config --format json | ./scripts/compose.sh .env.example config --format json |
jq --exit-status ' jq --exit-status '

View File

@ -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."

View File

@ -64,18 +64,18 @@ validate_upstream() {
fi 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) public_edge_network=$(require_value PUBLIC_EDGE_NETWORK)
edge_bind_address=$(require_value EDGE_BIND_ADDRESS) edge_bind_address=$(require_value EDGE_BIND_ADDRESS)
edge_http_port=$(require_value EDGE_HTTP_PORT) edge_http_port=$(require_value EDGE_HTTP_PORT)
edge_https_port=$(require_value EDGE_HTTPS_PORT) edge_https_port=$(require_value EDGE_HTTPS_PORT)
primary_domain=$(require_value PRIMARY_DOMAIN) primary_domain=$(require_value PRIMARY_DOMAIN)
primary_upstream=$(require_value PRIMARY_UPSTREAM) primary_upstream=$(require_value PRIMARY_UPSTREAM)
staging_domain=$(require_value STAGING_DOMAIN) test_domain=$(require_value TEST_DOMAIN)
staging_upstream=$(require_value STAGING_UPSTREAM) test_upstream=$(require_value TEST_UPSTREAM)
[[ "$compose_project_name" =~ ^[a-zA-Z0-9_-]+$ ]] || { [[ "$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 exit 1
} }
[[ "$public_edge_network" =~ ^[a-zA-Z0-9_-]+$ ]] || { [[ "$public_edge_network" =~ ^[a-zA-Z0-9_-]+$ ]] || {
@ -99,16 +99,16 @@ done
} }
validate_hostname PRIMARY_DOMAIN "$primary_domain" 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 PRIMARY_UPSTREAM "$primary_upstream"
validate_upstream STAGING_UPSTREAM "$staging_upstream" validate_upstream TEST_UPSTREAM "$test_upstream"
[[ "$primary_domain" != "$staging_domain" ]] || { [[ "$primary_domain" != "$test_domain" ]] || {
echo "PRIMARY_DOMAIN and STAGING_DOMAIN must be different." >&2 echo "PRIMARY_DOMAIN and TEST_DOMAIN must be different." >&2
exit 1 exit 1
} }
[[ "$primary_upstream" != "$staging_upstream" ]] || { [[ "$primary_upstream" != "$test_upstream" ]] || {
echo "PRIMARY_UPSTREAM and STAGING_UPSTREAM must be different." >&2 echo "PRIMARY_UPSTREAM and TEST_UPSTREAM must be different." >&2
exit 1 exit 1
} }

View File

@ -75,6 +75,9 @@ phx_host=$(require_value PHX_HOST)
deployment_target=$(require_value DEPLOYMENT_TARGET) deployment_target=$(require_value DEPLOYMENT_TARGET)
deployment_environment=$(require_value DEPLOYMENT_ENV) deployment_environment=$(require_value DEPLOYMENT_ENV)
compose_project_name=$(require_value COMPOSE_PROJECT_NAME) 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) app_topology=$(require_value APP_TOPOLOGY)
database_mode=$(require_value DATABASE_MODE) database_mode=$(require_value DATABASE_MODE)
phx_scheme=$(require_value PHX_SCHEME) 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) public_upstream_name=$(require_value PUBLIC_UPSTREAM_NAME)
trusted_proxy_ips=$(optional_value TRAEFIK_TRUSTED_IPS) trusted_proxy_ips=$(optional_value TRAEFIK_TRUSTED_IPS)
postgres_password=$(optional_value POSTGRES_PASSWORD) 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_url=$(require_value DATABASE_URL)
database_socket_dir=$(optional_value DATABASE_SOCKET_DIR) database_socket_dir=$(optional_value DATABASE_SOCKET_DIR)
secret_key_base=$(require_value SECRET_KEY_BASE) 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_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID)
google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET) google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET)
codex_session_id=$(require_value CODEX_SESSION_ID) 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 ]] || { [[ "$deployment_target" == compose ]] || {
echo "Production Compose validation requires DEPLOYMENT_TARGET=compose." >&2 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 echo "COMPOSE_PROJECT_NAME contains unsupported characters." >&2
exit 1 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)$ ]] || { [[ "$app_topology" =~ ^(compact|split)$ ]] || {
echo "APP_TOPOLOGY must be compact or split." >&2 echo "APP_TOPOLOGY must be compact or split." >&2
exit 1 exit 1
@ -190,13 +217,17 @@ case "$database_url" in
esac esac
if [[ "$database_mode" == container ]]; then 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" ]] || { [[ -n "$postgres_password" ]] || {
echo "POSTGRES_PASSWORD is required for DATABASE_MODE=container." >&2 echo "POSTGRES_PASSWORD is required for DATABASE_MODE=container." >&2
exit 1 exit 1
} }
reject_marker POSTGRES_PASSWORD "$postgres_password" 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" ]] || { [[ "$database_url" == "$expected_database_url" ]] || {
echo "Container DATABASE_URL does not match the generated PostgreSQL role/password/database." >&2 echo "Container DATABASE_URL does not match the generated PostgreSQL role/password/database." >&2
exit 1 exit 1
@ -325,6 +356,31 @@ case "$codex_session_id" in
esac esac
reject_marker CODEX_SESSION_ID "$codex_session_id" 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=( secrets=(
"$secret_key_base" "$secret_key_base"
"$handover_secret" "$handover_secret"
@ -346,6 +402,9 @@ for ((left = 0; left < ${#secrets[@]}; left++)); do
done done
"$ROOT/scripts/compose.sh" "$env_file" config --quiet "$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 "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." echo "This does not test DNS, TLS, email-provider availability/delivery, proxy source IPs, or server capacity."

121
scripts/validate-test-env.sh Executable file
View File

@ -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."

View File

@ -14,6 +14,10 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
assert content_security_policy =~ "frame-ancestors 'none'" assert content_security_policy =~ "frame-ancestors 'none'"
assert content_security_policy =~ "https://tile.openstreetmap.org" assert content_security_policy =~ "https://tile.openstreetmap.org"
assert content_security_policy =~ "ws://www.example.com" 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" refute content_security_policy =~ "wss://www.example.com"
assert get_resp_header(conn, "permissions-policy") == [ assert get_resp_header(conn, "permissions-policy") == [