diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index ce5c2b2..bc70509 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import NamedTuple from urllib.parse import parse_qs, urlparse -from . import backup, db, mirror, queue, relay, settings +from . import backup, db, mirror, queue, relay, release, settings from .commands import CommandError from .paths import backups_dir as default_backups_dir from .paths import secure_dir @@ -39,9 +39,11 @@ class ApiError(Exception): class Hub: - def __init__(self, db_path, backups_dir=None): + def __init__(self, db_path, backups_dir=None, release_dir=None): self.db_path = Path(db_path) self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() + self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release" + self.agent_update = {"update_state": None, "launcher_version": None} # последнее из claim; в памяти (R-3) self.claim_wait = 30 secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии conn = self.conn() @@ -63,6 +65,18 @@ class Hub: if conn: conn.close() + def note_agent_update(self, body): + """update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3). + Значение неверного типа игнорируется — claim из-за него не отвергается.""" + us = body.get("update_state") + if us is None or isinstance(us, dict): + if us is not None and isinstance(us.get("error"), str): + us = {**us, "error": us["error"][:500]} + self.agent_update["update_state"] = us + lv = body.get("launcher_version") + if lv is None or (isinstance(lv, int) and not isinstance(lv, bool)): + self.agent_update["launcher_version"] = lv + def make_backup(self, label): conn = self.conn() try: @@ -91,6 +105,7 @@ def _not_found(obj, what): def h_claim(hub, conn, m, q, body): + hub.note_agent_update(body) deadline = time.monotonic() + hub.claim_wait while True: cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"), @@ -98,7 +113,16 @@ def h_claim(hub, conn, m, q, body): if cmd or time.monotonic() >= deadline: break time.sleep(1) - return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version")} + rel = release.current(hub.release_dir) + return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version"), + "agent_release": {"version": rel["version"], "sha256": rel["sha256"]} if rel else None} + + +def h_agent_release(hub, conn, m, q, body): + rel = release.load(hub.release_dir) + if rel is None: + raise ApiError(404, "no_release", "версия агента не опубликована") + return 200, rel def h_agent_settings(hub, conn, m, q, body): @@ -231,7 +255,11 @@ def h_refresh(hub, conn, m, q, body): def h_agent_get(hub, conn, m, q, body): - return 200, queue.agent_view(conn) + view = queue.agent_view(conn) + rel = release.current(hub.release_dir) + us, lv = hub.agent_update["update_state"], hub.agent_update["launcher_version"] + view.update(release=rel, update_state=us, launcher_version=lv, update_hint=release.hint(rel, view, us, lv)) + return 200, view def h_sync_runs(hub, conn, m, q, body): @@ -269,6 +297,7 @@ ROUTES = [ ("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"), ("POST", r"^/agent/state$", h_agent_state, "agent"), ("GET", r"^/agent/known-keys$", h_known_keys, "agent"), + ("GET", r"^/agent/release$", h_agent_release, "agent"), ("GET", r"^/issues$", h_issues, "client"), ("GET", r"^/issues/([^/]+)$", h_issue, "client"), ("GET", r"^/board$", h_board, "client"), diff --git a/tests/test_api.py b/tests/test_api.py index cfd873d..5459c72 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -5,7 +5,7 @@ import urllib.request import pytest -from jira_hub import api, db, settings +from jira_hub import api, db, release, settings from jira_hub.client import HubClient, HubError @@ -394,3 +394,71 @@ def test_page_reads_hub_and_has_no_embedded_export(): assert "Нет в зеркале" in html assert 'author: "viewer"' in html and "/batches/" in html assert "KEY_RE.test(l.source) && KEY_RE.test(l.target)" in html # I-1: ключи связей из зеркала идут в разметку карточек + + +def _publish(hub, tmp_path, version): + src = tmp_path / f"agent{version}.py" + src.write_text(f"AGENT_VERSION = {version}\nprint('v{version}')\n", encoding="utf-8") + return release.publish(hub["hub"].release_dir, src) + + +def test_claim_release_field(hub, tmp_path): + a = hub["agent"] + assert a.post("/agent/claim", {"agent_version": 5})["agent_release"] is None + m = _publish(hub, tmp_path, 6) + r = a.post("/agent/claim", {"agent_version": 5}) + assert r["command"] is None and r["agent_release"] == {"version": 6, "sha256": m["sha256"]} + + +def test_agent_release_route(hub, tmp_path): + with pytest.raises(HubError) as e: + hub["agent"].get("/agent/release") + assert e.value.status == 404 and e.value.error == "no_release" + m = _publish(hub, tmp_path, 6) + r = hub["agent"].get("/agent/release") + assert r == {"version": 6, "sha256": m["sha256"], "content": "AGENT_VERSION = 6\nprint('v6')\n"} + with pytest.raises(HubError) as e: + hub["client"].get("/agent/release") + assert e.value.status == 401 + + +def test_claim_update_state_shown_in_agent(hub, tmp_path): + _publish(hub, tmp_path, 7) + us = {"state": "rolled_back", "from_version": 6, "to_version": 7, "sha256": "s", "error": "код 1 через 3 с" + "x" * 600, + "at": "2026-10-14T20:00:00+00:00"} + hub["agent"].post("/agent/claim", {"agent_version": 6, "update_state": us, "launcher_version": 1}) + v = hub["client"].get("/agent") + assert v["release"]["version"] == 7 and v["release"]["published_at"] + assert v["launcher_version"] == 1 + assert v["update_state"]["state"] == "rolled_back" and len(v["update_state"]["error"]) == 500 + assert v["update_hint"].startswith("откат с v7 на v6: код 1 через 3 с") + + +def test_claim_without_update_fields(hub, tmp_path): + hub["agent"].post("/agent/claim", {"agent_version": 6, "update_state": {"state": "ok", "from_version": 5, + "to_version": 6}, "launcher_version": 1}) + hub["agent"].post("/agent/claim", {"agent_version": 4}) # агент v4: полей нет + v = hub["client"].get("/agent") + assert v["launcher_version"] is None and v["update_state"] is None + + +def test_claim_bad_update_types_ignored(hub): + a = hub["agent"] + a.post("/agent/claim", {"agent_version": 5, "update_state": {"state": "ok"}, "launcher_version": 1}) + r = a.post("/agent/claim", {"agent_version": 5, "update_state": "ok", "launcher_version": "1"}) + assert "command" in r + a.post("/agent/claim", {"agent_version": 5, "update_state": {"state": "ok"}, "launcher_version": True}) + v = hub["client"].get("/agent") + assert v["update_state"] == {"state": "ok"} and v["launcher_version"] == 1 + + +def test_hint_without_launcher(hub, tmp_path): + _publish(hub, tmp_path, 6) + hub["agent"].post("/agent/claim", {"agent_version": 5}) + v = hub["client"].get("/agent") + assert v["online"] is True and v["update_hint"] == "агент запущен без run.py — обновится только вручную" + + +def test_agent_view_without_release(hub): + v = hub["client"].get("/agent") + assert v["release"] is None and v["update_state"] is None and v["launcher_version"] is None and v["update_hint"] is None