Feat: OLAP Table for CEL Eval Failures (#2012)

* feat: add table, wire up partitioning

* feat: wire failures into the OLAP db from rabbit

* feat: bubble failures up to controller

* fix: naming

* fix: hack around enum type

* fix: typo

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: typos

* fix: migration name

* feat: log debug failure

* feat: pub message from debug endpoint to log failure

* fix: error handling

* fix: use ingestor

* fix: olap suffix

* fix: pass source through

* fix: dont log ingest failure

* fix: rm debug as enum opt

* chore: gen

* Feat: Webhooks (#1978)

* feat: migration + go gen

* feat: non unique source name

* feat: api types

* fix: rm cruft

* feat: initial api for webhooks

* feat: handle encryption of incoming keys

* fix: nil pointer errors

* fix: import

* feat: add endpoint for incoming webhooks

* fix: naming

* feat: start wiring up basic auth

* feat: wire up cel event parsing

* feat: implement authentication

* fix: hack for plain text content

* feat: add source to enum

* feat: add source name enum

* feat: db source name enum fix

* fix: use source name enums

* feat: nest sources

* feat: first pass at stripe

* fix: clean up source name passing

* fix: use unique name for webhook

* feat: populator test

* fix: null values

* fix: ordering

* fix: rm unnecessary index

* fix: validation

* feat: validation on create

* fix: lint

* fix: naming

* feat: wire triggering webhook name through to events table

* feat: cleanup + python gen + e2e test for basic auth

* feat: query to insert webhook validation errors

* refactor: auth handler

* fix: naming

* refactor: validation errors, part II

* feat: wire up writes through olap

* fix: linting, fallthrough case

* fix: validation

* feat: tests for failure cases for basic auth

* feat: expand tests

* fix: correctly return 404 out of task getter

* chore: generated stuff

* fix: rm cruft

* fix: longer sleep

* debug: print name + events to logs

* feat: limit to N

* feat: add limit env var

* debug: ci test

* fix: apply namespaces to keys

* fix: namespacing, part ii

* fix: sdk config

* fix: handle prefixing

* feat: handle partitioning logic

* chore: gen

* feat: add webhook limit

* feat: wire up limits

* fix: gen

* fix: reverse order of generic fallthrough

* fix: comment for potential unexpected behavior

* fix: add check constraints, improve error handling

* chore: gen

* chore: gen

* fix: improve naming

* feat: scaffold webhooks page

* feat: sidebar

* feat: first pass at page

* feat: improve feedback on UI

* feat: initial work on create modal

* feat: change default to basic

* fix: openapi spec discriminated union

* fix: go side

* feat: start wiring up placeholders for stripe and github

* feat: pre-populated fields for Stripe + Github

* feat: add name section

* feat: copy improvements, show URL

* feat: UI cleanup

* fix: check if tenant populator errors

* feat: add comments

* chore: gen again

* fix: default name

* fix: styling

* fix: improve stripe header processing

* feat: docs, part 1

* fix: lint

* fix: migration order

* feat: implement rate limit per-webhook

* feat: comment

* feat: clean up docs

* chore: gen

* fix: migration versions

* fix: olap naming

* fix: partitions

* chore: gen

* feat: store webhook cel eval failures properly

* fix: pk order

* fix: auth tweaks, move fetches out of populator

* fix: pgtype.Text instead of string pointer

* chore: gen

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
matt
2025-07-30 13:27:38 -04:00
committed by GitHub
parent 1b2a2bf566
commit d6f8be2c0f
111 changed files with 11294 additions and 374 deletions

View File

@@ -0,0 +1,643 @@
import asyncio
import base64
import hashlib
import hmac
import json
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
import aiohttp
import pytest
from examples.webhooks.worker import WebhookInput
from hatchet_sdk import Hatchet
from hatchet_sdk.clients.rest.api.webhook_api import WebhookApi
from hatchet_sdk.clients.rest.models.v1_create_webhook_request import (
V1CreateWebhookRequest,
)
from hatchet_sdk.clients.rest.models.v1_create_webhook_request_api_key import (
V1CreateWebhookRequestAPIKey,
)
from hatchet_sdk.clients.rest.models.v1_create_webhook_request_basic_auth import (
V1CreateWebhookRequestBasicAuth,
)
from hatchet_sdk.clients.rest.models.v1_create_webhook_request_hmac import (
V1CreateWebhookRequestHMAC,
)
from hatchet_sdk.clients.rest.models.v1_event import V1Event
from hatchet_sdk.clients.rest.models.v1_task_status import V1TaskStatus
from hatchet_sdk.clients.rest.models.v1_task_summary import V1TaskSummary
from hatchet_sdk.clients.rest.models.v1_webhook import V1Webhook
from hatchet_sdk.clients.rest.models.v1_webhook_api_key_auth import V1WebhookAPIKeyAuth
from hatchet_sdk.clients.rest.models.v1_webhook_basic_auth import V1WebhookBasicAuth
from hatchet_sdk.clients.rest.models.v1_webhook_hmac_algorithm import (
V1WebhookHMACAlgorithm,
)
from hatchet_sdk.clients.rest.models.v1_webhook_hmac_auth import V1WebhookHMACAuth
from hatchet_sdk.clients.rest.models.v1_webhook_hmac_encoding import (
V1WebhookHMACEncoding,
)
from hatchet_sdk.clients.rest.models.v1_webhook_source_name import V1WebhookSourceName
TEST_BASIC_USERNAME = "test_user"
TEST_BASIC_PASSWORD = "test_password"
TEST_API_KEY_HEADER = "X-API-Key"
TEST_API_KEY_VALUE = "test_api_key_123"
TEST_HMAC_SIGNATURE_HEADER = "X-Signature"
TEST_HMAC_SECRET = "test_hmac_secret"
@pytest.fixture
def webhook_body() -> WebhookInput:
return WebhookInput(type="test", message="Hello, world!")
@pytest.fixture
def test_run_id() -> str:
return str(uuid4())
@pytest.fixture
def test_start() -> datetime:
return datetime.now(timezone.utc)
def create_hmac_signature(
payload: bytes,
secret: str,
algorithm: V1WebhookHMACAlgorithm = V1WebhookHMACAlgorithm.SHA256,
encoding: V1WebhookHMACEncoding = V1WebhookHMACEncoding.HEX,
) -> str:
algorithm_map = {
V1WebhookHMACAlgorithm.SHA1: hashlib.sha1,
V1WebhookHMACAlgorithm.SHA256: hashlib.sha256,
V1WebhookHMACAlgorithm.SHA512: hashlib.sha512,
V1WebhookHMACAlgorithm.MD5: hashlib.md5,
}
hash_func = algorithm_map[algorithm]
signature = hmac.new(secret.encode(), payload, hash_func).digest()
if encoding == V1WebhookHMACEncoding.HEX:
return signature.hex()
if encoding == V1WebhookHMACEncoding.BASE64:
return base64.b64encode(signature).decode()
if encoding == V1WebhookHMACEncoding.BASE64URL:
return base64.urlsafe_b64encode(signature).decode()
raise ValueError(f"Unsupported encoding: {encoding}")
async def send_webhook_request(
url: str,
body: WebhookInput,
auth_type: str,
auth_data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> aiohttp.ClientResponse:
request_headers = headers or {}
auth = None
if auth_type == "BASIC" and auth_data:
auth = aiohttp.BasicAuth(auth_data["username"], auth_data["password"])
elif auth_type == "API_KEY" and auth_data:
request_headers[auth_data["header_name"]] = auth_data["api_key"]
elif auth_type == "HMAC" and auth_data:
payload = json.dumps(body.model_dump()).encode()
signature = create_hmac_signature(
payload,
auth_data["secret"],
auth_data.get("algorithm", V1WebhookHMACAlgorithm.SHA256),
auth_data.get("encoding", V1WebhookHMACEncoding.HEX),
)
request_headers[auth_data["header_name"]] = signature
async with aiohttp.ClientSession() as session:
return await session.post(
url, json=body.model_dump(), auth=auth, headers=request_headers
)
async def wait_for_event(
hatchet: Hatchet,
webhook_name: str,
test_start: datetime,
) -> V1Event | None:
await asyncio.sleep(5)
events = await hatchet.event.aio_list(since=test_start)
if events.rows is None:
return None
return next(
(
event
for event in events.rows
if event.triggering_webhook_name == webhook_name
),
None,
)
async def wait_for_workflow_run(
hatchet: Hatchet, event_id: str, test_start: datetime
) -> V1TaskSummary | None:
await asyncio.sleep(5)
runs = await hatchet.runs.aio_list(
since=test_start,
additional_metadata={
"hatchet__event_id": event_id,
},
)
if len(runs.rows) == 0:
return None
return runs.rows[0]
@asynccontextmanager
async def basic_auth_webhook(
hatchet: Hatchet,
test_run_id: str,
username: str = TEST_BASIC_USERNAME,
password: str = TEST_BASIC_PASSWORD,
source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC,
) -> AsyncGenerator[V1Webhook, None]:
## Hack to get the API client
client = hatchet.metrics.client()
webhook_api = WebhookApi(client)
webhook_request = V1CreateWebhookRequestBasicAuth(
sourceName=source_name,
name=f"test-webhook-basic-{test_run_id}",
eventKeyExpression=f"'{hatchet.config.apply_namespace('webhook')}:' + input.type",
authType="BASIC",
auth=V1WebhookBasicAuth(
username=username,
password=password,
),
)
incoming_webhook = webhook_api.v1_webhook_create(
tenant=hatchet.tenant_id,
v1_create_webhook_request=V1CreateWebhookRequest(webhook_request),
)
try:
yield incoming_webhook
finally:
webhook_api.v1_webhook_delete(
tenant=hatchet.tenant_id,
v1_webhook=incoming_webhook.name,
)
@asynccontextmanager
async def api_key_webhook(
hatchet: Hatchet,
test_run_id: str,
header_name: str = TEST_API_KEY_HEADER,
api_key: str = TEST_API_KEY_VALUE,
source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC,
) -> AsyncGenerator[V1Webhook, None]:
client = hatchet.metrics.client()
webhook_api = WebhookApi(client)
webhook_request = V1CreateWebhookRequestAPIKey(
sourceName=source_name,
name=f"test-webhook-apikey-{test_run_id}",
eventKeyExpression=f"'{hatchet.config.apply_namespace('webhook')}:' + input.type",
authType="API_KEY",
auth=V1WebhookAPIKeyAuth(
headerName=header_name,
apiKey=api_key,
),
)
incoming_webhook = webhook_api.v1_webhook_create(
tenant=hatchet.tenant_id,
v1_create_webhook_request=V1CreateWebhookRequest(webhook_request),
)
try:
yield incoming_webhook
finally:
webhook_api.v1_webhook_delete(
tenant=hatchet.tenant_id,
v1_webhook=incoming_webhook.name,
)
@asynccontextmanager
async def hmac_webhook(
hatchet: Hatchet,
test_run_id: str,
signature_header_name: str = TEST_HMAC_SIGNATURE_HEADER,
signing_secret: str = TEST_HMAC_SECRET,
algorithm: V1WebhookHMACAlgorithm = V1WebhookHMACAlgorithm.SHA256,
encoding: V1WebhookHMACEncoding = V1WebhookHMACEncoding.HEX,
source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC,
) -> AsyncGenerator[V1Webhook, None]:
client = hatchet.metrics.client()
webhook_api = WebhookApi(client)
webhook_request = V1CreateWebhookRequestHMAC(
sourceName=source_name,
name=f"test-webhook-hmac-{test_run_id}",
eventKeyExpression=f"'{hatchet.config.apply_namespace('webhook')}:' + input.type",
authType="HMAC",
auth=V1WebhookHMACAuth(
algorithm=algorithm,
encoding=encoding,
signatureHeaderName=signature_header_name,
signingSecret=signing_secret,
),
)
incoming_webhook = webhook_api.v1_webhook_create(
tenant=hatchet.tenant_id,
v1_create_webhook_request=V1CreateWebhookRequest(webhook_request),
)
try:
yield incoming_webhook
finally:
webhook_api.v1_webhook_delete(
tenant=hatchet.tenant_id,
v1_webhook=incoming_webhook.name,
)
def url(tenant_id: str, webhook_name: str) -> str:
return f"http://localhost:8080/api/v1/stable/tenants/{tenant_id}/webhooks/{webhook_name}"
async def assert_has_runs(
hatchet: Hatchet,
test_start: datetime,
webhook_body: WebhookInput,
incoming_webhook: V1Webhook,
) -> None:
triggered_event = await wait_for_event(hatchet, incoming_webhook.name, test_start)
assert triggered_event is not None
assert (
triggered_event.key
== f"{hatchet.config.apply_namespace('webhook')}:{webhook_body.type}"
)
assert triggered_event.payload == webhook_body.model_dump()
workflow_run = await wait_for_workflow_run(
hatchet, triggered_event.metadata.id, test_start
)
assert workflow_run is not None
assert workflow_run.status == V1TaskStatus.COMPLETED
assert workflow_run.additional_metadata is not None
assert (
workflow_run.additional_metadata["hatchet__event_id"]
== triggered_event.metadata.id
)
assert workflow_run.additional_metadata["hatchet__event_key"] == triggered_event.key
assert workflow_run.status == V1TaskStatus.COMPLETED
async def assert_event_not_created(
hatchet: Hatchet,
test_start: datetime,
incoming_webhook: V1Webhook,
) -> None:
triggered_event = await wait_for_event(hatchet, incoming_webhook.name, test_start)
assert triggered_event is None
@pytest.mark.asyncio(loop_scope="session")
async def test_basic_auth_success(
hatchet: Hatchet,
test_run_id: str,
test_start: datetime,
webhook_body: WebhookInput,
) -> None:
async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook:
async with await send_webhook_request(
url(hatchet.tenant_id, incoming_webhook.name),
webhook_body,
"BASIC",
{"username": TEST_BASIC_USERNAME, "password": TEST_BASIC_PASSWORD},
) as response:
assert response.status == 200
data = await response.json()
assert data == {"message": "ok"}
await assert_has_runs(
hatchet,
test_start,
webhook_body,
incoming_webhook,
)
@pytest.mark.parametrize(
"username,password",
[
("test_user", "incorrect_password"),
("incorrect_user", "test_password"),
("incorrect_user", "incorrect_password"),
("", ""),
],
)
@pytest.mark.asyncio(loop_scope="session")
async def test_basic_auth_failure(
hatchet: Hatchet,
test_run_id: str,
test_start: datetime,
webhook_body: WebhookInput,
username: str,
password: str,
) -> None:
"""Test basic authentication failures."""
async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook:
async with await send_webhook_request(
url(hatchet.tenant_id, incoming_webhook.name),
webhook_body,
"BASIC",
{"username": username, "password": password},
) as response:
assert response.status == 403
await assert_event_not_created(
hatchet,
test_start,
incoming_webhook,
)
@pytest.mark.asyncio(loop_scope="session")
async def test_basic_auth_missing_credentials(
hatchet: Hatchet,
test_run_id: str,
test_start: datetime,
webhook_body: WebhookInput,
) -> None:
async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook:
async with await send_webhook_request(
url(hatchet.tenant_id, incoming_webhook.name), webhook_body, "NONE"
) as response:
assert response.status == 403
await assert_event_not_created(
hatchet,
test_start,
incoming_webhook,
)
@pytest.mark.asyncio(loop_scope="session")
async def test_api_key_success(
hatchet: Hatchet,
test_run_id: str,
test_start: datetime,
webhook_body: WebhookInput,
) -> None:
async with api_key_webhook(hatchet, test_run_id) as incoming_webhook:
async with await send_webhook_request(
url(hatchet.tenant_id, incoming_webhook.name),
webhook_body,
"API_KEY",
{"header_name": TEST_API_KEY_HEADER, "api_key": TEST_API_KEY_VALUE},
) as response:
assert response.status == 200
data = await response.json()
assert data == {"message": "ok"}
await assert_has_runs(
hatchet,
test_start,
webhook_body,
incoming_webhook,
)
@pytest.mark.parametrize(
"api_key",
[
"incorrect_api_key",
"",
"partial_key",
],
)
@pytest.mark.asyncio(loop_scope="session")
async def test_api_key_failure(
hatchet: Hatchet,
test_run_id: str,
test_start: datetime,
webhook_body: WebhookInput,
api_key: str,
) -> None:
async with api_key_webhook(hatchet, test_run_id) as incoming_webhook:
async with await send_webhook_request(
url(hatchet.tenant_id, incoming_webhook.name),
webhook_body,
"API_KEY",
{"header_name": TEST_API_KEY_HEADER, "api_key": api_key},
) as response:
assert response.status == 403
await assert_event_not_created(
hatchet,
test_start,
incoming_webhook,
)
@pytest.mark.asyncio(loop_scope="session")
async def test_api_key_missing_header(
hatchet: Hatchet,
test_run_id: str,
test_start: datetime,
webhook_body: WebhookInput,
) -> None:
async with api_key_webhook(hatchet, test_run_id) as incoming_webhook:
async with await send_webhook_request(
url(hatchet.tenant_id, incoming_webhook.name), webhook_body, "NONE"
) as response:
assert response.status == 403
await assert_event_not_created(
hatchet,
test_start,
incoming_webhook,
)
@pytest.mark.asyncio(loop_scope="session")
async def test_hmac_success(
hatchet: Hatchet,
test_run_id: str,
test_start: datetime,
webhook_body: WebhookInput,
) -> None:
async with hmac_webhook(hatchet, test_run_id) as incoming_webhook:
async with await send_webhook_request(
url(hatchet.tenant_id, incoming_webhook.name),
webhook_body,
"HMAC",
{
"header_name": TEST_HMAC_SIGNATURE_HEADER,
"secret": TEST_HMAC_SECRET,
"algorithm": V1WebhookHMACAlgorithm.SHA256,
"encoding": V1WebhookHMACEncoding.HEX,
},
) as response:
assert response.status == 200
data = await response.json()
assert data == {"message": "ok"}
await assert_has_runs(
hatchet,
test_start,
webhook_body,
incoming_webhook,
)
@pytest.mark.parametrize(
"algorithm,encoding",
[
(V1WebhookHMACAlgorithm.SHA1, V1WebhookHMACEncoding.HEX),
(V1WebhookHMACAlgorithm.SHA256, V1WebhookHMACEncoding.BASE64),
(V1WebhookHMACAlgorithm.SHA512, V1WebhookHMACEncoding.BASE64URL),
(V1WebhookHMACAlgorithm.MD5, V1WebhookHMACEncoding.HEX),
],
)
@pytest.mark.asyncio(loop_scope="session")
async def test_hmac_different_algorithms_and_encodings(
hatchet: Hatchet,
test_run_id: str,
test_start: datetime,
webhook_body: WebhookInput,
algorithm: V1WebhookHMACAlgorithm,
encoding: V1WebhookHMACEncoding,
) -> None:
async with hmac_webhook(
hatchet, test_run_id, algorithm=algorithm, encoding=encoding
) as incoming_webhook:
async with await send_webhook_request(
url(hatchet.tenant_id, incoming_webhook.name),
webhook_body,
"HMAC",
{
"header_name": TEST_HMAC_SIGNATURE_HEADER,
"secret": TEST_HMAC_SECRET,
"algorithm": algorithm,
"encoding": encoding,
},
) as response:
assert response.status == 200
data = await response.json()
assert data == {"message": "ok"}
await assert_has_runs(
hatchet,
test_start,
webhook_body,
incoming_webhook,
)
@pytest.mark.parametrize(
"secret",
[
"incorrect_secret",
"",
"partial_secret",
],
)
@pytest.mark.asyncio(loop_scope="session")
async def test_hmac_signature_failure(
hatchet: Hatchet,
test_run_id: str,
test_start: datetime,
webhook_body: WebhookInput,
secret: str,
) -> None:
async with hmac_webhook(hatchet, test_run_id) as incoming_webhook:
async with await send_webhook_request(
url(hatchet.tenant_id, incoming_webhook.name),
webhook_body,
"HMAC",
{
"header_name": TEST_HMAC_SIGNATURE_HEADER,
"secret": secret,
"algorithm": V1WebhookHMACAlgorithm.SHA256,
"encoding": V1WebhookHMACEncoding.HEX,
},
) as response:
assert response.status == 403
await assert_event_not_created(
hatchet,
test_start,
incoming_webhook,
)
@pytest.mark.asyncio(loop_scope="session")
async def test_hmac_missing_signature_header(
hatchet: Hatchet,
test_run_id: str,
test_start: datetime,
webhook_body: WebhookInput,
) -> None:
async with hmac_webhook(hatchet, test_run_id) as incoming_webhook:
async with await send_webhook_request(
url(hatchet.tenant_id, incoming_webhook.name), webhook_body, "NONE"
) as response:
assert response.status == 403
await assert_event_not_created(
hatchet,
test_start,
incoming_webhook,
)
@pytest.mark.parametrize(
"source_name",
[
V1WebhookSourceName.GENERIC,
V1WebhookSourceName.GITHUB,
],
)
@pytest.mark.asyncio(loop_scope="session")
async def test_different_source_types(
hatchet: Hatchet,
test_run_id: str,
test_start: datetime,
webhook_body: WebhookInput,
source_name: V1WebhookSourceName,
) -> None:
async with basic_auth_webhook(
hatchet, test_run_id, source_name=source_name
) as incoming_webhook:
async with await send_webhook_request(
url(hatchet.tenant_id, incoming_webhook.name),
webhook_body,
"BASIC",
{"username": TEST_BASIC_USERNAME, "password": TEST_BASIC_PASSWORD},
) as response:
assert response.status == 200
data = await response.json()
assert data == {"message": "ok"}
await assert_has_runs(
hatchet,
test_start,
webhook_body,
incoming_webhook,
)

View File

@@ -0,0 +1,27 @@
# > Webhooks
from pydantic import BaseModel
from hatchet_sdk import Context, Hatchet
hatchet = Hatchet(debug=True)
class WebhookInput(BaseModel):
type: str
message: str
@hatchet.task(input_validator=WebhookInput, on_events=["webhook:test"])
def webhook(input: WebhookInput, ctx: Context) -> dict[str, str]:
return input.model_dump()
def main() -> None:
worker = hatchet.worker("webhook-worker", workflows=[webhook])
worker.start()
if __name__ == "__main__":
main()

View File

@@ -26,6 +26,7 @@ from examples.on_failure.worker import on_failure_wf, on_failure_wf_with_details
from examples.return_exceptions.worker import return_exceptions_task
from examples.simple.worker import simple, simple_durable
from examples.timeout.worker import refresh_timeout_wf, timeout_wf
from examples.webhooks.worker import webhook
from hatchet_sdk import Hatchet
hatchet = Hatchet(debug=True)
@@ -66,6 +67,7 @@ def main() -> None:
bulk_replay_test_1,
bulk_replay_test_2,
bulk_replay_test_3,
webhook,
return_exceptions_task,
wait_for_sleep_twice,
],