Thomas Stallinger 11186dfdf9 feat: migrate provisioning API to Merkmal/Blueprint/Bereitstellungsvorlage model
Replaces the config.json-backed flat profile model with the
Postgres-backed Workspace/Merkmal/Backend/Blueprint/Bereitstellungsvorlage
schema (migrations 0003-0005). /api/v1/activate now returns Bereitstellungsvorlage
templates instead of profiles, and a new POST /api/v1/templates/{id}/resolve
endpoint resolves a chosen template to a full Runtime Blueprint (workspace,
backend, resolved Merkmal->Ansible-role blueprints, and installation
directives), recording the resulting device assignment.

Removes the now-unused config.json and file-based device registry
directory in favor of the PostgreSQL-backed activation codes and
device tables.
2026-07-18 10:48:29 +02:00

447 lines
14 KiB
Python

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 workspace_id, backend_id, disk_encryption, partitioning, secure_boot_required
FROM bereitstellungsvorlagen
WHERE 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, 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": str(workspace_id),
"backend_id": str(backend_id),
"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"
)