diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..73369a4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +**/bin/ +**/obj/ +**/TestResults/ +.git/ +.github/ +.vs/ +.idea/ +artifacts/ +tests/ +docs/ +deploy/ +tools/ +**/*.user +**/appsettings.Development.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..81ec41e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Restore + run: dotnet restore FamilyPlanner.slnx + + - name: Build (Warnungen sind Fehler) + run: dotnet build FamilyPlanner.slnx -c Release --no-restore + + - name: Unit- und Komponententests + run: | + dotnet test --project tests/FamilyPlanner.UnitTests -c Release --no-build + dotnet test --project tests/FamilyPlanner.Web.Tests -c Release --no-build + + - name: HTTPS-Entwicklungszertifikat (für Aspire-Integrationstests) + run: dotnet dev-certs https + + - name: Integrationstests (Aspire + SQL-Server-Container) + run: dotnet test --project tests/FamilyPlanner.IntegrationTests -c Release --no-build + + docker-images: + runs-on: ubuntu-latest + needs: build-and-test + strategy: + matrix: + project: [FamilyPlanner.Api, FamilyPlanner.Web, FamilyPlanner.MigrationService] + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Image bauen + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + build-args: PROJECT=${{ matrix.project }} + push: false + tags: familyplanner/${{ matrix.project }}:ci + cache-from: type=gha,scope=${{ matrix.project }} + cache-to: type=gha,mode=max,scope=${{ matrix.project }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..61a6f73 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1 +# Gemeinsames Dockerfile für Api, Web und MigrationService. +# docker build --build-arg PROJECT=FamilyPlanner.Api -t familyplanner-api . +# Alternativ ohne Docker-Build: dotnet publish src/ -c Release /t:PublishContainer + +ARG DOTNET_VERSION=10.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build +ARG PROJECT +WORKDIR /src + +# Zuerst nur Projektdateien kopieren, damit der Restore-Layer gecacht wird. +COPY global.json Directory.Build.props Directory.Packages.props .editorconfig ./ +COPY src/FamilyPlanner.Domain/*.csproj src/FamilyPlanner.Domain/ +COPY src/FamilyPlanner.Shared/*.csproj src/FamilyPlanner.Shared/ +COPY src/FamilyPlanner.Infrastructure/*.csproj src/FamilyPlanner.Infrastructure/ +COPY src/FamilyPlanner.ServiceDefaults/*.csproj src/FamilyPlanner.ServiceDefaults/ +COPY src/FamilyPlanner.Api/*.csproj src/FamilyPlanner.Api/ +COPY src/FamilyPlanner.MigrationService/*.csproj src/FamilyPlanner.MigrationService/ +COPY src/FamilyPlanner.Web/*.csproj src/FamilyPlanner.Web/ +COPY src/FamilyPlanner.Web.Client/*.csproj src/FamilyPlanner.Web.Client/ +RUN dotnet restore "src/${PROJECT}/${PROJECT}.csproj" + +COPY src/ src/ +# Kein --no-restore: das SDK referenziert Microsoft.AspNetCore.App.Internal.Assets (blazor.web.js) erst, +# wenn Razor-Komponenten im Projekt liegen – das ist beim Restore-Layer oben noch nicht der Fall. +RUN dotnet publish "src/${PROJECT}/${PROJECT}.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS final +ARG PROJECT + +# curl nur für Docker-Health-Checks. +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY --from=build /app/publish . + +ENV ASPNETCORE_HTTP_PORTS=8080 \ + DOTNET_RUNNING_IN_CONTAINER=true \ + APP_DLL=${PROJECT}.dll +EXPOSE 8080 + +# Non-Root: der Benutzer "app" (UID 1654) ist in den Microsoft-Images vorhanden. +USER $APP_UID + +ENTRYPOINT ["sh", "-c", "exec dotnet \"$APP_DLL\""] diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..da76ba3 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,26 @@ +# Kopieren nach deploy/.env und anpassen. Die .env-Datei niemals einchecken! + +# --- Datenbank --------------------------------------------------------------- +# Mindestens 8 Zeichen, Groß-/Kleinbuchstaben, Ziffern und Sonderzeichen (SQL-Server-Vorgabe). +SQL_SA_PASSWORD=Bitte-Aendern-2026! +# SQL-Server-Edition: Express (kostenlos, bis 10 GB) | Developer | Standard ... +MSSQL_PID=Express + +# --- Initialer Administrator (nur beim allerersten Start) ----------------------- +# Das Passwort muss beim ersten Login geändert werden. +ADMIN_USERNAME=admin +ADMIN_PASSWORD=Erstes-Passwort-2026 +ADMIN_EMAIL= +FAMILY_NAME=Familie Muster + +# --- Netzwerk ------------------------------------------------------------------- +# Ohne Caddy: Port des Web-Containers auf dem Host (127.0.0.1 = nur lokal bzw. für eigenen Reverse Proxy). +WEB_BIND=127.0.0.1 +WEB_PORT=8080 +# Mit Caddy (docker compose --profile proxy up -d): öffentlicher Domainname für das TLS-Zertifikat. +DOMAIN=familie.example.org + +# --- Images ---------------------------------------------------------------------- +# Optional: Registry-Präfix inkl. abschließendem Slash, z. B. ghcr.io/meinname/ +REGISTRY= +TAG=latest diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..77001fa --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,12 @@ +# Reverse Proxy mit automatischem HTTPS (Let's Encrypt). Domain kommt aus der Umgebungsvariable DOMAIN. +{$DOMAIN} { + encode zstd gzip + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + -Server + } + + # WebSockets (SignalR) werden automatisch durchgereicht. + reverse_proxy web:8080 +} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..aa52833 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,124 @@ +# Produktivbetrieb auf einem Linux-Docker-Host. +# +# cp .env.example .env # Werte anpassen! +# docker compose up -d --build +# docker compose --profile proxy up -d --build # inkl. Caddy (HTTPS mit Let's Encrypt) +# +# Die Anwendung verlangt HTTPS (Secure-Cookies). Ohne Caddy entweder einen eigenen Reverse Proxy +# mit TLS davor setzen oder nur über http://localhost testen. + +name: familyplanner + +x-app-build: &app-build + context: .. + dockerfile: Dockerfile + +x-connection-string: &connection-string + ConnectionStrings__familyplannerdb: "Server=sql,1433;Database=familyplannerdb;User Id=sa;Password=${SQL_SA_PASSWORD};TrustServerCertificate=True;Encrypt=True" + +services: + sql: + image: mcr.microsoft.com/mssql/server:2022-latest + # Das Image läuft als Benutzer "mssql" (non-root). + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: ${SQL_SA_PASSWORD:?SQL_SA_PASSWORD in .env setzen} + MSSQL_PID: ${MSSQL_PID:-Express} + TZ: Europe/Berlin + volumes: + - sql-data:/var/opt/mssql + - sql-backup:/var/opt/mssql/backup + healthcheck: + test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$$MSSQL_SA_PASSWORD\" -C -b -Q 'SELECT 1' > /dev/null"] + interval: 10s + timeout: 5s + retries: 20 + start_period: 30s + restart: unless-stopped + + migrations: + image: ${REGISTRY:-}familyplanner-migrations:${TAG:-latest} + build: + <<: *app-build + args: + PROJECT: FamilyPlanner.MigrationService + environment: + <<: *connection-string + DOTNET_ENVIRONMENT: Production + Seed__AdminUserName: ${ADMIN_USERNAME:-admin} + Seed__AdminPassword: ${ADMIN_PASSWORD:?ADMIN_PASSWORD in .env setzen} + Seed__AdminEmail: ${ADMIN_EMAIL:-} + Seed__FamilyName: ${FAMILY_NAME:-} + depends_on: + sql: + condition: service_healthy + restart: "no" + + api: + image: ${REGISTRY:-}familyplanner-api:${TAG:-latest} + build: + <<: *app-build + args: + PROJECT: FamilyPlanner.Api + environment: + <<: *connection-string + ASPNETCORE_ENVIRONMENT: Production + TZ: Europe/Berlin + depends_on: + migrations: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 20s + restart: unless-stopped + + web: + image: ${REGISTRY:-}familyplanner-web:${TAG:-latest} + build: + <<: *app-build + args: + PROJECT: FamilyPlanner.Web + environment: + ASPNETCORE_ENVIRONMENT: Production + TZ: Europe/Berlin + # Service Discovery: Adresse der Api im Docker-Netz (YARP-BFF) + services__api__http__0: http://api:8080 + ports: + - "${WEB_BIND:-127.0.0.1}:${WEB_PORT:-8080}:8080" + depends_on: + api: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/alive"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 15s + restart: unless-stopped + + caddy: + profiles: ["proxy"] + image: caddy:2 + environment: + DOMAIN: ${DOMAIN:-localhost} + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + web: + condition: service_healthy + restart: unless-stopped + +volumes: + sql-data: + sql-backup: + caddy-data: + caddy-config: diff --git a/deploy/scripts/backup.sh b/deploy/scripts/backup.sh new file mode 100644 index 0000000..e7b0cbc --- /dev/null +++ b/deploy/scripts/backup.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env sh +# Erstellt ein Voll-Backup der Datenbank im Volume "sql-backup" und kopiert es zusätzlich nach ./backups. +# Aufruf aus dem Ordner deploy/: ./scripts/backup.sh +set -eu + +cd "$(dirname "$0")/.." +. ./.env + +STAMP=$(date +%Y%m%d-%H%M%S) +FILE="familyplanner-${STAMP}.bak" + +docker compose exec -T sql /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P "$SQL_SA_PASSWORD" -C -b \ + -Q "BACKUP DATABASE [familyplannerdb] TO DISK = N'/var/opt/mssql/backup/${FILE}' WITH COMPRESSION, CHECKSUM, INIT" + +mkdir -p backups +docker compose cp "sql:/var/opt/mssql/backup/${FILE}" "backups/${FILE}" +echo "Backup erstellt: backups/${FILE}" + +# Alte Backups im Volume aufräumen (älter als 14 Tage). +docker compose exec -T sql sh -c "find /var/opt/mssql/backup -name 'familyplanner-*.bak' -mtime +14 -delete" diff --git a/deploy/scripts/restore.sh b/deploy/scripts/restore.sh new file mode 100644 index 0000000..bdf5ce1 --- /dev/null +++ b/deploy/scripts/restore.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env sh +# Stellt die Datenbank aus einer Backup-Datei wieder her. +# Aufruf aus dem Ordner deploy/: ./scripts/restore.sh backups/familyplanner-20261009-120000.bak +set -eu + +if [ $# -ne 1 ] || [ ! -f "$1" ]; then + echo "Aufruf: $0 " >&2 + exit 1 +fi + +cd "$(dirname "$0")/.." +. ./.env + +FILE=$(basename "$1") + +echo "Stoppe Anwendung ..." +docker compose stop web api + +docker compose cp "$1" "sql:/var/opt/mssql/backup/${FILE}" +docker compose exec -T sql /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P "$SQL_SA_PASSWORD" -C -b \ + -Q "ALTER DATABASE [familyplannerdb] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; RESTORE DATABASE [familyplannerdb] FROM DISK = N'/var/opt/mssql/backup/${FILE}' WITH REPLACE, CHECKSUM; ALTER DATABASE [familyplannerdb] SET MULTI_USER;" + +echo "Starte Anwendung ..." +docker compose up -d +echo "Wiederherstellung abgeschlossen."