feat: add selectable Compose deployment modes

This commit is contained in:
SimpleTest 2026-07-21 01:48:47 +03:00
parent c4721e6702
commit 0a588182a0
32 changed files with 800 additions and 62 deletions

View File

@ -16,6 +16,12 @@ PHX_SCHEME=https
PHX_URL_PORT=443 PHX_URL_PORT=443
MAP_TILE_URL=https://proxy/__e2e__/map-tile.png?z={z}&x={x}&y={y} 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_TOPOLOGY=split
DATABASE_MODE=container
POSTGRES_DB=who_need_help_e2e POSTGRES_DB=who_need_help_e2e
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=GENERATE_INDEPENDENT_E2E_DATABASE_PASSWORD POSTGRES_PASSWORD=GENERATE_INDEPENDENT_E2E_DATABASE_PASSWORD
@ -23,6 +29,7 @@ DATABASE_URL=ecto://postgres:GENERATE_URL_SAFE_PASSWORD@db/who_need_help_e2e
WEB_POOL_SIZE=4 WEB_POOL_SIZE=4
WORKER_POOL_SIZE=2 WORKER_POOL_SIZE=2
MIGRATE_POOL_SIZE=2 MIGRATE_POOL_SIZE=2
COMBINED_POOL_SIZE=4
OBAN_MAINTENANCE_CONCURRENCY=2 OBAN_MAINTENANCE_CONCURRENCY=2
OBAN_PUSH_CONCURRENCY=1 OBAN_PUSH_CONCURRENCY=1
WEB_REPLICAS=2 WEB_REPLICAS=2

View File

@ -1,5 +1,22 @@
# Copy this file to .env. Compose intentionally refuses to start without the # Copy this file to .env. Compose intentionally refuses to start without the
# required values. Replace every credential before any public deployment. # required values. Replace every credential before any public deployment.
# Deployment selection is consumed by scripts/deploy-up.sh and
# scripts/compose.sh; the application does not try to start an orchestrator.
DEPLOYMENT_TARGET=compose
DEPLOYMENT_ENV=development
COMPOSE_PROJECT_NAME=who_need_help
# split runs independently scalable web and worker replicas behind Traefik.
# compact runs one combined Phoenix + Oban VM directly on HTTP_PORT.
APP_TOPOLOGY=split
# container starts the project-owned PostGIS service. external excludes that
# service completely and connects every application role through DATABASE_URL.
DATABASE_MODE=container
# Only used when DEPLOYMENT_TARGET=kubernetes.
KUBERNETES_MODE=kind
KUBE_CONTEXT=
KUBE_NAMESPACE=who-need-help
KUBE_RELEASE=who-need-help
KUBE_VALUES_FILE=
HTTP_PORT=4010 HTTP_PORT=4010
# Bind the public Compose proxy to loopback when a reverse proxy runs on the # Bind the public Compose proxy to loopback when a reverse proxy runs on the
# same host. The current VPN staging path needs an address reachable by its # same host. The current VPN staging path needs an address reachable by its
@ -82,6 +99,7 @@ DATABASE_URL=ecto://postgres:replace-with-url-encoded-password@db/who_need_help
WEB_POOL_SIZE=4 WEB_POOL_SIZE=4
WORKER_POOL_SIZE=2 WORKER_POOL_SIZE=2
MIGRATE_POOL_SIZE=2 MIGRATE_POOL_SIZE=2
COMBINED_POOL_SIZE=4
OBAN_MAINTENANCE_CONCURRENCY=2 OBAN_MAINTENANCE_CONCURRENCY=2
OBAN_PUSH_CONCURRENCY=1 OBAN_PUSH_CONCURRENCY=1
WEB_REPLICAS=2 WEB_REPLICAS=2

View File

@ -2,6 +2,12 @@
# GENERATE_* marker with an independent local secret and restricts the result # GENERATE_* marker with an independent local secret and restricts the result
# to mode 600. This profile owns a separate Compose project and PostgreSQL # to mode 600. This profile owns a separate Compose project and PostgreSQL
# volume; it must never use the staging project name. # volume; it must never use the staging project name.
DEPLOYMENT_TARGET=compose
DEPLOYMENT_ENV=development
COMPOSE_PROJECT_NAME=who_need_help_load
APP_TOPOLOGY=split
DATABASE_MODE=container
LOAD_PROJECT=who_need_help_load LOAD_PROJECT=who_need_help_load
LOAD_HOST=load.local LOAD_HOST=load.local
HTTP_PORT=4110 HTTP_PORT=4110
@ -24,6 +30,7 @@ DATABASE_URL=GENERATE_DATABASE_URL
WEB_POOL_SIZE=4 WEB_POOL_SIZE=4
WORKER_POOL_SIZE=2 WORKER_POOL_SIZE=2
MIGRATE_POOL_SIZE=2 MIGRATE_POOL_SIZE=2
COMBINED_POOL_SIZE=4
OBAN_MAINTENANCE_CONCURRENCY=2 OBAN_MAINTENANCE_CONCURRENCY=2
OBAN_PUSH_CONCURRENCY=1 OBAN_PUSH_CONCURRENCY=1
WEB_REPLICAS=2 WEB_REPLICAS=2

View File

@ -50,8 +50,8 @@ thank-you link; money goes directly between users outside the platform.
publication are not configured. publication are not configured.
- Local, advisory Codex category review through the user's ChatGPT-authenticated - Local, advisory Codex category review through the user's ChatGPT-authenticated
Codex CLI. It receives a PII-free export and never writes to the database. Codex CLI. It receives a PII-free export and never writes to the database.
- One immutable release image with `web`, `worker`, and one-shot `migrate` - One immutable release image with `web`, `worker`, combined `app`, and
roles. one-shot `migrate` roles.
- Docker Compose and Helm/kind deployment paths with 2 web and 2 worker - Docker Compose and Helm/kind deployment paths with 2 web and 2 worker
replicas by default. replicas by default.
@ -65,7 +65,7 @@ policies are deliberately not claimed as complete.
Prerequisite: Docker with the Compose plugin. Prerequisite: Docker with the Compose plugin.
```bash ```bash
./scripts/compose-up.sh ./scripts/deploy-up.sh .env
``` ```
Open: Open:
@ -77,6 +77,25 @@ Compose starts Traefik, PostGIS, Mailpit, a migration runner, 2 web replicas,
and 2 Oban worker replicas. It waits for readiness and verifies a PubSub message and 2 Oban worker replicas. It waits for readiness and verifies a PubSub message
broadcast from a different BEAM node. Registration emails appear in Mailpit. broadcast from a different BEAM node. Registration emails appear in Mailpit.
Four deployment settings in the ignored environment select the runtime without
editing Compose files:
```dotenv
DEPLOYMENT_TARGET=compose # compose or kubernetes
APP_TOPOLOGY=split # split or compact
DATABASE_MODE=container # container or external
DATABASE_URL=ecto://... # the selected database
```
`split` keeps independently scalable web and worker containers and uses
Traefik; `WEB_REPLICAS` and `WORKER_REPLICAS` default to 2. `compact` starts one
Phoenix+Oban container and exposes it directly to the host reverse proxy. It is
the lower-overhead first-server mode. `container` starts the project-owned
PostGIS service. `external` excludes that service from the active Compose
model, checks PostgreSQL/PostGIS connectivity, applies migrations, and only
then starts the application. There is no Redis service: Oban, rate limits, and
cross-instance coordination use PostgreSQL.
Inspect the exact state: Inspect the exact state:
```bash ```bash
@ -170,9 +189,11 @@ links, and must set `app.mapTileUrl` to a tile service whose policy and capacity
fit the deployment. fit the deployment.
For a first Compose deployment, generate an ignored environment on the target For a first Compose deployment, generate an ignored environment on the target
Docker host. The command derives that host's Docker socket group, generates Docker host. The default is `APP_TOPOLOGY=compact` with a project-owned
independent database, Phoenix, handover, cluster, and metrics secrets without PostGIS container. The command derives that host's Docker socket group,
printing them, writes mode `0600`, and refuses to replace an existing file: generates independent database, Phoenix, handover, cluster, and metrics
secrets without printing them, writes mode `0600`, and refuses to replace an
existing file:
```bash ```bash
./scripts/init-production-env.sh whoneedhelp.com .env.production ./scripts/init-production-env.sh whoneedhelp.com .env.production
@ -185,13 +206,24 @@ 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.production whoneedhelp.com
docker compose \ ./scripts/deploy-up.sh .env.production
--env-file .env.production \
-f compose.yaml \
-f compose.production.yaml \
up -d --wait --build
``` ```
To generate a split deployment against an already provisioned PostgreSQL 18 +
PostGIS database, supply the mode and URL to the initializer:
```bash
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
```
Use `ssl=true` when the database provider requires TLS; the database check
reports whether the observed connection uses TLS without printing the URL or
credentials. Provider-specific CA/network requirements still have to be
configured from that provider's verified documentation.
`compose.production.yaml` leaves local Mailpit stopped. Validation deliberately `compose.production.yaml` leaves local Mailpit stopped. Validation deliberately
fails while the relay still points to Mailpit or a template marker remains. fails while the relay still points to Mailpit or a template marker remains.
It does not claim to test DNS, certificates, actual mail delivery, the It does not claim to test DNS, certificates, actual mail delivery, the

15
compose.compact.yaml Normal file
View File

@ -0,0 +1,15 @@
services:
# The compact topology exposes one combined Phoenix + Oban VM directly to
# the host reverse proxy. Split-only discovery and load-balancing services
# remain defined in the base model but are inactive under this override.
docker-api-proxy:
profiles: [split]
proxy:
profiles: [split]
web:
profiles: [split]
worker:
profiles: [split]

12
compose.external-db.yaml Normal file
View File

@ -0,0 +1,12 @@
services:
# External mode retains the service definition for reusable test/backup
# tooling but excludes it from normal `up`; DATABASE_URL is then the only
# application database destination.
db:
profiles: [container-database]
migrate:
depends_on:
db:
condition: service_healthy
required: false

View File

@ -110,9 +110,12 @@ services:
context: . context: .
dockerfile: Dockerfile.postgis dockerfile: Dockerfile.postgis
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB in .env} # Profiles are resolved after interpolation. These disabled-definition
POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env} # fallbacks let external mode render without inventing live credentials;
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} # scripts/compose.sh requires all three values before container mode.
POSTGRES_DB: ${POSTGRES_DB:-disabled_external_database}
POSTGRES_USER: ${POSTGRES_USER:-disabled_external_database}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-disabled-external-database}
healthcheck: healthcheck:
# The image starts a temporary Unix-socket-only PostgreSQL while it loads # The image starts a temporary Unix-socket-only PostgreSQL while it loads
# PostGIS into a fresh database, then stops it and starts the final server. # PostGIS into a fresh database, then stops it and starts the final server.
@ -153,10 +156,17 @@ services:
APP_ROLE: migrate APP_ROLE: migrate
POOL_SIZE: ${MIGRATE_POOL_SIZE:-${POOL_SIZE:?Set MIGRATE_POOL_SIZE in .env}} POOL_SIZE: ${MIGRATE_POOL_SIZE:-${POOL_SIZE:?Set MIGRATE_POOL_SIZE in .env}}
command: ["/app/bin/migrate"] command: ["/app/bin/migrate"]
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
networks: [internal] networks:
internal:
interface_name: eth0
egress:
interface_name: eth1
gw_priority: 1
read_only: true read_only: true
tmpfs: tmpfs:
- /tmp - /tmp
@ -165,6 +175,41 @@ services:
- no-new-privileges:true - no-new-privileges:true
restart: "no" restart: "no"
app:
profiles: [compact]
image: who-need-help:local
environment:
<<: *app-environment
APP_ROLE: combined
PHX_SERVER: "true"
DNS_CLUSTER_QUERY: ignore
POOL_SIZE: ${COMBINED_POOL_SIZE:-${WEB_POOL_SIZE:-4}}
depends_on:
migrate:
condition: service_completed_successfully
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "${HTTP_BIND_ADDRESS:-127.0.0.1}:${HTTP_PORT:-4010}:4000"
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"]
interval: 10s
timeout: 3s
retries: 10
networks:
internal:
interface_name: eth0
egress:
interface_name: eth1
gw_priority: 1
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
restart: unless-stopped
web: web:
image: who-need-help:local image: who-need-help:local
environment: environment:
@ -175,6 +220,8 @@ services:
depends_on: depends_on:
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
extra_hosts:
- "host.docker.internal:host-gateway"
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network=${TRAEFIK_DOCKER_NETWORK:-who_need_help_ingress} - traefik.docker.network=${TRAEFIK_DOCKER_NETWORK:-who_need_help_ingress}
@ -218,6 +265,8 @@ services:
depends_on: depends_on:
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck: healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"] test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"]
interval: 10s interval: 10s

View File

@ -4,8 +4,9 @@ app_role =
case System.get_env("APP_ROLE", "web") do case System.get_env("APP_ROLE", "web") do
"web" -> :web "web" -> :web
"worker" -> :worker "worker" -> :worker
"combined" -> :combined
"migrate" -> :migrate "migrate" -> :migrate
other -> raise "APP_ROLE must be web, worker, or migrate; got #{inspect(other)}" other -> raise "APP_ROLE must be web, worker, combined, or migrate; got #{inspect(other)}"
end end
rate_limit_policies = rate_limit_policies =
@ -265,23 +266,27 @@ config :who_need_help,
), ),
push_delivery_options: Keyword.delete(push_configuration, :adapter) push_delivery_options: Keyword.delete(push_configuration, :adapter)
if config_env() == :prod and app_role in [:web, :worker] do if config_env() == :prod and app_role in [:web, :worker, :combined] do
metrics_token = metrics_token =
System.get_env("METRICS_TOKEN") || System.get_env("METRICS_TOKEN") ||
raise """ raise """
environment variable METRICS_TOKEN is missing for the web or worker role. environment variable METRICS_TOKEN is missing for the web, worker, or combined role.
Generate an independent random value and store it in the deployment secret. Generate an independent random value and store it in the deployment secret.
""" """
if metrics_token == "" do if metrics_token == "" do
raise "METRICS_TOKEN must not be empty for the web or worker role." raise "METRICS_TOKEN must not be empty for the web, worker, or combined role."
end end
config :who_need_help, :metrics_token, metrics_token config :who_need_help, :metrics_token, metrics_token
end end
if dns_query = System.get_env("DNS_CLUSTER_QUERY") do case System.get_env("DNS_CLUSTER_QUERY") do
config :who_need_help, :dns_cluster_query, dns_query query when query in [nil, "", "ignore"] ->
config :who_need_help, :dns_cluster_query, :ignore
query ->
config :who_need_help, :dns_cluster_query, query
end end
# config/runtime.exs is executed for all environments, including # config/runtime.exs is executed for all environments, including

View File

@ -5,12 +5,14 @@ Status: target architecture for the first working release.
## System shape ## System shape
Who Need Help is a modular Phoenix application rather than a collection of Who Need Help is a modular Phoenix application rather than a collection of
premature microservices. It produces one immutable image with three runtime premature microservices. It produces one immutable image with four runtime
roles: roles:
- `web`: Phoenix Endpoint, LiveView, PubSub, Presence, and a producerless Oban - `web`: Phoenix Endpoint, LiveView, PubSub, Presence, and a producerless Oban
client for atomic job insertion. client for atomic job insertion.
- `worker`: Oban queues and scheduled jobs; no public HTTP listener. - `worker`: Oban queues and scheduled jobs; no public HTTP listener.
- `combined`: Phoenix Endpoint and the full Oban queues in one BEAM VM for a
lower-overhead single-server deployment.
- `migrate`: a one-shot database migration command before rollout. - `migrate`: a one-shot database migration command before rollout.
PostgreSQL with PostGIS is the system of record. User-visible writes are PostgreSQL with PostGIS is the system of record. User-visible writes are
@ -96,7 +98,7 @@ object storage rather than a container filesystem.
Web and migrate application processes start a producerless Oban client with no Web and migrate application processes start a producerless Oban client with no
queues, plugins, or peer leadership so transactions can insert unique jobs. queues, plugins, or peer leadership so transactions can insert unique jobs.
Only the worker role starts queue consumers and scheduled-job plugins. The worker and combined roles start queue consumers and scheduled-job plugins.
PostgreSQL coordinates queues and leadership, so no Redis dependency is PostgreSQL coordinates queues and leadership, so no Redis dependency is
introduced. The worker runs only the queues used by product code: introduced. The worker runs only the queues used by product code:
`maintenance` for expiry/probes and `push` for provider-neutral delivery. `maintenance` for expiry/probes and `push` for provider-neutral delivery.
@ -133,7 +135,18 @@ policy, not an application backend.
### Docker Compose ### Docker Compose
Normal development starts: The Compose launcher selects two independent dimensions from the ignored
environment:
- `APP_TOPOLOGY=split` starts Traefik plus independently scalable web and
worker replicas. Normal development defaults to 2 web and 2 worker replicas.
- `APP_TOPOLOGY=compact` starts one combined Phoenix+Oban VM and publishes its
port directly to the host reverse proxy.
- `DATABASE_MODE=container` starts one project-owned PostGIS container.
- `DATABASE_MODE=external` removes the PostGIS service from the active Compose
model and connects migrate/application roles through `DATABASE_URL`.
The normal split development combination starts:
- Traefik - Traefik
- 2 × web - 2 × web
@ -146,6 +159,11 @@ uses an isolated Compose project name and network. PostgreSQL is intentionally
single-instance in the local profile; multiple uncoordinated containers would single-instance in the local profile; multiple uncoordinated containers would
not create database high availability. not create database high availability.
Kubernetes is never started by Docker Compose. `DEPLOYMENT_TARGET=kubernetes`
is handled explicitly by `scripts/deploy-up.sh`; `KUBERNETES_MODE=kind` is the
local verification path and `KUBERNETES_MODE=helm` is the external-cluster
path.
### Kubernetes ### Kubernetes
The Helm chart contains separate web and worker Deployments, Services, a The Helm chart contains separate web and worker Deployments, Services, a

View File

@ -36,6 +36,18 @@ run URL and commit before protecting `main`.
## First production Compose environment ## First production Compose environment
The deployment environment selects topology and database ownership:
| Setting | Result |
| --- | --- |
| `APP_TOPOLOGY=compact` | One Phoenix+Oban container, directly published to the host; default for the first server. |
| `APP_TOPOLOGY=split` | Traefik, `WEB_REPLICAS` web containers, and `WORKER_REPLICAS` worker containers. |
| `DATABASE_MODE=container` | Start the project-owned PostgreSQL/PostGIS container and volume. |
| `DATABASE_MODE=external` | Do not create/start a Compose database; use only `DATABASE_URL`. |
There is no Redis dependency. Queues, rate-limit counters, Oban leadership,
and durable application state use PostgreSQL.
Run the initializer on the target Docker host after its final public hostname Run the initializer on the target Docker host after its final public hostname
is known: is known:
@ -66,11 +78,7 @@ 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.production whoneedhelp.com
docker compose \ ./scripts/deploy-up.sh .env.production
--env-file .env.production \
-f compose.yaml \
-f compose.production.yaml \
up -d --wait --build
``` ```
The production override keeps Mailpit behind its inactive `local-mail` profile, The production override keeps Mailpit behind its inactive `local-mail` profile,
@ -85,6 +93,36 @@ register a unique address through the public browser, receive its message at
the real mailbox, follow the HTTPS confirmation link, and remove only that the real mailbox, follow the HTTPS confirmation link, and remove only that
run-scoped account. run-scoped account.
### External PostgreSQL/PostGIS
Provision the database and role first, then generate the environment without
placing its credentials on a command line that is retained in shell history:
```bash
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
unset PRODUCTION_DATABASE_URL
```
Set `PRODUCTION_APP_TOPOLOGY=split` during generation when independent web and
worker scaling is required. In external mode `scripts/compose-up.sh` builds the
migration image, runs `scripts/check-database.sh`, and fails before migration
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
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.
The repository's Compose backup, restore, rotation, and rollback-based staging
scripts deliberately refuse `DATABASE_MODE=external`; they are scoped to the
project-owned database container. For an external database, use the provider's
verified backup/restore procedure and test recovery before public launch.
## Compose database backup ## Compose database backup
Create a PostgreSQL 18 custom-format archive, validate its table of contents, Create a PostgreSQL 18 custom-format archive, validate its table of contents,

View File

@ -54,6 +54,14 @@ sample measured 190.1205.5 MiB per application container and 168.3 MiB for
PostgreSQL. These local observations do not include a target server's OS and do PostgreSQL. These local observations do not include a target server's OS and do
not establish that a 1 GiB or 2 GiB machine is sufficient for production. not establish that a 1 GiB or 2 GiB machine is sufficient for production.
On 2026-07-21, an isolated production-compiled compact Compose run measured
198.3 MiB for the combined Phoenix+Oban container and 70.9 MiB for its fresh
PostgreSQL/PostGIS container in one no-load snapshot. An isolated split run
against a separately started external-database fixture measured 207.2219.6
MiB for each of two web and two worker containers, 20.05 MiB for the Docker API
proxy, 41.66 MiB for Traefik, and 117.6 MiB for that database fixture. These are
short workstation observations, not minimums, limits, or capacity claims.
The profile uses a separate Compose project, generated independent secrets, and The profile uses a separate Compose project, generated independent secrets, and
a separate PostgreSQL volume. Its Traefik instance is constrained to that exact a separate PostgreSQL volume. Its Traefik instance is constrained to that exact
Compose project; its router, service, middleware, and Host rule are unique, so Compose project; its router, service, middleware, and Host rule are unique, so

View File

@ -1049,6 +1049,49 @@ is `output/external-boundaries/preprod-gitea-pass`.
proxy, two web replicas and two worker replicas. This workstation snapshot is proxy, two web replicas and two worker replicas. This workstation snapshot is
not a production minimum, limit or capacity claim. not a production minimum, limit or capacity claim.
## Compose topology and database-mode verification on 2026-07-21
- The current ExUnit suite passed 260/260 after adding the combined runtime
role and deployment-mode selection.
- The complete isolated quality/security gate passed: ShellCheck, Hadolint,
actionlint, all Compose mode renders and production-environment rejection
cases, Helm lint/render, tracked-source scanning, format, warnings-as-errors
compilation, xref, Credo, Sobelow, Dialyzer, Hex/npm audits, 260/260 ExUnit
tests, and every configured runtime/infrastructure image scan. The
configured HIGH/CRITICAL finding count was zero.
- A fresh isolated `compact + container` production render contained only
`db`, one-shot `migrate`, and `app`. Readiness and a local PubSub probe passed;
the running combined container reported the expected 65,536 Erlang port
limit. Its exact containers, networks, and database volume were removed.
- A separate PostgreSQL 18.4/PostGIS 3.6 fixture was published only on the
Docker bridge gateway. The `split + external` render contained migrate,
Docker API proxy, Traefik, two web replicas, and two worker replicas, and did
not contain a `db` service or database volume. The preflight reported
PostgreSQL/PostGIS versions and `TLS=false` for that local fixture, all 13
migrations applied, readiness passed, all four BEAM nodes joined, and a
cross-node PubSub broadcast passed. Its logs contained no error marker and
all run-scoped containers, networks, fixture container, and fixture volume
were removed.
- The first compact drill exposed an isolation defect: the generated
production environment lacked `COMPOSE_PROJECT_NAME`, so Compose selected
the ordinary project name and recreated its database container with the
drill's environment. Authentication failed before any migration ran and the
compact app remained in `Created`. Read-only checks still found 13
migrations, 2 users, and 1 request. Backup
`output/backups/compose-20260720-223237.dump` was created, the ordinary split
stack was recreated with its original ignored `.env`, the same counts and
HTTP readiness were rechecked, and the never-started compact container was
removed. The initializer now writes and validates an explicit project name,
and both subsequent isolated drills used distinct verified project names.
- The final ordinary development stack again reported two healthy web and two
healthy worker replicas, cross-node PubSub, local and temporary-public-origin
readiness, unchanged counts (13 migrations, 2 users, 1 request), and no
error marker in the post-rollout application/proxy log window.
These checks establish the mode wiring on this workstation. They do not prove
the future provider's TLS/CA policy, network reachability, backup service, high
availability, or target-server capacity.
## Known work before a public production launch ## Known work before a public production launch
- Replace the temporary staging origin with the production-owned domain and - Replace the temporary staging origin with the production-owned domain and

View File

@ -37,6 +37,14 @@ defmodule WhoNeedHelp.Application do
:worker -> :worker ->
[{Oban, oban_config}, WhoNeedHelpWeb.WorkerMetricsPlug] [{Oban, oban_config}, WhoNeedHelpWeb.WorkerMetricsPlug]
:combined ->
[
{Oban, oban_config},
WhoNeedHelpWeb.Presence,
WhoNeedHelp.TrackingPresenceCleanup,
WhoNeedHelpWeb.Endpoint
]
:migrate -> :migrate ->
[{Oban, oban_client_config}] [{Oban, oban_client_config}]
end end
@ -53,7 +61,7 @@ defmodule WhoNeedHelp.Application do
# whenever the application is updated. # whenever the application is updated.
@impl true @impl true
def config_change(changed, _new, removed) do def config_change(changed, _new, removed) do
if Application.get_env(:who_need_help, :app_role) == :web do if Application.get_env(:who_need_help, :app_role) in [:web, :combined] do
WhoNeedHelpWeb.Endpoint.config_change(changed, removed) WhoNeedHelpWeb.Endpoint.config_change(changed, removed)
end end

View File

@ -9,7 +9,7 @@ defmodule WhoNeedHelpWeb.Telemetry do
@impl true @impl true
def init(_arg) do def init(_arg) do
reporter_children = reporter_children =
if Application.fetch_env!(:who_need_help, :app_role) in [:web, :worker] do if Application.fetch_env!(:who_need_help, :app_role) in [:web, :worker, :combined] do
[ [
{TelemetryMetricsPrometheus.Core, {TelemetryMetricsPrometheus.Core,
name: :prometheus_metrics, metrics: prometheus_metrics(), start_async: false} name: :prometheus_metrics, metrics: prometheus_metrics(), start_async: false}

View File

@ -49,6 +49,11 @@ set -a
. "$ENV_FILE" . "$ENV_FILE"
set +a set +a
if [[ "${DATABASE_MODE:-container}" != container ]]; then
echo "This rollback-based Android/browser drill requires DATABASE_MODE=container." >&2
exit 1
fi
: "${POSTGRES_DB:?POSTGRES_DB is missing from .env}" : "${POSTGRES_DB:?POSTGRES_DB is missing from .env}"
: "${WNH_BASE_URL:?WNH_BASE_URL is missing from .env}" : "${WNH_BASE_URL:?WNH_BASE_URL is missing from .env}"
: "${WNH_TRACKING_MIN_TIME_MS:?WNH_TRACKING_MIN_TIME_MS is missing from .env}" : "${WNH_TRACKING_MIN_TIME_MS:?WNH_TRACKING_MIN_TIME_MS is missing from .env}"

View File

@ -15,6 +15,12 @@ set -a
. "$ROOT/.env" . "$ROOT/.env"
set +a set +a
if [ "${DATABASE_MODE:-container}" != container ]; then
echo "backup-compose.sh only operates on DATABASE_MODE=container." >&2
echo "Use the external PostgreSQL provider's verified backup workflow instead." >&2
exit 1
fi
: "${POSTGRES_DB:?Set POSTGRES_DB in .env}" : "${POSTGRES_DB:?Set POSTGRES_DB in .env}"
: "${POSTGRES_USER:?Set POSTGRES_USER in .env}" : "${POSTGRES_USER:?Set POSTGRES_USER in .env}"
@ -38,7 +44,7 @@ cleanup() {
} }
trap cleanup EXIT HUP INT TERM trap cleanup EXIT HUP INT TERM
docker compose exec -T db \ "$ROOT/scripts/compose.sh" "$ROOT/.env" exec -T db \
pg_dump \ pg_dump \
--username "$POSTGRES_USER" \ --username "$POSTGRES_USER" \
--dbname "$POSTGRES_DB" \ --dbname "$POSTGRES_DB" \
@ -47,7 +53,7 @@ docker compose exec -T db \
test -s "$partial" test -s "$partial"
docker compose exec -T db pg_restore --list <"$partial" >/dev/null "$ROOT/scripts/compose.sh" "$ROOT/.env" exec -T db pg_restore --list <"$partial" >/dev/null
hash=$(sha256sum "$partial" | awk '{print $1}') hash=$(sha256sum "$partial" | awk '{print $1}')
printf '%s %s\n' "$hash" "$target_name" >"$checksum_partial" printf '%s %s\n' "$hash" "$target_name" >"$checksum_partial"

28
scripts/check-database.sh Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
env_file=${1:-"$ROOT/.env"}
"$ROOT/scripts/compose.sh" "$env_file" run --rm --no-deps migrate \
/app/bin/who_need_help eval '
Application.ensure_all_started(:ecto_sql)
{:ok, _pid} = WhoNeedHelp.Repo.start_link()
{:ok, %{rows: [[server, postgis, ssl]]}} =
Ecto.Adapters.SQL.query(
WhoNeedHelp.Repo,
"""
SELECT
version(),
PostGIS_Version(),
COALESCE(
(SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()),
false
)
""",
[]
)
IO.puts("Database check passed: PostgreSQL #{server}; PostGIS #{postgis}; TLS=#{ssl}")
'

View File

@ -139,6 +139,10 @@ 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)
cat >"$env_file" <<EOF cat >"$env_file" <<EOF
DEPLOYMENT_TARGET=compose
DEPLOYMENT_ENV=development
APP_TOPOLOGY=split
DATABASE_MODE=container
HTTP_PORT=0 HTTP_PORT=0
MAILPIT_PORT=0 MAILPIT_PORT=0
DOCKER_SOCKET_GID=$docker_socket_gid DOCKER_SOCKET_GID=$docker_socket_gid
@ -165,6 +169,7 @@ DATABASE_URL=ecto://$postgres_user:$postgres_password@db/$postgres_db
WEB_POOL_SIZE=4 WEB_POOL_SIZE=4
WORKER_POOL_SIZE=2 WORKER_POOL_SIZE=2
MIGRATE_POOL_SIZE=2 MIGRATE_POOL_SIZE=2
COMBINED_POOL_SIZE=4
OBAN_MAINTENANCE_CONCURRENCY=2 OBAN_MAINTENANCE_CONCURRENCY=2
OBAN_PUSH_CONCURRENCY=1 OBAN_PUSH_CONCURRENCY=1
WEB_REPLICAS=2 WEB_REPLICAS=2

View File

@ -3,8 +3,43 @@ set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT" cd "$ROOT"
ENV_FILE=${1:-"$ROOT/.env"}
case "$ENV_FILE" in
/*) ;;
*) ENV_FILE="$ROOT/$ENV_FILE" ;;
esac
if [ ! -f "$ENV_FILE" ]; then
echo "Deployment environment does not exist: $ENV_FILE" >&2
exit 1
fi
read_env_value() {
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"
}
DATABASE_MODE=$(read_env_value DATABASE_MODE 2>/dev/null || printf container)
COMPOSE_PROJECT_NAME=$(read_env_value COMPOSE_PROJECT_NAME 2>/dev/null || printf who_need_help)
export COMPOSE_PROJECT_NAME
CODEX_SESSION_ID=${CODEX_SESSION_ID:-${CODEX_THREAD_ID:-not-configured}} CODEX_SESSION_ID=${CODEX_SESSION_ID:-${CODEX_THREAD_ID:-not-configured}}
export CODEX_SESSION_ID export CODEX_SESSION_ID
docker compose -p who_need_help up -d --build --wait
if [ "$DATABASE_MODE" = external ]; then
"$ROOT/scripts/compose.sh" "$ENV_FILE" build migrate
"$ROOT/scripts/check-database.sh" "$ENV_FILE"
fi
"$ROOT/scripts/compose.sh" "$ENV_FILE" up -d --build --wait --remove-orphans
"$ROOT/scripts/verify-realtime-cluster.sh" compose "$ROOT/scripts/verify-realtime-cluster.sh" compose
"$ROOT/scripts/verify-beam-runtime.sh" compose "$ROOT/scripts/verify-beam-runtime.sh" compose

93
scripts/compose.sh Executable file
View File

@ -0,0 +1,93 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
env_file=${1:-"$ROOT/.env"}
shift || true
if [[ "$env_file" != /* ]]; then
env_file="$ROOT/$env_file"
fi
if [[ ! -f "$env_file" ]]; then
echo "Deployment environment does not exist: $env_file" >&2
exit 1
fi
read_env_value() {
local key=$1
if [[ -v "$key" ]]; then
printf '%s' "${!key}"
return
fi
awk -v key="$key" '
index($0, key "=") == 1 {
print substr($0, length(key) + 2)
found = 1
exit
}
END { if (!found) exit 1 }
' "$env_file"
}
deployment_target=$(read_env_value DEPLOYMENT_TARGET 2>/dev/null || printf compose)
deployment_environment=$(read_env_value DEPLOYMENT_ENV 2>/dev/null || printf development)
database_mode=$(read_env_value DATABASE_MODE 2>/dev/null || printf container)
app_topology=$(read_env_value APP_TOPOLOGY 2>/dev/null || printf split)
project=$(read_env_value COMPOSE_PROJECT_NAME 2>/dev/null || printf who_need_help)
[[ "$deployment_target" == compose ]] || {
echo "scripts/compose.sh requires DEPLOYMENT_TARGET=compose." >&2
exit 1
}
case "$deployment_environment" in
development | production) ;;
*) echo "DEPLOYMENT_ENV must be development or production." >&2; exit 1 ;;
esac
case "$database_mode" in
container | external) ;;
*) echo "DATABASE_MODE must be container or external." >&2; exit 1 ;;
esac
database_url=$(read_env_value DATABASE_URL 2>/dev/null || true)
: "${database_url:?Set DATABASE_URL in $env_file}"
if [[ "$database_mode" == container ]]; then
postgres_db=$(read_env_value POSTGRES_DB 2>/dev/null || true)
postgres_user=$(read_env_value POSTGRES_USER 2>/dev/null || true)
postgres_password=$(read_env_value POSTGRES_PASSWORD 2>/dev/null || true)
: "${postgres_db:?Set POSTGRES_DB for DATABASE_MODE=container}"
: "${postgres_user:?Set POSTGRES_USER for DATABASE_MODE=container}"
: "${postgres_password:?Set POSTGRES_PASSWORD for DATABASE_MODE=container}"
fi
case "$app_topology" in
compact | split) ;;
*) echo "APP_TOPOLOGY must be compact or split." >&2; exit 1 ;;
esac
compose=(
docker compose
--project-directory "$ROOT"
--project-name "$project"
--env-file "$env_file"
--file "$ROOT/compose.yaml"
)
if [[ "$database_mode" == external ]]; then
compose+=(--file "$ROOT/compose.external-db.yaml")
fi
if [[ "$app_topology" == compact ]]; then
compose+=(--file "$ROOT/compose.compact.yaml" --profile compact)
fi
if [[ "$deployment_environment" == production ]]; then
compose+=(--file "$ROOT/compose.production.yaml")
fi
exec "${compose[@]}" "$@"

88
scripts/deploy-up.sh Executable file
View File

@ -0,0 +1,88 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
env_file=${1:-"$ROOT/.env"}
if [[ "$env_file" != /* ]]; then
env_file="$ROOT/$env_file"
fi
if [[ ! -f "$env_file" ]]; then
echo "Deployment environment does not exist: $env_file" >&2
exit 1
fi
read_env_value() {
local key=$1
awk -v key="$key" '
index($0, key "=") == 1 {
print substr($0, length(key) + 2)
found = 1
exit
}
END { if (!found) exit 1 }
' "$env_file"
}
deployment_target=$(read_env_value DEPLOYMENT_TARGET 2>/dev/null || printf compose)
database_mode=$(read_env_value DATABASE_MODE 2>/dev/null || printf container)
case "$deployment_target" in
compose)
exec "$ROOT/scripts/compose-up.sh" "$env_file"
;;
kubernetes)
kubernetes_mode=$(read_env_value KUBERNETES_MODE 2>/dev/null || true)
case "${kubernetes_mode:-kind}" in
kind)
[[ "$database_mode" == container ]] || {
echo "The project-owned kind drill requires DATABASE_MODE=container." >&2
exit 1
}
exec "$ROOT/scripts/kind-up.sh"
;;
helm)
[[ "$database_mode" == external ]] || {
echo "A production Helm deployment requires DATABASE_MODE=external." >&2
exit 1
}
KUBE_CONTEXT=$(read_env_value KUBE_CONTEXT 2>/dev/null || true)
KUBE_NAMESPACE=$(read_env_value KUBE_NAMESPACE 2>/dev/null || true)
KUBE_RELEASE=$(read_env_value KUBE_RELEASE 2>/dev/null || true)
KUBE_VALUES_FILE=$(read_env_value KUBE_VALUES_FILE 2>/dev/null || true)
: "${KUBE_CONTEXT:?Set KUBE_CONTEXT for KUBERNETES_MODE=helm}"
: "${KUBE_NAMESPACE:?Set KUBE_NAMESPACE for KUBERNETES_MODE=helm}"
: "${KUBE_RELEASE:?Set KUBE_RELEASE for KUBERNETES_MODE=helm}"
: "${KUBE_VALUES_FILE:?Set KUBE_VALUES_FILE for KUBERNETES_MODE=helm}"
if [[ "$KUBE_VALUES_FILE" != /* ]]; then
KUBE_VALUES_FILE="$ROOT/$KUBE_VALUES_FILE"
fi
[[ -f "$KUBE_VALUES_FILE" ]] || {
echo "KUBE_VALUES_FILE does not exist: $KUBE_VALUES_FILE" >&2
exit 1
}
"$ROOT/scripts/bootstrap-kubernetes-tools.sh"
exec "$ROOT/.tools/bin/helm" upgrade --install "$KUBE_RELEASE" \
"$ROOT/deploy/helm/who-need-help" \
--kube-context "$KUBE_CONTEXT" \
--namespace "$KUBE_NAMESPACE" \
--values "$KUBE_VALUES_FILE" \
--wait
;;
*)
echo "KUBERNETES_MODE must be kind or helm." >&2
exit 1
;;
esac
;;
*)
echo "DEPLOYMENT_TARGET must be compose or kubernetes." >&2
exit 1
;;
esac

View File

@ -43,6 +43,10 @@ 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)
cat >"$target" <<EOF cat >"$target" <<EOF
DEPLOYMENT_TARGET=compose
DEPLOYMENT_ENV=development
APP_TOPOLOGY=split
DATABASE_MODE=container
HTTP_PORT=0 HTTP_PORT=0
MAILPIT_PORT=0 MAILPIT_PORT=0
DOCKER_SOCKET_GID=$docker_socket_gid DOCKER_SOCKET_GID=$docker_socket_gid
@ -63,6 +67,7 @@ DATABASE_URL=ecto://postgres:$postgres_password@db/who_need_help_e2e
WEB_POOL_SIZE=4 WEB_POOL_SIZE=4
WORKER_POOL_SIZE=2 WORKER_POOL_SIZE=2
MIGRATE_POOL_SIZE=2 MIGRATE_POOL_SIZE=2
COMBINED_POOL_SIZE=4
OBAN_MAINTENANCE_CONCURRENCY=2 OBAN_MAINTENANCE_CONCURRENCY=2
OBAN_PUSH_CONCURRENCY=1 OBAN_PUSH_CONCURRENCY=1
WEB_REPLICAS=2 WEB_REPLICAS=2

View File

@ -44,7 +44,41 @@ if [ ! -d "$target_dir" ]; then
exit 1 exit 1
fi fi
database_mode=${PRODUCTION_DATABASE_MODE:-container}
app_topology=${PRODUCTION_APP_TOPOLOGY:-compact}
compose_project_name=${PRODUCTION_COMPOSE_PROJECT_NAME:-who_need_help}
case "$compose_project_name" in
*[!a-zA-Z0-9_-]* | '')
echo "PRODUCTION_COMPOSE_PROJECT_NAME must contain only letters, numbers, underscores, or hyphens." >&2
exit 1
;;
esac
case "$database_mode" in
container)
postgres_password=$(openssl rand -hex 32) postgres_password=$(openssl rand -hex 32)
database_url="ecto://postgres:$postgres_password@db/who_need_help"
;;
external)
postgres_password=
database_url=${PRODUCTION_DATABASE_URL:-}
if [ -z "$database_url" ]; then
echo "PRODUCTION_DATABASE_URL is required when PRODUCTION_DATABASE_MODE=external." >&2
exit 1
fi
;;
*)
echo "PRODUCTION_DATABASE_MODE must be container or external." >&2
exit 1
;;
esac
case "$app_topology" in
compact | split) ;;
*) echo "PRODUCTION_APP_TOPOLOGY must be compact or split." >&2; exit 1 ;;
esac
secret_key_base=$(openssl rand -hex 64) secret_key_base=$(openssl rand -hex 64)
handover_secret=$(openssl rand -hex 64) handover_secret=$(openssl rand -hex 64)
release_cookie=$(openssl rand -hex 64) release_cookie=$(openssl rand -hex 64)
@ -52,6 +86,7 @@ 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)
http_bind_address=${PRODUCTION_HTTP_BIND_ADDRESS:-127.0.0.1} http_bind_address=${PRODUCTION_HTTP_BIND_ADDRESS:-127.0.0.1}
http_port=${PRODUCTION_HTTP_PORT:-4010}
trusted_proxy_ips=${PRODUCTION_TRAEFIK_TRUSTED_IPS:-REPLACE_WITH_VERIFIED_PROXY_IP_OR_CIDR} trusted_proxy_ips=${PRODUCTION_TRAEFIK_TRUSTED_IPS:-REPLACE_WITH_VERIFIED_PROXY_IP_OR_CIDR}
smtp_relay=${PRODUCTION_SMTP_RELAY:-REPLACE_WITH_TRANSACTIONAL_SMTP_RELAY} smtp_relay=${PRODUCTION_SMTP_RELAY:-REPLACE_WITH_TRANSACTIONAL_SMTP_RELAY}
smtp_port=${PRODUCTION_SMTP_PORT:-587} smtp_port=${PRODUCTION_SMTP_PORT:-587}
@ -67,11 +102,15 @@ trap 'rm -f "$tmp"' EXIT HUP INT TERM
chmod 600 "$tmp" chmod 600 "$tmp"
DOMAIN=$domain \ DOMAIN=$domain \
DATABASE_MODE_VALUE=$database_mode \
APP_TOPOLOGY_VALUE=$app_topology \
COMPOSE_PROJECT_NAME_VALUE=$compose_project_name \
HTTP_BIND_ADDRESS_VALUE=$http_bind_address \ HTTP_BIND_ADDRESS_VALUE=$http_bind_address \
HTTP_PORT_VALUE=$http_port \
DOCKER_SOCKET_GID_VALUE=$docker_socket_gid \ DOCKER_SOCKET_GID_VALUE=$docker_socket_gid \
TRUSTED_PROXY_IPS_VALUE=$trusted_proxy_ips \ TRUSTED_PROXY_IPS_VALUE=$trusted_proxy_ips \
POSTGRES_PASSWORD_VALUE=$postgres_password \ POSTGRES_PASSWORD_VALUE=$postgres_password \
DATABASE_URL_VALUE="ecto://postgres:$postgres_password@db/who_need_help" \ DATABASE_URL_VALUE=$database_url \
SECRET_KEY_BASE_VALUE=$secret_key_base \ SECRET_KEY_BASE_VALUE=$secret_key_base \
HANDOVER_SECRET_VALUE=$handover_secret \ HANDOVER_SECRET_VALUE=$handover_secret \
RELEASE_COOKIE_VALUE=$release_cookie \ RELEASE_COOKIE_VALUE=$release_cookie \
@ -86,9 +125,17 @@ SMTP_SSL_VALUE=$smtp_ssl \
EMAIL_FROM_ADDRESS_VALUE=$email_from_address \ EMAIL_FROM_ADDRESS_VALUE=$email_from_address \
awk ' awk '
BEGIN { BEGIN {
replacement["DEPLOYMENT_TARGET"] = "compose"
replacement["DEPLOYMENT_ENV"] = "production"
replacement["COMPOSE_PROJECT_NAME"] = ENVIRON["COMPOSE_PROJECT_NAME_VALUE"]
replacement["APP_TOPOLOGY"] = ENVIRON["APP_TOPOLOGY_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"]
replacement["HTTP_PORT"] = ENVIRON["HTTP_PORT_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_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"
replacement["PHX_URL_PORT"] = "443" replacement["PHX_URL_PORT"] = "443"

View File

@ -103,6 +103,44 @@ PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \
./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
external_production_env="$scan_dir/.env.production.external-db"
PRODUCTION_DATABASE_MODE=external \
PRODUCTION_DATABASE_URL=ecto://quality:external-password@database.internal/who_need_help \
PRODUCTION_SMTP_RELAY=smtp.help.test \
PRODUCTION_SMTP_PORT=587 \
PRODUCTION_SMTP_USERNAME=quality-user \
PRODUCTION_SMTP_PASSWORD=quality-password \
PRODUCTION_SMTP_AUTH=always \
PRODUCTION_SMTP_TLS=always \
PRODUCTION_SMTP_SSL=false \
PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \
./scripts/init-production-env.sh help.test "$external_production_env" >/dev/null
./scripts/validate-production-env.sh "$external_production_env" help.test >/dev/null
external_split_production_env="$scan_dir/.env.production.external-db-split"
PRODUCTION_APP_TOPOLOGY=split \
PRODUCTION_DATABASE_MODE=external \
PRODUCTION_DATABASE_URL=ecto://quality:external-password@database.internal/who_need_help \
PRODUCTION_TRAEFIK_TRUSTED_IPS=172.20.0.1/32 \
PRODUCTION_SMTP_RELAY=smtp.help.test \
PRODUCTION_SMTP_PORT=587 \
PRODUCTION_SMTP_USERNAME=quality-user \
PRODUCTION_SMTP_PASSWORD=quality-password \
PRODUCTION_SMTP_AUTH=always \
PRODUCTION_SMTP_TLS=always \
PRODUCTION_SMTP_SSL=false \
PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \
./scripts/init-production-env.sh help.test "$external_split_production_env" >/dev/null
./scripts/validate-production-env.sh "$external_split_production_env" help.test >/dev/null
invalid_external_env="$scan_dir/.env.production.invalid-external-db"
cp "$external_production_env" "$invalid_external_env"
chmod 600 "$invalid_external_env"
sed -i 's#^DATABASE_URL=.*#DATABASE_URL=ecto://quality:external-password@db/who_need_help#' \
"$invalid_external_env"
if ./scripts/validate-production-env.sh \
"$invalid_external_env" help.test >/dev/null 2>&1; then
echo "Production environment validator accepted the Compose db host in external mode." >&2
exit 1
fi
partial_google_env="$scan_dir/.env.production.partial-google" partial_google_env="$scan_dir/.env.production.partial-google"
cp "$production_env" "$partial_google_env" cp "$production_env" "$partial_google_env"
chmod 600 "$partial_google_env" chmod 600 "$partial_google_env"
@ -126,10 +164,11 @@ if ./scripts/validate-production-env.sh \
fi fi
echo "Rendering every Docker Compose profile" echo "Rendering every Docker Compose profile"
docker compose --env-file .env.example -f compose.yaml config --quiet ./scripts/compose.sh .env.example config --quiet
docker compose --env-file "$production_env" \ ./scripts/compose.sh "$production_env" config --quiet
-f compose.yaml -f compose.production.yaml config --quiet ./scripts/compose.sh "$external_production_env" config --quiet
docker compose --env-file .env.example -f compose.yaml config --format json | ./scripts/compose.sh "$external_split_production_env" config --quiet
./scripts/compose.sh .env.example config --format json |
jq --exit-status ' jq --exit-status '
. as $root . as $root
| [$root.services.migrate, $root.services.web, $root.services.worker] | [$root.services.migrate, $root.services.web, $root.services.worker]
@ -151,6 +190,7 @@ docker compose --env-file .env.example -f compose.yaml config --format json |
and ($root.services.proxy.networks | keys | sort) == ["docker-api", "edge", "ingress"] and ($root.services.proxy.networks | keys | sort) == ["docker-api", "edge", "ingress"]
and ($root.services.web.networks | keys | sort) == ["egress", "ingress", "internal"] and ($root.services.web.networks | keys | sort) == ["egress", "ingress", "internal"]
and ($root.services.worker.networks | keys | sort) == ["egress", "internal"] and ($root.services.worker.networks | keys | sort) == ["egress", "internal"]
and ($root.services.migrate.networks | keys | sort) == ["egress", "internal"]
and $root.services.web.networks.internal.interface_name == "eth0" and $root.services.web.networks.internal.interface_name == "eth0"
and $root.services.web.networks.internal.aliases == ["cluster-web"] and $root.services.web.networks.internal.aliases == ["cluster-web"]
and $root.services.web.networks.ingress.interface_name == "eth1" and $root.services.web.networks.ingress.interface_name == "eth1"
@ -167,13 +207,45 @@ docker compose --env-file .env.example -f compose.yaml config --format json |
and $root.services.proxy.ports[0].host_ip == "0.0.0.0" and $root.services.proxy.ports[0].host_ip == "0.0.0.0"
and $root.services.mailpit.ports[0].host_ip == "127.0.0.1" and $root.services.mailpit.ports[0].host_ip == "127.0.0.1"
' >/dev/null ' >/dev/null
./scripts/compose.sh "$production_env" config --format json |
jq --exit-status '
(.services | has("app")) and
(.services | has("db")) and
(.services | has("web") | not) and
(.services | has("worker") | not) and
(.services | has("proxy") | not) and
(.services | has("docker-api-proxy") | not) and
(.services | has("mailpit") | not) and
.services.app.environment.APP_ROLE == "combined" and
.services.app.environment.DNS_CLUSTER_QUERY == "ignore" and
.services.app.environment.POOL_SIZE == "4" and
.services.app.ports[0].host_ip == "127.0.0.1"
' >/dev/null
./scripts/compose.sh "$external_production_env" config --format json |
jq --exit-status '
(.services | has("app")) and
(.services | has("db") | not) and
(.services | has("web") | not) and
(.services | has("worker") | not) and
(.services | has("proxy") | not)
' >/dev/null
./scripts/compose.sh "$external_split_production_env" config --format json |
jq --exit-status '
(.services | has("db") | not) and
(.services | has("app") | not) and
(.services | has("web")) and
(.services | has("worker")) and
(.services | has("proxy")) and
.services.web.deploy.replicas == 2 and
.services.worker.deploy.replicas == 2
' >/dev/null
HTTP_BIND_ADDRESS=127.0.0.1 \ HTTP_BIND_ADDRESS=127.0.0.1 \
docker compose --env-file .env.example -f compose.yaml config --format json | ./scripts/compose.sh .env.example config --format json |
jq --exit-status ' jq --exit-status '
.services.proxy.ports[0].host_ip == "127.0.0.1" .services.proxy.ports[0].host_ip == "127.0.0.1"
' >/dev/null ' >/dev/null
WEB_REPLICAS=1 WORKER_REPLICAS=1 \ WEB_REPLICAS=1 WORKER_REPLICAS=1 \
docker compose --env-file .env.example -f compose.yaml config --format json | ./scripts/compose.sh .env.example config --format json |
jq --exit-status ' jq --exit-status '
.services.web.deploy.replicas == 1 and .services.web.deploy.replicas == 1 and
.services.worker.deploy.replicas == 1 .services.worker.deploy.replicas == 1

View File

@ -32,6 +32,11 @@ set -a
. "$ROOT/.env" . "$ROOT/.env"
set +a set +a
if [ "${DATABASE_MODE:-container}" != container ]; then
echo "restore-drill-compose.sh only operates on DATABASE_MODE=container." >&2
exit 1
fi
: "${POSTGRES_DB:?Set POSTGRES_DB in .env}" : "${POSTGRES_DB:?Set POSTGRES_DB in .env}"
: "${POSTGRES_USER:?Set POSTGRES_USER in .env}" : "${POSTGRES_USER:?Set POSTGRES_USER in .env}"
: "${DATABASE_URL:?Set DATABASE_URL in .env}" : "${DATABASE_URL:?Set DATABASE_URL in .env}"

View File

@ -31,6 +31,12 @@ set -a
. "$ENV_FILE" . "$ENV_FILE"
set +a set +a
if [ "${DATABASE_MODE:-container}" != container ]; then
echo "Local PostgreSQL rotation requires DATABASE_MODE=container." >&2
echo "Rotate external database credentials at their owning provider instead." >&2
exit 1
fi
: "${POSTGRES_USER:?POSTGRES_USER is missing from .env}" : "${POSTGRES_USER:?POSTGRES_USER is missing from .env}"
: "${POSTGRES_DB:?POSTGRES_DB is missing from .env}" : "${POSTGRES_DB:?POSTGRES_DB is missing from .env}"

View File

@ -17,6 +17,11 @@ set -a
. "$ENV_FILE" . "$ENV_FILE"
set +a set +a
if [[ "${DATABASE_MODE:-container}" != container ]]; then
echo "This rollback-based staging drill requires DATABASE_MODE=container." >&2
exit 1
fi
: "${POSTGRES_DB:?POSTGRES_DB is missing from .env}" : "${POSTGRES_DB:?POSTGRES_DB is missing from .env}"
: "${PHX_HOST:?PHX_HOST is missing from .env}" : "${PHX_HOST:?PHX_HOST is missing from .env}"
: "${PHX_SCHEME:?PHX_SCHEME is missing from .env}" : "${PHX_SCHEME:?PHX_SCHEME is missing from .env}"

View File

@ -17,6 +17,11 @@ set -a
. "$ENV_FILE" . "$ENV_FILE"
set +a set +a
if [[ "${DATABASE_MODE:-container}" != container ]]; then
echo "This rollback-based staging drill requires DATABASE_MODE=container." >&2
exit 1
fi
: "${POSTGRES_DB:?POSTGRES_DB is missing from .env}" : "${POSTGRES_DB:?POSTGRES_DB is missing from .env}"
: "${PHX_HOST:?PHX_HOST is missing from .env}" : "${PHX_HOST:?PHX_HOST is missing from .env}"
: "${PHX_SCHEME:?PHX_SCHEME is missing from .env}" : "${PHX_SCHEME:?PHX_SCHEME is missing from .env}"

View File

@ -14,6 +14,11 @@ set -a
. "$ROOT/.env" . "$ROOT/.env"
set +a set +a
if [ "${DATABASE_MODE:-container}" != container ]; then
echo "scripts/test.sh requires the isolated local DATABASE_MODE=container setup." >&2
exit 1
fi
: "${POSTGRES_USER:?Set POSTGRES_USER in .env}" : "${POSTGRES_USER:?Set POSTGRES_USER in .env}"
: "${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}" : "${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"

View File

@ -72,13 +72,18 @@ reject_marker() {
} }
phx_host=$(require_value PHX_HOST) 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_topology=$(require_value APP_TOPOLOGY)
database_mode=$(require_value DATABASE_MODE)
phx_scheme=$(require_value PHX_SCHEME) phx_scheme=$(require_value PHX_SCHEME)
phx_url_port=$(require_value PHX_URL_PORT) phx_url_port=$(require_value PHX_URL_PORT)
base_url=$(require_value WNH_BASE_URL) base_url=$(require_value WNH_BASE_URL)
debug_base_url=$(require_value WNH_DEBUG_BASE_URL) debug_base_url=$(require_value WNH_DEBUG_BASE_URL)
http_bind_address=$(require_value HTTP_BIND_ADDRESS) http_bind_address=$(require_value HTTP_BIND_ADDRESS)
trusted_proxy_ips=$(require_value TRAEFIK_TRUSTED_IPS) trusted_proxy_ips=$(optional_value TRAEFIK_TRUSTED_IPS)
postgres_password=$(require_value POSTGRES_PASSWORD) postgres_password=$(optional_value POSTGRES_PASSWORD)
database_url=$(require_value DATABASE_URL) database_url=$(require_value DATABASE_URL)
secret_key_base=$(require_value SECRET_KEY_BASE) secret_key_base=$(require_value SECRET_KEY_BASE)
handover_secret=$(require_value HANDOVER_SECRET) handover_secret=$(require_value HANDOVER_SECRET)
@ -95,6 +100,27 @@ email_from_address=$(require_value EMAIL_FROM_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)
[[ "$deployment_target" == compose ]] || {
echo "Production Compose validation requires DEPLOYMENT_TARGET=compose." >&2
exit 1
}
[[ "$deployment_environment" == production ]] || {
echo "Production validation requires DEPLOYMENT_ENV=production." >&2
exit 1
}
[[ "$compose_project_name" =~ ^[a-zA-Z0-9_-]+$ ]] || {
echo "COMPOSE_PROJECT_NAME contains unsupported characters." >&2
exit 1
}
[[ "$app_topology" =~ ^(compact|split)$ ]] || {
echo "APP_TOPOLOGY must be compact or split." >&2
exit 1
}
[[ "$database_mode" =~ ^(container|external)$ ]] || {
echo "DATABASE_MODE must be container or external." >&2
exit 1
}
[[ "$phx_host" == "$expected_domain" ]] || { [[ "$phx_host" == "$expected_domain" ]] || {
echo "PHX_HOST does not match EXPECTED_DOMAIN." >&2 echo "PHX_HOST does not match EXPECTED_DOMAIN." >&2
exit 1 exit 1
@ -114,8 +140,6 @@ google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET)
for pair in \ for pair in \
"HTTP_BIND_ADDRESS:$http_bind_address" \ "HTTP_BIND_ADDRESS:$http_bind_address" \
"TRAEFIK_TRUSTED_IPS:$trusted_proxy_ips" \
"POSTGRES_PASSWORD:$postgres_password" \
"DATABASE_URL:$database_url" \ "DATABASE_URL:$database_url" \
"SECRET_KEY_BASE:$secret_key_base" \ "SECRET_KEY_BASE:$secret_key_base" \
"HANDOVER_SECRET:$handover_secret" \ "HANDOVER_SECRET:$handover_secret" \
@ -127,6 +151,41 @@ do
reject_marker "${pair%%:*}" "${pair#*:}" reject_marker "${pair%%:*}" "${pair#*:}"
done done
if [[ "$app_topology" == split ]]; then
[[ -n "$trusted_proxy_ips" ]] || {
echo "TRAEFIK_TRUSTED_IPS is required for APP_TOPOLOGY=split." >&2
exit 1
}
reject_marker TRAEFIK_TRUSTED_IPS "$trusted_proxy_ips"
fi
case "$database_url" in
ecto://*) ;;
*) echo "DATABASE_URL must start with ecto://." >&2; exit 1 ;;
esac
if [[ "$database_mode" == container ]]; then
[[ -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"
[[ "$database_url" == "$expected_database_url" ]] || {
echo "Container DATABASE_URL does not match the generated PostgreSQL role/password/database." >&2
exit 1
}
else
database_authority=${database_url#ecto://}
database_authority=${database_authority%%/*}
database_host_port=${database_authority##*@}
if [[ "$database_host_port" == db || "$database_host_port" == db:* ]]; then
echo "External DATABASE_URL still targets the Compose db service." >&2
exit 1
fi
fi
[[ "$smtp_relay" != mailpit ]] || { [[ "$smtp_relay" != mailpit ]] || {
echo "SMTP_RELAY still targets local Mailpit; public registration needs a transactional relay." >&2 echo "SMTP_RELAY still targets local Mailpit; public registration needs a transactional relay." >&2
exit 1 exit 1
@ -177,13 +236,16 @@ if [[ -n "$google_oauth_client_id" || -n "$google_oauth_client_secret" ]]; then
fi fi
secrets=( secrets=(
"$postgres_password"
"$secret_key_base" "$secret_key_base"
"$handover_secret" "$handover_secret"
"$release_cookie" "$release_cookie"
"$metrics_token" "$metrics_token"
) )
if [[ "$database_mode" == container ]]; then
secrets+=("$postgres_password")
fi
for ((left = 0; left < ${#secrets[@]}; left++)); do for ((left = 0; left < ${#secrets[@]}; left++)); do
for ((right = left + 1; right < ${#secrets[@]}; right++)); do for ((right = left + 1; right < ${#secrets[@]}; right++)); do
if [[ "${secrets[$left]}" == "${secrets[$right]}" ]]; then if [[ "${secrets[$left]}" == "${secrets[$right]}" ]]; then
@ -193,17 +255,7 @@ for ((left = 0; left < ${#secrets[@]}; left++)); do
done done
done done
expected_database_url="ecto://postgres:$postgres_password@db/who_need_help" "$ROOT/scripts/compose.sh" "$env_file" config --quiet
[[ "$database_url" == "$expected_database_url" ]] || {
echo "DATABASE_URL does not match the generated PostgreSQL role/password/database." >&2
exit 1
}
docker compose \
--env-file "$env_file" \
--file "$ROOT/compose.yaml" \
--file "$ROOT/compose.production.yaml" \
config --quiet
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, SMTP reachability/delivery, proxy source IPs, or server capacity." echo "This does not test DNS, TLS, SMTP reachability/delivery, proxy source IPs, or server capacity."

View File

@ -51,7 +51,7 @@ case "$MODE" in
compose) compose)
mapfile -t targets < <( mapfile -t targets < <(
docker compose -p "${COMPOSE_PROJECT_NAME:-who_need_help}" \ docker compose -p "${COMPOSE_PROJECT_NAME:-who_need_help}" \
ps -q web worker | ps -q web worker app |
sort -u sort -u
) )

View File

@ -9,7 +9,10 @@ case "$MODE" in
compose) compose)
TARGET=$(docker compose -p "$COMPOSE_PROJECT_NAME" ps -q web | head -n 1) TARGET=$(docker compose -p "$COMPOSE_PROJECT_NAME" ps -q web | head -n 1)
if [ -z "$TARGET" ]; then if [ -z "$TARGET" ]; then
echo "No running Compose web replica was found." >&2 TARGET=$(docker compose -p "$COMPOSE_PROJECT_NAME" ps -q app | head -n 1)
fi
if [ -z "$TARGET" ]; then
echo "No running Compose web or compact app replica was found." >&2
exit 1 exit 1
fi fi
RUN="docker exec $TARGET" RUN="docker exec $TARGET"
@ -50,7 +53,20 @@ $RUN /app/bin/who_need_help rpc '
case live_peers do case live_peers do
[] -> [] ->
if Application.fetch_env!(:who_need_help, :app_role) == :combined do
topic = "local:verify:" <> Integer.to_string(System.unique_integer([:positive]))
:ok = Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, topic)
:ok = Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, topic, :local_pubsub_probe)
receive do
:local_pubsub_probe ->
IO.inspect(%{status: :ok, topology: :compact, subscriber: node(), peers: []})
after
5_000 -> exit({:local_pubsub_failed, node()})
end
else
exit({:no_live_cluster_peers, Node.list()}) exit({:no_live_cluster_peers, Node.list()})
end
peers -> peers ->
topic = "cluster:verify:" <> Integer.to_string(System.unique_integer([:positive])) topic = "cluster:verify:" <> Integer.to_string(System.unique_integer([:positive]))
@ -86,7 +102,7 @@ $RUN /app/bin/who_need_help rpc '
result || result ||
%{status: :timeout, subscriber: node(), attempted_peers: peers, peers: Node.list()} %{status: :timeout, subscriber: node(), attempted_peers: peers, peers: Node.list()}
IO.inspect(result) IO.inspect(Map.put(result, :topology, :split))
if result.status != :ok, do: exit({:cross_replica_pubsub_failed, result}) if result.status != :ok, do: exit({:cross_replica_pubsub_failed, result})
end end
' '