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:
admin committed 2026-07-18 10:48:29 +02:00
1 parent 02e2b9de94
commit 11186dfdf9
7 files changed
+271 -100

No files matched your search

+115 -57
View File
@@ -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")