374 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()