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
+35
View File
@@ -0,0 +1,35 @@
# Dev stack config (deploy/docker-compose.yml).
# Copy to deploy/.env and adjust. .env is not versioned (.gitignore ignores *.env).
# PostgreSQL (shared by Musify and Zitadel). Local apps use localhost:59000.
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=musify_db
POSTGRES_HOST_PORT=59000
# RabbitMQ (MassTransit)
RABBITMQ_DEFAULT_USER=guest
RABBITMQ_DEFAULT_PASS=guest
# Zitadel (OIDC). The masterkey MUST be exactly 32 characters.
ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters
# The external domain/port forms the token issuer and must match everywhere.
# host.docker.internal resolves the same from browser, local apps and containers,
# so auth works both locally and full-docker. Changing it needs a re-init
# (up.ps1 -Destroy; up.ps1). Admin login: <user>@zitadel.<domain>.
ZITADEL_EXTERNAL_DOMAIN=host.docker.internal
ZITADEL_EXTERNAL_PORT=8080
ZITADEL_ADMIN_USERNAME=admin
ZITADEL_ADMIN_PASSWORD=Password1!
# SeaweedFS (S3). Must match deploy/seaweedfs/s3.conf.
S3_ACCESS_KEY=admin_access_key
S3_SECRET_KEY=admin_secret_key
S3_BUCKET=webapi-storage
# Host paths used by up.ps1 and the apps overlay.
STREAM_KEYS_DIR=D:/weed
WORKER_TEMP_DIR=D:/tempsFilesDev
# OIDC ClientId, filled in by zitadel/provision.ps1 (used by the docker path).
AUTH_CLIENT_ID=
+192
View File
@@ -0,0 +1,192 @@
# Development deploy
Everything the backend needs for development, in one place. A single
`docker-compose.yml` brings up **PostgreSQL, Zitadel, RabbitMQ, SeaweedFS and
Jaeger**; the .NET apps run from the IDE (or, optionally, in Docker too).
## Quick start
```powershell
# From the repo root (Musify/)
./deploy/up.ps1
```
`up.ps1` does the full bootstrap:
1. Creates `deploy/.env` from `.env.example` if missing.
2. Starts the stack (`docker compose up -d`) and waits for Postgres to be healthy.
3. Creates the `webapi-storage` bucket in SeaweedFS (`seaweedfs-init` service).
4. Generates the RS256 stream-ticket keys in `D:\weed` if missing.
5. Creates the Worker temp directory (`D:\tempsFilesDev`).
6. Applies the EF Core migrations to `musify_db`.
7. **Provisions Zitadel automatically** (project + OIDC app with JWT token) and
wires the ClientId into `Musify.Api` user-secrets and `.env`.
When it finishes it prints a summary with URLs and credentials. **No manual steps.**
### Without the script (infra only)
```powershell
docker compose -f deploy/docker-compose.yml up -d
```
The compose file still creates the bucket on its own; run migrations with
`dotnet ef database update` (see [../docs/development.md](../docs/development.md)).
## Services and ports
| Service | Port(s) | Used for |
|---|---|---|
| PostgreSQL | `59000` → 5432 | API + Worker (EF Core) and the Zitadel database |
| Zitadel | `8080` | OIDC/OAuth2 (console at `/ui/console`) |
| RabbitMQ | `5672` / `15672` | MassTransit / management UI |
| SeaweedFS S3 | `8333` | S3 (presigned PUT/GET) |
| SeaweedFS filer | `8888` | serves files → StreamingGateway |
| SeaweedFS master | `9333` | admin/UI |
| Jaeger | `16686` / `4317` / `4318` | OTLP traces (UI / gRPC / HTTP) |
Ports are published to the host, so apps run locally connect without changing
`AppSettings.Development.json` (Postgres, RabbitMQ, S3, OTLP).
## Authentication (Zitadel) — automatic
There are **no manual steps**. `up.ps1` runs `zitadel/provision.ps1`, which
idempotently:
1. Uses a **service account** with a **PAT** that Zitadel writes on init
(`zitadel/.output/admin-sa.pat`).
2. Creates (or reuses) the **project** `Musify` and an **OIDC app** (PKCE,
`devMode`, **access token = JWT** — required for `JwtBearer` to validate).
3. Captures the generated **ClientId** and writes it to:
- **`Musify.Api` user-secrets** (for running the apps locally), and
- `deploy/.env` as `AUTH_CLIENT_ID` (for the full-docker path).
The values stay stable while the Postgres volume persists. After `up.ps1 -Destroy`
the next `up.ps1` re-provisions (new ClientId, re-wired).
- Console: `http://host.docker.internal:8080/ui/console`
- Admin login: `admin@zitadel.host.docker.internal` / `Password1!` (see `.env`).
- Test login: open Scalar (`http://localhost:5111/scalar/v1`) and authorize.
### Why `host.docker.internal`
`ZITADEL_EXTERNAL_DOMAIN=host.docker.internal` forms the token *issuer* and
resolves the **same** from the browser, local apps and containers, so token
validation works both ways (local and full-docker). Zitadel routes by the `Host`
header, so the domain must be identical everywhere. If you only run the apps
locally you can use `localhost`. Changing the domain requires a re-init:
`up.ps1 -Destroy; up.ps1`.
## Running the apps
### Recommended: from the IDE (best for debugging)
```powershell
dotnet run --project backend/Musify.Api # :5111
dotnet run --project backend/Musify.StreamingGateway # :8081
dotnet run --project backend/Musify.Worker # background
```
Host requirements: **.NET 10 SDK**, **ffmpeg** on PATH (Worker audio transcode).
### Optional: everything in Docker
```powershell
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.apps.yml up -d --build
```
Builds and runs `api`, `worker` and `gateway` as containers (the Worker already
ships ffmpeg). The stream-ticket keys are mounted from `D:\weed` (configurable via
`STREAM_KEYS_DIR` in `.env`). Auth works in this mode too (the API downloads the
Zitadel JWKS via `host.docker.internal`).
> Note: on Linux the apps look for `appsettings*.json` in lowercase. The `Api`/
> `Worker` projects use `AppSettings*.json` (PascalCase), so their Dockerfiles
> create lowercase copies at publish time. On Windows it did not matter
> (case-insensitive).
## Useful commands
```powershell
./deploy/up.ps1 -SkipMigrations # bring up without touching the DB
./deploy/up.ps1 -Down # stop and remove containers (keep data)
./deploy/up.ps1 -Destroy # stop and remove containers + volumes (wipe data)
docker compose -f deploy/docker-compose.yml logs -f zitadel
docker compose -f deploy/docker-compose.yml ps
```
## Project layout — what each file does
```
deploy/
├─ docker-compose.yml # infrastructure stack (single source of truth)
├─ docker-compose.apps.yml # optional overlay: the 3 .NET apps in Docker
├─ .env # local config (secrets/ports); not versioned
├─ .env.example # config template (versioned)
├─ up.ps1 # orchestrator: up / down / destroy
├─ README.md # this file
├─ scripts/
│ ├─ common.ps1 # shared helpers (Read-DotEnv, Write-Step)
│ ├─ stream-keys.ps1 # generates the RS256 stream-ticket key pair
│ └─ migrate.ps1 # applies EF Core migrations
├─ seaweedfs/
│ └─ s3.conf # SeaweedFS S3 identities/credentials
└─ zitadel/
├─ provision.ps1 # creates project + OIDC app, wires the ClientId
└─ .output/ # PAT + init secrets written by Zitadel (not versioned)
backend/
├─ Musify.Api.Dockerfile # API image
├─ Musify.Worker.Dockerfile # Worker image (includes ffmpeg)
├─ Musify.StreamingGateway.Dockerfile # Gateway image
└─ .dockerignore
```
### Compose
- **`docker-compose.yml`** — the infra stack (`postgres`, `zitadel`, `rabbitmq`,
`seaweedfs`, the one-shot `seaweedfs-init` bucket creator, and `jaeger`). This is
the single source of truth for services, ports and volumes. Values come from `.env`.
- **`docker-compose.apps.yml`** — an optional overlay that adds the `api`, `worker`
and `gateway` services. It merges with the infra file (same `musify-dev` project
and network) and shares the backend env via YAML anchors. Used to run the whole
stack in Docker; for active development prefer running the apps from the IDE.
### Config
- **`.env`** — the actual values used by compose (Postgres/RabbitMQ/Zitadel/S3
credentials, ports, host paths, `AUTH_CLIENT_ID`). Ignored by git (`*.env`).
- **`.env.example`** — the versioned template. Copy it to `.env` (or let `up.ps1`
do it) and adjust.
### Scripts (PowerShell)
- **`up.ps1`** — the orchestrator. Parses `.env`, starts the stack, waits for
Postgres, then calls the sub-scripts in order (keys → migrations → provisioning)
and prints the summary. Also handles `-Down` / `-Destroy` / `-SkipMigrations`.
- **`scripts/common.ps1`** — shared helpers dot-sourced by the others: `Read-DotEnv`
(parse `.env` into a hashtable) and `Write-Step` (step logging).
- **`scripts/stream-keys.ps1`** — generates the RS256 key pair that the API uses to
sign stream tickets and the Gateway uses to verify them. No-op if the keys exist.
- **`scripts/migrate.ps1`** — sets the connection string in user-secrets and runs
`dotnet ef database update` (startup project = `Musify.Infrastructure`).
- **`zitadel/provision.ps1`** — reads the service-account PAT, creates/reuses the
Zitadel project and OIDC app (JWT token), and wires the ClientId into
`Musify.Api` user-secrets and `.env`. Idempotent; can be run standalone.
### Service configs
- **`seaweedfs/s3.conf`** — the S3 identities (access/secret keys) SeaweedFS loads.
Must match `InfrastructureStorage:*` in the app settings and `S3_*` in `.env`.
- **`zitadel/.output/`** — created at runtime; holds the PAT Zitadel writes on init
(`admin-sa.pat`). Not versioned; wiped by `up.ps1 -Destroy`.
### Dockerfiles (in `backend/`)
- **`Musify.Api.Dockerfile`** / **`Musify.Worker.Dockerfile`** /
**`Musify.StreamingGateway.Dockerfile`** — multi-stage builds for each host. The
Worker image also installs ffmpeg. Built by the apps overlay; build context is
`backend/` so restore can copy the central props and all csproj files.
- **`.dockerignore`** — keeps `bin/`, `obj/`, IDE folders and `DesignSettings.json`
out of the build context.
+96
View File
@@ -0,0 +1,96 @@
name: musify-dev
# Optional overlay: runs the .NET apps in Docker. Merges with docker-compose.yml.
#
# docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.apps.yml up -d --build
#
# For active development prefer infra in Docker + apps from the IDE (easier to
# debug). Auth works in both modes: ZITADEL_EXTERNAL_DOMAIN=host.docker.internal
# resolves the same from the browser, local apps and containers, so the token
# issuer matches everywhere (containers reach the host via extra_hosts).
x-backend-depends: &backend-depends
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
seaweedfs:
condition: service_healthy
x-backend-env: &backend-env
Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
MassTransit__Address: "rabbitmq://rabbitmq:5672"
MassTransit__Username: ${RABBITMQ_DEFAULT_USER}
MassTransit__Password: ${RABBITMQ_DEFAULT_PASS}
InfrastructureStorage__Address: "http://seaweedfs:8333"
InfrastructureStorage__AccessKey: ${S3_ACCESS_KEY}
InfrastructureStorage__SecretAccessKey: ${S3_SECRET_KEY}
InfrastructureStorage__ForcePathStyle: "true"
InfrastructureStorage__UseHttp: "true"
ApplicationStorage__Bucket: ${S3_BUCKET}
OpenTelemetry__OtlpEndpoint: "http://jaeger:4317"
services:
api:
build:
context: ../backend
dockerfile: Musify.Api.Dockerfile
container_name: musify-api
depends_on: *backend-depends
environment:
<<: *backend-env
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:5111
StreamGateway__PublicBaseUrl: "http://localhost:8081"
StreamTicket__PrivateKeyPath: "/keys/stream_private.pem"
# Auth values provisioned by zitadel/provision.ps1 (AUTH_CLIENT_ID in .env).
Authentication__MetadataAddress: "http://${ZITADEL_EXTERNAL_DOMAIN}:${ZITADEL_EXTERNAL_PORT}/.well-known/openid-configuration"
Authentication__IssuerAddress: "http://${ZITADEL_EXTERNAL_DOMAIN}:${ZITADEL_EXTERNAL_PORT}"
Authentication__ClientId: "${AUTH_CLIENT_ID}"
Authentication__AudienceAddress: "${AUTH_CLIENT_ID}"
Authentication__AuthorizationEndpoint: "http://${ZITADEL_EXTERNAL_DOMAIN}:${ZITADEL_EXTERNAL_PORT}/oauth/v2/authorize?prompt=login"
Authentication__TokenEndpoint: "http://${ZITADEL_EXTERNAL_DOMAIN}:${ZITADEL_EXTERNAL_PORT}/oauth/v2/token"
Authentication__ScalarRedirectUri: "http://localhost:5111/scalar/"
Authentication__Scopes__0: "openid"
Authentication__Scopes__1: "profile"
Authentication__Scopes__2: "email"
Authentication__RequireHttpsMetadata: "false"
extra_hosts:
- "${ZITADEL_EXTERNAL_DOMAIN}:host-gateway"
volumes:
- "${STREAM_KEYS_DIR:-D:/weed}:/keys:ro"
ports:
- "5111:5111"
restart: unless-stopped
worker:
build:
context: ../backend
dockerfile: Musify.Worker.Dockerfile
container_name: musify-worker
depends_on: *backend-depends
environment:
<<: *backend-env
DOTNET_ENVIRONMENT: Development
AudioTranscoder__Routes__WorkingDirectory: "/tmp/musify"
Workers__Routes__TemporaryFilesDirectory: "/tmp/musify"
restart: unless-stopped
gateway:
build:
context: ../backend
dockerfile: Musify.StreamingGateway.Dockerfile
container_name: musify-gateway
depends_on:
seaweedfs:
condition: service_healthy
environment:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:8081
StreamTicket__PublicKeyPath: "/keys/stream_public.pem"
ReverseProxy__Clusters__seaweed-filer__Destinations__filer__Address: "http://seaweedfs:8888/"
volumes:
- "${STREAM_KEYS_DIR:-D:/weed}:/keys:ro"
ports:
- "8081:8081"
restart: unless-stopped
+146
View File
@@ -0,0 +1,146 @@
name: musify-dev
# Infrastructure stack for Musify development: everything the backend needs
# (Postgres, Zitadel, RabbitMQ, SeaweedFS + bucket, Jaeger/OTLP). The .NET apps
# run from the IDE and connect over the published ports. Config in deploy/.env
#
# docker compose -f deploy/docker-compose.yml up -d
services:
postgres:
image: postgres:17-alpine
container_name: musify-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "${POSTGRES_HOST_PORT}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
zitadel:
image: ghcr.io/zitadel/zitadel:latest
container_name: musify-zitadel
command: 'start-from-init --masterkeyFromEnv --tlsMode disabled'
environment:
ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY}
ZITADEL_EXTERNALSECURE: "false"
ZITADEL_EXTERNALDOMAIN: ${ZITADEL_EXTERNAL_DOMAIN}
ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNAL_PORT}
ZITADEL_TLS_ENABLED: "false"
# Reuses the same Postgres: the admin user creates the 'zitadel' database
# and the 'zitadel' user on first start.
ZITADEL_DATABASE_POSTGRES_HOST: postgres
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: ${POSTGRES_USER}
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_PASSWORD}
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
# Initial console admin. Login: <user>@zitadel.<domain>
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: ${ZITADEL_ADMIN_USERNAME}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false"
# Service account + PAT for automatic provisioning. Zitadel writes the PAT
# to PATPATH on init; provision.ps1 uses it to create the project and app.
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: zitadel-admin-sa
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Admin Service Account
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z"
ZITADEL_FIRSTINSTANCE_PATPATH: /output/admin-sa.pat
depends_on:
postgres:
condition: service_healthy
ports:
- "${ZITADEL_EXTERNAL_PORT}:8080"
volumes:
- ./zitadel/.output:/output
restart: unless-stopped
rabbitmq:
image: rabbitmq:4-management-alpine
container_name: musify-rabbitmq
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS}
ports:
- "5672:5672" # AMQP -> MassTransit (API + Worker)
- "15672:15672" # management UI
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
seaweedfs:
image: chrislusf/seaweedfs:latest
container_name: musify-seaweedfs
# All-in-one dev server: master + volume + filer + S3 gateway.
command: >
server
-ip.bind=0.0.0.0
-dir=/data
-master.volumeSizeLimitMB=1024
-volume.max=0
-filer
-s3
-s3.config=/etc/seaweedfs/s3.conf
ports:
- "9333:9333" # master (admin/UI)
- "8888:8888" # filer HTTP -> StreamingGateway
- "8333:8333" # S3 API -> WebApi/Worker (InfrastructureStorage:Address)
volumes:
- seaweedfs_data:/data
- ./seaweedfs/s3.conf:/etc/seaweedfs/s3.conf:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9333/cluster/healthz"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
# One-shot: creates the app bucket in SeaweedFS (it is not auto-created).
seaweedfs-init:
image: amazon/aws-cli:latest
container_name: musify-seaweedfs-init
depends_on:
seaweedfs:
condition: service_healthy
environment:
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}
AWS_DEFAULT_REGION: us-east-1
entrypoint: ["/bin/sh", "-c"]
command:
- |
aws --endpoint-url http://seaweedfs:8333 s3 mb s3://${S3_BUCKET} 2>/dev/null || true
aws --endpoint-url http://seaweedfs:8333 s3 ls
restart: "no"
# OTLP collector + trace UI (MassTransit/EF/HTTP spans). OtlpEndpoint = :4317
jaeger:
image: jaegertracing/all-in-one:latest
container_name: musify-jaeger
environment:
COLLECTOR_OTLP_ENABLED: "true"
ports:
- "16686:16686" # UI
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
restart: unless-stopped
volumes:
postgres_data:
rabbitmq_data:
seaweedfs_data:
+15
View File
@@ -0,0 +1,15 @@
#requires -Version 5.1
# Shared helpers for the 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).
function Read-DotEnv([string]$Path) {
$values = @{}
if (Test-Path $Path) {
Get-Content $Path | ForEach-Object {
if ($_ -match '^\s*([^#=\s][^=]*)=(.*)$') { $values[$matches[1].Trim()] = $matches[2].Trim() }
}
}
return $values
}
+15
View File
@@ -0,0 +1,15 @@
#requires -Version 5.1
# Applies EF Core migrations to the Musify database.
[CmdletBinding()]
param([Parameter(Mandatory)][string]$ConnectionString)
$ErrorActionPreference = "Stop"
$infraProject = Resolve-Path (Join-Path $PSScriptRoot "..\..\backend\Musify.Infrastructure")
# The design-time factory reads the connection string from user-secrets.
dotnet user-secrets set "Database:ConnectionString" $ConnectionString --project $infraProject | Out-Null
# Infrastructure is also the startup project: it owns the IDesignTimeDbContextFactory
# and the EFCore.Design package (Musify.Api does not reference Design).
dotnet ef database update --project $infraProject --startup-project $infraProject --context Database
if ($LASTEXITCODE -ne 0) { throw "EF Core migration failed" }
+28
View File
@@ -0,0 +1,28 @@
#requires -Version 5.1
# Generates the RS256 key pair used to sign (API) and verify (Gateway) stream tickets.
# No-op if both keys already exist.
[CmdletBinding()]
param([Parameter(Mandatory)][string]$KeysDir)
$ErrorActionPreference = "Stop"
$privateKey = Join-Path $KeysDir "stream_private.pem"
$publicKey = Join-Path $KeysDir "stream_public.pem"
if ((Test-Path $privateKey) -and (Test-Path $publicKey)) {
Write-Host "Stream-ticket keys already present in $KeysDir"
return
}
if (-not (Test-Path $KeysDir)) { New-Item -ItemType Directory -Path $KeysDir | Out-Null }
if (Get-Command openssl -ErrorAction SilentlyContinue) {
& openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out $privateKey
& openssl rsa -in $privateKey -pubout -out $publicKey
} else {
$rsa = [System.Security.Cryptography.RSA]::Create(2048)
[System.IO.File]::WriteAllText($privateKey, $rsa.ExportPkcs8PrivateKeyPem())
[System.IO.File]::WriteAllText($publicKey, $rsa.ExportSubjectPublicKeyInfoPem())
$rsa.Dispose()
}
Write-Host "Generated stream-ticket keys in $KeysDir"
-32
View File
@@ -1,32 +0,0 @@
services:
seaweedfs:
image: chrislusf/seaweedfs:latest
container_name: seaweedfs
# All-in-one dev server: master + volume + filer + S3 gateway.
command: >
server
-ip.bind=0.0.0.0
-dir=/data
-master.volumeSizeLimitMB=1024
-volume.max=0
-filer
-s3
-s3.config=/etc/seaweedfs/s3.conf
ports:
- "9333:9333" # master (admin/UI)
- "8888:8888" # filer HTTP -> used by the Media Gateway
- "8333:8333" # S3 API -> used by the WebApi (InfrastructureStorage:Address)
# Volume server (8080) is left unpublished on purpose: it only needs to be
# reachable internally, and 8080 clashes with Zitadel on the host.
volumes:
- seaweedfs_data:/data
- ./s3.conf:/etc/seaweedfs/s3.conf:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9333/cluster/healthz"]
interval: 10s
timeout: 5s
retries: 10
volumes:
seaweedfs_data:
+121
View File
@@ -0,0 +1,121 @@
#requires -Version 5.1
<#
.SYNOPSIS
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).
.EXAMPLE
./up.ps1 # Bring everything up
./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]$Down,
[switch]$Destroy,
[switch]$SkipMigrations
)
$ErrorActionPreference = "Stop"
$deployDir = $PSScriptRoot
$composeFile = Join-Path $deployDir "docker-compose.yml"
. (Join-Path $deployDir "scripts\common.ps1")
# Teardown -------------------------------------------------------------------
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.
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
}
return
}
# Config ---------------------------------------------------------------------
$envFile = Join-Path $deployDir ".env"
if (-not (Test-Path $envFile)) {
Write-Step "Creating .env from .env.example"
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" }
# Infrastructure -------------------------------------------------------------
Write-Step "Starting the infrastructure stack"
docker compose -f $composeFile up -d
if ($LASTEXITCODE -ne 0) { throw "docker compose up failed" }
Write-Step "Waiting for Postgres to become healthy"
$deadline = (Get-Date).AddMinutes(2)
do {
Start-Sleep -Seconds 2
$status = docker inspect --format '{{.State.Health.Status}}' musify-postgres 2>$null
} 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)."
} else {
Write-Step "Applying EF Core migrations"
$conn = "Host=localhost;Port=$($cfg['POSTGRES_HOST_PORT']);Database=$($cfg['POSTGRES_DB']);Username=$($cfg['POSTGRES_USER']);Password=$($cfg['POSTGRES_PASSWORD'])"
& (Join-Path $deployDir "scripts\migrate.ps1") -ConnectionString $conn
}
# 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
}
# Summary --------------------------------------------------------------------
$zDomain = $cfg['ZITADEL_EXTERNAL_DOMAIN']
$zPort = $cfg['ZITADEL_EXTERNAL_PORT']
Write-Host @"
============================================================
Musify development environment READY
============================================================
Service URL / port
------------------ --------------------------------------
PostgreSQL localhost:$($cfg['POSTGRES_HOST_PORT']) (db: $($cfg['POSTGRES_DB']))
Zitadel console http://$zDomain`:$zPort/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)
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
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
============================================================
"@ -ForegroundColor Green
+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"