feat: Bereitstellungsvorlage-Erstellung per API (schliesst Phase-5-Luecke)

Bislang gab es dafuer keinen API-/UI-Weg - Bereitstellungsvorlagen kamen
nur per Seed-Migration (0005/0011) fuer die "Default Lab"-Organisation
zustande. Eine wirklich neue Kundenorganisation hatte dadurch eine leere
Vorlagenauswahl auf der Kundenplattform-Installationsmedium-Seite (in
Phase 5 als offene Luecke dokumentiert).

Neuer POST /api/v1/organizations/{id}/bereitstellungsvorlagen: waehlt
Workspace + Backend, einfache Root-Dateisystem-Wahl (single-scheme,
ext4/btrfs - ein custom-Schema mit extra_partitions bleibt Sache
direkter DB-Pflege), optional Default/Diskverschluesselung/Secure-Boot.
Beachtet den bestehenden Unique-Index (nur eine is_default=TRUE-Zeile je
Organisation) durch expliziten Zwei-Schritte-Reset, gleiches Muster wie
Migration 0011.

Lokal end-to-end verifiziert: frische Organisation ohne jede Vorlage,
zwei Vorlagen nacheinander angelegt (zweite mit is_default=true kippt
die erste korrekt auf false), Partitioning/Diskverschluesselung/
Secure-Boot-Flags korrekt in der DB, Kundenplattform-Installationsmedium-
Seite zeigt die neue Vorlage danach korrekt in der Auswahl.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-07 01:59:21 +02:00
parent 122e9fb499
commit fb175bf861

86
app.py
View File

@ -116,6 +116,16 @@ class CreateWorkspaceRequest(BaseModel):
organization_id: str | None = None organization_id: str | None = None
class CreateBereitstellungsvorlageRequest(BaseModel):
workspace_id: str
backend_id: str
label: str
is_default: bool = False
disk_encryption: bool = False
root_filesystem: str = "ext4"
secure_boot_required: bool = False
class CreateEnrollmentSessionRequest(BaseModel): class CreateEnrollmentSessionRequest(BaseModel):
bereitstellungsvorlage_id: str bereitstellungsvorlage_id: str
max_devices: int max_devices: int
@ -467,6 +477,56 @@ def fetch_templates(organization_id: str) -> list[dict]:
for row in rows for row in rows
] ]
def create_bereitstellungsvorlage(
organization_id: str,
workspace_id: str,
backend_id: str,
label: str,
is_default: bool,
disk_encryption: bool,
root_filesystem: str,
secure_boot_required: bool,
) -> dict:
"""
Bislang gab es dafuer keinen API-/UI-Weg - Bereitstellungsvorlagen kamen
nur per Seed-Migration (0005/0011) fuer die "Default Lab"-Organisation
zustande (siehe Phase 5 des Self-Service-ISO-Plans, dort als offene
Luecke notiert). partitioning bekommt bewusst nur eine einfache
"root_filesystem"-Wahl im single-scheme (siehe Migration 0015 fuer die
volle Form) - ein custom-Schema mit extra_partitions ist Sache der
direkten DB-Pflege, nicht dieses Formulars.
"""
vorlage_id = uuid4()
partitioning = {"scheme": "single", "root_filesystem": root_filesystem}
with get_database_connection() as conn:
with conn.cursor() as cur:
if is_default:
# Der Unique-Index bereitstellungsvorlagen_one_default_per_org
# erlaubt nur eine is_default=TRUE-Zeile je Organisation -
# bestehenden Default zuerst explizit zuruecksetzen (gleiches
# Zwei-Schritte-Muster wie Migration 0011).
cur.execute(
"UPDATE bereitstellungsvorlagen SET is_default = FALSE WHERE organization_id = %s AND is_default",
(organization_id,),
)
cur.execute(
"""
INSERT INTO bereitstellungsvorlagen
(id, organization_id, workspace_id, backend_id, label, is_default,
disk_encryption, partitioning, secure_boot_required)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
vorlage_id, organization_id, workspace_id, backend_id, label, is_default,
disk_encryption, Jsonb(partitioning), secure_boot_required,
),
)
return {"id": str(vorlage_id), "label": label, "is_default": is_default}
def require_service_token(authorization: str | None) -> bool: def require_service_token(authorization: str | None) -> bool:
""" """
Akzeptiert entweder den Betreiber-Admin-Token oder das separate Akzeptiert entweder den Betreiber-Admin-Token oder das separate
@ -1899,6 +1959,32 @@ def list_bereitstellungsvorlagen(organization_id: str, authorization: str | None
return {"success": True, "bereitstellungsvorlagen": fetch_templates(organization_id)} return {"success": True, "bereitstellungsvorlagen": fetch_templates(organization_id)}
@app.post("/api/v1/organizations/{organization_id}/bereitstellungsvorlagen")
def create_bereitstellungsvorlage_endpoint(
organization_id: str,
payload: CreateBereitstellungsvorlageRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if not payload.label.strip():
return {"success": False, "error": "invalid_request", "message": "label darf nicht leer sein."}
vorlage = create_bereitstellungsvorlage(
organization_id,
payload.workspace_id,
payload.backend_id,
payload.label,
payload.is_default,
payload.disk_encryption,
payload.root_filesystem,
payload.secure_boot_required,
)
return {"success": True, "bereitstellungsvorlage": vorlage}
@app.get("/api/v1/organizations/{organization_id}/devices/unassigned") @app.get("/api/v1/organizations/{organization_id}/devices/unassigned")
def list_unassigned_devices(organization_id: str, authorization: str | None = Header(default=None)): def list_unassigned_devices(organization_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization): if not require_service_token(authorization):