From 3f7032260b253ff0d833e3f1e920a8b7cd478a4d Mon Sep 17 00:00:00 2001 From: BreakPilot Dev Date: Sun, 8 Feb 2026 22:45:03 -0800 Subject: [PATCH] feat(infrastructure): Add night-scheduler for automated Docker service shutdown Implement dashboard-controlled night mode for automatic Docker service management. Services are stopped at a configurable time (default 22:00) and restarted in the morning (default 06:00). Features: - Python/FastAPI scheduler service (port 8096) - Admin dashboard API routes at /api/admin/night-mode - Toggle for enable/disable night mode - Time picker for shutdown and startup times - Manual start/stop buttons for immediate actions - Excluded services (night-scheduler, nginx always run) Files added: - night-scheduler/scheduler.py - Main scheduler with REST API - night-scheduler/Dockerfile - Container with Docker CLI - night-scheduler/requirements.txt - FastAPI, Uvicorn, Pydantic - night-scheduler/tests/test_scheduler.py - Unit tests - admin-v2/app/api/admin/night-mode/* - API proxy routes - .claude/rules/night-scheduler.md - Developer documentation Co-Authored-By: Claude Opus 4.5 --- .claude/rules/night-scheduler.md | 297 +++ .../app/api/admin/night-mode/execute/route.ts | 50 + admin-v2/app/api/admin/night-mode/route.ts | 77 + .../api/admin/night-mode/services/route.ts | 41 + docker-compose.yml | 1806 +++++++++++++++++ night-scheduler/Dockerfile | 30 + night-scheduler/config/night-mode.json | 8 + night-scheduler/requirements.txt | 8 + night-scheduler/scheduler.py | 389 ++++ night-scheduler/tests/__init__.py | 1 + night-scheduler/tests/test_scheduler.py | 342 ++++ 11 files changed, 3049 insertions(+) create mode 100644 .claude/rules/night-scheduler.md create mode 100644 admin-v2/app/api/admin/night-mode/execute/route.ts create mode 100644 admin-v2/app/api/admin/night-mode/route.ts create mode 100644 admin-v2/app/api/admin/night-mode/services/route.ts create mode 100644 docker-compose.yml create mode 100644 night-scheduler/Dockerfile create mode 100644 night-scheduler/config/night-mode.json create mode 100644 night-scheduler/requirements.txt create mode 100644 night-scheduler/scheduler.py create mode 100644 night-scheduler/tests/__init__.py create mode 100644 night-scheduler/tests/test_scheduler.py diff --git a/.claude/rules/night-scheduler.md b/.claude/rules/night-scheduler.md new file mode 100644 index 0000000..8a001e0 --- /dev/null +++ b/.claude/rules/night-scheduler.md @@ -0,0 +1,297 @@ +# Night Scheduler - Entwicklerdokumentation + +**Status:** Produktiv +**Letzte Aktualisierung:** 2026-02-09 +**URL:** https://macmini:3002/infrastructure/night-mode +**API:** http://macmini:8096 + +--- + +## Uebersicht + +Der Night Scheduler ermoeglicht die automatische Nachtabschaltung der Docker-Services: +- Zeitgesteuerte Abschaltung (Standard: 22:00) +- Zeitgesteuerter Start (Standard: 06:00) +- Manuelle Sofortaktionen (Start/Stop) +- Dashboard-UI zur Konfiguration + +--- + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Admin Dashboard (Port 3002) │ +│ /infrastructure/night-mode │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ API Proxy: /api/admin/night-mode │ +│ - GET: Status abrufen │ +│ - POST: Konfiguration speichern │ +│ - POST /execute: Sofortaktion (start/stop) │ +│ - GET /services: Service-Liste │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ night-scheduler (Port 8096) │ +│ - Python/FastAPI Container │ +│ - Prueft jede Minute ob Aktion faellig │ +│ - Fuehrt docker compose start/stop aus │ +│ - Speichert Config in /config/night-mode.json │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Dateien + +| Pfad | Beschreibung | +|------|--------------| +| `night-scheduler/scheduler.py` | Python Scheduler mit FastAPI | +| `night-scheduler/Dockerfile` | Container mit Docker CLI | +| `night-scheduler/requirements.txt` | Dependencies | +| `night-scheduler/config/night-mode.json` | Konfigurationsdatei | +| `night-scheduler/tests/test_scheduler.py` | Unit Tests | +| `admin-v2/app/api/admin/night-mode/route.ts` | API Proxy | +| `admin-v2/app/api/admin/night-mode/execute/route.ts` | Execute Endpoint | +| `admin-v2/app/api/admin/night-mode/services/route.ts` | Services Endpoint | +| `admin-v2/app/(admin)/infrastructure/night-mode/page.tsx` | UI Seite | + +--- + +## API Endpoints + +### GET /api/night-mode +Status und Konfiguration abrufen. + +**Response:** +```json +{ + "config": { + "enabled": true, + "shutdown_time": "22:00", + "startup_time": "06:00", + "last_action": "startup", + "last_action_time": "2026-02-09T06:00:00", + "excluded_services": ["night-scheduler", "nginx"] + }, + "current_time": "14:30:00", + "next_action": "shutdown", + "next_action_time": "22:00", + "time_until_next_action": "7h 30min", + "services_status": { + "backend": "running", + "postgres": "running" + } +} +``` + +### POST /api/night-mode +Konfiguration aktualisieren. + +**Request:** +```json +{ + "enabled": true, + "shutdown_time": "23:00", + "startup_time": "07:00", + "excluded_services": ["night-scheduler", "nginx", "vault"] +} +``` + +### POST /api/night-mode/execute +Sofortige Aktion ausfuehren. + +**Request:** +```json +{ + "action": "stop" // oder "start" +} +``` + +**Response:** +```json +{ + "success": true, + "message": "Aktion 'stop' erfolgreich ausgefuehrt fuer 25 Services" +} +``` + +### GET /api/night-mode/services +Liste aller Services abrufen. + +**Response:** +```json +{ + "all_services": ["backend", "postgres", "valkey", ...], + "excluded_services": ["night-scheduler", "nginx"], + "status": { + "backend": "running", + "postgres": "running" + } +} +``` + +--- + +## Konfiguration + +### Config-Format (night-mode.json) + +```json +{ + "enabled": true, + "shutdown_time": "22:00", + "startup_time": "06:00", + "last_action": "startup", + "last_action_time": "2026-02-09T06:00:00", + "excluded_services": ["night-scheduler", "nginx"] +} +``` + +### Umgebungsvariablen + +| Variable | Default | Beschreibung | +|----------|---------|--------------| +| `COMPOSE_PROJECT_NAME` | `breakpilot-pwa` | Docker Compose Projektname | + +--- + +## Ausgeschlossene Services + +Diese Services werden NICHT gestoppt: + +1. **night-scheduler** - Muss laufen, um Services zu starten +2. **nginx** - Optional, fuer HTTPS-Zugriff + +Weitere Services koennen ueber die Konfiguration ausgeschlossen werden. + +--- + +## Docker Compose Integration + +```yaml +night-scheduler: + build: ./night-scheduler + container_name: breakpilot-pwa-night-scheduler + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./night-scheduler/config:/config + - ./docker-compose.yml:/app/docker-compose.yml:ro + environment: + - COMPOSE_PROJECT_NAME=breakpilot-pwa + ports: + - "8096:8096" + networks: + - breakpilot-pwa-network + restart: unless-stopped +``` + +--- + +## Tests ausfuehren + +```bash +# Im Container +docker exec -it breakpilot-pwa-night-scheduler pytest -v + +# Lokal (mit Dependencies) +cd night-scheduler +pip install -r requirements.txt +pytest -v tests/ +``` + +--- + +## Deployment + +```bash +# 1. Dateien synchronisieren +rsync -avz night-scheduler/ macmini:.../night-scheduler/ + +# 2. Container bauen +ssh macmini "docker compose -f .../docker-compose.yml build --no-cache night-scheduler" + +# 3. Container starten +ssh macmini "docker compose -f .../docker-compose.yml up -d night-scheduler" + +# 4. Testen +curl http://macmini:8096/health +curl http://macmini:8096/api/night-mode +``` + +--- + +## Troubleshooting + +### Problem: Services werden nicht gestoppt/gestartet + +1. Pruefen ob Docker Socket gemountet ist: + ```bash + docker exec breakpilot-pwa-night-scheduler ls -la /var/run/docker.sock + ``` + +2. Pruefen ob docker compose CLI verfuegbar ist: + ```bash + docker exec breakpilot-pwa-night-scheduler docker compose version + ``` + +3. Logs pruefen: + ```bash + docker logs breakpilot-pwa-night-scheduler + ``` + +### Problem: Konfiguration wird nicht gespeichert + +1. Pruefen ob /config beschreibbar ist: + ```bash + docker exec breakpilot-pwa-night-scheduler touch /config/test + ``` + +2. Volume-Mount pruefen in docker-compose.yml + +### Problem: API nicht erreichbar + +1. Container-Status pruefen: + ```bash + docker ps | grep night-scheduler + ``` + +2. Health-Check pruefen: + ```bash + curl http://localhost:8096/health + ``` + +--- + +## Sicherheitshinweise + +- Der Container benoetigt Zugriff auf den Docker Socket +- Nur interne Services koennen gestoppt/gestartet werden +- Keine Authentifizierung (internes Netzwerk) +- Keine sensitiven Daten in der Konfiguration + +--- + +## Dependencies (SBOM) + +| Package | Version | Lizenz | +|---------|---------|--------| +| FastAPI | 0.109.0 | MIT | +| Uvicorn | 0.27.0 | BSD-3-Clause | +| Pydantic | 2.5.3 | MIT | +| pytest | 8.0.0 | MIT | +| pytest-asyncio | 0.23.0 | Apache-2.0 | +| httpx | 0.26.0 | BSD-3-Clause | + +--- + +## Aenderungshistorie + +| Datum | Aenderung | +|-------|-----------| +| 2026-02-09 | Initiale Implementierung | + diff --git a/admin-v2/app/api/admin/night-mode/execute/route.ts b/admin-v2/app/api/admin/night-mode/execute/route.ts new file mode 100644 index 0000000..11e8d87 --- /dev/null +++ b/admin-v2/app/api/admin/night-mode/execute/route.ts @@ -0,0 +1,50 @@ +/** + * Night Mode Execute API Route + * + * POST - Sofortige Ausführung (start/stop) + */ + +import { NextRequest, NextResponse } from 'next/server' + +const NIGHT_SCHEDULER_URL = process.env.NIGHT_SCHEDULER_URL || 'http://night-scheduler:8096' + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + + if (!body.action || !['start', 'stop'].includes(body.action)) { + return NextResponse.json( + { error: 'Aktion muss "start" oder "stop" sein' }, + { status: 400 } + ) + } + + const response = await fetch(`${NIGHT_SCHEDULER_URL}/api/night-mode/execute`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + + if (!response.ok) { + const error = await response.text() + return NextResponse.json( + { error: `Night-Scheduler Fehler: ${error}` }, + { status: response.status } + ) + } + + const data = await response.json() + return NextResponse.json(data) + } catch (error) { + console.error('Night-Mode Execute API Error:', error) + return NextResponse.json( + { + error: 'Night-Scheduler nicht erreichbar', + details: error instanceof Error ? error.message : 'Unbekannter Fehler', + }, + { status: 503 } + ) + } +} diff --git a/admin-v2/app/api/admin/night-mode/route.ts b/admin-v2/app/api/admin/night-mode/route.ts new file mode 100644 index 0000000..70e881d --- /dev/null +++ b/admin-v2/app/api/admin/night-mode/route.ts @@ -0,0 +1,77 @@ +/** + * Night Mode API Route + * + * Proxy für den night-scheduler Service (Port 8096) + * GET - Status abrufen + * POST - Konfiguration speichern + */ + +import { NextRequest, NextResponse } from 'next/server' + +const NIGHT_SCHEDULER_URL = process.env.NIGHT_SCHEDULER_URL || 'http://night-scheduler:8096' + +export async function GET() { + try { + const response = await fetch(`${NIGHT_SCHEDULER_URL}/api/night-mode`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + cache: 'no-store', + }) + + if (!response.ok) { + const error = await response.text() + return NextResponse.json( + { error: `Night-Scheduler Fehler: ${error}` }, + { status: response.status } + ) + } + + const data = await response.json() + return NextResponse.json(data) + } catch (error) { + console.error('Night-Mode API Error:', error) + return NextResponse.json( + { + error: 'Night-Scheduler nicht erreichbar', + details: error instanceof Error ? error.message : 'Unbekannter Fehler', + }, + { status: 503 } + ) + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + + const response = await fetch(`${NIGHT_SCHEDULER_URL}/api/night-mode`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + + if (!response.ok) { + const error = await response.text() + return NextResponse.json( + { error: `Night-Scheduler Fehler: ${error}` }, + { status: response.status } + ) + } + + const data = await response.json() + return NextResponse.json(data) + } catch (error) { + console.error('Night-Mode API Error:', error) + return NextResponse.json( + { + error: 'Night-Scheduler nicht erreichbar', + details: error instanceof Error ? error.message : 'Unbekannter Fehler', + }, + { status: 503 } + ) + } +} diff --git a/admin-v2/app/api/admin/night-mode/services/route.ts b/admin-v2/app/api/admin/night-mode/services/route.ts new file mode 100644 index 0000000..42d4683 --- /dev/null +++ b/admin-v2/app/api/admin/night-mode/services/route.ts @@ -0,0 +1,41 @@ +/** + * Night Mode Services API Route + * + * GET - Liste aller Services abrufen + */ + +import { NextResponse } from 'next/server' + +const NIGHT_SCHEDULER_URL = process.env.NIGHT_SCHEDULER_URL || 'http://night-scheduler:8096' + +export async function GET() { + try { + const response = await fetch(`${NIGHT_SCHEDULER_URL}/api/night-mode/services`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + cache: 'no-store', + }) + + if (!response.ok) { + const error = await response.text() + return NextResponse.json( + { error: `Night-Scheduler Fehler: ${error}` }, + { status: response.status } + ) + } + + const data = await response.json() + return NextResponse.json(data) + } catch (error) { + console.error('Night-Mode Services API Error:', error) + return NextResponse.json( + { + error: 'Night-Scheduler nicht erreichbar', + details: error instanceof Error ? error.message : 'Unbekannter Fehler', + }, + { status: 503 } + ) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8bd56c5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,1806 @@ +services: + # ============================================ + # Nginx HTTPS Reverse Proxy + # Enables secure context for microphone/crypto + # Access via HTTPS on same ports as HTTP was before + # ============================================ + nginx: + image: nginx:alpine + container_name: breakpilot-pwa-nginx + ports: + - "443:443" # HTTPS Studio v2 (https://macmini/) + - "80:80" # HTTP -> HTTPS redirect + - "3000:3000" # HTTPS Admin Website (https://macmini:3000/) + - "3002:3002" # HTTPS Admin v2 (https://macmini:3002/) + - "8091:8091" # HTTPS Voice Service (wss://macmini:8091/) + - "8000:8000" # HTTPS Backend API + - "8086:8086" # HTTPS Klausur Service + - "8089:8089" # HTTPS Edu-Search proxy (edu-search runs on 8088) + - "8093:8093" # HTTPS AI Compliance SDK + - "8443:8443" # HTTPS Jitsi Meet (https://macmini:8443/) + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - vault_certs:/etc/nginx/certs:ro + depends_on: + vault-agent: + condition: service_started + studio-v2: + condition: service_started + voice-service: + condition: service_started + backend: + condition: service_started + klausur-service: + condition: service_started + website: + condition: service_started + ai-compliance-sdk: + condition: service_started + admin-v2: + condition: service_started + jitsi-web: + condition: service_started + extra_hosts: + - "breakpilot-edu-search:host-gateway" + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # HashiCorp Vault - Secrets Management + # Web UI: http://localhost:8200/ui + # ============================================ + vault: + image: hashicorp/vault:1.15 + container_name: breakpilot-pwa-vault + cap_add: + - IPC_LOCK + ports: + - "8200:8200" + environment: + - VAULT_DEV_ROOT_TOKEN_ID=${VAULT_DEV_TOKEN:-breakpilot-dev-token} + - VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200 + - VAULT_ADDR=http://127.0.0.1:8200 + volumes: + - vault_data:/vault/data + healthcheck: + test: ["CMD", "vault", "status"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 5s + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Vault PKI Initialization - runs once to set up PKI and issue initial certs + vault-init: + image: hashicorp/vault:1.15 + container_name: breakpilot-pwa-vault-init + environment: + - VAULT_ADDR=http://vault:8200 + - VAULT_TOKEN=${VAULT_DEV_TOKEN:-breakpilot-dev-token} + volumes: + - ./vault/init-pki.sh:/init-pki.sh:ro + - vault_agent_config:/vault/agent/data + - vault_certs:/vault/certs + entrypoint: ["/bin/sh", "-c"] + command: + - | + echo "Waiting for Vault to be ready..." + until vault status > /dev/null 2>&1; do sleep 1; done + echo "Vault is ready. Running PKI initialization..." + chmod +x /init-pki.sh + /init-pki.sh + echo "PKI initialization complete. Exiting." + depends_on: + vault: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: "no" + + # Vault Agent - manages certificate renewal for nginx + vault-agent: + image: hashicorp/vault:1.15 + container_name: breakpilot-pwa-vault-agent + environment: + - VAULT_ADDR=http://vault:8200 + volumes: + - ./vault/agent/config.hcl:/vault/agent/config.hcl:ro + - ./vault/agent/templates:/vault/agent/templates:ro + - ./vault/agent/split-certs.sh:/vault/agent/split-certs.sh:ro + - vault_agent_config:/vault/agent/data + - vault_certs:/vault/certs + entrypoint: ["vault", "agent", "-config=/vault/agent/config.hcl"] + depends_on: + vault: + condition: service_healthy + vault-init: + condition: service_completed_successfully + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # PostgreSQL Database with PostGIS Extension + # PostGIS is required for geo-service (OSM/Terrain features) + postgres: + image: postgis/postgis:16-3.4-alpine + container_name: breakpilot-pwa-postgres + ports: + - "5432:5432" + environment: + POSTGRES_USER: breakpilot + POSTGRES_PASSWORD: breakpilot123 + POSTGRES_DB: breakpilot_db + volumes: + - breakpilot_pwa_data:/var/lib/postgresql/data + - ./geo-service/scripts/init_postgis.sql:/docker-entrypoint-initdb.d/10-init-postgis.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U breakpilot -d breakpilot_db"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # Valkey - Session Cache (Redis-Fork, BSD-3) + # Redis-compatible, 100% Open Source + # ============================================ + valkey: + image: valkey/valkey:8-alpine + container_name: breakpilot-pwa-valkey + ports: + - "6379:6379" + volumes: + - valkey_data:/data + command: valkey-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Go Consent Service + consent-service: + build: + context: ./consent-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-consent-service + ports: + - "8081:8081" + environment: + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db?sslmode=disable + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET:-your-refresh-secret-key-change-in-production} + - PORT=8081 + - ENVIRONMENT=${ENVIRONMENT:-development} + - ALLOWED_ORIGINS=http://localhost:8000,http://backend:8000 + # Valkey Session Cache + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379} + - SESSION_TTL_HOURS=${SESSION_TTL_HOURS:-24} + # E-Mail Konfiguration (Mailpit für Entwicklung) + - SMTP_HOST=${SMTP_HOST:-mailpit} + - SMTP_PORT=${SMTP_PORT:-1025} + - SMTP_USERNAME=${SMTP_USERNAME:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM_NAME=${SMTP_FROM_NAME:-BreakPilot} + - SMTP_FROM_ADDR=${SMTP_FROM_ADDR:-noreply@breakpilot.app} + - FRONTEND_URL=${FRONTEND_URL:-http://localhost:8000} + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + mailpit: + condition: service_started + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Python Backend + backend: + build: + context: ./backend + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-backend + expose: + - "8000" + environment: + - CONSENT_SERVICE_URL=http://consent-service:8081 + - KLAUSUR_SERVICE_URL=http://klausur-service:8086 + - TROCR_SERVICE_URL=${TROCR_SERVICE_URL:-http://host.docker.internal:18084} + - CAMUNDA_URL=http://camunda:8080/engine-rest + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db?sslmode=disable + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - ENVIRONMENT=${ENVIRONMENT:-development} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - DEBUG=${DEBUG:-false} + # Alerts Agent (Google Alerts Monitoring) + - ALERTS_AGENT_ENABLED=${ALERTS_AGENT_ENABLED:-true} + # HashiCorp Vault - Secrets Management + - VAULT_ADDR=${VAULT_ADDR:-http://vault:8200} + - VAULT_TOKEN=${VAULT_TOKEN:-breakpilot-dev-token} + - VAULT_SECRETS_PATH=${VAULT_SECRETS_PATH:-breakpilot} + - USE_VAULT_SECRETS=${USE_VAULT_SECRETS:-true} + # Keycloak Authentication (optional - wenn nicht gesetzt wird lokales JWT verwendet) + - KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-} + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-} + - KEYCLOAK_VERIFY_SSL=${KEYCLOAK_VERIFY_SSL:-true} + # Valkey Session Cache + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379} + - SESSION_TTL_HOURS=${SESSION_TTL_HOURS:-24} + # vast.ai GPU Infrastructure + - VAST_API_KEY=${VAST_API_KEY:-} + - VAST_INSTANCE_ID=${VAST_INSTANCE_ID:-} + - CONTROL_API_KEY=${CONTROL_API_KEY:-} + - VAST_AUTO_SHUTDOWN=${VAST_AUTO_SHUTDOWN:-true} + - VAST_AUTO_SHUTDOWN_MINUTES=${VAST_AUTO_SHUTDOWN_MINUTES:-30} + # vLLM Backend + - VLLM_BASE_URL=${VLLM_BASE_URL:-} + - VLLM_ENABLED=${VLLM_ENABLED:-false} + # Ollama Backend (lokal auf Mac Mini - DSGVO-konform) + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + - OLLAMA_ENABLED=${OLLAMA_ENABLED:-true} + - OLLAMA_DEFAULT_MODEL=${OLLAMA_DEFAULT_MODEL:-qwen2.5:14b} + - OLLAMA_VISION_MODEL=${OLLAMA_VISION_MODEL:-qwen2.5vl:32b} + - OLLAMA_CORRECTION_MODEL=${OLLAMA_CORRECTION_MODEL:-qwen2.5:14b} + - OLLAMA_TIMEOUT=${OLLAMA_TIMEOUT:-180} + # Breakpilot Drive Game API + - GAME_USE_DATABASE=${GAME_USE_DATABASE:-true} + - GAME_REQUIRE_AUTH=${GAME_REQUIRE_AUTH:-false} + - GAME_REQUIRE_BILLING=${GAME_REQUIRE_BILLING:-false} + - GAME_LLM_MODEL=${GAME_LLM_MODEL:-} + # Compliance LLM Provider Configuration + # Options: "anthropic" (cloud) or "self_hosted" (Ollama/local) + - COMPLIANCE_LLM_PROVIDER=${COMPLIANCE_LLM_PROVIDER:-self_hosted} + - SELF_HOSTED_LLM_URL=${SELF_HOSTED_LLM_URL:-http://host.docker.internal:11434} + - SELF_HOSTED_LLM_MODEL=${SELF_HOSTED_LLM_MODEL:-llama3.1:70b} + - COMPLIANCE_LLM_MAX_TOKENS=${COMPLIANCE_LLM_MAX_TOKENS:-4096} + - COMPLIANCE_LLM_TEMPERATURE=${COMPLIANCE_LLM_TEMPERATURE:-0.3} + - COMPLIANCE_LLM_TIMEOUT=${COMPLIANCE_LLM_TIMEOUT:-120} + # E-Mail Konfiguration (Mailpit fuer Entwicklung) + - SMTP_HOST=${SMTP_HOST:-mailpit} + - SMTP_PORT=${SMTP_PORT:-1025} + - SMTP_USERNAME=${SMTP_USERNAME:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM_NAME=${SMTP_FROM_NAME:-BreakPilot} + - SMTP_FROM_ADDR=${SMTP_FROM_ADDR:-noreply@breakpilot.app} + extra_hosts: + - "host.docker.internal:host-gateway" + - "mac-mini:192.168.178.163" + volumes: + # Mount Docker socket for container monitoring (Mac Mini Control) + - /var/run/docker.sock:/var/run/docker.sock:ro + # Mount SBOM files for Security Dashboard + - ./docs/sbom:/app/docs/sbom:ro + # Mount Projekt-Verzeichnis fuer Test-Dashboard (echte Test-Discovery) + - /Users/benjaminadmin/Projekte/breakpilot-pwa:/app/project:ro + depends_on: + - consent-service + - valkey + - mailpit + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Automatic Database Backup (runs daily at 2 AM) + backup: + image: postgres:16-alpine + container_name: breakpilot-pwa-backup + volumes: + - ./backups:/backups + - postgres_data:/var/lib/postgresql/data:ro + environment: + - PGHOST=postgres + - PGUSER=breakpilot + - PGPASSWORD=breakpilot123 + - PGDATABASE=breakpilot_db + entrypoint: ["/bin/sh", "-c"] + command: + - | + echo "Backup service started. Running backup every day at 2 AM..." + while true; do + # Berechne Sekunden bis 2 Uhr + current_hour=$$(date +%H) + current_min=$$(date +%M) + current_sec=$$(date +%S) + + if [ $$current_hour -lt 2 ]; then + sleep_hours=$$((2 - current_hour - 1)) + else + sleep_hours=$$((26 - current_hour - 1)) + fi + sleep_mins=$$((60 - current_min - 1)) + sleep_secs=$$((60 - current_sec)) + total_sleep=$$((sleep_hours * 3600 + sleep_mins * 60 + sleep_secs)) + + echo "Next backup in $$total_sleep seconds (at 2:00 AM)" + sleep $$total_sleep + + # Backup erstellen + TIMESTAMP=$$(date +"%Y%m%d_%H%M%S") + BACKUP_FILE="/backups/breakpilot_$${TIMESTAMP}.sql.gz" + + echo "Creating backup: $$BACKUP_FILE" + pg_dump | gzip > "$$BACKUP_FILE" + + if [ $$? -eq 0 ]; then + echo "✓ Backup completed: $$BACKUP_FILE" + # Alte Backups löschen (älter als 30 Tage) + find /backups -name "breakpilot_*.sql.gz" -mtime +30 -delete + echo "✓ Old backups cleaned up" + else + echo "✗ Backup failed!" + fi + done + depends_on: + postgres: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + profiles: + - backup + + # Mailpit - Lokaler E-Mail-Server für Entwicklung + # Web UI: http://localhost:8025 + # SMTP: localhost:1025 + mailpit: + image: axllent/mailpit:latest + container_name: breakpilot-pwa-mailpit + ports: + - "8025:8025" # Web UI + - "1025:1025" # SMTP Server + environment: + - MP_SMTP_AUTH_ACCEPT_ANY=true + - MP_SMTP_AUTH_ALLOW_INSECURE=true + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Matrix Synapse - Schulkommunikation (E2EE Messenger) + # Admin: http://localhost:8008/_synapse/admin + synapse: + image: matrixdotorg/synapse:latest + container_name: breakpilot-pwa-synapse + ports: + - "8008:8008" # Client-Server API + - "8448:8448" # Federation (optional für später) + volumes: + - synapse_data:/data + environment: + - SYNAPSE_SERVER_NAME=${MATRIX_SERVER_NAME:-breakpilot.local} + - SYNAPSE_REPORT_STATS=no + - SYNAPSE_NO_TLS=1 + - SYNAPSE_ENABLE_REGISTRATION=no + - SYNAPSE_LOG_LEVEL=INFO + - UID=1000 + - GID=1000 + healthcheck: + test: ["CMD-SHELL", "curl -fSs http://localhost:8008/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # PostgreSQL für Matrix Synapse (separate DB) + synapse-db: + image: postgres:16-alpine + container_name: breakpilot-pwa-synapse-db + environment: + POSTGRES_USER: synapse + POSTGRES_PASSWORD: ${SYNAPSE_DB_PASSWORD:-synapse_secret_123} + POSTGRES_DB: synapse + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C" + volumes: + - synapse_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U synapse -d synapse"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Go School Service (Klausuren, Noten, Zeugnisse) + school-service: + build: + context: ./school-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-school-service + ports: + - "8084:8084" + environment: + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db?sslmode=disable + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - PORT=8084 + - ENVIRONMENT=${ENVIRONMENT:-development} + - ALLOWED_ORIGINS=http://localhost:8000,http://backend:8000 + - LLM_GATEWAY_URL=http://backend:8000/llm + depends_on: + postgres: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Embedding Service (ML-heavy operations) + # Handles: embeddings, re-ranking, PDF extraction + # Separated for faster klausur-service builds + embedding-service: + build: + context: ./klausur-service/embedding-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-embedding-service + ports: + - "8087:8087" + environment: + - EMBEDDING_BACKEND=${EMBEDDING_BACKEND:-local} + - LOCAL_EMBEDDING_MODEL=${LOCAL_EMBEDDING_MODEL:-BAAI/bge-m3} + - LOCAL_RERANKER_MODEL=${LOCAL_RERANKER_MODEL:-BAAI/bge-reranker-v2-m3} + - PDF_EXTRACTION_BACKEND=${PDF_EXTRACTION_BACKEND:-auto} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - COHERE_API_KEY=${COHERE_API_KEY:-} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + volumes: + - embedding_models:/root/.cache/huggingface + deploy: + resources: + limits: + memory: 4G + healthcheck: + test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8087/health').raise_for_status()"] + interval: 30s + timeout: 10s + start_period: 120s + retries: 3 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Klausur-Service (Abitur/Vorabitur Klausurkorrektur) + # React + FastAPI Microservice + # Web UI: http://localhost:8086 + klausur-service: + build: + context: ./klausur-service + dockerfile: Dockerfile + platform: linux/arm64 # Native ARM64 - PaddlePaddle 3.3.0 unterstützt ARM64 + container_name: breakpilot-pwa-klausur-service + expose: + - "8086" + environment: + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - BACKEND_URL=http://backend:8000 + - SCHOOL_SERVICE_URL=http://school-service:8084 + - ENVIRONMENT=${ENVIRONMENT:-development} + # PostgreSQL for OCR Labeling & Metrics + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db + # Embedding Service (ML operations) + - EMBEDDING_SERVICE_URL=http://embedding-service:8087 + # BYOEH Configuration + - QDRANT_URL=http://qdrant:6333 + - BYOEH_ENCRYPTION_ENABLED=true + - BYOEH_CHUNK_SIZE=1000 + - BYOEH_CHUNK_OVERLAP=200 + # MinIO Configuration (RAG Document Storage) + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-breakpilot} + - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-breakpilot123} + - MINIO_BUCKET=breakpilot-rag + - MINIO_SECURE=false + # Ollama LLM Configuration + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + - OLLAMA_ENABLED=${OLLAMA_ENABLED:-true} + - OLLAMA_DEFAULT_MODEL=${OLLAMA_DEFAULT_MODEL:-qwen2.5:14b} + - OLLAMA_VISION_MODEL=${OLLAMA_VISION_MODEL:-qwen2.5vl:32b} + - OLLAMA_CORRECTION_MODEL=${OLLAMA_CORRECTION_MODEL:-qwen2.5:14b} + # PaddleOCR Service (x86_64 via Rosetta) + - PADDLEOCR_SERVICE_URL=http://paddleocr-service:8095 + # HashiCorp Vault - Anthropic API Key for Loesung E + - VAULT_ADDR=http://vault:8200 + - VAULT_TOKEN=${VAULT_DEV_TOKEN:-breakpilot-dev-token} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + volumes: + - klausur_uploads:/app/uploads + - eh_uploads:/app/eh-uploads + - ocr_labeling:/app/ocr-labeling + - paddle_models:/root/.paddlex # Persist PaddleOCR models across restarts + - ./docs:/app/docs # NIBIS Abitur-Dateien + depends_on: + - backend + - school-service + - embedding-service + - postgres + - qdrant + - minio + - paddleocr-service + - vault + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8086/health"] + interval: 30s + timeout: 30s + retries: 3 + start_period: 10s + restart: unless-stopped + + # ============================================ + # PaddleOCR Service - x86_64 via Rosetta + # Runs in emulation to avoid ARM64 crashes + # ============================================ + paddleocr-service: + build: + context: ./paddleocr-service + dockerfile: Dockerfile + platform: linux/amd64 # Force x86_64 emulation via Rosetta + container_name: breakpilot-pwa-paddleocr + expose: + - "8095" + environment: + - PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True + volumes: + - paddleocr_models:/root/.paddlex # Cache PaddleX models + - paddleocr_models:/root/.paddleocr # Cache PaddleOCR 3.x models + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8095/health"] + interval: 30s + timeout: 60s + retries: 5 + start_period: 180s # Models need time to load in emulation + restart: unless-stopped + + # Qdrant Vector Database (BYOEH - Erwartungshorizont RAG) + # REST API: http://localhost:6333 + # gRPC: localhost:6334 + qdrant: + image: qdrant/qdrant:v1.12.1 + container_name: breakpilot-pwa-qdrant + ports: + - "6333:6333" + - "6334:6334" + volumes: + - qdrant_data:/qdrant/storage + environment: + - QDRANT__SERVICE__GRPC_PORT=6334 + healthcheck: + test: ["CMD-SHELL", "bash -c ' /dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + start_period: 30s + retries: 3 + + # DSMS Gateway - REST API für DSMS + dsms-gateway: + build: + context: ./dsms-gateway + dockerfile: Dockerfile + container_name: breakpilot-pwa-dsms-gateway + ports: + - "8082:8082" + environment: + - IPFS_API_URL=http://dsms-node:5001 + - IPFS_GATEWAY_URL=http://dsms-node:8080 + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + depends_on: + dsms-node: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # Jitsi Meet - Videokonferenzen für Schulungen + # Web UI: http://localhost:8443 + # ============================================ + + # Jitsi Web Frontend + jitsi-web: + image: jitsi/web:stable-9823 + container_name: breakpilot-pwa-jitsi-web + expose: + - "80" + environment: + - ENABLE_XMPP_WEBSOCKET=1 + - ENABLE_COLIBRI_WEBSOCKET=1 + - XMPP_DOMAIN=meet.jitsi + - XMPP_BOSH_URL_BASE=http://jitsi-xmpp:5280 + - XMPP_MUC_DOMAIN=muc.meet.jitsi + - XMPP_GUEST_DOMAIN=guest.meet.jitsi + - TZ=Europe/Berlin + - PUBLIC_URL=${JITSI_PUBLIC_URL:-https://macmini} + - JICOFO_AUTH_USER=focus + - ENABLE_AUTH=${JITSI_ENABLE_AUTH:-0} + - ENABLE_GUESTS=${JITSI_ENABLE_GUESTS:-1} + - ENABLE_RECORDING=${JITSI_ENABLE_RECORDING:-1} + - ENABLE_LIVESTREAMING=0 + - DISABLE_HTTPS=1 + # Branding + - APP_NAME=BreakPilot Meet + - NATIVE_APP_NAME=BreakPilot Meet + - PROVIDER_NAME=BreakPilot + volumes: + - jitsi_web_config:/config + - jitsi_web_crontabs:/var/spool/cron/crontabs + - jitsi_transcripts:/usr/share/jitsi-meet/transcripts + networks: + breakpilot-pwa-network: + aliases: + - meet.jitsi + restart: unless-stopped + depends_on: + - jitsi-xmpp + + # Prosody XMPP Server + jitsi-xmpp: + image: jitsi/prosody:stable-9823 + container_name: breakpilot-pwa-jitsi-xmpp + environment: + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_MUC_DOMAIN=muc.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal-muc.meet.jitsi + - XMPP_GUEST_DOMAIN=guest.meet.jitsi + - XMPP_RECORDER_DOMAIN=recorder.meet.jitsi + - XMPP_CROSS_DOMAIN=true + - TZ=Europe/Berlin + - JICOFO_AUTH_USER=focus + - JICOFO_AUTH_PASSWORD=${JITSI_JICOFO_AUTH_PASSWORD:-jicofo_secret_123} + - JVB_AUTH_USER=jvb + - JVB_AUTH_PASSWORD=${JITSI_JVB_AUTH_PASSWORD:-jvb_secret_123} + - JIGASI_XMPP_USER=jigasi + - JIGASI_XMPP_PASSWORD=${JITSI_JIGASI_XMPP_PASSWORD:-jigasi_secret_123} + - JIBRI_XMPP_USER=jibri + - JIBRI_XMPP_PASSWORD=${JITSI_JIBRI_XMPP_PASSWORD:-jibri_secret_123} + - JIBRI_RECORDER_USER=recorder + - JIBRI_RECORDER_PASSWORD=${JITSI_JIBRI_RECORDER_PASSWORD:-recorder_secret_123} + - LOG_LEVEL=info + - PUBLIC_URL=${JITSI_PUBLIC_URL:-https://macmini} + - ENABLE_AUTH=${JITSI_ENABLE_AUTH:-0} + - ENABLE_GUESTS=${JITSI_ENABLE_GUESTS:-1} + volumes: + - jitsi_prosody_config:/config + - jitsi_prosody_plugins:/prosody-plugins-custom + networks: + breakpilot-pwa-network: + aliases: + - xmpp.meet.jitsi + restart: unless-stopped + + # Jicofo - Jitsi Conference Focus + jitsi-jicofo: + image: jitsi/jicofo:stable-9823 + container_name: breakpilot-pwa-jitsi-jicofo + environment: + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_MUC_DOMAIN=muc.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal-muc.meet.jitsi + - XMPP_SERVER=jitsi-xmpp + - JICOFO_AUTH_USER=focus + - JICOFO_AUTH_PASSWORD=${JITSI_JICOFO_AUTH_PASSWORD:-jicofo_secret_123} + - TZ=Europe/Berlin + - ENABLE_AUTH=${JITSI_ENABLE_AUTH:-0} + - AUTH_TYPE=internal + - ENABLE_AUTO_OWNER=${JITSI_ENABLE_AUTO_OWNER:-1} + volumes: + - jitsi_jicofo_config:/config + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + - jitsi-xmpp + + # JVB - Jitsi Videobridge (WebRTC SFU) + jitsi-jvb: + image: jitsi/jvb:stable-9823 + container_name: breakpilot-pwa-jitsi-jvb + ports: + - "10000:10000/udp" # Video/Audio RTP + - "8080:8080" # Colibri REST API (internal) + environment: + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal-muc.meet.jitsi + - XMPP_SERVER=jitsi-xmpp + - JVB_AUTH_USER=jvb + - JVB_AUTH_PASSWORD=${JITSI_JVB_AUTH_PASSWORD:-jvb_secret_123} + - JVB_PORT=10000 + - JVB_STUN_SERVERS=meet-jit-si-turnrelay.jitsi.net:443 + - TZ=Europe/Berlin + - PUBLIC_URL=${JITSI_PUBLIC_URL:-https://macmini} + - COLIBRI_REST_ENABLED=true + - ENABLE_COLIBRI_WEBSOCKET=1 + volumes: + - jitsi_jvb_config:/config + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + - jitsi-xmpp + + # ============================================ + # Jibri - Jitsi Recording Service + # Recordings werden zu MinIO hochgeladen + # ============================================ + jibri: + build: + context: ./docker/jibri + dockerfile: Dockerfile + container_name: breakpilot-pwa-jibri + environment: + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal-muc.meet.jitsi + - XMPP_RECORDER_DOMAIN=recorder.meet.jitsi + - XMPP_SERVER=jitsi-xmpp + - XMPP_MUC_DOMAIN=muc.meet.jitsi + - JIBRI_XMPP_USER=jibri + - JIBRI_XMPP_PASSWORD=${JITSI_JIBRI_XMPP_PASSWORD:-jibri_secret_123} + - JIBRI_RECORDER_USER=recorder + - JIBRI_RECORDER_PASSWORD=${JITSI_JIBRI_RECORDER_PASSWORD:-recorder_secret_123} + - JIBRI_BREWERY_MUC=jibribrewery + - JIBRI_RECORDING_DIR=/recordings + - JIBRI_FINALIZE_SCRIPT=/config/finalize.sh + - TZ=Europe/Berlin + # X11 Display Konfiguration (Xvfb) + - DISPLAY=:0 + - RESOLUTION=1920x1080x24 + # Optional: VNC fuer Debugging (Port 5900) + # - VNC_PASSWORD=debug123 + # MinIO Upload Konfiguration + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=${MINIO_ROOT_USER} + - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD} + - MINIO_BUCKET=breakpilot-recordings + # Backend Webhook (wird nach Upload aufgerufen) + - BACKEND_WEBHOOK_URL=http://backend:8000/api/recordings/webhook + volumes: + - jibri_recordings:/recordings + - /dev/shm:/dev/shm + shm_size: '2gb' + cap_add: + - SYS_ADMIN + - NET_BIND_SERVICE + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + - jitsi-xmpp + - minio + profiles: + - recording + + # ============================================ + # Transcription Worker - Whisper + pyannote + # Verarbeitet Recordings asynchron + # ============================================ + transcription-worker: + build: + context: ./backend + dockerfile: Dockerfile.worker + container_name: breakpilot-pwa-transcription-worker + environment: + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db?sslmode=disable + - REDIS_URL=redis://valkey:6379/1 + - WHISPER_MODEL=${WHISPER_MODEL:-large-v3} + - WHISPER_DEVICE=${WHISPER_DEVICE:-cpu} + - WHISPER_COMPUTE_TYPE=${WHISPER_COMPUTE_TYPE:-int8} + # pyannote.audio Token (HuggingFace) + - PYANNOTE_AUTH_TOKEN=${PYANNOTE_AUTH_TOKEN:-} + # MinIO Storage + - MINIO_ENDPOINT=${MINIO_ENDPOINT:-minio:9000} + - MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-breakpilot} + - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-breakpilot123} + - MINIO_BUCKET=breakpilot-recordings + - MINIO_SECURE=false + - TZ=Europe/Berlin + volumes: + - transcription_models:/root/.cache/huggingface + - transcription_temp:/tmp/transcriptions + deploy: + resources: + limits: + memory: 8G + reservations: + memory: 4G + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + minio: + condition: service_started + profiles: + - recording + + # ============================================ + # ERPNext - Open Source ERP System + # Web UI: http://localhost:8090 + # Default: Administrator / admin + # ============================================ + + # MariaDB for ERPNext + erpnext-db: + image: mariadb:10.6 + container_name: breakpilot-pwa-erpnext-db + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --skip-character-set-client-handshake --skip-innodb-read-only-compressed + environment: + - MYSQL_ROOT_PASSWORD=${ERPNEXT_DB_ROOT_PASSWORD:-changeit123} + - MYSQL_DATABASE=erpnext + - MYSQL_USER=erpnext + - MYSQL_PASSWORD=${ERPNEXT_DB_PASSWORD:-erpnext123} + volumes: + - erpnext_db_data:/var/lib/mysql + networks: + - breakpilot-pwa-network + restart: unless-stopped + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${ERPNEXT_DB_ROOT_PASSWORD:-changeit123}"] + interval: 5s + timeout: 5s + retries: 10 + + # Redis Queue for ERPNext + erpnext-redis-queue: + image: redis:alpine + container_name: breakpilot-pwa-erpnext-redis-queue + volumes: + - erpnext_redis_queue_data:/data + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Redis Cache for ERPNext + erpnext-redis-cache: + image: redis:alpine + container_name: breakpilot-pwa-erpnext-redis-cache + volumes: + - erpnext_redis_cache_data:/data + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ERPNext Site Creator (runs once) + erpnext-create-site: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-create-site + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + if [[ ! -f sites/erpnext.local/site_config.json ]]; then + echo "Creating ERPNext site..."; + bench new-site erpnext.local --db-host=erpnext-db --db-port=3306 --admin-password=admin --db-root-password=${ERPNEXT_DB_ROOT_PASSWORD:-changeit123} --install-app erpnext --set-default; + echo "Site created successfully!"; + else + echo "Site already exists, skipping creation."; + fi; + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + depends_on: + erpnext-db: + condition: service_healthy + erpnext-redis-cache: + condition: service_started + erpnext-redis-queue: + condition: service_started + + # ERPNext Backend + erpnext-backend: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-backend + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + bench serve --port 8000 + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-db: + condition: service_healthy + erpnext-create-site: + condition: service_completed_successfully + + # ERPNext WebSocket + erpnext-websocket: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-websocket + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + bench watch + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-db: + condition: service_healthy + erpnext-create-site: + condition: service_completed_successfully + + # ERPNext Scheduler + erpnext-scheduler: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-scheduler + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + bench schedule + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-db: + condition: service_healthy + erpnext-create-site: + condition: service_completed_successfully + + # ERPNext Worker (Long) + erpnext-worker-long: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-worker-long + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + bench worker --queue long,default,short + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-db: + condition: service_healthy + erpnext-create-site: + condition: service_completed_successfully + + # ERPNext Worker (Short) + erpnext-worker-short: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-worker-short + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + bench worker --queue short,default + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-db: + condition: service_healthy + erpnext-create-site: + condition: service_completed_successfully + + # ERPNext Frontend (Nginx) + erpnext-frontend: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-frontend + command: + - nginx-entrypoint.sh + ports: + - "8090:8080" + environment: + - BACKEND=erpnext-backend:8000 + - SOCKETIO=erpnext-websocket:9000 + - UPSTREAM_REAL_IP_ADDRESS=127.0.0.1 + - UPSTREAM_REAL_IP_HEADER=X-Forwarded-For + - UPSTREAM_REAL_IP_RECURSIVE=off + - FRAPPE_SITE_NAME_HEADER=erpnext.local + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-backend: + condition: service_started + erpnext-websocket: + condition: service_started + + # ============================================ + # Breakpilot Drive - Lernspiel (Unity WebGL) + # Web UI: http://localhost:3001 + # ============================================ + breakpilot-drive: + build: + context: ./breakpilot-drive + dockerfile: Dockerfile + container_name: breakpilot-pwa-drive + ports: + - "3001:80" + environment: + # API Configuration (injected into Unity WebGL) + - API_BASE_URL=${GAME_API_URL:-http://localhost:8000/api/game} + # Feature Flags + - GAME_REQUIRE_AUTH=${GAME_REQUIRE_AUTH:-false} + - GAME_ENABLE_LEADERBOARDS=${GAME_ENABLE_LEADERBOARDS:-true} + networks: + - breakpilot-pwa-network + depends_on: + - backend + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/health.json"] + interval: 30s + timeout: 10s + retries: 3 + restart: unless-stopped + profiles: + - game + + # ============================================ + # Camunda 7 - BPMN Workflow Engine + # Web UI: http://localhost:8089/camunda + # REST API: http://localhost:8089/engine-rest + # License: Apache 2.0 (kommerziell nutzbar) + # ============================================ + camunda: + image: camunda/camunda-bpm-platform:7.21.0 + container_name: breakpilot-pwa-camunda + ports: + - "8089:8080" + environment: + - DB_DRIVER=org.postgresql.Driver + - DB_URL=jdbc:postgresql://postgres:5432/breakpilot_db + - DB_USERNAME=breakpilot + - DB_PASSWORD=${POSTGRES_PASSWORD:-breakpilot123} + - DB_VALIDATE_ON_BORROW=true + - WAIT_FOR=postgres:5432 + - CAMUNDA_BPM_ADMIN_USER_ID=admin + - CAMUNDA_BPM_ADMIN_USER_PASSWORD=${CAMUNDA_ADMIN_PASSWORD:-admin123} + depends_on: + postgres: + condition: service_healthy + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/camunda/api/engine || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + restart: unless-stopped + profiles: + - bpmn + + # ============================================ + # GeoEdu Service - Self-Hosted OSM + Terrain + # DSGVO-konforme Erdkunde-Lernplattform + # Web UI: http://localhost:8088 + # ============================================ + geo-service: + build: + context: ./geo-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-geo-service + ports: + - "8088:8088" + environment: + - PORT=8088 + - ENVIRONMENT=${ENVIRONMENT:-development} + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key} + # PostgreSQL (PostGIS fuer OSM-Daten) + - DATABASE_URL=postgresql+asyncpg://breakpilot:breakpilot123@postgres:5432/breakpilot_db + # MinIO (AOI Bundles, generierte Assets) + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-breakpilot} + - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-breakpilot123} + - MINIO_BUCKET=breakpilot-geo + - MINIO_SECURE=false + # Ollama (Lernstationen generieren) + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + - OLLAMA_MODEL=${OLLAMA_DEFAULT_MODEL:-qwen2.5:14b} + # Tile Server Config + - TILE_CACHE_DIR=/app/cache/tiles + - DEM_CACHE_DIR=/app/cache/dem + - MAX_AOI_SIZE_KM2=4 + volumes: + - geo_osm_data:/app/data/osm + - geo_dem_data:/app/data/dem + - geo_tile_cache:/app/cache/tiles + - geo_aoi_bundles:/app/bundles + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_started + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8088/health"] + interval: 30s + timeout: 10s + start_period: 60s + retries: 3 + restart: unless-stopped + + # ============================================ + # Voice Service - PersonaPlex + TaskOrchestrator + # Voice-First Interface fuer Breakpilot + # DSGVO-konform: Keine Audio-Persistenz + # Web UI: http://localhost:8091 + # ============================================ + voice-service: + build: + context: ./voice-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-voice-service + # Port 8091 wird von nginx HTTPS bereitgestellt (wss://macmini:8091) + expose: + - "8091" + environment: + - PORT=8091 + - DATABASE_URL=postgresql+asyncpg://breakpilot:breakpilot123@postgres:5432/breakpilot_db + - VALKEY_URL=redis://valkey:6379/2 + - PERSONAPLEX_ENABLED=${PERSONAPLEX_ENABLED:-false} + - PERSONAPLEX_WS_URL=${PERSONAPLEX_WS_URL:-ws://host.docker.internal:8998} + - ORCHESTRATOR_ENABLED=true + - FALLBACK_LLM_PROVIDER=${FALLBACK_LLM_PROVIDER:-ollama} + - OLLAMA_BASE_URL=http://host.docker.internal:11434 + - OLLAMA_VOICE_MODEL=qwen2.5:32b + - BQAS_JUDGE_MODEL=qwen2.5:14b + - KLAUSUR_SERVICE_URL=http://klausur-service:8086 + - ENCRYPTION_ENABLED=true + - AUDIO_PERSISTENCE=false + - AUDIO_SAMPLE_RATE=24000 + - ENVIRONMENT=${ENVIRONMENT:-development} + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + volumes: + - voice_session_data:/app/data/sessions + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8091/health"] + interval: 30s + timeout: 10s + start_period: 60s + retries: 3 + restart: unless-stopped + + # ============================================ + # MkDocs Documentation + # Web UI: http://localhost:8009 + # Material Theme with German language support + # ============================================ + docs: + build: + context: . + dockerfile: docs-src/Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-docs + ports: + - "8009:80" + networks: + - breakpilot-pwa-network + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + + # ============================================ + # Gitea - Self-hosted Git Server + # Web UI: http://localhost:3003 + # SSH: localhost:2222 + # Gitea Actions enabled for CI/CD + # ============================================ + gitea: + image: gitea/gitea:1.22-rootless + container_name: breakpilot-pwa-gitea + extra_hosts: + - "macmini:192.168.178.100" + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__database__DB_TYPE=postgres + - GITEA__database__HOST=postgres:5432 + - GITEA__database__NAME=gitea + - GITEA__database__USER=breakpilot + - GITEA__database__PASSWD=breakpilot123 + - GITEA__server__DOMAIN=macmini + - GITEA__server__SSH_DOMAIN=macmini + - GITEA__server__ROOT_URL=http://macmini:3003/ + - GITEA__server__HTTP_PORT=3003 + - GITEA__server__SSH_PORT=2222 + - GITEA__server__SSH_LISTEN_PORT=2222 + - GITEA__actions__ENABLED=true + - GITEA__actions__DEFAULT_ACTIONS_URL=https://gitea.com + - GITEA__service__DISABLE_REGISTRATION=true + - GITEA__service__REQUIRE_SIGNIN_VIEW=false + - GITEA__repository__DEFAULT_BRANCH=main + - GITEA__log__LEVEL=Info + - GITEA__webhook__ALLOWED_HOST_LIST=macmini,192.168.178.100,woodpecker-server,localhost,external + volumes: + - gitea_data:/var/lib/gitea + - gitea_config:/etc/gitea + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + - "3003:3003" + - "2222:2222" + depends_on: + postgres: + condition: service_healthy + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3003/api/healthz"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + restart: unless-stopped + + # ============================================ + # Gitea Actions Runner + # Executes CI/CD workflows defined in .gitea/workflows/ + # Includes Syft, Grype, Trivy for SBOM generation + # ============================================ + gitea-runner: + image: gitea/act_runner:latest + container_name: breakpilot-pwa-gitea-runner + environment: + - CONFIG_FILE=/config/config.yaml + - GITEA_INSTANCE_URL=http://gitea:3003 + - GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_TOKEN:-} + - GITEA_RUNNER_NAME=breakpilot-runner + - GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:20-bookworm,ubuntu-22.04:docker://ubuntu:22.04,self-hosted:host + volumes: + - gitea_runner_data:/data + - ./gitea/runner-config.yaml:/config/config.yaml:ro + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + gitea: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # Woodpecker CI - Server + # Modern CI/CD with native container support + # Web UI: http://localhost:8085 + # ============================================ + woodpecker-server: + image: woodpeckerci/woodpecker-server:v3 + container_name: breakpilot-pwa-woodpecker-server + ports: + - "8090:8000" + extra_hosts: + - "macmini:192.168.178.100" + environment: + - WOODPECKER_OPEN=true + - WOODPECKER_HOST=http://macmini:8090 + - WOODPECKER_ADMIN=pilotadmin,breakpilot_admin + # Gitea OAuth Integration + - WOODPECKER_GITEA=true + - WOODPECKER_GITEA_URL=http://macmini:3003 + - WOODPECKER_GITEA_CLIENT=${WOODPECKER_GITEA_CLIENT:-} + - WOODPECKER_GITEA_SECRET=${WOODPECKER_GITEA_SECRET:-} + # Agent Secret (fuer Agent-Authentifizierung) + - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET:-woodpecker-agent-secret-key} + # Database (SQLite fuer einfache Einrichtung) + - WOODPECKER_DATABASE_DRIVER=sqlite3 + - WOODPECKER_DATABASE_DATASOURCE=/var/lib/woodpecker/woodpecker.sqlite + # Logging + - WOODPECKER_LOG_LEVEL=info + # Trust all repos (allows privileged containers) + - WOODPECKER_PLUGINS_PRIVILEGED=true + - WOODPECKER_PLUGINS_TRUSTED_CLONE=true + volumes: + - woodpecker_data:/var/lib/woodpecker + depends_on: + gitea: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # Night Scheduler - Nachtabschaltung + # Stoppt Services nachts, startet sie morgens + # API: http://localhost:8096 + # ============================================ + night-scheduler: + build: ./night-scheduler + container_name: breakpilot-pwa-night-scheduler + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./night-scheduler/config:/config + - ./docker-compose.yml:/app/docker-compose.yml:ro + environment: + - COMPOSE_PROJECT_NAME=breakpilot-pwa + ports: + - "8096:8096" + networks: + - breakpilot-pwa-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8096/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + # ============================================ + # Woodpecker CI - Agent + # Executes pipeline steps in containers + # ============================================ + woodpecker-agent: + image: woodpeckerci/woodpecker-agent:v3 + container_name: breakpilot-pwa-woodpecker-agent + command: agent + environment: + - WOODPECKER_SERVER=woodpecker-server:9000 + - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET:-woodpecker-agent-secret-key} + - WOODPECKER_MAX_WORKFLOWS=4 + - WOODPECKER_LOG_LEVEL=info + # Backend für Container-Ausführung + - WOODPECKER_BACKEND=docker + - DOCKER_HOST=unix:///var/run/docker.sock + # Extra hosts für Pipeline-Container (damit sie macmini erreichen) + - WOODPECKER_BACKEND_DOCKER_EXTRA_HOSTS=macmini:192.168.178.100,gitea:192.168.178.100 + # Nutze das gleiche Netzwerk für Pipeline-Container + - WOODPECKER_BACKEND_DOCKER_NETWORK=breakpilot-dev_breakpilot-pwa-network + # Docker-Socket für Build-Steps (Host-Docker statt DinD) + - WOODPECKER_BACKEND_DOCKER_VOLUMES=/var/run/docker.sock:/var/run/docker.sock + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - woodpecker-server + networks: + - breakpilot-pwa-network + restart: unless-stopped + +volumes: + # Woodpecker CI Data + woodpecker_data: + driver: local + # Vault Data + vault_data: + driver: local + # Vault Agent Config (role-id, secret-id, token) + vault_agent_config: + driver: local + # Vault-managed SSL Certificates + vault_certs: + driver: local + breakpilot_pwa_data: + driver: local + # Embedding Service Model Cache + embedding_models: + driver: local + # Valkey Session Cache + valkey_data: + driver: local + dsms_data: + driver: local + klausur_uploads: + driver: local + eh_uploads: + driver: local + ocr_labeling: + driver: local + # PaddleOCR Model Cache (persist across container restarts) + paddle_models: + driver: local + # PaddleOCR Service Model Cache (x86_64 emulation) + paddleocr_models: + driver: local + qdrant_data: + driver: local + minio_data: + driver: local + synapse_data: + driver: local + synapse_db_data: + driver: local + # Jitsi Volumes + jitsi_web_config: + driver: local + jitsi_web_crontabs: + driver: local + jitsi_transcripts: + driver: local + jitsi_prosody_config: + driver: local + jitsi_prosody_plugins: + driver: local + jitsi_jicofo_config: + driver: local + jitsi_jvb_config: + driver: local + # Jibri Recording Volumes + jibri_recordings: + driver: local + # Transcription Worker Volumes + transcription_models: + driver: local + transcription_temp: + driver: local + # ERPNext Volumes + erpnext_db_data: + driver: local + erpnext_redis_queue_data: + driver: local + erpnext_redis_cache_data: + driver: local + erpnext_sites: + driver: local + erpnext_logs: + driver: local + # GeoEdu Service Volumes + geo_osm_data: + driver: local + geo_dem_data: + driver: local + geo_tile_cache: + driver: local + geo_aoi_bundles: + driver: local + # Voice Service Volumes (transient sessions only) + voice_session_data: + driver: local + # Gitea Volumes + gitea_data: + driver: local + gitea_config: + driver: local + gitea_runner_data: + driver: local + +networks: + breakpilot-pwa-network: + driver: bridge diff --git a/night-scheduler/Dockerfile b/night-scheduler/Dockerfile new file mode 100644 index 0000000..9ba9c93 --- /dev/null +++ b/night-scheduler/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.11-slim + +# Docker CLI installieren (für docker compose Befehle) +RUN apt-get update && apt-get install -y \ + curl \ + gnupg \ + lsb-release \ + && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list \ + && apt-get update \ + && apt-get install -y docker-ce-cli docker-compose-plugin \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Python Dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Anwendung kopieren +COPY scheduler.py . + +# Config-Verzeichnis +RUN mkdir -p /config + +# Port für REST-API +EXPOSE 8096 + +# Start +CMD ["python", "scheduler.py"] diff --git a/night-scheduler/config/night-mode.json b/night-scheduler/config/night-mode.json new file mode 100644 index 0000000..31cf278 --- /dev/null +++ b/night-scheduler/config/night-mode.json @@ -0,0 +1,8 @@ +{ + "enabled": false, + "shutdown_time": "22:00", + "startup_time": "06:00", + "last_action": null, + "last_action_time": null, + "excluded_services": ["night-scheduler", "nginx"] +} diff --git a/night-scheduler/requirements.txt b/night-scheduler/requirements.txt new file mode 100644 index 0000000..752cd17 --- /dev/null +++ b/night-scheduler/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +pydantic==2.5.3 + +# Testing +pytest==8.0.0 +pytest-asyncio==0.23.0 +httpx==0.26.0 diff --git a/night-scheduler/scheduler.py b/night-scheduler/scheduler.py new file mode 100644 index 0000000..33d575a --- /dev/null +++ b/night-scheduler/scheduler.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +""" +Night Scheduler - Leichtgewichtiger Scheduler für Nachtabschaltung + +Prüft jede Minute die Konfiguration und führt docker compose up/down aus. +REST-API auf Port 8096 für Dashboard-Zugriff. +""" + +import json +import os +import subprocess +import asyncio +from datetime import datetime, time, timedelta +from pathlib import Path +from typing import Optional +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field + +# Konfiguration +CONFIG_PATH = Path("/config/night-mode.json") +COMPOSE_FILE = Path("/app/docker-compose.yml") +COMPOSE_PROJECT = os.getenv("COMPOSE_PROJECT_NAME", "breakpilot-pwa") + +# Services die NICHT gestoppt werden sollen +EXCLUDED_SERVICES = {"night-scheduler", "nginx"} + + +class NightModeConfig(BaseModel): + """Konfiguration für den Nachtmodus""" + enabled: bool = False + shutdown_time: str = "22:00" + startup_time: str = "06:00" + last_action: Optional[str] = None # "shutdown" oder "startup" + last_action_time: Optional[str] = None + excluded_services: list[str] = Field(default_factory=lambda: list(EXCLUDED_SERVICES)) + + +class NightModeStatus(BaseModel): + """Status-Response für die API""" + config: NightModeConfig + current_time: str + next_action: Optional[str] = None # "shutdown" oder "startup" + next_action_time: Optional[str] = None + time_until_next_action: Optional[str] = None + services_status: dict[str, str] = Field(default_factory=dict) + + +class ExecuteRequest(BaseModel): + """Request für sofortige Ausführung""" + action: str # "start" oder "stop" + + +def load_config() -> NightModeConfig: + """Lädt die Konfiguration aus der JSON-Datei""" + if CONFIG_PATH.exists(): + try: + with open(CONFIG_PATH) as f: + data = json.load(f) + return NightModeConfig(**data) + except (json.JSONDecodeError, Exception) as e: + print(f"Fehler beim Laden der Konfiguration: {e}") + return NightModeConfig() + + +def save_config(config: NightModeConfig) -> None: + """Speichert die Konfiguration in die JSON-Datei""" + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(CONFIG_PATH, "w") as f: + json.dump(config.model_dump(), f, indent=2) + + +def parse_time(time_str: str) -> time: + """Parst einen Zeit-String im Format HH:MM""" + parts = time_str.split(":") + return time(int(parts[0]), int(parts[1])) + + +def get_services_to_manage() -> list[str]: + """Ermittelt alle Services, die verwaltet werden sollen""" + try: + result = subprocess.run( + ["docker", "compose", "-f", str(COMPOSE_FILE), "config", "--services"], + capture_output=True, + text=True, + timeout=30 + ) + if result.returncode == 0: + services = result.stdout.strip().split("\n") + config = load_config() + excluded = set(config.excluded_services) + return [s for s in services if s and s not in excluded] + except Exception as e: + print(f"Fehler beim Ermitteln der Services: {e}") + return [] + + +def get_services_status() -> dict[str, str]: + """Ermittelt den Status aller Services""" + status = {} + try: + result = subprocess.run( + ["docker", "compose", "-f", str(COMPOSE_FILE), "ps", "--format", "json"], + capture_output=True, + text=True, + timeout=30 + ) + if result.returncode == 0 and result.stdout.strip(): + # Docker compose ps gibt JSON-Lines aus + for line in result.stdout.strip().split("\n"): + if line: + try: + container = json.loads(line) + service = container.get("Service", container.get("Name", "unknown")) + state = container.get("State", container.get("Status", "unknown")) + status[service] = state + except json.JSONDecodeError: + continue + except Exception as e: + print(f"Fehler beim Abrufen des Service-Status: {e}") + return status + + +def execute_docker_command(action: str) -> tuple[bool, str]: + """ + Führt docker compose Befehl aus. + + Args: + action: "start" oder "stop" + + Returns: + (success, message) + """ + services = get_services_to_manage() + if not services: + return False, "Keine Services zum Verwalten gefunden" + + if action == "stop": + cmd = ["docker", "compose", "-f", str(COMPOSE_FILE), "stop"] + services + elif action == "start": + cmd = ["docker", "compose", "-f", str(COMPOSE_FILE), "start"] + services + else: + return False, f"Unbekannte Aktion: {action}" + + try: + print(f"Führe aus: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + + if result.returncode == 0: + return True, f"Aktion '{action}' erfolgreich ausgeführt für {len(services)} Services" + else: + return False, f"Fehler: {result.stderr}" + except subprocess.TimeoutExpired: + return False, "Timeout beim Ausführen des Befehls" + except Exception as e: + return False, f"Ausnahme: {str(e)}" + + +def calculate_next_action(config: NightModeConfig) -> tuple[Optional[str], Optional[datetime], Optional[timedelta]]: + """ + Berechnet die nächste Aktion basierend auf der aktuellen Zeit. + + Returns: + (action, next_time, time_until) + """ + if not config.enabled: + return None, None, None + + now = datetime.now() + today = now.date() + + shutdown_time = parse_time(config.shutdown_time) + startup_time = parse_time(config.startup_time) + + shutdown_dt = datetime.combine(today, shutdown_time) + startup_dt = datetime.combine(today, startup_time) + + # Wenn startup vor shutdown ist, bedeutet das über Mitternacht + # z.B. shutdown 22:00, startup 06:00 + if startup_time < shutdown_time: + # Wir sind in der Nacht + if now.time() < startup_time: + # Vor Startup-Zeit -> nächste Aktion ist Startup heute + return "startup", startup_dt, startup_dt - now + elif now.time() < shutdown_time: + # Zwischen Startup und Shutdown -> nächste Aktion ist Shutdown heute + return "shutdown", shutdown_dt, shutdown_dt - now + else: + # Nach Shutdown -> nächste Aktion ist Startup morgen + next_startup = startup_dt + timedelta(days=1) + return "startup", next_startup, next_startup - now + else: + # Startup nach Shutdown am selben Tag (ungewöhnlich, aber unterstützt) + if now.time() < shutdown_time: + return "shutdown", shutdown_dt, shutdown_dt - now + elif now.time() < startup_time: + return "startup", startup_dt, startup_dt - now + else: + next_shutdown = shutdown_dt + timedelta(days=1) + return "shutdown", next_shutdown, next_shutdown - now + + +def format_timedelta(td: timedelta) -> str: + """Formatiert ein timedelta als lesbaren String""" + total_seconds = int(td.total_seconds()) + hours, remainder = divmod(total_seconds, 3600) + minutes, _ = divmod(remainder, 60) + + if hours > 0: + return f"{hours}h {minutes}min" + return f"{minutes}min" + + +async def scheduler_loop(): + """Haupt-Scheduler-Schleife, prüft jede Minute""" + print("Scheduler-Loop gestartet") + + while True: + try: + config = load_config() + + if config.enabled: + now = datetime.now() + current_time = now.time() + + shutdown_time = parse_time(config.shutdown_time) + startup_time = parse_time(config.startup_time) + + # Prüfe ob Shutdown-Zeit erreicht + if (current_time.hour == shutdown_time.hour and + current_time.minute == shutdown_time.minute): + + if config.last_action != "shutdown" or ( + config.last_action_time and + datetime.fromisoformat(config.last_action_time).date() < now.date() + ): + print(f"Shutdown-Zeit erreicht: {config.shutdown_time}") + success, msg = execute_docker_command("stop") + print(f"Shutdown: {msg}") + + config.last_action = "shutdown" + config.last_action_time = now.isoformat() + save_config(config) + + # Prüfe ob Startup-Zeit erreicht + elif (current_time.hour == startup_time.hour and + current_time.minute == startup_time.minute): + + if config.last_action != "startup" or ( + config.last_action_time and + datetime.fromisoformat(config.last_action_time).date() < now.date() + ): + print(f"Startup-Zeit erreicht: {config.startup_time}") + success, msg = execute_docker_command("start") + print(f"Startup: {msg}") + + config.last_action = "startup" + config.last_action_time = now.isoformat() + save_config(config) + + except Exception as e: + print(f"Fehler in Scheduler-Loop: {e}") + + # Warte 60 Sekunden + await asyncio.sleep(60) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Lifecycle-Manager für FastAPI""" + # Startup: Starte den Scheduler + task = asyncio.create_task(scheduler_loop()) + yield + # Shutdown: Stoppe den Scheduler + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +# FastAPI App +app = FastAPI( + title="Night Scheduler API", + description="API für die Dashboard-gesteuerte Nachtabschaltung", + version="1.0.0", + lifespan=lifespan +) + +# CORS für Admin-Dashboard +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health") +async def health(): + """Health-Check-Endpoint""" + return {"status": "healthy", "service": "night-scheduler"} + + +@app.get("/api/night-mode", response_model=NightModeStatus) +async def get_status(): + """Gibt den aktuellen Status und die Konfiguration zurück""" + config = load_config() + now = datetime.now() + + next_action, next_time, time_until = calculate_next_action(config) + + return NightModeStatus( + config=config, + current_time=now.strftime("%H:%M:%S"), + next_action=next_action, + next_action_time=next_time.strftime("%H:%M") if next_time else None, + time_until_next_action=format_timedelta(time_until) if time_until else None, + services_status=get_services_status() + ) + + +@app.post("/api/night-mode", response_model=NightModeConfig) +async def update_config(new_config: NightModeConfig): + """Aktualisiert die Nachtmodus-Konfiguration""" + # Validiere Zeitformate + try: + parse_time(new_config.shutdown_time) + parse_time(new_config.startup_time) + except (ValueError, IndexError): + raise HTTPException(status_code=400, detail="Ungültiges Zeitformat. Erwartet: HH:MM") + + # Behalte last_action bei, wenn nicht explizit gesetzt + if new_config.last_action is None: + old_config = load_config() + new_config.last_action = old_config.last_action + new_config.last_action_time = old_config.last_action_time + + save_config(new_config) + return new_config + + +@app.post("/api/night-mode/execute") +async def execute_action(request: ExecuteRequest): + """Führt eine Aktion sofort aus (start/stop)""" + if request.action not in ["start", "stop"]: + raise HTTPException(status_code=400, detail="Aktion muss 'start' oder 'stop' sein") + + success, message = execute_docker_command(request.action) + + if success: + # Aktualisiere last_action + config = load_config() + config.last_action = "startup" if request.action == "start" else "shutdown" + config.last_action_time = datetime.now().isoformat() + save_config(config) + + return {"success": True, "message": message} + else: + raise HTTPException(status_code=500, detail=message) + + +@app.get("/api/night-mode/services") +async def get_services(): + """Gibt die Liste aller verwaltbaren Services zurück""" + return { + "all_services": get_services_to_manage(), + "excluded_services": list(load_config().excluded_services), + "status": get_services_status() + } + + +@app.get("/api/night-mode/logs") +async def get_logs(): + """Gibt die letzten Aktionen zurück""" + config = load_config() + return { + "last_action": config.last_action, + "last_action_time": config.last_action_time, + "enabled": config.enabled + } + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8096) diff --git a/night-scheduler/tests/__init__.py b/night-scheduler/tests/__init__.py new file mode 100644 index 0000000..eb0fc7a --- /dev/null +++ b/night-scheduler/tests/__init__.py @@ -0,0 +1 @@ +# Night Scheduler Tests diff --git a/night-scheduler/tests/test_scheduler.py b/night-scheduler/tests/test_scheduler.py new file mode 100644 index 0000000..e5c9d4f --- /dev/null +++ b/night-scheduler/tests/test_scheduler.py @@ -0,0 +1,342 @@ +""" +Tests für den Night Scheduler + +Unit Tests für: +- Konfiguration laden/speichern +- Zeit-Parsing +- Nächste Aktion berechnen +- API Endpoints +""" + +import json +import pytest +from datetime import datetime, time, timedelta +from pathlib import Path +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient + +# Importiere die zu testenden Funktionen +import sys +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from scheduler import ( + app, + NightModeConfig, + NightModeStatus, + parse_time, + calculate_next_action, + format_timedelta, + load_config, + save_config, + CONFIG_PATH, +) + + +# Test Client für FastAPI +client = TestClient(app) + + +class TestParseTime: + """Tests für die parse_time Funktion""" + + def test_parse_time_valid_morning(self): + """Gültige Morgenzeit parsen""" + result = parse_time("06:00") + assert result.hour == 6 + assert result.minute == 0 + + def test_parse_time_valid_evening(self): + """Gültige Abendzeit parsen""" + result = parse_time("22:30") + assert result.hour == 22 + assert result.minute == 30 + + def test_parse_time_midnight(self): + """Mitternacht parsen""" + result = parse_time("00:00") + assert result.hour == 0 + assert result.minute == 0 + + def test_parse_time_end_of_day(self): + """23:59 parsen""" + result = parse_time("23:59") + assert result.hour == 23 + assert result.minute == 59 + + +class TestFormatTimedelta: + """Tests für die format_timedelta Funktion""" + + def test_format_hours_and_minutes(self): + """Stunden und Minuten formatieren""" + td = timedelta(hours=4, minutes=23) + result = format_timedelta(td) + assert result == "4h 23min" + + def test_format_only_minutes(self): + """Nur Minuten formatieren""" + td = timedelta(minutes=45) + result = format_timedelta(td) + assert result == "45min" + + def test_format_zero(self): + """Null formatieren""" + td = timedelta(minutes=0) + result = format_timedelta(td) + assert result == "0min" + + def test_format_many_hours(self): + """Viele Stunden formatieren""" + td = timedelta(hours=15, minutes=30) + result = format_timedelta(td) + assert result == "15h 30min" + + +class TestCalculateNextAction: + """Tests für die calculate_next_action Funktion""" + + def test_disabled_returns_none(self): + """Deaktivierter Modus gibt None zurück""" + config = NightModeConfig(enabled=False) + action, next_time, time_until = calculate_next_action(config) + assert action is None + assert next_time is None + assert time_until is None + + def test_before_shutdown_time(self): + """Vor Shutdown-Zeit: Nächste Aktion ist Shutdown""" + config = NightModeConfig( + enabled=True, + shutdown_time="22:00", + startup_time="06:00" + ) + + # Mock datetime.now() auf 18:00 + with patch('scheduler.datetime') as mock_dt: + mock_now = datetime(2026, 2, 9, 18, 0, 0) + mock_dt.now.return_value = mock_now + mock_dt.combine = datetime.combine + + action, next_time, time_until = calculate_next_action(config) + assert action == "shutdown" + assert next_time is not None + assert next_time.hour == 22 + assert next_time.minute == 0 + + def test_after_shutdown_before_midnight(self): + """Nach Shutdown, vor Mitternacht: Nächste Aktion ist Startup morgen""" + config = NightModeConfig( + enabled=True, + shutdown_time="22:00", + startup_time="06:00" + ) + + with patch('scheduler.datetime') as mock_dt: + mock_now = datetime(2026, 2, 9, 23, 0, 0) + mock_dt.now.return_value = mock_now + mock_dt.combine = datetime.combine + + action, next_time, time_until = calculate_next_action(config) + assert action == "startup" + assert next_time is not None + # Startup sollte am nächsten Tag sein + assert next_time.day == 10 + + def test_early_morning_before_startup(self): + """Früher Morgen vor Startup: Nächste Aktion ist Startup heute""" + config = NightModeConfig( + enabled=True, + shutdown_time="22:00", + startup_time="06:00" + ) + + with patch('scheduler.datetime') as mock_dt: + mock_now = datetime(2026, 2, 9, 4, 0, 0) + mock_dt.now.return_value = mock_now + mock_dt.combine = datetime.combine + + action, next_time, time_until = calculate_next_action(config) + assert action == "startup" + assert next_time is not None + assert next_time.hour == 6 + + +class TestNightModeConfig: + """Tests für das NightModeConfig Model""" + + def test_default_config(self): + """Standard-Konfiguration erstellen""" + config = NightModeConfig() + assert config.enabled is False + assert config.shutdown_time == "22:00" + assert config.startup_time == "06:00" + assert config.last_action is None + assert "night-scheduler" in config.excluded_services + + def test_config_with_values(self): + """Konfiguration mit Werten erstellen""" + config = NightModeConfig( + enabled=True, + shutdown_time="23:00", + startup_time="07:30", + last_action="startup", + last_action_time="2026-02-09T07:30:00" + ) + assert config.enabled is True + assert config.shutdown_time == "23:00" + assert config.startup_time == "07:30" + assert config.last_action == "startup" + + +class TestAPIEndpoints: + """Tests für die API Endpoints""" + + def test_health_endpoint(self): + """Health Endpoint gibt Status zurück""" + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert data["service"] == "night-scheduler" + + def test_get_status_endpoint(self): + """GET /api/night-mode gibt Status zurück""" + with patch('scheduler.load_config') as mock_load: + mock_load.return_value = NightModeConfig() + + response = client.get("/api/night-mode") + assert response.status_code == 200 + data = response.json() + assert "config" in data + assert "current_time" in data + + def test_update_config_endpoint(self): + """POST /api/night-mode aktualisiert Konfiguration""" + with patch('scheduler.save_config') as mock_save: + with patch('scheduler.load_config') as mock_load: + mock_load.return_value = NightModeConfig() + + new_config = { + "enabled": True, + "shutdown_time": "23:00", + "startup_time": "07:00", + "excluded_services": ["night-scheduler", "nginx"] + } + + response = client.post("/api/night-mode", json=new_config) + assert response.status_code == 200 + mock_save.assert_called_once() + + def test_update_config_invalid_time(self): + """POST /api/night-mode mit ungültiger Zeit gibt Fehler""" + with patch('scheduler.load_config') as mock_load: + mock_load.return_value = NightModeConfig() + + new_config = { + "enabled": True, + "shutdown_time": "invalid", + "startup_time": "06:00", + "excluded_services": [] + } + + response = client.post("/api/night-mode", json=new_config) + assert response.status_code == 400 + + def test_execute_stop_endpoint(self): + """POST /api/night-mode/execute mit stop""" + with patch('scheduler.execute_docker_command') as mock_exec: + with patch('scheduler.load_config') as mock_load: + with patch('scheduler.save_config'): + mock_exec.return_value = (True, "Services gestoppt") + mock_load.return_value = NightModeConfig() + + response = client.post( + "/api/night-mode/execute", + json={"action": "stop"} + ) + assert response.status_code == 200 + mock_exec.assert_called_once_with("stop") + + def test_execute_start_endpoint(self): + """POST /api/night-mode/execute mit start""" + with patch('scheduler.execute_docker_command') as mock_exec: + with patch('scheduler.load_config') as mock_load: + with patch('scheduler.save_config'): + mock_exec.return_value = (True, "Services gestartet") + mock_load.return_value = NightModeConfig() + + response = client.post( + "/api/night-mode/execute", + json={"action": "start"} + ) + assert response.status_code == 200 + mock_exec.assert_called_once_with("start") + + def test_execute_invalid_action(self): + """POST /api/night-mode/execute mit ungültiger Aktion""" + response = client.post( + "/api/night-mode/execute", + json={"action": "invalid"} + ) + assert response.status_code == 400 + + def test_get_services_endpoint(self): + """GET /api/night-mode/services gibt Services zurück""" + with patch('scheduler.get_services_to_manage') as mock_services: + with patch('scheduler.get_services_status') as mock_status: + with patch('scheduler.load_config') as mock_load: + mock_services.return_value = ["backend", "frontend"] + mock_status.return_value = {"backend": "running", "frontend": "running"} + mock_load.return_value = NightModeConfig() + + response = client.get("/api/night-mode/services") + assert response.status_code == 200 + data = response.json() + assert "all_services" in data + assert "excluded_services" in data + assert "status" in data + + def test_get_logs_endpoint(self): + """GET /api/night-mode/logs gibt Logs zurück""" + with patch('scheduler.load_config') as mock_load: + mock_load.return_value = NightModeConfig( + last_action="shutdown", + last_action_time="2026-02-09T22:00:00" + ) + + response = client.get("/api/night-mode/logs") + assert response.status_code == 200 + data = response.json() + assert data["last_action"] == "shutdown" + + +class TestConfigPersistence: + """Tests für Konfigurations-Persistenz""" + + def test_load_missing_config_returns_default(self): + """Fehlende Konfiguration gibt Standard zurück""" + with patch.object(CONFIG_PATH, 'exists', return_value=False): + config = load_config() + assert config.enabled is False + assert config.shutdown_time == "22:00" + + def test_save_and_load_config(self, tmp_path): + """Konfiguration speichern und laden""" + config_file = tmp_path / "night-mode.json" + + with patch('scheduler.CONFIG_PATH', config_file): + original = NightModeConfig( + enabled=True, + shutdown_time="21:00", + startup_time="05:30" + ) + save_config(original) + + loaded = load_config() + assert loaded.enabled == original.enabled + assert loaded.shutdown_time == original.shutdown_time + assert loaded.startup_time == original.startup_time + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])