diff --git a/EXAMPLES.md b/EXAMPLES.md index 0d3ff62cc..92927089b 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -9,6 +9,7 @@ Runnable examples live in [`examples/`](./examples). - [Blueprint with Build Context](#blueprint-with-build-context) - [Devbox From Blueprint (Run Command, Shutdown)](#devbox-from-blueprint-lifecycle) +- [Devbox Tunnel (HTTP Server Access)](#devbox-tunnel) - [MCP Hub + Claude Code + GitHub](#mcp-github-tools) @@ -70,6 +71,36 @@ uv run pytest -m smoketest tests/smoketests/examples/ **Source:** [`examples/devbox_from_blueprint_lifecycle.py`](./examples/devbox_from_blueprint_lifecycle.py) + +## Devbox Tunnel (HTTP Server Access) + +**Use case:** Create a devbox, start an HTTP server, enable a tunnel, and access the server from the local machine through the tunnel. Uses the async SDK. + +**Tags:** `devbox`, `tunnel`, `networking`, `http`, `async` + +### Workflow +- Create a devbox +- Start an HTTP server inside the devbox +- Enable a tunnel for external access +- Make an HTTP request from the local machine through the tunnel +- Validate the response +- Shutdown the devbox + +### Prerequisites +- `RUNLOOP_API_KEY` + +### Run +```sh +uv run python -m examples.devbox_tunnel +``` + +### Test +```sh +uv run pytest -m smoketest tests/smoketests/examples/ +``` + +**Source:** [`examples/devbox_tunnel.py`](./examples/devbox_tunnel.py) + ## MCP Hub + Claude Code + GitHub diff --git a/examples/devbox_tunnel.py b/examples/devbox_tunnel.py new file mode 100644 index 000000000..479fa9957 --- /dev/null +++ b/examples/devbox_tunnel.py @@ -0,0 +1,115 @@ +#!/usr/bin/env -S uv run python +""" +--- +title: Devbox Tunnel (HTTP Server Access) +slug: devbox-tunnel +use_case: Create a devbox, start an HTTP server, enable a tunnel, and access the server from the local machine through the tunnel. Uses the async SDK. +workflow: + - Create a devbox + - Start an HTTP server inside the devbox + - Enable a tunnel for external access + - Make an HTTP request from the local machine through the tunnel + - Validate the response + - Shutdown the devbox +tags: + - devbox + - tunnel + - networking + - http + - async +prerequisites: + - RUNLOOP_API_KEY +run: uv run python -m examples.devbox_tunnel +test: uv run pytest -m smoketest tests/smoketests/examples/ +--- +""" + +from __future__ import annotations + +import asyncio + +import httpx + +from runloop_api_client import AsyncRunloopSDK + +from ._harness import run_as_cli, wrap_recipe +from .example_types import ExampleCheck, RecipeOutput, RecipeContext + +HTTP_SERVER_PORT = 8080 +SERVER_STARTUP_DELAY_S = 2 + + +async def recipe(ctx: RecipeContext) -> RecipeOutput: + """Create a devbox, start an HTTP server, enable a tunnel, and access it from the local machine.""" + cleanup = ctx.cleanup + + sdk = AsyncRunloopSDK() + + devbox = await sdk.devbox.create( + name="devbox-tunnel-example", + launch_parameters={ + "resource_size_request": "X_SMALL", + }, + ) + cleanup.add(f"devbox:{devbox.id}", devbox.shutdown) + + # Start a simple HTTP server inside the devbox using Python's built-in http.server + # We use exec_async because the server runs indefinitely until stopped + server_execution = await devbox.cmd.exec_async(f"python3 -m http.server {HTTP_SERVER_PORT} --directory /tmp") + + # Give the server a moment to start + await asyncio.sleep(SERVER_STARTUP_DELAY_S) + + # Enable a tunnel to expose the HTTP server + # For authenticated tunnels, use auth_mode="authenticated" and include the auth_token + # in your requests via the Authorization header: `Authorization: Bearer {tunnel.auth_token}` + tunnel = await devbox.net.enable_tunnel(auth_mode="open") + + # Get the tunnel URL for the server port + tunnel_url = await devbox.get_tunnel_url(HTTP_SERVER_PORT) + if tunnel_url is None: + raise RuntimeError("Failed to get tunnel URL after enabling tunnel") + + # Make an HTTP request from the LOCAL MACHINE through the tunnel to the devbox + # This demonstrates that the tunnel allows external access to the devbox service + async with httpx.AsyncClient() as client: + response = await client.get(tunnel_url) + response_text = response.text + + # Stop the HTTP server + await server_execution.kill() + + return RecipeOutput( + resources_created=[f"devbox:{devbox.id}"], + checks=[ + ExampleCheck( + name="tunnel was created successfully", + passed=bool(tunnel.tunnel_key), + details=f"tunnel_key={tunnel.tunnel_key}", + ), + ExampleCheck( + name="tunnel URL was constructed correctly", + passed=bool( + tunnel.tunnel_key and tunnel.tunnel_key in tunnel_url and str(HTTP_SERVER_PORT) in tunnel_url + ), + details=tunnel_url, + ), + ExampleCheck( + name="HTTP request through tunnel succeeded", + passed=response.is_success, + details=f"status={response.status_code}", + ), + ExampleCheck( + name="response contains directory listing", + passed="Directory listing" in response_text, + details=response_text[:200], + ), + ], + ) + + +run_devbox_tunnel_example = wrap_recipe(recipe) + + +if __name__ == "__main__": + run_as_cli(run_devbox_tunnel_example) diff --git a/examples/registry.py b/examples/registry.py index cb6b780a9..c2b7e7e48 100644 --- a/examples/registry.py +++ b/examples/registry.py @@ -7,6 +7,7 @@ from typing import Any, Callable, cast +from .devbox_tunnel import run_devbox_tunnel_example from .example_types import ExampleResult from .mcp_github_tools import run_mcp_github_tools_example from .blueprint_with_build_context import run_blueprint_with_build_context_example @@ -29,6 +30,13 @@ "required_env": ["RUNLOOP_API_KEY"], "run": run_devbox_from_blueprint_lifecycle_example, }, + { + "slug": "devbox-tunnel", + "title": "Devbox Tunnel (HTTP Server Access)", + "file_name": "devbox_tunnel.py", + "required_env": ["RUNLOOP_API_KEY"], + "run": run_devbox_tunnel_example, + }, { "slug": "mcp-github-tools", "title": "MCP Hub + Claude Code + GitHub", diff --git a/scripts/generate_examples_md.py b/scripts/generate_examples_md.py index bf4dc587a..9e47e9cae 100644 --- a/scripts/generate_examples_md.py +++ b/scripts/generate_examples_md.py @@ -150,12 +150,13 @@ def generate_markdown(examples: list[dict[str, Any]]) -> str: def generate_registry(examples: list[dict[str, Any]]) -> str: """Generate the registry.py content.""" - imports: list[str] = [] + imports: list[tuple[str, str]] = [("example_types", "ExampleResult")] for example in examples: module = example["file_name"].replace(".py", "") runner = f"run_{module}_example" - imports.append(f"from .{module} import {runner}") - imports.sort(key=len) + imports.append((module, runner)) + imports.sort(key=lambda x: (len(x[0]), x[0])) + import_lines = [f"from .{mod} import {name}" for mod, name in imports] entries: list[str] = [] for example in examples: @@ -181,8 +182,7 @@ def generate_registry(examples: list[dict[str, Any]]) -> str: from typing import Any, Callable, cast -from .example_types import ExampleResult -{chr(10).join(imports)} +{chr(10).join(import_lines)} ExampleRegistryEntry = dict[str, Any]