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