diff --git a/deploy/scripts/common.ps1 b/deploy/scripts/common.ps1 index 5cf2123..8d2e659 100644 --- a/deploy/scripts/common.ps1 +++ b/deploy/scripts/common.ps1 @@ -1,11 +1,11 @@ #requires -Version 5.1 -# Shared helpers for the deploy scripts. +# Shared helpers for the development deploy scripts. function Write-Step([string]$Message) { Write-Host "`n==> $Message" -ForegroundColor Cyan } -# Parses a .env file into a hashtable (ignores comments and blank lines). +# Parses a .env file into an ordered hashtable (ignores comments and blank lines). function Read-DotEnv([string]$Path) { - $values = @{} + $values = [ordered]@{} if (Test-Path $Path) { Get-Content $Path | ForEach-Object { if ($_ -match '^\s*([^#=\s][^=]*)=(.*)$') { $values[$matches[1].Trim()] = $matches[2].Trim() } @@ -13,3 +13,14 @@ function Read-DotEnv([string]$Path) { } return $values } + +# Sets (or appends) a key in a .env file, preserving the rest of the file. +function Set-DotEnvValue([string]$Path, [string]$Key, [string]$Value) { + $lines = @(Get-Content $Path) + if ($lines -match "^\s*$Key=") { + $lines = $lines -replace "^\s*$Key=.*", "$Key=$Value" + } else { + $lines += "$Key=$Value" + } + Set-Content -Path $Path -Value $lines -Encoding utf8 +} diff --git a/deploy/up.ps1 b/deploy/up.ps1 index 04f6421..ad0c2be 100644 --- a/deploy/up.ps1 +++ b/deploy/up.ps1 @@ -4,25 +4,35 @@ Brings up the Musify development environment. .DESCRIPTION - Starts the infrastructure stack, generates the stream-ticket keys, applies EF - Core migrations and provisions Zitadel (project + OIDC app + ClientId). + Starts the infrastructure stack, generates the stream-ticket keys, applies + the EF Core migrations and provisions Zitadel (project + OIDC apps). The + .NET apps and the web player then run from the IDE; add -Apps to run them + in Docker instead. .EXAMPLE - ./up.ps1 # Bring everything up + ./up.ps1 # Infrastructure only (apps from the IDE) + ./up.ps1 -Apps # Also build and run api, worker, gateway, web player + ./up.ps1 -Tools # Also start pgAdmin ./up.ps1 -SkipMigrations # Skip the database migration step ./up.ps1 -Down # Stop and remove containers (keep volumes) ./up.ps1 -Destroy # Stop and remove containers + volumes (wipe data) #> [CmdletBinding()] param( + [switch]$Apps, + [switch]$Tools, + [switch]$SkipMigrations, [switch]$Down, - [switch]$Destroy, - [switch]$SkipMigrations + [switch]$Destroy ) $ErrorActionPreference = "Stop" -$deployDir = $PSScriptRoot -$composeFile = Join-Path $deployDir "docker-compose.yml" +$deployDir = $PSScriptRoot +$compose = @( + "-f", (Join-Path $deployDir "compose.yml"), + "-f", (Join-Path $deployDir "compose.dev.yml") +) +$profiles = @("--profile", "apps", "--profile", "tools") . (Join-Path $deployDir "scripts\common.ps1") @@ -30,12 +40,12 @@ $composeFile = Join-Path $deployDir "docker-compose.yml" if ($Down -or $Destroy) { if ($Destroy) { Write-Step "Stopping the stack and REMOVING volumes (data is wiped)" - docker compose -f $composeFile down -v --remove-orphans - # Drop the Zitadel init secrets (PAT) so the next start re-initializes cleanly. + docker compose @compose @profiles down -v --remove-orphans + # Drop the Zitadel init secrets so the next start re-initializes cleanly. Get-ChildItem (Join-Path $deployDir "zitadel\.output") -File -ErrorAction SilentlyContinue | Remove-Item -Force } else { Write-Step "Stopping the stack (volumes are kept)" - docker compose -f $composeFile down --remove-orphans + docker compose @compose @profiles down --remove-orphans } return } @@ -47,12 +57,18 @@ if (-not (Test-Path $envFile)) { Copy-Item (Join-Path $deployDir ".env.example") $envFile } $cfg = Read-DotEnv $envFile -$streamKeysDir = if ($cfg['STREAM_KEYS_DIR']) { $cfg['STREAM_KEYS_DIR'] } else { "D:/weed" } -$workerTempDir = if ($cfg['WORKER_TEMP_DIR']) { $cfg['WORKER_TEMP_DIR'] } else { "D:/tempsFilesDev" } + +# Host directories the apps expect: RS256 keys (mounted into the api/gateway +# containers, read directly by the IDE runs) and the Worker scratch space. +& (Join-Path $deployDir "scripts\stream-keys.ps1") -KeysDir (Join-Path $deployDir "keys") +$tempDir = Join-Path $deployDir ".tmp" +if (-not (Test-Path $tempDir)) { New-Item -ItemType Directory -Path $tempDir | Out-Null } # Infrastructure ------------------------------------------------------------- Write-Step "Starting the infrastructure stack" -docker compose -f $composeFile up -d +$upArgs = @("up", "-d") +if ($Tools) { $upArgs = @("--profile", "tools") + $upArgs } +docker compose @compose @upArgs if ($LASTEXITCODE -ne 0) { throw "docker compose up failed" } Write-Step "Waiting for Postgres to become healthy" @@ -63,16 +79,6 @@ do { } until ($status -eq "healthy" -or (Get-Date) -gt $deadline) if ($status -ne "healthy") { throw "Postgres did not become healthy in time" } -# Stream-ticket keys --------------------------------------------------------- -Write-Step "Ensuring stream-ticket keys" -& (Join-Path $deployDir "scripts\stream-keys.ps1") -KeysDir $streamKeysDir - -# Worker temp directory ------------------------------------------------------ -if (-not (Test-Path $workerTempDir)) { - Write-Step "Creating the Worker temp directory: $workerTempDir" - New-Item -ItemType Directory -Path $workerTempDir | Out-Null -} - # Database migrations -------------------------------------------------------- if ($SkipMigrations) { Write-Host "`nSkipping migrations (-SkipMigrations)." @@ -83,16 +89,36 @@ if ($SkipMigrations) { } # Zitadel provisioning ------------------------------------------------------- -Write-Step "Provisioning Zitadel (project + OIDC app + ClientId)" -try { - & (Join-Path $deployDir "zitadel\provision.ps1") -} catch { - Write-Host "Warning: Zitadel provisioning failed: $($_.Exception.Message)" -ForegroundColor Yellow +# Writes the client ids into .env, Musify.Api user-secrets and web-player/.env, +# so it has to run before the app containers start. +& (Join-Path $deployDir "zitadel\provision.ps1") + +# Applications --------------------------------------------------------------- +if ($Apps) { + Write-Step "Building and starting the applications" + docker compose @compose --profile apps up -d --build + if ($LASTEXITCODE -ne 0) { throw "docker compose up (apps) failed" } } # Summary -------------------------------------------------------------------- -$zDomain = $cfg['ZITADEL_EXTERNAL_DOMAIN'] -$zPort = $cfg['ZITADEL_EXTERNAL_PORT'] +$cfg = Read-DotEnv $envFile +$authUrl = $cfg['PUBLIC_AUTH_URL'] +$appLines = if ($Apps) { +@" + Musify API $($cfg['PUBLIC_API_URL'])/scalar/v1 + Streaming gateway $($cfg['PUBLIC_STREAM_URL'])/health + Web player $($cfg['PUBLIC_WEB_URL']) +"@ +} else { +@" + Run the apps from the IDE (or ./up.ps1 -Apps to run them in Docker): + dotnet run --project backend/Musify.Api # :5111 + dotnet run --project backend/Musify.StreamingGateway # :8081 + dotnet run --project backend/Musify.Worker # background + npm --prefix web-player run dev # :5173 +"@ +} + Write-Host @" ============================================================ @@ -102,21 +128,18 @@ Write-Host @" Service URL / port ------------------ -------------------------------------- PostgreSQL localhost:$($cfg['POSTGRES_HOST_PORT']) (db: $($cfg['POSTGRES_DB'])) - pgAdmin http://localhost:$($cfg['PGADMIN_HOST_PORT']) ($($cfg['PGADMIN_DEFAULT_EMAIL'])/$($cfg['PGADMIN_DEFAULT_PASSWORD'])) - Zitadel console http://$zDomain`:$zPort/ui/console + Zitadel console $authUrl/ui/console RabbitMQ mgmt http://localhost:15672 ($($cfg['RABBITMQ_DEFAULT_USER'])/$($cfg['RABBITMQ_DEFAULT_PASS'])) SeaweedFS S3 http://localhost:8333 (bucket: $($cfg['S3_BUCKET'])) SeaweedFS filer http://localhost:8888 SeaweedFS master http://localhost:9333 Jaeger traces http://localhost:16686 (OTLP: localhost:4317) + pgAdmin (-Tools) http://localhost:$($cfg['PGADMIN_HOST_PORT']) ($($cfg['PGADMIN_DEFAULT_EMAIL'])/$($cfg['PGADMIN_DEFAULT_PASSWORD'])) - Zitadel admin: $($cfg['ZITADEL_ADMIN_USERNAME'])@zitadel.$zDomain / $($cfg['ZITADEL_ADMIN_PASSWORD']) - Auth is provisioned automatically; ClientId is in Musify.Api user-secrets and .env. - Log in via Scalar: http://localhost:5111/scalar/v1 + Zitadel admin $($cfg['ZITADEL_ADMIN_USERNAME'])@zitadel.$($cfg['ZITADEL_EXTERNAL_DOMAIN']) / $($cfg['ZITADEL_ADMIN_PASSWORD']) + API client id $($cfg['AUTH_CLIENT_ID']) + Web client id $($cfg['WEB_CLIENT_ID']) - Run the apps from the IDE or: - dotnet run --project backend/Musify.Api # :5111 - dotnet run --project backend/Musify.StreamingGateway # :8081 - dotnet run --project backend/Musify.Worker # background +$appLines ============================================================ "@ -ForegroundColor Green diff --git a/deploy/zitadel/provision.ps1 b/deploy/zitadel/provision.ps1 index 5fb9b75..434f4d8 100644 --- a/deploy/zitadel/provision.ps1 +++ b/deploy/zitadel/provision.ps1 @@ -1,31 +1,40 @@ #requires -Version 5.1 <# .SYNOPSIS - Provisions Zitadel for Musify (idempotent). + Provisions Zitadel for Musify in development (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. + Creates the Musify project, the API OIDC app (public, PKCE, JWT access + token) and the web player OIDC app (confidential), then wires the resulting + client ids into deploy/.env, Musify.Api user-secrets and web-player/.env. + Authenticates with the service-account PAT that Zitadel writes on init + (zitadel/.output/admin-sa.pat). Re-running reuses the existing project and + apps and just refreshes the wired values. #> [CmdletBinding()] param( [string]$ProjectName = "Musify", - [string]$AppName = "Musify API (Scalar)" + [string]$ApiAppName = "Musify API (Scalar)", + [string]$WebAppName = "Musify Web" ) $ErrorActionPreference = "Stop" -$deployDir = Split-Path $PSScriptRoot -Parent -$apiProject = Resolve-Path (Join-Path $deployDir "..\backend\Musify.Api") +$deployDir = Split-Path $PSScriptRoot -Parent +$repoDir = Split-Path $deployDir -Parent +$apiProject = Join-Path $repoDir "backend\Musify.Api" +$webEnvFile = Join-Path $repoDir "web-player\.env" +$envFile = Join-Path $deployDir ".env" $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/" +$cfg = Read-DotEnv $envFile +$issuer = $cfg['PUBLIC_AUTH_URL'] +$apiUrl = $cfg['PUBLIC_API_URL'] +$webUrl = $cfg['PUBLIC_WEB_URL'] +$viteUrl = "http://localhost:5173" # `npm run dev` in web-player/ + +Write-Step "Provisioning Zitadel at $issuer" # Zitadel writes the PAT during init; on a cold start it may take a moment. $deadline = (Get-Date).AddMinutes(3) @@ -41,7 +50,6 @@ function Invoke-Zitadel($method, $path, $body) { 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 } @@ -49,86 +57,126 @@ do { } until ($ready -or (Get-Date) -gt $deadline) if (-not $ready) { throw "Zitadel did not respond in time." } -# Login UI (idempotent) ------------------------------------------------------ -# The 'latest' Zitadel image requires the Login UI v2 (a separate 'login' -# container that this stack does not run), so OIDC would redirect to -# /ui/v2/login and 404. Disable the requirement to use the built-in v1 login. -Write-Step "Disabling required Login UI v2 (use built-in /ui/login)" +# 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. +# Opting out keeps the built-in v1 login. try { Invoke-RestMethod -Method PUT -Uri "$issuer/v2/features/instance" -Headers $headers ` -ContentType "application/json" -Body (@{ loginV2 = @{ required = $false } } | ConvertTo-Json) -TimeoutSec 20 | Out-Null - Write-Host " loginV2.required = false" } catch { - Write-Host " warning: could not update loginV2 feature: $($_.Exception.Message)" + Write-Host " warning: could not disable the required Login UI v2: $($_.Exception.Message)" } -# Project (idempotent) ------------------------------------------------------- -Write-Step "Project '$ProjectName'" +# Project -------------------------------------------------------------------- $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)" + Write-Host " project '$ProjectName' reused (id $projectId)" } else { $projectId = (Invoke-Zitadel POST "/projects" @{ name = $ProjectName }).id - Write-Host " created (id $projectId)" + Write-Host " project '$ProjectName' 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 +function Get-ZitadelApp($name) { + (Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{ + queries = @(@{ nameQuery = @{ name = $name; method = "TEXT_QUERY_METHOD_EQUALS" } }) + }).result | Where-Object { $_.name -eq $name } | Select-Object -First 1 +} -if ($app) { - $clientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($app.id)" $null).app.oidcConfig.clientId - Write-Host " reused (clientId $clientId)" +# API app: public client used by the Scalar UI to obtain a token ------------- +$apiApp = Get-ZitadelApp $ApiAppName +if ($apiApp) { + $apiClientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($apiApp.id)" $null).app.oidcConfig.clientId + Write-Host " app '$ApiAppName' reused (clientId $apiClientId)" } 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 + $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 = $true # allow http redirect URIs + accessTokenType = "OIDC_TOKEN_TYPE_JWT" # required so JwtBearer can validate it accessTokenRoleAssertion = $true idTokenRoleAssertion = $true idTokenUserinfoAssertion = $true }).clientId - Write-Host " created (clientId $clientId)" + Write-Host " app '$ApiAppName' created (clientId $apiClientId)" } -# Wire the config: user-secrets (local runs) + .env (docker runs) ------------- -Write-Step "Wiring Authentication into Musify.Api user-secrets" +# Web app: confidential client used by the SvelteKit server ------------------ +$webApp = Get-ZitadelApp $WebAppName +$webSecret = $cfg['WEB_CLIENT_SECRET'] +if ($webApp) { + $webClientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($webApp.id)" $null).app.oidcConfig.clientId + # The secret is only returned once, so regenerate it if we no longer have it. + if (-not $webSecret) { + $webSecret = (Invoke-Zitadel POST "/projects/$projectId/apps/$($webApp.id)/oidc_config/_generate_client_secret" @{}).clientSecret + Write-Host " app '$WebAppName' reused (clientId $webClientId, secret regenerated)" + } else { + Write-Host " app '$WebAppName' reused (clientId $webClientId)" + } +} else { + $created = Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{ + name = $WebAppName + redirectUris = @("$webUrl/auth/callback", "$viteUrl/auth/callback") + postLogoutRedirectUris = @("$webUrl/", "$viteUrl/") + 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 = $true + accessTokenType = "OIDC_TOKEN_TYPE_JWT" + accessTokenRoleAssertion = $true + idTokenRoleAssertion = $true + idTokenUserinfoAssertion = $true + } + $webClientId = $created.clientId + $webSecret = $created.clientSecret + Write-Host " app '$WebAppName' created (clientId $webClientId)" +} + +# Wire the values ------------------------------------------------------------ +Set-DotEnvValue $envFile "AUTH_CLIENT_ID" $apiClientId +Set-DotEnvValue $envFile "WEB_CLIENT_ID" $webClientId +Set-DotEnvValue $envFile "WEB_CLIENT_SECRET" $webSecret + +# Musify.Api reads these from user-secrets when it runs outside Docker. @{ - "Authentication:ClientId" = $clientId - "Authentication:AudienceAddress" = $clientId + "Authentication:ClientId" = $apiClientId + "Authentication:AudienceAddress" = $apiClientId "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:ScalarRedirectUri" = "$apiUrl/scalar/" "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" +# web-player/.env is what `npm run dev` reads (the container gets its config +# from compose instead). +if (-not (Test-Path $webEnvFile)) { + Copy-Item (Join-Path $repoDir "web-player\.env.example") $webEnvFile +} +@{ + ZITADEL_ISSUER = $issuer + ZITADEL_CLIENT_ID = $webClientId + ZITADEL_CLIENT_SECRET = $webSecret + AUTH_REDIRECT_URI = "$viteUrl/auth/callback" + AUTH_POST_LOGOUT_URI = "$viteUrl/" + SESSION_SECRET = $cfg['SESSION_SECRET'] + API_BASE_URL = $apiUrl +}.GetEnumerator() | ForEach-Object { + Set-DotEnvValue $webEnvFile $_.Key $_.Value } -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" +Write-Host " wired into deploy/.env, Musify.Api user-secrets and web-player/.env"