diff --git a/.claude/rules/night-scheduler.md b/.claude/rules/night-scheduler.md deleted file mode 100644 index 8a001e0..0000000 --- a/.claude/rules/night-scheduler.md +++ /dev/null @@ -1,297 +0,0 @@ -# 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/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 32c12aa..3545192 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -3,7 +3,7 @@ # # Services: # Go: consent-service -# Python: backend-core, voice-service (+ BQAS), embedding-service, night-scheduler +# Python: backend-core, voice-service (+ BQAS), embedding-service # Node.js: admin-core name: CI @@ -46,7 +46,7 @@ jobs: - name: Lint Python services run: | pip install --quiet ruff - for svc in backend-core voice-service night-scheduler embedding-service; do + for svc in backend-core voice-service embedding-service; do if [ -d "$svc" ]; then echo "=== Linting $svc ===" ruff check "$svc/" --output-format=github || true diff --git a/docker-compose.hetzner.yml b/docker-compose.hetzner.yml index 91b21b6..c353384 100644 --- a/docker-compose.hetzner.yml +++ b/docker-compose.hetzner.yml @@ -162,8 +162,6 @@ services: profiles: ["disabled"] gitea-runner: profiles: ["disabled"] - night-scheduler: - profiles: ["disabled"] admin-core: profiles: ["disabled"] pitch-deck: diff --git a/docker-compose.yml b/docker-compose.yml index 7e8a887..55a013c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -582,33 +582,6 @@ services: networks: - breakpilot-network - # ========================================================= - # NIGHT SCHEDULER - # ========================================================= - night-scheduler: - build: - context: ./night-scheduler - dockerfile: Dockerfile - container_name: bp-core-night-scheduler - ports: - - "8096:8096" - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./night-scheduler/config:/config - environment: - COMPOSE_PROJECT_NAME: breakpilot-core - CONTAINER_PATTERN: "bp-*" - EXCLUDED_CONTAINERS: "bp-core-night-scheduler,bp-core-nginx,bp-core-postgres,bp-core-valkey" - healthcheck: - test: ["CMD", "curl", "-f", "http://127.0.0.1:8096/health"] - interval: 30s - timeout: 10s - start_period: 10s - retries: 3 - restart: unless-stopped - networks: - - breakpilot-network - # ========================================================= # ADMIN CORE # ========================================================= diff --git a/night-scheduler/Dockerfile b/night-scheduler/Dockerfile deleted file mode 100644 index 9ba9c93..0000000 --- a/night-scheduler/Dockerfile +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 31cf278..0000000 --- a/night-scheduler/config/night-mode.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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 deleted file mode 100644 index 752cd17..0000000 --- a/night-scheduler/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 826f219..0000000 --- a/night-scheduler/scheduler.py +++ /dev/null @@ -1,402 +0,0 @@ -#!/usr/bin/env python3 -""" -Night Scheduler - Leichtgewichtiger Scheduler für Nachtabschaltung - -3-Projekte-Setup: Verwaltet Container aus breakpilot-core, breakpilot-lehrer, breakpilot-compliance. -Stoppt/Startet Container anhand des Namens-Patterns bp-*. -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-Dateien für alle 3 Projekte -COMPOSE_FILES = { - "core": Path(os.getenv("COMPOSE_FILE_CORE", "/compose/breakpilot-core/docker-compose.yml")), - "lehrer": Path(os.getenv("COMPOSE_FILE_LEHRER", "/compose/breakpilot-lehrer/docker-compose.yml")), - "compliance": Path(os.getenv("COMPOSE_FILE_COMPLIANCE", "/compose/breakpilot-compliance/docker-compose.yml")), -} - -# Container die NICHT gestoppt werden sollen -EXCLUDED_CONTAINERS = {"bp-core-night-scheduler", "bp-core-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 - last_action_time: Optional[str] = None - excluded_services: list[str] = Field(default_factory=lambda: list(EXCLUDED_CONTAINERS)) - - -class NightModeStatus(BaseModel): - """Status-Response für die API""" - config: NightModeConfig - current_time: str - next_action: Optional[str] = None - 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_all_bp_containers() -> list[dict]: - """Ermittelt alle bp-* Container""" - try: - result = subprocess.run( - ["docker", "ps", "-a", "--filter", "name=bp-", "--format", - '{"name":"{{.Names}}","status":"{{.Status}}","state":"{{.State}}"}'], - capture_output=True, text=True, timeout=30 - ) - if result.returncode == 0 and result.stdout.strip(): - containers = [] - for line in result.stdout.strip().split("\n"): - if line: - try: - containers.append(json.loads(line)) - except json.JSONDecodeError: - continue - return containers - except Exception as e: - print(f"Fehler beim Ermitteln der Container: {e}") - return [] - - -def get_services_status() -> dict[str, str]: - """Ermittelt den Status aller bp-* Container""" - containers = get_all_bp_containers() - return {c["name"]: c["state"] for c in containers} - - -def get_services_to_manage() -> list[str]: - """Ermittelt alle Container, die verwaltet werden sollen""" - containers = get_all_bp_containers() - config = load_config() - excluded = set(config.excluded_services) - return [c["name"] for c in containers if c["name"] not in excluded] - - -def execute_docker_command(action: str) -> tuple[bool, str]: - """ - Stoppt/Startet alle bp-* Container (außer excluded). - Reihenfolge: Stop = Lehrer+Compliance zuerst, dann Core. - Start = Core zuerst, dann Lehrer+Compliance. - """ - containers = get_services_to_manage() - if not containers: - return False, "Keine Container zum Verwalten gefunden" - - # Aufteilen nach Projekt - core_containers = [c for c in containers if c.startswith("bp-core-")] - lehrer_containers = [c for c in containers if c.startswith("bp-lehrer-")] - compliance_containers = [c for c in containers if c.startswith("bp-compliance-")] - - errors = [] - total_managed = 0 - - if action == "stop": - # Erst Lehrer + Compliance stoppen, dann Core - for batch_name, batch in [("lehrer", lehrer_containers), ("compliance", compliance_containers), ("core", core_containers)]: - if batch: - try: - cmd = ["docker", "stop"] + batch - print(f"Stoppe {batch_name}: {' '.join(batch)}") - result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) - if result.returncode == 0: - total_managed += len(batch) - else: - errors.append(f"{batch_name}: {result.stderr}") - except Exception as e: - errors.append(f"{batch_name}: {str(e)}") - - elif action == "start": - # Erst Core starten, dann Lehrer + Compliance - for batch_name, batch in [("core", core_containers), ("lehrer", lehrer_containers), ("compliance", compliance_containers)]: - if batch: - try: - cmd = ["docker", "start"] + batch - print(f"Starte {batch_name}: {' '.join(batch)}") - result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) - if result.returncode == 0: - total_managed += len(batch) - else: - errors.append(f"{batch_name}: {result.stderr}") - except Exception as e: - errors.append(f"{batch_name}: {str(e)}") - else: - return False, f"Unbekannte Aktion: {action}" - - if errors: - return False, f"{total_managed} Container verwaltet, Fehler: {'; '.join(errors)}" - - return True, f"Aktion '{action}' erfolgreich für {total_managed} Container (Core + Lehrer + Compliance)" - - -def calculate_next_action(config: NightModeConfig) -> tuple[Optional[str], Optional[datetime], Optional[timedelta]]: - """Berechnet die nächste Aktion basierend auf der aktuellen Zeit.""" - 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) - - if startup_time < shutdown_time: - if now.time() < startup_time: - return "startup", startup_dt, startup_dt - now - elif now.time() < shutdown_time: - return "shutdown", shutdown_dt, shutdown_dt - now - else: - next_startup = startup_dt + timedelta(days=1) - return "startup", next_startup, next_startup - now - else: - 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 (3-Projekte-Modus: Core + Lehrer + Compliance)") - - 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) - - 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) - - 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}") - - await asyncio.sleep(60) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """Lifecycle-Manager für FastAPI""" - task = asyncio.create_task(scheduler_loop()) - yield - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - -app = FastAPI( - title="Night Scheduler API", - description="Nachtabschaltung für 3-Projekte-Setup (Core + Lehrer + Compliance)", - version="2.0.0", - lifespan=lifespan -) - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -@app.get("/health") -async def health(): - """Health-Check-Endpoint""" - containers = get_all_bp_containers() - return { - "status": "healthy", - "service": "night-scheduler", - "mode": "3-projects", - "total_containers": len(containers) - } - - -@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""" - 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") - - 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: - 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 Container zurück""" - all_containers = get_all_bp_containers() - config = load_config() - excluded = set(config.excluded_services) - - # Gruppierung nach Projekt - by_project = {"core": [], "lehrer": [], "compliance": [], "other": []} - for c in all_containers: - name = c["name"] - if name.startswith("bp-core-"): - by_project["core"].append(name) - elif name.startswith("bp-lehrer-"): - by_project["lehrer"].append(name) - elif name.startswith("bp-compliance-"): - by_project["compliance"].append(name) - else: - by_project["other"].append(name) - - return { - "all_services": [c["name"] for c in all_containers], - "by_project": by_project, - "excluded_services": list(excluded), - "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 deleted file mode 100644 index eb0fc7a..0000000 --- a/night-scheduler/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Night Scheduler Tests diff --git a/night-scheduler/tests/test_scheduler.py b/night-scheduler/tests/test_scheduler.py deleted file mode 100644 index e5c9d4f..0000000 --- a/night-scheduler/tests/test_scheduler.py +++ /dev/null @@ -1,342 +0,0 @@ -""" -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"])