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.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
|
||||
# verified tunnel topology, so choose this per deployment.
|
||||
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_BIND_ADDRESS=127.0.0.1
|
||||
DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -51,6 +51,7 @@ __pycache__/
|
|||
/.env
|
||||
/.env.*
|
||||
!/.env.example
|
||||
!/.env.edge.example
|
||||
!/.env.load.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
|
||||
|
||||
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.
|
||||
|
||||
```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,
|
||||
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
|
||||
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)
|
||||
database_mode=$(read_env_value DATABASE_MODE 2>/dev/null || printf container)
|
||||
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)
|
||||
|
||||
[[ "$deployment_target" == compose ]] || {
|
||||
|
|
@ -70,6 +71,11 @@ case "$app_topology" in
|
|||
*) echo "APP_TOPOLOGY must be compact or split." >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
case "$public_edge_enabled" in
|
||||
true | false) ;;
|
||||
*) echo "PUBLIC_EDGE_ENABLED must be true or false." >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
compose=(
|
||||
docker compose
|
||||
--project-directory "$ROOT"
|
||||
|
|
@ -86,6 +92,10 @@ if [[ "$app_topology" == compact ]]; then
|
|||
compose+=(--file "$ROOT/compose.compact.yaml" --profile compact)
|
||||
fi
|
||||
|
||||
if [[ "$public_edge_enabled" == true ]]; then
|
||||
compose+=(--file "$ROOT/compose.public-app.yaml")
|
||||
fi
|
||||
|
||||
if [[ "$deployment_environment" == production ]]; then
|
||||
compose+=(--file "$ROOT/compose.production.yaml")
|
||||
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}
|
||||
app_topology=${PRODUCTION_APP_TOPOLOGY:-compact}
|
||||
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
|
||||
*[!a-zA-Z0-9_-]* | '')
|
||||
|
|
@ -79,6 +82,11 @@ case "$app_topology" in
|
|||
*) echo "PRODUCTION_APP_TOPOLOGY must be compact or split." >&2; exit 1 ;;
|
||||
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)
|
||||
handover_secret=$(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 \
|
||||
HTTP_BIND_ADDRESS_VALUE=$http_bind_address \
|
||||
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 \
|
||||
TRUSTED_PROXY_IPS_VALUE=$trusted_proxy_ips \
|
||||
POSTGRES_PASSWORD_VALUE=$postgres_password \
|
||||
|
|
@ -134,6 +145,9 @@ SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \
|
|||
replacement["DATABASE_MODE"] = ENVIRON["DATABASE_MODE_VALUE"]
|
||||
replacement["HTTP_BIND_ADDRESS"] = ENVIRON["HTTP_BIND_ADDRESS_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["TRAEFIK_TRUSTED_IPS"] = ENVIRON["TRUSTED_PROXY_IPS_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"
|
||||
socket_proxy_image="who-need-help:socket-proxy-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"
|
||||
scan_dir=$(mktemp -d "${TMPDIR:-/tmp}/wnh-quality-scan.XXXXXX")
|
||||
scan_list="${scan_dir}.files"
|
||||
|
|
@ -44,6 +45,7 @@ cleanup() {
|
|||
docker image rm "$quality_image" "$assets_image" "$e2e_image" "$release_image" \
|
||||
"$backup_image" "$minio_image" "$mc_image" \
|
||||
"$boundary_mock_image" "$socket_proxy_image" "$postgis_image" \
|
||||
"$caddy_image" \
|
||||
>/dev/null 2>&1 || true
|
||||
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
|
||||
|
||||
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_TRAEFIK_TRUSTED_IPS=172.20.0.1/32 \
|
||||
PRODUCTION_SMTP_RELAY=smtp.help.test \
|
||||
|
|
@ -164,6 +174,22 @@ if ./scripts/validate-production-env.sh \
|
|||
fi
|
||||
|
||||
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 "$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.DNS_CLUSTER_QUERY == "ignore" 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"
|
||||
' >/dev/null
|
||||
./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("web") | 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
|
||||
./scripts/compose.sh "$external_split_production_env" config --format json |
|
||||
jq --exit-status '
|
||||
|
|
@ -236,6 +265,7 @@ echo "Rendering every Docker Compose profile"
|
|||
(.services | has("web")) and
|
||||
(.services | has("worker")) and
|
||||
(.services | has("proxy")) and
|
||||
.services.web.networks.public_edge.aliases == ["who-need-help-production"] and
|
||||
.services.web.deploy.replicas == 2 and
|
||||
.services.worker.deploy.replicas == 2
|
||||
' >/dev/null
|
||||
|
|
@ -383,8 +413,11 @@ docker run --rm \
|
|||
echo "Building, smoke-testing, and scanning pinned runtime infrastructure images"
|
||||
docker build --tag "$socket_proxy_image" --file Dockerfile.socket-proxy .
|
||||
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}}' "$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 '
|
||||
test ! -e /usr/local/bin/gosu
|
||||
test "$(id -u)" = 70
|
||||
|
|
@ -439,6 +472,7 @@ for image in \
|
|||
"$socket_proxy_image" \
|
||||
"$postgis_image" \
|
||||
"traefik:v3.7.8@sha256:4299bbed850421258fc5448c2e0e6ad350981d4d335a68de11b92448aedbefe5" \
|
||||
"$caddy_image" \
|
||||
"axllent/mailpit:v1.30.4@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6"; do
|
||||
docker run --rm \
|
||||
--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)
|
||||
debug_base_url=$(require_value WNH_DEBUG_BASE_URL)
|
||||
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)
|
||||
postgres_password=$(optional_value POSTGRES_PASSWORD)
|
||||
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
|
||||
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" ]] || {
|
||||
echo "PHX_HOST does not match EXPECTED_DOMAIN." >&2
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user