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:
1 parent
10d31f8cf3
commit
bfbd8f7b17
2 files changed
+193
-2
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user