from pathlib import Path from datetime import datetime, timezone import json import os from uuid import uuid4 import psycopg from fastapi import FastAPI, Request from pydantic import BaseModel from fastapi.responses import FileResponse LOG_PATH = Path("activation.log") DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL") app = FastAPI(title="Provisioning Activation Server", version="0.1.0") class ActivationRequest(BaseModel): activation_code: str device_fingerprint: str hostname: str | None = None machine_id: str | None = None client_version: str | None = None hardware: dict class ResolveRequest(BaseModel): device_id: str 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") def get_database_connection(): if not DATABASE_URL: raise RuntimeError("TUXFLOTTE_DATABASE_URL ist nicht gesetzt.") return psycopg.connect(DATABASE_URL) def load_or_create_device( organization_id, device_fingerprint: str, hostname: str | None, ) -> tuple[dict, bool]: """ Lädt ein bekanntes Gerät oder legt es neu an. Rückgabe: (device, created) """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT id, organization_id, device_fingerprint, hostname, created_at, last_seen FROM devices WHERE device_fingerprint = %s """, (device_fingerprint,), ) row = cur.fetchone() if row is not None: if str(row[1]) != str(organization_id): raise RuntimeError( "Das Gerät ist bereits einer anderen Organization zugeordnet." ) cur.execute( """ UPDATE devices SET hostname = %s, last_seen = CURRENT_TIMESTAMP WHERE id = %s RETURNING id, organization_id, device_fingerprint, hostname, created_at, last_seen """, (hostname, row[0]), ) row = cur.fetchone() created = False else: device_id = uuid4() cur.execute( """ INSERT INTO devices ( id, organization_id, device_fingerprint, hostname ) VALUES (%s, %s, %s, %s) RETURNING id, organization_id, device_fingerprint, hostname, created_at, last_seen """, ( device_id, organization_id, device_fingerprint, hostname, ), ) row = cur.fetchone() created = True device = { "id": str(row[0]), "organization_id": str(row[1]), "device_fingerprint": row[2], "hostname": row[3], "created_at": row[4].isoformat(), "last_seen": row[5].isoformat(), } return device, created def store_hardware_snapshot( device_id: str, hardware: dict, ) -> str: snapshot_id = uuid4() identity = hardware.get("identity", {}) system = hardware.get("system", {}) mainboard = hardware.get("mainboard", {}) firmware = hardware.get("firmware", {}) security = hardware.get("security", {}) cpu = system.get("cpu", {}) with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO hardware_snapshots ( id, device_id, architecture, manufacturer, product_name, product_version, system_uuid, system_serial, board_vendor, board_name, board_serial, bios_vendor, bios_version, boot_mode, secure_boot, tpm_version, cpu_model, cpu_logical_count, memory_bytes ) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s ) """, ( snapshot_id, device_id, system["architecture"], system.get("manufacturer"), system.get("product_name"), system.get("product_version"), identity.get("system_uuid"), identity.get("system_serial"), mainboard.get("vendor"), mainboard.get("name"), identity.get("board_serial"), firmware.get("bios_vendor"), firmware.get("bios_version"), firmware.get("boot_mode"), firmware.get("secure_boot"), security.get("tpm_version"), cpu.get("model"), cpu.get("logical_count"), system.get("memory_bytes"), ), ) for interface in hardware.get("network_interfaces", []): cur.execute( """ INSERT INTO network_interfaces ( id, hardware_snapshot_id, name, type, mac_address ) VALUES (%s, %s, %s, %s, %s) """, ( uuid4(), snapshot_id, interface["name"], interface["type"], interface["mac"], ), ) for storage_device in hardware.get("storage_devices", []): cur.execute( """ INSERT INTO storage_devices ( id, hardware_snapshot_id, name, model, serial, size_bytes, transport ) VALUES (%s, %s, %s, %s, %s, %s, %s) """, ( uuid4(), snapshot_id, storage_device["name"], storage_device.get("model"), storage_device.get("serial"), storage_device["size_bytes"], storage_device.get("transport"), ), ) return str(snapshot_id) def find_activation_code(code: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT ac.organization_id, o.name FROM activation_codes ac JOIN organizations o ON o.id = ac.organization_id WHERE ac.code = %s AND ac.active """, (code,), ) row = cur.fetchone() if row is None: return None return {"organization_id": str(row[0]), "organization_name": row[1]} def fetch_templates(organization_id: str) -> list[dict]: with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT bv.id, bv.label, bv.is_default, w.id, w.name, b.id, b.name, b.version FROM bereitstellungsvorlagen bv JOIN workspaces w ON w.id = bv.workspace_id JOIN backends b ON b.id = bv.backend_id WHERE bv.organization_id = %s ORDER BY bv.is_default DESC, bv.label """, (organization_id,), ) rows = cur.fetchall() return [ { "id": str(row[0]), "label": row[1], "workspace": {"id": str(row[3]), "name": row[4]}, "backend": {"id": str(row[5]), "name": row[6], "version": row[7]}, "is_default": row[2], } for row in rows ] @app.get("/health") def health(): return { "status": "ok", "service": "provisioning-server", "version": "0.1.0" } @app.post("/api/v1/activate") def activate(payload: ActivationRequest, request: Request): activation = find_activation_code(payload.activation_code) success = activation is not None device = None device_created = False hardware_snapshot_id = None if success: organization_id = activation["organization_id"] device, device_created = load_or_create_device( organization_id, payload.device_fingerprint, payload.hostname, ) hardware_snapshot_id = store_hardware_snapshot( device["id"], payload.hardware, ) write_log({ "timestamp": datetime.now(timezone.utc).isoformat(), "event": "activate", "success": success, "remote_ip": request.client.host if request.client else None, "activation_code": payload.activation_code, "hostname": payload.hostname, "machine_id": payload.machine_id, "client_version": payload.client_version }) if not success: return { "success": False, "error": "invalid_activation_code", "message": "Der Aktivierungscode ist ungültig." } return { "success": True, "device": { "id": device["id"], "fingerprint": device["device_fingerprint"], "hostname": device["hostname"], "registration_status": ( "registered" if device_created else "existing" ), "hardware_snapshot_id": hardware_snapshot_id, }, "customer": { "id": activation["organization_id"], "organization_id": activation["organization_id"], "name": activation["organization_name"], }, "templates": fetch_templates(activation["organization_id"]), } @app.post("/api/v1/templates/{template_id}/resolve") def resolve_template(template_id: str, payload: ResolveRequest): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT bv.workspace_id, bv.backend_id, w.key, b.key, bv.disk_encryption, bv.partitioning, bv.secure_boot_required FROM bereitstellungsvorlagen bv JOIN workspaces w ON w.id = bv.workspace_id JOIN backends b ON b.id = bv.backend_id WHERE bv.id = %s """, (template_id,), ) template_row = cur.fetchone() if template_row is None: return { "success": False, "error": "template_not_found", "message": "Die angeforderte Bereitstellungsvorlage wurde nicht gefunden." } ( workspace_id, backend_id, workspace_key, backend_key, disk_encryption, partitioning, secure_boot_required, ) = template_row cur.execute( """ SELECT m.key, bp.ansible_role FROM workspace_merkmale wm JOIN merkmale m ON m.id = wm.merkmal_id JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s WHERE wm.workspace_id = %s """, (backend_id, workspace_id), ) blueprints = [ {"merkmal": key, "ansible_role": ansible_role} for key, ansible_role in cur.fetchall() ] cur.execute( """ INSERT INTO assignments (id, device_id, bereitstellungsvorlage_id) VALUES (%s, %s, %s) ON CONFLICT (device_id) DO UPDATE SET bereitstellungsvorlage_id = EXCLUDED.bereitstellungsvorlage_id """, (uuid4(), payload.device_id, template_id), ) return { "success": True, "runtime_blueprint": { "workspace_id": workspace_key, "backend_id": backend_key, "blueprints": blueprints, "installation_directives": { "disk_encryption": disk_encryption, "partitioning": partitioning, "secure_boot_required": secure_boot_required, }, }, } @app.get("/installers/fedora-workstation/ks.cfg") def get_fedora_kickstart(): return FileResponse( "installers/fedora-workstation/ks.cfg", media_type="text/plain" )