diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..077109e --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,115 @@ +# GerbilManager CI — Gitea Actions +# ================================= +# STATUS: ENTWURF — inaktiv bis Julian Gitea Actions aktiviert. +# Aktivierung: Gitea -> Repository -> Einstellungen -> Actions -> "Actions aktivieren" +# Anschliessend: Gitea Actions Runner auf der NAS installieren (siehe docs/ops.md). +# +# Was dieser Workflow tut: +# 1. dotnet test (alle xUnit-Projekte) +# 2. npm test + npm run build (Frontend) +# 3. Docker-Images bauen und in die Gitea-Registry pushen +# +# Registry: truenas:13000 (internes Gitea Container Registry) +# Images: +# truenas:13000/gulum/gerbilmanager-api:latest +# truenas:13000/gulum/gerbilmanager-frontend:latest + +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +env: + REGISTRY: truenas:13000 + REGISTRY_OWNER: gulum + DOTNET_VERSION: "10.0.x" + NODE_VERSION: "22" + +jobs: + test-backend: + name: Backend Tests (.NET) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: .NET SDK einrichten + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Pakete wiederherstellen + run: dotnet restore GerbilManager.slnx + + - name: Build + run: dotnet build GerbilManager.slnx --no-restore -c Release + + - name: Tests ausfuehren + run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal" + + test-frontend: + name: Frontend Tests (Node/Vite) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Node.js einrichten + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + cache-dependency-path: gerbil-manager-web/package-lock.json + + - name: Abhaengigkeiten installieren + run: npm ci + working-directory: gerbil-manager-web + + - name: Tests ausfuehren + run: npm test + working-directory: gerbil-manager-web + + - name: Produktionsbuild pruefen + run: npm run build + working-directory: gerbil-manager-web + + build-and-push: + name: Docker Build & Push + runs-on: ubuntu-latest + needs: [test-backend, test-frontend] + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + steps: + - uses: actions/checkout@v4 + + - name: Bei Gitea Registry anmelden + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Docker Buildx einrichten + uses: docker/setup-buildx-action@v3 + + - name: API-Image bauen und pushen + uses: docker/build-push-action@v5 + with: + context: . + file: GerbilManagerWebAPI/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-api:latest + ${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-api:${{ github.sha }} + + - name: Frontend-Image bauen und pushen + uses: docker/build-push-action@v5 + with: + context: . + file: gerbil-manager-web/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-frontend:latest + ${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-frontend:${{ github.sha }} diff --git a/GerbilManager.ServiceDefaults/Extensions.cs b/GerbilManager.ServiceDefaults/Extensions.cs index 97642a1..2a36702 100644 --- a/GerbilManager.ServiceDefaults/Extensions.cs +++ b/GerbilManager.ServiceDefaults/Extensions.cs @@ -108,19 +108,13 @@ public static class Extensions public static WebApplication MapDefaultEndpoints(this WebApplication app) { - // Adding health checks endpoints to applications in non-development environments has security implications. - // See https://aka.ms/aspire/healthchecks for details before enabling these endpoints in non-development environments. - if (app.Environment.IsDevelopment()) + // Health checks always exposed — compose healthchecks and Aspire both rely on them. + // Trusted home LAN only (no public exposure, no auth — see board constraint). + app.MapHealthChecks(HealthEndpointPath); + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions { - // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks(HealthEndpointPath); - - // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions - { - Predicate = r => r.Tags.Contains("live") - }); - } + Predicate = r => r.Tags.Contains("live") + }); return app; } diff --git a/GerbilManagerWebAPI/Dockerfile b/GerbilManagerWebAPI/Dockerfile index 1268601..3f78f6e 100644 --- a/GerbilManagerWebAPI/Dockerfile +++ b/GerbilManagerWebAPI/Dockerfile @@ -1,20 +1,23 @@ -FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base -WORKDIR /app -EXPOSE 80 +# Build context: repo root (includes GerbilManager.ServiceDefaults). +# docker build -f GerbilManagerWebAPI/Dockerfile -t gerbilmanager-api . -ENV ASPNETCORE_URLS=http://+:80 - -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:7.0 AS build -ARG configuration=Release +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src -COPY ["GerbilManagerWebAPI/GerbilManagerWebAPI.csproj", "GerbilManagerWebAPI/"] -RUN dotnet restore "GerbilManagerWebAPI/GerbilManagerWebAPI.csproj" -COPY . . -WORKDIR "/src/GerbilManagerWebAPI" -ARG configuration=Release -RUN dotnet publish "GerbilManagerWebAPI.csproj" -c $configuration -o /app/publish /p:UseAppHost=false -FROM base AS final +# Restore: project files only for layer-cache efficiency. +COPY GerbilManager.ServiceDefaults/GerbilManager.ServiceDefaults.csproj GerbilManager.ServiceDefaults/ +COPY GerbilManagerWebAPI/GerbilManagerWebAPI.csproj GerbilManagerWebAPI/ +RUN dotnet restore GerbilManagerWebAPI/GerbilManagerWebAPI.csproj + +# Full source copy + publish. +COPY GerbilManager.ServiceDefaults/ GerbilManager.ServiceDefaults/ +COPY GerbilManagerWebAPI/ GerbilManagerWebAPI/ +WORKDIR /src/GerbilManagerWebAPI +RUN dotnet publish GerbilManagerWebAPI.csproj -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final WORKDIR /app +# .NET 10 containers default to ASPNETCORE_HTTP_PORTS=8080 bound on all interfaces. +EXPOSE 8080 COPY --from=build /app/publish . ENTRYPOINT ["dotnet", "GerbilManagerWebAPI.dll"] diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index c960f6e..6c28261 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -50,12 +50,9 @@ app.MapDefaultEndpoints(); app.MapOpenApi(); app.MapScalarApiReference(); -if (app.Environment.IsDevelopment()) -{ - // Aspire provisions an empty database in dev — bring the schema up to date. - using var scope = app.Services.CreateScope(); +// Apply EF migrations at startup (no-op if schema is current; safe for single-instance deploy). +using (var scope = app.Services.CreateScope()) scope.ServiceProvider.GetRequiredService().Database.Migrate(); -} app.UseCors(LanCorsPolicy); diff --git a/deploy/truenas/.env.example b/deploy/truenas/.env.example new file mode 100644 index 0000000..f005d37 --- /dev/null +++ b/deploy/truenas/.env.example @@ -0,0 +1,23 @@ +# GerbilManager Produktionskonfiguration +# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start. + +# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen) +POSTGRES_PASSWORD=aendere_mich_bitte + +# Externer Port fuer das Frontend (Standard: 80) +PORT=80 + +# Gitea Container Registry (Standard: truenas:13000/gulum) +REGISTRY=truenas:13000/gulum +TAG=latest + +# NAS-Dataset-Pfade (TrueNAS SCALE: /mnt//) +PGDATA_PATH=/mnt/SSD/gerbil/pgdata +PHOTOS_PATH=/mnt/SSD/gerbil/photos +BACKUPS_PATH=/mnt/SSD/gerbil/backups + +# Backup-Rotation: Anzahl Tage (Standard: 7) +BACKUP_KEEP_DAYS=7 + +# Claude-API-Key fuer KI-Verkaufstext (FEAT-12a; leer lassen wenn nicht vorhanden) +ANTHROPIC_API_KEY= diff --git a/deploy/truenas/compose.yaml b/deploy/truenas/compose.yaml new file mode 100644 index 0000000..6810c8b --- /dev/null +++ b/deploy/truenas/compose.yaml @@ -0,0 +1,109 @@ +# GerbilManager — TrueNAS Custom Application +# ============================================== +# Vor dem ersten Start: +# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte. +# 2. Lege die Dataset-Pfade auf der NAS an (pgdata, photos, backups). +# 3. docker compose -f deploy/truenas/compose.yaml up -d +# +# Zugriff: http://:${PORT:-80} +# API-Doku: http://:${PORT:-80}/scalar + +services: + + # --- PostgreSQL-Datenbank --- + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + POSTGRES_DB: gerbilmanager + POSTGRES_USER: postgres + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d gerbilmanager"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + # --- .NET API (GerbilManagerWebAPI) --- + api: + image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-api:${TAG:-latest}" + build: + context: ../.. + dockerfile: GerbilManagerWebAPI/Dockerfile + restart: unless-stopped + environment: + ASPNETCORE_ENVIRONMENT: Production + # Verbindung zur Postgres-DB im selben Compose-Netz + ConnectionStrings__gerbilmanager: "Host=db;Port=5432;Database=gerbilmanager;Username=postgres;Password=${POSTGRES_PASSWORD}" + # Speicherort der hochgeladenen Fotos (NAS-Dataset gemounted unter /data/photos) + Photos__RootPath: /data/photos + # KI-Verkaufstext (FEAT-12a stub; leer lassen wenn kein Key vorhanden) + ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" + volumes: + - photos:/data/photos + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 90s + + # --- nginx Frontend (React SPA + API-Proxy) --- + frontend: + image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-frontend:${TAG:-latest}" + build: + context: ../.. + dockerfile: gerbil-manager-web/Dockerfile + restart: unless-stopped + ports: + - "${PORT:-80}:80" + depends_on: + api: + condition: service_healthy + + # --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) --- + backup: + image: postgres:17-alpine + restart: unless-stopped + environment: + PGPASSWORD: "${POSTGRES_PASSWORD}" + POSTGRES_HOST: db + POSTGRES_USER: postgres + POSTGRES_DB: gerbilmanager + BACKUP_KEEP_DAYS: "${BACKUP_KEEP_DAYS:-7}" + volumes: + - photos:/data/photos:ro + - backups:/backups + - ./scripts:/scripts:ro + depends_on: + db: + condition: service_healthy + entrypoint: ["/bin/sh", "/scripts/entrypoint.sh"] + +volumes: + # NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren). + # TrueNAS: Dataset-Pfad z.B. /mnt/SSD/gerbil/pgdata + pgdata: + driver: local + driver_opts: + type: none + o: bind + device: "${PGDATA_PATH:-/mnt/gerbil/pgdata}" + photos: + driver: local + driver_opts: + type: none + o: bind + device: "${PHOTOS_PATH:-/mnt/gerbil/photos}" + backups: + driver: local + driver_opts: + type: none + o: bind + device: "${BACKUPS_PATH:-/mnt/gerbil/backups}" diff --git a/deploy/truenas/scripts/backup.sh b/deploy/truenas/scripts/backup.sh new file mode 100644 index 0000000..b05989b --- /dev/null +++ b/deploy/truenas/scripts/backup.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# GerbilManager Backup-Skript (Container-Welt) +# Laeuft als Cron-Job im backup-Sidecar-Container. +# Volumes: /data/photos (read-only), /backups (read-write) +# Umgebungsvariablen: PGPASSWORD, POSTGRES_HOST, POSTGRES_USER, POSTGRES_DB, BACKUP_KEEP_DAYS +set -e + +TIMESTAMP=$(date +%Y-%m-%d_%H-%M) +BACKUP_DIR="/backups/$TIMESTAMP" +DB_DUMP="$BACKUP_DIR/gerbilmanager_${TIMESTAMP}.sql" +PHOTO_ARCHIVE="$BACKUP_DIR/photos_${TIMESTAMP}.tar.gz" +KEEP_DAYS="${BACKUP_KEEP_DAYS:-7}" +HOST="${POSTGRES_HOST:-db}" +USER="${POSTGRES_USER:-postgres}" +DB="${POSTGRES_DB:-gerbilmanager}" + +log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1"; } + +log "=== Backup gestartet ($TIMESTAMP) ===" +mkdir -p "$BACKUP_DIR" + +# --- Datenbank-Dump --- +log "Erstelle pg_dump fuer $DB@$HOST..." +if ! pg_dump -h "$HOST" -U "$USER" -d "$DB" --clean --if-exists --format=plain \ + > "$DB_DUMP" 2>/tmp/pg_err; then + log "FEHLER bei pg_dump: $(cat /tmp/pg_err)" + exit 1 +fi +DUMP_SIZE=$(du -k "$DB_DUMP" | cut -f1) +log "Dump erstellt: $DB_DUMP (${DUMP_SIZE} KB)" + +# Validierung: mindestens CREATE TABLE muss vorhanden sein +if ! grep -q "CREATE TABLE" "$DB_DUMP" 2>/dev/null; then + log "WARNUNG: Dump sieht ungueltig aus -- bitte $DB_DUMP pruefen" +fi + +# --- Fotos archivieren --- +if [ -d /data/photos ] && [ "$(ls -A /data/photos 2>/dev/null)" ]; then + PHOTO_COUNT=$(find /data/photos -type f | wc -l) + log "Archiviere $PHOTO_COUNT Fotos..." + tar czf "$PHOTO_ARCHIVE" -C /data photos/ 2>/tmp/tar_err || { + log "WARNUNG: Foto-Archiv fehlgeschlagen: $(cat /tmp/tar_err)" + } + PHOTO_SIZE=$(du -k "$PHOTO_ARCHIVE" 2>/dev/null | cut -f1) + log "Foto-Archiv erstellt: $PHOTO_ARCHIVE (${PHOTO_SIZE} KB)" +else + log "Keine Fotos gefunden -- Foto-Backup uebersprungen" +fi + +# --- Rotation: alte Backups loeschen --- +log "Rotiere Backups (behalte $KEEP_DAYS Tage)..." +find /backups -maxdepth 1 -type d -name '????-??-??_??-??' \ + -mtime "+$KEEP_DAYS" -exec rm -rf {} + 2>/dev/null || true +REMAINING=$(find /backups -maxdepth 1 -type d -name '????-??-??_??-??' | wc -l) +log "Backup abgeschlossen. Vorhandene Sicherungen: $REMAINING" +log "=== Ende ===" diff --git a/deploy/truenas/scripts/entrypoint.sh b/deploy/truenas/scripts/entrypoint.sh new file mode 100644 index 0000000..fafefc9 --- /dev/null +++ b/deploy/truenas/scripts/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Backup-Sidecar Entrypoint +# Installiert den Cron-Job und startet crond im Vordergrund. +set -e + +# Cron-Job: taeglich um 03:00 Uhr +CRON_SCHEDULE="${BACKUP_CRON:-0 3 * * *}" +echo "$CRON_SCHEDULE /bin/sh /scripts/backup.sh >> /backups/backup.log 2>&1" > /etc/crontabs/root + +echo "[$(date)] Backup-Sidecar gestartet. Naechste Sicherung: $CRON_SCHEDULE" +echo "[$(date)] Backup-Verzeichnis: /backups Fotos: /data/photos" + +# Ersten Backup-Lauf direkt beim Start ausfuehren (optional, auskommentieren wenn unerwuenscht) +# /bin/sh /scripts/backup.sh + +exec crond -f -l 6 diff --git a/deploy/truenas/scripts/restore.sh b/deploy/truenas/scripts/restore.sh new file mode 100644 index 0000000..ab63f31 --- /dev/null +++ b/deploy/truenas/scripts/restore.sh @@ -0,0 +1,77 @@ +#!/bin/sh +# GerbilManager Wiederherstellung (Container-Welt) +# Aufruf: docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/restore.sh +# Optional: .../restore.sh 2026-06-06_03-00 (bestimmtes Backup) +# +# WARNUNG: Ueberschreibt alle aktuellen Datenbankdaten und Fotos! +set -e + +HOST="${POSTGRES_HOST:-db}" +USER="${POSTGRES_USER:-postgres}" +DB="${POSTGRES_DB:-gerbilmanager}" +BACKUP_ROOT=/backups + +log() { echo "[$(date +'%H:%M:%S')] $1"; } + +# --- Backup-Verzeichnis bestimmen --- +if [ -n "$1" ]; then + BACKUP_DIR="$BACKUP_ROOT/$1" +else + BACKUP_DIR=$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name '????-??-??_??-??' \ + | sort -r | head -1) +fi + +if [ -z "$BACKUP_DIR" ] || [ ! -d "$BACKUP_DIR" ]; then + log "FEHLER: Backup-Verzeichnis nicht gefunden: ${BACKUP_DIR:-$BACKUP_ROOT}" + log "Verfuegbare Backups:" + find "$BACKUP_ROOT" -maxdepth 1 -type d -name '????-??-??_??-??' | sort -r + exit 1 +fi + +SQL_FILE=$(find "$BACKUP_DIR" -name '*.sql' | head -1) +PHOTO_ARCHIVE=$(find "$BACKUP_DIR" -name '*.tar.gz' | head -1) + +if [ -z "$SQL_FILE" ]; then + log "FEHLER: Kein SQL-Dump in $BACKUP_DIR gefunden." + exit 1 +fi + +log "Wiederherstellung aus: $BACKUP_DIR" +log " Datenbank : $(basename $SQL_FILE)" +[ -n "$PHOTO_ARCHIVE" ] && log " Fotos : $(basename $PHOTO_ARCHIVE)" + +# --- Bestaetigung (interaktiv; mit -f ueberspringen) --- +if [ "$1" != "-f" ] && [ "$2" != "-f" ]; then + printf "WARNUNG: Aktuelle Daten werden unwiderruflich ueberschrieben!\nFortfahren? (ja/nein): " + read -r CONFIRM + if [ "$CONFIRM" != "ja" ]; then + log "Abgebrochen." + exit 0 + fi +fi + +# --- Datenbankwiederherstellung --- +log "Trenne laufende DB-Verbindungen..." +psql -h "$HOST" -U "$USER" -d postgres -c \ + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='$DB' AND pid<>pg_backend_pid();" \ + > /dev/null 2>&1 || true + +log "Spiele SQL-Dump ein..." +if ! psql -h "$HOST" -U "$USER" -d "$DB" < "$SQL_FILE" > /tmp/restore_out 2>&1; then + log "FEHLER bei der DB-Wiederherstellung:" + cat /tmp/restore_out + exit 1 +fi +log "Datenbankwiederherstellung erfolgreich." + +# --- Fotos wiederherstellen --- +if [ -n "$PHOTO_ARCHIVE" ]; then + log "Stelle Fotos wieder her..." + rm -rf /data/photos/* 2>/dev/null || true + tar xzf "$PHOTO_ARCHIVE" -C /data + PHOTO_COUNT=$(find /data/photos -type f 2>/dev/null | wc -l) + log "Fotos wiederhergestellt: $PHOTO_COUNT Dateien." +fi + +log "Wiederherstellung abgeschlossen." +log "Starte API-Container neu: docker compose ... restart api" diff --git a/docs/ops.md b/docs/ops.md new file mode 100644 index 0000000..9c84753 --- /dev/null +++ b/docs/ops.md @@ -0,0 +1,381 @@ +# GerbilManager — Betriebsanleitung (TrueNAS) + +> Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung). +> Bookmark für die Ehefrau: **http://\/** (z. B. http://truenas/) + +--- + +## Inhaltsverzeichnis + +1. [Übersicht & Architektur](#1-übersicht--architektur) +2. [Voraussetzungen](#2-voraussetzungen) +3. [Erstinstallation auf TrueNAS](#3-erstinstallation-auf-truenas) +4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren) +5. [Backup & Wiederherstellung](#5-backup--wiederherstellung) +6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung) +7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions) +8. [Offene Fragen (bitte beantworten)](#8-offene-fragen) +9. [Fehlerbehebung](#9-fehlerbehebung) + +--- + +## 1. Übersicht & Architektur + +``` +Browser / Handy + | HTTP :80 + v + ┌──────────────────┐ + │ frontend (nginx) │ statisches React-SPA + Reverse-Proxy + └──────┬───────────┘ + │ /api/* → http://api:8080/* + │ /scalar → http://api:8080/scalar + v + ┌──────────────────┐ + │ api (.NET 10) │ GerbilManagerWebAPI, Minimal API + └──────┬───────────┘ + │ ConnectionStrings__gerbilmanager + v + ┌──────────────────┐ ┌──────────────────────┐ + │ db (Postgres 17)│ │ backup (Sidecar) │ + └──────────────────┘ │ pg_dump + tar + cron │ + │ └──────────────────────┘ + └─ pgdata-Volume (NAS-Dataset) + photos-Volume (NAS-Dataset) + backups-Volume (NAS-Dataset) +``` + +**Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`). +Alles andere läuft intern im Docker-Netz. + +--- + +## 2. Voraussetzungen + +| Was | Details | +|-----|---------| +| TrueNAS SCALE | Electric Eel 24.10+ (native Docker Custom Apps) | +| Gitea | http://truenas:13000 — Repository `Gulum/GerbilManager` | +| Docker | bereits auf TrueNAS vorhanden (Custom Apps nutzen es) | +| Datasets | Drei ZFS-Datasets anlegen (siehe Schritt 3) | + +--- + +## 3. Erstinstallation auf TrueNAS + +### 3.1 ZFS-Datasets anlegen + +In TrueNAS → **Datasets** → **Dataset hinzufügen** (je einmal wiederholen): + +| Dataset-Name | Empfohlener Pfad | Verwendung | +|---|---|---| +| `gerbil/pgdata` | `/mnt/SSD/gerbil/pgdata` | Postgres-Datenbankdateien | +| `gerbil/photos` | `/mnt/SSD/gerbil/photos` | Hochgeladene Tierfotos | +| `gerbil/backups` | `/mnt/SSD/gerbil/backups` | Tägliche Backups | + +> **Tipp:** Passe die Pool-Bezeichnung (`SSD`) an deinen tatsächlichen Pool an. + +### 3.2 Repository klonen + +```bash +# SSH in TrueNAS oder lokale Shell +git clone http://truenas:13000/Gulum/GerbilManager.git /opt/gerbilmanager +cd /opt/gerbilmanager +``` + +### 3.3 Konfiguration anlegen + +```bash +cp deploy/truenas/.env.example deploy/truenas/.env +# Jetzt .env bearbeiten: +nano deploy/truenas/.env +``` + +Mindestens setzen: +- `POSTGRES_PASSWORD` — sicheres Passwort (mind. 20 Zeichen) +- `PGDATA_PATH`, `PHOTOS_PATH`, `BACKUPS_PATH` — tatsächliche Dataset-Pfade + +### 3.4 Images bauen und App starten + +```bash +cd /opt/gerbilmanager +docker compose -f deploy/truenas/compose.yaml build +docker compose -f deploy/truenas/compose.yaml up -d +``` + +Erster Start dauert ca. 2–3 Minuten (Postgres-Init + EF-Migrationen). + +### 3.5 Prüfen + +```bash +# Alle Container laufen? +docker compose -f deploy/truenas/compose.yaml ps + +# API-Healthcheck +curl http://localhost/api/health + +# Webapp im Browser +http:/// +``` + +--- + +## 4. App starten / stoppen / aktualisieren + +### Starten + +```bash +docker compose -f deploy/truenas/compose.yaml up -d +``` + +### Stoppen + +```bash +docker compose -f deploy/truenas/compose.yaml down +``` + +### Aktualisieren (nach `git push` auf main) + +```bash +cd /opt/gerbilmanager +git pull +docker compose -f deploy/truenas/compose.yaml build +docker compose -f deploy/truenas/compose.yaml up -d +``` + +> EF-Migrationen laufen automatisch beim API-Start — kein manueller Schritt nötig. + +### Mit Gitea CI (wenn Actions aktiviert) + +Push auf `main` triggert automatisch Build → Test → Image-Push. +Danach auf der NAS: +```bash +docker compose -f deploy/truenas/compose.yaml pull +docker compose -f deploy/truenas/compose.yaml up -d +``` + +--- + +## 5. Backup & Wiederherstellung + +### Automatisches Backup + +Der `backup`-Sidecar-Container läuft dauerhaft und sichert täglich um **03:00 Uhr**: +- Kompletter `pg_dump` der Datenbank als `.sql` +- Komprimiertes Foto-Archiv als `.tar.gz` +- Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht + +Backups liegen unter: `${BACKUPS_PATH}/YYYY-MM-DD_HH-MM/` + +``` +/mnt/SSD/gerbil/backups/ + 2026-06-06_03-00/ + gerbilmanager_2026-06-06_03-00.sql (Datenbank) + photos_2026-06-06_03-00.tar.gz (Fotos) + backup.log (Protokoll) +``` + +### Manuelles Backup auslösen + +```bash +docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backup.sh +``` + +### Backup-Log prüfen + +```bash +tail -50 /mnt/SSD/gerbil/backups/backup.log +``` + +### Wiederherstellung — Runbook + +> **WARNUNG:** Alle aktuellen Daten werden überschrieben! + +**Schritt 1:** App stoppen (optional, aber empfohlen) +```bash +docker compose -f deploy/truenas/compose.yaml stop api frontend +``` + +**Schritt 2:** Restore ausführen +```bash +# Neuestes Backup wiederherstellen: +docker compose -f deploy/truenas/compose.yaml exec backup \ + /bin/sh /scripts/restore.sh + +# Bestimmtes Backup wiederherstellen: +docker compose -f deploy/truenas/compose.yaml exec backup \ + /bin/sh /scripts/restore.sh 2026-06-05_03-00 +``` + +**Schritt 3:** API neu starten +```bash +docker compose -f deploy/truenas/compose.yaml start api frontend +``` + +**Schritt 4:** Prüfen +```bash +curl http://localhost/api/color-varieties | grep -c '"id"' +# Erwarteter Wert: 73 +``` + +### Restore-Nachweis (Round-Trip-Test) + +Protokoll vom Test auf lokalem Aspire-Postgres (Vorgänger-Instanz, 2026-06-06 07:09): +``` +73 ColorVarieties vorhanden +→ DELETE 12 Zeilen → 61 verbleibend +→ pg_restore eingespielt +→ 73 ColorVarieties bestätigt +Exit-Code: 0 +``` +Die Container-Restore-Skripte nutzen dieselbe `psql < dump.sql` Logik. +**Erster echter Test auf TrueNAS:** nach Erstinstallation bitte ausführen und das Ergebnis notieren. + +--- + +## 6. ZFS-Snapshot-Schichtung + +ZFS-Snapshots ergänzen die pg_dump-Backups als zweite Sicherungsebene. +Sie schützen vor versehentlichem Datenverlust auf Dataset-Ebene. + +### Empfohlene Snapshot-Konfiguration + +In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots** → **Regelmäßige Snapshots**: + +| Dataset | Häufigkeit | Aufbewahrung | +|---------|-----------|--------------| +| `gerbil/photos` | Stündlich | 24 Stunden | +| `gerbil/photos` | Täglich | 30 Tage | +| `gerbil/pgdata` | Stündlich | 24 Stunden | +| `gerbil/pgdata` | Täglich | 30 Tage | +| `gerbil/backups` | Täglich | 90 Tage | + +> **Hinweis:** `pgdata` enthält Live-Postgres-Dateien. ZFS-Snapshots davon sind crash-konsistent, +> aber **nicht** application-konsistent — für einen sauberen DB-Restore immer den `pg_dump` verwenden, +> nicht den ZFS-Snapshot von `pgdata`. + +### Snapshot manuell erstellen (z. B. vor Update) + +```bash +# TrueNAS CLI +zfs snapshot SSD/gerbil/photos@vor-update-$(date +%Y%m%d) +zfs snapshot SSD/gerbil/backups@vor-update-$(date +%Y%m%d) +``` + +### Aus ZFS-Snapshot wiederherstellen (Fotos) + +```bash +# Snapshot auflisten +zfs list -t snapshot SSD/gerbil/photos + +# Datei aus Snapshot kopieren +cp /mnt/SSD/gerbil/photos/.zfs/snapshot//datei.jpg /mnt/SSD/gerbil/photos/ +``` + +--- + +## 7. CI/CD via Gitea Actions + +Der Workflow `.gitea/workflows/ci.yml` ist als **Entwurf vorhanden, aber inaktiv**. + +### Aktivierung + +1. **Gitea Actions aktivieren:** + Gitea → Repository `GerbilManager` → Einstellungen → Actions → "Actions aktivieren" + +2. **Gitea Actions Runner installieren** (auf TrueNAS oder einem separaten Gerät): + ```bash + # Gitea Runner Container (einfachste Variante für TrueNAS) + docker run -d --name gitea-runner \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v /opt/gitea-runner:/data \ + -e GITEA_INSTANCE_URL=http://truenas:13000 \ + -e GITEA_RUNNER_REGISTRATION_TOKEN= \ + gitea/act_runner:latest + ``` + Token: Gitea → Admin → Actions → Runner → "Runner hinzufügen" + +3. **Registry-Secrets konfigurieren:** + Gitea → Repository → Einstellungen → Secrets: + - `REGISTRY_USER` — dein Gitea-Benutzername + - `REGISTRY_TOKEN` — Gitea Access Token mit `package:write`-Berechtigung + +### Workflow nach Aktivierung + +``` +git push origin main + → Gitea Actions: dotnet test + npm test + npm run build + → Bei Erfolg: docker build + push zu truenas:13000/gulum/ + → Auf NAS: docker compose pull + up -d +``` + +--- + +## 8. Offene Fragen + +Bitte beantworte diese Fragen, damit das Setup fertiggestellt werden kann: + +| # | Frage | Auswirkung | +|---|-------|-----------| +| 1 | **TrueNAS SCALE Version?** Electric Eel 24.10 hat native Docker Custom Apps. Ältere Versionen nutzen Kubernetes. | Bestimmt ob `docker compose` direkt läuft | +| 2 | **Gitea Actions verfügbar/aktivierbar?** | CI/CD-Workflow aktiv oder nur manuell deployen | +| 3 | **Eigener Postgres-Container (empfohlen) oder vorhandene NAS-Postgres-App?** | Isolation vs. geteilte Instanz | +| 4 | **Genaue Dataset-Pfade?** Poolname und Pfad-Präfix | `.env`-Konfiguration | +| 5 | **Port-Wahl?** Standard 80 — frei auf der NAS? | `PORT`-Wert in `.env` | + +--- + +## 9. Fehlerbehebung + +### App startet nicht + +```bash +# Logs aller Container +docker compose -f deploy/truenas/compose.yaml logs + +# Logs eines bestimmten Containers +docker compose -f deploy/truenas/compose.yaml logs api +docker compose -f deploy/truenas/compose.yaml logs db +``` + +### Datenbank nicht erreichbar + +```bash +# DB-Container läuft? +docker compose -f deploy/truenas/compose.yaml ps db + +# Verbindung testen +docker compose -f deploy/truenas/compose.yaml exec db \ + psql -U postgres -d gerbilmanager -c "\dt" +``` + +### Backup-Fehler + +```bash +# Backup-Log prüfen +cat /mnt/SSD/gerbil/backups/backup.log | tail -30 + +# Backup manuell starten (mit Fehlerausgabe) +docker compose -f deploy/truenas/compose.yaml exec backup \ + /bin/sh /scripts/backup.sh +``` + +### Fotos werden nicht angezeigt + +Prüfe ob das `photos`-Volume korrekt gemounted ist: +```bash +docker compose -f deploy/truenas/compose.yaml exec api ls /data/photos +``` + +### Container-Status zurücksetzen (Neustart) + +```bash +docker compose -f deploy/truenas/compose.yaml restart api +``` + +### Kompletter Neustart (Daten bleiben erhalten) + +```bash +docker compose -f deploy/truenas/compose.yaml down +docker compose -f deploy/truenas/compose.yaml up -d +``` diff --git a/gerbil-manager-web/Dockerfile b/gerbil-manager-web/Dockerfile index 6c758fa..722b55e 100644 --- a/gerbil-manager-web/Dockerfile +++ b/gerbil-manager-web/Dockerfile @@ -1,25 +1,48 @@ -# Build-Stufe: Vite-Produktionsbuild +# Build context: repo root (Dockerfile liest aus gerbil-manager-web/). +# docker build -f gerbil-manager-web/Dockerfile -t gerbilmanager-frontend . +# +# Produktion: kein VITE_API_BASE_URL nötig — client.ts fällt auf '/api' zurück, +# nginx proxyt /api/* transparent zum API-Container (keine Host-Kopplung zur Build-Zeit). + FROM node:22-alpine AS build WORKDIR /app COPY gerbil-manager-web/package.json gerbil-manager-web/package-lock.json ./ RUN npm ci COPY gerbil-manager-web/ . -# Basis-URL der API (zur Build-Zeit eingebettet; Browser erreicht die API über den Host) -ARG VITE_API_BASE_URL=http://localhost:80 -ENV VITE_API_BASE_URL=$VITE_API_BASE_URL RUN npm run build -# Laufzeit-Stufe: statische Auslieferung über nginx FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html -# SPA-Fallback: alle Pfade auf index.html umleiten (react-router) + +# nginx: SPA-Fallback + Reverse-Proxy für /api, /scalar, /openapi zum API-Container. RUN printf 'server {\n\ - listen 3000;\n\ + listen 80;\n\ root /usr/share/nginx/html;\n\ index index.html;\n\ +\n\ + # API-Aufrufe: /api/* -> API-Container /* (Praefix wird entfernt)\n\ + location /api/ {\n\ + proxy_pass http://api:8080/;\n\ + proxy_set_header Host $host;\n\ + proxy_set_header X-Real-IP $remote_addr;\n\ + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\ + }\n\ +\n\ + # Scalar API-Doku und OpenAPI-Spec direkt vom Backend\n\ + location /scalar {\n\ + proxy_pass http://api:8080/scalar;\n\ + proxy_set_header Host $host;\n\ + }\n\ + location /openapi/ {\n\ + proxy_pass http://api:8080/openapi/;\n\ + proxy_set_header Host $host;\n\ + }\n\ +\n\ + # SPA-Fallback: alle anderen Pfade laden index.html (React Router)\n\ location / {\n\ try_files $uri $uri/ /index.html;\n\ }\n\ }\n' > /etc/nginx/conf.d/default.conf -EXPOSE 3000 + +EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] diff --git a/gerbil-manager-web/src/api/client.ts b/gerbil-manager-web/src/api/client.ts index 8f6eab6..08fc51d 100644 --- a/gerbil-manager-web/src/api/client.ts +++ b/gerbil-manager-web/src/api/client.ts @@ -2,11 +2,12 @@ import { de } from '../strings/de' /** * Basis-URL der GerbilManagerWebAPI. - * Per VITE_API_BASE_URL konfigurierbar (z. B. in Docker / Aspire); - * Standard ist das lokale Dev-Profil der API (launchSettings.json, Profil "http"). + * Dev (Vite dev server / e2e mock): absolute URL, damit Playwright-Route-Interception greift. + * Produktion (Vite build → nginx): relativer Pfad /api; nginx proxyt zum API-Container. + * Aspire (VITE_API_BASE_URL gesetzt): überschreibt immer (LAN-IP + Port für Handy-Zugriff). */ export const API_BASE_URL: string = - import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5179' + import.meta.env.VITE_API_BASE_URL ?? (import.meta.env.PROD ? '/api' : 'http://localhost:5179') export class ApiError extends Error { readonly status: number | null