Релиз: повторяемая миграция раскладки, права 0700 на release/ (#91)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3f4c9183b8
commit
7ab031a064
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user