From b6165bc0b20fdea5d5e06f7b1bbe1a11de07ce49 Mon Sep 17 00:00:00 2001 From: RubenRWU Date: Thu, 15 Oct 2026 15:36:35 +0200 Subject: [PATCH] =?UTF-8?q?Einstellungen:=20Gitea-Actions-Variablen=20per?= =?UTF-8?q?=20API=20=C3=BCbernehmen=20(Token=20nur=20f=C3=BCr=20den=20Abru?= =?UTF-8?q?f)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- app/routers/api.py | 17 ++++++- app/services/gitea_import.py | 96 ++++++++++++++++++++++++++++++++++++ app/templates/settings.html | 19 +++++++ tests/test_gitea_import.py | 59 ++++++++++++++++++++++ 4 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 app/services/gitea_import.py create mode 100644 tests/test_gitea_import.py diff --git a/app/routers/api.py b/app/routers/api.py index 6f00559..6e991c6 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -19,7 +19,7 @@ from app.agent.workspace import is_personal from app.db import get_db, session from app.deps import actor, tenant_or_404 from app.models import Blob, ChatMessage, CoreRelease, Deployment, Secret, Setting, Tenant, Version -from app.services import contract, crypto, defaults, deploy, guard, llm, onyva_login, preflight, preview, releases, status as status_svc, store, tenants as tsvc +from app.services import contract, crypto, defaults, deploy, gitea_import, guard, llm, onyva_login, preflight, preview, releases, status as status_svc, store, tenants as tsvc router = APIRouter(prefix="/api") @@ -640,6 +640,21 @@ def tenants_status(refresh: bool = False): # ── Einstellungen ─────────────────────────────────────────────────────────── +@router.post("/gitea/variables") +def gitea_variables(body: dict = Body(...), db: Session = Depends(get_db)): + """Liest die Gitea-Actions-Variablen (Klartext) und zeigt, was in den Einstellungen geändert würde. Der Token wird nicht gespeichert.""" + token = str(body.get("token", "")).strip() + if not token: + raise HTTPException(400, "Token fehlt") + try: + vars_ = gitea_import.fetch_variables(str(body.get("url") or "https://gitea.onyva.dev"), str(body.get("repo", "")), token) + except gitea_import.GiteaError as exc: + raise HTTPException(400, str(exc)) + except Exception as exc: # noqa: BLE001 + raise HTTPException(502, f"Gitea nicht erreichbar: {type(exc).__name__}") + return {"count": len(vars_), "rows": gitea_import.preview(db, vars_)} + + @router.get("/settings") def get_settings(db: Session = Depends(get_db)): return {s.key: s.value for s in db.scalars(select(Setting))} diff --git a/app/services/gitea_import.py b/app/services/gitea_import.py new file mode 100644 index 0000000..c81ecfa --- /dev/null +++ b/app/services/gitea_import.py @@ -0,0 +1,96 @@ +"""Einstellungen aus den Gitea-Actions-Variablen übernehmen (statt abzuschreiben). + +Liest über die Gitea-API die Repo- (und Organisations-)Variablen – Klartext, nie Secrets – und ordnet sie den Einstellungen der Admin-Plattform zu. +Der Token wird nur für diesen Abruf genutzt: nicht gespeichert, nicht protokolliert, nicht im Audit-Log. +""" +from __future__ import annotations + +import httpx + +from app.models import Setting +from app.services import contract + +# Gitea-Variable → Einstellung der Admin-Plattform (Rest ergibt sich aus dem Env-Schema des Cores) +SPECIAL = {"DOMAIN": "base_domain", "PROJECT_ID": "mittwald_project_id", "STACK_ID": "mittwald_stack_id", "REGISTRY": "registry"} +IGNORED = {"REGISTRY_HOST": "nur für den Docker-Login der CI", "ADMIN_URL": "gehört zur Core-CI (Release-Meldung), nicht in die Einstellungen"} +SECRETISH = ("KEY", "SECRET", "TOKEN", "PASSWORD") + + +class GiteaError(RuntimeError): + pass + + +def _real_get(url: str, token: str): + r = httpx.get(url, headers={"Authorization": f"token {token}"}, timeout=20, follow_redirects=True) + try: + data = r.json() + except ValueError: + data = None + return r.status_code, data + + +def _fetch(base: str, path: str, token: str, get) -> list[dict] | None: + out, page = [], 1 + while True: + status, data = get(f"{base}/api/v1{path}?page={page}&limit=50", token) + if status in (404, 405): + return None # Endpunkt/Scope nicht vorhanden + if status in (401, 403): + raise GiteaError(f"Gitea verweigert den Zugriff (HTTP {status}) – Token braucht Lesezugriff auf das Repository/die Organisation.") + if status != 200 or not isinstance(data, list): + raise GiteaError(f"Unerwartete Antwort von Gitea (HTTP {status}).") + out += data + if len(data) < 50: + return out + page += 1 + + +def fetch_variables(base_url: str, repo: str, token: str, get=_real_get) -> dict[str, str]: + """{NAME: Wert}. Repo-Variablen überschreiben Organisations-Variablen.""" + base = base_url.rstrip("/") + if "/" not in repo.strip("/"): + raise GiteaError("Repo als „Besitzer/Name“ angeben (z. B. Onyva/transkriptipt-platform).") + owner = repo.strip("/").split("/")[0] + merged: dict[str, str] = {} + found_any = False + for path in (f"/orgs/{owner}/actions/variables", f"/repos/{repo.strip('/')}/actions/variables"): + items = _fetch(base, path, token, get) + if items is None: + continue + found_any = True + for it in items: + merged[str(it.get("name") or it.get("Name"))] = str(it.get("data", it.get("Data", ""))) + if not found_any: + raise GiteaError("Die Gitea-Variablen-API ist nicht verfügbar (ältere Gitea-Version oder fehlender Scope) – bitte von Hand eintragen.") + return merged + + +def setting_for(name: str) -> str | None: + if name in SPECIAL: + return SPECIAL[name] + for e in contract.env_spec(): + if e["name"] == name and e["kind"] == "setting" and e["scope"] == "shared": + return e.get("setting") or name.lower() + return None + + +def preview(db, variables: dict[str, str]) -> list[dict]: + """Zeilen: {name, setting, value, current, status: neu|geändert|gleich|ignoriert|unbekannt, note}.""" + rows = [] + for name, value in sorted(variables.items()): + masked = any(s in name for s in SECRETISH) + if name in IGNORED: + rows.append({"name": name, "setting": None, "value": "", "current": "", "status": "ignoriert", "note": IGNORED[name]}) + continue + key = setting_for(name) + if key is None: + rows.append({"name": name, "setting": None, "value": "", "current": "", "status": "unbekannt", + "note": "wird vom aktuellen Core nicht gelesen (Altlast) – nicht übernommen"}) + continue + cur = db.get(Setting, key) + cur_v = cur.value if cur else "" + value = value.strip() + status = "gleich" if cur_v == value else ("neu" if not cur_v else "geändert") + rows.append({"name": name, "setting": key, "value": "••••" if masked else value, "raw": value if not masked else None, + "current": "••••" if (masked and cur_v) else cur_v, "status": status, "note": ""}) + return rows diff --git a/app/templates/settings.html b/app/templates/settings.html index 09d15e6..719b3d7 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -76,6 +76,11 @@
+

Aus Gitea übernehmen · statt Abschreiben

+

Liest die Actions-Variablen deines Repos (DOMAIN, PROJECT_ID, STACK_ID, REGISTRY, LLM_*, SMTP_*, COST_* …; nie Secrets) und trägt sie hier ein. Der Token wird nur für diesen Abruf genutzt und nicht gespeichert.

+
+
+

Weitere Core-Einstellungen · aus dem Schema des Cores erzeugt, gelten für alle Tenants

Alles, was der Core als geteilte Einstellung deklariert und oben noch keine eigene Maske hat. Neue Core-Variablen erscheinen hier automatisch und gehen beim Deploy ins Container-Env. Leer = Standard des Cores.

Lade …
@@ -105,6 +110,20 @@ async function testOidc(){ const i=document.getElementById('oidc-info'); i.textContent='Teste …'; try{const r=await api('POST','/api/oidc/test'); i.textContent='✔ Verbindung ok – '+r.apps+' App(s) im Login'}catch(e){i.textContent='✘ '+e.message} } +let gRows = []; +async function gFetch(){ + const i=document.getElementById('g-info'); i.textContent='Rufe ab …'; document.getElementById('g-out').innerHTML=''; + try{ + const r=await api('POST','/api/gitea/variables',{url:document.getElementById('g-url').value,repo:document.getElementById('g-repo').value,token:document.getElementById('g-token').value}); + document.getElementById('g-token').value=''; gRows=r.rows; i.textContent=r.count+' Variablen gelesen'; + document.getElementById('g-out').innerHTML=``+r.rows.map((x,n)=>``).join('')+`
Gitea-Variable→ EinstellungWert aus Giteaaktuell hierStatus
${x.setting&&x.status!=='gleich'?``:''}${esc(x.name)}${esc(x.setting||'–')}${esc(x.value)}${esc(x.current)}${x.status} ${esc(x.note)}
Danach „Alles speichern“ nicht nötig – wird direkt gespeichert.
`; + }catch(e){ i.textContent='✘ '+e.message; } +} +async function gApply(){ + const body={}; document.querySelectorAll('[data-g]:checked').forEach(c=>{const x=gRows[+c.dataset.g]; if(x.setting&&x.raw!=null) body[x.setting]=x.raw;}); + if(!Object.keys(body).length) return toast('Nichts ausgewählt',1); + try{ await api('PUT','/api/settings',body); toast(Object.keys(body).length+' Einstellungen übernommen'); setTimeout(()=>location.reload(),800); }catch(e){ toast(e.message,1) } +} async function loadDyn(){ const box=document.getElementById('dyn'); try{ diff --git a/tests/test_gitea_import.py b/tests/test_gitea_import.py new file mode 100644 index 0000000..c62d297 --- /dev/null +++ b/tests/test_gitea_import.py @@ -0,0 +1,59 @@ +"""Import der Gitea-Actions-Variablen in die Einstellungen (mit gefakter Gitea-API).""" +import pytest +from fastapi.testclient import TestClient + +from app.db import session +from app.main import app +from app.services import gitea_import + + +class FakeGitea: + def __init__(self, repo_vars, org_vars=None, status=200): + self.repo_vars, self.org_vars, self.status, self.urls, self.tokens = repo_vars, org_vars or [], status, [], [] + + def __call__(self, url, token): + self.urls.append(url) + self.tokens.append(token) + if self.status != 200: + return self.status, None + if "/orgs/" in url: + return (404, None) if self.org_vars is None else (200, self.org_vars) + return 200, self.repo_vars + + +def test_fetch_merges_org_and_repo_and_maps_settings(): + g = FakeGitea([{"name": "DOMAIN", "data": "kunde.de"}, {"name": "LLM_MODEL", "data": "claude-x"}, {"name": "REGISTRY", "data": "reg/o"}], + [{"name": "LLM_MODEL", "data": "org-model"}, {"name": "SMTP_HOST", "data": "mail.x"}]) + v = gitea_import.fetch_variables("https://gitea.test", "Org/Repo", "tok", g) + assert v == {"LLM_MODEL": "claude-x", "SMTP_HOST": "mail.x", "DOMAIN": "kunde.de", "REGISTRY": "reg/o"} # Repo schlägt Organisation + assert all(t == "tok" for t in g.tokens) and g.urls[0].startswith("https://gitea.test/api/v1/orgs/Org/actions/variables") + + +def test_errors_are_explained(): + with pytest.raises(gitea_import.GiteaError, match="Zugriff"): + gitea_import.fetch_variables("https://g", "O/R", "t", FakeGitea([], status=403)) + with pytest.raises(gitea_import.GiteaError, match="nicht verfügbar"): + gitea_import.fetch_variables("https://g", "O/R", "t", FakeGitea(None, None, status=404) if False else (lambda u, t: (404, None))) + with pytest.raises(gitea_import.GiteaError, match="Besitzer/Name"): + gitea_import.fetch_variables("https://g", "nurname", "t", FakeGitea([])) + + +def test_preview_statuses_and_masking(): + with TestClient(app): + with session() as db: + rows = {r["name"]: r for r in gitea_import.preview(db, {"DOMAIN": "kunde.de", "AUTH_ENABLED": "true", "SMTP_HOST": "mail.x", + "REGISTRY_HOST": "h", "GLADIA_NER": "1", "SOME_API_KEY": "geheim", "LLM_MODEL": "claude-4-6-sonnet"})} + assert rows["DOMAIN"]["setting"] == "base_domain" and rows["DOMAIN"]["status"] == "geändert" + assert rows["AUTH_ENABLED"]["status"] == "gleich" and rows["SMTP_HOST"]["status"] == "neu" and rows["LLM_MODEL"]["status"] == "gleich" + assert rows["REGISTRY_HOST"]["status"] == "ignoriert" and rows["GLADIA_NER"]["status"] == "unbekannt" + assert rows["SOME_API_KEY"]["status"] == "unbekannt" + + +def test_endpoint_never_stores_or_echoes_the_token(monkeypatch): + monkeypatch.setattr(gitea_import, "_real_get", lambda url, token: (200, [{"name": "DOMAIN", "data": "kunde.de"}]) if "/repos/" in url else (404, None)) + monkeypatch.setattr(gitea_import, "fetch_variables", lambda base, repo, token, get=None: {"DOMAIN": "kunde.de"}) + with TestClient(app) as c: + r = c.post("/api/gitea/variables", json={"repo": "O/R", "token": "supergeheim"}) + assert r.status_code == 200 and "supergeheim" not in r.text and r.json()["rows"][0]["setting"] == "base_domain" + assert c.post("/api/gitea/variables", json={"repo": "O/R"}).status_code == 400 + assert "supergeheim" not in c.get("/api/settings").text