diff --git a/.gitea/workflows/continuous_integration.yaml b/.gitea/workflows/continuous_integration.yaml new file mode 100644 index 0000000..18ee877 --- /dev/null +++ b/.gitea/workflows/continuous_integration.yaml @@ -0,0 +1,349 @@ +name: Continuous Integration + +on: + workflow_dispatch: + inputs: + deploy_production: + description: 'Also deploy to production after a successful build/test (in addition to the automatic test deploy)' + type: boolean + default: false + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [master] + +# Reusable settings for this workflow. Change these in one place if the .NET/Node/pnpm version, +# artifact names, or deploy destinations change. The env context is NOT available inside a called +# reusable workflow's `with:` inputs (see the `config` job below, which works around this by passing +# these values through as job outputs to the `deploy-test` / `deploy-production` jobs). +env: + DOTNET_VERSION: '10.0.x' + NODE_VERSION: '20' + PNPM_VERSION: '9' + PUBLISH_RID: linux-arm64 + ARTIFACT_NAME_TEST: app-test + ARTIFACT_NAME_PRODUCTION: app-production + DEPLOY_ENVIRONMENT_TEST: test + DEPLOY_ENVIRONMENT_PRODUCTION: production + DEPLOY_PATH_TEST: ${{ vars.DEPLOY_PATH_TEST }} + DEPLOY_PATH_PRODUCTION: ${{ vars.DEPLOY_PATH_PRODUCTION }} + SERVICE_NAME_TEST: ${{ vars.SERVICE_NAME_TEST }} + SERVICE_NAME_PRODUCTION: ${{ vars.SERVICE_NAME_PRODUCTION }} + HEALTH_CHECK_URL_TEST: ${{ vars.HEALTH_CHECK_URL_TEST }} + HEALTH_CHECK_URL_PRODUCTION: ${{ vars.HEALTH_CHECK_URL_PRODUCTION }} + +jobs: + # Passes the env: values above through as job outputs, since the env context is unavailable in + # the `with:` block of a job that calls a reusable workflow (see deploy-test / deploy-production). + config: + runs-on: ubuntu-latest + outputs: + artifact_name_test: ${{ steps.set.outputs.artifact_name_test }} + artifact_name_production: ${{ steps.set.outputs.artifact_name_production }} + deploy_environment_test: ${{ steps.set.outputs.deploy_environment_test }} + deploy_environment_production: ${{ steps.set.outputs.deploy_environment_production }} + deploy_path_test: ${{ steps.set.outputs.deploy_path_test }} + deploy_path_production: ${{ steps.set.outputs.deploy_path_production }} + service_name_test: ${{ steps.set.outputs.service_name_test }} + service_name_production: ${{ steps.set.outputs.service_name_production }} + health_check_url_test: ${{ steps.set.outputs.health_check_url_test }} + health_check_url_production: ${{ steps.set.outputs.health_check_url_production }} + steps: + - id: set + run: | + echo "artifact_name_test=${{ env.ARTIFACT_NAME_TEST }}" >> "$GITHUB_OUTPUT" + echo "artifact_name_production=${{ env.ARTIFACT_NAME_PRODUCTION }}" >> "$GITHUB_OUTPUT" + echo "deploy_environment_test=${{ env.DEPLOY_ENVIRONMENT_TEST }}" >> "$GITHUB_OUTPUT" + echo "deploy_environment_production=${{ env.DEPLOY_ENVIRONMENT_PRODUCTION }}" >> "$GITHUB_OUTPUT" + echo "deploy_path_test=${{ env.DEPLOY_PATH_TEST }}" >> "$GITHUB_OUTPUT" + echo "deploy_path_production=${{ env.DEPLOY_PATH_PRODUCTION }}" >> "$GITHUB_OUTPUT" + echo "service_name_test=${{ env.SERVICE_NAME_TEST }}" >> "$GITHUB_OUTPUT" + echo "service_name_production=${{ env.SERVICE_NAME_PRODUCTION }}" >> "$GITHUB_OUTPUT" + echo "health_check_url_test=${{ env.HEALTH_CHECK_URL_TEST }}" >> "$GITHUB_OUTPUT" + echo "health_check_url_production=${{ env.HEALTH_CHECK_URL_PRODUCTION }}" >> "$GITHUB_OUTPUT" + + # --- Gate 1: backend build --- + backend-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Restore and build (Release) + run: dotnet build SlpModularCms.sln -c Release + + # --- Gate 2: backend tests --- + backend-test: + needs: backend-build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Test (Release) + run: dotnet test SlpModularCms.sln -c Release + + # --- Gate 3: vulnerability scan --- + # `dotnet list package --vulnerable` always exits 0, even when it reports vulnerabilities, so the + # step greps its own output and fails deliberately (FR-22, D-12, OPEN-03 — closed by pinning + # Microsoft.OpenApi and System.Security.Cryptography.Xml in the relevant .csproj files). + vulnerability-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Check for vulnerable packages + run: | + OUTPUT=$(dotnet list SlpModularCms.sln package --vulnerable --include-transitive 2>&1) + echo "$OUTPUT" + if echo "$OUTPUT" | grep -q "has the following vulnerable packages"; then + echo "Vulnerable packages detected — see above." + exit 1 + fi + + frontend-prepare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - name: Get pnpm store directory + id: pnpm-store + working-directory: frontend + run: echo "path=$(pnpm store path)" >> "$GITHUB_OUTPUT" + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-${{ hashFiles('frontend/pnpm-lock.yaml') }} + - name: Install dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + + # --- Gate 4: frontend build --- + # Validates the frontend compiles on its own, independent of any environment. The actual + # per-environment deployable artifact is produced later by `dotnet publish` (publish-test / + # publish-production jobs below), which triggers the same frontend build internally via + # SlpModularCms.Api.csproj's BuildAndCopyAdminFrontend MSBuild target, with the environment's + # Vite variables set on the process so that target picks them up. + frontend-build: + needs: frontend-prepare + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - name: Get pnpm store directory + id: pnpm-store + working-directory: frontend + run: echo "path=$(pnpm store path)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-${{ hashFiles('frontend/pnpm-lock.yaml') }} + - name: Install dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + - name: Build + working-directory: frontend + run: pnpm run build + + # --- Gate 5: frontend tests --- + frontend-test: + needs: frontend-prepare + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - name: Get pnpm store directory + id: pnpm-store + working-directory: frontend + run: echo "path=$(pnpm store path)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-${{ hashFiles('frontend/pnpm-lock.yaml') }} + - name: Install dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + - name: Unit tests + working-directory: frontend + run: pnpm run test + + # --- Gate 6: frontend lint / format-check --- + frontend-lint: + needs: frontend-prepare + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - name: Get pnpm store directory + id: pnpm-store + working-directory: frontend + run: echo "path=$(pnpm store path)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-${{ hashFiles('frontend/pnpm-lock.yaml') }} + - name: Install dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + - name: Lint + working-directory: frontend + run: pnpm run lint + + # Publishes the Api project for the test environment. The admin SPA is built as part of this + # publish (BuildAndCopyAdminFrontend, BeforeTargets="Publish"), so the VITE_* variables below are + # set on this step's environment, not passed as CLI arguments — pnpm build (invoked by MSBuild) + # reads them the same way `vite build` always does. + # + # REF-U5-01: the Umami-origin drift check this gate performs exists because U3's own startup check + # (BR-U3-22) can't work — the backend can never see VITE_UMAMI_WEBSITE_ID at runtime. It compares + # two CI-time values instead: the frontend's Umami script origin, and a Gitea variable that MUST be + # kept in sync with the host's real SecurityHeaders__AllowedScriptOrigins__0 env var (D-16). This + # gate cannot see the actual runtime CSP configuration; it only catches drift between the frontend + # build and what this variable claims the CSP allows. + publish-test: + needs: [backend-build, backend-test, vulnerability-scan, frontend-build, frontend-test, frontend-lint] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Verify Umami origin is permitted (REF-U5-01) + if: ${{ vars.VITE_UMAMI_SCRIPT_URL != '' }} + run: | + ORIGIN=$(echo "${{ vars.VITE_UMAMI_SCRIPT_URL }}" | sed -E 's#^(https?://[^/]+).*#\1#') + if ! echo ",${{ vars.SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST }}," | grep -qF ",$ORIGIN,"; then + echo "Umami origin '$ORIGIN' is not present in the SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST Gitea variable." + echo "Fix the variable, or the test environment's SecurityHeaders CSP config it must mirror (D-16)." + exit 1 + fi + + - name: Publish (test) + working-directory: src/SlpModularCms.Api + env: + VITE_APP_ENV: test + VITE_UMAMI_SCRIPT_URL: ${{ vars.VITE_UMAMI_SCRIPT_URL }} + VITE_UMAMI_WEBSITE_ID: ${{ vars.VITE_UMAMI_WEBSITE_ID_TEST }} + VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} + run: > + dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false + -o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_TEST }} + + - name: Upload publish artifact + uses: actions/upload-artifact@v3 + with: + name: ${{ env.ARTIFACT_NAME_TEST }} + path: ${{ env.ARTIFACT_NAME_TEST }} + retention-days: 1 + + # Same as publish-test, but only for a manual workflow_dispatch run with deploy_production set — + # a distinct job rather than a parameterized reuse of publish-test, because VITE_APP_ENV is a + # Vite build-time value: one dist/ bundle cannot be tagged as both 'test' and 'production' (FR-05). + publish-production: + needs: [backend-build, backend-test, vulnerability-scan, frontend-build, frontend-test, frontend-lint] + if: github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_production == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Verify Umami origin is permitted (REF-U5-01) + if: ${{ vars.VITE_UMAMI_SCRIPT_URL != '' }} + run: | + ORIGIN=$(echo "${{ vars.VITE_UMAMI_SCRIPT_URL }}" | sed -E 's#^(https?://[^/]+).*#\1#') + if ! echo ",${{ vars.SECURITY_ALLOWED_SCRIPT_ORIGINS_PRODUCTION }}," | grep -qF ",$ORIGIN,"; then + echo "Umami origin '$ORIGIN' is not present in the SECURITY_ALLOWED_SCRIPT_ORIGINS_PRODUCTION Gitea variable." + echo "Fix the variable, or the production environment's SecurityHeaders CSP config it must mirror (D-16)." + exit 1 + fi + + - name: Publish (production) + working-directory: src/SlpModularCms.Api + env: + VITE_APP_ENV: production + VITE_UMAMI_SCRIPT_URL: ${{ vars.VITE_UMAMI_SCRIPT_URL }} + VITE_UMAMI_WEBSITE_ID: ${{ vars.VITE_UMAMI_WEBSITE_ID_PRODUCTION }} + VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} + run: > + dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false + -o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_PRODUCTION }} + + - name: Upload publish artifact + uses: actions/upload-artifact@v3 + with: + name: ${{ env.ARTIFACT_NAME_PRODUCTION }} + path: ${{ env.ARTIFACT_NAME_PRODUCTION }} + retention-days: 1 + + # Automatic test deploy on push to master, or on any workflow_dispatch run (FR-03, D-01, D-09). + deploy-test: + needs: [publish-test, config] + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/master') + uses: ./.gitea/workflows/deploy-scp.yaml + secrets: inherit + with: + artifact_name: ${{ needs.config.outputs.artifact_name_test }} + environment: ${{ needs.config.outputs.deploy_environment_test }} + deploy_path: ${{ needs.config.outputs.deploy_path_test }} + service_name: ${{ needs.config.outputs.service_name_test }} + health_check_url: ${{ needs.config.outputs.health_check_url_test }} + run_db_backup: false + + # Production deploy ONLY on an explicit workflow_dispatch run with deploy_production checked + # (FR-04, D-09) — never reachable from a plain push to master, so pushing cannot deploy production + # under any circumstance. + deploy-production: + needs: [publish-production, config] + if: github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_production == 'true' + uses: ./.gitea/workflows/deploy-scp.yaml + secrets: inherit + with: + artifact_name: ${{ needs.config.outputs.artifact_name_production }} + environment: ${{ needs.config.outputs.deploy_environment_production }} + deploy_path: ${{ needs.config.outputs.deploy_path_production }} + service_name: ${{ needs.config.outputs.service_name_production }} + health_check_url: ${{ needs.config.outputs.health_check_url_production }} + run_db_backup: true diff --git a/.gitea/workflows/deploy-scp.yaml b/.gitea/workflows/deploy-scp.yaml new file mode 100644 index 0000000..67bce96 --- /dev/null +++ b/.gitea/workflows/deploy-scp.yaml @@ -0,0 +1,150 @@ +name: Deploy (SCP) + +# Reusable deploy workflow, invoked by continuous_integration.yaml (U5) for both the automatic +# test deploy and the opt-in production deploy. One workflow, one input contract, so a second +# transport (e.g. FTPS for a future shared-hosting target) can be added later as an alternative +# without restructuring this workflow or its callers (FR-02, D-02, NFR-09). +on: + workflow_call: + inputs: + artifact_name: + description: 'Build artifact to download and deploy' + required: true + type: string + environment: + description: 'Environment label, used in log output and step naming' + required: true + type: string + deploy_path: + description: 'Release root on the target host for this environment' + required: true + type: string + service_name: + description: 'systemd --user unit to restart after switching releases' + required: true + type: string + health_check_url: + description: 'Public /health URL to verify after restart' + required: true + type: string + run_db_backup: + description: 'Take a database backup before deploying (production only)' + required: false + type: boolean + default: false + transport: + description: 'Transport mechanism. Only "scp" is implemented today; reserved for FTPS later' + required: false + type: string + default: scp + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Compute release timestamp + id: release + run: echo "timestamp=$(date -u +%Y%m%d%H%M%S)" >> "$GITHUB_OUTPUT" + + - name: Download build artifact + uses: actions/download-artifact@v3 + with: + name: ${{ inputs.artifact_name }} + path: ${{ inputs.artifact_name }} + + # Production-only database backup, taken before anything on the host changes (FR-20, D-26). + # The backup script itself lives on the host (created once during Operations host setup) and + # is only invoked here — no database connection string or credential is ever known to this + # workflow, keeping D-16 ("runtime secrets live in host environment variables") intact. + - name: Back up database (${{ inputs.environment }}) + if: ${{ inputs.run_db_backup }} + run: | + sudo apt-get update && sudo apt-get install -y sshpass + sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ + -p ${{ secrets.PI_MAIN_PORT }} \ + -o StrictHostKeyChecking=no \ + ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ + "bash ~/scripts/backup-slpsoftware-db.sh ${{ inputs.environment }}" + + # Uploads the published output to a fresh, timestamped release directory rather than + # overwriting the live one (FR-06, D-27) — the atomic switch happens in a later step, once + # this upload and the website-symlink step below have both succeeded. + # + # Plain shell step rather than a container SCP action: container-based actions fail on this + # runner with "failed to attach to container: unable to upgrade to tcp, received 409", a known + # limitation of Podman's Docker-compatible API for the attach/log-streaming that container + # actions rely on (D-05). A plain scp command needs no nested container. + - name: Upload release to ${{ inputs.environment }} (${{ inputs.transport }}) + run: | + sudo apt-get update && sudo apt-get install -y sshpass + RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" + sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ + -p ${{ secrets.PI_MAIN_PORT }} \ + -o StrictHostKeyChecking=no \ + ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ + "mkdir -p $RELEASE_DIR" + sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" scp \ + -P ${{ secrets.PI_MAIN_PORT }} \ + -o StrictHostKeyChecking=no \ + -r ${{ inputs.artifact_name }}/* \ + ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }}:"$RELEASE_DIR"/ + + # The customer's public website (wwwroot/web) must survive every CMS deploy (FR-08, ASM-01). + # It lives outside the swapped release directory in a persistent shared/ folder, and is + # symlinked into each new release. mkdir -p is idempotent, so this is also correct on the very + # first-ever deploy, before any website workspace has published anything there (U1 already + # tolerates a missing wwwroot/web at startup). The publish output's own wwwroot/web (empty, or + # containing only the placeholder page) is removed before the symlink is created, so it never + # shadows the persistent content. + - name: Link persistent website content + run: | + RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" + sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ + -p ${{ secrets.PI_MAIN_PORT }} \ + -o StrictHostKeyChecking=no \ + ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ + "mkdir -p ${{ inputs.deploy_path }}/shared/wwwroot-web && \ + rm -rf $RELEASE_DIR/wwwroot/web && \ + ln -s ../../../shared/wwwroot-web $RELEASE_DIR/wwwroot/web" + + # Atomic release switch (FR-06, D-27): `ln -sfn` replaces the `current` symlink target in a + # single filesystem operation, so there is no moment where `current` points at a half-written + # directory. The process is then restarted so it picks up the new assemblies — a running .NET + # process holds on to the ones it already loaded. + - name: Switch current release and restart service + run: | + RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" + sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ + -p ${{ secrets.PI_MAIN_PORT }} \ + -o StrictHostKeyChecking=no \ + ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ + "ln -sfn $RELEASE_DIR ${{ inputs.deploy_path }}/current && \ + systemctl --user restart ${{ inputs.service_name }}" + + # Verifies the restart actually produced a healthy process before this run is allowed to + # report success. Retries absorb ordinary process-startup time; a run that never turns healthy + # fails the job without touching `current` or pruning (see below) — no automatic rollback. + # A previous release is always still on disk to restore from manually (D-26). + - name: Verify /health + run: | + for attempt in $(seq 1 10); do + if curl -f -s -o /dev/null "${{ inputs.health_check_url }}"; then + echo "Health check passed on attempt $attempt" + exit 0 + fi + echo "Health check attempt $attempt failed, retrying..." + sleep 3 + done + echo "Health check did not pass after 10 attempts" + exit 1 + + # Retention: keep `current` plus exactly one previous release, so a manual rollback is always + # a re-point-and-restart away without rebuilding. Only runs after a passing health check — + # pruning after a failed check could leave the only other release as the sole survivor. + - name: Prune old releases + run: | + sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ + -p ${{ secrets.PI_MAIN_PORT }} \ + -o StrictHostKeyChecking=no \ + ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ + "cd ${{ inputs.deploy_path }}/releases && ls -1t | tail -n +3 | xargs -r rm -rf" diff --git a/README.md b/README.md index 337c4c0..c26ee3e 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,18 @@ Een modulaire monolith CMS gebouwd met .NET 10. ### Vereisten - .NET 10 SDK -- Podman of Docker (voor SQL Server) +- Podman of Docker (voor MariaDB) ### 1. Database opstarten -Start een SQL Server container met de volgende opdracht: +Start een MariaDB-container met de volgende opdracht: ```powershell -podman run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=MSSQL_s3cr3t_pw!" -p 1433:1433 --name sql-server -d mcr.microsoft.com/mssql/server:2022-latest +podman run -e "MARIADB_ROOT_PASSWORD=" -p 3306:3306 --name mariadb -d docker.io/library/mariadb:latest ``` +> **Migratie-notitie**: dit project draaide tot 2026-07-28 op Microsoft SQL Server. Overgestapt naar +> MariaDB omdat de productie-Pi geen SQL Server kan draaien (geen ARM64-build bestaat) en al MariaDB +> heeft geïnstalleerd. Zie `aidlc-docs/features/gitea-deployment-workflow/` voor de volledige +> rationale. Bestaande lokale SQL Server-databases/migraties zijn niet compatibel — begin met een +> verse MariaDB-database. ### 2. Configuratie De applicatie maakt gebruik van een drie-bestanden patroon voor configuratie: @@ -31,7 +36,7 @@ Zorg dat er een `src/SlpModularCms.Api/appsettings.local.json` aanwezig is met d ```json { "ConnectionStrings": { - "DefaultConnection": "Server=127.0.0.1,1433;User ID=sa;Password=MSSQL_s3cr3t_pw!;Database=SlpModularCms;TrustServerCertificate=True;MultipleActiveResultSets=true" + "DefaultConnection": "Server=127.0.0.1;Port=3306;Database=SlpModularCms;Uid=root;Pwd=" } } ``` @@ -286,20 +291,26 @@ Op een shared-hosting omgeving (zoals mijnhostingpartner.nl) is er meestal maar | Pad | Inhoud | |---|---| -| `/` | De publieke website van de klant — **geen onderdeel van deze repo**, wordt los aangeleverd/gedeployed in `wwwroot/` | +| `/` | De publieke website van de klant, uit `wwwroot/web/` — **geen onderdeel van deze repo**. Zie [`WEBSITE_WORKSPACE.md`](WEBSITE_WORKSPACE.md) voor het volledige contract (verplichte structuur, verboden/gereserveerde paden, SPA-fallback, same-origin API-calls, CSP, Umami) | | `/admin` | De CMS admin-UI (`frontend/`), gebouwd met `base: '/admin/'` en automatisch gekopieerd naar `wwwroot/admin/` bij `dotnet publish` | | `/api/v1/...` | Deze API | +| `/health` | Infrastructuur-liveness — zie "Health-check endpoint" hieronder | Beide SPA's krijgen een fallback naar hun eigen `index.html` zodat client-side routes (bijv. `/admin/dashboard`) werken; ontbrekende bestanden (met een extensie, bijv. `/admin/assets/x.js`) blijven gewoon 404'en. Zie `Program.cs` (`MapFallbackToFile`) en `SlpModularCms.Api.csproj` (`BuildAndCopyAdminFrontend`-target). +Ontbreekt `wwwroot/web/` nog (een verse deployment vóórdat een website-workspace er iets in heeft gezet), dan start de applicatie gewoon door en toont `/` een ingebouwde placeholder-pagina — `/admin` en `/api/v1` blijven onverminderd werken. + Voor lokale ontwikkeling verandert er niets: `pnpm dev` blijft op `http://localhost:5173` draaien zonder `/admin`-prefix. ### 1. Build & Publish -Compileer de applicatie voor productie — dit bouwt en kopieert automatisch ook de admin-frontend naar `wwwroot/admin/`: +Publiceren gebeurt via de Gitea Actions-pipeline (`.gitea/workflows/continuous_integration.yaml` → `deploy-scp.yaml`), niet met een losse handmatige `dotnet publish`-stap: elke push naar `master` deployt automatisch naar test, productie alleen via een expliciete `workflow_dispatch` met de `deploy_production`-vlag aan. De pipeline publiceert framework-dependent voor `linux-arm64` en bouwt daarbij automatisch ook de admin-frontend mee naar `wwwroot/admin/`. + +Elke deploy plaatst de nieuwe release in een verse map en wisselt pas daarna atomisch over naar `current` — er is dus nooit een moment waarop de applicatie een halfklare release serveert, en `wwwroot/web/` (de klantsite) staat structureel buiten die verwisselde map, zodat een deploy hem nooit kan raken. Volledige host-setup, rollback-procedure en de exacte deploy-stappen staan in de Operations-documentatie van deze feature, niet hier — dit README beschrijft alleen wat er gebouwd is, niet hoe je het voor het eerst inricht. + +Wil je toch lokaal handmatig publiceren (bijv. om de output te inspecteren): ```powershell -dotnet publish src/SlpModularCms.Api -c Release -o ./publish +dotnet publish src/SlpModularCms.Api -c Release -r linux-arm64 --self-contained false -o ./publish ``` -Kopieer daarna de publieke website van de klant naar `./publish/wwwroot/` (alles behalve de `admin/`-submap, die blijft ongemoeid). ### 2. Runtime Configuratie In productie moeten gevoelige instellingen worden doorgegeven via Environment Variables: @@ -308,9 +319,14 @@ In productie moeten gevoelige instellingen worden doorgegeven via Environment Va - `JwtSettings__Issuer` - `JwtSettings__Audience` - `MasterModule__MasterUrl` — publieke URL van deze master-instantie (alleen relevant als de Master CMS Module actief is) +- `Observability__SentryDsn` — leeg is een normale, ondersteunde staat: Sentry wordt dan overgeslagen en alleen console-logging blijft actief +- `SecurityHeaders__AllowedScriptOrigins__0`, `SecurityHeaders__AllowedConnectOrigins__0`, ... — externe origins die de CSP van `/admin` en `/api/v1` mag toestaan (bijv. de Umami-scripthost en Sentry's ingest-endpoint). Zie `appsettings.json → SecurityHeaders` voor de volledige optiesvorm; een onbekende `PathPolicies`-policy-naam laat de applicatie bij **opstarten** falen, niet pas bij de eerste request ### 2a. Data Protection key ring (Master CMS Module) -De API keys van geregistreerde slaves worden versleuteld opgeslagen met ASP.NET Core Data Protection, standaard met een bestandssysteem-key-store. Voor gecontaineriseerde of multi-instance deployments **moet** een persistente key ring geconfigureerd worden (bijv. `PersistKeysToDbContext` of `PersistKeysToAzureBlobStorage`). Zonder dit worden alle opgeslagen API keys onleesbaar zodra de container herstart, waardoor master↔slave-communicatie stopt totdat instanties opnieuw worden toegevoegd. +De API keys van geregistreerde slaves worden versleuteld opgeslagen met ASP.NET Core Data Protection. De key ring wordt **automatisch** persistent opgeslagen in de database (`PersistKeysToDbContext`, met een expliciete, stabiele applicatie-discriminator) — dit hoeft niet meer apart geconfigureerd te worden. Dit is bewust zo gebouwd omdat de atomische release-switch (zie hierboven) bij elke deploy een nieuwe content-root-map gebruikt: zonder een database-backed key ring zou dat elke keer de key ring weggooien en alle opgeslagen slave-API-keys onleesbaar maken. ### 3. Database -Zorg dat de doeltabel bestaat en de migraties zijn uitgevoerd. In productie kan dit via een CI/CD pipeline worden afgehandeld met `dotnet ef migrations script` of door de applicatie bij startup migraties te laten draaien (indien geconfigureerd). +Migraties op `ApplicationDbContext` (Identity/Core) worden bij het opstarten **automatisch** toegepast (`MigrateCoreDatabase()`), en falen hard (fail-fast) als dat niet lukt — de applicatie start dan bewust niet door. Voor productie-deploys wordt vóór elke deploy een databasebackup genomen; zie de Operations-documentatie van deze feature voor de exacte procedure. Per-module migraties (`Modules.Master`, `Modules.Availability`) worden zoals voorheen automatisch toegepast via `UseModule` (zie "Per-module migraties" hierboven) — daar verandert niets aan. + +### 4. Health-check endpoint +`GET /health` geeft alleen **infrastructuur-liveness** aan ("draait het proces en kan het een basale response geven") — géén databasecheck, om te voorkomen dat het endpoint rood kleurt om redenen die niets met "leeft het proces nog" te maken hebben. Dit is expliciet **iets anders** dan `GET /api/v1/Availability/status` (de CMS-eigen aan/uit-schakelaar) of `GET /api/v1/System/capabilities` — een instantie kan gezond zijn en toch bewust uitgeschakeld, en andersom. `/health` staat op de bypass-lijst van `AvailabilityMiddleware`, zodat een uitgeschakelde instantie dit endpoint altijd blijft beantwoorden. diff --git a/WEBSITE_WORKSPACE.md b/WEBSITE_WORKSPACE.md new file mode 100644 index 0000000..8df6f6e --- /dev/null +++ b/WEBSITE_WORKSPACE.md @@ -0,0 +1,80 @@ +# Website Workspace Contract + +Dit document is voor wie de publieke website bouwt die naast de SlpModularCms-admin draait. Je hoeft +de rest van deze repository niet te lezen om een werkende site te kunnen opleveren — dit contract is +compleet genoeg om zelfstandig te volgen. + +## Doelpad + +De website hoort in **`wwwroot/web/`**, in de root van de gepubliceerde applicatie. Dit pad **moet** +minimaal een `index.html` bevatten. Alles onder `wwwroot/web/` is van jou — de applicatie zelf raakt +deze map nooit aan buiten het plaatsen van de bestanden die je aanlevert, en een CMS-deploy verwijdert +of overschrijft de inhoud nooit (zie "Waarom dit veilig is" hieronder). + +``` +wwwroot/ + web/ ← jouw site komt hier (dit contract) + index.html ← verplicht + assets/... + ... + admin/ ← VERBODEN — dit is de CMS admin-UI, hoort niet bij deze repo +``` + +## Verboden en gereserveerde paden + +**Verboden** — plaats hier nooit bestanden: +- `wwwroot/admin/` — dit is de CMS admin-single-page-app, wordt door deze repository zelf beheerd + en bij elke build overschreven +- De applicatie-root zelf (waar de `.dll`-bestanden van de API staan) + +**Gereserveerd** — deze paden bestaan al en je site mag er niet mee botsen: +- `/admin` — de CMS admin-UI +- `/api/v1` — de backend-API +- `/health` — infrastructuur-liveness-check (zie hieronder — dit is geen CMS-functionaliteit) + +Als jouw site een eigen route of bestand op een van deze paden zou plaatsen, wint de gereserveerde +route altijd. + +## Routing (SPA-fallback) + +Voor paden die **geen bestandsextensie** hebben (bijv. `/over-ons`, `/producten/123`) valt de +applicatie terug op **`wwwroot/web/index.html`** — zo werkt client-side routing (React Router, Vue +Router, of vergelijkbaar) zoals verwacht. Voor paden die er wél uitzien als een bestand (bijv. +`/assets/logo.png`) geldt geen fallback: ontbreekt het bestand, dan krijg je gewoon een `404`, niet +per ongeluk de `index.html`. + +Dit betekent: bouw je een Single Page Application, dan hoeft je routing-configuratie niets speciaals +te doen voor deze server — de fallback wordt door de applicatie zelf verzorgd. + +## De API aanroepen + +Roep `/api/v1/...` aan met **relatieve URL's** (bijv. `fetch('/api/v1/System/capabilities')`). Omdat +je site en de API door hetzelfde proces op dezelfde origin worden geserveerd, is dit een **same-origin +request** — er is geen CORS-configuratie nodig, en er hoeft niets ingesteld te worden om dit te laten +werken. + +## Content-Security-Policy + +Jouw site valt onder het **`Relaxed`**-beleid (de standaardpolicy voor alle paden die niet expliciet +`Strict` zijn — `/admin`, `/api/v1` en `/health` krijgen `Strict`, `/` (jouw site) niet). Dit beleid is +bewust minder streng, zodat je niet gebonden bent aan restricties die voor de CMS-admin gelden maar +die je als website-bouwer nooit zou hoeven kennen. De exacte permissieve/strikte policy-definities +staan in code (`SlpModularCms.Core`), niet in configuratie — je hoeft ze niet zelf te lezen om te +weten dat je site onder het permissieve beleid valt. + +## Umami-analytics insluiten + +Als de instantie analytics gebruikt, wordt het Umami-trackingscript geladen via een build-time +omgevingsvariabele op de **admin**-kant (`VITE_UMAMI_SCRIPT_URL` / `VITE_UMAMI_WEBSITE_ID`) — dat +script wordt dus niet door jouw site zelf ingesloten. Wil je dat jouw website ook gemeten wordt via +dezelfde Umami-instantie, vraag dan de scriptregel en het bijbehorende website-ID op bij wie de CMS +beheert, en neem die regel zelf op in je `index.html` (Umami's standaard `