diff --git a/app.py b/app.py index f1e2120..b157c43 100644 --- a/app.py +++ b/app.py @@ -38,6 +38,16 @@ AGENT_POLL_INTERVAL_SECONDS = int(os.environ.get("TUXFLOTTE_AGENT_POLL_INTERVAL" DEBUG_MODE_POLL_INTERVAL_SECONDS = 60 DEBUG_MODE_DURATION_HOURS = 6 ANSIBLE_CONTENT_REPO = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO", "") +# Gestuftes Ausrollen (13.10.2026, Migration 0059, ADR-0043): Organisationen +# mit Ausrollstufe "stabil" bekommen im Check-in die Adresse des +# freigegebenen Repos, "vorab" die des Entwicklungsstands. Zwei Repos statt +# zwei Zweige, weil der Agent immer den Standardzweig der gelieferten Adresse +# zieht und sich auf installierten Geräten nicht aktualisiert. Ohne eigene +# Vorgabe gilt die Namenskonvention -stabil. +ANSIBLE_CONTENT_REPO_STABIL = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO_STABIL", "") or ( + re.sub(r"(\.git)?$", r"-stabil\1", ANSIBLE_CONTENT_REPO, count=1) if ANSIBLE_CONTENT_REPO else "" +) +AUSROLLSTUFEN = ("stabil", "vorab") ADMIN_TOKEN = os.environ.get("TUXFLOTTE_ADMIN_TOKEN", "") KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "") AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"} @@ -3172,7 +3182,8 @@ def fetch_organizations(): with conn.cursor() as cur: cur.execute( """ - SELECT id, name, created_at, kontakt_name, adresse, telefon, kontakt_email, notizen + SELECT id, name, created_at, kontakt_name, adresse, telefon, kontakt_email, notizen, + ausrollstufe FROM organizations ORDER BY name """ ) @@ -3186,19 +3197,50 @@ def fetch_organizations(): "telefon": telefon, "kontakt_email": kontakt_email, "notizen": notizen, + "ausrollstufe": ausrollstufe, } for ( org_id, name, created_at, - kontakt_name, adresse, telefon, kontakt_email, notizen, + kontakt_name, adresse, telefon, kontakt_email, notizen, ausrollstufe, ) in cur.fetchall() ] +def ansible_repo_fuer_geraet(device_id: str) -> str: + """Adresse des Rollen-Repos nach der Ausrollstufe der Organisation. + Unbekanntes Gerät: der freigegebene Stand.""" + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT o.ausrollstufe FROM devices d + JOIN organizations o ON o.id = d.organization_id + WHERE d.id = %s + """, + (device_id,), + ) + row = cur.fetchone() + + return ANSIBLE_CONTENT_REPO if row and row[0] == "vorab" else ANSIBLE_CONTENT_REPO_STABIL + + +def set_ausrollstufe(organization_id: str, ausrollstufe: str) -> bool: + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE organizations SET ausrollstufe = %s, updated_at = now() WHERE id = %s RETURNING id", + (ausrollstufe, organization_id), + ) + return cur.fetchone() is not None + + def fetch_organization(organization_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ - SELECT id, name, created_at, kontakt_name, adresse, telefon, kontakt_email, notizen + SELECT id, name, created_at, kontakt_name, adresse, telefon, kontakt_email, notizen, + ausrollstufe FROM organizations WHERE id = %s """, (organization_id,), @@ -3217,6 +3259,7 @@ def fetch_organization(organization_id: str): "telefon": row[5], "kontakt_email": row[6], "notizen": row[7], + "ausrollstufe": row[8], } def create_organization(name: str): @@ -5650,7 +5693,7 @@ def agent_checkin( # 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, + "ansible_repo": ansible_repo_fuer_geraet(payload.device_id), "poll_interval_seconds": effektives_intervall, } @@ -5938,6 +5981,28 @@ def get_organization_endpoint(organization_id: str, authorization: str | None = return {"success": True, "organization": organization} +class SetAusrollstufeRequest(BaseModel): + ausrollstufe: str + + +@app.put("/api/v1/organizations/{organization_id}/ausrollstufe") +def put_ausrollstufe( + organization_id: str, + payload: SetAusrollstufeRequest, + 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.ausrollstufe not in AUSROLLSTUFEN: + return {"success": False, "error": "invalid_request", "message": "Unbekannte Ausrollstufe."} + + if not set_ausrollstufe(organization_id, payload.ausrollstufe): + return {"success": False, "error": "organization_not_found", "message": "Die Organisation wurde nicht gefunden."} + + return {"success": True} + + @app.get("/api/v1/organizations/{organization_id}/dashboard") def get_customer_dashboard_endpoint(organization_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): diff --git a/migrations/0059_ausrollstufe.sql b/migrations/0059_ausrollstufe.sql new file mode 100644 index 0000000..4542757 --- /dev/null +++ b/migrations/0059_ausrollstufe.sql @@ -0,0 +1,24 @@ +BEGIN; + +-- Gestuftes Ausrollen der Ansible-Rollen (Nutzer-Entscheidung 13.10.2026, +-- platform-docs ADR-0043). Bisher zog jedes Gerät die Rollen direkt von +-- ansible-content main - ein Push war sofort Rollout auf alle Geräte. +-- +-- 'stabil': Geräte der Organisation ziehen vom freigegebenen Stand +-- (Repo ansible-content-stabil). Standard für neue Organisationen. +-- 'vorab': Geräte ziehen vom Entwicklungsstand (ansible-content main) - +-- nur für Testorganisationen. + +ALTER TABLE organizations ADD COLUMN ausrollstufe TEXT NOT NULL DEFAULT 'stabil' + CHECK (ausrollstufe IN ('stabil', 'vorab')); + +-- Testorganisationen (Test-Org-Konvention: Default Lab = VM 107, Liebherr = +-- Bare-Metal-Testhardware des Nutzers, WLAN-Test = WLAN-Installationstests). +UPDATE organizations SET ausrollstufe = 'vorab' +WHERE id IN ( + '11111111-1111-4111-8111-111111111111', + 'ab49c741-6795-43d3-abd4-50772565365a', + 'b6eb4075-ca26-4a2f-925f-3186d35610d1' +); + +COMMIT;