Skip to content

Commit b9e6cd6

Browse files
cosmicBboyclaude
andcommitted
feat(extras): receive SaaS webhooks, at flyte.extras.webhooks
Product-agnostic machinery for turning an inbound webhook into a Flyte run, so each flyteplugins-<product> package only has to say how to verify and parse its own deliveries. - WebhookAppEnvironment: one app serving a setup dashboard and a verified receiver at /webhook/{provider}, for whichever providers it is handed. - WebhookEvent: the normalized event every provider parses into. Its dedupe key folds in the product's own timestamp, so a later change to one resource gets its own key rather than collapsing onto the first. - idempotent_run: launch once per event key. Identity lives on a run *label*, never a name -- names race against concurrent launches and cap how many runs one key can ever have. - Provider plus the verification primitives, written once because they are the part that is easy to get subtly wrong. constant_time_equals compares bytes: hmac.compare_digest raises TypeError on non-ASCII str operands, and these headers come off the wire, so a crafted one would turn a clean 401 into a 500. - testing.assert_provider_conforms: the conformance check every product plugin runs, which replays a real sample delivery through verify and parse rather than trusting them to agree with each other. No new dependencies. pydantic is already a runtime dependency; fastapi stays behind the same lazy import flyte.app.extras._fastapi uses, so importing this package never requires it -- only building the app does. Product plugins declare their own [app] extra. Tested against a stub provider, so the SDK depends on no product package. examples/apps/webhook_custom_provider.py is a complete provider you can run with no account at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKZrTNjjWVzTZUxDFbn4Nk Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com>
1 parent b0bcb4e commit b9e6cd6

17 files changed

Lines changed: 1863 additions & 2 deletions
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Support a product this family does not ship a plugin for.
2+
3+
A provider is small: say which environment variable holds its secret, how to
4+
verify a delivery, and how to turn a payload into a `WebhookEvent`. Core does
5+
the rest — the app, the dashboard, dispatch, the scope allowlist, and idempotent
6+
launching.
7+
8+
Run it without an account:
9+
10+
python custom_provider.py --local
11+
12+
That posts a signed sample delivery through the app in-process, so you see
13+
verification, normalization, and dispatch end to end.
14+
15+
Once it works, move it into its own `flyteplugins-webhooks-<product>` package
16+
beside the others and add the one-line conformance test:
17+
18+
from flyte.extras.webhooks.testing import assert_provider_conforms
19+
import flyteplugins.webhooks.acme as plugin
20+
21+
def test_conformance():
22+
assert_provider_conforms(plugin)
23+
"""
24+
25+
import hashlib
26+
import hmac
27+
import json
28+
import os
29+
import sys
30+
from typing import Mapping
31+
32+
import flyte
33+
from flyte.extras.webhooks import (
34+
EventType,
35+
Provider,
36+
WebhookAppEnvironment,
37+
WebhookEvent,
38+
constant_time_equals,
39+
hex_hmac_sha256,
40+
json_body,
41+
lower_headers,
42+
)
43+
44+
DEFAULT_SECRET_ENV = "ACME_WEBHOOK_SECRET"
45+
46+
47+
class Ticket(EventType):
48+
"""Acme's ticket events. `ANY` matches every action on the type."""
49+
50+
ANY = "ticket"
51+
OPENED = "ticket.opened"
52+
CLOSED = "ticket.closed"
53+
54+
55+
def verify(body: bytes, headers: Mapping[str, str], secret: str) -> bool:
56+
"""Verify Acme's hex HMAC-SHA256 over the raw body.
57+
58+
Use `constant_time_equals` rather than `hmac.compare_digest` directly: the
59+
latter raises `TypeError` on `str` operands containing non-ASCII, and this
60+
header comes off the wire, so a crafted one would turn a clean 401 into a
61+
500.
62+
"""
63+
signature = lower_headers(headers).get("x-acme-signature")
64+
if not signature:
65+
return False
66+
return constant_time_equals(hex_hmac_sha256(secret, body), signature.strip())
67+
68+
69+
def parse(headers: Mapping[str, str], body: bytes) -> WebhookEvent:
70+
"""Normalize an Acme delivery.
71+
72+
Fill in `resource_id` and `occurred_at` wherever the product gives them:
73+
together they are the dedupe key, and without a timestamp every later change
74+
to one resource collapses onto the first one's key and never launches.
75+
"""
76+
payload = json_body(body)
77+
ticket = payload.get("ticket") or {}
78+
return WebhookEvent(
79+
provider="acme",
80+
event_type="ticket",
81+
action=payload.get("action"),
82+
delivery_id=str(payload.get("delivery_id") or ""),
83+
resource_id=str(ticket.get("id")) if ticket.get("id") is not None else None,
84+
occurred_at=ticket.get("updated_at"),
85+
scope=ticket.get("project"),
86+
title=ticket.get("subject"),
87+
url=ticket.get("url"),
88+
payload=payload,
89+
)
90+
91+
92+
class AcmeProvider(Provider):
93+
"""Acme's webhook provider, with its defaults pre-wired.
94+
95+
Users then write `providers=[AcmeProvider()]`, with `secret_env=` there for
96+
anyone storing the secret under a different name.
97+
"""
98+
99+
def __init__(self, *, secret_env: str = DEFAULT_SECRET_ENV) -> None:
100+
super().__init__(
101+
name="acme",
102+
secret_env=secret_env,
103+
verify=verify,
104+
parse=parse,
105+
setup_hint="Acme Settings -> Webhooks",
106+
)
107+
108+
109+
app_env = WebhookAppEnvironment(
110+
name="acme-webhooks",
111+
providers=[AcmeProvider()],
112+
image=flyte.Image.from_debian_base(python_version=(3, 12)).with_pip_packages("fastapi", "uvicorn"),
113+
secrets=[flyte.Secret(DEFAULT_SECRET_ENV, as_env_var=DEFAULT_SECRET_ENV)],
114+
)
115+
116+
117+
@app_env.on_event(Ticket.OPENED)
118+
async def on_ticket_opened(event):
119+
return {"saw": event.qualified_type, "resource": event.resource_id, "dedupe_key": event.dedupe_key()}
120+
121+
122+
#: A realistic delivery, the same thing a shipped plugin exports as SAMPLE_DELIVERY.
123+
SAMPLE_BODY = json.dumps(
124+
{
125+
"action": "opened",
126+
"delivery_id": "d-1",
127+
"ticket": {
128+
"id": 42,
129+
"subject": "Printer on fire",
130+
"project": "SUPPORT",
131+
"updated_at": "2024-01-01T00:00:00Z",
132+
"url": "https://acme.example/t/42",
133+
},
134+
}
135+
).encode()
136+
137+
138+
def _try_locally() -> None:
139+
from fastapi.testclient import TestClient
140+
141+
secret = os.environ.setdefault(DEFAULT_SECRET_ENV, "local-trial-secret")
142+
headers = {"X-Acme-Signature": hmac.new(secret.encode(), SAMPLE_BODY, hashlib.sha256).hexdigest()}
143+
assert app_env.app is not None # built in __post_init__
144+
client = TestClient(app_env.app)
145+
146+
print("POST /webhook/acme (signed with a throwaway secret)")
147+
response = client.post("/webhook/acme", content=SAMPLE_BODY, headers=headers)
148+
print(f" {response.status_code} {response.json()}\n")
149+
150+
print("an unsigned delivery is refused:")
151+
bad = client.post("/webhook/acme", content=SAMPLE_BODY, headers={})
152+
print(f" {bad.status_code} {bad.json()}")
153+
154+
155+
if __name__ == "__main__":
156+
if "--local" in sys.argv:
157+
_try_locally()
158+
else:
159+
flyte.init_from_config()
160+
handle = flyte.serve(app_env)
161+
handle.activate(wait=True)
162+
print(f"Dashboard ready at {handle.endpoint}")
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# SaaS integration plugins
2+
3+
Receive SaaS webhooks in Flyte and launch runs from them.
4+
5+
The receiver lives in `flyte.extras.webhooks`; each product is its own package
6+
contributing one `Provider`:
7+
8+
| Package | Product | Verification |
9+
| --- | --- | --- |
10+
| [`flyteplugins-github`](../github) | GitHub | HMAC-SHA256 (`X-Hub-Signature-256`) |
11+
| [`flyteplugins-slack`](../slack) | Slack Events API | HMAC-SHA256 with a replay window (`X-Slack-Signature`) |
12+
| [`flyteplugins-linear`](../linear) | Linear | HMAC-SHA256 (`X-Linear-Signature`) |
13+
| [`flyteplugins-clickup`](../clickup) | ClickUp | HMAC-SHA256 (`X-Clickup-Signature`) |
14+
| [`flyteplugins-jira`](../jira) | Jira Cloud | none — Jira does not sign; a shared token stands in |
15+
16+
Install core plus the packages for the products you wire up — each row above is
17+
a distribution name:
18+
19+
```bash
20+
pip install "flyteplugins-github[app]"
21+
```
22+
23+
## Try one without an account
24+
25+
Every plugin ships an example that replays a real sample delivery through the
26+
app in-process — verification, normalization, and dispatch, with nothing to
27+
configure:
28+
29+
```bash
30+
python plugins/webhooks/github/examples/github_webhooks.py --local
31+
```
32+
33+
## The division of labor
34+
35+
**`flyte.extras.webhooks` owns** the app, the dashboard, dispatch, the scope
36+
allowlist, idempotent launching, the normalized event, and the verification
37+
primitives — the parts that are easy to get subtly wrong and expensive to get
38+
wrong once per product. It ships with flyte, and adds no runtime dependency:
39+
serving the app needs `fastapi`, which stays an optional extra.
40+
41+
**A provider plugin owns** only what is specific to its product: which
42+
environment variable holds the secret, how to verify a delivery, how to parse
43+
one into a `WebhookEvent`, and the typed constants for its events. That is
44+
usually under 150 lines.
45+
46+
**Calling the product's API** is where these packages have room to grow. Today
47+
they own webhooks only, and the recipes in `examples/external_saas_integrations`
48+
call `PyGithub`, `slack_sdk`, and the rest directly. A plugin earns a client
49+
method when it does something the vendor SDK cannot — return a `flyte.io.File`
50+
instead of an inline megabyte diff, render into the task report, or participate
51+
in caching and fan-out. Forwarding arguments and reshaping JSON does not.
52+
53+
## One app, many products
54+
55+
```python
56+
import flyte
57+
from flyte.extras.webhooks import WebhookAppEnvironment
58+
from flyteplugins.github import GitHubProvider
59+
from flyteplugins.github import events as github_events
60+
from flyteplugins.slack import SlackProvider
61+
62+
app_env = WebhookAppEnvironment(name="saas-webhooks", providers=[GitHubProvider(), SlackProvider()])
63+
64+
65+
@app_env.on_event(github_events.PullRequest.OPENED)
66+
async def triage(event): ...
67+
```
68+
69+
Each provider gets a route at `/webhook/<name>`; anything not configured 404s.
70+
The dashboard at `/` shows one row per provider with its payload URL, whether
71+
its secret is mounted, and how it is verified.
72+
73+
## Conformance
74+
75+
Every provider plugin ships the same one-line test:
76+
77+
```python
78+
from flyte.extras.webhooks.testing import assert_provider_conforms
79+
import flyteplugins.github as plugin
80+
81+
82+
def test_conformance():
83+
assert_provider_conforms(plugin)
84+
```
85+
86+
CI fails if a plugin drifts. The harness checks the things that actually go
87+
wrong: a verifier that raises instead of returning False on a hostile header,
88+
event constants that render as `Class.MEMBER` rather than their wire value, a
89+
dedupe key that is unstable, a sample delivery that no constant spells.
90+
91+
It leans on `SAMPLE_DELIVERY`, a real payload each plugin ships. Without one
92+
there is no way to assert that `verify` and `parse` agree with the product
93+
rather than merely with each other.
94+
95+
## Adding a product
96+
97+
Copy the smallest existing plugin, implement `verify` and `parse`, export a
98+
`Provider` subclass with its defaults pre-wired, `events`, and
99+
`SAMPLE_DELIVERY`, and add the conformance test. `examples/apps/webhook_custom_provider.py`
100+
is a complete worked version you can run.

src/flyte/extras/__init__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,19 @@
1414
3. Sleep: Route a task to the backend `core-sleep` plugin, which executes in leaseworker with no
1515
task pod.
1616
17-
4. Shell: Wrap a CLI tool packaged in a container image. Designed as the foundation for
17+
4. Webhooks: Receive SaaS webhooks (GitHub, Slack, Jira, ...) and turn them into runs.
18+
`WebhookAppEnvironment` serves a verified receiver, and `idempotent_run`
19+
launches once per event key so a redelivery is a no-op. Products plug in
20+
through `Provider`; the `flyteplugins-<product>` packages ship those.
21+
Serving the app needs `fastapi`, which stays an optional extra.
22+
23+
5. Shell: Wrap a CLI tool packaged in a container image. Designed as the foundation for
1824
bio module libraries (bedtools, samtools, bcftools, GATK, etc.) and any other case
1925
where a user wants to call a pre-built binary in a published container with
2026
typed inputs and outputs.
2127
"""
2228

23-
from . import shell
29+
from . import shell, webhooks
2430
from ._container import ContainerTask
2531
from ._dynamic_batcher import (
2632
BatchStats,
@@ -46,4 +52,5 @@
4652
"serialize",
4753
"serialize_env",
4854
"shell",
55+
"webhooks",
4956
]
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Receive SaaS webhooks in Flyte, and turn them into runs.
2+
3+
This package holds the product-agnostic machinery, so each
4+
`flyteplugins-<product>` plugin stays thin and consistent:
5+
6+
- `WebhookAppEnvironment` — one app serving a dashboard and a verified receiver
7+
at `/webhook/{provider}`, for whichever providers you hand it.
8+
- `Provider` — the contract a plugin implements: which env var holds its secret,
9+
how to verify a delivery, how to parse one into an event.
10+
- `WebhookEvent` — the normalized event every provider parses into, so handlers
11+
and dedupe keys work the same regardless of which product sent it.
12+
- `idempotent_run` — launch a run once per event key. Webhook senders retry on
13+
any non-2xx and operators re-trigger by hand; this makes that safe.
14+
- `EventType` — base for the typed event constants each plugin ships.
15+
- `flyte.extras.webhooks.testing` — `assert_provider_conforms`, the
16+
CI-enforced conformance check every plugin runs.
17+
18+
Serving the app needs `fastapi` and `uvicorn`, which flyte keeps as the `app`
19+
extra rather than as runtime dependencies — importing this package never
20+
requires them; only building the app does.
21+
22+
The division of labor: core owns the app, dispatch, dedupe, and the verification
23+
primitives that are easy to get subtly wrong; a plugin owns only what is
24+
specific to its product.
25+
26+
```python
27+
import flyte
28+
from flyte.extras.webhooks import DuplicateRun, WebhookAppEnvironment, idempotent_run
29+
from flyteplugins.github import GitHubProvider
30+
from flyteplugins.github import events
31+
32+
app_env = WebhookAppEnvironment(name="saas-webhooks", providers=[GITHUB])
33+
34+
35+
@app_env.on_event(events.PullRequest.OPENED)
36+
async def triage(event):
37+
import flyte.remote as remote
38+
39+
task = remote.Task.get(name="github-triage.triage_pr", auto_version="latest")
40+
try:
41+
run = await idempotent_run.aio(task, key=event.dedupe_key(), repo=event.scope)
42+
except DuplicateRun as exc:
43+
return {"skipped": str(exc)}
44+
return {"run": run.name}
45+
```
46+
"""
47+
48+
from ._app import EventHandler, WebhookAppEnvironment
49+
from ._errors import SignatureError, WebhookPluginError
50+
from ._event import WebhookEvent
51+
from ._event_type import EventType
52+
from ._idempotent_run import DUPE_LABEL_KEY, DuplicateRun, blocking_run, idempotent_run
53+
from ._provider import (
54+
HandshakeFn,
55+
ParseFn,
56+
Provider,
57+
VerifyFn,
58+
constant_time_equals,
59+
hex_hmac_sha256,
60+
json_body,
61+
lower_headers,
62+
)
63+
64+
__all__ = [
65+
"DUPE_LABEL_KEY",
66+
"DuplicateRun",
67+
"EventHandler",
68+
"EventType",
69+
"HandshakeFn",
70+
"ParseFn",
71+
"Provider",
72+
"SignatureError",
73+
"VerifyFn",
74+
"WebhookAppEnvironment",
75+
"WebhookEvent",
76+
"WebhookPluginError",
77+
"blocking_run",
78+
"constant_time_equals",
79+
"hex_hmac_sha256",
80+
"idempotent_run",
81+
"json_body",
82+
"lower_headers",
83+
]

0 commit comments

Comments
 (0)