From da6d18f6d5f2e2c854df834b30461f5264a10239 Mon Sep 17 00:00:00 2001 From: Damir Date: Fri, 10 Apr 2026 01:46:19 +0300 Subject: [PATCH] update --- backend/__pycache__/auth.cpython-313.pyc | Bin 0 -> 4230 bytes backend/__pycache__/db.cpython-313.pyc | Bin 0 -> 1618 bytes backend/__pycache__/main.cpython-313.pyc | Bin 0 -> 24890 bytes backend/__pycache__/models.cpython-313.pyc | Bin 0 -> 3812 bytes .../pipeline_migrate.cpython-313.pyc | Bin 0 -> 1290 bytes backend/main.py | 116 ++++- backend/pipeline_migrate.py | 29 ++ frontend/ui.py | 460 +++++++++++++----- 8 files changed, 480 insertions(+), 125 deletions(-) create mode 100644 backend/__pycache__/auth.cpython-313.pyc create mode 100644 backend/__pycache__/db.cpython-313.pyc create mode 100644 backend/__pycache__/main.cpython-313.pyc create mode 100644 backend/__pycache__/models.cpython-313.pyc create mode 100644 backend/__pycache__/pipeline_migrate.cpython-313.pyc create mode 100644 backend/pipeline_migrate.py diff --git a/backend/__pycache__/auth.cpython-313.pyc b/backend/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0e79289762d3c45775506df753dbd6c4ccdb9751 GIT binary patch literal 4230 zcmb_f>u(d;6~FWFBYq}!FeE%;l61j9LN*W}>4x1942fL=WMbGVsoaS@NlcxwduJR- zM0ef&l(Ln8?xLj9en_Q$0I464+Db^>4{iSfTdo9Lg-5ljs(uR!s#e=CJ?Gk!5SDHq z>LhdL+;h)8_i=vb;dQUqMd0~q@KNTwKz)H7>#lNwt2{&;3N`0?DcPI_C zN!guOQL zN;9Xz%3ftBr}iqX$}Uc|PB=iz4r?CoHCnx+;vQoCDQ(JboN+~_YTfO3UM9 z7}*zY#(RDmkGZ@|uk*cq-ej127WE^?? z8GTi%#%m=BVqLs0CdBn(xZvqc(Or+J>44p`8bGf|HkeE^W?hn}m-#N={LrQngF&4yr^!*FNqfsS!Zg?ay7FC#Z^h8&xhG0}i9Pq+Pg!RvX zxC2;x#K|7ZfktpYf*^} zIUmH6gb1y4M7HK61P$VgE({1$!W8M3TF8`ePaavY70juQ6AKOD{ebW3)<{2%3Q_N~L2QW;Ej+a*r<7$qk znV>NzUjpV>K%jeI^URBCla?So0z5RY@qK8DWKFc&8dm+y5B)7=f6I!q;*XS^kuO)> zjq5~)kznZ7wWSYlf4FkBvi)!+*tO_f^);{h1GnD(cy4i~6xw&!xw3G-q2xKTE{T5E zbASDsO{{D9$C?dhf3@BSi+t3$oh-Qqc1pkK83;*#2-$$#;xhlG#orgK$oEEzi~obg zr8in!w5-c5F1==Pky~6G5H~GO_rY-^4jO?Ev(ITXGc`{SLWds0rURQ!Yz{*ck!crv znAb8*5)DP)#_?`w41_YcbGT|oe^uS4Ay2?CF7!1+?k)TGuI#V)`%2Ef|Cb@@QCMk< zn{LE8Jld8x_ivn(?!b+U)l2f|nwB<_g*;}Eh(M3yfiN1>PQ7j8a$Xgr-v$W;vJr#^ z;*&e{(A`#cx2>G3xVuYoHw^A7A|xl zLB=;?N$ll0tG zinqd~f|&wg-K^$)!&GPU)CME89UBie9Q1pUYJ~v3Nm`q%t4N#Lm=<%Ik%_hzr|Z4Xo&Z_=^}v4m^| zU)`3lS^8AG4cnXGXGwhRHXOCvu<;TDH?%;8`a}7bOYZ^g1VDp7@+8ttS(~!h*4w2&TOc} zU}Acb*{YB}i-KoRP)4sX5M~(it)O3zRcxw2DI73~5;Or41MnKZg$B~MlQ_LU{?T&u z;jRw&U-j+0E0vC(E&GN_&Y?%1hKHWrWzX(nbWOI~qQYum+tPub9av8MGWPk|2fgJ$ zbkXrB(6n^$_Q7SX5@=s^JOvjDwA>wh;4cS;N}i!LiPUdj10V6-Job}gpSCYMD(-Oc z^i#M0mbvu7(g(E#FO+>3OU{dr-2P(x%a>l_f$BlP1#v(3&zF6tOU~1460*KBFf9CP zKsdFZ{Bi%bQ~grJPIm%}Cq;?{C#RU5YG#4zujb0C$R$ezCSnGyaxpS&jx%{Om&-zm z-O8qyK?=Bd<(tBs0%aG0yU_McKS;vY!z1vEhN1^k#4P2TK~qQCnJbx`D!`t#WRjxe z(%W>$kDT9-MdQ~+o`L$T`NP-68pR;QtXtuy94#hgOX!~2WIDh!h2(WOo-Xc zWM{2vr0Da^XZ$d$X7QKId}of%@~tsft+ww>Pf|aZvKX^~*{s14TB}ti`EM-d8Ayx` zB`-xsmErRv{8zvxwCQQkQ4Q>vdMVZ)JQ4H2%z=52@W@#$Se);`GKH*mlBPfs+?5!= zg9cC_2v11s7o_eP=`NG*zmfybNc0)m^MvdN{$I%MC#2=izUGyoyECP>i+Mf-DqSJCs*E(-fzxQH)s>(Gk3;yqM!J$CsP=a=_YT&+dN<51Jj+eF#|H`gP0WK-2PDpB!GZ1%>)Y?#?v{ UzR#V0-e0p5yX)@`;PAu!6GcmnNB{r; literal 0 HcmV?d00001 diff --git a/backend/__pycache__/db.cpython-313.pyc b/backend/__pycache__/db.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1f1239c5217743fa68040fc1c6e993b9b5a3fab3 GIT binary patch literal 1618 zcmah}%}*Og6rb5$uRqow;Uh>TEisN2aHH^%U}8{P6>0*36p)uyE7j6!?Ohu;_O3g- zCI$6iRXxx|O;9DI9C|>?h1|GQ;>e!>@`a9?s#VlOZmGzjhaUQ7ZBr+gj=gVx@Aux! z)4Y#83xxs**4KTfrAItMFPPF^ViVxA?*Qx~8OeAFEn zEQ&+~9Fjf#=u3fk<*+Qe$d~oTknGL+Vkp^)kmQLwN7h8rXd=lJ2V-vv=ezXrpP?>xA(qd#1!&;8c>&UW%xxccNih%>OG zJQMB3s_owXCjfB!*4+@)R_5CEFgtl(7S4Ix;qQ^fbDrzqy&XK?!9#7&{RKIo@L?oN ziU2aGAQxkhA%zR0n}Ik=gqLY(bwysA&&7dkBR$8xL;IH+kz{@0G!)yL*t@!q_lNiU55^DH4sIU~9;Oc`kGhU-9L0~N`D0<8 zL?93irf1e>W@qF%Rmt34V?$K6tg>1qhFUF|#oMfnb0t%!ey!$Mg>ubarvV1@R;5yM z-e@ZU4!pvK%5g#Tjn-<9yNL#1oHo!dj>0m!2UeT)daDd?gi&>r4)W7TtPo}rSQ}4B>yv?!U>VE`J%k-5F!C0 z?=w{-hEpS^t0Xvbt!+qzXh2m9H82{gN?1OKk8KD**J4CAEVDHHA2DK)3Smv< zHlW)(WRUrY&2JwWVm5ZKXUMH{v+sN#R4lz#Ha;TbU}BlDKLzoM#~43Hu@}hyCyGBu zL$5^+54{eEcme;_jV|==ET6lGF1=$;qTk|=3wH)pQ?+$L@w?19VCyGlgLs% LcqV#qj~nh^k%n^% literal 0 HcmV?d00001 diff --git a/backend/__pycache__/main.cpython-313.pyc b/backend/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a12bfecd1ce7f28d80c7789bb36b08ce92e48bc8 GIT binary patch literal 24890 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 G(EkA0>ZzRo literal 0 HcmV?d00001 diff --git a/backend/main.py b/backend/main.py index cf1e12d..a6d8afd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from typing import Any import requests import yaml @@ -40,6 +41,7 @@ from backend.auth import ( # noqa: E402 ) from backend.db import engine, get_db # noqa: E402 from backend.models import AutonomousPipeline, Base, User, UserChatMessage # noqa: E402 +from backend.pipeline_migrate import migrate_definition # noqa: E402 class GenerationRequest(BaseModel): @@ -71,9 +73,90 @@ class LowCodePhaseIn(BaseModel): jenkins: JenkinsPhaseConfigIn = Field(default_factory=JenkinsPhaseConfigIn) +class GateCheckIn(BaseModel): + """Условие для перехода конвейера на следующий этап (имя переменной → ожидаемое значение).""" + + variable: str = "" + expected: str = "" + + +class StageIn(BaseModel): + """Этап (например стенд): внутри него выполняются фазы; checks — гейты перед выходом на следующий этап.""" + + id: str + name: str = "" + description: str = "" + checks: list[GateCheckIn] = Field(default_factory=list) + phases: list[LowCodePhaseIn] = Field(default_factory=list) + + class AutonomousPipelineSave(BaseModel): title: str = "" - phases: list[LowCodePhaseIn] = Field(default_factory=list) + stages: list[StageIn] = Field(default_factory=list) + + +class ValidateGateChecksIn(BaseModel): + """Проверка условий перехода: для каждого элемента checks сравнивается values[variable] с expected.""" + + checks: list[GateCheckIn] = Field(default_factory=list) + values: dict[str, Any] = Field(default_factory=dict) + + +class GateCheckFailure(BaseModel): + variable: str + reason: str # "missing" | "mismatch" + expected: str | None = None + actual: str | None = None + + +class ValidateGateChecksOut(BaseModel): + ok: bool + failures: list[GateCheckFailure] = Field(default_factory=list) + + +def evaluate_gate_checks( + checks: list[GateCheckIn], + values: dict[str, Any], +) -> tuple[bool, list[GateCheckFailure]]: + """Сравнивает ожидания с фактическими значениями (строки после strip). Строки с пустым variable игнорируются.""" + normalized: dict[str, str] = {} + for k, v in values.items(): + key = str(k).strip() + if not key: + continue + if v is None: + normalized[key] = "" + else: + normalized[key] = str(v).strip() + + failures: list[GateCheckFailure] = [] + for c in checks: + var = (c.variable or "").strip() + if not var: + continue + exp = (c.expected or "").strip() + if var not in normalized: + failures.append( + GateCheckFailure( + variable=var, + reason="missing", + expected=exp, + actual=None, + ) + ) + continue + act = normalized[var] + if act != exp: + failures.append( + GateCheckFailure( + variable=var, + reason="mismatch", + expected=exp, + actual=act, + ) + ) + ok = len(failures) == 0 + return ok, failures class JenkinsTriggerRequest(BaseModel): @@ -199,15 +282,15 @@ def get_autonomous_pipeline( user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None, ): if user is None: - return {"title": "", "phases": [], "updated_at": None} + return {"title": "", "stages": [], "updated_at": None} row = db.get(AutonomousPipeline, user.id) if row is None: - return {"title": "", "phases": [], "updated_at": None} - defn = row.definition if isinstance(row.definition, dict) else {} - phases = defn.get("phases", []) + return {"title": "", "stages": [], "updated_at": None} + defn = migrate_definition(row.definition if isinstance(row.definition, dict) else {}) + stages = defn.get("stages", []) return { "title": row.title, - "phases": phases, + "stages": stages, "updated_at": row.updated_at.isoformat() if row.updated_at else None, } @@ -222,22 +305,23 @@ def put_autonomous_pipeline( raise HTTPException(status_code=401, detail="Authentication required") try: row = db.get(AutonomousPipeline, user.id) - dumped = [p.model_dump() for p in body.phases] + dumped = [s.model_dump() for s in body.stages] + new_def = {"schema_version": 2, "stages": dumped} if row is None: row = AutonomousPipeline( user_id=user.id, title=body.title, - definition={"schema_version": 1, "phases": dumped}, + definition=new_def, ) db.add(row) else: row.title = body.title - row.definition = {"schema_version": 1, "phases": dumped} + row.definition = new_def db.commit() db.refresh(row) return { "title": row.title, - "phases": row.definition.get("phases", []), + "stages": row.definition.get("stages", []), "updated_at": row.updated_at.isoformat() if row.updated_at else None, } except SQLAlchemyError as e: @@ -245,6 +329,18 @@ def put_autonomous_pipeline( raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") +@app.post("/pipelines/autonomous/validate-gates", response_model=ValidateGateChecksOut) +def validate_autonomous_gates( + body: ValidateGateChecksIn, + user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None, +): + """Проверяет, что для каждого условия в `checks` в `values` есть совпадающее значение.""" + if user is None: + raise HTTPException(status_code=401, detail="Authentication required") + ok, failures = evaluate_gate_checks(body.checks, body.values) + return ValidateGateChecksOut(ok=ok, failures=failures) + + @app.post("/jenkins/trigger") def jenkins_trigger( req: JenkinsTriggerRequest, diff --git a/backend/pipeline_migrate.py b/backend/pipeline_migrate.py new file mode 100644 index 0000000..5b3ae84 --- /dev/null +++ b/backend/pipeline_migrate.py @@ -0,0 +1,29 @@ +"""Миграция черновика конвейера: плоский список фаз → этапы (стенды) с фазами и проверками.""" + +from __future__ import annotations + +import uuid +from typing import Any + + +def migrate_definition(defn: Any) -> dict: + """Приводит definition к schema_version 2 с ключом stages.""" + if not isinstance(defn, dict): + return {"schema_version": 2, "stages": []} + if defn.get("stages"): + return defn + phases = defn.get("phases") or [] + if not phases: + return {"schema_version": 2, "stages": []} + return { + "schema_version": 2, + "stages": [ + { + "id": str(uuid.uuid4()), + "name": "Этап 1", + "description": "Импорт: ранее фазы задавались без этапов (стендов).", + "checks": [], + "phases": phases, + } + ], + } diff --git a/frontend/ui.py b/frontend/ui.py index f8cdd61..b2659c7 100644 --- a/frontend/ui.py +++ b/frontend/ui.py @@ -51,7 +51,7 @@ def _title_bar(*, show_logout: bool): st.session_state.chat_messages = [] st.session_state.chat_hydrated = False st.session_state.autonomous_pl_loaded = False - st.session_state.autonomous_phases = [] + st.session_state.autonomous_stages = [] st.rerun() st.button( @@ -129,25 +129,36 @@ def _new_autonomous_phase() -> dict: } -def _mermaid_autonomous(phases: list) -> str: - if not phases: - return "flowchart LR\n E[Нет фаз]" +def _new_autonomous_stage() -> dict: + return { + "id": uuid.uuid4().hex, + "name": "", + "description": "", + "checks": [], + "phases": [], + } + + +def _mermaid_stages(stages: list) -> str: + if not stages: + return "flowchart LR\n E[Нет этапов]" lines = ["flowchart LR"] - for i, p in enumerate(phases): - label = (p.get("name") or f"Фаза {i + 1}").replace('"', "'").replace("\n", " ")[:48] - lines.append(f' P{i}["{label}"]') - for i in range(len(phases) - 1): - lines.append(f" P{i} --> P{i + 1}") + for i, s in enumerate(stages): + sn = (s.get("name") or f"Этап {i + 1}").replace('"', "'").replace("\n", " ")[:40] + nph = len(s.get("phases") or []) + lines.append(f' S{i}["{sn}\\n({nph} фаз)"]') + for i in range(len(stages) - 1): + lines.append(f" S{i} --> S{i + 1}") return "\n".join(lines) -def _collect_autonomous_for_save(phases: list, ver: int) -> tuple[list | None, str | None]: +def _collect_phases_for_save(phases: list, ver: int, sid: str) -> tuple[list | None, str | None]: out = [] for ph in phases: 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) + raw = st.session_state.get(f"ap_pj_{sid}_{pid}_{ver}", default_pj) try: params_obj = json.loads(raw) if str(raw).strip() else {} if not isinstance(params_obj, dict): @@ -157,11 +168,11 @@ def _collect_autonomous_for_save(phases: list, ver: int) -> tuple[list | None, s out.append( { "id": pid, - "name": st.session_state.get(f"ap_pn_{pid}_{ver}", ph.get("name", "")), - "description": st.session_state.get(f"ap_pd_{pid}_{ver}", ph.get("description", "")), + "name": st.session_state.get(f"ap_pn_{sid}_{pid}_{ver}", ph.get("name", "")), + "description": st.session_state.get(f"ap_pd_{sid}_{pid}_{ver}", ph.get("description", "")), "jenkins": { - "job": st.session_state.get(f"ap_jj_{pid}_{ver}", j.get("job", "")), - "server_url": st.session_state.get(f"ap_js_{pid}_{ver}", j.get("server_url", "")), + "job": st.session_state.get(f"ap_jj_{sid}_{pid}_{ver}", j.get("job", "")), + "server_url": st.session_state.get(f"ap_js_{sid}_{pid}_{ver}", j.get("server_url", "")), "parameters": {str(k): str(v) for k, v in params_obj.items()}, }, } @@ -169,22 +180,98 @@ 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 из текущих виджетов фазы.""" +def _collect_stages_for_save(stages: list, ver: int) -> tuple[list | None, str | None]: + out_stages = [] + for stg in stages: + sid = stg["id"] + default_ch = json.dumps(stg.get("checks") or [], ensure_ascii=False, indent=2) + raw_ch = st.session_state.get(f"st_ch_{sid}_{ver}", default_ch) + try: + checks_raw = json.loads(raw_ch) if str(raw_ch).strip() else [] + if not isinstance(checks_raw, list): + raise TypeError("нужен JSON-массив") + checks = [] + for c in checks_raw: + if not isinstance(c, dict): + continue + checks.append( + { + "variable": str(c.get("variable", "")), + "expected": str(c.get("expected", "")), + } + ) + except Exception as e: + return None, f"Этап «{stg.get('name') or sid}»: проверки перехода — {e}" + ph_list, err = _collect_phases_for_save(stg.get("phases") or [], ver, sid) + if err: + return None, err + out_stages.append( + { + "id": sid, + "name": st.session_state.get(f"st_nm_{sid}_{ver}", stg.get("name", "")), + "description": st.session_state.get(f"st_dc_{sid}_{ver}", stg.get("description", "")), + "checks": checks, + "phases": ph_list, + } + ) + return out_stages, None + + +def _parse_gate_checks_session(sid: str, ver: int, stg: dict) -> tuple[list | None, str | None]: + default_ch = json.dumps(stg.get("checks") or [], ensure_ascii=False, indent=2) + raw_ch = st.session_state.get(f"st_ch_{sid}_{ver}", default_ch) + try: + checks_raw = json.loads(raw_ch) if str(raw_ch).strip() else [] + if not isinstance(checks_raw, list): + raise TypeError("нужен JSON-массив") + checks = [] + for c in checks_raw: + if not isinstance(c, dict): + continue + checks.append( + { + "variable": str(c.get("variable", "")), + "expected": str(c.get("expected", "")), + } + ) + return checks, None + except Exception as e: + return None, str(e) + + +def _collect_flat_jenkins_params_for_stage(stg: dict, ver: int, sid: str) -> dict[str, str]: + """Все параметры JSON из фаз этапа (последняя фаза перекрывает ключи предыдущих при совпадении имён).""" + out: dict[str, str] = {} + for ph in stg.get("phases") or []: + 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_{sid}_{pid}_{ver}", default_pj) + try: + params_obj = json.loads(raw) if str(raw).strip() else {} + if isinstance(params_obj, dict): + for k, v in params_obj.items(): + out[str(k)] = str(v) + except Exception: + pass + return out + + +def _phase_for_jenkins_trigger(ph: dict, ver: int, sid: str) -> tuple[dict | None, str | None]: 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) + raw = st.session_state.get(f"ap_pj_{sid}_{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() + job = st.session_state.get(f"ap_jj_{sid}_{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() + surl = st.session_state.get(f"ap_js_{sid}_{pid}_{ver}", j.get("server_url", "")).strip() return ( { "server_url": surl, @@ -281,7 +368,7 @@ if not st.session_state.get("chat_hydrated"): st.session_state.chat_hydrated = True if not st.session_state.get("autonomous_pl_loaded"): - st.session_state.autonomous_phases = [] + st.session_state.autonomous_stages = [] st.session_state.autonomous_pl_title = "" try: ar = requests.get( @@ -292,16 +379,24 @@ if not st.session_state.get("autonomous_pl_loaded"): if ar.ok: data = ar.json() st.session_state.autonomous_pl_title = data.get("title") or "" - phases = data.get("phases") or [] - for ph in phases: - if not isinstance(ph, dict): + stages = data.get("stages") or [] + for stg in stages: + if not isinstance(stg, dict): continue - ph.setdefault("id", uuid.uuid4().hex) - ph.setdefault("jenkins", {}) - ph["jenkins"].setdefault("job", "") - ph["jenkins"].setdefault("server_url", "") - ph["jenkins"].setdefault("parameters", {}) - st.session_state.autonomous_phases = [p for p in phases if isinstance(p, dict)] + stg.setdefault("id", uuid.uuid4().hex) + stg.setdefault("name", "") + stg.setdefault("description", "") + stg.setdefault("checks", []) + stg.setdefault("phases", []) + for ph in stg.get("phases") or []: + if not isinstance(ph, dict): + continue + ph.setdefault("id", uuid.uuid4().hex) + ph.setdefault("jenkins", {}) + ph["jenkins"].setdefault("job", "") + ph["jenkins"].setdefault("server_url", "") + ph["jenkins"].setdefault("parameters", {}) + st.session_state.autonomous_stages = [s for s in stages if isinstance(s, dict)] except Exception: pass st.session_state.autonomous_pl_loaded = True @@ -392,13 +487,14 @@ with tab_info: with tab_autonomous: st.subheader("Автономные внедрения") st.caption( - "Low-code конвейер: фазы идут **слева направо**; под каждой фазой задаётся вызов **Jenkins** и параметры. " - "Идея близка к DPM / Orchestra R ([статья на Хабре](https://habr.com/ru/companies/sberbank/articles/855556/), " - "[документация Platform V DPM](https://sbertech.ru/documents/platform-v-devops-pipeline-management/4.0.0))." + "Иерархия: **конвейер → этапы (стенды) → фазы**. На этапе задаются **проверки** (переменная и ожидаемое значение) " + "для перехода к следующему этапу; внутри этапа фазы идут по порядку, с вызовом **Jenkins**. " + "См. DPM / Orchestra R ([Хабр](https://habr.com/ru/companies/sberbank/articles/855556/), " + "[DPM](https://sbertech.ru/documents/platform-v-devops-pipeline-management/4.0.0))." ) ver = int(st.session_state.get("autonomous_pl_version", 0)) - phases: list = st.session_state.autonomous_phases + stages: list = st.session_state.autonomous_stages c_head1, c_head2, c_head3 = st.columns([2, 1, 1]) with c_head1: @@ -407,12 +503,12 @@ with tab_autonomous: st.text_input( "Название конвейера", key="ap_global_title", - help="Сохраняется вместе с фазами в базе (один черновик на пользователя).", + help="Один черновик на пользователя в Postgres.", ) with c_head2: - if st.button("Добавить фазу", use_container_width=True, key="ap_add_phase"): - phases.append(_new_autonomous_phase()) - st.session_state.autonomous_phases = phases + if st.button("Добавить этап (стенд)", use_container_width=True, key="ap_add_stage"): + stages.append(_new_autonomous_stage()) + st.session_state.autonomous_stages = stages st.session_state.autonomous_pl_version = ver + 1 st.rerun() with c_head3: @@ -420,95 +516,229 @@ with tab_autonomous: st.session_state.autonomous_pl_loaded = False st.rerun() - with st.expander("Схема потока (Mermaid)", expanded=False): - st.markdown(f"```mermaid\n{_mermaid_autonomous(phases)}\n```") + with st.expander("Схема этапов (Mermaid)", expanded=False): + st.markdown(f"```mermaid\n{_mermaid_stages(stages)}\n```") - for idx, ph in enumerate(phases): - pid = ph["id"] - j = ph.setdefault("jenkins", {}) - j.setdefault("parameters", {}) - st.session_state.setdefault(f"ap_pn_{pid}_{ver}", ph.get("name", "")) - st.session_state.setdefault(f"ap_pd_{pid}_{ver}", ph.get("description", "")) - st.session_state.setdefault(f"ap_jj_{pid}_{ver}", j.get("job", "")) - st.session_state.setdefault(f"ap_js_{pid}_{ver}", j.get("server_url", "")) + for sidx, stg in enumerate(stages): + sid = stg["id"] + stg.setdefault("phases", []) + stg.setdefault("checks", []) + st.session_state.setdefault(f"st_nm_{sid}_{ver}", stg.get("name", "")) + st.session_state.setdefault(f"st_dc_{sid}_{ver}", stg.get("description", "")) st.session_state.setdefault( - f"ap_pj_{pid}_{ver}", - json.dumps(j.get("parameters", {}), ensure_ascii=False, indent=2), + f"st_ch_{sid}_{ver}", + json.dumps(stg.get("checks") or [], ensure_ascii=False, indent=2), ) - title_ph = st.session_state.get(f"ap_pn_{pid}_{ver}", "") or f"Фаза {idx + 1}" - with st.expander(f"◆ {title_ph}", expanded=(idx == len(phases) - 1)): - st.markdown("**Фаза (low-code)**") - st.text_input("Название", key=f"ap_pn_{pid}_{ver}") - st.text_area("Описание / что проверяется на этапе", key=f"ap_pd_{pid}_{ver}", height=68) - st.divider() - st.markdown("**Вызов Jenkins**") - st.caption("Укажите job (путь в Jenkins) и при необходимости базовый URL контроллера; параметры уйдут в job как build parameters.") - st.text_input("Job / путь", key=f"ap_jj_{pid}_{ver}", placeholder="folder/subfolder/job-name") - st.text_input("Jenkins URL (если не дефолтный)", key=f"ap_js_{pid}_{ver}", placeholder="https://jenkins.example.com/") - st.text_area( - "Параметры (JSON-объект)", - key=f"ap_pj_{pid}_{ver}", - height=120, - help='Например: {"BRANCH": "main", "ENV": "staging"}', + st_title = st.session_state.get(f"st_nm_{sid}_{ver}", "") or f"Этап {sidx + 1}" + with st.expander(f"▣ {st_title} (стенд / этап)", expanded=(sidx == len(stages) - 1)): + st.markdown("**Этап** — логический стенд; здесь же задаются **условия перехода** на следующий этап.") + st.text_input("Наименование этапа / стенда", key=f"st_nm_{sid}_{ver}") + st.text_area("Описание этапа", key=f"st_dc_{sid}_{ver}", height=56) + st.markdown("**Проверки для перехода на следующий этап**") + st.caption( + "JSON-массив `variable` / `expected`. Ниже задаются фактические значения; они объединяются с параметрами Jenkins фаз этого этапа (ручной JSON перекрывает параметры)." ) - b1, b2, b3 = st.columns(3) - with b1: - if st.button("Вверх", key=f"ap_up_{pid}_{ver}", disabled=idx == 0): - phases[idx - 1], phases[idx] = phases[idx], phases[idx - 1] - st.session_state.autonomous_phases = phases - st.session_state.autonomous_pl_version = ver + 1 - st.rerun() - with b2: - if st.button("Вниз", key=f"ap_dn_{pid}_{ver}", disabled=idx >= len(phases) - 1): - phases[idx + 1], phases[idx] = phases[idx], phases[idx + 1] - st.session_state.autonomous_phases = phases - st.session_state.autonomous_pl_version = ver + 1 - st.rerun() - with b3: - if st.button("Удалить фазу", key=f"ap_rm_{pid}_{ver}"): - 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…"): + st.text_area( + "Проверки (JSON)", + key=f"st_ch_{sid}_{ver}", + height=100, + help='Пример: [{"variable":"DEPLOY_STATUS","expected":"OK"},{"variable":"ENV","expected":"dev"}]', + ) + st.session_state.setdefault(f"st_val_{sid}_{ver}", "{}") + st.markdown("**Фактические значения для сравнения**") + st.text_area( + "Значения переменных (JSON-объект)", + key=f"st_val_{sid}_{ver}", + height=88, + help='Например: {"DEPLOY_STATUS":"OK","ENV":"dev"}. Пустые строки можно добить шаблоном.', + ) + gv1, gv2 = st.columns(2) + with gv1: + if st.button("Шаблон ключей из проверок", key=f"st_tpl_{sid}_{ver}", use_container_width=True): + chk, e2 = _parse_gate_checks_session(sid, ver, stg) + if e2: + st.error(f"Проверки: {e2}") + else: + tpl = { + str(c.get("variable", "")).strip(): "" + for c in (chk or []) + if str(c.get("variable", "")).strip() + } + st.session_state[f"st_val_{sid}_{ver}"] = json.dumps( + tpl, ensure_ascii=False, indent=2 + ) + st.rerun() + with gv2: + if st.button( + "Проверить условия перехода", + key=f"st_vg_{sid}_{ver}", + type="primary", + use_container_width=True, + ): + chk, e2 = _parse_gate_checks_session(sid, ver, stg) + if e2: + st.error(f"Проверки: {e2}") + elif not chk or not any(str(c.get("variable", "")).strip() for c in chk): + st.info("Проверки не заданы (или без имён переменных) — условия перехода не блокируются.") + else: + raw_val = st.session_state.get(f"st_val_{sid}_{ver}", "{}") 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 ''}" - ) + user_vals = json.loads(raw_val) if str(raw_val).strip() else {} + if not isinstance(user_vals, dict): + raise TypeError("нужен JSON-объект") except Exception as ex: - st.error(str(ex)) + st.error(f"Значения переменных: {ex}") + user_vals = None + if user_vals is not None: + jenk = _collect_flat_jenkins_params_for_stage(stg, ver, sid) + merged = {**jenk, **{str(k): v for k, v in user_vals.items()}} + try: + vr = requests.post( + f"{backend_base_url}/pipelines/autonomous/validate-gates", + json={"checks": chk, "values": merged}, + headers=_auth_headers(), + timeout=30, + ) + if vr.status_code >= 400: + st.error(_detail_or_text(vr)) + else: + data = vr.json() + if data.get("ok"): + nxt = "следующий этап" if sidx < len(stages) - 1 else "завершение конвейера" + st.success(f"Все проверки пройдены. Можно переходить к: {nxt}.") + else: + st.error("Проверки не пройдены.") + for f in data.get("failures") or []: + r = f.get("reason") + var = f.get("variable", "") + if r == "missing": + st.warning(f"• `{var}`: переменная отсутствует среди значений.") + elif r == "mismatch": + st.warning( + f"• `{var}`: ожидалось `{f.get('expected')}`, " + f"фактически `{f.get('actual')}`." + ) + else: + st.warning(f"• {f}") + except Exception as ex: + st.error(str(ex)) + if st.button("Добавить фазу на этом этапе", key=f"ap_add_ph_{sid}_{ver}", use_container_width=True): + stg.setdefault("phases", []).append(_new_autonomous_phase()) + st.session_state.autonomous_stages = stages + st.session_state.autonomous_pl_version = ver + 1 + st.rerun() + + phases = stg.get("phases") or [] + for pidx, ph in enumerate(phases): + pid = ph["id"] + j = ph.setdefault("jenkins", {}) + j.setdefault("parameters", {}) + st.session_state.setdefault(f"ap_pn_{sid}_{pid}_{ver}", ph.get("name", "")) + st.session_state.setdefault(f"ap_pd_{sid}_{pid}_{ver}", ph.get("description", "")) + st.session_state.setdefault(f"ap_jj_{sid}_{pid}_{ver}", j.get("job", "")) + st.session_state.setdefault(f"ap_js_{sid}_{pid}_{ver}", j.get("server_url", "")) + st.session_state.setdefault( + f"ap_pj_{sid}_{pid}_{ver}", + json.dumps(j.get("parameters", {}), ensure_ascii=False, indent=2), + ) + + title_ph = st.session_state.get(f"ap_pn_{sid}_{pid}_{ver}", "") or f"Фаза {pidx + 1}" + ph_expanded = sidx == len(stages) - 1 and pidx == len(phases) - 1 + with st.expander(f" ◆ Фаза: {title_ph}", expanded=ph_expanded): + st.markdown("**Фаза (low-code)**") + st.text_input("Название", key=f"ap_pn_{sid}_{pid}_{ver}") + st.text_area("Описание", key=f"ap_pd_{sid}_{pid}_{ver}", height=68) + st.divider() + st.markdown("**Jenkins**") + st.text_input("Job / путь", key=f"ap_jj_{sid}_{pid}_{ver}", placeholder="folder/subfolder/job-name") + st.text_input("Jenkins URL (если не дефолтный)", key=f"ap_js_{sid}_{pid}_{ver}") + st.text_area("Параметры (JSON)", key=f"ap_pj_{sid}_{pid}_{ver}", height=100) + b1, b2, b3 = st.columns(3) + with b1: + if st.button("Фаза вверх", key=f"ap_up_{sid}_{pid}_{ver}", disabled=pidx == 0): + phases[pidx - 1], phases[pidx] = phases[pidx], phases[pidx - 1] + st.session_state.autonomous_stages = stages + st.session_state.autonomous_pl_version = ver + 1 + st.rerun() + with b2: + if st.button( + "Фаза вниз", + key=f"ap_dn_{sid}_{pid}_{ver}", + disabled=pidx >= len(phases) - 1, + ): + phases[pidx + 1], phases[pidx] = phases[pidx], phases[pidx + 1] + st.session_state.autonomous_stages = stages + st.session_state.autonomous_pl_version = ver + 1 + st.rerun() + with b3: + if st.button("Удалить фазу", key=f"ap_rm_{sid}_{pid}_{ver}"): + stg["phases"] = [p for p in phases if p["id"] != pid] + st.session_state.autonomous_stages = stages + st.session_state.autonomous_pl_version = ver + 1 + st.rerun() + if st.button( + "Запустить Jenkins", + key=f"ap_run_{sid}_{pid}_{ver}", + use_container_width=True, + ): + pj, jerr = _phase_for_jenkins_trigger(ph, ver, sid) + 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)) + + sb1, sb2, sb3 = st.columns(3) + with sb1: + if st.button("Этап вверх", key=f"st_up_{sid}_{ver}", disabled=sidx == 0): + stages[sidx - 1], stages[sidx] = stages[sidx], stages[sidx - 1] + st.session_state.autonomous_stages = stages + st.session_state.autonomous_pl_version = ver + 1 + st.rerun() + with sb2: + if st.button( + "Этап вниз", + key=f"st_dn_{sid}_{ver}", + disabled=sidx >= len(stages) - 1, + ): + stages[sidx + 1], stages[sidx] = stages[sidx], stages[sidx + 1] + st.session_state.autonomous_stages = stages + st.session_state.autonomous_pl_version = ver + 1 + st.rerun() + with sb3: + if st.button("Удалить этап", key=f"st_rm_{sid}_{ver}"): + st.session_state.autonomous_stages = [s for s in stages if s["id"] != sid] + st.session_state.autonomous_pl_version = ver + 1 + st.rerun() save_col1, save_col2 = st.columns([1, 2]) with save_col1: if st.button("Сохранить в базу", type="primary", use_container_width=True, key="ap_save_db"): - to_save, err = _collect_autonomous_for_save(phases, ver) + to_save, err = _collect_stages_for_save(stages, ver) if err: st.error(err) else: try: body = { "title": st.session_state.get("ap_global_title", ""), - "phases": to_save, + "stages": to_save, } r = requests.put( f"{backend_base_url}/pipelines/autonomous", @@ -521,7 +751,7 @@ with tab_autonomous: else: data = r.json() st.session_state.autonomous_pl_title = data.get("title", "") - st.session_state.autonomous_phases = data.get("phases", []) + st.session_state.autonomous_stages = data.get("stages", []) if "ap_global_title" in st.session_state: del st.session_state["ap_global_title"] st.session_state.autonomous_pl_version = ver + 1 @@ -531,16 +761,16 @@ with tab_autonomous: st.error(str(e)) with save_col2: st.caption( - "Сохранение: `PUT /pipelines/autonomous`. Запуск: `POST /jenkins/trigger` " - "(учётные данные Jenkins — только в env backend: `JENKINS_USER`, `JENKINS_TOKEN`)." + "`PUT /pipelines/autonomous` — сохранение. Проверки: `POST /pipelines/autonomous/validate-gates`. " + "Jenkins: `POST /jenkins/trigger` + env credentials." ) with st.expander("Экспорт YAML (черновик)", expanded=False): - snap, err = _collect_autonomous_for_save(phases, ver) + snap, err = _collect_stages_for_save(stages, ver) if err: st.warning(err) else: - doc = {"title": st.session_state.get("ap_global_title", ""), "phases": snap} + doc = {"title": st.session_state.get("ap_global_title", ""), "stages": snap} st.code(yaml.safe_dump(doc, allow_unicode=True, sort_keys=False), language="yaml") with tab_infra: