hawser-server 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hawser_server/__init__.py +11 -0
- hawser_server/app.py +115 -0
- hawser_server/auth.py +187 -0
- hawser_server/cli.py +255 -0
- hawser_server/config.py +90 -0
- hawser_server/db.py +143 -0
- hawser_server/deps.py +68 -0
- hawser_server/errors.py +125 -0
- hawser_server/jobs.py +140 -0
- hawser_server/migrations.py +66 -0
- hawser_server/py.typed +0 -0
- hawser_server/routes/__init__.py +1 -0
- hawser_server/routes/approvals.py +50 -0
- hawser_server/routes/artifacts.py +41 -0
- hawser_server/routes/assets.py +90 -0
- hawser_server/routes/catalog.py +23 -0
- hawser_server/routes/evals.py +143 -0
- hawser_server/routes/examples.py +50 -0
- hawser_server/routes/reviews.py +144 -0
- hawser_server/routes/runs.py +286 -0
- hawser_server/routes/settings.py +58 -0
- hawser_server/routes/system.py +27 -0
- hawser_server/routes/webhooks.py +69 -0
- hawser_server/schemas.py +167 -0
- hawser_server/services.py +118 -0
- hawser_server/sse.py +58 -0
- hawser_server/static/.gitkeep +0 -0
- hawser_server/static/assets/index-BBvdMNrB.css +1 -0
- hawser_server/static/assets/index-C3C5r10w.js +264 -0
- hawser_server/static/index.html +13 -0
- hawser_server/storage.py +92 -0
- hawser_server/testing.py +62 -0
- hawser_server/util.py +41 -0
- hawser_server/webhooks.py +326 -0
- hawser_server/worker.py +190 -0
- hawser_server-0.1.0.dist-info/METADATA +70 -0
- hawser_server-0.1.0.dist-info/RECORD +40 -0
- hawser_server-0.1.0.dist-info/WHEEL +4 -0
- hawser_server-0.1.0.dist-info/entry_points.txt +2 -0
- hawser_server-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Hawser control plane: ``hawserd`` (ADR-0010)."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _pkg_version
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = _pkg_version("hawser-server")
|
|
8
|
+
except PackageNotFoundError: # running from a source tree without installed metadata
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__"]
|
hawser_server/app.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""The FastAPI application. ``create_app(services)`` wires routes, errors and request ids."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from importlib import resources
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI, Request, Response
|
|
11
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
12
|
+
from fastapi.responses import FileResponse, RedirectResponse
|
|
13
|
+
|
|
14
|
+
from hawser.models import CONTRACT_VERSION
|
|
15
|
+
from hawser_server import __version__
|
|
16
|
+
from hawser_server.errors import install_error_handlers
|
|
17
|
+
from hawser_server.routes import (
|
|
18
|
+
approvals,
|
|
19
|
+
artifacts,
|
|
20
|
+
assets,
|
|
21
|
+
catalog,
|
|
22
|
+
evals,
|
|
23
|
+
examples,
|
|
24
|
+
reviews,
|
|
25
|
+
runs,
|
|
26
|
+
settings,
|
|
27
|
+
system,
|
|
28
|
+
webhooks,
|
|
29
|
+
)
|
|
30
|
+
from hawser_server.services import Services
|
|
31
|
+
|
|
32
|
+
DESCRIPTION = (
|
|
33
|
+
"Hawser control plane. Bodies and responses are the contract models published under "
|
|
34
|
+
"`api/schemas/`; errors are `application/problem+json` with the error-taxonomy `code`."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_app(services: Services) -> FastAPI:
|
|
39
|
+
app = FastAPI(
|
|
40
|
+
title="Hawser API",
|
|
41
|
+
version=f"{CONTRACT_VERSION}+hawserd.{__version__}",
|
|
42
|
+
description=DESCRIPTION,
|
|
43
|
+
docs_url="/docs",
|
|
44
|
+
openapi_url="/openapi.json",
|
|
45
|
+
)
|
|
46
|
+
app.state.services = services
|
|
47
|
+
install_error_handlers(app)
|
|
48
|
+
|
|
49
|
+
@app.middleware("http")
|
|
50
|
+
async def _request_id(
|
|
51
|
+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
52
|
+
) -> Response:
|
|
53
|
+
rid = request.headers.get("X-Hawser-Request-Id") or f"req_{uuid.uuid4().hex[:20]}"
|
|
54
|
+
request.state.request_id = rid
|
|
55
|
+
response = await call_next(request)
|
|
56
|
+
response.headers["X-Hawser-Request-Id"] = rid
|
|
57
|
+
return response
|
|
58
|
+
|
|
59
|
+
if services.config.cors_origins:
|
|
60
|
+
app.add_middleware(
|
|
61
|
+
CORSMiddleware,
|
|
62
|
+
allow_origins=services.config.cors_origins,
|
|
63
|
+
allow_methods=["*"],
|
|
64
|
+
allow_headers=["*"],
|
|
65
|
+
expose_headers=["ETag", "Location", "X-Hawser-Request-Id", "X-Hawser-Dpi"],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
for r in (
|
|
69
|
+
system,
|
|
70
|
+
assets,
|
|
71
|
+
runs,
|
|
72
|
+
reviews,
|
|
73
|
+
approvals,
|
|
74
|
+
catalog,
|
|
75
|
+
artifacts,
|
|
76
|
+
webhooks,
|
|
77
|
+
evals,
|
|
78
|
+
settings,
|
|
79
|
+
examples,
|
|
80
|
+
):
|
|
81
|
+
app.include_router(r.router)
|
|
82
|
+
_mount_web(app, services.config.web_dir)
|
|
83
|
+
return app
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def web_dir(configured: Path | None) -> Path | None:
|
|
87
|
+
"""The built web app: an explicit directory, else the package's ``static`` folder."""
|
|
88
|
+
if configured is not None:
|
|
89
|
+
return configured if (configured / "index.html").exists() else None
|
|
90
|
+
with resources.as_file(resources.files("hawser_server") / "static") as path:
|
|
91
|
+
bundled = Path(path)
|
|
92
|
+
return bundled if (bundled / "index.html").exists() else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _mount_web(app: FastAPI, configured: Path | None) -> None:
|
|
96
|
+
root = web_dir(configured)
|
|
97
|
+
if root is None:
|
|
98
|
+
return
|
|
99
|
+
index = root / "index.html"
|
|
100
|
+
|
|
101
|
+
@app.get("/", include_in_schema=False)
|
|
102
|
+
async def _root() -> RedirectResponse:
|
|
103
|
+
return RedirectResponse("/app/")
|
|
104
|
+
|
|
105
|
+
@app.get("/app", include_in_schema=False)
|
|
106
|
+
@app.get("/app/{path:path}", include_in_schema=False)
|
|
107
|
+
async def _app(path: str = "") -> FileResponse:
|
|
108
|
+
# Single-page app: real files are served as-is, every other path gets index.html.
|
|
109
|
+
candidate = (root / path).resolve() if path else index
|
|
110
|
+
if path and candidate.is_file() and root.resolve() in candidate.parents:
|
|
111
|
+
return FileResponse(candidate)
|
|
112
|
+
return FileResponse(index)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
__all__ = ["create_app"]
|
hawser_server/auth.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""API keys and tenants.
|
|
2
|
+
|
|
3
|
+
Keys are ``hk_live_<40 url-safe chars>``, shown once, stored as an argon2id hash. Lookup is by
|
|
4
|
+
a short hash prefix (so any token shape works for ``HAWSERD_DEV_API_KEY``), then a full verify.
|
|
5
|
+
Verified tokens are cached in memory for a few minutes to keep argon2 off the request path.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import secrets
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from argon2 import PasswordHasher
|
|
17
|
+
from argon2.exceptions import VerificationError
|
|
18
|
+
|
|
19
|
+
from hawser.config import LOCAL_TENANT
|
|
20
|
+
from hawser.ids import new_id
|
|
21
|
+
from hawser.storage.sql import SqlBackend
|
|
22
|
+
from hawser_server.util import ts
|
|
23
|
+
|
|
24
|
+
KEY_PREFIX = "hk_live_"
|
|
25
|
+
_CACHE_TTL_S = 300.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Principal:
|
|
30
|
+
tenant_id: str
|
|
31
|
+
key_id: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Tenant:
|
|
36
|
+
id: str
|
|
37
|
+
name: str
|
|
38
|
+
created_at: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ApiKey:
|
|
43
|
+
id: str
|
|
44
|
+
tenant_id: str
|
|
45
|
+
name: str
|
|
46
|
+
prefix: str
|
|
47
|
+
created_at: str
|
|
48
|
+
revoked_at: str | None
|
|
49
|
+
last_used_at: str | None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _prefix(token: str) -> str:
|
|
53
|
+
return hashlib.sha256(token.encode()).hexdigest()[:16]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def generate_key() -> str:
|
|
57
|
+
return KEY_PREFIX + secrets.token_urlsafe(30)[:40]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AuthService:
|
|
61
|
+
def __init__(self, backend: SqlBackend) -> None:
|
|
62
|
+
self.backend = backend
|
|
63
|
+
self.hasher = PasswordHasher()
|
|
64
|
+
self._cache: dict[str, tuple[Principal, float]] = {}
|
|
65
|
+
self._lock = threading.Lock()
|
|
66
|
+
|
|
67
|
+
# -- tenants ---------------------------------------------------------------------------
|
|
68
|
+
def create_tenant(self, name: str, *, tenant_id: str | None = None) -> Tenant:
|
|
69
|
+
tenant = Tenant(id=tenant_id or new_id("ten"), name=name, created_at=ts())
|
|
70
|
+
with self.backend.transaction(write=True) as tx:
|
|
71
|
+
tx.execute(
|
|
72
|
+
"INSERT INTO tenants (id, name, created_at) VALUES (?,?,?) ON CONFLICT DO NOTHING",
|
|
73
|
+
(tenant.id, tenant.name, tenant.created_at),
|
|
74
|
+
)
|
|
75
|
+
return tenant
|
|
76
|
+
|
|
77
|
+
def get_tenant(self, tenant_id: str) -> Tenant | None:
|
|
78
|
+
with self.backend.transaction() as tx:
|
|
79
|
+
rows = tx.execute("SELECT id, name, created_at FROM tenants WHERE id = ?", (tenant_id,))
|
|
80
|
+
return None if not rows else Tenant(*(str(c) for c in rows[0]))
|
|
81
|
+
|
|
82
|
+
def list_tenants(self) -> list[Tenant]:
|
|
83
|
+
with self.backend.transaction() as tx:
|
|
84
|
+
rows = tx.execute("SELECT id, name, created_at FROM tenants ORDER BY created_at")
|
|
85
|
+
return [Tenant(*(str(c) for c in r)) for r in rows]
|
|
86
|
+
|
|
87
|
+
# -- keys ------------------------------------------------------------------------------
|
|
88
|
+
def create_key(
|
|
89
|
+
self, tenant_id: str, name: str, *, token: str | None = None
|
|
90
|
+
) -> tuple[ApiKey, str]:
|
|
91
|
+
"""Create a key; returns the record and the plaintext token (shown once)."""
|
|
92
|
+
if self.get_tenant(tenant_id) is None:
|
|
93
|
+
raise KeyError(tenant_id)
|
|
94
|
+
token = token or generate_key()
|
|
95
|
+
key = ApiKey(
|
|
96
|
+
id=new_id("key"),
|
|
97
|
+
tenant_id=tenant_id,
|
|
98
|
+
name=name,
|
|
99
|
+
prefix=_prefix(token),
|
|
100
|
+
created_at=ts(),
|
|
101
|
+
revoked_at=None,
|
|
102
|
+
last_used_at=None,
|
|
103
|
+
)
|
|
104
|
+
with self.backend.transaction(write=True) as tx:
|
|
105
|
+
tx.execute(
|
|
106
|
+
"INSERT INTO api_keys (id, tenant_id, name, prefix, hash, created_at)"
|
|
107
|
+
" VALUES (?,?,?,?,?,?)",
|
|
108
|
+
(
|
|
109
|
+
key.id,
|
|
110
|
+
key.tenant_id,
|
|
111
|
+
key.name,
|
|
112
|
+
key.prefix,
|
|
113
|
+
self.hasher.hash(token),
|
|
114
|
+
key.created_at,
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
return key, token
|
|
118
|
+
|
|
119
|
+
def revoke_key(self, key_id: str) -> bool:
|
|
120
|
+
with self.backend.transaction(write=True) as tx:
|
|
121
|
+
tx.execute(
|
|
122
|
+
"UPDATE api_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
|
|
123
|
+
(ts(), key_id),
|
|
124
|
+
)
|
|
125
|
+
rows = tx.execute("SELECT revoked_at FROM api_keys WHERE id = ?", (key_id,))
|
|
126
|
+
with self._lock:
|
|
127
|
+
self._cache = {k: v for k, v in self._cache.items() if v[0].key_id != key_id}
|
|
128
|
+
return bool(rows) and rows[0][0] is not None
|
|
129
|
+
|
|
130
|
+
def list_keys(self, tenant_id: str) -> list[ApiKey]:
|
|
131
|
+
with self.backend.transaction() as tx:
|
|
132
|
+
rows = tx.execute(
|
|
133
|
+
"SELECT id, tenant_id, name, prefix, created_at, revoked_at, last_used_at"
|
|
134
|
+
" FROM api_keys WHERE tenant_id = ? ORDER BY created_at",
|
|
135
|
+
(tenant_id,),
|
|
136
|
+
)
|
|
137
|
+
return [
|
|
138
|
+
ApiKey(
|
|
139
|
+
id=str(r[0]),
|
|
140
|
+
tenant_id=str(r[1]),
|
|
141
|
+
name=str(r[2]),
|
|
142
|
+
prefix=str(r[3]),
|
|
143
|
+
created_at=str(r[4]),
|
|
144
|
+
revoked_at=None if r[5] is None else str(r[5]),
|
|
145
|
+
last_used_at=None if r[6] is None else str(r[6]),
|
|
146
|
+
)
|
|
147
|
+
for r in rows
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
def authenticate(self, token: str) -> Principal | None:
|
|
151
|
+
cache_key = hashlib.sha256(token.encode()).hexdigest()
|
|
152
|
+
with self._lock:
|
|
153
|
+
hit = self._cache.get(cache_key)
|
|
154
|
+
if hit is not None and hit[1] > time.monotonic():
|
|
155
|
+
return hit[0]
|
|
156
|
+
with self.backend.transaction() as tx:
|
|
157
|
+
rows = tx.execute(
|
|
158
|
+
"SELECT id, tenant_id, hash FROM api_keys WHERE prefix = ? AND revoked_at IS NULL",
|
|
159
|
+
(_prefix(token),),
|
|
160
|
+
)
|
|
161
|
+
for key_id, tenant_id, digest in rows:
|
|
162
|
+
try:
|
|
163
|
+
self.hasher.verify(str(digest), token)
|
|
164
|
+
except VerificationError:
|
|
165
|
+
continue
|
|
166
|
+
principal = Principal(tenant_id=str(tenant_id), key_id=str(key_id))
|
|
167
|
+
with self._lock:
|
|
168
|
+
self._cache[cache_key] = (principal, time.monotonic() + _CACHE_TTL_S)
|
|
169
|
+
with self.backend.transaction(write=True) as tx:
|
|
170
|
+
tx.execute("UPDATE api_keys SET last_used_at = ? WHERE id = ?", (ts(), key_id))
|
|
171
|
+
return principal
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
# -- bootstrap -------------------------------------------------------------------------
|
|
175
|
+
def ensure_dev_key(self, token: str) -> Principal:
|
|
176
|
+
"""Development only: make ``token`` a valid key for the local tenant."""
|
|
177
|
+
existing = self.authenticate(token)
|
|
178
|
+
if existing is not None:
|
|
179
|
+
return existing
|
|
180
|
+
tenant = self.get_tenant(LOCAL_TENANT) or self.create_tenant(
|
|
181
|
+
"local", tenant_id=LOCAL_TENANT
|
|
182
|
+
)
|
|
183
|
+
key, _ = self.create_key(tenant.id, "dev", token=token)
|
|
184
|
+
return Principal(tenant_id=tenant.id, key_id=key.id)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
__all__ = ["ApiKey", "AuthService", "Principal", "Tenant", "generate_key"]
|
hawser_server/cli.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""``hawserd``: serve, worker, migrate, admin, openapi."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Annotated, Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from hawser_server import __version__
|
|
17
|
+
from hawser_server.config import ServerConfig
|
|
18
|
+
from hawser_server.services import Services
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(name="hawserd", help="Hawser control plane.", no_args_is_help=True)
|
|
21
|
+
admin_app = typer.Typer(help="Tenants and API keys.", no_args_is_help=True)
|
|
22
|
+
app.add_typer(admin_app, name="admin")
|
|
23
|
+
console = Console()
|
|
24
|
+
err = Console(stderr=True)
|
|
25
|
+
|
|
26
|
+
DEV_DIR = Path(".hawserd")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _version(value: bool) -> None:
|
|
30
|
+
if value:
|
|
31
|
+
console.print(f"hawserd {__version__}")
|
|
32
|
+
raise typer.Exit()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.callback()
|
|
36
|
+
def _main(
|
|
37
|
+
version: Annotated[
|
|
38
|
+
bool, typer.Option("--version", callback=_version, is_eager=True, help="Show the version.")
|
|
39
|
+
] = False,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Hawser control plane: API, durable worker, migrations."""
|
|
42
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _dev_key() -> str:
|
|
46
|
+
explicit = os.environ.get("HAWSERD_DEV_API_KEY")
|
|
47
|
+
if explicit:
|
|
48
|
+
return explicit
|
|
49
|
+
DEV_DIR.mkdir(exist_ok=True)
|
|
50
|
+
path = DEV_DIR / "dev.key"
|
|
51
|
+
if path.exists():
|
|
52
|
+
return path.read_text(encoding="utf-8").strip()
|
|
53
|
+
key = "hk_live_" + secrets.token_urlsafe(30)[:40]
|
|
54
|
+
path.write_text(key + "\n", encoding="utf-8")
|
|
55
|
+
path.chmod(0o600)
|
|
56
|
+
return key
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _config(*, dev: bool, **overrides: Any) -> ServerConfig:
|
|
60
|
+
if dev:
|
|
61
|
+
overrides.setdefault("dev_api_key", _dev_key())
|
|
62
|
+
return ServerConfig.load(**overrides)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command()
|
|
66
|
+
def serve( # noqa: PLR0917 - CLI options
|
|
67
|
+
host: Annotated[str | None, typer.Option("--host")] = None,
|
|
68
|
+
port: Annotated[int | None, typer.Option("--port")] = None,
|
|
69
|
+
dev: Annotated[
|
|
70
|
+
bool, typer.Option("--dev", help="SQLite, local files, in-process worker, dev API key.")
|
|
71
|
+
] = False,
|
|
72
|
+
with_worker: Annotated[
|
|
73
|
+
bool, typer.Option("--with-worker", help="Run a worker in this process too.")
|
|
74
|
+
] = False,
|
|
75
|
+
migrate: Annotated[
|
|
76
|
+
bool, typer.Option("--migrate", help="Apply pending migrations before serving.")
|
|
77
|
+
] = False,
|
|
78
|
+
web_dir: Annotated[
|
|
79
|
+
Path | None, typer.Option("--web-dir", help="Built web app to serve at /app.")
|
|
80
|
+
] = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Serve the HTTP API (and the web app at /app when a build is available)."""
|
|
83
|
+
import uvicorn # noqa: PLC0415 - heavy import only when serving
|
|
84
|
+
|
|
85
|
+
from hawser_server.app import create_app # noqa: PLC0415
|
|
86
|
+
from hawser_server.worker import Worker # noqa: PLC0415
|
|
87
|
+
|
|
88
|
+
cfg = _config(dev=dev, host=host, port=port, web_dir=web_dir)
|
|
89
|
+
svc = Services(cfg, migrate=True if (migrate or dev) else None)
|
|
90
|
+
who = svc.bootstrap()
|
|
91
|
+
if dev and who is not None:
|
|
92
|
+
console.print(
|
|
93
|
+
f"[bold]dev mode[/]: database {cfg.database_url}, artifacts {cfg.artifacts_dir}\n"
|
|
94
|
+
f"API key: [bold]{cfg.dev_api_key}[/] (tenant {who.tenant_id})\n"
|
|
95
|
+
f" export HAWSER_SERVER_URL=http://{cfg.host}:{cfg.port} "
|
|
96
|
+
f"HAWSER_API_KEY={cfg.dev_api_key}"
|
|
97
|
+
)
|
|
98
|
+
worker: Worker | None = None
|
|
99
|
+
if with_worker or dev:
|
|
100
|
+
worker = Worker(svc)
|
|
101
|
+
worker.start()
|
|
102
|
+
try:
|
|
103
|
+
uvicorn.run(create_app(svc), host=cfg.host, port=cfg.port, log_level="info")
|
|
104
|
+
finally:
|
|
105
|
+
if worker is not None:
|
|
106
|
+
worker.stop(wait=False)
|
|
107
|
+
svc.close()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command()
|
|
111
|
+
def worker(
|
|
112
|
+
concurrency: Annotated[int | None, typer.Option("--concurrency", "-c")] = None,
|
|
113
|
+
dev: Annotated[bool, typer.Option("--dev")] = False,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Run a durable worker (lease runs, execute from checkpoints, deliver webhooks)."""
|
|
116
|
+
from hawser_server.worker import Worker # noqa: PLC0415
|
|
117
|
+
|
|
118
|
+
cfg = _config(dev=dev)
|
|
119
|
+
svc = Services(cfg, migrate=True if dev else None)
|
|
120
|
+
svc.bootstrap()
|
|
121
|
+
w = Worker(svc, concurrency=concurrency)
|
|
122
|
+
try:
|
|
123
|
+
w.run_forever()
|
|
124
|
+
except KeyboardInterrupt:
|
|
125
|
+
w.stop()
|
|
126
|
+
finally:
|
|
127
|
+
svc.close()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.command()
|
|
131
|
+
def migrate(
|
|
132
|
+
status: Annotated[bool, typer.Option("--status", help="Show what is applied.")] = False,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Apply pending migrations (forward-only)."""
|
|
135
|
+
from hawser_server.db import open_backend # noqa: PLC0415
|
|
136
|
+
from hawser_server.migrations import ALL_MIGRATIONS # noqa: PLC0415
|
|
137
|
+
|
|
138
|
+
cfg = ServerConfig.load()
|
|
139
|
+
backend = open_backend(cfg.database_url)
|
|
140
|
+
try:
|
|
141
|
+
if status:
|
|
142
|
+
with backend.transaction() as tx:
|
|
143
|
+
tx.execute(
|
|
144
|
+
"CREATE TABLE IF NOT EXISTS schema_migrations"
|
|
145
|
+
" (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"
|
|
146
|
+
)
|
|
147
|
+
done = {
|
|
148
|
+
str(r[0]): str(r[1])
|
|
149
|
+
for r in tx.execute("SELECT name, applied_at FROM schema_migrations")
|
|
150
|
+
}
|
|
151
|
+
for m in ALL_MIGRATIONS:
|
|
152
|
+
mark = (
|
|
153
|
+
f"[green]applied {done[m.name]}[/]" if m.name in done else "[yellow]pending[/]"
|
|
154
|
+
)
|
|
155
|
+
console.print(f"{m.name:24} {mark}")
|
|
156
|
+
return
|
|
157
|
+
from hawser.storage.schema import apply_migrations # noqa: PLC0415
|
|
158
|
+
|
|
159
|
+
applied = apply_migrations(backend, ALL_MIGRATIONS)
|
|
160
|
+
console.print(
|
|
161
|
+
f"applied {len(applied)} migration(s)" + (": " + ", ".join(applied) if applied else "")
|
|
162
|
+
)
|
|
163
|
+
finally:
|
|
164
|
+
backend.close()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@admin_app.command("create-tenant")
|
|
168
|
+
def create_tenant(
|
|
169
|
+
name: Annotated[str, typer.Argument()],
|
|
170
|
+
tenant_id: Annotated[str | None, typer.Option("--id", help="Explicit ten_... id.")] = None,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Create a tenant."""
|
|
173
|
+
svc = Services(ServerConfig.load(), migrate=None)
|
|
174
|
+
try:
|
|
175
|
+
t = svc.auth.create_tenant(name, tenant_id=tenant_id)
|
|
176
|
+
console.print(f"tenant [bold]{t.id}[/] ({t.name})")
|
|
177
|
+
finally:
|
|
178
|
+
svc.close()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@admin_app.command("list-tenants")
|
|
182
|
+
def list_tenants() -> None:
|
|
183
|
+
svc = Services(ServerConfig.load(), migrate=None)
|
|
184
|
+
try:
|
|
185
|
+
for t in svc.auth.list_tenants():
|
|
186
|
+
console.print(f"{t.id} {t.name} {t.created_at}")
|
|
187
|
+
finally:
|
|
188
|
+
svc.close()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@admin_app.command("create-key")
|
|
192
|
+
def create_key(
|
|
193
|
+
tenant: Annotated[str, typer.Option("--tenant", help="Tenant id.")],
|
|
194
|
+
name: Annotated[str, typer.Option("--name")] = "default",
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Create an API key for a tenant. The key is printed once."""
|
|
197
|
+
svc = Services(ServerConfig.load(), migrate=None)
|
|
198
|
+
try:
|
|
199
|
+
try:
|
|
200
|
+
key, token = svc.auth.create_key(tenant, name)
|
|
201
|
+
except KeyError:
|
|
202
|
+
err.print(f"[red]unknown tenant {tenant}[/]")
|
|
203
|
+
raise typer.Exit(1) from None
|
|
204
|
+
console.print(f"key [bold]{key.id}[/] for {tenant}\n{token}")
|
|
205
|
+
finally:
|
|
206
|
+
svc.close()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@admin_app.command("revoke-key")
|
|
210
|
+
def revoke_key(key_id: Annotated[str, typer.Argument()]) -> None:
|
|
211
|
+
svc = Services(ServerConfig.load(), migrate=None)
|
|
212
|
+
try:
|
|
213
|
+
if svc.auth.revoke_key(key_id):
|
|
214
|
+
console.print(f"revoked {key_id}")
|
|
215
|
+
else:
|
|
216
|
+
err.print(f"[red]unknown key {key_id}[/]")
|
|
217
|
+
raise typer.Exit(1)
|
|
218
|
+
finally:
|
|
219
|
+
svc.close()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def openapi_document() -> str:
|
|
223
|
+
"""The OpenAPI document, rendered deterministically."""
|
|
224
|
+
from hawser_server.app import create_app # noqa: PLC0415
|
|
225
|
+
|
|
226
|
+
cfg = ServerConfig(
|
|
227
|
+
database_url="sqlite:///:memory:", artifacts_dir=Path(tempfile.mkdtemp(prefix="hawserd-"))
|
|
228
|
+
)
|
|
229
|
+
svc = Services(cfg, migrate=True)
|
|
230
|
+
try:
|
|
231
|
+
doc = create_app(svc).openapi()
|
|
232
|
+
finally:
|
|
233
|
+
svc.close()
|
|
234
|
+
return json.dumps(doc, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@app.command()
|
|
238
|
+
def openapi(
|
|
239
|
+
out: Annotated[Path, typer.Option("--out")] = Path("api/openapi/openapi.json"),
|
|
240
|
+
check: Annotated[bool, typer.Option("--check", help="Fail if the file is stale.")] = False,
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Write (or verify) the generated OpenAPI document."""
|
|
243
|
+
text = openapi_document()
|
|
244
|
+
if check:
|
|
245
|
+
if not out.exists() or out.read_text(encoding="utf-8") != text:
|
|
246
|
+
err.print(f"[red]{out} is stale; run `hawserd openapi`[/]")
|
|
247
|
+
raise typer.Exit(1)
|
|
248
|
+
console.print(f"{out} is up to date")
|
|
249
|
+
return
|
|
250
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
out.write_text(text, encoding="utf-8")
|
|
252
|
+
console.print(f"wrote {out}")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
__all__ = ["app", "openapi_document"]
|
hawser_server/config.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Server configuration: ``HAWSERD_*`` environment variables over defaults.
|
|
2
|
+
|
|
3
|
+
The runtime settings the engine needs (default model, record mode, skill paths, limits) come
|
|
4
|
+
from the regular ``HAWSER_*`` variables through :class:`hawser.config.HawserConfig`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
14
|
+
|
|
15
|
+
from hawser.config import HawserConfig
|
|
16
|
+
|
|
17
|
+
ENV_PREFIX = "HAWSERD_"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ServerConfig(BaseModel):
|
|
21
|
+
model_config = ConfigDict(extra="forbid")
|
|
22
|
+
|
|
23
|
+
#: ``postgresql://user:pass@host:5432/db`` or ``sqlite:///path/to/file.db`` (single node).
|
|
24
|
+
database_url: str = "sqlite:///./.hawserd/hawserd.db"
|
|
25
|
+
#: Local artifact directory; ignored when ``s3_bucket`` is set.
|
|
26
|
+
artifacts_dir: Path = Path("./.hawserd/store")
|
|
27
|
+
s3_bucket: str | None = None
|
|
28
|
+
s3_endpoint_url: str | None = None
|
|
29
|
+
s3_region: str | None = None
|
|
30
|
+
s3_access_key: str | None = None
|
|
31
|
+
s3_secret_key: str | None = None
|
|
32
|
+
s3_presign_ttl_s: int = Field(default=900, ge=1, le=7 * 24 * 3600)
|
|
33
|
+
#: Serve artifact bytes through the API instead of presigned URLs.
|
|
34
|
+
artifacts_proxy: bool = True
|
|
35
|
+
|
|
36
|
+
host: str = "127.0.0.1"
|
|
37
|
+
port: int = 8080
|
|
38
|
+
#: Base URL clients use to reach this server (in webhook payloads and Location headers).
|
|
39
|
+
public_url: str = "http://127.0.0.1:8080"
|
|
40
|
+
|
|
41
|
+
#: Bootstrap one tenant and this API key at start-up. Development and Compose only.
|
|
42
|
+
dev_api_key: str | None = None
|
|
43
|
+
|
|
44
|
+
lease_seconds: int = Field(default=60, ge=5)
|
|
45
|
+
worker_concurrency: int = Field(default=2, ge=1)
|
|
46
|
+
worker_max_attempts: int = Field(default=3, ge=1)
|
|
47
|
+
poll_interval_s: float = Field(default=0.5, gt=0)
|
|
48
|
+
max_upload_bytes: int = 50 * 1024 * 1024
|
|
49
|
+
|
|
50
|
+
webhook_timeout_s: float = 10.0
|
|
51
|
+
#: Give up on a delivery after this long (dead letter).
|
|
52
|
+
webhook_max_age_s: int = 24 * 3600
|
|
53
|
+
|
|
54
|
+
sse_keepalive_s: float = 15.0
|
|
55
|
+
|
|
56
|
+
#: Browser origins allowed to call the API (the Vite dev server by default).
|
|
57
|
+
cors_origins: list[str] = ["http://localhost:5173", "http://127.0.0.1:5173"]
|
|
58
|
+
#: Directory with the built web app (``web/dist``); default: the bundled ``static`` folder.
|
|
59
|
+
web_dir: Path | None = None
|
|
60
|
+
#: Rendered-page cache entries kept in memory per process.
|
|
61
|
+
page_cache_entries: int = 64
|
|
62
|
+
|
|
63
|
+
#: Engine configuration (``HAWSER_*``).
|
|
64
|
+
runtime: HawserConfig = Field(default_factory=HawserConfig.load)
|
|
65
|
+
|
|
66
|
+
@field_validator("cors_origins", mode="before")
|
|
67
|
+
@classmethod
|
|
68
|
+
def _split_origins(cls, value: Any) -> Any:
|
|
69
|
+
if isinstance(value, str):
|
|
70
|
+
return [o.strip() for o in value.split(",") if o.strip()]
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def load(cls, **overrides: Any) -> ServerConfig:
|
|
75
|
+
values: dict[str, Any] = {}
|
|
76
|
+
for name in cls.model_fields:
|
|
77
|
+
if name == "runtime":
|
|
78
|
+
continue
|
|
79
|
+
env = os.environ.get(f"{ENV_PREFIX}{name.upper()}")
|
|
80
|
+
if env: # empty means unset (compose passes blanks through)
|
|
81
|
+
values[name] = env
|
|
82
|
+
values.update({k: v for k, v in overrides.items() if v is not None})
|
|
83
|
+
return cls.model_validate(values)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def is_sqlite(self) -> bool:
|
|
87
|
+
return self.database_url.startswith("sqlite:")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = ["ENV_PREFIX", "ServerConfig"]
|