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,537 @@
1
+ """A pipeline's persisted triggers: its schedules and its inbound webhooks.
2
+
3
+ The instance keeps only the hash of a webhook's token, so the token is readable exactly
4
+ once: when it is minted or rotated.
5
+ """
6
+
7
+ from collections.abc import Mapping, Sequence
8
+ from datetime import timedelta
9
+ from types import MappingProxyType
10
+ from uuid import UUID
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.enums import LogLevel
17
+ from dirigent_client.schemas import (
18
+ DeliveryOut,
19
+ FiringOut,
20
+ Page,
21
+ ScheduleIn,
22
+ ScheduleOut,
23
+ SchedulePreview,
24
+ SchedulePreviewRequest,
25
+ WebhookIn,
26
+ WebhookOut,
27
+ WebhookTokenOut,
28
+ )
29
+ from dirigent_common.durations import format_duration
30
+ from dirigent_core.engine.definition import load_definition
31
+ from dirigent_core.engine.services import EngineServices
32
+ from dirigent_core.models import (
33
+ Pipeline,
34
+ Schedule,
35
+ ScheduleFiring,
36
+ TriggerDocument,
37
+ WebhookDelivery,
38
+ WebhookTrigger,
39
+ )
40
+ from dirigent_core.pipelines import UnknownPipeline, get_version, require_pipeline
41
+ from dirigent_core.triggers import (
42
+ DuplicateSchedule,
43
+ ScheduleError,
44
+ ScheduleRequest,
45
+ WebhookError,
46
+ WebhookRequest,
47
+ check_schedule_params,
48
+ check_webhook_mapping,
49
+ create_schedule,
50
+ create_webhook,
51
+ delete_schedule,
52
+ delete_webhook,
53
+ find_schedule,
54
+ find_webhook,
55
+ list_deliveries,
56
+ list_firings,
57
+ list_schedules,
58
+ list_webhooks,
59
+ preview_firings,
60
+ rotate_token,
61
+ set_active,
62
+ set_paused,
63
+ update_schedule,
64
+ )
65
+ from dirigent_server.dependencies import ServicesDep, SessionDep
66
+ from dirigent_server.pagination import DEFAULT_PAGE, AfterParam, LimitParam, clip, int_cursor
67
+ from dirigent_server.security import OperatorDep, PrincipalDep
68
+ from dirigent_server.transactions import Transactional
69
+
70
+ router = APIRouter(route_class=Transactional, tags=["triggers"])
71
+
72
+
73
+ def _schedule_request(payload: ScheduleIn) -> ScheduleRequest:
74
+ """Read a schedule declaration as the request the core schedule service takes."""
75
+ return ScheduleRequest(
76
+ code=payload.code,
77
+ name=payload.name,
78
+ description=payload.description,
79
+ cron=payload.cron,
80
+ interval=payload.interval,
81
+ at=payload.at,
82
+ timezone=payload.timezone,
83
+ params=payload.params,
84
+ connection_pins=payload.connection_pins,
85
+ log_levels={pattern: level.value for pattern, level in payload.log_levels.items()}
86
+ if payload.log_levels
87
+ else None,
88
+ priority=payload.priority,
89
+ )
90
+
91
+
92
+ def _webhook_request(payload: WebhookIn) -> WebhookRequest:
93
+ """Read a webhook declaration as the request the core webhook service takes."""
94
+ return WebhookRequest(
95
+ code=payload.code,
96
+ name=payload.name,
97
+ description=payload.description,
98
+ params_from_payload=payload.params_from_payload,
99
+ hmac_secret=payload.hmac_secret,
100
+ rate_limit_per_minute=payload.rate_limit_per_minute,
101
+ priority=payload.priority,
102
+ )
103
+
104
+
105
+ async def owning_documents(session: AsyncSession, rows: Sequence[Schedule | WebhookTrigger]) -> dict[UUID, str]:
106
+ """Read the code of every triggers document that owns one of these rows."""
107
+ wanted = {row.trigger_document_id for row in rows if row.trigger_document_id is not None}
108
+ if not wanted:
109
+ return {}
110
+ found = await session.execute(
111
+ sa.select(TriggerDocument.id, TriggerDocument.code).where(TriggerDocument.id.in_(wanted))
112
+ )
113
+ return {document_id: code for document_id, code in found.all()}
114
+
115
+
116
+ def render_schedule(row: Schedule, documents: Mapping[UUID, str] = MappingProxyType({})) -> ScheduleOut:
117
+ """Render a schedule row, writing its interval back as the humane duration it was."""
118
+ return ScheduleOut(
119
+ id=row.id,
120
+ code=row.code,
121
+ name=row.name,
122
+ description=row.description,
123
+ kind=row.kind,
124
+ cron=row.cron,
125
+ interval=format_duration(timedelta(seconds=row.interval_seconds)) if row.interval_seconds else None,
126
+ at=row.run_at,
127
+ timezone=row.timezone,
128
+ params=dict(row.params),
129
+ log_levels={pattern: LogLevel(str(level)) for pattern, level in row.log_levels.items()}
130
+ if row.log_levels
131
+ else None,
132
+ priority=row.priority,
133
+ paused=row.paused,
134
+ managed=row.managed,
135
+ trigger_document=documents.get(row.trigger_document_id) if row.trigger_document_id else None,
136
+ next_fire_at=row.next_fire_at,
137
+ last_fired_at=row.last_fired_at,
138
+ created_at=row.created_at,
139
+ )
140
+
141
+
142
+ def render_webhook(row: WebhookTrigger, documents: Mapping[UUID, str] = MappingProxyType({})) -> WebhookOut:
143
+ """Render a webhook row, saying whether it is signed rather than how."""
144
+ return WebhookOut(
145
+ id=row.id,
146
+ code=row.code,
147
+ name=row.name,
148
+ description=row.description,
149
+ token_prefix=row.token_prefix,
150
+ params_from_payload=dict(row.params_from_payload),
151
+ signed=row.hmac_secret is not None,
152
+ active=row.active,
153
+ managed=row.managed,
154
+ trigger_document=documents.get(row.trigger_document_id) if row.trigger_document_id else None,
155
+ rate_limit_per_minute=row.rate_limit_per_minute,
156
+ priority=row.priority,
157
+ last_delivery_at=row.last_delivery_at,
158
+ created_at=row.created_at,
159
+ )
160
+
161
+
162
+ async def _schedule_out(session: AsyncSession, row: Schedule) -> ScheduleOut:
163
+ """Render one schedule, with the code of whichever triggers document owns it."""
164
+ return render_schedule(row, await owning_documents(session, [row]))
165
+
166
+
167
+ async def _webhook_out(session: AsyncSession, row: WebhookTrigger) -> WebhookOut:
168
+ """Render one webhook, with the code of whichever triggers document owns it."""
169
+ return render_webhook(row, await owning_documents(session, [row]))
170
+
171
+
172
+ @router.post(
173
+ "/schedules/$preview",
174
+ operation_id="previewSchedule",
175
+ summary="Read back what a clock would fire",
176
+ response_model=SchedulePreview,
177
+ )
178
+ async def preview_schedule(payload: SchedulePreviewRequest, principal: PrincipalDep) -> SchedulePreview:
179
+ """Compute the next firings of a clock nothing has declared, with the scheduler's own arithmetic."""
180
+ try:
181
+ return SchedulePreview(
182
+ firings=preview_firings(
183
+ cron=payload.cron,
184
+ interval=payload.interval,
185
+ at=payload.at,
186
+ timezone=payload.timezone,
187
+ )
188
+ )
189
+ except ScheduleError as error:
190
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error)) from error
191
+
192
+
193
+ @router.get(
194
+ "/pipelines/{code}/triggers/schedules",
195
+ operation_id="listSchedules",
196
+ summary="List a pipeline's schedules",
197
+ response_model=Page[ScheduleOut],
198
+ )
199
+ async def schedules(
200
+ code: str,
201
+ session: SessionDep,
202
+ principal: PrincipalDep,
203
+ after: AfterParam = None,
204
+ limit: LimitParam = DEFAULT_PAGE,
205
+ ) -> Page[ScheduleOut]:
206
+ """List every schedule on a pipeline, with when each one next fires."""
207
+ pipeline = await _require(session, code)
208
+ rows = await list_schedules(session, pipeline.id, after=after, limit=limit + 1)
209
+ documents = await owning_documents(session, rows)
210
+ items, following = clip([render_schedule(row, documents) for row in rows], limit, lambda row: row.code)
211
+ return Page(items=items, next=following)
212
+
213
+
214
+ @router.get(
215
+ "/pipelines/{code}/triggers/schedules/{schedule}",
216
+ operation_id="getSchedule",
217
+ summary="Read one schedule",
218
+ response_model=ScheduleOut,
219
+ )
220
+ async def get_schedule(code: str, schedule: str, session: SessionDep, principal: PrincipalDep) -> ScheduleOut:
221
+ """Read one schedule by code."""
222
+ return await _schedule_out(session, await _require_schedule(session, code, schedule))
223
+
224
+
225
+ @router.post(
226
+ "/pipelines/{code}/triggers/schedules",
227
+ operation_id="createSchedule",
228
+ summary="Declare a schedule",
229
+ response_model=ScheduleOut,
230
+ status_code=status.HTTP_201_CREATED,
231
+ )
232
+ async def add_schedule(
233
+ code: str, payload: ScheduleIn, session: SessionDep, services: ServicesDep, principal: OperatorDep
234
+ ) -> ScheduleOut:
235
+ """Declare a schedule on a pipeline and compute when it first fires."""
236
+ pipeline = await _require(session, code)
237
+ try:
238
+ await _check_pins(session, services, pipeline.id, payload)
239
+ return await _schedule_out(session, await create_schedule(session, pipeline, _schedule_request(payload)))
240
+ except DuplicateSchedule as error:
241
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
242
+ except ScheduleError as error:
243
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error)) from error
244
+
245
+
246
+ @router.patch(
247
+ "/pipelines/{code}/triggers/schedules/{schedule}",
248
+ operation_id="updateSchedule",
249
+ summary="Redeclare a schedule",
250
+ response_model=ScheduleOut,
251
+ )
252
+ async def edit_schedule(
253
+ code: str,
254
+ schedule: str,
255
+ payload: ScheduleIn,
256
+ session: SessionDep,
257
+ services: ServicesDep,
258
+ principal: OperatorDep,
259
+ ) -> ScheduleOut:
260
+ """Change a schedule's clock or parameters, keeping whether it is paused."""
261
+ row = await _require_schedule(session, code, schedule)
262
+ try:
263
+ await _check_pins(session, services, row.pipeline_id, payload)
264
+ return await _schedule_out(session, await update_schedule(session, row, _schedule_request(payload)))
265
+ except ScheduleError as error:
266
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error)) from error
267
+
268
+
269
+ @router.post(
270
+ "/pipelines/{code}/triggers/schedules/{schedule}/$pause",
271
+ operation_id="pauseSchedule",
272
+ summary="Pause a schedule",
273
+ response_model=ScheduleOut,
274
+ )
275
+ async def pause(code: str, schedule: str, session: SessionDep, principal: OperatorDep) -> ScheduleOut:
276
+ """Stop a schedule firing, without losing it or its history."""
277
+ row = await _require_schedule(session, code, schedule)
278
+ return await _schedule_out(session, await set_paused(session, row, paused=True))
279
+
280
+
281
+ @router.post(
282
+ "/pipelines/{code}/triggers/schedules/{schedule}/$resume",
283
+ operation_id="resumeSchedule",
284
+ summary="Resume a schedule",
285
+ response_model=ScheduleOut,
286
+ )
287
+ async def resume(code: str, schedule: str, session: SessionDep, principal: OperatorDep) -> ScheduleOut:
288
+ """Start a schedule firing again, from the next slot rather than from the ones it missed."""
289
+ row = await _require_schedule(session, code, schedule)
290
+ return await _schedule_out(session, await set_paused(session, row, paused=False))
291
+
292
+
293
+ @router.get(
294
+ "/pipelines/{code}/triggers/schedules/{schedule}/firings",
295
+ operation_id="listScheduleFirings",
296
+ summary="List a schedule's firings",
297
+ response_model=Page[FiringOut],
298
+ )
299
+ async def firings(
300
+ code: str,
301
+ schedule: str,
302
+ session: SessionDep,
303
+ principal: PrincipalDep,
304
+ after: AfterParam = None,
305
+ limit: LimitParam = DEFAULT_PAGE,
306
+ ) -> Page[FiringOut]:
307
+ """Read what a schedule has actually done, newest first, including what it skipped."""
308
+ row = await _require_schedule(session, code, schedule)
309
+ rows = await list_firings(session, row.id, after=int_cursor(after), limit=limit + 1)
310
+ items, following = clip([_firing(entry) for entry in rows], limit, lambda entry: entry.id)
311
+ return Page(items=items, next=following)
312
+
313
+
314
+ def _firing(row: ScheduleFiring) -> FiringOut:
315
+ """Render one firing row."""
316
+ return FiringOut(
317
+ id=row.id,
318
+ scheduled_for=row.scheduled_for,
319
+ created_at=row.created_at,
320
+ outcome=row.outcome,
321
+ misfired=row.misfired,
322
+ run_id=row.run_id,
323
+ detail=row.detail,
324
+ )
325
+
326
+
327
+ @router.delete(
328
+ "/pipelines/{code}/triggers/schedules/{schedule}",
329
+ operation_id="deleteSchedule",
330
+ summary="Delete a schedule",
331
+ status_code=status.HTTP_204_NO_CONTENT,
332
+ )
333
+ async def remove_schedule(code: str, schedule: str, session: SessionDep, principal: OperatorDep) -> Response:
334
+ """Remove a schedule and its firing history."""
335
+ row = await _require_schedule(session, code, schedule)
336
+ await delete_schedule(session, row)
337
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
338
+
339
+
340
+ @router.get(
341
+ "/pipelines/{code}/triggers/webhooks",
342
+ operation_id="listWebhooks",
343
+ summary="List a pipeline's webhooks",
344
+ response_model=Page[WebhookOut],
345
+ )
346
+ async def webhooks(
347
+ code: str,
348
+ session: SessionDep,
349
+ principal: PrincipalDep,
350
+ after: AfterParam = None,
351
+ limit: LimitParam = DEFAULT_PAGE,
352
+ ) -> Page[WebhookOut]:
353
+ """List every webhook on a pipeline, with its mapping but never its token."""
354
+ pipeline = await _require(session, code)
355
+ rows = await list_webhooks(session, pipeline.id, after=after, limit=limit + 1)
356
+ documents = await owning_documents(session, rows)
357
+ items, following = clip([render_webhook(row, documents) for row in rows], limit, lambda row: row.code)
358
+ return Page(items=items, next=following)
359
+
360
+
361
+ @router.get(
362
+ "/pipelines/{code}/triggers/webhooks/{webhook}",
363
+ operation_id="getWebhook",
364
+ summary="Read one webhook",
365
+ response_model=WebhookOut,
366
+ )
367
+ async def get_webhook(code: str, webhook: str, session: SessionDep, principal: PrincipalDep) -> WebhookOut:
368
+ """Read one webhook by code, with its mapping but never its token."""
369
+ return await _webhook_out(session, await _require_webhook(session, code, webhook))
370
+
371
+
372
+ @router.post(
373
+ "/pipelines/{code}/triggers/webhooks",
374
+ operation_id="createWebhook",
375
+ summary="Declare a webhook and mint its token",
376
+ response_model=WebhookTokenOut,
377
+ status_code=status.HTTP_201_CREATED,
378
+ )
379
+ async def add_webhook(
380
+ code: str, payload: WebhookIn, session: SessionDep, services: ServicesDep, principal: OperatorDep
381
+ ) -> WebhookTokenOut:
382
+ """Declare a webhook and return its token, which is shown here and nowhere else again."""
383
+ pipeline = await _require(session, code)
384
+ await _check_mapping(session, pipeline.id, payload)
385
+ try:
386
+ minted = await create_webhook(session, pipeline, _webhook_request(payload), secrets=services.secrets)
387
+ except WebhookError as error:
388
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
389
+ return _token(minted.webhook_id, minted.code, minted.token.get_secret_value(), minted.prefix)
390
+
391
+
392
+ @router.post(
393
+ "/pipelines/{code}/triggers/webhooks/{webhook}/$rotate-token",
394
+ operation_id="rotateWebhookToken",
395
+ summary="Rotate a webhook's token",
396
+ response_model=WebhookTokenOut,
397
+ )
398
+ async def rotate(code: str, webhook: str, session: SessionDep, principal: OperatorDep) -> WebhookTokenOut:
399
+ """Mint a new token and forget the old one immediately; callers must be updated."""
400
+ row = await _require_webhook(session, code, webhook)
401
+ minted = await rotate_token(session, row)
402
+ return _token(minted.webhook_id, minted.code, minted.token.get_secret_value(), minted.prefix)
403
+
404
+
405
+ def _token(webhook_id: UUID, code: str, token: str, prefix: str) -> WebhookTokenOut:
406
+ """Render a minted token together with the path it is presented at."""
407
+ return WebhookTokenOut(webhook_id=webhook_id, code=code, token=token, prefix=prefix, url_path=f"/hooks/{token}")
408
+
409
+
410
+ @router.post(
411
+ "/pipelines/{code}/triggers/webhooks/{webhook}/$disable",
412
+ operation_id="disableWebhook",
413
+ summary="Disable a webhook",
414
+ response_model=WebhookOut,
415
+ )
416
+ async def disable(code: str, webhook: str, session: SessionDep, principal: OperatorDep) -> WebhookOut:
417
+ """Refuse deliveries without rotating or losing the token."""
418
+ row = await _require_webhook(session, code, webhook)
419
+ return await _webhook_out(session, await set_active(session, row, active=False))
420
+
421
+
422
+ @router.post(
423
+ "/pipelines/{code}/triggers/webhooks/{webhook}/$enable",
424
+ operation_id="enableWebhook",
425
+ summary="Enable a webhook",
426
+ response_model=WebhookOut,
427
+ )
428
+ async def enable(code: str, webhook: str, session: SessionDep, principal: OperatorDep) -> WebhookOut:
429
+ """Accept deliveries again on the token that was already issued."""
430
+ row = await _require_webhook(session, code, webhook)
431
+ return await _webhook_out(session, await set_active(session, row, active=True))
432
+
433
+
434
+ @router.get(
435
+ "/pipelines/{code}/triggers/webhooks/{webhook}/deliveries",
436
+ operation_id="listWebhookDeliveries",
437
+ summary="List a webhook's deliveries",
438
+ response_model=Page[DeliveryOut],
439
+ )
440
+ async def deliveries(
441
+ code: str,
442
+ webhook: str,
443
+ session: SessionDep,
444
+ principal: PrincipalDep,
445
+ after: AfterParam = None,
446
+ limit: LimitParam = DEFAULT_PAGE,
447
+ ) -> Page[DeliveryOut]:
448
+ """Read what has arrived, newest first, refusals included."""
449
+ row = await _require_webhook(session, code, webhook)
450
+ rows = await list_deliveries(session, row.id, after=int_cursor(after), limit=limit + 1)
451
+ items, following = clip([_delivery(entry) for entry in rows], limit, lambda entry: entry.id)
452
+ return Page(items=items, next=following)
453
+
454
+
455
+ def _delivery(row: WebhookDelivery) -> DeliveryOut:
456
+ """Render one delivery row."""
457
+ return DeliveryOut(
458
+ id=row.id,
459
+ created_at=row.created_at,
460
+ outcome=row.outcome,
461
+ run_id=row.run_id,
462
+ reason=row.reason,
463
+ mapped_params=row.mapped_params,
464
+ source=row.source,
465
+ )
466
+
467
+
468
+ @router.delete(
469
+ "/pipelines/{code}/triggers/webhooks/{webhook}",
470
+ operation_id="deleteWebhook",
471
+ summary="Delete a webhook",
472
+ status_code=status.HTTP_204_NO_CONTENT,
473
+ )
474
+ async def remove_webhook(code: str, webhook: str, session: SessionDep, principal: OperatorDep) -> Response:
475
+ """Remove a webhook, its token, and its delivery history."""
476
+ row = await _require_webhook(session, code, webhook)
477
+ await delete_webhook(session, row)
478
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
479
+
480
+
481
+ async def _check_pins(session: AsyncSession, services: EngineServices, pipeline_id: UUID, payload: ScheduleIn) -> None:
482
+ """Check a schedule's pinned parameters against the definition its firings will run.
483
+
484
+ A pipeline with no version yet declares no parameters to check against.
485
+ """
486
+ pipeline = await session.get(Pipeline, pipeline_id)
487
+ if pipeline is None or pipeline.current_version is None:
488
+ return
489
+ version = await get_version(session, pipeline)
490
+ check_schedule_params(load_definition(version.document), payload.params, services.format_checker)
491
+
492
+
493
+ async def _check_mapping(session: AsyncSession, pipeline_id: UUID, payload: WebhookIn) -> None:
494
+ """Check a webhook's mapping against the definition its deliveries will run.
495
+
496
+ A pipeline with no version yet declares no parameters to check against.
497
+ """
498
+ pipeline = await session.get(Pipeline, pipeline_id)
499
+ if pipeline is None or pipeline.current_version is None:
500
+ return
501
+ version = await get_version(session, pipeline)
502
+ try:
503
+ check_webhook_mapping(load_definition(version.document), payload.params_from_payload)
504
+ except WebhookError as error:
505
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error)) from error
506
+
507
+
508
+ async def _require(session: AsyncSession, code: str) -> Pipeline:
509
+ """Read a pipeline by code, translating "no such thing" into a 404."""
510
+ try:
511
+ return await require_pipeline(session, code)
512
+ except UnknownPipeline as error:
513
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
514
+
515
+
516
+ async def _require_schedule(session: AsyncSession, pipeline_code: str, code: str) -> Schedule:
517
+ """Read one schedule within its pipeline, or say which half was not found."""
518
+ pipeline = await _require(session, pipeline_code)
519
+ schedule = await find_schedule(session, pipeline.id, code)
520
+ if schedule is None:
521
+ raise HTTPException(
522
+ status_code=status.HTTP_404_NOT_FOUND,
523
+ detail=f"pipeline {pipeline_code!r} has no schedule coded {code!r}",
524
+ )
525
+ return schedule
526
+
527
+
528
+ async def _require_webhook(session: AsyncSession, pipeline_code: str, code: str) -> WebhookTrigger:
529
+ """Read one webhook within its pipeline, or say which half was not found."""
530
+ pipeline = await _require(session, pipeline_code)
531
+ webhook = await find_webhook(session, pipeline.id, code)
532
+ if webhook is None:
533
+ raise HTTPException(
534
+ status_code=status.HTTP_404_NOT_FOUND,
535
+ detail=f"pipeline {pipeline_code!r} has no webhook coded {code!r}",
536
+ )
537
+ return webhook