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.
This commit is contained in:
Thomas Stallinger 2026-07-18 10:48:29 +02:00
parent 02e2b9de94
commit 11186dfdf9
7 changed files with 271 additions and 100 deletions

2
.gitignore vendored
View File

@ -2,5 +2,3 @@
__pycache__/ __pycache__/
*.pyc *.pyc
activation.log activation.log
data/devices/*.json

164
app.py
View File

@ -11,7 +11,6 @@ from pydantic import BaseModel
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
CONFIG_PATH = Path("config.json")
LOG_PATH = Path("activation.log") LOG_PATH = Path("activation.log")
DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL") DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL")
app = FastAPI(title="Provisioning Activation Server", version="0.1.0") app = FastAPI(title="Provisioning Activation Server", version="0.1.0")
@ -26,9 +25,8 @@ class ActivationRequest(BaseModel):
hardware: dict hardware: dict
def load_config() -> dict: class ResolveRequest(BaseModel):
with CONFIG_PATH.open("r", encoding="utf-8") as f: device_id: str
return json.load(f)
def write_log(entry: dict) -> None: def write_log(entry: dict) -> None:
@ -255,6 +253,55 @@ def store_hardware_snapshot(
return str(snapshot_id) 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") @app.get("/health")
def health(): def health():
return { return {
@ -266,17 +313,16 @@ def health():
@app.post("/api/v1/activate") @app.post("/api/v1/activate")
def activate(payload: ActivationRequest, request: Request): def activate(payload: ActivationRequest, request: Request):
config = load_config() activation = find_activation_code(payload.activation_code)
activation_entry = config.get("activation_codes", {}).get(payload.activation_code)
success = activation_entry is not None success = activation is not None
device = None device = None
device_created = False device_created = False
hardware_snapshot_id = None hardware_snapshot_id = None
if success: if success:
organization_id = activation_entry["customer"]["organization_id"] organization_id = activation["organization_id"]
device, device_created = load_or_create_device( device, device_created = load_or_create_device(
organization_id, organization_id,
@ -306,13 +352,6 @@ def activate(payload: ActivationRequest, request: Request):
"message": "Der Aktivierungscode ist ungültig." "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 { return {
"success": True, "success": True,
@ -328,56 +367,75 @@ def activate(payload: ActivationRequest, request: Request):
"hardware_snapshot_id": hardware_snapshot_id, "hardware_snapshot_id": hardware_snapshot_id,
}, },
"customer": activation_entry["customer"], "customer": {
"profiles": available_profiles "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}") @app.post("/api/v1/templates/{template_id}/resolve")
def get_profile(profile_id: str): def resolve_template(template_id: str, payload: ResolveRequest):
config = load_config() with get_database_connection() as conn:
profile = config.get("profiles", {}).get(profile_id) 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: if template_row is None:
return { return {
"success": False, "success": False,
"error": "profile_not_found", "error": "template_not_found",
"message": "Das angeforderte Profil wurde nicht gefunden." "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 { return {
"success": True, "success": True,
"profile": profile "runtime_blueprint": {
} "workspace_id": str(workspace_id),
"backend_id": str(backend_id),
@app.get("/api/v1/profiles/{profile_id}/bootstrap") "blueprints": blueprints,
def get_profile_bootstrap(profile_id: str): "installation_directives": {
config = load_config() "disk_encryption": disk_encryption,
profile = config.get("profiles", {}).get(profile_id) "partitioning": partitioning,
"secure_boot_required": secure_boot_required,
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"]
}, },
"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") @app.get("/installers/fedora-workstation/ks.cfg")

View File

@ -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"
}
}
}
}

View File

View File

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

View File

@ -0,0 +1,5 @@
BEGIN;
ALTER TABLE backends ADD COLUMN version TEXT;
COMMIT;

View File

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