update low and add jenkins api

This commit is contained in:
2026-04-10 01:32:57 +03:00
parent 2d30fe258b
commit ccbbf64ddb
4 changed files with 217 additions and 2 deletions

109
backend/jenkins_client.py Normal file
View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import os
from typing import Any
from urllib.parse import quote
import requests
def _env(name: str, default: str) -> str:
value = os.getenv(name)
return value if value else default
def _normalize_base(url: str) -> str:
u = (url or "").strip().rstrip("/")
if not u:
return ""
if not u.startswith(("http://", "https://")):
u = f"https://{u}"
return u
def _job_url(base: str, job_path: str) -> str:
"""https://jenkins/job/a/job/b/job/c"""
parts = [p for p in (job_path or "").strip("/").split("/") if p]
if not parts:
raise ValueError("job path is empty")
segs = "/".join(f"job/{quote(p, safe='')}" for p in parts)
return f"{_normalize_base(base)}/{segs}"
def trigger_jenkins_job(
*,
server_url: str,
job_path: str,
parameters: dict[str, str],
username: str | None,
token: str | None,
verify_ssl: bool = True,
) -> tuple[bool, int, str, dict[str, Any]]:
"""
Запускает сборку через Jenkins REST API: /build или /buildWithParameters.
При включённом CSRF получает crumb с /crumbIssuer/api/json.
"""
base = _normalize_base(server_url)
if not base:
return False, 0, "server_url is empty", {}
job_url = _job_url(base, job_path)
auth = (username, token) if username and token else None
headers: dict[str, str] = {}
if auth:
try:
cr = requests.get(
f"{base}/crumbIssuer/api/json",
auth=auth,
timeout=30,
verify=verify_ssl,
)
if cr.ok:
cj = cr.json()
field = cj.get("crumbRequestField", "Jenkins-Crumb")
headers[field] = cj["crumb"]
except Exception:
pass
if parameters:
target = f"{job_url}/buildWithParameters"
resp = requests.post(
target,
auth=auth,
headers=headers,
data=parameters,
timeout=120,
verify=verify_ssl,
)
else:
target = f"{job_url}/build"
resp = requests.post(
target,
auth=auth,
headers=headers,
timeout=120,
verify=verify_ssl,
)
detail: dict[str, Any] = {
"http_status": resp.status_code,
"location": resp.headers.get("Location"),
"jenkins_body_preview": (resp.text or "")[:500],
}
ok = 200 <= resp.status_code < 300
if resp.status_code == 403 and "crumb" in (resp.text or "").lower():
return False, resp.status_code, "Jenkins вернул 403 — проверьте crumb/права или отключите CSRF для API.", detail
if not ok:
return False, resp.status_code, f"Jenkins HTTP {resp.status_code}", detail
return True, resp.status_code, "queued", detail
def default_jenkins_credentials() -> tuple[str, str, str, bool]:
"""default_url, user, token, verify_ssl"""
return (
_env("JENKINS_DEFAULT_URL", ""),
_env("JENKINS_USER", ""),
_env("JENKINS_TOKEN", ""),
_env("JENKINS_VERIFY_SSL", "true").lower() in ("1", "true", "yes", "on"),
)

View File

@@ -76,6 +76,12 @@ class AutonomousPipelineSave(BaseModel):
phases: list[LowCodePhaseIn] = Field(default_factory=list) phases: list[LowCodePhaseIn] = Field(default_factory=list)
class JenkinsTriggerRequest(BaseModel):
server_url: str = ""
job: str
parameters: dict[str, str] = Field(default_factory=dict)
def validate_config(config_text: str): def validate_config(config_text: str):
try: try:
yaml.safe_load(config_text) yaml.safe_load(config_text)
@@ -239,6 +245,44 @@ def put_autonomous_pipeline(
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
@app.post("/jenkins/trigger")
def jenkins_trigger(
req: JenkinsTriggerRequest,
user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None,
):
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
from backend.jenkins_client import default_jenkins_credentials, trigger_jenkins_job
default_url, ju, jt, verify_ssl = default_jenkins_credentials()
base = (req.server_url or "").strip() or default_url
if not base:
raise HTTPException(
status_code=400,
detail="Укажите Jenkins URL в фазе или задайте JENKINS_DEFAULT_URL в окружении backend.",
)
if not ju or not jt:
raise HTTPException(
status_code=503,
detail="Задайте JENKINS_USER и JENKINS_TOKEN (API token) в окружении backend.",
)
job = (req.job or "").strip()
if not job:
raise HTTPException(status_code=400, detail="Поле job обязательно.")
ok, _code, msg, detail = trigger_jenkins_job(
server_url=base,
job_path=job,
parameters=req.parameters,
username=ju,
token=jt,
verify_ssl=verify_ssl,
)
if not ok:
raise HTTPException(status_code=502, detail={"message": msg, **detail})
return {"ok": True, "message": msg, **detail}
@app.get("/chat/history") @app.get("/chat/history")
def chat_history( def chat_history(
limit: int = 500, limit: int = 500,

View File

@@ -13,6 +13,11 @@ services:
POSTGRES_PASSWORD: "ADzXo0Gm!" POSTGRES_PASSWORD: "ADzXo0Gm!"
JWT_SECRET: "z84ylghZVxFQam7kjMRN7BwhQpatvulE2N4/e9Nj7udwixGAcCqCyscdWWgMdFAUXpqy8O1jh+TlfZPPjmFeNeJjhkmHuQoHathKhvMev0DJo4sx8ybDwgIi/6+d75mgJqZRftqttXDtHcrNpfSUV2twD5FGc+TxqHmOaGLK8yg/RVS4RxrRzbKKJtB0577hbhfIiy/FLldfEWBcqM70NaIzNNceLO0x33Q==" JWT_SECRET: "z84ylghZVxFQam7kjMRN7BwhQpatvulE2N4/e9Nj7udwixGAcCqCyscdWWgMdFAUXpqy8O1jh+TlfZPPjmFeNeJjhkmHuQoHathKhvMev0DJo4sx8ybDwgIi/6+d75mgJqZRftqttXDtHcrNpfSUV2twD5FGc+TxqHmOaGLK8yg/RVS4RxrRzbKKJtB0577hbhfIiy/FLldfEWBcqM70NaIzNNceLO0x33Q=="
REQUIRE_AUTH_FOR_GENERATE: "true" REQUIRE_AUTH_FOR_GENERATE: "true"
# Jenkins REST API (для POST /jenkins/trigger): URL по умолчанию и API token пользователя
JENKINS_DEFAULT_URL: ""
JENKINS_USER: ""
JENKINS_TOKEN: ""
JENKINS_VERIFY_SSL: "true"
ports: ports:
- "8000:8000" - "8000:8000"
healthcheck: healthcheck:

View File

@@ -169,6 +169,32 @@ def _collect_autonomous_for_save(phases: list, ver: int) -> tuple[list | None, s
return out, None return out, None
def _phase_for_jenkins_trigger(ph: dict, ver: int) -> tuple[dict | None, str | None]:
"""Тело для POST /jenkins/trigger из текущих виджетов фазы."""
pid = ph["id"]
j = ph.get("jenkins") or {}
default_pj = json.dumps(j.get("parameters", {}), ensure_ascii=False, indent=2)
raw = st.session_state.get(f"ap_pj_{pid}_{ver}", default_pj)
try:
params_obj = json.loads(raw) if str(raw).strip() else {}
if not isinstance(params_obj, dict):
raise TypeError("нужен JSON-объект")
except Exception as e:
return None, f"Параметры Jenkins: {e}"
job = st.session_state.get(f"ap_jj_{pid}_{ver}", j.get("job", "")).strip()
if not job:
return None, "Укажите Job / путь."
surl = st.session_state.get(f"ap_js_{pid}_{ver}", j.get("server_url", "")).strip()
return (
{
"server_url": surl,
"job": job,
"parameters": {str(k): str(v) for k, v in params_obj.items()},
},
None,
)
def _show_account(): def _show_account():
_title_bar(show_logout=bool(st.session_state.token)) _title_bar(show_logout=bool(st.session_state.token))
st.caption("Войдите или создайте учётную запись, чтобы открыть разделы портала.") st.caption("Войдите или создайте учётную запись, чтобы открыть разделы портала.")
@@ -444,6 +470,33 @@ with tab_autonomous:
st.session_state.autonomous_phases = [p for p in phases if p["id"] != pid] st.session_state.autonomous_phases = [p for p in phases if p["id"] != pid]
st.session_state.autonomous_pl_version = ver + 1 st.session_state.autonomous_pl_version = ver + 1
st.rerun() st.rerun()
if st.button(
"Запустить Jenkins (build / buildWithParameters)",
key=f"ap_run_{pid}_{ver}",
use_container_width=True,
):
pj, jerr = _phase_for_jenkins_trigger(ph, ver)
if jerr:
st.error(jerr)
else:
with st.spinner("Запрос к Jenkins API…"):
try:
jr = requests.post(
f"{backend_base_url}/jenkins/trigger",
json=pj,
headers=_auth_headers(),
timeout=120,
)
if jr.status_code >= 400:
st.error(_detail_or_text(jr))
else:
info = jr.json()
loc = info.get("location") or ""
st.success(
f"Сборка поставлена в очередь. {('Queue: ' + loc) if loc else ''}"
)
except Exception as ex:
st.error(str(ex))
save_col1, save_col2 = st.columns([1, 2]) save_col1, save_col2 = st.columns([1, 2])
with save_col1: with save_col1:
@@ -469,14 +522,18 @@ with tab_autonomous:
data = r.json() data = r.json()
st.session_state.autonomous_pl_title = data.get("title", "") st.session_state.autonomous_pl_title = data.get("title", "")
st.session_state.autonomous_phases = data.get("phases", []) st.session_state.autonomous_phases = data.get("phases", [])
st.session_state.ap_global_title = data.get("title", "") if "ap_global_title" in st.session_state:
del st.session_state["ap_global_title"]
st.session_state.autonomous_pl_version = ver + 1 st.session_state.autonomous_pl_version = ver + 1
st.success("Сохранено.") st.success("Сохранено.")
st.rerun() st.rerun()
except Exception as e: except Exception as e:
st.error(str(e)) st.error(str(e))
with save_col2: with save_col2:
st.caption("Сохранение: `PUT /pipelines/autonomous` → таблица `autonomous_pipelines` (Postgres).") st.caption(
"Сохранение: `PUT /pipelines/autonomous`. Запуск: `POST /jenkins/trigger` "
"(учётные данные Jenkins — только в env backend: `JENKINS_USER`, `JENKINS_TOKEN`)."
)
with st.expander("Экспорт YAML (черновик)", expanded=False): with st.expander("Экспорт YAML (черновик)", expanded=False):
snap, err = _collect_autonomous_for_save(phases, ver) snap, err = _collect_autonomous_for_save(phases, ver)