Hostnamenkonvention: serverseitige Vergabe für neue Geräte

- Migration 0039: organizations.hostname_praefix/-stellen/-nummer_modus/
  -naechste_sequenz. Leerer Präfix = keine Konvention (unverändertes
  Verhalten, Client bestimmt den Hostnamen selbst).
- generate_hostname_for_organization(): erzeugt "<praefix>-<nummer>",
  sequenziell (Zeilen-Lock via FOR UPDATE) oder zufällig (freie Nummer
  per secrets.randbelow(), Kollisionsprüfung gegen vorhandene Hostnamen,
  Fallback auf nächste freie sequenzielle Nummer nach 20 Versuchen).
- resolve_hostname_for_activation(): greift nur bei einem GENUIN NEUEN
  Gerät (device_fingerprint noch unbekannt) - eine Re-Aktivierung eines
  bekannten Geräts behält immer den vom Client gemeldeten Hostnamen,
  überschreibt also keine manuelle Umbenennung.
- agent_checkin(): liefert jetzt auch "hostname" (devices.hostname) an
  den Agenten.
- Neue Endpunkte GET/PUT /api/v1/organizations/{id}/hostname-konvention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLk93dhDMrkPXefVdknMV9
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-10 12:06:35 +02:00
1 parent 10d31f8cf3
commit bfbd8f7b17
2 files changed
+193 -2

No files matched your search

+176 -2
View File
@@ -287,6 +287,12 @@ class SetGruppeWorkspaceRequest(BaseModel):
workspace_id: str
class SetHostnameKonventionRequest(BaseModel):
praefix: str | None = None
stellen: int = 4
nummer_modus: str = "sequenziell"
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")
@@ -1219,6 +1225,126 @@ def fetch_auftragskatalog_listing(device_id: str):
return ergebnis
def fetch_hostname_konvention(organization_id: str) -> dict | None:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT hostname_praefix, hostname_stellen, hostname_nummer_modus, hostname_naechste_sequenz "
"FROM organizations WHERE id = %s",
(organization_id,),
)
row = cur.fetchone()
if row is None:
return None
praefix, stellen, modus, naechste_sequenz = row
return {
"praefix": praefix,
"stellen": stellen,
"nummer_modus": modus,
"naechste_sequenz": naechste_sequenz,
}
def set_hostname_konvention(organization_id: str, praefix: str | None, stellen: int, nummer_modus: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE organizations
SET hostname_praefix = %s, hostname_stellen = %s, hostname_nummer_modus = %s
WHERE id = %s
RETURNING id
""",
(praefix or None, stellen, nummer_modus, organization_id),
)
return cur.fetchone() is not None
def generate_hostname_for_organization(cur, organization_id: str) -> str | None:
"""
Erzeugt einen Hostnamen nach der Organisations-Konvention (Design-Runde
10.09.2026, siehe ADR-0028/Roadmap) - None, wenn keine Konvention
konfiguriert ist (hostname_praefix leer), dann bleibt der vom Client
gemeldete Hostname wie bisher maßgeblich (siehe resolve_hostname_for_activation()).
"sequenziell": liest+erhöht hostname_naechste_sequenz innerhalb
derselben Transaktion wie der Aufrufer (SELECT ... FOR UPDATE sperrt
die Zeile bis zum Commit - reicht bei der heutigen Aktivierungsrate
locker aus, kein Bedarf für eine eigene DB-Sequence).
"zufaellig": wählt eine zufällige freie Nummer im Bereich
[0, 10^stellen) per secrets.randbelow() - prüft gegen bereits
vergebene Hostnamen der Organisation, bricht nach einer begrenzten
Anzahl Versuche ab und fällt dann auf die nächste freie sequenzielle
Nummer zurück (worst case bei fast vollem Nummernraum, kein
Endlos-Loop).
"""
cur.execute(
"""
SELECT hostname_praefix, hostname_stellen, hostname_nummer_modus, hostname_naechste_sequenz
FROM organizations WHERE id = %s FOR UPDATE
""",
(organization_id,),
)
row = cur.fetchone()
if row is None or not row[0]:
return None
praefix, stellen, modus, naechste_sequenz = row
obergrenze = 10 ** stellen
if modus == "zufaellig":
cur.execute(
"SELECT hostname FROM devices WHERE organization_id = %s AND hostname LIKE %s",
(organization_id, f"{praefix}-%"),
)
vergebene_nummern = set()
for (hostname,) in cur.fetchall():
suffix = hostname[len(praefix) + 1:]
if suffix.isdigit():
vergebene_nummern.add(int(suffix))
for _ in range(20):
kandidat = secrets.randbelow(obergrenze)
if kandidat not in vergebene_nummern:
return f"{praefix}-{kandidat:0{stellen}d}"
kandidat = 0
while kandidat in vergebene_nummern and kandidat < obergrenze:
kandidat += 1
return f"{praefix}-{kandidat:0{stellen}d}"
# sequenziell (Default)
cur.execute(
"UPDATE organizations SET hostname_naechste_sequenz = hostname_naechste_sequenz + 1 WHERE id = %s",
(organization_id,),
)
return f"{praefix}-{naechste_sequenz:0{stellen}d}"
def resolve_hostname_for_activation(organization_id: str, device_fingerprint: str, reported_hostname: str | None) -> str | None:
"""
Ermittelt den für die Aktivierung zu verwendenden Hostnamen: bei einem
bereits bekannten Gerät bleibt IMMER der vom Client gemeldete
maßgeblich (unverändertes Verhalten, siehe load_or_create_device() -
kein Überschreiben eines evtl. manuell umbenannten Bestandsgeräts bei
jeder Re-Aktivierung). Nur bei einem GENUIN NEUEN Gerät greift die
Organisations-Hostnamenkonvention, falls konfiguriert.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM devices WHERE device_fingerprint = %s", (device_fingerprint,))
ist_neues_geraet = cur.fetchone() is None
if not ist_neues_geraet:
return reported_hostname
generiert = generate_hostname_for_organization(cur, organization_id)
return generiert or reported_hostname
def fetch_organisationseinheiten(organization_id: str):
"""
Flache Liste aller OEs einer Organisation (inkl. parent_id) - der Baum
@@ -3220,10 +3346,13 @@ def activate(payload: ActivationRequest, request: Request):
hardware_snapshot_id = None
if success:
hostname = resolve_hostname_for_activation(
organization_id, payload.device_fingerprint, payload.hostname
)
device, device_created = load_or_create_device(
organization_id,
payload.device_fingerprint,
payload.hostname,
hostname,
)
hardware_snapshot_id = store_hardware_snapshot(
device["id"],
@@ -3541,7 +3670,7 @@ def agent_checkin(
health_load_15m = CASE WHEN %(hat_health)s THEN %(load15)s ELSE health_load_15m END,
health_collected_at = CASE WHEN %(hat_health)s THEN %(jetzt)s ELSE health_collected_at END
WHERE id = %(device_id)s
RETURNING debug_mode_until
RETURNING debug_mode_until, hostname
""",
{
"jetzt": jetzt_ts,
@@ -3557,6 +3686,7 @@ def agent_checkin(
)
row = cur.fetchone()
debug_mode_until = row[0] if row else None
hostname = row[1] if row else None
im_debug_modus = debug_mode_until is not None and debug_mode_until > jetzt_ts
effektives_intervall = DEBUG_MODE_POLL_INTERVAL_SECONDS if im_debug_modus else AGENT_POLL_INTERVAL_SECONDS
@@ -3566,6 +3696,12 @@ def agent_checkin(
"blueprints": fetch_assigned_blueprints(payload.device_id),
"auftraege": fetch_auftragskatalog_state(payload.device_id),
"webapps": fetch_workspace_webapps(payload.device_id),
# Soll-Hostname (Design-Runde 10.09.2026, siehe ADR-0028/Roadmap) -
# None, wenn keine Organisations-Konvention konfiguriert ist und/
# oder das Gerät seinen eigenen (per Client gemeldeten) Hostnamen
# behalten hat. Die neue "hostname"-Ansible-Rolle setzt ihn nur,
# wenn er sich vom lokal tatsächlich gesetzten unterscheidet.
"hostname": hostname,
"ansible_repo": ANSIBLE_CONTENT_REPO,
"poll_interval_seconds": effektives_intervall,
}
@@ -3863,6 +3999,44 @@ def update_organization_endpoint(
return {"success": True, "organization": organization}
@app.get("/api/v1/organizations/{organization_id}/hostname-konvention")
def get_hostname_konvention(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."}
konvention = fetch_hostname_konvention(organization_id)
if konvention is None:
return {"success": False, "error": "organization_not_found", "message": "Die Organisation wurde nicht gefunden."}
return {"success": True, "hostname_konvention": konvention}
@app.put("/api/v1/organizations/{organization_id}/hostname-konvention")
def set_hostname_konvention_endpoint(
organization_id: str,
payload: SetHostnameKonventionRequest,
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.nummer_modus not in ("sequenziell", "zufaellig"):
return {
"success": False,
"error": "invalid_nummer_modus",
"message": "nummer_modus muss 'sequenziell' oder 'zufaellig' sein.",
}
if payload.stellen < 1 or payload.stellen > 9:
return {"success": False, "error": "invalid_stellen", "message": "stellen muss zwischen 1 und 9 liegen."}
if not set_hostname_konvention(organization_id, payload.praefix, payload.stellen, payload.nummer_modus):
return {"success": False, "error": "organization_not_found", "message": "Die Organisation wurde nicht gefunden."}
return {"success": True}
@app.get("/api/v1/organizations/{organization_id}/activation-codes")
def list_activation_codes(
organization_id: str,
+17
View File
@@ -0,0 +1,17 @@
BEGIN;
-- Schritt 3 der Workspace-Creator-Design-Runde (10.09.2026, siehe
-- project_phase2_app_store_merkmale-Memory / Roadmap-Update): optionale
-- Organisations-weite Hostnamenkonvention - erzeugt bei der Aktivierung
-- eines GENUIN NEUEN Geräts (nicht bei Re-Aktivierung eines bekannten)
-- automatisch einen Hostnamen nach Muster "<praefix>-<nummer>", statt den
-- vom Client gemeldeten zu übernehmen. hostname_praefix bleibt NULL, wenn
-- keine Konvention konfiguriert ist - dann unverändertes Verhalten wie
-- bisher (Client bestimmt den Hostnamen selbst).
ALTER TABLE organizations ADD COLUMN hostname_praefix TEXT;
ALTER TABLE organizations ADD COLUMN hostname_stellen SMALLINT NOT NULL DEFAULT 4;
ALTER TABLE organizations ADD COLUMN hostname_nummer_modus TEXT NOT NULL DEFAULT 'sequenziell'
CHECK (hostname_nummer_modus IN ('sequenziell', 'zufaellig'));
ALTER TABLE organizations ADD COLUMN hostname_naechste_sequenz INTEGER NOT NULL DEFAULT 1;
COMMIT;