Skip to content

Commit f1897fe

Browse files
committed
feat(iroh): integrate Python clients and workers
1 parent c95042b commit f1897fe

9 files changed

Lines changed: 532 additions & 24 deletions

File tree

‎docs/how-to/iroh.md‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Serve and call a worker over Iroh
2+
3+
VGI supports two authenticated Iroh endpoint forms:
4+
5+
- `iroh://<endpoint-id>` keeps stream state on one connection.
6+
- `httpi://<endpoint-id>[/prefix]` preserves VGI's stateless HTTP exchanges,
7+
continuation tokens, request/response budgets, authentication headers, and
8+
externalized batches.
9+
10+
Install the optional native client binding:
11+
12+
```console
13+
pip install 'vgi-python[iroh]'
14+
```
15+
16+
## Start a raw upstream
17+
18+
The language-neutral `vgi-iroh-bridge` owns the public Iroh endpoint. The
19+
Python process binds only loopback and requires the bridge's PROXY-v2 identity
20+
preamble:
21+
22+
```console
23+
vgi-serve my_worker.py \
24+
--iroh-raw-upstream 127.0.0.1:9400 \
25+
--iroh-issuer production
26+
```
27+
28+
In another process:
29+
30+
```console
31+
vgi-iroh-bridge \
32+
--secret-key-file /run/secrets/vgi-iroh-key \
33+
--raw-upstream tcp://127.0.0.1:9400
34+
```
35+
36+
The bridge prints its EndpointId as the first stdout line. Development may use
37+
`--ephemeral`; a production endpoint must use a persistent secret key.
38+
The raw Iroh listener also enables standard RPC method description so
39+
standalone clients in other VGI languages can bootstrap directly.
40+
41+
## Start an HTTP upstream
42+
43+
HTTP-over-Iroh is the preferred mode for horizontally scaled workers:
44+
45+
```console
46+
vgi-serve my_worker.py \
47+
--http --host 127.0.0.1 --port 9401 \
48+
--iroh-issuer production
49+
50+
vgi-iroh-bridge \
51+
--secret-key-file /run/secrets/vgi-iroh-key \
52+
--http-upstream http://127.0.0.1:9401
53+
```
54+
55+
`--iroh-issuer` makes the worker trust exactly `127.0.0.1` by default and
56+
promotes the bridge-verified EndpointId to `CallContext.auth`. Use repeated
57+
`--iroh-trusted-proxy <exact-ip>` when the immediate bridge address differs.
58+
Use `--iroh-observe` to expose evidence without authenticating from it.
59+
60+
Never expose the bridge-facing listener as a general public HTTP or TCP
61+
listener. If another proxy sits between the bridge and worker, isolate that
62+
route, clear client-supplied `VGI-Forwarded-Iroh-Endpoint` fields on every other
63+
route, and configure the worker to trust only the intermediary's exact address.
64+
65+
## Connect
66+
67+
The same high-level client accepts both endpoint schemes:
68+
69+
```python
70+
from vgi.client import Client
71+
72+
with Client.from_iroh("iroh://<endpoint-id>") as client:
73+
print(client.catalogs())
74+
75+
with Client.from_iroh("httpi://<endpoint-id>/vgi") as client:
76+
print(client.catalogs())
77+
```
78+
79+
For a private relay:
80+
81+
```python
82+
client = Client.from_iroh(
83+
"httpi://<endpoint-id>/vgi",
84+
relay_urls=["https://relay.example.com"],
85+
remote_relay_url="https://relay.example.com",
86+
)
87+
```
88+
89+
The local relay set and the remote relay hint are distinct. `no_relay=True`
90+
disables relays and requires a working direct path; it never falls back to the
91+
public relay network.

‎mkdocs.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ nav:
126126
- Persist state across workers: how-to/state-storage.md
127127
- Serve over HTTP with auth: how-to/http-auth.md
128128
- Connect through a Tailnet: how-to/tailnet.md
129+
- Serve and connect over Iroh: how-to/iroh.md
129130
- Integrate with the optimizer: how-to/pushdown-and-statistics.md
130131
- Function Metadata: metadata.md
131132
- CLI: cli.md

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ dependencies = [
5252
haybarn = ["haybarn>=1.5.5rc1"]
5353
duckdb = ["duckdb>=1.5.3"]
5454
http = ["vgi-rpc[http]"]
55+
iroh = ["vgi-rpc[iroh]"]
5556
# transport="launch": vgi_rpc.launcher imports filelock unconditionally at
5657
# module level, which lives behind vgi-rpc's own `cli` extra (alongside
5758
# typer, already a core dependency here, and httpx2, ditto) rather than in

‎tests/client/test_iroh_client.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""High-level VGI client coverage for native Iroh endpoint routing."""
2+
3+
from __future__ import annotations
4+
5+
from contextlib import contextmanager
6+
from typing import Any
7+
8+
import pytest
9+
10+
from vgi.client import Client
11+
12+
ENDPOINT_ID = "01" * 32
13+
14+
15+
def test_from_iroh_selects_raw_and_http_semantics() -> None:
16+
"""The URI scheme selects stateful or HTTP continuation behavior."""
17+
raw = Client.from_iroh(f"iroh://{ENDPOINT_ID}")
18+
http = Client.from_iroh(f"httpi://{ENDPOINT_ID}/vgi")
19+
20+
assert raw._transport == "iroh"
21+
assert not raw.supports_resumable_scan
22+
assert http._transport == "httpi"
23+
assert http.supports_resumable_scan
24+
assert http._base_url == f"http://{ENDPOINT_ID}"
25+
assert http._http_prefix == "/vgi"
26+
27+
28+
@pytest.mark.parametrize(
29+
"endpoint",
30+
[
31+
f"https://{ENDPOINT_ID}",
32+
f"iroh://{ENDPOINT_ID}/vgi",
33+
f"httpi://{ENDPOINT_ID}/../vgi",
34+
],
35+
)
36+
def test_from_iroh_rejects_noncanonical_endpoints(endpoint: str) -> None:
37+
"""Non-Iroh and non-canonical endpoint strings fail before any I/O."""
38+
with pytest.raises(ValueError):
39+
Client.from_iroh(endpoint)
40+
41+
42+
def test_raw_iroh_connection_forwards_native_options(monkeypatch: pytest.MonkeyPatch) -> None:
43+
"""High-level options reach the official native RPC connector unchanged."""
44+
captured: dict[str, Any] = {}
45+
proxy = object()
46+
47+
@contextmanager
48+
def fake_connect(protocol: Any, endpoint: str, **options: Any): # type: ignore[no-untyped-def]
49+
captured.update(protocol=protocol, endpoint=endpoint, **options)
50+
yield proxy
51+
52+
monkeypatch.setattr("vgi_rpc.iroh.iroh_connect", fake_connect)
53+
client = Client.from_iroh(
54+
f"iroh://{ENDPOINT_ID}",
55+
relay_urls=["https://relay.example"],
56+
remote_relay_url="https://relay.example",
57+
direct_addresses=["127.0.0.1:4433"],
58+
)
59+
60+
connection = client._spawn_worker(3)
61+
assert connection.proxy is proxy
62+
assert captured["endpoint"] == f"iroh://{ENDPOINT_ID}"
63+
assert captured["relay_urls"] == ("https://relay.example",)
64+
assert captured["remote_relay_url"] == "https://relay.example"
65+
assert captured["direct_addresses"] == ("127.0.0.1:4433",)
66+
client._stop_worker(connection)

‎tests/test_serve.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,17 @@ def test_returns_falcon_app(self) -> None:
253253
app = create_app(_SingleWorker)
254254
assert isinstance(app, falcon.App)
255255

256+
def test_accepts_trusted_iroh_bridge_identity(self) -> None:
257+
"""Iroh HTTP identity no longer requires a hand-built WSGI app."""
258+
import falcon
259+
260+
app = create_app(
261+
_SingleWorker,
262+
iroh_bridge_issuer="test-mesh",
263+
iroh_trusted_proxy_addresses=("127.0.0.1",),
264+
)
265+
assert isinstance(app, falcon.App)
266+
256267
def test_custom_prefix(self) -> None:
257268
"""Custom prefix is used in the app."""
258269
app = create_app(_SingleWorker, prefix="/api")

‎uv.lock‎

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

‎vgi/client/catalog_mixin.py‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,9 @@ class CatalogClientMixin:
114114

115115
# Type hints for attributes expected from Client
116116
server_path: str | Sequence[str]
117-
_transport: Literal["subprocess", "http", "tcp", "launch"]
117+
_transport: Literal["subprocess", "http", "tcp", "iroh", "httpi", "launch"]
118+
_iroh_endpoint: str | None
119+
_iroh_options: dict[str, Any]
118120
_base_url: str | None
119121
_tcp_host: str | None
120122
_tcp_port: int | None
@@ -165,16 +167,38 @@ def _catalog_connect(self) -> Iterator[VgiProtocol]:
165167
"""
166168
try:
167169
transport = getattr(self, "_transport", "subprocess")
168-
if transport == "http":
170+
if transport in ("http", "httpi"):
169171
from vgi_rpc.http import http_connect
170172

171173
httpx_client = self._get_or_create_httpx_client()
172-
with http_connect(
174+
if transport == "httpi":
175+
connection = http_connect(
176+
VgiProtocol, # type: ignore[type-abstract]
177+
base_url=self._base_url,
178+
client=httpx_client,
179+
prefix=getattr(self, "_http_prefix", ""),
180+
external_location=getattr(self, "_external_location", None),
181+
accepted_max_response_bytes=self._accepted_max_response_bytes,
182+
)
183+
else:
184+
connection = http_connect(
185+
VgiProtocol, # type: ignore[type-abstract]
186+
base_url=self._base_url,
187+
client=httpx_client,
188+
external_location=getattr(self, "_external_location", None),
189+
accepted_max_response_bytes=self._accepted_max_response_bytes,
190+
)
191+
with connection as proxy:
192+
yield proxy
193+
elif transport == "iroh":
194+
from vgi_rpc.iroh import iroh_connect
195+
196+
assert self._iroh_endpoint is not None
197+
with iroh_connect(
173198
VgiProtocol, # type: ignore[type-abstract]
174-
base_url=self._base_url,
175-
client=httpx_client,
199+
self._iroh_endpoint,
176200
external_location=getattr(self, "_external_location", None),
177-
accepted_max_response_bytes=self._accepted_max_response_bytes,
201+
**self._iroh_options,
178202
) as proxy:
179203
yield proxy
180204
elif transport == "tcp":

0 commit comments

Comments
 (0)