manifold-cli 0.0.39__py3-none-any.whl
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.
- manifold_cli/__init__.py +3 -0
- manifold_cli/__main__.py +4 -0
- manifold_cli/bridge.py +70 -0
- manifold_cli/cli.py +54 -0
- manifold_cli/clients/__init__.py +0 -0
- manifold_cli/clients/auth_flow.py +169 -0
- manifold_cli/clients/credentials.py +53 -0
- manifold_cli/clients/docker_engine.py +43 -0
- manifold_cli/clients/image.py +130 -0
- manifold_cli/clients/manifold.py +1288 -0
- manifold_cli/clients/runner_home.py +165 -0
- manifold_cli/clients/workos.py +99 -0
- manifold_cli/commands/__init__.py +0 -0
- manifold_cli/commands/auth.py +101 -0
- manifold_cli/commands/benchmark.py +539 -0
- manifold_cli/commands/organization.py +154 -0
- manifold_cli/commands/policy.py +749 -0
- manifold_cli/commands/run.py +337 -0
- manifold_cli/commands/run_log.py +291 -0
- manifold_cli/commands/runner.py +852 -0
- manifold_cli/commands/table.py +101 -0
- manifold_cli/commands/token.py +144 -0
- manifold_cli/dependencies.py +116 -0
- manifold_cli/models/__init__.py +0 -0
- manifold_cli/models/auth.py +49 -0
- manifold_cli/models/benchmark.py +87 -0
- manifold_cli/models/catalog.py +189 -0
- manifold_cli/models/credentials.py +19 -0
- manifold_cli/models/errors.py +121 -0
- manifold_cli/models/log.py +114 -0
- manifold_cli/models/observability.py +121 -0
- manifold_cli/models/organization.py +19 -0
- manifold_cli/models/pagination.py +59 -0
- manifold_cli/models/policy.py +49 -0
- manifold_cli/models/run.py +381 -0
- manifold_cli/models/runner.py +186 -0
- manifold_cli/models/runner_home.py +69 -0
- manifold_cli/models/task_queue.py +40 -0
- manifold_cli/models/token.py +39 -0
- manifold_cli/replay/__init__.py +68 -0
- manifold_cli/replay/log.py +814 -0
- manifold_cli/runner/__init__.py +72 -0
- manifold_cli/runner/api_retry.py +36 -0
- manifold_cli/runner/benchmark/__init__.py +800 -0
- manifold_cli/runner/benchmark/live_view.py +248 -0
- manifold_cli/runner/benchmark/preassigned_scheduling.py +325 -0
- manifold_cli/runner/benchmark/recording.py +1208 -0
- manifold_cli/runner/benchmark/scheduling.py +256 -0
- manifold_cli/runner/benchmark/task_queue_scheduling.py +717 -0
- manifold_cli/runner/benchmark/task_server.py +123 -0
- manifold_cli/runner/benchmark/video.py +124 -0
- manifold_cli/runner/benchmark_steps.py +178 -0
- manifold_cli/runner/cancellation.py +22 -0
- manifold_cli/runner/capture.py +282 -0
- manifold_cli/runner/container.py +370 -0
- manifold_cli/runner/current_dispatch.py +25 -0
- manifold_cli/runner/daemon.py +520 -0
- manifold_cli/runner/dispatch.py +38 -0
- manifold_cli/runner/gpu.py +182 -0
- manifold_cli/runner/heartbeat.py +202 -0
- manifold_cli/runner/loopback.py +102 -0
- manifold_cli/runner/loopback_bench.py +183 -0
- manifold_cli/runner/loopback_policy.py +39 -0
- manifold_cli/runner/output.py +218 -0
- manifold_cli/runner/pairing_acceptance.py +57 -0
- manifold_cli/runner/plan.py +369 -0
- manifold_cli/runner/policy.py +350 -0
- manifold_cli/runner/policy_steps.py +164 -0
- manifold_cli/runner/preparation.py +47 -0
- manifold_cli/runner/process.py +219 -0
- manifold_cli/runner/ram.py +17 -0
- manifold_cli/runner/resource_usage.py +265 -0
- manifold_cli/runner/served.py +218 -0
- manifold_cli/runner/service.py +120 -0
- manifold_cli/runner/side_failure.py +68 -0
- manifold_cli/runner/signature.py +98 -0
- manifold_cli/runner/source.py +66 -0
- manifold_cli/runner/stages.py +241 -0
- manifold_cli/runner/step_timing.py +229 -0
- manifold_cli/runner/worker.py +534 -0
- manifold_cli/runner/workload.py +401 -0
- manifold_cli/services/__init__.py +0 -0
- manifold_cli/services/auth.py +89 -0
- manifold_cli/services/benchmark.py +357 -0
- manifold_cli/services/organization.py +22 -0
- manifold_cli/services/policy.py +234 -0
- manifold_cli/services/run.py +304 -0
- manifold_cli/services/runner.py +207 -0
- manifold_cli/services/runner_daemon.py +146 -0
- manifold_cli/services/token.py +52 -0
- manifold_cli/settings.py +30 -0
- manifold_cli/transport/__init__.py +31 -0
- manifold_cli/transport/diagnostics.py +72 -0
- manifold_cli/transport/interfaces.py +69 -0
- manifold_cli/transport/iroh.py +317 -0
- manifold_cli/transport/registry.py +34 -0
- manifold_cli/transport/tcp.py +146 -0
- manifold_cli-0.0.39.dist-info/METADATA +406 -0
- manifold_cli-0.0.39.dist-info/RECORD +102 -0
- manifold_cli-0.0.39.dist-info/WHEEL +4 -0
- manifold_cli-0.0.39.dist-info/entry_points.txt +2 -0
- manifold_cli-0.0.39.dist-info/licenses/LICENSE +373 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Access-token use cases - create, list, and revoke credentials."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from manifold_cli.models.token import AccessToken, CreatedAccessToken, Scope
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TokenApiClient(Protocol):
|
|
11
|
+
"""The access-token endpoints TokenService drives on the API client."""
|
|
12
|
+
|
|
13
|
+
def create_access_token(
|
|
14
|
+
self,
|
|
15
|
+
name: str,
|
|
16
|
+
scopes: list[Scope],
|
|
17
|
+
*,
|
|
18
|
+
expires_at: datetime | None,
|
|
19
|
+
) -> CreatedAccessToken: ...
|
|
20
|
+
|
|
21
|
+
def list_access_tokens(self) -> list[AccessToken]: ...
|
|
22
|
+
|
|
23
|
+
def revoke_access_token(self, token_id: UUID) -> AccessToken:
|
|
24
|
+
"""Raise AccessTokenNotFoundError when the token is not the caller's."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TokenService:
|
|
29
|
+
def __init__(self, manifold_client: TokenApiClient):
|
|
30
|
+
self._manifold_client = manifold_client
|
|
31
|
+
|
|
32
|
+
def create(
|
|
33
|
+
self,
|
|
34
|
+
name: str,
|
|
35
|
+
scopes: list[Scope],
|
|
36
|
+
*,
|
|
37
|
+
expires_at: datetime | None,
|
|
38
|
+
) -> CreatedAccessToken:
|
|
39
|
+
"""Create an access token and return the only copy of its plaintext."""
|
|
40
|
+
return self._manifold_client.create_access_token(
|
|
41
|
+
name, scopes, expires_at=expires_at
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# NOTE: this shadows the builtin `list` for the rest of the class body, so a
|
|
45
|
+
# method defined below cannot annotate anything as `list[...]`.
|
|
46
|
+
def list(self) -> list[AccessToken]:
|
|
47
|
+
"""The caller's tokens, oldest first, revoked included."""
|
|
48
|
+
return self._manifold_client.list_access_tokens()
|
|
49
|
+
|
|
50
|
+
def revoke(self, token_id: UUID) -> AccessToken:
|
|
51
|
+
"""Revoke an access token and return its stored metadata."""
|
|
52
|
+
return self._manifold_client.revoke_access_token(token_id)
|
manifold_cli/settings.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _default_config_dir() -> Path:
|
|
6
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
7
|
+
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
8
|
+
return base / "manifold"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Settings:
|
|
12
|
+
MANIFOLD_TOKEN: str | None = os.environ.get("MANIFOLD_TOKEN")
|
|
13
|
+
MANIFOLD_API_URL: str = os.environ.get(
|
|
14
|
+
"MANIFOLD_API_URL", "https://api.manifold.bifrost.ai"
|
|
15
|
+
)
|
|
16
|
+
MANIFOLD_WEBAPP_URL: str = os.environ.get(
|
|
17
|
+
"MANIFOLD_WEBAPP_URL",
|
|
18
|
+
MANIFOLD_API_URL.replace("://api.", "://app.", 1),
|
|
19
|
+
)
|
|
20
|
+
MANIFOLD_CONFIG_DIR: Path = Path(
|
|
21
|
+
os.environ.get("MANIFOLD_CONFIG_DIR", str(_default_config_dir()))
|
|
22
|
+
)
|
|
23
|
+
CREDENTIALS_PATH: Path = MANIFOLD_CONFIG_DIR / "credentials.json"
|
|
24
|
+
# The root the runner homes sit under. Overridable on its own, so a box
|
|
25
|
+
# under systemd puts them somewhere a service account reaches
|
|
26
|
+
# (/etc/manifold/runners) while the human's credential stays in the
|
|
27
|
+
# config dir.
|
|
28
|
+
MANIFOLD_RUNNERS_DIR: Path = Path(
|
|
29
|
+
os.environ.get("MANIFOLD_RUNNERS_DIR", str(MANIFOLD_CONFIG_DIR / "runners"))
|
|
30
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Transport package: interfaces, adapters, and the registry."""
|
|
2
|
+
|
|
3
|
+
from manifold_cli.transport.interfaces import (
|
|
4
|
+
ByteStream,
|
|
5
|
+
Connector,
|
|
6
|
+
Listener,
|
|
7
|
+
StreamClosedError,
|
|
8
|
+
TransportError,
|
|
9
|
+
)
|
|
10
|
+
from manifold_cli.transport.iroh import IrohConnector, IrohListener, IrohTransport
|
|
11
|
+
from manifold_cli.transport.registry import Transport, TransportRegistry
|
|
12
|
+
from manifold_cli.transport.tcp import TcpConnector, TcpListener, TcpTransport
|
|
13
|
+
|
|
14
|
+
DEFAULT_TRANSPORTS = TransportRegistry([TcpTransport(), IrohTransport()])
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DEFAULT_TRANSPORTS",
|
|
18
|
+
"ByteStream",
|
|
19
|
+
"Connector",
|
|
20
|
+
"IrohConnector",
|
|
21
|
+
"IrohListener",
|
|
22
|
+
"IrohTransport",
|
|
23
|
+
"Listener",
|
|
24
|
+
"StreamClosedError",
|
|
25
|
+
"TcpConnector",
|
|
26
|
+
"TcpListener",
|
|
27
|
+
"TcpTransport",
|
|
28
|
+
"Transport",
|
|
29
|
+
"TransportError",
|
|
30
|
+
"TransportRegistry",
|
|
31
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Transport diagnostics: a built-in echo policy and a ping round-trip.
|
|
2
|
+
|
|
3
|
+
These smoke-test a transport end to end -- `manifold policy serve --echo`
|
|
4
|
+
serves the echo side and `manifold benchmark run --ping` dials it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from manifold_cli.transport.interfaces import ByteStream, Connector, Listener
|
|
13
|
+
|
|
14
|
+
ECHO_CHUNK_SIZE = 64 * 1024
|
|
15
|
+
|
|
16
|
+
OnEvent = Callable[[str], None]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def echo_once(stream: ByteStream, client: str, on_event: OnEvent) -> None:
|
|
20
|
+
"""Echo one client's bytes back, logging each chunk it receives."""
|
|
21
|
+
total = 0
|
|
22
|
+
try:
|
|
23
|
+
while True:
|
|
24
|
+
chunk = await stream.read(ECHO_CHUNK_SIZE)
|
|
25
|
+
if not chunk:
|
|
26
|
+
break
|
|
27
|
+
total += len(chunk)
|
|
28
|
+
on_event(f"{client} received and echoed {len(chunk)} bytes")
|
|
29
|
+
await stream.write(chunk)
|
|
30
|
+
finally:
|
|
31
|
+
await stream.close()
|
|
32
|
+
on_event(f"{client} disconnected ({total} bytes total)")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def serve_echo(listener: Listener, on_event: OnEvent) -> None:
|
|
36
|
+
tasks: set[asyncio.Task[None]] = set()
|
|
37
|
+
client_count = 0
|
|
38
|
+
try:
|
|
39
|
+
while True:
|
|
40
|
+
stream = await listener.accept()
|
|
41
|
+
client_count += 1
|
|
42
|
+
client = f"client {client_count}"
|
|
43
|
+
on_event(f"{client} connected")
|
|
44
|
+
task = asyncio.create_task(echo_once(stream, client, on_event))
|
|
45
|
+
tasks.add(task)
|
|
46
|
+
task.add_done_callback(tasks.discard)
|
|
47
|
+
finally:
|
|
48
|
+
for task in tasks:
|
|
49
|
+
task.cancel()
|
|
50
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
51
|
+
await listener.close()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class PingResult:
|
|
56
|
+
matched: bool
|
|
57
|
+
size: int
|
|
58
|
+
elapsed_ms: float
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def ping(connector: Connector, payload: bytes) -> PingResult:
|
|
62
|
+
stream = await connector.connect()
|
|
63
|
+
start = time.perf_counter()
|
|
64
|
+
try:
|
|
65
|
+
await stream.write(payload)
|
|
66
|
+
echoed = await stream.read_exactly(len(payload))
|
|
67
|
+
finally:
|
|
68
|
+
await stream.close()
|
|
69
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
70
|
+
return PingResult(
|
|
71
|
+
matched=echoed == payload, size=len(payload), elapsed_ms=elapsed_ms
|
|
72
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Byte-only transport interfaces: ByteStream, Connector, Listener."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TransportError(Exception):
|
|
7
|
+
"""Transport-level error; adapters translate infrastructure failures into this."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StreamClosedError(TransportError):
|
|
11
|
+
"""Raised by `read_exactly` when EOF arrives before `n` bytes.
|
|
12
|
+
|
|
13
|
+
`partial` holds the bytes read before EOF.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, partial: bytes, expected: int) -> None:
|
|
17
|
+
self.partial = partial
|
|
18
|
+
self.expected = expected
|
|
19
|
+
super().__init__(
|
|
20
|
+
f"stream closed after {len(partial)} of {expected} expected bytes"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class ByteStream(Protocol):
|
|
26
|
+
"""A duplex byte pipe."""
|
|
27
|
+
|
|
28
|
+
async def read(self, n: int) -> bytes:
|
|
29
|
+
"""Read up to `n` bytes; returns `b""` at EOF."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
async def read_exactly(self, n: int) -> bytes:
|
|
33
|
+
"""Read exactly `n` bytes; raises `StreamClosedError` on EOF."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
async def write(self, data: bytes) -> None:
|
|
37
|
+
"""Write all of `data` and flush."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
async def close(self) -> None:
|
|
41
|
+
"""Close the stream. Idempotent."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@runtime_checkable
|
|
46
|
+
class Connector(Protocol):
|
|
47
|
+
"""The dialing side of a transport."""
|
|
48
|
+
|
|
49
|
+
async def connect(self) -> ByteStream:
|
|
50
|
+
"""Open a new `ByteStream` to the configured peer."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@runtime_checkable
|
|
55
|
+
class Listener(Protocol):
|
|
56
|
+
"""The serving side of a transport."""
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def target(self) -> str:
|
|
60
|
+
"""Dialable target string (e.g. `tcp:HOST:PORT` or `iroh:ID`)."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
async def accept(self) -> ByteStream:
|
|
64
|
+
"""Return the next inbound stream; raises `StreamClosedError` if closed."""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
async def close(self) -> None:
|
|
68
|
+
"""Stop accepting and release the bound address. Idempotent."""
|
|
69
|
+
...
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Iroh (QUIC/NAT-traversal) transport adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import ctypes
|
|
8
|
+
import logging
|
|
9
|
+
import struct
|
|
10
|
+
import weakref
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from importlib.metadata import version
|
|
13
|
+
|
|
14
|
+
import iroh
|
|
15
|
+
import iroh.iroh_ffi
|
|
16
|
+
|
|
17
|
+
from manifold_cli.transport.interfaces import StreamClosedError, TransportError
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
ALPN = b"manifold/transport/0"
|
|
22
|
+
# Nebius admits inbound UDP to the runners on exactly these ports, through
|
|
23
|
+
# `runner_iroh_ingress` in manifold's
|
|
24
|
+
# deployment/terraform-runners-nebius/network.tf. Change both together. The
|
|
25
|
+
# port numbers are arbitrary: any free ports below Linux's ephemeral range
|
|
26
|
+
# (32768-60999) work. Keep the range to eight ports, because Nebius accepts at
|
|
27
|
+
# most eight destination ports in one security rule.
|
|
28
|
+
ENDPOINT_PORTS = range(21300, 21308)
|
|
29
|
+
READ_CHUNK_SIZE = 64 * 1024
|
|
30
|
+
ONLINE_TIMEOUT_SECONDS = 20.0
|
|
31
|
+
DRAIN_TIMEOUT_SECONDS = 5.0
|
|
32
|
+
CLOSE_CODE_OK = 0
|
|
33
|
+
|
|
34
|
+
# The replacements below match the `_UniffiRustBufferBuilder` methods
|
|
35
|
+
# generated in these releases only; other releases are not patched.
|
|
36
|
+
IROH_VERSIONS_WITH_BYTE_LOOP = frozenset({"1.0.0", "1.1.0"})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _pack_into(self, size, format, value) -> None:
|
|
40
|
+
with self._reserve(size):
|
|
41
|
+
packed = struct.pack(format, value)
|
|
42
|
+
if size > 0:
|
|
43
|
+
ctypes.memmove(
|
|
44
|
+
ctypes.addressof(self.rbuf.data.contents) + self.rbuf.len,
|
|
45
|
+
packed,
|
|
46
|
+
size,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _write(self, value) -> None:
|
|
51
|
+
length = len(value)
|
|
52
|
+
with self._reserve(length):
|
|
53
|
+
if length > 0:
|
|
54
|
+
# memmove refuses a bytearray or memoryview; the binding's
|
|
55
|
+
# check_lower admits any bytes-like value.
|
|
56
|
+
ctypes.memmove(
|
|
57
|
+
ctypes.addressof(self.rbuf.data.contents) + self.rbuf.len,
|
|
58
|
+
bytes(value),
|
|
59
|
+
length,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if version("iroh") in IROH_VERSIONS_WITH_BYTE_LOOP:
|
|
64
|
+
iroh.iroh_ffi._UniffiRustBufferBuilder._pack_into = _pack_into
|
|
65
|
+
iroh.iroh_ffi._UniffiRustBufferBuilder.write = _write
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def wait_online(endpoint: iroh.Endpoint) -> None:
|
|
69
|
+
# online() can hang; wrapped in a timeout (relay needed only for id-only dialing).
|
|
70
|
+
with contextlib.suppress(TimeoutError):
|
|
71
|
+
await asyncio.wait_for(endpoint.online(), timeout=ONLINE_TIMEOUT_SECONDS)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class BiStream:
|
|
75
|
+
"""The send + recv halves of one resolved iroh bi-directional stream."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, send: iroh.SendStream, recv: iroh.RecvStream) -> None:
|
|
78
|
+
self.send = send
|
|
79
|
+
self.recv = recv
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class IrohByteStream:
|
|
83
|
+
"""A `ByteStream` over one iroh bi-directional stream."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
connection: iroh.Connection,
|
|
88
|
+
open_bi_stream: Callable[[iroh.Connection], Awaitable[BiStream]],
|
|
89
|
+
) -> None:
|
|
90
|
+
self._connection = connection
|
|
91
|
+
self._open_bi_stream = open_bi_stream
|
|
92
|
+
self._bi: BiStream | None = None
|
|
93
|
+
self._closed = False
|
|
94
|
+
# bi_lock: concurrent first read+write must not open two separate bi-streams.
|
|
95
|
+
self._bi_lock = asyncio.Lock()
|
|
96
|
+
|
|
97
|
+
async def _bi_stream(self) -> BiStream:
|
|
98
|
+
if self._bi is None:
|
|
99
|
+
async with self._bi_lock:
|
|
100
|
+
if self._bi is None:
|
|
101
|
+
self._bi = await self._open_bi_stream(self._connection)
|
|
102
|
+
return self._bi
|
|
103
|
+
|
|
104
|
+
async def read(self, n: int) -> bytes:
|
|
105
|
+
if n == 0:
|
|
106
|
+
return b""
|
|
107
|
+
bi = await self._bi_stream()
|
|
108
|
+
try:
|
|
109
|
+
return await bi.recv.read(n)
|
|
110
|
+
except iroh.IrohError:
|
|
111
|
+
# QUIC raises on peer-close where a socket would return b"" (EOF).
|
|
112
|
+
return b""
|
|
113
|
+
|
|
114
|
+
async def read_exactly(self, n: int) -> bytes:
|
|
115
|
+
if n == 0:
|
|
116
|
+
return b""
|
|
117
|
+
buffer = bytearray()
|
|
118
|
+
while len(buffer) < n:
|
|
119
|
+
chunk = await self.read(min(READ_CHUNK_SIZE, n - len(buffer)))
|
|
120
|
+
if not chunk:
|
|
121
|
+
raise StreamClosedError(bytes(buffer), n)
|
|
122
|
+
buffer.extend(chunk)
|
|
123
|
+
return bytes(buffer)
|
|
124
|
+
|
|
125
|
+
async def write(self, data: bytes) -> None:
|
|
126
|
+
if self._closed:
|
|
127
|
+
raise StreamClosedError(b"", len(data))
|
|
128
|
+
bi = await self._bi_stream()
|
|
129
|
+
await bi.send.write_all(data)
|
|
130
|
+
|
|
131
|
+
async def close(self) -> None:
|
|
132
|
+
if self._closed:
|
|
133
|
+
return
|
|
134
|
+
self._closed = True
|
|
135
|
+
# Must finish() then wait for stopped() before connection.close(): closing
|
|
136
|
+
# immediately drops buffered bytes the peer has not read yet.
|
|
137
|
+
try:
|
|
138
|
+
if self._bi is not None:
|
|
139
|
+
with contextlib.suppress(iroh.IrohError, TimeoutError):
|
|
140
|
+
await self._bi.send.finish()
|
|
141
|
+
await asyncio.wait_for(
|
|
142
|
+
self._bi.send.stopped(), timeout=DRAIN_TIMEOUT_SECONDS
|
|
143
|
+
)
|
|
144
|
+
finally:
|
|
145
|
+
with contextlib.suppress(iroh.IrohError):
|
|
146
|
+
self._connection.close(CLOSE_CODE_OK, b"closed")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def open_bi_dial(connection: iroh.Connection) -> BiStream:
|
|
150
|
+
bi = await connection.open_bi()
|
|
151
|
+
return BiStream(bi.send(), bi.recv())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def open_bi_accept(connection: iroh.Connection) -> BiStream:
|
|
155
|
+
bi = await connection.accept_bi()
|
|
156
|
+
return BiStream(bi.send(), bi.recv())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class IrohConnector:
|
|
160
|
+
"""Dials an iroh endpoint id from the process's shared `DialEndpoint`."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, endpoint_id: str, dial_endpoint: DialEndpoint) -> None:
|
|
163
|
+
self._endpoint_id = endpoint_id.removeprefix("iroh:")
|
|
164
|
+
self._dial_endpoint = dial_endpoint
|
|
165
|
+
|
|
166
|
+
async def connect(self) -> IrohByteStream:
|
|
167
|
+
try:
|
|
168
|
+
endpoint_id = iroh.EndpointId.from_string(self._endpoint_id)
|
|
169
|
+
except iroh.IrohError as exc:
|
|
170
|
+
raise TransportError(
|
|
171
|
+
f"invalid iroh endpoint id {self._endpoint_id!r}"
|
|
172
|
+
) from exc
|
|
173
|
+
endpoint = await self._dial_endpoint.endpoint()
|
|
174
|
+
await wait_online(endpoint)
|
|
175
|
+
addr = iroh.EndpointAddr(endpoint_id, None, [])
|
|
176
|
+
try:
|
|
177
|
+
connection = await endpoint.connect(addr, ALPN)
|
|
178
|
+
except iroh.IrohError as exc:
|
|
179
|
+
raise TransportError(
|
|
180
|
+
f"could not connect to iroh endpoint {self._endpoint_id!r}: {exc}"
|
|
181
|
+
) from exc
|
|
182
|
+
return IrohByteStream(connection, open_bi_dial)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class DialEndpoint:
|
|
186
|
+
"""The one iroh endpoint that every `IrohConnector` of a process dials from.
|
|
187
|
+
|
|
188
|
+
The endpoint binds the first free port of `ports`, and a random port once
|
|
189
|
+
every port in the range is in use.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def __init__(self, ports: range, preset: Callable[[], iroh.Preset]) -> None:
|
|
193
|
+
self._ports = ports
|
|
194
|
+
self._preset = preset
|
|
195
|
+
self._endpoint: iroh.Endpoint | None = None
|
|
196
|
+
# One bind lock per event loop: an asyncio.Lock binds to the first loop
|
|
197
|
+
# it waits in, and the daemon runs each side in a loop of its own.
|
|
198
|
+
self._bind_locks: weakref.WeakKeyDictionary[
|
|
199
|
+
asyncio.AbstractEventLoop, asyncio.Lock
|
|
200
|
+
] = weakref.WeakKeyDictionary()
|
|
201
|
+
|
|
202
|
+
async def endpoint(self) -> iroh.Endpoint:
|
|
203
|
+
"""The endpoint, bound on first use."""
|
|
204
|
+
lock = self._bind_locks.setdefault(asyncio.get_running_loop(), asyncio.Lock())
|
|
205
|
+
# Concurrent first dials must not bind two endpoints.
|
|
206
|
+
async with lock:
|
|
207
|
+
if self._endpoint is None:
|
|
208
|
+
self._endpoint = await self._bind()
|
|
209
|
+
return self._endpoint
|
|
210
|
+
|
|
211
|
+
async def _bind(self) -> iroh.Endpoint:
|
|
212
|
+
return await bind_endpoint(self._ports, self._preset)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class IrohListener:
|
|
216
|
+
"""Accepts inbound iroh connections as `ByteStream`s."""
|
|
217
|
+
|
|
218
|
+
def __init__(self, endpoint: iroh.Endpoint) -> None:
|
|
219
|
+
self._endpoint = endpoint
|
|
220
|
+
self._endpoint_id = str(endpoint.id())
|
|
221
|
+
self._pending: list[IrohByteStream] = []
|
|
222
|
+
self._closed = False
|
|
223
|
+
self._ready = asyncio.Event()
|
|
224
|
+
self._accept_loop = asyncio.create_task(self._run_accept_loop())
|
|
225
|
+
|
|
226
|
+
@classmethod
|
|
227
|
+
async def open(
|
|
228
|
+
cls, ports: range, preset: Callable[[], iroh.Preset]
|
|
229
|
+
) -> IrohListener:
|
|
230
|
+
endpoint = await bind_endpoint(ports, preset)
|
|
231
|
+
await wait_online(endpoint)
|
|
232
|
+
return cls(endpoint)
|
|
233
|
+
|
|
234
|
+
async def _run_accept_loop(self) -> None:
|
|
235
|
+
while not self._closed:
|
|
236
|
+
try:
|
|
237
|
+
incoming = await self._endpoint.accept_next()
|
|
238
|
+
if incoming is None:
|
|
239
|
+
return
|
|
240
|
+
accepting = await incoming.accept()
|
|
241
|
+
connection = await accepting.connect()
|
|
242
|
+
except iroh.IrohError:
|
|
243
|
+
if self._closed:
|
|
244
|
+
return
|
|
245
|
+
continue
|
|
246
|
+
# Hand off the connection; accept_bi blocks until peer writes (deadlock).
|
|
247
|
+
self._pending.append(IrohByteStream(connection, open_bi_accept))
|
|
248
|
+
self._ready.set()
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def target(self) -> str:
|
|
252
|
+
return f"iroh:{self._endpoint_id}"
|
|
253
|
+
|
|
254
|
+
async def accept(self) -> IrohByteStream:
|
|
255
|
+
while not self._pending:
|
|
256
|
+
if self._closed:
|
|
257
|
+
raise StreamClosedError(b"", 0)
|
|
258
|
+
self._ready.clear()
|
|
259
|
+
await self._ready.wait()
|
|
260
|
+
return self._pending.pop(0)
|
|
261
|
+
|
|
262
|
+
async def close(self) -> None:
|
|
263
|
+
if self._closed:
|
|
264
|
+
return
|
|
265
|
+
self._closed = True
|
|
266
|
+
self._ready.set()
|
|
267
|
+
self._accept_loop.cancel()
|
|
268
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
269
|
+
await self._accept_loop
|
|
270
|
+
try:
|
|
271
|
+
while self._pending:
|
|
272
|
+
with contextlib.suppress(iroh.IrohError):
|
|
273
|
+
await self._pending.pop(0).close()
|
|
274
|
+
finally:
|
|
275
|
+
await self._endpoint.close()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
async def bind_endpoint(
|
|
279
|
+
ports: range, preset: Callable[[], iroh.Preset]
|
|
280
|
+
) -> iroh.Endpoint:
|
|
281
|
+
"""Bind an endpoint to the first free port of `ports`, or to a random port
|
|
282
|
+
once every port in the range is in use."""
|
|
283
|
+
error: iroh.IrohError | None = None
|
|
284
|
+
for port in ports:
|
|
285
|
+
try:
|
|
286
|
+
return await iroh.Endpoint.bind(
|
|
287
|
+
iroh.EndpointOptions(
|
|
288
|
+
preset=preset(), alpns=[ALPN], bind_addr=f"0.0.0.0:{port}"
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
except iroh.IrohError as exc:
|
|
292
|
+
error = exc
|
|
293
|
+
logger.warning(
|
|
294
|
+
"could not bind iroh ports %d-%d (%s); binding a random port",
|
|
295
|
+
ports.start,
|
|
296
|
+
ports.stop - 1,
|
|
297
|
+
error,
|
|
298
|
+
)
|
|
299
|
+
return await iroh.Endpoint.bind(
|
|
300
|
+
iroh.EndpointOptions(preset=preset(), alpns=[ALPN], bind_addr=None)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class IrohTransport:
|
|
305
|
+
"""The `iroh` transport descriptor."""
|
|
306
|
+
|
|
307
|
+
scheme = "iroh"
|
|
308
|
+
|
|
309
|
+
def __init__(self) -> None:
|
|
310
|
+
self._dial_endpoint = DialEndpoint(ENDPOINT_PORTS, iroh.preset_n0)
|
|
311
|
+
|
|
312
|
+
def build_connector(self, rest: str) -> IrohConnector:
|
|
313
|
+
return IrohConnector(rest, self._dial_endpoint)
|
|
314
|
+
|
|
315
|
+
async def open_listener(self, rest: str) -> IrohListener:
|
|
316
|
+
del rest
|
|
317
|
+
return await IrohListener.open(ENDPOINT_PORTS, iroh.preset_n0)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Transport registry: maps `scheme:rest` target strings to adapter descriptors."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from manifold_cli.transport.interfaces import Connector, Listener, TransportError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Transport(Protocol):
|
|
10
|
+
"""A transport adapter descriptor exported by each adapter module."""
|
|
11
|
+
|
|
12
|
+
scheme: str
|
|
13
|
+
|
|
14
|
+
def build_connector(self, rest: str) -> Connector: ...
|
|
15
|
+
async def open_listener(self, rest: str) -> Listener: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TransportRegistry:
|
|
19
|
+
"""Maps scheme to adapter; raises `TransportError` on unknown schemes."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, transports: Iterable[Transport]) -> None:
|
|
22
|
+
self.transports = {transport.scheme: transport for transport in transports}
|
|
23
|
+
|
|
24
|
+
def resolve(self, target: str) -> tuple[Transport, str]:
|
|
25
|
+
"""Split `target` into its adapter and the trailing `rest` (after `scheme:`)."""
|
|
26
|
+
scheme, _, rest = target.partition(":")
|
|
27
|
+
transport = self.transports.get(scheme)
|
|
28
|
+
if transport is None:
|
|
29
|
+
known = ", ".join(sorted(self.transports)) or "(none)"
|
|
30
|
+
raise TransportError(
|
|
31
|
+
f"unknown transport scheme {scheme!r} in target {target!r}; "
|
|
32
|
+
f"known schemes: {known}"
|
|
33
|
+
)
|
|
34
|
+
return transport, rest
|