From 11186dfdf94a8ca3f8a69b9e4aedb1d3d99589f4 Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Sat, 18 Jul 2026 10:48:29 +0200 Subject: [PATCH] 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. --- .gitignore | 2 - app.py | 172 ++++++++++++------ config.json | 41 ----- data/devices/.gitkeep | 0 ...0003_workspace_backend_blueprint_model.sql | 96 ++++++++++ migrations/0004_add_backend_version.sql | 5 + ..._seed_workspace_backend_blueprint_data.sql | 55 ++++++ 7 files changed, 271 insertions(+), 100 deletions(-) delete mode 100644 config.json delete mode 100644 data/devices/.gitkeep create mode 100644 migrations/0003_workspace_backend_blueprint_model.sql create mode 100644 migrations/0004_add_backend_version.sql create mode 100644 migrations/0005_seed_workspace_backend_blueprint_data.sql diff --git a/.gitignore b/.gitignore index 4f273e9..4172497 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,3 @@ __pycache__/ *.pyc activation.log - -data/devices/*.json diff --git a/app.py b/app.py index ab7d5f0..52dd316 100644 --- a/app.py +++ b/app.py @@ -11,7 +11,6 @@ from pydantic import BaseModel 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") @@ -26,9 +25,8 @@ class ActivationRequest(BaseModel): hardware: dict -def load_config() -> dict: - with CONFIG_PATH.open("r", encoding="utf-8") as f: - return json.load(f) +class ResolveRequest(BaseModel): + device_id: str def write_log(entry: dict) -> None: @@ -255,6 +253,55 @@ def store_hardware_snapshot( 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 { @@ -266,17 +313,16 @@ def health(): @app.post("/api/v1/activate") def activate(payload: ActivationRequest, request: Request): - config = load_config() - activation_entry = config.get("activation_codes", {}).get(payload.activation_code) + activation = find_activation_code(payload.activation_code) - success = activation_entry is not None + success = activation is not None device = None device_created = False hardware_snapshot_id = None if success: - organization_id = activation_entry["customer"]["organization_id"] + organization_id = activation["organization_id"] device, device_created = load_or_create_device( organization_id, @@ -306,13 +352,6 @@ def activate(payload: ActivationRequest, request: Request): "message": "Der Aktivierungscode ist ungültig." } - profiles = config.get("profiles", {}) - available_profiles = [ - profiles[profile_id] - for profile_id in activation_entry.get("profile_ids", []) - if profile_id in profiles - ] - return { "success": True, @@ -328,56 +367,75 @@ def activate(payload: ActivationRequest, request: Request): "hardware_snapshot_id": hardware_snapshot_id, }, - "customer": activation_entry["customer"], - "profiles": available_profiles + "customer": { + "id": activation["organization_id"], + "organization_id": activation["organization_id"], + "name": activation["organization_name"], + }, + "templates": fetch_templates(activation["organization_id"]), } -@app.get("/api/v1/profiles/{profile_id}") -def get_profile(profile_id: str): - config = load_config() - profile = config.get("profiles", {}).get(profile_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 not profile: - return { - "success": False, - "error": "profile_not_found", - "message": "Das angeforderte Profil wurde nicht gefunden." - } + 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, - "profile": profile - } - -@app.get("/api/v1/profiles/{profile_id}/bootstrap") -def get_profile_bootstrap(profile_id: str): - config = load_config() - profile = config.get("profiles", {}).get(profile_id) - - if not profile: - return { - "success": False, - "error": "profile_not_found", - "message": "Das angeforderte Profil wurde nicht gefunden." - } - - return { - "success": True, - "profile": { - "id": profile["id"], - "label": profile["label"], - "distribution": profile["distribution"], - "version": profile["version"] + "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, + }, }, - "installer": profile.get("installer"), - "ansible": { - "repo": profile.get("ansible_repo"), - "mode": "pull" - }, - "ssh": { - "authorized_keys_url": "https://anode.tuxflotte.de/keys/default.pub" - } } @app.get("/installers/fedora-workstation/ks.cfg") diff --git a/config.json b/config.json deleted file mode 100644 index fb97ea8..0000000 --- a/config.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "activation_codes": { - "LAB-2026-START": { - "customer": { - "id": "default", - "organization_id": "11111111-1111-4111-8111-111111111111", - "name": "Default Lab" - }, - "profile_ids": [ - "mint-desktop", - "fedora-workstation" - ] - } - }, - "profiles": { - "mint-desktop": { - "id": "mint-desktop", - "label": "Linux Mint Desktop", - "distribution": "linux-mint", - "version": "22", - "description": "Standard-Desktop-Profil für Linux Mint.", - "ansible_repo": "https://git.example.com/stallinux/mint-desktop.git", - "installer": { - "type": "autoinstall", - "url": "https://anode.tuxflotte.de/installers/mint-desktop/user-data" - } - }, - "fedora-workstation": { - "id": "fedora-workstation", - "label": "Fedora Workstation", - "distribution": "fedora", - "version": "40", - "description": "Standard-Workstation-Profil für Fedora.", - "ansible_repo": "https://git.example.com/stallinux/fedora-workstation.git", - "installer": { - "type": "kickstart", - "url": "https://anode.tuxflotte.de/installers/fedora-workstation/ks.cfg" - } - } - } -} diff --git a/data/devices/.gitkeep b/data/devices/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/migrations/0003_workspace_backend_blueprint_model.sql b/migrations/0003_workspace_backend_blueprint_model.sql new file mode 100644 index 0000000..ceaef0f --- /dev/null +++ b/migrations/0003_workspace_backend_blueprint_model.sql @@ -0,0 +1,96 @@ +BEGIN; + +CREATE TABLE workspaces ( + id UUID PRIMARY KEY, + organization_id UUID + REFERENCES organizations(id) + ON DELETE CASCADE, + key TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (organization_id, key) +); + +CREATE TABLE merkmale ( + id UUID PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT +); + +CREATE TABLE workspace_merkmale ( + workspace_id UUID NOT NULL + REFERENCES workspaces(id) + ON DELETE CASCADE, + merkmal_id UUID NOT NULL + REFERENCES merkmale(id) + ON DELETE RESTRICT, + PRIMARY KEY (workspace_id, merkmal_id) +); + +CREATE TABLE backends ( + id UUID PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + installer_type TEXT NOT NULL +); + +CREATE TABLE blueprints ( + id UUID PRIMARY KEY, + merkmal_id UUID NOT NULL + REFERENCES merkmale(id) + ON DELETE CASCADE, + backend_id UUID NOT NULL + REFERENCES backends(id) + ON DELETE CASCADE, + ansible_role TEXT NOT NULL, + UNIQUE (merkmal_id, backend_id) +); + +CREATE TABLE bereitstellungsvorlagen ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL + REFERENCES organizations(id) + ON DELETE CASCADE, + workspace_id UUID NOT NULL + REFERENCES workspaces(id) + ON DELETE RESTRICT, + backend_id UUID NOT NULL + REFERENCES backends(id) + ON DELETE RESTRICT, + label TEXT NOT NULL, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + disk_encryption BOOLEAN NOT NULL DEFAULT FALSE, + partitioning TEXT NOT NULL DEFAULT 'default', + secure_boot_required BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX bereitstellungsvorlagen_one_default_per_org + ON bereitstellungsvorlagen (organization_id) + WHERE is_default; + +CREATE TABLE assignments ( + id UUID PRIMARY KEY, + device_id UUID NOT NULL UNIQUE + REFERENCES devices(id) + ON DELETE CASCADE, + bereitstellungsvorlage_id UUID NOT NULL + REFERENCES bereitstellungsvorlagen(id) + ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE activation_codes ( + code TEXT PRIMARY KEY, + organization_id UUID NOT NULL + REFERENCES organizations(id) + ON DELETE CASCADE, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +COMMIT; diff --git a/migrations/0004_add_backend_version.sql b/migrations/0004_add_backend_version.sql new file mode 100644 index 0000000..1f64440 --- /dev/null +++ b/migrations/0004_add_backend_version.sql @@ -0,0 +1,5 @@ +BEGIN; + +ALTER TABLE backends ADD COLUMN version TEXT; + +COMMIT; diff --git a/migrations/0005_seed_workspace_backend_blueprint_data.sql b/migrations/0005_seed_workspace_backend_blueprint_data.sql new file mode 100644 index 0000000..c1b6384 --- /dev/null +++ b/migrations/0005_seed_workspace_backend_blueprint_data.sql @@ -0,0 +1,55 @@ +BEGIN; + +-- Backends + +INSERT INTO backends (id, key, name, installer_type, version) VALUES + ('22222222-2222-4222-8222-222222222222', 'fedora', 'Fedora', 'kickstart', '40'), + ('33333333-3333-4333-8333-333333333333', 'mint', 'Linux Mint', 'autoinstall', '22'); + +-- Workspaces (global, organization_id NULL) + +INSERT INTO workspaces (id, organization_id, key, name, description) VALUES + ('44444444-4444-4444-8444-444444444444', NULL, 'schulcomputer', 'Schulcomputer', + 'Arbeitsplatz fuer den Schuleinsatz mit fluechtiger Gastsitzung.'), + ('55555555-5555-4555-8555-555555555555', NULL, 'entwickler', 'Entwickler', + 'Arbeitsplatz fuer Softwareentwicklung. Merkmale noch nicht konkretisiert.'); + +-- Merkmale (bisher nur fuer Schulcomputer konkretisiert) + +INSERT INTO merkmale (id, key, name, description) VALUES + ('66666666-6666-4666-8666-666666666666', 'guest-session-ephemeral', 'Fluechtige Gastsitzung', + 'Loescht Dateien, Cache und Verlauf beim Abmelden.'), + ('77777777-7777-4777-8777-777777777777', 'browser-brave', 'Brave Browser', NULL), + ('88888888-8888-4888-8888-888888888888', 'office-onlyoffice', 'OnlyOffice', NULL); + +INSERT INTO workspace_merkmale (workspace_id, merkmal_id) VALUES + ('44444444-4444-4444-8444-444444444444', '66666666-6666-4666-8666-666666666666'), + ('44444444-4444-4444-8444-444444444444', '77777777-7777-4777-8777-777777777777'), + ('44444444-4444-4444-8444-444444444444', '88888888-8888-4888-8888-888888888888'); + +-- Blueprints je Merkmal x Backend (Ansible-Rollen aus ADR-0002 uebernommen bzw. nach demselben Schema ergaenzt) + +INSERT INTO blueprints (id, merkmal_id, backend_id, ansible_role) VALUES + ('b1000000-0000-4000-8000-000000000001', '66666666-6666-4666-8666-666666666666', '22222222-2222-4222-8222-222222222222', 'guest-session'), + ('b1000000-0000-4000-8000-000000000002', '66666666-6666-4666-8666-666666666666', '33333333-3333-4333-8333-333333333333', 'guest-session'), + ('b1000000-0000-4000-8000-000000000003', '77777777-7777-4777-8777-777777777777', '22222222-2222-4222-8222-222222222222', 'brave-fedora'), + ('b1000000-0000-4000-8000-000000000004', '77777777-7777-4777-8777-777777777777', '33333333-3333-4333-8333-333333333333', 'brave-mint'), + ('b1000000-0000-4000-8000-000000000005', '88888888-8888-4888-8888-888888888888', '22222222-2222-4222-8222-222222222222', 'onlyoffice-fedora'), + ('b1000000-0000-4000-8000-000000000006', '88888888-8888-4888-8888-888888888888', '33333333-3333-4333-8333-333333333333', 'onlyoffice-mint'); + +-- Bereitstellungsvorlagen fuer die bestehende "Default Lab"-Organisation + +INSERT INTO bereitstellungsvorlagen (id, organization_id, workspace_id, backend_id, label, is_default) VALUES + ('c1000000-0000-4000-8000-000000000001', '11111111-1111-4111-8111-111111111111', + '44444444-4444-4444-8444-444444444444', '22222222-2222-4222-8222-222222222222', + 'Schulcomputer (Fedora)', TRUE), + ('c1000000-0000-4000-8000-000000000002', '11111111-1111-4111-8111-111111111111', + '44444444-4444-4444-8444-444444444444', '33333333-3333-4333-8333-333333333333', + 'Schulcomputer (Mint)', FALSE); + +-- Aktivierungscode (Ablösung des bisherigen config.json-Eintrags) + +INSERT INTO activation_codes (code, organization_id, active) VALUES + ('LAB-2026-START', '11111111-1111-4111-8111-111111111111', TRUE); + +COMMIT;