diff --git a/app.py b/app.py index 6fc78fc..4f6ad98 100644 --- a/app.py +++ b/app.py @@ -7,6 +7,7 @@ import os import secrets import subprocess import threading +import time from uuid import UUID, uuid4 import psycopg @@ -44,10 +45,19 @@ MINT_ISO_PATH = str(Path(os.environ.get("TUXFLOTTE_MINT_ISO_PATH", "data/upstrea # zum eigenen Arbeitsverzeichnis. ISO_BUILD_OUTPUT_DIR = Path(os.environ.get("TUXFLOTTE_ISO_BUILD_OUTPUT_DIR", "data/iso-builds")).resolve() ISO_BUILD_TIMEOUT_SECONDS = 1800 +# ISO-Ablauf nach 7 Tagen (Migration 0021): ergaenzt die bestehende +# "neuer Build ersetzt alten"-Aufraeumung um eine reine Zeitdimension. +ISO_BUILD_EXPIRY_DAYS = 7 +ISO_BUILD_EXPIRY_SWEEP_INTERVAL_SECONDS = 60 * 60 app = FastAPI(title="Provisioning Activation Server", version="0.1.0") +@app.on_event("startup") +def start_iso_build_expiry_sweep() -> None: + threading.Thread(target=run_iso_build_expiry_sweep, daemon=True).start() + + class ActivationRequest(BaseModel): activation_code: str device_fingerprint: str @@ -2074,7 +2084,7 @@ def recharge_enrollment_session(session_id: str, additional_devices: int, expire def iso_build_row_to_dict(row) -> dict: (build_id, organization_id, status, output_filename, error_message, - created_at, started_at, finished_at) = row + created_at, started_at, finished_at, expires_at) = row return { "id": str(build_id), "organization_id": str(organization_id), @@ -2084,6 +2094,7 @@ def iso_build_row_to_dict(row) -> dict: "created_at": created_at.isoformat(), "started_at": started_at.isoformat() if started_at else None, "finished_at": finished_at.isoformat() if finished_at else None, + "expires_at": expires_at.isoformat() if expires_at else None, } def fetch_iso_build(build_id: str): @@ -2092,7 +2103,7 @@ def fetch_iso_build(build_id: str): cur.execute( """ SELECT id, organization_id, status, output_filename, error_message, - created_at, started_at, finished_at + created_at, started_at, finished_at, expires_at FROM iso_builds WHERE id = %s """, @@ -2107,7 +2118,7 @@ def fetch_iso_builds(organization_id: str): cur.execute( """ SELECT id, organization_id, status, output_filename, error_message, - created_at, started_at, finished_at + created_at, started_at, finished_at, expires_at FROM iso_builds WHERE organization_id = %s ORDER BY created_at DESC @@ -2136,6 +2147,12 @@ def update_iso_build( mark_started: bool = False, mark_finished: bool = False, ): + # expires_at nur bei einem tatsaechlich erfolgreichen Abschluss setzen - + # ein "failed"-Build hat nichts zum Downloaden, das ablaufen koennte + # (dessen Zeile raeumt weiterhin ausschliesslich cleanup_old_iso_builds() + # beim naechsten Bau derselben Organisation auf). + setzt_expiry = mark_finished and status == "completed" + with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( @@ -2146,6 +2163,7 @@ def update_iso_build( error_message = %s {", started_at = CURRENT_TIMESTAMP" if mark_started else ""} {", finished_at = CURRENT_TIMESTAMP" if mark_finished else ""} + {f", expires_at = CURRENT_TIMESTAMP + INTERVAL '{ISO_BUILD_EXPIRY_DAYS} days'" if setzt_expiry else ""} WHERE id = %s """, (status, output_filename, error_message, build_id), @@ -2184,6 +2202,57 @@ def cleanup_old_iso_builds(organization_id: str, keep_build_id) -> None: (organization_id, keep_build_id), ) +def cleanup_expired_iso_builds() -> int: + """ + Zeitbasierte Ergaenzung zu cleanup_old_iso_builds(): loescht Datei + + Zeile jedes abgeschlossenen Builds, dessen expires_at in der + Vergangenheit liegt - unabhaengig davon, ob die Organisation je erneut + baut (die bisherige Logik greift nur beim naechsten Bau derselben Org). + Laeuft periodisch ueber run_iso_build_expiry_sweep(). Gibt die Anzahl + geloeschter Builds zurueck (fuer Logging/Tests). + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, output_filename FROM iso_builds + WHERE status = 'completed' AND expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP + """ + ) + abgelaufene_builds = cur.fetchall() + + for build_id, output_filename in abgelaufene_builds: + if output_filename: + (ISO_BUILD_OUTPUT_DIR / output_filename).unlink(missing_ok=True) + + cur.execute( + """ + DELETE FROM iso_builds + WHERE status = 'completed' AND expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP + """ + ) + + return len(abgelaufene_builds) + +def run_iso_build_expiry_sweep() -> None: + """ + Endlosschleife in einem Daemon-Thread (siehe on_startup unten) - stuendlich + abgelaufene ISO-Builds raeumen. Ein Fehler in einem Durchlauf (z.B. + kurzzeitig nicht erreichbare DB) darf die Schleife nicht beenden, sonst + liefe der Sweep fuer den Rest der Prozesslaufzeit gar nicht mehr. + """ + + while True: + time.sleep(ISO_BUILD_EXPIRY_SWEEP_INTERVAL_SECONDS) + + try: + anzahl = cleanup_expired_iso_builds() + if anzahl: + print(f"[iso-build-expiry] {anzahl} abgelaufene(n) ISO-Build(s) geloescht.") + except Exception as exc: + print(f"[iso-build-expiry] Sweep-Fehler (naechster Versuch in {ISO_BUILD_EXPIRY_SWEEP_INTERVAL_SECONDS}s): {exc}") + def run_iso_build(build_id, organization_id: str, activation_code: str, wifi_ssid: str | None, wifi_psk: str | None) -> None: output_path = ISO_BUILD_OUTPUT_DIR / f"{build_id}.iso" diff --git a/migrations/0021_iso_build_expiry.sql b/migrations/0021_iso_build_expiry.sql new file mode 100644 index 0000000..3ae8ddb --- /dev/null +++ b/migrations/0021_iso_build_expiry.sql @@ -0,0 +1,5 @@ +-- ISO-Ablauf nach 7 Tagen (siehe ADR-Nachtrag zu ADR-0014). Ergaenzt die +-- bestehende "neuer Build ersetzt alten Build derselben Org"-Aufraeumung +-- (cleanup_old_iso_builds()) um eine reine Zeitdimension, die unabhaengig +-- davon greift, ob die Organisation je erneut baut. +ALTER TABLE iso_builds ADD COLUMN expires_at TIMESTAMPTZ;