Add isolated Caddy edge for production and staging
Some checks are pending
Quality / full-local-gates (push) Waiting to run
Some checks are pending
Quality / full-local-gates (push) Waiting to run
This commit is contained in:
parent
e2ea2252bc
commit
87e6667e6a
|
|
@ -81,3 +81,4 @@ core.*
|
||||||
/.env
|
/.env
|
||||||
/.env.*
|
/.env.*
|
||||||
!/.env.example
|
!/.env.example
|
||||||
|
!/.env.edge.example
|
||||||
|
|
|
||||||
10
.env.edge.example
Normal file
10
.env.edge.example
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Copy to an ignored mode-0600 .env.edge on the public server.
|
||||||
|
COMPOSE_PROJECT_NAME=who_need_help_edge
|
||||||
|
PUBLIC_EDGE_NETWORK=who_need_help_public_edge
|
||||||
|
EDGE_BIND_ADDRESS=0.0.0.0
|
||||||
|
EDGE_HTTP_PORT=80
|
||||||
|
EDGE_HTTPS_PORT=443
|
||||||
|
PRIMARY_DOMAIN=whoneedhelp.com
|
||||||
|
PRIMARY_UPSTREAM=who-need-help-production:4000
|
||||||
|
STAGING_DOMAIN=staging.whoneedhelp.com
|
||||||
|
STAGING_UPSTREAM=who-need-help-staging:4000
|
||||||
|
|
@ -22,6 +22,11 @@ HTTP_PORT=4010
|
||||||
# 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
|
||||||
# verified tunnel topology, so choose this per deployment.
|
# verified tunnel topology, so choose this per deployment.
|
||||||
HTTP_BIND_ADDRESS=0.0.0.0
|
HTTP_BIND_ADDRESS=0.0.0.0
|
||||||
|
# Attach the selected app service to the separately managed public Caddy
|
||||||
|
# network. Keep disabled for ordinary local development.
|
||||||
|
PUBLIC_EDGE_ENABLED=false
|
||||||
|
PUBLIC_EDGE_NETWORK=who_need_help_public_edge
|
||||||
|
PUBLIC_UPSTREAM_NAME=who-need-help-local
|
||||||
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
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -51,6 +51,7 @@ __pycache__/
|
||||||
/.env
|
/.env
|
||||||
/.env.*
|
/.env.*
|
||||||
!/.env.example
|
!/.env.example
|
||||||
|
!/.env.edge.example
|
||||||
!/.env.load.example
|
!/.env.load.example
|
||||||
!/.env.e2e.example
|
!/.env.e2e.example
|
||||||
|
|
||||||
|
|
|
||||||
34
Dockerfile.caddy
Normal file
34
Dockerfile.caddy
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# syntax=docker/dockerfile:1.20.0
|
||||||
|
|
||||||
|
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS builder
|
||||||
|
|
||||||
|
ENV CGO_ENABLED=0
|
||||||
|
ENV GOTOOLCHAIN=local
|
||||||
|
|
||||||
|
RUN GOBIN=/out go install \
|
||||||
|
-trimpath \
|
||||||
|
-ldflags="-s -w -X github.com/caddyserver/caddy/v2.CustomVersion=v2.11.4" \
|
||||||
|
github.com/caddyserver/caddy/v2/cmd/caddy@v2.11.4
|
||||||
|
|
||||||
|
RUN mkdir -p /rootfs/data/caddy /rootfs/config/caddy /rootfs/tmp \
|
||||||
|
&& chown -R 1000:1000 /rootfs
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
|
||||||
|
ENV XDG_CONFIG_HOME=/config
|
||||||
|
ENV XDG_DATA_HOME=/data
|
||||||
|
ENV HOME=/tmp
|
||||||
|
|
||||||
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||||
|
COPY --from=builder /out/caddy /usr/bin/caddy
|
||||||
|
COPY --from=builder --chown=1000:1000 /rootfs/data /data
|
||||||
|
COPY --from=builder --chown=1000:1000 /rootfs/config /config
|
||||||
|
COPY --from=builder --chown=1000:1000 /rootfs/tmp /tmp
|
||||||
|
|
||||||
|
USER 1000:1000
|
||||||
|
WORKDIR /srv
|
||||||
|
|
||||||
|
EXPOSE 80 443 443/udp 2019
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/bin/caddy"]
|
||||||
|
CMD ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||||
|
|
@ -65,6 +65,13 @@ policies are deliberately not claimed as complete.
|
||||||
|
|
||||||
## Fast start with Docker Compose
|
## Fast start with Docker Compose
|
||||||
|
|
||||||
|
The public single-server path uses the compact application topology plus a
|
||||||
|
separately managed Caddy edge. Production and staging can run as isolated
|
||||||
|
Compose projects with distinct PostGIS volumes and secrets while sharing only a
|
||||||
|
Docker network used for HTTPS reverse proxying. See the
|
||||||
|
[operations runbook](docs/operations.md#public-https-edge-and-isolated-staging)
|
||||||
|
for the verified order of operations. Redis is not a project dependency.
|
||||||
|
|
||||||
Prerequisite: Docker with the Compose plugin.
|
Prerequisite: Docker with the Compose plugin.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
46
compose.edge.yaml
Normal file
46
compose.edge.yaml
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
name: who_need_help_edge
|
||||||
|
|
||||||
|
services:
|
||||||
|
edge:
|
||||||
|
image: ${CADDY_IMAGE:-who-need-help:caddy-local}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.caddy
|
||||||
|
user: "1000:1000"
|
||||||
|
environment:
|
||||||
|
PRIMARY_DOMAIN: ${PRIMARY_DOMAIN:?Set PRIMARY_DOMAIN in the edge environment}
|
||||||
|
PRIMARY_UPSTREAM: ${PRIMARY_UPSTREAM:?Set PRIMARY_UPSTREAM in the edge environment}
|
||||||
|
STAGING_DOMAIN: ${STAGING_DOMAIN:?Set STAGING_DOMAIN in the edge environment}
|
||||||
|
STAGING_UPSTREAM: ${STAGING_UPSTREAM:?Set STAGING_UPSTREAM in the edge environment}
|
||||||
|
ports:
|
||||||
|
- "${EDGE_BIND_ADDRESS:-0.0.0.0}:${EDGE_HTTP_PORT:-80}:80"
|
||||||
|
- "${EDGE_BIND_ADDRESS:-0.0.0.0}:${EDGE_HTTPS_PORT:-443}:443"
|
||||||
|
- "${EDGE_BIND_ADDRESS:-0.0.0.0}:${EDGE_HTTPS_PORT:-443}:443/udp"
|
||||||
|
volumes:
|
||||||
|
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
networks: [public_edge]
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop: [ALL]
|
||||||
|
cap_add: [NET_BIND_SERVICE]
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "caddy", "validate", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
networks:
|
||||||
|
public_edge:
|
||||||
|
name: ${PUBLIC_EDGE_NETWORK:?Set PUBLIC_EDGE_NETWORK in the edge environment}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
17
compose.public-app.yaml
Normal file
17
compose.public-app.yaml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
networks:
|
||||||
|
public_edge:
|
||||||
|
aliases:
|
||||||
|
- ${PUBLIC_UPSTREAM_NAME:?Set PUBLIC_UPSTREAM_NAME when PUBLIC_EDGE_ENABLED=true}
|
||||||
|
|
||||||
|
web:
|
||||||
|
networks:
|
||||||
|
public_edge:
|
||||||
|
aliases:
|
||||||
|
- ${PUBLIC_UPSTREAM_NAME:?Set PUBLIC_UPSTREAM_NAME when PUBLIC_EDGE_ENABLED=true}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
public_edge:
|
||||||
|
name: ${PUBLIC_EDGE_NETWORK:?Set PUBLIC_EDGE_NETWORK when PUBLIC_EDGE_ENABLED=true}
|
||||||
|
external: true
|
||||||
27
deploy/caddy/Caddyfile
Normal file
27
deploy/caddy/Caddyfile
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
admin off
|
||||||
|
}
|
||||||
|
|
||||||
|
{$PRIMARY_DOMAIN} {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
header {
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy {$PRIMARY_UPSTREAM}
|
||||||
|
}
|
||||||
|
|
||||||
|
www.{$PRIMARY_DOMAIN} {
|
||||||
|
redir https://{$PRIMARY_DOMAIN}{uri} permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
{$STAGING_DOMAIN} {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
header {
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy {$STAGING_UPSTREAM}
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,67 @@ The deployment environment selects topology and database ownership:
|
||||||
There is no Redis dependency. Queues, rate-limit counters, Oban leadership,
|
There is no Redis dependency. Queues, rate-limit counters, Oban leadership,
|
||||||
and durable application state use PostgreSQL.
|
and durable application state use PostgreSQL.
|
||||||
|
|
||||||
|
### Public HTTPS edge and isolated staging
|
||||||
|
|
||||||
|
The first single-server deployment uses one separately managed Caddy container
|
||||||
|
for ports 80/443 and two independent application projects. Caddy 2.11.4 is
|
||||||
|
built as a static non-root binary with pinned Go 1.26.5 and a scratch runtime.
|
||||||
|
Its persistent volume holds ACME account and
|
||||||
|
certificate state; do not remove that volume during an ordinary application
|
||||||
|
deploy. Production and staging each have their own application secret file,
|
||||||
|
PostGIS volume, migrations, and Docker DNS alias. They share only the named
|
||||||
|
`who_need_help_public_edge` network, so load and browser records created in
|
||||||
|
staging cannot enter the production database.
|
||||||
|
|
||||||
|
Generate and start the edge before either application project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/init-edge-env.sh \
|
||||||
|
whoneedhelp.com staging.whoneedhelp.com .env.edge
|
||||||
|
./scripts/edge-up.sh .env.edge
|
||||||
|
```
|
||||||
|
|
||||||
|
The authoritative A records for `whoneedhelp.com`, `www.whoneedhelp.com`, and
|
||||||
|
`staging.whoneedhelp.com` must point to the observed server address, and inbound
|
||||||
|
TCP 80/443 plus UDP 443 must be permitted before public certificate issuance.
|
||||||
|
Caddy obtains and renews public certificates and redirects HTTP to HTTPS. The
|
||||||
|
Phoenix release remains plain HTTP on the internal shared Docker network;
|
||||||
|
Caddy replaces incoming forwarding headers and WebSocket proxying is automatic.
|
||||||
|
The compact app's optional host-published HTTP port stays bound to loopback for
|
||||||
|
operator health checks and is not a public entry point.
|
||||||
|
|
||||||
|
Create staging with independent names and secrets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PRODUCTION_COMPOSE_PROJECT_NAME=who_need_help_staging \
|
||||||
|
PRODUCTION_PUBLIC_UPSTREAM_NAME=who-need-help-staging \
|
||||||
|
PRODUCTION_HTTP_PORT=4011 \
|
||||||
|
./scripts/init-production-env.sh \
|
||||||
|
staging.whoneedhelp.com .env.staging
|
||||||
|
```
|
||||||
|
|
||||||
|
Create production separately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PRODUCTION_COMPOSE_PROJECT_NAME=who_need_help_production \
|
||||||
|
PRODUCTION_PUBLIC_UPSTREAM_NAME=who-need-help-production \
|
||||||
|
PRODUCTION_HTTP_PORT=4010 \
|
||||||
|
./scripts/init-production-env.sh \
|
||||||
|
whoneedhelp.com .env.production
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure and validate SMTP/OAuth independently in each ignored file. Start
|
||||||
|
staging first, run database/load/browser/Android verification there, and then
|
||||||
|
start the clean production project. Stopping staging does not stop Caddy or
|
||||||
|
production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/compose.sh .env.staging down
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not add `--volumes` unless the exact staging database has been inspected and
|
||||||
|
its deletion is the intended operation.
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ deployment_target=$(read_env_value DEPLOYMENT_TARGET 2>/dev/null || printf compo
|
||||||
deployment_environment=$(read_env_value DEPLOYMENT_ENV 2>/dev/null || printf development)
|
deployment_environment=$(read_env_value DEPLOYMENT_ENV 2>/dev/null || printf development)
|
||||||
database_mode=$(read_env_value DATABASE_MODE 2>/dev/null || printf container)
|
database_mode=$(read_env_value DATABASE_MODE 2>/dev/null || printf container)
|
||||||
app_topology=$(read_env_value APP_TOPOLOGY 2>/dev/null || printf split)
|
app_topology=$(read_env_value APP_TOPOLOGY 2>/dev/null || printf split)
|
||||||
|
public_edge_enabled=$(read_env_value PUBLIC_EDGE_ENABLED 2>/dev/null || printf false)
|
||||||
project=$(read_env_value COMPOSE_PROJECT_NAME 2>/dev/null || printf who_need_help)
|
project=$(read_env_value COMPOSE_PROJECT_NAME 2>/dev/null || printf who_need_help)
|
||||||
|
|
||||||
[[ "$deployment_target" == compose ]] || {
|
[[ "$deployment_target" == compose ]] || {
|
||||||
|
|
@ -70,6 +71,11 @@ case "$app_topology" in
|
||||||
*) echo "APP_TOPOLOGY must be compact or split." >&2; exit 1 ;;
|
*) echo "APP_TOPOLOGY must be compact or split." >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
case "$public_edge_enabled" in
|
||||||
|
true | false) ;;
|
||||||
|
*) echo "PUBLIC_EDGE_ENABLED must be true or false." >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
compose=(
|
compose=(
|
||||||
docker compose
|
docker compose
|
||||||
--project-directory "$ROOT"
|
--project-directory "$ROOT"
|
||||||
|
|
@ -86,6 +92,10 @@ if [[ "$app_topology" == compact ]]; then
|
||||||
compose+=(--file "$ROOT/compose.compact.yaml" --profile compact)
|
compose+=(--file "$ROOT/compose.compact.yaml" --profile compact)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "$public_edge_enabled" == true ]]; then
|
||||||
|
compose+=(--file "$ROOT/compose.public-app.yaml")
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$deployment_environment" == production ]]; then
|
if [[ "$deployment_environment" == production ]]; then
|
||||||
compose+=(--file "$ROOT/compose.production.yaml")
|
compose+=(--file "$ROOT/compose.production.yaml")
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
22
scripts/edge-up.sh
Executable file
22
scripts/edge-up.sh
Executable file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
env_file=${1:-"$ROOT/.env.edge"}
|
||||||
|
|
||||||
|
if [[ "$env_file" != /* ]]; then
|
||||||
|
env_file="$ROOT/$env_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$env_file" ]]; then
|
||||||
|
echo "Edge environment does not exist: $env_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$ROOT/scripts/validate-edge-env.sh" "$env_file"
|
||||||
|
|
||||||
|
exec docker compose \
|
||||||
|
--project-directory "$ROOT" \
|
||||||
|
--env-file "$env_file" \
|
||||||
|
--file "$ROOT/compose.edge.yaml" \
|
||||||
|
up -d --build --wait --remove-orphans
|
||||||
58
scripts/init-edge-env.sh
Executable file
58
scripts/init-edge-env.sh
Executable file
|
|
@ -0,0 +1,58 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
primary_domain=${1:-}
|
||||||
|
staging_domain=${2:-}
|
||||||
|
target=${3:-"$ROOT/.env.edge"}
|
||||||
|
|
||||||
|
if [[ -z "$primary_domain" || -z "$staging_domain" ]]; then
|
||||||
|
echo "Usage: $0 PRIMARY_DOMAIN STAGING_DOMAIN [OUTPUT_FILE]" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$target" != /* ]]; then
|
||||||
|
target="$ROOT/$target"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -e "$target" ]]; then
|
||||||
|
echo "Refusing to overwrite existing edge 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
|
||||||
|
|
||||||
|
tmp=$(mktemp "$target_dir/.edge-env.XXXXXX")
|
||||||
|
trap 'rm -f "$tmp"' EXIT HUP INT TERM
|
||||||
|
chmod 600 "$tmp"
|
||||||
|
|
||||||
|
PRIMARY_DOMAIN_VALUE=$primary_domain \
|
||||||
|
STAGING_DOMAIN_VALUE=$staging_domain \
|
||||||
|
awk '
|
||||||
|
BEGIN {
|
||||||
|
replacement["PRIMARY_DOMAIN"] = ENVIRON["PRIMARY_DOMAIN_VALUE"]
|
||||||
|
replacement["STAGING_DOMAIN"] = ENVIRON["STAGING_DOMAIN_VALUE"]
|
||||||
|
}
|
||||||
|
{
|
||||||
|
separator = index($0, "=")
|
||||||
|
key = separator > 1 ? substr($0, 1, separator - 1) : ""
|
||||||
|
|
||||||
|
if (key in replacement) {
|
||||||
|
print key "=" replacement[key]
|
||||||
|
} else {
|
||||||
|
print
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$ROOT/.env.edge.example" >"$tmp"
|
||||||
|
|
||||||
|
mv "$tmp" "$target"
|
||||||
|
chmod 600 "$target"
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
|
||||||
|
"$ROOT/scripts/validate-edge-env.sh" "$target"
|
||||||
|
echo "Created mode-0600 edge environment: $target"
|
||||||
|
|
@ -47,6 +47,9 @@ fi
|
||||||
database_mode=${PRODUCTION_DATABASE_MODE:-container}
|
database_mode=${PRODUCTION_DATABASE_MODE:-container}
|
||||||
app_topology=${PRODUCTION_APP_TOPOLOGY:-compact}
|
app_topology=${PRODUCTION_APP_TOPOLOGY:-compact}
|
||||||
compose_project_name=${PRODUCTION_COMPOSE_PROJECT_NAME:-who_need_help}
|
compose_project_name=${PRODUCTION_COMPOSE_PROJECT_NAME:-who_need_help}
|
||||||
|
public_edge_enabled=${PRODUCTION_PUBLIC_EDGE_ENABLED:-true}
|
||||||
|
public_edge_network=${PRODUCTION_PUBLIC_EDGE_NETWORK:-who_need_help_public_edge}
|
||||||
|
public_upstream_name=${PRODUCTION_PUBLIC_UPSTREAM_NAME:-who-need-help-production}
|
||||||
|
|
||||||
case "$compose_project_name" in
|
case "$compose_project_name" in
|
||||||
*[!a-zA-Z0-9_-]* | '')
|
*[!a-zA-Z0-9_-]* | '')
|
||||||
|
|
@ -79,6 +82,11 @@ case "$app_topology" in
|
||||||
*) echo "PRODUCTION_APP_TOPOLOGY must be compact or split." >&2; exit 1 ;;
|
*) echo "PRODUCTION_APP_TOPOLOGY must be compact or split." >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
case "$public_edge_enabled" in
|
||||||
|
true | false) ;;
|
||||||
|
*) echo "PRODUCTION_PUBLIC_EDGE_ENABLED must be true or false." >&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)
|
||||||
|
|
@ -108,6 +116,9 @@ APP_TOPOLOGY_VALUE=$app_topology \
|
||||||
COMPOSE_PROJECT_NAME_VALUE=$compose_project_name \
|
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 \
|
HTTP_PORT_VALUE=$http_port \
|
||||||
|
PUBLIC_EDGE_ENABLED_VALUE=$public_edge_enabled \
|
||||||
|
PUBLIC_EDGE_NETWORK_VALUE=$public_edge_network \
|
||||||
|
PUBLIC_UPSTREAM_NAME_VALUE=$public_upstream_name \
|
||||||
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 \
|
||||||
|
|
@ -134,6 +145,9 @@ SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \
|
||||||
replacement["DATABASE_MODE"] = ENVIRON["DATABASE_MODE_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["HTTP_PORT"] = ENVIRON["HTTP_PORT_VALUE"]
|
||||||
|
replacement["PUBLIC_EDGE_ENABLED"] = ENVIRON["PUBLIC_EDGE_ENABLED_VALUE"]
|
||||||
|
replacement["PUBLIC_EDGE_NETWORK"] = ENVIRON["PUBLIC_EDGE_NETWORK_VALUE"]
|
||||||
|
replacement["PUBLIC_UPSTREAM_NAME"] = ENVIRON["PUBLIC_UPSTREAM_NAME_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_PROJECT_CONSTRAINT"] = ENVIRON["COMPOSE_PROJECT_NAME_VALUE"]
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ mc_image="who-need-help:mc-audit-$run_id"
|
||||||
boundary_mock_image="who-need-help:boundary-mock-audit-$run_id"
|
boundary_mock_image="who-need-help:boundary-mock-audit-$run_id"
|
||||||
socket_proxy_image="who-need-help:socket-proxy-audit-$run_id"
|
socket_proxy_image="who-need-help:socket-proxy-audit-$run_id"
|
||||||
postgis_image="who-need-help:postgis-audit-$run_id"
|
postgis_image="who-need-help:postgis-audit-$run_id"
|
||||||
|
caddy_image="who-need-help:caddy-audit-$run_id"
|
||||||
socket_proxy_container="wnh-socket-proxy-audit-$run_id"
|
socket_proxy_container="wnh-socket-proxy-audit-$run_id"
|
||||||
scan_dir=$(mktemp -d "${TMPDIR:-/tmp}/wnh-quality-scan.XXXXXX")
|
scan_dir=$(mktemp -d "${TMPDIR:-/tmp}/wnh-quality-scan.XXXXXX")
|
||||||
scan_list="${scan_dir}.files"
|
scan_list="${scan_dir}.files"
|
||||||
|
|
@ -44,6 +45,7 @@ cleanup() {
|
||||||
docker image rm "$quality_image" "$assets_image" "$e2e_image" "$release_image" \
|
docker image rm "$quality_image" "$assets_image" "$e2e_image" "$release_image" \
|
||||||
"$backup_image" "$minio_image" "$mc_image" \
|
"$backup_image" "$minio_image" "$mc_image" \
|
||||||
"$boundary_mock_image" "$socket_proxy_image" "$postgis_image" \
|
"$boundary_mock_image" "$socket_proxy_image" "$postgis_image" \
|
||||||
|
"$caddy_image" \
|
||||||
>/dev/null 2>&1 || true
|
>/dev/null 2>&1 || true
|
||||||
rm -rf "$scan_dir" "$scan_list" "$scan_tar"
|
rm -rf "$scan_dir" "$scan_list" "$scan_tar"
|
||||||
}
|
}
|
||||||
|
|
@ -90,6 +92,14 @@ grep -Fx '/act_runner' .dockerignore >/dev/null
|
||||||
grep -Fx '/act_runner-data/' .dockerignore >/dev/null
|
grep -Fx '/act_runner-data/' .dockerignore >/dev/null
|
||||||
|
|
||||||
echo "Checking production environment initialization and validation"
|
echo "Checking production environment initialization and validation"
|
||||||
|
edge_env="$scan_dir/.env.edge"
|
||||||
|
./scripts/init-edge-env.sh help.test staging.help.test "$edge_env" >/dev/null
|
||||||
|
test "$(stat -c '%a' "$edge_env")" = 600
|
||||||
|
./scripts/validate-edge-env.sh "$edge_env" >/dev/null
|
||||||
|
if ./scripts/init-edge-env.sh help.test staging.help.test "$edge_env" >/dev/null 2>&1; then
|
||||||
|
echo "Edge environment initializer overwrote an existing file." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
production_env="$scan_dir/.env.production"
|
production_env="$scan_dir/.env.production"
|
||||||
PRODUCTION_TRAEFIK_TRUSTED_IPS=172.20.0.1/32 \
|
PRODUCTION_TRAEFIK_TRUSTED_IPS=172.20.0.1/32 \
|
||||||
PRODUCTION_SMTP_RELAY=smtp.help.test \
|
PRODUCTION_SMTP_RELAY=smtp.help.test \
|
||||||
|
|
@ -164,6 +174,22 @@ if ./scripts/validate-production-env.sh \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Rendering every Docker Compose profile"
|
echo "Rendering every Docker Compose profile"
|
||||||
|
CADDY_IMAGE=who-need-help:caddy-local \
|
||||||
|
docker compose --project-directory "$ROOT" --env-file "$edge_env" \
|
||||||
|
--file compose.edge.yaml config --format json |
|
||||||
|
jq --exit-status '
|
||||||
|
.services.edge.image == "who-need-help:caddy-local" and
|
||||||
|
.services.edge.user == "1000:1000" and
|
||||||
|
.services.edge.read_only == true and
|
||||||
|
.services.edge.cap_drop == ["ALL"] and
|
||||||
|
.services.edge.cap_add == ["NET_BIND_SERVICE"] and
|
||||||
|
.services.edge.security_opt == ["no-new-privileges:true"] and
|
||||||
|
(.services.edge.tmpfs | index("/tmp") != null) and
|
||||||
|
(.services.edge.ports | map(select(.target == 80 and .published == "80" and .protocol == "tcp")) | length) == 1 and
|
||||||
|
(.services.edge.ports | map(select(.target == 443 and .published == "443" and .protocol == "tcp")) | length) == 1 and
|
||||||
|
(.services.edge.ports | map(select(.target == 443 and .published == "443" and .protocol == "udp")) | length) == 1 and
|
||||||
|
.networks.public_edge.name == "who_need_help_public_edge"
|
||||||
|
' >/dev/null
|
||||||
./scripts/compose.sh .env.example config --quiet
|
./scripts/compose.sh .env.example config --quiet
|
||||||
./scripts/compose.sh "$production_env" config --quiet
|
./scripts/compose.sh "$production_env" config --quiet
|
||||||
./scripts/compose.sh "$external_production_env" config --quiet
|
./scripts/compose.sh "$external_production_env" config --quiet
|
||||||
|
|
@ -219,6 +245,8 @@ echo "Rendering every Docker Compose profile"
|
||||||
.services.app.environment.APP_ROLE == "combined" and
|
.services.app.environment.APP_ROLE == "combined" and
|
||||||
.services.app.environment.DNS_CLUSTER_QUERY == "ignore" and
|
.services.app.environment.DNS_CLUSTER_QUERY == "ignore" and
|
||||||
.services.app.environment.POOL_SIZE == "4" and
|
.services.app.environment.POOL_SIZE == "4" and
|
||||||
|
.services.app.networks.public_edge.aliases == ["who-need-help-production"] and
|
||||||
|
.networks.public_edge.external == true and
|
||||||
.services.app.ports[0].host_ip == "127.0.0.1"
|
.services.app.ports[0].host_ip == "127.0.0.1"
|
||||||
' >/dev/null
|
' >/dev/null
|
||||||
./scripts/compose.sh "$external_production_env" config --format json |
|
./scripts/compose.sh "$external_production_env" config --format json |
|
||||||
|
|
@ -227,7 +255,8 @@ echo "Rendering every Docker Compose profile"
|
||||||
(.services | has("db") | not) and
|
(.services | has("db") | not) and
|
||||||
(.services | has("web") | not) and
|
(.services | has("web") | not) and
|
||||||
(.services | has("worker") | not) and
|
(.services | has("worker") | not) and
|
||||||
(.services | has("proxy") | not)
|
(.services | has("proxy") | not) and
|
||||||
|
.services.app.networks.public_edge.aliases == ["who-need-help-production"]
|
||||||
' >/dev/null
|
' >/dev/null
|
||||||
./scripts/compose.sh "$external_split_production_env" config --format json |
|
./scripts/compose.sh "$external_split_production_env" config --format json |
|
||||||
jq --exit-status '
|
jq --exit-status '
|
||||||
|
|
@ -236,6 +265,7 @@ echo "Rendering every Docker Compose profile"
|
||||||
(.services | has("web")) and
|
(.services | has("web")) and
|
||||||
(.services | has("worker")) and
|
(.services | has("worker")) and
|
||||||
(.services | has("proxy")) and
|
(.services | has("proxy")) and
|
||||||
|
.services.web.networks.public_edge.aliases == ["who-need-help-production"] and
|
||||||
.services.web.deploy.replicas == 2 and
|
.services.web.deploy.replicas == 2 and
|
||||||
.services.worker.deploy.replicas == 2
|
.services.worker.deploy.replicas == 2
|
||||||
' >/dev/null
|
' >/dev/null
|
||||||
|
|
@ -383,8 +413,11 @@ docker run --rm \
|
||||||
echo "Building, smoke-testing, and scanning pinned runtime infrastructure images"
|
echo "Building, smoke-testing, and scanning pinned runtime infrastructure images"
|
||||||
docker build --tag "$socket_proxy_image" --file Dockerfile.socket-proxy .
|
docker build --tag "$socket_proxy_image" --file Dockerfile.socket-proxy .
|
||||||
docker build --tag "$postgis_image" --file Dockerfile.postgis .
|
docker build --tag "$postgis_image" --file Dockerfile.postgis .
|
||||||
|
docker build --tag "$caddy_image" --file Dockerfile.caddy .
|
||||||
test "$(docker image inspect --format '{{.Config.User}}' "$socket_proxy_image")" = "haproxy"
|
test "$(docker image inspect --format '{{.Config.User}}' "$socket_proxy_image")" = "haproxy"
|
||||||
test "$(docker image inspect --format '{{.Config.User}}' "$postgis_image")" = "postgres"
|
test "$(docker image inspect --format '{{.Config.User}}' "$postgis_image")" = "postgres"
|
||||||
|
test "$(docker image inspect --format '{{.Config.User}}' "$caddy_image")" = "1000:1000"
|
||||||
|
docker run --rm "$caddy_image" version | grep -F 'v2.11.4' >/dev/null
|
||||||
docker run --rm --entrypoint sh "$postgis_image" -euc '
|
docker run --rm --entrypoint sh "$postgis_image" -euc '
|
||||||
test ! -e /usr/local/bin/gosu
|
test ! -e /usr/local/bin/gosu
|
||||||
test "$(id -u)" = 70
|
test "$(id -u)" = 70
|
||||||
|
|
@ -439,6 +472,7 @@ for image in \
|
||||||
"$socket_proxy_image" \
|
"$socket_proxy_image" \
|
||||||
"$postgis_image" \
|
"$postgis_image" \
|
||||||
"traefik:v3.7.8@sha256:4299bbed850421258fc5448c2e0e6ad350981d4d335a68de11b92448aedbefe5" \
|
"traefik:v3.7.8@sha256:4299bbed850421258fc5448c2e0e6ad350981d4d335a68de11b92448aedbefe5" \
|
||||||
|
"$caddy_image" \
|
||||||
"axllent/mailpit:v1.30.4@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6"; do
|
"axllent/mailpit:v1.30.4@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6"; do
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
|
|
||||||
121
scripts/validate-edge-env.sh
Executable file
121
scripts/validate-edge-env.sh
Executable file
|
|
@ -0,0 +1,121 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
env_file=${1:-}
|
||||||
|
|
||||||
|
if [[ -z "$env_file" || ! -f "$env_file" ]]; then
|
||||||
|
echo "Usage: $0 EDGE_ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$(stat -c '%a' "$env_file")" != 600 ]]; then
|
||||||
|
echo "Edge environment must have mode 0600: $env_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$(stat -c '%u' "$env_file")" != "$(id -u)" ]]; then
|
||||||
|
echo "Edge 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
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_hostname() {
|
||||||
|
local key=$1
|
||||||
|
local value=$2
|
||||||
|
|
||||||
|
if [[ ! "$value" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$ ]]; then
|
||||||
|
echo "$key must be a lowercase ASCII DNS hostname." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_upstream() {
|
||||||
|
local key=$1
|
||||||
|
local value=$2
|
||||||
|
|
||||||
|
if [[ ! "$value" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?:[0-9]+$ ]]; then
|
||||||
|
echo "$key must be a lowercase host and numeric port." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_project_name=$(require_value COMPOSE_PROJECT_NAME)
|
||||||
|
public_edge_network=$(require_value PUBLIC_EDGE_NETWORK)
|
||||||
|
edge_bind_address=$(require_value EDGE_BIND_ADDRESS)
|
||||||
|
edge_http_port=$(require_value EDGE_HTTP_PORT)
|
||||||
|
edge_https_port=$(require_value EDGE_HTTPS_PORT)
|
||||||
|
primary_domain=$(require_value PRIMARY_DOMAIN)
|
||||||
|
primary_upstream=$(require_value PRIMARY_UPSTREAM)
|
||||||
|
staging_domain=$(require_value STAGING_DOMAIN)
|
||||||
|
staging_upstream=$(require_value STAGING_UPSTREAM)
|
||||||
|
|
||||||
|
[[ "$compose_project_name" =~ ^[a-zA-Z0-9_-]+$ ]] || {
|
||||||
|
echo "COMPOSE_PROJECT_NAME contains unsupported characters." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$public_edge_network" =~ ^[a-zA-Z0-9_-]+$ ]] || {
|
||||||
|
echo "PUBLIC_EDGE_NETWORK contains unsupported characters." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$edge_bind_address" == "0.0.0.0" || "$edge_bind_address" == "127.0.0.1" ]] || {
|
||||||
|
echo "EDGE_BIND_ADDRESS must be 0.0.0.0 or 127.0.0.1." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
for port_name in edge_http_port edge_https_port; do
|
||||||
|
port=${!port_name}
|
||||||
|
if [[ ! "$port" =~ ^[0-9]+$ ]] || ((port < 1 || port > 65535)); then
|
||||||
|
echo "${port_name^^} must be between 1 and 65535." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[[ "$edge_http_port" != "$edge_https_port" ]] || {
|
||||||
|
echo "EDGE_HTTP_PORT and EDGE_HTTPS_PORT must be different." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_hostname PRIMARY_DOMAIN "$primary_domain"
|
||||||
|
validate_hostname STAGING_DOMAIN "$staging_domain"
|
||||||
|
validate_upstream PRIMARY_UPSTREAM "$primary_upstream"
|
||||||
|
validate_upstream STAGING_UPSTREAM "$staging_upstream"
|
||||||
|
|
||||||
|
[[ "$primary_domain" != "$staging_domain" ]] || {
|
||||||
|
echo "PRIMARY_DOMAIN and STAGING_DOMAIN must be different." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$primary_upstream" != "$staging_upstream" ]] || {
|
||||||
|
echo "PRIMARY_UPSTREAM and STAGING_UPSTREAM must be different." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
docker compose \
|
||||||
|
--project-directory "$ROOT" \
|
||||||
|
--env-file "$env_file" \
|
||||||
|
--file "$ROOT/compose.edge.yaml" \
|
||||||
|
config --quiet
|
||||||
|
|
||||||
|
echo "Public edge environment passed structural validation."
|
||||||
|
|
@ -82,6 +82,9 @@ 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)
|
||||||
|
public_edge_enabled=$(require_value PUBLIC_EDGE_ENABLED)
|
||||||
|
public_edge_network=$(require_value PUBLIC_EDGE_NETWORK)
|
||||||
|
public_upstream_name=$(require_value PUBLIC_UPSTREAM_NAME)
|
||||||
trusted_proxy_ips=$(optional_value TRAEFIK_TRUSTED_IPS)
|
trusted_proxy_ips=$(optional_value TRAEFIK_TRUSTED_IPS)
|
||||||
postgres_password=$(optional_value POSTGRES_PASSWORD)
|
postgres_password=$(optional_value POSTGRES_PASSWORD)
|
||||||
database_url=$(require_value DATABASE_URL)
|
database_url=$(require_value DATABASE_URL)
|
||||||
|
|
@ -121,6 +124,20 @@ google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET)
|
||||||
echo "DATABASE_MODE must be container or external." >&2
|
echo "DATABASE_MODE must be container or external." >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
[[ "$public_edge_enabled" =~ ^(true|false)$ ]] || {
|
||||||
|
echo "PUBLIC_EDGE_ENABLED must be true or false." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if [[ "$public_edge_enabled" == true ]]; then
|
||||||
|
[[ "$public_edge_network" =~ ^[a-zA-Z0-9_-]+$ ]] || {
|
||||||
|
echo "PUBLIC_EDGE_NETWORK contains unsupported characters." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$public_upstream_name" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || {
|
||||||
|
echo "PUBLIC_UPSTREAM_NAME must be a lowercase Docker DNS alias." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
[[ "$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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user