Isolate test and production deployments
Some checks are pending
Quality / full-local-gates (push) Waiting to run
Some checks are pending
Quality / full-local-gates (push) Waiting to run
This commit is contained in:
parent
562a1f5cdf
commit
eb5e0f896d
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
17
.env.example
17
.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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -51,7 +51,6 @@ __pycache__/
|
|||
/.env
|
||||
/.env.*
|
||||
!/.env.example
|
||||
!/.env.edge.example
|
||||
!/.env.load.example
|
||||
!/.env.e2e.example
|
||||
|
||||
|
|
|
|||
18
README.md
18
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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
14
compose.yaml
14
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
|
||||
|
|
|
|||
|
|
@ -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 != "" ->
|
||||
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/"),
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("; ")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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" <<SQL
|
|||
SET password_encryption = 'scram-sha-256';
|
||||
CREATE ROLE $production_role LOGIN PASSWORD '$production_password'
|
||||
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;
|
||||
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 $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 $staging_database TO $staging_role;
|
||||
GRANT CONNECT ON DATABASE $test_database TO $test_role;
|
||||
\connect $production_database
|
||||
CREATE EXTENSION IF NOT EXISTS citext;
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
\connect $staging_database
|
||||
\connect $test_database
|
||||
CREATE EXTENSION IF NOT EXISTS citext;
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
SQL
|
||||
|
|
@ -213,30 +213,30 @@ PGPASSWORD="$production_password" psql --no-psqlrc --set ON_ERROR_STOP=1 \
|
|||
--host "$socket_dir" --username "$production_role" \
|
||||
--dbname "$production_database" --quiet --tuples-only --no-align \
|
||||
--command 'SELECT current_user, current_database(), PostGIS_Version()' >/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"
|
||||
|
|
|
|||
|
|
@ -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 '
|
||||
|
|
|
|||
136
scripts/validate-deployment-isolation.sh
Executable file
136
scripts/validate-deployment-isolation.sh
Executable 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."
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
121
scripts/validate-test-env.sh
Executable file
121
scripts/validate-test-env.sh
Executable 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."
|
||||
|
|
@ -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") == [
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user