dirigent-server 0.9.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.
- dirigent_server/__init__.py +7 -0
- dirigent_server/app.py +171 -0
- dirigent_server/dependencies.py +51 -0
- dirigent_server/errors.py +116 -0
- dirigent_server/health.py +97 -0
- dirigent_server/logging.py +5 -0
- dirigent_server/pagination.py +56 -0
- dirigent_server/py.typed +0 -0
- dirigent_server/routes/__init__.py +79 -0
- dirigent_server/routes/alerts.py +289 -0
- dirigent_server/routes/auth.py +218 -0
- dirigent_server/routes/blocks.py +39 -0
- dirigent_server/routes/connections.py +294 -0
- dirigent_server/routes/hooks.py +114 -0
- dirigent_server/routes/pipelines.py +427 -0
- dirigent_server/routes/runs.py +818 -0
- dirigent_server/routes/schema.py +27 -0
- dirigent_server/routes/schemas.py +127 -0
- dirigent_server/routes/system.py +63 -0
- dirigent_server/routes/trigger_documents.py +109 -0
- dirigent_server/routes/triggers.py +537 -0
- dirigent_server/routes/users.py +226 -0
- dirigent_server/routes/workers.py +52 -0
- dirigent_server/security.py +186 -0
- dirigent_server/static/.gitkeep +0 -0
- dirigent_server/transactions.py +34 -0
- dirigent_server/ui.py +237 -0
- dirigent_server-0.9.0.dist-info/METADATA +17 -0
- dirigent_server-0.9.0.dist-info/RECORD +32 -0
- dirigent_server-0.9.0.dist-info/WHEEL +4 -0
- dirigent_server-0.9.0.dist-info/licenses/LICENSE +18 -0
- dirigent_server-0.9.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +631 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""The dirigent API server: the REST surface, authentication, and the UI."""
|
|
2
|
+
|
|
3
|
+
from dirigent_server.app import create_app
|
|
4
|
+
from dirigent_server.logging import configure_logging, get_logger
|
|
5
|
+
from dirigent_server.security import SESSION_COOKIE
|
|
6
|
+
|
|
7
|
+
__all__ = ["SESSION_COOKIE", "configure_logging", "create_app", "get_logger"]
|
dirigent_server/app.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""The FastAPI application factory."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
|
|
10
|
+
from dirigent_core import __version__
|
|
11
|
+
from dirigent_core.auth import BOOTSTRAP_PASSWORD_ENV, bootstrap_admin
|
|
12
|
+
from dirigent_core.config import Settings, get_settings
|
|
13
|
+
from dirigent_core.database import create_engine, create_session_factory, session_scope
|
|
14
|
+
from dirigent_core.directory import apply_directory
|
|
15
|
+
from dirigent_core.engine.services import EngineServices
|
|
16
|
+
from dirigent_core.plugins import PluginHost, load_plugin_host
|
|
17
|
+
from dirigent_core.scheduler import Scheduler
|
|
18
|
+
from dirigent_core.telemetry import configure_telemetry, instrument_fastapi
|
|
19
|
+
from dirigent_server.errors import install_error_handlers
|
|
20
|
+
from dirigent_server.health import build_registry
|
|
21
|
+
from dirigent_server.health import router as health_router
|
|
22
|
+
from dirigent_server.logging import get_logger
|
|
23
|
+
from dirigent_server.routes import TAGS, build_hooks_router, build_router
|
|
24
|
+
from dirigent_server.security import install_cross_site_guard
|
|
25
|
+
from dirigent_server.ui import mount_ui_assets, mount_ui_shell
|
|
26
|
+
|
|
27
|
+
DESCRIPTION = """
|
|
28
|
+
A generic pipeline orchestrator. Pipelines are data, composed from pluggable building
|
|
29
|
+
blocks and executed as a DAG on a durable engine.
|
|
30
|
+
|
|
31
|
+
Every endpoint under `/api/v1` requires authentication: a bearer token for automation, or
|
|
32
|
+
the session cookie `POST /api/v1/auth/login` sets for a browser. The probes under `/health`
|
|
33
|
+
and this document itself are the only exceptions. `POST /hooks/{token}` is outside the
|
|
34
|
+
versioned API and authenticates with its own per-trigger token.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_logger = get_logger("server")
|
|
38
|
+
|
|
39
|
+
BOOTSTRAP_ENV = BOOTSTRAP_PASSWORD_ENV
|
|
40
|
+
SCHEDULER_STOP_SECONDS = 10.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_app(
|
|
44
|
+
settings: Settings | None = None,
|
|
45
|
+
*,
|
|
46
|
+
host: PluginHost | None = None,
|
|
47
|
+
scheduler: bool | None = None,
|
|
48
|
+
) -> FastAPI:
|
|
49
|
+
"""Build the API application: health probes, the routers, the UI, and the embedded scheduler.
|
|
50
|
+
|
|
51
|
+
Scheduler leadership is an advisory lock, so running the scheduler here, in a dedicated
|
|
52
|
+
``dg scheduler``, or in both at once is safe.
|
|
53
|
+
|
|
54
|
+
The UI mounts in two pieces around the routers: its fixed paths before them, its shell
|
|
55
|
+
after them, so an API route is matched before anything the bundle claims.
|
|
56
|
+
"""
|
|
57
|
+
resolved = settings or get_settings()
|
|
58
|
+
embed = resolved.scheduler_enabled if scheduler is None else scheduler
|
|
59
|
+
configure_telemetry(resolved)
|
|
60
|
+
|
|
61
|
+
@asynccontextmanager
|
|
62
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
|
63
|
+
"""Open the database engine for the process, and dispose of it on shutdown."""
|
|
64
|
+
engine = create_engine(resolved)
|
|
65
|
+
app.state.engine = engine
|
|
66
|
+
app.state.session_factory = create_session_factory(engine)
|
|
67
|
+
app.state.health_checks = build_registry(engine)
|
|
68
|
+
await _bootstrap(app)
|
|
69
|
+
await _apply_startup_directory(app)
|
|
70
|
+
clock = await _start_scheduler(app, embed=embed)
|
|
71
|
+
_logger.info(
|
|
72
|
+
"server starting",
|
|
73
|
+
database="sqlite" if resolved.is_sqlite else "postgresql",
|
|
74
|
+
blocks=len(app.state.services.host.block_ids),
|
|
75
|
+
scheduler=embed,
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
yield
|
|
79
|
+
finally:
|
|
80
|
+
await _stop_scheduler(app, clock)
|
|
81
|
+
await engine.dispose()
|
|
82
|
+
_logger.info("server stopped")
|
|
83
|
+
|
|
84
|
+
app = FastAPI(
|
|
85
|
+
title="dirigent",
|
|
86
|
+
summary="A generic pipeline orchestrator.",
|
|
87
|
+
description=DESCRIPTION.strip(),
|
|
88
|
+
version=__version__,
|
|
89
|
+
openapi_tags=TAGS,
|
|
90
|
+
lifespan=lifespan,
|
|
91
|
+
)
|
|
92
|
+
app.state.settings = resolved
|
|
93
|
+
app.state.version = __version__
|
|
94
|
+
app.state.services = EngineServices.build(resolved, host or load_plugin_host())
|
|
95
|
+
install_cross_site_guard(app, resolved)
|
|
96
|
+
install_error_handlers(app)
|
|
97
|
+
mount_ui_assets(app, resolved)
|
|
98
|
+
app.include_router(health_router)
|
|
99
|
+
app.include_router(build_hooks_router())
|
|
100
|
+
app.include_router(build_router(), prefix=resolved.api_prefix)
|
|
101
|
+
mount_ui_shell(app, resolved)
|
|
102
|
+
instrument_fastapi(app)
|
|
103
|
+
return app
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def _start_scheduler(app: FastAPI, *, embed: bool) -> asyncio.Task[None] | None:
|
|
107
|
+
"""Start the embedded scheduler as a task in the API's own event loop."""
|
|
108
|
+
app.state.scheduler = None
|
|
109
|
+
if not embed:
|
|
110
|
+
return None
|
|
111
|
+
scheduler = Scheduler(app.state.session_factory, app.state.services)
|
|
112
|
+
app.state.scheduler = scheduler
|
|
113
|
+
return asyncio.create_task(scheduler.run())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def _stop_scheduler(app: FastAPI, task: asyncio.Task[None] | None) -> None:
|
|
117
|
+
"""Ask the embedded scheduler to finish its tick and hand back leadership."""
|
|
118
|
+
scheduler = getattr(app.state, "scheduler", None)
|
|
119
|
+
if scheduler is not None:
|
|
120
|
+
scheduler.request_stop()
|
|
121
|
+
if task is not None:
|
|
122
|
+
with contextlib.suppress(asyncio.CancelledError, TimeoutError):
|
|
123
|
+
await asyncio.wait_for(task, timeout=SCHEDULER_STOP_SECONDS)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def _apply_startup_directory(app: FastAPI) -> None:
|
|
127
|
+
"""Apply the configured directory of schemas and documents, before the scheduler starts firing.
|
|
128
|
+
|
|
129
|
+
The summary is one record; each refused file has already said its own warning. A
|
|
130
|
+
boot that found the lock held skips quietly: whoever holds it is doing this work.
|
|
131
|
+
"""
|
|
132
|
+
settings: Settings = app.state.settings
|
|
133
|
+
if settings.apply_dir is None:
|
|
134
|
+
return
|
|
135
|
+
summary = await apply_directory(
|
|
136
|
+
app.state.session_factory,
|
|
137
|
+
app.state.services,
|
|
138
|
+
settings.apply_dir,
|
|
139
|
+
prune=settings.apply_prune,
|
|
140
|
+
lock_key=settings.apply_lock_key,
|
|
141
|
+
)
|
|
142
|
+
if summary.skipped:
|
|
143
|
+
return
|
|
144
|
+
_logger.info(
|
|
145
|
+
"startup directory applied",
|
|
146
|
+
directory=str(settings.apply_dir),
|
|
147
|
+
applied=len(summary.applied),
|
|
148
|
+
updated=len(summary.updated),
|
|
149
|
+
unchanged=len(summary.unchanged),
|
|
150
|
+
schemas=len(summary.schemas),
|
|
151
|
+
refused=[refusal.code or refusal.path for refusal in summary.refused],
|
|
152
|
+
pruned=summary.pruned,
|
|
153
|
+
trigger_documents_removed=summary.trigger_documents_removed,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _bootstrap(app: FastAPI) -> None:
|
|
158
|
+
"""Create the first admin when a container names a password and no account exists yet.
|
|
159
|
+
|
|
160
|
+
Does nothing once any account exists, so leaving the variable set on every deploy
|
|
161
|
+
cannot reset a live instance's admin password.
|
|
162
|
+
"""
|
|
163
|
+
import os
|
|
164
|
+
|
|
165
|
+
password = os.environ.get(BOOTSTRAP_ENV)
|
|
166
|
+
if not password:
|
|
167
|
+
return
|
|
168
|
+
async with session_scope(app.state.session_factory) as session:
|
|
169
|
+
created = await bootstrap_admin(session, password)
|
|
170
|
+
if created is not None:
|
|
171
|
+
_logger.info("bootstrap admin created", username=created.username)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""FastAPI dependencies: the request session, the engine services, and the settings."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, Request
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
8
|
+
|
|
9
|
+
from dirigent_core.config import Settings
|
|
10
|
+
from dirigent_core.engine.services import EngineServices
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_settings(request: Request) -> Settings:
|
|
14
|
+
"""Return the settings this application was built with."""
|
|
15
|
+
resolved: Settings = request.app.state.settings
|
|
16
|
+
return resolved
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_services(request: Request) -> EngineServices:
|
|
20
|
+
"""Return the engine services: the plugin host, storage, and the secret box."""
|
|
21
|
+
services: EngineServices = request.app.state.services
|
|
22
|
+
return services
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_sessions(request: Request) -> async_sessionmaker[AsyncSession]:
|
|
26
|
+
"""Return the session factory the API and the engine share."""
|
|
27
|
+
factory: async_sessionmaker[AsyncSession] = request.app.state.session_factory
|
|
28
|
+
return factory
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def get_session(request: Request) -> AsyncGenerator[AsyncSession]:
|
|
32
|
+
"""Open one transaction for one request, and hand it to the route to commit.
|
|
33
|
+
|
|
34
|
+
The exit of a dependency that yields runs after the response has gone out, so committing
|
|
35
|
+
here tells a client its write happened before it had: a client that reads straight back
|
|
36
|
+
can miss what it just wrote, and a commit that fails does so after a 2xx. ``Transactional``
|
|
37
|
+
commits while the response can still change. This only has to undo one that did not get
|
|
38
|
+
there.
|
|
39
|
+
"""
|
|
40
|
+
async with get_sessions(request)() as session:
|
|
41
|
+
request.state.session = session
|
|
42
|
+
try:
|
|
43
|
+
yield session
|
|
44
|
+
except BaseException:
|
|
45
|
+
await session.rollback()
|
|
46
|
+
raise
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
50
|
+
ServicesDep = Annotated[EngineServices, Depends(get_services)]
|
|
51
|
+
SettingsDep = Annotated[Settings, Depends(get_settings)]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""One error envelope for every refusal, in the shape RFC 9457 describes."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from http import HTTPStatus
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, HTTPException, Request, Response
|
|
8
|
+
from fastapi.exceptions import RequestValidationError
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
11
|
+
|
|
12
|
+
from dirigent_client.schemas import Problem
|
|
13
|
+
from dirigent_core import __version__
|
|
14
|
+
from dirigent_core.logging import redact_path
|
|
15
|
+
from dirigent_server.logging import get_logger
|
|
16
|
+
|
|
17
|
+
VERSION_HEADER = "X-Dirigent-Version"
|
|
18
|
+
|
|
19
|
+
INTERNAL_DETAIL = "the server failed to handle this request; the server log has the detail"
|
|
20
|
+
|
|
21
|
+
_logger = get_logger("errors")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def render(status: int, detail: str, *, problems: list[str] | None = None, instance: str | None = None) -> Problem:
|
|
25
|
+
"""Build the one problem shape, from whichever handler is answering."""
|
|
26
|
+
try:
|
|
27
|
+
title = HTTPStatus(status).phrase
|
|
28
|
+
except ValueError: # pragma: no cover - a non-standard status from a plugin
|
|
29
|
+
title = "Error"
|
|
30
|
+
return Problem(status=status, title=title, detail=detail, problems=problems or [], instance=instance)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _problems_of(detail: Any) -> tuple[str, list[str]]:
|
|
34
|
+
"""Split whatever was raised into one sentence and, when there is one, a list."""
|
|
35
|
+
if isinstance(detail, str):
|
|
36
|
+
return detail, []
|
|
37
|
+
if isinstance(detail, list):
|
|
38
|
+
rendered = [_one(item) for item in cast(list[object], detail)]
|
|
39
|
+
return "; ".join(rendered), rendered
|
|
40
|
+
return str(detail), []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _one(item: object) -> str:
|
|
44
|
+
"""Render one entry of a problem list, whether it is a string or a pydantic error."""
|
|
45
|
+
if isinstance(item, str):
|
|
46
|
+
return item
|
|
47
|
+
if isinstance(item, dict):
|
|
48
|
+
mapping = cast(dict[str, Any], item)
|
|
49
|
+
location = ".".join(str(part) for part in mapping.get("loc", []))
|
|
50
|
+
message = str(mapping.get("msg", mapping))
|
|
51
|
+
return f"{location}: {message}" if location else message
|
|
52
|
+
return str(item)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _where(request: Request) -> str:
|
|
56
|
+
"""Name the path that was asked for, never including a credential from it."""
|
|
57
|
+
return redact_path(request.url.path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def answer(problem: Problem) -> JSONResponse:
|
|
61
|
+
"""Serialise a problem into a response."""
|
|
62
|
+
return JSONResponse(
|
|
63
|
+
status_code=problem.status,
|
|
64
|
+
content=problem.model_dump(mode="json"),
|
|
65
|
+
headers={VERSION_HEADER: __version__},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _stamp_version(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
|
|
70
|
+
"""Put the version header on every response."""
|
|
71
|
+
response = await call_next(request)
|
|
72
|
+
response.headers[VERSION_HEADER] = __version__
|
|
73
|
+
return response
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def http_error(request: Request, error: Exception) -> JSONResponse:
|
|
77
|
+
"""Render an HTTPException as a problem."""
|
|
78
|
+
if not isinstance(error, StarletteHTTPException): # pragma: no cover - registered for this class
|
|
79
|
+
return await unhandled(request, error)
|
|
80
|
+
detail, problems = _problems_of(error.detail)
|
|
81
|
+
response = answer(render(error.status_code, detail, problems=problems, instance=_where(request)))
|
|
82
|
+
if isinstance(error, HTTPException) and error.headers:
|
|
83
|
+
response.headers.update(error.headers)
|
|
84
|
+
return response
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def validation_error(request: Request, error: Exception) -> JSONResponse:
|
|
88
|
+
"""Render a request-validation failure as a problem with its field list intact."""
|
|
89
|
+
if not isinstance(error, RequestValidationError): # pragma: no cover - registered for this class
|
|
90
|
+
return await unhandled(request, error)
|
|
91
|
+
detail, problems = _problems_of(error.errors())
|
|
92
|
+
return answer(render(422, detail, problems=problems, instance=_where(request)))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def unhandled(request: Request, error: Exception) -> JSONResponse:
|
|
96
|
+
"""Answer an unhandled exception in the same shape, and say nothing about it.
|
|
97
|
+
|
|
98
|
+
An unexpected exception's message may carry a DSN, a path, or a fragment of a payload,
|
|
99
|
+
so it goes to the log only and never to the caller.
|
|
100
|
+
"""
|
|
101
|
+
_logger.error(
|
|
102
|
+
"unhandled exception",
|
|
103
|
+
path=_where(request),
|
|
104
|
+
method=request.method,
|
|
105
|
+
error=f"{type(error).__name__}: {error}",
|
|
106
|
+
exc_info=error,
|
|
107
|
+
)
|
|
108
|
+
return answer(render(500, INTERNAL_DETAIL, instance=_where(request)))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def install_error_handlers(app: FastAPI) -> None:
|
|
112
|
+
"""Install the error envelope and the version header on the application."""
|
|
113
|
+
app.middleware("http")(_stamp_version)
|
|
114
|
+
app.add_exception_handler(StarletteHTTPException, http_error)
|
|
115
|
+
app.add_exception_handler(RequestValidationError, validation_error)
|
|
116
|
+
app.add_exception_handler(Exception, unhandled)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""The liveness and readiness probes, and the checks readiness runs."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Request, Response, status
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
10
|
+
|
|
11
|
+
from dirigent_client.schemas import CheckResult, CheckStatus, Health, Readiness
|
|
12
|
+
from dirigent_core.database import ping
|
|
13
|
+
|
|
14
|
+
router = APIRouter(tags=["health"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
#: Aggregation takes the maximum, so a new status must sort worse than every status it
|
|
18
|
+
#: outranks.
|
|
19
|
+
_SEVERITY: dict[CheckStatus, int] = {
|
|
20
|
+
CheckStatus.HEALTHY: 0,
|
|
21
|
+
CheckStatus.DEGRADED: 1,
|
|
22
|
+
CheckStatus.UNHEALTHY: 2,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HealthCheck(ABC):
|
|
27
|
+
"""One named dependency the readiness probe verifies."""
|
|
28
|
+
|
|
29
|
+
name: ClassVar[str]
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
async def check(self) -> CheckResult:
|
|
33
|
+
"""Verify the dependency, returning a result rather than raising."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DatabaseHealthCheck(HealthCheck):
|
|
38
|
+
"""Verifies that the database answers a trivial query."""
|
|
39
|
+
|
|
40
|
+
name = "database"
|
|
41
|
+
|
|
42
|
+
def __init__(self, engine: AsyncEngine) -> None:
|
|
43
|
+
"""Bind the check to the process's engine."""
|
|
44
|
+
self._engine = engine
|
|
45
|
+
|
|
46
|
+
async def check(self) -> CheckResult:
|
|
47
|
+
"""Run SELECT 1 through the async engine."""
|
|
48
|
+
if await ping(self._engine):
|
|
49
|
+
return CheckResult(status=CheckStatus.HEALTHY)
|
|
50
|
+
return CheckResult(status=CheckStatus.UNHEALTHY, detail="database is unreachable")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
type HealthCheckRegistry = Mapping[str, HealthCheck]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_registry(engine: AsyncEngine) -> dict[str, HealthCheck]:
|
|
57
|
+
"""Assemble the checks this process runs."""
|
|
58
|
+
checks: list[HealthCheck] = [DatabaseHealthCheck(engine)]
|
|
59
|
+
return {check.name: check for check in checks}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def aggregate(results: Mapping[str, CheckResult]) -> CheckStatus:
|
|
63
|
+
"""Reduce a set of results to the worst status; no checks means healthy."""
|
|
64
|
+
if not results:
|
|
65
|
+
return CheckStatus.HEALTHY
|
|
66
|
+
return max(results.values(), key=lambda result: _SEVERITY[result.status]).status
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def run_checks(registry: HealthCheckRegistry) -> dict[str, CheckResult]:
|
|
70
|
+
"""Run every registered check concurrently, turning a raised error into unhealthy."""
|
|
71
|
+
|
|
72
|
+
async def run(check: HealthCheck) -> CheckResult:
|
|
73
|
+
try:
|
|
74
|
+
return await check.check()
|
|
75
|
+
except Exception as error:
|
|
76
|
+
return CheckResult(status=CheckStatus.UNHEALTHY, detail=f"{type(error).__name__}: {error}")
|
|
77
|
+
|
|
78
|
+
names = list(registry)
|
|
79
|
+
results = await asyncio.gather(*(run(registry[name]) for name in names))
|
|
80
|
+
return dict(zip(names, results, strict=True))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.get("/health", operation_id="health", response_model=Health, summary="Liveness")
|
|
84
|
+
async def health(request: Request) -> Health:
|
|
85
|
+
"""Report that the process is alive, without touching any dependency."""
|
|
86
|
+
return Health(version=request.app.state.version)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.get("/health/ready", operation_id="readiness", response_model=Readiness, summary="Readiness")
|
|
90
|
+
async def health_ready(request: Request, response: Response) -> Readiness:
|
|
91
|
+
"""Run every registered check and report the worst status, with 503 when unhealthy."""
|
|
92
|
+
registry: HealthCheckRegistry = request.app.state.health_checks
|
|
93
|
+
results = await run_checks(registry)
|
|
94
|
+
overall = aggregate(results)
|
|
95
|
+
if overall is CheckStatus.UNHEALTHY:
|
|
96
|
+
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
|
97
|
+
return Readiness(status=overall, checks=results)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""One envelope for every listing, and the keyset walk behind it.
|
|
2
|
+
|
|
3
|
+
Every listing answers ``{"items": [...], "next": <cursor or null>}``. ``limit`` bounds a page
|
|
4
|
+
at 500 and defaults to 50; ``after`` carries the cursor a previous page's ``next`` gave out.
|
|
5
|
+
A cursor is opaque to the caller and is the sort key of the last row returned, so a listing
|
|
6
|
+
selects ``limit + 1`` rows past it in its own order, answers ``limit`` of them, and says
|
|
7
|
+
``next`` only when the extra row existed. A cursor that does not parse is a 422.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
from uuid import UUID
|
|
13
|
+
|
|
14
|
+
from fastapi import HTTPException, Query, status
|
|
15
|
+
|
|
16
|
+
DEFAULT_PAGE = 50
|
|
17
|
+
|
|
18
|
+
MAX_PAGE = 500
|
|
19
|
+
|
|
20
|
+
LimitParam = Annotated[int, Query(ge=1, le=MAX_PAGE, description="How many rows to return.")]
|
|
21
|
+
|
|
22
|
+
AfterParam = Annotated[str | None, Query(description="Continue from a previous page's next cursor.")]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def clip[T](rows: list[T], limit: int, key: Callable[[T], object]) -> tuple[list[T], str | None]:
|
|
26
|
+
"""Cut a ``limit + 1`` selection down to the page, and name the cursor that continues it."""
|
|
27
|
+
page = rows[:limit]
|
|
28
|
+
if len(rows) <= limit or not page:
|
|
29
|
+
return page, None
|
|
30
|
+
return page, str(key(page[-1]))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def uuid_cursor(after: str | None) -> UUID | None:
|
|
34
|
+
"""Read an id cursor, refusing anything that is not one."""
|
|
35
|
+
if after is None:
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
return UUID(after)
|
|
39
|
+
except ValueError as error:
|
|
40
|
+
raise HTTPException(
|
|
41
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
42
|
+
detail=f"after={after!r} is not a cursor this listing gave out",
|
|
43
|
+
) from error
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def int_cursor(after: str | None, *, name: str = "after") -> int | None:
|
|
47
|
+
"""Read a numeric cursor, refusing anything that is not one."""
|
|
48
|
+
if after is None:
|
|
49
|
+
return None
|
|
50
|
+
try:
|
|
51
|
+
return int(after)
|
|
52
|
+
except ValueError as error:
|
|
53
|
+
raise HTTPException(
|
|
54
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
55
|
+
detail=f"{name}={after!r} is not a cursor this listing gave out",
|
|
56
|
+
) from error
|
dirigent_server/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""The versioned API, assembled from one router per resource.
|
|
2
|
+
|
|
3
|
+
Every business router is mounted behind :func:`require_principal`, so authentication is a
|
|
4
|
+
property of the mount. Only login is exempt. Webhook intake authenticates as the trigger
|
|
5
|
+
rather than as a person, so it is mounted at the application root instead.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends
|
|
9
|
+
|
|
10
|
+
from dirigent_server.routes import (
|
|
11
|
+
alerts,
|
|
12
|
+
auth,
|
|
13
|
+
blocks,
|
|
14
|
+
connections,
|
|
15
|
+
hooks,
|
|
16
|
+
pipelines,
|
|
17
|
+
runs,
|
|
18
|
+
schema,
|
|
19
|
+
schemas,
|
|
20
|
+
system,
|
|
21
|
+
trigger_documents,
|
|
22
|
+
triggers,
|
|
23
|
+
users,
|
|
24
|
+
workers,
|
|
25
|
+
)
|
|
26
|
+
from dirigent_server.security import require_principal
|
|
27
|
+
from dirigent_server.transactions import Transactional
|
|
28
|
+
|
|
29
|
+
TAGS: list[dict[str, str]] = [
|
|
30
|
+
{"name": "auth", "description": "Logging in, and the tokens automation uses."},
|
|
31
|
+
{"name": "pipelines", "description": "Applying, exporting, and running definitions."},
|
|
32
|
+
{"name": "runs", "description": "Runs, their attempts, their logs, and their reports."},
|
|
33
|
+
{"name": "triggers", "description": "A pipeline's schedules and inbound webhooks."},
|
|
34
|
+
{"name": "alerts", "description": "Alert rules, and the queue they deliver through."},
|
|
35
|
+
{"name": "hooks", "description": "Webhook intake, authenticated by its own token."},
|
|
36
|
+
{"name": "connections", "description": "Named credential records of contributed kinds."},
|
|
37
|
+
{"name": "blocks", "description": "The catalog every plugin contributes to."},
|
|
38
|
+
{"name": "schema", "description": "The shape a document is written against."},
|
|
39
|
+
{"name": "schemas", "description": "Named JSON Schemas the instance holds."},
|
|
40
|
+
{"name": "workers", "description": "The worker registry."},
|
|
41
|
+
{"name": "users", "description": "Local accounts."},
|
|
42
|
+
{"name": "system", "description": "What this instance is, and whether it is well."},
|
|
43
|
+
{"name": "health", "description": "Liveness and readiness."},
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_router() -> APIRouter:
|
|
48
|
+
"""Assemble the versioned API: the login route, then everything behind authentication."""
|
|
49
|
+
router = APIRouter(route_class=Transactional)
|
|
50
|
+
router.include_router(auth.public_router)
|
|
51
|
+
guarded = APIRouter(dependencies=[Depends(require_principal)], route_class=Transactional)
|
|
52
|
+
for module in (
|
|
53
|
+
auth,
|
|
54
|
+
pipelines,
|
|
55
|
+
triggers,
|
|
56
|
+
trigger_documents,
|
|
57
|
+
runs,
|
|
58
|
+
alerts,
|
|
59
|
+
connections,
|
|
60
|
+
blocks,
|
|
61
|
+
schema,
|
|
62
|
+
schemas,
|
|
63
|
+
workers,
|
|
64
|
+
users,
|
|
65
|
+
system,
|
|
66
|
+
):
|
|
67
|
+
guarded.include_router(module.router)
|
|
68
|
+
router.include_router(guarded)
|
|
69
|
+
return router
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_hooks_router() -> APIRouter:
|
|
73
|
+
"""Assemble the unauthenticated webhook intake, mounted at the application root."""
|
|
74
|
+
router = APIRouter(route_class=Transactional)
|
|
75
|
+
router.include_router(hooks.router)
|
|
76
|
+
return router
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["TAGS", "build_hooks_router", "build_router"]
|