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:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, hostname, device_fingerprint, agent_last_checkin
|
SELECT id, hostname, device_fingerprint, agent_last_checkin, deprovisioned_at
|
||||||
FROM devices
|
FROM devices
|
||||||
WHERE organization_id = %s
|
WHERE organization_id = %s AND archived_at IS NULL
|
||||||
ORDER BY hostname NULLS LAST, created_at
|
ORDER BY hostname NULLS LAST, created_at
|
||||||
""",
|
""",
|
||||||
(organization_id,),
|
(organization_id,),
|
||||||
@ -788,8 +788,11 @@ def fetch_devices_for_organization(organization_id: str):
|
|||||||
"agent_last_checkin": (
|
"agent_last_checkin": (
|
||||||
last_checkin.isoformat() if last_checkin is not None else None
|
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():
|
def fetch_all_devices():
|
||||||
@ -804,9 +807,10 @@ def fetch_all_devices():
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT d.id, d.hostname, d.device_fingerprint, d.agent_last_checkin,
|
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
|
FROM devices d
|
||||||
JOIN organizations o ON o.id = d.organization_id
|
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
|
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_id": str(organization_id),
|
||||||
"organization_name": organization_name,
|
"organization_name": organization_name,
|
||||||
|
"deprovisioned_at": (
|
||||||
|
deprovisioned_at.isoformat() if deprovisioned_at is not None else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
for (
|
for (
|
||||||
device_id, hostname, fingerprint, last_checkin,
|
device_id, hostname, fingerprint, last_checkin,
|
||||||
organization_id, organization_name,
|
organization_id, organization_name, deprovisioned_at,
|
||||||
) in cur.fetchall()
|
) 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():
|
def fetch_organizations():
|
||||||
with get_database_connection() as conn:
|
with get_database_connection() as conn:
|
||||||
with conn.cursor() as cur:
|
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()}
|
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")
|
@app.get("/api/v1/organizations")
|
||||||
def list_organizations(authorization: str | None = Header(default=None)):
|
def list_organizations(authorization: str | None = Header(default=None)):
|
||||||
if not require_service_token(authorization):
|
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