Merge feature/ops-1: TrueNAS deployment (compose + backup sidecar + nginx proxy + prod Dockerfiles), Gitea CI draft, German ops guide [god-QA: 18/18+47/47+e2e 48/48]
This commit is contained in:
115
.gitea/workflows/ci.yml
Normal file
115
.gitea/workflows/ci.yml
Normal file
@@ -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 }}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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<ApplicationContext>().Database.Migrate();
|
||||
}
|
||||
|
||||
app.UseCors(LanCorsPolicy);
|
||||
|
||||
|
||||
23
deploy/truenas/.env.example
Normal file
23
deploy/truenas/.env.example
Normal file
@@ -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/<Pool>/<Dataset>)
|
||||
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=
|
||||
109
deploy/truenas/compose.yaml
Normal file
109
deploy/truenas/compose.yaml
Normal file
@@ -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://<NAS-IP>:${PORT:-80}
|
||||
# API-Doku: http://<NAS-IP>:${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}"
|
||||
56
deploy/truenas/scripts/backup.sh
Normal file
56
deploy/truenas/scripts/backup.sh
Normal file
@@ -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 ==="
|
||||
16
deploy/truenas/scripts/entrypoint.sh
Normal file
16
deploy/truenas/scripts/entrypoint.sh
Normal file
@@ -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
|
||||
77
deploy/truenas/scripts/restore.sh
Normal file
77
deploy/truenas/scripts/restore.sh
Normal file
@@ -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"
|
||||
381
docs/ops.md
Normal file
381
docs/ops.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# GerbilManager — Betriebsanleitung (TrueNAS)
|
||||
|
||||
> Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung).
|
||||
> Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (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://<NAS-IP>/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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/<NAME>/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=<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
|
||||
```
|
||||
@@ -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;"]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user