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 @@
+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.
+ +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.
| Gitea-Variable | → Einstellung | Wert aus Gitea | aktuell hier | Status | |
|---|---|---|---|---|---|
| ${x.setting&&x.status!=='gleich'?``:''} | ${esc(x.name)} | ${esc(x.setting||'–')} | ${esc(x.value)} | ${esc(x.current)} | ${x.status} ${esc(x.note)} |