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,27 @@
|
|
|
1
|
+
"""The machine-readable shape of a document, for anything that edits one."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
|
|
5
|
+
from dirigent_common import JsonMap
|
|
6
|
+
from dirigent_core.documentschema import document_schema
|
|
7
|
+
from dirigent_server.dependencies import ServicesDep
|
|
8
|
+
from dirigent_server.security import PrincipalDep
|
|
9
|
+
from dirigent_server.transactions import Transactional
|
|
10
|
+
|
|
11
|
+
router = APIRouter(route_class=Transactional, tags=["schema"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.get(
|
|
15
|
+
"/schema/document",
|
|
16
|
+
operation_id="getDocumentSchema",
|
|
17
|
+
summary="The JSON Schema a pipeline document is written against",
|
|
18
|
+
response_model=JsonMap,
|
|
19
|
+
)
|
|
20
|
+
async def get_document_schema(services: ServicesDep, principal: PrincipalDep) -> JsonMap:
|
|
21
|
+
"""Serve ``dirigent/v1`` composed with this instance's block config schemas.
|
|
22
|
+
|
|
23
|
+
Each installed block contributes an ``if``/``then`` case on a step, so a config key the
|
|
24
|
+
named block does not take is wrong in an editor for the same reason an apply refuses it.
|
|
25
|
+
An instance with a plugin another instance lacks therefore answers with a different schema.
|
|
26
|
+
"""
|
|
27
|
+
return document_schema(services.host.catalog())
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Schemas: named JSON Schemas an instance holds, addressable by code and referenced by name.
|
|
2
|
+
|
|
3
|
+
A schema is locally authored, exactly like a pipeline or a connection: a person writes the
|
|
4
|
+
shape they expect a payload to have and applies it. Nothing here fetches or introspects a
|
|
5
|
+
schema from anywhere. Because a schema is a JSON Schema in its own right, its identity is
|
|
6
|
+
read from its own keywords -- ``$id`` for the code, ``title`` for the name, ``description``
|
|
7
|
+
for the description -- when a write does not give one, so what is stored is a portable
|
|
8
|
+
schema rather than a wrapper around one. A stored body is checked to be a valid JSON Schema
|
|
9
|
+
(Draft 2020-12) before anything lands.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sqlalchemy as sa
|
|
13
|
+
from fastapi import APIRouter, HTTPException, Response, status
|
|
14
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
15
|
+
|
|
16
|
+
from dirigent_client.schemas import Page, SchemaIn, SchemaOut, SchemaUpdate
|
|
17
|
+
from dirigent_core.models import Schema
|
|
18
|
+
from dirigent_core.schemas import SchemaRefused, check_valid_schema, resolve_identity
|
|
19
|
+
from dirigent_server.dependencies import SessionDep
|
|
20
|
+
from dirigent_server.pagination import DEFAULT_PAGE, AfterParam, LimitParam, clip
|
|
21
|
+
from dirigent_server.security import AdminDep, PrincipalDep
|
|
22
|
+
from dirigent_server.transactions import Transactional
|
|
23
|
+
|
|
24
|
+
router = APIRouter(route_class=Transactional, tags=["schemas"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render(row: Schema) -> SchemaOut:
|
|
28
|
+
"""Render a stored schema for a response."""
|
|
29
|
+
return SchemaOut(
|
|
30
|
+
id=row.id,
|
|
31
|
+
code=row.code,
|
|
32
|
+
name=row.name,
|
|
33
|
+
description=row.description,
|
|
34
|
+
body=row.body,
|
|
35
|
+
created_at=row.created_at,
|
|
36
|
+
updated_at=row.updated_at,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def find(session: AsyncSession, code: str) -> Schema:
|
|
41
|
+
"""Read one stored schema by code, or 404."""
|
|
42
|
+
row = (await session.execute(sa.select(Schema).where(Schema.code == code))).scalar_one_or_none()
|
|
43
|
+
if row is None:
|
|
44
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"no schema coded {code!r}")
|
|
45
|
+
return row
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.get("/schemas", operation_id="listSchemas", summary="List schemas", response_model=Page[SchemaOut])
|
|
49
|
+
async def list_schemas(
|
|
50
|
+
session: SessionDep,
|
|
51
|
+
principal: PrincipalDep,
|
|
52
|
+
after: AfterParam = None,
|
|
53
|
+
limit: LimitParam = DEFAULT_PAGE,
|
|
54
|
+
) -> Page[SchemaOut]:
|
|
55
|
+
"""List every schema the instance holds."""
|
|
56
|
+
statement = sa.select(Schema).order_by(Schema.code).limit(limit + 1)
|
|
57
|
+
if after is not None:
|
|
58
|
+
statement = statement.where(Schema.code > after)
|
|
59
|
+
rows = await session.execute(statement)
|
|
60
|
+
found = [render(row) for row in rows.scalars()]
|
|
61
|
+
items, following = clip(found, limit, lambda row: row.code)
|
|
62
|
+
return Page(items=items, next=following)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@router.post(
|
|
66
|
+
"/schemas",
|
|
67
|
+
operation_id="createSchema",
|
|
68
|
+
summary="Store a schema",
|
|
69
|
+
response_model=SchemaOut,
|
|
70
|
+
status_code=status.HTTP_201_CREATED,
|
|
71
|
+
)
|
|
72
|
+
async def create_schema(payload: SchemaIn, session: SessionDep, principal: AdminDep) -> SchemaOut:
|
|
73
|
+
"""Store a JSON Schema, taking its identity from its own keywords when the write gives none."""
|
|
74
|
+
try:
|
|
75
|
+
check_valid_schema(payload.body)
|
|
76
|
+
code, name, description = resolve_identity(
|
|
77
|
+
payload.body, code=payload.code, name=payload.name, description=payload.description
|
|
78
|
+
)
|
|
79
|
+
except SchemaRefused as error:
|
|
80
|
+
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error)) from error
|
|
81
|
+
existing = (await session.execute(sa.select(Schema).where(Schema.code == code))).scalar_one_or_none()
|
|
82
|
+
if existing is not None:
|
|
83
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"a schema coded {code!r} exists")
|
|
84
|
+
row = Schema(code=code, name=name, description=description, body=payload.body)
|
|
85
|
+
session.add(row)
|
|
86
|
+
await session.flush()
|
|
87
|
+
return render(row)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@router.get("/schemas/{code}", operation_id="getSchema", summary="Read a schema", response_model=SchemaOut)
|
|
91
|
+
async def get_schema(code: str, session: SessionDep, principal: PrincipalDep) -> SchemaOut:
|
|
92
|
+
"""Read one schema by its code."""
|
|
93
|
+
return render(await find(session, code))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@router.patch("/schemas/{code}", operation_id="updateSchema", summary="Update a schema", response_model=SchemaOut)
|
|
97
|
+
async def update_schema(code: str, payload: SchemaUpdate, session: SessionDep, principal: AdminDep) -> SchemaOut:
|
|
98
|
+
"""Replace a schema's body or its labels; the code is fixed once minted.
|
|
99
|
+
|
|
100
|
+
A field left out is left alone; a name or description sent as null is cleared. A body
|
|
101
|
+
sent replaces the stored schema and is checked the same way a create's is.
|
|
102
|
+
"""
|
|
103
|
+
row = await find(session, code)
|
|
104
|
+
if payload.changing("name"):
|
|
105
|
+
row.name = payload.name
|
|
106
|
+
if payload.changing("description"):
|
|
107
|
+
row.description = payload.description
|
|
108
|
+
if payload.body is not None:
|
|
109
|
+
try:
|
|
110
|
+
check_valid_schema(payload.body)
|
|
111
|
+
except SchemaRefused as error:
|
|
112
|
+
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error)) from error
|
|
113
|
+
row.body = payload.body
|
|
114
|
+
await session.flush()
|
|
115
|
+
return render(row)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.delete(
|
|
119
|
+
"/schemas/{code}",
|
|
120
|
+
operation_id="deleteSchema",
|
|
121
|
+
summary="Delete a schema",
|
|
122
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
123
|
+
)
|
|
124
|
+
async def delete_schema(code: str, session: SessionDep, principal: AdminDep) -> Response:
|
|
125
|
+
"""Remove a schema."""
|
|
126
|
+
await session.delete(await find(session, code))
|
|
127
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""What this instance is, and whether everything it depends on is answering."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import sqlalchemy as sa
|
|
6
|
+
from fastapi import APIRouter
|
|
7
|
+
|
|
8
|
+
from dirigent_client.schemas import ConnectionHealth, SystemInfo
|
|
9
|
+
from dirigent_core import __version__
|
|
10
|
+
from dirigent_core.models import Connection, Worker, utcnow
|
|
11
|
+
from dirigent_core.registry import STALE_AFTER
|
|
12
|
+
from dirigent_server.dependencies import ServicesDep, SessionDep, SettingsDep
|
|
13
|
+
from dirigent_server.routes.connections import check
|
|
14
|
+
from dirigent_server.security import PrincipalDep
|
|
15
|
+
from dirigent_server.transactions import Transactional
|
|
16
|
+
|
|
17
|
+
router = APIRouter(route_class=Transactional, tags=["system"])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.get(
|
|
21
|
+
"/system/info",
|
|
22
|
+
operation_id="getSystemInfo",
|
|
23
|
+
summary="Describe the instance and check every connection",
|
|
24
|
+
response_model=SystemInfo,
|
|
25
|
+
)
|
|
26
|
+
async def system_info(
|
|
27
|
+
session: SessionDep,
|
|
28
|
+
services: ServicesDep,
|
|
29
|
+
settings: SettingsDep,
|
|
30
|
+
principal: PrincipalDep,
|
|
31
|
+
) -> SystemInfo:
|
|
32
|
+
"""Describe the instance, and fan out over every connection's own health check."""
|
|
33
|
+
catalog = services.host.catalog()
|
|
34
|
+
rows = list((await session.execute(sa.select(Connection).order_by(Connection.code))).scalars())
|
|
35
|
+
reports = await asyncio.gather(*(check(row, services) for row in rows))
|
|
36
|
+
live = await session.execute(
|
|
37
|
+
sa.select(sa.func.count()).select_from(Worker).where(Worker.last_seen_at >= utcnow() - STALE_AFTER)
|
|
38
|
+
)
|
|
39
|
+
return SystemInfo(
|
|
40
|
+
version=__version__,
|
|
41
|
+
environment=settings.environment,
|
|
42
|
+
database="sqlite" if settings.is_sqlite else "postgresql",
|
|
43
|
+
checked_at=utcnow(),
|
|
44
|
+
plugins=catalog.plugins,
|
|
45
|
+
blocks=len(catalog.blocks),
|
|
46
|
+
storage_schemes=services.storage.schemes,
|
|
47
|
+
notifiers=[entry.id for entry in catalog.notifiers],
|
|
48
|
+
connection_kinds=[entry.id for entry in catalog.connection_kinds],
|
|
49
|
+
workers_live=int(live.scalar_one()),
|
|
50
|
+
unsafe_blocks_enabled=list(settings.enabled_unsafe_blocks),
|
|
51
|
+
secrets_configured=services.secrets.available,
|
|
52
|
+
connections=[
|
|
53
|
+
ConnectionHealth(
|
|
54
|
+
code=row.code,
|
|
55
|
+
name=row.name,
|
|
56
|
+
kind=row.kind,
|
|
57
|
+
connected=report.healthy,
|
|
58
|
+
detail=report.detail,
|
|
59
|
+
version=report.version,
|
|
60
|
+
)
|
|
61
|
+
for row, report in zip(rows, reports, strict=True)
|
|
62
|
+
],
|
|
63
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Triggers documents: the clocks and webhooks one document declares for another's pipeline.
|
|
2
|
+
|
|
3
|
+
A document is applied through ``POST /pipelines/$apply`` like any other; what is here is
|
|
4
|
+
reading the ones the instance holds, and removing one with the rows it owns.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sqlalchemy as sa
|
|
8
|
+
from fastapi import APIRouter, HTTPException, Response, status
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from dirigent_client.schemas import Page, TriggerDocumentDetail, TriggerDocumentOut
|
|
12
|
+
from dirigent_core.models import Pipeline, TriggerDocument
|
|
13
|
+
from dirigent_core.trigger_documents import (
|
|
14
|
+
delete_trigger_document,
|
|
15
|
+
find_trigger_document,
|
|
16
|
+
list_trigger_documents,
|
|
17
|
+
owned_codes,
|
|
18
|
+
)
|
|
19
|
+
from dirigent_server.dependencies import SessionDep
|
|
20
|
+
from dirigent_server.pagination import DEFAULT_PAGE, AfterParam, LimitParam, clip
|
|
21
|
+
from dirigent_server.security import OperatorDep, PrincipalDep
|
|
22
|
+
from dirigent_server.transactions import Transactional
|
|
23
|
+
|
|
24
|
+
router = APIRouter(route_class=Transactional, tags=["triggers"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render(row: TriggerDocument, pipeline: str) -> TriggerDocumentOut:
|
|
28
|
+
"""Render a triggers document row beside the code of the pipeline it fires."""
|
|
29
|
+
return TriggerDocumentOut(
|
|
30
|
+
id=row.id,
|
|
31
|
+
code=row.code,
|
|
32
|
+
name=row.name,
|
|
33
|
+
description=row.description,
|
|
34
|
+
pipeline=pipeline,
|
|
35
|
+
digest=row.digest,
|
|
36
|
+
provenance_source=row.provenance_source,
|
|
37
|
+
provenance_ref=row.provenance_ref,
|
|
38
|
+
applied_by=row.applied_by,
|
|
39
|
+
created_at=row.created_at,
|
|
40
|
+
updated_at=row.updated_at,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.get(
|
|
45
|
+
"/trigger-documents",
|
|
46
|
+
operation_id="listTriggerDocuments",
|
|
47
|
+
summary="List triggers documents",
|
|
48
|
+
response_model=Page[TriggerDocumentOut],
|
|
49
|
+
)
|
|
50
|
+
async def list_all(
|
|
51
|
+
session: SessionDep,
|
|
52
|
+
principal: PrincipalDep,
|
|
53
|
+
after: AfterParam = None,
|
|
54
|
+
limit: LimitParam = DEFAULT_PAGE,
|
|
55
|
+
) -> Page[TriggerDocumentOut]:
|
|
56
|
+
"""List every triggers document, in code order, with the pipeline each one fires."""
|
|
57
|
+
rows = await list_trigger_documents(session, after=after, limit=limit + 1)
|
|
58
|
+
found = [render(row, await _pipeline_code(session, row)) for row in rows]
|
|
59
|
+
items, following = clip(found, limit, lambda row: row.code)
|
|
60
|
+
return Page(items=items, next=following)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.get(
|
|
64
|
+
"/trigger-documents/{code}",
|
|
65
|
+
operation_id="getTriggerDocument",
|
|
66
|
+
summary="Read a triggers document",
|
|
67
|
+
response_model=TriggerDocumentDetail,
|
|
68
|
+
)
|
|
69
|
+
async def get_one(code: str, session: SessionDep, principal: PrincipalDep) -> TriggerDocumentDetail:
|
|
70
|
+
"""Read one triggers document, the document itself, and the rows it owns."""
|
|
71
|
+
row = await _require(session, code)
|
|
72
|
+
schedules, webhooks = await owned_codes(session, row.id)
|
|
73
|
+
base = render(row, await _pipeline_code(session, row))
|
|
74
|
+
return TriggerDocumentDetail(
|
|
75
|
+
**base.model_dump(),
|
|
76
|
+
document=dict(row.document),
|
|
77
|
+
schedules=schedules,
|
|
78
|
+
webhooks=webhooks,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.delete(
|
|
83
|
+
"/trigger-documents/{code}",
|
|
84
|
+
operation_id="deleteTriggerDocument",
|
|
85
|
+
summary="Delete a triggers document and its rows",
|
|
86
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
87
|
+
)
|
|
88
|
+
async def delete(code: str, session: SessionDep, principal: OperatorDep) -> Response:
|
|
89
|
+
"""Remove a triggers document, and with it every schedule and webhook it declared.
|
|
90
|
+
|
|
91
|
+
The pipeline it named is untouched, and so is anything the pipeline's own document or an
|
|
92
|
+
operator declared on it.
|
|
93
|
+
"""
|
|
94
|
+
await delete_trigger_document(session, await _require(session, code))
|
|
95
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def _require(session: AsyncSession, code: str) -> TriggerDocument:
|
|
99
|
+
"""Read a triggers document by code, translating "no such thing" into a 404."""
|
|
100
|
+
row = await find_trigger_document(session, code)
|
|
101
|
+
if row is None:
|
|
102
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"no triggers document coded {code!r}")
|
|
103
|
+
return row
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def _pipeline_code(session: AsyncSession, row: TriggerDocument) -> str:
|
|
107
|
+
"""Name the pipeline a triggers document fires; the cascade guarantees it is there."""
|
|
108
|
+
found = await session.execute(sa.select(Pipeline.code).where(Pipeline.id == row.pipeline_id))
|
|
109
|
+
return str(found.scalar_one())
|