From ccbbf64ddb435b6aab5ffe9e24ddbc218d1bb6eb Mon Sep 17 00:00:00 2001 From: Damir Date: Fri, 10 Apr 2026 01:32:57 +0300 Subject: [PATCH] update low and add jenkins api --- backend/jenkins_client.py | 109 ++++++++++++++++++++++++++++++++++++++ backend/main.py | 44 +++++++++++++++ docker-compose.yml | 5 ++ frontend/ui.py | 61 ++++++++++++++++++++- 4 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 backend/jenkins_client.py diff --git a/backend/jenkins_client.py b/backend/jenkins_client.py new file mode 100644 index 0000000..4bfd54b --- /dev/null +++ b/backend/jenkins_client.py @@ -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"), + ) diff --git a/backend/main.py b/backend/main.py index f47d0df..cf1e12d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -76,6 +76,12 @@ class AutonomousPipelineSave(BaseModel): 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): try: yaml.safe_load(config_text) @@ -239,6 +245,44 @@ def put_autonomous_pipeline( 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") def chat_history( limit: int = 500, diff --git a/docker-compose.yml b/docker-compose.yml index 656a468..5a56338 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,11 @@ services: POSTGRES_PASSWORD: "ADzXo0Gm!" JWT_SECRET: "z84ylghZVxFQam7kjMRN7BwhQpatvulE2N4/e9Nj7udwixGAcCqCyscdWWgMdFAUXpqy8O1jh+TlfZPPjmFeNeJjhkmHuQoHathKhvMev0DJo4sx8ybDwgIi/6+d75mgJqZRftqttXDtHcrNpfSUV2twD5FGc+TxqHmOaGLK8yg/RVS4RxrRzbKKJtB0577hbhfIiy/FLldfEWBcqM70NaIzNNceLO0x33Q==" 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: - "8000:8000" healthcheck: diff --git a/frontend/ui.py b/frontend/ui.py index 5176340..f8cdd61 100644 --- a/frontend/ui.py +++ b/frontend/ui.py @@ -169,6 +169,32 @@ def _collect_autonomous_for_save(phases: list, ver: int) -> tuple[list | None, s 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(): _title_bar(show_logout=bool(st.session_state.token)) 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_pl_version = ver + 1 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]) with save_col1: @@ -469,14 +522,18 @@ with tab_autonomous: data = r.json() st.session_state.autonomous_pl_title = data.get("title", "") 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.success("Сохранено.") st.rerun() except Exception as e: st.error(str(e)) 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): snap, err = _collect_autonomous_for_save(phases, ver)