feat: Geräte-Lebenszyklus (De-/Reprovisionieren, Archivieren) + Hardware-/Event-Read-Endpoints
Fundament für die neue Aktionen-Spalte im Kundenportal (/geraete):
- devices.deprovisioned_at / archived_at (Migration 0017)
- GET /api/v1/devices/{id}/hardware, GET .../events
- POST .../deprovision, .../reprovision, .../archive
- fetch_devices_for_organization/fetch_all_devices blenden archivierte
Geräte aus, liefern deprovisioned_at mit
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
fb175bf861
commit
382ec12245
288
app.py
288
app.py
@ -773,9 +773,9 @@ def fetch_devices_for_organization(organization_id: str):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, hostname, device_fingerprint, agent_last_checkin
|
||||
SELECT id, hostname, device_fingerprint, agent_last_checkin, deprovisioned_at
|
||||
FROM devices
|
||||
WHERE organization_id = %s
|
||||
WHERE organization_id = %s AND archived_at IS NULL
|
||||
ORDER BY hostname NULLS LAST, created_at
|
||||
""",
|
||||
(organization_id,),
|
||||
@ -788,8 +788,11 @@ def fetch_devices_for_organization(organization_id: str):
|
||||
"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 in cur.fetchall()
|
||||
for device_id, hostname, fingerprint, last_checkin, deprovisioned_at in cur.fetchall()
|
||||
]
|
||||
|
||||
def fetch_all_devices():
|
||||
@ -804,9 +807,10 @@ def fetch_all_devices():
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT d.id, d.hostname, d.device_fingerprint, d.agent_last_checkin,
|
||||
d.organization_id, o.name
|
||||
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
|
||||
"""
|
||||
)
|
||||
@ -820,13 +824,197 @@ def fetch_all_devices():
|
||||
),
|
||||
"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,
|
||||
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:
|
||||
@ -1736,6 +1924,96 @@ def list_all_devices(authorization: str | None = Header(default=None)):
|
||||
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):
|
||||
|
||||
11
migrations/0017_device_lifecycle.sql
Normal file
11
migrations/0017_device_lifecycle.sql
Normal file
@ -0,0 +1,11 @@
|
||||
BEGIN;
|
||||
|
||||
-- Kundenportal-Geräteaktionen (De-/Reprovisionieren, Löschen): zwei
|
||||
-- unabhängige Nullable-Timestamps statt eines Status-Enums, weil ein Gerät
|
||||
-- deprovisioniert und trotzdem noch in der aktiven Liste sichtbar sein kann,
|
||||
-- waehrend "archiviert" es komplett ausblendet - ein einzelnes Enum wuerde
|
||||
-- ungueltige Kombinationen erlauben oder Zustaende weglassen.
|
||||
ALTER TABLE devices ADD COLUMN deprovisioned_at TIMESTAMPTZ;
|
||||
ALTER TABLE devices ADD COLUMN archived_at TIMESTAMPTZ;
|
||||
|
||||
COMMIT;
|
||||
Loading…
x
Reference in New Issue
Block a user