Infrastructure now gives each project its own Postgres role, S3 identity limited to its bucket and a Zitadel service account that owns only its project, instead of handing out the admin accounts. - Postgres and S3 settings default to Ping's own (ping). - zitadel-init mounts ZITADEL_PAT_DIR (Infrastructure's zitadel/.output/ping), waits for that PAT and uses it. It no longer creates the Zitadel project, which that account can't do: Infrastructure does.
276 lines
12 KiB
YAML
276 lines
12 KiB
YAML
# Ping: base stack, shared by development and production.
|
|
#
|
|
# Zitadel and SeaweedFS are NOT run here: Ping reuses the shared instances in
|
|
# ../../../Infrastructure (see .env.example, ZITADEL_PAT_DIR / S3_*), with
|
|
# Ping's own credentials on each (provisioned there).
|
|
# Bring that stack up first.
|
|
#
|
|
# dev docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
|
|
#
|
|
# The `api` and `web` services sit behind the `apps` profile, so a plain `up`
|
|
# starts LiveKit and the one-shot bootstrap jobs (Postgres wait, migrations,
|
|
# Zitadel project/apps, SeaweedFS bucket) and stops there. That's the usual
|
|
# dev setup, with the apps running from the IDE. Add `--profile apps up -d
|
|
# --build` as a SECOND command to also build and run them in Docker: by then
|
|
# zitadel-init has already written the OIDC client ids into the env file.
|
|
|
|
x-backend-env: &backend-env
|
|
Database__ConnectionString: "Host=${POSTGRES_HOST};Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
|
|
# The public S3 URL, not the internal one: the same address is used to sign
|
|
# the presigned upload/download URLs the browser has to be able to reach.
|
|
InfrastructureStorage__Address: ${S3_PUBLIC_URL}
|
|
InfrastructureStorage__AccessKey: ${S3_ACCESS_KEY}
|
|
InfrastructureStorage__SecretAccessKey: ${S3_SECRET_KEY}
|
|
InfrastructureStorage__ForcePathStyle: "true"
|
|
InfrastructureStorage__UseHttp: ${S3_USE_HTTP}
|
|
ApplicationStorage__Bucket: ${S3_BUCKET}
|
|
|
|
services:
|
|
# --- Infrastructure -------------------------------------------------------
|
|
|
|
# Voice/video SFU. --dev keeps it single-node with in-memory state (fine for local
|
|
# development), but the key pair is overridden via LIVEKIT_KEYS: the LiveKit .NET server SDK
|
|
# rejects secrets shorter than 256 bits, and --dev's built-in "devkey/secret" pair is too short.
|
|
livekit:
|
|
image: livekit/livekit-server:latest
|
|
container_name: ping-livekit
|
|
command: --dev --bind 0.0.0.0
|
|
environment:
|
|
LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
|
|
# Node IP is "automatically determined" by default, which inside a container means the
|
|
# Docker bridge network's internal address (e.g. 172.x.x.x), unreachable from the
|
|
# browser, so ICE/media never connects even though the WebSocket signaling (which goes
|
|
# through the published port, not this address) succeeds. Every public URL in this dev
|
|
# stack is already hardcoded to localhost (see PUBLIC_WEB_URL, VITE_LIVEKIT_URL), so the
|
|
# browser and this container are always on the same machine. Advertise that directly.
|
|
NODE_IP: 127.0.0.1
|
|
restart: unless-stopped
|
|
|
|
# Waits for Infrastructure's Postgres to accept connections before `migrate`
|
|
# runs. A cross-compose-project health check isn't possible, since
|
|
# Postgres is no longer part of this compose file.
|
|
wait-for-postgres:
|
|
image: postgres:17-alpine
|
|
container_name: ping-wait-for-postgres
|
|
entrypoint: ["/bin/sh", "-c"]
|
|
command:
|
|
- |
|
|
set -e
|
|
deadline=$$(( $$(date +%s) + 60 ))
|
|
until pg_isready -h ${POSTGRES_HOST} -p 5432 -U ${POSTGRES_USER} >/dev/null 2>&1; do
|
|
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "postgres did not respond at ${POSTGRES_HOST}:5432. Is Infrastructure running?" >&2; exit 1; }
|
|
sleep 2
|
|
done
|
|
restart: "no"
|
|
|
|
# One-shot: creates the Ping project and its two OIDC apps in the shared
|
|
# Zitadel (or reuses them if they already exist), then writes
|
|
# AUTH_CLIENT_ID / WEB_CLIENT_ID back into the env file mounted at
|
|
# /workspace/.env, so a later `--profile apps up` picks up real values
|
|
# instead of empty ones. Both apps are public clients (PKCE, no secret): the
|
|
# Scalar docs UI and the React SPA.
|
|
zitadel-init:
|
|
image: alpine:3.20
|
|
container_name: ping-zitadel-init
|
|
volumes:
|
|
- ${ZITADEL_PAT_DIR}:/zitadel-output:ro
|
|
- ./.env:/workspace/.env
|
|
environment:
|
|
PUBLIC_AUTH_URL: ${PUBLIC_AUTH_URL}
|
|
PUBLIC_API_URL: ${PUBLIC_API_URL}
|
|
PUBLIC_WEB_URL: ${PUBLIC_WEB_URL}
|
|
ZITADEL_DEV_MODE: ${ZITADEL_DEV_MODE:-false}
|
|
entrypoint: ["/bin/sh", "-c"]
|
|
command:
|
|
- |
|
|
set -e
|
|
apk add --no-cache curl jq >/dev/null
|
|
|
|
ENV_FILE=/workspace/.env
|
|
if [ ! -f "$$ENV_FILE" ]; then
|
|
echo "no env file mounted at $$ENV_FILE, skipping"
|
|
exit 0
|
|
fi
|
|
|
|
env_set() {
|
|
if grep -qE "^$$2=" "$$1"; then
|
|
awk -v k="$$2" -v v="$$3" '{ sub(/\r$$/, "") } index($$0,k "=")==1{print k "=" v; next} {print}' "$$1" >"$$1.tmp" && cat "$$1.tmp" >"$$1" && rm -f "$$1.tmp"
|
|
else
|
|
printf '%s=%s\n' "$$2" "$$3" >>"$$1"
|
|
fi
|
|
}
|
|
|
|
# Ping's own service account, owner of the Ping project only
|
|
# (created by Infrastructure's `provision`, see its README).
|
|
deadline=$$(( $$(date +%s) + 180 ))
|
|
while [ ! -s /zitadel-output/zitadel.pat ]; do
|
|
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "no zitadel.pat in ZITADEL_PAT_DIR. Is Infrastructure up, and does ZITADEL_PAT_DIR point at its zitadel/.output/ping? Check 'docker logs infra-provision'." >&2; exit 1; }
|
|
sleep 3
|
|
done
|
|
PAT=$$(tr -d '\r\n' </zitadel-output/zitadel.pat)
|
|
MGMT="$$PUBLIC_AUTH_URL/management/v1"
|
|
|
|
echo "==> waiting for Zitadel to answer at $$PUBLIC_AUTH_URL"
|
|
deadline=$$(( $$(date +%s) + 60 ))
|
|
until curl -fsS "$$PUBLIC_AUTH_URL/.well-known/openid-configuration" >/dev/null 2>&1; do
|
|
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "Zitadel did not respond at $$PUBLIC_AUTH_URL" >&2; exit 1; }
|
|
sleep 3
|
|
done
|
|
|
|
api_call() {
|
|
if [ $$# -ge 3 ]; then
|
|
curl -fsS -X "$$1" "$$MGMT$$2" -H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" -d "$$3"
|
|
else
|
|
curl -fsS -X "$$1" "$$MGMT$$2" -H "Authorization: Bearer $$PAT"
|
|
fi
|
|
}
|
|
name_query() { printf '{"queries":[{"nameQuery":{"name":"%s","method":"TEXT_QUERY_METHOD_EQUALS"}}]}' "$$1"; }
|
|
|
|
echo "==> project"
|
|
PROJECT_NAME=Ping
|
|
# Created by Infrastructure's `provision`: this account can't create
|
|
# projects, only manage this one.
|
|
PROJECT_ID=$$(api_call POST /projects/_search "$$(name_query "$$PROJECT_NAME")" | jq -r --arg n "$$PROJECT_NAME" '.result // [] | map(select(.name==$$n)) | first | .id // empty')
|
|
[ -n "$$PROJECT_ID" ] || { echo "Zitadel project $$PROJECT_NAME not found. Is ping in Infrastructure's x-projects?" >&2; exit 1; }
|
|
echo " found (id $$PROJECT_ID)"
|
|
|
|
find_app() { api_call POST "/projects/$$PROJECT_ID/apps/_search" "$$(name_query "$$1")" | jq -r --arg n "$$1" '.result // [] | map(select(.name==$$n)) | first | .id // empty'; }
|
|
|
|
echo "==> API app (Scalar)"
|
|
API_APP_NAME="Ping API (Scalar)"
|
|
API_APP_ID=$$(find_app "$$API_APP_NAME")
|
|
if [ -n "$$API_APP_ID" ]; then
|
|
API_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$API_APP_ID" | jq -r '.app.oidcConfig.clientId')
|
|
echo " reused (clientId $$API_CLIENT_ID)"
|
|
else
|
|
API_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" "$$(jq -n --arg name "$$API_APP_NAME" --arg api "$$PUBLIC_API_URL" '{
|
|
name: $$name,
|
|
redirectUris: [($$api + "/scalar/"), ($$api + "/scalar/oauth2-redirect.html")],
|
|
postLogoutRedirectUris: [($$api + "/scalar/")],
|
|
responseTypes: ["OIDC_RESPONSE_TYPE_CODE"],
|
|
grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"],
|
|
appType: "OIDC_APP_TYPE_USER_AGENT",
|
|
authMethodType: "OIDC_AUTH_METHOD_TYPE_NONE",
|
|
version: "OIDC_VERSION_1_0",
|
|
devMode: ($$ENV.ZITADEL_DEV_MODE == "true"),
|
|
accessTokenType: "OIDC_TOKEN_TYPE_JWT",
|
|
accessTokenRoleAssertion: true,
|
|
idTokenRoleAssertion: true,
|
|
idTokenUserinfoAssertion: true
|
|
}')" | jq -r '.clientId')
|
|
echo " created (clientId $$API_CLIENT_ID)"
|
|
fi
|
|
|
|
echo "==> web app (React SPA, public client, PKCE)"
|
|
WEB_APP_NAME="Ping Web"
|
|
WEB_APP_ID=$$(find_app "$$WEB_APP_NAME")
|
|
if [ -n "$$WEB_APP_ID" ]; then
|
|
WEB_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$WEB_APP_ID" | jq -r '.app.oidcConfig.clientId')
|
|
echo " reused (clientId $$WEB_CLIENT_ID)"
|
|
else
|
|
WEB_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" "$$(jq -n --arg name "$$WEB_APP_NAME" --arg web "$$PUBLIC_WEB_URL" '{
|
|
name: $$name,
|
|
redirectUris: [($$web + "/auth/callback")],
|
|
postLogoutRedirectUris: [($$web + "/")],
|
|
responseTypes: ["OIDC_RESPONSE_TYPE_CODE"],
|
|
grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
|
|
appType: "OIDC_APP_TYPE_USER_AGENT",
|
|
authMethodType: "OIDC_AUTH_METHOD_TYPE_NONE",
|
|
version: "OIDC_VERSION_1_0",
|
|
devMode: ($$ENV.ZITADEL_DEV_MODE == "true"),
|
|
accessTokenType: "OIDC_TOKEN_TYPE_JWT",
|
|
accessTokenRoleAssertion: true,
|
|
idTokenRoleAssertion: true,
|
|
idTokenUserinfoAssertion: true
|
|
}')" | jq -r '.clientId')
|
|
echo " created (clientId $$WEB_CLIENT_ID)"
|
|
fi
|
|
|
|
env_set "$$ENV_FILE" AUTH_CLIENT_ID "$$API_CLIENT_ID"
|
|
env_set "$$ENV_FILE" WEB_CLIENT_ID "$$WEB_CLIENT_ID"
|
|
echo "==> wired into $$ENV_FILE"
|
|
restart: "no"
|
|
|
|
# One-shot: SeaweedFS does not auto-create buckets. Reuses the shared
|
|
# SeaweedFS instance (same S3 endpoint/credentials) but creates Ping's own,
|
|
# separate bucket. `aws s3 ls` doubles as the readiness probe.
|
|
seaweedfs-init:
|
|
image: amazon/aws-cli:latest
|
|
container_name: ping-seaweedfs-init
|
|
environment:
|
|
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY}
|
|
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}
|
|
AWS_DEFAULT_REGION: us-east-1
|
|
entrypoint: ["/bin/sh", "-c"]
|
|
command:
|
|
- |
|
|
set -e
|
|
deadline=$$(( $$(date +%s) + 60 ))
|
|
until aws --endpoint-url ${S3_PUBLIC_URL} s3 ls >/dev/null 2>&1; do
|
|
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "seaweedfs did not respond at ${S3_PUBLIC_URL}. Is Infrastructure running?" >&2; exit 1; }
|
|
sleep 2
|
|
done
|
|
aws --endpoint-url ${S3_PUBLIC_URL} s3 mb s3://${S3_BUCKET} 2>/dev/null || true
|
|
aws --endpoint-url ${S3_PUBLIC_URL} s3 ls
|
|
restart: "no"
|
|
|
|
# One-shot: applies the EF Core migrations, then exits.
|
|
migrate:
|
|
build:
|
|
context: ..
|
|
dockerfile: Ping.Migrator.Dockerfile
|
|
container_name: ping-migrate
|
|
depends_on:
|
|
wait-for-postgres:
|
|
condition: service_completed_successfully
|
|
environment:
|
|
Database__ConnectionString: "Host=${POSTGRES_HOST};Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
|
|
restart: "no"
|
|
|
|
# --- Applications (profile: apps) -----------------------------------------
|
|
|
|
api:
|
|
profiles: [apps]
|
|
build:
|
|
context: ..
|
|
dockerfile: Ping.Api.Dockerfile
|
|
container_name: ping-api
|
|
depends_on:
|
|
migrate:
|
|
condition: service_completed_successfully
|
|
environment:
|
|
<<: *backend-env
|
|
ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
|
|
ASPNETCORE_URLS: http://+:5211
|
|
# AUTH_CLIENT_ID is written to the env file by the zitadel-init service.
|
|
Authentication__MetadataAddress: "${PUBLIC_AUTH_URL}/.well-known/openid-configuration"
|
|
Authentication__IssuerAddress: ${PUBLIC_AUTH_URL}
|
|
Authentication__ClientId: ${AUTH_CLIENT_ID}
|
|
Authentication__AudienceAddress: ${AUTH_CLIENT_ID}
|
|
Authentication__AuthorizationEndpoint: "${PUBLIC_AUTH_URL}/oauth/v2/authorize?prompt=login"
|
|
Authentication__TokenEndpoint: "${PUBLIC_AUTH_URL}/oauth/v2/token"
|
|
Authentication__ScalarRedirectUri: "${PUBLIC_API_URL}/scalar/"
|
|
Authentication__Scopes__0: openid
|
|
Authentication__Scopes__1: profile
|
|
Authentication__Scopes__2: email
|
|
Authentication__RequireHttpsMetadata: ${AUTH_REQUIRE_HTTPS}
|
|
LiveKit__ApiKey: ${LIVEKIT_API_KEY}
|
|
LiveKit__ApiSecret: ${LIVEKIT_API_SECRET}
|
|
restart: unless-stopped
|
|
|
|
web:
|
|
profiles: [apps]
|
|
build:
|
|
context: ../../frontend
|
|
dockerfile: Dockerfile
|
|
args:
|
|
VITE_API_BASE_URL: ${PUBLIC_API_URL}
|
|
VITE_AUTH_AUTHORITY: ${PUBLIC_AUTH_URL}
|
|
VITE_AUTH_CLIENT_ID: ${WEB_CLIENT_ID}
|
|
VITE_AUTH_REDIRECT_URI: "${PUBLIC_WEB_URL}/auth/callback"
|
|
VITE_LIVEKIT_URL: ws://localhost:7880
|
|
container_name: ping-web
|
|
depends_on:
|
|
- api
|
|
restart: unless-stopped
|