update low and add jenkins api
This commit is contained in:
109
backend/jenkins_client.py
Normal file
109
backend/jenkins_client.py
Normal 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"),
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user