Thomas Stallinger 507bab7282 fix: agent_bootstrap verweigert deprovisionierte Geräte
Ohne dieses Gate würde der neue Self-Heal-Re-Bootstrap im Agenten (siehe
provisioning-agent) die De-Provisionierung wirkungslos machen: der Agent
hätte sich bei jedem 401 auf checkin einfach selbst ein neues Secret
geholt, ganz ohne dass jemand 'erneut provisionieren' ausgelöst hat.
reprovision_device() (löscht deprovisioned_at) ist jetzt die tatsächliche
Voraussetzung dafür, dass ein erneuter Bootstrap-Aufruf erfolgreich ist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 13:14:49 +02:00

2496 lines
87 KiB
Python

from pathlib import Path
from datetime import datetime, timezone
import hashlib
import hmac
import json
import os
import secrets
import subprocess
import threading
from uuid import UUID, uuid4
import psycopg
from psycopg.types.json import Jsonb
from fastapi import FastAPI, Header, Request
from pydantic import BaseModel
from fastapi.responses import FileResponse
LOG_PATH = Path("activation.log")
DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL")
AGENT_POLL_INTERVAL_SECONDS = int(os.environ.get("TUXFLOTTE_AGENT_POLL_INTERVAL", "300"))
ANSIBLE_CONTENT_REPO = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO", "")
ADMIN_TOKEN = os.environ.get("TUXFLOTTE_ADMIN_TOKEN", "")
KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "")
AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"}
# Phase 4 des Self-Service-ISO-Plans (siehe tuxflotte-installer/scripts/
# build_customer_iso.sh). TUXFLOTTE_INSTALLER_DIR ist ein separat auf anode
# geklontes Repo (nicht Teil von provisioning-server selbst), MINT_ISO_PATH
# das einmalig von der offiziellen Downloadseite geholte Quellabbild.
TUXFLOTTE_INSTALLER_DIR = os.environ.get("TUXFLOTTE_INSTALLER_DIR", "/root/tuxflotte-installer")
# Absolut aufloesen: run_iso_build() startet build_customer_iso.sh mit
# cwd=TUXFLOTTE_INSTALLER_DIR, ein relativer Pfad wuerde also dort statt
# relativ zum eigenen Arbeitsverzeichnis (WorkingDirectory des Service)
# gesucht - genau das hat den ersten Live-Test auf anode fehlschlagen lassen.
MINT_ISO_PATH = str(Path(os.environ.get("TUXFLOTTE_MINT_ISO_PATH", "data/upstream/linuxmint-22.3-cinnamon-64bit.iso")).resolve())
# Aus demselben Grund wie MINT_ISO_PATH absolut aufloesen: der als $2 an
# build_customer_iso.sh uebergebene Ausgabepfad wird von xorriso relativ
# zum Subprocess-cwd (TUXFLOTTE_INSTALLER_DIR) interpretiert, nicht relativ
# zum eigenen Arbeitsverzeichnis.
ISO_BUILD_OUTPUT_DIR = Path(os.environ.get("TUXFLOTTE_ISO_BUILD_OUTPUT_DIR", "data/iso-builds")).resolve()
ISO_BUILD_TIMEOUT_SECONDS = 1800
app = FastAPI(title="Provisioning Activation Server", version="0.1.0")
class ActivationRequest(BaseModel):
activation_code: str
device_fingerprint: str
hostname: str | None = None
machine_id: str | None = None
client_version: str | None = None
hardware: dict
class ResolveRequest(BaseModel):
device_id: str
class AgentBootstrapRequest(BaseModel):
device_id: str
class AgentCheckinRequest(BaseModel):
device_id: str
class AgentReportEntry(BaseModel):
merkmal: str
event_type: str
detail: dict | None = None
class AgentReportRequest(BaseModel):
device_id: str
results: list[AgentReportEntry]
class AuftragSelectRequest(BaseModel):
optionen: dict = {}
class CreateOrganizationRequest(BaseModel):
name: str
class UpdateOrganizationRequest(BaseModel):
name: str
class CreateMerkmalRequest(BaseModel):
key: str
name: str
description: str | None = None
class UpdateMerkmalRequest(BaseModel):
name: str
description: str | None = None
kategorie_id: str | None = None
im_auftragskatalog: bool = False
class CreateKategorieRequest(BaseModel):
key: str
name: str
description: str | None = None
sort_order: int = 0
class CreateWorkspaceRequest(BaseModel):
key: str
name: str
description: 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):
bereitstellungsvorlage_id: str
max_devices: int
expires_at: str
class RechargeEnrollmentSessionRequest(BaseModel):
additional_devices: int = 0
expires_at: str | None = None
class CreateIsoBuildRequest(BaseModel):
activation_code: str
wifi_ssid: str | None = None
wifi_psk: str | None = None
def write_log(entry: dict) -> None:
with LOG_PATH.open("a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
def get_database_connection():
if not DATABASE_URL:
raise RuntimeError("TUXFLOTTE_DATABASE_URL ist nicht gesetzt.")
return psycopg.connect(DATABASE_URL)
def load_or_create_device(
organization_id,
device_fingerprint: str,
hostname: str | None,
) -> tuple[dict, bool]:
"""
Lädt ein bekanntes Gerät oder legt es neu an.
Rückgabe:
(device, created)
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
id,
organization_id,
device_fingerprint,
hostname,
created_at,
last_seen
FROM devices
WHERE device_fingerprint = %s
""",
(device_fingerprint,),
)
row = cur.fetchone()
if row is not None:
if str(row[1]) != str(organization_id):
raise RuntimeError(
"Das Gerät ist bereits einer anderen Organization zugeordnet."
)
cur.execute(
"""
UPDATE devices
SET
hostname = %s,
last_seen = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING
id,
organization_id,
device_fingerprint,
hostname,
created_at,
last_seen
""",
(hostname, row[0]),
)
row = cur.fetchone()
created = False
else:
device_id = uuid4()
cur.execute(
"""
INSERT INTO devices (
id,
organization_id,
device_fingerprint,
hostname
)
VALUES (%s, %s, %s, %s)
RETURNING
id,
organization_id,
device_fingerprint,
hostname,
created_at,
last_seen
""",
(
device_id,
organization_id,
device_fingerprint,
hostname,
),
)
row = cur.fetchone()
created = True
device = {
"id": str(row[0]),
"organization_id": str(row[1]),
"device_fingerprint": row[2],
"hostname": row[3],
"created_at": row[4].isoformat(),
"last_seen": row[5].isoformat(),
}
return device, created
def store_hardware_snapshot(
device_id: str,
hardware: dict,
) -> str:
snapshot_id = uuid4()
identity = hardware.get("identity", {})
system = hardware.get("system", {})
mainboard = hardware.get("mainboard", {})
firmware = hardware.get("firmware", {})
security = hardware.get("security", {})
cpu = system.get("cpu", {})
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO hardware_snapshots (
id,
device_id,
architecture,
manufacturer,
product_name,
product_version,
system_uuid,
system_serial,
board_vendor,
board_name,
board_serial,
bios_vendor,
bios_version,
boot_mode,
secure_boot,
tpm_version,
cpu_model,
cpu_logical_count,
memory_bytes
)
VALUES (
%s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, %s, %s
)
""",
(
snapshot_id,
device_id,
system["architecture"],
system.get("manufacturer"),
system.get("product_name"),
system.get("product_version"),
identity.get("system_uuid"),
identity.get("system_serial"),
mainboard.get("vendor"),
mainboard.get("name"),
identity.get("board_serial"),
firmware.get("bios_vendor"),
firmware.get("bios_version"),
firmware.get("boot_mode"),
firmware.get("secure_boot"),
security.get("tpm_version"),
cpu.get("model"),
cpu.get("logical_count"),
system.get("memory_bytes"),
),
)
for interface in hardware.get("network_interfaces", []):
cur.execute(
"""
INSERT INTO network_interfaces (
id,
hardware_snapshot_id,
name,
type,
mac_address
)
VALUES (%s, %s, %s, %s, %s)
""",
(
uuid4(),
snapshot_id,
interface["name"],
interface["type"],
interface["mac"],
),
)
for storage_device in hardware.get("storage_devices", []):
cur.execute(
"""
INSERT INTO storage_devices (
id,
hardware_snapshot_id,
name,
model,
serial,
size_bytes,
transport
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
uuid4(),
snapshot_id,
storage_device["name"],
storage_device.get("model"),
storage_device.get("serial"),
storage_device["size_bytes"],
storage_device.get("transport"),
),
)
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 find_enrollment_session(code: str):
"""
Enrollment Sessions haben kein eigenes code-Feld - ihre id dient direkt
als Bearer-Credential (unratbare UUID, siehe migrations/0012). Nur
gültige Sessions (nicht widerrufen, nicht abgelaufen, Kontingent nicht
erschöpft) werden zurückgegeben; jede andere Bedingung lässt /activate
denselben "invalid_activation_code"-Fehlschlag melden wie ein
ungültiger klassischer Aktivierungscode - das bricht den Installer
bereits beim Server-Handshake (Schritt 15) ab, lange vor jeder
destruktiven Aktion (siehe ADR-0008).
"""
try:
session_id = UUID(code)
except ValueError:
return None
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT es.id, es.organization_id, o.name, es.bereitstellungsvorlage_id,
es.max_devices, es.devices_used, es.expires_at, es.revoked_at
FROM enrollment_sessions es
JOIN organizations o ON o.id = es.organization_id
WHERE es.id = %s
""",
(session_id,),
)
row = cur.fetchone()
if row is None:
return None
(
session_id, organization_id, organization_name, bereitstellungsvorlage_id,
max_devices, devices_used, expires_at, revoked_at,
) = row
if revoked_at is not None:
return None
if expires_at <= datetime.now(timezone.utc):
return None
if devices_used >= max_devices:
return None
return {
"session_id": str(session_id),
"organization_id": str(organization_id),
"organization_name": organization_name,
"bereitstellungsvorlage_id": str(bereitstellungsvorlage_id),
}
def consume_enrollment_session(session_id: str) -> None:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE enrollment_sessions SET devices_used = devices_used + 1 WHERE id = %s",
(session_id,),
)
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
]
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:
"""
Akzeptiert entweder den Betreiber-Admin-Token oder das separate
Kundenplattform-Service-Token (siehe ADR-0011) - beide dürfen die
Auftragskatalog-Endpoints aufrufen, Kompromittierung/Rotation des einen
betrifft den anderen nicht.
"""
if authorization is None or not authorization.startswith("Bearer "):
return False
provided_token = authorization.removeprefix("Bearer ")
if ADMIN_TOKEN and hmac.compare_digest(provided_token, ADMIN_TOKEN):
return True
if KUNDENPLATTFORM_TOKEN and hmac.compare_digest(provided_token, KUNDENPLATTFORM_TOKEN):
return True
return False
def hash_agent_secret(secret: str) -> str:
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
def verify_agent_secret(device_id: str, provided_secret: str) -> bool:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT agent_secret_hash FROM devices WHERE id = %s",
(device_id,),
)
row = cur.fetchone()
if row is None or row[0] is None:
return False
return hmac.compare_digest(row[0], hash_agent_secret(provided_secret))
def fetch_assigned_blueprints(device_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.workspace_id, bv.backend_id
FROM assignments a
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
WHERE a.device_id = %s
""",
(device_id,),
)
assignment_row = cur.fetchone()
if assignment_row is None:
return []
workspace_id, backend_id = assignment_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),
)
return [
{"merkmal": key, "ansible_role": ansible_role}
for key, ansible_role in cur.fetchall()
]
def fetch_auftragskatalog_state(device_id: str):
"""
Voller Soll-Zustand (present/absent) aller katalogfähigen Merkmale für
das Backend des Device (siehe ADR-0010). Zustandslos aus der aktuellen
Auswahl abgeleitet, keine Historie nötig.
Default ohne explizite Auftragszuweisung ist die Workspace-Zugehörigkeit:
der Auftragskatalog besteht aus denselben Merkmalen, aus denen auch
Workspaces zusammengesetzt werden, und ein Workspace ist fachlich nichts
anderes als eine Vorauswahl aus dem Katalog. Eine vorhandene
device_merkmale-Zeile überschreibt diesen Default in beide Richtungen
(auch ein workspace-komponiertes Merkmal lässt sich damit geräteweise
abwählen).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.workspace_id, bv.backend_id
FROM assignments a
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
WHERE a.device_id = %s
""",
(device_id,),
)
assignment_row = cur.fetchone()
if assignment_row is None:
return []
workspace_id, backend_id = assignment_row
cur.execute(
"""
SELECT m.key, bp.ansible_role,
COALESCE(dm.aktiv, wm.merkmal_id IS NOT NULL),
COALESCE(dm.optionen, '{}'::jsonb)
FROM merkmale m
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
LEFT JOIN device_merkmale dm
ON dm.merkmal_id = m.id AND dm.device_id = %s
LEFT JOIN workspace_merkmale wm
ON wm.merkmal_id = m.id AND wm.workspace_id = %s
WHERE m.im_auftragskatalog
""",
(backend_id, device_id, workspace_id),
)
return [
{
"merkmal": key,
"ansible_role": ansible_role,
"state": "present" if aktiv else "absent",
"optionen": optionen,
}
for key, ansible_role, aktiv, optionen in cur.fetchall()
]
def fetch_auftragskatalog_listing(device_id: str):
"""
Wie fetch_auftragskatalog_state, aber für die Auswahl-API angereichert um
Anzeige-relevante Felder (Name, Beschreibung, Kategorie) statt nur die
für den Agenten nötigen (Rollenname, State).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.workspace_id, bv.backend_id
FROM assignments a
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
WHERE a.device_id = %s
""",
(device_id,),
)
assignment_row = cur.fetchone()
if assignment_row is None:
return None
workspace_id, backend_id = assignment_row
cur.execute(
"""
SELECT m.key, m.name, m.description,
k.key, k.name, k.sort_order,
COALESCE(dm.aktiv, wm.merkmal_id IS NOT NULL),
COALESCE(dm.optionen, '{}'::jsonb)
FROM merkmale m
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
LEFT JOIN kategorien k ON k.id = m.kategorie_id
LEFT JOIN device_merkmale dm
ON dm.merkmal_id = m.id AND dm.device_id = %s
LEFT JOIN workspace_merkmale wm
ON wm.merkmal_id = m.id AND wm.workspace_id = %s
WHERE m.im_auftragskatalog
ORDER BY k.sort_order NULLS LAST, m.name
""",
(backend_id, device_id, workspace_id),
)
return [
{
"merkmal": key,
"name": name,
"description": description,
"kategorie": (
{"key": kat_key, "name": kat_name, "sort_order": sort_order}
if kat_key is not None else None
),
"state": "present" if aktiv else "absent",
"optionen": optionen,
}
for (
key, name, description,
kat_key, kat_name, sort_order,
aktiv, optionen,
) in cur.fetchall()
]
def set_auftrag_selection(device_id: str, merkmal_key: str, aktiv: bool, optionen: dict):
"""
Setzt die geräteweise Auswahl eines katalogfähigen Merkmals (Upsert in
device_merkmale) und protokolliert die Änderung als selected/deselected-
Event. Gibt False zurück, wenn kein katalogfähiges Merkmal mit diesem Key
existiert (unbekannter Key oder im_auftragskatalog = false).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id FROM merkmale WHERE key = %s AND im_auftragskatalog",
(merkmal_key,),
)
merkmal_row = cur.fetchone()
if merkmal_row is None:
return False
merkmal_id = merkmal_row[0]
cur.execute(
"""
INSERT INTO device_merkmale (id, device_id, merkmal_id, aktiv, optionen)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (device_id, merkmal_id) DO UPDATE
SET aktiv = EXCLUDED.aktiv,
optionen = EXCLUDED.optionen,
updated_at = CURRENT_TIMESTAMP
""",
(uuid4(), device_id, merkmal_id, aktiv, Jsonb(optionen)),
)
cur.execute(
"""
INSERT INTO device_merkmal_events (id, device_id, merkmal_id, event_type)
VALUES (%s, %s, %s, %s)
""",
(uuid4(), device_id, merkmal_id, "selected" if aktiv else "deselected"),
)
return True
def fetch_devices_for_organization(organization_id: str):
"""
Für Kundenplattforms Geräteliste + Besitz-Validierung (siehe ADR-0011):
alle Geräte einer Organisation, unabhängig von Bereitstellungsvorlagen-
Zuweisung.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, hostname, device_fingerprint, agent_last_checkin, deprovisioned_at
FROM devices
WHERE organization_id = %s AND archived_at IS NULL
ORDER BY hostname NULLS LAST, created_at
""",
(organization_id,),
)
return [
{
"id": str(device_id),
"hostname": hostname,
"device_fingerprint": fingerprint,
"agent_last_checkin": (
last_checkin.isoformat() if last_checkin is not None else None
),
"deprovisioned_at": (
deprovisioned_at.isoformat() if deprovisioned_at is not None else None
),
}
for device_id, hostname, fingerprint, last_checkin, deprovisioned_at in cur.fetchall()
]
def fetch_all_devices():
"""
Organisationsübergreifende Geräteliste für den Kundenplattform-Admin-
Bereich (Verwaltung -> Geräte) - Pendant zu fetch_devices_for_organization,
ohne Org-Filter, mit Organisationsname gejoint.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT d.id, d.hostname, d.device_fingerprint, d.agent_last_checkin,
d.organization_id, o.name, d.deprovisioned_at
FROM devices d
JOIN organizations o ON o.id = d.organization_id
WHERE d.archived_at IS NULL
ORDER BY o.name, d.hostname NULLS LAST, d.created_at
"""
)
return [
{
"id": str(device_id),
"hostname": hostname,
"device_fingerprint": fingerprint,
"agent_last_checkin": (
last_checkin.isoformat() if last_checkin is not None else None
),
"organization_id": str(organization_id),
"organization_name": organization_name,
"deprovisioned_at": (
deprovisioned_at.isoformat() if deprovisioned_at is not None else None
),
}
for (
device_id, hostname, fingerprint, last_checkin,
organization_id, organization_name, deprovisioned_at,
) in cur.fetchall()
]
def fetch_latest_hardware_snapshot(device_id: str):
"""
Für die Kundenportal-Aktion "Hardware-Informationen": neuester
hardware_snapshots-Eintrag des Geräts (mehrere Snapshots über die Zeit
sind möglich, siehe store_hardware_snapshot() - hier interessiert nur der
aktuelle Stand) plus dessen Netzwerk-Interfaces und Storage-Devices.
None, falls das Gerät noch nie aktiviert wurde und somit noch keinen
Snapshot hat.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, collected_at, architecture, manufacturer, product_name,
product_version, system_uuid, system_serial, board_vendor,
board_name, board_serial, bios_vendor, bios_version,
boot_mode, secure_boot, tpm_version, cpu_model,
cpu_logical_count, memory_bytes
FROM hardware_snapshots
WHERE device_id = %s
ORDER BY collected_at DESC
LIMIT 1
""",
(device_id,),
)
row = cur.fetchone()
if row is None:
return None
(
snapshot_id, collected_at, architecture, manufacturer, product_name,
product_version, system_uuid, system_serial, board_vendor,
board_name, board_serial, bios_vendor, bios_version,
boot_mode, secure_boot, tpm_version, cpu_model,
cpu_logical_count, memory_bytes,
) = row
cur.execute(
"SELECT name, type, mac_address FROM network_interfaces WHERE hardware_snapshot_id = %s ORDER BY name",
(snapshot_id,),
)
network_interfaces = [
{"name": name, "type": type_, "mac_address": mac_address}
for name, type_, mac_address in cur.fetchall()
]
cur.execute(
"SELECT name, model, serial, size_bytes, transport FROM storage_devices WHERE hardware_snapshot_id = %s ORDER BY name",
(snapshot_id,),
)
storage_devices = [
{
"name": name,
"model": model,
"serial": serial,
"size_bytes": size_bytes,
"transport": transport,
}
for name, model, serial, size_bytes, transport in cur.fetchall()
]
return {
"collected_at": collected_at.isoformat(),
"architecture": architecture,
"manufacturer": manufacturer,
"product_name": product_name,
"product_version": product_version,
"system_uuid": system_uuid,
"system_serial": system_serial,
"board_vendor": board_vendor,
"board_name": board_name,
"board_serial": board_serial,
"bios_vendor": bios_vendor,
"bios_version": bios_version,
"boot_mode": boot_mode,
"secure_boot": secure_boot,
"tpm_version": tpm_version,
"cpu_model": cpu_model,
"cpu_logical_count": cpu_logical_count,
"memory_bytes": memory_bytes,
"network_interfaces": network_interfaces,
"storage_devices": storage_devices,
}
def fetch_device_events(device_id: str):
"""
Für die Kundenportal-Aktion "Installations-/Aktions-Log": alle bisher
vom Agenten/Portal gemeldeten device_merkmal_events des Geräts, neueste
zuerst.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT e.occurred_at, m.key, m.name, e.event_type, e.detail
FROM device_merkmal_events e
JOIN merkmale m ON m.id = e.merkmal_id
WHERE e.device_id = %s
ORDER BY e.occurred_at DESC
""",
(device_id,),
)
return [
{
"occurred_at": occurred_at.isoformat(),
"merkmal": merkmal_key,
"merkmal_name": merkmal_name,
"event_type": event_type,
"detail": detail,
}
for occurred_at, merkmal_key, merkmal_name, event_type, detail in cur.fetchall()
]
def deprovision_device(device_id: str) -> bool:
"""
Serverseitiger Entzug (siehe Kundenportal-Geräteaktionen-Plan): macht das
aktuelle Agent-Secret ungültig, ohne den Geräte-Datensatz oder dessen
Historie anzutasten. Der Agent auf dem Gerät selbst wird nicht aktiv
kontaktiert - er bekommt beim nächsten Check-in schlicht 401 und bleibt
bis zur Reprovisionierung untätig liegen (siehe agent.py Self-Heal).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE devices
SET agent_secret_hash = NULL, agent_secret_issued_at = NULL,
deprovisioned_at = CURRENT_TIMESTAMP
WHERE id = %s AND archived_at IS NULL
RETURNING id
""",
(device_id,),
)
return cur.fetchone() is not None
def reprovision_device(device_id: str) -> bool:
"""
Hebt die Deprovisionierung auf. Erzeugt bewusst kein neues Agent-Secret -
das holt sich der Agent beim nächsten Poll-Zyklus selbst über den
bestehenden /api/v1/agent/bootstrap-Endpunkt (401 auf checkin loest im
Agenten einen Re-Bootstrap aus, siehe agent.py).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE devices
SET deprovisioned_at = NULL
WHERE id = %s AND archived_at IS NULL
RETURNING id
""",
(device_id,),
)
return cur.fetchone() is not None
def archive_device(device_id: str) -> bool:
"""
Kundenportal-Aktion "Gerät löschen": Soft Delete. Das Gerät verschwindet
aus fetch_devices_for_organization()/fetch_all_devices(), Datensatz und
Historie (hardware_snapshots, device_merkmal_events) bleiben in der DB
erhalten.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE devices
SET archived_at = CURRENT_TIMESTAMP
WHERE id = %s AND archived_at IS NULL
RETURNING id
""",
(device_id,),
)
return cur.fetchone() is not None
def fetch_organizations():
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id, name, created_at FROM organizations ORDER BY name"
)
return [
{"id": str(org_id), "name": name, "created_at": created_at.isoformat()}
for org_id, name, created_at in cur.fetchall()
]
def fetch_organization(organization_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id, name, created_at FROM organizations WHERE id = %s",
(organization_id,),
)
row = cur.fetchone()
if row is None:
return None
return {"id": str(row[0]), "name": row[1], "created_at": row[2].isoformat()}
def create_organization(name: str):
organization_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO organizations (id, name) VALUES (%s, %s)",
(organization_id, name),
)
return {"id": str(organization_id), "name": name}
def update_organization(organization_id: str, name: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE organizations SET name = %s WHERE id = %s RETURNING id, name",
(name, organization_id),
)
row = cur.fetchone()
if row is None:
return None
return {"id": str(row[0]), "name": row[1]}
def fetch_activation_codes(organization_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT code, active, created_at
FROM activation_codes
WHERE organization_id = %s
ORDER BY created_at DESC
""",
(organization_id,),
)
return [
{"code": code, "active": active, "created_at": created_at.isoformat()}
for code, active, created_at in cur.fetchall()
]
def create_activation_code(organization_id: str):
code = "-".join(
secrets.token_hex(3).upper()[i:i + 3] for i in range(0, 6, 3)
)
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO activation_codes (code, organization_id) VALUES (%s, %s)",
(code, organization_id),
)
return {"code": code, "active": True}
def fetch_merkmale():
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT m.id, m.key, m.name, m.description, m.im_auftragskatalog,
m.kategorie_id, k.name
FROM merkmale m
LEFT JOIN kategorien k ON k.id = m.kategorie_id
ORDER BY m.key
"""
)
return [
{
"id": str(mid),
"key": key,
"name": name,
"description": description,
"im_auftragskatalog": im_auftragskatalog,
"kategorie_id": str(kategorie_id) if kategorie_id else None,
"kategorie_name": kategorie_name,
}
for (
mid, key, name, description, im_auftragskatalog,
kategorie_id, kategorie_name,
) in cur.fetchall()
]
def create_merkmal(key: str, name: str, description: str | None):
merkmal_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO merkmale (id, key, name, description) VALUES (%s, %s, %s, %s)",
(merkmal_id, key, name, description),
)
return {"id": str(merkmal_id), "key": key, "name": name}
def update_merkmal(merkmal_id: str, name: str, description: str | None, kategorie_id: str | None, im_auftragskatalog: bool):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE merkmale
SET name = %s, description = %s, kategorie_id = %s, im_auftragskatalog = %s
WHERE id = %s
RETURNING id
""",
(name, description, kategorie_id, im_auftragskatalog, merkmal_id),
)
return cur.fetchone() is not None
def fetch_kategorien():
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id, key, name, description, sort_order FROM kategorien ORDER BY sort_order, name"
)
return [
{
"id": str(kid),
"key": key,
"name": name,
"description": description,
"sort_order": sort_order,
}
for kid, key, name, description, sort_order in cur.fetchall()
]
def create_kategorie(key: str, name: str, description: str | None, sort_order: int):
kategorie_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO kategorien (id, key, name, description, sort_order)
VALUES (%s, %s, %s, %s, %s)
""",
(kategorie_id, key, name, description, sort_order),
)
return {"id": str(kategorie_id), "key": key, "name": name}
def fetch_workspaces():
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name
FROM workspaces w
LEFT JOIN organizations o ON o.id = w.organization_id
ORDER BY o.name NULLS FIRST, w.name
"""
)
return [
{
"id": str(wid),
"key": key,
"name": name,
"description": description,
"organization_id": str(organization_id) if organization_id else None,
"organization_name": organization_name,
}
for (
wid, key, name, description, organization_id, organization_name,
) in cur.fetchall()
]
def create_workspace(key: str, name: str, description: str | None, organization_id: str | None):
workspace_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO workspaces (id, organization_id, key, name, description)
VALUES (%s, %s, %s, %s, %s)
""",
(workspace_id, organization_id, key, name, description),
)
return {"id": str(workspace_id), "key": key, "name": name}
def fetch_workspace_detail(workspace_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name
FROM workspaces w
LEFT JOIN organizations o ON o.id = w.organization_id
WHERE w.id = %s
""",
(workspace_id,),
)
row = cur.fetchone()
if row is None:
return None
cur.execute(
"""
SELECT m.id, m.key, m.name
FROM workspace_merkmale wm
JOIN merkmale m ON m.id = wm.merkmal_id
WHERE wm.workspace_id = %s
ORDER BY m.key
""",
(workspace_id,),
)
merkmale = [
{"id": str(mid), "key": key, "name": name}
for mid, key, name in cur.fetchall()
]
return {
"id": str(row[0]),
"key": row[1],
"name": row[2],
"description": row[3],
"organization_id": str(row[4]) if row[4] else None,
"organization_name": row[5],
"merkmale": merkmale,
}
def add_merkmal_to_workspace(workspace_id: str, merkmal_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO workspace_merkmale (workspace_id, merkmal_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
(workspace_id, merkmal_id),
)
def remove_merkmal_from_workspace(workspace_id: str, merkmal_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM workspace_merkmale WHERE workspace_id = %s AND merkmal_id = %s",
(workspace_id, merkmal_id),
)
def fetch_backends():
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT id, key, name, installer_type, version FROM backends ORDER BY key")
return [
{"id": str(bid), "key": key, "name": name, "installer_type": installer_type, "version": version}
for bid, key, name, installer_type, version in cur.fetchall()
]
def fetch_blueprints():
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT m.key, b.key, bp.ansible_role
FROM blueprints bp
JOIN merkmale m ON m.id = bp.merkmal_id
JOIN backends b ON b.id = bp.backend_id
ORDER BY m.key, b.key
"""
)
return [
{"merkmal": merkmal_key, "backend": backend_key, "ansible_role": ansible_role}
for merkmal_key, backend_key, ansible_role in cur.fetchall()
]
def fetch_unassigned_devices(organization_id: str):
"""
Geräte einer Organisation ohne Bereitstellungsvorlagen-Zuweisung - die
Kandidaten für die Neugerät-Bestätigung (ADR-0008) im Flows-Bereich.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT d.id, d.hostname, d.device_fingerprint, d.created_at
FROM devices d
LEFT JOIN assignments a ON a.device_id = d.id
WHERE d.organization_id = %s AND a.id IS NULL
ORDER BY d.created_at
""",
(organization_id,),
)
return [
{
"id": str(device_id),
"hostname": hostname,
"device_fingerprint": fingerprint,
"created_at": created_at.isoformat(),
}
for device_id, hostname, fingerprint, created_at in cur.fetchall()
]
def fetch_enrollment_sessions(organization_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT es.id, bv.label, es.max_devices, es.devices_used,
es.expires_at, es.revoked_at, es.created_at
FROM enrollment_sessions es
JOIN bereitstellungsvorlagen bv ON bv.id = es.bereitstellungsvorlage_id
WHERE es.organization_id = %s
ORDER BY es.created_at DESC
""",
(organization_id,),
)
return [
{
"id": str(sid),
"bereitstellungsvorlage_label": label,
"max_devices": max_devices,
"devices_used": devices_used,
"expires_at": expires_at.isoformat(),
"revoked_at": revoked_at.isoformat() if revoked_at else None,
"created_at": created_at.isoformat(),
}
for (
sid, label, max_devices, devices_used,
expires_at, revoked_at, created_at,
) in cur.fetchall()
]
def create_enrollment_session(organization_id: str, bereitstellungsvorlage_id: str, max_devices: int, expires_at: str):
session_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO enrollment_sessions
(id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at)
VALUES (%s, %s, %s, %s, %s)
""",
(session_id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at),
)
return {"id": str(session_id)}
def revoke_enrollment_session(session_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE enrollment_sessions
SET revoked_at = CURRENT_TIMESTAMP
WHERE id = %s AND revoked_at IS NULL
RETURNING id
""",
(session_id,),
)
return cur.fetchone() is not None
def recharge_enrollment_session(session_id: str, additional_devices: int, expires_at: str | None):
"""
Phase 5 (Kundenplattform-Selfservice): eine bereits ausgeteilte,
personalisierte Kunden-ISO bettet die Enrollment-Session-Id fest als
Aktivierungscode ein (siehe build_customer_iso.sh) - Kontingent/
Gueltigkeit dieser Session zu erhoehen macht die ISO weiter nutzbar,
ohne dass eine neue gebaut/neu verteilt werden muss. additional_devices
wird auf max_devices addiert (nicht ersetzt - "aufladen", nicht
"neu setzen"), expires_at ersetzt den bisherigen Wert, wenn angegeben.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE enrollment_sessions
SET max_devices = max_devices + %s,
expires_at = COALESCE(%s, expires_at)
WHERE id = %s AND revoked_at IS NULL
RETURNING id
""",
(additional_devices, expires_at, session_id),
)
return cur.fetchone() is not None
def iso_build_row_to_dict(row) -> dict:
(build_id, organization_id, status, output_filename, error_message,
created_at, started_at, finished_at) = row
return {
"id": str(build_id),
"organization_id": str(organization_id),
"status": status,
"output_filename": output_filename,
"error_message": error_message,
"created_at": created_at.isoformat(),
"started_at": started_at.isoformat() if started_at else None,
"finished_at": finished_at.isoformat() if finished_at else None,
}
def fetch_iso_build(build_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, organization_id, status, output_filename, error_message,
created_at, started_at, finished_at
FROM iso_builds
WHERE id = %s
""",
(build_id,),
)
row = cur.fetchone()
return iso_build_row_to_dict(row) if row else None
def fetch_iso_builds(organization_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, organization_id, status, output_filename, error_message,
created_at, started_at, finished_at
FROM iso_builds
WHERE organization_id = %s
ORDER BY created_at DESC
""",
(organization_id,),
)
return [iso_build_row_to_dict(row) for row in cur.fetchall()]
def create_iso_build(organization_id: str) -> dict:
build_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO iso_builds (id, organization_id) VALUES (%s, %s)",
(build_id, organization_id),
)
return {"id": str(build_id), "status": "pending"}
def update_iso_build(
build_id,
status: str,
output_filename: str | None = None,
error_message: str | None = None,
mark_started: bool = False,
mark_finished: bool = False,
):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
f"""
UPDATE iso_builds
SET status = %s,
output_filename = COALESCE(%s, output_filename),
error_message = %s
{", started_at = CURRENT_TIMESTAMP" if mark_started else ""}
{", finished_at = CURRENT_TIMESTAMP" if mark_finished else ""}
WHERE id = %s
""",
(status, output_filename, error_message, build_id),
)
def cleanup_old_iso_builds(organization_id: str, keep_build_id) -> None:
"""
Loescht Ausgabedateien + Zeilen abgeschlossener frueherer Baeulaeufe
derselben Organisation (Speicherplatz auf anode ist begrenzt) - eine neu
erzeugte Kunden-ISO ersetzt die vorherige, ein erneuter Download der
alten ergibt fuer den Kunden ohnehin keinen Sinn mehr.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, output_filename FROM iso_builds
WHERE organization_id = %s AND id != %s
AND status IN ('completed', 'failed')
""",
(organization_id, keep_build_id),
)
old_builds = cur.fetchall()
for old_id, output_filename in old_builds:
if output_filename:
(ISO_BUILD_OUTPUT_DIR / output_filename).unlink(missing_ok=True)
cur.execute(
"""
DELETE FROM iso_builds
WHERE organization_id = %s AND id != %s
AND status IN ('completed', 'failed')
""",
(organization_id, keep_build_id),
)
def run_iso_build(build_id, organization_id: str, activation_code: str, wifi_ssid: str | None, wifi_psk: str | None) -> None:
output_path = ISO_BUILD_OUTPUT_DIR / f"{build_id}.iso"
update_iso_build(build_id, status="running", mark_started=True)
try:
result = subprocess.run(
[
"bash",
f"{TUXFLOTTE_INSTALLER_DIR}/scripts/build_customer_iso.sh",
MINT_ISO_PATH,
str(output_path),
activation_code,
wifi_ssid or "",
wifi_psk or "",
],
cwd=TUXFLOTTE_INSTALLER_DIR,
capture_output=True,
text=True,
timeout=ISO_BUILD_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired:
output_path.unlink(missing_ok=True)
update_iso_build(build_id, status="failed", error_message="Zeitüberschreitung beim ISO-Bau.", mark_finished=True)
return
except Exception as exc:
output_path.unlink(missing_ok=True)
update_iso_build(build_id, status="failed", error_message=str(exc)[:4000], mark_finished=True)
return
if result.returncode != 0 or not output_path.is_file():
output_path.unlink(missing_ok=True)
error_tail = (result.stderr or result.stdout or "unbekannter Fehler")[-4000:]
update_iso_build(build_id, status="failed", error_message=error_tail, mark_finished=True)
return
update_iso_build(build_id, status="completed", output_filename=output_path.name, mark_finished=True)
cleanup_old_iso_builds(organization_id, build_id)
@app.get("/health")
def health():
return {
"status": "ok",
"service": "provisioning-server",
"version": "0.1.0"
}
@app.post("/api/v1/activate")
def activate(payload: ActivationRequest, request: Request):
# Enrollment Sessions zuerst versuchen (Auto-Modus-ISOs, siehe
# tuxflotte-installer Phase 2/3) - ihre id ist selbst der Code, kollidiert
# nicht mit klassischen Aktivierungscodes (eigene, meist sprechende
# Strings wie "LAB-2026-START"). Fällt bei Nicht-UUID sofort auf den
# klassischen Pfad zurück.
enrollment_session = find_enrollment_session(payload.activation_code)
activation = None
if enrollment_session is not None:
organization_id = enrollment_session["organization_id"]
organization_name = enrollment_session["organization_name"]
success = True
else:
activation = find_activation_code(payload.activation_code)
success = activation is not None
if success:
organization_id = activation["organization_id"]
organization_name = activation["organization_name"]
device = None
device_created = False
hardware_snapshot_id = None
if success:
device, device_created = load_or_create_device(
organization_id,
payload.device_fingerprint,
payload.hostname,
)
hardware_snapshot_id = store_hardware_snapshot(
device["id"],
payload.hardware,
)
if enrollment_session is not None:
consume_enrollment_session(enrollment_session["session_id"])
write_log({
"timestamp": datetime.now(timezone.utc).isoformat(),
"event": "activate",
"success": success,
"remote_ip": request.client.host if request.client else None,
"activation_code": payload.activation_code,
"hostname": payload.hostname,
"machine_id": payload.machine_id,
"client_version": payload.client_version
})
if not success:
return {
"success": False,
"error": "invalid_activation_code",
"message": "Der Aktivierungscode ist ungültig."
}
if enrollment_session is not None:
# Die Bereitstellungsvorlage steht durch die Enrollment Session schon
# fest - nicht die volle Organisationsliste zurückgeben (20_profile_
# selection.sh würde im Auto-Modus sonst nichts Eindeutiges zum
# Auswählen haben), sondern nur diese eine, als is_default markiert
# (dieselbe Erkennung, die der Installer schon für den regulären
# Standard-Vorlagen-Fall benutzt - keine Installer-Änderung nötig).
templates = [
{**template, "is_default": True}
for template in fetch_templates(organization_id)
if template["id"] == enrollment_session["bereitstellungsvorlage_id"]
]
else:
templates = fetch_templates(organization_id)
return {
"success": True,
"device": {
"id": device["id"],
"fingerprint": device["device_fingerprint"],
"hostname": device["hostname"],
"registration_status": (
"registered"
if device_created
else "existing"
),
"hardware_snapshot_id": hardware_snapshot_id,
},
"customer": {
"id": organization_id,
"organization_id": organization_id,
"name": organization_name,
},
"templates": templates,
}
@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 bv.workspace_id, bv.backend_id, w.key, b.key,
bv.disk_encryption, bv.partitioning, bv.secure_boot_required
FROM bereitstellungsvorlagen bv
JOIN workspaces w ON w.id = bv.workspace_id
JOIN backends b ON b.id = bv.backend_id
WHERE bv.id = %s
""",
(template_id,),
)
template_row = cur.fetchone()
if template_row is None:
return {
"success": False,
"error": "template_not_found",
"message": "Die angeforderte Bereitstellungsvorlage wurde nicht gefunden."
}
(
workspace_id, backend_id, workspace_key, backend_key,
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,
"runtime_blueprint": {
"workspace_id": workspace_key,
"backend_id": backend_key,
"blueprints": blueprints,
"installation_directives": {
"disk_encryption": disk_encryption,
"partitioning": partitioning,
"secure_boot_required": secure_boot_required,
},
},
}
@app.get("/installers/fedora-workstation/ks.cfg")
def get_fedora_kickstart():
return FileResponse(
"installers/fedora-workstation/ks.cfg",
media_type="text/plain"
)
@app.post("/api/v1/agent/bootstrap")
def agent_bootstrap(payload: AgentBootstrapRequest):
agent_secret = secrets.token_urlsafe(32)
# deprovisioned_at wird hier bewusst mitgeprueft: der Agent ruft diesen
# Endpoint auch selbststaendig bei 401 auf checkin auf (siehe agent.py
# Self-Heal). Ohne dieses Gate wuerde ein per Kundenportal
# deprovisioniertes Geraet sich beim naechsten Poll-Zyklus einfach
# selbst ein neues Secret holen und die De-Provisionierung waere
# wirkungslos - das Gate macht reprovision_device() (Loeschen von
# deprovisioned_at) zur tatsaechlichen Voraussetzung fuer den Erfolg
# dieses Aufrufs.
existing = None
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE devices
SET agent_secret_hash = %s, agent_secret_issued_at = %s
WHERE id = %s AND deprovisioned_at IS NULL
RETURNING id
""",
(
hash_agent_secret(agent_secret),
datetime.now(timezone.utc),
payload.device_id,
),
)
updated = cur.fetchone()
if updated is None:
cur.execute(
"SELECT deprovisioned_at FROM devices WHERE id = %s",
(payload.device_id,),
)
existing = cur.fetchone()
if updated is None:
if existing is not None and existing[0] is not None:
return {
"success": False,
"error": "device_deprovisioned",
"message": "Das Gerät ist deprovisioniert und muss zuerst über das Kundenportal reprovisioniert werden.",
}
return {
"success": False,
"error": "device_not_found",
"message": "Das angegebene Gerät wurde nicht gefunden.",
}
return {"success": True, "agent_secret": agent_secret}
@app.post("/api/v1/agent/checkin")
def agent_checkin(
payload: AgentCheckinRequest,
authorization: str | None = Header(default=None),
):
if authorization is None or not authorization.startswith("Bearer "):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Authorization-Header.",
}
provided_secret = authorization.removeprefix("Bearer ")
if not verify_agent_secret(payload.device_id, provided_secret):
return {
"success": False,
"error": "unauthorized",
"message": "Ungültiges Agent-Secret.",
}
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE devices SET agent_last_checkin = %s WHERE id = %s",
(datetime.now(timezone.utc), payload.device_id),
)
return {
"success": True,
"blueprints": fetch_assigned_blueprints(payload.device_id),
"auftraege": fetch_auftragskatalog_state(payload.device_id),
"ansible_repo": ANSIBLE_CONTENT_REPO,
"poll_interval_seconds": AGENT_POLL_INTERVAL_SECONDS,
}
@app.post("/api/v1/agent/report")
def agent_report(
payload: AgentReportRequest,
authorization: str | None = Header(default=None),
):
if authorization is None or not authorization.startswith("Bearer "):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Authorization-Header.",
}
provided_secret = authorization.removeprefix("Bearer ")
if not verify_agent_secret(payload.device_id, provided_secret):
return {
"success": False,
"error": "unauthorized",
"message": "Ungültiges Agent-Secret.",
}
for entry in payload.results:
if entry.event_type not in AGENT_REPORTABLE_EVENT_TYPES:
return {
"success": False,
"error": "invalid_event_type",
"message": f"Kein vom Agenten meldbarer event_type: {entry.event_type}",
}
with get_database_connection() as conn:
with conn.cursor() as cur:
merkmal_ids = {}
for entry in payload.results:
if entry.merkmal in merkmal_ids:
continue
cur.execute(
"SELECT id FROM merkmale WHERE key = %s",
(entry.merkmal,),
)
merkmal_row = cur.fetchone()
if merkmal_row is None:
return {
"success": False,
"error": "unknown_merkmal",
"message": f"Unbekanntes Merkmal: {entry.merkmal}",
}
merkmal_ids[entry.merkmal] = merkmal_row[0]
for entry in payload.results:
cur.execute(
"""
INSERT INTO device_merkmal_events (
id, device_id, merkmal_id, event_type, detail
)
VALUES (%s, %s, %s, %s, %s)
""",
(
uuid4(),
payload.device_id,
merkmal_ids[entry.merkmal],
entry.event_type,
Jsonb(entry.detail) if entry.detail is not None else None,
),
)
return {"success": True}
@app.get("/api/v1/organizations/{organization_id}/devices")
def get_organization_devices(
organization_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.",
}
return {"success": True, "devices": fetch_devices_for_organization(organization_id)}
@app.get("/api/v1/devices")
def list_all_devices(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
return {"success": True, "devices": fetch_all_devices()}
@app.get("/api/v1/devices/{device_id}/hardware")
def get_device_hardware(device_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.",
}
snapshot = fetch_latest_hardware_snapshot(device_id)
if snapshot is None:
return {
"success": False,
"error": "no_snapshot",
"message": "Für dieses Gerät liegt noch kein Hardware-Snapshot vor.",
}
return {"success": True, "hardware": snapshot}
@app.get("/api/v1/devices/{device_id}/events")
def get_device_events(device_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.",
}
return {"success": True, "events": fetch_device_events(device_id)}
@app.post("/api/v1/devices/{device_id}/deprovision")
def deprovision(device_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.",
}
if not deprovision_device(device_id):
return {
"success": False,
"error": "device_not_found",
"message": "Das angegebene Gerät wurde nicht gefunden.",
}
return {"success": True}
@app.post("/api/v1/devices/{device_id}/reprovision")
def reprovision(device_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.",
}
if not reprovision_device(device_id):
return {
"success": False,
"error": "device_not_found",
"message": "Das angegebene Gerät wurde nicht gefunden.",
}
return {"success": True}
@app.post("/api/v1/devices/{device_id}/archive")
def archive(device_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.",
}
if not archive_device(device_id):
return {
"success": False,
"error": "device_not_found",
"message": "Das angegebene Gerät wurde nicht gefunden.",
}
return {"success": True}
@app.get("/api/v1/organizations")
def list_organizations(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
return {"success": True, "organizations": fetch_organizations()}
@app.post("/api/v1/organizations")
def create_organization_endpoint(
payload: CreateOrganizationRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
return {"success": True, "organization": create_organization(payload.name)}
@app.get("/api/v1/organizations/{organization_id}")
def get_organization_endpoint(organization_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."}
organization = fetch_organization(organization_id)
if organization is None:
return {"success": False, "error": "organization_not_found", "message": "Die Organisation wurde nicht gefunden."}
return {"success": True, "organization": organization}
@app.patch("/api/v1/organizations/{organization_id}")
def update_organization_endpoint(
organization_id: str,
payload: UpdateOrganizationRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
organization = update_organization(organization_id, payload.name)
if organization is None:
return {
"success": False,
"error": "organization_not_found",
"message": "Die Organisation wurde nicht gefunden.",
}
return {"success": True, "organization": organization}
@app.get("/api/v1/organizations/{organization_id}/activation-codes")
def list_activation_codes(
organization_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.",
}
return {"success": True, "activation_codes": fetch_activation_codes(organization_id)}
@app.post("/api/v1/organizations/{organization_id}/activation-codes")
def create_activation_code_endpoint(
organization_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.",
}
return {"success": True, "activation_code": create_activation_code(organization_id)}
@app.get("/api/v1/merkmale")
def list_merkmale(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "merkmale": fetch_merkmale()}
@app.post("/api/v1/merkmale")
def create_merkmal_endpoint(payload: CreateMerkmalRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "merkmal": create_merkmal(payload.key, payload.name, payload.description)}
@app.patch("/api/v1/merkmale/{merkmal_id}")
def update_merkmal_endpoint(
merkmal_id: str, payload: UpdateMerkmalRequest, authorization: str | None = Header(default=None)
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
updated = update_merkmal(
merkmal_id, payload.name, payload.description, payload.kategorie_id, payload.im_auftragskatalog
)
if not updated:
return {"success": False, "error": "not_found", "message": "Merkmal wurde nicht gefunden."}
return {"success": True}
@app.get("/api/v1/kategorien")
def list_kategorien(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "kategorien": fetch_kategorien()}
@app.post("/api/v1/kategorien")
def create_kategorie_endpoint(payload: CreateKategorieRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {
"success": True,
"kategorie": create_kategorie(payload.key, payload.name, payload.description, payload.sort_order),
}
@app.get("/api/v1/workspaces")
def list_workspaces(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "workspaces": fetch_workspaces()}
@app.post("/api/v1/workspaces")
def create_workspace_endpoint(payload: CreateWorkspaceRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {
"success": True,
"workspace": create_workspace(payload.key, payload.name, payload.description, payload.organization_id),
}
@app.get("/api/v1/workspaces/{workspace_id}")
def get_workspace(workspace_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."}
workspace = fetch_workspace_detail(workspace_id)
if workspace is None:
return {"success": False, "error": "not_found", "message": "Workspace wurde nicht gefunden."}
return {"success": True, "workspace": workspace}
@app.post("/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}")
def add_workspace_merkmal(workspace_id: str, merkmal_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."}
add_merkmal_to_workspace(workspace_id, merkmal_id)
return {"success": True}
@app.delete("/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}")
def remove_workspace_merkmal(workspace_id: str, merkmal_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."}
remove_merkmal_from_workspace(workspace_id, merkmal_id)
return {"success": True}
@app.get("/api/v1/backends")
def list_backends(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "backends": fetch_backends()}
@app.get("/api/v1/blueprints")
def list_blueprints(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "blueprints": fetch_blueprints()}
@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):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
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")
def list_unassigned_devices(organization_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."}
return {"success": True, "devices": fetch_unassigned_devices(organization_id)}
@app.get("/api/v1/organizations/{organization_id}/enrollment-sessions")
def list_enrollment_sessions(organization_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."}
return {"success": True, "enrollment_sessions": fetch_enrollment_sessions(organization_id)}
@app.post("/api/v1/organizations/{organization_id}/enrollment-sessions")
def create_enrollment_session_endpoint(
organization_id: str,
payload: CreateEnrollmentSessionRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
session = create_enrollment_session(
organization_id, payload.bereitstellungsvorlage_id, payload.max_devices, payload.expires_at
)
return {"success": True, "enrollment_session": session}
@app.post("/api/v1/enrollment-sessions/{session_id}/revoke")
def revoke_enrollment_session_endpoint(session_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."}
revoked = revoke_enrollment_session(session_id)
if not revoked:
return {"success": False, "error": "not_found", "message": "Enrollment Session wurde nicht gefunden oder ist bereits widerrufen."}
return {"success": True}
@app.patch("/api/v1/enrollment-sessions/{session_id}")
def recharge_enrollment_session_endpoint(
session_id: str,
payload: RechargeEnrollmentSessionRequest,
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 payload.additional_devices < 0:
return {"success": False, "error": "invalid_request", "message": "additional_devices darf nicht negativ sein."}
recharged = recharge_enrollment_session(session_id, payload.additional_devices, payload.expires_at)
if not recharged:
return {"success": False, "error": "not_found", "message": "Enrollment Session wurde nicht gefunden oder ist widerrufen."}
return {"success": True}
@app.get("/api/v1/organizations/{organization_id}/iso-builds")
def list_iso_builds(organization_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."}
return {"success": True, "iso_builds": fetch_iso_builds(organization_id)}
@app.post("/api/v1/organizations/{organization_id}/iso-builds")
def create_iso_build_endpoint(
organization_id: str,
payload: CreateIsoBuildRequest,
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.activation_code.strip():
return {"success": False, "error": "invalid_request", "message": "activation_code darf nicht leer sein."}
ISO_BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
iso_build = create_iso_build(organization_id)
thread = threading.Thread(
target=run_iso_build,
args=(UUID(iso_build["id"]), organization_id, payload.activation_code, payload.wifi_ssid, payload.wifi_psk),
daemon=True,
)
thread.start()
return {"success": True, "iso_build": iso_build}
@app.get("/api/v1/iso-builds/{build_id}")
def get_iso_build_endpoint(build_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."}
iso_build = fetch_iso_build(build_id)
if iso_build is None:
return {"success": False, "error": "not_found", "message": "ISO-Build wurde nicht gefunden."}
return {"success": True, "iso_build": iso_build}
@app.get("/api/v1/iso-builds/{build_id}/download")
def download_iso_build(build_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."}
iso_build = fetch_iso_build(build_id)
if iso_build is None:
return {"success": False, "error": "not_found", "message": "ISO-Build wurde nicht gefunden."}
if iso_build["status"] != "completed" or not iso_build["output_filename"]:
return {"success": False, "error": "not_ready", "message": "Der ISO-Build ist noch nicht abgeschlossen."}
output_path = ISO_BUILD_OUTPUT_DIR / iso_build["output_filename"]
if not output_path.is_file():
return {"success": False, "error": "file_missing", "message": "Die ISO-Datei wurde nicht gefunden."}
return FileResponse(
output_path,
media_type="application/octet-stream",
filename="tuxflotte-installationsmedium.iso",
)
@app.get("/api/v1/devices/{device_id}/auftragskatalog")
def get_device_auftragskatalog(
device_id: str,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Admin-Token.",
}
katalog = fetch_auftragskatalog_listing(device_id)
if katalog is None:
return {
"success": False,
"error": "device_not_assigned",
"message": "Das Gerät hat keine aktive Bereitstellungsvorlagen-Zuweisung.",
}
return {"success": True, "auftragskatalog": katalog}
@app.post("/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/select")
def select_auftrag(
device_id: str,
merkmal_key: str,
payload: AuftragSelectRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Admin-Token.",
}
if not set_auftrag_selection(device_id, merkmal_key, True, payload.optionen):
return {
"success": False,
"error": "unknown_merkmal",
"message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}",
}
return {"success": True}
@app.post("/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/deselect")
def deselect_auftrag(
device_id: str,
merkmal_key: str,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Admin-Token.",
}
if not set_auftrag_selection(device_id, merkmal_key, False, {}):
return {
"success": False,
"error": "unknown_merkmal",
"message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}",
}
return {"success": True}