Публикация агента: прерванная прошлая публикация восстанавливается по манифесту (#129, #39 T2.2, M-6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
151a5e6175
commit
5dc96dd43c
@ -99,6 +99,7 @@ def publish(release_dir, source) -> dict:
|
||||
raise PublishError("файл не в UTF-8") from e
|
||||
version = parse_version(text)
|
||||
rd = Path(release_dir)
|
||||
_recover(rd)
|
||||
prev = current(rd)
|
||||
if prev and version <= prev["version"]:
|
||||
raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше")
|
||||
@ -123,6 +124,41 @@ def publish(release_dir, source) -> dict:
|
||||
return manifest
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str | None:
|
||||
try:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def _recover(rd: Path) -> None:
|
||||
"""Привести release/ к опубликованной версии после прерванной публикации (design hub-backend-fixes, решение 9).
|
||||
Сверка с manifest.json по sha256: agent.py.prev совпадает, а agent.py нет или не совпадает — .prev возвращается на
|
||||
место; agent.py совпадает — лишний .prev удаляется; иначе .prev не трогается, только предупреждение. Сбой
|
||||
(OSError) — предупреждение в лог, публикация продолжается."""
|
||||
agent, prev_file = rd / "agent.py", rd / "agent.py.prev"
|
||||
try:
|
||||
if not prev_file.exists():
|
||||
return
|
||||
try:
|
||||
m = json.loads((rd / "manifest.json").read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
m = None
|
||||
sha = m.get("sha256") if _manifest_problem(m) is None else None
|
||||
if sha is None:
|
||||
log.warning("release: найден %s, но манифеста нет или он негоден — не трогаем", prev_file)
|
||||
elif _sha256(agent) == sha:
|
||||
prev_file.unlink()
|
||||
log.warning("release: лишний %s удалён: agent.py сходится с манифестом", prev_file)
|
||||
elif _sha256(prev_file) == sha:
|
||||
os.replace(prev_file, agent)
|
||||
log.warning("release: прерванная публикация: %s возвращён на место agent.py", prev_file)
|
||||
else:
|
||||
log.warning("release: найден %s, не сходится с манифестом — не трогаем", prev_file)
|
||||
except OSError as e:
|
||||
log.warning("release: восстановление после прерванной публикации не удалось: %s", e)
|
||||
|
||||
|
||||
def _undo(agent: Path, prev_file: Path, moved: bool, existed: bool) -> None:
|
||||
"""Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было —
|
||||
убрать записанный agent.py. Прежний agent.py, которого не сдвинули (сбой до переименования), не трогаем.
|
||||
|
||||
@ -298,3 +298,99 @@ def test_invalid_manifest_warning_not_repeated_across_alternating_dirs(tmp_path,
|
||||
assert release.current(bad) is None
|
||||
assert release.current(none) is None
|
||||
assert len([r for r in caplog.records if "манифест" in r.getMessage()]) == 1
|
||||
|
||||
|
||||
# --- пакет C: восстановление прерванной публикации (design hub-backend-fixes, решение 9; #39 T2.2, M-6) ----------
|
||||
|
||||
def _no_space(monkeypatch):
|
||||
def no_space(fd):
|
||||
raise OSError(28, "No space left on device")
|
||||
|
||||
monkeypatch.setattr(release.os, "fsync", no_space)
|
||||
|
||||
|
||||
def test_recover_missing_agent_restores_prev(tmp_path, monkeypatch, caplog):
|
||||
rd = tmp_path / "release"
|
||||
v5 = agent_file(tmp_path, 5).read_bytes()
|
||||
release.publish(rd, agent_file(tmp_path, 5))
|
||||
os.replace(rd / "agent.py", rd / "agent.py.prev") # процесс упал между переименованием и записью
|
||||
_no_space(monkeypatch)
|
||||
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
|
||||
release.publish(rd, agent_file(tmp_path, 6, name="new.py"))
|
||||
monkeypatch.undo()
|
||||
assert (rd / "agent.py").read_bytes() == v5
|
||||
assert not (rd / "agent.py.prev").exists()
|
||||
assert release.load(rd)["version"] == 5
|
||||
assert "agent.py.prev" in caplog.text
|
||||
|
||||
|
||||
def test_recover_partial_agent_keeps_good_prev(tmp_path, monkeypatch):
|
||||
rd = tmp_path / "release"
|
||||
v5 = agent_file(tmp_path, 5).read_bytes()
|
||||
release.publish(rd, agent_file(tmp_path, 5))
|
||||
(rd / "agent.py.prev").write_bytes(v5)
|
||||
(rd / "agent.py").write_bytes("AGENT_VERSION = 6\n# обрыв".encode())
|
||||
_no_space(monkeypatch)
|
||||
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
|
||||
release.publish(rd, agent_file(tmp_path, 6, name="new.py"))
|
||||
monkeypatch.undo()
|
||||
assert (rd / "agent.py").read_bytes() == v5
|
||||
assert not (rd / "agent.py.prev").exists() or (rd / "agent.py.prev").read_bytes() == v5
|
||||
assert release.load(rd)["version"] == 5
|
||||
|
||||
|
||||
def test_recover_success_after_crash(tmp_path):
|
||||
rd = tmp_path / "release"
|
||||
v5 = agent_file(tmp_path, 5).read_bytes()
|
||||
release.publish(rd, agent_file(tmp_path, 5))
|
||||
(rd / "agent.py.prev").write_bytes(v5)
|
||||
(rd / "agent.py").write_bytes("AGENT_VERSION = 6\n# обрыв".encode())
|
||||
v6 = agent_file(tmp_path, 6, name="new.py")
|
||||
release.publish(rd, v6)
|
||||
assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"]
|
||||
assert (rd / "agent.py").read_bytes() == v6.read_bytes()
|
||||
assert release.load(rd)["version"] == 6
|
||||
|
||||
|
||||
def test_recover_leftover_prev_removed(tmp_path, monkeypatch):
|
||||
"""agent.py совпадает с манифестом, рядом лишний .prev: _recover удаляет его, даже если публикация потом падает
|
||||
до переименования agent.py в .prev."""
|
||||
rd = tmp_path / "release"
|
||||
v5 = agent_file(tmp_path, 5).read_bytes()
|
||||
release.publish(rd, agent_file(tmp_path, 5))
|
||||
(rd / "agent.py.prev").write_bytes(v5)
|
||||
_fail_replace_into(monkeypatch, "agent.py.prev")
|
||||
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
|
||||
release.publish(rd, agent_file(tmp_path, 6, name="new.py"))
|
||||
monkeypatch.undo()
|
||||
assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"]
|
||||
assert release.load(rd)["version"] == 5
|
||||
|
||||
|
||||
def test_recover_unknown_state_untouched(tmp_path, caplog):
|
||||
rd = tmp_path / "release"
|
||||
rd.mkdir()
|
||||
(rd / "agent.py.prev").write_bytes(b"AGENT_VERSION = 3\n")
|
||||
release._recover(rd)
|
||||
assert _listing(rd) == {"agent.py.prev": b"AGENT_VERSION = 3\n"}
|
||||
assert "agent.py.prev" in caplog.text
|
||||
assert release.publish(rd, agent_file(tmp_path, 1))["version"] == 1 # первая публикация проходит
|
||||
assert release.load(rd)["version"] == 1
|
||||
|
||||
|
||||
def test_recover_oserror_does_not_stop_publish(tmp_path, monkeypatch, caplog):
|
||||
rd = tmp_path / "release"
|
||||
v5 = agent_file(tmp_path, 5).read_bytes()
|
||||
release.publish(rd, agent_file(tmp_path, 5))
|
||||
(rd / "agent.py.prev").write_bytes(v5)
|
||||
real = Path.unlink
|
||||
|
||||
def fake(self, *a, **kw):
|
||||
if self.name == "agent.py.prev" and not getattr(fake, "done", False):
|
||||
fake.done = True
|
||||
raise OSError(13, "Permission denied")
|
||||
return real(self, *a, **kw)
|
||||
|
||||
monkeypatch.setattr(Path, "unlink", fake)
|
||||
assert release.publish(rd, agent_file(tmp_path, 6, name="new.py"))["version"] == 6
|
||||
assert "Permission denied" in caplog.text
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user