feat(deploy): consolidated dev stack with auto-provisioned Zitadel

Single docker-compose stack for local development (Postgres, Zitadel,
RabbitMQ, SeaweedFS + bucket init, Jaeger) plus an optional overlay to run
the .NET apps in Docker. up.ps1 orchestrates the full bootstrap: stack up,
stream-ticket keys, EF Core migrations and Zitadel provisioning.

- Zitadel is provisioned automatically (service-account PAT -> project +
  OIDC app with JWT access token); the generated ClientId is wired into
  Musify.Api user-secrets and .env. No manual console steps.
- ZITADEL_EXTERNAL_DOMAIN=host.docker.internal so token validation works
  both with local apps and full-docker.
- App Dockerfiles copy AppSettings*.json to lowercase so config loads on
  case-sensitive Linux; Worker image ships ffmpeg.
- Scripts split by responsibility (common/stream-keys/migrate/provision);
  apps overlay de-duped via YAML anchors.
- Remove the superseded standalone seaweedfs compose; point docs/development
  at the unified stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 20:32:40 +02:00
co-authored by Claude Opus 4.8
parent d6c269d80f
commit 1d40abcab7
16 changed files with 865 additions and 48 deletions
+2
View File
@@ -0,0 +1,2 @@
# Secrets Zitadel writes on init (do not version)
.output/
+121
View File
@@ -0,0 +1,121 @@
#requires -Version 5.1
<#
.SYNOPSIS
Provisions Zitadel for Musify (idempotent).
.DESCRIPTION
Creates the project and the OIDC app (PKCE, JWT access token) and wires the
generated ClientId into Musify.Api user-secrets and deploy/.env (AUTH_CLIENT_ID).
Uses the service-account PAT that Zitadel writes on init (zitadel/.output/admin-sa.pat).
Re-running reuses the existing project/app and refreshes the wired values.
#>
[CmdletBinding()]
param(
[string]$ProjectName = "Musify",
[string]$AppName = "Musify API (Scalar)"
)
$ErrorActionPreference = "Stop"
$deployDir = Split-Path $PSScriptRoot -Parent
$apiProject = Resolve-Path (Join-Path $deployDir "..\backend\Musify.Api")
$patFile = Join-Path $PSScriptRoot ".output\admin-sa.pat"
. (Join-Path $deployDir "scripts\common.ps1")
$cfg = Read-DotEnv (Join-Path $deployDir ".env")
$issuer = "http://$($cfg['ZITADEL_EXTERNAL_DOMAIN']):$($cfg['ZITADEL_EXTERNAL_PORT'])"
$apiBase = "http://localhost:5111"
$scalarRedirect = "$apiBase/scalar/"
# Zitadel writes the PAT during init; on a cold start it may take a moment.
$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 logs musify-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
}
Write-Step "Waiting for Zitadel at $issuer"
$deadline = (Get-Date).AddMinutes(2)
do {
try { Invoke-RestMethod "$issuer/.well-known/openid-configuration" -TimeoutSec 5 | 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." }
# Project (idempotent) -------------------------------------------------------
Write-Step "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)"
}
# OIDC app (idempotent) ------------------------------------------------------
Write-Step "OIDC app '$AppName'"
$app = (Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{
queries = @(@{ nameQuery = @{ name = $AppName; method = "TEXT_QUERY_METHOD_EQUALS" } })
}).result | Where-Object { $_.name -eq $AppName } | Select-Object -First 1
if ($app) {
$clientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($app.id)" $null).app.oidcConfig.clientId
Write-Host " reused (clientId $clientId)"
} else {
$clientId = (Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{
name = $AppName
redirectUris = @($scalarRedirect, "$apiBase/scalar/oauth2-redirect.html")
postLogoutRedirectUris = @($scalarRedirect)
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 = $true # allow http redirect URIs
accessTokenType = "OIDC_TOKEN_TYPE_JWT" # required so JwtBearer can validate
accessTokenRoleAssertion = $true
idTokenRoleAssertion = $true
idTokenUserinfoAssertion = $true
}).clientId
Write-Host " created (clientId $clientId)"
}
# Wire the config: user-secrets (local runs) + .env (docker runs) -------------
Write-Step "Wiring Authentication into Musify.Api user-secrets"
@{
"Authentication:ClientId" = $clientId
"Authentication:AudienceAddress" = $clientId
"Authentication:IssuerAddress" = $issuer
"Authentication:MetadataAddress" = "$issuer/.well-known/openid-configuration"
"Authentication:AuthorizationEndpoint" = "$issuer/oauth/v2/authorize?prompt=login"
"Authentication:TokenEndpoint" = "$issuer/oauth/v2/token"
"Authentication:ScalarRedirectUri" = $scalarRedirect
"Authentication:RequireHttpsMetadata" = "false"
}.GetEnumerator() | ForEach-Object {
dotnet user-secrets set $_.Key $_.Value --project $apiProject | Out-Null
}
$envPath = Join-Path $deployDir ".env"
$envLines = Get-Content $envPath
if ($envLines -match '^\s*AUTH_CLIENT_ID=') {
$envLines = $envLines -replace '^\s*AUTH_CLIENT_ID=.*', "AUTH_CLIENT_ID=$clientId"
} else {
$envLines += "AUTH_CLIENT_ID=$clientId"
}
Set-Content -Path $envPath -Value $envLines -Encoding utf8
Write-Host "`nZitadel provisioned:" -ForegroundColor Green
Write-Host " Project: $ProjectName ($projectId)"
Write-Host " ClientId: $clientId"
Write-Host " Issuer: $issuer"