ISO-Ablauf nach 7 Tagen: expires_at + periodischer Sweep

Siehe ADR-0014-Nachtrag. Migration 0021 (iso_builds.expires_at),
update_iso_build() setzt expires_at=finished_at+7d bei Erfolg,
cleanup_expired_iso_builds() + stündlicher Sweep-Thread
(run_iso_build_expiry_sweep(), gestartet via @app.on_event('startup')).
Ergänzt die bestehende 'neuer Build löscht alte'-Logik, ersetzt sie nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-26 09:52:00 +02:00
parent 34e5254984
commit 4dea06b0c6
2 changed files with 77 additions and 3 deletions

75
app.py
View File

@ -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"

View File

@ -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;