Compare commits

...

3 Commits

Author SHA1 Message Date
Gud Boi d067505add Plan typed tunnel address decoding
Record native tagged encoding for complete `TunnelledAddress` graphs as
a deferred design follow-up.

Deats,
- cover concrete overlay-address and tunnel-spec unions
- preserve optional `BindspaceRef` metadata through decoding
- replace untyped payload inspection with typed roundtrip tests

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-27 19:14:34 -04:00
Gud Boi 33b349bf25 Retain realized `BindspaceRef` metadata
Add `TunnelledAddress.with_bindspace_ref()` to annotate frozen tunnel
declarations with stable, serializable namespace metadata.

Deats,
- omit absent refs from the existing msgspec shape
- reject declared and realized namespace-name mismatches
- report the declared key before realization and inode afterward
- keep transport peeling free of live bindspace capability state
- expose realized refs through real listener diagnostics

Prompt-IO: ai/prompt-io/opencode/20260826T030534Z_d130431c_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-27 19:14:25 -04:00
Gud Boi f538c4f5d1 Rename bindspace resource models
Replace the unshipped `BindspaceIdentity` and `BindspaceHandle` names
with `BindspaceRef` and `Bindspace` across existing lifecycle APIs.

Deats,
- define refs as wire-safe, host-local and non-owning records
- reserve `Bindspace` for the live FD-backed capability
- rename the capability's realized-resource field to `.ref`
- update lifecycle tests and active design contracts
- omit compatibility aliases for the unshipped model names

Prompt-IO: ai/prompt-io/opencode/20260827T211115Z_d130431c_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-27 17:22:28 -04:00
13 changed files with 465 additions and 169 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -115,9 +115,10 @@ Hard constraints learned from the existing two:
*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` and a scoped, non-serializable `BindspaceHandle`;
the latter owns namespace identity/FD/lifetime and is consumed at
spawn bootstrap before a concrete address reaches transport bind.
`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

View File

@ -378,7 +378,7 @@ Keep the authority surface deliberately small:
The root owns the manager's lifetime. `wgman` must outlive all
siblings borrowing its tunnels and exit before the root drops the
underlying namespace/capability handles. A manager crash fails closed:
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
@ -400,8 +400,8 @@ 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 two bindspace representations with deliberately different
lifetimes:
Keep three bindspace representations with deliberately different roles
and lifetimes:
```python
class BindspaceSpec(msgspec.Struct, frozen=True):
@ -411,17 +411,17 @@ class BindspaceSpec(msgspec.Struct, frozen=True):
lifecycle: Literal['attach', 'open']
class BindspaceIdentity(msgspec.Struct, frozen=True):
'''Stable identity of the realized platform resource.'''
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 # stable Linux namespace identity
inode: int # host-local Linux nsfs fingerprint
class BindspaceHandle(ProcessLocal):
class Bindspace(ProcessLocal):
'''Scoped, non-serializable capability for one live bindspace.'''
spec: BindspaceSpec
identity: BindspaceIdentity
ref: BindspaceRef
namespace_fd: int|None
ownership: Literal['owned', 'borrowed']
@ -429,7 +429,7 @@ class BindspaceHandle(ProcessLocal):
@acm
async def open_bindspace(
spec: BindspaceSpec,
) -> AsyncGenerator[BindspaceHandle, None]:
) -> AsyncGenerator[Bindspace, None]:
'''
Provision/borrow one bindspace and yield its live capability.
@ -437,15 +437,17 @@ async def open_bindspace(
```
The initial model limits `BindspaceKind` to `netns` while preserving
the required lifetime split. `BindspaceSpec` and
`BindspaceIdentity` are frozen msgspec structs which cross
config/spawn serialization. `BindspaceHandle` also 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 handle validates any supplied FD against
`BindspaceIdentity.inode`; explicit FD transfer belongs to the
supervisor bootstrap path. An FD avoids name-resolution TOCTOU,
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.
@ -459,7 +461,7 @@ 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 identity from the opened FD, yields
`/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
@ -474,7 +476,8 @@ 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 handle to replace an overlay while preserving every tunnel:
use the live bindspace to replace an overlay while preserving every
tunnel:
```python
async with open_bindspace(
@ -503,13 +506,13 @@ tunnel/bindspace layer:
@acm
async def open_netns(
spec: BindspaceSpec,
) -> AsyncGenerator[BindspaceHandle, None]: ...
) -> AsyncGenerator[Bindspace, None]: ...
@acm
async def open_wg_iface(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
bindspace: BindspaceHandle,
bindspace: Bindspace,
role: Literal['listen', 'dial'],
) -> AsyncGenerator[WGTunnelSpec, None]: ...
```
@ -542,7 +545,7 @@ entries. Extend it to carry the tunnel stack, not to *enter* it.
`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 `BindspaceHandle` for endpoint
`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.
@ -554,8 +557,12 @@ composed maddr can name a server source or client destination (§5.4).
### 5.3 `Address.namespace`, at last
- `TunnelledAddress.namespace``(kind, id)` e.g.
`('netns', 'tractor-wg0')`.
- 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.
@ -564,12 +571,19 @@ composed maddr can name a server source or client destination (§5.4).
`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)` identity
pair. Layer C should move that shape into `BindspaceIdentity`, avoid a
`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
`BindspaceHandle` to pin the identity.
`Bindspace` to pin the referenced resource.
### 5.4 the netns/process reality — read this before designing
@ -612,7 +626,7 @@ server bound in the old namespace.
- 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 `BindspaceHandle` context.
the parent/supervisor's `Bindspace` context.
- document the constraint rather than hiding it; a
`RuntimeError` if namespace entry is attempted after bootstrap.
- capabilities: iface/netns creation/config needs `CAP_NET_ADMIN`;
@ -641,7 +655,7 @@ server bound in the old namespace.
- 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 handle;
- 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
@ -713,7 +727,7 @@ consider doing it *first* for exactly that reason.
| 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 `BindspaceHandle`; borrowed handles never remove resources |
| 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 |

View File

@ -1,5 +1,5 @@
'''
Bindspace declaration, identity and live-capability contracts.
Bindspace declaration, reference and live-capability contracts.
'''
from __future__ import annotations
@ -14,9 +14,9 @@ import pytest
import trio
from tractor.discovery import (
BindspaceHandle,
BindspaceIdentity,
Bindspace,
BindspaceOwnership,
BindspaceRef,
BindspaceSpec,
CURRENT_NETNS,
attach_netns,
@ -29,15 +29,15 @@ from tractor.msg import ProcessLocal
def test_bindspace_declarations_roundtrip() -> None:
'''
Spawn configuration and realized identity must cross actor IPC.
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 identity survive
concrete types, proving names and stable inode refs survive
without carrying any process-local capability state.
'''
values: tuple[
BindspaceSpec|BindspaceIdentity,
BindspaceSpec|BindspaceRef,
...,
] = (
BindspaceSpec(
@ -45,16 +45,16 @@ def test_bindspace_declarations_roundtrip() -> None:
key='tractor-wg0',
lifecycle='open',
),
BindspaceIdentity(
BindspaceRef(
kind='netns',
key='tractor-wg0',
inode=1234,
),
)
value: BindspaceSpec|BindspaceIdentity
value: BindspaceSpec|BindspaceRef
for value in values:
encoded: bytes = msgspec.msgpack.encode(value)
decoded: BindspaceSpec|BindspaceIdentity = (
decoded: BindspaceSpec|BindspaceRef = (
msgspec.msgpack.decode(
encoded,
type=type(value),
@ -63,16 +63,17 @@ def test_bindspace_declarations_roundtrip() -> None:
assert decoded == value
def test_bindspace_handle_pins_local_capability(
def test_bindspace_pins_local_capability(
tmp_path: Path,
) -> None:
'''
A live handle pins one exact FD and realized identity.
A live bindspace pins one exact FD and realized ref.
Open a stand-in platform handle, record its inode in the realized
identity and construct an owned capability. Prove the generic
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 handle.
encode ordinary fields is not authority to transfer the
bindspace.
'''
token_path: Path = tmp_path / 'bindspace'
@ -86,36 +87,36 @@ def test_bindspace_handle_pins_local_capability(
key='tractor-wg0',
lifecycle='open',
)
identity: BindspaceIdentity = BindspaceIdentity(
ref: BindspaceRef = BindspaceRef(
kind='netns',
key='tractor-wg0',
inode=inode,
)
handle: BindspaceHandle = BindspaceHandle(
bindspace: Bindspace = Bindspace(
spec=spec,
identity=identity,
ref=ref,
namespace_fd=namespace_fd,
ownership='owned',
)
assert handle.spec is spec
assert handle.identity is identity
assert handle.namespace_fd == namespace_file.fileno()
assert handle.ownership == 'owned'
assert isinstance(handle, msgspec.Struct)
assert isinstance(handle, ProcessLocal)
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(handle)
msgspec.msgpack.encode(bindspace)
def test_bindspace_handle_rejects_mismatched_identity(
def test_bindspace_rejects_mismatched_ref(
tmp_path: Path,
) -> None:
'''
A name or inode mismatch would make a handle stale authority.
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
@ -128,39 +129,39 @@ def test_bindspace_handle_rejects_mismatched_identity(
kind='netns',
key='tractor-wg0',
)
# Keep ownership and FD fixed so only identity changes below.
# 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: BindspaceIdentity = BindspaceIdentity(
wrong_name: BindspaceRef = BindspaceRef(
kind='netns',
key='other-wg',
inode=token_path.stat().st_ino,
)
with pytest.raises(
ValueError,
match='Spec.key.*Identity.key',
match='Spec.key.*Ref.key',
):
BindspaceHandle(
Bindspace(
spec=spec,
identity=wrong_name,
ref=wrong_name,
namespace_fd=namespace_fd,
ownership=ownership,
)
wrong_inode: BindspaceIdentity = BindspaceIdentity(
wrong_inode: BindspaceRef = BindspaceRef(
kind='netns',
key='tractor-wg0',
inode=token_path.stat().st_ino + 1,
)
with pytest.raises(
ValueError,
match='FD inode.*identity inode',
match='FD inode.*reference inode',
):
BindspaceHandle(
Bindspace(
spec=spec,
identity=wrong_inode,
ref=wrong_inode,
namespace_fd=namespace_fd,
ownership=ownership,
)
@ -170,14 +171,14 @@ def test_bindspace_handle_rejects_mismatched_identity(
('model', 'kwargs', 'match'),
(
pytest.param(
BindspaceIdentity,
BindspaceRef,
{
'kind': 'netns',
'key': None,
'inode': None,
},
'must be a positive `int`',
id='identity-requires-inode',
id='ref-requires-inode',
),
pytest.param(
BindspaceSpec,
@ -186,14 +187,14 @@ def test_bindspace_handle_rejects_mismatched_identity(
id='spec-rejects-kind',
),
pytest.param(
BindspaceIdentity,
BindspaceRef,
{
'kind': 'vrf',
'key': 'blue',
'inode': 1234,
},
'Unsupported bindspace kind',
id='identity-rejects-kind',
id='ref-rejects-kind',
),
pytest.param(
BindspaceSpec,
@ -224,19 +225,19 @@ def test_bindspace_handle_rejects_mismatched_identity(
id='spec-rejects-lifecycle',
),
pytest.param(
BindspaceIdentity,
BindspaceRef,
{
'kind': 'netns',
'key': '',
'inode': 1234,
},
'BindspaceIdentity.key',
id='identity-rejects-empty-key',
'BindspaceRef.key',
id='ref-rejects-empty-key',
),
),
)
def test_bindspace_models_reject_invalid_values(
model: type[BindspaceSpec]|type[BindspaceIdentity],
model: type[BindspaceSpec]|type[BindspaceRef],
kwargs: dict[str, object],
match: str,
) -> None:
@ -245,7 +246,7 @@ def test_bindspace_models_reject_invalid_values(
Parameterize the missing stable inode and future, unimplemented
kinds. Prove neither serializable model can carry invalid
identity or provisioning instructions into spawn configuration.
refs or provisioning instructions into spawn configuration.
'''
with pytest.raises(ValueError, match=match):
@ -260,7 +261,7 @@ def test_open_bindspace_attaches_current_netns() -> None:
'''
The unnamed spec must borrow and pin the caller's current netns.
Open `/proc/self/ns/net`, prove the yielded handle records its
Open `/proc/self/ns/net`, prove the yielded bindspace records its
stable inode and borrowed ownership, then exit the context and
prove the exact descriptor was closed without altering the
namespace itself.
@ -275,15 +276,15 @@ def test_open_bindspace_attaches_current_netns() -> None:
kind='netns',
)
assert spec.key is CURRENT_NETNS
async with open_bindspace(spec) as handle:
namespace_fd: int|None = handle.namespace_fd
async with open_bindspace(spec) as bindspace:
namespace_fd: int|None = bindspace.namespace_fd
assert namespace_fd is not None
assert handle.spec is spec
assert handle.identity.key is None
assert handle.identity.inode == os.fstat(
assert bindspace.spec is spec
assert bindspace.ref.key is None
assert bindspace.ref.inode == os.fstat(
namespace_fd
).st_ino
assert handle.ownership == 'borrowed'
assert bindspace.ownership == 'borrowed'
return namespace_fd
namespace_fd: int = trio.run(main)
@ -324,12 +325,12 @@ def test_attach_named_netns_uses_run_directory(
kind='netns',
key='tractor-wg0',
)
async with attach_netns(spec) as handle:
namespace_fd: int|None = handle.namespace_fd
async with attach_netns(spec) as bindspace:
namespace_fd: int|None = bindspace.namespace_fd
assert namespace_fd is not None
assert handle.identity.key == 'tractor-wg0'
assert handle.identity.inode == netns_path.stat().st_ino
assert handle.ownership == 'borrowed'
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)
@ -389,7 +390,7 @@ def test_open_netns_owns_lifecycle(
Successful creation must yield ownership and remove on exit.
Fake pyroute2 creation with a named stand-in file, verify the
yielded FD and identity while it exists, then prove FD closure
yielded FD and ref while it exists, then prove FD closure
precedes resource removal when the context exits.
'''
@ -444,11 +445,11 @@ def test_open_netns_owns_lifecycle(
key='tractor-wg0',
lifecycle='open',
)
async with open_bindspace(spec) as handle:
fd: int|None = handle.namespace_fd
async with open_bindspace(spec) as bindspace:
fd: int|None = bindspace.namespace_fd
assert fd is not None
assert handle.ownership == 'owned'
assert handle.identity.inode == os.fstat(fd).st_ino
assert bindspace.ownership == 'owned'
assert bindspace.ref.inode == os.fstat(fd).st_ino
namespace_fds.append(fd)
events.append('yield')

View File

@ -15,6 +15,7 @@ import msgspec
import pytest
from tractor.discovery import (
BindspaceRef,
TunnelledAddress,
WGTunnelSpec,
mb_pubkey,
@ -196,6 +197,71 @@ def test_namespace_comes_from_the_tunnel(
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,

View File

@ -14,8 +14,8 @@ import pytest
import trio
from tractor.discovery import (
BindspaceHandle,
BindspaceIdentity,
Bindspace,
BindspaceRef,
BindspaceSpec,
WGInterfaceConfig,
WGPeerConfig,
@ -159,7 +159,7 @@ def test_open_wg_iface_shields_cancelled_cleanup(
def create(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
bindspace: BindspaceHandle,
bindspace: Bindspace,
listen_port: int|None,
peers: tuple[dict[str, object], ...],
) -> None:
@ -174,7 +174,7 @@ def test_open_wg_iface_shields_cancelled_cleanup(
def remove(
spec: WGTunnelSpec,
bindspace: BindspaceHandle,
bindspace: Bindspace,
) -> None:
'''
Record shielded removal after cancellation.
@ -200,9 +200,9 @@ def test_open_wg_iface_shields_cancelled_cleanup(
kind='netns',
key='tractor-wg0',
)
bindspace: BindspaceHandle = BindspaceHandle(
bindspace: Bindspace = Bindspace(
spec=bindspace_spec,
identity=BindspaceIdentity(
ref=BindspaceRef(
kind='netns',
key='tractor-wg0',
inode=os.fstat(namespace_fd).st_ino,
@ -249,8 +249,8 @@ def test_open_wg_bindspace_nests_resource_lifetimes(
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 the
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.
@ -261,16 +261,16 @@ def test_open_wg_bindspace_nests_resource_lifetimes(
tuple[
WGTunnelSpec,
WGInterfaceConfig,
BindspaceHandle,
Bindspace,
_tunnel.WGRole,
]
] = []
bindspace_spec: BindspaceSpec = BindspaceSpec(
kind='netns',
)
bindspace: BindspaceHandle = BindspaceHandle(
bindspace: Bindspace = Bindspace(
spec=bindspace_spec,
identity=BindspaceIdentity(
ref=BindspaceRef(
kind='netns',
key=None,
inode=1,
@ -302,7 +302,7 @@ def test_open_wg_bindspace_nests_resource_lifetimes(
@acm
async def fake_open_bindspace(
spec: BindspaceSpec,
) -> AsyncIterator[BindspaceHandle]:
) -> AsyncIterator[Bindspace]:
'''
Yield the stand-in bindspace and record its full lifetime.
@ -319,19 +319,19 @@ def test_open_wg_bindspace_nests_resource_lifetimes(
async def fake_open_wg_iface(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
handle: BindspaceHandle,
bindspace_arg: Bindspace,
role: _tunnel.WGRole,
) -> AsyncIterator[WGTunnelSpec]:
'''
Record one interface's arguments and nested lifetime.
'''
calls.append((spec, config, handle, role))
calls.append((spec, config, bindspace_arg, role))
events.append(f'{spec.iface}-enter')
try:
yield spec
finally:
assert handle is bindspace
assert bindspace_arg is bindspace
events.append(f'{spec.iface}-exit')
monkeypatch.setattr(
@ -355,8 +355,8 @@ def test_open_wg_bindspace_nests_resource_lifetimes(
bindspace_spec,
layers,
'dial',
) as handle:
assert handle is bindspace
) as opened_bindspace:
assert opened_bindspace is bindspace
events.append('yield')
scope.cancel()
await trio.sleep_forever()

View File

@ -7,6 +7,7 @@ from __future__ import annotations
import trio
from tractor.discovery import (
BindspaceRef,
TunnelledAddress,
WGTunnelSpec,
tunnels_of,
@ -25,8 +26,9 @@ def test_server_peels_before_endpoint_construction() -> None:
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 tunnel namespace for diagnostics and the future
bindspace lifecycle.
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)
@ -38,11 +40,19 @@ def test_server_peels_before_endpoint_construction() -> None:
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=[tunnelled],
accept_addrs=[declared],
)
assert len(eps) == 1
endpoint = eps[0]
@ -51,9 +61,9 @@ def test_server_peels_before_endpoint_construction() -> None:
_, host, port = endpoint.addr.unwrap()
assert host == overlay.unwrap()[1]
assert port > 0
assert endpoint.addr is not tunnelled
assert endpoint.declared_addr is tunnelled
namespace: tuple[str, str] = ('netns', 'actor-net')
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()
@ -61,9 +71,9 @@ def test_server_peels_before_endpoint_construction() -> None:
assert expected_namespace in endpoint_repr
assert ' |_namespaces:' in server_repr
assert 'netns' in server_repr
assert 'actor-net' in server_repr
assert tunnels_of(tunnelled) == (
tunnelled.tunnel,
assert '1234' in server_repr
assert tunnels_of(declared) == (
declared.tunnel,
)
server.cancel()

View File

@ -25,11 +25,11 @@ here to avoid circular imports; use direct module paths for those.
'''
from ._bindspace import (
BindspaceHandle as BindspaceHandle,
BindspaceIdentity as BindspaceIdentity,
Bindspace as Bindspace,
BindspaceKind as BindspaceKind,
BindspaceLifecycle as BindspaceLifecycle,
BindspaceOwnership as BindspaceOwnership,
BindspaceRef as BindspaceRef,
BindspaceSpec as BindspaceSpec,
CURRENT_NETNS as CURRENT_NETNS,
attach_netns as attach_netns,

View File

@ -15,7 +15,7 @@
# License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
'''
Serializable bindspace declarations and live capability handles.
Serializable bindspace declarations and live capabilities.
'''
from __future__ import annotations
@ -91,7 +91,7 @@ def _validate_bindspace_key(
Reject empty or path-like platform-resource names.
`None` is valid. Spell it `CURRENT_NETNS` for
`BindspaceSpec.key`; `BindspaceIdentity.key = None` records an
`BindspaceSpec.key`; `BindspaceRef.key = None` records an
unnamed realized netns.
'''
@ -145,16 +145,17 @@ class BindspaceSpec(
)
class BindspaceIdentity(
class BindspaceRef(
msgspec.Struct,
frozen=True,
):
'''
Serializable stable identity of one realized bindspace.
Serializable, non-owning ref to one realized bindspace.
`.key` is an optional, mutable namespace name. `.inode` is the
required kernel identity which remains stable after rename or
unlink.
`.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
@ -163,14 +164,14 @@ class BindspaceIdentity(
def __post_init__(self) -> None:
'''
Require a stable platform identity and an optional name.
Require a host-local resource inode and an optional locator.
'''
_validate_bindspace_kind(self.kind)
_validate_bindspace_key(
self.kind,
self.key,
'BindspaceIdentity.key',
'BindspaceRef.key',
)
if (
type(self.inode) is not int
@ -178,23 +179,23 @@ class BindspaceIdentity(
self.inode <= 0
):
raise ValueError(
'`BindspaceIdentity.inode` must be a positive `int`!'
'`BindspaceRef.inode` must be a positive `int`!'
)
class BindspaceHandle(
class Bindspace(
ProcessLocal,
):
'''
Process-local capability for one live realized bindspace.
`ProcessLocal` provides compact typed storage plus a default
wire-encoding guard. Explicit FD transfer and handle construction
belong to the supervisor's spawn/bootstrap path.
wire-encoding guard. `Bindspace` construction and explicit FD
transfer belong to the supervisor's spawn/bootstrap path.
'''
spec: BindspaceSpec
identity: BindspaceIdentity
ref: BindspaceRef
namespace_fd: int|None
ownership: BindspaceOwnership
@ -204,23 +205,23 @@ class BindspaceHandle(
'''
spec: BindspaceSpec = self.spec
identity: BindspaceIdentity = self.identity
ref: BindspaceRef = self.ref
namespace_fd: int|None = self.namespace_fd
ownership: BindspaceOwnership = self.ownership
if spec.kind != identity.kind:
if spec.kind != ref.kind:
raise ValueError(
'`BindspaceSpec.kind` does not match '
'`BindspaceIdentity.kind`!'
'`BindspaceRef.kind`!'
)
if (
spec.key is not None
and
spec.key != identity.key
spec.key != ref.key
):
raise ValueError(
'`BindspaceSpec.key` does not match '
'`BindspaceIdentity.key`!'
'`BindspaceRef.key`!'
)
if ownership not in get_args(BindspaceOwnership):
raise ValueError(
@ -246,20 +247,20 @@ class BindspaceHandle(
'`namespace_fd` must be non-negative or `None`!'
)
fd_inode: int = os.fstat(namespace_fd).st_ino
if identity.inode != fd_inode:
if ref.inode != fd_inode:
raise ValueError(
f'Namespace FD inode {fd_inode} does not match '
f'identity inode {identity.inode}!'
f'reference inode {ref.inode}!'
)
def __repr__(self) -> str:
'''
Render capability identity without dereferencing its FD.
Render the capability ref without dereferencing its FD.
'''
return (
f'{type(self).__name__}('
f'identity={self.identity!r}, '
f'ref={self.ref!r}, '
f'ownership={self.ownership!r}, '
f'namespace_fd={self.namespace_fd!r})'
)
@ -269,7 +270,7 @@ class BindspaceHandle(
async def _pin_netns(
spec: BindspaceSpec,
ownership: BindspaceOwnership,
) -> AsyncIterator[BindspaceHandle]:
) -> AsyncIterator[Bindspace]:
'''
Pin one existing Linux network namespace with explicit ownership.
@ -286,18 +287,18 @@ async def _pin_netns(
)
try:
inode: int = os.fstat(namespace_fd).st_ino
identity: BindspaceIdentity = BindspaceIdentity(
ref: BindspaceRef = BindspaceRef(
kind='netns',
key=key,
inode=inode,
)
handle: BindspaceHandle = BindspaceHandle(
bindspace: Bindspace = Bindspace(
spec=spec,
identity=identity,
ref=ref,
namespace_fd=namespace_fd,
ownership=ownership,
)
yield handle
yield bindspace
finally:
os.close(namespace_fd)
@ -305,7 +306,7 @@ async def _pin_netns(
@acm
async def attach_netns(
spec: BindspaceSpec,
) -> AsyncIterator[BindspaceHandle]:
) -> AsyncIterator[Bindspace]:
'''
Borrow and pin one existing Linux network namespace.
@ -326,8 +327,8 @@ async def attach_netns(
async with _pin_netns(
spec,
ownership='borrowed',
) as handle:
yield handle
) as bindspace:
yield bindspace
def _create_netns(
@ -367,7 +368,7 @@ def _remove_netns(
@acm
async def open_netns(
spec: BindspaceSpec,
) -> AsyncIterator[BindspaceHandle]:
) -> AsyncIterator[Bindspace]:
'''
Create, pin and own one named Linux network namespace.
@ -404,8 +405,8 @@ async def open_netns(
async with _pin_netns(
spec,
ownership='owned',
) as handle:
yield handle
) as bindspace:
yield bindspace
finally:
if created:
with trio.CancelScope(shield=True):
@ -419,7 +420,7 @@ async def open_netns(
@acm
async def open_bindspace(
spec: BindspaceSpec,
) -> AsyncIterator[BindspaceHandle]:
) -> AsyncIterator[Bindspace]:
'''
Dispatch one declared bindspace lifecycle.
@ -428,8 +429,8 @@ async def open_bindspace(
'''
if spec.lifecycle == 'attach':
async with attach_netns(spec) as handle:
yield handle
async with attach_netns(spec) as bindspace:
yield bindspace
else:
async with open_netns(spec) as handle:
yield handle
async with open_netns(spec) as bindspace:
yield bindspace

View File

@ -96,7 +96,8 @@ import trio
from ..msg._local import ProcessLocal
from ._bindspace import (
BindspaceHandle,
Bindspace,
BindspaceRef,
BindspaceSpec,
open_bindspace,
)
@ -460,7 +461,7 @@ def _wg_iface_settings(
def _sync_create_wg_iface(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
bindspace: BindspaceHandle,
bindspace: Bindspace,
listen_port: int|None,
peers: tuple[dict[str, object], ...],
) -> None:
@ -556,7 +557,7 @@ def _sync_create_wg_iface(
def _sync_remove_wg_iface(
spec: WGTunnelSpec,
bindspace: BindspaceHandle,
bindspace: Bindspace,
) -> None:
'''
Remove one owned WireGuard iface when it still exists.
@ -590,7 +591,7 @@ def _sync_remove_wg_iface(
async def open_wg_iface(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
bindspace: BindspaceHandle,
bindspace: Bindspace,
role: WGRole,
) -> AsyncIterator[WGTunnelSpec]:
'''
@ -634,7 +635,7 @@ async def open_wg_bindspace(
bindspace_spec: BindspaceSpec,
layers: Sequence[tuple[WGTunnelSpec, WGInterfaceConfig]],
role: WGRole,
) -> AsyncIterator[BindspaceHandle]:
) -> AsyncIterator[Bindspace]:
'''
Open one bindspace and its ordered WireGuard interface stack.
@ -659,7 +660,7 @@ async def open_wg_bindspace(
...,
] = tuple(layers)
async with AsyncExitStack() as stack:
bindspace: BindspaceHandle = await (
bindspace: Bindspace = await (
stack.enter_async_context(
open_bindspace(bindspace_spec)
)
@ -858,6 +859,7 @@ def _wg_proto_code() -> int:
class TunnelledAddress(
msgspec.Struct,
frozen=True,
omit_defaults=True,
):
'''
An `Address` annotated with the tunnel it must be reached
@ -872,6 +874,32 @@ class TunnelledAddress(
'''
overlay: Address|TunnelledAddress
tunnel: TunnelSpec
bindspace_ref: BindspaceRef|None = None
def __post_init__(self) -> None:
'''
Validate the retained ref against the tunnel declaration.
'''
ref: BindspaceRef|None = self.bindspace_ref
if ref is None:
return
if not isinstance(ref, BindspaceRef):
raise TypeError(
'`TunnelledAddress.bindspace_ref` must be a '
'`BindspaceRef` or `None`!'
)
declared_netns: str|None = self.tunnel.netns
if (
declared_netns is not None
and
ref.key != declared_netns
):
raise ValueError(
f'Declared netns {declared_netns!r} does not match '
f'realized bindspace key {ref.key!r}!'
)
# ---- delegated, so the runtime can't tell the difference ----
@ -911,14 +939,35 @@ class TunnelledAddress(
@property
def namespace(self) -> tuple[str, str|int]|None:
'''
The tunnel's netns, when it declares one.
Return the realized ref or declared tunnel netns.
'''
ref: BindspaceRef|None = self.bindspace_ref
if ref is not None:
return (
ref.kind,
ref.inode,
)
if (netns := self.tunnel.netns) is None:
return self.overlay.namespace
return ('netns', netns)
def with_bindspace_ref(
self,
ref: BindspaceRef,
) -> TunnelledAddress:
'''
Return a copy retaining one realized bindspace ref.
'''
realized: TunnelledAddress = msgspec.structs.replace(
self,
bindspace_ref=ref,
)
return realized
def __repr__(self) -> str:
return (
f'{type(self).__name__}(\n'