Compose WireGuard bindspace lifecycles
Add `open_wg_bindspace()` to enter one declared bindspace and an ordered WireGuard interface stack as one async lifetime. Deats, - snapshot caller layer ordering before the first checkpoint - enter interfaces outermost-first through `AsyncExitStack` - unwind interfaces before releasing the namespace capability - yield the live `BindspaceHandle` for endpoint allocation - test mutable input and cancellation ordering with lifecycle fakes Prompt-IO: ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))wkt/wg_pyroute2_read
parent
f7bf068d09
commit
6dd149df3d
|
|
@ -0,0 +1,47 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-26T02:24:34Z
|
||||||
|
git_ref: 2245f094
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260826T022434Z_2245f094_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing the owned WireGuard interface lifecycle, the human
|
||||||
|
asked to continue with the next established incremental step.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added a WG-specific composition context which owns one declared
|
||||||
|
bindspace and an ordered interface stack. It snapshots caller-owned
|
||||||
|
layer order before checkpointing, enters interfaces outermost-first,
|
||||||
|
and guarantees inside-out interface teardown before bindspace release.
|
||||||
|
Its inbound type shows the `(WGTunnelSpec, WGInterfaceConfig)` pairing
|
||||||
|
directly, and its docstring depicts the stack and unwind order.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_tunnel.py` - bindspace/interface stack driver.
|
||||||
|
- `tractor/discovery/__init__.py` - public layer and driver exports.
|
||||||
|
- `tests/discovery/test_wg_iface_lifecycle.py` - mutable-input,
|
||||||
|
cancellation and nested teardown-order coverage.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - implemented composition
|
||||||
|
and ownership-order contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human accepted and committed the preceding reviewed lifecycle
|
||||||
|
boundary, then explicitly requested the next step. That selected the
|
||||||
|
previously established bindspace/WireGuard composition boundary. In
|
||||||
|
staged review, the human requested an explicit interface-stack analogy
|
||||||
|
and a small ASCII diagram explaining entry and exit order. The human
|
||||||
|
then identified `WGInterfaceLayer` as an unnecessary single-use alias
|
||||||
|
which obscured the inbound tuple shape. The agent applied both
|
||||||
|
human-directed edits. The human accepted the fake lifecycle test as a
|
||||||
|
focused control-flow check which avoids pyroute2 overhead, while noting
|
||||||
|
their general preference for end-to-end coverage. No direct manual
|
||||||
|
source edits were observed.
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-26T02:24:34Z
|
||||||
|
git_ref: 2245f094
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - compose WireGuard bindspace lifecycles
|
||||||
|
|
||||||
|
After committing the owned WireGuard interface lifecycle, the human
|
||||||
|
requested the next incremental change.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_iface_lifecycle.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added `open_wg_bindspace()` to compose one declared bindspace with an
|
||||||
|
ordered sequence of WireGuard interface layers. The context enters the
|
||||||
|
bindspace first, enters interfaces outermost-first through
|
||||||
|
`AsyncExitStack`, yields the live `BindspaceHandle`, and removes
|
||||||
|
interfaces inside-out before releasing or removing the namespace.
|
||||||
|
|
||||||
|
The caller's layer sequence is snapshotted before the first checkpoint,
|
||||||
|
so concurrent mutation cannot alter resource entry or unwind order.
|
||||||
|
Fake lifecycle tests clear a mutable input list during bindspace entry,
|
||||||
|
cancel from the yielded scope, and prove the original stack unwinds in
|
||||||
|
dependency-safe order.
|
||||||
|
|
||||||
|
Ruff passed and focused bindspace/WireGuard coverage passed 39 tests.
|
||||||
|
|
@ -530,8 +530,8 @@ exit. Creation/removal run in shielded Trio worker calls. A listen
|
||||||
bearer supplies the local listen port; a dial bearer supplies an
|
bearer supplies the local listen port; a dial bearer supplies an
|
||||||
omitted endpoint only for the selected maddr peer.
|
omitted endpoint only for the selected maddr peer.
|
||||||
|
|
||||||
and a driver that folds a list of specs into nested contexts
|
The composition driver folds a list of specs into nested contexts with
|
||||||
(`contextlib.AsyncExitStack` for the N-deep case). The
|
`contextlib.AsyncExitStack` for the N-deep case. The
|
||||||
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
|
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
|
||||||
it already returns
|
it already returns
|
||||||
`dict[name, list[Address|TunnelledAddress]]` and the
|
`dict[name, list[Address|TunnelledAddress]]` and the
|
||||||
|
|
@ -539,6 +539,14 @@ it already returns
|
||||||
`dict[str, list[Address]]|dict[...]` return for tunnelled
|
`dict[str, list[Address]]|dict[...]` return for tunnelled
|
||||||
entries. Extend it to carry the tunnel stack, not to *enter* it.
|
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
|
||||||
|
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.
|
||||||
|
|
||||||
The caller supplies `role` to tunnel-resource contexts such as
|
The caller supplies `role` to tunnel-resource contexts such as
|
||||||
`open_wg_iface()`; do not infer it from maddr shape. Bindspace
|
`open_wg_iface()`; do not infer it from maddr shape. Bindspace
|
||||||
lifecycle remains the independent explicit policy above. The same
|
lifecycle remains the independent explicit policy above. The same
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ WireGuard interface policy and owned lifecycle contracts.
|
||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager as acm
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import BinaryIO
|
from typing import BinaryIO
|
||||||
|
|
@ -18,6 +20,7 @@ from tractor.discovery import (
|
||||||
WGInterfaceConfig,
|
WGInterfaceConfig,
|
||||||
WGPeerConfig,
|
WGPeerConfig,
|
||||||
WGTunnelSpec,
|
WGTunnelSpec,
|
||||||
|
open_wg_bindspace,
|
||||||
open_wg_iface,
|
open_wg_iface,
|
||||||
)
|
)
|
||||||
from tractor.discovery import _tunnel
|
from tractor.discovery import _tunnel
|
||||||
|
|
@ -237,3 +240,139 @@ def test_open_wg_iface_shields_cancelled_cleanup(
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
||||||
assert events == ['create', 'yield', 'remove']
|
assert events == ['create', 'yield', 'remove']
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_wg_bindspace_nests_resource_lifetimes(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Nested WG interfaces must exit before their bindspace capability.
|
||||||
|
|
||||||
|
Fake two interface layers over one bindspace. Clear the caller's
|
||||||
|
mutable layer list at bindspace entry, then cancel from inside the
|
||||||
|
yielded application scope and checkpoint. The trace proves the
|
||||||
|
stack snapshots its declaration before entry, layers enter
|
||||||
|
outermost-first, cancellation exits them inside-out, and the live
|
||||||
|
bindspace remains available through every interface exit.
|
||||||
|
|
||||||
|
'''
|
||||||
|
events: list[str] = []
|
||||||
|
calls: list[
|
||||||
|
tuple[
|
||||||
|
WGTunnelSpec,
|
||||||
|
WGInterfaceConfig,
|
||||||
|
BindspaceHandle,
|
||||||
|
_tunnel.WGRole,
|
||||||
|
]
|
||||||
|
] = []
|
||||||
|
bindspace_spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
)
|
||||||
|
bindspace: BindspaceHandle = BindspaceHandle(
|
||||||
|
spec=bindspace_spec,
|
||||||
|
identity=BindspaceIdentity(
|
||||||
|
kind='netns',
|
||||||
|
key=None,
|
||||||
|
inode=1,
|
||||||
|
),
|
||||||
|
namespace_fd=None,
|
||||||
|
ownership='borrowed',
|
||||||
|
)
|
||||||
|
outer_spec: WGTunnelSpec = WGTunnelSpec(
|
||||||
|
peer_pubkey=_PEER_KEY,
|
||||||
|
iface='wg-outer',
|
||||||
|
)
|
||||||
|
inner_spec: WGTunnelSpec = WGTunnelSpec(
|
||||||
|
peer_pubkey=_LOCAL_KEY,
|
||||||
|
iface='wg-inner',
|
||||||
|
)
|
||||||
|
outer_config: WGInterfaceConfig = WGInterfaceConfig(
|
||||||
|
private_key=_LOCAL_KEY,
|
||||||
|
)
|
||||||
|
inner_config: WGInterfaceConfig = WGInterfaceConfig(
|
||||||
|
private_key=_PEER_KEY,
|
||||||
|
)
|
||||||
|
layers: list[
|
||||||
|
tuple[WGTunnelSpec, WGInterfaceConfig]
|
||||||
|
] = [
|
||||||
|
(outer_spec, outer_config),
|
||||||
|
(inner_spec, inner_config),
|
||||||
|
]
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def fake_open_bindspace(
|
||||||
|
spec: BindspaceSpec,
|
||||||
|
) -> AsyncIterator[BindspaceHandle]:
|
||||||
|
'''
|
||||||
|
Yield the stand-in bindspace and record its full lifetime.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert spec is bindspace_spec
|
||||||
|
events.append('bindspace-enter')
|
||||||
|
layers.clear()
|
||||||
|
try:
|
||||||
|
yield bindspace
|
||||||
|
finally:
|
||||||
|
events.append('bindspace-exit')
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def fake_open_wg_iface(
|
||||||
|
spec: WGTunnelSpec,
|
||||||
|
config: WGInterfaceConfig,
|
||||||
|
handle: BindspaceHandle,
|
||||||
|
role: _tunnel.WGRole,
|
||||||
|
) -> AsyncIterator[WGTunnelSpec]:
|
||||||
|
'''
|
||||||
|
Record one interface's arguments and nested lifetime.
|
||||||
|
|
||||||
|
'''
|
||||||
|
calls.append((spec, config, handle, role))
|
||||||
|
events.append(f'{spec.iface}-enter')
|
||||||
|
try:
|
||||||
|
yield spec
|
||||||
|
finally:
|
||||||
|
assert handle is bindspace
|
||||||
|
events.append(f'{spec.iface}-exit')
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_tunnel,
|
||||||
|
'open_bindspace',
|
||||||
|
fake_open_bindspace,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_tunnel,
|
||||||
|
'open_wg_iface',
|
||||||
|
fake_open_wg_iface,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Cancel while both interface layers are live.
|
||||||
|
|
||||||
|
'''
|
||||||
|
with trio.CancelScope() as scope:
|
||||||
|
async with open_wg_bindspace(
|
||||||
|
bindspace_spec,
|
||||||
|
layers,
|
||||||
|
'dial',
|
||||||
|
) as handle:
|
||||||
|
assert handle is bindspace
|
||||||
|
events.append('yield')
|
||||||
|
scope.cancel()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
assert calls == [
|
||||||
|
(outer_spec, outer_config, bindspace, 'dial'),
|
||||||
|
(inner_spec, inner_config, bindspace, 'dial'),
|
||||||
|
]
|
||||||
|
assert events == [
|
||||||
|
'bindspace-enter',
|
||||||
|
'wg-outer-enter',
|
||||||
|
'wg-inner-enter',
|
||||||
|
'yield',
|
||||||
|
'wg-inner-exit',
|
||||||
|
'wg-outer-exit',
|
||||||
|
'bindspace-exit',
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ from ._tunnel import (
|
||||||
WGRole as WGRole,
|
WGRole as WGRole,
|
||||||
mb_pubkey as mb_pubkey,
|
mb_pubkey as mb_pubkey,
|
||||||
mk_wg_maddr as mk_wg_maddr,
|
mk_wg_maddr as mk_wg_maddr,
|
||||||
|
open_wg_bindspace as open_wg_bindspace,
|
||||||
open_wg_iface as open_wg_iface,
|
open_wg_iface as open_wg_iface,
|
||||||
parse_wg_maddr as parse_wg_maddr,
|
parse_wg_maddr as parse_wg_maddr,
|
||||||
read_wg_peers as read_wg_peers,
|
read_wg_peers as read_wg_peers,
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,16 @@ Unwrap at the parse or bindspace boundary; see `.overlay` and
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from collections.abc import AsyncIterator
|
|
||||||
|
from collections.abc import (
|
||||||
|
AsyncIterator,
|
||||||
|
Sequence,
|
||||||
|
)
|
||||||
import base64
|
import base64
|
||||||
from contextlib import asynccontextmanager as acm
|
from contextlib import (
|
||||||
|
AsyncExitStack,
|
||||||
|
asynccontextmanager as acm,
|
||||||
|
)
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import sys
|
import sys
|
||||||
from typing import (
|
from typing import (
|
||||||
|
|
@ -85,6 +92,11 @@ import multibase
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from ..msg._local import ProcessLocal
|
from ..msg._local import ProcessLocal
|
||||||
|
from ._bindspace import (
|
||||||
|
BindspaceHandle,
|
||||||
|
BindspaceSpec,
|
||||||
|
open_bindspace,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from multiaddr import Multiaddr
|
from multiaddr import Multiaddr
|
||||||
|
|
@ -93,10 +105,8 @@ if TYPE_CHECKING:
|
||||||
Address,
|
Address,
|
||||||
UnwrappedAddress,
|
UnwrappedAddress,
|
||||||
)
|
)
|
||||||
from ._bindspace import BindspaceHandle
|
|
||||||
else:
|
else:
|
||||||
Address = Any
|
Address = Any
|
||||||
BindspaceHandle = Any
|
|
||||||
Multiaddr = Any
|
Multiaddr = Any
|
||||||
UnwrappedAddress = Any
|
UnwrappedAddress = Any
|
||||||
|
|
||||||
|
|
@ -622,6 +632,59 @@ async def open_wg_iface(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def open_wg_bindspace(
|
||||||
|
bindspace_spec: BindspaceSpec,
|
||||||
|
layers: Sequence[tuple[WGTunnelSpec, WGInterfaceConfig]],
|
||||||
|
role: WGRole,
|
||||||
|
) -> AsyncIterator[BindspaceHandle]:
|
||||||
|
'''
|
||||||
|
Open one bindspace and its ordered WireGuard interface stack.
|
||||||
|
|
||||||
|
`layers` is an interface stack declared outermost first:
|
||||||
|
|
||||||
|
application scope
|
||||||
|
|
|
||||||
|
layers[-1] <- last entered, first exited
|
||||||
|
|
|
||||||
|
...
|
||||||
|
|
|
||||||
|
layers[0] <- first entered, last exited
|
||||||
|
|
|
||||||
|
bindspace
|
||||||
|
|
||||||
|
`AsyncExitStack` builds it bottom-up in declaration order and
|
||||||
|
unwinds it top-down before the bindspace closes.
|
||||||
|
|
||||||
|
'''
|
||||||
|
layer_stack: tuple[
|
||||||
|
tuple[WGTunnelSpec, WGInterfaceConfig],
|
||||||
|
...,
|
||||||
|
] = tuple(layers)
|
||||||
|
async with AsyncExitStack() as stack:
|
||||||
|
bindspace: BindspaceHandle = await (
|
||||||
|
stack.enter_async_context(
|
||||||
|
open_bindspace(bindspace_spec)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
layer: tuple[WGTunnelSpec, WGInterfaceConfig]
|
||||||
|
for layer in layer_stack:
|
||||||
|
tunnel_spec: WGTunnelSpec
|
||||||
|
config: WGInterfaceConfig
|
||||||
|
tunnel_spec, config = layer
|
||||||
|
await stack.enter_async_context(
|
||||||
|
open_wg_iface(
|
||||||
|
tunnel_spec,
|
||||||
|
config,
|
||||||
|
bindspace,
|
||||||
|
role,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
yield bindspace
|
||||||
|
|
||||||
|
|
||||||
def _sync_read_wg_keys(
|
def _sync_read_wg_keys(
|
||||||
iface: str,
|
iface: str,
|
||||||
netns: str|None,
|
netns: str|None,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue