diff --git a/app.py b/app.py index d907a0a..46a886d 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from pathlib import Path -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import hashlib import hmac import json @@ -22,7 +22,15 @@ from fastapi.responses import FileResponse LOG_PATH = Path("activation.log") DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL") -AGENT_POLL_INTERVAL_SECONDS = int(os.environ.get("TUXFLOTTE_AGENT_POLL_INTERVAL", "300")) +# Standard 6h (26.08.2026-Planung) - Jitter kommt bewusst nicht von hier, +# sondern aus dem Agenten selbst (agent.py), der pro Geraet zufaellig +# streut, damit nicht alle Geraete exakt gleichzeitig einchecken. +AGENT_POLL_INTERVAL_SECONDS = int(os.environ.get("TUXFLOTTE_AGENT_POLL_INTERVAL", str(6 * 60 * 60))) +# Debug-Modus: kurzes Intervall zum Testen, faellt nach DEBUG_MODE_DURATION_HOURS +# automatisch auf AGENT_POLL_INTERVAL_SECONDS zurueck - rein durch Zeitvergleich +# bei jedem Checkin, kein Cron/Aufraeum-Job noetig (siehe agent_checkin()). +DEBUG_MODE_POLL_INTERVAL_SECONDS = 60 +DEBUG_MODE_DURATION_HOURS = 6 ANSIBLE_CONTENT_REPO = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO", "") ADMIN_TOKEN = os.environ.get("TUXFLOTTE_ADMIN_TOKEN", "") KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "") @@ -77,8 +85,25 @@ class AgentBootstrapRequest(BaseModel): device_id: str +class AgentHealthPayload(BaseModel): + """ + Health-Metriken (26.08.2026-Planung) - alle optional, damit ein + fehlgeschlagener Einzelwert (z.B. load average auf ungewoehnlichen + Systemen) nicht den ganzen Checkin blockiert. Werte kommen direkt aus + Python-Stdlib-Aufrufen im Agenten (kein neues Paket noetig). + """ + + disk_used_percent: float | None = None + ram_used_percent: float | None = None + uptime_seconds: int | None = None + load_1m: float | None = None + load_5m: float | None = None + load_15m: float | None = None + + class AgentCheckinRequest(BaseModel): device_id: str + health: AgentHealthPayload | None = None class AgentReportEntry(BaseModel): @@ -1360,6 +1385,29 @@ def fetch_device_gruppen(device_id: str): ) return [{"id": str(gruppe_id), "name": name} for gruppe_id, name in cur.fetchall()] +# Debug-Modus + Health-Spalten (26.08.2026-Planung) - gemeinsam von +# fetch_devices_for_organization() und fetch_all_devices() gebraucht, daher +# als Konstante statt zweimal ausgeschrieben. +GERAETE_HEALTH_SPALTEN_SQL = """d.debug_mode_until, d.health_disk_used_percent, d.health_ram_used_percent, + d.health_uptime_seconds, d.health_load_1m, d.health_load_5m, d.health_load_15m, + d.health_collected_at""" + + +def _health_felder_aus_row(debug_mode_until, disk, ram, uptime, load1, load5, load15, collected_at) -> dict: + return { + "debug_mode_until": debug_mode_until.isoformat() if debug_mode_until is not None else None, + "health": { + "disk_used_percent": disk, + "ram_used_percent": ram, + "uptime_seconds": uptime, + "load_1m": load1, + "load_5m": load5, + "load_15m": load15, + "collected_at": collected_at.isoformat() if collected_at is not None else None, + } if collected_at is not None else None, + } + + def fetch_devices_for_organization(organization_id: str): """ Für Kundenplattforms Geräteliste + Besitz-Validierung (siehe ADR-0011): @@ -1370,9 +1418,9 @@ def fetch_devices_for_organization(organization_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( - """ + f""" SELECT d.id, d.hostname, d.device_fingerprint, d.agent_last_checkin, - d.deprovisioned_at, d.oe_id, oe.name + d.deprovisioned_at, d.oe_id, oe.name, {GERAETE_HEALTH_SPALTEN_SQL} FROM devices d LEFT JOIN organisationseinheiten oe ON oe.id = d.oe_id WHERE d.organization_id = %s AND d.archived_at IS NULL @@ -1393,8 +1441,12 @@ def fetch_devices_for_organization(organization_id: str): ), "oe_id": str(oe_id) if oe_id is not None else None, "oe_name": oe_name, + **_health_felder_aus_row(*health_spalten), } - for device_id, hostname, fingerprint, last_checkin, deprovisioned_at, oe_id, oe_name in cur.fetchall() + for ( + device_id, hostname, fingerprint, last_checkin, deprovisioned_at, oe_id, oe_name, + *health_spalten, + ) in cur.fetchall() ] def fetch_all_devices(): @@ -1407,9 +1459,10 @@ def fetch_all_devices(): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( - """ + f""" SELECT d.id, d.hostname, d.device_fingerprint, d.agent_last_checkin, - d.organization_id, o.name, d.deprovisioned_at, d.oe_id, oe.name + d.organization_id, o.name, d.deprovisioned_at, d.oe_id, oe.name, + {GERAETE_HEALTH_SPALTEN_SQL} FROM devices d JOIN organizations o ON o.id = d.organization_id LEFT JOIN organisationseinheiten oe ON oe.id = d.oe_id @@ -1432,11 +1485,13 @@ def fetch_all_devices(): ), "oe_id": str(oe_id) if oe_id is not None else None, "oe_name": oe_name, + **_health_felder_aus_row(*health_spalten), } for ( device_id, hostname, fingerprint, last_checkin, organization_id, organization_name, deprovisioned_at, oe_id, oe_name, + *health_spalten, ) in cur.fetchall() ] @@ -2577,19 +2632,57 @@ def agent_checkin( "message": "Ungültiges Agent-Secret.", } + health = payload.health + jetzt_ts = datetime.now(timezone.utc) + with get_database_connection() as conn: with conn.cursor() as cur: + # Ein kombiniertes UPDATE ... RETURNING statt zwei Roundtrips - + # debug_mode_until wird direkt mitgeliefert, um das effektive + # Intervall unten zu bestimmen (siehe ADR: Debug-Modus faellt + # rein durch Zeitvergleich automatisch zurueck, kein Cron noetig). + # Die health_*-Spalten werden nur ueberschrieben, wenn dieser + # Checkin ueberhaupt ein health-Objekt mitschickt - fehlt es + # (z.B. Sammel-Fehler im Agenten), bleibt der letzte bekannte + # Stand stehen statt auf NULL zurueckzufallen. cur.execute( - "UPDATE devices SET agent_last_checkin = %s WHERE id = %s", - (datetime.now(timezone.utc), payload.device_id), + """ + UPDATE devices + SET agent_last_checkin = %(jetzt)s, + health_disk_used_percent = CASE WHEN %(hat_health)s THEN %(disk)s ELSE health_disk_used_percent END, + health_ram_used_percent = CASE WHEN %(hat_health)s THEN %(ram)s ELSE health_ram_used_percent END, + health_uptime_seconds = CASE WHEN %(hat_health)s THEN %(uptime)s ELSE health_uptime_seconds END, + health_load_1m = CASE WHEN %(hat_health)s THEN %(load1)s ELSE health_load_1m END, + health_load_5m = CASE WHEN %(hat_health)s THEN %(load5)s ELSE health_load_5m END, + health_load_15m = CASE WHEN %(hat_health)s THEN %(load15)s ELSE health_load_15m END, + health_collected_at = CASE WHEN %(hat_health)s THEN %(jetzt)s ELSE health_collected_at END + WHERE id = %(device_id)s + RETURNING debug_mode_until + """, + { + "jetzt": jetzt_ts, + "hat_health": health is not None, + "disk": health.disk_used_percent if health else None, + "ram": health.ram_used_percent if health else None, + "uptime": health.uptime_seconds if health else None, + "load1": health.load_1m if health else None, + "load5": health.load_5m if health else None, + "load15": health.load_15m if health else None, + "device_id": payload.device_id, + }, ) + row = cur.fetchone() + debug_mode_until = row[0] if row else None + + im_debug_modus = debug_mode_until is not None and debug_mode_until > jetzt_ts + effektives_intervall = DEBUG_MODE_POLL_INTERVAL_SECONDS if im_debug_modus else AGENT_POLL_INTERVAL_SECONDS return { "success": True, "blueprints": fetch_assigned_blueprints(payload.device_id), "auftraege": fetch_auftragskatalog_state(payload.device_id), "ansible_repo": ANSIBLE_CONTENT_REPO, - "poll_interval_seconds": AGENT_POLL_INTERVAL_SECONDS, + "poll_interval_seconds": effektives_intervall, } @@ -3487,3 +3580,41 @@ def remove_device_gruppe_endpoint(device_id: str, gruppe_id: str, authorization: remove_device_from_gruppe(device_id, gruppe_id) return {"success": True} + + +@app.post("/api/v1/devices/{device_id}/debug-mode") +def aktiviere_debug_modus(device_id: str, authorization: str | None = Header(default=None)): + """ + Debug-Modus (26.08.2026-Planung): setzt debug_mode_until auf jetzt+6h - + agent_checkin() vergleicht das bei jedem Checkin und liefert solange das + kurze Debug-Intervall statt des Standardintervalls, faellt danach von + selbst zurueck (kein Cron noetig). Org-Zugehoerigkeitspruefung ist wie + ueberall Aufgabe der aufrufenden Kundenplattform (find_device_in_ + organization()), nicht dieses Endpunkts. + """ + + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + debug_bis = datetime.now(timezone.utc) + timedelta(hours=DEBUG_MODE_DURATION_HOURS) + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE devices SET debug_mode_until = %s WHERE id = %s", + (debug_bis, device_id), + ) + + return {"success": True, "debug_mode_until": debug_bis.isoformat()} + + +@app.delete("/api/v1/devices/{device_id}/debug-mode") +def deaktiviere_debug_modus(device_id: str, authorization: str | None = Header(default=None)): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute("UPDATE devices SET debug_mode_until = NULL WHERE id = %s", (device_id,)) + + return {"success": True} diff --git a/migrations/0022_agent_debug_mode_und_health.sql b/migrations/0022_agent_debug_mode_und_health.sql new file mode 100644 index 0000000..d822685 --- /dev/null +++ b/migrations/0022_agent_debug_mode_und_health.sql @@ -0,0 +1,17 @@ +-- Agent-Intervall/Debug-Modus/Health-Monitoring (26.08.2026-Planung, siehe +-- neue ADR). debug_mode_until ist der einzige Zustand fuer den Debug-Modus - +-- der Checkin-Handler vergleicht ihn bei jeder Anfrage mit CURRENT_TIMESTAMP, +-- kein separater Cron/Aufraeum-Job noetig, das Zurueckfallen auf das +-- Standardintervall passiert von selbst. +-- +-- Health-Spalten halten bewusst nur den jeweils letzten Stand (wie schon +-- agent_last_checkin), keine wachsende Historie - konsistent mit dem +-- Plattenplatz-Bewusstsein an anderer Stelle (siehe iso_builds-Aufraeumung). +ALTER TABLE devices ADD COLUMN debug_mode_until TIMESTAMPTZ; +ALTER TABLE devices ADD COLUMN health_disk_used_percent REAL; +ALTER TABLE devices ADD COLUMN health_ram_used_percent REAL; +ALTER TABLE devices ADD COLUMN health_uptime_seconds BIGINT; +ALTER TABLE devices ADD COLUMN health_load_1m REAL; +ALTER TABLE devices ADD COLUMN health_load_5m REAL; +ALTER TABLE devices ADD COLUMN health_load_15m REAL; +ALTER TABLE devices ADD COLUMN health_collected_at TIMESTAMPTZ;