Релиз: повторяемая миграция раскладки, права 0700 на release/ (#91)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 01:08:10 +04:00
parent 3f4c9183b8
commit 7ab031a064
5 changed files with 69 additions and 18 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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]

View File

@ -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)

View File

@ -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):