Skip to content

Commit 97bcd70

Browse files
authored
feat: example for using tunnels (#754)
1 parent e31e345 commit 97bcd70

5 files changed

Lines changed: 162 additions & 7 deletions

File tree

EXAMPLES.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Runnable examples live in [`examples/`](./examples).
1010
- [Blueprint with Build Context](#blueprint-with-build-context)
1111
- [Devbox From Blueprint (Run Command, Shutdown)](#devbox-from-blueprint-lifecycle)
1212
- [Devbox Snapshot and Resume](#devbox-snapshot-resume)
13+
- [Devbox Tunnel (HTTP Server Access)](#devbox-tunnel)
1314
- [MCP Hub + Claude Code + GitHub](#mcp-github-tools)
1415
- [Secrets with Devbox (Create, Inject, Verify, Delete)](#secrets-with-devbox)
1516

@@ -105,6 +106,35 @@ uv run pytest -m smoketest tests/smoketests/examples/
105106

106107
**Source:** [`examples/devbox_snapshot_resume.py`](./examples/devbox_snapshot_resume.py)
107108

109+
<a id="devbox-tunnel"></a>
110+
## Devbox Tunnel (HTTP Server Access)
111+
112+
**Use case:** Create a devbox with a tunnel, start an HTTP server, and access the server from the local machine through the tunnel. Uses the async SDK.
113+
114+
**Tags:** `devbox`, `tunnel`, `networking`, `http`, `async`
115+
116+
### Workflow
117+
- Create a devbox with tunnel configuration
118+
- Start an HTTP server inside the devbox
119+
- Make an HTTP request from the local machine through the tunnel
120+
- Validate the response
121+
- Shutdown the devbox
122+
123+
### Prerequisites
124+
- `RUNLOOP_API_KEY`
125+
126+
### Run
127+
```sh
128+
uv run python -m examples.devbox_tunnel
129+
```
130+
131+
### Test
132+
```sh
133+
uv run pytest -m smoketest tests/smoketests/examples/
134+
```
135+
136+
**Source:** [`examples/devbox_tunnel.py`](./examples/devbox_tunnel.py)
137+
108138
<a id="mcp-github-tools"></a>
109139
## MCP Hub + Claude Code + GitHub
110140

examples/devbox_tunnel.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env -S uv run python
2+
"""
3+
---
4+
title: Devbox Tunnel (HTTP Server Access)
5+
slug: devbox-tunnel
6+
use_case: Create a devbox with a tunnel, start an HTTP server, and access the server from the local machine through the tunnel. Uses the async SDK.
7+
workflow:
8+
- Create a devbox with tunnel configuration
9+
- Start an HTTP server inside the devbox
10+
- Make an HTTP request from the local machine through the tunnel
11+
- Validate the response
12+
- Shutdown the devbox
13+
tags:
14+
- devbox
15+
- tunnel
16+
- networking
17+
- http
18+
- async
19+
prerequisites:
20+
- RUNLOOP_API_KEY
21+
run: uv run python -m examples.devbox_tunnel
22+
test: uv run pytest -m smoketest tests/smoketests/examples/
23+
---
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import asyncio
29+
30+
import httpx
31+
32+
from runloop_api_client import AsyncRunloopSDK
33+
34+
from ._harness import run_as_cli, wrap_recipe
35+
from .example_types import ExampleCheck, RecipeOutput, RecipeContext
36+
37+
HTTP_SERVER_PORT = 8080
38+
SERVER_STARTUP_DELAY_S = 2
39+
40+
41+
async def recipe(ctx: RecipeContext) -> RecipeOutput:
42+
"""Create a devbox with a tunnel, start an HTTP server, and access it from the local machine."""
43+
cleanup = ctx.cleanup
44+
45+
sdk = AsyncRunloopSDK()
46+
47+
devbox = await sdk.devbox.create(
48+
name="devbox-tunnel-example",
49+
launch_parameters={
50+
"resource_size_request": "X_SMALL",
51+
},
52+
tunnel={"auth_mode": "open"},
53+
)
54+
cleanup.add(f"devbox:{devbox.id}", devbox.shutdown)
55+
56+
# Start a simple HTTP server inside the devbox using Python's built-in http.server
57+
# We use exec_async because the server runs indefinitely until stopped
58+
server_execution = await devbox.cmd.exec_async(f"python3 -m http.server {HTTP_SERVER_PORT} --directory /tmp")
59+
60+
# Give the server a moment to start
61+
await asyncio.sleep(SERVER_STARTUP_DELAY_S)
62+
63+
# The tunnel was created with the devbox. For authenticated tunnels, set
64+
# tunnel={"auth_mode": "authenticated"} on create and include the auth_token
65+
# in your requests via the Authorization header: `Authorization: Bearer {tunnel.auth_token}`
66+
tunnel = await devbox.get_tunnel()
67+
if tunnel is None:
68+
raise RuntimeError("Failed to create tunnel at devbox launch time")
69+
70+
# Get the tunnel URL for the server port
71+
tunnel_url = await devbox.get_tunnel_url(HTTP_SERVER_PORT)
72+
if tunnel_url is None:
73+
raise RuntimeError("Failed to get tunnel URL after creating tunnel")
74+
75+
# Make an HTTP request from the LOCAL MACHINE through the tunnel to the devbox
76+
# This demonstrates that the tunnel allows external access to the devbox service
77+
async with httpx.AsyncClient() as client:
78+
response = await client.get(tunnel_url)
79+
response_text = response.text
80+
81+
# Stop the HTTP server
82+
await server_execution.kill()
83+
84+
return RecipeOutput(
85+
resources_created=[f"devbox:{devbox.id}"],
86+
checks=[
87+
ExampleCheck(
88+
name="tunnel was created successfully",
89+
passed=bool(tunnel.tunnel_key),
90+
details=f"tunnel_key={tunnel.tunnel_key}",
91+
),
92+
ExampleCheck(
93+
name="tunnel URL was constructed correctly",
94+
passed=bool(
95+
tunnel.tunnel_key and tunnel.tunnel_key in tunnel_url and str(HTTP_SERVER_PORT) in tunnel_url
96+
),
97+
details=tunnel_url,
98+
),
99+
ExampleCheck(
100+
name="HTTP request through tunnel succeeded",
101+
passed=response.is_success,
102+
details=f"status={response.status_code}",
103+
),
104+
ExampleCheck(
105+
name="response contains directory listing",
106+
passed="Directory listing" in response_text,
107+
details=response_text[:200],
108+
),
109+
],
110+
)
111+
112+
113+
run_devbox_tunnel_example = wrap_recipe(recipe)
114+
115+
116+
if __name__ == "__main__":
117+
run_as_cli(run_devbox_tunnel_example)

examples/registry.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from typing import Any, Callable, cast
99

10+
from .devbox_tunnel import run_devbox_tunnel_example
1011
from .example_types import ExampleResult
1112
from .mcp_github_tools import run_mcp_github_tools_example
1213
from .secrets_with_devbox import run_secrets_with_devbox_example
@@ -38,6 +39,13 @@
3839
"required_env": ["RUNLOOP_API_KEY"],
3940
"run": run_devbox_snapshot_resume_example,
4041
},
42+
{
43+
"slug": "devbox-tunnel",
44+
"title": "Devbox Tunnel (HTTP Server Access)",
45+
"file_name": "devbox_tunnel.py",
46+
"required_env": ["RUNLOOP_API_KEY"],
47+
"run": run_devbox_tunnel_example,
48+
},
4149
{
4250
"slug": "mcp-github-tools",
4351
"title": "MCP Hub + Claude Code + GitHub",

scripts/generate_examples_md.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,13 @@ def generate_markdown(examples: list[dict[str, Any]]) -> str:
150150

151151
def generate_registry(examples: list[dict[str, Any]]) -> str:
152152
"""Generate the registry.py content."""
153-
imports: list[str] = []
153+
imports: list[tuple[str, str]] = [("example_types", "ExampleResult")]
154154
for example in examples:
155155
module = example["file_name"].replace(".py", "")
156156
runner = f"run_{module}_example"
157-
imports.append(f"from .{module} import {runner}")
158-
imports.sort(key=len)
157+
imports.append((module, runner))
158+
imports.sort(key=lambda x: (len(x[0]), x[0]))
159+
import_lines = [f"from .{mod} import {name}" for mod, name in imports]
159160

160161
entries: list[str] = []
161162
for example in examples:
@@ -181,8 +182,7 @@ def generate_registry(examples: list[dict[str, Any]]) -> str:
181182
182183
from typing import Any, Callable, cast
183184
184-
from .example_types import ExampleResult
185-
{chr(10).join(imports)}
185+
{chr(10).join(import_lines)}
186186
187187
ExampleRegistryEntry = dict[str, Any]
188188

uv.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)