diff --git a/app.py b/app.py index 8d8c8d4..7d531b1 100644 --- a/app.py +++ b/app.py @@ -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, diff --git a/migrations/0039_hostnamenkonvention.sql b/migrations/0039_hostnamenkonvention.sql new file mode 100644 index 0000000..457b52a --- /dev/null +++ b/migrations/0039_hostnamenkonvention.sql @@ -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 "-", 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;