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`))
Gud Boi 2026-08-27 17:22:28 -04:00
parent cdd5591428
commit 932722b325
9 changed files with 232 additions and 157 deletions

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 *ALPN + relay/discovery realm* (plan 02). Do not overload this
transport-level bind selector with process namespace lifecycle. transport-level bind selector with process namespace lifecycle.
Plan 03 augments an maddr/address declaration with a serializable Plan 03 augments an maddr/address declaration with a serializable
`BindspaceSpec` and a scoped, non-serializable `BindspaceHandle`; `BindspaceSpec` request and host-local `BindspaceRef`. A scoped,
the latter owns namespace identity/FD/lifetime and is consumed at non-serializable `Bindspace` carries that ref and owns the FD/lifetime
spawn bootstrap before a concrete address reaches transport bind. used during spawn bootstrap before a concrete address reaches
transport bind.
`Address.namespace` is already spec'd in the Protocol as `Address.namespace` is already spec'd in the Protocol as
"the if-available OS-specific network namespace key" and is "the if-available OS-specific network namespace key" and is
currently unimplemented by both backends — plan 03 is the 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 The root owns the manager's lifetime. `wgman` must outlive all
siblings borrowing its tunnels and exit before the root drops the 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 dependent operations receive an explicit service error; restart, if
enabled, reconciles declared state idempotently before advertising enabled, reconciles declared state idempotently before advertising
readiness again. Do not silently let siblings fall back to privileged 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 interface, user namespace, or equivalent platform resource is
orthogonal augmentation carried alongside/below the maddr. orthogonal augmentation carried alongside/below the maddr.
Keep two bindspace representations with deliberately different Keep three bindspace representations with deliberately different roles
lifetimes: and lifetimes:
```python ```python
class BindspaceSpec(msgspec.Struct, frozen=True): class BindspaceSpec(msgspec.Struct, frozen=True):
@ -411,17 +411,17 @@ class BindspaceSpec(msgspec.Struct, frozen=True):
lifecycle: Literal['attach', 'open'] lifecycle: Literal['attach', 'open']
class BindspaceIdentity(msgspec.Struct, frozen=True): class BindspaceRef(msgspec.Struct, frozen=True):
'''Stable identity of the realized platform resource.''' '''Wire-safe, non-owning ref to the realized resource.'''
kind: str kind: str
key: str|None # mutable name, absent after unlink 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.''' '''Scoped, non-serializable capability for one live bindspace.'''
spec: BindspaceSpec spec: BindspaceSpec
identity: BindspaceIdentity ref: BindspaceRef
namespace_fd: int|None namespace_fd: int|None
ownership: Literal['owned', 'borrowed'] ownership: Literal['owned', 'borrowed']
@ -429,7 +429,7 @@ class BindspaceHandle(ProcessLocal):
@acm @acm
async def open_bindspace( async def open_bindspace(
spec: BindspaceSpec, spec: BindspaceSpec,
) -> AsyncGenerator[BindspaceHandle, None]: ) -> AsyncGenerator[Bindspace, None]:
''' '''
Provision/borrow one bindspace and yield its live capability. 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 initial model limits `BindspaceKind` to `netns` while preserving
the required lifetime split. `BindspaceSpec` and the required role split. `BindspaceSpec` is the requested resource and
`BindspaceIdentity` are frozen msgspec structs which cross lifecycle policy. `BindspaceRef` is a serializable, non-owning,
config/spawn serialization. `BindspaceHandle` also uses msgspec's host-local record of the resource that was actually opened; it can be
generic struct storage by inheriting the global compared or logged, but cannot reopen, pin or enter that resource.
`tractor.msg.ProcessLocal` marker. Its hidden unsupported sentinel `Bindspace` is the live capability and uses msgspec's generic struct
blocks direct and nested default msgspec encoding without a recursive storage by inheriting the global `tractor.msg.ProcessLocal` marker. Its
IPC hot-path scan. The handle validates any supplied FD against hidden unsupported sentinel blocks direct and nested default msgspec
`BindspaceIdentity.inode`; explicit FD transfer belongs to the encoding without a recursive IPC hot-path scan. The live bindspace
supervisor bootstrap path. An FD avoids name-resolution TOCTOU, 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 survives rename/unlink, and identifies the exact namespace the parent
provisioned. Extend the kind/field union only when a second platform provisioned. Extend the kind/field union only when a second platform
resource is implemented. resource is implemented.
@ -459,7 +461,7 @@ or locally owned networking.
The first lifecycle implementation is deliberately borrow-only: The first lifecycle implementation is deliberately borrow-only:
`attach_netns()` opens either `/proc/self/ns/net` when `attach_netns()` opens either `/proc/self/ns/net` when
`BindspaceSpec.key = CURRENT_NETNS`, or a named entry beneath `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 `ownership='borrowed'`, and closes only that FD on exit. "Attach" does
not call `setns()`; it never creates, enters or removes a namespace. not call `setns()`; it never creates, enters or removes a namespace.
Future `open_netns()` creation and owned teardown remain a separate 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 `open_bindspace()` is **not** an address factory and does not return a
`TunnelledAddress`. At the declaration layer, listener allocation can `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 ```python
async with open_bindspace( async with open_bindspace(
@ -503,13 +506,13 @@ tunnel/bindspace layer:
@acm @acm
async def open_netns( async def open_netns(
spec: BindspaceSpec, spec: BindspaceSpec,
) -> AsyncGenerator[BindspaceHandle, None]: ... ) -> AsyncGenerator[Bindspace, None]: ...
@acm @acm
async def open_wg_iface( async def open_wg_iface(
spec: WGTunnelSpec, spec: WGTunnelSpec,
config: WGInterfaceConfig, config: WGInterfaceConfig,
bindspace: BindspaceHandle, bindspace: Bindspace,
role: Literal['listen', 'dial'], role: Literal['listen', 'dial'],
) -> AsyncGenerator[WGTunnelSpec, None]: ... ) -> 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 `open_wg_bindspace()` is the initial driver for one bindspace and an
ordered sequence of `(WGTunnelSpec, WGInterfaceConfig)` layers. It ordered sequence of `(WGTunnelSpec, WGInterfaceConfig)` layers. It
opens the bindspace first, enters WG interfaces outermost-first through 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 allocation. Exit is inside-out, so every interface is removed while the
namespace FD remains pinned; only then can an owned namespace be namespace FD remains pinned; only then can an owned namespace be
removed. Endpoint/channel lifetimes belong inside the yielded scope. removed. Endpoint/channel lifetimes belong inside the yielded scope.
@ -566,10 +569,10 @@ composed maddr can name a server source or client destination (§5.4).
Use `github/ns_aware@e4688cad` as prototype evidence, not code to Use `github/ns_aware@e4688cad` as prototype evidence, not code to
cherry-pick unchanged. Its `/proc/<pid>/ns/<type>` inode reader and cherry-pick unchanged. Its `/proc/<pid>/ns/<type>` inode reader and
`ip netns identify` probe establish the useful `(key, inode)` identity `ip netns identify` probe establish the useful `(key, inode)` reference
pair. Layer C should move that shape into `BindspaceIdentity`, avoid a record. Layer C should move that shape into `BindspaceRef`, avoid a
subprocess where netlink/procfs suffices, and hold the namespace FD in 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 ### 5.4 the netns/process reality — read this before designing
@ -612,7 +615,7 @@ server bound in the old namespace.
- a root/single-actor process follows the same ordering: enter during - a root/single-actor process follows the same ordering: enter during
root bootstrap, never after actor runtime startup. root bootstrap, never after actor runtime startup.
- iface/route/WG provisioning is genuinely scoped and remains under - 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 - document the constraint rather than hiding it; a
`RuntimeError` if namespace entry is attempted after bootstrap. `RuntimeError` if namespace entry is attempted after bootstrap.
- capabilities: iface/netns creation/config needs `CAP_NET_ADMIN`; - capabilities: iface/netns creation/config needs `CAP_NET_ADMIN`;
@ -641,7 +644,7 @@ server bound in the old namespace.
- teardown follows capability ownership, not just address type: - teardown follows capability ownership, not just address type:
- owned listener bindspaces tear down after endpoints/channels and - owned listener bindspaces tear down after endpoints/channels and
the actor process have exited; 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 - nested resources exit inside-out, but shared resources remain until
their owning supervisor drops the final capability. their owning supervisor drops the final capability.
- teardown must be idempotent and tolerant: an iface/netns - teardown must be idempotent and tolerant: an iface/netns
@ -713,7 +716,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 | | 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 | | 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 | | 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 | | 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 | | `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 | | 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 from __future__ import annotations
@ -14,9 +14,9 @@ import pytest
import trio import trio
from tractor.discovery import ( from tractor.discovery import (
BindspaceHandle, Bindspace,
BindspaceIdentity,
BindspaceOwnership, BindspaceOwnership,
BindspaceRef,
BindspaceSpec, BindspaceSpec,
CURRENT_NETNS, CURRENT_NETNS,
attach_netns, attach_netns,
@ -29,15 +29,15 @@ from tractor.msg import ProcessLocal
def test_bindspace_declarations_roundtrip() -> None: 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 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. without carrying any process-local capability state.
''' '''
values: tuple[ values: tuple[
BindspaceSpec|BindspaceIdentity, BindspaceSpec|BindspaceRef,
..., ...,
] = ( ] = (
BindspaceSpec( BindspaceSpec(
@ -45,16 +45,16 @@ def test_bindspace_declarations_roundtrip() -> None:
key='tractor-wg0', key='tractor-wg0',
lifecycle='open', lifecycle='open',
), ),
BindspaceIdentity( BindspaceRef(
kind='netns', kind='netns',
key='tractor-wg0', key='tractor-wg0',
inode=1234, inode=1234,
), ),
) )
value: BindspaceSpec|BindspaceIdentity value: BindspaceSpec|BindspaceRef
for value in values: for value in values:
encoded: bytes = msgspec.msgpack.encode(value) encoded: bytes = msgspec.msgpack.encode(value)
decoded: BindspaceSpec|BindspaceIdentity = ( decoded: BindspaceSpec|BindspaceRef = (
msgspec.msgpack.decode( msgspec.msgpack.decode(
encoded, encoded,
type=type(value), type=type(value),
@ -63,16 +63,17 @@ def test_bindspace_declarations_roundtrip() -> None:
assert decoded == value assert decoded == value
def test_bindspace_handle_pins_local_capability( def test_bindspace_pins_local_capability(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> 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 Open a stand-in platform FD, record its inode in the realized
identity and construct an owned capability. Prove the generic ref and construct an owned capability. Prove the generic
msgspec struct retains that exact local state. Its ability to 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' token_path: Path = tmp_path / 'bindspace'
@ -86,36 +87,36 @@ def test_bindspace_handle_pins_local_capability(
key='tractor-wg0', key='tractor-wg0',
lifecycle='open', lifecycle='open',
) )
identity: BindspaceIdentity = BindspaceIdentity( ref: BindspaceRef = BindspaceRef(
kind='netns', kind='netns',
key='tractor-wg0', key='tractor-wg0',
inode=inode, inode=inode,
) )
handle: BindspaceHandle = BindspaceHandle( bindspace: Bindspace = Bindspace(
spec=spec, spec=spec,
identity=identity, ref=ref,
namespace_fd=namespace_fd, namespace_fd=namespace_fd,
ownership='owned', ownership='owned',
) )
assert handle.spec is spec assert bindspace.spec is spec
assert handle.identity is identity assert bindspace.ref is ref
assert handle.namespace_fd == namespace_file.fileno() assert bindspace.namespace_fd == namespace_file.fileno()
assert handle.ownership == 'owned' assert bindspace.ownership == 'owned'
assert isinstance(handle, msgspec.Struct) assert isinstance(bindspace, msgspec.Struct)
assert isinstance(handle, ProcessLocal) assert isinstance(bindspace, ProcessLocal)
with pytest.raises( with pytest.raises(
TypeError, TypeError,
match='_ProcessLocalToken.*unsupported', 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, tmp_path: Path,
) -> None: ) -> 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 Construct a requested named spec, then prove both a different
realized name and an inode not belonging to the supplied FD are 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', kind='netns',
key='tractor-wg0', 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' ownership: BindspaceOwnership = 'borrowed'
namespace_file: BinaryIO namespace_file: BinaryIO
with token_path.open('rb') as namespace_file: with token_path.open('rb') as namespace_file:
namespace_fd: int = namespace_file.fileno() namespace_fd: int = namespace_file.fileno()
wrong_name: BindspaceIdentity = BindspaceIdentity( wrong_name: BindspaceRef = BindspaceRef(
kind='netns', kind='netns',
key='other-wg', key='other-wg',
inode=token_path.stat().st_ino, inode=token_path.stat().st_ino,
) )
with pytest.raises( with pytest.raises(
ValueError, ValueError,
match='Spec.key.*Identity.key', match='Spec.key.*Ref.key',
): ):
BindspaceHandle( Bindspace(
spec=spec, spec=spec,
identity=wrong_name, ref=wrong_name,
namespace_fd=namespace_fd, namespace_fd=namespace_fd,
ownership=ownership, ownership=ownership,
) )
wrong_inode: BindspaceIdentity = BindspaceIdentity( wrong_inode: BindspaceRef = BindspaceRef(
kind='netns', kind='netns',
key='tractor-wg0', key='tractor-wg0',
inode=token_path.stat().st_ino + 1, inode=token_path.stat().st_ino + 1,
) )
with pytest.raises( with pytest.raises(
ValueError, ValueError,
match='FD inode.*identity inode', match='FD inode.*reference inode',
): ):
BindspaceHandle( Bindspace(
spec=spec, spec=spec,
identity=wrong_inode, ref=wrong_inode,
namespace_fd=namespace_fd, namespace_fd=namespace_fd,
ownership=ownership, ownership=ownership,
) )
@ -170,14 +171,14 @@ def test_bindspace_handle_rejects_mismatched_identity(
('model', 'kwargs', 'match'), ('model', 'kwargs', 'match'),
( (
pytest.param( pytest.param(
BindspaceIdentity, BindspaceRef,
{ {
'kind': 'netns', 'kind': 'netns',
'key': None, 'key': None,
'inode': None, 'inode': None,
}, },
'must be a positive `int`', 'must be a positive `int`',
id='identity-requires-inode', id='ref-requires-inode',
), ),
pytest.param( pytest.param(
BindspaceSpec, BindspaceSpec,
@ -186,14 +187,14 @@ def test_bindspace_handle_rejects_mismatched_identity(
id='spec-rejects-kind', id='spec-rejects-kind',
), ),
pytest.param( pytest.param(
BindspaceIdentity, BindspaceRef,
{ {
'kind': 'vrf', 'kind': 'vrf',
'key': 'blue', 'key': 'blue',
'inode': 1234, 'inode': 1234,
}, },
'Unsupported bindspace kind', 'Unsupported bindspace kind',
id='identity-rejects-kind', id='ref-rejects-kind',
), ),
pytest.param( pytest.param(
BindspaceSpec, BindspaceSpec,
@ -224,19 +225,19 @@ def test_bindspace_handle_rejects_mismatched_identity(
id='spec-rejects-lifecycle', id='spec-rejects-lifecycle',
), ),
pytest.param( pytest.param(
BindspaceIdentity, BindspaceRef,
{ {
'kind': 'netns', 'kind': 'netns',
'key': '', 'key': '',
'inode': 1234, 'inode': 1234,
}, },
'BindspaceIdentity.key', 'BindspaceRef.key',
id='identity-rejects-empty-key', id='ref-rejects-empty-key',
), ),
), ),
) )
def test_bindspace_models_reject_invalid_values( def test_bindspace_models_reject_invalid_values(
model: type[BindspaceSpec]|type[BindspaceIdentity], model: type[BindspaceSpec]|type[BindspaceRef],
kwargs: dict[str, object], kwargs: dict[str, object],
match: str, match: str,
) -> None: ) -> None:
@ -245,7 +246,7 @@ def test_bindspace_models_reject_invalid_values(
Parameterize the missing stable inode and future, unimplemented Parameterize the missing stable inode and future, unimplemented
kinds. Prove neither serializable model can carry invalid 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): 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. 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 stable inode and borrowed ownership, then exit the context and
prove the exact descriptor was closed without altering the prove the exact descriptor was closed without altering the
namespace itself. namespace itself.
@ -275,15 +276,15 @@ def test_open_bindspace_attaches_current_netns() -> None:
kind='netns', kind='netns',
) )
assert spec.key is CURRENT_NETNS assert spec.key is CURRENT_NETNS
async with open_bindspace(spec) as handle: async with open_bindspace(spec) as bindspace:
namespace_fd: int|None = handle.namespace_fd namespace_fd: int|None = bindspace.namespace_fd
assert namespace_fd is not None assert namespace_fd is not None
assert handle.spec is spec assert bindspace.spec is spec
assert handle.identity.key is None assert bindspace.ref.key is None
assert handle.identity.inode == os.fstat( assert bindspace.ref.inode == os.fstat(
namespace_fd namespace_fd
).st_ino ).st_ino
assert handle.ownership == 'borrowed' assert bindspace.ownership == 'borrowed'
return namespace_fd return namespace_fd
namespace_fd: int = trio.run(main) namespace_fd: int = trio.run(main)
@ -324,12 +325,12 @@ def test_attach_named_netns_uses_run_directory(
kind='netns', kind='netns',
key='tractor-wg0', key='tractor-wg0',
) )
async with attach_netns(spec) as handle: async with attach_netns(spec) as bindspace:
namespace_fd: int|None = handle.namespace_fd namespace_fd: int|None = bindspace.namespace_fd
assert namespace_fd is not None assert namespace_fd is not None
assert handle.identity.key == 'tractor-wg0' assert bindspace.ref.key == 'tractor-wg0'
assert handle.identity.inode == netns_path.stat().st_ino assert bindspace.ref.inode == netns_path.stat().st_ino
assert handle.ownership == 'borrowed' assert bindspace.ownership == 'borrowed'
return namespace_fd return namespace_fd
namespace_fd: int = trio.run(main) 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. Successful creation must yield ownership and remove on exit.
Fake pyroute2 creation with a named stand-in file, verify the 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. precedes resource removal when the context exits.
''' '''
@ -444,11 +445,11 @@ def test_open_netns_owns_lifecycle(
key='tractor-wg0', key='tractor-wg0',
lifecycle='open', lifecycle='open',
) )
async with open_bindspace(spec) as handle: async with open_bindspace(spec) as bindspace:
fd: int|None = handle.namespace_fd fd: int|None = bindspace.namespace_fd
assert fd is not None assert fd is not None
assert handle.ownership == 'owned' assert bindspace.ownership == 'owned'
assert handle.identity.inode == os.fstat(fd).st_ino assert bindspace.ref.inode == os.fstat(fd).st_ino
namespace_fds.append(fd) namespace_fds.append(fd)
events.append('yield') events.append('yield')

View File

@ -14,8 +14,8 @@ import pytest
import trio import trio
from tractor.discovery import ( from tractor.discovery import (
BindspaceHandle, Bindspace,
BindspaceIdentity, BindspaceRef,
BindspaceSpec, BindspaceSpec,
WGInterfaceConfig, WGInterfaceConfig,
WGPeerConfig, WGPeerConfig,
@ -159,7 +159,7 @@ def test_open_wg_iface_shields_cancelled_cleanup(
def create( def create(
spec: WGTunnelSpec, spec: WGTunnelSpec,
config: WGInterfaceConfig, config: WGInterfaceConfig,
bindspace: BindspaceHandle, bindspace: Bindspace,
listen_port: int|None, listen_port: int|None,
peers: tuple[dict[str, object], ...], peers: tuple[dict[str, object], ...],
) -> None: ) -> None:
@ -174,7 +174,7 @@ def test_open_wg_iface_shields_cancelled_cleanup(
def remove( def remove(
spec: WGTunnelSpec, spec: WGTunnelSpec,
bindspace: BindspaceHandle, bindspace: Bindspace,
) -> None: ) -> None:
''' '''
Record shielded removal after cancellation. Record shielded removal after cancellation.
@ -200,9 +200,9 @@ def test_open_wg_iface_shields_cancelled_cleanup(
kind='netns', kind='netns',
key='tractor-wg0', key='tractor-wg0',
) )
bindspace: BindspaceHandle = BindspaceHandle( bindspace: Bindspace = Bindspace(
spec=bindspace_spec, spec=bindspace_spec,
identity=BindspaceIdentity( ref=BindspaceRef(
kind='netns', kind='netns',
key='tractor-wg0', key='tractor-wg0',
inode=os.fstat(namespace_fd).st_ino, 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. Nested WG interfaces must exit before their bindspace capability.
Fake two interface layers over one bindspace. Clear the caller's Fake two interface layers over one bindspace. Clear the caller's
mutable layer list at bindspace entry, then cancel from inside the mutable layer list at bindspace entry, then cancel from inside
yielded application scope and checkpoint. The trace proves the the yielded application scope and checkpoint. The trace proves
stack snapshots its declaration before entry, layers enter stack snapshots its declaration before entry, layers enter
outermost-first, cancellation exits them inside-out, and the live outermost-first, cancellation exits them inside-out, and the live
bindspace remains available through every interface exit. bindspace remains available through every interface exit.
@ -261,16 +261,16 @@ def test_open_wg_bindspace_nests_resource_lifetimes(
tuple[ tuple[
WGTunnelSpec, WGTunnelSpec,
WGInterfaceConfig, WGInterfaceConfig,
BindspaceHandle, Bindspace,
_tunnel.WGRole, _tunnel.WGRole,
] ]
] = [] ] = []
bindspace_spec: BindspaceSpec = BindspaceSpec( bindspace_spec: BindspaceSpec = BindspaceSpec(
kind='netns', kind='netns',
) )
bindspace: BindspaceHandle = BindspaceHandle( bindspace: Bindspace = Bindspace(
spec=bindspace_spec, spec=bindspace_spec,
identity=BindspaceIdentity( ref=BindspaceRef(
kind='netns', kind='netns',
key=None, key=None,
inode=1, inode=1,
@ -302,7 +302,7 @@ def test_open_wg_bindspace_nests_resource_lifetimes(
@acm @acm
async def fake_open_bindspace( async def fake_open_bindspace(
spec: BindspaceSpec, spec: BindspaceSpec,
) -> AsyncIterator[BindspaceHandle]: ) -> AsyncIterator[Bindspace]:
''' '''
Yield the stand-in bindspace and record its full lifetime. 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( async def fake_open_wg_iface(
spec: WGTunnelSpec, spec: WGTunnelSpec,
config: WGInterfaceConfig, config: WGInterfaceConfig,
handle: BindspaceHandle, bindspace_arg: Bindspace,
role: _tunnel.WGRole, role: _tunnel.WGRole,
) -> AsyncIterator[WGTunnelSpec]: ) -> AsyncIterator[WGTunnelSpec]:
''' '''
Record one interface's arguments and nested lifetime. 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') events.append(f'{spec.iface}-enter')
try: try:
yield spec yield spec
finally: finally:
assert handle is bindspace assert bindspace_arg is bindspace
events.append(f'{spec.iface}-exit') events.append(f'{spec.iface}-exit')
monkeypatch.setattr( monkeypatch.setattr(
@ -355,8 +355,8 @@ def test_open_wg_bindspace_nests_resource_lifetimes(
bindspace_spec, bindspace_spec,
layers, layers,
'dial', 'dial',
) as handle: ) as opened_bindspace:
assert handle is bindspace assert opened_bindspace is bindspace
events.append('yield') events.append('yield')
scope.cancel() scope.cancel()
await trio.sleep_forever() await trio.sleep_forever()

View File

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

View File

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

View File

@ -93,7 +93,7 @@ import trio
from ..msg._local import ProcessLocal from ..msg._local import ProcessLocal
from ._bindspace import ( from ._bindspace import (
BindspaceHandle, Bindspace,
BindspaceSpec, BindspaceSpec,
open_bindspace, open_bindspace,
) )
@ -463,7 +463,7 @@ def _wg_iface_settings(
def _sync_create_wg_iface( def _sync_create_wg_iface(
spec: WGTunnelSpec, spec: WGTunnelSpec,
config: WGInterfaceConfig, config: WGInterfaceConfig,
bindspace: BindspaceHandle, bindspace: Bindspace,
listen_port: int|None, listen_port: int|None,
peers: tuple[dict[str, object], ...], peers: tuple[dict[str, object], ...],
) -> None: ) -> None:
@ -559,7 +559,7 @@ def _sync_create_wg_iface(
def _sync_remove_wg_iface( def _sync_remove_wg_iface(
spec: WGTunnelSpec, spec: WGTunnelSpec,
bindspace: BindspaceHandle, bindspace: Bindspace,
) -> None: ) -> None:
''' '''
Remove one owned WireGuard iface when it still exists. Remove one owned WireGuard iface when it still exists.
@ -593,7 +593,7 @@ def _sync_remove_wg_iface(
async def open_wg_iface( async def open_wg_iface(
spec: WGTunnelSpec, spec: WGTunnelSpec,
config: WGInterfaceConfig, config: WGInterfaceConfig,
bindspace: BindspaceHandle, bindspace: Bindspace,
role: WGRole, role: WGRole,
) -> AsyncIterator[WGTunnelSpec]: ) -> AsyncIterator[WGTunnelSpec]:
''' '''
@ -637,7 +637,7 @@ async def open_wg_bindspace(
bindspace_spec: BindspaceSpec, bindspace_spec: BindspaceSpec,
layers: Sequence[tuple[WGTunnelSpec, WGInterfaceConfig]], layers: Sequence[tuple[WGTunnelSpec, WGInterfaceConfig]],
role: WGRole, role: WGRole,
) -> AsyncIterator[BindspaceHandle]: ) -> AsyncIterator[Bindspace]:
''' '''
Open one bindspace and its ordered WireGuard interface stack. Open one bindspace and its ordered WireGuard interface stack.
@ -662,7 +662,7 @@ async def open_wg_bindspace(
..., ...,
] = tuple(layers) ] = tuple(layers)
async with AsyncExitStack() as stack: async with AsyncExitStack() as stack:
bindspace: BindspaceHandle = await ( bindspace: Bindspace = await (
stack.enter_async_context( stack.enter_async_context(
open_bindspace(bindspace_spec) open_bindspace(bindspace_spec)
) )