Initial commit: breakpilot-core - Shared Infrastructure
Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
30
night-scheduler/Dockerfile
Normal file
30
night-scheduler/Dockerfile
Normal file
@@ -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"]
|
||||
8
night-scheduler/config/night-mode.json
Normal file
8
night-scheduler/config/night-mode.json
Normal file
@@ -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"]
|
||||
}
|
||||
8
night-scheduler/requirements.txt
Normal file
8
night-scheduler/requirements.txt
Normal file
@@ -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
|
||||
402
night-scheduler/scheduler.py
Normal file
402
night-scheduler/scheduler.py
Normal file
@@ -0,0 +1,402 @@
|
||||
#!/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)
|
||||
1
night-scheduler/tests/__init__.py
Normal file
1
night-scheduler/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Night Scheduler Tests
|
||||
342
night-scheduler/tests/test_scheduler.py
Normal file
342
night-scheduler/tests/test_scheduler.py
Normal file
@@ -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"])
|
||||
Reference in New Issue
Block a user