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,427 @@
1
+ """Pipelines: apply, export, versions, activation, deletion, the ad hoc run, and backfill."""
2
+
3
+ from typing import Annotated
4
+
5
+ from fastapi import APIRouter, HTTPException, Query, Response, status
6
+ from fastapi.responses import PlainTextResponse
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from dirigent_client.schemas import (
10
+ ApplyRequest,
11
+ ApplyResult,
12
+ BackfillAccepted,
13
+ BackfilledRun,
14
+ BackfillRequest,
15
+ LastRun,
16
+ Page,
17
+ PipelineDetail,
18
+ PipelineOut,
19
+ PipelineVersionOut,
20
+ PruneRequest,
21
+ PruneResult,
22
+ RunAccepted,
23
+ RunRequest,
24
+ ValidationIssue,
25
+ )
26
+ from dirigent_core.directory import prune_absent
27
+ from dirigent_core.documents import DocumentError, carried_refusal, load_document
28
+ from dirigent_core.engine import Attribution, ParameterError, create_run
29
+ from dirigent_core.engine.definition import load_definition
30
+ from dirigent_core.engine.runs import Provenance, RunCreationError, RunWindow
31
+ from dirigent_core.models import Pipeline
32
+ from dirigent_core.pipelines import (
33
+ NO_COUNTS,
34
+ PipelineCounts,
35
+ PipelineError,
36
+ PipelineInUse,
37
+ UnknownPipeline,
38
+ apply_document,
39
+ delete_pipeline,
40
+ export_pipeline,
41
+ get_version,
42
+ last_runs,
43
+ list_pipelines,
44
+ list_versions,
45
+ listing_counts,
46
+ require_pipeline,
47
+ revalidate,
48
+ set_active,
49
+ )
50
+ from dirigent_core.triggers.backfill import BackfillError, backfill
51
+ from dirigent_core.triggers.schedules import find_schedule
52
+ from dirigent_server.dependencies import ServicesDep, SessionDep
53
+ from dirigent_server.pagination import DEFAULT_PAGE, AfterParam, LimitParam, clip, int_cursor
54
+ from dirigent_server.security import AdminDep, OperatorDep, PrincipalDep
55
+ from dirigent_server.transactions import Transactional
56
+
57
+ router = APIRouter(route_class=Transactional, tags=["pipelines"])
58
+
59
+
60
+ def render(row: Pipeline, counts: PipelineCounts = NO_COUNTS, last: LastRun | None = None) -> PipelineOut:
61
+ """Render a pipeline row with the counts and the last run a listing draws beside it."""
62
+ return PipelineOut.model_validate(row, from_attributes=True).model_copy(
63
+ update={
64
+ "active_runs": counts.active_runs,
65
+ "schedules": counts.schedules,
66
+ "webhooks": counts.webhooks,
67
+ "last_run": last,
68
+ }
69
+ )
70
+
71
+
72
+ @router.get(
73
+ "/pipelines",
74
+ operation_id="listPipelines",
75
+ summary="List pipelines",
76
+ response_model=Page[PipelineOut],
77
+ )
78
+ async def list_all(
79
+ session: SessionDep,
80
+ principal: PrincipalDep,
81
+ after: AfterParam = None,
82
+ limit: LimitParam = DEFAULT_PAGE,
83
+ tag: Annotated[
84
+ list[str] | None, Query(description="Only pipelines wearing this tag; repeat it to name more.")
85
+ ] = None,
86
+ ) -> Page[PipelineOut]:
87
+ """List every pipeline, what fires it, how many runs are in flight, and how the last one went.
88
+
89
+ ``tag`` repeats, and repeating it narrows: a pipeline is listed only if it wears every
90
+ tag named.
91
+ """
92
+ rows = await list_pipelines(session, after=after, limit=limit + 1, tags=tag or ())
93
+ page, following = clip(rows, limit, lambda row: row.code)
94
+ ids = [row.id for row in page]
95
+ counts = await listing_counts(session, ids)
96
+ latest = await last_runs(session, ids)
97
+ found = [render(row, counts.get(row.id, NO_COUNTS), latest.get(row.id)) for row in page]
98
+ return Page(items=found, next=following)
99
+
100
+
101
+ @router.post(
102
+ "/pipelines/$apply",
103
+ operation_id="applyPipeline",
104
+ summary="Apply a pipeline document",
105
+ response_model=ApplyResult,
106
+ )
107
+ async def apply(
108
+ payload: ApplyRequest,
109
+ session: SessionDep,
110
+ services: ServicesDep,
111
+ principal: OperatorDep,
112
+ dry_run: Annotated[bool, Query(description="Report the plan without writing anything.")] = False,
113
+ ) -> ApplyResult:
114
+ """Validate a document against this instance and commit a new version, or plan one.
115
+
116
+ ``pause_schedules`` on the request creates this apply's new schedules paused; one the
117
+ instance already holds keeps the paused state it has.
118
+ """
119
+ raw = dict(payload.document)
120
+ if payload.code is not None:
121
+ raw["code"] = payload.code
122
+ try:
123
+ definition = load_document(raw)
124
+ except DocumentError as error:
125
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=error.problems) from error
126
+ refusal = carried_refusal(definition)
127
+ if refusal is not None:
128
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=[refusal])
129
+ return await apply_document(
130
+ session,
131
+ services,
132
+ definition,
133
+ provenance=Provenance(source=payload.source, ref=payload.source_ref, applied_by=principal.label),
134
+ dry_run=dry_run,
135
+ pause_schedules=payload.pause_schedules,
136
+ )
137
+
138
+
139
+ @router.post(
140
+ "/pipelines/$prune",
141
+ operation_id="prunePipelines",
142
+ summary="Deactivate directory pipelines absent from a set",
143
+ response_model=PruneResult,
144
+ )
145
+ async def prune(
146
+ payload: PruneRequest,
147
+ session: SessionDep,
148
+ principal: OperatorDep,
149
+ dry_run: Annotated[bool, Query(description="Report what would be deactivated without writing.")] = False,
150
+ ) -> PruneResult:
151
+ """The reconcile half of a directory apply.
152
+
153
+ Every active pipeline whose current version a directory apply wrote, and whose code is
154
+ not in ``keep``, is deactivated -- never deleted. A directory-provenance triggers document
155
+ absent from ``keep`` is deleted instead, and takes the rows it owned with it. An empty
156
+ ``keep`` is refused outside a dry run: an empty directory is an accident, not an
157
+ instruction to turn everything off.
158
+ """
159
+ if not payload.keep and not dry_run:
160
+ raise HTTPException(
161
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
162
+ detail=["keep names no codes, and pruning against an empty set would deactivate every directory pipeline"],
163
+ )
164
+ return await prune_absent(session, set(payload.keep), dry_run=dry_run)
165
+
166
+
167
+ @router.get(
168
+ "/pipelines/{code}",
169
+ operation_id="getPipeline",
170
+ summary="Read a pipeline",
171
+ response_model=PipelineDetail,
172
+ )
173
+ async def get_one(code: str, session: SessionDep, principal: PrincipalDep) -> PipelineDetail:
174
+ """Read a pipeline and the document its current version holds."""
175
+ pipeline = await _require(session, code)
176
+ document = None
177
+ if pipeline.current_version is not None:
178
+ document = (await get_version(session, pipeline)).ordered_document
179
+ counts = await listing_counts(session, [pipeline.id])
180
+ latest = await last_runs(session, [pipeline.id])
181
+ base = render(pipeline, counts.get(pipeline.id, NO_COUNTS), latest.get(pipeline.id))
182
+ return PipelineDetail(**base.model_dump(), document=document)
183
+
184
+
185
+ @router.get(
186
+ "/pipelines/{code}/versions",
187
+ operation_id="listPipelineVersions",
188
+ summary="List a pipeline's versions",
189
+ response_model=Page[PipelineVersionOut],
190
+ )
191
+ async def versions(
192
+ code: str,
193
+ session: SessionDep,
194
+ principal: PrincipalDep,
195
+ after: AfterParam = None,
196
+ limit: LimitParam = DEFAULT_PAGE,
197
+ ) -> Page[PipelineVersionOut]:
198
+ """List every immutable version, newest first, with its provenance."""
199
+ pipeline = await _require(session, code)
200
+ rows = await list_versions(session, pipeline.id, after=int_cursor(after), limit=limit + 1)
201
+ found = [PipelineVersionOut.model_validate(row, from_attributes=True) for row in rows]
202
+ items, following = clip(found, limit, lambda row: row.version)
203
+ return Page(items=items, next=following)
204
+
205
+
206
+ @router.get(
207
+ "/pipelines/{code}/$export",
208
+ operation_id="exportPipeline",
209
+ summary="Export a pipeline as canonical YAML",
210
+ response_class=PlainTextResponse,
211
+ responses={200: {"content": {"application/yaml": {}}, "description": "The canonical document."}},
212
+ )
213
+ async def export(
214
+ code: str,
215
+ session: SessionDep,
216
+ principal: PrincipalDep,
217
+ version: Annotated[int | None, Query(description="Export this version instead of the current one.")] = None,
218
+ ) -> PlainTextResponse:
219
+ """Render a stored pipeline as the canonical YAML a git repository holds."""
220
+ try:
221
+ text = await export_pipeline(session, code, version=version)
222
+ except PipelineError as error:
223
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
224
+ return PlainTextResponse(text, media_type="application/yaml")
225
+
226
+
227
+ @router.post(
228
+ "/pipelines/{code}/$validate",
229
+ operation_id="validatePipeline",
230
+ summary="Re-check a stored pipeline against this instance",
231
+ response_model=list[ValidationIssue],
232
+ )
233
+ async def validate_stored(
234
+ code: str,
235
+ session: SessionDep,
236
+ services: ServicesDep,
237
+ principal: PrincipalDep,
238
+ version: Annotated[int | None, Query(description="Check this version instead of the current one.")] = None,
239
+ ) -> list[ValidationIssue]:
240
+ """Report what a stored version would fail on if it ran now, or an empty list."""
241
+ try:
242
+ return await revalidate(session, services, code, version=version)
243
+ except PipelineError as error:
244
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
245
+
246
+
247
+ @router.post(
248
+ "/pipelines/{code}/$activate",
249
+ operation_id="activatePipeline",
250
+ summary="Activate a pipeline",
251
+ response_model=PipelineOut,
252
+ )
253
+ async def activate(code: str, session: SessionDep, principal: OperatorDep) -> PipelineOut:
254
+ """Make a pipeline runnable again, and let its schedules fire."""
255
+ return render(await _act(session, code, active=True))
256
+
257
+
258
+ @router.post(
259
+ "/pipelines/{code}/$deactivate",
260
+ operation_id="deactivatePipeline",
261
+ summary="Deactivate a pipeline",
262
+ response_model=PipelineOut,
263
+ )
264
+ async def deactivate(code: str, session: SessionDep, principal: OperatorDep) -> PipelineOut:
265
+ """Deregister a pipeline: schedules pause, it stops being runnable, history is kept."""
266
+ return render(await _act(session, code, active=False))
267
+
268
+
269
+ @router.delete(
270
+ "/pipelines/{code}",
271
+ operation_id="deletePipeline",
272
+ summary="Delete a pipeline and its history",
273
+ status_code=status.HTTP_204_NO_CONTENT,
274
+ )
275
+ async def delete(code: str, session: SessionDep, principal: AdminDep) -> Response:
276
+ """Delete a pipeline and every run ever attributed to it, in one transaction.
277
+
278
+ Admin, unlike every other pipeline verb, and it takes the history with it: the runs,
279
+ their items, attempts, logs and artifact references, plus the versions, schedules and
280
+ webhooks the definition owns. Runs still in flight refuse the delete with a 409 --
281
+ finish or cancel them first. Deactivating is the reversible verb an operator has.
282
+ """
283
+ try:
284
+ await delete_pipeline(session, code)
285
+ except UnknownPipeline as error:
286
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
287
+ except PipelineInUse as error:
288
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
289
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
290
+
291
+
292
+ @router.post(
293
+ "/pipelines/{code}/$run",
294
+ operation_id="runPipeline",
295
+ summary="Start an ad hoc run",
296
+ response_model=RunAccepted,
297
+ status_code=status.HTTP_202_ACCEPTED,
298
+ )
299
+ async def start_run(
300
+ code: str,
301
+ payload: RunRequest,
302
+ session: SessionDep,
303
+ services: ServicesDep,
304
+ principal: OperatorDep,
305
+ ) -> RunAccepted:
306
+ """Validate parameters against the pipeline's schema and instantiate a run."""
307
+ pipeline = await _require(session, code)
308
+ if not pipeline.active:
309
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"pipeline {code!r} is deactivated")
310
+ if pipeline.current_version is None:
311
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"pipeline {code!r} has no versions yet")
312
+ version = await get_version(session, pipeline)
313
+ definition = load_definition(version.document)
314
+ try:
315
+ definition.validate_params(payload.params, services.format_checker)
316
+ except ParameterError as error:
317
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error)) from error
318
+ try:
319
+ run = await create_run(
320
+ session,
321
+ services,
322
+ version,
323
+ params=payload.params,
324
+ attribution=Attribution(
325
+ kind=principal.trigger_kind,
326
+ id=principal.token_id or principal.user_id,
327
+ label=principal.label,
328
+ ),
329
+ window=_window(payload),
330
+ log_levels={pattern: level.value for pattern, level in payload.log_levels.items()}
331
+ if payload.log_levels
332
+ else None,
333
+ priority=payload.priority,
334
+ )
335
+ except RunCreationError as error:
336
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
337
+ if run is None:
338
+ return RunAccepted(status="skipped", detail="a run of this pipeline is already in flight")
339
+ return RunAccepted(run_id=run.id, status=run.status.value)
340
+
341
+
342
+ def _window(payload: RunRequest) -> RunWindow | None:
343
+ """Read the window a run was asked for, which the body has both ends of or neither."""
344
+ if payload.window_start is None or payload.window_end is None:
345
+ return None
346
+ return RunWindow(start=payload.window_start, end=payload.window_end)
347
+
348
+
349
+ @router.post(
350
+ "/pipelines/{code}/$backfill",
351
+ operation_id="backfillPipeline",
352
+ summary="Fill the windows a schedule's cadence has already gone past",
353
+ response_model=BackfillAccepted,
354
+ status_code=status.HTTP_202_ACCEPTED,
355
+ )
356
+ async def start_backfill(
357
+ code: str,
358
+ payload: BackfillRequest,
359
+ session: SessionDep,
360
+ services: ServicesDep,
361
+ principal: OperatorDep,
362
+ ) -> BackfillAccepted:
363
+ """Enumerate a schedule's firings inside an interval and create one run per window.
364
+
365
+ The schedule's own clock is untouched: this fills what has already gone past, and every
366
+ run it creates is attributed to the backfill rather than to a firing.
367
+ """
368
+ del principal
369
+ pipeline = await _require(session, code)
370
+ if not pipeline.active:
371
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"pipeline {code!r} is deactivated")
372
+ if pipeline.current_version is None:
373
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"pipeline {code!r} has no versions yet")
374
+ schedule = await find_schedule(session, pipeline.id, payload.schedule)
375
+ if schedule is None:
376
+ raise HTTPException(
377
+ status_code=status.HTTP_404_NOT_FOUND,
378
+ detail=f"pipeline {code!r} has no schedule coded {payload.schedule!r}",
379
+ )
380
+ version = await get_version(session, pipeline)
381
+ try:
382
+ filled = await backfill(
383
+ session,
384
+ services,
385
+ version,
386
+ schedule,
387
+ start=payload.from_,
388
+ end=payload.to,
389
+ params=payload.params,
390
+ dry_run=payload.dry_run,
391
+ )
392
+ except BackfillError as error:
393
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error)) from error
394
+ except ParameterError as error:
395
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error)) from error
396
+ except RunCreationError as error:
397
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
398
+ return BackfillAccepted(
399
+ pipeline=pipeline.code,
400
+ schedule=schedule.code,
401
+ dry_run=payload.dry_run,
402
+ windows=[
403
+ BackfilledRun(
404
+ window_start=one.window.start,
405
+ window_end=one.window.end,
406
+ run_id=one.run_id,
407
+ detail=one.detail,
408
+ )
409
+ for one in filled
410
+ ],
411
+ )
412
+
413
+
414
+ async def _require(session: AsyncSession, code: str) -> Pipeline:
415
+ """Read a pipeline by code, translating "no such thing" into a 404."""
416
+ try:
417
+ return await require_pipeline(session, code)
418
+ except UnknownPipeline as error:
419
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
420
+
421
+
422
+ async def _act(session: AsyncSession, code: str, *, active: bool) -> Pipeline:
423
+ """Activate or deactivate, translating "no such thing" into a 404."""
424
+ try:
425
+ return await set_active(session, code, active=active)
426
+ except UnknownPipeline as error:
427
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error