703 lines
34 KiB
Python
703 lines
34 KiB
Python
"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос).
|
||
Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).
|
||
Без токена — статика без данных: веб-приложение (GET /app…) и перенаправление старых адресов доски (GET /, GET /links)."""
|
||
import base64
|
||
import hmac
|
||
import json
|
||
import logging
|
||
import re
|
||
import threading
|
||
import time
|
||
import traceback
|
||
from datetime import timedelta
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path
|
||
from typing import NamedTuple
|
||
from urllib.parse import parse_qs, quote, unquote, urlparse
|
||
|
||
from . import backup, db, files, projects, queue, relay, release, settings
|
||
from ..modules.jira import attachments, fetch, history, mirror, refresh # временно: R-1, до Task 5
|
||
from ..modules.jira import projects as jira_projects
|
||
from .errors import CommandError
|
||
from .paths import backups_dir as default_backups_dir
|
||
from .paths import secure_dir
|
||
from .queue import ClaimedError
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт
|
||
WEB_APP = Path(__file__).resolve().parent.parent / "web" / "app" # сборка веб-приложения (make web, не в git); читается на каждый запрос
|
||
ASSET_TYPES = {".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml",
|
||
".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".map": "application/json"}
|
||
ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # имена файлов сборки содержат хэш (R-3)
|
||
NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните "
|
||
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").encode("utf-8")
|
||
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
|
||
MIME_RE = re.compile(r"[\w!#$&^.+-]+/[\w!#$&^.+-]+", re.ASCII) # тип/подтип без параметров, пробелов и не-ASCII: заголовок не внушить
|
||
SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений»)
|
||
|
||
|
||
class Raw(NamedTuple):
|
||
"""Не-JSON ответ обработчика (веб-приложение, перенаправление). headers=None — Cache-Control: no-cache."""
|
||
content_type: str
|
||
data: bytes
|
||
headers: dict | None = None
|
||
|
||
|
||
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, release_dir=None, web_dir=None, files_dir=None):
|
||
self.db_path = Path(db_path)
|
||
self.web_dir = Path(web_dir) if web_dir else WEB_APP
|
||
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
|
||
self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release"
|
||
self.files_dir = Path(files_dir) if files_dir else self.db_path.parent / "files"
|
||
self.downloads_dir = self.db_path.parent / "downloads" # копии вложений для MCP; каталог создаёт MCP
|
||
self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15)
|
||
# последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version
|
||
# неизвестен, а не null (hub-hygiene-d, решение 7)
|
||
self.agent_update = {"update_state": None, "launcher_version": None, "reported": False}
|
||
self.claim_wait = 30
|
||
secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии
|
||
secure_dir(self.files_dir) # files/ — 0700 при каждом старте, как backups/
|
||
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()
|
||
refresh.maybe_auto_refresh(conn)
|
||
except Exception: # noqa: BLE001
|
||
log.exception("автообновление: сбой тика")
|
||
try:
|
||
if conn is not None:
|
||
self.sweep_files(conn)
|
||
except Exception: # noqa: BLE001
|
||
log.exception("уборка файлов: сбой")
|
||
finally:
|
||
if conn is not None:
|
||
conn.close()
|
||
|
||
def sweep_files(self, conn, now=None):
|
||
"""Уборка files/ (files.sweep) не чаще раза в SWEEP_EVERY; время прошлой — в памяти процесса, после
|
||
перезапуска хаба первая уборка — на первом тике (решение 15). None — ещё рано."""
|
||
now = time.time() if now is None else now
|
||
if self.last_sweep is not None and now - self.last_sweep < SWEEP_EVERY:
|
||
return None
|
||
self.last_sweep = now
|
||
removed = files.sweep(conn, self.files_dir, now)
|
||
if removed:
|
||
log.info("уборка файлов: удалено %d", len(removed))
|
||
try:
|
||
gone = files.sweep_downloads(self.downloads_dir, now)
|
||
if gone:
|
||
log.info("уборка копий вложений: удалено %d", len(gone))
|
||
except Exception: # noqa: BLE001 — копии MCP не должны мешать основной уборке
|
||
log.exception("уборка копий вложений: сбой")
|
||
return removed
|
||
|
||
def note_agent_update(self, body):
|
||
"""update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3).
|
||
Значение неверного типа игнорируется — claim из-за него не отвергается; сам claim отмечается (reported)."""
|
||
new = dict(self.agent_update)
|
||
new["reported"] = True
|
||
us = body.get("update_state")
|
||
if us is None or isinstance(us, dict):
|
||
if us is not None and isinstance(us.get("error"), str):
|
||
us = {**us, "error": us["error"][:500]}
|
||
new["update_state"] = us
|
||
lv = body.get("launcher_version")
|
||
if lv is None or (isinstance(lv, int) and not isinstance(lv, bool)):
|
||
new["launcher_version"] = lv
|
||
self.agent_update = new
|
||
|
||
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):
|
||
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)
|
||
if cmd or time.monotonic() >= deadline:
|
||
break
|
||
time.sleep(1)
|
||
try: # команда уже выдана: публикация не должна превращать ответ в 500 (I-1)
|
||
rel = release.current(hub.release_dir)
|
||
except Exception: # noqa: BLE001
|
||
log.exception("release: чтение публикации при claim")
|
||
rel = None
|
||
return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version"),
|
||
"agent_release": {"version": rel["version"], "sha256": rel["sha256"]} if rel else None}
|
||
|
||
|
||
def h_agent_release(hub, conn, m, q, body):
|
||
rel = release.load(hub.release_dir)
|
||
if rel is None:
|
||
raise ApiError(404, "no_release", "версия агента не опубликована")
|
||
return 200, rel
|
||
|
||
|
||
def h_agent_file(hub, conn, m, q, body):
|
||
"""Файл вложения агенту: {file, size, content}, content — base64 (проходит и реле папки, и HTTP). id не из 64 hex
|
||
нижнего регистра — тот же 404 no_file (решение 16)."""
|
||
data = files.read(hub.files_dir, m.group(1))
|
||
if data is None:
|
||
raise ApiError(404, "no_file", f"файла {m.group(1)} нет в хранилище хаба")
|
||
return 200, {"file": m.group(1), "size": len(data), "content": base64.b64encode(data).decode("ascii")}
|
||
|
||
|
||
def h_agent_settings(hub, conn, m, q, body):
|
||
return 200, settings.for_agent(conn)
|
||
|
||
|
||
def h_agent_command(hub, conn, m, q, body):
|
||
"""Команда по id для восстановления из журнала агента: параметры — как при выдаче (с size у ensure_attachment)."""
|
||
cmd = _not_found(queue.get_command(conn, _int(m)), "command")
|
||
return 200, {**cmd, "params": queue.deliver_params(conn, cmd, hub.files_dir)}
|
||
|
||
|
||
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), files_dir=hub.files_dir)
|
||
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):
|
||
issues = body.get("issues") or []
|
||
try:
|
||
mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком
|
||
mirror.check_attachments(issues)
|
||
except ValueError as e:
|
||
raise ApiError(422, "bad_state", str(e))
|
||
with db.transaction(conn):
|
||
out = mirror.apply_state(conn, issues, body.get("links") or [], body.get("neighbor_keys") or [],
|
||
authoritative=refresh.is_full_refresh(conn, body.get("command_id")))
|
||
if body.get("command_id"):
|
||
refresh.record_state(conn, body["command_id"], out["issues"], out["links"])
|
||
return 200, out
|
||
|
||
|
||
def h_agent_history(hub, conn, m, q, body):
|
||
changes = body.get("changes")
|
||
try:
|
||
history.check_changes(changes) # до записи: неверная пачка не применяется целиком
|
||
except ValueError as e:
|
||
raise ApiError(422, "bad_history", str(e))
|
||
return 200, history.record_changes(conn, changes)
|
||
|
||
|
||
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", "hide_closed", "project")
|
||
|
||
|
||
def _project_param(conn, q) -> str | None:
|
||
"""q["project"] — метка существующего проекта; без параметра — None, неизвестная метка — 404."""
|
||
label = q.get("project")
|
||
if label in (None, ""):
|
||
return None
|
||
if projects.get(conn, label) is None:
|
||
raise ApiError(404, "not_found", f"project {label}")
|
||
return label
|
||
|
||
|
||
def h_issues(hub, conn, m, q, body):
|
||
project = _project_param(conn, q)
|
||
filters = {k: q.get(k) for k in ISSUE_FILTERS}
|
||
filters["project"] = project
|
||
closed = settings.get(conn, "closed_statuses")
|
||
closed = [s for s in closed if isinstance(s, str)] if isinstance(closed, list) else []
|
||
return 200, {"issues": mirror.list_issues(conn, filters, _int_param(q, "limit", 50), _int_param(q, "offset", 0),
|
||
closed_statuses=closed)}
|
||
|
||
|
||
def h_issue(hub, conn, m, q, body):
|
||
return 200, _not_found(mirror.get_issue(conn, m.group(1), hub.files_dir), "issue")
|
||
|
||
|
||
_OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$")
|
||
|
||
|
||
def _time_param(q, name, default):
|
||
raw = q.get(name)
|
||
if raw in (None, ""):
|
||
return default
|
||
value = history.jira_utc(raw) if _OFFSET.search(raw) else None # без смещения — не угадываем пояс (M-2)
|
||
if value is None:
|
||
raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z")
|
||
return value
|
||
|
||
|
||
def h_changes(hub, conn, m, q, body):
|
||
project = _project_param(conn, q)
|
||
until = _time_param(q, "until", db.now())
|
||
since = _time_param(q, "since", (db.parse(until) - timedelta(hours=24)).isoformat())
|
||
if since > until:
|
||
raise ApiError(422, "bad_request", "since: позже until")
|
||
try:
|
||
kinds = history.parse_kinds(q.get("kinds"))
|
||
except ValueError as e:
|
||
raise ApiError(422, "bad_request", str(e))
|
||
try:
|
||
limit = int(q.get("limit", 500))
|
||
except (TypeError, ValueError):
|
||
limit = 0
|
||
if not 1 <= limit <= 2000:
|
||
raise ApiError(422, "bad_request", "limit: целое от 1 до 2000")
|
||
return 200, history.feed(conn, since, until, kinds, project=project, key=q.get("key") or None, limit=limit)
|
||
|
||
|
||
def h_board(hub, conn, m, q, body):
|
||
return 200, mirror.board(conn, jira_projects.base_jql(conn), _project_param(conn, q))
|
||
|
||
|
||
def h_app(hub, conn, m, q, body):
|
||
"""Веб-приложение: файлы assets/ сборки как есть, любой другой путь /app… — index.html (маршруты на клиенте).
|
||
Путь файла после разрешения обязан лежать внутри assets/, иначе 404; без сборки — 503 с подсказкой."""
|
||
index = hub.web_dir / "index.html"
|
||
if not index.is_file():
|
||
return 503, Raw("text/html; charset=utf-8", NOT_BUILT)
|
||
rest = unquote(m.group(1) or "")
|
||
if rest.startswith("assets/"):
|
||
if "\x00" in rest: # T1.1: нулевой байт — 404, а не 500 из resolve()
|
||
raise ApiError(404, "not_found", "/app/" + rest.replace("\x00", "%00"))
|
||
assets = (hub.web_dir / "assets").resolve()
|
||
try:
|
||
f = (hub.web_dir / rest).resolve()
|
||
except (OSError, ValueError):
|
||
raise ApiError(404, "not_found", "/app/" + rest) from None
|
||
if not f.is_relative_to(assets) or not f.is_file():
|
||
raise ApiError(404, "not_found", "/app/" + rest)
|
||
return 200, Raw(ASSET_TYPES.get(f.suffix, "application/octet-stream"), f.read_bytes(), ASSET_CACHE)
|
||
return 200, Raw("text/html; charset=utf-8", index.read_bytes())
|
||
|
||
|
||
def h_root(hub, conn, m, q, body):
|
||
"""Старые адреса доски (/, /links) — на страницу доски веб-приложения."""
|
||
return 302, Raw("text/html; charset=utf-8", b"", {"Location": "/app/board", "Cache-Control": "no-cache"})
|
||
|
||
|
||
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 — список команд")
|
||
if body.get("project") is not None and not isinstance(body["project"], str):
|
||
raise ApiError(422, "bad_batch", "project — строка")
|
||
if body.get("project") == "":
|
||
raise ApiError(422, "bad_batch", "project: пустая строка")
|
||
try:
|
||
return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"),
|
||
files_dir=hub.files_dir, project=body.get("project"))
|
||
except fetch.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (M-2)
|
||
raise ApiError(413, "too_large", str(e)) from None
|
||
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), project=q.get("project") or None)}
|
||
|
||
|
||
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 _put_file(hub, body, allow_empty=False):
|
||
"""Общая часть POST /files и POST /agent/files: {content: base64} → {file: sha256, size}. Пробельные символы
|
||
(переносы строк base64) отбрасываются до строгой проверки (решение 14)."""
|
||
content = body.get("content")
|
||
if not isinstance(content, str):
|
||
raise ApiError(400, "bad_request", "content — строка base64")
|
||
try:
|
||
data = base64.b64decode("".join(content.split()), validate=True)
|
||
except ValueError: # binascii.Error — подкласс ValueError
|
||
raise ApiError(400, "bad_request", "content — не base64") from None
|
||
try:
|
||
sha, size = files.put(hub.files_dir, data, allow_empty=allow_empty)
|
||
except files.EmptyFile:
|
||
raise ApiError(422, "empty_file", "пустой файл") from None
|
||
except files.TooLarge:
|
||
raise ApiError(413, "too_large", f"файл больше {files.MAX_SIZE} байт ({len(data)})") from None
|
||
return 200, {"file": sha, "size": size}
|
||
|
||
|
||
def h_files_post(hub, conn, m, q, body):
|
||
return _put_file(hub, body)
|
||
|
||
|
||
def h_agent_files_post(hub, conn, m, q, body):
|
||
"""Файл от агента (fetch_attachment, решение 5): как POST /files, но пустой файл принимается (решение 13)."""
|
||
return _put_file(hub, body, allow_empty=True)
|
||
|
||
|
||
def _attachment(hub, conn, m):
|
||
att = attachments.get(conn, hub.files_dir, m.group(1))
|
||
if att is None:
|
||
raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу")
|
||
return att
|
||
|
||
|
||
def h_attachment_get(hub, conn, m, q, body):
|
||
return 200, _attachment(hub, conn, m)
|
||
|
||
|
||
def h_attachment_fetch(hub, conn, m, q, body):
|
||
author = body.get("author") or "api"
|
||
if not isinstance(author, str):
|
||
raise ApiError(422, "bad_request", "author — строка")
|
||
try:
|
||
return 200, fetch.request_fetch(conn, hub.files_dir, m.group(1), author)
|
||
except KeyError:
|
||
raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу") from None
|
||
except fetch.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (решение 17)
|
||
raise ApiError(413, "too_large", str(e)) from None
|
||
except fetch.AgentTooOld as e:
|
||
raise ApiError(409, "agent_too_old", str(e)) from None
|
||
except CommandError as e:
|
||
raise ApiError(422, "bad_batch", str(e)) from None
|
||
|
||
|
||
def h_attachment_content(hub, conn, m, q, body):
|
||
"""Содержимое скачанного вложения; mime — из вложения, негодный → octet-stream; имя — filename* (RFC 5987)."""
|
||
att = _attachment(hub, conn, m)
|
||
# state == ready означает, что запись attachment_file есть и файл в хранилище на месте (attachments.state)
|
||
row = conn.execute("SELECT file FROM attachment_file WHERE attachment_id = ?", (m.group(1),)).fetchone() \
|
||
if att["state"] == "ready" else None
|
||
data = files.read(hub.files_dir, row["file"]) if row else None
|
||
if data is None:
|
||
raise ApiError(404, "not_fetched", "вложение не скачано — запросите скачивание")
|
||
files.touch(hub.files_dir, row["file"])
|
||
mime = att["mime_type"] if isinstance(att["mime_type"], str) and MIME_RE.fullmatch(att["mime_type"]) else "application/octet-stream"
|
||
return 200, Raw(mime, data, {"Content-Disposition": "attachment; filename*=UTF-8''" + quote(att["filename"], safe=""),
|
||
"X-Content-Type-Options": "nosniff", "Cache-Control": "no-cache"})
|
||
|
||
|
||
def h_command_retry(hub, conn, m, q, body):
|
||
if not isinstance(body.get("author") or "api", str):
|
||
raise ApiError(422, "bad_retry", "author — строка")
|
||
if body.get("project") is not None and not isinstance(body["project"], str):
|
||
raise ApiError(422, "bad_retry", "project — строка")
|
||
if body.get("project") == "":
|
||
raise ApiError(422, "bad_retry", "project: пустая строка")
|
||
try:
|
||
return 201, queue.retry_command(conn, _int(m), body.get("author") or "api", files_dir=hub.files_dir,
|
||
project=body.get("project"))
|
||
except KeyError:
|
||
raise ApiError(404, "not_found", "command")
|
||
except fetch.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (M-2)
|
||
raise ApiError(413, "too_large", str(e)) from None
|
||
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"):
|
||
jql = jira_projects.base_jql(conn)
|
||
if jql is None:
|
||
raise ApiError(422, "bad_refresh", "нет проектов")
|
||
params = {"jql": 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):
|
||
view = queue.agent_view(conn)
|
||
rel = release.current(hub.release_dir)
|
||
au = hub.agent_update
|
||
us, lv, reported = au["update_state"], au["launcher_version"], au["reported"]
|
||
view.update(release=rel, update_state=us, launcher_version=lv, agent_reported=reported,
|
||
update_hint=release.hint(rel, view, us, lv, reported=reported))
|
||
return 200, view
|
||
|
||
|
||
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), "base_jql": jira_projects.base_jql(conn)}
|
||
|
||
|
||
def h_settings_put(hub, conn, m, q, body):
|
||
"""Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения
|
||
(settings.validate): ошибка — 422, версия настроек не меняется. Ответ — все настройки после записи, как у
|
||
GET /settings (hub-hygiene-d, решение 9)."""
|
||
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))
|
||
with db.transaction(conn):
|
||
for key, value in body.items():
|
||
settings.set_(conn, key, value)
|
||
return 200, {**settings.get_all(conn), "base_jql": jira_projects.base_jql(conn)}
|
||
|
||
|
||
def h_projects_get(hub, conn, m, q, body):
|
||
return 200, {"projects": projects.list_projects(conn)}
|
||
|
||
|
||
def h_projects_post(hub, conn, m, q, body):
|
||
"""Новый проект: проект и пакет «Новый проект <метка>» (один refresh по базовой JQL с новой меткой) — в одной
|
||
транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»)."""
|
||
author = body.get("author")
|
||
if author is None or author == "":
|
||
author = "api"
|
||
elif not isinstance(author, str):
|
||
raise ApiError(422, "bad_project", "author — строка")
|
||
try:
|
||
with db.transaction(conn):
|
||
project = projects.create(conn, body.get("label"), body.get("name"), body.get("board_id"))
|
||
batch = queue.create_batch(conn, f"Новый проект {project['label']}", author,
|
||
[{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
|
||
except projects.ProjectExists as e:
|
||
raise ApiError(409, "project_exists", str(e))
|
||
except projects.ProjectError as e:
|
||
raise ApiError(422, "bad_project", str(e))
|
||
except CommandError as e:
|
||
raise ApiError(422, "bad_batch", str(e))
|
||
return 201, {"project": project, "batch_id": batch["id"]}
|
||
|
||
|
||
ROUTES = [
|
||
("GET", r"^/(links)?$", h_root, None),
|
||
("GET", r"^/app(?:/(.*))?$", h_app, None),
|
||
("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"),
|
||
("POST", r"^/agent/history$", h_agent_history, "agent"),
|
||
("GET", r"^/agent/known-keys$", h_known_keys, "agent"),
|
||
("GET", r"^/agent/release$", h_agent_release, "agent"),
|
||
("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"),
|
||
("POST", r"^/agent/files$", h_agent_files_post, "agent"),
|
||
("GET", r"^/issues$", h_issues, "client"),
|
||
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
|
||
("GET", r"^/board$", h_board, "client"),
|
||
("GET", r"^/changes$", h_changes, "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"^/files$", h_files_post, "client"),
|
||
("GET", r"^/attachments/(\d+)$", h_attachment_get, "client"),
|
||
("POST", r"^/attachments/(\d+)/fetch$", h_attachment_fetch, "client"),
|
||
("GET", r"^/attachments/(\d+)/content$", h_attachment_content, "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"),
|
||
("GET", r"^/projects$", h_projects_get, "client"),
|
||
("POST", r"^/projects$", h_projects_post, "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
|
||
if scope is not None:
|
||
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):
|
||
if isinstance(payload, Raw):
|
||
ctype, data = payload.content_type, payload.data
|
||
extra = payload.headers if payload.headers is not None else {"Cache-Control": "no-cache"}
|
||
else:
|
||
ctype, extra = "application/json; charset=utf-8", {}
|
||
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", ctype)
|
||
self.send_header("Content-Length", str(len(data)))
|
||
for name, value in extra.items():
|
||
self.send_header(name, value)
|
||
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()
|