347 lines
11 KiB
Bash
Executable File
347 lines
11 KiB
Bash
Executable File
#!/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"
|
|
}
|
|
|
|
optional_value() {
|
|
local key=$1
|
|
|
|
read_value "$key" 2>/dev/null || true
|
|
}
|
|
|
|
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)
|
|
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)
|
|
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)
|
|
database_socket_dir=$(optional_value DATABASE_SOCKET_DIR)
|
|
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)
|
|
email_delivery_provider=$(require_value EMAIL_DELIVERY_PROVIDER)
|
|
smtp_relay=$(optional_value SMTP_RELAY)
|
|
smtp_port=$(optional_value SMTP_PORT)
|
|
smtp_username=$(optional_value SMTP_USERNAME)
|
|
smtp_password=$(optional_value SMTP_PASSWORD)
|
|
smtp_auth=$(optional_value SMTP_AUTH)
|
|
smtp_tls=$(optional_value SMTP_TLS)
|
|
smtp_ssl=$(optional_value SMTP_SSL)
|
|
unisender_go_api_key=$(optional_value UNISENDER_GO_API_KEY)
|
|
unisender_go_base_url=$(optional_value UNISENDER_GO_BASE_URL)
|
|
email_http_connect_timeout_ms=$(optional_value EMAIL_HTTP_CONNECT_TIMEOUT_MS)
|
|
email_http_receive_timeout_ms=$(optional_value EMAIL_HTTP_RECEIVE_TIMEOUT_MS)
|
|
email_from_address=$(require_value EMAIL_FROM_ADDRESS)
|
|
support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS)
|
|
google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID)
|
|
google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET)
|
|
codex_session_id=$(require_value CODEX_SESSION_ID)
|
|
|
|
[[ "$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
|
|
}
|
|
[[ "$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
|
|
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" \
|
|
"DATABASE_URL:$database_url" \
|
|
"SECRET_KEY_BASE:$secret_key_base" \
|
|
"HANDOVER_SECRET:$handover_secret" \
|
|
"RELEASE_COOKIE:$release_cookie" \
|
|
"METRICS_TOKEN:$metrics_token" \
|
|
"EMAIL_FROM_ADDRESS:$email_from_address"
|
|
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
|
|
|
|
if [[ -n "$database_socket_dir" ]]; then
|
|
[[ "$database_socket_dir" == /* ]] || {
|
|
echo "DATABASE_SOCKET_DIR must be an absolute path." >&2
|
|
exit 1
|
|
}
|
|
reject_marker DATABASE_SOCKET_DIR "$database_socket_dir"
|
|
fi
|
|
fi
|
|
if [[ "$database_mode" != external && -n "$database_socket_dir" ]]; then
|
|
echo "DATABASE_SOCKET_DIR is only valid for DATABASE_MODE=external." >&2
|
|
exit 1
|
|
fi
|
|
|
|
case "$email_delivery_provider" in
|
|
smtp)
|
|
[[ -n "$smtp_relay" ]] || {
|
|
echo "SMTP_RELAY is required when EMAIL_DELIVERY_PROVIDER=smtp." >&2
|
|
exit 1
|
|
}
|
|
reject_marker SMTP_RELAY "$smtp_relay"
|
|
[[ "$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
|
|
}
|
|
if [[ -n "$smtp_username" || -n "$smtp_password" ]]; then
|
|
[[ -n "$smtp_username" && -n "$smtp_password" ]] || {
|
|
echo "SMTP username and password must either both be set or both be empty." >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
if [[ "$smtp_auth" == always && (-z "$smtp_username" || -z "$smtp_password") ]]; then
|
|
echo "SMTP username and password are required when SMTP_AUTH is always." >&2
|
|
exit 1
|
|
fi
|
|
if [[ "$smtp_ssl" =~ ^(true|1)$ && "$smtp_tls" != never ]]; then
|
|
echo "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection." >&2
|
|
exit 1
|
|
fi
|
|
;;
|
|
unisender_go)
|
|
[[ -n "$unisender_go_api_key" ]] || {
|
|
echo "UNISENDER_GO_API_KEY is required when EMAIL_DELIVERY_PROVIDER=unisender_go." >&2
|
|
exit 1
|
|
}
|
|
reject_marker UNISENDER_GO_API_KEY "$unisender_go_api_key"
|
|
[[ "$unisender_go_base_url" =~ ^https://[^/@?#[:space:]]+(/[^?#[:space:]]*)?$ ]] || {
|
|
echo "UNISENDER_GO_BASE_URL must be an HTTPS origin and path without credentials, query, or fragment." >&2
|
|
exit 1
|
|
}
|
|
reject_marker UNISENDER_GO_BASE_URL "$unisender_go_base_url"
|
|
;;
|
|
*)
|
|
echo "EMAIL_DELIVERY_PROVIDER must be smtp or unisender_go." >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
for timeout_pair in \
|
|
"EMAIL_HTTP_CONNECT_TIMEOUT_MS:$email_http_connect_timeout_ms" \
|
|
"EMAIL_HTTP_RECEIVE_TIMEOUT_MS:$email_http_receive_timeout_ms"
|
|
do
|
|
timeout_value=${timeout_pair#*:}
|
|
if [[ -n "$timeout_value" && ! "$timeout_value" =~ ^[1-9][0-9]*$ ]]; then
|
|
echo "${timeout_pair%%:*} must be a positive integer when configured." >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
[[ "$email_from_address" == *@* ]] || {
|
|
echo "EMAIL_FROM_ADDRESS is not an email address." >&2
|
|
exit 1
|
|
}
|
|
if [[ -n "$support_inbox_address" &&
|
|
! "$support_inbox_address" =~ ^[^@,\;[:space:]]+@[^@,\;[:space:]]+$ ]]; then
|
|
echo "SUPPORT_INBOX_ADDRESS is not an email address." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -n "$google_oauth_client_id" || -n "$google_oauth_client_secret" ]]; then
|
|
[[ -n "$google_oauth_client_id" && -n "$google_oauth_client_secret" ]] || {
|
|
echo "Google OAuth client ID and secret must either both be set or both be empty." >&2
|
|
exit 1
|
|
}
|
|
|
|
reject_marker GOOGLE_OAUTH_CLIENT_ID "$google_oauth_client_id"
|
|
reject_marker GOOGLE_OAUTH_CLIENT_SECRET "$google_oauth_client_secret"
|
|
fi
|
|
|
|
case "$codex_session_id" in
|
|
not-configured | copy-the-main-local-codex-session-id)
|
|
echo "CODEX_SESSION_ID must identify the Build Week Codex session." >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
reject_marker CODEX_SESSION_ID "$codex_session_id"
|
|
|
|
secrets=(
|
|
"$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
|
|
echo "Deployment secrets must be independent." >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
done
|
|
|
|
"$ROOT/scripts/compose.sh" "$env_file" config --quiet
|
|
|
|
echo "Production environment structure passed validation without printing secrets."
|
|
echo "This does not test DNS, TLS, email-provider availability/delivery, proxy source IPs, or server capacity."
|