ginkgo-slack 0.4.0__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.
- ginkgo_slack-0.4.0/.gitignore +58 -0
- ginkgo_slack-0.4.0/PKG-INFO +14 -0
- ginkgo_slack-0.4.0/pyproject.toml +20 -0
- ginkgo_slack-0.4.0/src/ginkgo_slack/__init__.py +15 -0
- ginkgo_slack-0.4.0/src/ginkgo_slack/subscriber.py +434 -0
- ginkgo_slack-0.4.0/src/ginkgo_slack/webhook.py +253 -0
- ginkgo_slack-0.4.0/tests/test_slack_subscriber.py +310 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Local environment and tool caches
|
|
2
|
+
.pixi/*
|
|
3
|
+
!.pixi/config.toml
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
.ruff_cache/
|
|
6
|
+
ui/node_modules/
|
|
7
|
+
ui/.vite/
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.py[cod]
|
|
10
|
+
|
|
11
|
+
*__pycache__*
|
|
12
|
+
# Ginkgo runtime state
|
|
13
|
+
.ginkgo/
|
|
14
|
+
examples/**/logs/
|
|
15
|
+
examples/**/results/
|
|
16
|
+
benchmarks/results/
|
|
17
|
+
|
|
18
|
+
# Agent / local-instructions files
|
|
19
|
+
AGENTS.md
|
|
20
|
+
# ...but not the one `ginkgo init` scaffolds into new projects.
|
|
21
|
+
!src/ginkgo/templates/init/agents/AGENTS.md
|
|
22
|
+
CLAUDE.md
|
|
23
|
+
.claude/
|
|
24
|
+
.agents/
|
|
25
|
+
|
|
26
|
+
# uncoded symbol index (regenerated locally via `uncoded sync`)
|
|
27
|
+
.uncoded/
|
|
28
|
+
|
|
29
|
+
# Local planning / design / worklog docs
|
|
30
|
+
plan.md
|
|
31
|
+
*plan.local.md
|
|
32
|
+
worklog.md
|
|
33
|
+
*worklog*.md
|
|
34
|
+
ginkgo-design-doc.md
|
|
35
|
+
example-workflow.py
|
|
36
|
+
docs/_build/
|
|
37
|
+
docs/site/node_modules/
|
|
38
|
+
docs/site/dist/
|
|
39
|
+
docs/site/.astro/
|
|
40
|
+
|
|
41
|
+
# OS/editor noise
|
|
42
|
+
.DS_Store
|
|
43
|
+
|
|
44
|
+
*.lock
|
|
45
|
+
# Env lockfiles that are part of a fixture or example are inputs, not build
|
|
46
|
+
# output: an example whose lock is missing re-solves on first use, which
|
|
47
|
+
# changes its env identity between runs and silently breaks cache reuse.
|
|
48
|
+
!tests/**/pixi.lock
|
|
49
|
+
!plugins/**/pixi.lock
|
|
50
|
+
!examples/**/pixi.lock
|
|
51
|
+
|
|
52
|
+
# Maintainer working documents (CLAUDE.md: plans are not committed)
|
|
53
|
+
docs/*-plan.md
|
|
54
|
+
docs/user-testing-*.md
|
|
55
|
+
docs/oke-validation.md
|
|
56
|
+
docs/*.pptx
|
|
57
|
+
scratchpad/
|
|
58
|
+
.vscode/
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ginkgo-slack
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Slack run notifications for ginkgo
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: ginkgo-workflow==0.4.0
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# ginkgo-slack
|
|
11
|
+
|
|
12
|
+
Slack run notifications for ginkgo.
|
|
13
|
+
|
|
14
|
+
A plugin for [Ginkgo](https://sanjaynagi.github.io/ginkgo/). Install it with `pip install "ginkgo-workflow[slack]"`, and see the [plugins guide](https://sanjaynagi.github.io/ginkgo/getting-started/installation/#plugins).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ginkgo-slack"
|
|
3
|
+
version = "0.4.0"
|
|
4
|
+
description = "Slack run notifications for ginkgo"
|
|
5
|
+
requires-python = ">= 3.11"
|
|
6
|
+
readme = { text = "# ginkgo-slack\n\nSlack run notifications for ginkgo.\n\nA plugin for [Ginkgo](https://sanjaynagi.github.io/ginkgo/). Install it with `pip install \"ginkgo-workflow[slack]\"`, and see the [plugins guide](https://sanjaynagi.github.io/ginkgo/getting-started/installation/#plugins).", content-type = "text/markdown" }
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"ginkgo-workflow==0.4.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.entry-points."ginkgo.plugins"]
|
|
13
|
+
slack = "ginkgo_slack:PLUGIN"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["src/ginkgo_slack"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""ginkgo-slack: posts run notifications to a Slack incoming webhook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ginkgo.plugins.api import EventSubscriberType, Plugin
|
|
6
|
+
from ginkgo_slack.subscriber import build_slack_subscriber, check_slack_config
|
|
7
|
+
|
|
8
|
+
__all__ = ["PLUGIN"]
|
|
9
|
+
|
|
10
|
+
PLUGIN = Plugin(
|
|
11
|
+
name="ginkgo-slack",
|
|
12
|
+
contributions=(
|
|
13
|
+
EventSubscriberType(name="slack", build=build_slack_subscriber, check=check_slack_config),
|
|
14
|
+
),
|
|
15
|
+
)
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""Slack run notifications: configuration and the delivery subscriber."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ginkgo.core.secret import SecretRef
|
|
12
|
+
from ginkgo.plugins.api import (
|
|
13
|
+
EventSubscriberCheckContext,
|
|
14
|
+
EventSubscriberConfigProblem,
|
|
15
|
+
EventSubscriberContext,
|
|
16
|
+
)
|
|
17
|
+
from ginkgo.plugins.registry import PluginNotFoundError
|
|
18
|
+
from ginkgo.runtime.events import GinkgoEvent, RunCompleted, RunStarted, TaskFailed
|
|
19
|
+
from ginkgo_slack.webhook import (
|
|
20
|
+
SlackDeliveryError,
|
|
21
|
+
SlackTaskFailure,
|
|
22
|
+
SlackUnreachableError,
|
|
23
|
+
build_retry_exhausted_payload,
|
|
24
|
+
build_run_failed_payload,
|
|
25
|
+
build_run_started_payload,
|
|
26
|
+
build_run_succeeded_payload,
|
|
27
|
+
post_slack_message,
|
|
28
|
+
)
|
|
29
|
+
from ginkgo.runtime.rundir import combined_log_tail
|
|
30
|
+
from ginkgo.runtime.environment.secrets import SecretResolutionError, SecretResolver
|
|
31
|
+
from ginkgo.runtime.run_summary import RunSummary, TaskSummary
|
|
32
|
+
from ginkgo.store.protocol import ProvenanceStore
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
VALID_NOTIFICATION_EVENTS = frozenset(
|
|
36
|
+
{"run_started", "run_succeeded", "run_failed", "retry_exhausted"}
|
|
37
|
+
)
|
|
38
|
+
EVENT_ALIASES = {"run_completed": "run_succeeded"}
|
|
39
|
+
|
|
40
|
+
#: The settings ``[notifications.slack]`` accepts, with each integer's bounds.
|
|
41
|
+
_INT_SETTINGS = {"log_tail_lines": (1, 25), "max_failed_tasks": (1, 10)}
|
|
42
|
+
_SETTINGS = frozenset({"enabled", "webhook", "events", *_INT_SETTINGS})
|
|
43
|
+
_TABLE = "[notifications.slack]"
|
|
44
|
+
_WEBHOOK_EXAMPLE = 'webhook = { env = "GINKGO_SLACK_WEBHOOK" }'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, kw_only=True)
|
|
48
|
+
class SlackNotificationConfig:
|
|
49
|
+
"""Resolved Slack notification settings.
|
|
50
|
+
|
|
51
|
+
``ignored_events`` holds one message per ``events`` entry that names no
|
|
52
|
+
event. A misspelt entry beside valid ones is ignored rather than turning
|
|
53
|
+
every notification off, so the run still sends the events it can.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
enabled: bool = False
|
|
57
|
+
webhook: SecretRef | None = None
|
|
58
|
+
events: frozenset[str] = frozenset({"run_failed"})
|
|
59
|
+
log_tail_lines: int = 10
|
|
60
|
+
max_failed_tasks: int = 3
|
|
61
|
+
ignored_events: tuple[str, ...] = ()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(kw_only=True)
|
|
65
|
+
class SlackSubscriber:
|
|
66
|
+
"""Subscribe to runtime events and deliver Slack notifications."""
|
|
67
|
+
|
|
68
|
+
config: SlackNotificationConfig
|
|
69
|
+
webhook_url: str
|
|
70
|
+
store: ProvenanceStore
|
|
71
|
+
run_id: str
|
|
72
|
+
run_dir: Path
|
|
73
|
+
workflow_path: Path
|
|
74
|
+
logger: Callable[[str], None]
|
|
75
|
+
_executor: ThreadPoolExecutor = field(
|
|
76
|
+
default_factory=lambda: ThreadPoolExecutor(
|
|
77
|
+
max_workers=1, thread_name_prefix="ginkgo-slack"
|
|
78
|
+
),
|
|
79
|
+
init=False,
|
|
80
|
+
repr=False,
|
|
81
|
+
)
|
|
82
|
+
_futures: list[tuple[str, Future[None]]] = field(default_factory=list, init=False, repr=False)
|
|
83
|
+
_unreachable: SlackUnreachableError | None = field(default=None, init=False, repr=False)
|
|
84
|
+
|
|
85
|
+
def handle(self, event: GinkgoEvent) -> None:
|
|
86
|
+
"""Handle one runtime event."""
|
|
87
|
+
if isinstance(event, RunStarted):
|
|
88
|
+
self._handle_run_started(event=event)
|
|
89
|
+
return
|
|
90
|
+
if isinstance(event, TaskFailed):
|
|
91
|
+
self._handle_task_failed(event=event)
|
|
92
|
+
return
|
|
93
|
+
if isinstance(event, RunCompleted):
|
|
94
|
+
self._handle_run_completed(event=event)
|
|
95
|
+
|
|
96
|
+
def close(self) -> None:
|
|
97
|
+
"""Wait for outstanding deliveries and emit warnings for failures."""
|
|
98
|
+
self._executor.shutdown(wait=True)
|
|
99
|
+
for event_name, future in self._futures:
|
|
100
|
+
try:
|
|
101
|
+
future.result()
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
self.logger(f"Slack notification for {event_name} failed: {exc}")
|
|
104
|
+
|
|
105
|
+
def _handle_run_started(self, *, event: RunStarted) -> None:
|
|
106
|
+
if "run_started" not in self.config.events:
|
|
107
|
+
return
|
|
108
|
+
payload = build_run_started_payload(
|
|
109
|
+
workflow_label=self._workflow_label,
|
|
110
|
+
run_id=event.run_id,
|
|
111
|
+
ts=event.ts,
|
|
112
|
+
)
|
|
113
|
+
self._submit(event_name="run_started", payload=payload)
|
|
114
|
+
|
|
115
|
+
def _handle_task_failed(self, *, event: TaskFailed) -> None:
|
|
116
|
+
if "retry_exhausted" not in self.config.events:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
task_failure = self._retry_exhausted_failure(task_id=event.task_id)
|
|
120
|
+
if task_failure is None:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
payload = build_retry_exhausted_payload(
|
|
124
|
+
workflow_label=self._workflow_label,
|
|
125
|
+
run_id=event.run_id,
|
|
126
|
+
ts=event.ts,
|
|
127
|
+
failed_task=task_failure,
|
|
128
|
+
)
|
|
129
|
+
self._submit(event_name=f"retry_exhausted ({task_failure.task_name})", payload=payload)
|
|
130
|
+
|
|
131
|
+
def _handle_run_completed(self, *, event: RunCompleted) -> None:
|
|
132
|
+
# A run someone interrupted needs no announcement: the person who
|
|
133
|
+
# stopped it is watching the terminal it stopped in, and posting it as
|
|
134
|
+
# a failure would report a deliberate act as an incident.
|
|
135
|
+
if event.status == "cancelled":
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
if event.status == "success":
|
|
139
|
+
if "run_succeeded" not in self.config.events:
|
|
140
|
+
return
|
|
141
|
+
payload = build_run_succeeded_payload(
|
|
142
|
+
workflow_label=self._workflow_label,
|
|
143
|
+
run_id=event.run_id,
|
|
144
|
+
ts=event.ts,
|
|
145
|
+
task_counts=event.task_counts,
|
|
146
|
+
)
|
|
147
|
+
self._submit(event_name="run_succeeded", payload=payload)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
if "run_failed" not in self.config.events:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
payload = build_run_failed_payload(
|
|
154
|
+
workflow_label=self._workflow_label,
|
|
155
|
+
run_id=event.run_id,
|
|
156
|
+
ts=event.ts,
|
|
157
|
+
task_counts=event.task_counts,
|
|
158
|
+
failed_tasks=self._failed_tasks(),
|
|
159
|
+
error=event.error,
|
|
160
|
+
)
|
|
161
|
+
self._submit(event_name="run_failed", payload=payload)
|
|
162
|
+
|
|
163
|
+
def _submit(self, *, event_name: str, payload: dict[str, Any]) -> None:
|
|
164
|
+
"""Post *payload* in the background; *event_name* names it if delivery fails."""
|
|
165
|
+
self._futures.append((event_name, self._executor.submit(self._deliver, payload=payload)))
|
|
166
|
+
|
|
167
|
+
def _deliver(self, *, payload: dict[str, Any]) -> None:
|
|
168
|
+
"""Post one payload, unless an earlier one found Slack unreachable.
|
|
169
|
+
|
|
170
|
+
Deliveries run one at a time on a single thread, so once the host has
|
|
171
|
+
failed to answer, each later notification would wait out the same
|
|
172
|
+
timeout and the run's end would wait for all of them. They are given up
|
|
173
|
+
instead, so an unreachable Slack costs a run one timeout.
|
|
174
|
+
"""
|
|
175
|
+
if self._unreachable is not None:
|
|
176
|
+
raise SlackDeliveryError(f"not sent, as an earlier one failed: {self._unreachable}")
|
|
177
|
+
try:
|
|
178
|
+
post_slack_message(webhook_url=self.webhook_url, payload=payload)
|
|
179
|
+
except SlackUnreachableError as exc:
|
|
180
|
+
self._unreachable = exc
|
|
181
|
+
raise
|
|
182
|
+
|
|
183
|
+
def _summary(self) -> RunSummary:
|
|
184
|
+
"""Reload the run from the ledger, which the recorder has committed."""
|
|
185
|
+
return RunSummary.load(self.store, self.run_id, runs_root=self.run_dir.parent)
|
|
186
|
+
|
|
187
|
+
def _failed_tasks(self) -> list[SlackTaskFailure]:
|
|
188
|
+
failed = self._summary().failed_tasks
|
|
189
|
+
return [self._failure(task=task) for task in failed[: self.config.max_failed_tasks]]
|
|
190
|
+
|
|
191
|
+
def _retry_exhausted_failure(self, *, task_id: str) -> SlackTaskFailure | None:
|
|
192
|
+
task = next(
|
|
193
|
+
(item for item in self._summary().tasks if item.task_key == task_id),
|
|
194
|
+
None,
|
|
195
|
+
)
|
|
196
|
+
if task is None or task.max_attempts is None or task.max_attempts < 2:
|
|
197
|
+
return None
|
|
198
|
+
if task.attempts < task.max_attempts:
|
|
199
|
+
return None
|
|
200
|
+
return self._failure(task=task)
|
|
201
|
+
|
|
202
|
+
def _failure(self, *, task: TaskSummary) -> SlackTaskFailure:
|
|
203
|
+
return SlackTaskFailure(
|
|
204
|
+
task_name=task.label,
|
|
205
|
+
exit_code=task.exit_code,
|
|
206
|
+
attempt=task.attempts,
|
|
207
|
+
max_attempts=task.max_attempts,
|
|
208
|
+
log_tail=tuple(
|
|
209
|
+
combined_log_tail(
|
|
210
|
+
run_dir=self.run_dir,
|
|
211
|
+
stdout_log=task.stdout_log,
|
|
212
|
+
stderr_log=task.stderr_log,
|
|
213
|
+
lines=self.config.log_tail_lines,
|
|
214
|
+
)
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def _workflow_label(self) -> str:
|
|
220
|
+
try:
|
|
221
|
+
return str(self.workflow_path.relative_to(Path.cwd()))
|
|
222
|
+
except ValueError:
|
|
223
|
+
return str(self.workflow_path)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def build_slack_subscriber(context: EventSubscriberContext) -> SlackSubscriber | None:
|
|
227
|
+
"""Return a ready-to-use notification service when Slack is configured.
|
|
228
|
+
|
|
229
|
+
Parameters
|
|
230
|
+
----------
|
|
231
|
+
context : EventSubscriberContext
|
|
232
|
+
The run being observed.
|
|
233
|
+
|
|
234
|
+
Returns
|
|
235
|
+
-------
|
|
236
|
+
SlackSubscriber | None
|
|
237
|
+
``None`` when ``[notifications.slack]`` is disabled or has problems;
|
|
238
|
+
each problem is reported through ``context.warn``.
|
|
239
|
+
"""
|
|
240
|
+
config, problems = parse_notification_config(config=context.config)
|
|
241
|
+
if not config.enabled:
|
|
242
|
+
return None
|
|
243
|
+
webhook_url = None
|
|
244
|
+
if not problems:
|
|
245
|
+
webhook_url, problem = _resolve_webhook(
|
|
246
|
+
ref=config.webhook, resolver=context.secret_resolver
|
|
247
|
+
)
|
|
248
|
+
problems = [] if problem is None else [problem]
|
|
249
|
+
if webhook_url is None:
|
|
250
|
+
for problem in problems:
|
|
251
|
+
context.warn(f"Slack notifications are off: {problem}")
|
|
252
|
+
return None
|
|
253
|
+
for message in config.ignored_events:
|
|
254
|
+
context.warn(message)
|
|
255
|
+
|
|
256
|
+
return SlackSubscriber(
|
|
257
|
+
config=config,
|
|
258
|
+
webhook_url=webhook_url,
|
|
259
|
+
store=context.store,
|
|
260
|
+
run_id=context.run_id,
|
|
261
|
+
run_dir=context.run_dir,
|
|
262
|
+
workflow_path=context.workflow_path,
|
|
263
|
+
logger=context.warn,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def check_slack_config(context: EventSubscriberCheckContext) -> list[EventSubscriberConfigProblem]:
|
|
268
|
+
"""Return every problem with ``[notifications.slack]``, for ``ginkgo doctor``.
|
|
269
|
+
|
|
270
|
+
Parameters
|
|
271
|
+
----------
|
|
272
|
+
context : EventSubscriberCheckContext
|
|
273
|
+
The runtime config and the secret resolver a run would use.
|
|
274
|
+
|
|
275
|
+
Returns
|
|
276
|
+
-------
|
|
277
|
+
list[EventSubscriberConfigProblem]
|
|
278
|
+
One per problem; empty when a run would send notifications as
|
|
279
|
+
configured, or when they are disabled and otherwise valid. An unknown
|
|
280
|
+
event beside valid ones is not blocking: the run ignores it and still
|
|
281
|
+
sends the others.
|
|
282
|
+
"""
|
|
283
|
+
config, problems = parse_notification_config(config=context.config)
|
|
284
|
+
if config.enabled and not problems:
|
|
285
|
+
_, problem = _resolve_webhook(ref=config.webhook, resolver=context.secret_resolver)
|
|
286
|
+
if problem is not None:
|
|
287
|
+
problems.append(problem)
|
|
288
|
+
return [
|
|
289
|
+
*(EventSubscriberConfigProblem(message=message) for message in problems),
|
|
290
|
+
*(
|
|
291
|
+
EventSubscriberConfigProblem(message=message, blocking=False)
|
|
292
|
+
for message in config.ignored_events
|
|
293
|
+
),
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def parse_notification_config(
|
|
298
|
+
*, config: Mapping[str, Any] | None
|
|
299
|
+
) -> tuple[SlackNotificationConfig, list[str]]:
|
|
300
|
+
"""Parse ``[notifications.slack]`` from the loaded config mapping.
|
|
301
|
+
|
|
302
|
+
Parameters
|
|
303
|
+
----------
|
|
304
|
+
config : Mapping[str, Any] | None
|
|
305
|
+
The merged runtime config.
|
|
306
|
+
|
|
307
|
+
Returns
|
|
308
|
+
-------
|
|
309
|
+
tuple[SlackNotificationConfig, list[str]]
|
|
310
|
+
The settings, and one message per invalid or unknown setting. A setting
|
|
311
|
+
with a problem keeps its default, so the settings are only to be used
|
|
312
|
+
when there are no problems.
|
|
313
|
+
"""
|
|
314
|
+
notifications = config.get("notifications", {}) if isinstance(config, Mapping) else {}
|
|
315
|
+
raw = notifications.get("slack", {}) if isinstance(notifications, Mapping) else {}
|
|
316
|
+
if not isinstance(raw, Mapping):
|
|
317
|
+
return SlackNotificationConfig(), [f"{_TABLE} must be a table."]
|
|
318
|
+
|
|
319
|
+
problems = [
|
|
320
|
+
f"{_TABLE} has no setting {key!r}; the settings are {', '.join(sorted(_SETTINGS))}."
|
|
321
|
+
for key in sorted(set(raw) - _SETTINGS, key=str)
|
|
322
|
+
]
|
|
323
|
+
settings: dict[str, Any] = {}
|
|
324
|
+
|
|
325
|
+
enabled = raw.get("enabled", False)
|
|
326
|
+
if isinstance(enabled, bool):
|
|
327
|
+
settings["enabled"] = enabled
|
|
328
|
+
else:
|
|
329
|
+
problems.append(f"{_TABLE} enabled must be true or false, not {enabled!r}.")
|
|
330
|
+
|
|
331
|
+
if "events" in raw:
|
|
332
|
+
events, ignored, event_problems = _parse_events(raw["events"])
|
|
333
|
+
settings["events"] = events
|
|
334
|
+
settings["ignored_events"] = ignored
|
|
335
|
+
problems.extend(event_problems)
|
|
336
|
+
|
|
337
|
+
for key, (minimum, maximum) in _INT_SETTINGS.items():
|
|
338
|
+
if key not in raw:
|
|
339
|
+
continue
|
|
340
|
+
value = raw[key]
|
|
341
|
+
if isinstance(value, int) and not isinstance(value, bool) and minimum <= value <= maximum:
|
|
342
|
+
settings[key] = value
|
|
343
|
+
else:
|
|
344
|
+
problems.append(
|
|
345
|
+
f"{_TABLE} {key} must be a whole number from {minimum} to {maximum}, "
|
|
346
|
+
f"not {value!r}."
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# A disabled table may leave the webhook out; one it gives must still be valid.
|
|
350
|
+
if "webhook" in raw or enabled is True:
|
|
351
|
+
webhook, problem = _parse_webhook(raw.get("webhook"))
|
|
352
|
+
settings["webhook"] = webhook
|
|
353
|
+
if problem is not None:
|
|
354
|
+
problems.append(problem)
|
|
355
|
+
|
|
356
|
+
return SlackNotificationConfig(**settings), problems
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _parse_events(value: Any) -> tuple[frozenset[str], tuple[str, ...], list[str]]:
|
|
360
|
+
"""Parse the ``events`` list.
|
|
361
|
+
|
|
362
|
+
Returns the events, one message per entry that names no event (ignored
|
|
363
|
+
while another entry is valid), and the problems that leave nothing to send.
|
|
364
|
+
"""
|
|
365
|
+
if not isinstance(value, list):
|
|
366
|
+
return (
|
|
367
|
+
frozenset(),
|
|
368
|
+
(),
|
|
369
|
+
[f"{_TABLE} events must be a list of event names, not {value!r}."],
|
|
370
|
+
)
|
|
371
|
+
if not value:
|
|
372
|
+
return frozenset(), (), [f"{_TABLE} events is empty, so nothing would be sent."]
|
|
373
|
+
known = ", ".join(sorted({*VALID_NOTIFICATION_EVENTS, *EVENT_ALIASES}))
|
|
374
|
+
parsed: set[str] = set()
|
|
375
|
+
unknown: list[Any] = []
|
|
376
|
+
for item in value:
|
|
377
|
+
name = EVENT_ALIASES.get(item, item) if isinstance(item, str) else item
|
|
378
|
+
if name in VALID_NOTIFICATION_EVENTS:
|
|
379
|
+
parsed.add(name)
|
|
380
|
+
else:
|
|
381
|
+
unknown.append(item)
|
|
382
|
+
if not parsed:
|
|
383
|
+
return (
|
|
384
|
+
frozenset(),
|
|
385
|
+
(),
|
|
386
|
+
[
|
|
387
|
+
f"{_TABLE} events has no event {item!r}; the events are {known}."
|
|
388
|
+
for item in unknown
|
|
389
|
+
],
|
|
390
|
+
)
|
|
391
|
+
ignored = tuple(
|
|
392
|
+
f"{_TABLE} events has no event {item!r}, so it is ignored and the other events "
|
|
393
|
+
f"are still sent; the events are {known}."
|
|
394
|
+
for item in unknown
|
|
395
|
+
)
|
|
396
|
+
return frozenset(parsed), ignored, []
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _parse_webhook(value: Any) -> tuple[SecretRef | None, str | None]:
|
|
400
|
+
"""Parse the webhook's secret reference, explaining the shape when it is wrong."""
|
|
401
|
+
if value is None:
|
|
402
|
+
return None, f"{_TABLE} webhook is missing; add {_WEBHOOK_EXAMPLE}."
|
|
403
|
+
if isinstance(value, str):
|
|
404
|
+
return None, (
|
|
405
|
+
f"{_TABLE} webhook must be a secret reference such as {_WEBHOOK_EXAMPLE}, "
|
|
406
|
+
"naming where the URL is kept rather than the URL itself: a webhook URL "
|
|
407
|
+
"is a credential and does not belong in config."
|
|
408
|
+
)
|
|
409
|
+
if isinstance(value, Mapping) and len(value) == 1:
|
|
410
|
+
backend, name = next(iter(value.items()))
|
|
411
|
+
if isinstance(backend, str) and isinstance(name, str) and backend.strip() and name.strip():
|
|
412
|
+
return SecretRef(name=name.strip(), backend=backend.strip()), None
|
|
413
|
+
return None, f"{_TABLE} webhook must name one secret, such as {_WEBHOOK_EXAMPLE}."
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _resolve_webhook(
|
|
417
|
+
*, ref: SecretRef | None, resolver: SecretResolver | None
|
|
418
|
+
) -> tuple[str | None, str | None]:
|
|
419
|
+
"""Resolve the webhook URL, or say which secret is unset."""
|
|
420
|
+
assert ref is not None
|
|
421
|
+
if resolver is None:
|
|
422
|
+
return None, f"{_TABLE} webhook cannot be read: no secret resolver is available."
|
|
423
|
+
where = (
|
|
424
|
+
f"the environment variable {ref.name}"
|
|
425
|
+
if ref.backend == "env"
|
|
426
|
+
else f"the {ref.backend} secret {ref.name!r}"
|
|
427
|
+
)
|
|
428
|
+
try:
|
|
429
|
+
url = resolver.resolve(ref=ref)
|
|
430
|
+
except (SecretResolutionError, PluginNotFoundError):
|
|
431
|
+
url = ""
|
|
432
|
+
if not url.strip():
|
|
433
|
+
return None, f"{_TABLE} webhook reads {where}, which is unset or empty."
|
|
434
|
+
return url, None
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Slack notification transport and payload helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.error import HTTPError, URLError
|
|
9
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
10
|
+
|
|
11
|
+
from ginkgo.errors import GinkgoError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
#: Seconds one delivery may wait on Slack. Delivery runs beside the workflow,
|
|
15
|
+
#: but the run waits for what is outstanding as it ends, so an unreachable
|
|
16
|
+
#: host costs at most this, once (see ``SlackSubscriber``).
|
|
17
|
+
DELIVERY_TIMEOUT_SECONDS = 3.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SlackDeliveryError(GinkgoError, RuntimeError):
|
|
21
|
+
"""Raised when a Slack webhook request cannot be delivered."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SlackUnreachableError(SlackDeliveryError):
|
|
25
|
+
"""Raised when the Slack webhook's host could not be reached at all."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _RefuseRedirects(HTTPRedirectHandler):
|
|
29
|
+
"""Surface a redirect as an ``HTTPError``: a followed one was never delivered."""
|
|
30
|
+
|
|
31
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, kw_only=True)
|
|
36
|
+
class SlackTaskFailure:
|
|
37
|
+
"""Compact failed-task summary for Slack payloads."""
|
|
38
|
+
|
|
39
|
+
task_name: str
|
|
40
|
+
exit_code: int | None
|
|
41
|
+
attempt: int | None = None
|
|
42
|
+
max_attempts: int | None = None
|
|
43
|
+
log_tail: tuple[str, ...] = ()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def post_slack_message(
|
|
47
|
+
*,
|
|
48
|
+
webhook_url: str,
|
|
49
|
+
payload: dict[str, Any],
|
|
50
|
+
timeout_seconds: float = DELIVERY_TIMEOUT_SECONDS,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Send one JSON payload to a Slack incoming webhook."""
|
|
53
|
+
body = json.dumps(payload).encode("utf-8")
|
|
54
|
+
request = Request(
|
|
55
|
+
webhook_url,
|
|
56
|
+
data=body,
|
|
57
|
+
headers={"Content-Type": "application/json"},
|
|
58
|
+
method="POST",
|
|
59
|
+
)
|
|
60
|
+
try:
|
|
61
|
+
with build_opener(_RefuseRedirects).open(request, timeout=timeout_seconds) as response:
|
|
62
|
+
status_code = getattr(response, "status", response.getcode())
|
|
63
|
+
except HTTPError as exc:
|
|
64
|
+
raise SlackDeliveryError(
|
|
65
|
+
f"Slack webhook rejected the request with HTTP {exc.code}."
|
|
66
|
+
) from exc
|
|
67
|
+
except URLError as exc:
|
|
68
|
+
reason = getattr(exc, "reason", None)
|
|
69
|
+
detail = str(reason) if reason else exc.__class__.__name__
|
|
70
|
+
raise SlackUnreachableError(f"Slack webhook request failed: {detail}.") from exc
|
|
71
|
+
except Exception as exc: # pragma: no cover - defensive wrapper
|
|
72
|
+
raise SlackDeliveryError("Slack webhook request failed.") from exc
|
|
73
|
+
|
|
74
|
+
if not 200 <= status_code < 300:
|
|
75
|
+
raise SlackDeliveryError(f"Slack webhook rejected the request with HTTP {status_code}.")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_run_started_payload(
|
|
79
|
+
*,
|
|
80
|
+
workflow_label: str,
|
|
81
|
+
run_id: str,
|
|
82
|
+
ts: str,
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
"""Build a Slack payload for run start notifications."""
|
|
85
|
+
summary_lines = [
|
|
86
|
+
f"*Workflow*: `{workflow_label}`",
|
|
87
|
+
f"*Run*: `{run_id}`",
|
|
88
|
+
f"*Started*: `{ts}`",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
return _build_payload(
|
|
92
|
+
fallback=f"Ginkgo run started: {workflow_label} ({run_id})",
|
|
93
|
+
title="Ginkgo Run Started",
|
|
94
|
+
body_lines=summary_lines,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_run_succeeded_payload(
|
|
99
|
+
*,
|
|
100
|
+
workflow_label: str,
|
|
101
|
+
run_id: str,
|
|
102
|
+
ts: str,
|
|
103
|
+
task_counts: dict[str, int],
|
|
104
|
+
) -> dict[str, Any]:
|
|
105
|
+
"""Build a Slack payload for successful run completion."""
|
|
106
|
+
summary_lines = [
|
|
107
|
+
f"*Workflow*: `{workflow_label}`",
|
|
108
|
+
f"*Run*: `{run_id}`",
|
|
109
|
+
f"*Completed*: `{ts}`",
|
|
110
|
+
f"*Task counts*: {_format_task_counts(task_counts)}",
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
return _build_payload(
|
|
114
|
+
fallback=f"Ginkgo run succeeded: {workflow_label} ({run_id})",
|
|
115
|
+
title="Ginkgo Run Succeeded",
|
|
116
|
+
body_lines=summary_lines,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_run_failed_payload(
|
|
121
|
+
*,
|
|
122
|
+
workflow_label: str,
|
|
123
|
+
run_id: str,
|
|
124
|
+
ts: str,
|
|
125
|
+
task_counts: dict[str, int],
|
|
126
|
+
failed_tasks: list[SlackTaskFailure],
|
|
127
|
+
error: str | None,
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
"""Build a Slack payload for failed run completion.
|
|
130
|
+
|
|
131
|
+
The counts are as much use here as on a success, and more so under a
|
|
132
|
+
non-fatal failure policy: a card naming three failures without saying
|
|
133
|
+
that five thousand tasks went unrun describes a different run.
|
|
134
|
+
"""
|
|
135
|
+
summary_lines = [
|
|
136
|
+
f"*Workflow*: `{workflow_label}`",
|
|
137
|
+
f"*Run*: `{run_id}`",
|
|
138
|
+
f"*Failed*: `{ts}`",
|
|
139
|
+
f"*Task counts*: {_format_task_counts(task_counts)}",
|
|
140
|
+
]
|
|
141
|
+
if error:
|
|
142
|
+
summary_lines.append(f"*Error*: `{_truncate(error, limit=180)}`")
|
|
143
|
+
|
|
144
|
+
return _build_payload(
|
|
145
|
+
fallback=f"Ginkgo run failed: {workflow_label} ({run_id})",
|
|
146
|
+
title="Ginkgo Run Failed",
|
|
147
|
+
body_lines=summary_lines,
|
|
148
|
+
failure_lines=_format_failure_lines(failed_tasks),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_retry_exhausted_payload(
|
|
153
|
+
*,
|
|
154
|
+
workflow_label: str,
|
|
155
|
+
run_id: str,
|
|
156
|
+
ts: str,
|
|
157
|
+
failed_task: SlackTaskFailure,
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
"""Build a Slack payload for retry exhaustion notifications."""
|
|
160
|
+
summary_lines = [
|
|
161
|
+
f"*Workflow*: `{workflow_label}`",
|
|
162
|
+
f"*Run*: `{run_id}`",
|
|
163
|
+
f"*Task*: `{failed_task.task_name}`",
|
|
164
|
+
f"*Failed*: `{ts}`",
|
|
165
|
+
]
|
|
166
|
+
attempt_line = _format_attempts(failed_task)
|
|
167
|
+
if attempt_line is not None:
|
|
168
|
+
summary_lines.append(f"*Attempts*: `{attempt_line}`")
|
|
169
|
+
if failed_task.exit_code is not None:
|
|
170
|
+
summary_lines.append(f"*Exit code*: `{failed_task.exit_code}`")
|
|
171
|
+
|
|
172
|
+
return _build_payload(
|
|
173
|
+
fallback=f"Ginkgo task exhausted retries: {failed_task.task_name} ({run_id})",
|
|
174
|
+
title="Ginkgo Retries Exhausted",
|
|
175
|
+
body_lines=summary_lines,
|
|
176
|
+
failure_lines=_format_failure_lines([failed_task]),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _build_payload(
|
|
181
|
+
*,
|
|
182
|
+
fallback: str,
|
|
183
|
+
title: str,
|
|
184
|
+
body_lines: list[str],
|
|
185
|
+
failure_lines: list[str] | None = None,
|
|
186
|
+
) -> dict[str, Any]:
|
|
187
|
+
blocks: list[dict[str, Any]] = [
|
|
188
|
+
{
|
|
189
|
+
"type": "header",
|
|
190
|
+
"text": {"type": "plain_text", "text": title},
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"type": "section",
|
|
194
|
+
"text": {"type": "mrkdwn", "text": "\n".join(body_lines)},
|
|
195
|
+
},
|
|
196
|
+
]
|
|
197
|
+
if failure_lines:
|
|
198
|
+
blocks.append(
|
|
199
|
+
{
|
|
200
|
+
"type": "section",
|
|
201
|
+
"text": {"type": "mrkdwn", "text": "\n".join(failure_lines)},
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return {"text": fallback, "blocks": blocks}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _format_task_counts(task_counts: dict[str, int]) -> str:
|
|
209
|
+
ordered = [
|
|
210
|
+
(name, count)
|
|
211
|
+
for name, count in (
|
|
212
|
+
("succeeded", task_counts.get("succeeded", 0)),
|
|
213
|
+
("cached", task_counts.get("cached", 0)),
|
|
214
|
+
("failed", task_counts.get("failed", 0)),
|
|
215
|
+
("skipped", task_counts.get("skipped", 0)),
|
|
216
|
+
("running", task_counts.get("running", 0)),
|
|
217
|
+
("pending", task_counts.get("pending", 0)),
|
|
218
|
+
)
|
|
219
|
+
if count
|
|
220
|
+
]
|
|
221
|
+
if not ordered:
|
|
222
|
+
return "none"
|
|
223
|
+
return ", ".join(f"{name}={count}" for name, count in ordered)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _format_failure_lines(failed_tasks: list[SlackTaskFailure]) -> list[str]:
|
|
227
|
+
lines: list[str] = []
|
|
228
|
+
for item in failed_tasks:
|
|
229
|
+
detail_parts = [f"*Task*: `{item.task_name}`"]
|
|
230
|
+
if item.exit_code is not None:
|
|
231
|
+
detail_parts.append(f"*Exit*: `{item.exit_code}`")
|
|
232
|
+
attempt_line = _format_attempts(item)
|
|
233
|
+
if attempt_line is not None:
|
|
234
|
+
detail_parts.append(f"*Attempts*: `{attempt_line}`")
|
|
235
|
+
lines.append(" | ".join(detail_parts))
|
|
236
|
+
if item.log_tail:
|
|
237
|
+
excerpt = "\n".join(_truncate(line, limit=140) for line in item.log_tail)
|
|
238
|
+
lines.append(f"```{_truncate(excerpt, limit=900)}```")
|
|
239
|
+
return lines
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _format_attempts(item: SlackTaskFailure) -> str | None:
|
|
243
|
+
if item.attempt is None:
|
|
244
|
+
return None
|
|
245
|
+
if item.max_attempts is None:
|
|
246
|
+
return str(item.attempt)
|
|
247
|
+
return f"{item.attempt}/{item.max_attempts}"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _truncate(value: str, *, limit: int) -> str:
|
|
251
|
+
if len(value) <= limit:
|
|
252
|
+
return value
|
|
253
|
+
return value[: limit - 3] + "..."
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Slack notification tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from ginkgo.core.secret import SecretRef
|
|
13
|
+
from ginkgo.plugins.api import EventSubscriberCheckContext, EventSubscriberConfigProblem
|
|
14
|
+
from ginkgo.runtime.environment.secrets import build_secret_resolver
|
|
15
|
+
from ginkgo_slack.subscriber import (
|
|
16
|
+
SlackNotificationConfig,
|
|
17
|
+
SlackSubscriber,
|
|
18
|
+
check_slack_config,
|
|
19
|
+
parse_notification_config,
|
|
20
|
+
)
|
|
21
|
+
from ginkgo_slack.webhook import (
|
|
22
|
+
SlackDeliveryError,
|
|
23
|
+
SlackUnreachableError,
|
|
24
|
+
build_run_failed_payload,
|
|
25
|
+
post_slack_message,
|
|
26
|
+
)
|
|
27
|
+
from tests.conftest import run_cli
|
|
28
|
+
|
|
29
|
+
_WORKFLOW = """
|
|
30
|
+
from ginkgo import flow, task
|
|
31
|
+
|
|
32
|
+
@task(retries=1)
|
|
33
|
+
def fail() -> str:
|
|
34
|
+
raise RuntimeError("boom from task")
|
|
35
|
+
|
|
36
|
+
@task()
|
|
37
|
+
def succeed() -> str:
|
|
38
|
+
return "ok"
|
|
39
|
+
|
|
40
|
+
@flow
|
|
41
|
+
def main():
|
|
42
|
+
return {body}
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _WebhookHandler(BaseHTTPRequestHandler):
|
|
47
|
+
def do_POST(self) -> None: # noqa: N802
|
|
48
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
49
|
+
body = self.rfile.read(length).decode("utf-8")
|
|
50
|
+
self.server.requests.append(json.loads(body)) # type: ignore[attr-defined]
|
|
51
|
+
self.send_response(200)
|
|
52
|
+
self.end_headers()
|
|
53
|
+
self.wfile.write(b"ok")
|
|
54
|
+
|
|
55
|
+
def log_message(self, format: str, *args) -> None:
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _RedirectingHandler(_WebhookHandler):
|
|
60
|
+
def do_POST(self) -> None: # noqa: N802
|
|
61
|
+
self.send_response(302)
|
|
62
|
+
self.send_header("Location", "/elsewhere")
|
|
63
|
+
self.end_headers()
|
|
64
|
+
|
|
65
|
+
def do_GET(self) -> None: # noqa: N802
|
|
66
|
+
self.send_response(200)
|
|
67
|
+
self.end_headers()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _WebhookServer:
|
|
71
|
+
def __init__(self, handler: type[BaseHTTPRequestHandler] = _WebhookHandler) -> None:
|
|
72
|
+
self._server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
73
|
+
self._server.requests = [] # type: ignore[attr-defined]
|
|
74
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def url(self) -> str:
|
|
78
|
+
host, port = self._server.server_address
|
|
79
|
+
return f"http://{host}:{port}/hook"
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def requests(self) -> list[dict[str, object]]:
|
|
83
|
+
return list(self._server.requests) # type: ignore[attr-defined]
|
|
84
|
+
|
|
85
|
+
def __enter__(self) -> _WebhookServer:
|
|
86
|
+
self._thread.start()
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __exit__(self, *_: object) -> None:
|
|
90
|
+
self._server.shutdown()
|
|
91
|
+
self._server.server_close()
|
|
92
|
+
self._thread.join(timeout=5)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _payload_text(payload: dict[str, object]) -> str:
|
|
96
|
+
blocks = payload.get("blocks", [])
|
|
97
|
+
if not isinstance(blocks, list):
|
|
98
|
+
return str(payload.get("text", ""))
|
|
99
|
+
return "\n".join(
|
|
100
|
+
block["text"]["text"]
|
|
101
|
+
for block in blocks
|
|
102
|
+
if isinstance(block, dict) and isinstance(block.get("text"), dict)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _slack(**settings: object) -> dict[str, object]:
|
|
107
|
+
return {"notifications": {"slack": {"enabled": True, **settings}}}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _check(
|
|
111
|
+
config: dict[str, object], *, environ: dict[str, str]
|
|
112
|
+
) -> list[EventSubscriberConfigProblem]:
|
|
113
|
+
resolver = build_secret_resolver(project_root=Path.cwd(), config=config, environ=environ)
|
|
114
|
+
return check_slack_config(EventSubscriberCheckContext(config=config, secret_resolver=resolver))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_WEBHOOK = {"env": "GINKGO_SLACK_WEBHOOK"}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TestNotificationConfig:
|
|
121
|
+
def test_parse_notification_config_supports_aliases_and_secret_refs(self) -> None:
|
|
122
|
+
config, problems = parse_notification_config(
|
|
123
|
+
config=_slack(
|
|
124
|
+
webhook=_WEBHOOK,
|
|
125
|
+
events=["run_started", "run_completed", "retry_exhausted"],
|
|
126
|
+
log_tail_lines=25,
|
|
127
|
+
max_failed_tasks=10,
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
assert problems == []
|
|
132
|
+
assert config.webhook == SecretRef(name="GINKGO_SLACK_WEBHOOK", backend="env")
|
|
133
|
+
# "run_completed" is an alias for "run_succeeded"; the parser normalises it.
|
|
134
|
+
assert config.events == frozenset({"run_started", "run_succeeded", "retry_exhausted"})
|
|
135
|
+
assert (config.log_tail_lines, config.max_failed_tasks) == (25, 10)
|
|
136
|
+
|
|
137
|
+
def test_every_invalid_setting_is_named(self) -> None:
|
|
138
|
+
_, problems = parse_notification_config(
|
|
139
|
+
config=_slack(
|
|
140
|
+
webhook=_WEBHOOK, events=["run_finished"], channel="#alerts", log_tail_lines=-3
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
assert len(problems) == 3
|
|
145
|
+
assert "'channel'" in problems[0]
|
|
146
|
+
assert "'run_finished'" in problems[1]
|
|
147
|
+
assert "log_tail_lines" in problems[2]
|
|
148
|
+
|
|
149
|
+
def test_a_misspelt_event_beside_valid_ones_is_ignored_not_fatal(self) -> None:
|
|
150
|
+
"""RP4-4: one typo must not turn off the alerts that were spelt right."""
|
|
151
|
+
settings = _slack(webhook=_WEBHOOK, events=["run_faild", "run_failed"])
|
|
152
|
+
config, problems = parse_notification_config(config=settings)
|
|
153
|
+
|
|
154
|
+
assert problems == []
|
|
155
|
+
assert config.events == frozenset({"run_failed"})
|
|
156
|
+
[ignored] = config.ignored_events
|
|
157
|
+
assert "'run_faild', so it is ignored" in ignored
|
|
158
|
+
# doctor still names it, as a warning: the run still sends.
|
|
159
|
+
environ = {"GINKGO_SLACK_WEBHOOK": "http://127.0.0.1:1/"}
|
|
160
|
+
assert _check(settings, environ=environ) == [
|
|
161
|
+
EventSubscriberConfigProblem(message=ignored, blocking=False)
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
def test_a_literal_webhook_url_is_explained(self) -> None:
|
|
165
|
+
_, problems = parse_notification_config(
|
|
166
|
+
config=_slack(webhook="https://hooks.slack.com/services/x")
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
assert len(problems) == 1
|
|
170
|
+
assert 'webhook = { env = "GINKGO_SLACK_WEBHOOK" }' in problems[0]
|
|
171
|
+
|
|
172
|
+
def test_the_check_names_an_unset_environment_variable(self) -> None:
|
|
173
|
+
config = _slack(webhook=_WEBHOOK)
|
|
174
|
+
|
|
175
|
+
[problem] = _check(config, environ={"GINKGO_SLACK_WEBHOOK": ""})
|
|
176
|
+
assert "GINKGO_SLACK_WEBHOOK, which is unset or empty" in problem.message
|
|
177
|
+
assert _check(config, environ={"GINKGO_SLACK_WEBHOOK": "http://127.0.0.1:1/"}) == []
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_once_slack_is_unreachable_later_notifications_are_not_attempted(
|
|
181
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
182
|
+
) -> None:
|
|
183
|
+
attempts: list[dict[str, object]] = []
|
|
184
|
+
|
|
185
|
+
def unreachable(*, webhook_url: str, payload: dict[str, object]) -> None:
|
|
186
|
+
attempts.append(payload)
|
|
187
|
+
raise SlackUnreachableError("Slack webhook request failed: timed out.")
|
|
188
|
+
|
|
189
|
+
monkeypatch.setattr("ginkgo_slack.subscriber.post_slack_message", unreachable)
|
|
190
|
+
warnings: list[str] = []
|
|
191
|
+
subscriber = SlackSubscriber(
|
|
192
|
+
config=SlackNotificationConfig(enabled=True),
|
|
193
|
+
webhook_url="http://127.0.0.1:1/hook",
|
|
194
|
+
store=None, # type: ignore[arg-type]
|
|
195
|
+
run_id="run-1",
|
|
196
|
+
run_dir=tmp_path,
|
|
197
|
+
workflow_path=tmp_path / "workflow.py",
|
|
198
|
+
logger=warnings.append,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
subscriber._submit(event_name="run_started", payload={"text": "a"})
|
|
202
|
+
subscriber._submit(event_name="run_failed", payload={"text": "b"})
|
|
203
|
+
subscriber.close()
|
|
204
|
+
|
|
205
|
+
assert attempts == [{"text": "a"}]
|
|
206
|
+
assert len(warnings) == 2
|
|
207
|
+
assert "run_failed" in warnings[1] and "not sent" in warnings[1]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_a_redirected_webhook_is_not_delivered() -> None:
|
|
211
|
+
with _WebhookServer(_RedirectingHandler) as server:
|
|
212
|
+
with pytest.raises(SlackDeliveryError, match="HTTP 302"):
|
|
213
|
+
post_slack_message(webhook_url=server.url, payload={"text": "hi"})
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_the_failed_run_card_counts_the_tasks_a_failure_left_unrun() -> None:
|
|
217
|
+
"""Three failures beside five thousand skips is a different run."""
|
|
218
|
+
payload = build_run_failed_payload(
|
|
219
|
+
workflow_label="flow.py",
|
|
220
|
+
run_id="run_1",
|
|
221
|
+
ts="2026-09-02T09:00:00+00:00",
|
|
222
|
+
task_counts={"succeeded": 4997, "failed": 3, "skipped": 4997},
|
|
223
|
+
failed_tasks=[],
|
|
224
|
+
error="3 tasks failed; the run continued under its failure policy.",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
text = _payload_text(payload)
|
|
228
|
+
assert "failed=3" in text
|
|
229
|
+
assert "skipped=4997" in text
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class TestSlackNotifications:
|
|
233
|
+
@pytest.fixture(autouse=True)
|
|
234
|
+
def _run_in_tmp_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
235
|
+
# Each test writes a workflow and config into the working directory.
|
|
236
|
+
monkeypatch.chdir(tmp_path)
|
|
237
|
+
|
|
238
|
+
def _project(self, *, events: list[str], body: str = "succeed()") -> None:
|
|
239
|
+
Path("ginkgo.toml").write_text(
|
|
240
|
+
"[notifications.slack]\nenabled = true\n"
|
|
241
|
+
'webhook = { env = "GINKGO_SLACK_WEBHOOK" }\n'
|
|
242
|
+
f"events = {json.dumps(events)}\nlog_tail_lines = 5\n",
|
|
243
|
+
encoding="utf-8",
|
|
244
|
+
)
|
|
245
|
+
Path("workflow.py").write_text(_WORKFLOW.format(body=body), encoding="utf-8")
|
|
246
|
+
|
|
247
|
+
@pytest.mark.parametrize(
|
|
248
|
+
("events", "body", "code", "expected"),
|
|
249
|
+
[
|
|
250
|
+
(
|
|
251
|
+
["run_started", "run_completed"],
|
|
252
|
+
"succeed()",
|
|
253
|
+
0,
|
|
254
|
+
[("Ginkgo Run Started", "workflow.py"), ("Ginkgo Run Succeeded", "succeeded=1")],
|
|
255
|
+
),
|
|
256
|
+
(
|
|
257
|
+
["retry_exhausted", "run_failed"],
|
|
258
|
+
"fail()",
|
|
259
|
+
1,
|
|
260
|
+
[("Ginkgo Retries Exhausted", "boom from task"), ("Ginkgo Run Failed", "fail")],
|
|
261
|
+
),
|
|
262
|
+
],
|
|
263
|
+
)
|
|
264
|
+
def test_cli_run_sends_the_configured_notifications(
|
|
265
|
+
self, monkeypatch, events, body, code, expected
|
|
266
|
+
) -> None:
|
|
267
|
+
with _WebhookServer() as server:
|
|
268
|
+
monkeypatch.setenv("GINKGO_SLACK_WEBHOOK", server.url)
|
|
269
|
+
self._project(events=events, body=body)
|
|
270
|
+
|
|
271
|
+
result = run_cli("run", "workflow.py")
|
|
272
|
+
assert result.returncode == code, result.stderr
|
|
273
|
+
texts = [_payload_text(request) for request in server.requests]
|
|
274
|
+
|
|
275
|
+
assert len(texts) == len(expected)
|
|
276
|
+
for text, fragments in zip(texts, expected):
|
|
277
|
+
assert all(fragment in text for fragment in fragments)
|
|
278
|
+
|
|
279
|
+
def test_doctor_blocks_an_invalid_slack_table(self, monkeypatch) -> None:
|
|
280
|
+
monkeypatch.delenv("GINKGO_SLACK_WEBHOOK", raising=False)
|
|
281
|
+
self._project(events=["run_finished"])
|
|
282
|
+
|
|
283
|
+
result = run_cli("doctor", "workflow.py")
|
|
284
|
+
|
|
285
|
+
assert result.returncode == 1
|
|
286
|
+
assert "notification_config" in result.stderr
|
|
287
|
+
assert "'run_finished'" in result.stderr
|
|
288
|
+
|
|
289
|
+
def test_doctor_warns_but_passes_on_an_unknown_event_beside_valid_ones(
|
|
290
|
+
self, monkeypatch
|
|
291
|
+
) -> None:
|
|
292
|
+
monkeypatch.setenv("GINKGO_SLACK_WEBHOOK", "http://127.0.0.1:1/hook")
|
|
293
|
+
self._project(events=["run_faild", "run_failed"])
|
|
294
|
+
|
|
295
|
+
result = run_cli("doctor", "workflow.py")
|
|
296
|
+
|
|
297
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
298
|
+
assert "'run_faild'" in result.stdout + result.stderr
|
|
299
|
+
|
|
300
|
+
def test_cli_run_warns_when_slack_webhook_is_unreachable(self, monkeypatch) -> None:
|
|
301
|
+
monkeypatch.setenv("GINKGO_SLACK_WEBHOOK", "http://127.0.0.1:1/hook")
|
|
302
|
+
self._project(events=["run_completed"])
|
|
303
|
+
|
|
304
|
+
result = run_cli("run", "workflow.py")
|
|
305
|
+
assert result.returncode == 0, result.stderr
|
|
306
|
+
assert "Slack notification for run_succeeded failed:" in result.stderr
|
|
307
|
+
|
|
308
|
+
# The failure is kept with the run, not only printed while it ran.
|
|
309
|
+
shown = run_cli("runs", "show")
|
|
310
|
+
assert "Notice: Slack notification for run_succeeded failed:" in shown.stdout
|