dirigent-core 0.18.0__tar.gz → 0.18.2__tar.gz

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.
Files changed (59) hide show
  1. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/PKG-INFO +4 -4
  2. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/pyproject.toml +4 -4
  3. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/pyproject.toml.orig +4 -4
  4. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/alembic/env.py +12 -10
  5. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/alembic/versions/0001_baseline_schema.py +11 -0
  6. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/alerting.py +75 -25
  7. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/definition.py +6 -1
  8. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/runs.py +2 -0
  9. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/messages.py +15 -2
  10. dirigent_core-0.18.2/src/dirigent_core/migrations.py +203 -0
  11. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/models.py +15 -0
  12. dirigent_core-0.18.0/src/dirigent_core/migrations.py +0 -98
  13. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/LICENSE +0 -0
  14. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/README.md +0 -0
  15. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/__init__.py +0 -0
  16. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/alembic/script.py.mako +0 -0
  17. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/artifacts.py +0 -0
  18. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/auth.py +0 -0
  19. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/blockdocs.py +0 -0
  20. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/config.py +0 -0
  21. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/configdocs.py +0 -0
  22. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/database.py +0 -0
  23. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/directory.py +0 -0
  24. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/documents.py +0 -0
  25. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/documentschema.py +0 -0
  26. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/__init__.py +0 -0
  27. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/claim.py +0 -0
  28. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/context.py +0 -0
  29. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/executor.py +0 -0
  30. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/failure.py +0 -0
  31. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/recovery.py +0 -0
  32. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/references.py +0 -0
  33. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/services.py +0 -0
  34. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/state.py +0 -0
  35. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/errors.py +0 -0
  36. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/examples.py +0 -0
  37. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/ids.py +0 -0
  38. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/logging.py +0 -0
  39. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/pipelines.py +0 -0
  40. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/plugins.py +0 -0
  41. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/protocol.py +0 -0
  42. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/py.typed +0 -0
  43. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/ratelimit.py +0 -0
  44. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/registry.py +0 -0
  45. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/reporting.py +0 -0
  46. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/retention.py +0 -0
  47. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/scheduler.py +0 -0
  48. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/schemas.py +0 -0
  49. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/secrets.py +0 -0
  50. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/storage.py +0 -0
  51. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/telemetry.py +0 -0
  52. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/trigger_documents.py +0 -0
  53. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/triggers/__init__.py +0 -0
  54. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/triggers/backfill.py +0 -0
  55. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/triggers/materialize.py +0 -0
  56. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/triggers/schedules.py +0 -0
  57. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/triggers/webhooks.py +0 -0
  58. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/types.py +0 -0
  59. {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-core
3
- Version: 0.18.0
3
+ Version: 0.18.2
4
4
  Summary: Dirigent engine core: schema, configuration, and plugin host.
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
@@ -12,9 +12,9 @@ Requires-Dist: argon2-cffi>=25.1.0
12
12
  Requires-Dist: asyncpg>=0.31.0
13
13
  Requires-Dist: cronsim>=2.7
14
14
  Requires-Dist: cryptography>=46.0.5
15
- Requires-Dist: dirigent-client==0.18.0
16
- Requires-Dist: dirigent-common==0.18.0
17
- Requires-Dist: dirigent-plugin==0.18.0
15
+ Requires-Dist: dirigent-client==0.18.2
16
+ Requires-Dist: dirigent-common==0.18.2
17
+ Requires-Dist: dirigent-plugin==0.18.2
18
18
  Requires-Dist: jsonschema>=4.26.0
19
19
  Requires-Dist: opentelemetry-api>=1.44.0
20
20
  Requires-Dist: pydantic-settings>=2.15.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-core"
3
- version = "0.18.0"
3
+ version = "0.18.2"
4
4
  description = "Dirigent engine core: schema, configuration, and plugin host."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -17,9 +17,9 @@ dependencies = [
17
17
  "asyncpg>=0.31.0",
18
18
  "cronsim>=2.7",
19
19
  "cryptography>=46.0.5",
20
- "dirigent-client==0.18.0",
21
- "dirigent-common==0.18.0",
22
- "dirigent-plugin==0.18.0",
20
+ "dirigent-client==0.18.2",
21
+ "dirigent-common==0.18.2",
22
+ "dirigent-plugin==0.18.2",
23
23
  "jsonschema>=4.26.0",
24
24
  "opentelemetry-api>=1.44.0",
25
25
  "pydantic-settings>=2.15.0",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-core"
3
- version = "0.18.0"
3
+ version = "0.18.2"
4
4
  description = "Dirigent engine core: schema, configuration, and plugin host."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -17,9 +17,9 @@ dependencies = [
17
17
  "asyncpg>=0.31.0",
18
18
  "cronsim>=2.7",
19
19
  "cryptography>=46.0.5",
20
- "dirigent-client==0.18.0",
21
- "dirigent-common==0.18.0",
22
- "dirigent-plugin==0.18.0",
20
+ "dirigent-client==0.18.2",
21
+ "dirigent-common==0.18.2",
22
+ "dirigent-plugin==0.18.2",
23
23
  "jsonschema>=4.26.0",
24
24
  "opentelemetry-api>=1.44.0",
25
25
  "pydantic-settings>=2.15.0",
@@ -1,14 +1,12 @@
1
1
  """Alembic environment: one migration history that runs on both PostgreSQL and SQLite."""
2
2
 
3
3
  import asyncio
4
- from typing import Any
5
4
 
6
5
  from alembic import context
7
6
  from sqlalchemy import Connection
8
- from sqlalchemy.ext.asyncio import async_engine_from_config
9
- from sqlalchemy.pool import NullPool
10
7
 
11
8
  from dirigent_core.config import get_settings
9
+ from dirigent_core.database import create_engine
12
10
  from dirigent_core.models import Base
13
11
 
14
12
  config = context.config
@@ -47,13 +45,17 @@ def do_run_migrations(connection: Connection) -> None:
47
45
 
48
46
 
49
47
  async def run_async_migrations() -> None:
50
- """Open the async engine and drive the migrations through it."""
51
- section: dict[str, Any] = dict(config.get_section(config.config_ini_section) or {})
52
- section["sqlalchemy.url"] = database_url()
53
- engine = async_engine_from_config(section, prefix="sqlalchemy.", poolclass=NullPool)
54
- async with engine.connect() as connection:
55
- await connection.run_sync(do_run_migrations)
56
- await engine.dispose()
48
+ """Open the engine on the URL to migrate and drive the migrations through it.
49
+
50
+ The engine is the instance's own, so this path opens SQLite with the pragmas every other
51
+ connection in the process is configured with.
52
+ """
53
+ engine = create_engine(get_settings().model_copy(update={"database_url": database_url()}))
54
+ try:
55
+ async with engine.connect() as connection:
56
+ await connection.run_sync(do_run_migrations)
57
+ finally:
58
+ await engine.dispose()
57
59
 
58
60
 
59
61
  def run_migrations_online() -> None:
@@ -96,6 +96,12 @@ def upgrade() -> None:
96
96
  sa.Column("name", sa.String(length=200), nullable=True),
97
97
  sa.Column("description", sa.Text(), nullable=True),
98
98
  sa.Column("tags", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=False),
99
+ sa.Column(
100
+ "importance",
101
+ sa.Enum("routine", "normal", "critical", name="importance", native_enum=False, length=32),
102
+ server_default="normal",
103
+ nullable=False,
104
+ ),
99
105
  sa.Column("active", sa.Boolean(), server_default=sa.true(), nullable=False),
100
106
  sa.Column("current_version", sa.Integer(), nullable=True),
101
107
  sa.Column(
@@ -208,6 +214,11 @@ def upgrade() -> None:
208
214
  "scope", sa.Enum("global", "pipeline", name="alert_scope", native_enum=False, length=32), nullable=False
209
215
  ),
210
216
  sa.Column("pipeline_id", sa.Uuid(), nullable=True),
217
+ sa.Column(
218
+ "importance",
219
+ sa.Enum("routine", "normal", "critical", name="importance", native_enum=False, length=32),
220
+ nullable=True,
221
+ ),
211
222
  sa.Column("notifier", sa.String(length=100), nullable=False),
212
223
  sa.Column("connection_id", sa.Uuid(), nullable=True),
213
224
  sa.Column("template", sa.Text(), nullable=True),
@@ -17,7 +17,16 @@ from pydantic import BaseModel, ConfigDict, Field, JsonValue
17
17
  from sqlalchemy.exc import IntegrityError
18
18
  from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
19
19
 
20
- from dirigent_client.enums import AlertEvent, AlertScope, LogLevel, NotificationStatus, RunStatus
20
+ from dirigent_client.enums import (
21
+ IMPORTANCE_RANK,
22
+ AlertEvent,
23
+ AlertScope,
24
+ Importance,
25
+ LogLevel,
26
+ NotificationStatus,
27
+ RunStatus,
28
+ )
29
+ from dirigent_client.schemas.alerts import LOG_NOTIFIER
21
30
  from dirigent_common import (
22
31
  EntityName,
23
32
  JsonMap,
@@ -42,7 +51,7 @@ from dirigent_core.messages import (
42
51
  NOTIFICATION_IN_FLIGHT,
43
52
  NOTIFIER_NOT_ON_WORKER,
44
53
  SCOPE_NEEDS_PIPELINE,
45
- UNKNOWN_NOTIFIER,
54
+ TARGET_HAS_NO_NOTIFIER,
46
55
  )
47
56
  from dirigent_core.models import (
48
57
  AlertRule,
@@ -101,10 +110,11 @@ class AlertRuleRequest(BaseModel):
101
110
  name: str | None = None
102
111
  description: str | None = None
103
112
  event: AlertEvent
104
- notifier: str
105
113
  scope: AlertScope = AlertScope.GLOBAL
106
114
  pipeline: str | None = None
115
+ importance: Importance | None = None
107
116
  connection: str | None = None
117
+ """The connection this rule delivers through, whose kind names the sender; none is the log."""
108
118
  template: str | None = None
109
119
  body: str | None = None
110
120
  throttle: timedelta = timedelta(0)
@@ -173,15 +183,12 @@ async def list_rules(
173
183
 
174
184
 
175
185
  async def create_rule(session: AsyncSession, services: EngineServices, request: AlertRuleRequest) -> AlertRule:
176
- """Declare an alert rule, refusing a notifier or a scope this instance cannot honour."""
177
- if request.notifier not in services.host.notifiers:
178
- installed = ", ".join(sorted(services.host.notifiers)) or "none are installed"
179
- raise AlertError(UNKNOWN_NOTIFIER, notifier=repr(request.notifier), installed=installed)
186
+ """Declare an alert rule, refusing a target or a scope this instance cannot honour."""
180
187
  if await find_rule(session, request.code) is not None:
181
188
  raise AlertError(DUPLICATE_RULE, code=repr(request.code))
182
189
  check_templates(template=request.template, body=request.body)
183
190
  pipeline_id = await _scope_pipeline(session, request)
184
- connection_id = await _connection_id(session, request.connection) if request.connection else None
191
+ notifier, connection_id = await _target(session, services, request.connection)
185
192
  rule = AlertRule(
186
193
  code=request.code,
187
194
  name=request.name,
@@ -189,7 +196,8 @@ async def create_rule(session: AsyncSession, services: EngineServices, request:
189
196
  event=request.event,
190
197
  scope=request.scope,
191
198
  pipeline_id=pipeline_id,
192
- notifier=request.notifier,
199
+ importance=request.importance,
200
+ notifier=notifier,
193
201
  connection_id=connection_id,
194
202
  template=request.template,
195
203
  body=request.body,
@@ -225,13 +233,31 @@ async def _scope_pipeline(session: AsyncSession, request: AlertRuleRequest) -> U
225
233
  return pipeline.id
226
234
 
227
235
 
228
- async def _connection_id(session: AsyncSession, code: str) -> UUID:
229
- """Resolve the connection a notifier delivers through, refusing an unknown code."""
236
+ async def _connection(session: AsyncSession, code: str) -> Connection:
237
+ """Read one connection by code, refusing a code this instance does not hold."""
230
238
  found = await session.execute(sa.select(Connection).where(Connection.code == code))
231
239
  connection = found.scalar_one_or_none()
232
240
  if connection is None:
233
241
  raise AlertError(ALERT_UNKNOWN_CONNECTION, code=repr(code))
234
- return connection.id
242
+ return connection
243
+
244
+
245
+ async def _target(session: AsyncSession, services: EngineServices, code: str | None) -> tuple[str, UUID | None]:
246
+ """Resolve the one target named into the sender that delivers it and what it opens.
247
+
248
+ A target is a connection, and the notifier is that connection's kind; naming nothing is
249
+ the process log. A kind no installed notifier answers to is refused here rather than
250
+ stored, because the row would otherwise hold a sender no worker can dispatch on.
251
+ """
252
+ if code is None:
253
+ return LOG_NOTIFIER, None
254
+ connection = await _connection(session, code)
255
+ if connection.kind not in services.host.notifiers:
256
+ installed = ", ".join(sorted(services.host.notifiers)) or "none are installed"
257
+ raise AlertError(
258
+ TARGET_HAS_NO_NOTIFIER, code=repr(connection.code), kind=repr(connection.kind), installed=installed
259
+ )
260
+ return connection.kind, connection.id
235
261
 
236
262
 
237
263
  async def delete_rule(session: AsyncSession, rule: AlertRule) -> None:
@@ -242,11 +268,23 @@ async def delete_rule(session: AsyncSession, rule: AlertRule) -> None:
242
268
  _logger.info("alert rule deleted", rule=code)
243
269
 
244
270
 
245
- async def matching_rules(session: AsyncSession, event: AlertEvent, pipeline_id: UUID) -> list[AlertRule]:
271
+ def _satisfied_levels(importance: Importance) -> list[Importance]:
272
+ """The floors a pipeline of this importance clears: its own and every lesser one."""
273
+ reached = IMPORTANCE_RANK[importance]
274
+ return [level for level, rank in IMPORTANCE_RANK.items() if rank <= reached]
275
+
276
+
277
+ async def matching_rules(
278
+ session: AsyncSession, event: AlertEvent, pipeline_id: UUID, importance: Importance
279
+ ) -> list[AlertRule]:
246
280
  """Find the live rules that want to hear about one event, throttled or not.
247
281
 
248
282
  A paused rule matches nothing. Pausing is instance state an operator sets on the row, so
249
283
  it is read here rather than folded into ``active``, which is what the rule itself declares.
284
+
285
+ A rule that names an importance wants only the pipelines that carry at least that much;
286
+ one that names none wants every pipeline, so a rule that pages the critical failures and
287
+ a rule that logs everything both raise on the same run.
250
288
  """
251
289
  rows = await session.execute(
252
290
  sa.select(AlertRule).where(
@@ -254,6 +292,7 @@ async def matching_rules(session: AsyncSession, event: AlertEvent, pipeline_id:
254
292
  AlertRule.active.is_(True),
255
293
  AlertRule.paused.is_(False),
256
294
  sa.or_(AlertRule.scope == AlertScope.GLOBAL, AlertRule.pipeline_id == pipeline_id),
295
+ sa.or_(AlertRule.importance.is_(None), AlertRule.importance.in_(_satisfied_levels(importance))),
257
296
  )
258
297
  )
259
298
  return list(rows.scalars())
@@ -268,14 +307,17 @@ async def set_paused(session: AsyncSession, rule: AlertRule, *, paused: bool) ->
268
307
 
269
308
 
270
309
  #: What a PATCH may write on a rule, and nothing else on the row.
271
- UPDATABLE: Final = ("paused", "template", "body")
310
+ UPDATABLE: Final = ("paused", "template", "body", "importance", "connection")
272
311
 
273
312
 
274
- async def update_rule(session: AsyncSession, rule: AlertRule, changes: Mapping[str, object]) -> AlertRule:
313
+ async def update_rule(
314
+ session: AsyncSession, services: EngineServices, rule: AlertRule, changes: Mapping[str, object]
315
+ ) -> AlertRule:
275
316
  """Write the fields a PATCH named on a rule, leaving every field it did not name.
276
317
 
277
- A subject or a body is compiled here as it is at creation, so a rule on the row always
278
- holds a template that renders.
318
+ A subject or a body is compiled here as it is at creation, and a target is resolved here as
319
+ it is at creation, so a rule on the row always holds a template that renders and a sender a
320
+ worker can dispatch on.
279
321
  """
280
322
  named = {name: value for name, value in changes.items() if name in UPDATABLE}
281
323
  if "template" in named or "body" in named:
@@ -287,6 +329,16 @@ async def update_rule(session: AsyncSession, rule: AlertRule, changes: Mapping[s
287
329
  rule.body = cast("str | None", named.get("body", rule.body))
288
330
  await session.flush()
289
331
  _logger.info("alert rule retemplated", rule=rule.code)
332
+ if "importance" in named:
333
+ floor = cast("Importance | None", named["importance"])
334
+ rule.importance = floor
335
+ await session.flush()
336
+ _logger.info("alert rule reweighted", rule=rule.code, importance=floor.value if floor else None)
337
+ if "connection" in named:
338
+ code = cast("str | None", named["connection"])
339
+ rule.notifier, rule.connection_id = await _target(session, services, code)
340
+ await session.flush()
341
+ _logger.info("alert rule retargeted", rule=rule.code, notifier=rule.notifier, connection=code)
290
342
  if "paused" in named:
291
343
  await set_paused(session, rule, paused=bool(named["paused"]))
292
344
  return rule
@@ -386,7 +438,7 @@ async def _queue_for_run(
386
438
  pipeline = await session.get(Pipeline, run.pipeline_id)
387
439
  if pipeline is None: # pragma: no cover - the foreign key makes this unreachable
388
440
  return []
389
- rules = await matching_rules(session, event, run.pipeline_id)
441
+ rules = await matching_rules(session, event, run.pipeline_id, pipeline.importance)
390
442
  if not rules:
391
443
  return []
392
444
  report_url = report_url_for(services.settings.alert_base_url, report_artifact_id)
@@ -633,26 +685,24 @@ async def queue_test_message(
633
685
  session: AsyncSession,
634
686
  services: EngineServices,
635
687
  *,
636
- notifier: str,
637
688
  connection: str | None = None,
638
689
  subject: str = "dirigent test alert",
639
690
  body: str = "This is a test message sent through the notifier surface.",
640
691
  ) -> Notification:
641
692
  """Queue one unattached message, which is what ``dg alerts test`` sends.
642
693
 
643
- The subject and the body are rendered as a rule's are, over a stand-in context, so a
644
- template can be tried out before it is written onto a rule.
694
+ The message names one target the way a rule does, and the sender follows from that
695
+ connection's kind. The subject and the body are rendered as a rule's are, over a stand-in
696
+ context, so a template can be tried out before it is written onto a rule.
645
697
  """
646
- if notifier not in services.host.notifiers:
647
- installed = ", ".join(sorted(services.host.notifiers)) or "none are installed"
648
- raise AlertError(UNKNOWN_NOTIFIER, notifier=repr(notifier), installed=installed)
698
+ notifier, connection_id = await _target(session, services, connection)
649
699
  context: JsonMap = {"run": {"pipeline": "(test)", "status": "succeeded"}, "report": None}
650
700
  notification = Notification(
651
701
  alert_rule_id=None,
652
702
  run_id=None,
653
703
  event=AlertEvent.RUN_SUCCEEDED,
654
704
  notifier=notifier,
655
- connection_id=await _connection_id(session, connection) if connection else None,
705
+ connection_id=connection_id,
656
706
  subject=_one_line(_or_literal(subject, context, max_bytes=SUBJECT_RENDER_CAP)),
657
707
  body=_or_literal(body, context, max_bytes=int(services.settings.report_max_size)),
658
708
  context=context,
@@ -9,7 +9,7 @@ from jsonschema import Draft202012Validator, FormatChecker
9
9
  from jsonschema import ValidationError as SchemaValidationError
10
10
  from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator
11
11
 
12
- from dirigent_client.enums import RunPriority
12
+ from dirigent_client.enums import Importance, RunPriority
13
13
  from dirigent_client.schemas import Requirements
14
14
  from dirigent_common import TEMPLATE_MEDIA_TYPE, EntityName, JsonMap, StepName, TemplateError, compile_template
15
15
  from dirigent_common.durations import Duration
@@ -314,6 +314,11 @@ class PipelineDefinition(BaseModel):
314
314
 
315
315
  A trigger may override it for what it fires, and an ad hoc run may override it again."""
316
316
 
317
+ importance: Importance = Importance.NORMAL
318
+ """How much this pipeline matters, which is not when its work is claimed; priority says that.
319
+
320
+ An alert rule may name the least importance a pipeline must have before it fires."""
321
+
317
322
  params: JsonMap = Field(default_factory=lambda: dict(EMPTY_PARAMS_SCHEMA))
318
323
  steps: dict[StepName, StepDefinition] = Field(min_length=1)
319
324
  triggers: TriggerSpecs = Field(default_factory=TriggerSpecs)
@@ -166,6 +166,7 @@ async def _save_pipeline_once(
166
166
  name=definition.name,
167
167
  description=description or definition.description,
168
168
  tags=list(definition.tags),
169
+ importance=definition.importance,
169
170
  )
170
171
  session.add(pipeline)
171
172
  await session.flush()
@@ -176,6 +177,7 @@ async def _save_pipeline_once(
176
177
  pipeline.name = definition.name
177
178
  pipeline.description = description or definition.description
178
179
  pipeline.tags = list(definition.tags)
180
+ pipeline.importance = definition.importance
179
181
  recorded = provenance or Provenance()
180
182
  next_version = (pipeline.current_version or 0) + 1
181
183
  version = PipelineVersion(
@@ -6,6 +6,16 @@ renders are what another language, a log reader and a test all hold on to.
6
6
 
7
7
  from dirigent_common import Catalogue, Message
8
8
 
9
+ DATABASE = Catalogue("database")
10
+
11
+ SCHEMA_STALE = DATABASE.define(
12
+ "schema_stale",
13
+ "the database at {where} was written by a different dirigent: {differences} difference(s), "
14
+ "first {first}; before 1.0 the schema is not migrated, so start from an empty state "
15
+ "(`dg dev --wipe-state`, or delete the state directory) or point at a database this version created",
16
+ )
17
+
18
+
9
19
  AUTH = Catalogue("auth")
10
20
 
11
21
  WEAK_PASSWORD = AUTH.define("weak_password", "a password must be at least {minimum} characters")
@@ -488,8 +498,6 @@ REQUIRED_UNMAPPED = WEBHOOK.define(
488
498
 
489
499
  ALERT = Catalogue("alert")
490
500
 
491
- UNKNOWN_NOTIFIER = ALERT.define("unknown_notifier", "no notifier {notifier} is installed ({installed})")
492
-
493
501
  DUPLICATE_RULE = ALERT.define("duplicate_rule", "an alert rule coded {code} already exists")
494
502
 
495
503
  BAD_ALERT_TEMPLATE = ALERT.define("bad_template", "{field} is not a Jinja template: {detail}")
@@ -502,6 +510,11 @@ ALERT_UNKNOWN_PIPELINE = ALERT.define("unknown_pipeline", "no pipeline coded {co
502
510
 
503
511
  ALERT_UNKNOWN_CONNECTION = ALERT.define("unknown_connection", "no connection coded {code}")
504
512
 
513
+ TARGET_HAS_NO_NOTIFIER = ALERT.define(
514
+ "target_has_no_notifier",
515
+ "connection {code} is of kind {kind}, which no installed notifier delivers through ({installed})",
516
+ )
517
+
505
518
  ALERT_VERSION_GONE = ALERT.define("version_gone", "run {run} pins a pipeline version that is gone")
506
519
 
507
520
  NOTIFIER_NOT_ON_WORKER = ALERT.define("notifier_not_on_worker", "notifier {notifier} is not installed on this worker")
@@ -0,0 +1,203 @@
1
+ """Programmatic Alembic access, so the CLI, the server, and the tests drive one migration history."""
2
+
3
+ import asyncio
4
+ import io
5
+ from collections.abc import Iterable, Iterator
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+ from typing import Any, Final
9
+
10
+ import sqlalchemy as sa
11
+ from alembic import command
12
+ from alembic.autogenerate import compare_metadata
13
+ from alembic.config import Config
14
+ from alembic.runtime.migration import MigrationContext
15
+ from alembic.script import ScriptDirectory
16
+ from pydantic import BaseModel, ConfigDict
17
+ from sqlalchemy import Connection
18
+ from sqlalchemy.ext.asyncio import AsyncEngine
19
+
20
+ from dirigent_core.config import Settings, get_settings
21
+ from dirigent_core.database import create_engine
22
+ from dirigent_core.models import Base
23
+
24
+ #: The migration scripts ship inside the package, so no working directory is assumed.
25
+ ALEMBIC_DIRECTORY = Path(__file__).resolve().parent / "alembic"
26
+
27
+
28
+ def alembic_config(settings: Settings | None = None) -> Config:
29
+ """Build an Alembic config pointing at the packaged migrations and the configured database."""
30
+ resolved = settings or get_settings()
31
+ config = Config()
32
+ config.set_main_option("script_location", str(ALEMBIC_DIRECTORY))
33
+ config.set_main_option("sqlalchemy.url", resolved.database_url)
34
+ config.set_main_option("timezone", "UTC")
35
+ return config
36
+
37
+
38
+ def _run_command(connection: Connection, name: str, revision: str, settings: Settings | None) -> None:
39
+ """Run one Alembic command on an established synchronous connection."""
40
+ config = alembic_config(settings)
41
+ config.attributes["connection"] = connection
42
+ getattr(command, name)(config, revision)
43
+
44
+
45
+ async def _with_connection(name: str, revision: str, settings: Settings | None) -> None:
46
+ """Open the async engine and hand a synchronous connection to Alembic."""
47
+ engine = create_engine(settings or get_settings())
48
+ try:
49
+ async with engine.begin() as connection:
50
+ await connection.run_sync(_run_command, name, revision, settings)
51
+ finally:
52
+ await engine.dispose()
53
+
54
+
55
+ async def upgrade_async(revision: str = "head", settings: Settings | None = None) -> None:
56
+ """Bring the database up to a revision, creating the schema on an empty database."""
57
+ await _with_connection("upgrade", revision, settings)
58
+
59
+
60
+ async def downgrade_async(revision: str, settings: Settings | None = None) -> None:
61
+ """Take the database back to a revision."""
62
+ await _with_connection("downgrade", revision, settings)
63
+
64
+
65
+ def _read_revision(connection: Connection) -> str | None:
66
+ """Read the alembic_version stamp from an established connection."""
67
+ return MigrationContext.configure(connection).get_current_revision()
68
+
69
+
70
+ async def current_revision_async(settings: Settings | None = None) -> str | None:
71
+ """Read the revision the database is stamped with, or None when it has never been migrated."""
72
+ engine = create_engine(settings or get_settings())
73
+ try:
74
+ async with engine.connect() as connection:
75
+ return await connection.run_sync(_read_revision)
76
+ finally:
77
+ await engine.dispose()
78
+
79
+
80
+ def upgrade(revision: str = "head", settings: Settings | None = None) -> None:
81
+ """Run an upgrade from synchronous code, such as the CLI."""
82
+ asyncio.run(upgrade_async(revision, settings))
83
+
84
+
85
+ def downgrade(revision: str, settings: Settings | None = None) -> None:
86
+ """Run a downgrade from synchronous code, such as the CLI."""
87
+ asyncio.run(downgrade_async(revision, settings))
88
+
89
+
90
+ def current_revision(settings: Settings | None = None) -> str | None:
91
+ """Read the current revision from synchronous code, such as the CLI."""
92
+ return asyncio.run(current_revision_async(settings))
93
+
94
+
95
+ def head_revision(settings: Settings | None = None) -> str | None:
96
+ """Read the newest revision the packaged migrations define."""
97
+ return ScriptDirectory.from_config(alembic_config(settings)).get_current_head()
98
+
99
+
100
+ def history(settings: Settings | None = None) -> str:
101
+ """Render the migration history as Alembic prints it, without touching the database."""
102
+ config = alembic_config(settings)
103
+ buffer = io.StringIO()
104
+ config.stdout = buffer
105
+ command.history(config, verbose=False)
106
+ return buffer.getvalue()
107
+
108
+
109
+ class DifferenceKind(StrEnum):
110
+ """What one difference between a live database and the models is."""
111
+
112
+ MISSING_TABLE = "missing_table"
113
+ """The models declare a table the database has not got."""
114
+
115
+ MISSING_COLUMN = "missing_column"
116
+ """A declared table is there, without a column the models give it."""
117
+
118
+ EXTRA_COLUMN = "extra_column"
119
+ """A declared table carries a column the models do not declare."""
120
+
121
+ CHANGED = "changed"
122
+ """A declared table differs some other way: a column's type, an index, a constraint."""
123
+
124
+
125
+ #: How each kind reads after the table or column it is about.
126
+ _WORDING: Final[dict[DifferenceKind, str]] = {
127
+ DifferenceKind.MISSING_TABLE: "missing",
128
+ DifferenceKind.MISSING_COLUMN: "missing",
129
+ DifferenceKind.EXTRA_COLUMN: "unexpected",
130
+ DifferenceKind.CHANGED: "changed",
131
+ }
132
+
133
+
134
+ class SchemaDifference(BaseModel):
135
+ """One way a live database differs from the models, named where it is."""
136
+
137
+ model_config = ConfigDict(frozen=True)
138
+
139
+ kind: DifferenceKind
140
+ """What the difference is."""
141
+
142
+ table: str
143
+ """The table it is in."""
144
+
145
+ column: str | None = None
146
+ """The column it is in, when the difference is about one."""
147
+
148
+ def __str__(self) -> str:
149
+ """Render the difference as the phrase a refusal names it by."""
150
+ where = f"{self.table}.{self.column}" if self.column else f"table {self.table}"
151
+ return f"{where} {_WORDING[self.kind]}"
152
+
153
+
154
+ def _flattened(differences: Iterable[Any]) -> Iterator[tuple[Any, ...]]:
155
+ """Walk Alembic's diff list, which groups several changes to one column in a nested list."""
156
+ for entry in differences:
157
+ if isinstance(entry, list):
158
+ yield from entry
159
+ else:
160
+ yield entry
161
+
162
+
163
+ def _named(difference: tuple[Any, ...]) -> SchemaDifference | None:
164
+ """Name the table and column one Alembic difference is about.
165
+
166
+ A table the models do not declare is not a difference: the database may be shared, and
167
+ nothing dirigent runs reads it.
168
+ """
169
+ what = difference[0]
170
+ if what == "add_table":
171
+ return SchemaDifference(kind=DifferenceKind.MISSING_TABLE, table=difference[1].name)
172
+ if what == "remove_table":
173
+ return None
174
+ if what == "add_column":
175
+ return SchemaDifference(kind=DifferenceKind.MISSING_COLUMN, table=difference[2], column=difference[3].name)
176
+ if what == "remove_column":
177
+ return SchemaDifference(kind=DifferenceKind.EXTRA_COLUMN, table=difference[2], column=difference[3].name)
178
+ if what.startswith("modify_"):
179
+ return SchemaDifference(kind=DifferenceKind.CHANGED, table=difference[2], column=difference[3])
180
+ element = difference[1]
181
+ table = element if isinstance(element, sa.Table) else getattr(element, "table", None)
182
+ return None if table is None else SchemaDifference(kind=DifferenceKind.CHANGED, table=table.name)
183
+
184
+
185
+ def _compare(connection: Connection) -> list[SchemaDifference]:
186
+ """Diff one live connection's schema against the ORM metadata."""
187
+ context = MigrationContext.configure(connection, opts={"compare_type": True, "include_schemas": False})
188
+ found = [_named(difference) for difference in _flattened(compare_metadata(context, Base.metadata))]
189
+ differences = [one for one in found if one is not None]
190
+ absent = {one.table for one in differences if one.kind is DifferenceKind.MISSING_TABLE}
191
+ # Every index and constraint of a table that is not there restates the missing table.
192
+ return [one for one in differences if one.kind is DifferenceKind.MISSING_TABLE or one.table not in absent]
193
+
194
+
195
+ async def schema_differences(engine: AsyncEngine) -> list[SchemaDifference]:
196
+ """Say how the live database differs from the models, in one reflection.
197
+
198
+ Empty means the database holds the schema this code was built against. Before 1.0 the
199
+ baseline migration is edited in place, so a database an older dirigent wrote is stamped
200
+ at the same revision and the stamp cannot answer this; reflection can.
201
+ """
202
+ async with engine.connect() as connection:
203
+ return await connection.run_sync(_compare)
@@ -13,6 +13,7 @@ from dirigent_client.enums import (
13
13
  AttemptKind,
14
14
  AttemptStatus,
15
15
  FiringOutcome,
16
+ Importance,
16
17
  LogLevel,
17
18
  NotificationStatus,
18
19
  ProvenanceSource,
@@ -91,6 +92,17 @@ class Pipeline(Entity):
91
92
  tags: Mapped[JsonList] = mapped_column(JsonDocument, nullable=False, default=list)
92
93
  """What the current document says this pipeline is for; an apply replaces the whole list."""
93
94
 
95
+ importance: Mapped[Importance] = mapped_column(
96
+ string_enum(Importance, "importance"),
97
+ nullable=False,
98
+ default=Importance.NORMAL,
99
+ server_default=Importance.NORMAL.value,
100
+ )
101
+ """How much the current document says this pipeline matters.
102
+
103
+ On the row rather than read from the version document, so an alert rule matches without
104
+ loading one."""
105
+
94
106
  active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True, server_default=sa.true())
95
107
  current_version: Mapped[int | None] = mapped_column(sa.Integer)
96
108
 
@@ -485,6 +497,9 @@ class AlertRule(Entity):
485
497
  string_enum(AlertScope, "alert_scope"), nullable=False, default=AlertScope.GLOBAL
486
498
  )
487
499
  pipeline_id: Mapped[UUID | None] = mapped_column(sa.ForeignKey("pipelines.id", ondelete="CASCADE"), index=True)
500
+ importance: Mapped[Importance | None] = mapped_column(string_enum(Importance, "importance"))
501
+ """The least importance a pipeline must carry before this rule fires; null fires for every one."""
502
+
488
503
  notifier: Mapped[str] = mapped_column(sa.String(100), nullable=False)
489
504
  connection_id: Mapped[UUID | None] = mapped_column(sa.ForeignKey("connections.id", ondelete="RESTRICT"))
490
505
  template: Mapped[str | None] = mapped_column(sa.Text)
@@ -1,98 +0,0 @@
1
- """Programmatic Alembic access, so the CLI, the server, and the tests drive one migration history."""
2
-
3
- import asyncio
4
- import io
5
- from pathlib import Path
6
-
7
- from alembic import command
8
- from alembic.config import Config
9
- from alembic.runtime.migration import MigrationContext
10
- from alembic.script import ScriptDirectory
11
- from sqlalchemy import Connection
12
-
13
- from dirigent_core.config import Settings, get_settings
14
- from dirigent_core.database import create_engine
15
-
16
- #: The migration scripts ship inside the package, so no working directory is assumed.
17
- ALEMBIC_DIRECTORY = Path(__file__).resolve().parent / "alembic"
18
-
19
-
20
- def alembic_config(settings: Settings | None = None) -> Config:
21
- """Build an Alembic config pointing at the packaged migrations and the configured database."""
22
- resolved = settings or get_settings()
23
- config = Config()
24
- config.set_main_option("script_location", str(ALEMBIC_DIRECTORY))
25
- config.set_main_option("sqlalchemy.url", resolved.database_url)
26
- config.set_main_option("timezone", "UTC")
27
- return config
28
-
29
-
30
- def _run_command(connection: Connection, name: str, revision: str, settings: Settings | None) -> None:
31
- """Run one Alembic command on an established synchronous connection."""
32
- config = alembic_config(settings)
33
- config.attributes["connection"] = connection
34
- getattr(command, name)(config, revision)
35
-
36
-
37
- async def _with_connection(name: str, revision: str, settings: Settings | None) -> None:
38
- """Open the async engine and hand a synchronous connection to Alembic."""
39
- engine = create_engine(settings or get_settings())
40
- try:
41
- async with engine.begin() as connection:
42
- await connection.run_sync(_run_command, name, revision, settings)
43
- finally:
44
- await engine.dispose()
45
-
46
-
47
- async def upgrade_async(revision: str = "head", settings: Settings | None = None) -> None:
48
- """Bring the database up to a revision, creating the schema on an empty database."""
49
- await _with_connection("upgrade", revision, settings)
50
-
51
-
52
- async def downgrade_async(revision: str, settings: Settings | None = None) -> None:
53
- """Take the database back to a revision."""
54
- await _with_connection("downgrade", revision, settings)
55
-
56
-
57
- def _read_revision(connection: Connection) -> str | None:
58
- """Read the alembic_version stamp from an established connection."""
59
- return MigrationContext.configure(connection).get_current_revision()
60
-
61
-
62
- async def current_revision_async(settings: Settings | None = None) -> str | None:
63
- """Read the revision the database is stamped with, or None when it has never been migrated."""
64
- engine = create_engine(settings or get_settings())
65
- try:
66
- async with engine.connect() as connection:
67
- return await connection.run_sync(_read_revision)
68
- finally:
69
- await engine.dispose()
70
-
71
-
72
- def upgrade(revision: str = "head", settings: Settings | None = None) -> None:
73
- """Run an upgrade from synchronous code, such as the CLI."""
74
- asyncio.run(upgrade_async(revision, settings))
75
-
76
-
77
- def downgrade(revision: str, settings: Settings | None = None) -> None:
78
- """Run a downgrade from synchronous code, such as the CLI."""
79
- asyncio.run(downgrade_async(revision, settings))
80
-
81
-
82
- def current_revision(settings: Settings | None = None) -> str | None:
83
- """Read the current revision from synchronous code, such as the CLI."""
84
- return asyncio.run(current_revision_async(settings))
85
-
86
-
87
- def head_revision(settings: Settings | None = None) -> str | None:
88
- """Read the newest revision the packaged migrations define."""
89
- return ScriptDirectory.from_config(alembic_config(settings)).get_current_head()
90
-
91
-
92
- def history(settings: Settings | None = None) -> str:
93
- """Render the migration history as Alembic prints it, without touching the database."""
94
- config = alembic_config(settings)
95
- buffer = io.StringIO()
96
- config.stdout = buffer
97
- command.history(config, verbose=False)
98
- return buffer.getvalue()
File without changes
File without changes