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`))
parent
d130431ca1
commit
f538c4f5d1
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
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 +615,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 +644,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 +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 |
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ import trio
|
|||
|
||||
from ..msg._local import ProcessLocal
|
||||
from ._bindspace import (
|
||||
BindspaceHandle,
|
||||
Bindspace,
|
||||
BindspaceSpec,
|
||||
open_bindspace,
|
||||
)
|
||||
|
|
@ -460,7 +460,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 +556,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 +590,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 +634,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 +659,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)
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue