Skip to content

Commit 34f44f9

Browse files
cosmicBboyclaude
andauthored
feat(extras): receive SaaS webhooks, at flyte.extras.webhooks (#1512)
First of a stack that replaces the six per-product integration plugins (#1502#1507) with the parts that are genuinely Flyte's. Structured like `plugins/agents`: **core holds the contract, each product is its own thin package.** **Stack:** this → github → slack → linear → clickup → jira → examples. Each PR's diff is just its own package. ## What core owns | | | | --- | --- | | `WebhookAppEnvironment` | One app: a dashboard and a verified receiver at `/webhook/{provider}`, for whichever providers it is handed | | `Provider` | The contract a product plugin implements | | `WebhookEvent` | The normalized event every provider parses into | | `run_once` | Launch a run once per event key, returning the run that covers it either way | | `EventType` | Base for the typed event constants each plugin ships | | verification primitives | `constant_time_equals`, `hex_hmac_sha256`, `json_body`, `lower_headers` | | `testing.assert_provider_conforms` | The conformance check every plugin runs | A product plugin owns only what is specific to it — usually under 150 lines. Core is tested against a **stub provider**, so it depends on no product package. ## Two decisions worth reviewing **Identity lives on a run label, never a name.** `run_once` launches only when no run carrying the same `dedupe` label is live or succeeded; when one is, it returns that run with `created=False` rather than launching a second or raising, so a handler can answer "already handled" and link to it. Names race against concurrent launches and cap how many runs one key can ever have, so the control plane assigns them. It is async-first behind `@syncify` — the blocking form stalls an app's event loop, and webhook senders time out in seconds. A test asserts four concurrent launches overlap; against a blocking implementation it measures 0.81s instead of 0.42s and fails. **`constant_time_equals` compares bytes.** `hmac.compare_digest` raises `TypeError` on `str` operands containing non-ASCII, and these headers come off the wire — ASGI hands Starlette raw bytes which it decodes latin-1, so a crafted header would turn a clean 401 into a 500. Writing it once here is the main argument for a shared core. ## The conformance harness Each product plugin ships `SAMPLE_DELIVERY`, a real payload. The harness signs and replays it, so `verify` and `parse` are checked against an actual payload rather than against each other — without one, every other check is vacuous. It earned its keep during development twice: - It caught that **Jira's shared token is not bound to the request body**, unlike the four HMAC providers. That is inherent to an unsigned webhook, not a bug, so `signed=False` now opts out of the body-tampering check *and* makes the dashboard say the product does not sign. - It caught **a hole in itself**: the hostile-header check sent a bare non-ASCII value, which GitHub and Slack reject on their scheme prefix before reaching the comparison — leaving the check vacuous for exactly the two providers it mattered most for. Hostile values now preserve the prefix, and a `str`-based `compare_digest` fails conformance as intended. I verified it discriminates by injecting three regressions (plain `str, Enum` constants, a `str` comparison, a constant that no longer matches the parser); each one fails with a specific message. ## Notes No new dependencies land in `flyte`: the shared core lives in `flyte.extras.webhooks` behind the same soft-import pattern the other `flyte.extras` apps use, and each product plugin ships its own provider. 46 core tests plus each plugin's suite. `make fmt`, `make mypy` (864 files), `make ty`, ruff, and codespell pass. The caveat from the review that prompted this: the parsers still encode payload-shape assumptions I cannot verify without live accounts. That surface is much smaller now — the six API clients and their ~213 field assumptions are gone — and `SAMPLE_DELIVERY` pins what remains, but one real pass per product is still the thing that would confirm it. --------- Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 434003c commit 34f44f9

72 files changed

Lines changed: 13811 additions & 2 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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 ClassVar, 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+
45+
class Ticket(EventType):
46+
"""Acme's ticket events. `ANY` matches every action on the type."""
47+
48+
ANY = "ticket"
49+
OPENED = "ticket.opened"
50+
CLOSED = "ticket.closed"
51+
52+
53+
def verify(body: bytes, headers: Mapping[str, str], secret: str) -> bool:
54+
"""Verify Acme's hex HMAC-SHA256 over the raw body.
55+
56+
Use `constant_time_equals` rather than `hmac.compare_digest` directly: the
57+
latter raises `TypeError` on `str` operands containing non-ASCII, and this
58+
header comes off the wire, so a crafted one would turn a clean 401 into a
59+
500.
60+
"""
61+
signature = lower_headers(headers).get("x-acme-signature")
62+
if not signature:
63+
return False
64+
return constant_time_equals(hex_hmac_sha256(secret, body), signature.strip())
65+
66+
67+
def parse(headers: Mapping[str, str], body: bytes) -> WebhookEvent:
68+
"""Normalize an Acme delivery.
69+
70+
Fill in `resource_id` and `occurred_at` wherever the product gives them:
71+
together they are the dedupe key, and without a timestamp every later change
72+
to one resource collapses onto the first one's key and never launches.
73+
"""
74+
payload = json_body(body)
75+
ticket = payload.get("ticket") or {}
76+
return WebhookEvent(
77+
provider="acme",
78+
event_type="ticket",
79+
action=payload.get("action"),
80+
delivery_id=str(payload.get("delivery_id") or ""),
81+
resource_id=str(ticket.get("id")) if ticket.get("id") is not None else None,
82+
occurred_at=ticket.get("updated_at"),
83+
scope=ticket.get("project"),
84+
title=ticket.get("subject"),
85+
url=ticket.get("url"),
86+
payload=payload,
87+
)
88+
89+
90+
class AcmeProvider(Provider):
91+
"""Acme's webhook provider, with its defaults pre-wired.
92+
93+
Users then write `providers=[AcmeProvider()]`. The app mounts
94+
`default_secret_env` for them; `secret_env=` is there for anyone storing the
95+
secret under a different name.
96+
"""
97+
98+
default_secret_env: ClassVar[str] = "ACME_WEBHOOK_SECRET"
99+
100+
def __init__(self, *, secret_env: str | None = None) -> None:
101+
super().__init__(
102+
name="acme",
103+
secret_env=secret_env or self.default_secret_env,
104+
verify=verify,
105+
parse=parse,
106+
setup_hint="Acme Settings -> Webhooks",
107+
)
108+
109+
110+
app_env = WebhookAppEnvironment(
111+
name="acme-webhooks",
112+
providers=[AcmeProvider()],
113+
image=flyte.Image.from_debian_base(python_version=(3, 12)).with_pip_packages("fastapi", "uvicorn"),
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(AcmeProvider.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: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# External SaaS integrations
2+
3+
Recipes for driving GitHub, Slack, Linear, ClickUp, and Jira from Flyte.
4+
5+
There is deliberately **no Flyte client plugin** for these products. Each vendor
6+
already ships (or the community maintains) a Python client that is tested
7+
against the live API by people who get deprecation notices first, and a task is
8+
just a function — so calling `PyGithub` or `slack_sdk` from a task needs nothing
9+
in between. A wrapper here would only add a surface to keep in sync with someone
10+
else's release calendar.
11+
12+
What *is* Flyte's job, and what these examples use from it:
13+
14+
- **`flyte.extras.webhooks`** — ships with flyte: one app that authenticates an inbound
15+
delivery with the product's own scheme, normalizes it into a single event
16+
model, and launches a run once per event key with `run_once`.
17+
- **`flyteplugins-webhooks-<product>`** — one small package per product,
18+
contributing just its verification and parsing.
19+
- **`flyte.new_condition`** — park a run on a human decision, with a typed
20+
payload coming back. `flyteplugins.github.review_pr` wraps this into a PR
21+
review gate, which is the one place a plugin beats calling the vendor SDK:
22+
the condition is Flyte's, not GitHub's.
23+
24+
## The examples
25+
26+
| File | What it shows | Client |
27+
| --- | --- | --- |
28+
| `webhook_receiver.py` | One app receiving from all five products, launching a task per event | `flyteplugins-webhooks-*` |
29+
| `github_pr_review_gate.py` | Human-gated merge, on `flyteplugins.github.review_pr` | plugin + `PyGithub` |
30+
| `github_triage_pr.py` | Label, comment, and report a check run | `PyGithub` |
31+
| `slack_notify.py` | Post, thread, react, answer a mention | `slack_sdk` |
32+
| `linear_triage_issue.py` | Query a backlog and comment, over GraphQL | `gql` |
33+
| `clickup_manage_ticket.py` | Open and close tickets, with a status pre-check | `httpx` |
34+
| `jira_manage_ticket.py` | Open, transition, and search issues | `jira` |
35+
36+
Linear and ClickUp ship no official Python SDK. Linear's API is a single GraphQL
37+
endpoint, so `gql` is the maintained client; ClickUp's is a handful of REST
38+
calls, so `httpx` directly beats a thin third-party wrapper.
39+
40+
## Putting it together
41+
42+
The receiver and the tasks are separate on purpose: the app authenticates and
43+
dispatches, the tasks do the work and can be run, tested, and retried on their
44+
own.
45+
46+
```bash
47+
# 1. deploy the tasks the receiver will launch
48+
flyte deploy examples/external_saas_integrations/github_triage_pr.py env
49+
flyte deploy examples/external_saas_integrations/slack_notify.py env
50+
51+
# 2. run one directly, to confirm credentials work before any webhook is involved
52+
flyte run examples/external_saas_integrations/github_triage_pr.py triage_pr \
53+
--repo <owner>/<repo> --number <pr>
54+
55+
# 3. deploy the receiver and point each provider at the URL its dashboard shows
56+
python examples/external_saas_integrations/webhook_receiver.py
57+
```
58+
59+
Task names are qualified by their environment when deployed — `triage_pr` in
60+
`github_triage_pr.py` becomes `github-triage.triage_pr`, which is what the
61+
receiver looks up. That qualifier is also what keeps the two `triage_issue`
62+
tasks here (Linear's and Jira's) from colliding.
63+
64+
`plugins/webhooks/README.md` has the full end-to-end testing guide, including
65+
how to wire up each provider and what to check at every step.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Read and write ClickUp from tasks, with `httpx` against its REST v2 API.
2+
3+
ClickUp ships no official Python SDK and the community ones are thin and
4+
sporadically maintained, so this calls the REST API directly with `httpx`
5+
rather than adding a dependency that wraps four endpoints.
6+
7+
Requirements:
8+
pip install flyte httpx
9+
10+
Setup:
11+
flyte create secret CLICKUP_TOKEN --value pk_...
12+
13+
Usage:
14+
flyte run examples/external_saas_integrations/clickup_manage_ticket.py \\
15+
open_ticket --list_id <list-id> --name "From Flyte"
16+
"""
17+
18+
import os
19+
20+
import flyte
21+
22+
image = flyte.Image.from_debian_base(python_version=(3, 12)).with_pip_packages("httpx")
23+
24+
env = flyte.TaskEnvironment(
25+
name="clickup-tickets",
26+
image=image,
27+
secrets=[flyte.Secret("CLICKUP_TOKEN", as_env_var="CLICKUP_TOKEN")],
28+
)
29+
30+
API = "https://api.clickup.com/api/v2"
31+
32+
33+
def _client():
34+
import httpx
35+
36+
return httpx.AsyncClient(base_url=API, headers={"Authorization": os.environ["CLICKUP_TOKEN"]}, timeout=30)
37+
38+
39+
@env.task
40+
async def open_ticket(list_id: str, name: str, description: str = "") -> str:
41+
"""Create a task and return its URL."""
42+
async with _client() as client:
43+
response = await client.post(f"/list/{list_id}/task", json={"name": name, "description": description})
44+
response.raise_for_status()
45+
return response.json()["url"]
46+
47+
48+
@env.task
49+
async def triage_task(task_id: str) -> str:
50+
"""Comment on a newly created task.
51+
52+
This is what `webhook_receiver.py` launches for every `taskCreated`.
53+
"""
54+
async with _client() as client:
55+
task = await client.get(f"/task/{task_id}")
56+
task.raise_for_status()
57+
status = (task.json().get("status") or {}).get("status")
58+
posted = await client.post(
59+
f"/task/{task_id}/comment", json={"comment_text": f"Flyte triaged this ticket (status: {status})."}
60+
)
61+
posted.raise_for_status()
62+
return f"triaged {task_id}"
63+
64+
65+
@env.task
66+
async def close_ticket(task_id: str, done_status: str = "done") -> str:
67+
"""Move a ticket to a Done-like status, validating it first.
68+
69+
ClickUp rejects transitions to statuses the ticket's list does not define,
70+
with an opaque 400 — so check the list's statuses before trying.
71+
"""
72+
async with _client() as client:
73+
task = await client.get(f"/task/{task_id}")
74+
task.raise_for_status()
75+
list_id = (task.json().get("list") or {}).get("id")
76+
77+
listing = await client.get(f"/list/{list_id}")
78+
listing.raise_for_status()
79+
valid = [s["status"] for s in listing.json().get("statuses", [])]
80+
if done_status not in valid:
81+
raise ValueError(f"status {done_status!r} is not defined on list {list_id}; valid: {valid}")
82+
83+
updated = await client.put(f"/task/{task_id}", json={"status": done_status})
84+
updated.raise_for_status()
85+
return f"{task_id} -> {done_status}"
86+
87+
88+
if __name__ == "__main__":
89+
flyte.init_from_config()
90+
print(flyte.run(open_ticket, list_id="LIST_ID", name="Flyte test ticket").url)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Human-gated PR merging: a condition carrying a JSON payload.
2+
3+
A task collects review metadata from a pull request, embeds it as JSON in a
4+
markdown condition prompt, parks the run until a human answers in the Flyte UI,
5+
and parses the response into a typed decision. Approved PRs get merged.
6+
7+
The gate itself is `flyteplugins.github.review_pr` — it lives in the plugin
8+
because `flyte.new_condition` is the part only Flyte can do. Merging is
9+
`PyGithub`, called directly here.
10+
11+
Requirements:
12+
pip install "flyteplugins-github[review]"
13+
14+
Setup:
15+
flyte create secret GITHUB_TOKEN --value <token-with-repo-scope>
16+
17+
Usage:
18+
flyte run examples/external_saas_integrations/github_pr_review_gate.py \\
19+
gated_merge --repo octocat/hello-world --number 1
20+
"""
21+
22+
import asyncio
23+
import os
24+
25+
from flyteplugins.github import ReviewDecision, review_pr
26+
27+
import flyte
28+
29+
image = flyte.Image.from_debian_base(python_version=(3, 12)).with_pip_packages("flyteplugins-github[review]")
30+
31+
env = flyte.TaskEnvironment(
32+
name="github-review-gate",
33+
image=image,
34+
secrets=[flyte.Secret("GITHUB_TOKEN", as_env_var="GITHUB_TOKEN")],
35+
)
36+
37+
38+
def _comment(repo: str, number: int, body: str) -> None:
39+
from github import Auth, Github
40+
41+
with Github(auth=Auth.Token(os.environ["GITHUB_TOKEN"])) as gh:
42+
gh.get_repo(repo).get_issue(number).create_comment(body)
43+
44+
45+
def _merge(repo: str, number: int) -> str:
46+
from github import Auth, Github
47+
48+
with Github(auth=Auth.Token(os.environ["GITHUB_TOKEN"])) as gh:
49+
result = gh.get_repo(repo).get_pull(number).merge(merge_method="squash")
50+
return f"merged {result.sha}"
51+
52+
53+
@env.task
54+
async def gated_merge(repo: str, number: int) -> str:
55+
"""Wait for a human review, then merge if approved.
56+
57+
The run parks at `review_pr` until someone answers the condition in the
58+
Flyte UI. Pass `timeout=` to bound that wait.
59+
"""
60+
decision: ReviewDecision = await review_pr(repo, number)
61+
62+
if not decision.is_approved:
63+
# Post the reviewer's reasoning back to the PR before bailing out, so
64+
# the decision is visible where the author is looking.
65+
blockers = "\n".join(f"- `{c.path}`: {c.body}" for c in decision.blocking_comments)
66+
await asyncio.to_thread(
67+
_comment, repo, number, f"Review gate blocked this merge: {decision.summary}\n{blockers}"
68+
)
69+
return f"blocked: {decision.summary}"
70+
71+
# PyGithub is synchronous; keep it off the event loop.
72+
return await asyncio.to_thread(_merge, repo, number)
73+
74+
75+
if __name__ == "__main__":
76+
flyte.init_from_config()
77+
run = flyte.with_runcontext().run(gated_merge, repo="octocat/hello-world", number=1)
78+
print(run.url)

0 commit comments

Comments
 (0)