"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос). Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).""" import hmac import json import logging import re import threading import time import traceback from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse from . import backup, db, mirror, queue, relay, settings from .commands import CommandError from .paths import backups_dir as default_backups_dir from .queue import ClaimedError log = logging.getLogger(__name__) MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит class ApiError(Exception): def __init__(self, status, error, detail=None): super().__init__(error) self.status, self.error, self.detail = status, error, detail class Hub: def __init__(self, db_path, backups_dir=None): self.db_path = Path(db_path) self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() self.claim_wait = 30 conn = self.conn() settings.ensure_defaults(conn) conn.close() def conn(self): return db.connect(self.db_path) def tick(self): """Один шаг фонового автообновления; любой сбой логируется и не прерывает цикл.""" conn = None try: conn = self.conn() queue.maybe_auto_refresh(conn) except Exception: # noqa: BLE001 log.exception("автообновление: сбой тика") finally: if conn: conn.close() def make_backup(self, label): conn = self.conn() try: backup.make_backup(conn, self.backups_dir, label, int(settings.get(conn, "backup_keep") or 30)) finally: conn.close() # --- обработчики: fn(hub, conn, match, query, body) -> (status, payload) ---------- def _int(m, i=1): return int(m.group(i)) def _int_param(q, name, default): try: return int(q.get(name, default)) except (TypeError, ValueError): raise ApiError(400, "bad_param", f"{name}: ожидается целое число") def _not_found(obj, what): if obj is None: raise ApiError(404, "not_found", what) return obj def h_claim(hub, conn, m, q, 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) if cmd or time.monotonic() >= deadline: break time.sleep(1) return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version")} def h_agent_settings(hub, conn, m, q, body): return 200, settings.for_agent(conn) def h_agent_command(hub, conn, m, q, body): return 200, _not_found(queue.get_command(conn, _int(m)), "command") def h_agent_result(hub, conn, m, q, body): try: cmd = queue.report_result(conn, _int(m), body.get("status"), body.get("result"), body.get("error"), body.get("jira_calls", 0), body.get("rate_limited", 0)) except KeyError: raise ApiError(404, "not_found", "command") except queue.NotClaimedError as e: raise ApiError(409, "not_claimed", str(e)) except ValueError as e: raise ApiError(422, "bad_status", str(e)) return 200, cmd def h_agent_state(hub, conn, m, q, body): with db.transaction(conn): out = mirror.apply_state(conn, body.get("issues") or [], body.get("links") or [], body.get("neighbor_keys") or [], authoritative=queue.is_full_refresh(conn, body.get("command_id"))) if body.get("command_id"): queue.record_state(conn, body["command_id"], out["issues"], out["links"]) return 200, out def h_known_keys(hub, conn, m, q, body): return 200, {"keys": mirror.known_keys(conn)} ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q") def h_issues(hub, conn, m, q, body): filters = {k: q.get(k) for k in ISSUE_FILTERS} return 200, {"issues": mirror.list_issues(conn, filters, _int_param(q, "limit", 50), _int_param(q, "offset", 0))} def h_issue(hub, conn, m, q, body): return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue") def h_note_put(hub, conn, m, q, body): fields = {k: v for k, v in body.items() if k != "updated_by"} try: return 200, mirror.set_note(conn, m.group(1), fields, body.get("updated_by") or "api") except ValueError as e: raise ApiError(422, "bad_note", str(e)) def h_batches_post(hub, conn, m, q, body): if not body.get("title") or not isinstance(body["title"], str): raise ApiError(422, "bad_batch", "title — непустая строка") if not isinstance(body.get("author") or "api", str): raise ApiError(422, "bad_batch", "author — строка") if not isinstance(body.get("commands"), list): raise ApiError(422, "bad_batch", "commands — список команд") try: return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands")) except CommandError as e: raise ApiError(422, "bad_batch", str(e)) def h_batches_get(hub, conn, m, q, body): return 200, {"batches": queue.list_batches(conn, _int_param(q, "limit", 20))} def h_batch_get(hub, conn, m, q, body): return 200, _not_found(queue.batch_view(conn, _int(m)), "batch") def h_batch_cancel(hub, conn, m, q, body): try: return 200, queue.cancel_batch(conn, _int(m)) except KeyError: raise ApiError(404, "not_found", "batch") def h_command_retry(hub, conn, m, q, body): if not isinstance(body.get("author") or "api", str): raise ApiError(422, "bad_retry", "author — строка") try: return 201, queue.retry_command(conn, _int(m), body.get("author") or "api") except KeyError: raise ApiError(404, "not_found", "command") except CommandError as e: raise ApiError(422, "bad_batch", str(e)) def h_command_cancel(hub, conn, m, q, body): try: return 200, queue.cancel_command(conn, _int(m), bool(body.get("force"))) except KeyError: raise ApiError(404, "not_found", "command") except ClaimedError as e: raise ApiError(409, "claimed", e.claimed_at) def h_refresh(hub, conn, m, q, body): if not isinstance(body.get("author") or "api", str): raise ApiError(422, "bad_refresh", "author — строка") keys = body.get("keys") if keys is not None and (not isinstance(keys, list) or not all(isinstance(k, str) and k for k in keys)): raise ApiError(422, "bad_refresh", "keys — список строк") if body.get("jql") is not None and not isinstance(body["jql"], str): raise ApiError(422, "bad_refresh", "jql — строка") if body.get("full"): params = {"jql": settings.get(conn, "base_jql")} elif body.get("jql"): params = {"jql": body["jql"]} elif body.get("keys"): params = {"keys": keys} else: raise ApiError(422, "bad_refresh", "нужно full, jql или keys") return 201, queue.create_batch(conn, "Обновление", body.get("author") or "api", [{"type": "refresh", "params": params}]) def h_agent_get(hub, conn, m, q, body): return 200, queue.agent_view(conn) def h_sync_runs(hub, conn, m, q, body): rows = conn.execute("SELECT * FROM sync_run ORDER BY id DESC LIMIT ?", (_int_param(q, "limit", 20),)).fetchall() return 200, {"runs": [dict(r) for r in rows]} def h_settings_get(hub, conn, m, q, body): return 200, settings.get_all(conn) def h_settings_put(hub, conn, m, q, body): """Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения (settings.validate): ошибка — 422, версия настроек не меняется.""" bad = [k for k in body if k in settings.PROTECTED] if bad: raise ApiError(422, "protected", ", ".join(bad)) for key, value in body.items(): try: settings.validate(key, value) except ValueError as e: raise ApiError(422, "bad_setting", str(e)) version = settings.get(conn, "settings_version") with db.transaction(conn): for key, value in body.items(): version = settings.set_(conn, key, value) return 200, {"settings_version": version} ROUTES = [ ("POST", r"^/agent/claim$", h_claim, "agent"), ("GET", r"^/agent/settings$", h_agent_settings, "agent"), ("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"), ("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"), ("POST", r"^/agent/state$", h_agent_state, "agent"), ("GET", r"^/agent/known-keys$", h_known_keys, "agent"), ("GET", r"^/issues$", h_issues, "client"), ("GET", r"^/issues/([^/]+)$", h_issue, "client"), ("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"), ("POST", r"^/batches$", h_batches_post, "client"), ("GET", r"^/batches$", h_batches_get, "client"), ("GET", r"^/batches/(\d+)$", h_batch_get, "client"), ("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"), ("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"), ("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"), ("POST", r"^/refresh$", h_refresh, "client"), ("GET", r"^/agent$", h_agent_get, "client"), ("GET", r"^/sync-runs$", h_sync_runs, "client"), ("GET", r"^/settings$", h_settings_get, "client"), ("PUT", r"^/settings$", h_settings_put, "client"), ] _COMPILED = [(m, re.compile(p), fn, scope) for m, p, fn, scope in ROUTES] class Handler(BaseHTTPRequestHandler): timeout = SOCKET_TIMEOUT hub: Hub = None # подставляется в make_server def do_GET(self): self._dispatch("GET") def do_POST(self): self._dispatch("POST") def do_PUT(self): self._dispatch("PUT") def log_message(self, fmt, *args): # тише стандартного: без адреса клиента print(f"[{self.log_date_time_string()}] {fmt % args}") def _auth(self, scope): 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")): raise ApiError(401, "unauthorized", "неверный токен для этого маршрута") def _dispatch(self, method): url = urlparse(self.path) try: query = {k: v[0] for k, v in parse_qs(url.query).items()} raw_len = (self.headers.get("Content-Length") or "0").strip() if not raw_len.isascii() or not raw_len.isdigit(): raise ApiError(400, "bad_content_length", "Content-Length должен быть неотрицательным целым") length = int(raw_len) 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: if route_method != method: continue match = rx.match(url.path) if not match: continue self._auth(scope) try: body = json.loads(raw.decode("utf-8")) if raw else {} except ValueError as e: # битый JSON или не UTF-8 raise ApiError(400, "bad_json", str(e)) if not isinstance(body, dict): raise ApiError(400, "bad_json", "ожидается объект") conn = self.hub.conn() try: status, payload = fn(self.hub, conn, match, query, body) finally: conn.close() return self._send(status, payload) raise ApiError(404, "not_found", url.path) except ApiError as e: self._send(e.status, {"error": e.error, "detail": e.detail}) except Exception as e: # noqa: BLE001 — любой сбой обработчика виден клиенту и в логе traceback.print_exc() self._send(500, {"error": "internal", "detail": str(e)}) def _send(self, status, payload): data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def make_server(hub: Hub, host="0.0.0.0", port=0) -> ThreadingHTTPServer: handler = type("HubHandler", (Handler,), {"hub": hub}) return ThreadingHTTPServer((host, port), handler) def start(hub: Hub, host="0.0.0.0", port=8790) -> tuple[ThreadingHTTPServer, threading.Event]: """Создать HTTP-сервер и запустить в фоне тикер автообновления и реле общей папки. Обслуживание HTTP (serve_forever) запускает вызывающий; stop останавливает тикер и реле.""" srv = make_server(hub, host, port) stop = threading.Event() def ticker(): while not stop.wait(60): hub.tick() threading.Thread(target=ticker, daemon=True).start() target = "127.0.0.1" if host in ("", "0.0.0.0") else host threading.Thread(target=relay.Relay(hub, srv.server_port, host=target).run, args=(stop,), daemon=True).start() return srv, stop def serve(hub: Hub, host="0.0.0.0", port=8790) -> None: srv, stop = start(hub, host, port) print(f"jira-hub: http://{host}:{srv.server_port}, база {hub.db_path}", flush=True) try: srv.serve_forever() except KeyboardInterrupt: pass finally: stop.set() srv.server_close()