Files
breakpilot-core/night-scheduler/scheduler.py
Benjamin Boenisch ad111d5e69 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>
2026-02-11 23:47:13 +01:00

403 lines
14 KiB
Python

#!/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)