Релиз: повторяемая миграция раскладки, права 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 «Хранилище файлов вложений»)
|
SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений»)
|
||||||
|
|
||||||
|
|
||||||
|
def _no_update() -> dict:
|
||||||
|
return {"update_state": None, "launcher_version": None, "reported": False}
|
||||||
|
|
||||||
|
|
||||||
class Hub:
|
class Hub:
|
||||||
def __init__(self, db_path, backups_dir=None, release_root=None, web_dir=None, files_dir=None):
|
def __init__(self, db_path, backups_dir=None, release_root=None, web_dir=None, files_dir=None):
|
||||||
self.db_path = Path(db_path)
|
self.db_path = Path(db_path)
|
||||||
@ -44,11 +48,15 @@ class Hub:
|
|||||||
self.module_state: dict[str, dict] = {} # память модулей между тиками (имя модуля → словарь)
|
self.module_state: dict[str, dict] = {} # память модулей между тиками (имя модуля → словарь)
|
||||||
# последнее из claim по каждому агенту; в памяти (R-3). reported — был ли claim с запуска хаба: до него
|
# последнее из claim по каждому агенту; в памяти (R-3). reported — был ли claim с запуска хаба: до него
|
||||||
# launcher_version неизвестен, а не null (hub-hygiene-d, решение 7)
|
# 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
|
self.claim_wait = 30
|
||||||
secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии
|
secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии
|
||||||
secure_dir(self.files_dir) # files/ — 0700 при каждом старте, как backups/
|
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()
|
conn = self.conn()
|
||||||
settings.ensure_defaults(conn)
|
settings.ensure_defaults(conn)
|
||||||
conn.close()
|
conn.close()
|
||||||
@ -89,14 +97,10 @@ class Hub:
|
|||||||
log.info("уборка файлов: удалено %d", len(removed))
|
log.info("уборка файлов: удалено %d", len(removed))
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _no_update() -> dict:
|
|
||||||
return {"update_state": None, "launcher_version": None, "reported": False}
|
|
||||||
|
|
||||||
def note_agent_update(self, agent, body):
|
def note_agent_update(self, agent, body):
|
||||||
"""update_state и launcher_version из claim агента: хранятся в памяти, агент шлёт их в каждом claim (R-3).
|
"""update_state и launcher_version из claim агента: хранятся в памяти, агент шлёт их в каждом claim (R-3).
|
||||||
Значение неверного типа игнорируется — claim из-за него не отвергается; сам claim отмечается (reported)."""
|
Значение неверного типа игнорируется — 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
|
new["reported"] = True
|
||||||
us = body.get("update_state")
|
us = body.get("update_state")
|
||||||
if us is None or isinstance(us, dict):
|
if us is None or isinstance(us, dict):
|
||||||
@ -130,7 +134,7 @@ def h_claim(hub, conn, m, q, body):
|
|||||||
break
|
break
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
try: # команда уже выдана: публикация не должна превращать ответ в 500 (I-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
|
except Exception: # noqa: BLE001
|
||||||
log.exception("release: чтение публикации при claim")
|
log.exception("release: чтение публикации при claim")
|
||||||
rel = None
|
rel = None
|
||||||
@ -139,7 +143,7 @@ def h_claim(hub, conn, m, q, body):
|
|||||||
|
|
||||||
|
|
||||||
def h_agent_release(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:
|
if rel is None:
|
||||||
raise ApiError(404, "no_release", "версия агента не опубликована")
|
raise ApiError(404, "no_release", "версия агента не опубликована")
|
||||||
return 200, rel
|
return 200, rel
|
||||||
@ -295,8 +299,8 @@ def h_command_cancel(hub, conn, m, q, body):
|
|||||||
|
|
||||||
def _agent_view(hub, conn, name) -> dict:
|
def _agent_view(hub, conn, name) -> dict:
|
||||||
view = queue.agent_view(conn, name)
|
view = queue.agent_view(conn, name)
|
||||||
rel = release.current(release.module_dir(hub.release_root, name))
|
rel = release.current(hub.release_root / name)
|
||||||
au = hub.agent_update.get(name) or hub._no_update()
|
au = hub.agent_update.get(name) or _no_update()
|
||||||
us, lv, reported = au["update_state"], au["launcher_version"], au["reported"]
|
us, lv, reported = au["update_state"], au["launcher_version"], au["reported"]
|
||||||
view.update(release=rel, update_state=us, launcher_version=lv, agent_reported=reported,
|
view.update(release=rel, update_state=us, launcher_version=lv, agent_reported=reported,
|
||||||
update_hint=release.hint(rel, view, us, lv, 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):
|
def h_agent_get(hub, conn, m, q, body):
|
||||||
"""Статус агента модуля: ?module=<имя>, по умолчанию jira; неизвестный или без агента — 404."""
|
"""Статус агента модуля: ?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()]:
|
if name not in [a.name for a in registry.agent_modules()]:
|
||||||
raise ApiError(404, "not_found", f"module {name}")
|
raise ApiError(404, "not_found", f"module {name}")
|
||||||
return 200, _agent_view(hub, conn, name)
|
return 200, _agent_view(hub, conn, name)
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from . import db, paths, registry, settings
|
from . import db, paths, registry, settings
|
||||||
|
from .paths import secure_dir
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
def main(argv=None) -> int:
|
||||||
@ -26,7 +27,7 @@ def main(argv=None) -> int:
|
|||||||
p = sub.add_parser("agent", help="версия агента для самообновления")
|
p = sub.add_parser("agent", help="версия агента для самообновления")
|
||||||
agent_sub = p.add_subparsers(dest="agent_cmd", required=True)
|
agent_sub = p.add_subparsers(dest="agent_cmd", required=True)
|
||||||
p = agent_sub.add_parser("publish", help="опубликовать agent.py (версия только вверх)")
|
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")
|
p.add_argument("path")
|
||||||
args = ap.parse_args(argv)
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
@ -37,6 +38,7 @@ def main(argv=None) -> int:
|
|||||||
print(f"неизвестный модуль {args.module}; модули с агентами: {', '.join(names)}", file=sys.stderr)
|
print(f"неизвестный модуль {args.module}; модули с агентами: {', '.join(names)}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
try:
|
try:
|
||||||
|
secure_dir(paths.release_root())
|
||||||
m = release.publish(release.module_dir(paths.release_root(), args.module), args.path)
|
m = release.publish(release.module_dir(paths.release_root(), args.module), args.path)
|
||||||
except release.PublishError as e:
|
except release.PublishError as e:
|
||||||
print(e, file=sys.stderr)
|
print(e, file=sys.stderr)
|
||||||
|
|||||||
@ -16,6 +16,9 @@ def modules() -> list[Module]:
|
|||||||
return _cache
|
return _cache
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_AGENT = "jira" # агент по умолчанию: GET /agent без module, agent publish без --module
|
||||||
|
|
||||||
|
|
||||||
def agent_modules() -> list[Module]:
|
def agent_modules() -> list[Module]:
|
||||||
"""Модули с командами: у каждого свой агент (имя агента = имя модуля), в порядке реестра."""
|
"""Модули с командами: у каждого свой агент (имя агента = имя модуля), в порядке реестра."""
|
||||||
return [m for m in modules() if m.commands]
|
return [m for m in modules() if m.commands]
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .paths import secure_dir
|
from .paths import secure_dir
|
||||||
|
from .registry import DEFAULT_AGENT
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -36,15 +37,20 @@ def module_dir(root, module) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def migrate_layout(root) -> None:
|
def migrate_layout(root) -> None:
|
||||||
"""Раскладка до модулей: agent.py и manifest.json лежали прямо в root. Если root/jira/ нет — переносим их в
|
"""Раскладка до модулей: agent.py и manifest.json лежали прямо в root. Пока в root/jira/ нет manifest.json —
|
||||||
root/jira/ (манифест последним: по нему версия считается опубликованной). Сбой — предупреждение в лог, хаб стартует."""
|
переносим их в root/jira/ покадрово (манифест последним: по нему версия считается опубликованной); файл, уже
|
||||||
|
лежащий в приёмнике, не перезаписываем, а корневой убираем. После частичного сбоя следующий старт доделывает перенос.
|
||||||
|
Сбой — предупреждение в лог, хаб стартует."""
|
||||||
root = Path(root)
|
root = Path(root)
|
||||||
try:
|
try:
|
||||||
old = [root / n for n in ("agent.py", "manifest.json") if (root / n).exists()]
|
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
|
return
|
||||||
dest = module_dir(root, "jira")
|
dest = module_dir(root, DEFAULT_AGENT)
|
||||||
for f in old:
|
for f in old:
|
||||||
|
if (dest / f.name).exists():
|
||||||
|
f.unlink()
|
||||||
|
else:
|
||||||
os.replace(f, dest / f.name)
|
os.replace(f, dest / f.name)
|
||||||
log.info("release: публикация агента перенесена в %s", dest)
|
log.info("release: публикация агента перенесена в %s", dest)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
"""Агенты модулей: токен определяет модуль, миграция v7 сохраняет агента jira (wiki-module, решения 1–3, 6, 15)."""
|
"""Агенты модулей: токен определяет модуль, миграция v7 сохраняет агента jira (wiki-module, решения 1–3, 6, 15)."""
|
||||||
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from jira_hub.core import api, cli, db, queue, release, settings
|
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 = tmp_path / "release"
|
||||||
root.mkdir()
|
root.mkdir()
|
||||||
(root / "agent.py").write_text("AGENT_VERSION = 14\n", encoding="utf-8")
|
(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, "нет прав")))
|
monkeypatch.setattr("os.replace", lambda *a, **k: (_ for _ in ()).throw(OSError(13, "нет прав")))
|
||||||
release.migrate_layout(root) # без исключения
|
release.migrate_layout(root) # без исключения
|
||||||
assert any("release" in r.getMessage() for r in caplog.records)
|
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):
|
def test_publish_unknown_module(tmp_path, monkeypatch, capsys):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user