Design Prot Merge #1
@@ -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