Миграция v7: токены и статус по агентам модулей (#91)
tokens -> {client, agents: {модуль: токен}}, agent_status по агентам
(ключ agent, last_call_at), настройки агента и маршруты agent-scope по
модулю токена, min_agent_version переехал в модуль jira.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
274e0571eb
commit
43c43ade81
@ -15,6 +15,7 @@ from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
from . import backup, db, files, projects, queue, registry, relay, release, settings
|
||||
from .errors import CommandError, HttpMapped
|
||||
from . import http
|
||||
from .http import ApiError, Raw, int_group, int_param, mapped, not_found
|
||||
from .paths import backups_dir as default_backups_dir
|
||||
from .paths import secure_dir
|
||||
@ -117,8 +118,9 @@ def h_claim(hub, conn, m, q, body):
|
||||
hub.note_agent_update(body)
|
||||
deadline = time.monotonic() + hub.claim_wait
|
||||
while True:
|
||||
cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"),
|
||||
backup=hub.make_backup, files_dir=hub.files_dir)
|
||||
cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"),
|
||||
body.get("last_call_at", body.get("last_jira_call_at")),
|
||||
backup=hub.make_backup, files_dir=hub.files_dir, agent=http.agent_of(q))
|
||||
if cmd or time.monotonic() >= deadline:
|
||||
break
|
||||
time.sleep(1)
|
||||
@ -148,7 +150,7 @@ def h_agent_file(hub, conn, m, q, body):
|
||||
|
||||
|
||||
def h_agent_settings(hub, conn, m, q, body):
|
||||
return 200, settings.for_agent(conn)
|
||||
return 200, settings.for_agent(conn, http.agent_of(q))
|
||||
|
||||
|
||||
def h_agent_command(hub, conn, m, q, body):
|
||||
@ -384,7 +386,9 @@ CORE_ROUTES = [
|
||||
# маршрут ядра и модуля не делят метод + шаблон: пересечение — ошибка старта; ROUTES собирается после проверки
|
||||
registry.check(registry.modules(), CORE_ROUTES, settings.CORE_DEFAULTS)
|
||||
ROUTES = CORE_ROUTES + [r for mod in registry.modules() for r in mod.routes]
|
||||
_COMPILED = [(m, re.compile(p), fn, scope) for m, p, fn, scope in ROUTES]
|
||||
# (метод, regex, обработчик, scope, модуль-владелец маршрута; None — маршрут ядра)
|
||||
_COMPILED = [(m, re.compile(p), fn, scope, None) for m, p, fn, scope in CORE_ROUTES] + \
|
||||
[(m, re.compile(p), fn, scope, mod.name) for mod in registry.modules() for m, p, fn, scope in mod.routes]
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
@ -403,16 +407,27 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args): # тише стандартного: без адреса клиента
|
||||
print(f"[{self.log_date_time_string()}] {fmt % args}")
|
||||
|
||||
def _auth(self, scope):
|
||||
def _auth(self, scope, owner=None) -> str | None:
|
||||
"""Проверить токен для scope маршрута. Для agent возвращает модуль агента по токену; маршрут модуля пускает
|
||||
только агента этого модуля. Для client возвращает None."""
|
||||
token = (self.headers.get("Authorization") or "").removeprefix("Bearer ").strip()
|
||||
conn = self.hub.conn()
|
||||
try:
|
||||
tokens = settings.tokens(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
expected = tokens["agent" if scope == "agent" else "client"]
|
||||
if not token or not hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8")):
|
||||
raw = token.encode("utf-8")
|
||||
if scope != "agent":
|
||||
if not token or not hmac.compare_digest(raw, tokens["client"].encode("utf-8")):
|
||||
raise ApiError(401, "unauthorized", "неверный токен для этого маршрута")
|
||||
return None
|
||||
found = None
|
||||
for name, expected in tokens["agents"].items(): # сравниваем со всеми: время не зависит от совпавшего
|
||||
if token and hmac.compare_digest(raw, expected.encode("utf-8")):
|
||||
found = name
|
||||
if found is None or (owner is not None and owner != found):
|
||||
raise ApiError(401, "unauthorized", "неверный токен для этого маршрута")
|
||||
return found
|
||||
|
||||
def _dispatch(self, method):
|
||||
url = urlparse(self.path)
|
||||
@ -425,14 +440,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if length > MAX_BODY:
|
||||
raise ApiError(413, "too_large", f"тело больше {MAX_BODY} байт")
|
||||
raw = self.rfile.read(length) if length else b""
|
||||
for route_method, rx, fn, scope in _COMPILED:
|
||||
for route_method, rx, fn, scope, owner in _COMPILED:
|
||||
if route_method != method:
|
||||
continue
|
||||
match = rx.match(url.path)
|
||||
if not match:
|
||||
continue
|
||||
if scope is not None:
|
||||
self._auth(scope)
|
||||
agent = self._auth(scope, owner)
|
||||
if agent is not None:
|
||||
query[http.AGENT] = agent # перезаписывает значение из адреса
|
||||
try:
|
||||
body = json.loads(raw.decode("utf-8")) if raw else {}
|
||||
except ValueError as e: # битый JSON или не UTF-8
|
||||
|
||||
@ -22,7 +22,7 @@ def main(argv=None) -> int:
|
||||
for m in registry.modules():
|
||||
if m.cli_register:
|
||||
m.cli_register(sub)
|
||||
sub.add_parser("tokens", help="показать токены агента и клиента")
|
||||
sub.add_parser("tokens", help="показать токены клиента и агентов")
|
||||
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 (версия только вверх)")
|
||||
@ -84,7 +84,10 @@ def main(argv=None) -> int:
|
||||
|
||||
def _print_tokens(conn) -> None:
|
||||
tok = settings.tokens(conn)
|
||||
print(f"агентский токен: {tok['agent']}\nклиентский токен: {tok['client']}\nбаза: {paths.db_path()}")
|
||||
print(f"клиентский токен: {tok['client']}")
|
||||
for m in registry.agent_modules():
|
||||
print(f"агент {m.name}: {tok['agents'][m.name]}")
|
||||
print(f"база: {paths.db_path()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -8,7 +8,7 @@ from pathlib import Path
|
||||
|
||||
from . import backup, paths
|
||||
|
||||
SCHEMA_VERSION = 6
|
||||
SCHEMA_VERSION = 7
|
||||
|
||||
CORE_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS setting (
|
||||
@ -50,12 +50,12 @@ CREATE TABLE IF NOT EXISTS project (
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS agent_status (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
agent TEXT PRIMARY KEY,
|
||||
last_seen_at TEXT,
|
||||
agent_version TEXT,
|
||||
settings_version INTEGER,
|
||||
current_command_id INTEGER,
|
||||
last_jira_call_at TEXT
|
||||
last_call_at TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
@ -69,6 +69,39 @@ def _fill_comment_utc(conn) -> None:
|
||||
history.fill_comment_utc(conn)
|
||||
|
||||
|
||||
def _tokens_v7(conn) -> None:
|
||||
"""tokens {agent, client} → {client, agents: {jira: agent}}: прежний агентский токен остаётся токеном агента jira
|
||||
(jira-agent v14 работает без обновления). Имя jira — данные истории, как в _migrate_projects."""
|
||||
row = conn.execute("SELECT value FROM setting WHERE key = 'tokens'").fetchone()
|
||||
if row is None:
|
||||
return
|
||||
tokens = json.loads(row["value"])
|
||||
if "agents" in tokens or "agent" not in tokens:
|
||||
return
|
||||
new = {"client": tokens["client"], "agents": {"jira": tokens["agent"]}}
|
||||
conn.execute("UPDATE setting SET value = ? WHERE key = 'tokens'", (json.dumps(new, ensure_ascii=False),))
|
||||
|
||||
|
||||
def _agent_status_v7(conn) -> None:
|
||||
"""agent_status по агентам: ключ agent вместо id = 1, last_jira_call_at → last_call_at; строка id = 1 уходит в
|
||||
агента jira. Уже пересозданную таблицу (база собрана из схемы v7) не трогает."""
|
||||
if "agent" in {r["name"] for r in conn.execute("PRAGMA table_info(agent_status)")}:
|
||||
return
|
||||
conn.execute("""CREATE TABLE agent_status_new (
|
||||
agent TEXT PRIMARY KEY,
|
||||
last_seen_at TEXT,
|
||||
agent_version TEXT,
|
||||
settings_version INTEGER,
|
||||
current_command_id INTEGER,
|
||||
last_call_at TEXT
|
||||
)""")
|
||||
conn.execute("INSERT INTO agent_status_new(agent, last_seen_at, agent_version, settings_version, "
|
||||
"current_command_id, last_call_at) SELECT 'jira', last_seen_at, agent_version, settings_version, "
|
||||
"current_command_id, last_jira_call_at FROM agent_status WHERE id = 1")
|
||||
conn.execute("DROP TABLE agent_status")
|
||||
conn.execute("ALTER TABLE agent_status_new RENAME TO agent_status")
|
||||
|
||||
|
||||
def schema() -> str:
|
||||
"""Схема ядра и модулей: CREATE TABLE IF NOT EXISTS, порядок не важен."""
|
||||
from . import registry # внутри функции: реестр импортирует модули лениво
|
||||
@ -92,6 +125,7 @@ MIGRATIONS = {
|
||||
4: [_add_column("issue_comment", "created_at"), _add_column("sync_run", "history_at"), # v4 → v5: журнал изменений
|
||||
_fill_comment_utc],
|
||||
5: [], # v5 → v6: issue_attachment и attachment_file создаются схемой (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
|
||||
6: [_tokens_v7, _agent_status_v7], # v6 → v7: токены и статус по агентам модулей (wiki-module, решение 15)
|
||||
}
|
||||
|
||||
|
||||
@ -145,7 +179,9 @@ def migrate(conn: sqlite3.Connection, path=None) -> None:
|
||||
step(conn) if callable(step) else conn.execute(step)
|
||||
current += 1
|
||||
conn.execute("UPDATE setting SET value = ? WHERE key = 'schema_version'", (json.dumps(current),))
|
||||
conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)")
|
||||
from . import registry # внутри функции: реестр импортирует модули лениво
|
||||
for m in registry.agent_modules(): # при каждом вызове: модуль, подключённый позже, получает свою строку (R-1)
|
||||
conn.execute("INSERT OR IGNORE INTO agent_status(agent) VALUES (?)", (m.name,))
|
||||
|
||||
|
||||
_savepoints = itertools.count(1)
|
||||
|
||||
@ -5,6 +5,14 @@ from . import projects
|
||||
from .errors import HttpMapped
|
||||
|
||||
|
||||
AGENT = "_agent" # ключ query: имя модуля агента запроса; диспетчер кладёт его для scope "agent"
|
||||
|
||||
|
||||
def agent_of(q) -> str:
|
||||
"""Модуль агента, сделавшего запрос (по его токену)."""
|
||||
return q[AGENT]
|
||||
|
||||
|
||||
class Raw(NamedTuple):
|
||||
"""Не-JSON ответ обработчика (веб-приложение, перенаправление, файл). headers=None — Cache-Control: no-cache."""
|
||||
content_type: str
|
||||
|
||||
@ -38,6 +38,7 @@ class Module:
|
||||
schema: str = ""
|
||||
settings_defaults: dict = field(default_factory=dict)
|
||||
agent_keys: tuple[str, ...] = ()
|
||||
agent_min_version_key: str | None = None # ключ настроек с минимальной версией агента модуля
|
||||
validate_setting: Callable[[str, object], bool] | None = None # True — ключ модуля проверен (ValueError — плохо)
|
||||
settings_view: Callable[[Connection], dict] | None = None # вычисляемые поля GET/PUT /settings
|
||||
on_init: Callable[[Connection, dict, bool], list[str]] | None = None # init --config: строки для вывода
|
||||
|
||||
@ -115,9 +115,14 @@ def has_active(conn) -> bool:
|
||||
return conn.execute("SELECT 1 FROM command WHERE status IN ('queued', 'claimed') LIMIT 1").fetchone() is not None
|
||||
|
||||
|
||||
def agent_version(conn):
|
||||
def _agent(agent: str | None) -> str:
|
||||
"""Имя агента; без имени — первый агент реестра (временно, пока claim не получает агента из запроса)."""
|
||||
return agent or registry.agent_modules()[0].name
|
||||
|
||||
|
||||
def agent_version(conn, agent: str | None = None):
|
||||
"""Версия агента из его последней выдачи; None — агент не отчитывался."""
|
||||
row = conn.execute("SELECT agent_version FROM agent_status WHERE id = 1").fetchone()
|
||||
row = conn.execute("SELECT agent_version FROM agent_status WHERE agent = ?", (_agent(agent),)).fetchone()
|
||||
return row["agent_version"] if row else None
|
||||
|
||||
|
||||
@ -213,7 +218,7 @@ def _redeliver(conn, now) -> dict | None:
|
||||
h = _hooks(cmd["type"])
|
||||
if h:
|
||||
h.on_failed(conn, cmd, now)
|
||||
conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1 AND current_command_id = ?", (cmd["id"],))
|
||||
conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE current_command_id = ?", (cmd["id"],))
|
||||
log.warning("команда %s: агент не отчитался после %s выдач — failed", cmd["id"], MAX_DELIVERIES)
|
||||
|
||||
|
||||
@ -224,13 +229,15 @@ def deliver_params(conn, cmd, files_dir) -> dict:
|
||||
return h.deliver_params(conn, cmd, files_dir) if h else cmd["params"]
|
||||
|
||||
|
||||
def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None, files_dir=None) -> dict | None:
|
||||
def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None, files_dir=None,
|
||||
agent: str | None = None) -> dict | None:
|
||||
now = db.now()
|
||||
agent = _agent(agent)
|
||||
to_backup = None
|
||||
with db.transaction(conn):
|
||||
conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = COALESCE(?, agent_version), "
|
||||
"settings_version = COALESCE(?, settings_version), last_jira_call_at = COALESCE(?, last_jira_call_at) WHERE id = 1",
|
||||
(now, None if agent_version is None else str(agent_version), settings_version, last_jira_call_at))
|
||||
"settings_version = COALESCE(?, settings_version), last_call_at = COALESCE(?, last_call_at) WHERE agent = ?",
|
||||
(now, None if agent_version is None else str(agent_version), settings_version, last_jira_call_at, agent))
|
||||
cmd = _redeliver(conn, now)
|
||||
if cmd is not None:
|
||||
pause = _pause(conn, cmd["type"])
|
||||
@ -239,12 +246,12 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non
|
||||
h = _hooks(cmd["type"])
|
||||
if h:
|
||||
h.on_claim(conn, cmd, now, True)
|
||||
conn.execute("UPDATE agent_status SET current_command_id = ? WHERE id = 1", (cmd["id"],))
|
||||
conn.execute("UPDATE agent_status SET current_command_id = ? WHERE agent = ?", (cmd["id"], agent))
|
||||
log.info("команда %s выдана снова (выдача %s)", cmd["id"], cmd["deliveries"] + 1)
|
||||
else:
|
||||
cmd = _next(conn)
|
||||
if cmd is None:
|
||||
conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1")
|
||||
conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE agent = ?", (agent,))
|
||||
return None
|
||||
pause = _pause(conn, cmd["type"])
|
||||
first = conn.execute("SELECT COUNT(*) AS n FROM command WHERE batch_id = ? AND status != 'queued'",
|
||||
@ -254,7 +261,7 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non
|
||||
(cmd["batch_id"], *read)).fetchone() is not None
|
||||
conn.execute("UPDATE command SET status = 'claimed', claimed_at = ?, pause_seconds = ?, params = ?, deliveries = 1 "
|
||||
"WHERE id = ?", (now, pause, _dump(cmd["params"]), cmd["id"]))
|
||||
conn.execute("UPDATE agent_status SET current_command_id = ? WHERE id = 1", (cmd["id"],))
|
||||
conn.execute("UPDATE agent_status SET current_command_id = ? WHERE agent = ?", (cmd["id"], agent))
|
||||
h = _hooks(cmd["type"])
|
||||
if h:
|
||||
h.on_claim(conn, cmd, now, False)
|
||||
@ -295,7 +302,7 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls=
|
||||
error = why[:ERROR_LIMIT]
|
||||
conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?",
|
||||
(status, now, _dump(result) if result is not None else None, error, int(jira_calls or 0), int(rate_limited or 0), command_id))
|
||||
conn.execute("UPDATE agent_status SET current_command_id = NULL, last_seen_at = ? WHERE id = 1 AND current_command_id = ?",
|
||||
conn.execute("UPDATE agent_status SET current_command_id = NULL, last_seen_at = ? WHERE current_command_id = ?",
|
||||
(now, command_id))
|
||||
if h:
|
||||
h.on_result(conn, cmd, status, result, jira_calls, rate_limited, now, files_dir)
|
||||
@ -351,7 +358,7 @@ def cancel_command(conn, command_id, force=False) -> dict:
|
||||
raise ClaimedError(cmd["claimed_at"])
|
||||
if cmd["status"] in ("queued", "claimed"):
|
||||
conn.execute("UPDATE command SET status = 'cancelled', finished_at = ? WHERE id = ?", (db.now(), command_id))
|
||||
conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1 AND current_command_id = ?", (command_id,))
|
||||
conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE current_command_id = ?", (command_id,))
|
||||
return get_command(conn, command_id)
|
||||
|
||||
|
||||
@ -370,11 +377,13 @@ def retry_command(conn, command_id, author, files_dir=None, project=None) -> dic
|
||||
|
||||
# --- агент и автообновление ----------------------------------------------------
|
||||
|
||||
def agent_view(conn) -> dict:
|
||||
d = dict(conn.execute("SELECT * FROM agent_status WHERE id = 1").fetchone())
|
||||
def agent_view(conn, agent: str | None = None) -> dict:
|
||||
agent = _agent(agent)
|
||||
d = dict(conn.execute("SELECT * FROM agent_status WHERE agent = ?", (agent,)).fetchone())
|
||||
now = db.utcnow()
|
||||
d["online"] = bool(d["last_seen_at"]) and now - db.parse(d["last_seen_at"]) < timedelta(minutes=ONLINE_MINUTES)
|
||||
min_version = int(settings.get(conn, "min_agent_version") or 0)
|
||||
m = registry.module(agent)
|
||||
min_version = int(settings.get(conn, m.agent_min_version_key) or 0) if m and m.agent_min_version_key else 0
|
||||
d["warning"] = "обновите агента" if d["agent_version"] and int(d["agent_version"]) < min_version else None
|
||||
d["rate_limited_24h"] = conn.execute("SELECT COALESCE(SUM(rate_limited), 0) AS n FROM command WHERE finished_at >= ?",
|
||||
((now - timedelta(days=1)).isoformat(),)).fetchone()["n"]
|
||||
|
||||
@ -16,6 +16,11 @@ def modules() -> list[Module]:
|
||||
return _cache
|
||||
|
||||
|
||||
def agent_modules() -> list[Module]:
|
||||
"""Модули с командами: у каждого свой агент (имя агента = имя модуля), в порядке реестра."""
|
||||
return [m for m in modules() if m.commands]
|
||||
|
||||
|
||||
def module(name: str) -> Module | None:
|
||||
return next((m for m in modules() if m.name == name), None)
|
||||
|
||||
|
||||
@ -8,7 +8,6 @@ from . import registry
|
||||
|
||||
CORE_DEFAULTS = {
|
||||
"backup_keep": 30,
|
||||
"min_agent_version": 1,
|
||||
"port": 8790,
|
||||
"settings_version": 1,
|
||||
"initialized": False,
|
||||
@ -16,7 +15,7 @@ CORE_DEFAULTS = {
|
||||
}
|
||||
|
||||
# Ключи ядра в ответе агенту: идут после ключей модулей
|
||||
CORE_AGENT_KEYS = ["min_agent_version", "settings_version"]
|
||||
CORE_AGENT_KEYS = ["settings_version"]
|
||||
|
||||
PROTECTED = {"settings_version", "schema_version", "tokens"}
|
||||
|
||||
@ -33,17 +32,15 @@ def defaults() -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def agent_keys() -> list[str]:
|
||||
"""Ключи настроек для агента: сначала модулей, затем ядра."""
|
||||
out: list[str] = []
|
||||
for m in registry.modules():
|
||||
out += m.agent_keys
|
||||
return out + CORE_AGENT_KEYS
|
||||
def agent_keys(module: str) -> list[str]:
|
||||
"""Ключи настроек для агента модуля: сначала ключи модуля, затем ядра. Неизвестный модуль — только ключи ядра."""
|
||||
m = registry.module(module)
|
||||
return (list(m.agent_keys) if m else []) + CORE_AGENT_KEYS
|
||||
|
||||
|
||||
def validate(key, value) -> None:
|
||||
"""Проверить значение настройки до записи; ValueError — на 422 в API.
|
||||
Сначала ключи ядра: backup_keep, min_agent_version — целые >= 0; port — целое 1..65535;
|
||||
Сначала ключи ядра: backup_keep — целое >= 0; port — целое 1..65535;
|
||||
relay_dir — null или абсолютный путь строкой. Затем validate_setting модулей (их ключи).
|
||||
Остальные ключи — по типу значения по умолчанию (объект, список, строка, флаг), если оно не None;
|
||||
неизвестные ключи и ключи с умолчанием None принимаются как есть."""
|
||||
@ -51,9 +48,9 @@ def validate(key, value) -> None:
|
||||
if not _is_int(value) or not 1 <= value <= 65535:
|
||||
raise ValueError("port — целое 1..65535")
|
||||
return
|
||||
if key in ("backup_keep", "min_agent_version"):
|
||||
if key == "backup_keep":
|
||||
if not _is_int(value) or value < 0:
|
||||
raise ValueError(f"{key} — целое >= 0")
|
||||
raise ValueError("backup_keep — целое >= 0")
|
||||
return
|
||||
if key == "relay_dir":
|
||||
if value is not None and (not isinstance(value, str) or not os.path.isabs(value)):
|
||||
@ -71,9 +68,19 @@ def validate(key, value) -> None:
|
||||
def ensure_defaults(conn) -> None:
|
||||
for key, value in defaults().items():
|
||||
conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, ?)", (key, _dump(value)))
|
||||
if get(conn, "tokens") is None:
|
||||
conn.execute("INSERT INTO setting(key, value) VALUES ('tokens', ?)",
|
||||
(_dump({"agent": secrets.token_urlsafe(24), "client": secrets.token_urlsafe(24)}),))
|
||||
tokens = get(conn, "tokens") or {}
|
||||
changed = False
|
||||
if "client" not in tokens:
|
||||
tokens["client"] = secrets.token_urlsafe(24)
|
||||
changed = True
|
||||
agents = tokens.setdefault("agents", {})
|
||||
for m in registry.agent_modules(): # недостающий токен агента модуля создаётся при каждом вызове
|
||||
if m.name not in agents:
|
||||
agents[m.name] = secrets.token_urlsafe(24)
|
||||
changed = True
|
||||
if changed:
|
||||
conn.execute("INSERT INTO setting(key, value) VALUES ('tokens', ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value", (_dump(tokens),))
|
||||
|
||||
|
||||
def _dump(value) -> str:
|
||||
@ -105,9 +112,15 @@ def set_(conn, key, value, bump=True) -> int:
|
||||
return get(conn, "settings_version", 1)
|
||||
|
||||
|
||||
def for_agent(conn) -> dict:
|
||||
return {key: get(conn, key) for key in agent_keys()}
|
||||
def for_agent(conn, module: str) -> dict:
|
||||
"""Настройки агента модуля: его ключи и ключи ядра, чужих ключей нет."""
|
||||
return {key: get(conn, key) for key in agent_keys(module)}
|
||||
|
||||
|
||||
def tokens(conn) -> dict:
|
||||
"""{"client": токен, "agents": {модуль: токен}}."""
|
||||
return get(conn, "tokens")
|
||||
|
||||
|
||||
def agent_token(conn, module: str) -> str:
|
||||
return tokens(conn)["agents"][module]
|
||||
|
||||
@ -14,6 +14,7 @@ MODULE = Module(
|
||||
schema=schema.SQL,
|
||||
settings_defaults=settings.DEFAULTS,
|
||||
agent_keys=tuple(settings.AGENT_KEYS),
|
||||
agent_min_version_key="min_agent_version",
|
||||
validate_setting=settings.validate_setting,
|
||||
settings_view=settings.settings_view,
|
||||
on_init=settings.on_init,
|
||||
|
||||
@ -7,6 +7,7 @@ DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 3
|
||||
"ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "fetch_attachment": 2, "refresh": 2}
|
||||
|
||||
DEFAULTS = {
|
||||
"min_agent_version": 1,
|
||||
"project_key": "AURORA",
|
||||
"pacing": DEFAULT_PACING,
|
||||
"auto_refresh_minutes": 60,
|
||||
@ -25,10 +26,10 @@ DEFAULTS = {
|
||||
"closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed)
|
||||
}
|
||||
|
||||
# Что получает агент от модуля (без токенов и служебных ключей хаба); min_agent_version и settings_version добавляет ядро
|
||||
# Что получает агент от модуля (без токенов и служебных ключей хаба); settings_version добавляет ядро
|
||||
AGENT_KEYS = ["project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link",
|
||||
"priority_map", "priority_value_key", "labels", "status_labels", "kind_labels", "comment_marker",
|
||||
"transitions_map", "link_types", "board_id", "pacing"]
|
||||
"transitions_map", "link_types", "pacing", "min_agent_version"]
|
||||
|
||||
|
||||
def _is_int(v) -> bool:
|
||||
@ -39,7 +40,7 @@ def validate_setting(key, value) -> bool:
|
||||
"""True — ключ принадлежит модулю и проверен; ValueError — значение плохое (422 в API).
|
||||
base_jql и board_id не пишутся: JQL собирается из проектов, доска — свойство проекта;
|
||||
pacing — объект {тип команды: целое >= 0} только с известными типами команд;
|
||||
auto_refresh_minutes — целое >= 0."""
|
||||
auto_refresh_minutes и min_agent_version — целые >= 0."""
|
||||
if key == "base_jql":
|
||||
raise ValueError("базовая JQL собирается из проектов (GET /projects)")
|
||||
if key == "board_id":
|
||||
@ -53,7 +54,7 @@ def validate_setting(key, value) -> bool:
|
||||
if not _is_int(pause) or pause < 0:
|
||||
raise ValueError(f"pacing.{ctype}: ожидается целое >= 0")
|
||||
return True
|
||||
if key == "auto_refresh_minutes":
|
||||
if key in ("auto_refresh_minutes", "min_agent_version"):
|
||||
if not _is_int(value) or value < 0:
|
||||
raise ValueError(f"{key} — целое >= 0")
|
||||
return True
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
"""Общие фикстуры тестов. Соединения SQLite, открытые тестом, закрываются после него — в тестах нет
|
||||
ResourceWarning «unclosed database» (hub-hygiene-d, решение 14; M-9/O-4 #4)."""
|
||||
import re
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import db, projects
|
||||
from jira_hub.core import api, db, projects, registry, settings
|
||||
from jira_hub.core.client import HubClient
|
||||
from jira_hub.core.modules import CommandHooks, Module
|
||||
from jira_hub.modules.jira import MODULE as JIRA_MODULE
|
||||
from jira_hub.modules.jira import projects as jira_projects
|
||||
|
||||
|
||||
@ -32,3 +38,78 @@ def add_project():
|
||||
jira_projects.record_board_types(conn, {str(board_id): board_type})
|
||||
return projects.get(conn, label)
|
||||
return make
|
||||
|
||||
|
||||
def _stub_route(hub, conn, m, q, body):
|
||||
return 200, {"agent": q["_agent"]}
|
||||
|
||||
|
||||
def _stub_hooks() -> CommandHooks:
|
||||
"""Заглушка хуков второго модуля: типы stub_cmd (только чтение), остальное — пустые функции."""
|
||||
noop = lambda *a, **k: None # noqa: E731
|
||||
return CommandHooks(
|
||||
types=frozenset({"stub_cmd"}), read_types=frozenset({"stub_cmd"}), validate=lambda cmds: cmds,
|
||||
check_batch=lambda conn, cmds, project, files_dir: cmds, tail=lambda cmds: None, pause=lambda conn, t: 0,
|
||||
deliver_params=lambda conn, cmd, files_dir: cmd["params"], temp_definer=None, temp_keys_used=lambda c: [],
|
||||
substitute=lambda t, p, mapping: p, drop_unresolved=lambda c, u: u, retry_params=lambda c: c["params"],
|
||||
check_confirmed=lambda conn, c: (True, None), on_claim=noop, on_failed=noop,
|
||||
check_result=lambda conn, c, status, result, files_dir: (status, None), on_result=noop, after_result=noop,
|
||||
on_late_result=lambda *a: False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub_module(monkeypatch) -> Module:
|
||||
"""Второй модуль с командами и своим маршрутом агента; реестр подменён на [jira, stub]. Маршрут добавлен и
|
||||
в скомпилированную таблицу api (она собирается при импорте)."""
|
||||
stub = Module(name="stub", commands=_stub_hooks(), agent_keys=("stub_key",), settings_defaults={"stub_key": 1},
|
||||
routes=(("POST", r"^/agent/stub$", _stub_route, "agent"),))
|
||||
monkeypatch.setattr(registry, "_cache", [JIRA_MODULE, stub])
|
||||
route = stub.routes[0]
|
||||
monkeypatch.setattr(api, "_COMPILED", api._COMPILED + [(route[0], re.compile(route[1]), route[2], route[3], "stub")])
|
||||
return stub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn_two_modules(stub_module, open_db):
|
||||
"""База в памяти при двух модулях: строки agent_status и токены на каждый."""
|
||||
conn = open_db()
|
||||
settings.ensure_defaults(conn)
|
||||
return conn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub_server_two_modules(stub_module, tmp_path):
|
||||
"""HTTP-хаб на порту 0 при двух модулях: {"hub", "base", "client", "agents": {модуль: HubClient}}."""
|
||||
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||||
h.claim_wait = 0
|
||||
conn = h.conn()
|
||||
tok = settings.tokens(conn)
|
||||
conn.close()
|
||||
srv = api.make_server(h, "127.0.0.1", 0)
|
||||
th = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
th.start()
|
||||
base = f"http://127.0.0.1:{srv.server_port}"
|
||||
yield {"hub": h, "base": base, "client": HubClient(base, tok["client"]),
|
||||
"agents": {name: HubClient(base, token) for name, token in tok["agents"].items()}}
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
th.join(5)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub_server(tmp_path):
|
||||
"""HTTP-хаб на порту 0 с одним модулем: {"hub", "base", "client", "agent"} (агент — токеном jira)."""
|
||||
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||||
h.claim_wait = 0
|
||||
conn = h.conn()
|
||||
tok = settings.tokens(conn)
|
||||
conn.close()
|
||||
srv = api.make_server(h, "127.0.0.1", 0)
|
||||
th = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
th.start()
|
||||
base = f"http://127.0.0.1:{srv.server_port}"
|
||||
yield {"hub": h, "base": base, "client": HubClient(base, tok["client"]),
|
||||
"agent": HubClient(base, tok["agents"]["jira"])}
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
th.join(5)
|
||||
|
||||
4
tests/fixtures/baseline/schema.expected.json
vendored
4
tests/fixtures/baseline/schema.expected.json
vendored
@ -28,7 +28,7 @@
|
||||
[
|
||||
"table",
|
||||
"agent_status",
|
||||
"CREATE TABLE agent_status (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n last_seen_at TEXT,\n agent_version TEXT,\n settings_version INTEGER,\n current_command_id INTEGER,\n last_jira_call_at TEXT\n)"
|
||||
"CREATE TABLE agent_status (\n agent TEXT PRIMARY KEY,\n last_seen_at TEXT,\n agent_version TEXT,\n settings_version INTEGER,\n current_command_id INTEGER,\n last_call_at TEXT\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
@ -91,5 +91,5 @@
|
||||
"CREATE TABLE sync_run (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n started_at TEXT NOT NULL,\n finished_at TEXT,\n kind TEXT NOT NULL,\n query TEXT,\n issues_count INTEGER NOT NULL DEFAULT 0,\n links_count INTEGER NOT NULL DEFAULT 0,\n jira_calls INTEGER NOT NULL DEFAULT 0,\n rate_limited INTEGER NOT NULL DEFAULT 0,\n command_id INTEGER,\n history_at TEXT\n)"
|
||||
]
|
||||
],
|
||||
"schema_version": 6
|
||||
"schema_version": 7
|
||||
}
|
||||
|
||||
@ -13,7 +13,6 @@
|
||||
"comment_marker",
|
||||
"transitions_map",
|
||||
"link_types",
|
||||
"board_id",
|
||||
"pacing",
|
||||
"min_agent_version",
|
||||
"settings_version"
|
||||
|
||||
95
tests/fixtures/schema_v6.json
vendored
Normal file
95
tests/fixtures/schema_v6.json
vendored
Normal file
@ -0,0 +1,95 @@
|
||||
{
|
||||
"objects": [
|
||||
[
|
||||
"index",
|
||||
"issue_attachment_key",
|
||||
"CREATE INDEX issue_attachment_key ON issue_attachment(key)"
|
||||
],
|
||||
[
|
||||
"index",
|
||||
"ix_command_status",
|
||||
"CREATE INDEX ix_command_status ON command(status, batch_id, seq)"
|
||||
],
|
||||
[
|
||||
"index",
|
||||
"ix_issue_change_at",
|
||||
"CREATE INDEX ix_issue_change_at ON issue_change(at)"
|
||||
],
|
||||
[
|
||||
"index",
|
||||
"ix_issue_change_key",
|
||||
"CREATE INDEX ix_issue_change_key ON issue_change(key, at)"
|
||||
],
|
||||
[
|
||||
"index",
|
||||
"ix_issue_link_target",
|
||||
"CREATE INDEX ix_issue_link_target ON issue_link(target_key)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"agent_status",
|
||||
"CREATE TABLE agent_status (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n last_seen_at TEXT,\n agent_version TEXT,\n settings_version INTEGER,\n current_command_id INTEGER,\n last_jira_call_at TEXT\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"attachment_file",
|
||||
"CREATE TABLE attachment_file (\n attachment_id TEXT PRIMARY KEY,\n file TEXT NOT NULL,\n size INTEGER NOT NULL,\n fetched_at TEXT NOT NULL\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"batch",
|
||||
"CREATE TABLE batch (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n title TEXT NOT NULL,\n author TEXT NOT NULL,\n created_at TEXT NOT NULL,\n cancelled_at TEXT,\n estimate_seconds INTEGER NOT NULL DEFAULT 0,\n project TEXT\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"command",
|
||||
"CREATE TABLE command (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n batch_id INTEGER NOT NULL REFERENCES batch(id),\n seq INTEGER NOT NULL,\n type TEXT NOT NULL,\n params TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'queued',\n claimed_at TEXT,\n deliveries INTEGER NOT NULL DEFAULT 0,\n finished_at TEXT,\n result TEXT,\n error TEXT,\n jira_calls INTEGER NOT NULL DEFAULT 0,\n rate_limited INTEGER NOT NULL DEFAULT 0,\n pause_seconds INTEGER,\n depends_on INTEGER REFERENCES command(id)\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"issue",
|
||||
"CREATE TABLE issue (\n key TEXT PRIMARY KEY,\n id TEXT,\n issue_type TEXT,\n summary TEXT,\n description TEXT,\n status TEXT,\n priority TEXT,\n assignee TEXT,\n reporter TEXT,\n duedate TEXT,\n planned_start TEXT,\n planned_end TEXT,\n epic_key TEXT,\n labels TEXT NOT NULL DEFAULT '[]',\n sprint TEXT,\n created TEXT,\n jira_updated TEXT,\n fetched_at TEXT NOT NULL,\n neighbor INTEGER NOT NULL DEFAULT 0\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"issue_attachment",
|
||||
"CREATE TABLE issue_attachment (\n attachment_id TEXT PRIMARY KEY,\n key TEXT NOT NULL,\n filename TEXT NOT NULL,\n size INTEGER,\n mime_type TEXT,\n author TEXT,\n created TEXT,\n created_at TEXT\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"issue_change",
|
||||
"CREATE TABLE issue_change (\n history_id TEXT NOT NULL,\n seq INTEGER NOT NULL,\n key TEXT NOT NULL,\n at TEXT,\n created TEXT NOT NULL,\n author TEXT,\n field TEXT NOT NULL,\n field_id TEXT,\n field_name TEXT NOT NULL,\n from_value TEXT,\n from_string TEXT,\n to_value TEXT,\n to_string TEXT,\n fetched_at TEXT NOT NULL,\n PRIMARY KEY (history_id, seq)\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"issue_comment",
|
||||
"CREATE TABLE issue_comment (\n key TEXT NOT NULL,\n comment_id TEXT NOT NULL,\n author TEXT,\n created TEXT,\n updated TEXT,\n body TEXT NOT NULL DEFAULT '',\n created_at TEXT,\n PRIMARY KEY (key, comment_id)\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"issue_link",
|
||||
"CREATE TABLE issue_link (\n source_key TEXT NOT NULL,\n target_key TEXT NOT NULL,\n link_type TEXT NOT NULL,\n PRIMARY KEY (source_key, target_key, link_type)\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"issue_note",
|
||||
"CREATE TABLE issue_note (\n key TEXT PRIMARY KEY,\n fact_status TEXT,\n work_kind TEXT,\n done_text TEXT,\n rest_text TEXT,\n owner_note TEXT,\n updated_at TEXT,\n updated_by TEXT\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"project",
|
||||
"CREATE TABLE project (\n label TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n board_id INTEGER,\n board_type TEXT,\n created_at TEXT NOT NULL\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"setting",
|
||||
"CREATE TABLE setting (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"sync_run",
|
||||
"CREATE TABLE sync_run (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n started_at TEXT NOT NULL,\n finished_at TEXT,\n kind TEXT NOT NULL,\n query TEXT,\n issues_count INTEGER NOT NULL DEFAULT 0,\n links_count INTEGER NOT NULL DEFAULT 0,\n jira_calls INTEGER NOT NULL DEFAULT 0,\n rate_limited INTEGER NOT NULL DEFAULT 0,\n command_id INTEGER,\n history_at TEXT\n)"
|
||||
]
|
||||
],
|
||||
"schema_version": 6
|
||||
}
|
||||
63
tests/fixtures/settings_v6.json
vendored
Normal file
63
tests/fixtures/settings_v6.json
vendored
Normal file
@ -0,0 +1,63 @@
|
||||
{
|
||||
"agent_keys": [
|
||||
"project_key",
|
||||
"issue_types",
|
||||
"epic_name_field_id",
|
||||
"field_ids",
|
||||
"epic_link",
|
||||
"priority_map",
|
||||
"priority_value_key",
|
||||
"labels",
|
||||
"status_labels",
|
||||
"kind_labels",
|
||||
"comment_marker",
|
||||
"transitions_map",
|
||||
"link_types",
|
||||
"board_id",
|
||||
"pacing",
|
||||
"min_agent_version",
|
||||
"settings_version"
|
||||
],
|
||||
"defaults": {
|
||||
"auto_refresh_minutes": 60,
|
||||
"backup_keep": 30,
|
||||
"closed_statuses": [
|
||||
"Готово",
|
||||
"Снято"
|
||||
],
|
||||
"comment_marker": "",
|
||||
"epic_link": {},
|
||||
"epic_name_field_id": null,
|
||||
"field_ids": {},
|
||||
"initialized": false,
|
||||
"issue_types": {},
|
||||
"kind_labels": {},
|
||||
"labels": [],
|
||||
"link_types": {},
|
||||
"min_agent_version": 1,
|
||||
"pacing": {
|
||||
"ensure_attachment": 30,
|
||||
"ensure_comment": 30,
|
||||
"ensure_fields": 120,
|
||||
"ensure_issue": 120,
|
||||
"ensure_link": 30,
|
||||
"ensure_sprint": 30,
|
||||
"ensure_status": 30,
|
||||
"fetch_attachment": 2,
|
||||
"refresh": 2
|
||||
},
|
||||
"port": 8790,
|
||||
"priority_map": {},
|
||||
"priority_value_key": "name",
|
||||
"project_key": "AURORA",
|
||||
"relay_dir": null,
|
||||
"settings_version": 1,
|
||||
"status_labels": {},
|
||||
"transitions_map": {}
|
||||
},
|
||||
"protected": [
|
||||
"schema_version",
|
||||
"settings_version",
|
||||
"tokens"
|
||||
]
|
||||
}
|
||||
93
tests/test_agents.py
Normal file
93
tests/test_agents.py
Normal file
@ -0,0 +1,93 @@
|
||||
"""Агенты модулей: токен определяет модуль, миграция v7 сохраняет агента jira (wiki-module, решения 1–3, 6, 15)."""
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import cli, db, queue, settings
|
||||
from jira_hub.core.client import HubError
|
||||
from v6db import make_v6_db
|
||||
|
||||
|
||||
def test_v6_upgrade_keeps_jira_agent(tmp_path):
|
||||
make_v6_db(tmp_path / "hub.sqlite")
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
try:
|
||||
assert db._schema_version(conn) == 7
|
||||
tok = settings.tokens(conn)
|
||||
assert tok["client"] == "T-client" and tok["agents"]["jira"] == "T-agent" and "agent" not in tok
|
||||
row = conn.execute("SELECT * FROM agent_status WHERE agent = 'jira'").fetchone()
|
||||
assert row["agent_version"] == "14"
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
|
||||
assert list((tmp_path / "backups").glob("*schema-v6*")) # копия перед миграцией
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_v6_upgrade_keeps_settings_and_projects(tmp_path):
|
||||
make_v6_db(tmp_path / "hub.sqlite")
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
try:
|
||||
assert settings.get(conn, "min_agent_version") == 1
|
||||
rows = conn.execute("SELECT label, board_id, board_type FROM project ORDER BY label").fetchall()
|
||||
assert [tuple(r) for r in rows] == [("AWPCA", 4216, "scrum"), ("sfm_kedr", None, None)]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_migrate_adds_status_row_for_new_agent_module(conn_two_modules):
|
||||
"""R-1: строки agent_status для модулей реестра вставляются при каждом migrate, а не только в шаге 6 → 7."""
|
||||
names = {r["agent"] for r in conn_two_modules.execute("SELECT agent FROM agent_status")}
|
||||
assert names == {"jira", "stub"}
|
||||
|
||||
|
||||
def test_ensure_defaults_adds_missing_agent_token(conn_two_modules):
|
||||
tok = settings.tokens(conn_two_modules)
|
||||
assert set(tok["agents"]) == {"jira", "stub"} and tok["agents"]["jira"] != tok["agents"]["stub"]
|
||||
|
||||
|
||||
def test_agent_token_routes(hub_server):
|
||||
assert hub_server["agent"].post("/agent/claim", {})["command"] is None
|
||||
assert hub_server["agent"].get("/agent/settings")["settings_version"] >= 1
|
||||
with pytest.raises(HubError) as e:
|
||||
hub_server["client"].post("/agent/claim", {})
|
||||
assert e.value.status == 401
|
||||
|
||||
|
||||
def test_module_agent_route_requires_own_module(hub_server_two_modules):
|
||||
jira, stub = hub_server_two_modules["agents"]["jira"], hub_server_two_modules["agents"]["stub"]
|
||||
with pytest.raises(HubError) as e:
|
||||
stub.post("/agent/state", {})
|
||||
assert e.value.status == 401
|
||||
with pytest.raises(HubError) as e:
|
||||
jira.post("/agent/stub", {})
|
||||
assert e.value.status == 401
|
||||
assert stub.post("/agent/stub", {}) == {"agent": "stub"}
|
||||
assert jira.post("/agent/claim", {})["command"] is None # маршруты ядра открыты обоим агентам
|
||||
assert stub.post("/agent/claim", {})["command"] is None
|
||||
|
||||
|
||||
def test_agent_settings_per_module(hub_server_two_modules):
|
||||
jira = hub_server_two_modules["agents"]["jira"].get("/agent/settings")
|
||||
stub = hub_server_two_modules["agents"]["stub"].get("/agent/settings")
|
||||
assert "project_key" in jira and "transitions_map" in jira and "settings_version" in jira
|
||||
assert "stub_key" not in jira and "board_id" not in jira
|
||||
assert set(stub) == {"stub_key", "settings_version"}
|
||||
|
||||
|
||||
def test_agent_query_cannot_be_forged(hub_server_two_modules):
|
||||
"""Служебный ключ запроса приходит только от диспетчера: _agent в адресе перезаписывается."""
|
||||
stub = hub_server_two_modules["agents"]["stub"]
|
||||
assert stub.call("POST", "/agent/stub", {}, params={"_agent": "jira"}) == {"agent": "stub"}
|
||||
|
||||
|
||||
def test_tokens_cli_lists_agents(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path))
|
||||
assert cli.main(["tokens"]) == 0
|
||||
lines = capsys.readouterr().out.splitlines()
|
||||
assert lines[0].startswith("клиентский токен: ") and lines[1].startswith("агент jira: ")
|
||||
assert lines[2].startswith("база: ")
|
||||
|
||||
|
||||
def test_agent_view_min_version_from_module_key(conn_two_modules):
|
||||
conn_two_modules.execute("UPDATE agent_status SET agent_version = '3' WHERE agent = 'jira'")
|
||||
settings.set_(conn_two_modules, "min_agent_version", 5)
|
||||
assert queue.agent_view(conn_two_modules, "jira")["warning"] == "обновите агента"
|
||||
assert queue.agent_view(conn_two_modules, "stub")["warning"] is None
|
||||
@ -29,7 +29,7 @@ def hub(tmp_path):
|
||||
th = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
th.start()
|
||||
base = f"http://127.0.0.1:{srv.server_port}"
|
||||
yield {"hub": h, "agent": HubClient(base, tok["agent"]), "client": HubClient(base, tok["client"]), "base": base}
|
||||
yield {"hub": h, "agent": HubClient(base, tok["agents"]["jira"]), "client": HubClient(base, tok["client"]), "base": base}
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
th.join(5)
|
||||
@ -530,7 +530,7 @@ def test_agent_reported_after_hub_restart(hub, tmp_path):
|
||||
"""M-2 #25, спека «Хаб перезапущен, агент ещё не выходил на связь»: в базе агент онлайн, claim с запуска не было."""
|
||||
_publish(hub, tmp_path, 10)
|
||||
conn = hub["hub"].conn()
|
||||
conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = '9' WHERE id = 1", (db.now(),))
|
||||
conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = '9' WHERE agent = 'jira'", (db.now(),))
|
||||
conn.close()
|
||||
v = hub["client"].get("/agent")
|
||||
assert v["online"] is True and v["agent_reported"] is False
|
||||
@ -602,7 +602,7 @@ def test_agent_warning_and_offline(hub):
|
||||
assert c.get("/agent")["warning"] is None
|
||||
old = (db.utcnow() - timedelta(minutes=queue.ONLINE_MINUTES + 1)).isoformat()
|
||||
conn = hub["hub"].conn()
|
||||
conn.execute("UPDATE agent_status SET last_seen_at = ? WHERE id = 1", (old,))
|
||||
conn.execute("UPDATE agent_status SET last_seen_at = ? WHERE agent = 'jira'", (old,))
|
||||
conn.close()
|
||||
assert c.get("/agent")["online"] is False
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ def test_routes():
|
||||
|
||||
|
||||
def test_settings():
|
||||
_check("settings", {"defaults": settings.defaults(), "agent_keys": settings.agent_keys(),
|
||||
_check("settings", {"defaults": settings.defaults(), "agent_keys": settings.agent_keys("jira"),
|
||||
"protected": sorted(settings.PROTECTED)})
|
||||
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ def test_init_import_tokens(tmp_path, monkeypatch, capsys, open_db):
|
||||
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
|
||||
assert cli.main(["init", "--config", str(FIXTURE)]) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "transitions_map" in out and "агентский токен" in out
|
||||
assert "transitions_map" in out and "агент jira: " in out
|
||||
assert cli.main(["init", "--config", str(FIXTURE)]) == 1
|
||||
assert "уже инициализирован" in capsys.readouterr().err
|
||||
conn = open_db(paths.db_path())
|
||||
|
||||
@ -19,7 +19,7 @@ def test_connect_creates_schema(open_db):
|
||||
assert "created_at" in {r["name"] for r in conn.execute("PRAGMA table_info(issue_comment)")}
|
||||
assert "history_at" in {r["name"] for r in conn.execute("PRAGMA table_info(sync_run)")}
|
||||
assert "project" in {r["name"] for r in conn.execute("PRAGMA table_info(batch)")}
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")}
|
||||
assert "deliveries" in cols
|
||||
@ -59,7 +59,7 @@ def test_migrates_v1_with_backup(tmp_path):
|
||||
path = tmp_path / "hub.sqlite"
|
||||
_v1_database(path)
|
||||
conn = db.connect(path)
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
rows = conn.execute("SELECT status, deliveries FROM command ORDER BY id").fetchall()
|
||||
assert [(r["status"], r["deliveries"]) for r in rows] == [("claimed", 1), ("done", 0), ("queued", 0)]
|
||||
assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе"
|
||||
@ -206,7 +206,7 @@ def test_parallel_connect_makes_one_backup(tmp_path):
|
||||
assert errors == []
|
||||
assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1
|
||||
conn = db.connect(path)
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
conn.close()
|
||||
|
||||
|
||||
@ -223,7 +223,7 @@ def test_migrates_v2_with_backup(tmp_path):
|
||||
conn.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'")
|
||||
conn.close()
|
||||
conn = db.connect(path)
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM issue_comment").fetchone()["n"] == 0
|
||||
assert conn.execute("SELECT summary FROM issue WHERE key = 'A-1'").fetchone()["summary"] == "Т"
|
||||
assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе"
|
||||
@ -291,7 +291,7 @@ def test_migrates_v3_creates_awpca(tmp_path):
|
||||
queued = [json.loads(r["params"])["jql"] for r in conn.execute("SELECT params FROM command WHERE status = 'queued'")]
|
||||
assert sorted(queued) == sorted([new, "key = A-1"])
|
||||
assert conn.execute("SELECT project FROM batch").fetchall()[0]["project"] is None
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
assert len(list((tmp_path / "backups").glob("*-schema-v3.sqlite"))) == 1
|
||||
conn.close()
|
||||
|
||||
@ -310,7 +310,7 @@ def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog):
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
assert projects.list_projects(conn) == []
|
||||
assert "ORDER BY key" in caplog.text
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
conn.close()
|
||||
|
||||
|
||||
@ -344,7 +344,7 @@ def test_migrates_v4(tmp_path):
|
||||
path = tmp_path / "hub.sqlite"
|
||||
_v4_database(path)
|
||||
conn = db.connect(path)
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
rows = {r["comment_id"]: (r["created"], r["created_at"]) for r in conn.execute("SELECT * FROM issue_comment")}
|
||||
assert rows == {"1": ("2026-10-22T13:05:11.000+0300", "2026-10-22T10:05:11+00:00"), "2": ("мусор", None)}
|
||||
assert conn.execute("SELECT history_at FROM sync_run").fetchone()["history_at"] is None
|
||||
@ -360,7 +360,7 @@ def test_migrate_v5_to_v6(tmp_path):
|
||||
conn.execute("UPDATE setting SET value = '5' WHERE key = 'schema_version'")
|
||||
conn.close()
|
||||
conn = db.connect(path)
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM issue_attachment").fetchone()["n"] == 0
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0
|
||||
assert len(list((tmp_path / "backups").glob("*-schema-v5.sqlite"))) == 1
|
||||
|
||||
@ -19,7 +19,7 @@ def live(tmp_path, add_project):
|
||||
th = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
th.start()
|
||||
base = f"http://127.0.0.1:{srv.server_port}"
|
||||
yield HubClient(base, tok["agent"]), HubClient(base, tok["client"])
|
||||
yield HubClient(base, tok["agents"]["jira"]), HubClient(base, tok["client"])
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
th.join(5)
|
||||
|
||||
@ -30,7 +30,7 @@ def live(tmp_path, monkeypatch, add_project):
|
||||
monkeypatch.setattr(core_mcp, "client", lambda: HubClient(base, tok["client"]))
|
||||
monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.05)
|
||||
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
|
||||
yield {"agent": HubClient(base, tok["agent"]), "client": HubClient(base, tok["client"])}
|
||||
yield {"agent": HubClient(base, tok["agents"]["jira"]), "client": HubClient(base, tok["client"])}
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
|
||||
|
||||
@ -1148,13 +1148,13 @@ def test_request_fetch_ready(fconn, tmp_path):
|
||||
|
||||
|
||||
def test_request_fetch_agent_too_old(fconn, tmp_path):
|
||||
fconn.execute("UPDATE agent_status SET agent_version = '13' WHERE id = 1")
|
||||
fconn.execute("UPDATE agent_status SET agent_version = '13' WHERE agent = 'jira'")
|
||||
with pytest.raises(jira_fetch.AgentTooOld) as e:
|
||||
jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")
|
||||
assert str(e.value) == "агент v13 не умеет скачивать вложения — нужен v14"
|
||||
fconn.execute("UPDATE agent_status SET agent_version = '14' WHERE id = 1")
|
||||
fconn.execute("UPDATE agent_status SET agent_version = '14' WHERE agent = 'jira'")
|
||||
assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching"
|
||||
fconn.execute("UPDATE agent_status SET agent_version = NULL WHERE id = 1")
|
||||
fconn.execute("UPDATE agent_status SET agent_version = NULL WHERE agent = 'jira'")
|
||||
assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching"
|
||||
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ def env(tmp_path):
|
||||
th.start()
|
||||
r = relay.Relay(h, srv.server_port)
|
||||
client = HubClient(f"http://127.0.0.1:{srv.server_port}", tok["client"])
|
||||
yield {"hub": h, "root": root, "relay": r, "agent": "Bearer " + tok["agent"], "client": client,
|
||||
yield {"hub": h, "root": root, "relay": r, "agent": "Bearer " + tok["agents"]["jira"], "client": client,
|
||||
"port": srv.server_port}
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
@ -251,7 +251,7 @@ def test_serve_runs_relay(tmp_path):
|
||||
root.mkdir()
|
||||
conn = h.conn()
|
||||
settings.set_(conn, "relay_dir", str(root))
|
||||
auth = "Bearer " + settings.tokens(conn)["agent"]
|
||||
auth = "Bearer " + settings.tokens(conn)["agents"]["jira"]
|
||||
conn.close()
|
||||
srv, stop = api.start(h, "127.0.0.1", 0) # реле стучится на тот же хост, что слушает HTTP
|
||||
th = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
|
||||
@ -21,15 +21,15 @@ def conn(open_db):
|
||||
def test_defaults_and_tokens(conn):
|
||||
assert settings.get(conn, "pacing")["ensure_fields"] == 120
|
||||
tok = settings.tokens(conn)
|
||||
assert len(tok["agent"]) > 20 and tok["agent"] != tok["client"]
|
||||
assert len(tok["agents"]["jira"]) > 20 and tok["agents"]["jira"] != tok["client"]
|
||||
assert "tokens" not in settings.get_all(conn)
|
||||
|
||||
|
||||
def test_defaults_without_jql_and_board(conn):
|
||||
assert "base_jql" not in settings.defaults() and "board_id" not in settings.defaults()
|
||||
assert "base_jql" not in settings.get_all(conn) and "board_id" not in settings.get_all(conn)
|
||||
assert "base_jql" not in settings.agent_keys() and "board_id" in settings.agent_keys()
|
||||
assert settings.for_agent(conn)["board_id"] is None
|
||||
assert "base_jql" not in settings.agent_keys("jira") and "board_id" not in settings.agent_keys("jira")
|
||||
assert "board_id" not in settings.for_agent(conn, "jira") # мёртвый ключ: агент читает его через .get (design 6)
|
||||
|
||||
|
||||
def test_jql_and_board_not_writable():
|
||||
@ -60,7 +60,7 @@ def test_init_from_config(conn):
|
||||
assert settings.get(conn, "field_ids")["planned_end"] == "customfield_10802"
|
||||
assert settings.get(conn, "issue_types")["epic"] == {"name": "Фича", "id": "12300"}
|
||||
assert settings.get(conn, "pacing") == jira_settings.DEFAULT_PACING
|
||||
agent_view = settings.for_agent(conn)
|
||||
agent_view = settings.for_agent(conn, "jira")
|
||||
assert "tokens" not in agent_view and "auth" not in agent_view and "base_url" not in agent_view
|
||||
assert agent_view["link_types"]["связанные"] == "ASUPR_Связанные"
|
||||
with pytest.raises(RuntimeError):
|
||||
@ -77,7 +77,7 @@ def test_relay_dir_default_and_validation(tmp_path, open_db):
|
||||
for bad in ("rshb/jira-bridge", "", 5, ["/x"]):
|
||||
with pytest.raises(ValueError):
|
||||
settings.validate("relay_dir", bad)
|
||||
assert "relay_dir" not in settings.agent_keys()
|
||||
assert "relay_dir" not in settings.agent_keys("jira")
|
||||
|
||||
|
||||
def test_init_without_bug_keys_rejects_bug(conn):
|
||||
@ -112,8 +112,8 @@ def test_attachment_pacing(conn):
|
||||
|
||||
def test_closed_statuses_default(conn):
|
||||
assert settings.get(conn, "closed_statuses") == ["Готово", "Снято"]
|
||||
assert "closed_statuses" not in settings.agent_keys()
|
||||
assert "closed_statuses" not in settings.for_agent(conn)
|
||||
assert "closed_statuses" not in settings.agent_keys("jira")
|
||||
assert "closed_statuses" not in settings.for_agent(conn, "jira")
|
||||
|
||||
|
||||
def test_closed_statuses_must_be_list():
|
||||
|
||||
28
tests/v6db.py
Normal file
28
tests/v6db.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""База хаба версии 6 для тестов миграции v7: схема и умолчания настроек берутся из замороженных эталонов
|
||||
(tests/fixtures/schema_v6.json, settings_v6.json), а не из кода, который со временем уйдёт вперёд."""
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def make_v6_db(path, *, agent_token="T-agent", client_token="T-client") -> None:
|
||||
schema = json.loads((FIXTURES / "schema_v6.json").read_text(encoding="utf-8"))
|
||||
defaults = json.loads((FIXTURES / "settings_v6.json").read_text(encoding="utf-8"))["defaults"]
|
||||
conn = sqlite3.connect(str(path), isolation_level=None)
|
||||
try:
|
||||
for kind in ("table", "index"): # индексы — после таблиц
|
||||
for typ, _name, sql in schema["objects"]:
|
||||
if typ == kind and sql:
|
||||
conn.execute(sql)
|
||||
values = {**defaults, "schema_version": 6, "tokens": {"agent": agent_token, "client": client_token}}
|
||||
for key, value in values.items():
|
||||
conn.execute("INSERT INTO setting(key, value) VALUES (?, ?)", (key, json.dumps(value, ensure_ascii=False)))
|
||||
conn.execute("INSERT INTO agent_status(id, agent_version) VALUES (1, '14')")
|
||||
conn.execute("INSERT INTO project(label, name, board_id, board_type, created_at) VALUES "
|
||||
"('AWPCA', 'АРМ КА', 4216, 'scrum', '2026-10-01T00:00:00+00:00')")
|
||||
conn.execute("INSERT INTO project(label, name, board_id, board_type, created_at) VALUES "
|
||||
"('sfm_kedr', 'sfm_kedr', NULL, NULL, '2026-10-02T00:00:00+00:00')")
|
||||
finally:
|
||||
conn.close()
|
||||
Loading…
x
Reference in New Issue
Block a user