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