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.db import get_db, session
from app.deps import actor, tenant_or_404 from app.deps import actor, tenant_or_404
from app.models import Blob, ChatMessage, CoreRelease, Deployment, Secret, Setting, Tenant, Version 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") router = APIRouter(prefix="/api")
@@ -640,6 +640,21 @@ def tenants_status(refresh: bool = False):
# ── Einstellungen ─────────────────────────────────────────────────────────── # ── 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") @router.get("/settings")
def get_settings(db: Session = Depends(get_db)): def get_settings(db: Session = Depends(get_db)):
return {s.key: s.value for s in db.scalars(select(Setting))} 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 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>
<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> <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> <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> <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 …'; 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} 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(){ async function loadDyn(){
const box=document.getElementById('dyn'); const box=document.getElementById('dyn');
try{ 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