diff --git a/app.py b/app.py index 0950989..2643bf0 100644 --- a/app.py +++ b/app.py @@ -5,6 +5,8 @@ import hmac import json import os import secrets +import subprocess +import threading from uuid import UUID, uuid4 import psycopg @@ -22,6 +24,16 @@ 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", "") AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"} + +# Phase 4 des Self-Service-ISO-Plans (siehe tuxflotte-installer/scripts/ +# build_customer_iso.sh). TUXFLOTTE_INSTALLER_DIR ist ein separat auf anode +# geklontes Repo (nicht Teil von provisioning-server selbst), MINT_ISO_PATH +# das einmalig von der offiziellen Downloadseite geholte Quellabbild. +TUXFLOTTE_INSTALLER_DIR = os.environ.get("TUXFLOTTE_INSTALLER_DIR", "/root/tuxflotte-installer") +MINT_ISO_PATH = os.environ.get("TUXFLOTTE_MINT_ISO_PATH", "data/upstream/linuxmint-22.3-cinnamon-64bit.iso") +ISO_BUILD_OUTPUT_DIR = Path(os.environ.get("TUXFLOTTE_ISO_BUILD_OUTPUT_DIR", "data/iso-builds")) +ISO_BUILD_TIMEOUT_SECONDS = 1800 + app = FastAPI(title="Provisioning Activation Server", version="0.1.0") @@ -102,6 +114,12 @@ class CreateEnrollmentSessionRequest(BaseModel): expires_at: str +class CreateIsoBuildRequest(BaseModel): + activation_code: str + wifi_ssid: str | None = None + wifi_psk: str | None = None + + def write_log(entry: dict) -> None: with LOG_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") @@ -1106,6 +1124,157 @@ def revoke_enrollment_session(session_id: str): ) return cur.fetchone() is not None +def iso_build_row_to_dict(row) -> dict: + (build_id, organization_id, status, output_filename, error_message, + created_at, started_at, finished_at) = row + return { + "id": str(build_id), + "organization_id": str(organization_id), + "status": status, + "output_filename": output_filename, + "error_message": error_message, + "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, + } + +def fetch_iso_build(build_id: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, organization_id, status, output_filename, error_message, + created_at, started_at, finished_at + FROM iso_builds + WHERE id = %s + """, + (build_id,), + ) + row = cur.fetchone() + return iso_build_row_to_dict(row) if row else None + +def fetch_iso_builds(organization_id: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, organization_id, status, output_filename, error_message, + created_at, started_at, finished_at + FROM iso_builds + WHERE organization_id = %s + ORDER BY created_at DESC + """, + (organization_id,), + ) + return [iso_build_row_to_dict(row) for row in cur.fetchall()] + +def create_iso_build(organization_id: str) -> dict: + build_id = uuid4() + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO iso_builds (id, organization_id) VALUES (%s, %s)", + (build_id, organization_id), + ) + + return {"id": str(build_id), "status": "pending"} + +def update_iso_build( + build_id, + status: str, + output_filename: str | None = None, + error_message: str | None = None, + mark_started: bool = False, + mark_finished: bool = False, +): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + f""" + UPDATE iso_builds + SET status = %s, + output_filename = COALESCE(%s, output_filename), + error_message = %s + {", started_at = CURRENT_TIMESTAMP" if mark_started else ""} + {", finished_at = CURRENT_TIMESTAMP" if mark_finished else ""} + WHERE id = %s + """, + (status, output_filename, error_message, build_id), + ) + +def cleanup_old_iso_builds(organization_id: str, keep_build_id) -> None: + """ + Loescht Ausgabedateien + Zeilen abgeschlossener frueherer Baeulaeufe + derselben Organisation (Speicherplatz auf anode ist begrenzt) - eine neu + erzeugte Kunden-ISO ersetzt die vorherige, ein erneuter Download der + alten ergibt fuer den Kunden ohnehin keinen Sinn mehr. + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, output_filename FROM iso_builds + WHERE organization_id = %s AND id != %s + AND status IN ('completed', 'failed') + """, + (organization_id, keep_build_id), + ) + old_builds = cur.fetchall() + + for old_id, output_filename in old_builds: + if output_filename: + (ISO_BUILD_OUTPUT_DIR / output_filename).unlink(missing_ok=True) + + cur.execute( + """ + DELETE FROM iso_builds + WHERE organization_id = %s AND id != %s + AND status IN ('completed', 'failed') + """, + (organization_id, keep_build_id), + ) + +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" + + update_iso_build(build_id, status="running", mark_started=True) + + try: + result = subprocess.run( + [ + "bash", + f"{TUXFLOTTE_INSTALLER_DIR}/scripts/build_customer_iso.sh", + MINT_ISO_PATH, + str(output_path), + activation_code, + wifi_ssid or "", + wifi_psk or "", + ], + cwd=TUXFLOTTE_INSTALLER_DIR, + capture_output=True, + text=True, + timeout=ISO_BUILD_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired: + output_path.unlink(missing_ok=True) + update_iso_build(build_id, status="failed", error_message="Zeitüberschreitung beim ISO-Bau.", mark_finished=True) + return + except Exception as exc: + output_path.unlink(missing_ok=True) + update_iso_build(build_id, status="failed", error_message=str(exc)[:4000], mark_finished=True) + return + + if result.returncode != 0 or not output_path.is_file(): + output_path.unlink(missing_ok=True) + error_tail = (result.stderr or result.stdout or "unbekannter Fehler")[-4000:] + update_iso_build(build_id, status="failed", error_message=error_tail, mark_finished=True) + return + + update_iso_build(build_id, status="completed", output_filename=output_path.name, mark_finished=True) + cleanup_old_iso_builds(organization_id, build_id) + @app.get("/health") def health(): return { @@ -1710,6 +1879,78 @@ def revoke_enrollment_session_endpoint(session_id: str, authorization: str | Non return {"success": True} +@app.get("/api/v1/organizations/{organization_id}/iso-builds") +def list_iso_builds(organization_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."} + + return {"success": True, "iso_builds": fetch_iso_builds(organization_id)} + + +@app.post("/api/v1/organizations/{organization_id}/iso-builds") +def create_iso_build_endpoint( + organization_id: str, + payload: CreateIsoBuildRequest, + authorization: str | None = Header(default=None), +): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + if not payload.activation_code.strip(): + return {"success": False, "error": "invalid_request", "message": "activation_code darf nicht leer sein."} + + ISO_BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + iso_build = create_iso_build(organization_id) + + thread = threading.Thread( + target=run_iso_build, + args=(UUID(iso_build["id"]), organization_id, payload.activation_code, payload.wifi_ssid, payload.wifi_psk), + daemon=True, + ) + thread.start() + + return {"success": True, "iso_build": iso_build} + + +@app.get("/api/v1/iso-builds/{build_id}") +def get_iso_build_endpoint(build_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."} + + iso_build = fetch_iso_build(build_id) + + if iso_build is None: + return {"success": False, "error": "not_found", "message": "ISO-Build wurde nicht gefunden."} + + return {"success": True, "iso_build": iso_build} + + +@app.get("/api/v1/iso-builds/{build_id}/download") +def download_iso_build(build_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."} + + iso_build = fetch_iso_build(build_id) + + if iso_build is None: + return {"success": False, "error": "not_found", "message": "ISO-Build wurde nicht gefunden."} + + if iso_build["status"] != "completed" or not iso_build["output_filename"]: + return {"success": False, "error": "not_ready", "message": "Der ISO-Build ist noch nicht abgeschlossen."} + + output_path = ISO_BUILD_OUTPUT_DIR / iso_build["output_filename"] + + if not output_path.is_file(): + return {"success": False, "error": "file_missing", "message": "Die ISO-Datei wurde nicht gefunden."} + + return FileResponse( + output_path, + media_type="application/octet-stream", + filename="tuxflotte-installationsmedium.iso", + ) + + @app.get("/api/v1/devices/{device_id}/auftragskatalog") def get_device_auftragskatalog( device_id: str, diff --git a/migrations/0016_iso_builds.sql b/migrations/0016_iso_builds.sql new file mode 100644 index 0000000..f61f2c3 --- /dev/null +++ b/migrations/0016_iso_builds.sql @@ -0,0 +1,27 @@ +BEGIN; + +-- Phase 4 des Self-Service-ISO-Plans: Bautracking fuer personalisierte +-- Kunden-ISOs (siehe tuxflotte-installer/scripts/build_customer_iso.sh). +-- Der Bau selbst laeuft als Hintergrund-Thread im provisioning-server +-- (siehe run_iso_build() in app.py) - diese Tabelle ist der Status- +-- Speicher dafuer, den der Trigger-/Status-/Download-Endpoint gemeinsam +-- nutzen. WLAN-Zugangsdaten werden bewusst NICHT hier gespeichert (nur +-- transient waehrend des Baus verwendet, siehe app.py) - persistente, +-- verschluesselte Ablage ist Sache der Kundenplattform (Phase 5). +CREATE TABLE iso_builds ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL + REFERENCES organizations(id) + ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'running', 'completed', 'failed')), + output_filename TEXT, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ +); + +CREATE INDEX iso_builds_organization_id_idx ON iso_builds (organization_id); + +COMMIT;