Bereitstellungsvorlagen: Bearbeiten, Loeschen, Nutzung, Distro aus Workspace

Liste liefert Geraete-/Medien-Anzahl, Verschluesselung, Secure Boot. Neue
Endpunkte PUT/DELETE (Loeschen gesperrt solange Geraete oder
Installationsmedien verweisen). backend_id optional - ohne Angabe gilt
die Distro des Workspace (ADR-0038). Pruefung fuer Anlegen und
Bearbeiten in _bereitstellungsvorlage_pruefen(). GUI-Runde 10/2026 Punkt 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 11:34:55 +02:00
1 parent b038350e72
commit 0561691f20
1 file changed
+141 -39
+141 -39
View File
@@ -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):