From 7ab031a0645e381f01cb0627cbb0f89910d38b5f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 01:08:10 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=BB=D0=B8=D0=B7:=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=D1=8F=D0=B5=D0=BC=D0=B0=D1=8F=20=D0=BC?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8,=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B0=200700=20=D0=BD=D0=B0=20release/=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/core/api.py | 28 +++++++++++++++------------ src/jira_hub/core/cli.py | 4 +++- src/jira_hub/core/registry.py | 3 +++ src/jira_hub/core/release.py | 16 +++++++++++----- tests/test_agents.py | 36 +++++++++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 18 deletions(-) diff --git a/src/jira_hub/core/api.py b/src/jira_hub/core/api.py index 350b3be..cbe8c7d 100644 --- a/src/jira_hub/core/api.py +++ b/src/jira_hub/core/api.py @@ -33,6 +33,10 @@ SOCKET_TIMEOUT = 60 # чтение запроса; ожидание SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений») +def _no_update() -> dict: + return {"update_state": None, "launcher_version": None, "reported": False} + + class Hub: def __init__(self, db_path, backups_dir=None, release_root=None, web_dir=None, files_dir=None): self.db_path = Path(db_path) @@ -44,11 +48,15 @@ class Hub: self.module_state: dict[str, dict] = {} # память модулей между тиками (имя модуля → словарь) # последнее из claim по каждому агенту; в памяти (R-3). reported — был ли claim с запуска хаба: до него # launcher_version неизвестен, а не null (hub-hygiene-d, решение 7) - self.agent_update: dict[str, dict] = {m.name: self._no_update() for m in registry.agent_modules()} + self.agent_update: dict[str, dict] = {m.name: _no_update() for m in registry.agent_modules()} self.claim_wait = 30 secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии secure_dir(self.files_dir) # files/ — 0700 при каждом старте, как backups/ - release.migrate_layout(self.release_root) # release/agent.py → release/jira/ (раскладка по модулям) + secure_dir(self.release_root) # release/ — 0700 при каждом старте + release.migrate_layout(self.release_root) + for m in registry.agent_modules(): + if (self.release_root / m.name).is_dir(): + secure_dir(self.release_root / m.name) # release/agent.py → release/jira/ (раскладка по модулям) conn = self.conn() settings.ensure_defaults(conn) conn.close() @@ -89,14 +97,10 @@ class Hub: log.info("уборка файлов: удалено %d", len(removed)) return removed - @staticmethod - def _no_update() -> dict: - return {"update_state": None, "launcher_version": None, "reported": False} - def note_agent_update(self, agent, body): """update_state и launcher_version из claim агента: хранятся в памяти, агент шлёт их в каждом claim (R-3). Значение неверного типа игнорируется — claim из-за него не отвергается; сам claim отмечается (reported).""" - new = dict(self.agent_update.get(agent) or self._no_update()) + new = dict(self.agent_update.get(agent) or _no_update()) new["reported"] = True us = body.get("update_state") if us is None or isinstance(us, dict): @@ -130,7 +134,7 @@ def h_claim(hub, conn, m, q, body): break time.sleep(1) try: # команда уже выдана: публикация не должна превращать ответ в 500 (I-1) - rel = release.current(release.module_dir(hub.release_root, agent)) + rel = release.current(hub.release_root / agent) except Exception: # noqa: BLE001 log.exception("release: чтение публикации при claim") rel = None @@ -139,7 +143,7 @@ def h_claim(hub, conn, m, q, body): def h_agent_release(hub, conn, m, q, body): - rel = release.load(release.module_dir(hub.release_root, http.agent_of(q))) + rel = release.load(hub.release_root / http.agent_of(q)) if rel is None: raise ApiError(404, "no_release", "версия агента не опубликована") return 200, rel @@ -295,8 +299,8 @@ def h_command_cancel(hub, conn, m, q, body): def _agent_view(hub, conn, name) -> dict: view = queue.agent_view(conn, name) - rel = release.current(release.module_dir(hub.release_root, name)) - au = hub.agent_update.get(name) or hub._no_update() + rel = release.current(hub.release_root / name) + au = hub.agent_update.get(name) or _no_update() us, lv, reported = au["update_state"], au["launcher_version"], au["reported"] view.update(release=rel, update_state=us, launcher_version=lv, agent_reported=reported, update_hint=release.hint(rel, view, us, lv, reported=reported)) @@ -305,7 +309,7 @@ def _agent_view(hub, conn, name) -> dict: def h_agent_get(hub, conn, m, q, body): """Статус агента модуля: ?module=<имя>, по умолчанию jira; неизвестный или без агента — 404.""" - name = q.get("module") or "jira" + name = q.get("module") or registry.DEFAULT_AGENT if name not in [a.name for a in registry.agent_modules()]: raise ApiError(404, "not_found", f"module {name}") return 200, _agent_view(hub, conn, name) diff --git a/src/jira_hub/core/cli.py b/src/jira_hub/core/cli.py index f32bc76..ace03f0 100644 --- a/src/jira_hub/core/cli.py +++ b/src/jira_hub/core/cli.py @@ -5,6 +5,7 @@ import json import sys from . import db, paths, registry, settings +from .paths import secure_dir def main(argv=None) -> int: @@ -26,7 +27,7 @@ def main(argv=None) -> int: p = sub.add_parser("agent", help="версия агента для самообновления") agent_sub = p.add_subparsers(dest="agent_cmd", required=True) p = agent_sub.add_parser("publish", help="опубликовать agent.py (версия только вверх)") - p.add_argument("--module", default="jira", help="модуль агента (по умолчанию jira)") + p.add_argument("--module", default=registry.DEFAULT_AGENT, help=f"модуль агента (по умолчанию {registry.DEFAULT_AGENT})") p.add_argument("path") args = ap.parse_args(argv) @@ -37,6 +38,7 @@ def main(argv=None) -> int: print(f"неизвестный модуль {args.module}; модули с агентами: {', '.join(names)}", file=sys.stderr) return 2 try: + secure_dir(paths.release_root()) m = release.publish(release.module_dir(paths.release_root(), args.module), args.path) except release.PublishError as e: print(e, file=sys.stderr) diff --git a/src/jira_hub/core/registry.py b/src/jira_hub/core/registry.py index 4b566d2..8b6a7f1 100644 --- a/src/jira_hub/core/registry.py +++ b/src/jira_hub/core/registry.py @@ -16,6 +16,9 @@ def modules() -> list[Module]: return _cache +DEFAULT_AGENT = "jira" # агент по умолчанию: GET /agent без module, agent publish без --module + + def agent_modules() -> list[Module]: """Модули с командами: у каждого свой агент (имя агента = имя модуля), в порядке реестра.""" return [m for m in modules() if m.commands] diff --git a/src/jira_hub/core/release.py b/src/jira_hub/core/release.py index 43a35c6..0b7cfb7 100644 --- a/src/jira_hub/core/release.py +++ b/src/jira_hub/core/release.py @@ -10,6 +10,7 @@ from datetime import datetime, timezone from pathlib import Path from .paths import secure_dir +from .registry import DEFAULT_AGENT log = logging.getLogger(__name__) @@ -36,16 +37,21 @@ def module_dir(root, module) -> Path: def migrate_layout(root) -> None: - """Раскладка до модулей: agent.py и manifest.json лежали прямо в root. Если root/jira/ нет — переносим их в - root/jira/ (манифест последним: по нему версия считается опубликованной). Сбой — предупреждение в лог, хаб стартует.""" + """Раскладка до модулей: agent.py и manifest.json лежали прямо в root. Пока в root/jira/ нет manifest.json — + переносим их в root/jira/ покадрово (манифест последним: по нему версия считается опубликованной); файл, уже + лежащий в приёмнике, не перезаписываем, а корневой убираем. После частичного сбоя следующий старт доделывает перенос. + Сбой — предупреждение в лог, хаб стартует.""" root = Path(root) try: old = [root / n for n in ("agent.py", "manifest.json") if (root / n).exists()] - if not old or (root / "jira").exists(): + if not old or (root / DEFAULT_AGENT / "manifest.json").exists(): return - dest = module_dir(root, "jira") + dest = module_dir(root, DEFAULT_AGENT) for f in old: - os.replace(f, dest / f.name) + if (dest / f.name).exists(): + f.unlink() + else: + os.replace(f, dest / f.name) log.info("release: публикация агента перенесена в %s", dest) except OSError as e: log.warning("release: раскладка %s не перенесена в jira/: %s", root, e) diff --git a/tests/test_agents.py b/tests/test_agents.py index 9024498..0770e38 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -1,4 +1,6 @@ """Агенты модулей: токен определяет модуль, миграция v7 сохраняет агента jira (wiki-module, решения 1–3, 6, 15).""" +import os + import pytest from jira_hub.core import api, cli, db, queue, release, settings @@ -181,9 +183,43 @@ def test_migrate_layout_failure_is_warning(tmp_path, monkeypatch, caplog): root = tmp_path / "release" root.mkdir() (root / "agent.py").write_text("AGENT_VERSION = 14\n", encoding="utf-8") + real = os.replace monkeypatch.setattr("os.replace", lambda *a, **k: (_ for _ in ()).throw(OSError(13, "нет прав"))) release.migrate_layout(root) # без исключения assert any("release" in r.getMessage() for r in caplog.records) + assert (root / "agent.py").exists() + monkeypatch.setattr("os.replace", real) + + +def test_migrate_layout_resumes_after_partial_failure(tmp_path, monkeypatch): + root = tmp_path / "release" + root.mkdir() + (root / "agent.py").write_text("AGENT_VERSION = 14\n", encoding="utf-8") + release.publish(root, root / "agent.py") + real, calls = os.replace, [] + + def flaky(a, b, *x, **k): + calls.append(a) + if len(calls) == 2: + raise OSError(5, "сбой") + return real(a, b, *x, **k) + + monkeypatch.setattr("os.replace", flaky) + release.migrate_layout(root) + assert (root / "jira" / "agent.py").exists() and not (root / "jira" / "manifest.json").exists() + assert (root / "manifest.json").exists() and not (root / "agent.py").exists() + monkeypatch.setattr("os.replace", real) + release.migrate_layout(root) # повторный старт доделывает + assert release.current(root / "jira")["version"] == 14 + assert not (root / "manifest.json").exists() and not (root / "agent.py").exists() + + +def test_release_dirs_are_private(tmp_path): + root = tmp_path / "release" + root.mkdir(mode=0o755) + (root / "jira").mkdir(mode=0o755) + api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + assert root.stat().st_mode & 0o777 == 0o700 and (root / "jira").stat().st_mode & 0o777 == 0o700 def test_publish_unknown_module(tmp_path, monkeypatch, capsys):