feat: add selectable Compose deployment modes
This commit is contained in:
parent
c4721e6702
commit
0a588182a0
|
|
@ -16,6 +16,12 @@ PHX_SCHEME=https
|
|||
PHX_URL_PORT=443
|
||||
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_USER=postgres
|
||||
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
|
||||
WORKER_POOL_SIZE=2
|
||||
MIGRATE_POOL_SIZE=2
|
||||
COMBINED_POOL_SIZE=4
|
||||
OBAN_MAINTENANCE_CONCURRENCY=2
|
||||
OBAN_PUSH_CONCURRENCY=1
|
||||
WEB_REPLICAS=2
|
||||
|
|
|
|||
18
.env.example
18
.env.example
|
|
@ -1,5 +1,22 @@
|
|||
# Copy this file to .env. Compose intentionally refuses to start without the
|
||||
# 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
|
||||
# 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
|
||||
|
|
@ -82,6 +99,7 @@ DATABASE_URL=ecto://postgres:replace-with-url-encoded-password@db/who_need_help
|
|||
WEB_POOL_SIZE=4
|
||||
WORKER_POOL_SIZE=2
|
||||
MIGRATE_POOL_SIZE=2
|
||||
COMBINED_POOL_SIZE=4
|
||||
OBAN_MAINTENANCE_CONCURRENCY=2
|
||||
OBAN_PUSH_CONCURRENCY=1
|
||||
WEB_REPLICAS=2
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
# GENERATE_* marker with an independent local secret and restricts the result
|
||||
# to mode 600. This profile owns a separate Compose project and PostgreSQL
|
||||
# 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_HOST=load.local
|
||||
HTTP_PORT=4110
|
||||
|
|
@ -24,6 +30,7 @@ DATABASE_URL=GENERATE_DATABASE_URL
|
|||
WEB_POOL_SIZE=4
|
||||
WORKER_POOL_SIZE=2
|
||||
MIGRATE_POOL_SIZE=2
|
||||
COMBINED_POOL_SIZE=4
|
||||
OBAN_MAINTENANCE_CONCURRENCY=2
|
||||
OBAN_PUSH_CONCURRENCY=1
|
||||
WEB_REPLICAS=2
|
||||
|
|
|
|||
54
README.md
54
README.md
|
|
@ -50,8 +50,8 @@ thank-you link; money goes directly between users outside the platform.
|
|||
publication are not configured.
|
||||
- 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.
|
||||
- One immutable release image with `web`, `worker`, and one-shot `migrate`
|
||||
roles.
|
||||
- One immutable release image with `web`, `worker`, combined `app`, and
|
||||
one-shot `migrate` roles.
|
||||
- Docker Compose and Helm/kind deployment paths with 2 web and 2 worker
|
||||
replicas by default.
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ policies are deliberately not claimed as complete.
|
|||
Prerequisite: Docker with the Compose plugin.
|
||||
|
||||
```bash
|
||||
./scripts/compose-up.sh
|
||||
./scripts/deploy-up.sh .env
|
||||
```
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
```bash
|
||||
|
|
@ -170,9 +189,11 @@ links, and must set `app.mapTileUrl` to a tile service whose policy and capacity
|
|||
fit the deployment.
|
||||
|
||||
For a first Compose deployment, generate an ignored environment on the target
|
||||
Docker host. The command derives that host's Docker socket group, generates
|
||||
independent database, Phoenix, handover, cluster, and metrics secrets without
|
||||
printing them, writes mode `0600`, and refuses to replace an existing file:
|
||||
Docker host. The default is `APP_TOPOLOGY=compact` with a project-owned
|
||||
PostGIS container. The command derives that host's Docker socket group,
|
||||
generates independent database, Phoenix, handover, cluster, and metrics
|
||||
secrets without printing them, writes mode `0600`, and refuses to replace an
|
||||
existing file:
|
||||
|
||||
```bash
|
||||
./scripts/init-production-env.sh whoneedhelp.com .env.production
|
||||
|
|
@ -185,13 +206,24 @@ Then validate the file structure and the production Compose render:
|
|||
|
||||
```bash
|
||||
./scripts/validate-production-env.sh .env.production whoneedhelp.com
|
||||
docker compose \
|
||||
--env-file .env.production \
|
||||
-f compose.yaml \
|
||||
-f compose.production.yaml \
|
||||
up -d --wait --build
|
||||
./scripts/deploy-up.sh .env.production
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
|
|
|
|||
15
compose.compact.yaml
Normal file
15
compose.compact.yaml
Normal 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
12
compose.external-db.yaml
Normal 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
|
||||
57
compose.yaml
57
compose.yaml
|
|
@ -110,9 +110,12 @@ services:
|
|||
context: .
|
||||
dockerfile: Dockerfile.postgis
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB in .env}
|
||||
POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||
# Profiles are resolved after interpolation. These disabled-definition
|
||||
# fallbacks let external mode render without inventing live credentials;
|
||||
# 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:
|
||||
# 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.
|
||||
|
|
@ -153,10 +156,17 @@ services:
|
|||
APP_ROLE: migrate
|
||||
POOL_SIZE: ${MIGRATE_POOL_SIZE:-${POOL_SIZE:?Set MIGRATE_POOL_SIZE in .env}}
|
||||
command: ["/app/bin/migrate"]
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks: [internal]
|
||||
networks:
|
||||
internal:
|
||||
interface_name: eth0
|
||||
egress:
|
||||
interface_name: eth1
|
||||
gw_priority: 1
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
|
|
@ -165,6 +175,41 @@ services:
|
|||
- no-new-privileges:true
|
||||
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:
|
||||
image: who-need-help:local
|
||||
environment:
|
||||
|
|
@ -175,6 +220,8 @@ services:
|
|||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=${TRAEFIK_DOCKER_NETWORK:-who_need_help_ingress}
|
||||
|
|
@ -218,6 +265,8 @@ services:
|
|||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"]
|
||||
interval: 10s
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ app_role =
|
|||
case System.get_env("APP_ROLE", "web") do
|
||||
"web" -> :web
|
||||
"worker" -> :worker
|
||||
"combined" -> :combined
|
||||
"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
|
||||
|
||||
rate_limit_policies =
|
||||
|
|
@ -265,23 +266,27 @@ config :who_need_help,
|
|||
),
|
||||
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 =
|
||||
System.get_env("METRICS_TOKEN") ||
|
||||
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.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
config :who_need_help, :metrics_token, metrics_token
|
||||
end
|
||||
|
||||
if dns_query = System.get_env("DNS_CLUSTER_QUERY") do
|
||||
config :who_need_help, :dns_cluster_query, dns_query
|
||||
case System.get_env("DNS_CLUSTER_QUERY") do
|
||||
query when query in [nil, "", "ignore"] ->
|
||||
config :who_need_help, :dns_cluster_query, :ignore
|
||||
|
||||
query ->
|
||||
config :who_need_help, :dns_cluster_query, query
|
||||
end
|
||||
|
||||
# config/runtime.exs is executed for all environments, including
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ Status: target architecture for the first working release.
|
|||
## System shape
|
||||
|
||||
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:
|
||||
|
||||
- `web`: Phoenix Endpoint, LiveView, PubSub, Presence, and a producerless Oban
|
||||
client for atomic job insertion.
|
||||
- `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.
|
||||
|
||||
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
|
||||
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
|
||||
introduced. The worker runs only the queues used by product code:
|
||||
`maintenance` for expiry/probes and `push` for provider-neutral delivery.
|
||||
|
|
@ -133,7 +135,18 @@ policy, not an application backend.
|
|||
|
||||
### 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
|
||||
- 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
|
||||
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
|
||||
|
||||
The Helm chart contains separate web and worker Deployments, Services, a
|
||||
|
|
|
|||
|
|
@ -36,6 +36,18 @@ run URL and commit before protecting `main`.
|
|||
|
||||
## 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
|
||||
is known:
|
||||
|
||||
|
|
@ -66,11 +78,7 @@ URI. Leave both credentials empty to keep the feature disabled. Then run:
|
|||
|
||||
```bash
|
||||
./scripts/validate-production-env.sh .env.production whoneedhelp.com
|
||||
docker compose \
|
||||
--env-file .env.production \
|
||||
-f compose.yaml \
|
||||
-f compose.production.yaml \
|
||||
up -d --wait --build
|
||||
./scripts/deploy-up.sh .env.production
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
Create a PostgreSQL 18 custom-format archive, validate its table of contents,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,14 @@ sample measured 190.1–205.5 MiB per application container and 168.3 MiB for
|
|||
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.
|
||||
|
||||
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.2–219.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
|
||||
a separate PostgreSQL volume. Its Traefik instance is constrained to that exact
|
||||
Compose project; its router, service, middleware, and Host rule are unique, so
|
||||
|
|
|
|||
|
|
@ -1049,6 +1049,49 @@ is `output/external-boundaries/preprod-gitea-pass`.
|
|||
proxy, two web replicas and two worker replicas. This workstation snapshot is
|
||||
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
|
||||
|
||||
- Replace the temporary staging origin with the production-owned domain and
|
||||
|
|
|
|||
|
|
@ -37,6 +37,14 @@ defmodule WhoNeedHelp.Application do
|
|||
:worker ->
|
||||
[{Oban, oban_config}, WhoNeedHelpWeb.WorkerMetricsPlug]
|
||||
|
||||
:combined ->
|
||||
[
|
||||
{Oban, oban_config},
|
||||
WhoNeedHelpWeb.Presence,
|
||||
WhoNeedHelp.TrackingPresenceCleanup,
|
||||
WhoNeedHelpWeb.Endpoint
|
||||
]
|
||||
|
||||
:migrate ->
|
||||
[{Oban, oban_client_config}]
|
||||
end
|
||||
|
|
@ -53,7 +61,7 @@ defmodule WhoNeedHelp.Application do
|
|||
# whenever the application is updated.
|
||||
@impl true
|
||||
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)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ defmodule WhoNeedHelpWeb.Telemetry do
|
|||
@impl true
|
||||
def init(_arg) do
|
||||
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,
|
||||
name: :prometheus_metrics, metrics: prometheus_metrics(), start_async: false}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@ set -a
|
|||
. "$ENV_FILE"
|
||||
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}"
|
||||
: "${WNH_BASE_URL:?WNH_BASE_URL is missing from .env}"
|
||||
: "${WNH_TRACKING_MIN_TIME_MS:?WNH_TRACKING_MIN_TIME_MS is missing from .env}"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ set -a
|
|||
. "$ROOT/.env"
|
||||
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_USER:?Set POSTGRES_USER in .env}"
|
||||
|
||||
|
|
@ -38,7 +44,7 @@ cleanup() {
|
|||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
docker compose exec -T db \
|
||||
"$ROOT/scripts/compose.sh" "$ROOT/.env" exec -T db \
|
||||
pg_dump \
|
||||
--username "$POSTGRES_USER" \
|
||||
--dbname "$POSTGRES_DB" \
|
||||
|
|
@ -47,7 +53,7 @@ docker compose exec -T db \
|
|||
|
||||
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}')
|
||||
printf '%s %s\n' "$hash" "$target_name" >"$checksum_partial"
|
||||
|
|
|
|||
28
scripts/check-database.sh
Executable file
28
scripts/check-database.sh
Executable 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}")
|
||||
'
|
||||
|
|
@ -139,6 +139,10 @@ metrics_token=$(openssl rand -hex 32)
|
|||
docker_socket_gid=$(stat -c '%g' /var/run/docker.sock)
|
||||
|
||||
cat >"$env_file" <<EOF
|
||||
DEPLOYMENT_TARGET=compose
|
||||
DEPLOYMENT_ENV=development
|
||||
APP_TOPOLOGY=split
|
||||
DATABASE_MODE=container
|
||||
HTTP_PORT=0
|
||||
MAILPIT_PORT=0
|
||||
DOCKER_SOCKET_GID=$docker_socket_gid
|
||||
|
|
@ -165,6 +169,7 @@ DATABASE_URL=ecto://$postgres_user:$postgres_password@db/$postgres_db
|
|||
WEB_POOL_SIZE=4
|
||||
WORKER_POOL_SIZE=2
|
||||
MIGRATE_POOL_SIZE=2
|
||||
COMBINED_POOL_SIZE=4
|
||||
OBAN_MAINTENANCE_CONCURRENCY=2
|
||||
OBAN_PUSH_CONCURRENCY=1
|
||||
WEB_REPLICAS=2
|
||||
|
|
|
|||
|
|
@ -3,8 +3,43 @@ set -eu
|
|||
|
||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
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}}
|
||||
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-beam-runtime.sh" compose
|
||||
|
|
|
|||
93
scripts/compose.sh
Executable file
93
scripts/compose.sh
Executable 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
88
scripts/deploy-up.sh
Executable 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
|
||||
|
|
@ -43,6 +43,10 @@ metrics_token=$(openssl rand -hex 32)
|
|||
docker_socket_gid=$(stat -c '%g' /var/run/docker.sock)
|
||||
|
||||
cat >"$target" <<EOF
|
||||
DEPLOYMENT_TARGET=compose
|
||||
DEPLOYMENT_ENV=development
|
||||
APP_TOPOLOGY=split
|
||||
DATABASE_MODE=container
|
||||
HTTP_PORT=0
|
||||
MAILPIT_PORT=0
|
||||
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
|
||||
WORKER_POOL_SIZE=2
|
||||
MIGRATE_POOL_SIZE=2
|
||||
COMBINED_POOL_SIZE=4
|
||||
OBAN_MAINTENANCE_CONCURRENCY=2
|
||||
OBAN_PUSH_CONCURRENCY=1
|
||||
WEB_REPLICAS=2
|
||||
|
|
|
|||
|
|
@ -44,7 +44,41 @@ if [ ! -d "$target_dir" ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
postgres_password=$(openssl rand -hex 32)
|
||||
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)
|
||||
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)
|
||||
handover_secret=$(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)
|
||||
|
||||
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}
|
||||
smtp_relay=${PRODUCTION_SMTP_RELAY:-REPLACE_WITH_TRANSACTIONAL_SMTP_RELAY}
|
||||
smtp_port=${PRODUCTION_SMTP_PORT:-587}
|
||||
|
|
@ -67,11 +102,15 @@ trap 'rm -f "$tmp"' EXIT HUP INT TERM
|
|||
chmod 600 "$tmp"
|
||||
|
||||
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_PORT_VALUE=$http_port \
|
||||
DOCKER_SOCKET_GID_VALUE=$docker_socket_gid \
|
||||
TRUSTED_PROXY_IPS_VALUE=$trusted_proxy_ips \
|
||||
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 \
|
||||
HANDOVER_SECRET_VALUE=$handover_secret \
|
||||
RELEASE_COOKIE_VALUE=$release_cookie \
|
||||
|
|
@ -86,9 +125,17 @@ SMTP_SSL_VALUE=$smtp_ssl \
|
|||
EMAIL_FROM_ADDRESS_VALUE=$email_from_address \
|
||||
awk '
|
||||
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_PORT"] = ENVIRON["HTTP_PORT_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"]
|
||||
replacement["TRAEFIK_DOCKER_NETWORK"] = ENVIRON["COMPOSE_PROJECT_NAME_VALUE"] "_ingress"
|
||||
replacement["PHX_HOST"] = ENVIRON["DOMAIN"]
|
||||
replacement["PHX_SCHEME"] = "https"
|
||||
replacement["PHX_URL_PORT"] = "443"
|
||||
|
|
|
|||
|
|
@ -103,6 +103,44 @@ PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \
|
|||
./scripts/init-production-env.sh help.test "$production_env" >/dev/null
|
||||
test "$(stat -c '%a' "$production_env")" = 600
|
||||
./scripts/validate-production-env.sh "$production_env" help.test >/dev/null
|
||||
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"
|
||||
cp "$production_env" "$partial_google_env"
|
||||
chmod 600 "$partial_google_env"
|
||||
|
|
@ -126,10 +164,11 @@ if ./scripts/validate-production-env.sh \
|
|||
fi
|
||||
|
||||
echo "Rendering every Docker Compose profile"
|
||||
docker compose --env-file .env.example -f compose.yaml config --quiet
|
||||
docker compose --env-file "$production_env" \
|
||||
-f compose.yaml -f compose.production.yaml config --quiet
|
||||
docker compose --env-file .env.example -f compose.yaml config --format json |
|
||||
./scripts/compose.sh .env.example config --quiet
|
||||
./scripts/compose.sh "$production_env" config --quiet
|
||||
./scripts/compose.sh "$external_production_env" config --quiet
|
||||
./scripts/compose.sh "$external_split_production_env" config --quiet
|
||||
./scripts/compose.sh .env.example config --format json |
|
||||
jq --exit-status '
|
||||
. as $root
|
||||
| [$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.web.networks | keys | sort) == ["egress", "ingress", "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.aliases == ["cluster-web"]
|
||||
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.mailpit.ports[0].host_ip == "127.0.0.1"
|
||||
' >/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 \
|
||||
docker compose --env-file .env.example -f compose.yaml config --format json |
|
||||
./scripts/compose.sh .env.example config --format json |
|
||||
jq --exit-status '
|
||||
.services.proxy.ports[0].host_ip == "127.0.0.1"
|
||||
' >/dev/null
|
||||
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 '
|
||||
.services.web.deploy.replicas == 1 and
|
||||
.services.worker.deploy.replicas == 1
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ set -a
|
|||
. "$ROOT/.env"
|
||||
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_USER:?Set POSTGRES_USER in .env}"
|
||||
: "${DATABASE_URL:?Set DATABASE_URL in .env}"
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ set -a
|
|||
. "$ENV_FILE"
|
||||
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_DB:?POSTGRES_DB is missing from .env}"
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ set -a
|
|||
. "$ENV_FILE"
|
||||
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}"
|
||||
: "${PHX_HOST:?PHX_HOST is missing from .env}"
|
||||
: "${PHX_SCHEME:?PHX_SCHEME is missing from .env}"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ set -a
|
|||
. "$ENV_FILE"
|
||||
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}"
|
||||
: "${PHX_HOST:?PHX_HOST is missing from .env}"
|
||||
: "${PHX_SCHEME:?PHX_SCHEME is missing from .env}"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ set -a
|
|||
. "$ROOT/.env"
|
||||
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_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
|
||||
|
||||
|
|
|
|||
|
|
@ -72,13 +72,18 @@ reject_marker() {
|
|||
}
|
||||
|
||||
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_url_port=$(require_value PHX_URL_PORT)
|
||||
base_url=$(require_value WNH_BASE_URL)
|
||||
debug_base_url=$(require_value WNH_DEBUG_BASE_URL)
|
||||
http_bind_address=$(require_value HTTP_BIND_ADDRESS)
|
||||
trusted_proxy_ips=$(require_value TRAEFIK_TRUSTED_IPS)
|
||||
postgres_password=$(require_value POSTGRES_PASSWORD)
|
||||
trusted_proxy_ips=$(optional_value TRAEFIK_TRUSTED_IPS)
|
||||
postgres_password=$(optional_value POSTGRES_PASSWORD)
|
||||
database_url=$(require_value DATABASE_URL)
|
||||
secret_key_base=$(require_value SECRET_KEY_BASE)
|
||||
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_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" ]] || {
|
||||
echo "PHX_HOST does not match EXPECTED_DOMAIN." >&2
|
||||
exit 1
|
||||
|
|
@ -114,8 +140,6 @@ google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET)
|
|||
|
||||
for pair in \
|
||||
"HTTP_BIND_ADDRESS:$http_bind_address" \
|
||||
"TRAEFIK_TRUSTED_IPS:$trusted_proxy_ips" \
|
||||
"POSTGRES_PASSWORD:$postgres_password" \
|
||||
"DATABASE_URL:$database_url" \
|
||||
"SECRET_KEY_BASE:$secret_key_base" \
|
||||
"HANDOVER_SECRET:$handover_secret" \
|
||||
|
|
@ -127,6 +151,41 @@ do
|
|||
reject_marker "${pair%%:*}" "${pair#*:}"
|
||||
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 ]] || {
|
||||
echo "SMTP_RELAY still targets local Mailpit; public registration needs a transactional relay." >&2
|
||||
exit 1
|
||||
|
|
@ -177,13 +236,16 @@ if [[ -n "$google_oauth_client_id" || -n "$google_oauth_client_secret" ]]; then
|
|||
fi
|
||||
|
||||
secrets=(
|
||||
"$postgres_password"
|
||||
"$secret_key_base"
|
||||
"$handover_secret"
|
||||
"$release_cookie"
|
||||
"$metrics_token"
|
||||
)
|
||||
|
||||
if [[ "$database_mode" == container ]]; then
|
||||
secrets+=("$postgres_password")
|
||||
fi
|
||||
|
||||
for ((left = 0; left < ${#secrets[@]}; left++)); do
|
||||
for ((right = left + 1; right < ${#secrets[@]}; right++)); do
|
||||
if [[ "${secrets[$left]}" == "${secrets[$right]}" ]]; then
|
||||
|
|
@ -193,17 +255,7 @@ for ((left = 0; left < ${#secrets[@]}; left++)); do
|
|||
done
|
||||
done
|
||||
|
||||
expected_database_url="ecto://postgres:$postgres_password@db/who_need_help"
|
||||
[[ "$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
|
||||
"$ROOT/scripts/compose.sh" "$env_file" config --quiet
|
||||
|
||||
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."
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ case "$MODE" in
|
|||
compose)
|
||||
mapfile -t targets < <(
|
||||
docker compose -p "${COMPOSE_PROJECT_NAME:-who_need_help}" \
|
||||
ps -q web worker |
|
||||
ps -q web worker app |
|
||||
sort -u
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ case "$MODE" in
|
|||
compose)
|
||||
TARGET=$(docker compose -p "$COMPOSE_PROJECT_NAME" ps -q web | head -n 1)
|
||||
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
|
||||
fi
|
||||
RUN="docker exec $TARGET"
|
||||
|
|
@ -50,7 +53,20 @@ $RUN /app/bin/who_need_help rpc '
|
|||
|
||||
case live_peers do
|
||||
[] ->
|
||||
exit({:no_live_cluster_peers, Node.list()})
|
||||
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()})
|
||||
end
|
||||
|
||||
peers ->
|
||||
topic = "cluster:verify:" <> Integer.to_string(System.unique_integer([:positive]))
|
||||
|
|
@ -86,7 +102,7 @@ $RUN /app/bin/who_need_help rpc '
|
|||
result ||
|
||||
%{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})
|
||||
end
|
||||
'
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user