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.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
# Shared helpers for the production deploy scripts.
|
||||
|
||||
step() { printf '\n==> %s\n' "$1"; }
|
||||
fail() { printf 'error: %s\n' "$1" >&2; exit 1; }
|
||||
|
||||
require_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "$1 is required but not installed."
|
||||
}
|
||||
|
||||
# env_get <file> <key> — prints the value, empty if the key is absent.
|
||||
env_get() {
|
||||
grep -E "^$2=" "$1" 2>/dev/null | tail -n 1 | cut -d= -f2-
|
||||
}
|
||||
|
||||
# env_set <file> <key> <value> — replaces the key in place, appends if missing.
|
||||
env_set() {
|
||||
_file=$1; _key=$2; _value=$3
|
||||
_tmp=$(mktemp)
|
||||
if grep -qE "^$_key=" "$_file"; then
|
||||
awk -v k="$_key" -v v="$_value" '
|
||||
index($0, k "=") == 1 { print k "=" v; next }
|
||||
{ print }
|
||||
' "$_file" >"$_tmp"
|
||||
else
|
||||
cp "$_file" "$_tmp"
|
||||
printf '%s=%s\n' "$_key" "$_value" >>"$_tmp"
|
||||
fi
|
||||
cat "$_tmp" >"$_file"
|
||||
rm -f "$_tmp"
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Applies EF Core migrations to the prod Postgres, from a throwaway SDK
|
||||
# container attached to the compose network (postgres is not published to the host).
|
||||
set -eu
|
||||
|
||||
DEPLOY_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
BACKEND_DIR="$(CDPATH= cd -- "$DEPLOY_DIR/../backend" && pwd)"
|
||||
ENV_FILE="$DEPLOY_DIR/.env.prod"
|
||||
|
||||
. "$ENV_FILE"
|
||||
|
||||
NETWORK="musify-prod_default"
|
||||
CONN="Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
|
||||
|
||||
docker run --rm \
|
||||
--network "$NETWORK" \
|
||||
-v "$BACKEND_DIR":/src \
|
||||
-w /src/Musify.Infrastructure \
|
||||
mcr.microsoft.com/dotnet/sdk:10.0 \
|
||||
sh -c "dotnet tool install --global dotnet-ef >/dev/null 2>&1 || true; \
|
||||
export PATH=\$PATH:/root/.dotnet/tools; \
|
||||
dotnet restore Musify.Infrastructure.csproj; \
|
||||
dotnet user-secrets set 'Database:ConnectionString' '$CONN' >/dev/null; \
|
||||
dotnet ef database update --context Database"
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
# Applies the EF Core migrations to the production database from a throwaway SDK
|
||||
# container attached to the compose network (Postgres is not published to the host).
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
DEPLOY_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
||||
BACKEND_DIR=$(CDPATH= cd -- "$DEPLOY_DIR/../backend" && pwd)
|
||||
ENV_FILE="$DEPLOY_DIR/.env.prod"
|
||||
NETWORK=${MUSIFY_NETWORK:-musify-prod_default}
|
||||
|
||||
. "$SCRIPT_DIR/lib.sh"
|
||||
|
||||
CONN="Host=postgres;Port=5432;Database=$(env_get "$ENV_FILE" POSTGRES_DB);Username=$(env_get "$ENV_FILE" POSTGRES_USER);Password=$(env_get "$ENV_FILE" POSTGRES_PASSWORD)"
|
||||
|
||||
# Musify.Infrastructure is both the migrations and the startup project: it owns
|
||||
# the IDesignTimeDbContextFactory and the EFCore.Design package.
|
||||
docker run --rm \
|
||||
--network "$NETWORK" \
|
||||
-v "$BACKEND_DIR":/src \
|
||||
-w /src/Musify.Infrastructure \
|
||||
-e CONN="$CONN" \
|
||||
mcr.microsoft.com/dotnet/sdk:10.0 \
|
||||
sh -c 'dotnet tool install --global dotnet-ef >/dev/null 2>&1 || true
|
||||
export PATH=$PATH:/root/.dotnet/tools
|
||||
dotnet restore Musify.Infrastructure.csproj
|
||||
dotnet user-secrets set "Database:ConnectionString" "$CONN" >/dev/null
|
||||
dotnet ef database update --context Database'
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
# Generates the RS256 key pair used to sign (API) and verify (Gateway) stream
|
||||
# tickets. No-op if both keys already exist.
|
||||
set -eu
|
||||
|
||||
KEYS_DIR=${1:?usage: stream-keys.sh <keys-dir>}
|
||||
PRIVATE_KEY="$KEYS_DIR/stream_private.pem"
|
||||
PUBLIC_KEY="$KEYS_DIR/stream_public.pem"
|
||||
|
||||
if [ -f "$PRIVATE_KEY" ] && [ -f "$PUBLIC_KEY" ]; then
|
||||
echo "Stream-ticket keys already present in $KEYS_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$KEYS_DIR"
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$PRIVATE_KEY" 2>/dev/null
|
||||
openssl rsa -in "$PRIVATE_KEY" -pubout -out "$PUBLIC_KEY" 2>/dev/null
|
||||
chmod 644 "$PUBLIC_KEY"
|
||||
chmod 600 "$PRIVATE_KEY"
|
||||
|
||||
echo "Generated stream-ticket keys in $KEYS_DIR"
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
#!/bin/sh
|
||||
# Brings up (or updates) the Musify production stack on the server.
|
||||
#
|
||||
# ./up.sh build + start everything, provision Zitadel, migrate
|
||||
# ./up.sh --no-build same, reusing the images that are already built
|
||||
# ./up.sh --down stop and remove the containers (volumes are kept)
|
||||
#
|
||||
# Everything it needs lives in deploy/.env.prod (see .env.prod.example) and in
|
||||
# deploy/nginx/certs/ (origin.pem + origin.key).
|
||||
set -eu
|
||||
|
||||
DEPLOY_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
ENV_FILE="$DEPLOY_DIR/.env.prod"
|
||||
INFRA="postgres zitadel rabbitmq seaweedfs seaweedfs-init jaeger"
|
||||
|
||||
. "$DEPLOY_DIR/scripts/lib.sh"
|
||||
|
||||
require_command docker
|
||||
require_command openssl
|
||||
|
||||
compose() {
|
||||
docker compose --env-file "$ENV_FILE" \
|
||||
-f "$DEPLOY_DIR/compose.yml" -f "$DEPLOY_DIR/compose.prod.yml" \
|
||||
--profile apps "$@"
|
||||
}
|
||||
|
||||
BUILD=--build
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-build) BUILD= ;;
|
||||
--down) step "Stopping the stack (volumes are kept)"; compose down --remove-orphans; exit 0 ;;
|
||||
*) fail "unknown option: $arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Preflight ------------------------------------------------------------------
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
cp "$DEPLOY_DIR/.env.prod.example" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
fail "created $ENV_FILE from the template — fill it in (domain + secrets) and run again."
|
||||
fi
|
||||
|
||||
if grep -q 'CHANGE_ME\|example\.com' "$ENV_FILE"; then
|
||||
fail "$ENV_FILE still contains CHANGE_ME or example.com placeholders."
|
||||
fi
|
||||
|
||||
for cert in origin.pem origin.key; do
|
||||
[ -f "$DEPLOY_DIR/nginx/certs/$cert" ] \
|
||||
|| fail "missing nginx/certs/$cert (Cloudflare Origin Certificate)."
|
||||
done
|
||||
|
||||
mkdir -p "$DEPLOY_DIR/secrets" "$DEPLOY_DIR/keys" "$DEPLOY_DIR/zitadel/.output"
|
||||
case "$(env_get "$ENV_FILE" YTDLP_ADDITIONAL_ARGUMENTS)" in
|
||||
*--cookies*)
|
||||
[ -f "$DEPLOY_DIR/secrets/youtube_cookies.txt" ] \
|
||||
|| echo "warning: YTDLP_ADDITIONAL_ARGUMENTS expects secrets/youtube_cookies.txt, which is missing." ;;
|
||||
esac
|
||||
|
||||
step "Ensuring stream-ticket keys"
|
||||
sh "$DEPLOY_DIR/scripts/stream-keys.sh" "$DEPLOY_DIR/keys"
|
||||
|
||||
# Infrastructure -------------------------------------------------------------
|
||||
step "Starting the infrastructure"
|
||||
# shellcheck disable=SC2086
|
||||
compose up -d $INFRA
|
||||
|
||||
step "Waiting for Postgres"
|
||||
deadline=$(( $(date +%s) + 120 ))
|
||||
until [ "$(docker inspect --format '{{.State.Health.Status}}' musify-postgres 2>/dev/null)" = healthy ]; do
|
||||
[ "$(date +%s)" -lt "$deadline" ] || fail "Postgres did not become healthy in time."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# nginx has to be up before provisioning: it terminates TLS for the Zitadel
|
||||
# domain. --no-deps keeps the apps from starting before they have a client id.
|
||||
step "Starting the edge"
|
||||
compose up -d --no-deps nginx
|
||||
|
||||
# Zitadel + database ---------------------------------------------------------
|
||||
sh "$DEPLOY_DIR/zitadel/provision.sh"
|
||||
|
||||
step "Applying EF Core migrations"
|
||||
sh "$DEPLOY_DIR/scripts/migrate.sh"
|
||||
|
||||
# Applications ---------------------------------------------------------------
|
||||
step "Starting the applications"
|
||||
# shellcheck disable=SC2086
|
||||
compose up -d $BUILD
|
||||
|
||||
step "Done"
|
||||
compose ps
|
||||
cat <<EOF
|
||||
|
||||
Web player $(env_get "$ENV_FILE" PUBLIC_WEB_URL)
|
||||
API $(env_get "$ENV_FILE" PUBLIC_API_URL)/scalar/v1
|
||||
Streaming $(env_get "$ENV_FILE" PUBLIC_STREAM_URL)/health
|
||||
Zitadel $(env_get "$ENV_FILE" PUBLIC_AUTH_URL)/ui/console
|
||||
$(env_get "$ENV_FILE" ZITADEL_ADMIN_USERNAME)@zitadel.$(env_get "$ENV_FILE" ZITADEL_EXTERNAL_DOMAIN)
|
||||
|
||||
Jaeger collects traces but publishes no port. To look at them, forward its
|
||||
container address over SSH from your machine:
|
||||
ssh -L 16686:\$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' musify-jaeger):16686 <server>
|
||||
EOF
|
||||
@@ -1,157 +0,0 @@
|
||||
#requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Provisions Zitadel for Musify in production (idempotent).
|
||||
|
||||
.DESCRIPTION
|
||||
Same idea as provision.ps1 but for the VPS deploy: creates the project, the
|
||||
API OIDC app (PKCE, JWT) and the Web OIDC app (confidential, JWT), then
|
||||
wires the values into deploy/.env.prod. Run from deploy/zitadel/ on the VPS
|
||||
after `docker compose ... up -d` has brought zitadel up.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ProjectName = "Musify",
|
||||
[string]$ApiAppName = "Musify API",
|
||||
[string]$WebAppName = "Musify Web"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$deployDir = Split-Path $PSScriptRoot -Parent
|
||||
$envPath = Join-Path $deployDir ".env.prod"
|
||||
$patFile = Join-Path $PSScriptRoot ".output/admin-sa.pat"
|
||||
|
||||
function Read-DotEnv($path) {
|
||||
$map = @{}
|
||||
Get-Content $path | ForEach-Object {
|
||||
if ($_ -match '^\s*#' -or $_ -notmatch '=') { return }
|
||||
$k, $v = $_.Split('=', 2)
|
||||
$map[$k.Trim()] = $v.Trim()
|
||||
}
|
||||
return $map
|
||||
}
|
||||
|
||||
$cfg = Read-DotEnv $envPath
|
||||
$issuer = "https://$($cfg['AUTH_DOMAIN'])"
|
||||
$apiUrl = "https://$($cfg['API_DOMAIN'])"
|
||||
$webUrl = "https://$($cfg['WEB_DOMAIN'])"
|
||||
|
||||
$deadline = (Get-Date).AddMinutes(3)
|
||||
while (-not (Test-Path $patFile) -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 3 }
|
||||
if (-not (Test-Path $patFile)) { throw "PAT not found ($patFile). Check 'docker compose logs zitadel'." }
|
||||
|
||||
$headers = @{ Authorization = "Bearer $((Get-Content $patFile -Raw).Trim())" }
|
||||
$mgmt = "$issuer/management/v1"
|
||||
|
||||
function Invoke-Zitadel($method, $path, $body) {
|
||||
$params = @{ Method = $method; Uri = "$mgmt$path"; Headers = $headers; ContentType = "application/json"; TimeoutSec = 20 }
|
||||
if ($body) { $params.Body = ($body | ConvertTo-Json -Depth 10) }
|
||||
Invoke-RestMethod @params -SkipCertificateCheck
|
||||
}
|
||||
|
||||
Write-Host "Waiting for Zitadel at $issuer"
|
||||
$deadline = (Get-Date).AddMinutes(2)
|
||||
do {
|
||||
try { Invoke-RestMethod "$issuer/.well-known/openid-configuration" -TimeoutSec 5 -SkipCertificateCheck | Out-Null; $ready = $true }
|
||||
catch { $ready = $false; Start-Sleep 3 }
|
||||
} until ($ready -or (Get-Date) -gt $deadline)
|
||||
if (-not $ready) { throw "Zitadel did not respond in time." }
|
||||
|
||||
Write-Host "Disabling required Login UI v2 (use built-in /ui/login)"
|
||||
try {
|
||||
Invoke-RestMethod -Method PUT -Uri "$issuer/v2/features/instance" -Headers $headers `
|
||||
-ContentType "application/json" -Body (@{ loginV2 = @{ required = $false } } | ConvertTo-Json) -TimeoutSec 20 -SkipCertificateCheck | Out-Null
|
||||
} catch {
|
||||
Write-Host " warning: could not update loginV2 feature: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
Write-Host "Project '$ProjectName'"
|
||||
$project = (Invoke-Zitadel POST "/projects/_search" @{
|
||||
queries = @(@{ nameQuery = @{ name = $ProjectName; method = "TEXT_QUERY_METHOD_EQUALS" } })
|
||||
}).result | Where-Object { $_.name -eq $ProjectName } | Select-Object -First 1
|
||||
|
||||
if ($project) {
|
||||
$projectId = $project.id
|
||||
Write-Host " reused (id $projectId)"
|
||||
} else {
|
||||
$projectId = (Invoke-Zitadel POST "/projects" @{ name = $ProjectName }).id
|
||||
Write-Host " created (id $projectId)"
|
||||
}
|
||||
|
||||
Write-Host "OIDC app '$ApiAppName' (public, PKCE)"
|
||||
$apiApp = (Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{
|
||||
queries = @(@{ nameQuery = @{ name = $ApiAppName; method = "TEXT_QUERY_METHOD_EQUALS" } })
|
||||
}).result | Where-Object { $_.name -eq $ApiAppName } | Select-Object -First 1
|
||||
|
||||
if ($apiApp) {
|
||||
$apiClientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($apiApp.id)" $null).app.oidcConfig.clientId
|
||||
Write-Host " reused (clientId $apiClientId)"
|
||||
} else {
|
||||
$apiClientId = (Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{
|
||||
name = $ApiAppName
|
||||
redirectUris = @("$apiUrl/scalar/", "$apiUrl/scalar/oauth2-redirect.html")
|
||||
postLogoutRedirectUris = @("$apiUrl/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
|
||||
}).clientId
|
||||
Write-Host " created (clientId $apiClientId)"
|
||||
}
|
||||
|
||||
Write-Host "OIDC app '$WebAppName' (confidential)"
|
||||
$webApp = (Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{
|
||||
queries = @(@{ nameQuery = @{ name = $WebAppName; method = "TEXT_QUERY_METHOD_EQUALS" } })
|
||||
}).result | Where-Object { $_.name -eq $WebAppName } | Select-Object -First 1
|
||||
|
||||
$webClientSecret = $null
|
||||
if ($webApp) {
|
||||
$webClientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($webApp.id)" $null).app.oidcConfig.clientId
|
||||
Write-Host " reused (clientId $webClientId, secret NOT regenerated)"
|
||||
} else {
|
||||
$created = Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{
|
||||
name = $WebAppName
|
||||
redirectUris = @("$webUrl/auth/callback")
|
||||
postLogoutRedirectUris = @("$webUrl/")
|
||||
responseTypes = @("OIDC_RESPONSE_TYPE_CODE")
|
||||
grantTypes = @("OIDC_GRANT_TYPE_AUTHORIZATION_CODE")
|
||||
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
|
||||
}
|
||||
$webClientId = $created.clientId
|
||||
$webClientSecret = $created.clientSecret
|
||||
Write-Host " created (clientId $webClientId)"
|
||||
}
|
||||
|
||||
$envLines = Get-Content $envPath
|
||||
function Set-EnvValue([string[]]$lines, [string]$key, [string]$value) {
|
||||
if ($lines -match "^\s*$key=") {
|
||||
return $lines -replace "^\s*$key=.*", "$key=$value"
|
||||
}
|
||||
return $lines + "$key=$value"
|
||||
}
|
||||
$envLines = Set-EnvValue $envLines "AUTH_CLIENT_ID" $apiClientId
|
||||
$envLines = Set-EnvValue $envLines "WEB_CLIENT_ID" $webClientId
|
||||
if ($webClientSecret) {
|
||||
$envLines = Set-EnvValue $envLines "WEB_CLIENT_SECRET" $webClientSecret
|
||||
}
|
||||
Set-Content -Path $envPath -Value $envLines -Encoding utf8
|
||||
|
||||
Write-Host "`nZitadel provisioned:" -ForegroundColor Green
|
||||
Write-Host " Project: $ProjectName ($projectId)"
|
||||
Write-Host " API app: $apiClientId"
|
||||
Write-Host " Web app: $webClientId"
|
||||
Write-Host " Issuer: $issuer"
|
||||
if ($webClientSecret) { Write-Host " Web app secret written to .env.prod (WEB_CLIENT_SECRET)" }
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/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")"
|
||||
Reference in New Issue
Block a user