diff --git a/app.py b/app.py index 30de69b..5a4f4aa 100644 --- a/app.py +++ b/app.py @@ -305,7 +305,8 @@ class ExtraPartitionRequest(BaseModel): class CreateBereitstellungsvorlageRequest(BaseModel): workspace_id: str - backend_id: str + # Seit ADR-0038 optional: ohne Angabe gilt die Distro des Workspace. + backend_id: str | None = None label: str is_default: bool = False disk_encryption: bool = False @@ -753,7 +754,9 @@ def fetch_templates(organization_id: str) -> list[dict]: bv.id, bv.label, bv.is_default, w.id, w.name, b.id, b.name, b.version, - bv.partitioning + bv.partitioning, bv.disk_encryption, bv.secure_boot_required, + (SELECT count(*) FROM assignments a WHERE a.bereitstellungsvorlage_id = bv.id), + (SELECT count(*) FROM enrollment_sessions es WHERE es.bereitstellungsvorlage_id = bv.id) FROM bereitstellungsvorlagen bv JOIN workspaces w ON w.id = bv.workspace_id JOIN backends b ON b.id = bv.backend_id @@ -772,6 +775,12 @@ def fetch_templates(organization_id: str) -> list[dict]: "backend": {"id": str(row[5]), "name": row[6], "version": row[7]}, "is_default": row[2], "partitioning": row[8], + "disk_encryption": row[9], + "secure_boot_required": row[10], + # GUI-Runde 09.10.2026 (Punkt 5): Nutzung - solange Geräte oder + # Installationsmedien darauf verweisen, ist Löschen gesperrt. + "geraete_anzahl": row[11], + "medien_anzahl": row[12], } for row in rows ] @@ -6333,6 +6342,51 @@ def list_blueprints(authorization: str | None = Header(default=None)): return {"success": True, "blueprints": fetch_blueprints()} +def _bereitstellungsvorlage_pruefen(payload) -> tuple[str | None, dict | None]: + """Gemeinsame Prüfung für Anlegen und Bearbeiten (GUI-Runde 09.10.2026). + Rückgabe: (backend_id, fehler). Seit ADR-0038 hat ein Workspace genau + eine Distro - fehlt backend_id, gilt die des Workspace.""" + + if not payload.label.strip(): + return None, {"success": False, "error": "invalid_request", "message": "Die Bezeichnung darf nicht leer sein."} + if payload.target_disk_rank < 1: + return None, {"success": False, "error": "invalid_request", "message": "target_disk_rank muss mindestens 1 sein."} + if payload.scheme not in ("single", "custom"): + return None, {"success": False, "error": "invalid_request", "message": "scheme muss 'single' oder 'custom' sein."} + if payload.scheme == "custom": + mountpoints = [p.mountpoint for p in payload.extra_partitions] + if len(mountpoints) != len(set(mountpoints)): + return None, {"success": False, "error": "invalid_request", "message": "extra_partitions enthält doppelte Mountpoints."} + if sum(p.percent for p in payload.extra_partitions) >= 100: + return None, {"success": False, "error": "invalid_request", "message": "Die Summe der Partitionsanteile muss unter 100 % bleiben."} + for p in payload.extra_partitions: + if not p.mountpoint.startswith("/"): + return None, {"success": False, "error": "invalid_request", "message": f"Mountpoint '{p.mountpoint}' muss mit '/' beginnen."} + if p.percent <= 0: + return None, {"success": False, "error": "invalid_request", "message": f"Der Anteil für '{p.mountpoint}' muss positiv sein."} + if p.filesystem not in ("ext4", "btrfs"): + return None, {"success": False, "error": "invalid_request", "message": f"Nicht unterstütztes Dateisystem für '{p.mountpoint}': {p.filesystem}"} + elif payload.extra_partitions: + return None, {"success": False, "error": "invalid_request", "message": "Eigene Partitionen gibt es nur bei scheme='custom'."} + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT backend_id FROM workspaces WHERE id = %s", (payload.workspace_id,)) + ws_row = cur.fetchone() + if ws_row is None: + return None, {"success": False, "error": "workspace_not_found", "message": "Der Workspace wurde nicht gefunden."} + workspace_backend = str(ws_row[0]) if ws_row[0] is not None else None + backend_id = payload.backend_id or workspace_backend + if backend_id is None: + return None, {"success": False, "error": "invalid_request", "message": "Der Workspace hat keine Distro - bitte eine Distro angeben."} + if workspace_backend is not None and workspace_backend != backend_id: + return None, { + "success": False, "error": "distro_konflikt", + "message": "Der Workspace gehört zu einer anderen Distro als das Profil (ein Workspace hat genau eine Distro).", + } + return backend_id, None + + @app.get("/api/v1/organizations/{organization_id}/bereitstellungsvorlagen") def list_bereitstellungsvorlagen(organization_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): @@ -6350,47 +6404,14 @@ def create_bereitstellungsvorlage_endpoint( 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."} - - if payload.target_disk_rank < 1: - return {"success": False, "error": "invalid_request", "message": "target_disk_rank muss mindestens 1 sein."} - - if payload.scheme not in ("single", "custom"): - return {"success": False, "error": "invalid_request", "message": "scheme muss 'single' oder 'custom' sein."} - - if payload.scheme == "custom": - mountpoints = [p.mountpoint for p in payload.extra_partitions] - if len(mountpoints) != len(set(mountpoints)): - return {"success": False, "error": "invalid_request", "message": "extra_partitions enthält doppelte Mountpoints."} - if sum(p.percent for p in payload.extra_partitions) >= 100: - return {"success": False, "error": "invalid_request", "message": "Summe der extra_partitions-Prozente muss unter 100 bleiben."} - for p in payload.extra_partitions: - if not p.mountpoint.startswith("/"): - return {"success": False, "error": "invalid_request", "message": f"Mountpoint '{p.mountpoint}' muss mit '/' beginnen."} - if p.percent <= 0: - return {"success": False, "error": "invalid_request", "message": f"percent für '{p.mountpoint}' muss positiv sein."} - if p.filesystem not in ("ext4", "btrfs"): - return {"success": False, "error": "invalid_request", "message": f"Nicht unterstütztes Dateisystem für '{p.mountpoint}': {p.filesystem}"} - elif payload.extra_partitions: - return {"success": False, "error": "invalid_request", "message": "extra_partitions ist nur bei scheme='custom' erlaubt."} - - with get_database_connection() as conn: - with conn.cursor() as cur: - cur.execute("SELECT backend_id FROM workspaces WHERE id = %s", (payload.workspace_id,)) - ws_row = cur.fetchone() - if ws_row is None: - return {"success": False, "error": "workspace_not_found", "message": "Der Workspace wurde nicht gefunden."} - if ws_row[0] is not None and str(ws_row[0]) != payload.backend_id: - return { - "success": False, "error": "distro_konflikt", - "message": "Der Workspace gehört zu einer anderen Distro als das Profil (ein Workspace hat genau eine Distro).", - } + backend_id, fehler = _bereitstellungsvorlage_pruefen(payload) + if fehler: + return fehler vorlage = create_bereitstellungsvorlage( organization_id, payload.workspace_id, - payload.backend_id, + backend_id, payload.label, payload.is_default, payload.disk_encryption, @@ -6404,6 +6425,87 @@ def create_bereitstellungsvorlage_endpoint( return {"success": True, "bereitstellungsvorlage": vorlage} +def update_bereitstellungsvorlage(organization_id: str, vorlage_id: str, payload, backend_id: str) -> bool: + partitioning = { + "scheme": payload.scheme, + "root_filesystem": payload.root_filesystem, + "target_disk_rank": payload.target_disk_rank, + "extra_partitions": [p.model_dump() for p in payload.extra_partitions], + } + with get_database_connection() as conn: + with conn.cursor() as cur: + if payload.is_default: + cur.execute( + "UPDATE bereitstellungsvorlagen SET is_default = FALSE WHERE organization_id = %s AND is_default AND id <> %s", + (organization_id, vorlage_id), + ) + cur.execute( + """ + UPDATE bereitstellungsvorlagen + SET workspace_id = %s, backend_id = %s, label = %s, is_default = %s, + disk_encryption = %s, partitioning = %s, secure_boot_required = %s, updated_at = now() + WHERE id = %s AND organization_id = %s + """, + ( + payload.workspace_id, backend_id, payload.label.strip(), payload.is_default, + payload.disk_encryption, Jsonb(partitioning), payload.secure_boot_required, + vorlage_id, organization_id, + ), + ) + return cur.rowcount == 1 + + +@app.put("/api/v1/organizations/{organization_id}/bereitstellungsvorlagen/{vorlage_id}") +def update_bereitstellungsvorlage_endpoint( + organization_id: str, + vorlage_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."} + + backend_id, fehler = _bereitstellungsvorlage_pruefen(payload) + if fehler: + return fehler + if not update_bereitstellungsvorlage(organization_id, vorlage_id, payload, backend_id): + return {"success": False, "error": "not_found", "message": "Die Bereitstellungsvorlage wurde nicht gefunden."} + return {"success": True} + + +@app.delete("/api/v1/organizations/{organization_id}/bereitstellungsvorlagen/{vorlage_id}") +def delete_bereitstellungsvorlage_endpoint( + organization_id: str, + vorlage_id: str, + authorization: str | None = Header(default=None), +): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT (SELECT count(*) FROM assignments WHERE bereitstellungsvorlage_id = %s) + + (SELECT count(*) FROM enrollment_sessions WHERE bereitstellungsvorlage_id = %s) + """, + (vorlage_id, vorlage_id), + ) + (verweise,) = cur.fetchone() + if verweise: + return { + "success": False, "error": "in_benutzung", + "message": "Die Vorlage wird noch von Geräten oder Installationsmedien verwendet und kann nicht gelöscht werden.", + } + cur.execute( + "DELETE FROM bereitstellungsvorlagen WHERE id = %s AND organization_id = %s", + (vorlage_id, organization_id), + ) + if cur.rowcount != 1: + return {"success": False, "error": "not_found", "message": "Die Bereitstellungsvorlage wurde nicht gefunden."} + return {"success": True} + + @app.get("/api/v1/organizations/{organization_id}/devices/unassigned") def list_unassigned_devices(organization_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization):