Gestuftes Ausrollen: Ausrollstufe je Organisation (Migration 0059)

Der Check-in liefert Organisationen mit Stufe "stabil" die Adresse des
freigegebenen Repos (ansible-content-stabil), "vorab" die von main. Neue
Organisationen sind stabil; Default Lab, Liebherr und WLAN-Test vorab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-13 16:57:20 +02:00
1 parent 26e26e1b3b
commit 0c268e9088
2 files changed
+93 -4

No files matched your search

+69 -4
View File
@@ -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 <repo>-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):