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.
@@ -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())