From eb66c2144a09fdc11980d19bf991a10d4998bb32 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 22:09:28 +0400 Subject: [PATCH] =?UTF-8?q?feat(hub):=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20=E2=80=94=20rel?= =?UTF-8?q?ease.py=20=D0=B8=20jira-hub=20agent=20publish=20(#22)?= 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/cli.py | 17 ++++++- src/jira_hub/paths.py | 4 ++ src/jira_hub/release.py | 104 ++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 12 +++++ tests/test_release.py | 103 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 src/jira_hub/release.py create mode 100644 tests/test_release.py diff --git a/src/jira_hub/cli.py b/src/jira_hub/cli.py index 7a51962..ce90d04 100644 --- a/src/jira_hub/cli.py +++ b/src/jira_hub/cli.py @@ -1,4 +1,5 @@ -"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens.""" +"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens, +agent publish (версия агента для самообновления).""" import argparse import json import sys @@ -19,8 +20,22 @@ def main(argv=None) -> int: p.add_argument("folder") sub.add_parser("mcp", help="MCP-сервер по stdio") sub.add_parser("tokens", help="показать токены агента и клиента") + 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("path") args = ap.parse_args(argv) + if args.cmd == "agent": + from . import release + try: + m = release.publish(paths.release_dir(), args.path) + except release.PublishError as e: + print(e, file=sys.stderr) + return 1 + print(f"опубликована v{m['version']}, sha256 {m['sha256'][:12]}…, {m['size']} байт") + return 0 + if args.cmd == "serve": from . import api hub = api.Hub(paths.db_path()) diff --git a/src/jira_hub/paths.py b/src/jira_hub/paths.py index 159f9bc..b68ff1d 100644 --- a/src/jira_hub/paths.py +++ b/src/jira_hub/paths.py @@ -18,6 +18,10 @@ def backups_dir() -> Path: return home() / "backups" +def release_dir() -> Path: + return home() / "release" + + def secure_dir(path: Path) -> None: """Создать каталог и закрыть его владельцу (0700): в базе и копиях лежат токены (M-7). Сбой chmod (каталог чужой) — предупреждение, хаб продолжает работу.""" diff --git a/src/jira_hub/release.py b/src/jira_hub/release.py new file mode 100644 index 0000000..8ce1569 --- /dev/null +++ b/src/jira_hub/release.py @@ -0,0 +1,104 @@ +"""Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД. +Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5.""" +import hashlib +import json +import logging +import os +import re +from datetime import datetime, timezone +from pathlib import Path + +from .paths import secure_dir + +log = logging.getLogger(__name__) + +MAX_SIZE = 1024 * 1024 +VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M) + + +class PublishError(Exception): + pass + + +def parse_version(text: str) -> int: + found = VERSION_RE.findall(text) + if len(found) != 1: + raise PublishError(f"в файле должна быть ровно одна строка AGENT_VERSION = <целое>, найдено {len(found)}") + return int(found[0]) + + +def _write_atomic(path: Path, data: bytes) -> None: + tmp = path.with_name("." + path.name + ".tmp") + tmp.write_bytes(data) + os.replace(tmp, path) + + +def publish(release_dir, source) -> dict: + """Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной).""" + src = Path(source) + try: + data = src.read_bytes() + except OSError as e: + raise PublishError(f"файл не прочитан {src}: {e}") from e + if len(data) > MAX_SIZE: + raise PublishError(f"файл больше 1 МБ ({len(data)} байт)") + try: + text = data.decode("utf-8") + except UnicodeDecodeError as e: + raise PublishError("файл не в UTF-8") from e + version = parse_version(text) + rd = Path(release_dir) + prev = current(rd) + if prev and version <= prev["version"]: + raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше") + secure_dir(rd) + manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data), + "published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()} + _write_atomic(rd / "agent.py", data) + _write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8")) + return manifest + + +def current(release_dir) -> dict | None: + """Опубликованная версия по манифесту; нет или битый манифест — None (агенту нечего обновлять).""" + path = Path(release_dir) / "manifest.json" + if not path.exists(): + return None + try: + m = json.loads(path.read_text(encoding="utf-8")) + return {"version": int(m["version"]), "sha256": str(m["sha256"]), "published_at": m.get("published_at")} + except (OSError, ValueError, KeyError, TypeError) as e: + log.warning("release: манифест %s не читается (%s) — публикации нет", path, e) + return None + + +def load(release_dir) -> dict | None: + """Файл агента для GET /agent/release; sha256 пересчитывается — несовпадение с манифестом = публикации нет.""" + m = current(release_dir) + if m is None: + return None + try: + data = (Path(release_dir) / "agent.py").read_bytes() + except OSError as e: + log.error("release: agent.py не прочитан: %s", e) + return None + if hashlib.sha256(data).hexdigest() != m["sha256"]: + log.error("release: agent.py не сходится с манифестом по sha256 — публикации нет") + return None + return {"version": m["version"], "sha256": m["sha256"], "content": data.decode("utf-8")} + + +def hint(rel, agent, update_state, launcher_version) -> str | None: + """Подсказка для agent_status (design решение 5).""" + try: + agent_version = int(agent.get("agent_version") or 0) + except (TypeError, ValueError): + agent_version = 0 + if rel and agent.get("online") and agent_version and rel["version"] > agent_version and launcher_version is None: + return "агент запущен без run.py — обновится только вручную" + us = update_state or {} + if us.get("state") == "rolled_back": + return f"откат с v{us.get('to_version')} на v{us.get('from_version')}: {us.get('error') or '—'}" + if us.get("state") == "rejected": + return f"v{us.get('to_version')} отвергнута: {us.get('error') or '—'}" + return None diff --git a/tests/test_cli.py b/tests/test_cli.py index d1733a9..ae2e938 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,3 +23,15 @@ def test_init_import_tokens(tmp_path, monkeypatch, capsys): assert cli.main(["tokens"]) == 0 assert settings.tokens(conn)["client"] in capsys.readouterr().out conn.close() + + +def test_agent_publish(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + src = tmp_path / "agent.py" + src.write_text("AGENT_VERSION = 6\n", encoding="utf-8") + assert cli.main(["agent", "publish", str(src)]) == 0 + out = capsys.readouterr().out + assert out.startswith("опубликована v6, sha256 ") and "…, 18 байт" in out + assert (paths.release_dir() / "manifest.json").exists() + assert cli.main(["agent", "publish", str(src)]) == 1 + assert "опубликована v6, новая должна быть больше" in capsys.readouterr().err diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..041445e --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,103 @@ +import json +import os +import stat + +import pytest + +from jira_hub import release + + +def agent_file(tmp_path, version, body="print('x')\n", name="agent.py"): + p = tmp_path / name + p.write_text(f'#!/usr/bin/env python3\n"""агент"""\nAGENT_VERSION = {version}\n{body}', encoding="utf-8") + return p + + +def test_publish_writes_file_and_manifest(tmp_path): + rd = tmp_path / "release" + src = agent_file(tmp_path, 6) + m = release.publish(rd, src) + assert m["version"] == 6 and m["size"] == src.stat().st_size and len(m["sha256"]) == 64 + assert (rd / "agent.py").read_bytes() == src.read_bytes() + assert json.loads((rd / "manifest.json").read_text(encoding="utf-8")) == m + assert stat.S_IMODE(os.stat(rd).st_mode) == 0o700 + assert release.current(rd) == {"version": 6, "sha256": m["sha256"], "published_at": m["published_at"]} + loaded = release.load(rd) + assert loaded == {"version": 6, "sha256": m["sha256"], "content": src.read_text(encoding="utf-8")} + assert not [p.name for p in rd.iterdir() if p.name.startswith(".")] # временных файлов не осталось + + +def test_publish_only_up(tmp_path): + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 6)) + before = (rd / "manifest.json").read_bytes() + with pytest.raises(release.PublishError, match="опубликована v6, новая должна быть больше"): + release.publish(rd, agent_file(tmp_path, 6, body="print('y')\n")) + with pytest.raises(release.PublishError, match="опубликована v6"): + release.publish(rd, agent_file(tmp_path, 5)) + assert (rd / "manifest.json").read_bytes() == before + assert release.publish(rd, agent_file(tmp_path, 7))["version"] == 7 + + +@pytest.mark.parametrize("text", ["print(1)\n", "AGENT_VERSION = 5\nAGENT_VERSION = 6\n", "AGENT_VERSION = 'v5'\n", + " AGENT_VERSION = 5\n"]) +def test_version_line_required_once(tmp_path, text): + p = tmp_path / "a.py" + p.write_text(text, encoding="utf-8") + with pytest.raises(release.PublishError, match="AGENT_VERSION"): + release.publish(tmp_path / "release", p) + assert not (tmp_path / "release" / "manifest.json").exists() + + +def test_publish_rejects_non_utf8_and_big(tmp_path): + p = tmp_path / "a.py" + p.write_bytes(b"AGENT_VERSION = 5\n\xff\xfe") + with pytest.raises(release.PublishError, match="UTF-8"): + release.publish(tmp_path / "release", p) + p.write_bytes(b"AGENT_VERSION = 5\n" + b"#" * (1024 * 1024)) + with pytest.raises(release.PublishError, match="1 МБ"): + release.publish(tmp_path / "release", p) + with pytest.raises(release.PublishError, match="не прочитан"): + release.publish(tmp_path / "release", tmp_path / "нет.py") + + +def test_current_none_without_release(tmp_path): + assert release.current(tmp_path / "release") is None + assert release.load(tmp_path / "release") is None + + +def test_current_broken_manifest(tmp_path): + rd = tmp_path / "release" + rd.mkdir() + (rd / "manifest.json").write_text('{"version": 6, "sha', encoding="utf-8") + assert release.current(rd) is None + (rd / "manifest.json").write_text('{"version": "шесть", "sha256": "x"}', encoding="utf-8") + assert release.current(rd) is None + + +def test_load_rejects_sha_mismatch(tmp_path): + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 6)) + (rd / "agent.py").write_text("AGENT_VERSION = 6\nподменён\n", encoding="utf-8") + assert release.current(rd)["version"] == 6 + assert release.load(rd) is None + + +def test_hint(): + rel = {"version": 6, "sha256": "s", "published_at": "t"} + online5 = {"agent_version": "5", "online": True} + assert release.hint(rel, online5, None, None) == "агент запущен без run.py — обновится только вручную" + assert release.hint(rel, online5, None, 1) is None + assert release.hint(rel, {"agent_version": "5", "online": False}, None, None) is None + assert release.hint(rel, {"agent_version": "6", "online": True}, None, None) is None + assert release.hint(None, online5, None, None) is None + rb = {"state": "rolled_back", "from_version": 6, "to_version": 7, "error": "код 1 через 3 с"} + assert release.hint(rel, {"agent_version": "6", "online": True}, rb, 1) == "откат с v7 на v6: код 1 через 3 с" + rj = {"state": "rejected", "from_version": 5, "to_version": 6, "error": "sha256 не сходится"} + assert release.hint(rel, online5, rj, 1) == "v6 отвергнута: sha256 не сходится" + assert release.hint(rel, online5, {"state": "ok", "from_version": 5, "to_version": 6}, 1) is None + + +def test_hint_none_when_no_agent(): + rel = {"version": 6, "sha256": "s", "published_at": "t"} + assert release.hint(rel, {"agent_version": None, "online": False}, None, None) is None