- Postgres в compose; пакет db (SQLAlchemy async): clients, windows, requests. - Все эндпоинты, кроме /health, — только с ключом в X-API-Key, иначе 401; без выпущенных ключей доступа нет. В базе — только sha256 ключа. - Задача и окно сессии привязаны к клиенту: чужой ключ их не видит. - Воркер пишет журнал: окно (открыто, последнее обращение, закрыто) и задачу (в очереди, начало, конец, итог, данные без секретов). Сбой записи не роняет задачу. - python -m db.cli create|list|revoke|stats; make key-create/key-list/key-revoke/stats. - smoke и loadtest с ключом. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9dc2c0b619
commit
1a4bd8d672
34 files changed
+857
-81
No files matched your search
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Postgres (#4, #17): клиенты с API-ключами и журнал работы — окна браузера и запросы.
|
||||
|
||||
Общий для api и worker. Схема создаётся при старте сервиса (`init_db`). `DATABASE_URL` —
|
||||
`postgresql+asyncpg://...`; в тестах — `sqlite+aiosqlite://...`.
|
||||
"""
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Клиенты и API-ключи:
|
||||
|
||||
python -m db.cli create <name> # выпустить ключ: печатается один раз
|
||||
python -m db.cli list
|
||||
python -m db.cli revoke <id>
|
||||
python -m db.cli stats [--days 7] # запросы, их время и время работы браузера по клиентам
|
||||
|
||||
В compose — через make: make key-create name=..., make key-list, make key-revoke id=..., make stats days=...
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
from db import clients, stats
|
||||
from db.engine import connect
|
||||
|
||||
|
||||
async def run(args) -> None:
|
||||
db = connect()
|
||||
try:
|
||||
await db.init()
|
||||
if args.command == "create":
|
||||
client, key = await clients.create_client(db, args.name)
|
||||
print(f"client {client.id} ({client.name}), API key — сохраните, больше он не показывается:\n{key}")
|
||||
elif args.command == "list":
|
||||
for c in await clients.list_clients(db):
|
||||
state = f"revoked {c.revoked_at:%Y-%m-%d %H:%M}" if c.revoked_at else "active"
|
||||
last = f"{c.last_access_at:%Y-%m-%d %H:%M}" if c.last_access_at else "never"
|
||||
print(f"{c.id:>4} {c.key_prefix}… {state:<24} last access {last:<16} {c.name}")
|
||||
elif args.command == "revoke":
|
||||
print("revoked" if await clients.revoke(db, args.id) else "no active client with this id")
|
||||
elif args.command == "stats":
|
||||
print_stats(await stats.usage(db, stats.since_days(args.days)), args.days)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
def _ms(value: int | None) -> str:
|
||||
return "—" if value is None else f"{value / 1000:.1f}s"
|
||||
|
||||
|
||||
def print_stats(rows: list, days: float) -> None:
|
||||
print(f"за {days:g} дн.")
|
||||
print(f"{'клиент':<24} {'запросов':>8} {'ok':>6} {'ср.':>7} {'p95':>7} {'окон':>5} {'браузер, ч':>10} ошибки")
|
||||
for u in rows:
|
||||
errors = ", ".join(f"{k} {v}" for k, v in sorted(u.errors.items())) or "—"
|
||||
name = f"{u.client_id or '-'} {u.name}"[:24]
|
||||
print(
|
||||
f"{name:<24} {u.requests:>8} {u.ok:>6} {_ms(u.avg_ms):>7} {_ms(u.p95_ms):>7} "
|
||||
f"{u.windows:>5} {u.browser_seconds / 3600:>10.2f} {errors}"
|
||||
)
|
||||
if not rows:
|
||||
print("запросов не было")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(prog="python -m db.cli", description="Клиенты Robotex и их API-ключи")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
sub.add_parser("create", help="выпустить ключ новому клиенту").add_argument("name")
|
||||
sub.add_parser("list", help="список клиентов")
|
||||
sub.add_parser("revoke", help="отозвать ключ").add_argument("id", type=int)
|
||||
sub.add_parser("stats", help="статистика использования по клиентам").add_argument("--days", type=float, default=7)
|
||||
asyncio.run(run(parser.parse_args()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Клиенты и API-ключи (#4). Ключ показывается один раз при выпуске, в базе — только sha256."""
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from db.engine import Database
|
||||
from db.models import Client, utcnow
|
||||
|
||||
KEY_PREFIX = "rbx_"
|
||||
|
||||
|
||||
def hash_key(key: str) -> str:
|
||||
return hashlib.sha256(key.encode()).hexdigest()
|
||||
|
||||
|
||||
async def create_client(db: Database, name: str) -> tuple[Client, str]:
|
||||
key = KEY_PREFIX + secrets.token_urlsafe(32)
|
||||
client = Client(name=name, key_hash=hash_key(key), key_prefix=key[:12])
|
||||
async with db.session() as session, session.begin():
|
||||
session.add(client)
|
||||
return client, key
|
||||
|
||||
|
||||
async def authenticate(db: Database, key: str | None) -> Client | None:
|
||||
"""Клиент по ключу, если ключ не отозван; отмечает время последнего обращения."""
|
||||
if not key:
|
||||
return None
|
||||
async with db.session() as session, session.begin():
|
||||
client = await session.scalar(
|
||||
select(Client).where(Client.key_hash == hash_key(key), Client.revoked_at.is_(None))
|
||||
)
|
||||
if client:
|
||||
client.last_access_at = utcnow()
|
||||
return client
|
||||
|
||||
|
||||
async def revoke(db: Database, client_id: int) -> bool:
|
||||
async with db.session() as session, session.begin():
|
||||
result = await session.execute(
|
||||
update(Client).where(Client.id == client_id, Client.revoked_at.is_(None)).values(revoked_at=utcnow())
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
async def list_clients(db: Database) -> list[Client]:
|
||||
async with db.session() as session:
|
||||
return list(await session.scalars(select(Client).order_by(Client.id)))
|
||||
@@ -0,0 +1,30 @@
|
||||
import os
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
||||
|
||||
from db.models import Base
|
||||
|
||||
DEFAULT_URL = "postgresql+asyncpg://robotex:robotex@localhost:5432/robotex"
|
||||
# api и worker создают схему при старте одновременно — в Postgres через advisory lock
|
||||
_SCHEMA_LOCK = 780078
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, engine: AsyncEngine):
|
||||
self.engine = engine
|
||||
self.session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async def init(self) -> None:
|
||||
"""Создать недостающие таблицы."""
|
||||
async with self.engine.begin() as conn:
|
||||
if conn.dialect.name == "postgresql":
|
||||
await conn.execute(text(f"SELECT pg_advisory_xact_lock({_SCHEMA_LOCK})"))
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.engine.dispose()
|
||||
|
||||
|
||||
def connect(url: str | None = None) -> Database:
|
||||
return Database(create_async_engine(url or os.getenv("DATABASE_URL", DEFAULT_URL), pool_pre_ping=True))
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Журнал воркера (#17): окна браузера и задачи. Ошибка записи не роняет задачу — только предупреждение в лог.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import update
|
||||
|
||||
from db.engine import Database
|
||||
from db.models import Request, Window, utcnow
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Journal:
|
||||
def __init__(self, db: Database | None):
|
||||
self.db = db
|
||||
|
||||
async def _write(self, what: str, fn):
|
||||
if self.db is None:
|
||||
return None
|
||||
try:
|
||||
async with self.db.session() as session, session.begin():
|
||||
return await fn(session)
|
||||
except Exception:
|
||||
log.warning("journal: %s failed", what, exc_info=True)
|
||||
return None
|
||||
|
||||
async def window_opened(self, client_id: int | None, session_id: str, task_id: str, worker_id: str) -> int | None:
|
||||
async def write(session):
|
||||
window = Window(client_id=client_id, session_id=session_id, task_id=task_id, worker_id=worker_id)
|
||||
session.add(window)
|
||||
await session.flush()
|
||||
return window.id
|
||||
return await self._write("window opened", write)
|
||||
|
||||
async def window_used(self, window_id: int | None) -> None:
|
||||
if window_id is not None:
|
||||
await self._write("window used", lambda s: s.execute(
|
||||
update(Window).where(Window.id == window_id).values(last_access_at=utcnow())
|
||||
))
|
||||
|
||||
async def window_closed(self, window_id: int | None) -> None:
|
||||
if window_id is not None:
|
||||
await self._write("window closed", lambda s: s.execute(
|
||||
update(Window).where(Window.id == window_id).values(closed_at=utcnow())
|
||||
))
|
||||
|
||||
async def request_started(
|
||||
self, task_id: str, client_id: int | None, session_id: str, api: str,
|
||||
created_at: float | None, task_data: dict,
|
||||
) -> None:
|
||||
created = datetime.fromtimestamp(created_at, timezone.utc) if created_at else None
|
||||
|
||||
async def write(session):
|
||||
session.add(Request(
|
||||
task_id=task_id, client_id=client_id, session_id=session_id, api=api,
|
||||
created_at=created, task_data=task_data,
|
||||
))
|
||||
await self._write("request started", write)
|
||||
|
||||
async def request_finished(self, task_id: str, window_id: int | None, result: dict | None) -> None:
|
||||
"""result — ответ задачи; None — задачу не удалось выполнить (status failed)."""
|
||||
values = {"finished_at": utcnow(), "window_id": window_id, "status": "failed"}
|
||||
if result is not None:
|
||||
values.update(status=result.get("status"), code=result.get("code"), duration_ms=result.get("duration_ms"))
|
||||
await self._write("request finished", lambda s: s.execute(
|
||||
update(Request).where(Request.task_id == task_id).values(**values)
|
||||
))
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Таблицы:
|
||||
|
||||
* `clients` — клиенты API («Users» в #17): ключ (только sha256), время создания и последнего обращения.
|
||||
* `windows` — окна браузера: когда открыто и закрыто, последнее обращение, клиент, задача, открывшая окно.
|
||||
Из них считается время, которое браузер был активен.
|
||||
* `requests` — задачи: создание (постановка в очередь), начало и конец выполнения, итог, данные задачи без
|
||||
секретов (прокси, ключ 2captcha, вводимые значения и значения cookies не пишутся).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
type_annotation_map = {datetime: DateTime(timezone=True)}
|
||||
|
||||
|
||||
class Client(Base):
|
||||
__tablename__ = "clients"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
# sha256 ключа: сам ключ показывается один раз при выпуске и нигде не хранится
|
||||
key_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
# начало ключа — чтобы отличать ключи в списке
|
||||
key_prefix: Mapped[str] = mapped_column(String(16))
|
||||
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
last_access_at: Mapped[datetime | None]
|
||||
revoked_at: Mapped[datetime | None]
|
||||
|
||||
|
||||
class Window(Base):
|
||||
__tablename__ = "windows"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id"), index=True)
|
||||
session_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
# задача, открывшая окно
|
||||
task_id: Mapped[str] = mapped_column(String(36))
|
||||
worker_id: Mapped[str] = mapped_column(String(100))
|
||||
started_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
last_access_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
closed_at: Mapped[datetime | None]
|
||||
|
||||
|
||||
class Request(Base):
|
||||
__tablename__ = "requests"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
task_id: Mapped[str] = mapped_column(String(36), unique=True)
|
||||
client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id"), index=True)
|
||||
session_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
window_id: Mapped[int | None] = mapped_column(ForeignKey("windows.id"))
|
||||
# api: /v1/solve или /v2/tasks
|
||||
api: Mapped[str] = mapped_column(String(8))
|
||||
created_at: Mapped[datetime | None]
|
||||
started_at: Mapped[datetime] = mapped_column(default=utcnow, index=True)
|
||||
finished_at: Mapped[datetime | None]
|
||||
duration_ms: Mapped[int | None]
|
||||
# ok / err / failed; code — как в ответе (challenge_failed, action_failed, ...)
|
||||
status: Mapped[str | None] = mapped_column(String(16))
|
||||
code: Mapped[str | None] = mapped_column(String(32))
|
||||
task_data: Mapped[dict] = mapped_column(JSON)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Статистика использования API (#17): число запросов, их время и время, которое был открыт браузер."""
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from db.engine import Database
|
||||
from db.models import Client, Request, Window, utcnow
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientUsage:
|
||||
client_id: int | None
|
||||
name: str
|
||||
requests: int = 0
|
||||
ok: int = 0
|
||||
# по code: challenge_failed, action_failed, request_failed; failed — задачу не выполнили
|
||||
errors: dict[str, int] = field(default_factory=dict)
|
||||
durations_ms: list[int] = field(default_factory=list)
|
||||
windows: int = 0
|
||||
browser_seconds: float = 0.0
|
||||
|
||||
@property
|
||||
def avg_ms(self) -> int | None:
|
||||
return int(sum(self.durations_ms) / len(self.durations_ms)) if self.durations_ms else None
|
||||
|
||||
@property
|
||||
def p95_ms(self) -> int | None:
|
||||
if not self.durations_ms:
|
||||
return None
|
||||
ordered = sorted(self.durations_ms)
|
||||
return ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))]
|
||||
|
||||
|
||||
def _aware(dt: datetime) -> datetime:
|
||||
# sqlite отдаёт время без зоны
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=utcnow().tzinfo)
|
||||
|
||||
|
||||
async def usage(db: Database, since: datetime) -> list[ClientUsage]:
|
||||
"""По клиентам за период: запросы, начатые после since, и время окон в пределах периода."""
|
||||
now = utcnow()
|
||||
async with db.session() as session:
|
||||
names = {c.id: c.name for c in await session.scalars(select(Client))}
|
||||
requests = list(await session.scalars(select(Request).where(Request.started_at >= since)))
|
||||
windows = list(await session.scalars(
|
||||
select(Window).where((Window.closed_at.is_(None)) | (Window.closed_at >= since))
|
||||
))
|
||||
|
||||
result: dict[int | None, ClientUsage] = {}
|
||||
|
||||
def entry(client_id):
|
||||
return result.setdefault(client_id, ClientUsage(client_id, names.get(client_id, "—")))
|
||||
|
||||
for r in requests:
|
||||
u = entry(r.client_id)
|
||||
u.requests += 1
|
||||
if r.status == "ok":
|
||||
u.ok += 1
|
||||
elif r.finished_at is not None:
|
||||
key = r.code or r.status or "unknown"
|
||||
u.errors[key] = u.errors.get(key, 0) + 1
|
||||
if r.duration_ms is not None:
|
||||
u.durations_ms.append(r.duration_ms)
|
||||
|
||||
for w in windows:
|
||||
u = entry(w.client_id)
|
||||
u.windows += 1
|
||||
start = max(_aware(w.started_at), since)
|
||||
end = _aware(w.closed_at) if w.closed_at else now
|
||||
u.browser_seconds += max(0.0, (end - start).total_seconds())
|
||||
|
||||
return sorted(result.values(), key=lambda u: -u.requests)
|
||||
|
||||
|
||||
def since_days(days: float) -> datetime:
|
||||
return utcnow() - timedelta(days=days)
|
||||
Reference in new issue
Block a user