Files
Admin a7929c0ac9
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Canceled after 0s
CI/CD / BackEnd (push) Canceled after 39s
Enhance upload functionality with genre selection and validation; refactor session cookie handling
2026-09-23 18:33:21 +02:00

401 lines
18 KiB
YAML

# Musify: base stack, shared by development and production.
#
# Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are NOT part of this
# file. They're shared infrastructure that lives in ../../../Infrastructure
# and must already be running (see Infrastructure/README.md). This file only
# has what's specific to Musify: its own database, its own OIDC apps and
# storage bucket (created against the shared instances below), and its own
# apps.
#
# dev docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
# prod docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d
#
# The four application services sit behind the `apps` profile, so a plain
# `up` runs only the one-shot bootstrap jobs (stream-ticket keys, database
# migrations, Zitadel project/apps, the storage 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, so the apps start with real values. See deploy/README.md.
# Backend settings that are identical for the API and the Worker. ASP.NET Core
# maps `__` to `:`, so these override the matching appsettings.json sections.
# POSTGRES_HOST / RABBITMQ_HOST / JAEGER_HOST differ between environments
# (host.docker.internal in dev, the container name over infra-net in prod,
# see .env.example / .env.prod.example), same as PUBLIC_AUTH_URL/S3_PUBLIC_URL
# already do.
x-backend-env: &backend-env
Database__ConnectionString: "Host=${POSTGRES_HOST};Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
MassTransit__Address: "rabbitmq://${RABBITMQ_HOST}:5672"
MassTransit__Username: ${RABBITMQ_DEFAULT_USER}
MassTransit__Password: ${RABBITMQ_DEFAULT_PASS}
# 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}
OpenTelemetry__OtlpEndpoint: "http://${JAEGER_HOST}:4317"
services:
# --- Shared-infrastructure bootstrap ---------------------------------------
# Waits for Infrastructure's Postgres to accept connections before `migrate`
# runs. This replaces a `depends_on: postgres: condition: service_healthy`:
# that only works within the same compose file, and Postgres now lives in a
# separate one (../../../Infrastructure).
wait-for-postgres:
image: postgres:17-alpine
container_name: musify-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: SeaweedFS does not auto-create buckets. `aws s3 ls` doubles as
# the readiness probe, since seaweedfs-init no longer has an in-file
# `depends_on: seaweedfs: condition: service_healthy` to wait for.
seaweedfs-init:
image: amazon/aws-cli:latest
container_name: musify-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: generates the RS256 key pair used to sign (API) and verify
# (Gateway) stream tickets, if it is not already in ./keys. No host tools
# required, since openssl runs inside the container.
keys-init:
image: alpine:3.20
container_name: musify-keys-init
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
if [ -f /keys/stream_private.pem ] && [ -f /keys/stream_public.pem ]; then
echo "stream-ticket keys already present"
else
apk add --no-cache openssl >/dev/null
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /keys/stream_private.pem
openssl rsa -in /keys/stream_private.pem -pubout -out /keys/stream_public.pem
chmod 644 /keys/stream_public.pem
chmod 600 /keys/stream_private.pem
echo "generated stream-ticket keys"
fi
volumes:
- ./keys:/keys
restart: "no"
# One-shot: applies the EF Core migrations, then exits. See
# backend/Musify.Migrator.Dockerfile.
migrate:
build:
context: ../backend
dockerfile: Musify.Migrator.Dockerfile
container_name: musify-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"
# One-shot: creates the Musify 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 and WEB_CLIENT_SECRET back into the env file
# mounted at /workspace/.env (see compose.dev.yml / compose.prod.yml), so a
# later `--profile apps up` picks up real values instead of empty ones.
zitadel-init:
image: alpine:3.20
container_name: musify-zitadel-init
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}
volumes:
- ${ZITADEL_ADMIN_PAT_DIR}:/output:ro
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
# The env file may have CRLF line endings (edited on Windows), so
# strip \r on read and drop it on write, otherwise a "blank" value
# is actually a lone \r, and `[ -z ... ]` lies.
env_get() { grep -E "^$$2=" "$$1" 2>/dev/null | tail -n1 | cut -d= -f2- | tr -d '\r'; }
env_set() {
# $$1 is a bind-mounted file: rewrite its content in place rather
# than renaming a temp file over it (rename onto a bind-mounted
# inode fails with "Resource busy").
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
}
echo "==> waiting for the Zitadel service-account PAT"
deadline=$$(( $$(date +%s) + 180 ))
while [ ! -f /output/admin-sa.pat ]; do
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "PAT not found under /output. Is Infrastructure's Zitadel running? Check 'docker logs infra-zitadel'." >&2; exit 1; }
sleep 3
done
PAT=$$(tr -d '\r\n' </output/admin-sa.pat)
MGMT="$$PUBLIC_AUTH_URL/management/v1"
echo "==> waiting for Zitadel to answer at $$PUBLIC_AUTH_URL"
deadline=$$(( $$(date +%s) + 120 ))
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"; }
# `latest` images require the standalone Login UI v2 container, which
# this stack does not run; opting out keeps the built-in /ui/login.
curl -fsS -X PUT "$$PUBLIC_AUTH_URL/v2/features/instance" \
-H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" \
-d '{"loginV2":{"required":false}}' >/dev/null 2>&1 || echo "warning: could not disable Login UI v2"
echo "==> project"
PROJECT_NAME=Musify
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')
if [ -z "$$PROJECT_ID" ]; then
PROJECT_ID=$$(api_call POST /projects "$$(printf '{"name":"%s"}' "$$PROJECT_NAME")" | jq -r '.id')
echo " created (id $$PROJECT_ID)"
else
echo " reused (id $$PROJECT_ID)"
fi
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"
API_APP_NAME="Musify 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"
WEB_APP_NAME="Musify Web"
WEB_APP_ID=$$(find_app "$$WEB_APP_NAME")
WEB_CLIENT_SECRET=$$(env_get "$$ENV_FILE" WEB_CLIENT_SECRET)
if [ -n "$$WEB_APP_ID" ]; then
WEB_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$WEB_APP_ID" | jq -r '.app.oidcConfig.clientId')
if [ -z "$$WEB_CLIENT_SECRET" ]; then
WEB_CLIENT_SECRET=$$(api_call POST "/projects/$$PROJECT_ID/apps/$$WEB_APP_ID/oidc_config/_generate_client_secret" '{}' | jq -r '.clientSecret')
echo " reused (clientId $$WEB_CLIENT_ID, secret regenerated)"
else
echo " reused (clientId $$WEB_CLIENT_ID)"
fi
else
CREATED=$$(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_WEB",
authMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC",
version: "OIDC_VERSION_1_0",
devMode: ($$ENV.ZITADEL_DEV_MODE == "true"),
accessTokenType: "OIDC_TOKEN_TYPE_JWT",
accessTokenRoleAssertion: true,
idTokenRoleAssertion: true,
idTokenUserinfoAssertion: true
}')")
WEB_CLIENT_ID=$$(echo "$$CREATED" | jq -r '.clientId')
WEB_CLIENT_SECRET=$$(echo "$$CREATED" | jq -r '.clientSecret')
echo " created (clientId $$WEB_CLIENT_ID)"
fi
echo "==> native app (KMP client: desktop loopback + mobile custom scheme)"
NATIVE_APP_NAME="Musify Native"
NATIVE_APP_ID=$$(find_app "$$NATIVE_APP_NAME")
if [ -n "$$NATIVE_APP_ID" ]; then
NATIVE_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID" | jq -r '.app.oidcConfig.clientId')
echo " reused (clientId $$NATIVE_CLIENT_ID)"
else
NATIVE_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" '{
"name": "Musify Native",
"redirectUris": ["http://127.0.0.1:17453/callback", "musify://callback"],
"postLogoutRedirectUris": ["http://127.0.0.1:17453/logout", "musify://logout"],
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
"appType": "OIDC_APP_TYPE_NATIVE",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE",
"version": "OIDC_VERSION_1_0",
"devMode": true,
"accessTokenType": "OIDC_TOKEN_TYPE_JWT",
"accessTokenRoleAssertion": true,
"idTokenRoleAssertion": true,
"idTokenUserinfoAssertion": true
}' | jq -r '.clientId')
echo " created (clientId $$NATIVE_CLIENT_ID)"
fi
env_set "$$ENV_FILE" AUTH_CLIENT_ID "$$API_CLIENT_ID"
env_set "$$ENV_FILE" WEB_CLIENT_ID "$$WEB_CLIENT_ID"
env_set "$$ENV_FILE" WEB_CLIENT_SECRET "$$WEB_CLIENT_SECRET"
env_set "$$ENV_FILE" NATIVE_CLIENT_ID "$$NATIVE_CLIENT_ID"
echo "==> wired into $$ENV_FILE"
restart: "no"
# --- Applications (profile: apps) -----------------------------------------
api:
profiles: [apps]
build:
context: ../backend
dockerfile: Musify.Api.Dockerfile
container_name: musify-api
depends_on:
migrate:
condition: service_completed_successfully
keys-init:
condition: service_completed_successfully
environment:
<<: *backend-env
ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
ASPNETCORE_URLS: http://+:5111
StreamGateway__PublicBaseUrl: ${PUBLIC_STREAM_URL}
StreamTicket__PrivateKeyPath: /keys/stream_private.pem
Workers__Routes__TemporaryFilesDirectory: /tmp/musify
# 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}
volumes:
- ./keys:/keys:ro
restart: unless-stopped
worker:
profiles: [apps]
build:
context: ../backend
dockerfile: Musify.Worker.Dockerfile
container_name: musify-worker
depends_on:
migrate:
condition: service_completed_successfully
environment:
<<: *backend-env
DOTNET_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
AudioTranscoder__Routes__WorkingDirectory: /tmp/musify
Workers__Routes__TemporaryFilesDirectory: /tmp/musify
restart: unless-stopped
# YARP in front of the SeaweedFS filer: validates the RS256 stream ticket and
# streams the bytes, so audio never goes through the API.
gateway:
profiles: [apps]
build:
context: ../backend
dockerfile: Musify.StreamingGateway.Dockerfile
container_name: musify-gateway
depends_on:
keys-init:
condition: service_completed_successfully
environment:
ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
ASPNETCORE_URLS: http://+:8081
StreamTicket__PublicKeyPath: /keys/stream_public.pem
ReverseProxy__Clusters__seaweed-filer__Destinations__filer__Address: "http://${SEAWEEDFS_HOST}:8888/"
ReverseProxy__Routes__media__Transforms__0__PathPattern: "/buckets/${S3_BUCKET}/{**rest}"
Cors__AllowedOrigins__0: ${PUBLIC_WEB_URL}
volumes:
- ./keys:/keys:ro
restart: unless-stopped
web-player:
profiles: [apps]
build:
context: ../web-player
dockerfile: Dockerfile
container_name: musify-web-player
depends_on:
- api
environment:
NODE_ENV: production
PORT: 3000
# adapter-node rejects request bodies over 512K by default; audio
# uploads are posted to /upload. Keep in line with the edge's
# client_max_body_size.
BODY_SIZE_LIMIT: 1G
ORIGIN: ${PUBLIC_WEB_URL}
ZITADEL_ISSUER: ${PUBLIC_AUTH_URL}
ZITADEL_CLIENT_ID: ${WEB_CLIENT_ID}
ZITADEL_CLIENT_SECRET: ${WEB_CLIENT_SECRET}
AUTH_REDIRECT_URI: "${PUBLIC_WEB_URL}/auth/callback"
AUTH_POST_LOGOUT_URI: "${PUBLIC_WEB_URL}/"
AUTH_ALLOW_INSECURE_HTTP: ${AUTH_ALLOW_INSECURE_HTTP}
SESSION_SECRET: ${SESSION_SECRET}
API_BASE_URL: http://api:5111
# Presigned PUTs are signed for the public S3 host; the container sends
# them straight to SeaweedFS instead of going back out through the edge.
S3_UPLOAD_ENDPOINT: ${S3_INTERNAL_URL}
restart: unless-stopped