fix(release): публикация с fsync и откатом через agent.py.prev, проверка манифеста (M-1, M-3, M-5, M-6, M-7 #25) (#32)

Сбой записи — «не удалось записать публикацию: …», код 1, прежняя версия остаётся; манифест годен при целой version ≥ 1 и sha256 из 64 hex; предупреждение о негодном — одно на ошибку; не-UTF-8 agent.py — 404 (решения 1–6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 10:31:22 +04:00
parent ea37d54946
commit f977d9bd34
6 changed files with 299 additions and 14 deletions

View File

@ -15,7 +15,7 @@
| `backup` | копии файла базы, ротация | `paths` | | `backup` | копии файла базы, ротация | `paths` |
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` | | `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` | | `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`) | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
| `mcp_server` | инструменты MCP по stdio | `client` | | `mcp_server` | инструменты MCP по stdio | `client` |
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | | `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |

View File

@ -111,6 +111,10 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
`agent_status` (MCP) покажет «обновление: ok v<старая> → v<новая>». Если новая версия упала или не вышла на связь, `agent_status` (MCP) покажет «обновление: ok v<старая> → v<новая>». Если новая версия упала или не вышла на связь,
запускатель вернёт прежнюю — `agent_status` покажет «откат с v<N> …»; исправление публикуется следующим номером. запускатель вернёт прежнюю — `agent_status` покажет «откат с v<N> …»; исправление публикуется следующим номером.
Снять публикацию нельзя: откат «назад» — тот же старый код с новым номером. Снять публикацию нельзя: откат «назад» — тот же старый код с новым номером.
Если записать публикацию не удалось (диск полон, нет прав), команда печатает «не удалось записать публикацию: …» и
выходит с кодом 1; опубликованной остаётся прежняя версия, временных файлов в `release/` нет. Манифест, который хаб
не может разобрать (`version` не целое, `sha256` не 64 hex-символа), означает «публикации нет» — одно предупреждение в
логе хаба на каждую новую ошибку.
## HTTP API ## HTTP API

View File

@ -1,10 +1,11 @@
"""Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД. """Опубликованная версия агента для самообновления: <данные>/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 hashlib
import json import json
import logging import logging
import os import os
import re import re
import threading
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -15,6 +16,11 @@ log = logging.getLogger(__name__)
MAX_SIZE = 1024 * 1024 MAX_SIZE = 1024 * 1024
VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M) 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): class PublishError(Exception):
pass pass
@ -28,13 +34,27 @@ def parse_version(text: str) -> int:
def _write_atomic(path: Path, data: bytes) -> None: def _write_atomic(path: Path, data: bytes) -> None:
"""Файл целиком или никак: .<имя>.tmp в том же каталоге, write + flush + fsync, переименование (решение 1).
При OSError временный файл удаляется (ошибка удаления не важна), исключение пробрасывается."""
tmp = path.with_name("." + path.name + ".tmp") tmp = path.with_name("." + path.name + ".tmp")
tmp.write_bytes(data) try:
os.replace(tmp, path) 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: def publish(release_dir, source) -> dict:
"""Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной).""" """Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной).
Прежний agent.py на время записи — agent.py.prev; сбой записи (OSError) возвращает его на место (решение 2):
опубликованной остаётся прежняя версия, наружу — только PublishError."""
src = Path(source) src = Path(source)
try: try:
data = src.read_bytes() data = src.read_bytes()
@ -51,25 +71,79 @@ def publish(release_dir, source) -> dict:
prev = current(rd) prev = current(rd)
if prev and version <= prev["version"]: if prev and version <= prev["version"]:
raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше") raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше")
secure_dir(rd)
manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data), manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data),
"published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()} "published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()}
_write_atomic(rd / "agent.py", data) agent, prev_file = rd / "agent.py", rd / "agent.py.prev"
_write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8")) 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 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: def current(release_dir) -> dict | None:
"""Опубликованная версия по манифесту; нет или битый манифест — None (агенту нечего обновлять).""" """Опубликованная версия по манифесту; нет, битый или негодный манифест — None (агенту нечего обновлять)."""
path = Path(release_dir) / "manifest.json" path = Path(release_dir) / "manifest.json"
try: try:
m = json.loads(path.read_text(encoding="utf-8")) 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): except (FileNotFoundError, NotADirectoryError):
_note_manifest(None, path)
return None return None
except (OSError, ValueError, KeyError, TypeError) as e: # нет прав, битый JSON: публикации нет, мост работает except (OSError, ValueError) as e: # нет прав, битый JSON: публикации нет, мост работает
log.warning("release: манифест %s не читается (%s) — публикации нет", path, e) _note_manifest(f"не читается ({e})", path)
return None 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: def load(release_dir) -> dict | None:
@ -85,7 +159,12 @@ def load(release_dir) -> dict | None:
if hashlib.sha256(data).hexdigest() != m["sha256"]: if hashlib.sha256(data).hexdigest() != m["sha256"]:
log.error("release: agent.py не сходится с манифестом по sha256 — публикации нет") log.error("release: agent.py не сходится с манифестом по sha256 — публикации нет")
return None 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: def hint(rel, agent, update_state, launcher_version) -> str | None:

View File

@ -453,3 +453,34 @@ def test_claim_survives_unreadable_release(hub):
assert b["id"] assert b["id"]
finally: finally:
os.chmod(rd, 0o700) 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"

View File

@ -1,7 +1,8 @@
import json import json
import os
from pathlib import Path 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" 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 (paths.release_dir() / "manifest.json").exists()
assert cli.main(["agent", "publish", str(src)]) == 1 assert cli.main(["agent", "publish", str(src)]) == 1
assert "опубликована v6, новая должна быть больше" in capsys.readouterr().err 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"]

View File

@ -1,6 +1,8 @@
import hashlib
import json import json
import os import os
import stat import stat
from pathlib import Path
import pytest import pytest
@ -101,3 +103,147 @@ def test_hint():
def test_hint_none_when_no_agent(): def test_hint_none_when_no_agent():
rel = {"version": 6, "sha256": "s", "published_at": "t"} rel = {"version": 6, "sha256": "s", "published_at": "t"}
assert release.hint(rel, {"agent_version": None, "online": False}, None, None) is None 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