From 0a588182a0aba46b1db63848e7071c8c13cdc3aa Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Tue, 21 Jul 2026 01:48:47 +0300 Subject: [PATCH] feat: add selectable Compose deployment modes --- .env.e2e.example | 7 ++ .env.example | 18 +++++ .env.load.example | 7 ++ README.md | 54 ++++++++++++--- compose.compact.yaml | 15 +++++ compose.external-db.yaml | 12 ++++ compose.yaml | 57 ++++++++++++++-- config/runtime.exs | 17 +++-- docs/architecture.md | 24 ++++++- docs/operations.md | 48 +++++++++++-- docs/performance.md | 8 +++ docs/verification.md | 43 ++++++++++++ lib/who_need_help/application.ex | 10 ++- lib/who_need_help_web/telemetry.ex | 2 +- scripts/android-browser-staging-e2e.sh | 5 ++ scripts/backup-compose.sh | 10 ++- scripts/check-database.sh | 28 ++++++++ scripts/clean-deploy-verify.sh | 5 ++ scripts/compose-up.sh | 37 +++++++++- scripts/compose.sh | 93 ++++++++++++++++++++++++++ scripts/deploy-up.sh | 88 ++++++++++++++++++++++++ scripts/ensure-local-e2e-env.sh | 5 ++ scripts/init-production-env.sh | 51 +++++++++++++- scripts/quality.sh | 84 +++++++++++++++++++++-- scripts/restore-drill-compose.sh | 5 ++ scripts/rotate-local-secrets.sh | 6 ++ scripts/staging-e2e-run.sh | 5 ++ scripts/staging-full-e2e-run.sh | 5 ++ scripts/test.sh | 5 ++ scripts/validate-production-env.sh | 84 ++++++++++++++++++----- scripts/verify-beam-runtime.sh | 2 +- scripts/verify-realtime-cluster.sh | 22 +++++- 32 files changed, 800 insertions(+), 62 deletions(-) create mode 100644 compose.compact.yaml create mode 100644 compose.external-db.yaml create mode 100755 scripts/check-database.sh create mode 100755 scripts/compose.sh create mode 100755 scripts/deploy-up.sh diff --git a/.env.e2e.example b/.env.e2e.example index e4d5aa4..aa9cbee 100644 --- a/.env.e2e.example +++ b/.env.e2e.example @@ -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 diff --git a/.env.example b/.env.example index 671e506..1279743 100644 --- a/.env.example +++ b/.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 diff --git a/.env.load.example b/.env.load.example index a42e246..2d17180 100644 --- a/.env.load.example +++ b/.env.load.example @@ -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 diff --git a/README.md b/README.md index f2612c3..99651c2 100644 --- a/README.md +++ b/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 diff --git a/compose.compact.yaml b/compose.compact.yaml new file mode 100644 index 0000000..ad2cc66 --- /dev/null +++ b/compose.compact.yaml @@ -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] diff --git a/compose.external-db.yaml b/compose.external-db.yaml new file mode 100644 index 0000000..89a83fc --- /dev/null +++ b/compose.external-db.yaml @@ -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 diff --git a/compose.yaml b/compose.yaml index b9750de..8b0bc18 100644 --- a/compose.yaml +++ b/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 diff --git a/config/runtime.exs b/config/runtime.exs index 5be771e..d227eaf 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 852f49e..103a3cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/operations.md b/docs/operations.md index 9dbce4e..68c04fe 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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, diff --git a/docs/performance.md b/docs/performance.md index a9d853a..19512cc 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -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 diff --git a/docs/verification.md b/docs/verification.md index 2ea4679..2ae113a 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -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 diff --git a/lib/who_need_help/application.ex b/lib/who_need_help/application.ex index 8db5bf5..cfe0a82 100644 --- a/lib/who_need_help/application.ex +++ b/lib/who_need_help/application.ex @@ -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 diff --git a/lib/who_need_help_web/telemetry.ex b/lib/who_need_help_web/telemetry.ex index eba7ced..98d8c1c 100644 --- a/lib/who_need_help_web/telemetry.ex +++ b/lib/who_need_help_web/telemetry.ex @@ -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} diff --git a/scripts/android-browser-staging-e2e.sh b/scripts/android-browser-staging-e2e.sh index 05f9d79..6e146d8 100755 --- a/scripts/android-browser-staging-e2e.sh +++ b/scripts/android-browser-staging-e2e.sh @@ -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}" diff --git a/scripts/backup-compose.sh b/scripts/backup-compose.sh index 4d0b692..270c921 100755 --- a/scripts/backup-compose.sh +++ b/scripts/backup-compose.sh @@ -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" diff --git a/scripts/check-database.sh b/scripts/check-database.sh new file mode 100755 index 0000000..2bb96c9 --- /dev/null +++ b/scripts/check-database.sh @@ -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}") + ' diff --git a/scripts/clean-deploy-verify.sh b/scripts/clean-deploy-verify.sh index d2aaa5f..33387b3 100755 --- a/scripts/clean-deploy-verify.sh +++ b/scripts/clean-deploy-verify.sh @@ -139,6 +139,10 @@ metrics_token=$(openssl rand -hex 32) docker_socket_gid=$(stat -c '%g' /var/run/docker.sock) cat >"$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 diff --git a/scripts/compose.sh b/scripts/compose.sh new file mode 100755 index 0000000..c219c21 --- /dev/null +++ b/scripts/compose.sh @@ -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[@]}" "$@" diff --git a/scripts/deploy-up.sh b/scripts/deploy-up.sh new file mode 100755 index 0000000..8a6ea55 --- /dev/null +++ b/scripts/deploy-up.sh @@ -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 diff --git a/scripts/ensure-local-e2e-env.sh b/scripts/ensure-local-e2e-env.sh index b0bf22f..5865f8e 100755 --- a/scripts/ensure-local-e2e-env.sh +++ b/scripts/ensure-local-e2e-env.sh @@ -43,6 +43,10 @@ metrics_token=$(openssl rand -hex 32) docker_socket_gid=$(stat -c '%g' /var/run/docker.sock) cat >"$target" <&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" diff --git a/scripts/quality.sh b/scripts/quality.sh index d682a27..aab7075 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -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 diff --git a/scripts/restore-drill-compose.sh b/scripts/restore-drill-compose.sh index c014bc5..db7f5e5 100755 --- a/scripts/restore-drill-compose.sh +++ b/scripts/restore-drill-compose.sh @@ -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}" diff --git a/scripts/rotate-local-secrets.sh b/scripts/rotate-local-secrets.sh index f42c4f2..89a76e9 100755 --- a/scripts/rotate-local-secrets.sh +++ b/scripts/rotate-local-secrets.sh @@ -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}" diff --git a/scripts/staging-e2e-run.sh b/scripts/staging-e2e-run.sh index ade5a4f..df6e31b 100755 --- a/scripts/staging-e2e-run.sh +++ b/scripts/staging-e2e-run.sh @@ -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}" diff --git a/scripts/staging-full-e2e-run.sh b/scripts/staging-full-e2e-run.sh index 07e08d2..0c6f506 100755 --- a/scripts/staging-full-e2e-run.sh +++ b/scripts/staging-full-e2e-run.sh @@ -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}" diff --git a/scripts/test.sh b/scripts/test.sh index a413e08..91c4096 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -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}" diff --git a/scripts/validate-production-env.sh b/scripts/validate-production-env.sh index cf6d1f8..85222d2 100755 --- a/scripts/validate-production-env.sh +++ b/scripts/validate-production-env.sh @@ -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." diff --git a/scripts/verify-beam-runtime.sh b/scripts/verify-beam-runtime.sh index 0b707a7..a24e1ac 100755 --- a/scripts/verify-beam-runtime.sh +++ b/scripts/verify-beam-runtime.sh @@ -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 ) diff --git a/scripts/verify-realtime-cluster.sh b/scripts/verify-realtime-cluster.sh index 0cd33b8..dc7f616 100755 --- a/scripts/verify-realtime-cluster.sh +++ b/scripts/verify-realtime-cluster.sh @@ -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 '