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
|
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
|
||||||
|
|
|
||||||
18
.env.example
18
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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.
|
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
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: .
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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
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)
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
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)
|
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
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,41 @@ if [ ! -d "$target_dir" ]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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)
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
'
|
'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user