feat: prepare first production deployment
This commit is contained in:
parent
9b51d0807b
commit
14987bb21b
|
|
@ -1,6 +1,10 @@
|
||||||
# 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.
|
||||||
HTTP_PORT=4010
|
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
|
||||||
|
# verified tunnel topology, so choose this per deployment.
|
||||||
|
HTTP_BIND_ADDRESS=0.0.0.0
|
||||||
MAILPIT_PORT=8027
|
MAILPIT_PORT=8027
|
||||||
MAILPIT_BIND_ADDRESS=127.0.0.1
|
MAILPIT_BIND_ADDRESS=127.0.0.1
|
||||||
DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID
|
DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID
|
||||||
|
|
|
||||||
28
README.md
28
README.md
|
|
@ -168,6 +168,34 @@ set `app.host`, `app.scheme`, and `app.urlPort` to the public URL used in email
|
||||||
links, and must set `app.mapTileUrl` to a tile service whose policy and capacity
|
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
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/init-production-env.sh whoneedhelp.com .env.production
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure the verified reverse-proxy source IP/CIDR and transactional SMTP
|
||||||
|
provider in that file, then validate its 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
|
||||||
|
```
|
||||||
|
|
||||||
|
`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
|
||||||
|
deployment's observed proxy source address, or capacity; verify those on the
|
||||||
|
target host before opening registration.
|
||||||
|
|
||||||
Rotate all local application secrets and the existing local PostgreSQL role
|
Rotate all local application secrets and the existing local PostgreSQL role
|
||||||
without printing the generated values:
|
without printing the generated values:
|
||||||
|
|
||||||
|
|
|
||||||
6
compose.production.yaml
Normal file
6
compose.production.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
services:
|
||||||
|
# Public deployments use the configured transactional SMTP relay. Mailpit is
|
||||||
|
# retained in the base file for local development but is not started by this
|
||||||
|
# production override unless the operator explicitly enables local-mail.
|
||||||
|
mailpit:
|
||||||
|
profiles: [local-mail]
|
||||||
|
|
@ -87,7 +87,7 @@ services:
|
||||||
- --entrypoints.websecure.address=:443
|
- --entrypoints.websecure.address=:443
|
||||||
- --entrypoints.web.forwardedheaders.trustedips=${TRAEFIK_TRUSTED_IPS:-127.0.0.1/32}
|
- --entrypoints.web.forwardedheaders.trustedips=${TRAEFIK_TRUSTED_IPS:-127.0.0.1/32}
|
||||||
ports:
|
ports:
|
||||||
- "${HTTP_PORT:-4010}:80"
|
- "${HTTP_BIND_ADDRESS:-0.0.0.0}:${HTTP_PORT:-4010}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- docker-api-proxy
|
- docker-api-proxy
|
||||||
networks: [docker-api, edge, ingress]
|
networks: [docker-api, edge, ingress]
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,53 @@ time objective, retention period, storage capacity, or high-availability model;
|
||||||
those values require product policy and measurements from the eventual
|
those values require product policy and measurements from the eventual
|
||||||
production environment.
|
production environment.
|
||||||
|
|
||||||
|
## First production Compose environment
|
||||||
|
|
||||||
|
Run the initializer on the target Docker host after its final public hostname
|
||||||
|
is known:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/init-production-env.sh whoneedhelp.com .env.production
|
||||||
|
```
|
||||||
|
|
||||||
|
The initializer reads `.env.example`, derives the numeric group of that host's
|
||||||
|
Docker socket, generates independent random values for PostgreSQL,
|
||||||
|
`SECRET_KEY_BASE`, handover codes, the BEAM release cookie, and metrics access,
|
||||||
|
and writes an ignored mode-`0600` file. It does not print those values and
|
||||||
|
refuses to overwrite an existing destination.
|
||||||
|
|
||||||
|
By default, the generated public proxy port binds to `127.0.0.1`, which is
|
||||||
|
appropriate only when the verified reverse proxy reaches the application on
|
||||||
|
the same host. Set `PRODUCTION_HTTP_BIND_ADDRESS` when generating the file, or
|
||||||
|
edit `HTTP_BIND_ADDRESS` afterward, to match the observed target topology.
|
||||||
|
Replace `TRAEFIK_TRUSTED_IPS` with the exact source IP/CIDR observed at Traefik;
|
||||||
|
do not copy the temporary VPN value into an unrelated server.
|
||||||
|
|
||||||
|
Configure the transactional SMTP relay and sender accepted by that provider.
|
||||||
|
Provider-specific auth, TLS, ports, and credentials can be supplied to the
|
||||||
|
initializer through the documented `PRODUCTION_SMTP_*` environment values or
|
||||||
|
edited in the resulting ignored file. 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
|
||||||
|
```
|
||||||
|
|
||||||
|
The production override keeps Mailpit behind its inactive `local-mail` profile,
|
||||||
|
so public registration cannot appear to succeed while mail is only retained
|
||||||
|
locally. The validator checks file ownership/mode, origin consistency,
|
||||||
|
template markers, independent generated secrets, supported SMTP values, the
|
||||||
|
generated database URL, and the final Compose render without printing secrets.
|
||||||
|
It does not contact DNS, TLS, SMTP, the reverse proxy, or the application.
|
||||||
|
After deployment, verify `/healthz/ready`, inspect all replica health and logs,
|
||||||
|
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.
|
||||||
|
|
||||||
## 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,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,17 @@
|
||||||
|
|
||||||
<.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password">
|
<.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password">
|
||||||
<input type="hidden" name="action" value="update_password" />
|
<input type="hidden" name="action" value="update_password" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="update_password_username"
|
||||||
|
name="username"
|
||||||
|
value={@current_scope.user.email}
|
||||||
|
autocomplete="username"
|
||||||
|
class="hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabindex="-1"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
|
||||||
<.input
|
<.input
|
||||||
field={f[:password]}
|
field={f[:password]}
|
||||||
|
|
|
||||||
133
scripts/init-production-env.sh
Executable file
133
scripts/init-production-env.sh
Executable file
|
|
@ -0,0 +1,133 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
domain=${1:-}
|
||||||
|
target=${2:-"$ROOT/.env.production"}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 DOMAIN [OUTPUT_FILE]" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -z "$domain" ]; then
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! printf '%s\n' "$domain" |
|
||||||
|
grep -Eq '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$'; then
|
||||||
|
echo "DOMAIN must be a lowercase ASCII DNS hostname without a scheme, port, or path." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for command in awk docker grep mktemp openssl stat; do
|
||||||
|
if ! command -v "$command" >/dev/null 2>&1; then
|
||||||
|
echo "Required command is unavailable: $command" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ! -S /var/run/docker.sock ]; then
|
||||||
|
echo "/var/run/docker.sock is unavailable; run this on the target Docker host." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -e "$target" ]; then
|
||||||
|
echo "Refusing to overwrite existing deployment environment: $target" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
target_dir=$(dirname -- "$target")
|
||||||
|
if [ ! -d "$target_dir" ]; then
|
||||||
|
echo "Output directory does not exist: $target_dir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
postgres_password=$(openssl rand -hex 32)
|
||||||
|
secret_key_base=$(openssl rand -hex 64)
|
||||||
|
handover_secret=$(openssl rand -hex 64)
|
||||||
|
release_cookie=$(openssl rand -hex 64)
|
||||||
|
metrics_token=$(openssl rand -hex 32)
|
||||||
|
docker_socket_gid=$(stat -c '%g' /var/run/docker.sock)
|
||||||
|
|
||||||
|
http_bind_address=${PRODUCTION_HTTP_BIND_ADDRESS:-127.0.0.1}
|
||||||
|
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}
|
||||||
|
smtp_username=${PRODUCTION_SMTP_USERNAME:-}
|
||||||
|
smtp_password=${PRODUCTION_SMTP_PASSWORD:-}
|
||||||
|
smtp_auth=${PRODUCTION_SMTP_AUTH:-always}
|
||||||
|
smtp_tls=${PRODUCTION_SMTP_TLS:-always}
|
||||||
|
smtp_ssl=${PRODUCTION_SMTP_SSL:-false}
|
||||||
|
email_from_address=${PRODUCTION_EMAIL_FROM_ADDRESS:-"contact@$domain"}
|
||||||
|
|
||||||
|
tmp=$(mktemp "$target_dir/.production-env.XXXXXX")
|
||||||
|
trap 'rm -f "$tmp"' EXIT HUP INT TERM
|
||||||
|
chmod 600 "$tmp"
|
||||||
|
|
||||||
|
DOMAIN=$domain \
|
||||||
|
HTTP_BIND_ADDRESS_VALUE=$http_bind_address \
|
||||||
|
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" \
|
||||||
|
SECRET_KEY_BASE_VALUE=$secret_key_base \
|
||||||
|
HANDOVER_SECRET_VALUE=$handover_secret \
|
||||||
|
RELEASE_COOKIE_VALUE=$release_cookie \
|
||||||
|
METRICS_TOKEN_VALUE=$metrics_token \
|
||||||
|
SMTP_RELAY_VALUE=$smtp_relay \
|
||||||
|
SMTP_PORT_VALUE=$smtp_port \
|
||||||
|
SMTP_USERNAME_VALUE=$smtp_username \
|
||||||
|
SMTP_PASSWORD_VALUE=$smtp_password \
|
||||||
|
SMTP_AUTH_VALUE=$smtp_auth \
|
||||||
|
SMTP_TLS_VALUE=$smtp_tls \
|
||||||
|
SMTP_SSL_VALUE=$smtp_ssl \
|
||||||
|
EMAIL_FROM_ADDRESS_VALUE=$email_from_address \
|
||||||
|
awk '
|
||||||
|
BEGIN {
|
||||||
|
replacement["HTTP_BIND_ADDRESS"] = ENVIRON["HTTP_BIND_ADDRESS_VALUE"]
|
||||||
|
replacement["DOCKER_SOCKET_GID"] = ENVIRON["DOCKER_SOCKET_GID_VALUE"]
|
||||||
|
replacement["TRAEFIK_TRUSTED_IPS"] = ENVIRON["TRUSTED_PROXY_IPS_VALUE"]
|
||||||
|
replacement["PHX_HOST"] = ENVIRON["DOMAIN"]
|
||||||
|
replacement["PHX_SCHEME"] = "https"
|
||||||
|
replacement["PHX_URL_PORT"] = "443"
|
||||||
|
replacement["WNH_DEBUG_BASE_URL"] = "https://" ENVIRON["DOMAIN"]
|
||||||
|
replacement["WNH_BASE_URL"] = "https://" ENVIRON["DOMAIN"]
|
||||||
|
replacement["POSTGRES_PASSWORD"] = ENVIRON["POSTGRES_PASSWORD_VALUE"]
|
||||||
|
replacement["DATABASE_URL"] = ENVIRON["DATABASE_URL_VALUE"]
|
||||||
|
replacement["SECRET_KEY_BASE"] = ENVIRON["SECRET_KEY_BASE_VALUE"]
|
||||||
|
replacement["HANDOVER_SECRET"] = ENVIRON["HANDOVER_SECRET_VALUE"]
|
||||||
|
replacement["RELEASE_COOKIE"] = ENVIRON["RELEASE_COOKIE_VALUE"]
|
||||||
|
replacement["METRICS_TOKEN"] = ENVIRON["METRICS_TOKEN_VALUE"]
|
||||||
|
replacement["SMTP_RELAY"] = ENVIRON["SMTP_RELAY_VALUE"]
|
||||||
|
replacement["SMTP_PORT"] = ENVIRON["SMTP_PORT_VALUE"]
|
||||||
|
replacement["SMTP_USERNAME"] = ENVIRON["SMTP_USERNAME_VALUE"]
|
||||||
|
replacement["SMTP_PASSWORD"] = ENVIRON["SMTP_PASSWORD_VALUE"]
|
||||||
|
replacement["SMTP_AUTH"] = ENVIRON["SMTP_AUTH_VALUE"]
|
||||||
|
replacement["SMTP_TLS"] = ENVIRON["SMTP_TLS_VALUE"]
|
||||||
|
replacement["SMTP_SSL"] = ENVIRON["SMTP_SSL_VALUE"]
|
||||||
|
replacement["EMAIL_FROM_ADDRESS"] = ENVIRON["EMAIL_FROM_ADDRESS_VALUE"]
|
||||||
|
replacement["CODEX_SESSION_ID"] = "not-configured"
|
||||||
|
}
|
||||||
|
{
|
||||||
|
separator = index($0, "=")
|
||||||
|
key = separator > 1 ? substr($0, 1, separator - 1) : ""
|
||||||
|
|
||||||
|
if (key in replacement) {
|
||||||
|
print key "=" replacement[key]
|
||||||
|
} else {
|
||||||
|
print
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$ROOT/.env.example" >"$tmp"
|
||||||
|
|
||||||
|
mv "$tmp" "$target"
|
||||||
|
chmod 600 "$target"
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
|
||||||
|
unset postgres_password secret_key_base handover_secret release_cookie metrics_token
|
||||||
|
|
||||||
|
echo "Generated independent deployment secrets without printing them."
|
||||||
|
echo "Created mode-0600 environment: $target"
|
||||||
|
echo "Run scripts/validate-production-env.sh '$target' '$domain' after configuring the verified proxy and SMTP values."
|
||||||
|
|
@ -78,8 +78,36 @@ echo "Checking crash dumps are excluded from the Docker build context"
|
||||||
grep -Fx 'core' .dockerignore >/dev/null
|
grep -Fx 'core' .dockerignore >/dev/null
|
||||||
grep -Fx 'core.*' .dockerignore >/dev/null
|
grep -Fx 'core.*' .dockerignore >/dev/null
|
||||||
|
|
||||||
|
echo "Checking production environment initialization and validation"
|
||||||
|
production_env="$scan_dir/.env.production"
|
||||||
|
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 "$production_env" >/dev/null
|
||||||
|
test "$(stat -c '%a' "$production_env")" = 600
|
||||||
|
./scripts/validate-production-env.sh "$production_env" help.test >/dev/null
|
||||||
|
if ./scripts/init-production-env.sh help.test "$production_env" >/dev/null 2>&1; then
|
||||||
|
echo "Production environment initializer overwrote an existing file." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
incomplete_production_env="$scan_dir/.env.production.incomplete"
|
||||||
|
./scripts/init-production-env.sh help.test "$incomplete_production_env" >/dev/null
|
||||||
|
if ./scripts/validate-production-env.sh \
|
||||||
|
"$incomplete_production_env" help.test >/dev/null 2>&1; then
|
||||||
|
echo "Production environment validator accepted unresolved deployment inputs." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Rendering every Docker Compose profile"
|
echo "Rendering every Docker Compose profile"
|
||||||
docker compose --env-file .env.example -f compose.yaml config --quiet
|
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 |
|
docker compose --env-file .env.example -f compose.yaml config --format json |
|
||||||
jq --exit-status '
|
jq --exit-status '
|
||||||
. as $root
|
. as $root
|
||||||
|
|
@ -115,8 +143,14 @@ docker compose --env-file .env.example -f compose.yaml config --format json |
|
||||||
and $root.networks.internal.internal == true
|
and $root.networks.internal.internal == true
|
||||||
and ($root.networks.egress.internal // false) == false
|
and ($root.networks.egress.internal // false) == false
|
||||||
and $root.services.db.security_opt == ["no-new-privileges:true"]
|
and $root.services.db.security_opt == ["no-new-privileges:true"]
|
||||||
|
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
|
||||||
|
HTTP_BIND_ADDRESS=127.0.0.1 \
|
||||||
|
docker compose --env-file .env.example -f compose.yaml config --format json |
|
||||||
|
jq --exit-status '
|
||||||
|
.services.proxy.ports[0].host_ip == "127.0.0.1"
|
||||||
|
' >/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 |
|
docker compose --env-file .env.example -f compose.yaml config --format json |
|
||||||
jq --exit-status '
|
jq --exit-status '
|
||||||
|
|
|
||||||
175
scripts/validate-production-env.sh
Executable file
175
scripts/validate-production-env.sh
Executable file
|
|
@ -0,0 +1,175 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
env_file=${1:-}
|
||||||
|
expected_domain=${2:-}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 ENV_FILE EXPECTED_DOMAIN" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "$env_file" || -z "$expected_domain" ]]; then
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$env_file" ]]; then
|
||||||
|
echo "Deployment environment does not exist: $env_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$(stat -c '%a' "$env_file")" != 600 ]]; then
|
||||||
|
echo "Deployment environment must have mode 0600: $env_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$(stat -c '%u' "$env_file")" != "$(id -u)" ]]; then
|
||||||
|
echo "Deployment environment must be owned by the current operator." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
read_value() {
|
||||||
|
local key=$1
|
||||||
|
|
||||||
|
awk -v key="$key" '
|
||||||
|
index($0, key "=") == 1 {
|
||||||
|
print substr($0, length(key) + 2)
|
||||||
|
found = 1
|
||||||
|
}
|
||||||
|
END { if (!found) exit 1 }
|
||||||
|
' "$env_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_value() {
|
||||||
|
local key=$1
|
||||||
|
local value
|
||||||
|
|
||||||
|
if ! value=$(read_value "$key") || [[ -z "$value" ]]; then
|
||||||
|
echo "$key is missing or empty in $env_file." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
reject_marker() {
|
||||||
|
local key=$1
|
||||||
|
local value=$2
|
||||||
|
|
||||||
|
case "$value" in
|
||||||
|
*REPLACE* | *GENERATE* | *example.com*)
|
||||||
|
echo "$key still contains a template value." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
phx_host=$(require_value PHX_HOST)
|
||||||
|
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)
|
||||||
|
database_url=$(require_value DATABASE_URL)
|
||||||
|
secret_key_base=$(require_value SECRET_KEY_BASE)
|
||||||
|
handover_secret=$(require_value HANDOVER_SECRET)
|
||||||
|
release_cookie=$(require_value RELEASE_COOKIE)
|
||||||
|
metrics_token=$(require_value METRICS_TOKEN)
|
||||||
|
smtp_relay=$(require_value SMTP_RELAY)
|
||||||
|
smtp_port=$(require_value SMTP_PORT)
|
||||||
|
smtp_auth=$(require_value SMTP_AUTH)
|
||||||
|
smtp_tls=$(require_value SMTP_TLS)
|
||||||
|
smtp_ssl=$(require_value SMTP_SSL)
|
||||||
|
email_from_address=$(require_value EMAIL_FROM_ADDRESS)
|
||||||
|
|
||||||
|
[[ "$phx_host" == "$expected_domain" ]] || {
|
||||||
|
echo "PHX_HOST does not match EXPECTED_DOMAIN." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$phx_scheme" == https && "$phx_url_port" == 443 ]] || {
|
||||||
|
echo "Production PHX_SCHEME/PHX_URL_PORT must describe the public HTTPS origin." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$base_url" == "https://$expected_domain" ]] || {
|
||||||
|
echo "WNH_BASE_URL does not match the public HTTPS origin." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$debug_base_url" == "$base_url" ]] || {
|
||||||
|
echo "WNH_DEBUG_BASE_URL and WNH_BASE_URL must use the same deployment origin." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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" \
|
||||||
|
"RELEASE_COOKIE:$release_cookie" \
|
||||||
|
"METRICS_TOKEN:$metrics_token" \
|
||||||
|
"SMTP_RELAY:$smtp_relay" \
|
||||||
|
"EMAIL_FROM_ADDRESS:$email_from_address"
|
||||||
|
do
|
||||||
|
reject_marker "${pair%%:*}" "${pair#*:}"
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ "$smtp_relay" != mailpit ]] || {
|
||||||
|
echo "SMTP_RELAY still targets local Mailpit; public registration needs a transactional relay." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$smtp_port" =~ ^[0-9]+$ ]] || {
|
||||||
|
echo "SMTP_PORT must be numeric." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$smtp_auth" =~ ^(always|never|if_available)$ ]] || {
|
||||||
|
echo "SMTP_AUTH has an unsupported value." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$smtp_tls" =~ ^(always|never|if_available)$ ]] || {
|
||||||
|
echo "SMTP_TLS has an unsupported value." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$smtp_ssl" =~ ^(true|false|0|1)$ ]] || {
|
||||||
|
echo "SMTP_SSL has an unsupported value." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$email_from_address" == *@* ]] || {
|
||||||
|
echo "EMAIL_FROM_ADDRESS is not an email address." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
secrets=(
|
||||||
|
"$postgres_password"
|
||||||
|
"$secret_key_base"
|
||||||
|
"$handover_secret"
|
||||||
|
"$release_cookie"
|
||||||
|
"$metrics_token"
|
||||||
|
)
|
||||||
|
|
||||||
|
for ((left = 0; left < ${#secrets[@]}; left++)); do
|
||||||
|
for ((right = left + 1; right < ${#secrets[@]}; right++)); do
|
||||||
|
if [[ "${secrets[$left]}" == "${secrets[$right]}" ]]; then
|
||||||
|
echo "Deployment secrets must be independent." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
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
|
||||||
|
|
||||||
|
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."
|
||||||
|
|
@ -12,6 +12,8 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
|
||||||
conn = get(conn, ~p"/users/settings")
|
conn = get(conn, ~p"/users/settings")
|
||||||
response = html_response(conn, 200)
|
response = html_response(conn, 200)
|
||||||
assert response =~ "Settings"
|
assert response =~ "Settings"
|
||||||
|
assert response =~ ~s(id="update_password_username")
|
||||||
|
assert response =~ ~s(autocomplete="username")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "redirects if user is not logged in" do
|
test "redirects if user is not logged in" do
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user