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:
@@ -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))}
|
||||
|
||||
96
app/services/gitea_import.py
Normal file
96
app/services/gitea_import.py
Normal 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
|
||||
@@ -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{
|
||||
|
||||
59
tests/test_gitea_import.py
Normal file
59
tests/test_gitea_import.py
Normal 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
|
||||
Reference in New Issue
Block a user