Einstellungen: Gitea-Actions-Variablen per API übernehmen (Token nur für den Abruf)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
RubenRWU
2026-10-15 15:36:35 +02:00
parent 7d8c226da2
commit b6165bc0b2
4 changed files with 190 additions and 1 deletions

View File

@@ -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))}

View File

@@ -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

View File

@@ -76,6 +76,11 @@
<div class="fgrid"><label class="f">COST_API_BASE<input type="text" data-k="cost_api_base" value="{{ v.cost_api_base }}"></label><label class="f">COST_TRACKING_ENABLED<input type="text" data-k="cost_tracking_enabled" value="{{ v.cost_tracking_enabled }}" placeholder="true"></label></div>
</div>
<div class="card" style="grid-column:1/-1"><h3>Aus Gitea übernehmen <span class="muted" style="font-weight:400">· statt Abschreiben</span></h3>
<p class="sub">Liest die Actions-Variablen deines Repos (<span class="mono">DOMAIN, PROJECT_ID, STACK_ID, REGISTRY, LLM_*, SMTP_*, COST_* …</span>; nie Secrets) und trägt sie hier ein. Der Token wird nur für diesen Abruf genutzt und nicht gespeichert.</p>
<div class="fgrid c3"><label class="f">Gitea-URL<input type="text" id="g-url" value="https://gitea.onyva.dev"></label><label class="f">Repo (Besitzer/Name)<input type="text" id="g-repo" value="Onyva/transkriptipt-platform"></label><label class="f">Token (Lesezugriff)<input type="password" id="g-token" autocomplete="off"></label></div>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="gFetch()">Variablen abrufen</button><span class="hint" id="g-info"></span></div><div id="g-out" style="margin-top:8px"></div></div>
<div class="card" style="grid-column:1/-1"><h3>Weitere Core-Einstellungen <span class="muted" style="font-weight:400">· aus dem Schema des Cores erzeugt, gelten für alle Tenants</span></h3>
<p class="sub">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.</p>
<div id="dyn"><span class="muted">Lade …</span></div></div>
@@ -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=`<table class="t"><thead><tr><th></th><th>Gitea-Variable</th><th>→ Einstellung</th><th>Wert aus Gitea</th><th>aktuell hier</th><th>Status</th></tr></thead><tbody>`+r.rows.map((x,n)=>`<tr><td>${x.setting&&x.status!=='gleich'?`<input type="checkbox" data-g="${n}" checked>`:''}</td><td class="mono">${esc(x.name)}</td><td class="mono">${esc(x.setting||'–')}</td><td>${esc(x.value)}</td><td class="muted">${esc(x.current)}</td><td><span class="badge ${x.status==='neu'?'cyan':x.status==='geändert'?'warn':x.status==='gleich'?'ok':''}">${x.status}</span> <span class="hint">${esc(x.note)}</span></td></tr>`).join('')+`</tbody></table><div class="row" style="margin-top:8px"><button class="btn sm cyan" onclick="gApply()">Ausgewählte übernehmen</button><span class="hint">Danach „Alles speichern“ nicht nötig – wird direkt gespeichert.</span></div>`;
}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{

View File

@@ -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