Replace deploy scripts with self-contained compose bootstrap jobs
Removes every wrapper script under deploy/ (up.ps1, up.sh, scripts/*,
zitadel/provision.*) and folds their logic into three one-shot services
in compose.yml, following the pattern already used by seaweedfs-init:
keys-init generates the RS256 stream-ticket key pair
migrate applies the EF Core migrations (backend/Musify.Migrator.Dockerfile)
zitadel-init creates the Musify project + OIDC apps, writes the client
ids back into the active env file
Deploying is now two plain `docker compose` commands, no scripts to run
or chmod:
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build
(swap compose.dev.yml for compose.prod.yml + --env-file deploy/.env.prod
for production). The first command brings up infra and lets the bootstrap
jobs run to completion; the second builds and starts the apps, which by
then read real OIDC values instead of empty ones — compose interpolates
${VAR} once per invocation, so this can't collapse into a single command
without the apps reading their own config file at container start instead
of via compose env (out of scope here).
Also:
- DatabaseDesignTimeFactory now also reads Database__ConnectionString
from the environment, and DesignSettings.json is optional, so the new
migrate container works without user-secrets or a config file excluded
from the Docker build context by .dockerignore.
- nginx's depends_on no longer lists the apps-profile services: it does
not need them at its own startup (every vhost resolves its upstream at
request time via the embedded Docker resolver), and requiring them made
`compose.prod.yml up -d` (without --profile apps) an invalid project.
- env_get/env_set in zitadel-init strip \r: the mounted env file can have
CRLF line endings on Windows, which otherwise makes an empty-looking
value non-empty to `[ -z ... ]`. .env.example/.env.prod.example are
pinned to LF via .gitattributes for the same reason. Also fixed:
rewriting the bind-mounted env file in place instead of renaming a temp
file over it (rename onto a bind-mounted inode fails with "Resource
busy").
- Removed compose.share.yml and zitadel/.output-share/ — a "temporary"
personal overlay that had leaked a real Zitadel service-account PAT
into git history (committed in 70968b3). That token should be rotated.
- Removed deploy/.tmp/ from .gitignore: nothing mounts or creates it
anymore (it never actually did, either — no compose service referenced
it, only the now-removed up.ps1 did).
Verified end-to-end with Docker: a clean `down -v` + fresh `up -d` +
`--profile apps up -d --build` on the dev stack brings up all four one-shot
jobs successfully and all four apps healthy (api/gateway return 200,
worker starts its consumers with no errors, web-player responds). Also
verified compose.prod.yml starts nginx cleanly with dummy certs and no
apps running, confirming the depends_on fix.
This commit is contained in:
+209
-7
@@ -8,9 +8,12 @@
|
||||
# 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`
|
||||
# starts only the infrastructure (the usual dev setup: apps from the IDE).
|
||||
# Add `--profile apps` to run them in Docker. Use ./up.ps1 (dev) or ./up.sh
|
||||
# (prod) instead of calling compose by hand.
|
||||
# starts the infrastructure plus the one-shot bootstrap jobs (stream-ticket
|
||||
# keys, database migrations, Zitadel project/apps) and stops there — 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.
|
||||
|
||||
x-app-depends: &app-depends
|
||||
postgres:
|
||||
@@ -168,6 +171,196 @@ services:
|
||||
aws --endpoint-url http://seaweedfs:8333 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 — 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:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
Database__ConnectionString: "Host=postgres;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 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
|
||||
depends_on:
|
||||
zitadel:
|
||||
condition: service_started
|
||||
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/.output:/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, check 'docker logs musify-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
|
||||
|
||||
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"
|
||||
echo "==> wired into $$ENV_FILE"
|
||||
restart: "no"
|
||||
|
||||
# OTLP collector + trace UI (MassTransit / EF Core / HTTP spans).
|
||||
jaeger:
|
||||
image: jaegertracing/all-in-one:latest
|
||||
@@ -184,7 +377,12 @@ services:
|
||||
context: ../backend
|
||||
dockerfile: Musify.Api.Dockerfile
|
||||
container_name: musify-api
|
||||
depends_on: *app-depends
|
||||
depends_on:
|
||||
<<: *app-depends
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
keys-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
<<: *backend-env
|
||||
ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
|
||||
@@ -192,8 +390,7 @@ services:
|
||||
StreamGateway__PublicBaseUrl: ${PUBLIC_STREAM_URL}
|
||||
StreamTicket__PrivateKeyPath: /keys/stream_private.pem
|
||||
Workers__Routes__TemporaryFilesDirectory: /tmp/musify
|
||||
# Client id and secret are written to the .env file by the Zitadel
|
||||
# provisioning script (deploy/zitadel/provision.*).
|
||||
# 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}
|
||||
@@ -215,7 +412,10 @@ services:
|
||||
context: ../backend
|
||||
dockerfile: Musify.Worker.Dockerfile
|
||||
container_name: musify-worker
|
||||
depends_on: *app-depends
|
||||
depends_on:
|
||||
<<: *app-depends
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
<<: *backend-env
|
||||
DOTNET_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
|
||||
@@ -235,6 +435,8 @@ services:
|
||||
depends_on:
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
keys-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
|
||||
ASPNETCORE_URLS: http://+:8081
|
||||
|
||||
Reference in New Issue
Block a user