Files
Musify/deploy/zitadel/provision.sh
T
Admin 5a7755efa9 feat(deploy): add a one-command production deploy in shell
Production had no orchestrator at all and its provisioning script required
PowerShell 7 on the server. up.sh now does the whole thing in POSIX shell:
it refuses to start until .env.prod and the origin certificate are in place,
generates the stream keys, brings up the infrastructure and the edge,
provisions Zitadel, applies the migrations and finally builds and starts the
applications. It is idempotent, so the same command deploys a new version.

provision.sh replaces provision.prod.ps1 (curl + jq) and also creates the web
player client, and migrate.prod.sh moves to scripts/migrate.sh reading the env
file with a parser instead of sourcing it as a shell script.
2026-07-20 15:13:54 +02:00

146 lines
6.1 KiB
Bash

#!/bin/sh
# Provisions Zitadel for Musify in production (idempotent).
#
# Creates the Musify project, the API OIDC app (public, PKCE, JWT access token)
# and the web player OIDC app (confidential), then writes AUTH_CLIENT_ID,
# WEB_CLIENT_ID and WEB_CLIENT_SECRET into deploy/.env.prod. Authenticates with
# the service-account PAT that Zitadel writes on init. Re-running reuses the
# existing project and apps.
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
DEPLOY_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
ENV_FILE="$DEPLOY_DIR/.env.prod"
PAT_FILE="$SCRIPT_DIR/.output/admin-sa.pat"
. "$DEPLOY_DIR/scripts/lib.sh"
require_command curl
require_command jq
PROJECT_NAME=${PROJECT_NAME:-Musify}
API_APP_NAME=${API_APP_NAME:-Musify API}
WEB_APP_NAME=${WEB_APP_NAME:-Musify Web}
ISSUER=$(env_get "$ENV_FILE" PUBLIC_AUTH_URL)
API_URL=$(env_get "$ENV_FILE" PUBLIC_API_URL)
WEB_URL=$(env_get "$ENV_FILE" PUBLIC_WEB_URL)
MGMT="$ISSUER/management/v1"
step "Provisioning Zitadel at $ISSUER"
# Zitadel writes the PAT during init; on a cold start it may take a moment.
deadline=$(( $(date +%s) + 180 ))
while [ ! -f "$PAT_FILE" ] && [ "$(date +%s)" -lt "$deadline" ]; do sleep 3; done
[ -f "$PAT_FILE" ] || fail "PAT not found ($PAT_FILE). Check 'docker logs musify-zitadel'."
PAT=$(tr -d '\r\n' <"$PAT_FILE")
api_call() {
# api_call <method> <path> [json body]
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
}
deadline=$(( $(date +%s) + 120 ))
until curl -fsS "$ISSUER/.well-known/openid-configuration" >/dev/null 2>&1; do
[ "$(date +%s)" -lt "$deadline" ] || fail "Zitadel did not respond at $ISSUER."
sleep 3
done
# The images tagged `latest` require the standalone Login UI v2 container, which
# this stack does not run, so OIDC would redirect to /ui/v2/login and 404.
curl -fsS -X PUT "$ISSUER/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 the required Login UI v2"
name_query() {
printf '{"queries":[{"nameQuery":{"name":"%s","method":"TEXT_QUERY_METHOD_EQUALS"}}]}' "$1"
}
# Project --------------------------------------------------------------------
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 [ -n "$PROJECT_ID" ]; then
echo " project '$PROJECT_NAME' reused (id $PROJECT_ID)"
else
PROJECT_ID=$(api_call POST "/projects" "$(printf '{"name":"%s"}' "$PROJECT_NAME")" | jq -r '.id')
echo " project '$PROJECT_NAME' created (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'
}
# API app: public client used by the Scalar UI to obtain a token -------------
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 " app '$API_APP_NAME' 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 "$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: false,
accessTokenType: "OIDC_TOKEN_TYPE_JWT",
accessTokenRoleAssertion: true,
idTokenRoleAssertion: true,
idTokenUserinfoAssertion: true
}')" | jq -r '.clientId')
echo " app '$API_APP_NAME' created (clientId $API_CLIENT_ID)"
fi
# Web app: confidential client used by the SvelteKit server ------------------
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
# The secret is only returned once, so regenerate it if we no longer have it.
WEB_CLIENT_SECRET=$(api_call POST "/projects/$PROJECT_ID/apps/$WEB_APP_ID/oidc_config/_generate_client_secret" '{}' \
| jq -r '.clientSecret')
echo " app '$WEB_APP_NAME' reused (clientId $WEB_CLIENT_ID, secret regenerated)"
else
echo " app '$WEB_APP_NAME' 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 "$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: false,
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 " app '$WEB_APP_NAME' 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 $(basename "$ENV_FILE")"