From 2683b65ffcc45205e3f186de15775735d78d9404 Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Sun, 12 Jul 2026 15:22:22 +0200 Subject: [PATCH] feat: migrate device registry to PostgreSQL --- app.py | 122 ++++++++++++++---- config.json | 1 + migrations/0002_seed_default_organization.sql | 12 ++ 3 files changed, 112 insertions(+), 23 deletions(-) create mode 100644 migrations/0002_seed_default_organization.sql diff --git a/app.py b/app.py index 9e481de..63bbdc6 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,10 @@ 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 @@ -9,7 +13,7 @@ from fastapi.responses import FileResponse CONFIG_PATH = Path("config.json") LOG_PATH = Path("activation.log") - +DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL") app = FastAPI(title="Provisioning Activation Server", version="0.1.0") @@ -30,9 +34,17 @@ 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") -DEVICES_DIR = Path("data/devices") +def get_database_connection(): + if not DATABASE_URL: + raise RuntimeError("TUXFLOTTE_DATABASE_URL ist nicht gesetzt.") -def load_or_create_device(device_fingerprint: str) -> tuple[dict, bool]: + 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. @@ -40,33 +52,90 @@ def load_or_create_device(device_fingerprint: str) -> tuple[dict, bool]: (device, created) """ - DEVICES_DIR.mkdir(parents=True, exist_ok=True) + 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,), + ) - device_file = DEVICES_DIR / f"{device_fingerprint}.json" + row = cur.fetchone() - if device_file.exists(): - with device_file.open("r", encoding="utf-8") as f: - device = json.load(f) + 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]), + ) - device["last_seen"] = datetime.now(timezone.utc).isoformat() - device["activation_count"] = device.get("activation_count", 0) + 1 + row = cur.fetchone() + created = False + else: + device_id = uuid4() - with device_file.open("w", encoding="utf-8") as f: - json.dump(device, f, indent=2) + cur.execute( + """ + INSERT INTO devices ( + id, + organization_id, + device_fingerprint, + hostname + ) + VALUES (%s, %s, %s, %s) + RETURNING + id, + device_fingerprint, + hostname, + created_at, + last_seen + """, + ( + device_id, + organization_id, + device_fingerprint, + hostname, + ), + ) - return device, False + row = cur.fetchone() + created = True device = { - "device_fingerprint": device_fingerprint, - "created_at": datetime.now(timezone.utc).isoformat(), - "last_seen": datetime.now(timezone.utc).isoformat(), - "activation_count": 1 + "id": str(row[0]), + "organization_id": str(row[1]), + "device_fingerprint": row[1], + "hostname": row[2], + "created_at": row[3].isoformat(), + "last_seen": row[4].isoformat(), } - with device_file.open("w", encoding="utf-8") as f: - json.dump(device, f, indent=2) - - return device, True + return device, created @app.get("/health") def health(): @@ -87,8 +156,12 @@ def activate(payload: ActivationRequest, request: Request): device_created = False if success: + organization_id = activation_entry["customer"]["organization_id"] + device, device_created = load_or_create_device( - payload.device_fingerprint + organization_id, + payload.device_fingerprint, + payload.hostname, ) write_log({ @@ -120,7 +193,10 @@ def activate(payload: ActivationRequest, request: Request): "success": True, "device": { - "fingerprint": device["device_fingerprint"], + "id": device["id"], + "fingerprint": device["device_fingerprint"], + "hostname": device["hostname"], + "created": device_created, }, "customer": activation_entry["customer"], diff --git a/config.json b/config.json index ccbb010..fb97ea8 100644 --- a/config.json +++ b/config.json @@ -3,6 +3,7 @@ "LAB-2026-START": { "customer": { "id": "default", + "organization_id": "11111111-1111-4111-8111-111111111111", "name": "Default Lab" }, "profile_ids": [ diff --git a/migrations/0002_seed_default_organization.sql b/migrations/0002_seed_default_organization.sql new file mode 100644 index 0000000..55eaa8d --- /dev/null +++ b/migrations/0002_seed_default_organization.sql @@ -0,0 +1,12 @@ +BEGIN; + +INSERT INTO organizations ( + id, + name +) +VALUES ( + '11111111-1111-4111-8111-111111111111', + 'Default Lab' +); + +COMMIT;