render-lab-triggers 0.1.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.
- render_lab_triggers-0.1.0/.gitignore +11 -0
- render_lab_triggers-0.1.0/LICENSE +21 -0
- render_lab_triggers-0.1.0/PKG-INFO +72 -0
- render_lab_triggers-0.1.0/README.md +61 -0
- render_lab_triggers-0.1.0/pyproject.toml +16 -0
- render_lab_triggers-0.1.0/src/render_lab_triggers/__init__.py +6 -0
- render_lab_triggers-0.1.0/src/render_lab_triggers/cron.py +50 -0
- render_lab_triggers-0.1.0/src/render_lab_triggers/dispatch.py +71 -0
- render_lab_triggers-0.1.0/src/render_lab_triggers/py.typed +0 -0
- render_lab_triggers-0.1.0/src/render_lab_triggers/server.py +185 -0
- render_lab_triggers-0.1.0/src/render_lab_triggers/types.py +57 -0
- render_lab_triggers-0.1.0/tests/test_triggers.py +195 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Render Lab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: render-lab-triggers
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HTTP, webhook, and cron dispatch for Render workflows
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: render==1.0.1
|
|
9
|
+
Requires-Dist: uvicorn<1,>=0.35
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# render-lab-triggers
|
|
13
|
+
|
|
14
|
+
Registration-free HTTP, webhook, and cron dispatch to a separate Render Workflow.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from render_lab_triggers import create_dispatch_server
|
|
18
|
+
|
|
19
|
+
app = create_dispatch_server()
|
|
20
|
+
# uvicorn main:app --host 0.0.0.0 --port 3000
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`GET /healthz` returns `ok`. `POST /tasks/{namespace.task}` requires
|
|
24
|
+
`Authorization: Bearer <DISPATCH_TOKEN>` and accepts JSON positional arguments
|
|
25
|
+
(an array), a single JSON value, or an empty body (`[{}]`). It returns HTTP 202
|
|
26
|
+
with `runId`. Add `?wait=1` for a bounded wait: HTTP 200 with status/results on
|
|
27
|
+
completion or HTTP 202 when the wait expires. The default wait is 25 seconds.
|
|
28
|
+
|
|
29
|
+
`create_dispatch_server` accepts `workflow_slug`, `token`, `dispatcher`,
|
|
30
|
+
`webhooks`, `wait_timeout_ms`, and `max_body_bytes`. Bodies are capped at 1 MiB
|
|
31
|
+
by counting streamed bytes, regardless of Content-Length. Oversized bodies
|
|
32
|
+
return 413 before signature verification. The ASGI server owns connection cleanup.
|
|
33
|
+
|
|
34
|
+
Vendor adapters live in `render_lab_tasks_<vendor>.webhooks`; mount them in the
|
|
35
|
+
`webhooks` mapping at `/webhooks/{name}`. Their `verify` function sees the exact
|
|
36
|
+
raw UTF-8 body and lowercase headers before JSON parsing. `map` returns a
|
|
37
|
+
`{"task": ..., "args": [...]}` dispatch or `None` to acknowledge and ignore.
|
|
38
|
+
As in the pinned TS server, webhook bodies must be JSON. Twilio form-encoded
|
|
39
|
+
signatures can be verified with its standalone adapter, but form dispatch needs
|
|
40
|
+
a custom endpoint that decodes the form after verification.
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from render_lab_triggers import run_cron
|
|
44
|
+
|
|
45
|
+
# In an async cron entry point:
|
|
46
|
+
# result = await run_cron(task="report.daily", args=[{}])
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`run_cron` defaults to asynchronous dispatch. `wait=True` waits at most five
|
|
50
|
+
minutes; `wait_timeout_ms` overrides it. `render_dispatcher(slug=...)` starts via
|
|
51
|
+
the pinned Render SDK and consumes its public SSE event iterator. It closes
|
|
52
|
+
streams on completion, timeout, and cancellation, without adding retries.
|
|
53
|
+
`client=` injects a RenderAsync-compatible client; the caller owns that client.
|
|
54
|
+
`serve_dispatch_server(port=..., **options)` runs Uvicorn until shutdown.
|
|
55
|
+
|
|
56
|
+
## Environment
|
|
57
|
+
|
|
58
|
+
- `WORKFLOW_SLUG`: target Workflow slug, unless provided explicitly.
|
|
59
|
+
- `DISPATCH_TOKEN`: bearer token for `/tasks/*`; unset rejects all such requests.
|
|
60
|
+
- `RENDER_API_KEY`: Render SDK credential, read when dispatching.
|
|
61
|
+
- `CRON_TASK`: default cron task name.
|
|
62
|
+
- `CRON_INPUT`: JSON cron input; an array is positional arguments.
|
|
63
|
+
- `PORT`: serving port, default 3000.
|
|
64
|
+
|
|
65
|
+
Imports never read credentials or register tasks. Tests inject dispatchers and
|
|
66
|
+
SDK event streams; live cross-service dispatch remains in the testing backlog.
|
|
67
|
+
|
|
68
|
+
## Installation
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
pip install render-lab-triggers==0.1.0
|
|
72
|
+
```
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# render-lab-triggers
|
|
2
|
+
|
|
3
|
+
Registration-free HTTP, webhook, and cron dispatch to a separate Render Workflow.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from render_lab_triggers import create_dispatch_server
|
|
7
|
+
|
|
8
|
+
app = create_dispatch_server()
|
|
9
|
+
# uvicorn main:app --host 0.0.0.0 --port 3000
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`GET /healthz` returns `ok`. `POST /tasks/{namespace.task}` requires
|
|
13
|
+
`Authorization: Bearer <DISPATCH_TOKEN>` and accepts JSON positional arguments
|
|
14
|
+
(an array), a single JSON value, or an empty body (`[{}]`). It returns HTTP 202
|
|
15
|
+
with `runId`. Add `?wait=1` for a bounded wait: HTTP 200 with status/results on
|
|
16
|
+
completion or HTTP 202 when the wait expires. The default wait is 25 seconds.
|
|
17
|
+
|
|
18
|
+
`create_dispatch_server` accepts `workflow_slug`, `token`, `dispatcher`,
|
|
19
|
+
`webhooks`, `wait_timeout_ms`, and `max_body_bytes`. Bodies are capped at 1 MiB
|
|
20
|
+
by counting streamed bytes, regardless of Content-Length. Oversized bodies
|
|
21
|
+
return 413 before signature verification. The ASGI server owns connection cleanup.
|
|
22
|
+
|
|
23
|
+
Vendor adapters live in `render_lab_tasks_<vendor>.webhooks`; mount them in the
|
|
24
|
+
`webhooks` mapping at `/webhooks/{name}`. Their `verify` function sees the exact
|
|
25
|
+
raw UTF-8 body and lowercase headers before JSON parsing. `map` returns a
|
|
26
|
+
`{"task": ..., "args": [...]}` dispatch or `None` to acknowledge and ignore.
|
|
27
|
+
As in the pinned TS server, webhook bodies must be JSON. Twilio form-encoded
|
|
28
|
+
signatures can be verified with its standalone adapter, but form dispatch needs
|
|
29
|
+
a custom endpoint that decodes the form after verification.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from render_lab_triggers import run_cron
|
|
33
|
+
|
|
34
|
+
# In an async cron entry point:
|
|
35
|
+
# result = await run_cron(task="report.daily", args=[{}])
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`run_cron` defaults to asynchronous dispatch. `wait=True` waits at most five
|
|
39
|
+
minutes; `wait_timeout_ms` overrides it. `render_dispatcher(slug=...)` starts via
|
|
40
|
+
the pinned Render SDK and consumes its public SSE event iterator. It closes
|
|
41
|
+
streams on completion, timeout, and cancellation, without adding retries.
|
|
42
|
+
`client=` injects a RenderAsync-compatible client; the caller owns that client.
|
|
43
|
+
`serve_dispatch_server(port=..., **options)` runs Uvicorn until shutdown.
|
|
44
|
+
|
|
45
|
+
## Environment
|
|
46
|
+
|
|
47
|
+
- `WORKFLOW_SLUG`: target Workflow slug, unless provided explicitly.
|
|
48
|
+
- `DISPATCH_TOKEN`: bearer token for `/tasks/*`; unset rejects all such requests.
|
|
49
|
+
- `RENDER_API_KEY`: Render SDK credential, read when dispatching.
|
|
50
|
+
- `CRON_TASK`: default cron task name.
|
|
51
|
+
- `CRON_INPUT`: JSON cron input; an array is positional arguments.
|
|
52
|
+
- `PORT`: serving port, default 3000.
|
|
53
|
+
|
|
54
|
+
Imports never read credentials or register tasks. Tests inject dispatchers and
|
|
55
|
+
SDK event streams; live cross-service dispatch remains in the testing backlog.
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
pip install render-lab-triggers==0.1.0
|
|
61
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "render-lab-triggers"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "HTTP, webhook, and cron dispatch for Render workflows"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
dependencies = ["render==1.0.1", "uvicorn>=0.35,<1"]
|
|
14
|
+
|
|
15
|
+
[tool.hatch.build.targets.wheel]
|
|
16
|
+
packages = ["src/render_lab_triggers"]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Registration-free HTTP, webhook, and cron dispatch."""
|
|
2
|
+
|
|
3
|
+
from .cron import run_cron as run_cron
|
|
4
|
+
from .dispatch import render_dispatcher as render_dispatcher
|
|
5
|
+
from .server import create_dispatch_server as create_dispatch_server
|
|
6
|
+
from .server import serve_dispatch_server as serve_dispatch_server
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Dispatch one run from a cron job."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from .dispatch import render_dispatcher
|
|
10
|
+
from .types import CronResult, Json, WorkflowDispatcher
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_args(text: str | None) -> list[Json]:
|
|
14
|
+
if not text:
|
|
15
|
+
return [{}]
|
|
16
|
+
|
|
17
|
+
def invalid(value: str) -> None:
|
|
18
|
+
raise ValueError(f"Invalid JSON constant: {value}")
|
|
19
|
+
|
|
20
|
+
value = cast(Json, json.loads(text, parse_constant=invalid))
|
|
21
|
+
# Reject overflow to infinity as well as named nonfinite constants.
|
|
22
|
+
json.dumps(value, allow_nan=False)
|
|
23
|
+
return value if isinstance(value, list) else [value]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def run_cron(
|
|
27
|
+
*,
|
|
28
|
+
workflow_slug: str | None = None,
|
|
29
|
+
task: str | None = None,
|
|
30
|
+
args: list[Json] | None = None,
|
|
31
|
+
dispatcher: WorkflowDispatcher | None = None,
|
|
32
|
+
wait: bool = False,
|
|
33
|
+
wait_timeout_ms: float = 300_000,
|
|
34
|
+
) -> CronResult:
|
|
35
|
+
slug = workflow_slug if workflow_slug is not None else os.getenv("WORKFLOW_SLUG")
|
|
36
|
+
if not slug:
|
|
37
|
+
raise ValueError("run_cron: set WORKFLOW_SLUG or pass workflow_slug.")
|
|
38
|
+
target = task if task is not None else os.getenv("CRON_TASK")
|
|
39
|
+
if not target:
|
|
40
|
+
raise ValueError("run_cron: set CRON_TASK or pass task.")
|
|
41
|
+
inputs = args if args is not None else parse_args(os.getenv("CRON_INPUT"))
|
|
42
|
+
dispatch = dispatcher if dispatcher is not None else render_dispatcher(slug=slug)
|
|
43
|
+
if wait:
|
|
44
|
+
result = await dispatch.run(target, inputs, wait_timeout_ms)
|
|
45
|
+
out: CronResult = {"runId": result["runId"], "status": result["status"]}
|
|
46
|
+
if "results" in result:
|
|
47
|
+
out["results"] = result["results"]
|
|
48
|
+
return out
|
|
49
|
+
started = await dispatch.start(target, inputs)
|
|
50
|
+
return {"runId": started["runId"], "status": "running"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Bounded cross-service dispatch using the Render SDK's public SSE iterator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from typing import TYPE_CHECKING, cast
|
|
9
|
+
|
|
10
|
+
from .types import Json, RunOutcome, StartedRun
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from render import RenderAsync
|
|
14
|
+
from render.client.workflows import WorkflowsService
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RenderDispatcher:
|
|
18
|
+
def __init__(self, slug: str, client: RenderAsync | None = None) -> None:
|
|
19
|
+
self.slug = slug
|
|
20
|
+
self._client = client
|
|
21
|
+
|
|
22
|
+
@asynccontextmanager
|
|
23
|
+
async def _service(self) -> AsyncIterator[WorkflowsService]:
|
|
24
|
+
if self._client is not None:
|
|
25
|
+
yield self._client.workflows
|
|
26
|
+
return
|
|
27
|
+
from render import RenderAsync
|
|
28
|
+
|
|
29
|
+
client = RenderAsync()
|
|
30
|
+
# The generated client owns its HTTP pool; close it after this dispatch.
|
|
31
|
+
async with client.client.internal:
|
|
32
|
+
yield client.workflows
|
|
33
|
+
|
|
34
|
+
async def start(self, task: str, args: list[Json]) -> StartedRun:
|
|
35
|
+
async with self._service() as service:
|
|
36
|
+
started = await service.start_task(f"{self.slug}/{task}", args)
|
|
37
|
+
return {"runId": started.id}
|
|
38
|
+
|
|
39
|
+
async def run(self, task: str, args: list[Json], timeout_ms: float) -> RunOutcome:
|
|
40
|
+
async with self._service() as service:
|
|
41
|
+
started = await service.start_task(f"{self.slug}/{task}", args)
|
|
42
|
+
stream = service.task_run_events([started.id])
|
|
43
|
+
# aclosing's protocol is stronger than the SDK iterator annotation;
|
|
44
|
+
# close the public async generator when it exposes aclose.
|
|
45
|
+
try:
|
|
46
|
+
async with asyncio.timeout(max(0, timeout_ms) / 1000) as deadline:
|
|
47
|
+
async for details in stream:
|
|
48
|
+
if details.id != started.id:
|
|
49
|
+
continue
|
|
50
|
+
status = str(details.status)
|
|
51
|
+
if status not in {"succeeded", "completed", "failed", "canceled"}:
|
|
52
|
+
continue
|
|
53
|
+
return {
|
|
54
|
+
"runId": started.id,
|
|
55
|
+
"status": status,
|
|
56
|
+
"results": cast(Json, details.results),
|
|
57
|
+
"timedOut": False,
|
|
58
|
+
}
|
|
59
|
+
raise RuntimeError("Workflow event stream ended before the run finished")
|
|
60
|
+
except TimeoutError:
|
|
61
|
+
if not deadline.expired():
|
|
62
|
+
raise
|
|
63
|
+
return {"runId": started.id, "status": "running", "timedOut": True}
|
|
64
|
+
finally:
|
|
65
|
+
close = getattr(stream, "aclose", None)
|
|
66
|
+
if close is not None:
|
|
67
|
+
await close()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def render_dispatcher(*, slug: str, client: RenderAsync | None = None) -> RenderDispatcher:
|
|
71
|
+
return RenderDispatcher(slug, client)
|
|
File without changes
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Small ASGI dispatch application with bounded request bodies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import parse_qs
|
|
11
|
+
|
|
12
|
+
from .cron import parse_args
|
|
13
|
+
from .dispatch import render_dispatcher
|
|
14
|
+
from .types import Json, WebhookAdapter, WorkflowDispatcher
|
|
15
|
+
|
|
16
|
+
type Message = dict[str, Any]
|
|
17
|
+
type Receive = Callable[[], Awaitable[Message]]
|
|
18
|
+
type Send = Callable[[Message], Awaitable[None]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _body(receive: Receive, maximum: int) -> str | None:
|
|
22
|
+
chunks: list[bytes] = []
|
|
23
|
+
size = 0
|
|
24
|
+
while True:
|
|
25
|
+
message = await receive()
|
|
26
|
+
if message["type"] == "http.disconnect":
|
|
27
|
+
raise ConnectionError("Client disconnected")
|
|
28
|
+
chunk: bytes = message.get("body", b"")
|
|
29
|
+
size += len(chunk)
|
|
30
|
+
if size > maximum:
|
|
31
|
+
return None
|
|
32
|
+
chunks.append(chunk)
|
|
33
|
+
if not message.get("more_body", False):
|
|
34
|
+
return b"".join(chunks).decode("utf-8", errors="replace")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _respond(send: Send, status: int, body: Json = None, *, text: bool = False) -> None:
|
|
38
|
+
data = (
|
|
39
|
+
b""
|
|
40
|
+
if status == 204
|
|
41
|
+
else (
|
|
42
|
+
str(body).encode()
|
|
43
|
+
if text
|
|
44
|
+
else json.dumps(body, allow_nan=False, separators=(",", ":")).encode()
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
content_type = b"text/plain; charset=UTF-8" if text else b"application/json"
|
|
48
|
+
await send(
|
|
49
|
+
{
|
|
50
|
+
"type": "http.response.start",
|
|
51
|
+
"status": status,
|
|
52
|
+
"headers": [
|
|
53
|
+
(b"content-type", content_type),
|
|
54
|
+
(b"content-length", str(len(data)).encode()),
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
await send({"type": "http.response.body", "body": data})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DispatchServer:
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
workflow_slug: str | None = None,
|
|
66
|
+
token: str | None = None,
|
|
67
|
+
webhooks: Mapping[str, WebhookAdapter] | None = None,
|
|
68
|
+
dispatcher: WorkflowDispatcher | None = None,
|
|
69
|
+
wait_timeout_ms: float = 25_000,
|
|
70
|
+
max_body_bytes: int = 1_048_576,
|
|
71
|
+
) -> None:
|
|
72
|
+
if max_body_bytes < 0:
|
|
73
|
+
raise ValueError("max_body_bytes must be nonnegative")
|
|
74
|
+
self.slug = workflow_slug if workflow_slug is not None else os.getenv("WORKFLOW_SLUG")
|
|
75
|
+
self.token = token if token is not None else os.getenv("DISPATCH_TOKEN")
|
|
76
|
+
self.webhooks = dict(webhooks or {})
|
|
77
|
+
self.dispatcher = (
|
|
78
|
+
dispatcher if dispatcher is not None else render_dispatcher(slug=self.slug or "")
|
|
79
|
+
)
|
|
80
|
+
self.wait_timeout_ms = wait_timeout_ms
|
|
81
|
+
self.max_body_bytes = max_body_bytes
|
|
82
|
+
|
|
83
|
+
async def __call__(self, scope: Message, receive: Receive, send: Send) -> None:
|
|
84
|
+
if scope["type"] == "lifespan":
|
|
85
|
+
while True:
|
|
86
|
+
message = await receive()
|
|
87
|
+
if message["type"] == "lifespan.startup":
|
|
88
|
+
await send({"type": "lifespan.startup.complete"})
|
|
89
|
+
elif message["type"] == "lifespan.shutdown":
|
|
90
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
91
|
+
return
|
|
92
|
+
if scope["type"] != "http":
|
|
93
|
+
raise ValueError("Only HTTP and lifespan ASGI scopes are supported")
|
|
94
|
+
path = scope["path"]
|
|
95
|
+
if path == "/healthz" and scope["method"] == "GET":
|
|
96
|
+
await _respond(send, 200, "ok", text=True)
|
|
97
|
+
return
|
|
98
|
+
parts = path.split("/")
|
|
99
|
+
generic = len(parts) == 3 and parts[1] == "tasks" and bool(parts[2])
|
|
100
|
+
adapter = (
|
|
101
|
+
self.webhooks.get(parts[2]) if len(parts) == 3 and parts[1] == "webhooks" else None
|
|
102
|
+
)
|
|
103
|
+
if scope["method"] != "POST" or not (generic or adapter):
|
|
104
|
+
await _respond(send, 404, {"error": "not found"})
|
|
105
|
+
return
|
|
106
|
+
headers = {
|
|
107
|
+
k.decode("latin1").lower(): v.decode("latin1") for k, v in scope.get("headers", [])
|
|
108
|
+
}
|
|
109
|
+
if generic:
|
|
110
|
+
header = headers.get("authorization", "")
|
|
111
|
+
if (
|
|
112
|
+
not self.token
|
|
113
|
+
or not header.startswith("Bearer ")
|
|
114
|
+
or not hmac.compare_digest(header[7:].encode(), self.token.encode())
|
|
115
|
+
):
|
|
116
|
+
await _respond(send, 401, {"error": "unauthorized"})
|
|
117
|
+
return
|
|
118
|
+
if not self.slug:
|
|
119
|
+
await _respond(send, 500, {"error": "WORKFLOW_SLUG not configured"})
|
|
120
|
+
return
|
|
121
|
+
raw = await _body(receive, self.max_body_bytes)
|
|
122
|
+
if raw is None:
|
|
123
|
+
await _respond(send, 413, {"error": "payload too large"})
|
|
124
|
+
return
|
|
125
|
+
if adapter is not None and not adapter.verify({"headers": headers, "rawBody": raw}):
|
|
126
|
+
await _respond(send, 401, {"error": "invalid signature"})
|
|
127
|
+
return
|
|
128
|
+
try:
|
|
129
|
+
args = parse_args(raw)
|
|
130
|
+
body = json.loads(raw) if raw else {}
|
|
131
|
+
except (ValueError, TypeError):
|
|
132
|
+
await _respond(send, 400, {"error": "invalid JSON body"})
|
|
133
|
+
return
|
|
134
|
+
task = parts[2]
|
|
135
|
+
if adapter is not None:
|
|
136
|
+
mapped = adapter.map({"headers": headers, "body": body})
|
|
137
|
+
if mapped is None:
|
|
138
|
+
await _respond(send, 204)
|
|
139
|
+
return
|
|
140
|
+
task, args = mapped["task"], mapped["args"]
|
|
141
|
+
try:
|
|
142
|
+
wait = parse_qs(scope.get("query_string", b"").decode()).get("wait", [""])[0]
|
|
143
|
+
if generic and wait in {"1", "true"}:
|
|
144
|
+
result = await self.dispatcher.run(task, args, self.wait_timeout_ms)
|
|
145
|
+
output: dict[str, Json] = {"runId": result["runId"], "status": result["status"]}
|
|
146
|
+
if result["timedOut"]:
|
|
147
|
+
output["status"] = "running"
|
|
148
|
+
elif "results" in result:
|
|
149
|
+
output["results"] = result["results"]
|
|
150
|
+
await _respond(send, 202 if result["timedOut"] else 200, output)
|
|
151
|
+
else:
|
|
152
|
+
started = await self.dispatcher.start(task, args)
|
|
153
|
+
await _respond(send, 202, {"runId": started["runId"]})
|
|
154
|
+
except Exception as error:
|
|
155
|
+
await _respond(send, 502, {"error": "dispatch failed", "detail": str(error)})
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def create_dispatch_server(
|
|
159
|
+
*,
|
|
160
|
+
workflow_slug: str | None = None,
|
|
161
|
+
token: str | None = None,
|
|
162
|
+
webhooks: Mapping[str, WebhookAdapter] | None = None,
|
|
163
|
+
dispatcher: WorkflowDispatcher | None = None,
|
|
164
|
+
wait_timeout_ms: float = 25_000,
|
|
165
|
+
max_body_bytes: int = 1_048_576,
|
|
166
|
+
) -> DispatchServer:
|
|
167
|
+
return DispatchServer(
|
|
168
|
+
workflow_slug=workflow_slug,
|
|
169
|
+
token=token,
|
|
170
|
+
webhooks=webhooks,
|
|
171
|
+
dispatcher=dispatcher,
|
|
172
|
+
wait_timeout_ms=wait_timeout_ms,
|
|
173
|
+
max_body_bytes=max_body_bytes,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def serve_dispatch_server(*, port: int | None = None, **options: Any) -> None:
|
|
178
|
+
"""Serve until shutdown; Python's Uvicorn runner owns the event loop."""
|
|
179
|
+
import uvicorn
|
|
180
|
+
|
|
181
|
+
uvicorn.run(
|
|
182
|
+
create_dispatch_server(**options),
|
|
183
|
+
host="0.0.0.0",
|
|
184
|
+
port=port if port is not None else int(os.getenv("PORT", "3000")),
|
|
185
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Registration-free trigger contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import NotRequired, Protocol, TypedDict
|
|
8
|
+
|
|
9
|
+
type Json = None | bool | int | float | str | list[Json] | dict[str, Json]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StartedRun(TypedDict):
|
|
13
|
+
runId: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RunOutcome(StartedRun):
|
|
17
|
+
status: str
|
|
18
|
+
results: NotRequired[Json]
|
|
19
|
+
timedOut: bool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WorkflowDispatcher(Protocol):
|
|
23
|
+
async def start(self, task: str, args: list[Json]) -> StartedRun: ...
|
|
24
|
+
async def run(self, task: str, args: list[Json], timeout_ms: float) -> RunOutcome: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WebhookRequest(TypedDict):
|
|
28
|
+
headers: dict[str, str]
|
|
29
|
+
rawBody: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WebhookContext(TypedDict):
|
|
33
|
+
headers: dict[str, str]
|
|
34
|
+
body: Json
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WebhookDispatch(TypedDict):
|
|
38
|
+
task: str
|
|
39
|
+
args: list[Json]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WebhookAdapter(Protocol):
|
|
43
|
+
def verify(self, request: WebhookRequest) -> bool: ...
|
|
44
|
+
def map(self, context: WebhookContext) -> WebhookDispatch | None: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Adapter:
|
|
49
|
+
"""A pair of pure signature and event mapping functions."""
|
|
50
|
+
|
|
51
|
+
verify: Callable[[WebhookRequest], bool]
|
|
52
|
+
map: Callable[[WebhookContext], WebhookDispatch | None]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CronResult(StartedRun):
|
|
56
|
+
status: str
|
|
57
|
+
results: NotRequired[Json]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from types import SimpleNamespace
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import pytest
|
|
6
|
+
from render_lab_triggers import create_dispatch_server, render_dispatcher, run_cron
|
|
7
|
+
from render_lab_triggers.types import Adapter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Dispatcher:
|
|
11
|
+
def __init__(self, *, timeout=False, failure=False):
|
|
12
|
+
self.calls = []
|
|
13
|
+
self.timeout = timeout
|
|
14
|
+
self.failure = failure
|
|
15
|
+
|
|
16
|
+
async def start(self, task, args):
|
|
17
|
+
self.calls.append((task, args))
|
|
18
|
+
if self.failure:
|
|
19
|
+
raise RuntimeError("vendor unavailable")
|
|
20
|
+
return {"runId": "run-1"}
|
|
21
|
+
|
|
22
|
+
async def run(self, task, args, timeout_ms):
|
|
23
|
+
self.calls.append((task, args, timeout_ms))
|
|
24
|
+
return {"runId": "run-1", "status": "succeeded", "results": [3], "timedOut": self.timeout}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.parametrize(
|
|
28
|
+
"body,args", [(b"", [{}]), (b'{"a":1}', [{"a": 1}]), (b"[1,2]", [1, 2]), (b"null", [None])]
|
|
29
|
+
)
|
|
30
|
+
async def test_dispatch_args_and_auth(body, args):
|
|
31
|
+
dispatcher = Dispatcher()
|
|
32
|
+
app = create_dispatch_server(workflow_slug="workflow", token="secret", dispatcher=dispatcher)
|
|
33
|
+
async with httpx.AsyncClient(
|
|
34
|
+
transport=httpx.ASGITransport(app), base_url="http://test"
|
|
35
|
+
) as client:
|
|
36
|
+
assert (await client.get("/healthz")).text == "ok"
|
|
37
|
+
assert (await client.post("/tasks/demo.run", content=body)).status_code == 401
|
|
38
|
+
response = await client.post(
|
|
39
|
+
"/tasks/demo.run", content=body, headers={"Authorization": "Bearer secret"}
|
|
40
|
+
)
|
|
41
|
+
assert response.status_code == 202
|
|
42
|
+
assert response.json() == {"runId": "run-1"}
|
|
43
|
+
assert dispatcher.calls == [("demo.run", args)]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.parametrize("timed_out,status", [(False, 200), (True, 202)])
|
|
47
|
+
async def test_bounded_wait_response(timed_out, status):
|
|
48
|
+
dispatcher = Dispatcher(timeout=timed_out)
|
|
49
|
+
app = create_dispatch_server(
|
|
50
|
+
workflow_slug="w", token="t", dispatcher=dispatcher, wait_timeout_ms=17
|
|
51
|
+
)
|
|
52
|
+
async with httpx.AsyncClient(
|
|
53
|
+
transport=httpx.ASGITransport(app), base_url="http://test"
|
|
54
|
+
) as client:
|
|
55
|
+
response = await client.post(
|
|
56
|
+
"/tasks/a?wait=true", json={}, headers={"Authorization": "Bearer t"}
|
|
57
|
+
)
|
|
58
|
+
assert response.status_code == status
|
|
59
|
+
assert response.json() == (
|
|
60
|
+
{"runId": "run-1", "status": "running"}
|
|
61
|
+
if timed_out
|
|
62
|
+
else {"runId": "run-1", "status": "succeeded", "results": [3]}
|
|
63
|
+
)
|
|
64
|
+
assert dispatcher.calls == [("a", [{}], 17)]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def test_stream_body_limit_precedes_signature_and_ignores_content_length():
|
|
68
|
+
verified = []
|
|
69
|
+
adapter = Adapter(lambda request: verified.append(request) or True, lambda context: None)
|
|
70
|
+
app = create_dispatch_server(workflow_slug="w", webhooks={"vendor": adapter}, max_body_bytes=4)
|
|
71
|
+
chunks = iter([b"123", b"45", b"must not be read"])
|
|
72
|
+
reads, sent = [], []
|
|
73
|
+
|
|
74
|
+
async def receive():
|
|
75
|
+
chunk = next(chunks)
|
|
76
|
+
reads.append(chunk)
|
|
77
|
+
return {"type": "http.request", "body": chunk, "more_body": True}
|
|
78
|
+
|
|
79
|
+
async def send(message):
|
|
80
|
+
sent.append(message)
|
|
81
|
+
|
|
82
|
+
await app(
|
|
83
|
+
{
|
|
84
|
+
"type": "http",
|
|
85
|
+
"method": "POST",
|
|
86
|
+
"path": "/webhooks/vendor",
|
|
87
|
+
"headers": [(b"content-length", b"1")],
|
|
88
|
+
},
|
|
89
|
+
receive,
|
|
90
|
+
send,
|
|
91
|
+
)
|
|
92
|
+
assert sent[0]["status"] == 413
|
|
93
|
+
assert reads == [b"123", b"45"]
|
|
94
|
+
assert not verified
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def test_signature_before_json_then_mapping_and_errors():
|
|
98
|
+
dispatcher = Dispatcher()
|
|
99
|
+
adapter = Adapter(
|
|
100
|
+
lambda request: request["headers"].get("x-signature") == "valid",
|
|
101
|
+
lambda context: (
|
|
102
|
+
{"task": "events.handle", "args": [context["body"]]} if context["body"] else None
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
app = create_dispatch_server(
|
|
106
|
+
workflow_slug="w", token="t", dispatcher=dispatcher, webhooks={"vendor": adapter}
|
|
107
|
+
)
|
|
108
|
+
async with httpx.AsyncClient(
|
|
109
|
+
transport=httpx.ASGITransport(app), base_url="http://test"
|
|
110
|
+
) as client:
|
|
111
|
+
assert (await client.post("/webhooks/vendor", content="bad")).status_code == 401
|
|
112
|
+
assert (
|
|
113
|
+
await client.post("/webhooks/vendor", content="bad", headers={"X-Signature": "valid"})
|
|
114
|
+
).status_code == 400
|
|
115
|
+
assert (
|
|
116
|
+
await client.post("/webhooks/vendor", json={}, headers={"X-Signature": "valid"})
|
|
117
|
+
).status_code == 204
|
|
118
|
+
assert (
|
|
119
|
+
await client.post(
|
|
120
|
+
"/webhooks/vendor", json={"event": 1}, headers={"X-Signature": "valid"}
|
|
121
|
+
)
|
|
122
|
+
).status_code == 202
|
|
123
|
+
dispatcher.failure = True
|
|
124
|
+
assert (
|
|
125
|
+
await client.post("/tasks/a", headers={"Authorization": "Bearer t"})
|
|
126
|
+
).status_code == 502
|
|
127
|
+
for body in ["NaN", "1e999"]:
|
|
128
|
+
assert (
|
|
129
|
+
await client.post("/tasks/a", content=body, headers={"Authorization": "Bearer t"})
|
|
130
|
+
).status_code == 400
|
|
131
|
+
assert dispatcher.calls[0] == ("events.handle", [{"event": 1}])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def test_cron_env_and_explicit_empty_args(monkeypatch):
|
|
135
|
+
monkeypatch.setenv("WORKFLOW_SLUG", "w")
|
|
136
|
+
monkeypatch.setenv("CRON_TASK", "cron.run")
|
|
137
|
+
monkeypatch.setenv("CRON_INPUT", '{"a":1}')
|
|
138
|
+
dispatcher = Dispatcher()
|
|
139
|
+
assert await run_cron(dispatcher=dispatcher) == {"runId": "run-1", "status": "running"}
|
|
140
|
+
assert dispatcher.calls[-1] == ("cron.run", [{"a": 1}])
|
|
141
|
+
result = await run_cron(dispatcher=dispatcher, args=[], wait=True, wait_timeout_ms=31)
|
|
142
|
+
assert result["results"] == [3]
|
|
143
|
+
assert dispatcher.calls[-1] == ("cron.run", [], 31)
|
|
144
|
+
with pytest.raises(ValueError, match="WORKFLOW_SLUG"):
|
|
145
|
+
await run_cron(workflow_slug="", dispatcher=dispatcher)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Service:
|
|
149
|
+
def __init__(self, mode):
|
|
150
|
+
self.mode = mode
|
|
151
|
+
self.closed = False
|
|
152
|
+
self.calls = []
|
|
153
|
+
self.entered = asyncio.Event()
|
|
154
|
+
|
|
155
|
+
async def start_task(self, slug, args):
|
|
156
|
+
self.calls.append((slug, args))
|
|
157
|
+
return SimpleNamespace(id="run-1")
|
|
158
|
+
|
|
159
|
+
async def task_run_events(self, ids):
|
|
160
|
+
assert ids == ["run-1"]
|
|
161
|
+
self.entered.set()
|
|
162
|
+
try:
|
|
163
|
+
if self.mode == "timeout":
|
|
164
|
+
await asyncio.Event().wait()
|
|
165
|
+
if self.mode == "network_timeout":
|
|
166
|
+
raise TimeoutError("upstream timeout")
|
|
167
|
+
if self.mode == "end":
|
|
168
|
+
return
|
|
169
|
+
yield SimpleNamespace(id="other", status="failed", results=[])
|
|
170
|
+
yield SimpleNamespace(id="run-1", status="running", results=[])
|
|
171
|
+
yield SimpleNamespace(id="run-1", status="succeeded", results=[{"ok": True}])
|
|
172
|
+
finally:
|
|
173
|
+
self.closed = True
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@pytest.mark.parametrize("mode", ["success", "timeout", "end", "network_timeout", "cancel"])
|
|
177
|
+
async def test_sdk_dispatch_wait_cleanup(mode):
|
|
178
|
+
service = Service(mode if mode != "cancel" else "timeout")
|
|
179
|
+
dispatcher = render_dispatcher(slug="workflow", client=SimpleNamespace(workflows=service))
|
|
180
|
+
if mode == "cancel":
|
|
181
|
+
task = asyncio.create_task(dispatcher.run("demo.run", [3], 10000))
|
|
182
|
+
await service.entered.wait()
|
|
183
|
+
task.cancel()
|
|
184
|
+
with pytest.raises(asyncio.CancelledError):
|
|
185
|
+
await task
|
|
186
|
+
elif mode in {"end", "network_timeout"}:
|
|
187
|
+
with pytest.raises((RuntimeError, TimeoutError)):
|
|
188
|
+
await dispatcher.run("demo.run", [3], 10000)
|
|
189
|
+
else:
|
|
190
|
+
result = await dispatcher.run("demo.run", [3], 1 if mode == "timeout" else 10000)
|
|
191
|
+
assert result["timedOut"] is (mode == "timeout")
|
|
192
|
+
if mode == "success":
|
|
193
|
+
assert result["results"] == [{"ok": True}]
|
|
194
|
+
assert service.calls == [("workflow/demo.run", [3])]
|
|
195
|
+
assert service.closed
|