Compare commits
30 Commits
93dbbd5a02
...
d80dcbe60f
| Author | SHA1 | Date |
|---|---|---|
|
|
d80dcbe60f | |
|
|
793ba9f94c | |
|
|
3e5a699df6 | |
|
|
b1f6ade840 | |
|
|
2fb6143604 | |
|
|
6d5b145787 | |
|
|
503a3ed766 | |
|
|
d0261787fd | |
|
|
d1ffb436d0 | |
|
|
244e080efe | |
|
|
6dd149df3d | |
|
|
f7bf068d09 | |
|
|
1fd857ce21 | |
|
|
1b0b085667 | |
|
|
790a52ca7a | |
|
|
7abbb2b5f0 | |
|
|
40e96b2991 | |
|
|
b9dca728d9 | |
|
|
be3e78197d | |
|
|
ece3827bdf | |
|
|
35e496c166 | |
|
|
0a0aa64305 | |
|
|
51f4e31ce0 | |
|
|
0a741282af | |
|
|
419ce6a631 | |
|
|
176e0c3e61 | |
|
|
6b8e9ad298 | |
|
|
bcf8f87ea5 | |
|
|
c7451bb2cb | |
|
|
fa8067f79f |
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: intercepted-claude-7b9c97c4-fff7-4ac4-97fb-35720453308e
|
||||
timestamp: 2026-08-18T02:17:29Z
|
||||
git_ref: d9a6e2e9
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260818T021729Z_d9a6e2e9_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
> Intercept Claude session
|
||||
> `7b9c97c4-fff7-4ac4-97fb-35720453308e`, pick up where it
|
||||
> stopped in its open worktree, finish the tunnelled-address change
|
||||
> and commit plan, and prepare any outstanding context for another
|
||||
> provider.
|
||||
|
||||
The recovered final prompt specifically called out the `uds` versus
|
||||
`unix` boundary decision, absent concrete `Address.namespace`
|
||||
implementations, the corrected namespace test, and the passing
|
||||
focused and wider suites.
|
||||
|
||||
During review, the human further required that tunnelled-maddr work
|
||||
delegate to `multiaddr`'s encapsulation APIs, challenged the premature
|
||||
transport-shaped listener hooks and directed their removal, corrected
|
||||
the long-term bearer provisioning model, and confirmed the intended
|
||||
split between discovery metadata and bindspace lifecycle code.
|
||||
|
||||
## Response summary
|
||||
|
||||
Recovered the transcript and matched it to `wkts/addr_unpacking`,
|
||||
audited the staged implementation, and completed the interrupted
|
||||
verification and commit-plan work. The audit removed premature
|
||||
transport-shaped listener hooks, widened the namespace identifier
|
||||
type, updated stale import documentation, and removed an
|
||||
invalid-escape warning from the maddr diagram. It also preserved the
|
||||
layer-C design where tractor provisions the kernel-owned tunnel
|
||||
bearer without treating it as a message transport.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/_tunnel.py` - tunnel specs, address wrapper, and
|
||||
peeling helpers.
|
||||
- `tractor/discovery/_addr.py` - wrapped-address recognition and
|
||||
namespace typing.
|
||||
- `tractor/discovery/__init__.py` - public tunnel API exports.
|
||||
- `tests/discovery/test_tunnelled_addr.py` - delegation and boundary
|
||||
regression coverage.
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - distinguish
|
||||
tractor-owned bindspace provisioning from kernel socket ownership.
|
||||
|
||||
## Human edits
|
||||
|
||||
Substantial human-directed editing occurred over several review turns:
|
||||
|
||||
- required use of `multiaddr`'s `.encapsulate()`/`.decapsulate()`
|
||||
family rather than a hand-rolled tunnel peeler
|
||||
- rejected the premature `start_listener()`/`close_listener()` hooks
|
||||
and directed their removal from this foundational change
|
||||
- corrected the documentation so tractor retains ownership of future
|
||||
bindspace provisioning while the kernel owns the bearer socket
|
||||
- reviewed and accepted the placement of declarative tunnel metadata
|
||||
under `tractor.discovery`, with lifecycle code kept separate
|
||||
|
||||
The final source lines were applied through the coding agents, but
|
||||
these design corrections and deletion decisions came from the human
|
||||
review and materially shaped the patch.
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-18T02:17:29Z
|
||||
git_ref: d9a6e2e9
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - tunnelled-address handoff completion
|
||||
|
||||
Recovered Claude Code session
|
||||
`7b9c97c4-fff7-4ac4-97fb-35720453308e` and continued its
|
||||
interrupted `wkts/addr_unpacking` changes.
|
||||
|
||||
## Generated code
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py`
|
||||
|
||||
Added frozen `WGTunnelSpec` and `TunnelledAddress` structs. The
|
||||
wrapper delegates transport identity, validity, bindspace, and wire
|
||||
serialization to its overlay while retaining tunnel metadata locally.
|
||||
Added pure helpers to peel nested wrappers and enumerate their tunnel
|
||||
specs. The module documents why wrappers must be peeled before
|
||||
`Endpoint` selects the overlay transport backend.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_addr.py`
|
||||
|
||||
Extended `is_wrapped_addr()` to recognize `TunnelledAddress` without
|
||||
registering tunnels as message transports, and widened the namespace
|
||||
identifier type to cover named network namespaces.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/__init__.py`
|
||||
|
||||
Exported the tunnel address API from `tractor.discovery` and updated
|
||||
the eager-import documentation.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tests/discovery/test_tunnelled_addr.py`
|
||||
|
||||
Added focused coverage for delegation, serialization, rewrapping,
|
||||
namespace fallback, nested peeling order, and frozen structs.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||
|
||||
Clarified that tractor owns the eventual bindspace lifecycle and may
|
||||
provision the WireGuard iface, routes, and kernel UDP listener through
|
||||
netlink/`pyroute2`. Kernel socket ownership does not make the bearer an
|
||||
application `MsgTransport` endpoint.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused tests:
|
||||
|
||||
```text
|
||||
9 passed in 0.03s
|
||||
```
|
||||
|
||||
Discovery and IPC suites:
|
||||
|
||||
```text
|
||||
67 passed, 2 xpassed
|
||||
```
|
||||
|
||||
The audit removed premature module-level listener hooks. Runtime
|
||||
integration must peel the wrapper explicitly at bind and dial
|
||||
boundaries rather than make `._tunnel` impersonate a transport
|
||||
backend.
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking-followup
|
||||
timestamp: 2026-08-18T07:50:31Z
|
||||
git_ref: dd02c7c0
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260818T075031Z_dd02c7c0_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
The human requested the complete tunnelled-maddr parsing/composition
|
||||
slice as an unattended batch, with every numbered requirement finished
|
||||
and atomic commit plans prepared at the end. Existing human decisions
|
||||
required native `multiaddr` encapsulation APIs, no hand-written peeler,
|
||||
and preservation of tractor's future bindspace lifecycle ownership.
|
||||
|
||||
## Response summary
|
||||
|
||||
Implemented strict WG key codecs and native single/nested tunnel maddr
|
||||
parsing and composition, integrated them into discovery APIs, migrated
|
||||
the multihost example off its duplicate parser, corrected package
|
||||
dependency metadata, and added focused and end-to-end parser
|
||||
regressions. Verified the complete tractor suite and built both package
|
||||
artifacts.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/` - WG codecs, parser/composer, wrapper typing,
|
||||
public exports, and discovery dispatch.
|
||||
- `tests/discovery/` - key, grammar, nesting, round-trip, and public
|
||||
boundary regressions.
|
||||
- `examples/multihost/wg_lan/` - production parser migration and
|
||||
updated usage documentation.
|
||||
- `pyproject.toml`, `uv.lock` - reproducible WG codec and multibase
|
||||
dependencies for checkout and package installs.
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - current layer-A state
|
||||
and future bindspace ownership.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human selected the five-step scope and batch execution model,
|
||||
required delegation to `multiaddr`'s encapsulation APIs, rejected
|
||||
transport-shaped listener placeholders in the prerequisite commit, and
|
||||
clarified that tractor will eventually provision the kernel-owned
|
||||
bearer through its bindspace layer. The agent implemented and tested
|
||||
those decisions; no direct manual source edits were observed during
|
||||
this batch.
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-18T07:50:31Z
|
||||
git_ref: dd02c7c0
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - native WireGuard maddr integration
|
||||
|
||||
The human requested completion of the five-step tunnelled-maddr slice:
|
||||
port the proven WireGuard parser, delegate to `py-multiaddr`'s native
|
||||
tunnel APIs, integrate public parse and composition entry points, add
|
||||
regressions, and return atomic commit plans after completing the batch.
|
||||
|
||||
## Generated code
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/_multiaddr.py tractor/discovery/_addr.py tractor/discovery/__init__.py`
|
||||
|
||||
Added strict WireGuard standard-base64/multibase key codecs and native
|
||||
WG maddr parsing/composition. Nested stacks peel the last `/wg/`
|
||||
repeatedly with `.decapsulate_code()`, isolate segments through
|
||||
`.split()`/`.join()`, and compose recursively with `.encapsulate()`.
|
||||
Public discovery parsing, wrapping, endpoint-table parsing, and maddr
|
||||
composition now preserve `TunnelledAddress` metadata locally.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- pyproject.toml uv.lock`
|
||||
|
||||
Declared `py-multibase` directly and pinned the unreleased
|
||||
py-multiaddr#108 WG codec revision in distribution metadata. Enabled
|
||||
Hatch direct-reference metadata so editable, wheel, and sdist builds
|
||||
use the same dependency contract.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tests/discovery/test_multiaddr.py tests/discovery/test_tunnelled_addr.py`
|
||||
|
||||
Added key-codec, single/nested WG round-trip, malformed key, invalid
|
||||
bearer/overlay, missing codec, missing bearer, `wrap_address()`, and
|
||||
`parse_endpoints()` coverage while retaining plain TCP/UDS regressions.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- examples/multihost/wg_lan ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||
|
||||
Removed the duplicate example parser, migrated callers to production
|
||||
`TunnelledAddress`, retained only explicit `wg(8)` verification, and
|
||||
updated dependency and future bindspace ownership documentation.
|
||||
|
||||
## Verification
|
||||
|
||||
- focused discovery files: `45 passed`
|
||||
- discovery and IPC suites: `81 passed, 2 xpassed`
|
||||
- full suite: `443 passed, 9 skipped, 7 xfailed, 4 xpassed`
|
||||
- collection: `462 tests`
|
||||
- Ruff: clean
|
||||
- sdist and wheel builds: successful
|
||||
|
||||
## Review corrections
|
||||
|
||||
A review caught that `[tool.uv.sources]` alone would not constrain
|
||||
built-package consumers to a WG-capable py-multiaddr revision. The
|
||||
dependency was moved into PEP 621 metadata and the parser now converts
|
||||
missing-codec failures into an actionable tractor error without
|
||||
misclassifying Unix paths containing a `wg` directory.
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking-followup
|
||||
timestamp: 2026-08-19T21:31:44Z
|
||||
git_ref: f81fc5e5
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260819T213144Z_f81fc5e5_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
The human requested runtime boundary integration with the established
|
||||
per-step implementation and commit-plan workflow.
|
||||
|
||||
## Response summary
|
||||
|
||||
Kept `TunnelledAddress` available to callers while peeling it at the
|
||||
last outbound boundary before transport lookup and dialing. Added a
|
||||
regression which captures both transport arguments and confirms plain
|
||||
TCP behavior is unchanged.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/ipc/_chan.py` - peel tunnel annotations before outbound
|
||||
transport dispatch and connection.
|
||||
- `tests/ipc/test_channel_tunnel_boundary.py` - verify plain and
|
||||
tunnelled channel inputs deliver only TCP overlays.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human chose the runtime-boundary slice, required the existing
|
||||
per-step commit-plan flow, and previously established that wrappers
|
||||
must retain bindspace metadata without impersonating transports. The
|
||||
agent implemented those constraints; no direct manual source edits were
|
||||
observed during this step.
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-19T21:31:44Z
|
||||
git_ref: f81fc5e5
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - outbound tunnel boundary
|
||||
|
||||
The human requested the next tunnelled-address slice using the same
|
||||
per-step commit-plan flow. Existing design decisions require retaining
|
||||
tunnel metadata until the narrow IPC transport boundary and never
|
||||
teaching exact-type transport tables about tunnel wrappers.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/ipc/_chan.py tests/ipc/test_channel_tunnel_boundary.py`
|
||||
|
||||
Extended channel address inputs to accept tunnel declarations, then
|
||||
called `strip_tunnels()` immediately before exact-type transport lookup
|
||||
and `connect_to()`. Added plain/tunnel parameterized coverage proving
|
||||
both operations receive the identical TCP overlay while the original
|
||||
wrapper retains its tunnel spec.
|
||||
|
||||
Verification included focused IPC tests, Ruff, discovery/IPC suites,
|
||||
and the full tractor suite.
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking-followup
|
||||
timestamp: 2026-08-19T21:31:45Z
|
||||
git_ref: f81fc5e5
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260819T213145Z_f81fc5e5_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
The human requested completion of inbound runtime peeling using the
|
||||
same per-step implementation and commit-plan workflow.
|
||||
|
||||
## Response summary
|
||||
|
||||
Preserved tunnel declarations through listener configuration, peeled
|
||||
them immediately before `Endpoint` construction, and used the overlay
|
||||
for backend-specific random listener allocation after registry
|
||||
discovery. Added a real listener regression for the reflection and
|
||||
exact-type boundary.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/ipc/_server.py` - accept wrapper declarations and peel at
|
||||
`Endpoint` construction.
|
||||
- `tractor/_root.py` - allocate random transport addresses from the
|
||||
contacted registry's overlay.
|
||||
- `tests/ipc/test_server_tunnel_boundary.py` - verify a real listener
|
||||
stores only TCP while preserving the source annotation.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human selected the runtime-boundary work and previously corrected
|
||||
the architecture so tractor retains future bindspace provisioning
|
||||
ownership while `Endpoint` sees only application transports. The agent
|
||||
implemented and tested that direction; no direct manual source edits
|
||||
were observed during this step.
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-19T21:31:45Z
|
||||
git_ref: f81fc5e5
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - inbound tunnel boundary
|
||||
|
||||
The human requested runtime boundary integration while preserving the
|
||||
future tractor-owned bindspace lifecycle.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/ipc/_server.py tractor/_root.py tests/ipc/test_server_tunnel_boundary.py`
|
||||
|
||||
Broadened listener declarations to carry tunnel wrappers until
|
||||
`_serve_ipc_eps()` and peeled immediately before `Endpoint`
|
||||
construction. Also peeled a contacted tunnelled registry before
|
||||
backend-specific random listener allocation. Added a real TCP listener
|
||||
regression proving `Endpoint` stores only the resolved overlay while
|
||||
the original declaration retains bindspace metadata.
|
||||
|
||||
Verification included `465` collected tests, `84` passing
|
||||
discovery/IPC tests with two xpasses, Ruff, and the full suite with
|
||||
`447` passes.
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking-followup
|
||||
timestamp: 2026-08-20T02:15:16Z
|
||||
git_ref: dfad66a0
|
||||
scope: docs
|
||||
substantive: true
|
||||
raw_file: 20260820T021516Z_dfad66a0_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
The human requested that the bindspace plan preserve the agreed
|
||||
capability, spawn-bootstrap, endpoint-role, namespace augmentation,
|
||||
random-address, and teardown semantics, using `github/ns_aware` as
|
||||
prototype input.
|
||||
|
||||
## Response summary
|
||||
|
||||
Updated plan-03 and the shared backend contract to separate serializable
|
||||
bindspace declarations from scoped live capabilities, make namespace
|
||||
entry a pre-runtime spawn operation, keep maddr paths role-neutral, and
|
||||
define listen/dial provisioning plus ownership-sensitive teardown.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - layer-C capability,
|
||||
bootstrap, role, teardown, test, and risk model.
|
||||
- `ai/tpt-backends/00_shared_backend_contract.md` - distinguish
|
||||
transport bind selectors from process namespace lifecycle.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human supplied the core architecture: structured scoped
|
||||
capabilities, spawn-time namespace entry, orthogonal namespace
|
||||
augmentation, source/destination-dependent provisioning, and
|
||||
role-dependent teardown. They also rejected premature assumptions about
|
||||
`open_bindspace()` returning an address and requested grounding in the
|
||||
existing namespace prototype. The agent translated those decisions into
|
||||
the plan text; no direct manual source edits were observed.
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-20T02:15:16Z
|
||||
git_ref: dfad66a0
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - bindspace capability design
|
||||
|
||||
The human corrected the layer-C design around local network-stack
|
||||
realization. They established that bindspace state should be both
|
||||
structured and a scoped capability; namespace entry belongs in
|
||||
subactor bootstrap; maddrs can describe source or destination network
|
||||
paths while namespace selection augments them orthogonally; random
|
||||
address and teardown behavior depend on operation role and ownership.
|
||||
|
||||
They directed comparison with the prototype on `github/ns_aware` and
|
||||
requested these decisions be preserved in the plan.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- ai/tpt-backends/03_wg_tunnel_bindspace.md ai/tpt-backends/00_shared_backend_contract.md`
|
||||
|
||||
Reworked layer C around serializable `BindspaceSpec`, stable
|
||||
`BindspaceIdentity`, and scoped non-serializable `BindspaceHandle`
|
||||
concepts. Namespace FDs pin identity and lifetime; parent/supervisor
|
||||
provisioning transfers entry capability through spawn; the child enters
|
||||
before runtime, channels, listeners, sockets, or worker threads and then
|
||||
drops authority. Listen/dial roles and owned/borrowed teardown are
|
||||
explicit, while maddrs remain role-neutral network-path declarations.
|
||||
|
||||
The shared backend contract now separates transport-level `.bindspace`
|
||||
selectors from process namespace lifecycle. Added tests/risks for FD
|
||||
identity, bootstrap ordering, privilege drop, role ownership, and
|
||||
shared-resource teardown.
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-20T03:31:07Z
|
||||
git_ref: ba07e09d
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260820T033107Z_ba07e09d_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
The human requested canonical tagged transport addresses with a
|
||||
reader-first migration. TCP should decode `('tcp', host, port)`, Unix
|
||||
should decode `('unix', path)`, `uds` should remain an accepted input
|
||||
alias and internal transport key, and legacy tuple/list inputs must keep
|
||||
working before writers switch formats.
|
||||
|
||||
## Response summary
|
||||
|
||||
Introduced canonical and compatibility address aliases, explicit tagged
|
||||
dispatch, transport-specific tagged readers, and focused serialization
|
||||
tests. Kept legacy pair inputs and native IPv6 socket values readable so
|
||||
this boundary can ship before tagged emission.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/_addr.py` - address aliases and tagged dispatch.
|
||||
- `tractor/ipc/_tcp.py` - tagged, legacy, and IPv6 TCP decoding.
|
||||
- `tractor/ipc/_uds.py` - canonical Unix and UDS-alias decoding.
|
||||
- `tests/discovery/test_address_serialization.py` - reader compatibility
|
||||
coverage.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human supplied the canonical `tcp` and `unix` forms, chose `uds` as
|
||||
an input-only serialization alias while preserving it as the runtime
|
||||
transport key, and required a reader-first commit boundary. The agent
|
||||
implemented those decisions; no direct manual source edits were
|
||||
observed.
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-20T03:31:07Z
|
||||
git_ref: ba07e09d
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - tagged address readers
|
||||
|
||||
The human requested a migration away from ambiguous untagged transport
|
||||
tuples. They established `('tcp', host, port)` and `('unix', path)` as
|
||||
canonical forms, retained `('uds', path)` as an input alias, and required
|
||||
a reader-first compatibility boundary before changing emitted values.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_addr.py tractor/ipc/_tcp.py tractor/ipc/_uds.py tests/discovery/test_address_serialization.py`
|
||||
|
||||
Added explicit tagged address aliases and dispatch, taught TCP and UDS
|
||||
readers to decode tagged tuple/list payloads, preserved legacy pair input,
|
||||
and retained native IPv6 socket-address decoding. Added focused tests for
|
||||
canonical tags, the UDS alias, msgpack-style lists, legacy pairs, and IPv6
|
||||
socket values.
|
||||
|
||||
Focused reader tests and Ruff checks passed before the writer migration
|
||||
was applied.
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-20T03:31:08Z
|
||||
git_ref: ba07e09d
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260820T033108Z_ba07e09d_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
The human asked the agent to continue after adding tagged readers,
|
||||
complete canonical address emission without dropping legacy input
|
||||
compatibility, verify the migration, and prepare a complete multi-commit
|
||||
package when the turn was done.
|
||||
|
||||
## Response summary
|
||||
|
||||
Changed `.unwrap()` to emit tagged TCP and Unix addresses, updated direct
|
||||
tuple consumers and spawn payload declarations, and aligned multiaddr,
|
||||
runtime, IPC, and discovery tests with canonical serialized equality.
|
||||
Kept untagged tuples and the `uds` spelling readable at input boundaries.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/_addr.py` - canonical output alias.
|
||||
- `tractor/discovery/_multiaddr.py` - tagged address composition.
|
||||
- `tractor/ipc/_tcp.py` - tagged emission and direct socket dialing.
|
||||
- `tractor/ipc/_uds.py` - tagged full-path emission.
|
||||
- `tractor/msg/types.py` - protocol-neutral spawn tuple containers.
|
||||
- `tests/discovery/test_address_serialization.py` - writer assertions.
|
||||
- `tests/discovery/test_multiaddr.py` - canonical round-trip assertions.
|
||||
- `tests/discovery/test_tpt_bind_addrs.py` - tagged bind assertions.
|
||||
- `tests/ipc/test_each_tpt.py` - canonical runtime address assertions.
|
||||
- `tests/ipc/test_server_tunnel_boundary.py` - tagged TCP destructuring.
|
||||
- `tests/test_local.py` - canonical registry comparison.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human established the reader-before-writer sequencing, canonical tag
|
||||
spellings, retained compatibility expectations, and requested final
|
||||
multi-commit packaging. The agent implemented and tested those choices;
|
||||
no direct manual source edits were observed.
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-20T03:31:08Z
|
||||
git_ref: ba07e09d
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - canonical tagged address writers
|
||||
|
||||
After the reader compatibility boundary, the human asked the agent to
|
||||
continue the migration and package the completed work as dependency-
|
||||
ordered commits.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_addr.py tractor/discovery/_multiaddr.py tractor/ipc/_tcp.py tractor/ipc/_uds.py tractor/msg/types.py tests/discovery/test_address_serialization.py tests/discovery/test_multiaddr.py tests/discovery/test_tpt_bind_addrs.py tests/ipc/test_each_tpt.py tests/ipc/test_server_tunnel_boundary.py tests/test_local.py`
|
||||
|
||||
Switched TCP and Unix `.unwrap()` output to canonical tagged tuples,
|
||||
updated direct transport and multiaddr consumers, widened spawn message
|
||||
tuple containers for protocol-specific shapes, and migrated runtime and
|
||||
test comparisons to serialized address equality. Legacy inputs remain
|
||||
accepted at `wrap_address()` and backend reader boundaries.
|
||||
|
||||
Ruff and focused tests passed. The complete non-debugger TCP suite passed
|
||||
with 412 tests; the UDS suite reached 80% without failure before the
|
||||
harness timeout, then all 97 remaining tests passed on resume. Debugger
|
||||
PTY coverage was excluded after an unrelated timeout.
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-21T23:32:04Z
|
||||
git_ref: 5d92595f
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260821T233204Z_5d92595f_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
The human asked to proceed with WireGuard Layer B from PR #505's
|
||||
head, using the established one-change-per-turn workflow and
|
||||
finishing with a commit plan. This turn was limited to read-only
|
||||
pyroute2 inspection; peer verification and bindspace provisioning
|
||||
remain later changes.
|
||||
|
||||
During review, the human required the generated Python to follow the
|
||||
deployed `/py-codestyle` rules and asked for the medium-term plan to
|
||||
remove pyroute2's asyncio I/O runtime from the Trio read path.
|
||||
|
||||
## Response summary
|
||||
|
||||
Added Linux-only read helpers for WireGuard device and peer public
|
||||
keys. Pyroute2's synchronous API is fully contained in a Trio worker
|
||||
thread, supports named netns reads without creation side effects,
|
||||
validates decoded keys and always closes the netlink client.
|
||||
Follow-up edits added the required local annotations, boolean layout,
|
||||
helper docstrings and 69-column source formatting.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `pyproject.toml` - Linux-only `wg` optional dependency.
|
||||
- `uv.lock` - resolved pyroute2 0.9.6 metadata.
|
||||
- `tractor/discovery/_tunnel.py` - read-only WireGuard helpers.
|
||||
- `tractor/discovery/__init__.py` - public helper exports.
|
||||
- `tests/discovery/test_wg_inspection.py` - fake-backed netlink and
|
||||
worker-thread regressions.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human chose the exact #505 head as a stacked base, required
|
||||
incremental changes ending in commit plans, and limited this turn to
|
||||
read-only pyroute2 inspection. The human then identified that Ruff
|
||||
success had not established `/py-codestyle` compliance and directed
|
||||
the agent to correct the Python-specific annotation, documentation
|
||||
and layout rules. No direct manual source edits were observed.
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-21T23:32:04Z
|
||||
git_ref: 5d92595f
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - read WireGuard state through pyroute2
|
||||
|
||||
The human authorized the first incremental WireGuard Layer B change
|
||||
as a stacked branch from tractor PR #505, with one atomic change and
|
||||
a commit plan at the end of the turn.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- pyproject.toml uv.lock tractor/discovery/__init__.py tractor/discovery/_tunnel.py tests/discovery/test_wg_inspection.py`
|
||||
|
||||
Added a Linux-only `wg` extra using pyroute2 0.9.6, plus
|
||||
asynchronous public helpers for reading one interface's public key
|
||||
and peer keys. The complete synchronous open/read/parse/close
|
||||
sequence runs in a Trio worker thread because pyroute2 owns a private
|
||||
asyncio loop internally.
|
||||
|
||||
Named namespace reads pass `flags=0` to override pyroute2's `O_CREAT`
|
||||
default, ensuring inspection cannot create a missing namespace. Fake
|
||||
netlink messages cover multipart dumps, key validation, stable peer
|
||||
deduplication, worker-thread execution, netns selection and cleanup
|
||||
on success/error.
|
||||
|
||||
Ruff and lock checks passed. Focused tunnel/multiaddr coverage passed
|
||||
47 tests; the complete discovery suite passed 88 tests with 2
|
||||
xpasses.
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-22T02:32:26Z
|
||||
git_ref: 59a8ecfd
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260822T023226Z_59a8ecfd_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
After committing the read-only pyroute2 helpers and `wgman` design
|
||||
update, the human authorized the next isolated Layer B change:
|
||||
explicit `verify_wg_peer()` composition over WireGuard inspection.
|
||||
|
||||
## Response summary
|
||||
|
||||
Added and exported async `verify_wg_peer()` using one validated
|
||||
WireGuard key snapshot. It recognizes local-interface and configured
|
||||
peer identities without coupling kernel inspection to address
|
||||
parsing. Updated the multihost examples to use the production helper
|
||||
and removed their subprocess-based probe.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/_tunnel.py` - shared async snapshot reader and
|
||||
explicit verification helper.
|
||||
- `tractor/discovery/__init__.py` - public verification export.
|
||||
- `tests/discovery/test_wg_inspection.py` - local, peer, absent and
|
||||
malformed-key verification coverage.
|
||||
- `examples/multihost/wg_lan/host_a_srv.py` - async local-key check.
|
||||
- `examples/multihost/wg_lan/host_b_client.py` - async peer-key check.
|
||||
- `examples/multihost/wg_lan/wg_maddr.py` - removed obsolete
|
||||
subprocess probe.
|
||||
- `examples/multihost/wg_lan/README.md` - pyroute2 requirements and
|
||||
verification workflow.
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - async API contract.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human selected this pre-agreed verification layer as the next
|
||||
atomic change after reviewing and committing the preceding read and
|
||||
architecture changes. The agent implemented the source changes; no
|
||||
direct manual edits or follow-up corrections were observed.
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-22T02:32:26Z
|
||||
git_ref: 59a8ecfd
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - verify declared WireGuard identities
|
||||
|
||||
The human authorized the next incremental Layer B change after
|
||||
committing the read-only pyroute2 helpers and first-child `wgman`
|
||||
design update.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_inspection.py examples/multihost/wg_lan ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||
|
||||
Added async `verify_wg_peer()` over one WireGuard key snapshot. It
|
||||
validates the declared `WGTunnelSpec.peer_pubkey` before I/O, forwards
|
||||
the spec's iface/netns, and accepts either the local interface key for
|
||||
a source/listen declaration or a configured peer key for a
|
||||
destination/dial declaration.
|
||||
|
||||
Refactored worker offload behind one shared async reader so
|
||||
verification cannot compare two different netlink snapshots. Exported
|
||||
the helper, added local/peer/absent/malformed-key coverage, and moved
|
||||
the multihost examples from their local `wg show` subprocess probe to
|
||||
the production API.
|
||||
|
||||
Ruff and lock checks passed. Focused WireGuard/tunnel/multiaddr
|
||||
coverage passed 51 tests; the complete discovery suite passed 92
|
||||
tests with 2 xpasses.
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-22T03:25:20Z
|
||||
git_ref: d35c802b
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260822T032520Z_d35c802b_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
The human reported the explicit WireGuard verification commit done,
|
||||
asked the agent to check it, and authorized the next incremental
|
||||
Layer C change.
|
||||
|
||||
## Response summary
|
||||
|
||||
Verified commit `d35c802b`, made plain transport namespace behavior
|
||||
explicit, and retained each original listener declaration beside its
|
||||
peeled transport address. Endpoint and server diagnostics can now
|
||||
report a tunnel's namespace without violating the transport boundary.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/ipc/_tcp.py` - explicit plain-address namespace property.
|
||||
- `tractor/ipc/_uds.py` - explicit plain-address namespace property.
|
||||
- `tractor/discovery/_tunnel.py` - direct overlay namespace delegation.
|
||||
- `tractor/ipc/_server.py` - retained declaration and namespace
|
||||
diagnostics.
|
||||
- `tests/discovery/test_tunnelled_addr.py` - plain and tunnel namespace
|
||||
behavior.
|
||||
- `tests/ipc/test_server_tunnel_boundary.py` - declaration retention
|
||||
and diagnostic coverage.
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - concrete endpoint
|
||||
boundary contract.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human selected continued incremental implementation after
|
||||
reviewing and committing the preceding verification layer. The agent
|
||||
implemented this dependency-ordered namespace slice; no direct manual
|
||||
edits or follow-up corrections were observed.
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-22T03:25:20Z
|
||||
git_ref: d35c802b
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - retain endpoint namespace declarations
|
||||
|
||||
The human reported the explicit WireGuard verification commit done,
|
||||
asked for it to be checked, and authorized the next incremental
|
||||
change.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/ipc/_tcp.py tractor/ipc/_uds.py tractor/discovery/_tunnel.py tractor/ipc/_server.py tests/discovery/test_tunnelled_addr.py tests/ipc/test_server_tunnel_boundary.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||
|
||||
Confirmed commit `d35c802b` and a clean worktree, then implemented the
|
||||
smallest dependency-ordered Layer C slice. Plain TCP and UDS addresses
|
||||
now explicitly report no namespace, allowing `TunnelledAddress` to
|
||||
delegate without an attribute fallback.
|
||||
|
||||
Added required `Endpoint.declared_addr` metadata beside the peeled,
|
||||
resolved `Endpoint.addr`. Endpoint and server diagnostics expose the
|
||||
declaration's namespace without passing a tunnel wrapper into
|
||||
transport reflection. Updated the Layer C plan and tests for plain,
|
||||
tunnelled, endpoint and server namespace behavior.
|
||||
|
||||
Ruff passed. Focused namespace tests passed 13 tests; combined
|
||||
discovery and IPC coverage passed 101 tests with 2 xpasses.
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-22T04:20:26Z
|
||||
git_ref: 29141f0b
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260822T042026Z_29141f0b_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
After committing endpoint namespace visibility, the human authorized
|
||||
continued Layer C implementation.
|
||||
|
||||
During staged review, the human requested Literal-derived validation,
|
||||
ownership documentation, stable-inode clarification, explicit
|
||||
non-serialization rationale and consolidated invalid-model tests.
|
||||
|
||||
## Response summary
|
||||
|
||||
Added the foundational bindspace model: serializable declarations and
|
||||
stable identities are separated from a process-local live capability.
|
||||
The handle validates names, ownership and FD identity. A global
|
||||
`ProcessLocal` sentinel blocks default encoding while retaining
|
||||
msgspec struct behavior.
|
||||
Review fixes require a positive inode for every realized netns,
|
||||
derive runtime choices from the Literal aliases and clarify that an FD
|
||||
integer is not transferable capability authority.
|
||||
The human then clarified that msgspec structs are useful generic
|
||||
storage independently of serialization policy, so the live handle now
|
||||
uses a struct while remaining process-local by contract.
|
||||
The human first selected an opaque FD wrapper, then recognized that
|
||||
future process-local handles need the same guard and directed a global
|
||||
marker under `tractor.msg` instead.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/_bindspace.py` - declaration, identity and live
|
||||
capability models.
|
||||
- `tractor/discovery/__init__.py` - public bindspace exports.
|
||||
- `tractor/msg/_local.py` - reusable process-local struct marker.
|
||||
- `tractor/msg/__init__.py` - public `ProcessLocal` export.
|
||||
- `tests/discovery/test_bindspace.py` - serialization and capability
|
||||
invariant coverage.
|
||||
- `tests/msg/test_process_local.py` - direct and nested wire rejection.
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - concrete initial model
|
||||
contract.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human selected the foundational Layer C capability model, then
|
||||
reviewed the staged patch and directed all eight follow-up changes:
|
||||
remove distracting ownership variation, consolidate invalid-model
|
||||
tests, derive containment checks from Literal aliases, require a
|
||||
stable inode and explain why the live handle is not a msgspec struct.
|
||||
The human subsequently corrected that last design choice: struct use
|
||||
does not imply IPC authorization, so `BindspaceHandle` should retain
|
||||
msgspec's generic storage benefits. The agent applied those
|
||||
human-directed edits. The human first chose an opaque process-local FD
|
||||
wrapper, then refined that decision to a reusable global marker under
|
||||
`tractor.msg` for future local handles. No direct manual source edits
|
||||
were observed.
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-22T04:20:26Z
|
||||
git_ref: 29141f0b
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - model bindspace capabilities
|
||||
|
||||
The human authorized the next incremental Layer C change after
|
||||
committing endpoint namespace visibility.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||
|
||||
Added frozen, serializable `BindspaceSpec` and
|
||||
`BindspaceIdentity` models for the initial Linux netns kind. Added a
|
||||
plain slotted `BindspaceHandle` which carries ownership and an optional
|
||||
namespace FD without being serializable by default msgspec encoding.
|
||||
|
||||
The live handle validates declaration/identity kind and name, ownership
|
||||
values, non-negative FDs and FD/inode correspondence. Serializable
|
||||
models reject empty identities and kinds without implementations.
|
||||
|
||||
Added round-trip, non-serialization, identity-mismatch, empty-identity
|
||||
and unsupported-kind tests. Ruff passed; discovery plus
|
||||
message-serialization coverage passed 125 tests with 2 xpasses.
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-25T04:55:57Z
|
||||
git_ref: fdccfd7e
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260825T045557Z_fdccfd7e_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
After committing the foundational bindspace capability model, the
|
||||
human authorized the next incremental Layer C change.
|
||||
|
||||
## Response summary
|
||||
|
||||
Added a borrow-only existing-netns context manager which pins stable
|
||||
identity through an open FD and always releases that local descriptor.
|
||||
The API does not create, enter or remove namespaces and rejects named
|
||||
path traversal.
|
||||
Staged review renamed the context to `attach_netns()`, introduced the
|
||||
readable `CURRENT_NETNS` alias and made key validation errors identify
|
||||
their exact owning field.
|
||||
Follow-up review made the empty-key error spell out that `None` is
|
||||
written as `CURRENT_NETNS` for `BindspaceSpec.key` declarations.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/_bindspace.py` - existing-netns lifecycle and key
|
||||
validation.
|
||||
- `tractor/discovery/__init__.py` - public lifecycle export.
|
||||
- `tests/discovery/test_bindspace.py` - current, named, missing and
|
||||
traversal coverage.
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - borrow-only lifecycle
|
||||
contract.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human selected the previously deferred borrow-only netns lifecycle
|
||||
as the next incremental Layer C change. The agent implemented the
|
||||
source changes. During staged review, the human selected
|
||||
`attach_netns()` terminology, requested explicit
|
||||
`BindspaceSpec.key = CURRENT_NETNS` semantics and field-specific key
|
||||
validation. Follow-up review requested the validation error itself
|
||||
connect `None` to `CURRENT_NETNS`. The agent applied those
|
||||
human-directed edits; no direct manual source edits were observed.
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-25T04:55:57Z
|
||||
git_ref: fdccfd7e
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - borrow existing netns bindspaces
|
||||
|
||||
The human reported the bindspace capability model committed and
|
||||
authorized the next incremental Layer C change.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||
|
||||
Added async `open_existing_netns()` as a borrow-only context manager.
|
||||
It opens the current process netns or a named entry under the standard
|
||||
iproute2 run directory, derives stable identity from the opened FD,
|
||||
and yields a borrowed process-local `BindspaceHandle`.
|
||||
|
||||
The context uses `O_CLOEXEC`, never creates, enters or removes a
|
||||
namespace, and synchronously closes only its FD on exit. Netns keys
|
||||
reject paths to keep named lookup beneath the run directory.
|
||||
|
||||
Added current, named, missing and traversal tests. Ruff and lock checks
|
||||
passed; discovery plus message coverage passed 132 tests with 2
|
||||
xpasses.
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-25T19:05:29Z
|
||||
git_ref: e1007547
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260825T190529Z_e1007547_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
After committing the attach-only network namespace lifecycle, the
|
||||
human authorized the next incremental Layer C change.
|
||||
|
||||
## Response summary
|
||||
|
||||
Added owned `open_netns()` creation and teardown through shielded
|
||||
pyroute2 worker calls. The context pins identity through
|
||||
`attach_netns()`, yields an owned handle, closes its FD before removal
|
||||
and cleans up despite caller cancellation.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/_bindspace.py` - owned netns lifecycle.
|
||||
- `tractor/discovery/__init__.py` - public lifecycle export.
|
||||
- `tests/discovery/test_bindspace.py` - ownership, ordering,
|
||||
cancellation and name requirements.
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - owned lifecycle and
|
||||
spawn-boundary contract.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human selected owned network namespace creation as the next
|
||||
incremental Layer C change. The agent implemented the source changes;
|
||||
no direct manual edits or follow-up corrections were observed.
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-25T19:05:29Z
|
||||
git_ref: e1007547
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - own created netns bindspaces
|
||||
|
||||
The human reported the attach-only netns lifecycle committed and
|
||||
authorized the next incremental Layer C change.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||
|
||||
Added async `open_netns()` as the owned counterpart to
|
||||
`attach_netns()`. It requires a named spec, creates through pyroute2 in
|
||||
a shielded worker call, attaches the resulting namespace FD and yields
|
||||
an owned process-local `BindspaceHandle`.
|
||||
|
||||
FD closure occurs before shielded namespace removal on normal,
|
||||
exceptional and cancelled exits. The context never calls `setns()`;
|
||||
namespace entry remains a spawn/bootstrap responsibility.
|
||||
|
||||
Privileged operations are faked in tests. Ruff and lock checks passed;
|
||||
discovery plus message coverage passed 137 tests with 2 xpasses.
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-25T19:18:45Z
|
||||
git_ref: 5b2a064a
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260825T191845Z_5b2a064a_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
The human pointed out that the agent had produced no changes after the
|
||||
previous commit and expected the next incremental implementation.
|
||||
|
||||
## Response summary
|
||||
|
||||
Corrected the sequencing mistake and added explicit serialized
|
||||
bindspace lifecycle policy plus generic `open_bindspace()` dispatch.
|
||||
Borrowed and owned netns contexts share a policy-neutral FD-pinning
|
||||
primitive while validating their public lifecycle before side effects.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/_bindspace.py` - lifecycle policy, ownership
|
||||
invariants, shared pinning and dispatch.
|
||||
- `tractor/discovery/__init__.py` - public policy and dispatcher exports.
|
||||
- `tests/discovery/test_bindspace.py` - lifecycle serialization,
|
||||
validation and both dispatcher branches.
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - explicit lifecycle
|
||||
policy independent of transport role.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human identified that the agent had accidentally repeated a
|
||||
summary of already committed work instead of implementing the next
|
||||
slice. That correction directly caused this lifecycle/dispatcher
|
||||
change to be implemented. The agent wrote the source changes; no
|
||||
direct manual edits were observed.
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-25T19:18:45Z
|
||||
git_ref: 5b2a064a
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - dispatch explicit bindspace lifecycle
|
||||
|
||||
The human noticed the agent had re-reported already committed work
|
||||
without changing the worktree. The agent confirmed the sequencing
|
||||
error and proceeded to the actual next Layer C slice.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||
|
||||
Added serialized `BindspaceSpec.lifecycle` policy with explicit
|
||||
`attach` and `open` choices. Added `open_bindspace()` dispatch without
|
||||
inferring resource ownership from listen/dial role.
|
||||
|
||||
Refactored FD attachment into policy-neutral `_pin_netns()` so
|
||||
borrowed and owned public lifecycles can share identity pinning while
|
||||
enforcing their own policy before side effects. Handle construction
|
||||
also verifies lifecycle and resulting ownership agree.
|
||||
|
||||
One composition error introduced during implementation was caught by
|
||||
the focused test: owned creation initially called the newly guarded
|
||||
public attach API. `_pin_netns()` fixed that layering error. Ruff and
|
||||
lock checks passed; discovery plus message coverage passed 138 tests
|
||||
with 2 xpasses.
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-25T23:46:31Z
|
||||
git_ref: b973e78c
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260825T234631Z_b973e78c_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
The human pointed out that the agent had again produced no worktree
|
||||
changes after the latest commit and expected actual next-layer work.
|
||||
|
||||
## Response summary
|
||||
|
||||
Corrected the sequencing mistake and added a process-local WireGuard
|
||||
interface configuration boundary. Secret/routing policy is separated
|
||||
from serializable maddr-derived tunnel declarations, redacted from
|
||||
repr and blocked from default wire encoding.
|
||||
Staged review clarified that address/network parser calls validate
|
||||
every declaration and do not select or consume a first address.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/_tunnel.py` - local WG config and validation.
|
||||
- `tractor/discovery/__init__.py` - public config export.
|
||||
- `tests/discovery/test_wg_config.py` - secrecy and validation coverage.
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - secret/config boundary
|
||||
for future interface provisioning.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human identified the second sequencing failure where the agent
|
||||
summarized an already committed slice instead of implementing the next
|
||||
one. That correction directly caused this new WG configuration layer
|
||||
to be implemented. During staged review, the human requested explicit
|
||||
explanation of the address and allowed-route validation loops. The
|
||||
agent applied that human-directed clarification; no direct manual edits
|
||||
were observed.
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-25T23:46:31Z
|
||||
git_ref: b973e78c
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - separate local WireGuard configuration
|
||||
|
||||
The human again noticed the agent had re-reported already committed
|
||||
work without changing the worktree. The agent confirmed the sequencing
|
||||
mistake and moved to the next unimplemented dependency.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_config.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||
|
||||
Added process-local `WGInterfaceConfig` for private/preshared keys,
|
||||
local interface CIDRs, peer allowed CIDRs, listen port and persistent
|
||||
keepalive. Secrets are redacted from repr and the global
|
||||
`ProcessLocal` marker blocks default wire encoding.
|
||||
|
||||
Removed the unused serialized `WGTunnelSpec.maybe_allowed_ips`
|
||||
placeholder so maddr-derived declarations retain only public identity,
|
||||
endpoint and interface selection. Added validation for keys, CIDRs,
|
||||
ports and keepalive before future kernel mutation.
|
||||
|
||||
Ruff and lock checks passed. Focused WG coverage passed 25 tests;
|
||||
discovery plus message coverage passed 145 tests with 2 xpasses.
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-26T00:14:42Z
|
||||
git_ref: dcdf4d82
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260826T001442Z_dcdf4d82_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
The human selected an explicit peer-list model rather than a
|
||||
dial-only single-peer shortcut before `open_wg_iface()`.
|
||||
|
||||
## Response summary
|
||||
|
||||
Added process-local per-peer configuration and refactored interface
|
||||
configuration to own a unique peer tuple. This supports multi-peer
|
||||
listeners and dial targets without overloading role-dependent tunnel
|
||||
maddr identity.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/_tunnel.py` - peer model and interface peer list.
|
||||
- `tractor/discovery/__init__.py` - public peer-config export.
|
||||
- `tests/discovery/test_wg_config.py` - peer secrecy and validation.
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - explicit multi-peer
|
||||
provisioning contract.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human chose explicit peer lists over a dial-only implementation so
|
||||
listeners can represent multiple client public keys and routing
|
||||
policies. The agent implemented that human-selected design; no direct
|
||||
manual source edits were observed.
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-26T00:14:42Z
|
||||
git_ref: dcdf4d82
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - model explicit WireGuard peers
|
||||
|
||||
After committing local WG interface configuration, the human selected
|
||||
an explicit peer-list model before interface provisioning.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_config.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||
|
||||
Added process-local `WGPeerConfig` with public key, allowed CIDRs,
|
||||
optional endpoint, preshared key and keepalive. Refactored
|
||||
`WGInterfaceConfig` to own a tuple of unique peers beside its private
|
||||
key, local addresses and listen port.
|
||||
|
||||
Peer PSKs remain redacted and nested `ProcessLocal` sentinels prevent
|
||||
default wire encoding. Validation covers keys, routes, endpoints,
|
||||
ports, keepalive and duplicate peers.
|
||||
|
||||
Ruff and lock checks passed. Focused peer/config coverage passed 11
|
||||
tests. A broad unrelated registrar cancellation case timed out after
|
||||
86 passes and 2 xpasses; its isolated rerun passed.
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-26T00:34:30Z
|
||||
git_ref: 6dd39da0
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260826T003430Z_6dd39da0_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
After committing explicit WireGuard peer configuration, the human
|
||||
authorized the next incremental lifecycle change.
|
||||
|
||||
## Response summary
|
||||
|
||||
Added owned WireGuard interface provisioning and teardown through
|
||||
cancellation-shielded pyroute2 worker calls. Role-specific policy is
|
||||
validated before mutation, local addresses and explicit peers are
|
||||
configured, and owned interfaces are removed on exit.
|
||||
Staged review clarified role-specific test assertions, added the
|
||||
second peer's endpoint-port check, included conflicting values in
|
||||
policy errors and aligned annotated assignment with `/py-codestyle`.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/_tunnel.py` - role policy and WG iface lifecycle.
|
||||
- `tractor/discovery/__init__.py` - public lifecycle and role exports.
|
||||
- `tests/discovery/test_wg_iface_lifecycle.py` - policy and cancellation
|
||||
coverage with privileged calls faked.
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - initial owned WG iface
|
||||
lifecycle contract.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human selected continuation from explicit peer modeling into the
|
||||
owned WireGuard interface lifecycle. During staged review, the human
|
||||
requested clearer role assertions, complete endpoint checks, concrete
|
||||
error values and inline annotated assignment. The agent applied those
|
||||
human-directed edits; no direct manual source edits were observed.
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-26T00:34:30Z
|
||||
git_ref: 6dd39da0
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - own WireGuard interface lifecycle
|
||||
|
||||
After committing explicit peer configuration, the human authorized the
|
||||
next incremental lifecycle change.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_iface_lifecycle.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||
|
||||
Added owned async `open_wg_iface()` lifecycle with role-policy
|
||||
validation before side effects. Listen bearer ports configure the local
|
||||
listener; dial bearers fill omitted endpoints only for the selected
|
||||
maddr peer.
|
||||
|
||||
Pyroute2 worker code creates the WireGuard link, assigns local
|
||||
addresses, configures private key/listen port and every explicit peer,
|
||||
raises the link, and removes it on exit. Partial create failures clean
|
||||
up synchronously; async create/remove calls are cancellation-shielded.
|
||||
|
||||
Privileged operations are faked in tests. Ruff and lock checks passed;
|
||||
focused WG lifecycle/config/inspection coverage passed 22 tests. A
|
||||
broad unrelated registrar cancellation parameter timed out after 76
|
||||
passes and 2 xpasses; its isolated rerun passed.
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-26T02:24:34Z
|
||||
git_ref: 2245f094
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260826T022434Z_2245f094_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
After committing the owned WireGuard interface lifecycle, the human
|
||||
asked to continue with the next established incremental step.
|
||||
|
||||
## Response summary
|
||||
|
||||
Added a WG-specific composition context which owns one declared
|
||||
bindspace and an ordered interface stack. It snapshots caller-owned
|
||||
layer order before checkpointing, enters interfaces outermost-first,
|
||||
and guarantees inside-out interface teardown before bindspace release.
|
||||
Its inbound type shows the `(WGTunnelSpec, WGInterfaceConfig)` pairing
|
||||
directly, and its docstring depicts the stack and unwind order.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/_tunnel.py` - bindspace/interface stack driver.
|
||||
- `tractor/discovery/__init__.py` - public layer and driver exports.
|
||||
- `tests/discovery/test_wg_iface_lifecycle.py` - mutable-input,
|
||||
cancellation and nested teardown-order coverage.
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - implemented composition
|
||||
and ownership-order contract.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human accepted and committed the preceding reviewed lifecycle
|
||||
boundary, then explicitly requested the next step. That selected the
|
||||
previously established bindspace/WireGuard composition boundary. In
|
||||
staged review, the human requested an explicit interface-stack analogy
|
||||
and a small ASCII diagram explaining entry and exit order. The human
|
||||
then identified `WGInterfaceLayer` as an unnecessary single-use alias
|
||||
which obscured the inbound tuple shape. The agent applied both
|
||||
human-directed edits. The human accepted the fake lifecycle test as a
|
||||
focused control-flow check which avoids pyroute2 overhead, while noting
|
||||
their general preference for end-to-end coverage. No direct manual
|
||||
source edits were observed.
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-26T02:24:34Z
|
||||
git_ref: 2245f094
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - compose WireGuard bindspace lifecycles
|
||||
|
||||
After committing the owned WireGuard interface lifecycle, the human
|
||||
requested the next incremental change.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_iface_lifecycle.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||
|
||||
Added `open_wg_bindspace()` to compose one declared bindspace with an
|
||||
ordered sequence of WireGuard interface layers. The context enters the
|
||||
bindspace first, enters interfaces outermost-first through
|
||||
`AsyncExitStack`, yields the live `BindspaceHandle`, and removes
|
||||
interfaces inside-out before releasing or removing the namespace.
|
||||
|
||||
The caller's layer sequence is snapshotted before the first checkpoint,
|
||||
so concurrent mutation cannot alter resource entry or unwind order.
|
||||
Fake lifecycle tests clear a mutable input list during bindspace entry,
|
||||
cancel from the yielded scope, and prove the original stack unwinds in
|
||||
dependency-safe order.
|
||||
|
||||
Ruff passed and focused bindspace/WireGuard coverage passed 39 tests.
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-26T03:05:34Z
|
||||
git_ref: d130431c
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260826T030534Z_d130431c_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
After committing the reviewed bindspace/interface stack, the human
|
||||
asked to continue with the next established Layer C step.
|
||||
|
||||
## Response summary
|
||||
|
||||
Retained a serializable realized `BindspaceRef` on frozen tunnelled
|
||||
address declarations without carrying the process-local `Bindspace`
|
||||
capability. The namespace API remains tuple-shaped, using the declared
|
||||
key before realization and stable inode afterward. Existing transport
|
||||
peeling and unrealized serialization remain unchanged.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/_tunnel.py` - realized bindspace-ref retention and
|
||||
immutable annotation method.
|
||||
- `tests/discovery/test_tunnelled_addr.py` - ref, compatibility and
|
||||
mismatch coverage.
|
||||
- `tests/ipc/test_server_tunnel_boundary.py` - real listener diagnostic
|
||||
coverage for the realized inode.
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - realized namespace
|
||||
visibility contract.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human accepted and committed the preceding reviewed composition
|
||||
boundary, then requested the next step. Their stated preference for
|
||||
end-to-end tests informed use of the existing real TCP listener path to
|
||||
verify endpoint/server diagnostics, while focused unit coverage handles
|
||||
metadata invariants without pyroute2 overhead. During review, the human
|
||||
chose the final unshipped terminology: `BindspaceRef` for the
|
||||
serializable non-owning reference, `Bindspace` for the live
|
||||
process-local capability, `.ref` for that capability's reference, and
|
||||
`bindspace_ref` at the tunnel declaration API.
|
||||
They then requested the prerequisite model rename as a separate commit
|
||||
before this feature. The human also replaced the module-level helper
|
||||
with `TunnelledAddress.with_bindspace_ref()` and requested inline
|
||||
msgspec encode/decode expressions in the serialization assertions. The
|
||||
human chose not to add a second typed `.namespace` projection, and
|
||||
requested native tagged `TunnelledAddress` decoding remain as a separate
|
||||
design-plan follow-up. The agent applied those human-directed changes;
|
||||
no direct manual source edits were observed.
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-26T03:05:34Z
|
||||
git_ref: d130431c
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - retain realized bindspace identity
|
||||
|
||||
After committing WireGuard bindspace composition, the human requested
|
||||
the next incremental Layer C change.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_tunnelled_addr.py tests/ipc/test_server_tunnel_boundary.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||
|
||||
Added optional `BindspaceIdentity` metadata to frozen
|
||||
`TunnelledAddress` declarations and a pure
|
||||
`with_bindspace_identity()` annotation helper. Unrealized declarations
|
||||
retain their prior serialized shape. Realized declarations retain only
|
||||
serializable key/inode identity, never the FD-bearing capability.
|
||||
|
||||
The existing `.namespace` tuple contract remains compatible:
|
||||
unrealized declarations report `(kind, key)`, while realized
|
||||
declarations report the stable `(kind, inode)`. Name mismatches between
|
||||
the tunnel declaration and realized bindspace are rejected.
|
||||
|
||||
Unit coverage verifies immutability, serialization, delegation and
|
||||
mismatch handling. The existing real TCP listener test proves endpoint
|
||||
and server diagnostics expose the retained inode without implying that
|
||||
the process entered that namespace. Ruff passed and focused tunnel,
|
||||
listener and bindspace coverage passed 30 tests.
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-27T21:11:15Z
|
||||
git_ref: d130431c
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260827T211115Z_d130431c_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
During review, the human requested concrete names for the existing
|
||||
bindspace abstractions and asked to isolate that rename before realized
|
||||
reference retention.
|
||||
|
||||
## Response summary
|
||||
|
||||
Renamed the serializable resource record to `BindspaceRef`, the live
|
||||
FD-backed capability to `Bindspace`, and the capability's record field
|
||||
to `.ref`. Updated existing lifecycle APIs, tests and active design
|
||||
contracts without compatibility aliases.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/_bindspace.py` - renamed models and `.ref` field.
|
||||
- `tractor/discovery/_tunnel.py` - existing lifecycle annotations.
|
||||
- `tractor/discovery/__init__.py` - renamed public model exports.
|
||||
- `tests/discovery/test_bindspace.py` - renamed model contracts.
|
||||
- `tests/discovery/test_wg_iface_lifecycle.py` - live bindspace usage.
|
||||
- `ai/tpt-backends/00_shared_backend_contract.md` - shared terminology.
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - request, ref and live
|
||||
capability terminology.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human identified the earlier identity/handle terminology as too
|
||||
abstract for an IPC-passed non-owning reference and live resource. They
|
||||
selected `BindspaceRef`, `Bindspace`, and `.ref`, then requested this
|
||||
rename as a prerequisite commit separate from the realized-ref feature.
|
||||
The agent applied those human-directed names; no direct manual source
|
||||
edits were observed.
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-27T21:11:15Z
|
||||
git_ref: d130431c
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - simplify bindspace model names
|
||||
|
||||
While reviewing realized bindspace metadata, the human requested more
|
||||
concrete names for the existing bindspace model family and asked that
|
||||
the rename remain separate from the subsequent feature.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py tests/discovery/test_wg_iface_lifecycle.py ai/tpt-backends/00_shared_backend_contract.md ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||
|
||||
Renamed `BindspaceIdentity` to `BindspaceRef` for the serializable,
|
||||
non-owning, host-local resource record. Renamed `BindspaceHandle` to
|
||||
`Bindspace` for the live process-local FD-backed capability, and renamed
|
||||
its `.identity` field to `.ref`.
|
||||
|
||||
No compatibility aliases were added because the models are unshipped.
|
||||
Active design contracts now distinguish the serialized request, the
|
||||
wire-safe non-owning ref, and the live capability. Ruff passed, 518
|
||||
tests collected, and 53 focused bindspace/WireGuard/listener tests
|
||||
passed across the final two-commit tree.
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-27T23:25:00Z
|
||||
git_ref: d067505a
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260827T232500Z_d067505a_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
After committing the reviewed bindspace-reference changes, the human
|
||||
asked to continue with the next Layer C increment.
|
||||
|
||||
## Response summary
|
||||
|
||||
Added the private Linux network-namespace entry primitive required by
|
||||
future child bootstrap wiring. It verifies the inherited capability
|
||||
before `setns()`, constrains entry to network namespaces, and verifies
|
||||
the resulting process namespace before runtime initialization proceeds.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/spawn/_netns.py` - synchronous verified netns entry.
|
||||
- `tests/test_netns_spawn.py` - stale-FD, syscall and post-entry guards.
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - primitive ownership and
|
||||
remaining integration boundary.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human requested the next incremental implementation after accepting
|
||||
the prior split commits. Their established preference for end-to-end
|
||||
tests informed use of real FDs where possible; only privileged `setns()`
|
||||
and procfs observation are replaced to keep the suite unprivileged. In
|
||||
staged review, the human requested the full project license header,
|
||||
explicit comments on both intentional inode mismatches, and independent
|
||||
call traces proving both fake syscall and procfs hooks execute. The
|
||||
agent applied those human-directed revisions; no direct manual source
|
||||
edits were observed.
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-27T23:25:00Z
|
||||
git_ref: d067505a
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - verify pre-runtime netns entry
|
||||
|
||||
After committing realized bindspace references and their deferred codec
|
||||
plan, the human requested the next Layer C increment.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/spawn/_netns.py tests/test_netns_spawn.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||
|
||||
Added a private synchronous `enter_netns()` bootstrap primitive. It
|
||||
validates the inherited FD and expected inode, constrains `setns()` to
|
||||
`CLONE_NEWNET`, verifies `/proc/self/ns/net` afterward, and leaves FD
|
||||
closure to the future spawn-bootstrap caller.
|
||||
|
||||
Tests use real stand-in FDs while replacing only the privileged syscall
|
||||
and post-entry procfs observation. They prove stale FDs fail before
|
||||
entry, the syscall receives the exact namespace type, and bootstrap
|
||||
rejects an unexpected post-entry namespace. Ruff and all three focused
|
||||
tests passed.
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: addr-unpacking-rebase-conflict
|
||||
timestamp: 2026-08-28T00:38:05Z
|
||||
git_ref: e40c39da
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260828T003805Z_e40c39da_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
The human asked the agent to resolve the first conflict in the
|
||||
`wkt/addr_unpacking` rebase because the replayed history was primarily
|
||||
agent-generated.
|
||||
|
||||
## Response summary
|
||||
|
||||
Preserved the lazy-import and platform-aware behavior from the new
|
||||
base while restoring the replayed WireGuard address parsing and
|
||||
composition behavior. Reconciled the changed dependency lock with the
|
||||
replayed direct dependency contract and validated the combined result.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/_addr.py` - combined address-platform and tunnel
|
||||
typing changes.
|
||||
- `tractor/discovery/_multiaddr.py` - combined lazy imports with WG
|
||||
parse and composition dispatch.
|
||||
- `tractor/discovery/_tunnel.py` - retained cold-import behavior for
|
||||
the newly added WG implementation.
|
||||
- `tests/discovery/test_multiaddr.py` - adapted missing-codec
|
||||
monkeypatching to the lazily imported upstream protocol module.
|
||||
- `uv.lock` - restored the WG-capable multiaddr source and direct
|
||||
multibase metadata.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human chose to abort the first rebase attempt, restart with an
|
||||
explicit old-base boundary, and delegated the conflict resolution to
|
||||
the agent. The human also chose not to preserve the superseded
|
||||
module-level protocol-lookup test seam; the agent redirected the test
|
||||
to the upstream protocol module. No direct manual source edits were
|
||||
observed during this resolution.
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-28T00:38:05Z
|
||||
git_ref: e40c39da
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - addr-unpacking rebase resolution
|
||||
|
||||
The human delegated resolution of the first conflict while rebasing
|
||||
`wkt/addr_unpacking` from `d9a6e2e9` onto `85a44588`.
|
||||
|
||||
## Generated code
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_addr.py tractor/discovery/_multiaddr.py tractor/discovery/_tunnel.py`
|
||||
|
||||
Combined the rebased base's platform-aware address registries and lazy
|
||||
optional-dependency imports with the replayed `TunnelledAddress` typing
|
||||
and WireGuard multiaddr dispatch. Adapted the new tunnel module so
|
||||
importing tractor does not eagerly load `multiaddr`.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- uv.lock`
|
||||
|
||||
Regenerated the lockfile to retain the replayed WG-capable
|
||||
`py-multiaddr` revision and direct `py-multibase` dependency after the
|
||||
new base's independently changed lockfile silently omitted that hunk.
|
||||
|
||||
## Verification
|
||||
|
||||
Conflict-marker, syntax, lock consistency, lint, collection, focused
|
||||
discovery, and cold-import checks were selected for the resolution.
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: wg-netns-bootstrap-entry
|
||||
timestamp: 2026-08-28T01:22:05Z
|
||||
git_ref: 9ae7cd86
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260828T012205Z_9ae7cd86_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
After completing and auditing the stacked rebase, the human approved
|
||||
the next WireGuard Layer C slice: integrate the verified network
|
||||
namespace entry primitive into child spawn bootstrap.
|
||||
|
||||
## Response summary
|
||||
|
||||
Wire an optional child-owned namespace FD and expected inode into both
|
||||
child entrypoints. Keep namespace entry and FD closure synchronous and
|
||||
ordered before actor runtime initialization, while deliberately
|
||||
deferring backend FD transport and public API plumbing.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/spawn/_entry.py` - enter and release an optional inherited
|
||||
namespace capability before multiprocessing child runtime startup.
|
||||
- `tractor/_child.py` - consume the capability at the start of the
|
||||
Trio child bootloader, before patching or actor construction.
|
||||
- `tests/test_netns_spawn.py` - prove entry ordering, failure isolation,
|
||||
and exact FD ownership for both child entrypoints.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human selected spawn-time namespace integration as the next work
|
||||
slice and authorized implementation. No direct manual source edits
|
||||
were observed during generation.
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-28T01:22:05Z
|
||||
git_ref: 9ae7cd86
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - enter inherited netns during child bootstrap
|
||||
|
||||
The human asked to continue the WireGuard Layer C implementation after
|
||||
the rebased branch landed the verified `spawn._netns.enter_netns()`
|
||||
primitive.
|
||||
|
||||
## Generated code
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/_child.py tractor/spawn/_entry.py tests/test_netns_spawn.py`
|
||||
|
||||
Add one optional atomic `(namespace_fd, expected_inode)` bootstrap
|
||||
capability to the multiprocessing entrypoint and Trio child bootloader.
|
||||
Enter and verify the namespace before Trio patching, actor construction,
|
||||
runtime state, logging, Trio startup, parent connection, sockets, or
|
||||
worker threads, then close the child-owned inherited FD before
|
||||
continuing.
|
||||
|
||||
Add privilege-free direct-entrypoint tests using real stand-in FDs and
|
||||
a fake namespace syscall boundary. Cover helper-level no-op behavior,
|
||||
successful entry ordering, exact FD closure, malformed-FD isolation,
|
||||
primary-error preservation, and failure cleanup for both child
|
||||
bootstrap paths.
|
||||
|
||||
## Scope boundary
|
||||
|
||||
This increment does not transfer FDs through spawn backends or expose a
|
||||
public actor API. Backend-specific FD duplication, bootstrap failure
|
||||
reporting, and root-process namespace entry remain follow-up work.
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: trio-pre-handshake-child-death
|
||||
timestamp: 2026-08-28T04:51:19Z
|
||||
git_ref: fb6d81d3
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260828T045119Z_fb6d81d3_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
The human approved the next WireGuard Layer C implementation step after
|
||||
committing child-side network namespace bootstrap.
|
||||
|
||||
## Response summary
|
||||
|
||||
Restore and harden the boot-race design originally implemented by
|
||||
`3b0724eba85b4014170ed95773e1e41a60d5c513` before transferring
|
||||
namespace FDs. Race the initial peer handshake against child-process
|
||||
death so a pre-connection bootstrap failure surfaces instead of
|
||||
parking `ActorNursery.start_actor()` forever.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/spawn/_spawn.py` - provide the generic handshake/process
|
||||
death race.
|
||||
- `tractor/spawn/_trio.py` - use the race during Trio child startup.
|
||||
- `tests/test_spawning.py` - cover handshake-first and death-first
|
||||
schedules, simultaneous outcomes, waiter errors, and backend cleanup.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human approved continued Layer C implementation. The agent narrowed
|
||||
the next slice to failure detection after analysis showed FD transfer
|
||||
would otherwise introduce a parent hang. The human then identified the
|
||||
earlier `wait_for_peer_or_proc_death()` implementation and required
|
||||
explicit credit; the source docstring and commit message now cite its
|
||||
original commit rather than a later cherry-pick. The human directly
|
||||
reflowed `_trio.py` to the project line limit and normalized nearby
|
||||
quotes. The human also required naming the race helper in its lifecycle
|
||||
comment, explicit `trio.Cancelled` handling in waiter tests, expected
|
||||
cancellation diagnostics, and a `trio.Process`-constrained test double.
|
||||
After mainline portability verification, the human chose to commit the
|
||||
fix on the current WireGuard branch and require its cherry-pick onto
|
||||
`main` as a blocker in the eventual WireGuard pull request.
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-28T04:51:19Z
|
||||
git_ref: fb6d81d3
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - detect child death before parent handshake
|
||||
|
||||
The human asked to continue WireGuard Layer C after child entrypoints
|
||||
learned to consume inherited network namespace capabilities.
|
||||
|
||||
## Generated code
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/spawn/_spawn.py tractor/spawn/_trio.py tests/test_spawning.py`
|
||||
|
||||
Race a spawned child's initial parent handshake against its process
|
||||
wait operation. Return the established channel when the handshake wins;
|
||||
raise `ActorFailure` with the exit status when the process dies first.
|
||||
Use the race in the Trio exec backend instead of waiting indefinitely
|
||||
on `IPCServer.wait_for_peer()`.
|
||||
|
||||
Add deterministic Trio tests for handshake-first, death-first,
|
||||
simultaneous, and waiter-error schedules, controlling readiness with
|
||||
events rather than sleeps. Exercise the full Trio backend with a fake
|
||||
dead process to prove pre-publication event registration and exact
|
||||
failed-startup cleanup.
|
||||
|
||||
## Scope boundary
|
||||
|
||||
This prerequisite prevents pre-connection namespace-entry failures
|
||||
from hanging their parent. Namespace FD transfer itself remains the
|
||||
next commit.
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: trio-bindspace-fd-transport
|
||||
timestamp: 2026-08-28T17:29:43Z
|
||||
git_ref: 2ca8c570
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260828T172943Z_2ca8c570_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
After committing generic pre-handshake child-death detection, the human
|
||||
approved the proposed next Layer C slice: transport a live bindspace
|
||||
capability through the Trio spawn backend.
|
||||
|
||||
## Response summary
|
||||
|
||||
Plumb an optional process-local `Bindspace` through actor spawn APIs.
|
||||
Give a Trio exec child its own inherited namespace descriptor, preserve
|
||||
existing caller `pass_fds`, transport the FD/inode pair through the CLI,
|
||||
and close the temporary parent descriptor on every open-process outcome.
|
||||
Reject unsupported multiprocessing transport explicitly. Exercise the
|
||||
complete Trio path with a real actor that enters a distinct network
|
||||
namespace and reports its namespace and inherited-FD inodes over RPC.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/runtime/_supervise.py` - accept and relay a child bindspace.
|
||||
- `tractor/to_actor/_api.py` - expose bindspace spawn configuration.
|
||||
- `tractor/spawn/_spawn.py` - relay the bindspace to spawn backends.
|
||||
- `tractor/spawn/_trio.py` - duplicate, transfer, and close the FD.
|
||||
- `tractor/spawn/_mp.py` - reject unsupported MP transport.
|
||||
- `tractor/_child.py` - parse and forward the atomic CLI capability.
|
||||
- `tests/test_netns_spawn.py` - cover transport and cleanup schedules.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human approved the Trio-first transport boundary after reviewing
|
||||
the proposed dependency order, then supplied staged-diff comments across
|
||||
multiple review passes. Human-directed revisions replaced abstract
|
||||
ownership wording with concrete FD and process terminology, clarified
|
||||
descriptor cleanup, preserved existing `pass_fds` explicitly, and
|
||||
replaced the mocked success-path test with a real child actor, real
|
||||
`setns()`, handshake, and RPC round trip inside unprivileged user/network
|
||||
namespaces. Follow-up review added direct parent FD-table verification
|
||||
that the temporary child-bound duplicate is closed after successful
|
||||
spawn.
|
||||
|
||||
Multiprocessing FD reduction and root-process namespace entry remain
|
||||
separate follow-up slices. No direct manual source edits were observed;
|
||||
the review-driven source revisions were applied by the agent.
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-28T17:29:43Z
|
||||
git_ref: 2ca8c570
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - transport bindspace FD through Trio spawn
|
||||
|
||||
The human approved the next WireGuard Layer C increment after landing
|
||||
pre-handshake child-death detection.
|
||||
|
||||
## Generated code
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/_child.py tractor/runtime/_supervise.py tractor/spawn/_spawn.py tractor/spawn/_trio.py tractor/spawn/_mp.py tractor/to_actor/_api.py tests/test_netns_spawn.py`
|
||||
|
||||
Thread an optional live `Bindspace` through actor spawn APIs. For the
|
||||
Trio backend, duplicate its namespace FD, append that temporary
|
||||
descriptor to existing `pass_fds`, and send the atomic descriptor/inode
|
||||
pair through the child CLI. Close the parent temporary duplicate as
|
||||
soon as `open_process()` returns, raises, or is cancelled, while the
|
||||
child consumes its inherited descriptor before runtime startup.
|
||||
|
||||
Reject bindspace transport explicitly in multiprocessing backends until
|
||||
their descriptor-reduction slice is implemented. Add privilege-free
|
||||
tests for CLI forwarding, merged pass-FD state, caller immutability,
|
||||
source/duplicate ownership, exec failure, cancellation, absent live
|
||||
authority, death-before-handshake cleanup, and MP rejection.
|
||||
|
||||
## Scope boundary
|
||||
|
||||
Multiprocessing FD reduction and root-process namespace entry remain
|
||||
follow-up work.
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-net-api-extraction
|
||||
timestamp: 2026-08-30T02:52:01Z
|
||||
git_ref: b1f6ade8
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260830T025201Z_b1f6ade8_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
After rebasing the WireGuard Layer C stack, the human questioned the
|
||||
placement of bindspace APIs under `tractor.discovery`, considered new
|
||||
public package names, selected `tractor.net`, and authorized the
|
||||
implementation.
|
||||
|
||||
## Response summary
|
||||
|
||||
Extracted bindspace and tunnel implementations into a lazy
|
||||
`tractor.net` package. Moved public multiaddr, bindspace, tunnel, and
|
||||
WireGuard symbols out of `tractor.discovery`, updated internal imports,
|
||||
tests, examples, and documentation, and added cold-import regressions
|
||||
that keep optional networking dependencies off the root import path.
|
||||
Current-netns attachment pins `/proc/thread-self/ns/net` so calls from
|
||||
non-leader threads retain the caller's namespace rather than the process
|
||||
leader's.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/net/` - lazy public network API and implementation modules.
|
||||
- `tractor/discovery/` - retain actor discovery and internal address parsing.
|
||||
- `tractor/__init__.py` - expose `tractor.net` lazily.
|
||||
- `tests/net/` - network declaration and lifecycle coverage.
|
||||
- `tests/test_lazy_imports.py` - enforce the lazy package boundary.
|
||||
- `examples/multihost/wg_lan/` - use the public network package.
|
||||
- `docs/` - document network APIs separately from actor discovery.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human rejected `tractor.discovery` as the long-term public home,
|
||||
considered tunnel- and namespace-specific alternatives, and selected
|
||||
the broader `tractor.net` boundary because bindspaces may include plain
|
||||
netns, WireGuard, VRF, veth, and later network resources. The agent
|
||||
applied the resulting source changes; no direct manual edits were
|
||||
observed.
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-30T02:52:01Z
|
||||
git_ref: b1f6ade8
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - extract public network APIs
|
||||
|
||||
The human questioned whether bindspace and WireGuard lifecycle APIs
|
||||
belonged under actor discovery, selected the proposed `tractor.net`
|
||||
boundary, and authorized implementation.
|
||||
|
||||
## Generated code
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/net tractor/discovery tractor/__init__.py`
|
||||
|
||||
Move bindspace and tunnel implementations into a lazy public network
|
||||
package. Keep actor discovery focused on registry and lookup behavior,
|
||||
while exposing bindspace, tunnel, WireGuard, and multiaddr declarations
|
||||
through `tractor.net` without loading optional networking dependencies
|
||||
during `import tractor`.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tests/net tests/test_lazy_imports.py tests/ipc examples/multihost/wg_lan docs`
|
||||
|
||||
Move network-focused tests to `tests/net`, update internal and public
|
||||
imports, verify lazy symbol resolution and removed discovery exports,
|
||||
and document the new package boundary.
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: root-bindspace-bootstrap
|
||||
timestamp: 2026-08-30T02:52:02Z
|
||||
git_ref: b1f6ade8
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260830T025202Z_b1f6ade8_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
The human chose to defer multiprocessing bindspace FD transport because
|
||||
that backend may be removed, selected root/single-actor namespace
|
||||
bootstrap as the next stage, approved caller-thread restoration and
|
||||
public `tractor.net.open_wg_bindspace()` composition, and authorized
|
||||
implementation after the stack rebase.
|
||||
|
||||
## Response summary
|
||||
|
||||
Added `bindspace=` to `tractor.open_root_actor()`. Root startup now
|
||||
duplicates and validates the live namespace FD, enters before any
|
||||
debugger, registry, IPC, or actor-runtime work, and synchronously
|
||||
restores the caller thread's original namespace after complete root
|
||||
teardown. Added deterministic failure/cancellation coverage, a real UDS
|
||||
root actor E2E across two network namespaces, and an executable public
|
||||
WireGuard-bindspace/root composition regression. Bound roots reject the
|
||||
persistent `mp_forkserver` backend because a helper from an earlier
|
||||
runtime may retain a stale namespace; the public annotation also remains
|
||||
runtime-resolvable without importing `tractor.net` eagerly.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/_root.py` - public root bindspace lifecycle integration.
|
||||
- `tractor/spawn/_netns.py` - temporary thread-local netns enter/restore.
|
||||
- `tests/test_netns_spawn.py` - deterministic and real root regressions.
|
||||
- `tests/net/test_wg_iface_lifecycle.py` - public root composition test.
|
||||
- `docs/api/net.rst` - root ownership and restoration contract.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human explicitly deferred multiprocessing support, selected root
|
||||
bootstrap as the next implementation stage, approved the requirement to
|
||||
restore the caller's original namespace so owned teardown can complete,
|
||||
and approved composition through public `tractor.net` contexts rather
|
||||
than a new one-shot convenience API. The human later rewrote
|
||||
`_enter_netns_temporarily()` to separate restoration from FD cleanup and
|
||||
make the transition flow easier to inspect. The agent retained that
|
||||
intent with nested lexical FD ownership while fixing setup-error masking,
|
||||
conditional restoration, and duplicate-FD cleanup found during the
|
||||
requested audit.
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-30T02:52:02Z
|
||||
git_ref: b1f6ade8
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - bootstrap the root actor in a bindspace
|
||||
|
||||
The human deferred multiprocessing descriptor transport, selected root
|
||||
namespace bootstrap as the next Layer C stage, approved enter/restore
|
||||
semantics and public `tractor.net` composition, and authorized
|
||||
implementation.
|
||||
|
||||
## Generated code
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/_root.py tractor/spawn/_netns.py`
|
||||
|
||||
Add an optional realized `Bindspace` to `open_root_actor()`. Duplicate
|
||||
its namespace FD, enter before debugger, registry, IPC, or runtime
|
||||
startup, and restore the caller thread's original namespace after root
|
||||
teardown. Preserve caller FD ownership and primary errors across
|
||||
restoration and close failures.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tests/test_netns_spawn.py tests/net/test_wg_iface_lifecycle.py docs/api/net.rst`
|
||||
|
||||
Exercise same-netns behavior, body errors, cancellation, restoration
|
||||
failures, missing and closed FDs, real root entry/restoration under an
|
||||
unprivileged user namespace, and public WireGuard-bindspace/root
|
||||
composition.
|
||||
|
|
@ -47,7 +47,7 @@ Here is a small example from piker,
|
|||
We should take whatever common API is needed to support this and
|
||||
distill it into a
|
||||
```python
|
||||
tractor.discovery.parse_endpoints(
|
||||
tractor.net.parse_endpoints(
|
||||
) -> dict[
|
||||
str,
|
||||
list[Address]
|
||||
|
|
|
|||
|
|
@ -112,7 +112,13 @@ Hard constraints learned from the existing two:
|
|||
- **`.bindspace` semantics**: "the address' bindable space" —
|
||||
ip/host for `tcp`, the socket-file *directory* for `uds`. For
|
||||
the new backends: the TIPC *scope* (§1 of plan 01), the iroh
|
||||
*ALPN + relay/discovery realm* (plan 02), the netns (plan 03).
|
||||
*ALPN + relay/discovery realm* (plan 02). Do not overload this
|
||||
transport-level bind selector with process namespace lifecycle.
|
||||
Plan 03 augments an maddr/address declaration with a serializable
|
||||
`BindspaceSpec` request and host-local `BindspaceRef`. A scoped,
|
||||
non-serializable `Bindspace` carries that ref and owns the FD/lifetime
|
||||
used during spawn bootstrap before a concrete address reaches
|
||||
transport bind.
|
||||
`Address.namespace` is already spec'd in the Protocol as
|
||||
"the if-available OS-specific network namespace key" and is
|
||||
currently unimplemented by both backends — plan 03 is the
|
||||
|
|
|
|||
|
|
@ -22,24 +22,23 @@ onto `trio` as the library's sans-io layer allows.
|
|||
## 1. What exists today (verified, per #482)
|
||||
|
||||
- `wrap_address()` accepts maddr `str`s (leading-`/` dispatch,
|
||||
`_addr.py:262`) but `parse_maddr()` only knows
|
||||
`/ip4|ip6/<h>/tcp/<p>` and `/unix/<p>`; a `.../wg/u<key>`
|
||||
maddr raises `ValueError('Unsupported multiaddr protocol
|
||||
combo')`.
|
||||
`_addr.py:262`). `parse_maddr()` and `mk_maddr()` support plain
|
||||
TCP/UDS addresses plus nested, canonical bearer-first `/wg/`
|
||||
stacks represented locally as `TunnelledAddress` wrappers.
|
||||
- there is no `wg` proto in the multiaddr *spec* yet, but
|
||||
multiformats/py-multiaddr#108 (key form `u<base64url>`) is
|
||||
**merged** as of 2026-07-28 (`f86519da`) — and unreleased, the
|
||||
latest `0.2.0` predating it. Spec registration is still tracked
|
||||
by multiformats/py-multiaddr#107 and gh #483.
|
||||
- so **today's deployable story is declarative**: run `wg-quick`
|
||||
out-of-band, parse the maddr, strip to the overlay
|
||||
- **today's deployable story remains declarative**: run `wg-quick`
|
||||
out-of-band, parse the maddr, strip its wrapper to the overlay
|
||||
`(host, port)`, verify the pubkey against the live tunnel,
|
||||
hand the overlay addr to `registry_addrs=`/`tpt_bind_addrs=`.
|
||||
#482 already contains working example code for exactly this.
|
||||
- `Address.namespace` exists in the Protocol
|
||||
(`_addr.py:94-101`, "the if-available OS-specific network
|
||||
namespace key") and **no backend implements it**. This plan is
|
||||
its first consumer.
|
||||
namespace key"). `TunnelledAddress` implements it from its spec;
|
||||
no concrete transport backend implements it yet.
|
||||
|
||||
## 2. Three layers, three PRs
|
||||
|
||||
|
|
@ -123,7 +122,7 @@ that the key decodes to exactly 32 bytes, so a truncated key is a
|
|||
```
|
||||
/ip4/192.168.1.50/udp/51820/wg/u<A_pub>/ip4/10.0.11.1/tcp/1616
|
||||
\_______ bearer __________/\__ key __/\______ overlay ______/
|
||||
underlay, wg `ListenPort` the ONLY part we bind
|
||||
underlay, wg `ListenPort` the `MsgTransport` bind
|
||||
```
|
||||
|
||||
The `/wg/` segment is **infix, not suffix** — the segments
|
||||
|
|
@ -150,11 +149,11 @@ Observed protocol-name lists, for writing the `match`:
|
|||
- so the three parts have **three different owners**, and only the
|
||||
third is an `Endpoint`:
|
||||
|
||||
| part | bound by | in the runtime? |
|
||||
| part | socket owner / provisioner | runtime role |
|
||||
| --- | --- | --- |
|
||||
| bearer | kernel, via `wg-quick`/`pyroute2` | no |
|
||||
| `/wg/u<key>` | nothing — it's an identity | no, verified out-of-band |
|
||||
| overlay | `tractor`'s `IPCServer` | **yes**, as `.overlay` |
|
||||
| bearer | kernel-owned; externally provisioned in layer A, tractor bindspace-provisioned in layer C | control-plane metadata, never an `Endpoint` |
|
||||
| `/wg/u<key>` | nothing — it's an identity | parsed and explicitly verified |
|
||||
| overlay | `tractor`'s `IPCServer` | application `MsgTransport`, as `.overlay` |
|
||||
|
||||
This owner-split is the real axis of the design, *not* whether
|
||||
the maddr stack is "composed" (it is).
|
||||
|
|
@ -197,9 +196,9 @@ Observed protocol-name lists, for writing the `match`:
|
|||
- `mk_maddr()` inverse for `TunnelledAddress` is just
|
||||
`.encapsulate()` composition; don't rebuild `str`s by hand.
|
||||
- **pending an upstream release**: py-multiaddr#108 is merged, so
|
||||
`Multiaddr('/…/wg/u…')` parses — but off a `[tool.uv.sources]`
|
||||
`rev` pin, since no release carries the codec. Gate the tests
|
||||
on `_have_wg_maddr_proto()`, implemented as
|
||||
`Multiaddr('/…/wg/u…')` parses off a PEP 621 direct-revision pin,
|
||||
since no release carries the codec. Gate parser entry on
|
||||
`_wg_proto_code()`, implemented as
|
||||
`protocols.protocol_with_name('wg')` under
|
||||
`except ProtocolNotFoundError`. Do **not** probe by parsing a
|
||||
dummy like `Multiaddr('/wg/uAAAA')` — the codec enforces a
|
||||
|
|
@ -207,20 +206,21 @@ Observed protocol-name lists, for writing the `match`:
|
|||
**not** hand-roll a `wg` parser in `tractor` — the whole point
|
||||
of #429 was dropping the NIH parser.
|
||||
|
||||
### 3.3 verification helper (pure, composable)
|
||||
### 3.3 pure codecs + explicit verification
|
||||
|
||||
Port #482 §2's helpers into `tractor/discovery/_tunnel.py` as
|
||||
*pure functions* + one impure probe, cleanly separated:
|
||||
Port #482 §2's pure helpers into
|
||||
`tractor/net/_tunnel.py`, keeping the impure probe cleanly
|
||||
separated until layer B:
|
||||
|
||||
```python
|
||||
def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure
|
||||
def wg8_pubkey(multibase_key: str) -> str: ... # pure
|
||||
def verify_wg_peer(spec: WGTunnelSpec) -> bool: ... # impure probe
|
||||
async def verify_wg_peer(spec: WGTunnelSpec) -> bool: ... # layer B
|
||||
```
|
||||
|
||||
In layer A `verify_wg_peer()` may shell out (`wg show <if>
|
||||
peers`), but it must be a *single* function so layer B swaps
|
||||
only its body. Never call it implicitly from
|
||||
Layer A's example-local `verify_wg_peer()` may shell out (`wg show
|
||||
<if> peers`), but layer B replaces that probe with one explicit async
|
||||
function backed by pyroute2. Never call it implicitly from
|
||||
`wrap_address()`/`parse_maddr()` — parsing must stay pure and
|
||||
side-effect-free; verification is the *caller's* explicit step
|
||||
(and later, the bindspace `@acm`'s).
|
||||
|
|
@ -243,7 +243,7 @@ side-effect-free; verification is the *caller's* explicit step
|
|||
(`proto_key`/`unwrap` identical to overlay), `wrap_address()`
|
||||
regression (a tunnelled maddr `str` → `TunnelledAddress`; a
|
||||
plain one → unchanged), and **a real end-to-end over a
|
||||
locally-created wg pair** gated on `CAP_NET_ADMIN` (see §5.3).
|
||||
locally-created wg pair** gated on `CAP_NET_ADMIN` (see §5.4).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -289,9 +289,12 @@ converting anything else — there is no perf argument here, only
|
|||
a "no foreign event loop in a trio actor" argument, which (1)
|
||||
already satisfies (a thread is not an event loop).
|
||||
|
||||
Explicitly **do not** pull in `trio-asyncio` for pyroute2: it
|
||||
would be the one place in the runtime where an asyncio loop
|
||||
exists for no reason.
|
||||
Explicitly **do not** pull in `trio-asyncio` for pyroute2 or infect
|
||||
every wg-using actor merely to service one-shot netlink calls. A
|
||||
dedicated `wgman` actor (§5.1) is the one plausible asyncio-hosted
|
||||
shape: it can use tractor's own `.to_asyncio` task linkage while
|
||||
keeping the foreign loop and provisioning authority out of ordinary
|
||||
actor processes.
|
||||
|
||||
### 4.2 API shape
|
||||
|
||||
|
|
@ -306,7 +309,8 @@ async def read_wg_peers(
|
|||
async def read_wg_pubkey(iface: str = 'wg0', ...) -> str: ...
|
||||
```
|
||||
|
||||
and `verify_wg_peer()` becomes a thin composition over the two.
|
||||
and `verify_wg_peer()` becomes a thin composition over one shared key
|
||||
snapshot.
|
||||
Note the pure-getter rule: no `read_wg_peers(..., create=True)`.
|
||||
|
||||
---
|
||||
|
|
@ -319,57 +323,269 @@ data-structure which can easily be passed to nested `@acm`s
|
|||
which consecutively setup nested net bindspaces for binding the
|
||||
endpoint addrs"*.
|
||||
|
||||
### 5.1 the composition
|
||||
Layer C is where tractor takes ownership of bindspace orchestration.
|
||||
For a fully bootstrapped deployment it may create the netns and wg
|
||||
iface, configure peers/routes, and ask the kernel to establish the
|
||||
bearer's UDP `ListenPort` through netlink/`pyroute2`. "Kernel-owned"
|
||||
describes the data-plane socket, not who provisions it: tractor owns
|
||||
the lifecycle while `Endpoint`/`MsgTransport` remain responsible only
|
||||
for the overlay application socket.
|
||||
|
||||
### 5.1 candidate default: first-child `wgman`
|
||||
|
||||
For a WG-enabled deployment profile, consider eagerly spawning one
|
||||
private **WireGuard manager** (`wgman`) as the root actor's logical
|
||||
first child. It is a narrow network-control-plane service, not a
|
||||
general worker and not an application-visible transport endpoint.
|
||||
The naive profile gets one manager for the actor tree; advanced
|
||||
deployments may disable it for pre-provisioned networking or place
|
||||
one manager in each capability/bindspace security domain.
|
||||
|
||||
"First child" describes supervision and teardown ordering, not a
|
||||
serial startup barrier. Submit the `wgman` spawn in the same startup
|
||||
wave as ordinary children, start its pyroute2 import, generic-netlink
|
||||
discovery and declared-tunnel reconciliation immediately, and publish
|
||||
a readiness signal separately. Sibling processes can boot in parallel;
|
||||
only their first WG-dependent bind/dial waits for manager readiness.
|
||||
This overlaps setup with actor-tree startup and avoids every sibling
|
||||
paying its own pyroute2/loop/socket initialization latency.
|
||||
|
||||
The initial manager can still call the sync helpers from §4.1. A
|
||||
natural follow-up is to spawn it with `infect_asyncio=True` and keep
|
||||
`AsyncWireGuard` clients alive on asyncio's host loop through
|
||||
`tractor.to_asyncio.run_task()`. Tractor then owns cross-loop task
|
||||
linkage, cancellation and error propagation, while normal siblings
|
||||
remain plain Trio actors. Keep one client per realized namespace or
|
||||
other kernel control domain; do not share a pyroute2 socket across
|
||||
domains merely to reduce object count.
|
||||
|
||||
Keep the authority surface deliberately small:
|
||||
|
||||
- accept structured inspect/verify/ensure/release requests derived
|
||||
from `WGTunnelSpec`, `BindspaceSpec` and explicit `role`; never
|
||||
expose arbitrary pyroute2 calls, shell commands or `setns()` RPC;
|
||||
- let the root/supervisor mediate access initially, or hand siblings
|
||||
a scoped manager capability; do not register a privileged `wgman`
|
||||
endpoint for unrestricted cluster-wide discovery;
|
||||
- never return private keys or namespace FDs to application actors;
|
||||
pass secrets and live capabilities into the manager through the
|
||||
supervisor-owned bootstrap path;
|
||||
- grant only the capabilities required for the manager's assigned
|
||||
domain. Prefer a manager already placed in that user/net namespace
|
||||
over one process holding ambient authority across every namespace;
|
||||
- make ensure/release idempotent and reference-count ownership so one
|
||||
sibling cannot tear down a tunnel still borrowed by another.
|
||||
|
||||
The root owns the manager's lifetime. `wgman` must outlive all
|
||||
siblings borrowing its tunnels and exit before the root drops the
|
||||
underlying namespace capabilities. A manager crash fails closed:
|
||||
dependent operations receive an explicit service error; restart, if
|
||||
enabled, reconciles declared state idempotently before advertising
|
||||
readiness again. Do not silently let siblings fall back to privileged
|
||||
local provisioning, since that defeats both the security boundary and
|
||||
the single warm control-plane benefit.
|
||||
|
||||
Treat eager `wgman` as a measured deployment-profile choice. Compare
|
||||
root startup with no WG declarations, pre-provisioned read-only WG,
|
||||
and runtime-managed tunnels before making it unconditional whenever
|
||||
the `wg` extra is installed. The intended invariant is "one warm
|
||||
manager per simple WG actor tree", not "every tractor program spawns
|
||||
a privileged child".
|
||||
|
||||
### 5.2 the composition
|
||||
|
||||
The maddr describes the composed network path and can be used as
|
||||
either a source/listen or destination/dial handle. It does **not**
|
||||
select the local instance of that network stack. A netns, VRF,
|
||||
interface, user namespace, or equivalent platform resource is
|
||||
orthogonal augmentation carried alongside/below the maddr.
|
||||
|
||||
Keep three bindspace representations with deliberately different roles
|
||||
and lifetimes:
|
||||
|
||||
```python
|
||||
class BindspaceSpec(msgspec.Struct, frozen=True):
|
||||
'''Serializable spawn/config declaration.'''
|
||||
kind: str # `netns`, later `vrf`, ...
|
||||
key: str|None # requested name/key, if any
|
||||
lifecycle: Literal['attach', 'open']
|
||||
|
||||
|
||||
class BindspaceRef(msgspec.Struct, frozen=True):
|
||||
'''Wire-safe, non-owning ref to the realized resource.'''
|
||||
kind: str
|
||||
key: str|None # mutable name, absent after unlink
|
||||
inode: int # host-local Linux nsfs fingerprint
|
||||
|
||||
|
||||
class Bindspace(ProcessLocal):
|
||||
'''Scoped, non-serializable capability for one live bindspace.'''
|
||||
spec: BindspaceSpec
|
||||
ref: BindspaceRef
|
||||
namespace_fd: int|None
|
||||
ownership: Literal['owned', 'borrowed']
|
||||
|
||||
|
||||
@acm
|
||||
async def open_bindspace(
|
||||
addr: TunnelledAddress,
|
||||
) -> AsyncGenerator[Address, None]:
|
||||
spec: BindspaceSpec,
|
||||
) -> AsyncGenerator[Bindspace, None]:
|
||||
'''
|
||||
Enter the net-bindspace implied by `addr`'s tunnel stack,
|
||||
yielding the *overlay* `Address` ready to bind/connect.
|
||||
|
||||
Nests: one `@acm` per tunnel segment, outermost-first, so
|
||||
a 2-deep stack is just two nested `async with`s and the
|
||||
teardown order is guaranteed by `trio`.
|
||||
Provision/borrow one bindspace and yield its live capability.
|
||||
|
||||
'''
|
||||
```
|
||||
|
||||
with per-tunnel-kind implementations:
|
||||
The initial model limits `BindspaceKind` to `netns` while preserving
|
||||
the required role split. `BindspaceSpec` is the requested resource and
|
||||
lifecycle policy. `BindspaceRef` is a serializable, non-owning,
|
||||
host-local record of the resource that was actually opened; it can be
|
||||
compared or logged, but cannot reopen, pin or enter that resource.
|
||||
`Bindspace` is the live capability and uses msgspec's generic struct
|
||||
storage by inheriting the global `tractor.msg.ProcessLocal` marker. Its
|
||||
hidden unsupported sentinel blocks direct and nested default msgspec
|
||||
encoding without a recursive IPC hot-path scan. The live bindspace
|
||||
validates any supplied FD against `BindspaceRef.inode`; explicit FD
|
||||
transfer belongs to the supervisor bootstrap path. An FD avoids
|
||||
name-resolution TOCTOU,
|
||||
survives rename/unlink, and identifies the exact namespace the parent
|
||||
provisioned. Extend the kind/field union only when a second platform
|
||||
resource is implemented.
|
||||
|
||||
`BindspaceSpec.lifecycle` is explicit serialized policy:
|
||||
`'attach'` borrows an existing resource and `'open'` creates/owns one.
|
||||
`open_bindspace()` dispatches that policy by bindspace kind. Never
|
||||
infer it from a listen/dial role: either role may use pre-provisioned
|
||||
or locally owned networking.
|
||||
|
||||
The first lifecycle implementation is deliberately borrow-only:
|
||||
`attach_netns()` opens either `/proc/self/ns/net` when
|
||||
`BindspaceSpec.key = CURRENT_NETNS`, or a named entry beneath
|
||||
`/var/run/netns`. It derives a `BindspaceRef` from the opened FD, yields
|
||||
`ownership='borrowed'`, and closes only that FD on exit. "Attach" does
|
||||
not call `setns()`; it never creates, enters or removes a namespace.
|
||||
Future `open_netns()` creation and owned teardown remain a separate
|
||||
privileged supervisor change.
|
||||
|
||||
`open_netns()` is that owned counterpart: it requires a named spec,
|
||||
creates through pyroute2 in a shielded worker call, attaches the live
|
||||
FD, and yields `ownership='owned'`. FD closure precedes another
|
||||
shielded pyroute2 removal call on every post-creation exit, including
|
||||
cancellation. It still never calls `setns()`; process entry remains a
|
||||
spawn/bootstrap operation.
|
||||
|
||||
`open_bindspace()` is **not** an address factory and does not return a
|
||||
`TunnelledAddress`. At the declaration layer, listener allocation can
|
||||
use the live bindspace to replace an overlay while preserving every
|
||||
tunnel:
|
||||
|
||||
```python
|
||||
async with open_bindspace(
|
||||
bindspace_spec,
|
||||
) as bindspace:
|
||||
listen_decl = declared_addr.get_random(
|
||||
bindspace=bindspace,
|
||||
)
|
||||
transport_addr = strip_tunnels(listen_decl)
|
||||
```
|
||||
|
||||
That sketch intentionally leaves the `.get_random()`/bindspace value
|
||||
contract open. A concrete transport call returns a concrete overlay;
|
||||
a declaration-level call may replace the overlay and return a new
|
||||
`TunnelledAddress`. In either case wrappers remain until the final
|
||||
transport bind/dial boundary, where `strip_tunnels()` is mandatory.
|
||||
At the listener boundary, keep the split explicit:
|
||||
`Endpoint.addr` is the peeled concrete address used for transport
|
||||
reflection, while `Endpoint.declared_addr` retains the original
|
||||
wrapper for namespace diagnostics and later bindspace orchestration.
|
||||
|
||||
Per-platform provisioning still composes one resource context per
|
||||
tunnel/bindspace layer:
|
||||
|
||||
```python
|
||||
@acm
|
||||
async def open_netns(name: str) -> AsyncGenerator[None, None]: ...
|
||||
async def open_netns(
|
||||
spec: BindspaceSpec,
|
||||
) -> AsyncGenerator[Bindspace, None]: ...
|
||||
|
||||
@acm
|
||||
async def open_wg_iface(spec: WGTunnelSpec) -> AsyncGenerator[WGTunnelSpec, None]: ...
|
||||
async def open_wg_iface(
|
||||
spec: WGTunnelSpec,
|
||||
config: WGInterfaceConfig,
|
||||
bindspace: Bindspace,
|
||||
role: Literal['listen', 'dial'],
|
||||
) -> AsyncGenerator[WGTunnelSpec, None]: ...
|
||||
```
|
||||
|
||||
and a driver that folds a list of specs into nested contexts
|
||||
(`contextlib.AsyncExitStack` for the N-deep case). The
|
||||
`WGInterfaceConfig` and each `WGPeerConfig` are process-local and
|
||||
rejected by the global `ProcessLocal` wire guard. The interface config
|
||||
owns its private key, local addresses and listen port; each peer owns
|
||||
its public key, allowed CIDRs, optional endpoint, preshared key and
|
||||
keepalive. Reprs redact private/preshared keys. `WGTunnelSpec` remains
|
||||
serializable public maddr-derived identity/endpoint data. This split
|
||||
supports multi-peer listeners without overloading the tunnel maddr.
|
||||
|
||||
The initial `open_wg_iface()` lifecycle is owned and Linux-only. It
|
||||
validates role-dependent bearer policy before side effects, creates the
|
||||
iface and addresses through `IPRoute`, configures keys/peers through
|
||||
`WireGuard`, raises the link, and removes it on every post-creation
|
||||
exit. Creation/removal run in shielded Trio worker calls. A listen
|
||||
bearer supplies the local listen port; a dial bearer supplies an
|
||||
omitted endpoint only for the selected maddr peer.
|
||||
|
||||
The composition driver folds a list of specs into nested contexts with
|
||||
`contextlib.AsyncExitStack` for the N-deep case. The
|
||||
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
|
||||
it already returns `dict[name, list[Address]]` and the
|
||||
it already returns
|
||||
`dict[name, list[Address|TunnelledAddress]]` and the
|
||||
`multiaddr_declare_eps.md` sketch anticipates the recursive
|
||||
`dict[str, list[Address]]|dict[...]` return for tunnelled
|
||||
entries. Extend it to carry the tunnel stack, not to *enter* it.
|
||||
|
||||
### 5.2 `Address.namespace`, at last
|
||||
`open_wg_bindspace()` is the initial driver for one bindspace and an
|
||||
ordered sequence of `(WGTunnelSpec, WGInterfaceConfig)` layers. It
|
||||
opens the bindspace first, enters WG interfaces outermost-first through
|
||||
`AsyncExitStack`, and yields the live `Bindspace` for endpoint
|
||||
allocation. Exit is inside-out, so every interface is removed while the
|
||||
namespace FD remains pinned; only then can an owned namespace be
|
||||
removed. Endpoint/channel lifetimes belong inside the yielded scope.
|
||||
|
||||
- `TunnelledAddress.namespace` → `(kind, id)` e.g.
|
||||
`('netns', 'tractor-wg0')`.
|
||||
- **and** the existing backends should implement it as `None`
|
||||
explicitly (they currently just don't define it), so the
|
||||
Protocol stops lying.
|
||||
- consumers to audit: nothing reads `.namespace` today — so
|
||||
adding it is safe, but the *point* is that
|
||||
`Endpoint`/`Server.pformat()` should start showing it (there's
|
||||
already a `# !TODO, always be ns aware!` +
|
||||
`f'|_netns: {netns}\n'` placeholder sitting in
|
||||
`Endpoint.pformat()`, `_server.py:645`). Fill that in; it's
|
||||
the cheapest possible proof the layer is wired.
|
||||
The caller supplies `role` to tunnel-resource contexts such as
|
||||
`open_wg_iface()`; do not infer it from maddr shape. Bindspace
|
||||
lifecycle remains the independent explicit policy above. The same
|
||||
composed maddr can name a server source or client destination (§5.4).
|
||||
|
||||
### 5.3 the netns/process reality — read this before designing
|
||||
### 5.3 `Address.namespace`, at last
|
||||
|
||||
- an unrealized `TunnelledAddress.namespace` reports its declared name
|
||||
as `(kind, key)`, e.g. `('netns', 'tractor-wg0')`;
|
||||
- `TunnelledAddress.with_bindspace_ref()` returns a frozen declaration
|
||||
annotated with `bindspace.ref`, never the FD-bearing `Bindspace`.
|
||||
Its `.namespace` reports `(kind, inode)` so the
|
||||
realized ref remains stable across rename or unlink;
|
||||
- existing plain backends implement it explicitly as `None`, so the
|
||||
Protocol does not lie and tunnel delegation needs no `getattr()`
|
||||
fallback.
|
||||
- `Endpoint.namespace` reads the retained declaration rather than its
|
||||
peeled transport addr; both `Endpoint.pformat()` and
|
||||
`Server.pformat()` expose that value as the cheapest proof the layer
|
||||
is wired.
|
||||
|
||||
Deferred follow-ups:
|
||||
|
||||
- add native tagged encoding for the complete `TunnelledAddress` graph,
|
||||
including its concrete overlay-address union, tunnel-spec union and
|
||||
optional `BindspaceRef`. Once that codec exists, tests should perform
|
||||
typed roundtrips instead of inspecting an untyped decoded payload.
|
||||
|
||||
Use `github/ns_aware@e4688cad` as prototype evidence, not code to
|
||||
cherry-pick unchanged. Its `/proc/<pid>/ns/<type>` inode reader and
|
||||
`ip netns identify` probe establish the useful `(key, inode)` reference
|
||||
record. Layer C should move that shape into `BindspaceRef`, avoid a
|
||||
subprocess where netlink/procfs suffices, and hold the namespace FD in
|
||||
`Bindspace` to pin the referenced resource.
|
||||
|
||||
### 5.4 the netns/process reality — read this before designing
|
||||
|
||||
**The headline consequence, stated up front**: netns is a
|
||||
**runtime-level config API, not an actor-app-code API.** It is
|
||||
|
|
@ -396,33 +612,65 @@ server bound in the old namespace.
|
|||
- entering a netns is *process-global-ish and irreversible-ish*
|
||||
in practice. Therefore: **netns membership belongs to the
|
||||
actor process, decided before the runtime binds**, not to a
|
||||
mid-life `@acm`. Design:
|
||||
- the root/parent decides the netns for a subactor and passes
|
||||
it in the spawn spec (there's already
|
||||
mid-life actor API. Design:
|
||||
- the root/parent decides the `BindspaceSpec`, provisions or
|
||||
borrows it, and passes the spec plus an inherited/transferred
|
||||
namespace-FD capability through the spawn backend (there's already
|
||||
`enable_transports`/`accept_addrs` plumbing at
|
||||
`_runtime.py:1595-1615` — the netns rides alongside).
|
||||
- the child, in `_runtime.async_main()` **before**
|
||||
`IPCServer.listen_on()`, enters it.
|
||||
- the mid-life `@acm` form is then only for the *root* /
|
||||
single-actor case, and for iface creation (which is
|
||||
genuinely scoped).
|
||||
- the child spawn/bootstrap trampoline calls `setns()` **before**
|
||||
`_runtime.async_main()`, `IPCServer.listen_on()`, parent-channel
|
||||
connection, or creation of any worker thread/socket.
|
||||
- `spawn._netns.enter_netns()` is the first private bootstrap
|
||||
primitive: it checks the inherited FD against the expected inode,
|
||||
calls `setns(fd, CLONE_NEWNET)`, and verifies
|
||||
`/proc/self/ns/net` before returning. It deliberately does not own
|
||||
or close the FD; spawn propagation and status reporting remain the
|
||||
caller's next integration boundary.
|
||||
- only after successful entry does the child drop namespace-entry
|
||||
privileges and initialize the actor runtime.
|
||||
- a root/single-actor process follows the same ordering: enter during
|
||||
root bootstrap, never after actor runtime startup.
|
||||
- iface/route/WG provisioning is genuinely scoped and remains under
|
||||
the parent/supervisor's `Bindspace` context.
|
||||
- document the constraint rather than hiding it; a
|
||||
`RuntimeError` if `open_netns()` is entered after any
|
||||
listener exists.
|
||||
- privileges: iface/netns creation needs `CAP_NET_ADMIN`.
|
||||
Never `sudo` from inside the runtime. Two supported modes:
|
||||
`RuntimeError` if namespace entry is attempted after bootstrap.
|
||||
- capabilities: iface/netns creation/config needs `CAP_NET_ADMIN`;
|
||||
entering an existing Linux namespace normally requires
|
||||
`CAP_SYS_ADMIN` in the owning user namespace. Never `sudo` from
|
||||
inside the runtime. A privileged parent/helper should provision the
|
||||
stack and open the namespace FD; the child receives only the scoped
|
||||
capability and temporary authority needed to enter it, then drops
|
||||
that authority before actor code runs. This separates create/config
|
||||
authority from enter/use authority and fits user-namespace/capability
|
||||
deployments without granting every actor broad ambient caps.
|
||||
Two supported modes remain:
|
||||
(i) pre-provisioned out-of-band (layers A/B — the default,
|
||||
and what #482 documents), (ii) runtime-managed when the
|
||||
process already holds the cap. Detect with a cheap
|
||||
`os.geteuid()==0 or CAP_NET_ADMIN in /proc/self/status`
|
||||
probe and *fail loudly with an actionable message* otherwise.
|
||||
and what #482 documents), (ii) runtime-managed when the supervising
|
||||
process/helper holds the required caps. Probe exact required caps and
|
||||
*fail loudly with an actionable message* otherwise.
|
||||
- role semantics are explicit:
|
||||
- `listen`: may create/own the local bindspace, iface, routes, WG
|
||||
peer/listener state, and random local overlay; lifetime normally
|
||||
extends through all listeners and the actor process.
|
||||
- `dial`: may borrow an actor-wide bindspace or ensure local routing
|
||||
and tunnel state reaches the remote stack; it does not own the
|
||||
remote maddr and may need no new local resource at all.
|
||||
- source/destination use is an operation property, never permanently
|
||||
encoded into the maddr or inferred from segment ordering.
|
||||
- teardown follows capability ownership, not just address type:
|
||||
- owned listener bindspaces tear down after endpoints/channels and
|
||||
the actor process have exited;
|
||||
- borrowed dial/actor-wide bindspaces only release their capability;
|
||||
- nested resources exit inside-out, but shared resources remain until
|
||||
their owning supervisor drops the final capability.
|
||||
- teardown must be idempotent and tolerant: an iface/netns
|
||||
already gone must not strand the rest of the teardown — the
|
||||
exact lesson `_uds.close_listener()`'s `FileNotFoundError`
|
||||
tolerance and `_serve_ipc_eps()`'s per-ep `try/except`
|
||||
encode. Mirror both.
|
||||
|
||||
### 5.4 tests for layer C
|
||||
### 5.5 tests for layer C
|
||||
|
||||
- unit: fold-N-tunnel-specs-into-nested-`@acm`s, with fakes; assert
|
||||
enter/exit ordering (outermost-last-out) via a trace list.
|
||||
|
|
@ -432,8 +680,23 @@ server bound in the old namespace.
|
|||
one and a subactor in the other, `find_actor()` across the
|
||||
tunnel. This is a *fantastic* test to have and is fully
|
||||
self-contained — no second host, no `sudo` in the test body.
|
||||
- the `to_thread`-netns-mismatch regression from §5.3, written
|
||||
- the `to_thread`-netns-mismatch regression from §5.4, written
|
||||
**first** (red), then the fix (green), per project convention.
|
||||
- bootstrap ordering: assert the child reports the expected namespace
|
||||
inode before parent-channel connect and listener creation.
|
||||
- FD capability: rename/unlink the namespace name after opening its FD
|
||||
and prove child entry still selects the pinned inode.
|
||||
- privilege drop: prove actor code lacks provisioning caps after entry.
|
||||
- role/ownership: fake listen/dial resources and assert owned listener
|
||||
teardown versus borrowed dial-handle release.
|
||||
- `wgman` bootstrap: prove sibling process startup overlaps manager
|
||||
reconciliation while the first WG operation still waits for its
|
||||
readiness signal.
|
||||
- `wgman` authority: reject arbitrary callers/operations and prove an
|
||||
unprivileged sibling cannot receive secrets, FDs or provisioning
|
||||
authority through the manager API.
|
||||
- `wgman` lifetime: prove it outlives tunnel borrowers, fails pending
|
||||
requests explicitly on crash and reconciles before restart-ready.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -448,7 +711,7 @@ machinery covers any iface-layer tunnel `pyroute2` can drive —
|
|||
`kind: ClassVar[str]`, and dispatch `open_*` by `match` on it.
|
||||
Design for it now (union + `match`), implement only `wg` +
|
||||
`netns`. `veth`-pairs-in-netns is the natural second one because
|
||||
it makes the §5.4 integration test possible without wg at all —
|
||||
it makes the §5.5 integration test possible without wg at all —
|
||||
consider doing it *first* for exactly that reason.
|
||||
|
||||
## 7. Non-goals
|
||||
|
|
@ -466,11 +729,17 @@ consider doing it *first* for exactly that reason.
|
|||
|
||||
| risk | mitigation |
|
||||
| --- | --- |
|
||||
| `to_thread` worker runs in the wrong netns | §5.3; pass `netns=` to pyroute2 or pin a worker; test-first |
|
||||
| py-multiaddr#108 merged but unreleased | `[tool.uv.sources]` `rev` pin + `_have_wg_maddr_proto()` gate; layer A's overlay-addr path works regardless |
|
||||
| `TunnelledAddress` leaks into `Endpoint` and breaks `inspect.getmodule()` | unwrap at parse/bindspace boundary; assert `not isinstance(ep.addr, TunnelledAddress)` in `Endpoint.__post_init__` |
|
||||
| `to_thread` worker runs in the wrong netns | §5.4; pass `netns=` to pyroute2 or pin a worker; test-first |
|
||||
| namespace name is renamed/replaced between provision and spawn | pass an open namespace FD; verify `(key, inode)` after child entry |
|
||||
| child starts sockets/threads before `setns()` | enter in the spawn bootstrap trampoline before `_runtime.async_main()`; assert inode ordering |
|
||||
| ambient capabilities leak into actor app code | split provision/enter authority and drop caps before runtime initialization |
|
||||
| dial path tears down a shared actor bindspace | encode ownership in `Bindspace`; borrowed bindspaces never remove resources |
|
||||
| py-multiaddr#108 merged but unreleased | PEP 621 direct-revision pin + `_wg_proto_code()` gate; replace with a release floor once published |
|
||||
| `TunnelledAddress` leaks into transport reflection/type dispatch | keep wrappers through declaration/bindspace handling, call `strip_tunnels()` at channel/endpoint boundaries, and retain the boundary regressions |
|
||||
| privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default |
|
||||
| pyroute2 0.9 asyncio core drags a loop into the actor | option (1) is a *thread*, not a loop; forbid `trio-asyncio` here (§4.1) |
|
||||
| pyroute2 0.9 asyncio core drags a loop into every actor | use a worker for one-shots; confine persistent asyncio to an infected `wgman` (§4.1, §5.1) |
|
||||
| eager `wgman` serializes or slows root bootstrap | spawn it in parallel; gate only WG-dependent operations on readiness; measure before making the profile unconditional |
|
||||
| `wgman` becomes a cluster-wide privilege oracle | keep it private/scoped, expose structured verbs only and split managers by capability domain |
|
||||
| netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` |
|
||||
|
||||
## 9. Follow-up issue seeds
|
||||
|
|
@ -482,6 +751,7 @@ consider doing it *first* for exactly that reason.
|
|||
- `wg` proto into the multiaddr **spec** (gh #483), then flip
|
||||
`MsgTransport.maddr` to always return `Multiaddr` (the third
|
||||
#443 bullet)
|
||||
- runtime-managed wg key rotation / peer add-remove as a
|
||||
`tractor` service actor — the natural "actor that owns the
|
||||
network" demo
|
||||
- first-child `wgman` prototype: concurrent bootstrap, scoped sibling
|
||||
access, infected-asyncio pyroute2 ownership and restart reconciliation
|
||||
- runtime-managed wg key rotation / peer add-remove through `wgman` —
|
||||
the natural "actor that owns the network" demo
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ Most-used names at a glance:
|
|||
core
|
||||
context
|
||||
discovery
|
||||
net
|
||||
errors
|
||||
msg
|
||||
trionics
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ already distributed-system aware.
|
|||
.. seealso::
|
||||
|
||||
:doc:`/explain/architecture` for the transport/server
|
||||
internals, :doc:`/api/discovery` for how channel addresses
|
||||
get registered and found, and :doc:`/api/msg` for the codec
|
||||
layer every channel speaks.
|
||||
internals, :doc:`/api/net` for network declarations,
|
||||
:doc:`/api/discovery` for how channel addresses get registered
|
||||
and found, and :doc:`/api/msg` for the codec layer every channel
|
||||
speaks.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
Network declarations and lifecycles
|
||||
===================================
|
||||
|
||||
``tractor.net`` provides address composition, bindspace declarations,
|
||||
and tunnel configuration. The package is lazy: importing
|
||||
``tractor`` or ``tractor.net`` does not load multiaddr, WireGuard, or
|
||||
pyroute2 implementation modules until a public symbol is used.
|
||||
|
||||
Multiaddr helpers
|
||||
-----------------
|
||||
|
||||
.. currentmodule:: tractor.net
|
||||
|
||||
.. autofunction:: mk_maddr
|
||||
|
||||
.. autofunction:: parse_maddr
|
||||
|
||||
.. autofunction:: parse_endpoints
|
||||
|
||||
Bindspaces
|
||||
----------
|
||||
|
||||
.. autoclass:: BindspaceSpec
|
||||
|
||||
.. autoclass:: BindspaceRef
|
||||
|
||||
.. autoclass:: Bindspace
|
||||
|
||||
.. autofunction:: attach_netns
|
||||
|
||||
.. autofunction:: open_netns
|
||||
|
||||
.. autofunction:: open_bindspace
|
||||
|
||||
Root actor composition
|
||||
----------------------
|
||||
|
||||
A live :class:`Bindspace` can scope the root actor itself. Compose the
|
||||
bindspace manager outside :func:`tractor.open_root_actor` so its network
|
||||
namespace remains pinned for the complete actor runtime::
|
||||
|
||||
async with tractor.net.open_wg_bindspace(
|
||||
bindspace_spec=bindspace_spec,
|
||||
layers=layers,
|
||||
role='listen',
|
||||
) as bindspace:
|
||||
async with tractor.open_root_actor(
|
||||
bindspace=bindspace,
|
||||
enable_transports=['uds'],
|
||||
) as root_actor:
|
||||
...
|
||||
|
||||
Root entry happens before registry probes, IPC listeners, runtime sockets,
|
||||
or actor startup. On every exit, including cancellation or a body error,
|
||||
the calling thread is restored to its original network namespace before
|
||||
``open_root_actor()`` returns. The root context duplicates the live
|
||||
``Bindspace.namespace_fd`` and never consumes or closes the descriptor
|
||||
owned by ``open_wg_bindspace()``. Default child processes inherit the root
|
||||
namespace naturally; passing an explicit alternate child ``bindspace``
|
||||
continues to use that spawn backend's existing behavior. Bound roots reject
|
||||
the persistent ``mp_forkserver`` backend because a server started by an
|
||||
earlier runtime may retain that runtime's network namespace.
|
||||
|
||||
This is the current low-level composition API. A future convenience API
|
||||
may accept a tunnel-bearing multiaddr, realize its WireGuard bindspace
|
||||
internally, and supply that live capability to root startup.
|
||||
|
||||
Tunnels and WireGuard
|
||||
---------------------
|
||||
|
||||
.. autoclass:: TunnelledAddress
|
||||
|
||||
.. autoclass:: WGTunnelSpec
|
||||
|
||||
.. autoclass:: WGInterfaceConfig
|
||||
|
||||
.. autoclass:: WGPeerConfig
|
||||
|
||||
.. autofunction:: parse_wg_maddr
|
||||
|
||||
.. autofunction:: mk_wg_maddr
|
||||
|
||||
.. autofunction:: strip_tunnels
|
||||
|
||||
.. autofunction:: tunnels_of
|
||||
|
||||
.. autofunction:: open_wg_iface
|
||||
|
||||
.. autofunction:: open_wg_bindspace
|
||||
|
||||
.. autofunction:: read_wg_pubkey
|
||||
|
||||
.. autofunction:: read_wg_peers
|
||||
|
||||
.. autofunction:: verify_wg_peer
|
||||
|
|
@ -264,7 +264,7 @@ terminology is retired: it's *registrar*/*registry* everywhere now
|
|||
substitute "registrar" and you're up to date.
|
||||
|
||||
.. note::
|
||||
Multihoming nerds: ``tractor.discovery`` also ships
|
||||
Multihoming nerds: ``tractor.net`` ships
|
||||
libp2p-style *multiaddr* helpers — ``mk_maddr()`` and
|
||||
``parse_maddr()`` — for describing transport endpoints as
|
||||
structured strings.
|
||||
|
|
|
|||
|
|
@ -21,16 +21,16 @@ Supersedes the example set in gh
|
|||
/ip4/192.168.1.50/udp/51820/wg/u<A_pub>/ip4/10.0.11.1/tcp/1616
|
||||
\____ wg bearer ___________/\__ key __/\____ tractor ep _____/
|
||||
underlay, wg `ListenPort` overlay, on the wg iface
|
||||
(kernel/`wg(8)` owns it) (the ONLY part tractor binds)
|
||||
(kernel owns the socket) (`MsgTransport` binds this)
|
||||
```
|
||||
|
||||
Three parts, three different owners:
|
||||
|
||||
| part | who binds it | in the runtime? |
|
||||
| part | socket owner / provisioner | runtime role |
|
||||
| --- | --- | --- |
|
||||
| `/ip4/../udp/51820` bearer | kernel via `wg-quick`/`pyroute2` | no |
|
||||
| `/wg/u<key>` | nothing — it's an identity | no, verified out-of-band |
|
||||
| `/ip4/../tcp/1616` overlay | `tractor`'s `IPCServer` | **yes**, as `.overlay` |
|
||||
| `/ip4/../udp/51820` bearer | kernel-owned; `wg-quick` now, tractor bindspace later | control-plane metadata |
|
||||
| `/wg/u<key>` | nothing — it's an identity | parsed, verified explicitly |
|
||||
| `/ip4/../tcp/1616` overlay | `tractor`'s `IPCServer` | application `MsgTransport` |
|
||||
|
||||
Verified against py-multiaddr
|
||||
[#108](https://github.com/multiformats/py-multiaddr/pull/108):
|
||||
|
|
@ -41,23 +41,21 @@ this composed form parses and round-trips
|
|||
|
||||
py-multiaddr #108 is **merged** (2026-07-28) but ships in no
|
||||
release yet — the latest `0.2.0` (2026-03-17) predates it and has
|
||||
no `wg` codec. So `pyproject.toml` carries a temporary
|
||||
`[tool.uv.sources]` `rev` pin at the merge commit, and a plain
|
||||
no `wg` codec. So `pyproject.toml` temporarily pins the merge commit
|
||||
in its PEP 621 dependency metadata, and a plain
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv sync --extra wg
|
||||
```
|
||||
|
||||
gets you a `wg`-aware `multiaddr`. That pin goes away once a
|
||||
release carries the codec. You also need `multibase`:
|
||||
|
||||
```bash
|
||||
uv pip install multibase
|
||||
```
|
||||
gets you a `wg`-aware `multiaddr` plus pyroute2's Linux netlink API.
|
||||
The multiaddr pin goes away once a release carries the codec.
|
||||
`py-multibase` is a direct dependency.
|
||||
|
||||
Without the codec `parse_wg_maddr()` raises immediately with an
|
||||
actionable message — there is deliberately **no** degraded
|
||||
hand-split fallback. `_have_wg_maddr_proto()` is the predicate.
|
||||
hand-split fallback. `_wg_proto_code()` performs the capability
|
||||
check before parsing.
|
||||
|
||||
Every peel and re-compose here goes through `py-multiaddr`'s own
|
||||
tunnel API (`.decapsulate_code()`, `.split()`, `.join()`,
|
||||
|
|
@ -118,9 +116,9 @@ ping -c1 10.0.11.1 # from B
|
|||
|
||||
```bash
|
||||
python -c "
|
||||
import base64, multibase
|
||||
key = open('wg_pub.key').read().strip()
|
||||
print(multibase.encode('base64url', base64.b64decode(key)).decode())
|
||||
from tractor.net import mb_pubkey
|
||||
key = open('wg_pub.key').read().strip()
|
||||
print(mb_pubkey(key))
|
||||
"
|
||||
```
|
||||
|
||||
|
|
@ -153,24 +151,36 @@ Four corrections, all from
|
|||
all. `parse_wg_maddr()` now rejects it with an actionable
|
||||
error.
|
||||
2. **parsing is pure.** #482's helper had the key-check adjacent
|
||||
to the parse; `verify_wg_peer()` is now a separate, explicitly
|
||||
composed step that the caller invokes. A parser that shells
|
||||
out is a nasty surprise.
|
||||
3. **no `sudo`.** #482 ran `sudo wg show`; a library/example must
|
||||
never escalate. `wg show` works unprivileged for read on most
|
||||
setups; if yours needs root, run the script as root rather
|
||||
than embedding `sudo`.
|
||||
to the parse; async `verify_wg_peer()` is now a separate,
|
||||
explicitly composed step that the caller invokes. Implicit
|
||||
kernel inspection from a parser is a nasty surprise.
|
||||
3. **no `sudo` or subprocess.** #482 ran `sudo wg show`; tractor's
|
||||
helper reads generic netlink through pyroute2 and never attempts
|
||||
privilege escalation or namespace creation.
|
||||
4. **no new `Address` proto-type.** The tunnel rides *beside* the
|
||||
overlay addr in a frozen `WGTunnelledAddr`, and only `.overlay`
|
||||
overlay addr in a frozen `TunnelledAddress`, and only `.overlay`
|
||||
crosses into `open_nursery()`. #482 §6 floated a `WGAddress`
|
||||
registered in `_address_types` — that table is a `bidict`
|
||||
(1:1 proto-key↔type) and `_addr_to_transport` wants a
|
||||
`MsgTransport` per addr-type, which `wg` doesn't have.
|
||||
|
||||
## next
|
||||
## root composition
|
||||
|
||||
`WGTunnelledAddr` is deliberately example-local. Promoting it to
|
||||
`tractor.discovery` as a `TunnelledAddress` whose
|
||||
`.proto_key`/`.unwrap()` delegate to `.overlay`, plus
|
||||
`open_bindspace()` `@acm`s that create/tear down the iface +
|
||||
netns via `pyroute2`, is layers A→C of the plan doc.
|
||||
The `TunnelledAddress`, native maddr parser, bindspace lifecycle, and
|
||||
explicit pyroute2 verification APIs live in `tractor.net`. Keep the
|
||||
owning bindspace context outside the root actor so its namespace FD
|
||||
remains live through complete actor teardown:
|
||||
|
||||
```python
|
||||
async with tractor.net.open_wg_bindspace(
|
||||
bindspace_spec,
|
||||
layers,
|
||||
role='listen',
|
||||
) as bindspace:
|
||||
async with tractor.open_root_actor(bindspace=bindspace):
|
||||
...
|
||||
```
|
||||
|
||||
The root actor enters before registry or IPC setup and restores the
|
||||
calling thread's original namespace before the outer bindspace context
|
||||
removes owned WireGuard and netns resources.
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ from __future__ import annotations
|
|||
|
||||
import tractor
|
||||
import trio
|
||||
|
||||
from wg_maddr import (
|
||||
from tractor.net import (
|
||||
TunnelledAddress,
|
||||
mk_maddr,
|
||||
parse_wg_maddr,
|
||||
verify_wg_peer,
|
||||
WGTunnelledAddr,
|
||||
)
|
||||
|
||||
# bearer = host A's underlay `(ip, wg ListenPort)`
|
||||
|
|
@ -33,27 +33,27 @@ async def echo(msg: str) -> str:
|
|||
|
||||
|
||||
async def main():
|
||||
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
|
||||
assert verify_wg_peer(addr), (
|
||||
addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
|
||||
assert await verify_wg_peer(addr.tunnel), (
|
||||
f'wg pubkey from maddr not active on wg0 !\n'
|
||||
f'maddr: {WG_MADDR}\n'
|
||||
f'key: {addr.peer_pubkey}\n'
|
||||
f'key: {addr.tunnel.peer_pubkey}\n'
|
||||
)
|
||||
print(
|
||||
f'wg bearer (kernel-owned): {addr.bearer}\n'
|
||||
f'wg bearer (kernel-owned): {addr.tunnel.bearer}\n'
|
||||
f'tractor overlay ep: {addr.overlay}\n'
|
||||
)
|
||||
async with tractor.open_nursery(
|
||||
# XXX only `.overlay` crosses into the runtime; the bearer
|
||||
# + key are iface-layer concerns `tractor` never binds.
|
||||
# + key are bindspace metadata, never `Endpoint` addrs.
|
||||
registry_addrs=[addr.overlay],
|
||||
enable_transports=[addr.overlay_proto],
|
||||
enable_transports=[addr.overlay.proto_key],
|
||||
) as an:
|
||||
await an.start_actor(
|
||||
'echo_srv',
|
||||
enable_modules=[__name__],
|
||||
)
|
||||
print(f'echo_srv up on\n {addr.maddr}\n')
|
||||
print(f'echo_srv up on\n {mk_maddr(addr)}\n')
|
||||
await trio.sleep_forever()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,14 +8,13 @@ from __future__ import annotations
|
|||
|
||||
import tractor
|
||||
import trio
|
||||
|
||||
from host_a_srv import echo # noqa: F401 (RPC refs it by mod path)
|
||||
from wg_maddr import (
|
||||
from tractor.net import (
|
||||
TunnelledAddress,
|
||||
parse_wg_maddr,
|
||||
verify_wg_peer,
|
||||
WGTunnelledAddr,
|
||||
)
|
||||
|
||||
from host_a_srv import echo # noqa: F401 (RPC refs it by mod path)
|
||||
# same maddr as host A: A's bearer, A's key, A's overlay ep
|
||||
WG_MADDR: str = (
|
||||
'/ip4/192.168.1.50/udp/51820'
|
||||
|
|
@ -25,8 +24,8 @@ WG_MADDR: str = (
|
|||
|
||||
|
||||
async def main():
|
||||
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
|
||||
assert verify_wg_peer(addr), (
|
||||
addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
|
||||
assert await verify_wg_peer(addr.tunnel), (
|
||||
f'wg pubkey from maddr not a peer on wg0 !\n'
|
||||
f'maddr: {WG_MADDR}\n'
|
||||
)
|
||||
|
|
@ -34,7 +33,7 @@ async def main():
|
|||
tractor.open_root_actor(
|
||||
name='wg_client',
|
||||
registry_addrs=[addr.overlay],
|
||||
enable_transports=[addr.overlay_proto],
|
||||
enable_transports=[addr.overlay.proto_key],
|
||||
),
|
||||
tractor.find_actor(
|
||||
'echo_srv',
|
||||
|
|
|
|||
|
|
@ -1,299 +0,0 @@
|
|||
# tractor: distributed structured concurrency.
|
||||
r'''
|
||||
Parse `wg`-tunnelled multiaddrs into `tractor`-ready addrs.
|
||||
|
||||
The canonical form (per py-multiaddr #108, verified against its
|
||||
upstream merge) nests the *overlay* endpoint **after** the `/wg/`
|
||||
segment:
|
||||
|
||||
/ip4/10.0.0.1/udp/51820/wg/u<key>/ip4/10.0.11.1/tcp/1616
|
||||
\_______ wg bearer ______/\_ key _/\____ tractor ep _____/
|
||||
(underlay, wg
|
||||
`ListenPort`)
|
||||
|
||||
Naming follows `py-multiaddr`'s own encapsulation model, where
|
||||
earlier segments *wrap* later ones (`.encapsulate()` appends), so
|
||||
the two roles are:
|
||||
|
||||
- **bearer**: the segs *before* `/wg/`, i.e. the underlay
|
||||
`(ip, udp-port)` that `wg(8)` itself listens on. Nothing in
|
||||
`tractor` ever binds this — the kernel/`wg` iface owns it.
|
||||
- **overlay**: the segs *after*, i.e. the addr `tractor` actually
|
||||
binds/dials. The only part the runtime ever sees.
|
||||
|
||||
We deliberately avoid `inner`/`outer` for these two: in a *call*
|
||||
stack "inner" reads as higher-up and later-called, whereas here
|
||||
the encapsulated addr is bound *first* and sits deeper in the
|
||||
maddr — two opposite intuitions on one word.
|
||||
|
||||
`/wg/u<key>` itself carries the tunnel peer's Curve25519 pubkey
|
||||
as multibase base64url (std base64 from `wg(8)` contains `/` and
|
||||
so can't go in a `/`-delimited maddr). It binds nothing at all;
|
||||
it's an identity, verified out-of-band.
|
||||
|
||||
XXX NOTE, `tractor`'s own `parse_maddr()` can't parse this yet
|
||||
(`ValueError('Unsupported multiaddr protocol combo')`), which is
|
||||
why this module exists: peel here, hand `.overlay` to the
|
||||
runtime.
|
||||
|
||||
Design rules this module follows (see
|
||||
`ai/tpt-backends/03_wg_tunnel_bindspace.md`):
|
||||
|
||||
- **let `py-multiaddr` do the parsing**. Every peel/compose goes
|
||||
through `.decapsulate_code()`, `.split()`, `.join()`,
|
||||
`.encapsulate()` and `.value_for_protocol()`. We hand-roll no
|
||||
segment splitting whatsoever — the whole point of gh #429 was
|
||||
dropping the NIH parser, and that applies to *peeling a tunnel
|
||||
stack* every bit as much as to decoding a single proto.
|
||||
- **parsing is pure**. `parse_wg_maddr()` does no I/O, no
|
||||
`subprocess`, no netlink. A parser that shells out is a nasty
|
||||
surprise.
|
||||
- **verification is an explicit, separate step**. The caller
|
||||
composes `verify_wg_peer()` when it wants it; nothing implicit.
|
||||
- **no new `Address` proto-type**. `wg` gets no entry in
|
||||
`tractor.discovery._addr._address_types` (a `bidict`, so 1:1
|
||||
proto-key<->type) bc it has no `MsgTransport` of its own. The
|
||||
tunnel is a *bindspace*, so we carry it beside the overlay
|
||||
addr and strip to `.overlay` at bind/dial time.
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
import base64
|
||||
import subprocess
|
||||
from typing import Literal
|
||||
|
||||
import msgspec
|
||||
from multiaddr import Multiaddr
|
||||
from multiaddr.protocols import P_WG
|
||||
|
||||
|
||||
IPProto = Literal['ip4', 'ip6']
|
||||
|
||||
|
||||
class WGTunnelledAddr(
|
||||
msgspec.Struct,
|
||||
frozen=True,
|
||||
):
|
||||
'''
|
||||
A `wg`-tunnelled endpoint: the underlay bearer, the tunnel
|
||||
peer key, and the overlay addr `tractor` binds/dials.
|
||||
|
||||
'''
|
||||
# underlay, owned by `wg(8)`/the kernel — NEVER bound by us
|
||||
bearer: tuple[str, int]
|
||||
|
||||
# tunnel peer pubkey in the std-base64 `wg(8)` form, i.e.
|
||||
# directly comparable to `wg show <if> peers` output
|
||||
peer_pubkey: str
|
||||
|
||||
# overlay ep: an `UnwrappedAddress` as accepted by
|
||||
# `tractor.discovery.wrap_address()`
|
||||
overlay: tuple[str, int]
|
||||
overlay_proto: Literal['tcp'] = 'tcp'
|
||||
|
||||
# kept so `.as_multiaddr()` re-renders the same ip family it
|
||||
# was parsed from, rather than assuming v4
|
||||
bearer_ip: IPProto = 'ip4'
|
||||
overlay_ip: IPProto = 'ip4'
|
||||
|
||||
def as_multiaddr(self) -> Multiaddr:
|
||||
'''
|
||||
Re-compose the canonical `Multiaddr`, bearer outward-in,
|
||||
using `.encapsulate()` exactly as py-multiaddr's own
|
||||
tunneling example does.
|
||||
|
||||
'''
|
||||
b_host, b_port = self.bearer
|
||||
o_host, o_port = self.overlay
|
||||
return (
|
||||
Multiaddr(f'/{self.bearer_ip}/{b_host}/udp/{b_port}')
|
||||
.encapsulate(
|
||||
Multiaddr(f'/wg/{mb_pubkey(self.peer_pubkey)}')
|
||||
)
|
||||
.encapsulate(
|
||||
Multiaddr(
|
||||
f'/{self.overlay_ip}/{o_host}'
|
||||
f'/{self.overlay_proto}/{o_port}'
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def maddr(self) -> str:
|
||||
'''
|
||||
The canonical maddr `str` form.
|
||||
|
||||
'''
|
||||
return str(self.as_multiaddr())
|
||||
|
||||
|
||||
def mb_pubkey(wg8_key: str) -> str:
|
||||
'''
|
||||
`wg(8)` std-base64 pubkey -> multibase base64url (`u`-prefixed).
|
||||
|
||||
'''
|
||||
import multibase
|
||||
raw: bytes = base64.b64decode(wg8_key)
|
||||
return multibase.encode('base64url', raw).decode('ascii')
|
||||
|
||||
|
||||
def wg8_pubkey(mb_key: str) -> str:
|
||||
'''
|
||||
Inverse of `mb_pubkey()`: multibase -> `wg(8)` std-base64.
|
||||
|
||||
'''
|
||||
import multibase
|
||||
raw: bytes = multibase.decode(mb_key)
|
||||
return base64.b64encode(raw).decode('ascii')
|
||||
|
||||
|
||||
_wg_proto_known: bool|None = None
|
||||
|
||||
|
||||
def _have_wg_maddr_proto() -> bool:
|
||||
'''
|
||||
True iff the installed `py-multiaddr` knows the `/wg/` proto,
|
||||
i.e. carries py-multiaddr#108.
|
||||
|
||||
Merged upstream 2026-07-28 (`f86519da`) but in no release as
|
||||
of `0.2.0`, hence the `[tool.uv.sources]` `rev` pin in
|
||||
`pyproject.toml`.
|
||||
|
||||
Pure predicate; result cached since it can't change without a
|
||||
reinstall.
|
||||
|
||||
'''
|
||||
global _wg_proto_known
|
||||
if _wg_proto_known is None:
|
||||
from multiaddr.protocols import protocol_with_name
|
||||
from multiaddr.exceptions import ProtocolNotFoundError
|
||||
try:
|
||||
protocol_with_name('wg')
|
||||
_wg_proto_known = True
|
||||
except ProtocolNotFoundError:
|
||||
_wg_proto_known = False
|
||||
|
||||
return _wg_proto_known
|
||||
|
||||
|
||||
def parse_wg_maddr(
|
||||
maddr: str|Multiaddr,
|
||||
) -> WGTunnelledAddr:
|
||||
'''
|
||||
Peel a `wg`-tunnelled maddr into its bearer/key/overlay
|
||||
parts. Pure — no I/O.
|
||||
|
||||
Every cut is made by `py-multiaddr`, so a malformed maddr
|
||||
(incl. a `wg` key that isn't exactly 32B) raises out of
|
||||
`Multiaddr()` rather than yielding a struct quietly built
|
||||
from garbage segs.
|
||||
|
||||
'''
|
||||
if not _have_wg_maddr_proto():
|
||||
raise RuntimeError(
|
||||
f'Installed `py-multiaddr` has no `/wg/` proto!\n'
|
||||
f'Needs py-multiaddr#108, merged upstream but not\n'
|
||||
f'yet released; a `uv sync` picks up the pinned rev.\n'
|
||||
f'maddr: {maddr!r}\n'
|
||||
)
|
||||
|
||||
ma: Multiaddr = (
|
||||
maddr
|
||||
if isinstance(maddr, Multiaddr)
|
||||
else Multiaddr(maddr)
|
||||
)
|
||||
segs: list[Multiaddr] = ma.split()
|
||||
names: list[str] = [
|
||||
proto.name
|
||||
for seg in segs
|
||||
for proto in seg.protocols()
|
||||
]
|
||||
if 'wg' not in names:
|
||||
raise ValueError(
|
||||
f'Not a `wg`-tunnelled maddr, no `/wg/` segment ??\n'
|
||||
f'maddr: {ma}\n'
|
||||
)
|
||||
|
||||
# NOTE, `.decapsulate_code()` cuts at the LAST occurrence of
|
||||
# the proto and keeps the *prefix*, which is exactly the
|
||||
# bearer. It handles `/wg/` cleanly precisely bc it cuts on
|
||||
# proto-code and never tries to match an addr value — the
|
||||
# key seg has no addr of its own.
|
||||
bearer_ma: Multiaddr = ma.decapsulate_code(P_WG)
|
||||
overlay_ma: Multiaddr = Multiaddr.join(
|
||||
*segs[names.index('wg') + 1:]
|
||||
)
|
||||
|
||||
match [proto.name for proto in bearer_ma.protocols()]:
|
||||
case [('ip4' | 'ip6') as b_ip, 'udp']:
|
||||
bearer = (
|
||||
bearer_ma.value_for_protocol(b_ip),
|
||||
int(bearer_ma.value_for_protocol('udp')),
|
||||
)
|
||||
case _:
|
||||
raise ValueError(
|
||||
f'Bad `wg` bearer, expected `/ip4|ip6/<h>/udp/<p>`\n'
|
||||
f'got: {bearer_ma}\n'
|
||||
f'from maddr: {ma}\n'
|
||||
)
|
||||
|
||||
match [proto.name for proto in overlay_ma.protocols()]:
|
||||
case [('ip4' | 'ip6') as o_ip, ('tcp') as l4]:
|
||||
overlay = (
|
||||
overlay_ma.value_for_protocol(o_ip),
|
||||
int(overlay_ma.value_for_protocol(l4)),
|
||||
)
|
||||
case []:
|
||||
raise ValueError(
|
||||
f'`wg` maddr declares no overlay endpoint!\n'
|
||||
f'A bare `/…/wg/<key>` names only the tunnel; '
|
||||
f'append the ep `tractor` should bind, e.g.\n'
|
||||
f' {ma}/ip4/10.0.11.1/tcp/1616\n'
|
||||
)
|
||||
case _:
|
||||
raise ValueError(
|
||||
f'Unsupported `wg` overlay proto combo\n'
|
||||
f'got: {overlay_ma}\n'
|
||||
f'from maddr: {ma}\n'
|
||||
)
|
||||
|
||||
return WGTunnelledAddr(
|
||||
bearer=bearer,
|
||||
peer_pubkey=wg8_pubkey(ma.value_for_protocol('wg')),
|
||||
overlay=overlay,
|
||||
overlay_proto=l4,
|
||||
bearer_ip=b_ip,
|
||||
overlay_ip=o_ip,
|
||||
)
|
||||
|
||||
|
||||
def verify_wg_peer(
|
||||
addr: WGTunnelledAddr,
|
||||
iface: str = 'wg0',
|
||||
) -> bool:
|
||||
'''
|
||||
True iff `addr.peer_pubkey` is a configured peer (or our own
|
||||
pubkey) on `iface`.
|
||||
|
||||
IMPURE + explicit by design: never called from
|
||||
`parse_wg_maddr()`.
|
||||
|
||||
?TODO, per plan-03 layer B, swap this body for `pyroute2`
|
||||
(keeping the signature) — and note `setns(2)` is *per-thread*,
|
||||
so a query issued via `trio.to_thread` lands in the ORIGINAL
|
||||
netns unless `netns=` is passed down.
|
||||
|
||||
'''
|
||||
def _wg(*args: str) -> str:
|
||||
return subprocess.run(
|
||||
['wg', 'show', iface, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout
|
||||
|
||||
return (
|
||||
addr.peer_pubkey in _wg('peers').split()
|
||||
or
|
||||
addr.peer_pubkey == _wg('public-key').strip()
|
||||
)
|
||||
|
|
@ -48,7 +48,10 @@ dependencies = [
|
|||
# typed IPC msging
|
||||
"msgspec>=0.20.0",
|
||||
"bidict>=0.23.1",
|
||||
"multiaddr>=0.2.0",
|
||||
# unreleased `/wg/` codec from py-multiaddr#108
|
||||
"multiaddr @ git+https://github.com/multiformats/py-multiaddr.git@f86519daaa21699023d0037c58cdff600313dd09",
|
||||
# encode/decode `wg` pubkeys carried by multiaddrs
|
||||
"py-multibase>=2.0.0,<3",
|
||||
"platformdirs>=4.4.0",
|
||||
# per-actor `argv[0]` proc-title for OS-level diag tools
|
||||
# (`ps`, `top`, `psutil`-backed tooling like `acli.pytree`).
|
||||
|
|
@ -59,6 +62,12 @@ dependencies = [
|
|||
"setproctitle>=1.3,<2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
wg = [
|
||||
# read/provision Linux WireGuard state through netlink
|
||||
"pyroute2>=0.9.6,<0.10 ; sys_platform == 'linux'",
|
||||
]
|
||||
|
||||
# ------ project ------
|
||||
|
||||
[dependency-groups]
|
||||
|
|
@ -165,20 +174,8 @@ sync_pause = {requires-python = ">=3.13, <3.14"}
|
|||
# editable = true
|
||||
|
||||
# ------ tool.uv.sources ------
|
||||
# TODO, distributed (multi-host) extensions
|
||||
# linux kernel networking
|
||||
# 'pyroute2
|
||||
|
||||
# XXX TEMP, the `/wg/u<key>` maddr proto is MERGED upstream (in
|
||||
# py-multiaddr#108, 2026-07-28) but is in NO release yet; the
|
||||
# latest `0.2.0` (2026-03-17) predates the merge by ~4 months.
|
||||
# Pinned by `rev` (not `branch`) so CI stays reproducible.
|
||||
#
|
||||
# Drop this pin (and bump the `multiaddr` dep floor above) the
|
||||
# moment a release carries the `wg` codec; the only consumer is
|
||||
# `examples/multihost/wg_lan/`.
|
||||
# |_https://github.com/multiformats/py-multiaddr/pull/108
|
||||
multiaddr = { git = 'https://github.com/multiformats/py-multiaddr.git', rev = 'f86519daaa21699023d0037c58cdff600313dd09' }
|
||||
# Linux kernel networking is provided by the optional `wg` extra.
|
||||
# Add any temporary `pyroute2` source overrides here.
|
||||
|
||||
# ------ tool.uv.sources ------
|
||||
|
||||
|
|
@ -194,6 +191,9 @@ python-preference = 'system'
|
|||
|
||||
# ------ tool.uv ------
|
||||
|
||||
[tool.hatch.metadata]
|
||||
allow-direct-references = true
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = ["tractor"]
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
'''
|
||||
Canonical tagged-address decoding and legacy input compatibility.
|
||||
|
||||
'''
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tractor.discovery._addr import wrap_address
|
||||
from tractor.ipc._tcp import TCPAddress
|
||||
from tractor.ipc._uds import UDSAddress
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'value',
|
||||
[
|
||||
('tcp', '127.0.0.1', 1616),
|
||||
['tcp', '127.0.0.1', 1616],
|
||||
],
|
||||
)
|
||||
def test_decode_tagged_tcp_address(value):
|
||||
'''
|
||||
Shape-only decoding cannot distinguish future transport address
|
||||
forms. Feed canonical tuple and msgpack-style list values through
|
||||
the compatibility boundary and prove the explicit `tcp` tag
|
||||
selects the TCP backend and emits the canonical tagged form.
|
||||
|
||||
'''
|
||||
addr = wrap_address(value)
|
||||
|
||||
assert type(addr) is TCPAddress
|
||||
assert addr.unwrap() == ('tcp', '127.0.0.1', 1616)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'tag',
|
||||
['unix', 'uds'],
|
||||
)
|
||||
@pytest.mark.parametrize('container', [tuple, list])
|
||||
def test_decode_tagged_unix_address(
|
||||
tag: str,
|
||||
container: type,
|
||||
):
|
||||
'''
|
||||
Multiaddr calls the protocol `unix` while tractor's transport key
|
||||
remains `uds`. Decode both spellings from tuple/list containers,
|
||||
normalize them to one `UDSAddress`, and emit the canonical `unix`
|
||||
spelling.
|
||||
|
||||
'''
|
||||
value = container((tag, '/tmp/tractor/registry.sock'))
|
||||
addr = wrap_address(value)
|
||||
|
||||
assert type(addr) is UDSAddress
|
||||
assert addr.sockpath == Path('/tmp/tractor/registry.sock')
|
||||
assert addr.unwrap() == (
|
||||
'unix',
|
||||
'/tmp/tractor/registry.sock',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'value, expected_type',
|
||||
[
|
||||
(('127.0.0.1', 1616), TCPAddress),
|
||||
(['127.0.0.1', 1616], TCPAddress),
|
||||
(('/tmp/tractor', 'registry.sock'), UDSAddress),
|
||||
(['/tmp/tractor', 'registry.sock'], UDSAddress),
|
||||
],
|
||||
)
|
||||
def test_decode_legacy_address_forms(
|
||||
value,
|
||||
expected_type: type,
|
||||
):
|
||||
'''
|
||||
Existing callers, config, and older msgpack payloads still
|
||||
provide untagged pairs. Keep tuple/list forms readable while
|
||||
canonical tagged emission is introduced, proving the writer
|
||||
migration does not break shipped input behavior.
|
||||
|
||||
'''
|
||||
addr = wrap_address(value)
|
||||
|
||||
assert type(addr) is expected_type
|
||||
assert addr.unwrap()[0] in {'tcp', 'unix'}
|
||||
|
||||
|
||||
def test_tcp_from_native_ipv6_sockname():
|
||||
'''
|
||||
`socket.getsockname()` returns a four-item IPv6 sockaddr which is
|
||||
neither a wire form nor a legacy two-item pair. Preserve it as an
|
||||
OS compatibility boundary and intentionally ignore unsupported
|
||||
flow-info/scope-id fields when constructing `TCPAddress`.
|
||||
|
||||
'''
|
||||
addr = TCPAddress.from_addr(
|
||||
('::1', 1616, 0, 0)
|
||||
)
|
||||
|
||||
assert addr.unwrap() == ('tcp', '::1', 1616)
|
||||
|
|
@ -18,7 +18,7 @@ from tractor.devx import dump_on_hang
|
|||
from tractor.trionics import collapse_eg
|
||||
from tractor._testing import tractor_test
|
||||
from tractor.discovery._addr import wrap_address
|
||||
from tractor.discovery._multiaddr import mk_maddr
|
||||
from tractor.net import mk_maddr
|
||||
import trio
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ from tractor.discovery import _api
|
|||
from tractor.discovery._addr import (
|
||||
wrap_address,
|
||||
)
|
||||
from tractor.discovery._multiaddr import mk_maddr
|
||||
from tractor.net import mk_maddr
|
||||
from tractor.ipc import _connect_chan
|
||||
from tractor._testing.addr import get_rando_addr
|
||||
|
||||
|
|
@ -371,7 +371,7 @@ def test_non_registrar_root_tpt_bind_addrs(
|
|||
for uw_addr in bound:
|
||||
w = wrap_address(uw_addr)
|
||||
if w.proto_key == 'tcp':
|
||||
_host, port = uw_addr
|
||||
_, _host, port = uw_addr
|
||||
assert port > 0
|
||||
|
||||
trio.run(_main)
|
||||
|
|
@ -443,7 +443,7 @@ def test_tpt_bind_addrs_as_maddr_str(
|
|||
for uw_addr in actor.accept_addrs:
|
||||
w = wrap_address(uw_addr)
|
||||
if w.proto_key == 'tcp':
|
||||
_host, port = uw_addr
|
||||
_, _host, port = uw_addr
|
||||
assert port > 0
|
||||
|
||||
trio.run(_main)
|
||||
|
|
@ -475,7 +475,7 @@ def test_registrar_merge_binds_union(
|
|||
# actually differ (always true for TCP, may
|
||||
# collide for UDS).
|
||||
expect_disjoint: bool = (
|
||||
tuple(reg_addr) != rando.unwrap()
|
||||
reg_wrapped.unwrap() != rando.unwrap()
|
||||
)
|
||||
|
||||
async def _main():
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
'''
|
||||
Tunnel annotation peeling at the outbound IPC transport boundary.
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import trio
|
||||
|
||||
from tractor.net import (
|
||||
TunnelledAddress,
|
||||
WGTunnelSpec,
|
||||
tunnels_of,
|
||||
)
|
||||
from tractor.ipc import _chan
|
||||
from tractor.ipc._tcp import TCPAddress
|
||||
|
||||
|
||||
_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def overlay() -> TCPAddress:
|
||||
return TCPAddress('127.0.0.1', 0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tunnelled(
|
||||
overlay: TCPAddress,
|
||||
) -> TunnelledAddress:
|
||||
return TunnelledAddress(
|
||||
overlay=overlay,
|
||||
tunnel=WGTunnelSpec(
|
||||
peer_pubkey=_PUBKEY,
|
||||
bearer=('192.168.1.50', 51820),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('use_tunnel', [False, True])
|
||||
def test_channel_peels_before_transport_dispatch(
|
||||
monkeypatch,
|
||||
overlay: TCPAddress,
|
||||
tunnelled: TunnelledAddress,
|
||||
use_tunnel: bool,
|
||||
):
|
||||
'''
|
||||
Exact-type transport lookup cannot dispatch a `TunnelledAddress`,
|
||||
and passing one onward would make TCP dial the wrong object. Feed
|
||||
both a plain overlay and its annotated wrapper into
|
||||
`Channel.from_addr()`, capture lookup and connect arguments, and
|
||||
prove both transport operations receive only the same bindable
|
||||
TCP address while the caller's tunnel metadata remains intact.
|
||||
|
||||
'''
|
||||
seen: list[tuple[str, TCPAddress]] = []
|
||||
|
||||
class FakeTransport:
|
||||
@classmethod
|
||||
async def connect_to(
|
||||
cls,
|
||||
addr: TCPAddress,
|
||||
**kwargs,
|
||||
) -> FakeTransport:
|
||||
seen.append(('connect', addr))
|
||||
return cls()
|
||||
|
||||
def fake_transport_from_addr(
|
||||
addr: TCPAddress,
|
||||
) -> type[FakeTransport]:
|
||||
seen.append(('lookup', addr))
|
||||
return FakeTransport
|
||||
|
||||
monkeypatch.setattr(
|
||||
_chan,
|
||||
'transport_from_addr',
|
||||
fake_transport_from_addr,
|
||||
)
|
||||
|
||||
async def main() -> None:
|
||||
declared = tunnelled if use_tunnel else overlay
|
||||
chan = await _chan.Channel.from_addr(declared)
|
||||
assert isinstance(chan.transport, FakeTransport)
|
||||
|
||||
trio.run(main)
|
||||
|
||||
assert seen == [
|
||||
('lookup', overlay),
|
||||
('connect', overlay),
|
||||
]
|
||||
assert tunnels_of(tunnelled) == (tunnelled.tunnel,)
|
||||
|
|
@ -703,17 +703,20 @@ def test_uds_bindspace_created_implicitly(
|
|||
|
||||
root: Actor = tractor.current_actor()
|
||||
assert root.is_registrar
|
||||
canonical_addr = _addr.wrap_address(
|
||||
registry_addr,
|
||||
).unwrap()
|
||||
|
||||
assert registry_addr in root.reg_addrs
|
||||
assert canonical_addr in root.reg_addrs
|
||||
assert (
|
||||
registry_addr
|
||||
canonical_addr
|
||||
in
|
||||
_state._runtime_vars['_registry_addrs']
|
||||
)
|
||||
assert (
|
||||
_addr.wrap_address(registry_addr)
|
||||
canonical_addr
|
||||
in
|
||||
root.registry_addrs
|
||||
[addr.unwrap() for addr in root.registry_addrs]
|
||||
)
|
||||
|
||||
trio.run(main)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
'''
|
||||
Tunnel annotation peeling at the inbound IPC transport boundary.
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import trio
|
||||
|
||||
from tractor.net import (
|
||||
BindspaceRef,
|
||||
TunnelledAddress,
|
||||
WGTunnelSpec,
|
||||
tunnels_of,
|
||||
)
|
||||
from tractor.ipc._server import open_ipc_server
|
||||
from tractor.ipc._tcp import TCPAddress
|
||||
|
||||
|
||||
_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
|
||||
|
||||
|
||||
def test_server_peels_before_endpoint_construction() -> None:
|
||||
'''
|
||||
`Endpoint.start_listener()` reflects on its address's declaring
|
||||
module, so retaining a tunnel wrapper there selects `._tunnel`
|
||||
instead of the TCP backend. Start a real listener from the
|
||||
wrapper, assert the resulting `Endpoint` contains only a resolved
|
||||
`TCPAddress`. Prove `Endpoint.declared_addr` still retains the
|
||||
original declaration and realized bindspace ref for
|
||||
diagnostics. This retained metadata does not claim the listener
|
||||
process entered that namespace.
|
||||
|
||||
'''
|
||||
overlay = TCPAddress('127.0.0.1', 0)
|
||||
tunnelled = TunnelledAddress(
|
||||
overlay=overlay,
|
||||
tunnel=WGTunnelSpec(
|
||||
peer_pubkey=_PUBKEY,
|
||||
bearer=('192.168.1.50', 51820),
|
||||
netns='actor-net',
|
||||
),
|
||||
)
|
||||
ref: BindspaceRef = BindspaceRef(
|
||||
kind='netns',
|
||||
key='actor-net',
|
||||
inode=1234,
|
||||
)
|
||||
declared: TunnelledAddress = tunnelled.with_bindspace_ref(
|
||||
ref,
|
||||
)
|
||||
|
||||
async def main() -> None:
|
||||
async with open_ipc_server() as server:
|
||||
eps = await server.listen_on(
|
||||
accept_addrs=[declared],
|
||||
)
|
||||
assert len(eps) == 1
|
||||
endpoint = eps[0]
|
||||
|
||||
assert type(endpoint.addr) is TCPAddress
|
||||
_, host, port = endpoint.addr.unwrap()
|
||||
assert host == overlay.unwrap()[1]
|
||||
assert port > 0
|
||||
assert endpoint.addr is not declared
|
||||
assert endpoint.declared_addr is declared
|
||||
namespace: tuple[str, int] = ('netns', 1234)
|
||||
assert endpoint.namespace == namespace
|
||||
endpoint_repr: str = endpoint.pformat()
|
||||
server_repr: str = server.pformat()
|
||||
expected_namespace: str = f'namespace: {namespace!r}'
|
||||
assert expected_namespace in endpoint_repr
|
||||
assert ' |_namespaces:' in server_repr
|
||||
assert 'netns' in server_repr
|
||||
assert '1234' in server_repr
|
||||
assert tunnels_of(declared) == (
|
||||
declared.tunnel,
|
||||
)
|
||||
|
||||
server.cancel()
|
||||
|
||||
trio.run(main)
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
'''
|
||||
Process-local struct wire-encoding guards.
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
|
||||
from tractor.msg import ProcessLocal
|
||||
|
||||
|
||||
class LocalHandle(ProcessLocal):
|
||||
'''
|
||||
Minimal process-local struct used to exercise the global marker.
|
||||
|
||||
'''
|
||||
resource_id: int
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'nested',
|
||||
(
|
||||
pytest.param(
|
||||
False,
|
||||
id='direct',
|
||||
),
|
||||
pytest.param(
|
||||
True,
|
||||
id='nested',
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_process_local_rejects_default_encoding(
|
||||
nested: bool,
|
||||
) -> None:
|
||||
'''
|
||||
Process-local values can appear directly or deep in a payload.
|
||||
|
||||
Embed the same marked struct at both depths and prove msgspec's
|
||||
normal traversal reaches the unsupported sentinel without a
|
||||
tractor-specific recursive payload scan.
|
||||
|
||||
'''
|
||||
handle: LocalHandle = LocalHandle(resource_id=1)
|
||||
value: object = (
|
||||
{'nested': [handle]}
|
||||
if nested
|
||||
else handle
|
||||
)
|
||||
|
||||
assert repr(handle) == 'LocalHandle(resource_id=1)'
|
||||
with pytest.raises(
|
||||
TypeError,
|
||||
match='_ProcessLocalToken.*unsupported',
|
||||
):
|
||||
msgspec.msgpack.encode(value)
|
||||
|
|
@ -0,0 +1 @@
|
|||
'''Network declaration and lifecycle tests.'''
|
||||
|
|
@ -0,0 +1,572 @@
|
|||
'''
|
||||
Bindspace declaration, reference and live-capability contracts.
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
from typing import BinaryIO
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
import trio
|
||||
|
||||
from tractor.net import (
|
||||
Bindspace,
|
||||
BindspaceOwnership,
|
||||
BindspaceRef,
|
||||
BindspaceSpec,
|
||||
CURRENT_NETNS,
|
||||
attach_netns,
|
||||
open_bindspace,
|
||||
open_netns,
|
||||
)
|
||||
from tractor.net import _bindspace
|
||||
from tractor.msg import ProcessLocal
|
||||
|
||||
|
||||
def test_bindspace_declarations_roundtrip() -> None:
|
||||
'''
|
||||
Spawn configuration and realized refs must cross actor IPC.
|
||||
|
||||
Encode both frozen structs through msgpack and decode with their
|
||||
concrete types, proving names and stable inode refs survive
|
||||
without carrying any process-local capability state.
|
||||
|
||||
'''
|
||||
values: tuple[
|
||||
BindspaceSpec|BindspaceRef,
|
||||
...,
|
||||
] = (
|
||||
BindspaceSpec(
|
||||
kind='netns',
|
||||
key='tractor-wg0',
|
||||
lifecycle='open',
|
||||
),
|
||||
BindspaceRef(
|
||||
kind='netns',
|
||||
key='tractor-wg0',
|
||||
inode=1234,
|
||||
),
|
||||
)
|
||||
value: BindspaceSpec|BindspaceRef
|
||||
for value in values:
|
||||
encoded: bytes = msgspec.msgpack.encode(value)
|
||||
decoded: BindspaceSpec|BindspaceRef = (
|
||||
msgspec.msgpack.decode(
|
||||
encoded,
|
||||
type=type(value),
|
||||
)
|
||||
)
|
||||
assert decoded == value
|
||||
|
||||
|
||||
def test_bindspace_pins_local_capability(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
'''
|
||||
A live bindspace pins one exact FD and realized ref.
|
||||
|
||||
Open a stand-in platform FD, record its inode in the realized
|
||||
ref and construct an owned capability. Prove the generic
|
||||
msgspec struct retains that exact local state. Its ability to
|
||||
encode ordinary fields is not authority to transfer the
|
||||
bindspace.
|
||||
|
||||
'''
|
||||
token_path: Path = tmp_path / 'bindspace'
|
||||
token_path.touch()
|
||||
namespace_file: BinaryIO
|
||||
with token_path.open('rb') as namespace_file:
|
||||
namespace_fd: int = namespace_file.fileno()
|
||||
inode: int = token_path.stat().st_ino
|
||||
spec: BindspaceSpec = BindspaceSpec(
|
||||
kind='netns',
|
||||
key='tractor-wg0',
|
||||
lifecycle='open',
|
||||
)
|
||||
ref: BindspaceRef = BindspaceRef(
|
||||
kind='netns',
|
||||
key='tractor-wg0',
|
||||
inode=inode,
|
||||
)
|
||||
bindspace: Bindspace = Bindspace(
|
||||
spec=spec,
|
||||
ref=ref,
|
||||
namespace_fd=namespace_fd,
|
||||
ownership='owned',
|
||||
)
|
||||
|
||||
assert bindspace.spec is spec
|
||||
assert bindspace.ref is ref
|
||||
assert bindspace.namespace_fd == namespace_file.fileno()
|
||||
assert bindspace.ownership == 'owned'
|
||||
assert isinstance(bindspace, msgspec.Struct)
|
||||
assert isinstance(bindspace, ProcessLocal)
|
||||
with pytest.raises(
|
||||
TypeError,
|
||||
match='_ProcessLocalToken.*unsupported',
|
||||
):
|
||||
msgspec.msgpack.encode(bindspace)
|
||||
|
||||
|
||||
def test_bindspace_rejects_mismatched_ref(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
'''
|
||||
A name or inode mismatch would make a bindspace stale authority.
|
||||
|
||||
Construct a requested named spec, then prove both a different
|
||||
realized name and an inode not belonging to the supplied FD are
|
||||
rejected before either can become a live capability.
|
||||
|
||||
'''
|
||||
token_path: Path = tmp_path / 'bindspace'
|
||||
token_path.touch()
|
||||
spec: BindspaceSpec = BindspaceSpec(
|
||||
kind='netns',
|
||||
key='tractor-wg0',
|
||||
)
|
||||
# Keep ownership and FD fixed so only the ref changes below.
|
||||
ownership: BindspaceOwnership = 'borrowed'
|
||||
namespace_file: BinaryIO
|
||||
with token_path.open('rb') as namespace_file:
|
||||
namespace_fd: int = namespace_file.fileno()
|
||||
wrong_name: BindspaceRef = BindspaceRef(
|
||||
kind='netns',
|
||||
key='other-wg',
|
||||
inode=token_path.stat().st_ino,
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match='Spec.key.*Ref.key',
|
||||
):
|
||||
Bindspace(
|
||||
spec=spec,
|
||||
ref=wrong_name,
|
||||
namespace_fd=namespace_fd,
|
||||
ownership=ownership,
|
||||
)
|
||||
|
||||
wrong_inode: BindspaceRef = BindspaceRef(
|
||||
kind='netns',
|
||||
key='tractor-wg0',
|
||||
inode=token_path.stat().st_ino + 1,
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match='FD inode.*reference inode',
|
||||
):
|
||||
Bindspace(
|
||||
spec=spec,
|
||||
ref=wrong_inode,
|
||||
namespace_fd=namespace_fd,
|
||||
ownership=ownership,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('model', 'kwargs', 'match'),
|
||||
(
|
||||
pytest.param(
|
||||
BindspaceRef,
|
||||
{
|
||||
'kind': 'netns',
|
||||
'key': None,
|
||||
'inode': None,
|
||||
},
|
||||
'must be a positive `int`',
|
||||
id='ref-requires-inode',
|
||||
),
|
||||
pytest.param(
|
||||
BindspaceSpec,
|
||||
{'kind': 'vrf'},
|
||||
'Unsupported bindspace kind',
|
||||
id='spec-rejects-kind',
|
||||
),
|
||||
pytest.param(
|
||||
BindspaceRef,
|
||||
{
|
||||
'kind': 'vrf',
|
||||
'key': 'blue',
|
||||
'inode': 1234,
|
||||
},
|
||||
'Unsupported bindspace kind',
|
||||
id='ref-rejects-kind',
|
||||
),
|
||||
pytest.param(
|
||||
BindspaceSpec,
|
||||
{
|
||||
'kind': 'netns',
|
||||
'key': '../outside',
|
||||
},
|
||||
'Invalid netns name',
|
||||
id='spec-rejects-path',
|
||||
),
|
||||
pytest.param(
|
||||
BindspaceSpec,
|
||||
{
|
||||
'kind': 'netns',
|
||||
'key': '',
|
||||
},
|
||||
'BindspaceSpec.key',
|
||||
id='spec-rejects-empty-key',
|
||||
),
|
||||
pytest.param(
|
||||
BindspaceSpec,
|
||||
{
|
||||
'kind': 'netns',
|
||||
'key': 'tractor-wg0',
|
||||
'lifecycle': 'replace',
|
||||
},
|
||||
'Unsupported bindspace lifecycle',
|
||||
id='spec-rejects-lifecycle',
|
||||
),
|
||||
pytest.param(
|
||||
BindspaceRef,
|
||||
{
|
||||
'kind': 'netns',
|
||||
'key': '',
|
||||
'inode': 1234,
|
||||
},
|
||||
'BindspaceRef.key',
|
||||
id='ref-rejects-empty-key',
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_bindspace_models_reject_invalid_values(
|
||||
model: type[BindspaceSpec]|type[BindspaceRef],
|
||||
kwargs: dict[str, object],
|
||||
match: str,
|
||||
) -> None:
|
||||
'''
|
||||
Direct msgspec construction does not enforce field annotations.
|
||||
|
||||
Parameterize the missing stable inode and future, unimplemented
|
||||
kinds. Prove neither serializable model can carry invalid
|
||||
refs or provisioning instructions into spawn configuration.
|
||||
|
||||
'''
|
||||
with pytest.raises(ValueError, match=match):
|
||||
model(**kwargs) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform != 'linux',
|
||||
reason='Linux netns API',
|
||||
)
|
||||
def test_open_bindspace_attaches_current_netns() -> None:
|
||||
'''
|
||||
The unnamed spec must borrow and pin the caller's current netns.
|
||||
|
||||
Opening `/proc/self/ns/net` could pin the thread-group leader's
|
||||
namespace when this context runs from another thread. Prove the
|
||||
implementation selects `/proc/thread-self/ns/net`, records the
|
||||
calling thread's stable inode and borrowed ownership, then closes
|
||||
the exact descriptor without altering the namespace itself.
|
||||
|
||||
'''
|
||||
async def main() -> int:
|
||||
'''
|
||||
Borrow the current netns and return its descriptor number.
|
||||
|
||||
'''
|
||||
spec: BindspaceSpec = BindspaceSpec(
|
||||
kind='netns',
|
||||
)
|
||||
assert spec.key is CURRENT_NETNS
|
||||
assert _bindspace._THREAD_NETNS == Path(
|
||||
'/proc/thread-self/ns/net'
|
||||
)
|
||||
async with open_bindspace(spec) as bindspace:
|
||||
namespace_fd: int|None = bindspace.namespace_fd
|
||||
assert namespace_fd is not None
|
||||
assert bindspace.spec is spec
|
||||
assert bindspace.ref.key is None
|
||||
assert bindspace.ref.inode == os.fstat(
|
||||
namespace_fd
|
||||
).st_ino
|
||||
assert bindspace.ownership == 'borrowed'
|
||||
return namespace_fd
|
||||
|
||||
namespace_fd: int = trio.run(main)
|
||||
with pytest.raises(OSError):
|
||||
os.fstat(namespace_fd)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform != 'linux',
|
||||
reason='Linux netns API',
|
||||
)
|
||||
def test_attach_named_netns_uses_run_directory(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
'''
|
||||
A named spec must resolve only beneath the configured netns dir.
|
||||
|
||||
Replace the run directory with a temporary stand-in, borrow its
|
||||
named inode, and prove the context neither deletes the existing
|
||||
resource nor leaves its descriptor open after exit.
|
||||
|
||||
'''
|
||||
netns_path: Path = tmp_path / 'tractor-wg0'
|
||||
netns_path.touch()
|
||||
monkeypatch.setattr(
|
||||
_bindspace,
|
||||
'_NETNS_RUN_DIR',
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
async def main() -> int:
|
||||
'''
|
||||
Borrow the named stand-in and return its descriptor number.
|
||||
|
||||
'''
|
||||
spec: BindspaceSpec = BindspaceSpec(
|
||||
kind='netns',
|
||||
key='tractor-wg0',
|
||||
)
|
||||
async with attach_netns(spec) as bindspace:
|
||||
namespace_fd: int|None = bindspace.namespace_fd
|
||||
assert namespace_fd is not None
|
||||
assert bindspace.ref.key == 'tractor-wg0'
|
||||
assert bindspace.ref.inode == netns_path.stat().st_ino
|
||||
assert bindspace.ownership == 'borrowed'
|
||||
return namespace_fd
|
||||
|
||||
namespace_fd: int = trio.run(main)
|
||||
assert netns_path.exists()
|
||||
with pytest.raises(OSError):
|
||||
os.fstat(namespace_fd)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform != 'linux',
|
||||
reason='Linux netns API',
|
||||
)
|
||||
def test_attach_named_netns_never_creates(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
'''
|
||||
Borrow-only lookup must fail without creating a missing resource.
|
||||
|
||||
Point the run directory at an empty location, request one named
|
||||
netns, and prove the open error propagates while no path appears.
|
||||
|
||||
'''
|
||||
monkeypatch.setattr(
|
||||
_bindspace,
|
||||
'_NETNS_RUN_DIR',
|
||||
tmp_path,
|
||||
)
|
||||
missing_path: Path = tmp_path / 'missing'
|
||||
|
||||
async def main() -> None:
|
||||
'''
|
||||
Attempt to borrow one absent named netns.
|
||||
|
||||
'''
|
||||
spec: BindspaceSpec = BindspaceSpec(
|
||||
kind='netns',
|
||||
key='missing',
|
||||
)
|
||||
async with attach_netns(spec):
|
||||
raise AssertionError('Missing netns unexpectedly opened')
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
trio.run(main)
|
||||
assert not missing_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform != 'linux',
|
||||
reason='Linux netns API',
|
||||
)
|
||||
def test_open_netns_owns_lifecycle(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
'''
|
||||
Successful creation must yield ownership and remove on exit.
|
||||
|
||||
Fake pyroute2 creation with a named stand-in file, verify the
|
||||
yielded FD and ref while it exists, then prove FD closure
|
||||
precedes resource removal when the context exits.
|
||||
|
||||
'''
|
||||
events: list[str] = []
|
||||
namespace_fds: list[int] = []
|
||||
netns_path: Path = tmp_path / 'tractor-wg0'
|
||||
|
||||
def create(key: str) -> None:
|
||||
'''
|
||||
Create the named stand-in and record lifecycle order.
|
||||
|
||||
'''
|
||||
assert key == 'tractor-wg0'
|
||||
netns_path.touch()
|
||||
events.append('create')
|
||||
|
||||
def remove(key: str) -> None:
|
||||
'''
|
||||
Remove the stand-in after its FD has closed.
|
||||
|
||||
'''
|
||||
assert key == 'tractor-wg0'
|
||||
events.append('fd-closed')
|
||||
with pytest.raises(OSError):
|
||||
os.fstat(namespace_fds[0])
|
||||
netns_path.unlink()
|
||||
events.append('remove')
|
||||
|
||||
monkeypatch.setattr(
|
||||
_bindspace,
|
||||
'_NETNS_RUN_DIR',
|
||||
tmp_path,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_bindspace,
|
||||
'_create_netns',
|
||||
create,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_bindspace,
|
||||
'_remove_netns',
|
||||
remove,
|
||||
)
|
||||
|
||||
async def main() -> None:
|
||||
'''
|
||||
Open the fake netns and publish its live descriptor number.
|
||||
|
||||
'''
|
||||
spec: BindspaceSpec = BindspaceSpec(
|
||||
kind='netns',
|
||||
key='tractor-wg0',
|
||||
lifecycle='open',
|
||||
)
|
||||
async with open_bindspace(spec) as bindspace:
|
||||
fd: int|None = bindspace.namespace_fd
|
||||
assert fd is not None
|
||||
assert bindspace.ownership == 'owned'
|
||||
assert bindspace.ref.inode == os.fstat(fd).st_ino
|
||||
namespace_fds.append(fd)
|
||||
events.append('yield')
|
||||
|
||||
trio.run(main)
|
||||
assert events == [
|
||||
'create',
|
||||
'yield',
|
||||
'fd-closed',
|
||||
'remove',
|
||||
]
|
||||
assert not netns_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform != 'linux',
|
||||
reason='Linux netns API',
|
||||
)
|
||||
def test_open_netns_shields_cancelled_cleanup(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
'''
|
||||
Cancellation after creation must not leak an owned namespace.
|
||||
|
||||
Cancel the caller inside the yielded context and checkpoint.
|
||||
Prove shielded teardown still removes the stand-in before
|
||||
cancellation leaves the enclosing scope.
|
||||
|
||||
'''
|
||||
netns_path: Path = tmp_path / 'tractor-wg0'
|
||||
removed: list[str] = []
|
||||
|
||||
def create(key: str) -> None:
|
||||
'''
|
||||
Create the named stand-in before cancellation.
|
||||
|
||||
'''
|
||||
netns_path.touch()
|
||||
|
||||
def remove(key: str) -> None:
|
||||
'''
|
||||
Remove the stand-in despite caller cancellation.
|
||||
|
||||
'''
|
||||
netns_path.unlink()
|
||||
removed.append(key)
|
||||
|
||||
monkeypatch.setattr(
|
||||
_bindspace,
|
||||
'_NETNS_RUN_DIR',
|
||||
tmp_path,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_bindspace,
|
||||
'_create_netns',
|
||||
create,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_bindspace,
|
||||
'_remove_netns',
|
||||
remove,
|
||||
)
|
||||
|
||||
async def main() -> None:
|
||||
'''
|
||||
Cancel while borrowing the newly owned namespace.
|
||||
|
||||
'''
|
||||
spec: BindspaceSpec = BindspaceSpec(
|
||||
kind='netns',
|
||||
key='tractor-wg0',
|
||||
lifecycle='open',
|
||||
)
|
||||
with trio.CancelScope() as scope:
|
||||
async with open_netns(spec):
|
||||
scope.cancel()
|
||||
await trio.sleep_forever()
|
||||
|
||||
trio.run(main)
|
||||
assert removed == ['tractor-wg0']
|
||||
assert not netns_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform != 'linux',
|
||||
reason='Linux netns API',
|
||||
)
|
||||
def test_open_netns_requires_name() -> None:
|
||||
'''
|
||||
Creation cannot target the caller's current netns.
|
||||
|
||||
Pass `CURRENT_NETNS` and prove validation rejects it before any
|
||||
privileged pyroute2 operation can run.
|
||||
|
||||
'''
|
||||
spec: BindspaceSpec = BindspaceSpec(
|
||||
kind='netns',
|
||||
key=CURRENT_NETNS,
|
||||
lifecycle='open',
|
||||
)
|
||||
|
||||
async def main() -> None:
|
||||
'''
|
||||
Attempt to create the unnamed current namespace.
|
||||
|
||||
'''
|
||||
async with open_netns(spec):
|
||||
raise AssertionError(
|
||||
'Current netns unexpectedly created'
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match='requires a named',
|
||||
):
|
||||
trio.run(main)
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
'''
|
||||
Multiaddr construction, parsing, and round-trip tests for
|
||||
`tractor.discovery._multiaddr.mk_maddr()` and
|
||||
`tractor.discovery._multiaddr.parse_maddr()`.
|
||||
`tractor.net.mk_maddr()` and `tractor.net.parse_maddr()`.
|
||||
|
||||
'''
|
||||
from pathlib import Path
|
||||
|
|
@ -10,18 +9,39 @@ from types import SimpleNamespace
|
|||
import pytest
|
||||
from multiaddr import Multiaddr
|
||||
|
||||
from tractor.net import (
|
||||
TunnelledAddress,
|
||||
WGTunnelSpec,
|
||||
mb_pubkey,
|
||||
mk_wg_maddr,
|
||||
mk_maddr,
|
||||
parse_endpoints,
|
||||
parse_maddr,
|
||||
parse_wg_maddr,
|
||||
tunnels_of,
|
||||
)
|
||||
from tractor.ipc._tcp import TCPAddress
|
||||
from tractor.ipc._uds import UDSAddress
|
||||
from tractor.discovery._multiaddr import (
|
||||
mk_maddr,
|
||||
parse_maddr,
|
||||
parse_endpoints,
|
||||
_tpt_proto_to_maddr,
|
||||
_maddr_to_tpt_proto,
|
||||
)
|
||||
from tractor.discovery._addr import wrap_address
|
||||
|
||||
|
||||
_WG_PUBKEY: str = (
|
||||
'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
|
||||
)
|
||||
_WG_PUBKEY_2: str = (
|
||||
'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8='
|
||||
)
|
||||
_WG_MADDR: str = (
|
||||
f'/ip4/192.168.1.50/udp/51820'
|
||||
f'/wg/{mb_pubkey(_WG_PUBKEY)}'
|
||||
f'/ip4/10.0.11.1/tcp/1616'
|
||||
)
|
||||
|
||||
|
||||
def test_tpt_proto_to_maddr_mapping():
|
||||
'''
|
||||
`_tpt_proto_to_maddr` maps all supported `proto_key`
|
||||
|
|
@ -167,7 +187,7 @@ def test_parse_maddr_tcp_ipv4():
|
|||
result = parse_maddr('/ip4/127.0.0.1/tcp/1234')
|
||||
|
||||
assert isinstance(result, TCPAddress)
|
||||
assert result.unwrap() == ('127.0.0.1', 1234)
|
||||
assert result.unwrap() == ('tcp', '127.0.0.1', 1234)
|
||||
|
||||
|
||||
def test_parse_maddr_tcp_ipv6():
|
||||
|
|
@ -179,7 +199,7 @@ def test_parse_maddr_tcp_ipv6():
|
|||
result = parse_maddr('/ip6/::1/tcp/5678')
|
||||
|
||||
assert isinstance(result, TCPAddress)
|
||||
assert result.unwrap() == ('::1', 5678)
|
||||
assert result.unwrap() == ('tcp', '::1', 5678)
|
||||
|
||||
|
||||
def test_parse_maddr_uds():
|
||||
|
|
@ -192,9 +212,10 @@ def test_parse_maddr_uds():
|
|||
result = parse_maddr('/unix/tmp/tractor_test/test.sock')
|
||||
|
||||
assert isinstance(result, UDSAddress)
|
||||
filedir, filename = result.unwrap()
|
||||
assert filename == 'test.sock'
|
||||
assert str(filedir) == '/tmp/tractor_test'
|
||||
assert result.unwrap() == (
|
||||
'unix',
|
||||
'/tmp/tractor_test/test.sock',
|
||||
)
|
||||
|
||||
|
||||
def test_parse_maddr_unsupported():
|
||||
|
|
@ -210,6 +231,181 @@ def test_parse_maddr_unsupported():
|
|||
parse_maddr('/ip4/127.0.0.1/udp/1234')
|
||||
|
||||
|
||||
def test_parse_wg_maddr():
|
||||
'''
|
||||
`parse_maddr()` previously rejected the canonical infix `/wg/`
|
||||
grammar even though `py-multiaddr` parsed it. Feed a bearer,
|
||||
identity, and TCP overlay through both the WG-specific and public
|
||||
parsers, then prove they produce the same local-only tunnel
|
||||
annotation without changing the bindable overlay.
|
||||
|
||||
'''
|
||||
parsed = parse_wg_maddr(_WG_MADDR)
|
||||
|
||||
assert parse_maddr(_WG_MADDR) == parsed
|
||||
assert isinstance(parsed, TunnelledAddress)
|
||||
assert parsed.tunnel == WGTunnelSpec(
|
||||
peer_pubkey=_WG_PUBKEY,
|
||||
bearer=('192.168.1.50', 51820),
|
||||
)
|
||||
assert isinstance(parsed.overlay, TCPAddress)
|
||||
assert parsed.overlay.unwrap() == ('tcp', '10.0.11.1', 1616)
|
||||
|
||||
|
||||
def test_mk_wg_maddr_roundtrip():
|
||||
'''
|
||||
`mk_maddr()` previously saw only the wrapper's delegated TCP
|
||||
proto-key and silently dropped all tunnel metadata. Parse the
|
||||
canonical maddr, compose it through both public entry points, and
|
||||
prove bearer, key, and overlay survive byte-for-byte.
|
||||
|
||||
'''
|
||||
parsed = parse_wg_maddr(_WG_MADDR)
|
||||
|
||||
assert str(mk_wg_maddr(parsed)) == _WG_MADDR
|
||||
assert str(mk_maddr(parsed)) == _WG_MADDR
|
||||
|
||||
|
||||
def test_nested_wg_maddr_roundtrip():
|
||||
'''
|
||||
A single first-match lookup confuses nested WG keys and bearers.
|
||||
Arrange an IPv4 outer bearer around an IPv6 inner bearer, parse
|
||||
from the last `/wg/` outward, and assert tunnel ordering plus an
|
||||
exact re-composition of the original stack.
|
||||
|
||||
'''
|
||||
nested_maddr: str = (
|
||||
f'/ip4/192.168.1.50/udp/51820'
|
||||
f'/wg/{mb_pubkey(_WG_PUBKEY)}'
|
||||
f'/ip6/2001:db8::2/udp/51821'
|
||||
f'/wg/{mb_pubkey(_WG_PUBKEY_2)}'
|
||||
f'/ip4/10.0.11.1/tcp/1616'
|
||||
)
|
||||
|
||||
parsed = parse_maddr(nested_maddr)
|
||||
specs = tunnels_of(parsed)
|
||||
|
||||
assert len(specs) == 2
|
||||
assert specs[0].peer_pubkey == _WG_PUBKEY
|
||||
assert specs[0].bearer == ('192.168.1.50', 51820)
|
||||
assert specs[1].peer_pubkey == _WG_PUBKEY_2
|
||||
assert specs[1].bearer == ('2001:db8::2', 51821)
|
||||
assert str(mk_maddr(parsed)) == nested_maddr
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'maddr, match',
|
||||
[
|
||||
pytest.param(
|
||||
(
|
||||
f'/ip4/192.168.1.50/tcp/51820'
|
||||
f'/wg/{mb_pubkey(_WG_PUBKEY)}'
|
||||
f'/ip4/10.0.11.1/tcp/1616'
|
||||
),
|
||||
'Bad `wg` bearer',
|
||||
id='non-udp-bearer',
|
||||
),
|
||||
pytest.param(
|
||||
(
|
||||
f'/ip4/192.168.1.50/udp/51820'
|
||||
f'/wg/{mb_pubkey(_WG_PUBKEY)}'
|
||||
),
|
||||
'no overlay endpoint',
|
||||
id='missing-overlay',
|
||||
),
|
||||
pytest.param(
|
||||
(
|
||||
f'/ip4/192.168.1.50/udp/51820'
|
||||
f'/wg/{mb_pubkey(_WG_PUBKEY)}'
|
||||
f'/ip4/10.0.11.1/udp/1616'
|
||||
),
|
||||
'Unsupported `wg` overlay',
|
||||
id='non-tcp-overlay',
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_parse_wg_maddr_rejects_bad_grammar(
|
||||
maddr: str,
|
||||
match: str,
|
||||
):
|
||||
'''
|
||||
Accepting an invalid bearer or overlay assigns an endpoint to the
|
||||
wrong runtime owner. Exercise parseable but unsupported protocol
|
||||
combinations and prove each fails before constructing a wrapper,
|
||||
with an error identifying the violated WG grammar boundary.
|
||||
|
||||
'''
|
||||
with pytest.raises(ValueError, match=match):
|
||||
parse_maddr(maddr)
|
||||
|
||||
|
||||
def test_parse_wg_maddr_rejects_malformed_key():
|
||||
'''
|
||||
A truncated multibase key used to be vulnerable to silent
|
||||
identity corruption in hand-written parsers. Give the upstream
|
||||
`/wg/` codec a short key and prove `Multiaddr()` rejects it
|
||||
before tractor's wrapper parser runs.
|
||||
|
||||
'''
|
||||
maddr: str = (
|
||||
'/ip4/192.168.1.50/udp/51820'
|
||||
'/wg/udG9vIHNob3J0'
|
||||
'/ip4/10.0.11.1/tcp/1616'
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
parse_maddr(maddr)
|
||||
|
||||
|
||||
def test_parse_wg_maddr_reports_missing_codec(
|
||||
monkeypatch,
|
||||
):
|
||||
'''
|
||||
Released `multiaddr==0.2.0` does not know `/wg/` and emits an
|
||||
opaque unknown-protocol parse error. Simulate that registry and
|
||||
prove an actual WG stack reports the dependency action while a
|
||||
Unix path containing a `wg` directory remains ordinary UDS data.
|
||||
|
||||
'''
|
||||
from multiaddr.exceptions import ProtocolNotFoundError
|
||||
from multiaddr import protocols
|
||||
|
||||
def no_wg_proto(name: str):
|
||||
raise ProtocolNotFoundError(name)
|
||||
|
||||
monkeypatch.setattr(
|
||||
protocols,
|
||||
'protocol_with_name',
|
||||
no_wg_proto,
|
||||
)
|
||||
uds = parse_maddr('/unix/tmp/wg/service.sock')
|
||||
assert isinstance(uds, UDSAddress)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match='py-multiaddr#108',
|
||||
):
|
||||
parse_maddr(_WG_MADDR)
|
||||
|
||||
|
||||
def test_mk_wg_maddr_requires_bearer():
|
||||
'''
|
||||
A key-only tunnel spec relies on local configuration and cannot
|
||||
be reconstructed as the canonical bearer-first maddr. Build that
|
||||
incomplete annotation and prove composition raises instead of
|
||||
emitting a misleading overlay-only address.
|
||||
|
||||
'''
|
||||
addr = TunnelledAddress(
|
||||
overlay=TCPAddress('10.0.11.1', 1616),
|
||||
tunnel=WGTunnelSpec(peer_pubkey=_WG_PUBKEY),
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match='without a bearer',
|
||||
):
|
||||
mk_maddr(addr)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'addr',
|
||||
[
|
||||
|
|
@ -249,7 +445,22 @@ def test_wrap_address_maddr_str():
|
|||
result = wrap_address('/ip4/127.0.0.1/tcp/9999')
|
||||
|
||||
assert isinstance(result, TCPAddress)
|
||||
assert result.unwrap() == ('127.0.0.1', 9999)
|
||||
assert result.unwrap() == ('tcp', '127.0.0.1', 9999)
|
||||
|
||||
|
||||
def test_wrap_address_wg_maddr_str():
|
||||
'''
|
||||
`wrap_address()` delegates slash-prefixed strings to
|
||||
`parse_maddr()`. Pass a canonical WG maddr through that public
|
||||
boundary and prove it preserves the tunnel annotation rather than
|
||||
rejecting the protocol stack or returning only its TCP overlay.
|
||||
|
||||
'''
|
||||
result = wrap_address(_WG_MADDR)
|
||||
|
||||
assert isinstance(result, TunnelledAddress)
|
||||
assert result.tunnel.peer_pubkey == _WG_PUBKEY
|
||||
assert result.overlay.unwrap() == ('tcp', '10.0.11.1', 1616)
|
||||
|
||||
|
||||
# ------ parse_endpoints() tests ------
|
||||
|
|
@ -270,11 +481,11 @@ def test_parse_endpoints_tcp_only():
|
|||
|
||||
reg_addr = result['registry'][0]
|
||||
assert isinstance(reg_addr, TCPAddress)
|
||||
assert reg_addr.unwrap() == ('127.0.0.1', 1616)
|
||||
assert reg_addr.unwrap() == ('tcp', '127.0.0.1', 1616)
|
||||
|
||||
feed_addr = result['data_feed'][0]
|
||||
assert isinstance(feed_addr, TCPAddress)
|
||||
assert feed_addr.unwrap() == ('0.0.0.0', 5555)
|
||||
assert feed_addr.unwrap() == ('tcp', '0.0.0.0', 5555)
|
||||
|
||||
|
||||
def test_parse_endpoints_mixed_tpts():
|
||||
|
|
@ -294,12 +505,37 @@ def test_parse_endpoints_mixed_tpts():
|
|||
|
||||
assert len(addrs) == 2
|
||||
assert isinstance(addrs[0], TCPAddress)
|
||||
assert addrs[0].unwrap() == ('127.0.0.1', 4040)
|
||||
assert addrs[0].unwrap() == ('tcp', '127.0.0.1', 4040)
|
||||
|
||||
assert isinstance(addrs[1], UDSAddress)
|
||||
filedir, filename = addrs[1].unwrap()
|
||||
assert filename == 'broker.sock'
|
||||
assert str(filedir) == '/tmp/tractor'
|
||||
assert addrs[1].unwrap() == (
|
||||
'unix',
|
||||
'/tmp/tractor/broker.sock',
|
||||
)
|
||||
|
||||
|
||||
def test_parse_endpoints_wg_maddr():
|
||||
'''
|
||||
Service endpoint tables previously rejected WG protocol stacks.
|
||||
Put a tunnelled maddr beside a plain TCP address and prove
|
||||
`parse_endpoints()` retains input order while delivering the
|
||||
wrapper needed by the future bindspace lifecycle.
|
||||
|
||||
'''
|
||||
table = {
|
||||
'registry': [
|
||||
_WG_MADDR,
|
||||
'/ip4/127.0.0.1/tcp/1616',
|
||||
],
|
||||
}
|
||||
addrs = parse_endpoints(table)['registry']
|
||||
|
||||
assert isinstance(addrs[0], TunnelledAddress)
|
||||
assert addrs[0].tunnel.bearer == (
|
||||
'192.168.1.50',
|
||||
51820,
|
||||
)
|
||||
assert isinstance(addrs[1], TCPAddress)
|
||||
|
||||
|
||||
def test_parse_endpoints_unwrapped_tuples():
|
||||
|
|
@ -315,7 +551,7 @@ def test_parse_endpoints_unwrapped_tuples():
|
|||
|
||||
addr = result['ems'][0]
|
||||
assert isinstance(addr, TCPAddress)
|
||||
assert addr.unwrap() == ('127.0.0.1', 6666)
|
||||
assert addr.unwrap() == ('tcp', '127.0.0.1', 6666)
|
||||
|
||||
|
||||
def test_parse_endpoints_mixed_str_and_tuple():
|
||||
|
|
@ -335,10 +571,10 @@ def test_parse_endpoints_mixed_str_and_tuple():
|
|||
|
||||
assert len(addrs) == 2
|
||||
assert isinstance(addrs[0], TCPAddress)
|
||||
assert addrs[0].unwrap() == ('127.0.0.1', 7777)
|
||||
assert addrs[0].unwrap() == ('tcp', '127.0.0.1', 7777)
|
||||
|
||||
assert isinstance(addrs[1], TCPAddress)
|
||||
assert addrs[1].unwrap() == ('127.0.0.1', 8888)
|
||||
assert addrs[1].unwrap() == ('tcp', '127.0.0.1', 8888)
|
||||
|
||||
|
||||
def test_parse_endpoints_unsupported_proto():
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
'''
|
||||
`TunnelledAddress` delegation + peeling semantics.
|
||||
|
||||
A tunnel annotates an existing L4 addr rather than being its own
|
||||
transport, so the contract under test is mostly *delegation*: the
|
||||
runtime must not be able to tell a tunnelled addr from its
|
||||
overlay, and **nothing** about the tunnel may cross the wire.
|
||||
|
||||
See `ai/tpt-backends/03_wg_tunnel_bindspace.md` §3.1/§3.4.
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
|
||||
from tractor.net import (
|
||||
BindspaceRef,
|
||||
TunnelledAddress,
|
||||
WGTunnelSpec,
|
||||
mb_pubkey,
|
||||
strip_tunnels,
|
||||
tunnels_of,
|
||||
wg8_pubkey,
|
||||
)
|
||||
from tractor.discovery._addr import (
|
||||
is_wrapped_addr,
|
||||
wrap_address,
|
||||
)
|
||||
from tractor.ipc._tcp import TCPAddress
|
||||
from tractor.ipc._uds import UDSAddress
|
||||
|
||||
|
||||
# a valid-looking std-base64 `wg(8)` pubkey (32B -> 44 chars)
|
||||
_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def overlay() -> TCPAddress:
|
||||
return TCPAddress('10.0.11.1', 1616)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spec() -> WGTunnelSpec:
|
||||
return WGTunnelSpec(
|
||||
peer_pubkey=_PUBKEY,
|
||||
bearer=('192.168.1.50', 51820),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tunnelled(
|
||||
overlay: TCPAddress,
|
||||
spec: WGTunnelSpec,
|
||||
) -> TunnelledAddress:
|
||||
return TunnelledAddress(overlay=overlay, tunnel=spec)
|
||||
|
||||
|
||||
def test_wg_pubkey_codec_roundtrip():
|
||||
'''
|
||||
Standard `wg(8)` base64 keys can contain `/`, which cannot be
|
||||
embedded unchanged in a slash-delimited maddr. Prove the helper
|
||||
emits `u`-prefixed multibase base64url without `/` and decodes
|
||||
it back to the exact original 32-byte key.
|
||||
|
||||
'''
|
||||
mb_key: str = mb_pubkey(_PUBKEY)
|
||||
|
||||
assert mb_key.startswith('u')
|
||||
assert '/' not in mb_key
|
||||
assert wg8_pubkey(mb_key) == _PUBKEY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'key, converter',
|
||||
[
|
||||
pytest.param(
|
||||
'dG9vIHNob3J0',
|
||||
mb_pubkey,
|
||||
id='wg8-base64',
|
||||
),
|
||||
pytest.param(
|
||||
'udG9vIHNob3J0',
|
||||
wg8_pubkey,
|
||||
id='multibase',
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_wg_pubkey_codec_rejects_wrong_size(
|
||||
key: str,
|
||||
converter,
|
||||
):
|
||||
'''
|
||||
WireGuard silently-corrupt key handling would let an invalid
|
||||
identity reach peer verification. Exercise both input encodings
|
||||
with a short payload and prove conversion rejects it before a
|
||||
tunnel spec or maddr can be constructed.
|
||||
|
||||
'''
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match='must decode to 32 bytes',
|
||||
):
|
||||
converter(key)
|
||||
|
||||
|
||||
def test_proto_key_delegates(
|
||||
tunnelled: TunnelledAddress,
|
||||
overlay: TCPAddress,
|
||||
):
|
||||
'''
|
||||
A tunnel has no transport of its own, so every table lookup
|
||||
must see the *overlay's* proto-key.
|
||||
|
||||
'''
|
||||
assert tunnelled.proto_key == overlay.proto_key == 'tcp'
|
||||
|
||||
|
||||
def test_unwrap_is_identical_to_overlay(
|
||||
tunnelled: TunnelledAddress,
|
||||
overlay: TCPAddress,
|
||||
):
|
||||
'''
|
||||
The whole point: nothing new crosses the wire, so a peer
|
||||
never has to understand tunnels.
|
||||
|
||||
'''
|
||||
assert tunnelled.unwrap() == overlay.unwrap()
|
||||
|
||||
# and it must survive msgpack as-is
|
||||
enc: bytes = msgspec.msgpack.encode(tunnelled.unwrap())
|
||||
assert msgspec.msgpack.decode(enc) == list(overlay.unwrap())
|
||||
|
||||
|
||||
def test_unwrap_roundtrips_back_to_plain_overlay(
|
||||
tunnelled: TunnelledAddress,
|
||||
overlay: TCPAddress,
|
||||
):
|
||||
'''
|
||||
`wrap_address()` on a tunnelled addr's unwrapped form yields
|
||||
the *plain* overlay type — the tunnel is simply absent, which
|
||||
is correct: it was never on the wire.
|
||||
|
||||
'''
|
||||
rewrapped = wrap_address(tunnelled.unwrap())
|
||||
assert type(rewrapped) is TCPAddress
|
||||
assert rewrapped == overlay
|
||||
assert not isinstance(rewrapped, TunnelledAddress)
|
||||
|
||||
|
||||
def test_bindspace_and_validity_delegate(
|
||||
tunnelled: TunnelledAddress,
|
||||
overlay: TCPAddress,
|
||||
):
|
||||
assert tunnelled.bindspace == overlay.bindspace
|
||||
assert tunnelled.is_valid == overlay.is_valid
|
||||
|
||||
|
||||
def test_is_wrapped_addr_accepts_tunnelled(
|
||||
tunnelled: TunnelledAddress,
|
||||
overlay: TCPAddress,
|
||||
):
|
||||
'''
|
||||
`TunnelledAddress` is deliberately absent from
|
||||
`_address_types`, so `is_wrapped_addr()` needs its own
|
||||
clause.
|
||||
|
||||
'''
|
||||
assert is_wrapped_addr(overlay)
|
||||
assert is_wrapped_addr(tunnelled)
|
||||
# the unwrapped form is NOT a wrapped addr
|
||||
assert not is_wrapped_addr(tunnelled.unwrap())
|
||||
|
||||
|
||||
def test_namespace_comes_from_the_tunnel(
|
||||
overlay: TCPAddress,
|
||||
):
|
||||
'''
|
||||
Plain transport addresses explicitly select no namespace, while
|
||||
a tunnel can select one for the same concrete overlay.
|
||||
|
||||
'''
|
||||
uds_addr: UDSAddress = UDSAddress('/tmp', 'tractor-test.sock')
|
||||
assert overlay.namespace is None
|
||||
assert uds_addr.namespace is None
|
||||
|
||||
no_ns = TunnelledAddress(
|
||||
overlay=overlay,
|
||||
tunnel=WGTunnelSpec(peer_pubkey=_PUBKEY),
|
||||
)
|
||||
assert no_ns.namespace is None
|
||||
|
||||
in_ns = TunnelledAddress(
|
||||
overlay=overlay,
|
||||
tunnel=WGTunnelSpec(peer_pubkey=_PUBKEY, netns='wg-test'),
|
||||
)
|
||||
assert in_ns.namespace == ('netns', 'wg-test')
|
||||
|
||||
|
||||
def test_realized_namespace_uses_stable_ref(
|
||||
overlay: TCPAddress,
|
||||
) -> None:
|
||||
'''
|
||||
Realization must retain a stable ref without mutating the maddr.
|
||||
|
||||
Build an unrealized named declaration, annotate it with the
|
||||
matching realized key and inode, and prove the frozen original is
|
||||
unchanged. The annotated copy must preserve transport delegation
|
||||
and expose the stable inode through `.namespace`. Direct msgspec
|
||||
encoding also proves only serializable ref metadata was retained.
|
||||
|
||||
'''
|
||||
declared: TunnelledAddress = TunnelledAddress(
|
||||
overlay=overlay,
|
||||
tunnel=WGTunnelSpec(
|
||||
peer_pubkey=_PUBKEY,
|
||||
netns='wg-test',
|
||||
),
|
||||
)
|
||||
ref: BindspaceRef = BindspaceRef(
|
||||
kind='netns',
|
||||
key='wg-test',
|
||||
inode=1234,
|
||||
)
|
||||
realized: TunnelledAddress = declared.with_bindspace_ref(
|
||||
ref,
|
||||
)
|
||||
|
||||
assert declared.bindspace_ref is None
|
||||
assert realized.bindspace_ref is ref
|
||||
assert realized.namespace == ('netns', 1234)
|
||||
assert realized.overlay is declared.overlay
|
||||
assert realized.tunnel is declared.tunnel
|
||||
assert realized.unwrap() == declared.unwrap()
|
||||
assert realized.bindspace == declared.bindspace
|
||||
|
||||
declared_payload: dict[str, object] = msgspec.msgpack.decode(
|
||||
msgspec.msgpack.encode(declared)
|
||||
)
|
||||
assert 'bindspace_ref' not in declared_payload
|
||||
|
||||
decoded: dict[str, object] = msgspec.msgpack.decode(
|
||||
msgspec.msgpack.encode(realized)
|
||||
)
|
||||
assert decoded['bindspace_ref'] == {
|
||||
'kind': 'netns',
|
||||
'key': 'wg-test',
|
||||
'inode': 1234,
|
||||
}
|
||||
|
||||
mismatched: BindspaceRef = BindspaceRef(
|
||||
kind='netns',
|
||||
key='other-netns',
|
||||
inode=5678,
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match='wg-test.*other-netns',
|
||||
):
|
||||
declared.with_bindspace_ref(
|
||||
mismatched,
|
||||
)
|
||||
|
||||
|
||||
def test_strip_tunnels(
|
||||
tunnelled: TunnelledAddress,
|
||||
overlay: TCPAddress,
|
||||
spec: WGTunnelSpec,
|
||||
):
|
||||
# idempotent on a plain addr
|
||||
assert strip_tunnels(overlay) is overlay
|
||||
# peels one
|
||||
assert strip_tunnels(tunnelled) is overlay
|
||||
# and collapses a nested stack in one call
|
||||
nested = TunnelledAddress(overlay=tunnelled, tunnel=spec)
|
||||
assert strip_tunnels(nested) is overlay
|
||||
|
||||
|
||||
def test_tunnels_of_is_outermost_first(
|
||||
tunnelled: TunnelledAddress,
|
||||
overlay: TCPAddress,
|
||||
):
|
||||
assert tunnels_of(overlay) == ()
|
||||
assert tunnels_of(tunnelled) == (tunnelled.tunnel,)
|
||||
|
||||
inner_spec = WGTunnelSpec(peer_pubkey=_PUBKEY, iface='wg1')
|
||||
nested = TunnelledAddress(
|
||||
overlay=tunnelled,
|
||||
tunnel=inner_spec,
|
||||
)
|
||||
assert tunnels_of(nested) == (inner_spec, tunnelled.tunnel)
|
||||
|
||||
|
||||
def test_frozen(
|
||||
tunnelled: TunnelledAddress,
|
||||
):
|
||||
with pytest.raises(AttributeError):
|
||||
tunnelled.overlay = TCPAddress('127.0.0.1', 1)
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
'''
|
||||
Process-local WireGuard interface configuration contracts.
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
|
||||
from tractor.net import (
|
||||
WGInterfaceConfig,
|
||||
WGPeerConfig,
|
||||
)
|
||||
from tractor.msg import ProcessLocal
|
||||
|
||||
|
||||
_PRIVATE_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
||||
_PEER_KEY: str = 'r1LKM1pqhuY9Z6L4y5jQ2fGX67kJSrq5kRV5Jk2ywEo='
|
||||
_PRESHARED_KEY: str = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA='
|
||||
|
||||
|
||||
def test_wg_config_is_process_local_and_redacted() -> None:
|
||||
'''
|
||||
Private WireGuard configuration must neither print nor cross IPC.
|
||||
|
||||
Construct a complete local config and prove its public routing
|
||||
policy remains inspectable while both keys are absent from repr.
|
||||
Verify `ProcessLocal` blocks default msgpack encoding.
|
||||
|
||||
'''
|
||||
peer: WGPeerConfig = WGPeerConfig(
|
||||
public_key=_PEER_KEY,
|
||||
allowed_ips=('10.1.0.0/16', 'fd01::/64'),
|
||||
endpoint=('192.0.2.1', 51820),
|
||||
preshared_key=_PRESHARED_KEY,
|
||||
persistent_keepalive=25,
|
||||
)
|
||||
config: WGInterfaceConfig = WGInterfaceConfig(
|
||||
private_key=_PRIVATE_KEY,
|
||||
addresses=('10.0.0.1/24', 'fd00::1/64'),
|
||||
listen_port=51820,
|
||||
peers=(peer,),
|
||||
)
|
||||
|
||||
config_repr: str = repr(config)
|
||||
assert isinstance(config, ProcessLocal)
|
||||
assert _PRIVATE_KEY not in config_repr
|
||||
assert _PRESHARED_KEY not in config_repr
|
||||
assert config.addresses[0] in config_repr
|
||||
assert peer.allowed_ips[0] in config_repr
|
||||
with pytest.raises(
|
||||
TypeError,
|
||||
match='_ProcessLocalToken.*unsupported',
|
||||
):
|
||||
msgspec.msgpack.encode(config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('kwargs', 'error'),
|
||||
(
|
||||
pytest.param(
|
||||
{'private_key': 'not-base64'},
|
||||
ValueError,
|
||||
id='private-key',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'private_key': _PRIVATE_KEY,
|
||||
'addresses': ('not-an-interface',),
|
||||
},
|
||||
ValueError,
|
||||
id='address',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'private_key': _PRIVATE_KEY,
|
||||
'listen_port': 65536,
|
||||
},
|
||||
ValueError,
|
||||
id='listen-port',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'private_key': _PRIVATE_KEY,
|
||||
'peers': (
|
||||
WGPeerConfig(public_key=_PEER_KEY),
|
||||
WGPeerConfig(public_key=_PEER_KEY),
|
||||
),
|
||||
},
|
||||
ValueError,
|
||||
id='duplicate-peer',
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_wg_config_rejects_invalid_values(
|
||||
kwargs: dict[str, object],
|
||||
error: type[Exception],
|
||||
) -> None:
|
||||
'''
|
||||
Invalid config must fail before kernel mutation.
|
||||
|
||||
Parameterize every validated input class and prove direct msgspec
|
||||
construction cannot carry malformed configuration into a future
|
||||
pyroute2 interface lifecycle.
|
||||
|
||||
'''
|
||||
with pytest.raises(error):
|
||||
WGInterfaceConfig(**kwargs) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'kwargs',
|
||||
(
|
||||
pytest.param(
|
||||
{'public_key': 'not-base64'},
|
||||
id='public-key',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'public_key': _PEER_KEY,
|
||||
'preshared_key': 'not-base64',
|
||||
},
|
||||
id='preshared-key',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'public_key': _PEER_KEY,
|
||||
'allowed_ips': ('not-a-network',),
|
||||
},
|
||||
id='allowed-ip',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'public_key': _PEER_KEY,
|
||||
'endpoint': ('not-an-ip', 51820),
|
||||
},
|
||||
id='endpoint-host',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'public_key': _PEER_KEY,
|
||||
'endpoint': ('192.0.2.1', 65536),
|
||||
},
|
||||
id='endpoint-port',
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
'public_key': _PEER_KEY,
|
||||
'persistent_keepalive': -1,
|
||||
},
|
||||
id='keepalive',
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_wg_peer_config_rejects_invalid_values(
|
||||
kwargs: dict[str, object],
|
||||
) -> None:
|
||||
'''
|
||||
Invalid peer policy must fail before kernel mutation.
|
||||
|
||||
'''
|
||||
with pytest.raises(ValueError):
|
||||
WGPeerConfig(**kwargs) # type: ignore[arg-type]
|
||||
|
|
@ -0,0 +1,476 @@
|
|||
'''
|
||||
WireGuard interface policy and owned lifecycle contracts.
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager as acm
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import BinaryIO
|
||||
|
||||
import pytest
|
||||
import trio
|
||||
|
||||
import tractor
|
||||
from tractor.net import (
|
||||
Bindspace,
|
||||
BindspaceRef,
|
||||
BindspaceSpec,
|
||||
WGInterfaceConfig,
|
||||
WGPeerConfig,
|
||||
WGTunnelSpec,
|
||||
open_wg_bindspace,
|
||||
open_wg_iface,
|
||||
)
|
||||
from tractor.net import _tunnel
|
||||
|
||||
|
||||
_LOCAL_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
||||
_PEER_KEY: str = 'r1LKM1pqhuY9Z6L4y5jQ2fGX67kJSrq5kRV5Jk2ywEo='
|
||||
|
||||
|
||||
def test_wg_iface_settings_follow_role() -> None:
|
||||
'''
|
||||
Listen and dial roles interpret the tunnel bearer differently.
|
||||
|
||||
Prove listen derives its local port from the bearer while dial
|
||||
applies the bearer as the selected peer's omitted endpoint. Other
|
||||
explicit peers retain their own endpoint and routing policy.
|
||||
|
||||
'''
|
||||
selected: WGPeerConfig = WGPeerConfig(
|
||||
public_key=_PEER_KEY,
|
||||
allowed_ips=('10.1.0.0/16',),
|
||||
)
|
||||
other: WGPeerConfig = WGPeerConfig(
|
||||
public_key=_LOCAL_KEY,
|
||||
endpoint=('198.51.100.2', 51821),
|
||||
)
|
||||
config: WGInterfaceConfig = WGInterfaceConfig(
|
||||
private_key=_LOCAL_KEY,
|
||||
peers=(selected, other),
|
||||
)
|
||||
spec: WGTunnelSpec = WGTunnelSpec(
|
||||
peer_pubkey=_PEER_KEY,
|
||||
bearer=('192.0.2.1', 51820),
|
||||
)
|
||||
|
||||
listen_port: int|None
|
||||
listen_peers: tuple[dict[str, object], ...]
|
||||
listen_port, listen_peers = _tunnel._wg_iface_settings(
|
||||
spec,
|
||||
config,
|
||||
'listen',
|
||||
)
|
||||
assert listen_port == 51820
|
||||
# A listen bearer configures the local port, not a peer endpoint.
|
||||
assert 'endpoint_addr' not in listen_peers[0]
|
||||
|
||||
dial_port: int|None
|
||||
dial_peers: tuple[dict[str, object], ...]
|
||||
dial_port, dial_peers = _tunnel._wg_iface_settings(
|
||||
spec,
|
||||
config,
|
||||
'dial',
|
||||
)
|
||||
# No local dial listen port was declared; the bearer is remote.
|
||||
assert dial_port is None
|
||||
assert dial_peers[0]['endpoint_addr'] == '192.0.2.1'
|
||||
assert dial_peers[0]['endpoint_port'] == 51820
|
||||
assert dial_peers[0]['allowed_ips'] == ['10.1.0.0/16']
|
||||
assert dial_peers[1]['endpoint_addr'] == '198.51.100.2'
|
||||
assert dial_peers[1]['endpoint_port'] == 51821
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('config', 'role', 'match'),
|
||||
(
|
||||
pytest.param(
|
||||
WGInterfaceConfig(
|
||||
private_key=_LOCAL_KEY,
|
||||
),
|
||||
'dial',
|
||||
'not in.*configured peer keys',
|
||||
id='missing-dial-peer',
|
||||
),
|
||||
pytest.param(
|
||||
WGInterfaceConfig(
|
||||
private_key=_LOCAL_KEY,
|
||||
listen_port=51821,
|
||||
),
|
||||
'listen',
|
||||
'51821.*51820',
|
||||
id='listen-port-51821-vs-bearer-51820',
|
||||
),
|
||||
pytest.param(
|
||||
WGInterfaceConfig(
|
||||
private_key=_LOCAL_KEY,
|
||||
peers=(
|
||||
WGPeerConfig(
|
||||
public_key=_PEER_KEY,
|
||||
endpoint=('198.51.100.1', 51820),
|
||||
),
|
||||
),
|
||||
),
|
||||
'dial',
|
||||
'198.51.100.1.*192.0.2.1',
|
||||
id='dial-endpoint-conflict',
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_wg_iface_settings_reject_conflicts(
|
||||
config: WGInterfaceConfig,
|
||||
role: str,
|
||||
match: str,
|
||||
) -> None:
|
||||
'''
|
||||
Role-dependent conflicts must fail before pyroute2 side effects.
|
||||
|
||||
'''
|
||||
spec: WGTunnelSpec = WGTunnelSpec(
|
||||
peer_pubkey=_PEER_KEY,
|
||||
bearer=('192.0.2.1', 51820),
|
||||
)
|
||||
with pytest.raises(ValueError, match=match):
|
||||
_tunnel._wg_iface_settings(
|
||||
spec,
|
||||
config,
|
||||
role, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def test_open_wg_iface_shields_cancelled_cleanup(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
'''
|
||||
Cancellation after creation must still remove the owned WG iface.
|
||||
|
||||
Pin a stand-in namespace FD and fake privileged create/remove
|
||||
calls. Cancel inside the yielded context and prove shielded
|
||||
teardown runs before cancellation leaves the enclosing scope.
|
||||
|
||||
'''
|
||||
token_path: Path = tmp_path / 'netns'
|
||||
token_path.touch()
|
||||
events: list[str] = []
|
||||
|
||||
def create(
|
||||
spec: WGTunnelSpec,
|
||||
config: WGInterfaceConfig,
|
||||
bindspace: Bindspace,
|
||||
listen_port: int|None,
|
||||
peers: tuple[dict[str, object], ...],
|
||||
) -> None:
|
||||
'''
|
||||
Record the validated create request.
|
||||
|
||||
'''
|
||||
assert bindspace.namespace_fd is not None
|
||||
assert listen_port is None
|
||||
assert peers[0]['public_key'] == _PEER_KEY
|
||||
events.append('create')
|
||||
|
||||
def remove(
|
||||
spec: WGTunnelSpec,
|
||||
bindspace: Bindspace,
|
||||
) -> None:
|
||||
'''
|
||||
Record shielded removal after cancellation.
|
||||
|
||||
'''
|
||||
events.append('remove')
|
||||
|
||||
monkeypatch.setattr(
|
||||
_tunnel,
|
||||
'_sync_create_wg_iface',
|
||||
create,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_tunnel,
|
||||
'_sync_remove_wg_iface',
|
||||
remove,
|
||||
)
|
||||
|
||||
namespace_file: BinaryIO
|
||||
with token_path.open('rb') as namespace_file:
|
||||
namespace_fd: int = namespace_file.fileno()
|
||||
bindspace_spec: BindspaceSpec = BindspaceSpec(
|
||||
kind='netns',
|
||||
key='tractor-wg0',
|
||||
)
|
||||
bindspace: Bindspace = Bindspace(
|
||||
spec=bindspace_spec,
|
||||
ref=BindspaceRef(
|
||||
kind='netns',
|
||||
key='tractor-wg0',
|
||||
inode=os.fstat(namespace_fd).st_ino,
|
||||
),
|
||||
namespace_fd=namespace_fd,
|
||||
ownership='borrowed',
|
||||
)
|
||||
peer: WGPeerConfig = WGPeerConfig(
|
||||
public_key=_PEER_KEY,
|
||||
)
|
||||
config: WGInterfaceConfig = WGInterfaceConfig(
|
||||
private_key=_LOCAL_KEY,
|
||||
peers=(peer,),
|
||||
)
|
||||
spec: WGTunnelSpec = WGTunnelSpec(
|
||||
peer_pubkey=_PEER_KEY,
|
||||
)
|
||||
|
||||
async def main() -> None:
|
||||
'''
|
||||
Cancel while the fake WG iface is owned.
|
||||
|
||||
'''
|
||||
with trio.CancelScope() as scope:
|
||||
async with open_wg_iface(
|
||||
spec,
|
||||
config,
|
||||
bindspace,
|
||||
'dial',
|
||||
):
|
||||
events.append('yield')
|
||||
scope.cancel()
|
||||
await trio.sleep_forever()
|
||||
|
||||
trio.run(main)
|
||||
|
||||
assert events == ['create', 'yield', 'remove']
|
||||
|
||||
|
||||
def test_open_wg_bindspace_nests_resource_lifetimes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
'''
|
||||
Nested WG interfaces must exit before their bindspace capability.
|
||||
|
||||
Fake two interface layers over one bindspace. Clear the caller's
|
||||
mutable layer list at bindspace entry, then cancel from inside
|
||||
the yielded application scope and checkpoint. The trace proves
|
||||
stack snapshots its declaration before entry, layers enter
|
||||
outermost-first, cancellation exits them inside-out, and the live
|
||||
bindspace remains available through every interface exit.
|
||||
|
||||
'''
|
||||
events: list[str] = []
|
||||
calls: list[
|
||||
tuple[
|
||||
WGTunnelSpec,
|
||||
WGInterfaceConfig,
|
||||
Bindspace,
|
||||
_tunnel.WGRole,
|
||||
]
|
||||
] = []
|
||||
bindspace_spec: BindspaceSpec = BindspaceSpec(
|
||||
kind='netns',
|
||||
)
|
||||
bindspace: Bindspace = Bindspace(
|
||||
spec=bindspace_spec,
|
||||
ref=BindspaceRef(
|
||||
kind='netns',
|
||||
key=None,
|
||||
inode=1,
|
||||
),
|
||||
namespace_fd=None,
|
||||
ownership='borrowed',
|
||||
)
|
||||
outer_spec: WGTunnelSpec = WGTunnelSpec(
|
||||
peer_pubkey=_PEER_KEY,
|
||||
iface='wg-outer',
|
||||
)
|
||||
inner_spec: WGTunnelSpec = WGTunnelSpec(
|
||||
peer_pubkey=_LOCAL_KEY,
|
||||
iface='wg-inner',
|
||||
)
|
||||
outer_config: WGInterfaceConfig = WGInterfaceConfig(
|
||||
private_key=_LOCAL_KEY,
|
||||
)
|
||||
inner_config: WGInterfaceConfig = WGInterfaceConfig(
|
||||
private_key=_PEER_KEY,
|
||||
)
|
||||
layers: list[
|
||||
tuple[WGTunnelSpec, WGInterfaceConfig]
|
||||
] = [
|
||||
(outer_spec, outer_config),
|
||||
(inner_spec, inner_config),
|
||||
]
|
||||
|
||||
@acm
|
||||
async def fake_open_bindspace(
|
||||
spec: BindspaceSpec,
|
||||
) -> AsyncIterator[Bindspace]:
|
||||
'''
|
||||
Yield the stand-in bindspace and record its full lifetime.
|
||||
|
||||
'''
|
||||
assert spec is bindspace_spec
|
||||
events.append('bindspace-enter')
|
||||
layers.clear()
|
||||
try:
|
||||
yield bindspace
|
||||
finally:
|
||||
events.append('bindspace-exit')
|
||||
|
||||
@acm
|
||||
async def fake_open_wg_iface(
|
||||
spec: WGTunnelSpec,
|
||||
config: WGInterfaceConfig,
|
||||
bindspace_arg: Bindspace,
|
||||
role: _tunnel.WGRole,
|
||||
) -> AsyncIterator[WGTunnelSpec]:
|
||||
'''
|
||||
Record one interface's arguments and nested lifetime.
|
||||
|
||||
'''
|
||||
calls.append((spec, config, bindspace_arg, role))
|
||||
events.append(f'{spec.iface}-enter')
|
||||
try:
|
||||
yield spec
|
||||
finally:
|
||||
assert bindspace_arg is bindspace
|
||||
events.append(f'{spec.iface}-exit')
|
||||
|
||||
monkeypatch.setattr(
|
||||
_tunnel,
|
||||
'open_bindspace',
|
||||
fake_open_bindspace,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_tunnel,
|
||||
'open_wg_iface',
|
||||
fake_open_wg_iface,
|
||||
)
|
||||
|
||||
async def main() -> None:
|
||||
'''
|
||||
Cancel while both interface layers are live.
|
||||
|
||||
'''
|
||||
with trio.CancelScope() as scope:
|
||||
async with open_wg_bindspace(
|
||||
bindspace_spec,
|
||||
layers,
|
||||
'dial',
|
||||
) as opened_bindspace:
|
||||
assert opened_bindspace is bindspace
|
||||
events.append('yield')
|
||||
scope.cancel()
|
||||
await trio.sleep_forever()
|
||||
|
||||
trio.run(main)
|
||||
|
||||
assert calls == [
|
||||
(outer_spec, outer_config, bindspace, 'dial'),
|
||||
(inner_spec, inner_config, bindspace, 'dial'),
|
||||
]
|
||||
assert events == [
|
||||
'bindspace-enter',
|
||||
'wg-outer-enter',
|
||||
'wg-inner-enter',
|
||||
'yield',
|
||||
'wg-inner-exit',
|
||||
'wg-outer-exit',
|
||||
'bindspace-exit',
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform != 'linux',
|
||||
reason='network namespaces are Linux-only',
|
||||
)
|
||||
def test_public_wg_bindspace_scopes_root_actor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tpt_proto: str,
|
||||
) -> None:
|
||||
'''
|
||||
Public network contexts must fully enclose the root runtime.
|
||||
|
||||
Attach the real current netns through `tractor.net`, fake only WG
|
||||
interface provisioning, and open a real root actor with the yielded
|
||||
`Bindspace`. The trace and inode checks prove interface setup wraps
|
||||
actor startup, the runtime occupies the realized bindspace, and root
|
||||
restoration finishes before network-resource teardown.
|
||||
|
||||
'''
|
||||
events: list[str] = []
|
||||
bindspace_spec: BindspaceSpec = BindspaceSpec(
|
||||
kind='netns',
|
||||
lifecycle='attach',
|
||||
)
|
||||
tunnel_spec: WGTunnelSpec = WGTunnelSpec(
|
||||
peer_pubkey=_PEER_KEY,
|
||||
iface='wg-root',
|
||||
)
|
||||
config: WGInterfaceConfig = WGInterfaceConfig(
|
||||
private_key=_LOCAL_KEY,
|
||||
)
|
||||
|
||||
@acm
|
||||
async def fake_open_wg_iface(
|
||||
spec: WGTunnelSpec,
|
||||
iface_config: WGInterfaceConfig,
|
||||
bindspace: Bindspace,
|
||||
role: _tunnel.WGRole,
|
||||
) -> AsyncIterator[WGTunnelSpec]:
|
||||
'''
|
||||
Trace one WG layer around the real root actor lifetime.
|
||||
|
||||
'''
|
||||
assert spec is tunnel_spec
|
||||
assert iface_config is config
|
||||
assert role == 'listen'
|
||||
assert bindspace.namespace_fd is not None
|
||||
events.append('wg-enter')
|
||||
try:
|
||||
yield spec
|
||||
finally:
|
||||
events.append('wg-exit')
|
||||
|
||||
monkeypatch.setattr(
|
||||
_tunnel,
|
||||
'open_wg_iface',
|
||||
fake_open_wg_iface,
|
||||
)
|
||||
|
||||
async def main() -> None:
|
||||
'''
|
||||
Compose the public network and root actor context managers.
|
||||
|
||||
'''
|
||||
async with tractor.net.open_wg_bindspace(
|
||||
bindspace_spec=bindspace_spec,
|
||||
layers=((tunnel_spec, config),),
|
||||
role='listen',
|
||||
) as bindspace:
|
||||
events.append('bindspace-open')
|
||||
async with tractor.open_root_actor(
|
||||
bindspace=bindspace,
|
||||
enable_transports=[tpt_proto],
|
||||
):
|
||||
events.append('root-open')
|
||||
assert bindspace.namespace_fd is not None
|
||||
assert os.fstat(
|
||||
bindspace.namespace_fd,
|
||||
).st_ino == bindspace.ref.inode
|
||||
assert Path(
|
||||
'/proc/thread-self/ns/net'
|
||||
).stat().st_ino == bindspace.ref.inode
|
||||
events.append('root-closed')
|
||||
|
||||
events.append('bindspace-closed')
|
||||
|
||||
trio.run(main)
|
||||
assert events == [
|
||||
'wg-enter',
|
||||
'bindspace-open',
|
||||
'root-open',
|
||||
'root-closed',
|
||||
'wg-exit',
|
||||
'bindspace-closed',
|
||||
]
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
'''
|
||||
Read-only WireGuard netlink inspection tests.
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import (
|
||||
Any,
|
||||
NoReturn,
|
||||
)
|
||||
|
||||
import pytest
|
||||
import trio
|
||||
|
||||
from tractor.net import (
|
||||
read_wg_peers,
|
||||
read_wg_pubkey,
|
||||
verify_wg_peer,
|
||||
WGTunnelSpec,
|
||||
)
|
||||
from tractor.net import _tunnel
|
||||
|
||||
pyroute2: Any = pytest.importorskip('pyroute2')
|
||||
|
||||
|
||||
_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
|
||||
_PEER_1: str = '7PClzcj8o1yAjyPJb0zL2Gt0s2J7yZ6c0JXYqNBGr0E='
|
||||
_PEER_2: str = 'H7bJbl1bpY7VzDlB5wI3KjA7JsiYoMWGDJd8dYgc5iw='
|
||||
_MISSING_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
||||
|
||||
|
||||
class Attrs:
|
||||
'''
|
||||
Minimal pyroute2 netlink-attribute message fake.
|
||||
|
||||
'''
|
||||
def __init__(
|
||||
self,
|
||||
**attrs: Any,
|
||||
) -> None:
|
||||
'''
|
||||
Store attributes for `.get_attr()` lookups.
|
||||
|
||||
'''
|
||||
self._attrs: dict[str, Any] = attrs
|
||||
|
||||
def get_attr(
|
||||
self,
|
||||
name: str,
|
||||
) -> Any:
|
||||
'''
|
||||
Return the named fake netlink attribute.
|
||||
|
||||
'''
|
||||
return self._attrs.get(name)
|
||||
|
||||
|
||||
def test_read_wg_keys_in_worker_thread(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
'''
|
||||
Pyroute2's synchronous `WireGuard` API owns a private asyncio
|
||||
loop. Running it in the Trio thread would either block Trio or
|
||||
introduce that foreign loop into the actor runtime.
|
||||
|
||||
Replace `pyroute2.WireGuard` with a fake which records thread,
|
||||
iface, netns and close state. Return a multipart dump containing
|
||||
duplicate peers, then prove both public helpers execute
|
||||
off-thread, preserve named-netns selection, validate keys,
|
||||
deduplicate peers in kernel order and close every netlink client.
|
||||
|
||||
'''
|
||||
trio_thread: int = threading.get_ident()
|
||||
|
||||
class FakeWireGuard:
|
||||
'''
|
||||
Record each read-only `pyroute2.WireGuard` interaction.
|
||||
|
||||
'''
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
netns: str|None,
|
||||
flags: int,
|
||||
) -> None:
|
||||
'''
|
||||
Record namespace selection without opening netlink.
|
||||
|
||||
'''
|
||||
self.netns = netns
|
||||
self.flags = flags
|
||||
self.closed = False
|
||||
self.thread_id: int|None = None
|
||||
self.iface: str|None = None
|
||||
instances.append(self)
|
||||
|
||||
def info(
|
||||
self,
|
||||
iface: str,
|
||||
) -> tuple[Attrs, Attrs]:
|
||||
'''
|
||||
Return a multipart WireGuard device dump.
|
||||
|
||||
'''
|
||||
self.thread_id = threading.get_ident()
|
||||
self.iface = iface
|
||||
peer_1: Attrs = Attrs(
|
||||
WGPEER_A_PUBLIC_KEY=_PEER_1.encode(),
|
||||
)
|
||||
peer_2: Attrs = Attrs(
|
||||
WGPEER_A_PUBLIC_KEY=_PEER_2.encode(),
|
||||
)
|
||||
return (
|
||||
Attrs(
|
||||
WGDEVICE_A_PUBLIC_KEY=_PUBKEY.encode(),
|
||||
WGDEVICE_A_PEERS=[peer_1],
|
||||
),
|
||||
Attrs(
|
||||
WGDEVICE_A_PUBLIC_KEY=_PUBKEY.encode(),
|
||||
WGDEVICE_A_PEERS=[peer_2, peer_1],
|
||||
),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
'''
|
||||
Record netlink-client cleanup.
|
||||
|
||||
'''
|
||||
self.closed = True
|
||||
|
||||
instances: list[FakeWireGuard] = []
|
||||
monkeypatch.setattr(
|
||||
pyroute2,
|
||||
'WireGuard',
|
||||
FakeWireGuard,
|
||||
)
|
||||
|
||||
async def main() -> None:
|
||||
'''
|
||||
Read both key views from Trio's run thread.
|
||||
|
||||
'''
|
||||
assert await read_wg_pubkey(
|
||||
iface='wg-test',
|
||||
netns='actor-net',
|
||||
) == _PUBKEY
|
||||
assert await read_wg_peers(
|
||||
iface='wg-test',
|
||||
netns='actor-net',
|
||||
) == (_PEER_1, _PEER_2)
|
||||
|
||||
trio.run(main)
|
||||
|
||||
assert len(instances) == 2
|
||||
instance: FakeWireGuard
|
||||
for instance in instances:
|
||||
assert instance.netns == 'actor-net'
|
||||
assert instance.flags == 0
|
||||
assert instance.iface == 'wg-test'
|
||||
assert instance.thread_id != trio_thread
|
||||
assert instance.closed
|
||||
|
||||
|
||||
def test_wg_client_closes_when_read_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
'''
|
||||
A failed netlink read must not leak pyroute2's socket or private
|
||||
event loop. Raise from the fake `.info()` call and prove the same
|
||||
error reaches the Trio caller only after `.close()` runs.
|
||||
|
||||
'''
|
||||
class FakeWireGuard:
|
||||
'''
|
||||
Raise during device inspection and record cleanup.
|
||||
|
||||
'''
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
netns: str|None,
|
||||
flags: int,
|
||||
) -> None:
|
||||
'''
|
||||
Publish this fake instance for the cleanup assertion.
|
||||
|
||||
'''
|
||||
nonlocal instance
|
||||
self.closed = False
|
||||
instance = self
|
||||
|
||||
def info(self, iface: str) -> NoReturn:
|
||||
'''
|
||||
Simulate a failing netlink device read.
|
||||
|
||||
'''
|
||||
raise OSError('netlink read failed')
|
||||
|
||||
def close(self) -> None:
|
||||
'''
|
||||
Record cleanup after the failed read.
|
||||
|
||||
'''
|
||||
self.closed = True
|
||||
|
||||
instance: FakeWireGuard|None = None
|
||||
monkeypatch.setattr(
|
||||
pyroute2,
|
||||
'WireGuard',
|
||||
FakeWireGuard,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
OSError,
|
||||
match='netlink read failed',
|
||||
):
|
||||
trio.run(read_wg_pubkey)
|
||||
|
||||
assert instance is not None
|
||||
assert instance.closed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('declared_key', 'expected'),
|
||||
(
|
||||
(_PUBKEY, True),
|
||||
(_PEER_2, True),
|
||||
(_MISSING_KEY, False),
|
||||
),
|
||||
)
|
||||
def test_verify_wg_peer(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
declared_key: str,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
'''
|
||||
A tunnel declaration can identify either side of one local iface.
|
||||
|
||||
Return one stable key snapshot from the async reader, then prove
|
||||
a local interface key and configured peer both verify while an
|
||||
absent key does not. Also prove the spec selects the iface/netns
|
||||
supplied to the read instead of silently using process defaults.
|
||||
|
||||
'''
|
||||
reads: list[tuple[str, str|None]] = []
|
||||
|
||||
async def read_keys(
|
||||
iface: str,
|
||||
netns: str|None,
|
||||
) -> tuple[str, tuple[str, ...]]:
|
||||
'''
|
||||
Return one deterministic WireGuard key snapshot.
|
||||
|
||||
'''
|
||||
reads.append((iface, netns))
|
||||
return _PUBKEY, (_PEER_1, _PEER_2)
|
||||
|
||||
monkeypatch.setattr(
|
||||
_tunnel,
|
||||
'_read_wg_keys',
|
||||
read_keys,
|
||||
)
|
||||
spec: WGTunnelSpec = WGTunnelSpec(
|
||||
peer_pubkey=declared_key,
|
||||
iface='wg-test',
|
||||
netns='actor-net',
|
||||
)
|
||||
|
||||
assert trio.run(verify_wg_peer, spec) is expected
|
||||
assert reads == [('wg-test', 'actor-net')]
|
||||
|
||||
|
||||
def test_verify_wg_peer_validates_before_read(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
'''
|
||||
A directly constructed tunnel spec can contain a malformed key.
|
||||
|
||||
Install a reader which would fail if called, pass malformed
|
||||
base64, and prove validation rejects the declaration before any
|
||||
kernel-state inspection occurs.
|
||||
|
||||
'''
|
||||
async def unexpected_read(
|
||||
iface: str,
|
||||
netns: str|None,
|
||||
) -> NoReturn:
|
||||
'''
|
||||
Fail if malformed-key validation reaches the read boundary.
|
||||
|
||||
'''
|
||||
raise AssertionError('WireGuard read must not run')
|
||||
|
||||
monkeypatch.setattr(
|
||||
_tunnel,
|
||||
'_read_wg_keys',
|
||||
unexpected_read,
|
||||
)
|
||||
spec: WGTunnelSpec = WGTunnelSpec(
|
||||
peer_pubkey='not-base64',
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
trio.run(verify_wg_peer, spec)
|
||||
|
|
@ -12,6 +12,7 @@ from typing import (
|
|||
get_type_hints,
|
||||
)
|
||||
|
||||
import tractor
|
||||
from tractor.discovery import (
|
||||
_addr,
|
||||
_multiaddr,
|
||||
|
|
@ -44,8 +45,8 @@ def test_lazy_to_asyncio_package_api():
|
|||
Before the lazy conversion, package import side effects exposed
|
||||
`to_asyncio` to `dir()` and wildcard imports. Exercise those APIs
|
||||
in cold interpreters so this test proves normal `import tractor`
|
||||
leaves `asyncio` unloaded, while discovery and wildcard access
|
||||
still advertise and resolve the public submodule.
|
||||
leaves `asyncio` unloaded, while introspection and wildcard
|
||||
access still advertise and resolve the public submodule.
|
||||
|
||||
'''
|
||||
cold = run_cold_import(
|
||||
|
|
@ -102,6 +103,12 @@ def test_cold_import_budget():
|
|||
'bidict',
|
||||
'colorlog',
|
||||
'multiaddr',
|
||||
'multibase',
|
||||
'pyroute2',
|
||||
'tractor.discovery._multiaddr',
|
||||
'tractor.net',
|
||||
'tractor.net._bindspace',
|
||||
'tractor.net._tunnel',
|
||||
'wrapt',
|
||||
)
|
||||
code = (
|
||||
|
|
@ -136,6 +143,123 @@ def test_cold_import_budget():
|
|||
)
|
||||
|
||||
|
||||
def test_lazy_net_package_api():
|
||||
'''
|
||||
Keep the public network package cold until symbol access.
|
||||
|
||||
The old discovery re-exports imported bindspace, tunnel,
|
||||
multiaddr and optional dependencies while initializing a package.
|
||||
Import `tractor.net` in a clean interpreter, inspect its public
|
||||
surface, and prove no implementation or optional dependency was
|
||||
loaded. Then resolve one symbol from each backing module and
|
||||
prove the facade caches each value while preserving boundaries.
|
||||
|
||||
'''
|
||||
modules: tuple[str, ...] = (
|
||||
'tractor.net._bindspace',
|
||||
'tractor.net._tunnel',
|
||||
'tractor.discovery._multiaddr',
|
||||
'multiaddr',
|
||||
'multibase',
|
||||
'pyroute2',
|
||||
)
|
||||
cold: dict[str, object] = run_cold_import(
|
||||
'import json, sys; import tractor.net as net; '
|
||||
f'names = {modules!r}; '
|
||||
'print(json.dumps({'
|
||||
'"public": all(name in dir(net) for name in net.__all__), '
|
||||
'"loaded": [name for name in names if name in sys.modules]'
|
||||
'}))'
|
||||
)
|
||||
assert cold == {
|
||||
'public': True,
|
||||
'loaded': [],
|
||||
}
|
||||
|
||||
resolved: dict[str, object] = run_cold_import(
|
||||
'import json, sys; import tractor.net as net; '
|
||||
'bindspace = net.BindspaceSpec; '
|
||||
'bindspace_cached = net.BindspaceSpec is bindspace; '
|
||||
'maddr = net.mk_maddr; '
|
||||
'maddr_cached = net.mk_maddr is maddr; '
|
||||
'tunnel = net.WGTunnelSpec; '
|
||||
'tunnel_cached = net.WGTunnelSpec is tunnel; '
|
||||
'print(json.dumps({'
|
||||
'"bindspace_cached": bindspace_cached, '
|
||||
'"maddr_cached": maddr_cached, '
|
||||
'"tunnel_cached": tunnel_cached, '
|
||||
'"bindspace_module": bindspace.__module__, '
|
||||
'"maddr_module": maddr.__module__, '
|
||||
'"tunnel_module": tunnel.__module__, '
|
||||
'"multiaddr_loaded": "multiaddr" in sys.modules, '
|
||||
'"pyroute2_loaded": "pyroute2" in sys.modules'
|
||||
'}))'
|
||||
)
|
||||
assert resolved == {
|
||||
'bindspace_cached': True,
|
||||
'maddr_cached': True,
|
||||
'tunnel_cached': True,
|
||||
'bindspace_module': 'tractor.net._bindspace',
|
||||
'maddr_module': 'tractor.discovery._multiaddr',
|
||||
'tunnel_module': 'tractor.net._tunnel',
|
||||
'multiaddr_loaded': False,
|
||||
'pyroute2_loaded': False,
|
||||
}
|
||||
|
||||
|
||||
def test_net_root_export_and_old_discovery_surface():
|
||||
'''
|
||||
Publish networking only from its approved namespace.
|
||||
|
||||
Before extraction, unshipped network names and implementation
|
||||
modules lived under `tractor.discovery`. Exercise root attribute
|
||||
and wildcard access in clean interpreters, proving `tractor.net`
|
||||
is discoverable and cached without loading implementations. Also
|
||||
prove the old exports are absent and their modules no longer
|
||||
resolve, preventing accidental compatibility aliases.
|
||||
|
||||
'''
|
||||
root: dict[str, object] = run_cold_import(
|
||||
'import json, sys, tractor; '
|
||||
'advertised = "net" in dir(tractor); '
|
||||
'net = tractor.net; '
|
||||
'print(json.dumps({'
|
||||
'"advertised": advertised, '
|
||||
'"cached": tractor.net is net, '
|
||||
'"module": net.__name__, '
|
||||
'"bindspace_loaded": '
|
||||
'"tractor.net._bindspace" in sys.modules, '
|
||||
'"tunnel_loaded": "tractor.net._tunnel" in sys.modules'
|
||||
'}))'
|
||||
)
|
||||
assert root == {
|
||||
'advertised': True,
|
||||
'cached': True,
|
||||
'module': 'tractor.net',
|
||||
'bindspace_loaded': False,
|
||||
'tunnel_loaded': False,
|
||||
}
|
||||
|
||||
old: dict[str, object] = run_cold_import(
|
||||
'import importlib.util, json; '
|
||||
'import tractor.discovery as discovery; '
|
||||
'old_names = ("Bindspace", "TunnelledAddress", '
|
||||
'"mk_maddr", "parse_maddr", "parse_endpoints"); '
|
||||
'old_modules = ("tractor.discovery._bindspace", '
|
||||
'"tractor.discovery._tunnel"); '
|
||||
'print(json.dumps({'
|
||||
'"exports": [name for name in old_names '
|
||||
'if hasattr(discovery, name)], '
|
||||
'"modules": [name for name in old_modules '
|
||||
'if importlib.util.find_spec(name) is not None]'
|
||||
'}))'
|
||||
)
|
||||
assert old == {
|
||||
'exports': [],
|
||||
'modules': [],
|
||||
}
|
||||
|
||||
|
||||
def test_lazy_annotation_names_resolve():
|
||||
'''
|
||||
Resolve annotations without importing optional dependencies.
|
||||
|
|
@ -157,4 +281,7 @@ def test_lazy_annotation_names_resolve():
|
|||
assert get_type_hints(_addr.Address.get_random)[
|
||||
'current_actor'
|
||||
] is Any
|
||||
assert get_type_hints(tractor.open_root_actor)[
|
||||
'bindspace'
|
||||
] == Any|None
|
||||
assert _addr.__annotations__['_address_types'].startswith('dict')
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import trio
|
|||
import tractor
|
||||
|
||||
from tractor._testing import tractor_test
|
||||
from tractor.discovery._addr import wrap_address
|
||||
|
||||
|
||||
def test_no_runtime():
|
||||
|
|
@ -48,7 +49,7 @@ async def test_self_is_registered_localportal(reg_addr):
|
|||
with trio.fail_after(0.2):
|
||||
sockaddr = await portal.run_from_ns(
|
||||
'self', 'wait_for_actor', name='root')
|
||||
assert sockaddr[0] == reg_addr
|
||||
assert sockaddr[0] == wrap_address(reg_addr).unwrap()
|
||||
|
||||
|
||||
def test_local_actor_async_func(reg_addr):
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,15 +10,25 @@ API design.
|
|||
|
||||
"""
|
||||
from functools import partial
|
||||
from types import SimpleNamespace
|
||||
from typing import (
|
||||
Any,
|
||||
)
|
||||
from unittest.mock import (
|
||||
AsyncMock,
|
||||
MagicMock,
|
||||
)
|
||||
|
||||
import pytest
|
||||
import trio
|
||||
import tractor
|
||||
|
||||
from tractor._exceptions import ActorFailure
|
||||
from tractor._testing import tractor_test
|
||||
from tractor.spawn import (
|
||||
_spawn,
|
||||
_trio,
|
||||
)
|
||||
|
||||
data_to_pass_down = {
|
||||
'doggy': 10,
|
||||
|
|
@ -26,6 +36,396 @@ data_to_pass_down = {
|
|||
}
|
||||
|
||||
|
||||
def test_peer_handshake_wins_child_boot_race() -> None:
|
||||
'''
|
||||
A connected child must cancel process-death monitoring cleanly.
|
||||
|
||||
Start the death waiter first and hold it at a checkpoint. Let the
|
||||
fake server then return one peer event and channel. The helper must
|
||||
preserve the normal handshake result and cancel the losing process
|
||||
waiter before its nursery exits. The fake explicitly catches and
|
||||
re-raises `trio.Cancelled` to prove cancellation caused its exit.
|
||||
|
||||
'''
|
||||
async def main() -> None:
|
||||
'''
|
||||
Control the handshake-first schedule with Trio events.
|
||||
|
||||
'''
|
||||
uid: tuple[str, str] = ('handshake-child', 'test')
|
||||
death_started = trio.Event()
|
||||
death_cancelled = trio.Event()
|
||||
peer_event = trio.Event()
|
||||
channel = object()
|
||||
|
||||
async def wait_for_peer(
|
||||
child_uid: tuple[str, str],
|
||||
) -> tuple[trio.Event, object]:
|
||||
'''
|
||||
Return the peer only after death monitoring is active.
|
||||
|
||||
'''
|
||||
assert child_uid == uid
|
||||
await death_started.wait()
|
||||
return (peer_event, channel)
|
||||
|
||||
async def wait_for_death() -> int:
|
||||
'''
|
||||
Block until the winning handshake cancels this waiter.
|
||||
|
||||
'''
|
||||
death_started.set()
|
||||
try:
|
||||
await trio.sleep_forever()
|
||||
except trio.Cancelled:
|
||||
death_cancelled.set()
|
||||
raise
|
||||
|
||||
result = await _spawn.wait_for_peer_or_proc_death(
|
||||
ipc_server=SimpleNamespace(
|
||||
wait_for_peer=wait_for_peer,
|
||||
),
|
||||
uid=uid,
|
||||
proc_wait=wait_for_death,
|
||||
proc_repr='handshake-proc',
|
||||
)
|
||||
|
||||
assert result == (peer_event, channel)
|
||||
assert death_cancelled.is_set()
|
||||
|
||||
trio.run(main)
|
||||
|
||||
|
||||
def test_child_death_wins_peer_handshake_race() -> None:
|
||||
'''
|
||||
Pre-handshake child death must fail startup instead of hanging.
|
||||
|
||||
Start the peer waiter first and leave it parked like
|
||||
`IPCServer.wait_for_peer()` on an unset event. Return a distinctive
|
||||
process status from the competing waiter, then prove the helper
|
||||
cancels the handshake and raises `ActorFailure` with child identity,
|
||||
status, and process diagnostics.
|
||||
|
||||
'''
|
||||
async def main() -> None:
|
||||
'''
|
||||
Control the death-first schedule with Trio events.
|
||||
|
||||
'''
|
||||
uid: tuple[str, str] = ('dead-child', 'test')
|
||||
handshake_started = trio.Event()
|
||||
handshake_cancelled = trio.Event()
|
||||
|
||||
async def wait_for_peer(
|
||||
child_uid: tuple[str, str],
|
||||
) -> tuple[trio.Event, object]:
|
||||
'''
|
||||
Park until process death cancels this handshake waiter.
|
||||
|
||||
'''
|
||||
assert child_uid == uid
|
||||
handshake_started.set()
|
||||
try:
|
||||
await trio.sleep_forever()
|
||||
except trio.Cancelled:
|
||||
handshake_cancelled.set()
|
||||
raise
|
||||
|
||||
async def wait_for_death() -> int:
|
||||
'''
|
||||
Report child death after handshake monitoring is active.
|
||||
|
||||
'''
|
||||
await handshake_started.wait()
|
||||
return 23
|
||||
|
||||
with pytest.raises(ActorFailure) as exc_info:
|
||||
await _spawn.wait_for_peer_or_proc_death(
|
||||
ipc_server=SimpleNamespace(
|
||||
wait_for_peer=wait_for_peer,
|
||||
),
|
||||
uid=uid,
|
||||
proc_wait=wait_for_death,
|
||||
proc_repr='dead-proc',
|
||||
)
|
||||
|
||||
message: str = str(exc_info.value)
|
||||
assert repr(uid) in message
|
||||
assert 'died during boot' in message
|
||||
assert '(rc=23)' in message
|
||||
assert 'parent-handshake' in message
|
||||
assert 'dead-proc' in message
|
||||
assert handshake_cancelled.is_set()
|
||||
|
||||
trio.run(main)
|
||||
|
||||
|
||||
def test_child_death_wins_simultaneous_boot_results() -> None:
|
||||
'''
|
||||
Observed process death must outrank a simultaneous handshake.
|
||||
|
||||
Hold both fake waits behind one barrier with cancellation shielding,
|
||||
then release them together so both publish a committed result before
|
||||
sibling cancellation takes effect. Because the child has exited
|
||||
before receiving its `SpawnSpec`, bootstrap must raise `ActorFailure`
|
||||
rather than return its briefly established channel.
|
||||
|
||||
'''
|
||||
async def main() -> None:
|
||||
'''
|
||||
Release both boot outcomes from one controlled barrier.
|
||||
|
||||
'''
|
||||
uid: tuple[str, str] = ('simultaneous-child', 'test')
|
||||
handshake_ready = trio.Event()
|
||||
death_ready = trio.Event()
|
||||
release = trio.Event()
|
||||
peer_event = trio.Event()
|
||||
|
||||
async def wait_for_peer(
|
||||
child_uid: tuple[str, str],
|
||||
) -> tuple[trio.Event, object]:
|
||||
'''
|
||||
Publish a handshake despite sibling cancellation.
|
||||
|
||||
'''
|
||||
assert child_uid == uid
|
||||
handshake_ready.set()
|
||||
with trio.CancelScope(shield=True):
|
||||
await release.wait()
|
||||
return (peer_event, object())
|
||||
|
||||
async def wait_for_death() -> int:
|
||||
'''
|
||||
Publish process death despite sibling cancellation.
|
||||
|
||||
'''
|
||||
death_ready.set()
|
||||
with trio.CancelScope(shield=True):
|
||||
await release.wait()
|
||||
return 0
|
||||
|
||||
async def release_both() -> None:
|
||||
'''
|
||||
Open the barrier only after both waiters are parked.
|
||||
|
||||
'''
|
||||
await handshake_ready.wait()
|
||||
await death_ready.wait()
|
||||
release.set()
|
||||
|
||||
async with trio.open_nursery() as nursery:
|
||||
nursery.start_soon(release_both)
|
||||
with pytest.raises(
|
||||
ActorFailure,
|
||||
match=r'simultaneous-child.*rc=0',
|
||||
):
|
||||
await _spawn.wait_for_peer_or_proc_death(
|
||||
ipc_server=SimpleNamespace(
|
||||
wait_for_peer=wait_for_peer,
|
||||
),
|
||||
uid=uid,
|
||||
proc_wait=wait_for_death,
|
||||
)
|
||||
|
||||
trio.run(main)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failing_waiter', ('handshake', 'death'))
|
||||
def test_child_boot_race_preserves_waiter_error(
|
||||
failing_waiter: str,
|
||||
) -> None:
|
||||
'''
|
||||
Waiter failures must retain their original exception identity.
|
||||
|
||||
Park the non-failing sibling and raise one unique error from either
|
||||
the peer or process waiter. The helper's internal nursery must
|
||||
cancel the sibling and re-raise that exact exception instead of
|
||||
wrapping it in an `ExceptionGroup`.
|
||||
|
||||
'''
|
||||
async def main() -> None:
|
||||
'''
|
||||
Trigger one selected waiter after its sibling starts.
|
||||
|
||||
'''
|
||||
uid: tuple[str, str] = ('errored-child', 'test')
|
||||
sibling_started = trio.Event()
|
||||
wait_error = RuntimeError(f'{failing_waiter} failed')
|
||||
|
||||
async def wait_for_peer(
|
||||
child_uid: tuple[str, str],
|
||||
) -> tuple[trio.Event, object]:
|
||||
'''
|
||||
Raise or park according to the selected peer schedule.
|
||||
|
||||
'''
|
||||
assert child_uid == uid
|
||||
if failing_waiter == 'handshake':
|
||||
await sibling_started.wait()
|
||||
raise wait_error
|
||||
|
||||
sibling_started.set()
|
||||
await trio.sleep_forever()
|
||||
|
||||
async def wait_for_death() -> int:
|
||||
'''
|
||||
Raise or park according to the selected process schedule.
|
||||
|
||||
'''
|
||||
if failing_waiter == 'death':
|
||||
await sibling_started.wait()
|
||||
raise wait_error
|
||||
|
||||
sibling_started.set()
|
||||
await trio.sleep_forever()
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await _spawn.wait_for_peer_or_proc_death(
|
||||
ipc_server=SimpleNamespace(
|
||||
wait_for_peer=wait_for_peer,
|
||||
),
|
||||
uid=uid,
|
||||
proc_wait=wait_for_death,
|
||||
)
|
||||
|
||||
assert exc_info.value is wait_error
|
||||
|
||||
trio.run(main)
|
||||
|
||||
|
||||
def test_trio_proc_cleans_failed_child_peer_event(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
'''
|
||||
Death-first Trio startup must release provisional peer state.
|
||||
|
||||
Return one already-dead fake process while its server handshake
|
||||
parks forever. The fake nursery proves the peer event exists before
|
||||
provisional child publication. After `ActorFailure`, both that
|
||||
exact event and the provisional child record must be gone so repeated
|
||||
failed spawns cannot leak server state.
|
||||
|
||||
'''
|
||||
uid: tuple[str, str] = ('dead-trio-child', 'test')
|
||||
proc: trio.Process = MagicMock(spec=trio.Process)
|
||||
proc.pid = 1234
|
||||
proc.wait = AsyncMock(return_value=23)
|
||||
proc.poll.return_value = 23
|
||||
proc.__str__.return_value = 'dead-trio-proc'
|
||||
|
||||
class FakeServer:
|
||||
'''
|
||||
Hold the peer registry used during Trio child startup.
|
||||
|
||||
'''
|
||||
def __init__(self) -> None:
|
||||
self._peer_connected: dict[
|
||||
tuple[str, str],
|
||||
trio.Event,
|
||||
] = {}
|
||||
|
||||
async def wait_for_peer(
|
||||
self,
|
||||
child_uid: tuple[str, str],
|
||||
) -> tuple[trio.Event, object]:
|
||||
'''
|
||||
Park like a child that never reaches its handshake.
|
||||
|
||||
'''
|
||||
assert child_uid == uid
|
||||
await trio.sleep_forever()
|
||||
|
||||
server = FakeServer()
|
||||
|
||||
class FakeNursery:
|
||||
'''
|
||||
Track provisional child publication and cleanup.
|
||||
|
||||
'''
|
||||
def __init__(self) -> None:
|
||||
self._actor = SimpleNamespace(ipc_server=server)
|
||||
self._children: dict[tuple[str, str], tuple] = {}
|
||||
|
||||
def _register_child(
|
||||
self,
|
||||
subactor: object,
|
||||
proc: object,
|
||||
portal: object|None,
|
||||
) -> tuple[trio.Event, trio.Event, bool]:
|
||||
'''
|
||||
Require peer-event registration before child publication.
|
||||
|
||||
'''
|
||||
assert uid in server._peer_connected
|
||||
assert portal is None
|
||||
self._children[uid] = (subactor, proc, portal)
|
||||
return (trio.Event(), trio.Event(), False)
|
||||
|
||||
async def fake_open_process(
|
||||
command: list[str],
|
||||
**kwargs: object,
|
||||
) -> trio.Process:
|
||||
'''
|
||||
Return a process whose death wins the bootstrap race.
|
||||
|
||||
'''
|
||||
assert command
|
||||
return proc
|
||||
|
||||
async def fake_wait_for_debugger(**kwargs: object) -> None:
|
||||
'''
|
||||
Keep hard-reap cleanup deterministic and non-interactive.
|
||||
|
||||
'''
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
_trio.trio.lowlevel,
|
||||
'open_process',
|
||||
fake_open_process,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_trio.debug,
|
||||
'maybe_wait_for_debugger',
|
||||
fake_wait_for_debugger,
|
||||
)
|
||||
|
||||
nursery = FakeNursery()
|
||||
subactor = SimpleNamespace(
|
||||
aid=tractor.msg.Aid(
|
||||
name=uid[0],
|
||||
uuid=uid[1],
|
||||
),
|
||||
loglevel=None,
|
||||
pformat=lambda: 'dead-trio-child',
|
||||
)
|
||||
|
||||
async def main() -> None:
|
||||
'''
|
||||
Run the full Trio backend through death-first cleanup.
|
||||
|
||||
'''
|
||||
with pytest.raises(
|
||||
ActorFailure,
|
||||
match=r'dead-trio-child.*rc=23',
|
||||
):
|
||||
await _trio.trio_proc(
|
||||
name=uid[0],
|
||||
actor_nursery=nursery,
|
||||
subactor=subactor,
|
||||
errors={},
|
||||
bind_addrs=[],
|
||||
parent_addr=('127.0.0.1', 1616),
|
||||
_runtime_vars={},
|
||||
)
|
||||
|
||||
trio.run(main)
|
||||
|
||||
assert server._peer_connected == {}
|
||||
assert nursery._children == {}
|
||||
|
||||
|
||||
async def run_same_func_in_child(
|
||||
should_be_root: bool,
|
||||
data: dict,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
tractor: structured concurrent ``trio``-"actors".
|
||||
|
||||
"""
|
||||
from types import ModuleType as _ModuleType
|
||||
|
||||
from ._clustering import (
|
||||
open_actor_cluster as open_actor_cluster,
|
||||
|
|
@ -82,6 +83,7 @@ __all__: tuple[str, ...] = tuple(
|
|||
for name in globals()
|
||||
if not name.startswith('_')
|
||||
) + (
|
||||
'net',
|
||||
'to_asyncio',
|
||||
)
|
||||
|
||||
|
|
@ -92,21 +94,18 @@ def __dir__() -> list[str]:
|
|||
|
||||
def __getattr__(name: str):
|
||||
'''
|
||||
PEP 562 lazy sub-module loading, presently only for
|
||||
`.to_asyncio` which (transitively) imports `asyncio`
|
||||
itself: a non-trivial multi-ms chunk of the eager
|
||||
`import tractor` cost (gh #470) unneeded by
|
||||
`trio`-only apps.
|
||||
PEP 562 lazy public sub-package loading.
|
||||
|
||||
Any `tractor.to_asyncio.<attr>` access (or a
|
||||
`from tractor import to_asyncio`) still works, the
|
||||
sub-mod is simply imported on first-access instead
|
||||
of at pkg-import time.
|
||||
`tractor.to_asyncio` transitively imports `asyncio`, while
|
||||
`tractor.net` owns optional network dependencies. Neither is
|
||||
needed by most applications merely importing the root package.
|
||||
|
||||
'''
|
||||
if name == 'to_asyncio':
|
||||
if name in ('net', 'to_asyncio'):
|
||||
from importlib import import_module
|
||||
return import_module('.to_asyncio', __name__)
|
||||
module: _ModuleType = import_module(f'.{name}', __name__)
|
||||
globals()[name] = module
|
||||
return module
|
||||
|
||||
raise AttributeError(
|
||||
f'module {__name__!r} has no attribute {name!r}'
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ from ast import literal_eval
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from .runtime._runtime import Actor
|
||||
from .spawn._entry import _trio_main
|
||||
from .spawn._entry import (
|
||||
_consume_netns_bootstrap,
|
||||
_trio_main,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .discovery._addr import UnwrappedAddress
|
||||
|
|
@ -46,12 +49,41 @@ def parse_ipaddr(arg):
|
|||
return arg
|
||||
|
||||
|
||||
def parse_netns_bootstrap(arg: str) -> tuple[int, int]:
|
||||
'''
|
||||
Parse one atomic inherited namespace capability.
|
||||
|
||||
Descriptor and inode validation remains in
|
||||
`_consume_netns_bootstrap()` so every valid descriptor-shaped
|
||||
input reaches its exact child-owned cleanup boundary.
|
||||
|
||||
'''
|
||||
try:
|
||||
value: object = literal_eval(arg)
|
||||
except (ValueError, SyntaxError) as exc:
|
||||
raise argparse.ArgumentTypeError(
|
||||
'netns bootstrap must be an `(fd, inode)` tuple'
|
||||
) from exc
|
||||
|
||||
if (
|
||||
not isinstance(value, tuple)
|
||||
or
|
||||
len(value) != 2
|
||||
):
|
||||
raise argparse.ArgumentTypeError(
|
||||
'netns bootstrap must be an `(fd, inode)` tuple'
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _actor_child_main(
|
||||
uid: tuple[str, str],
|
||||
loglevel: str | None,
|
||||
parent_addr: UnwrappedAddress | None,
|
||||
infect_asyncio: bool,
|
||||
spawn_method: SpawnMethodKey = 'trio',
|
||||
netns_bootstrap: tuple[int, int]|None = None,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
|
|
@ -62,7 +94,13 @@ def _actor_child_main(
|
|||
invokes this from inside a fresh `concurrent.interpreters`
|
||||
sub-interpreter via `Interpreter.call()`.
|
||||
|
||||
Consume `netns_bootstrap` before Trio patching, actor construction,
|
||||
process-title setup, or actor-runtime entry. The spawn backend must
|
||||
supply an exclusively child-owned FD duplicate.
|
||||
|
||||
'''
|
||||
_consume_netns_bootstrap(netns_bootstrap)
|
||||
|
||||
# Apply defensive monkey-patches for upstream `trio`
|
||||
# bugs we've encountered while running tractor — see
|
||||
# `tractor.trionics.patches` for the catalog +
|
||||
|
|
@ -113,7 +151,11 @@ def _actor_child_main(
|
|||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
def main(argv: list[str]|None = None) -> None:
|
||||
'''
|
||||
Parse Trio child-bootstrap arguments and enter actor runtime.
|
||||
|
||||
'''
|
||||
__tracebackhide__: bool = True
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
|
|
@ -121,7 +163,11 @@ if __name__ == "__main__":
|
|||
parser.add_argument("--loglevel", type=str)
|
||||
parser.add_argument("--parent_addr", type=parse_ipaddr)
|
||||
parser.add_argument("--asyncio", action='store_true')
|
||||
args = parser.parse_args()
|
||||
parser.add_argument(
|
||||
'--netns_bootstrap',
|
||||
type=parse_netns_bootstrap,
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
_actor_child_main(
|
||||
uid=args.uid,
|
||||
|
|
@ -129,4 +175,9 @@ if __name__ == "__main__":
|
|||
parent_addr=args.parent_addr,
|
||||
infect_asyncio=args.asyncio,
|
||||
spawn_method='trio',
|
||||
netns_bootstrap=args.netns_bootstrap,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@
|
|||
Root actor runtime ignition(s).
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import (
|
||||
asynccontextmanager as acm,
|
||||
)
|
||||
|
|
@ -31,6 +34,7 @@ import sys
|
|||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
import warnings
|
||||
|
||||
|
|
@ -63,6 +67,11 @@ from ._exceptions import (
|
|||
RuntimeFailure,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .net._bindspace import Bindspace
|
||||
else:
|
||||
Bindspace = Any
|
||||
|
||||
|
||||
logger = log.get_logger('tractor')
|
||||
|
||||
|
|
@ -153,9 +162,30 @@ async def maybe_block_bp(
|
|||
os.environ.pop('PYTHONBREAKPOINT', None)
|
||||
|
||||
|
||||
@acm
|
||||
async def _enter_root_bindspace(
|
||||
bindspace: Bindspace|None,
|
||||
) -> AsyncIterator[None]:
|
||||
'''
|
||||
Adapt synchronous root netns entry to the outer async lifecycle.
|
||||
|
||||
The wrapped context has no checkpoints, so `trio` cancellation
|
||||
cannot interrupt thread-local namespace restoration.
|
||||
|
||||
'''
|
||||
from .spawn._netns import _enter_netns_temporarily
|
||||
|
||||
with _enter_netns_temporarily(bindspace):
|
||||
yield
|
||||
|
||||
|
||||
@acm
|
||||
async def open_root_actor(
|
||||
*,
|
||||
# Low-level realized scope. A future tunnelled-address bootstrap
|
||||
# may open and supply this capability internally.
|
||||
bindspace: Bindspace|None = None,
|
||||
|
||||
tpt_bind_addrs: list[
|
||||
Address # `Address.get_random()` case
|
||||
|UnwrappedAddress # registrar case `= uw_reg_addrs`
|
||||
|
|
@ -219,6 +249,10 @@ async def open_root_actor(
|
|||
All (disjoint) actor-process-trees-as-programs are created via
|
||||
this entrypoint.
|
||||
|
||||
When `bindspace` is provided, enter its network namespace before
|
||||
any registry or IPC activity and restore the calling thread's
|
||||
original namespace after complete actor teardown.
|
||||
|
||||
'''
|
||||
# XXX NEVER allow nested actor-trees!
|
||||
if already_actor := _state.current_actor(
|
||||
|
|
@ -239,10 +273,29 @@ async def open_root_actor(
|
|||
f'_registry_addrs: {registry_addrs!r}\n'
|
||||
)
|
||||
|
||||
effective_start_method: str = (
|
||||
os.environ.get('TRACTOR_SPAWN_METHOD')
|
||||
or start_method
|
||||
or _spawn._spawn_method
|
||||
)
|
||||
if (
|
||||
bindspace is not None
|
||||
and
|
||||
effective_start_method == 'mp_forkserver'
|
||||
):
|
||||
raise NotImplementedError(
|
||||
'Root actor bindspaces are not supported by the '
|
||||
'`mp_forkserver` spawn backend because a persistent '
|
||||
'forkserver may retain its original network namespace!'
|
||||
)
|
||||
|
||||
# debug.mk_pdb().set_trace()
|
||||
async with maybe_block_bp(
|
||||
debug_mode=debug_mode,
|
||||
maybe_enable_greenback=maybe_enable_greenback,
|
||||
async with (
|
||||
_enter_root_bindspace(bindspace),
|
||||
maybe_block_bp(
|
||||
debug_mode=debug_mode,
|
||||
maybe_enable_greenback=maybe_enable_greenback,
|
||||
),
|
||||
):
|
||||
if enable_transports is None:
|
||||
enable_transports: list[str] = _state.current_ipc_protos()
|
||||
|
|
@ -504,7 +557,10 @@ async def open_root_actor(
|
|||
# XXX INSTEAD, bind random addrs using the same tpt
|
||||
# proto if not already provided.
|
||||
if not tpt_bind_addrs:
|
||||
from .net._tunnel import strip_tunnels
|
||||
|
||||
for addr in ponged_addrs:
|
||||
bindable_addr: Address = strip_tunnels(addr)
|
||||
tpt_bind_addrs.append(
|
||||
# XXX, these are `Address` NOT `UnwrappedAddress`.
|
||||
#
|
||||
|
|
@ -512,8 +568,8 @@ async def open_root_actor(
|
|||
# protos we allocate port=0 such that the system
|
||||
# allocates a random value at bind time; this
|
||||
# happens in the `.ipc.*` stack's backend.
|
||||
addr.get_random(
|
||||
bindspace=addr.bindspace,
|
||||
bindable_addr.get_random(
|
||||
bindspace=bindable_addr.bindspace,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,18 +15,8 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
'''
|
||||
Discovery (protocols) API for automatic addressing
|
||||
and location management of (service) actors.
|
||||
Actor discovery and registrar implementation package.
|
||||
|
||||
NOTE: this ``__init__`` only eagerly imports the
|
||||
``._multiaddr`` submodule (for public re-exports).
|
||||
Heavier submodules like ``._addr`` and ``._api``
|
||||
are NOT imported here to avoid circular imports;
|
||||
use direct module paths for those.
|
||||
Network declarations and helpers are public from `tractor.net`.
|
||||
|
||||
'''
|
||||
from ._multiaddr import (
|
||||
parse_endpoints as parse_endpoints,
|
||||
parse_maddr as parse_maddr,
|
||||
mk_maddr as mk_maddr,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ from typing import (
|
|||
Any,
|
||||
Protocol,
|
||||
ClassVar,
|
||||
Literal,
|
||||
Type,
|
||||
TypeAlias,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
|
|
@ -40,9 +42,13 @@ from ..ipc._uds import (
|
|||
if TYPE_CHECKING:
|
||||
# ONLY type-annots, the eager import costs ~4.5ms
|
||||
# of `import tractor` wall-time (gh #470).
|
||||
from tractor.net._tunnel import (
|
||||
TunnelledAddress,
|
||||
)
|
||||
from ..runtime._runtime import Actor
|
||||
else:
|
||||
Actor = Any
|
||||
TunnelledAddress = Any
|
||||
|
||||
log = get_logger()
|
||||
|
||||
|
|
@ -69,25 +75,44 @@ log = get_logger()
|
|||
# seems like the right name as per,
|
||||
# https://www.geeksforgeeks.org/introduction-to-address-descriptor/
|
||||
#
|
||||
UnwrappedAddress = (
|
||||
# tcp/udp/uds
|
||||
tuple[
|
||||
str, # host/domain(tcp), filesys-dir(uds)
|
||||
int|str, # port/path(uds)
|
||||
]
|
||||
# ?TODO? should we also include another 2 fields from
|
||||
# our `Aid` msg such that we include the runtime `Actor.uid`
|
||||
# of `.name` and `.uuid`?
|
||||
# - would ensure uniqueness across entire net?
|
||||
# - allows for easier runtime-level filtering of "actors by
|
||||
# service name"
|
||||
TaggedTCPAddress: TypeAlias = tuple[
|
||||
Literal['tcp'],
|
||||
str,
|
||||
int,
|
||||
]
|
||||
TaggedUnixAddress: TypeAlias = tuple[
|
||||
Literal['unix'],
|
||||
str,
|
||||
]
|
||||
TaggedUDSAlias: TypeAlias = tuple[
|
||||
Literal['uds'],
|
||||
str,
|
||||
]
|
||||
TaggedAddress: TypeAlias = (
|
||||
TaggedTCPAddress
|
||||
|TaggedUnixAddress
|
||||
)
|
||||
|
||||
# Input-only compatibility forms retained for older callers and
|
||||
# serialized payloads.
|
||||
LegacyTCPAddress: TypeAlias = tuple[str, int]
|
||||
LegacyUDSAddress: TypeAlias = tuple[str, str]
|
||||
LegacyUnwrappedAddress: TypeAlias = (
|
||||
LegacyTCPAddress
|
||||
|LegacyUDSAddress
|
||||
)
|
||||
UnwrappedAddress = TaggedAddress
|
||||
# ?TODO? should we also include another 2 fields from our `Aid` msg
|
||||
# such that we include the runtime `Actor.uid` of `.name` and `.uuid`?
|
||||
# - would ensure uniqueness across entire net?
|
||||
# - allows for easier runtime-level filtering of "actors by service
|
||||
# name"
|
||||
|
||||
|
||||
# TODO, maybe rename to `SocketAddress`?
|
||||
class Address(Protocol):
|
||||
proto_key: ClassVar[str]
|
||||
unwrapped_type: ClassVar[UnwrappedAddress]
|
||||
unwrapped_type: ClassVar[type]
|
||||
|
||||
# TODO, i feel like an `.is_bound()` is a better thing to
|
||||
# support?
|
||||
|
|
@ -99,7 +124,7 @@ class Address(Protocol):
|
|||
|
||||
# TODO, maybe `.netns` is a better name?
|
||||
@property
|
||||
def namespace(self) -> tuple[str, int]|None:
|
||||
def namespace(self) -> tuple[str, str|int]|None:
|
||||
'''
|
||||
The if-available, OS-specific "network namespace" key.
|
||||
|
||||
|
|
@ -209,7 +234,17 @@ def get_address_cls(name: str) -> Type[Address]:
|
|||
|
||||
|
||||
def is_wrapped_addr(addr: any) -> bool:
|
||||
return type(addr) in _address_types.values()
|
||||
# XXX NOTE, a `TunnelledAddress` is genuinely "wrapped" but is
|
||||
# deliberately NOT in `_address_types`: it has no
|
||||
# `MsgTransport` of its own (a tunnel is transparent to
|
||||
# `socket(2)`), so it gets no proto-key entry. See
|
||||
# `tractor.net._tunnel`.
|
||||
from tractor.net._tunnel import TunnelledAddress
|
||||
return (
|
||||
type(addr) in _address_types.values()
|
||||
or
|
||||
isinstance(addr, TunnelledAddress)
|
||||
)
|
||||
|
||||
|
||||
def mk_uuid() -> str:
|
||||
|
|
@ -223,8 +258,16 @@ def mk_uuid() -> str:
|
|||
|
||||
|
||||
def wrap_address(
|
||||
addr: UnwrappedAddress|str,
|
||||
) -> Address:
|
||||
addr: (
|
||||
TaggedAddress
|
||||
|TaggedUDSAlias
|
||||
|LegacyUnwrappedAddress
|
||||
|list[str|int]
|
||||
|str
|
||||
|Address
|
||||
|TunnelledAddress
|
||||
),
|
||||
) -> Address|TunnelledAddress:
|
||||
'''
|
||||
Wrap an `UnwrappedAddress` as an `Address`-type based
|
||||
on matching builtin python data-structures which we adhoc
|
||||
|
|
@ -246,6 +289,20 @@ def wrap_address(
|
|||
# import pdbp; pdbp.set_trace()
|
||||
match addr:
|
||||
|
||||
case (
|
||||
('tcp', str(), int())
|
||||
|
|
||||
['tcp', str(), int()]
|
||||
):
|
||||
return TCPAddress.from_addr(addr)
|
||||
|
||||
case (
|
||||
(('unix' | 'uds'), str())
|
||||
|
|
||||
[('unix' | 'uds'), str()]
|
||||
):
|
||||
return UDSAddress.from_addr(addr)
|
||||
|
||||
# classic network socket-address as tuple/list
|
||||
case (
|
||||
(str(), int())
|
||||
|
|
@ -277,7 +334,7 @@ def wrap_address(
|
|||
# multiaddr-format string, e.g.
|
||||
# '/ip4/127.0.0.1/tcp/1616'
|
||||
case str() if addr.startswith('/'):
|
||||
from tractor.discovery._multiaddr import (
|
||||
from tractor.net import (
|
||||
parse_maddr,
|
||||
)
|
||||
return parse_maddr(addr)
|
||||
|
|
|
|||
|
|
@ -38,9 +38,13 @@ if TYPE_CHECKING:
|
|||
# `import tractor` path (gh #470).
|
||||
from multiaddr import Multiaddr
|
||||
from tractor.discovery._addr import Address
|
||||
from tractor.net._tunnel import (
|
||||
TunnelledAddress,
|
||||
)
|
||||
else:
|
||||
Multiaddr = Any
|
||||
Address = Any
|
||||
TunnelledAddress = Any
|
||||
|
||||
# map from tractor-internal `proto_key` identifiers
|
||||
# to the standard multiaddr protocol name strings.
|
||||
|
|
@ -57,7 +61,7 @@ _maddr_to_tpt_proto: dict[str, str] = {
|
|||
|
||||
|
||||
def mk_maddr(
|
||||
addr: 'Address',
|
||||
addr: 'Address|TunnelledAddress',
|
||||
) -> Multiaddr:
|
||||
'''
|
||||
Construct a `Multiaddr` from a tractor `Address` instance,
|
||||
|
|
@ -67,6 +71,13 @@ def mk_maddr(
|
|||
'''
|
||||
from multiaddr import Multiaddr
|
||||
|
||||
from tractor.net._tunnel import (
|
||||
TunnelledAddress,
|
||||
mk_wg_maddr,
|
||||
)
|
||||
if isinstance(addr, TunnelledAddress):
|
||||
return mk_wg_maddr(addr)
|
||||
|
||||
proto_key: str = addr.proto_key
|
||||
maddr_proto: str|None = _tpt_proto_to_maddr.get(proto_key)
|
||||
if maddr_proto is None:
|
||||
|
|
@ -76,7 +87,7 @@ def mk_maddr(
|
|||
|
||||
match proto_key:
|
||||
case 'tcp':
|
||||
host, port = addr.unwrap()
|
||||
_, host, port = addr.unwrap()
|
||||
ip = ipaddress.ip_address(host)
|
||||
net_proto: str = (
|
||||
'ip4' if ip.version == 4
|
||||
|
|
@ -87,13 +98,12 @@ def mk_maddr(
|
|||
)
|
||||
|
||||
case 'uds':
|
||||
filedir, filename = addr.unwrap()
|
||||
filepath = Path(filedir) / filename
|
||||
_, sockpath = addr.unwrap()
|
||||
# NOTE, strip any leading `/` to avoid
|
||||
# double-slash `/unix//run/..` which the
|
||||
# multiaddr parser rejects as "empty
|
||||
# protocol path".
|
||||
fpath_str: str = str(filepath).lstrip('/')
|
||||
fpath_str: str = sockpath.lstrip('/')
|
||||
return Multiaddr(
|
||||
f'/{maddr_proto}/{fpath_str}'
|
||||
)
|
||||
|
|
@ -101,7 +111,7 @@ def mk_maddr(
|
|||
|
||||
def parse_maddr(
|
||||
maddr_str: str,
|
||||
) -> 'Address':
|
||||
) -> 'Address|TunnelledAddress':
|
||||
'''
|
||||
Parse a multiaddr string into a tractor `Address`.
|
||||
|
||||
|
|
@ -113,7 +123,16 @@ def parse_maddr(
|
|||
from tractor.ipc._tcp import TCPAddress
|
||||
from tractor.ipc._uds import UDSAddress
|
||||
|
||||
maddr = Multiaddr(maddr_str)
|
||||
try:
|
||||
maddr = Multiaddr(maddr_str)
|
||||
except ValueError:
|
||||
# Diagnose an unavailable WG codec after upstream parsing
|
||||
# fails. Pre-checking the raw string would misclassify valid
|
||||
# values such as `/unix/tmp/wg/service.sock`.
|
||||
if '/wg/' in maddr_str:
|
||||
from tractor.net._tunnel import _wg_proto_code
|
||||
_wg_proto_code()
|
||||
raise
|
||||
proto_names: list[str] = [
|
||||
p.name for p in maddr.protocols()
|
||||
]
|
||||
|
|
@ -136,6 +155,10 @@ def parse_maddr(
|
|||
filename=sockpath.name,
|
||||
)
|
||||
|
||||
case _ if 'wg' in proto_names:
|
||||
from tractor.net._tunnel import parse_wg_maddr
|
||||
return parse_wg_maddr(maddr)
|
||||
|
||||
case _:
|
||||
raise ValueError(
|
||||
f'Unsupported multiaddr protocol combo: '
|
||||
|
|
@ -154,11 +177,11 @@ EndpointsTable = dict[
|
|||
list[str|tuple], # maddr strs or UnwrappedAddress
|
||||
]
|
||||
|
||||
# output table: actor/service name -> list of wrapped
|
||||
# `Address` instances ready for transport binding.
|
||||
# output table: actor/service name -> list of wrapped address
|
||||
# declarations ready for bindspace handling.
|
||||
ParsedEndpoints = dict[
|
||||
str, # actor/service name
|
||||
list['Address'],
|
||||
list['Address|TunnelledAddress'],
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -167,7 +190,7 @@ def parse_endpoints(
|
|||
) -> ParsedEndpoints:
|
||||
'''
|
||||
Parse a service-endpoint config table into wrapped
|
||||
`Address` instances suitable for transport binding.
|
||||
address declarations suitable for bindspace handling.
|
||||
|
||||
Each key is an actor/service name and each value is
|
||||
a list of addresses in any format accepted by
|
||||
|
|
@ -179,6 +202,8 @@ def parse_endpoints(
|
|||
``/uds/`` proto_key)
|
||||
- raw unwrapped tuples: ``('127.0.0.1', 1616)``
|
||||
- pre-wrapped `Address` objects (passed through)
|
||||
- `wg` maddrs, returned as `TunnelledAddress` wrappers which
|
||||
must be peeled at the eventual bind/dial boundary
|
||||
|
||||
Returns a new `dict` with the same keys, where each
|
||||
value list contains the corresponding `Address`
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ from tractor.msg import (
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from ._transport import MsgTransport
|
||||
from tractor.net._tunnel import TunnelledAddress
|
||||
else:
|
||||
TunnelledAddress = Any
|
||||
|
||||
|
||||
log = get_logger()
|
||||
|
|
@ -182,16 +185,19 @@ class Channel:
|
|||
@classmethod
|
||||
async def from_addr(
|
||||
cls,
|
||||
addr: UnwrappedAddress,
|
||||
addr: UnwrappedAddress|Address|TunnelledAddress,
|
||||
**kwargs
|
||||
) -> Channel:
|
||||
|
||||
if not is_wrapped_addr(addr):
|
||||
addr: Address = wrap_address(addr)
|
||||
from tractor.net._tunnel import strip_tunnels
|
||||
|
||||
transport_cls = transport_from_addr(addr)
|
||||
if not is_wrapped_addr(addr):
|
||||
addr = wrap_address(addr)
|
||||
|
||||
transport_addr: Address = strip_tunnels(addr)
|
||||
transport_cls = transport_from_addr(transport_addr)
|
||||
transport = await transport_cls.connect_to(
|
||||
addr,
|
||||
transport_addr,
|
||||
**kwargs,
|
||||
)
|
||||
# XXX, for UDS *no!* since we recv the peer-pid and build out
|
||||
|
|
@ -551,7 +557,7 @@ class Channel:
|
|||
|
||||
@acm
|
||||
async def _connect_chan(
|
||||
addr: UnwrappedAddress,
|
||||
addr: UnwrappedAddress|Address|TunnelledAddress,
|
||||
close_timeout: float|None = None,
|
||||
) -> typing.AsyncGenerator[Channel, None]:
|
||||
'''
|
||||
|
|
|
|||
|
|
@ -59,12 +59,16 @@ from ..msg import (
|
|||
from ..trionics import maybe_open_nursery
|
||||
from ..runtime import _state
|
||||
from .. import log
|
||||
from ..discovery._addr import Address
|
||||
from ..discovery._addr import (
|
||||
Address,
|
||||
UnwrappedAddress,
|
||||
)
|
||||
from ._chan import Channel
|
||||
from ._transport import MsgTransport
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..net._tunnel import TunnelledAddress
|
||||
from ..runtime._runtime import Actor
|
||||
from ..runtime._supervise import ActorNursery
|
||||
|
||||
|
|
@ -627,6 +631,7 @@ class Endpoint(Struct):
|
|||
|
||||
'''
|
||||
addr: Address
|
||||
declared_addr: Address|TunnelledAddress
|
||||
listen_tn: Nursery
|
||||
stream_handler_tn: Nursery|None = None
|
||||
|
||||
|
|
@ -641,15 +646,27 @@ class Endpoint(Struct):
|
|||
MsgTransport, # handle to encoded-msg transport stream
|
||||
] = {}
|
||||
|
||||
@property
|
||||
def namespace(self) -> tuple[str, str|int]|None:
|
||||
'''
|
||||
Return the original address declaration's namespace.
|
||||
|
||||
`Endpoint.addr` is peeled to its concrete transport before
|
||||
listener reflection, so `.declared_addr` retains bindspace
|
||||
metadata for diagnostics and later provisioning.
|
||||
|
||||
'''
|
||||
return self.declared_addr.namespace
|
||||
|
||||
def pformat(
|
||||
self,
|
||||
indent: int = 0,
|
||||
privates: bool = False,
|
||||
) -> str:
|
||||
type_repr: str = type(self).__name__
|
||||
namespace: tuple[str, str|int]|None = self.namespace
|
||||
fmtstr: str = (
|
||||
# !TODO, always be ns aware!
|
||||
# f'|_netns: {netns}\n'
|
||||
f' |.namespace: {namespace!r}\n'
|
||||
f' |.addr: {self.addr!r}\n'
|
||||
f' |_peers: {len(self.peer_tpts)}\n'
|
||||
)
|
||||
|
|
@ -927,9 +944,17 @@ class Server(Struct):
|
|||
ep.addr for ep in eps
|
||||
]
|
||||
repr_eps: str = ppfmt(addrs)
|
||||
namespaces: list[
|
||||
tuple[str, str|int]|None
|
||||
] = []
|
||||
ep: Endpoint
|
||||
for ep in eps:
|
||||
namespaces.append(ep.namespace)
|
||||
repr_namespaces: str = ppfmt(namespaces)
|
||||
|
||||
fmtstr += (
|
||||
f' |_endpoints: {repr_eps}\n'
|
||||
f' |_namespaces: {repr_namespaces}\n'
|
||||
# ^TODO? how to indent closing ']'..
|
||||
)
|
||||
|
||||
|
|
@ -965,7 +990,9 @@ class Server(Struct):
|
|||
async def listen_on(
|
||||
self,
|
||||
*,
|
||||
accept_addrs: list[tuple[str, int|str]]|None = None,
|
||||
accept_addrs: list[
|
||||
UnwrappedAddress|Address|TunnelledAddress
|
||||
]|None = None,
|
||||
stream_handler_nursery: Nursery|None = None,
|
||||
) -> list[Endpoint]:
|
||||
'''
|
||||
|
|
@ -1048,7 +1075,7 @@ async def _serve_ipc_eps(
|
|||
*,
|
||||
server: IPCServer,
|
||||
stream_handler_tn: Nursery,
|
||||
listen_addrs: list[tuple[str, int|str]],
|
||||
listen_addrs: list[Address|TunnelledAddress],
|
||||
|
||||
task_status: TaskStatus[
|
||||
Nursery,
|
||||
|
|
@ -1064,6 +1091,8 @@ async def _serve_ipc_eps(
|
|||
`.cancel_server()` is called.
|
||||
|
||||
'''
|
||||
from ..net._tunnel import strip_tunnels
|
||||
|
||||
try:
|
||||
listen_tn: Nursery
|
||||
async with trio.open_nursery() as listen_tn:
|
||||
|
|
@ -1072,11 +1101,13 @@ async def _serve_ipc_eps(
|
|||
# XXX NOTE, required to call `serve_listeners()` below.
|
||||
# ?TODO, maybe just pass `list(eps.values()` tho?
|
||||
listeners: list[trio.abc.Listener] = []
|
||||
for addr in listen_addrs:
|
||||
for declared_addr in listen_addrs:
|
||||
addr: Address = strip_tunnels(declared_addr)
|
||||
ep = Endpoint(
|
||||
addr=addr,
|
||||
listen_tn=listen_tn,
|
||||
stream_handler_tn=stream_handler_tn,
|
||||
declared_addr=declared_addr,
|
||||
)
|
||||
try:
|
||||
ep_sclang: str = nest_from_op(
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ from trio import (
|
|||
|
||||
from tractor.msg import MsgCodec
|
||||
from tractor.log import get_logger
|
||||
from tractor.discovery._multiaddr import mk_maddr
|
||||
from tractor.ipc._transport import (
|
||||
MsgTransport,
|
||||
MsgpackTransport,
|
||||
|
|
@ -47,8 +46,10 @@ if TYPE_CHECKING:
|
|||
# ONLY type-annots, the eager import costs
|
||||
# `import tractor` wall-time (gh #470).
|
||||
from multiaddr import Multiaddr
|
||||
from tractor.discovery._addr import TaggedTCPAddress
|
||||
else:
|
||||
Multiaddr = Any
|
||||
TaggedTCPAddress = Any
|
||||
|
||||
|
||||
log = get_logger()
|
||||
|
|
@ -70,7 +71,7 @@ class TCPAddress(
|
|||
) from valerr
|
||||
|
||||
proto_key: ClassVar[str] = 'tcp'
|
||||
unwrapped_type: ClassVar[type] = tuple[str, int]
|
||||
unwrapped_type: ClassVar[type] = tuple
|
||||
def_bindspace: ClassVar[str] = '127.0.0.1'
|
||||
|
||||
# ?TODO, actually validate ipv4/6 with stdlib's `ipaddress`
|
||||
|
|
@ -105,6 +106,14 @@ class TCPAddress(
|
|||
def bindspace(self) -> str:
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def namespace(self) -> None:
|
||||
'''
|
||||
Report that plain TCP uses the process's current namespace.
|
||||
|
||||
'''
|
||||
return None
|
||||
|
||||
@property
|
||||
def domain(self) -> str:
|
||||
return self._host
|
||||
|
|
@ -112,19 +121,35 @@ class TCPAddress(
|
|||
@classmethod
|
||||
def from_addr(
|
||||
cls,
|
||||
addr: tuple[str, int]
|
||||
addr: tuple|list,
|
||||
) -> TCPAddress:
|
||||
match addr:
|
||||
case (str(), int()):
|
||||
return TCPAddress(addr[0], addr[1])
|
||||
case (
|
||||
('tcp', str() as host, int() as port)
|
||||
|
|
||||
['tcp', str() as host, int() as port]
|
||||
|
|
||||
(str() as host, int() as port)
|
||||
|
|
||||
[str() as host, int() as port]
|
||||
|
|
||||
(
|
||||
str() as host,
|
||||
int() as port,
|
||||
int(),
|
||||
int(),
|
||||
)
|
||||
):
|
||||
return TCPAddress(host, port)
|
||||
case _:
|
||||
raise ValueError(
|
||||
f'Invalid unwrapped address for {cls}\n'
|
||||
f'{addr}\n'
|
||||
)
|
||||
|
||||
def unwrap(self) -> tuple[str, int]:
|
||||
def unwrap(self) -> TaggedTCPAddress:
|
||||
return (
|
||||
self.proto_key,
|
||||
self._host,
|
||||
self._port,
|
||||
)
|
||||
|
|
@ -209,6 +234,8 @@ class MsgpackTCPStream(MsgpackTransport):
|
|||
|
||||
@property
|
||||
def maddr(self) -> Multiaddr:
|
||||
from tractor.net import mk_maddr
|
||||
|
||||
return mk_maddr(self.raddr)
|
||||
|
||||
def connected(self) -> bool:
|
||||
|
|
@ -223,7 +250,8 @@ class MsgpackTCPStream(MsgpackTransport):
|
|||
**kwargs
|
||||
) -> MsgpackTCPStream:
|
||||
stream = await trio.open_tcp_stream(
|
||||
*destaddr.unwrap(),
|
||||
destaddr._host,
|
||||
destaddr._port,
|
||||
**kwargs
|
||||
)
|
||||
return MsgpackTCPStream(
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ from trio._highlevel_open_unix_stream import (
|
|||
|
||||
from tractor.msg import MsgCodec
|
||||
from tractor.log import get_logger
|
||||
from tractor.discovery._multiaddr import mk_maddr
|
||||
from tractor.ipc._transport import (
|
||||
MsgpackTransport,
|
||||
)
|
||||
|
|
@ -77,10 +76,12 @@ if TYPE_CHECKING:
|
|||
# ONLY type-annots, the eager import costs
|
||||
# `import tractor` wall-time (gh #470).
|
||||
from multiaddr import Multiaddr
|
||||
from tractor.discovery._addr import TaggedUnixAddress
|
||||
from tractor.runtime._runtime import Actor
|
||||
else:
|
||||
Multiaddr = Any
|
||||
Actor = Any
|
||||
TaggedUnixAddress = Any
|
||||
|
||||
|
||||
# Platform-specific credential passing constants
|
||||
|
|
@ -147,7 +148,7 @@ class UDSAddress(
|
|||
# -[ ] need to check what other mult-transport frameworks do
|
||||
# like zmq, nng, uri-spec et al!
|
||||
proto_key: ClassVar[str] = 'uds'
|
||||
unwrapped_type: ClassVar[type] = tuple[str, int]
|
||||
unwrapped_type: ClassVar[type] = tuple
|
||||
def_bindspace: ClassVar[Path] = get_rt_dir()
|
||||
|
||||
@property
|
||||
|
|
@ -163,9 +164,17 @@ class UDSAddress(
|
|||
self.def_bindspace
|
||||
)
|
||||
|
||||
@property
|
||||
def namespace(self) -> None:
|
||||
'''
|
||||
Report that plain UDS uses the process's current namespace.
|
||||
|
||||
'''
|
||||
return None
|
||||
|
||||
@property
|
||||
def sockpath(self) -> Path:
|
||||
return self.bindspace / self.filename
|
||||
return Path(self.bindspace) / self.filename
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
|
|
@ -179,16 +188,26 @@ class UDSAddress(
|
|||
def from_addr(
|
||||
cls,
|
||||
addr: (
|
||||
tuple[Path|str, Path|str]|Path|str
|
||||
tuple|list|Path|str
|
||||
),
|
||||
) -> UDSAddress:
|
||||
match addr:
|
||||
case tuple()|list():
|
||||
filedir = Path(addr[0])
|
||||
filename = Path(addr[1])
|
||||
case (
|
||||
(('unix' | 'uds'), str()|Path() as sockpath)
|
||||
|
|
||||
[('unix' | 'uds'), str()|Path() as sockpath]
|
||||
):
|
||||
path = Path(sockpath)
|
||||
return UDSAddress(*unwrap_sockpath(path))
|
||||
|
||||
case (
|
||||
(str()|Path() as filedir, str()|Path() as filename)
|
||||
|
|
||||
[str()|Path() as filedir, str()|Path() as filename]
|
||||
):
|
||||
return UDSAddress(
|
||||
filedir=filedir,
|
||||
filename=filename,
|
||||
filedir=Path(filedir),
|
||||
filename=Path(filename),
|
||||
# maybe_pid=pid,
|
||||
)
|
||||
# NOTE, in case we ever decide to just `.unwrap()`
|
||||
|
|
@ -203,12 +222,10 @@ class UDSAddress(
|
|||
f'{addr!r}\n'
|
||||
)
|
||||
|
||||
def unwrap(self) -> tuple[str, int]:
|
||||
# XXX NOTE, since this gets passed DIRECTLY to
|
||||
# `.ipc._uds.open_unix_socket_w_passcred()`
|
||||
def unwrap(self) -> TaggedUnixAddress:
|
||||
return (
|
||||
str(self.filedir),
|
||||
str(self.filename),
|
||||
'unix',
|
||||
str(self.sockpath),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -593,6 +610,8 @@ class MsgpackUDSStream(MsgpackTransport):
|
|||
|
||||
@property
|
||||
def maddr(self) -> Multiaddr|str:
|
||||
from tractor.net import mk_maddr
|
||||
|
||||
if not self.raddr:
|
||||
return '<unknown-peer>'
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ from .ptr import (
|
|||
from .pretty_struct import (
|
||||
Struct as Struct,
|
||||
)
|
||||
from ._local import (
|
||||
ProcessLocal as ProcessLocal,
|
||||
)
|
||||
from ._codec import (
|
||||
_def_msgspec_codec as _def_msgspec_codec,
|
||||
_ctxvar_MsgCodec as _ctxvar_MsgCodec,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
# tractor: structured concurrent "actors".
|
||||
# Copyright 2018-eternity Tyler Goodlet.
|
||||
|
||||
# This program is free software: you can redistribute it and/or
|
||||
# modify it under the terms of the GNU Affero General Public License
|
||||
# as published by the Free Software Foundation, either version 3 of
|
||||
# the License, or (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU Affero General Public
|
||||
# License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
'''
|
||||
Markers for process-local values which must not cross actor IPC.
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import msgspec
|
||||
|
||||
|
||||
class _ProcessLocalToken:
|
||||
'''
|
||||
Unsupported msgspec value embedded in every `ProcessLocal`.
|
||||
|
||||
'''
|
||||
__slots__ = ()
|
||||
|
||||
|
||||
_PROCESS_LOCAL_TOKEN: _ProcessLocalToken = _ProcessLocalToken()
|
||||
|
||||
|
||||
class ProcessLocal(
|
||||
msgspec.Struct,
|
||||
kw_only=True,
|
||||
repr_omit_defaults=True,
|
||||
):
|
||||
'''
|
||||
Generic struct marker which rejects default msgspec encoding.
|
||||
|
||||
The hidden sentinel remains part of the encoded field set, so
|
||||
msgspec encounters `_ProcessLocalToken` and raises `TypeError`
|
||||
even when this value is nested inside another supported payload.
|
||||
A custom encode hook may explicitly override that safeguard.
|
||||
|
||||
Keyword-only fields let subclasses add required fields after the
|
||||
marker's default sentinel.
|
||||
|
||||
'''
|
||||
_process_local: _ProcessLocalToken = _PROCESS_LOCAL_TOKEN
|
||||
|
|
@ -213,10 +213,11 @@ class SpawnSpec(
|
|||
# module import capability
|
||||
enable_modules: dict[str, str]
|
||||
|
||||
# TODO: not just sockaddr pairs?
|
||||
# -[ ] abstract into a `TransportAddr` type?
|
||||
reg_addrs: list[tuple[str, str|int]]
|
||||
bind_addrs: list[tuple[str, str|int]]|None
|
||||
# Tagged addresses have protocol-specific tuple shapes which
|
||||
# msgspec cannot express as one decodable union. `wrap_address()`
|
||||
# validates each tuple at the transport boundary.
|
||||
reg_addrs: list[tuple]
|
||||
bind_addrs: list[tuple]|None
|
||||
|
||||
|
||||
# TODO: caps based RPC support in the payload?
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
# tractor: structured concurrent "actors".
|
||||
# Copyright 2018-eternity Tyler Goodlet.
|
||||
|
||||
'''
|
||||
Network declarations, bindspaces and tunnels.
|
||||
|
||||
Public symbols are imported and cached on first access so importing
|
||||
this package does not load optional network dependencies.
|
||||
|
||||
'''
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
_SYMBOL_MODULES: dict[str, str] = {
|
||||
'Bindspace': '._bindspace',
|
||||
'BindspaceKind': '._bindspace',
|
||||
'BindspaceLifecycle': '._bindspace',
|
||||
'BindspaceOwnership': '._bindspace',
|
||||
'BindspaceRef': '._bindspace',
|
||||
'BindspaceSpec': '._bindspace',
|
||||
'CURRENT_NETNS': '._bindspace',
|
||||
'attach_netns': '._bindspace',
|
||||
'open_bindspace': '._bindspace',
|
||||
'open_netns': '._bindspace',
|
||||
'TunnelledAddress': '._tunnel',
|
||||
'TunnelSpec': '._tunnel',
|
||||
'WGTunnelSpec': '._tunnel',
|
||||
'WGInterfaceConfig': '._tunnel',
|
||||
'WGPeerConfig': '._tunnel',
|
||||
'WGRole': '._tunnel',
|
||||
'mb_pubkey': '._tunnel',
|
||||
'mk_wg_maddr': '._tunnel',
|
||||
'open_wg_bindspace': '._tunnel',
|
||||
'open_wg_iface': '._tunnel',
|
||||
'parse_wg_maddr': '._tunnel',
|
||||
'read_wg_peers': '._tunnel',
|
||||
'read_wg_pubkey': '._tunnel',
|
||||
'strip_tunnels': '._tunnel',
|
||||
'tunnels_of': '._tunnel',
|
||||
'verify_wg_peer': '._tunnel',
|
||||
'wg8_pubkey': '._tunnel',
|
||||
'mk_maddr': '..discovery._multiaddr',
|
||||
'parse_maddr': '..discovery._multiaddr',
|
||||
'parse_endpoints': '..discovery._multiaddr',
|
||||
}
|
||||
|
||||
__all__: tuple[str, ...] = tuple(_SYMBOL_MODULES)
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
'''
|
||||
Advertise the complete lazy public API.
|
||||
|
||||
'''
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
'''
|
||||
Import and cache one public network symbol on first access.
|
||||
|
||||
'''
|
||||
try:
|
||||
module_name: str = _SYMBOL_MODULES[name]
|
||||
except KeyError:
|
||||
raise AttributeError(
|
||||
f'module {__name__!r} has no attribute {name!r}'
|
||||
) from None
|
||||
|
||||
value: object = getattr(
|
||||
import_module(module_name, __name__),
|
||||
name,
|
||||
)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
|
@ -0,0 +1,436 @@
|
|||
# tractor: structured concurrent "actors".
|
||||
# Copyright 2018-eternity Tyler Goodlet.
|
||||
|
||||
# This program is free software: you can redistribute it and/or
|
||||
# modify it under the terms of the GNU Affero General Public License
|
||||
# as published by the Free Software Foundation, either version 3 of
|
||||
# the License, or (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU Affero General Public
|
||||
# License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
'''
|
||||
Serializable bindspace declarations and live capabilities.
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager as acm
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import (
|
||||
Final,
|
||||
get_args,
|
||||
Literal,
|
||||
TypeAlias,
|
||||
)
|
||||
|
||||
import msgspec
|
||||
import trio
|
||||
|
||||
from ..msg._local import ProcessLocal
|
||||
|
||||
|
||||
BindspaceKind: TypeAlias = Literal[
|
||||
'netns',
|
||||
]
|
||||
BindspaceLifecycle: TypeAlias = Literal[
|
||||
'attach', # borrow one existing platform resource
|
||||
'open', # create, own and remove one platform resource
|
||||
]
|
||||
BindspaceOwnership: TypeAlias = Literal[
|
||||
'owned', # manager tears down the resource after final release
|
||||
'borrowed', # manager leaves the pre-existing resource intact
|
||||
]
|
||||
|
||||
_NETNS_RUN_DIR: Path = Path('/var/run/netns')
|
||||
_THREAD_NETNS: Path = Path('/proc/thread-self/ns/net')
|
||||
|
||||
CURRENT_NETNS: Final[None] = None
|
||||
|
||||
|
||||
def _validate_bindspace_kind(
|
||||
kind: BindspaceKind,
|
||||
) -> None:
|
||||
'''
|
||||
Reject platform-resource kinds without an implementation.
|
||||
|
||||
'''
|
||||
if kind not in get_args(BindspaceKind):
|
||||
raise ValueError(
|
||||
f'Unsupported bindspace kind: {kind!r}'
|
||||
)
|
||||
|
||||
|
||||
def _validate_bindspace_lifecycle(
|
||||
lifecycle: BindspaceLifecycle,
|
||||
) -> None:
|
||||
'''
|
||||
Reject lifecycle policies without an implementation.
|
||||
|
||||
'''
|
||||
if lifecycle not in get_args(BindspaceLifecycle):
|
||||
raise ValueError(
|
||||
f'Unsupported bindspace lifecycle: {lifecycle!r}'
|
||||
)
|
||||
|
||||
|
||||
def _validate_bindspace_key(
|
||||
kind: BindspaceKind,
|
||||
key: str|None,
|
||||
field: str,
|
||||
) -> None:
|
||||
'''
|
||||
Reject empty or path-like platform-resource names.
|
||||
|
||||
`None` is valid. Spell it `CURRENT_NETNS` for
|
||||
`BindspaceSpec.key`; `BindspaceRef.key = None` records an
|
||||
unnamed realized netns.
|
||||
|
||||
'''
|
||||
if key == '':
|
||||
raise ValueError(
|
||||
f'`{field}` must be a non-empty name or `None` '
|
||||
f'(`CURRENT_NETNS` for `BindspaceSpec.key`)!'
|
||||
)
|
||||
if (
|
||||
kind == 'netns'
|
||||
and
|
||||
key is not None
|
||||
and
|
||||
(
|
||||
Path(key).name != key
|
||||
or
|
||||
key in ('.', '..')
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
f'Invalid netns name: {key!r}'
|
||||
)
|
||||
|
||||
|
||||
class BindspaceSpec(
|
||||
msgspec.Struct,
|
||||
frozen=True,
|
||||
):
|
||||
'''
|
||||
Serializable declaration of one requested bindspace.
|
||||
|
||||
For a netns spec, `.key = CURRENT_NETNS` selects the calling
|
||||
thread's current namespace without a named-path lookup.
|
||||
|
||||
'''
|
||||
kind: BindspaceKind
|
||||
key: str|None = CURRENT_NETNS
|
||||
lifecycle: BindspaceLifecycle = 'attach'
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
'''
|
||||
Reject an empty platform-resource key.
|
||||
|
||||
'''
|
||||
_validate_bindspace_kind(self.kind)
|
||||
_validate_bindspace_lifecycle(self.lifecycle)
|
||||
_validate_bindspace_key(
|
||||
self.kind,
|
||||
self.key,
|
||||
'BindspaceSpec.key',
|
||||
)
|
||||
|
||||
|
||||
class BindspaceRef(
|
||||
msgspec.Struct,
|
||||
frozen=True,
|
||||
):
|
||||
'''
|
||||
Serializable, non-owning ref to one realized bindspace.
|
||||
|
||||
`.key` is an optional mutable namespace locator. `.inode` is a
|
||||
host-local kernel fingerprint which remains stable while the
|
||||
resource exists or a live `Bindspace` pins it. This ref grants no
|
||||
authority and cannot reopen the resource by itself.
|
||||
|
||||
'''
|
||||
kind: BindspaceKind
|
||||
key: str|None
|
||||
inode: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
'''
|
||||
Require a host-local resource inode and an optional locator.
|
||||
|
||||
'''
|
||||
_validate_bindspace_kind(self.kind)
|
||||
_validate_bindspace_key(
|
||||
self.kind,
|
||||
self.key,
|
||||
'BindspaceRef.key',
|
||||
)
|
||||
if (
|
||||
type(self.inode) is not int
|
||||
or
|
||||
self.inode <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
'`BindspaceRef.inode` must be a positive `int`!'
|
||||
)
|
||||
|
||||
|
||||
class Bindspace(
|
||||
ProcessLocal,
|
||||
):
|
||||
'''
|
||||
Process-local capability for one live realized bindspace.
|
||||
|
||||
`ProcessLocal` provides compact typed storage plus a default
|
||||
wire-encoding guard. `Bindspace` construction and explicit FD
|
||||
transfer belong to the supervisor's spawn/bootstrap path.
|
||||
|
||||
'''
|
||||
spec: BindspaceSpec
|
||||
ref: BindspaceRef
|
||||
namespace_fd: int|None
|
||||
ownership: BindspaceOwnership
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
'''
|
||||
Validate and retain one scoped bindspace capability.
|
||||
|
||||
'''
|
||||
spec: BindspaceSpec = self.spec
|
||||
ref: BindspaceRef = self.ref
|
||||
namespace_fd: int|None = self.namespace_fd
|
||||
ownership: BindspaceOwnership = self.ownership
|
||||
|
||||
if spec.kind != ref.kind:
|
||||
raise ValueError(
|
||||
'`BindspaceSpec.kind` does not match '
|
||||
'`BindspaceRef.kind`!'
|
||||
)
|
||||
if (
|
||||
spec.key is not None
|
||||
and
|
||||
spec.key != ref.key
|
||||
):
|
||||
raise ValueError(
|
||||
'`BindspaceSpec.key` does not match '
|
||||
'`BindspaceRef.key`!'
|
||||
)
|
||||
if ownership not in get_args(BindspaceOwnership):
|
||||
raise ValueError(
|
||||
f'Invalid bindspace ownership: {ownership!r}'
|
||||
)
|
||||
expected_ownership: BindspaceOwnership = (
|
||||
'borrowed'
|
||||
if spec.lifecycle == 'attach'
|
||||
else 'owned'
|
||||
)
|
||||
if ownership != expected_ownership:
|
||||
raise ValueError(
|
||||
f'`BindspaceSpec.lifecycle={spec.lifecycle!r}` '
|
||||
f'requires ownership={expected_ownership!r}!'
|
||||
)
|
||||
if namespace_fd is not None:
|
||||
if (
|
||||
type(namespace_fd) is not int
|
||||
or
|
||||
namespace_fd < 0
|
||||
):
|
||||
raise ValueError(
|
||||
'`namespace_fd` must be non-negative or `None`!'
|
||||
)
|
||||
fd_inode: int = os.fstat(namespace_fd).st_ino
|
||||
if ref.inode != fd_inode:
|
||||
raise ValueError(
|
||||
f'Namespace FD inode {fd_inode} does not match '
|
||||
f'reference inode {ref.inode}!'
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
'''
|
||||
Render the capability ref without dereferencing its FD.
|
||||
|
||||
'''
|
||||
return (
|
||||
f'{type(self).__name__}('
|
||||
f'ref={self.ref!r}, '
|
||||
f'ownership={self.ownership!r}, '
|
||||
f'namespace_fd={self.namespace_fd!r})'
|
||||
)
|
||||
|
||||
|
||||
@acm
|
||||
async def _pin_netns(
|
||||
spec: BindspaceSpec,
|
||||
ownership: BindspaceOwnership,
|
||||
) -> AsyncIterator[Bindspace]:
|
||||
'''
|
||||
Pin one existing Linux network namespace with explicit ownership.
|
||||
|
||||
'''
|
||||
key: str|None = spec.key
|
||||
namespace_path: Path = (
|
||||
_THREAD_NETNS
|
||||
if key is CURRENT_NETNS
|
||||
else _NETNS_RUN_DIR / key
|
||||
)
|
||||
namespace_fd: int = os.open(
|
||||
namespace_path,
|
||||
os.O_RDONLY | os.O_CLOEXEC,
|
||||
)
|
||||
try:
|
||||
inode: int = os.fstat(namespace_fd).st_ino
|
||||
ref: BindspaceRef = BindspaceRef(
|
||||
kind='netns',
|
||||
key=key,
|
||||
inode=inode,
|
||||
)
|
||||
bindspace: Bindspace = Bindspace(
|
||||
spec=spec,
|
||||
ref=ref,
|
||||
namespace_fd=namespace_fd,
|
||||
ownership=ownership,
|
||||
)
|
||||
yield bindspace
|
||||
finally:
|
||||
os.close(namespace_fd)
|
||||
|
||||
|
||||
@acm
|
||||
async def attach_netns(
|
||||
spec: BindspaceSpec,
|
||||
) -> AsyncIterator[Bindspace]:
|
||||
'''
|
||||
Borrow and pin one existing Linux network namespace.
|
||||
|
||||
`BindspaceSpec.key = CURRENT_NETNS` selects the calling process's
|
||||
current netns. A named key resolves beneath the standard iproute2
|
||||
netns run directory. "Attach" pins an existing namespace FD; this
|
||||
context never calls `setns()` or creates/removes a namespace.
|
||||
|
||||
'''
|
||||
if sys.platform != 'linux':
|
||||
raise NotImplementedError(
|
||||
'Network namespace bindspaces are Linux-only!'
|
||||
)
|
||||
if spec.lifecycle != 'attach':
|
||||
raise ValueError(
|
||||
'`attach_netns()` requires lifecycle=`attach`!'
|
||||
)
|
||||
async with _pin_netns(
|
||||
spec,
|
||||
ownership='borrowed',
|
||||
) as bindspace:
|
||||
yield bindspace
|
||||
|
||||
|
||||
def _create_netns(
|
||||
key: str,
|
||||
) -> None:
|
||||
'''
|
||||
Create one named netns through pyroute2's synchronous API.
|
||||
|
||||
'''
|
||||
try:
|
||||
from pyroute2 import netns
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
'Netns creation requires the `tractor[wg]` extra.'
|
||||
) from exc
|
||||
|
||||
netns.create(key)
|
||||
|
||||
|
||||
def _remove_netns(
|
||||
key: str,
|
||||
) -> None:
|
||||
'''
|
||||
Remove one named netns through pyroute2's synchronous API.
|
||||
|
||||
'''
|
||||
try:
|
||||
from pyroute2 import netns
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
'Netns removal requires the `tractor[wg]` extra.'
|
||||
) from exc
|
||||
|
||||
netns.remove(key)
|
||||
|
||||
|
||||
@acm
|
||||
async def open_netns(
|
||||
spec: BindspaceSpec,
|
||||
) -> AsyncIterator[Bindspace]:
|
||||
'''
|
||||
Create, pin and own one named Linux network namespace.
|
||||
|
||||
Creation and removal are shielded synchronous pyroute2 calls in a
|
||||
worker thread. This context never enters the namespace.
|
||||
Spawn-time bootstrap remains responsible for eventual `setns()`.
|
||||
|
||||
'''
|
||||
if sys.platform != 'linux':
|
||||
raise NotImplementedError(
|
||||
'Network namespace bindspaces are Linux-only!'
|
||||
)
|
||||
if spec.lifecycle != 'open':
|
||||
raise ValueError(
|
||||
'`open_netns()` requires lifecycle=`open`!'
|
||||
)
|
||||
|
||||
key: str|None = spec.key
|
||||
if key is CURRENT_NETNS:
|
||||
raise ValueError(
|
||||
'`open_netns()` requires a named `BindspaceSpec.key`!'
|
||||
)
|
||||
|
||||
created: bool = False
|
||||
try:
|
||||
with trio.CancelScope(shield=True):
|
||||
await trio.to_thread.run_sync(
|
||||
_create_netns,
|
||||
key,
|
||||
abandon_on_cancel=False,
|
||||
)
|
||||
created = True
|
||||
|
||||
async with _pin_netns(
|
||||
spec,
|
||||
ownership='owned',
|
||||
) as bindspace:
|
||||
yield bindspace
|
||||
finally:
|
||||
if created:
|
||||
with trio.CancelScope(shield=True):
|
||||
await trio.to_thread.run_sync(
|
||||
_remove_netns,
|
||||
key,
|
||||
abandon_on_cancel=False,
|
||||
)
|
||||
|
||||
|
||||
@acm
|
||||
async def open_bindspace(
|
||||
spec: BindspaceSpec,
|
||||
) -> AsyncIterator[Bindspace]:
|
||||
'''
|
||||
Dispatch one declared bindspace lifecycle.
|
||||
|
||||
Lifecycle is explicit serialized policy. It is never inferred
|
||||
from whether the eventual transport role is listen or dial.
|
||||
|
||||
'''
|
||||
if spec.lifecycle == 'attach':
|
||||
async with attach_netns(spec) as bindspace:
|
||||
yield bindspace
|
||||
else:
|
||||
async with open_netns(spec) as bindspace:
|
||||
yield bindspace
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue