feat: ISO-Build-Orchestrierung fuer personalisierte Kunden-ISOs (Phase 4)

Neue iso_builds-Tabelle (Migration 0016) + vier Endpunkte, die
tuxflotte-installer/scripts/build_customer_iso.sh als Hintergrund-Thread
anstossen: POST/GET .../iso-builds (Trigger + Liste), GET
/api/v1/iso-builds/{id} (Status-Poll) und .../download (fertige ISO
ausliefern). Aeltere abgeschlossene Builds derselben Organisation werden
nach einem erfolgreichen neuen Build automatisch aufgeraeumt (Datei +
DB-Zeile) - anodes Root-Dateisystem ist mit 28GB knapp bemessen.

WLAN-Zugangsdaten werden bewusst nicht in iso_builds gespeichert, nur
transient an build_customer_iso.sh durchgereicht.

Lokal end-to-end gegen einen Wegwerf-Postgres-Container verifiziert (alle
Migrationen 0001-0016, echter build_customer_iso.sh-Lauf gegen die
gecachte Mint-ISO, Status-Uebergaenge pending->running->completed,
Download-Endpunkt liefert byte-identische Datei, Cleanup-Logik entfernt
den vorherigen Build einer Organisation nach dem naechsten erfolgreichen
Bau). Testorganisation danach wieder geloescht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-07 00:39:32 +02:00
parent 896c22c537
commit f69ed66267
2 changed files with 268 additions and 0 deletions

241
app.py
View File

@ -5,6 +5,8 @@ import hmac
import json import json
import os import os
import secrets import secrets
import subprocess
import threading
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import psycopg import psycopg
@ -22,6 +24,16 @@ ANSIBLE_CONTENT_REPO = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO", "")
ADMIN_TOKEN = os.environ.get("TUXFLOTTE_ADMIN_TOKEN", "") ADMIN_TOKEN = os.environ.get("TUXFLOTTE_ADMIN_TOKEN", "")
KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "") KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "")
AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"} 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") app = FastAPI(title="Provisioning Activation Server", version="0.1.0")
@ -102,6 +114,12 @@ class CreateEnrollmentSessionRequest(BaseModel):
expires_at: str 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: def write_log(entry: dict) -> None:
with LOG_PATH.open("a", encoding="utf-8") as f: with LOG_PATH.open("a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n") 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 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") @app.get("/health")
def health(): def health():
return { return {
@ -1710,6 +1879,78 @@ def revoke_enrollment_session_endpoint(session_id: str, authorization: str | Non
return {"success": True} 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") @app.get("/api/v1/devices/{device_id}/auftragskatalog")
def get_device_auftragskatalog( def get_device_auftragskatalog(
device_id: str, device_id: str,

View File

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