diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b22bae3..d5bee2c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -15,7 +15,7 @@ | `backup` | копии файла базы, ротация | `paths` | | `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` | | `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` | -| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх), `current`, `load` (с проверкой sha256), подсказка `hint` для `agent_status` | `paths` | +| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) | | `mcp_server` | инструменты MCP по stdio | `client` | | `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | diff --git a/README.md b/README.md index 381e66f..0154f7c 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,10 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py `agent_status` (MCP) покажет «обновление: ok v<старая> → v<новая>». Если новая версия упала или не вышла на связь, запускатель вернёт прежнюю — `agent_status` покажет «откат с v …»; исправление публикуется следующим номером. Снять публикацию нельзя: откат «назад» — тот же старый код с новым номером. +Если записать публикацию не удалось (диск полон, нет прав), команда печатает «не удалось записать публикацию: …» и +выходит с кодом 1; опубликованной остаётся прежняя версия, временных файлов в `release/` нет. Манифест, который хаб +не может разобрать (`version` не целое, `sha256` не 64 hex-символа), означает «публикации нет» — одно предупреждение в +логе хаба на каждую новую ошибку. ## HTTP API diff --git a/src/jira_hub/release.py b/src/jira_hub/release.py index a0d093a..1f1c7d6 100644 --- a/src/jira_hub/release.py +++ b/src/jira_hub/release.py @@ -1,10 +1,11 @@ """Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД. -Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5.""" +Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5; hub-hygiene-d решения 1–7.""" import hashlib import json import logging import os import re +import threading from datetime import datetime, timezone from pathlib import Path @@ -15,6 +16,11 @@ log = logging.getLogger(__name__) MAX_SIZE = 1024 * 1024 VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M) +SHA256_RE = re.compile(r"[0-9a-f]{64}") + +_warned: str | None = None # текст последнего предупреждения о манифесте: одно на каждую новую ошибку (решение 5) +_warned_lock = threading.Lock() + class PublishError(Exception): pass @@ -28,13 +34,27 @@ def parse_version(text: str) -> int: def _write_atomic(path: Path, data: bytes) -> None: + """Файл целиком или никак: .<имя>.tmp в том же каталоге, write + flush + fsync, переименование (решение 1). + При OSError временный файл удаляется (ошибка удаления не важна), исключение пробрасывается.""" tmp = path.with_name("." + path.name + ".tmp") - tmp.write_bytes(data) - os.replace(tmp, path) + try: + with open(tmp, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + except OSError: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + raise def publish(release_dir, source) -> dict: - """Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной).""" + """Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной). + Прежний agent.py на время записи — agent.py.prev; сбой записи (OSError) возвращает его на место (решение 2): + опубликованной остаётся прежняя версия, наружу — только PublishError.""" src = Path(source) try: data = src.read_bytes() @@ -51,25 +71,79 @@ def publish(release_dir, source) -> dict: 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")) + agent, prev_file = rd / "agent.py", rd / "agent.py.prev" + moved = False + try: + secure_dir(rd) + if agent.exists(): + os.replace(agent, prev_file) + moved = True + _write_atomic(agent, data) + _write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8")) + except OSError as e: + _undo(agent, prev_file, moved) + raise PublishError(f"не удалось записать публикацию: {e}") from e + try: + prev_file.unlink(missing_ok=True) # и прежний, и оставленный упавшей публикацией (риск в design) + except OSError as e: + log.warning("release: %s не удалён: %s", prev_file, e) return manifest +def _undo(agent: Path, prev_file: Path, moved: bool) -> None: + """Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было — + убрать записанный agent.py. Сбой возврата — в лог: команда всё равно завершается сообщением.""" + try: + if moved: + os.replace(prev_file, agent) + else: + agent.unlink(missing_ok=True) + except OSError as e: + log.error("release: прежняя публикация не восстановлена (%s): %s", prev_file if moved else agent, e) + + +def _manifest_problem(m) -> str | None: + """Почему манифест негоден, или None (решение 4; та же проверка, что у агента v9).""" + if not isinstance(m, dict): + return "не объект" + version = m.get("version") + if not isinstance(version, int) or isinstance(version, bool) or version < 1: + return f"version — не целое ≥ 1: {version!r}" + sha = m.get("sha256") + if not isinstance(sha, str) or not SHA256_RE.fullmatch(sha): + return "sha256 — не 64 символа [0-9a-f]" + return None + + +def _note_manifest(problem: str | None, path: Path) -> None: + """Предупреждение о негодном манифесте — один раз на каждый новый текст; годный или отсутствующий манифест + сбрасывает память (решение 5). Замок: claim и GET /agent читают манифест из разных потоков.""" + global _warned + text = None if problem is None else f"release: манифест {path}: {problem} — публикации нет" + with _warned_lock: + if text is not None and text != _warned: + log.warning("%s", text) + _warned = text + + def current(release_dir) -> dict | None: - """Опубликованная версия по манифесту; нет или битый манифест — None (агенту нечего обновлять).""" + """Опубликованная версия по манифесту; нет, битый или негодный манифест — None (агенту нечего обновлять).""" path = Path(release_dir) / "manifest.json" 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 (FileNotFoundError, NotADirectoryError): + _note_manifest(None, path) return None - except (OSError, ValueError, KeyError, TypeError) as e: # нет прав, битый JSON: публикации нет, мост работает - log.warning("release: манифест %s не читается (%s) — публикации нет", path, e) + except (OSError, ValueError) as e: # нет прав, битый JSON: публикации нет, мост работает + _note_manifest(f"не читается ({e})", path) return None + problem = _manifest_problem(m) + _note_manifest(problem, path) + if problem: + return None + return {"version": m["version"], "sha256": m["sha256"], "published_at": m.get("published_at")} def load(release_dir) -> dict | None: @@ -85,7 +159,12 @@ def load(release_dir) -> dict | 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")} + try: + content = data.decode("utf-8") + except UnicodeDecodeError: # решение 6: 404, а не 500 + log.error("release: agent.py не в UTF-8 — публикации нет") + return None + return {"version": m["version"], "sha256": m["sha256"], "content": content} def hint(rel, agent, update_state, launcher_version) -> str | None: diff --git a/tests/test_api.py b/tests/test_api.py index 4f35df8..5a1b0c6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -453,3 +453,34 @@ def test_claim_survives_unreadable_release(hub): assert b["id"] finally: os.chmod(rd, 0o700) + + +# --- hub-hygiene-d --------------------------------------------------------------------------------------------- + +def test_invalid_manifest_one_warning_for_many_claims(hub, monkeypatch, caplog): + """M-3/M-7 #25, спека «Негодный манифест»: version = 6.9 — публикации нет; 10 claim и GET /agent — одно предупреждение.""" + monkeypatch.setattr(release, "_warned", None) + rd = hub["hub"].release_dir + rd.mkdir() + (rd / "manifest.json").write_text('{"version": 6.9, "sha256": "' + "a" * 64 + '"}', encoding="utf-8") + for _ in range(10): + assert hub["agent"].post("/agent/claim", {"agent_version": 5})["agent_release"] is None + assert hub["client"].get("/agent")["release"] is None + with pytest.raises(HubError) as e: + hub["agent"].get("/agent/release") + assert e.value.status == 404 and e.value.error == "no_release" + assert len([r for r in caplog.records if "манифест" in r.getMessage()]) == 1 + + +def test_agent_release_non_utf8_is_404(hub, tmp_path): + """M-5 #25: agent.py не в UTF-8 при совпавшем sha256 — 404 no_release, а не 500.""" + import hashlib + _publish(hub, tmp_path, 6) + rd = hub["hub"].release_dir + data = b"AGENT_VERSION = 6\n\xff\n" + (rd / "agent.py").write_bytes(data) + m = json.loads((rd / "manifest.json").read_text(encoding="utf-8")) + (rd / "manifest.json").write_text(json.dumps({**m, "sha256": hashlib.sha256(data).hexdigest()}), encoding="utf-8") + with pytest.raises(HubError) as e: + hub["agent"].get("/agent/release") + assert e.value.status == 404 and e.value.error == "no_release" diff --git a/tests/test_cli.py b/tests/test_cli.py index ae2e938..b7c6c42 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,8 @@ import json +import os from pathlib import Path -from jira_hub import cli, db, paths, settings +from jira_hub import cli, db, paths, release, settings FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" @@ -35,3 +36,27 @@ def test_agent_publish(tmp_path, monkeypatch, capsys): assert (paths.release_dir() / "manifest.json").exists() assert cli.main(["agent", "publish", str(src)]) == 1 assert "опубликована v6, новая должна быть больше" in capsys.readouterr().err + + +def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys): + """Диск полон при записи манифеста: сообщение и код 1 без трейсбека, опубликованной остаётся v8 (M-1 #25).""" + monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + src = tmp_path / "agent.py" + src.write_text("AGENT_VERSION = 8\n", encoding="utf-8") + assert cli.main(["agent", "publish", str(src)]) == 0 + capsys.readouterr() + real = os.replace + + def fake(s, d, *a, **kw): + if Path(d).name == "manifest.json": + raise OSError(28, "No space left on device") + return real(s, d, *a, **kw) + + monkeypatch.setattr(os, "replace", fake) + src.write_text("AGENT_VERSION = 9\n", encoding="utf-8") + assert cli.main(["agent", "publish", str(src)]) == 1 + err = capsys.readouterr().err + assert err.startswith("не удалось записать публикацию: ") and "Traceback" not in err + monkeypatch.setattr(os, "replace", real) + assert release.load(paths.release_dir())["version"] == 8 + assert sorted(p.name for p in paths.release_dir().iterdir()) == ["agent.py", "manifest.json"] diff --git a/tests/test_release.py b/tests/test_release.py index 041445e..1d777db 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -1,6 +1,8 @@ +import hashlib import json import os import stat +from pathlib import Path import pytest @@ -101,3 +103,147 @@ def test_hint(): 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 + + +# --- hub-hygiene-d: публикация при сбое записи, проверка манифеста (решения 1–6) --------------------------------- + +def _fail_replace_into(monkeypatch, *names): + """os.replace в файл с одним из имён names падает «диск полон»; остальные переименования работают.""" + real = os.replace + + def fake(src, dst, *a, **kw): + if Path(dst).name in names: + raise OSError(28, "No space left on device") + return real(src, dst, *a, **kw) + + monkeypatch.setattr(release.os, "replace", fake) + + +def _listing(rd): + return {p.name: p.read_bytes() for p in rd.iterdir()} + + +def test_publish_success_leaves_no_prev(tmp_path): + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 5)) + release.publish(rd, agent_file(tmp_path, 6)) + assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"] + assert release.current(rd)["version"] == 6 + + +def test_manifest_write_failure_keeps_previous(tmp_path, monkeypatch): + """M-1 #25, спека «Диск полон при записи манифеста»: прежние v8 agent.py и манифест, ни .tmp, ни agent.py.prev.""" + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 8)) + before = _listing(rd) + _fail_replace_into(monkeypatch, "manifest.json") + with pytest.raises(release.PublishError, match=r"^не удалось записать публикацию: .*No space left on device"): + release.publish(rd, agent_file(tmp_path, 9, body="print('v9')\n")) + monkeypatch.undo() + assert _listing(rd) == before + assert release.load(rd)["version"] == 8 + + +def test_agent_write_failure_keeps_previous(tmp_path, monkeypatch): + """M-1, M-6 #25: сбой fsync при записи agent.py — временный файл удалён, прежний agent.py на месте.""" + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 8)) + before = _listing(rd) + + def no_space(fd): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(release.os, "fsync", no_space) + with pytest.raises(release.PublishError, match="не удалось записать публикацию"): + release.publish(rd, agent_file(tmp_path, 9)) + monkeypatch.undo() + assert _listing(rd) == before + assert release.load(rd)["version"] == 8 + + +def test_first_publish_failure_leaves_nothing(tmp_path, monkeypatch): + """Review Focus 3: прежней публикации нет — после сбоя release/ пуст, а не с agent.py без манифеста.""" + rd = tmp_path / "release" + _fail_replace_into(monkeypatch, "manifest.json") + with pytest.raises(release.PublishError, match="не удалось записать публикацию"): + release.publish(rd, agent_file(tmp_path, 1)) + monkeypatch.undo() + assert list(rd.iterdir()) == [] and release.current(rd) is None + + +def test_stale_prev_removed_by_next_publish(tmp_path): + """Review Focus 1: agent.py.prev, оставленный упавшим процессом, убирает следующая успешная публикация.""" + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 6)) + (rd / "agent.py").rename(rd / "agent.py.prev") # процесс упал между переименованием и записью + assert release.load(rd) is None + release.publish(rd, agent_file(tmp_path, 7)) + assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"] + assert release.load(rd)["version"] == 7 + + +def test_restore_failure_is_logged_not_raised(tmp_path, monkeypatch, caplog): + """Review Focus 2: и возврат agent.py.prev не удался — PublishError с текстом, причина возврата в логе.""" + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 8)) + _fail_replace_into(monkeypatch, "manifest.json") + real = release.os.replace + + def fake(src, dst, *a, **kw): + if Path(src).name == "agent.py.prev": + raise OSError(13, "Permission denied") + return real(src, dst, *a, **kw) + + monkeypatch.setattr(release.os, "replace", fake) + with pytest.raises(release.PublishError, match="не удалось записать публикацию"): + release.publish(rd, agent_file(tmp_path, 9)) + assert "прежняя публикация не восстановлена" in caplog.text + + +@pytest.mark.parametrize("manifest", [ + {"version": 6.9, "sha256": "a" * 64}, {"version": True, "sha256": "a" * 64}, {"version": "7", "sha256": "a" * 64}, + {"version": 0, "sha256": "a" * 64}, {"version": 6, "sha256": "a" * 63}, {"version": 6, "sha256": "A" * 64}, + {"version": 6}, [6, "a" * 64], None, +]) +def test_invalid_manifest_means_no_release(tmp_path, manifest): + """M-7 #25, решение 4: version — целое (не bool) ≥ 1, sha256 — 64 символа [0-9a-f], корень — объект.""" + rd = tmp_path / "release" + rd.mkdir() + (rd / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + assert release.current(rd) is None and release.load(rd) is None + + +def test_invalid_manifest_warns_once_per_new_error(tmp_path, monkeypatch, caplog): + """M-3 #25, решение 5: одно предупреждение на каждый новый текст ошибки; годный манифест сбрасывает память.""" + monkeypatch.setattr(release, "_warned", None) + rd = tmp_path / "release" + rd.mkdir() + manifest = rd / "manifest.json" + manifest.write_text('{"version": 6.9, "sha256": "' + "a" * 64 + '"}', encoding="utf-8") + for _ in range(100): + assert release.current(rd) is None + warnings = lambda: [r for r in caplog.records if "манифест" in r.getMessage()] + assert len(warnings()) == 1 and "6.9" in warnings()[0].getMessage() + manifest.write_text('{"version": 6, "sha256": "short"}', encoding="utf-8") # другая ошибка — ещё одно + release.current(rd) + release.current(rd) + assert len(warnings()) == 2 + manifest.write_text('{"version": 6, "sha256": "' + "a" * 64 + '"}', encoding="utf-8") # годный сбрасывает память + assert release.current(rd)["version"] == 6 + manifest.write_text('{"version": 6, "sha256": "short"}', encoding="utf-8") + release.current(rd) + assert len(warnings()) == 3 + + +def test_load_non_utf8_with_matching_sha_is_none(tmp_path, caplog): + """M-5 #25, решение 6: sha256 сошёлся, но файл не в UTF-8 — публикации нет, ошибка в логе.""" + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 6)) + data = b"AGENT_VERSION = 6\n\xff\xfe\n" + (rd / "agent.py").write_bytes(data) + m = json.loads((rd / "manifest.json").read_text(encoding="utf-8")) + m["sha256"] = hashlib.sha256(data).hexdigest() + (rd / "manifest.json").write_text(json.dumps(m), encoding="utf-8") + assert release.current(rd)["version"] == 6 + assert release.load(rd) is None + assert "agent.py не в UTF-8" in caplog.text