Open owned network namespaces

Add `open_netns()` to create a named Linux netns through pyroute2,
pin its identity and yield an owned `BindspaceHandle`.

Deats,
- run synchronous creation and removal in Trio worker threads
- shield both privileged side effects from caller cancellation
- reuse `attach_netns()` to pin identity and manage the FD
- close the FD before removing the owned namespace
- fake privileged operations while testing ordering and cancellation

Prompt-IO: ai/prompt-io/opencode/20260825T190529Z_e1007547_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Gud Boi 2026-08-25 15:11:08 -04:00
parent 1569788ea6
commit a625a2757d
6 changed files with 345 additions and 0 deletions

View File

@ -0,0 +1,37 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking
timestamp: 2026-08-25T19:05:29Z
git_ref: e1007547
scope: code
substantive: true
raw_file: 20260825T190529Z_e1007547_prompt_io.raw.md
---
## Prompt
After committing the attach-only network namespace lifecycle, the
human authorized the next incremental Layer C change.
## Response summary
Added owned `open_netns()` creation and teardown through shielded
pyroute2 worker calls. The context pins identity through
`attach_netns()`, yields an owned handle, closes its FD before removal
and cleans up despite caller cancellation.
## Files changed
- `tractor/discovery/_bindspace.py` - owned netns lifecycle.
- `tractor/discovery/__init__.py` - public lifecycle export.
- `tests/discovery/test_bindspace.py` - ownership, ordering,
cancellation and name requirements.
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - owned lifecycle and
spawn-boundary contract.
## Human edits
The human selected owned network namespace creation as the next
incremental Layer C change. The agent implemented the source changes;
no direct manual edits or follow-up corrections were observed.

View File

@ -0,0 +1,26 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-25T19:05:29Z
git_ref: e1007547
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - own created netns bindspaces
The human reported the attach-only netns lifecycle committed and
authorized the next incremental Layer C change.
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
Added async `open_netns()` as the owned counterpart to
`attach_netns()`. It requires a named spec, creates through pyroute2 in
a shielded worker call, attaches the resulting namespace FD and yields
an owned process-local `BindspaceHandle`.
FD closure occurs before shielded namespace removal on normal,
exceptional and cancelled exits. The context never calls `setns()`;
namespace entry remains a spawn/bootstrap responsibility.
Privileged operations are faked in tests. Ruff and lock checks passed;
discovery plus message coverage passed 137 tests with 2 xpasses.

View File

@ -460,6 +460,13 @@ not call `setns()`; it never creates, enters or removes a namespace.
Future `open_netns()` creation and owned teardown remain a separate
privileged supervisor change.
`open_netns()` is that owned counterpart: it requires a named spec,
creates through pyroute2 in a shielded worker call, attaches the live
FD, and yields `ownership='owned'`. FD closure precedes another
shielded pyroute2 removal call on every post-creation exit, including
cancellation. It still never calls `setns()`; process entry remains a
spawn/bootstrap operation.
`open_bindspace()` is **not** an address factory and does not return a
`TunnelledAddress`. At the declaration layer, listener allocation can
use the handle to replace an overlay while preserving every tunnel:

View File

@ -20,6 +20,7 @@ from tractor.discovery import (
BindspaceSpec,
CURRENT_NETNS,
attach_netns,
open_netns,
)
from tractor.discovery import _bindspace
from tractor.msg import ProcessLocal
@ -358,3 +359,190 @@ def test_attach_named_netns_never_creates(
with pytest.raises(FileNotFoundError):
trio.run(main)
assert not missing_path.exists()
@pytest.mark.skipif(
sys.platform != 'linux',
reason='Linux netns API',
)
def test_open_netns_owns_lifecycle(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
'''
Successful creation must yield ownership and remove on exit.
Fake pyroute2 creation with a named stand-in file, verify the
yielded FD and identity while it exists, then prove FD closure
precedes resource removal when the context exits.
'''
events: list[str] = []
namespace_fds: list[int] = []
netns_path: Path = tmp_path / 'tractor-wg0'
def create(key: str) -> None:
'''
Create the named stand-in and record lifecycle order.
'''
assert key == 'tractor-wg0'
netns_path.touch()
events.append('create')
def remove(key: str) -> None:
'''
Remove the stand-in after its FD has closed.
'''
assert key == 'tractor-wg0'
events.append('fd-closed')
with pytest.raises(OSError):
os.fstat(namespace_fds[0])
netns_path.unlink()
events.append('remove')
monkeypatch.setattr(
_bindspace,
'_NETNS_RUN_DIR',
tmp_path,
)
monkeypatch.setattr(
_bindspace,
'_create_netns',
create,
)
monkeypatch.setattr(
_bindspace,
'_remove_netns',
remove,
)
async def main() -> None:
'''
Open the fake netns and publish its live descriptor number.
'''
spec: BindspaceSpec = BindspaceSpec(
kind='netns',
key='tractor-wg0',
)
async with open_netns(spec) as handle:
fd: int|None = handle.namespace_fd
assert fd is not None
assert handle.ownership == 'owned'
assert handle.identity.inode == os.fstat(fd).st_ino
namespace_fds.append(fd)
events.append('yield')
trio.run(main)
assert events == [
'create',
'yield',
'fd-closed',
'remove',
]
assert not netns_path.exists()
@pytest.mark.skipif(
sys.platform != 'linux',
reason='Linux netns API',
)
def test_open_netns_shields_cancelled_cleanup(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
'''
Cancellation after creation must not leak an owned namespace.
Cancel the caller inside the yielded context and checkpoint.
Prove shielded teardown still removes the stand-in before
cancellation leaves the enclosing scope.
'''
netns_path: Path = tmp_path / 'tractor-wg0'
removed: list[str] = []
def create(key: str) -> None:
'''
Create the named stand-in before cancellation.
'''
netns_path.touch()
def remove(key: str) -> None:
'''
Remove the stand-in despite caller cancellation.
'''
netns_path.unlink()
removed.append(key)
monkeypatch.setattr(
_bindspace,
'_NETNS_RUN_DIR',
tmp_path,
)
monkeypatch.setattr(
_bindspace,
'_create_netns',
create,
)
monkeypatch.setattr(
_bindspace,
'_remove_netns',
remove,
)
async def main() -> None:
'''
Cancel while borrowing the newly owned namespace.
'''
spec: BindspaceSpec = BindspaceSpec(
kind='netns',
key='tractor-wg0',
)
with trio.CancelScope() as scope:
async with open_netns(spec):
scope.cancel()
await trio.sleep_forever()
trio.run(main)
assert removed == ['tractor-wg0']
assert not netns_path.exists()
@pytest.mark.skipif(
sys.platform != 'linux',
reason='Linux netns API',
)
def test_open_netns_requires_name() -> None:
'''
Creation cannot target the caller's current netns.
Pass `CURRENT_NETNS` and prove validation rejects it before any
privileged pyroute2 operation can run.
'''
spec: BindspaceSpec = BindspaceSpec(
kind='netns',
key=CURRENT_NETNS,
)
async def main() -> None:
'''
Attempt to create the unnamed current namespace.
'''
async with open_netns(spec):
raise AssertionError(
'Current netns unexpectedly created'
)
with pytest.raises(
ValueError,
match='requires a named',
):
trio.run(main)

View File

@ -32,6 +32,7 @@ from ._bindspace import (
BindspaceSpec as BindspaceSpec,
CURRENT_NETNS as CURRENT_NETNS,
attach_netns as attach_netns,
open_netns as open_netns,
)
from ._multiaddr import (
parse_endpoints as parse_endpoints,

View File

@ -33,6 +33,7 @@ from typing import (
)
import msgspec
import trio
from ..msg._local import ProcessLocal
@ -279,3 +280,88 @@ async def attach_netns(
yield handle
finally:
os.close(namespace_fd)
def _create_netns(
key: str,
) -> None:
'''
Create one named netns through pyroute2's synchronous API.
'''
try:
from pyroute2 import netns
except ImportError as exc:
raise RuntimeError(
'Netns creation requires the `tractor[wg]` extra.'
) from exc
netns.create(key)
def _remove_netns(
key: str,
) -> None:
'''
Remove one named netns through pyroute2's synchronous API.
'''
try:
from pyroute2 import netns
except ImportError as exc:
raise RuntimeError(
'Netns removal requires the `tractor[wg]` extra.'
) from exc
netns.remove(key)
@acm
async def open_netns(
spec: BindspaceSpec,
) -> AsyncIterator[BindspaceHandle]:
'''
Create, pin and own one named Linux network namespace.
Creation and removal are shielded synchronous pyroute2 calls in a
worker thread. This context never enters the namespace.
Spawn-time bootstrap remains responsible for eventual `setns()`.
'''
if sys.platform != 'linux':
raise NotImplementedError(
'Network namespace bindspaces are Linux-only!'
)
key: str|None = spec.key
if key is CURRENT_NETNS:
raise ValueError(
'`open_netns()` requires a named `BindspaceSpec.key`!'
)
created: bool = False
try:
with trio.CancelScope(shield=True):
await trio.to_thread.run_sync(
_create_netns,
key,
abandon_on_cancel=False,
)
created = True
async with attach_netns(spec) as borrowed:
handle: BindspaceHandle = BindspaceHandle(
spec=spec,
identity=borrowed.identity,
namespace_fd=borrowed.namespace_fd,
ownership='owned',
)
yield handle
finally:
if created:
with trio.CancelScope(shield=True):
await trio.to_thread.run_sync(
_remove_netns,
key,
abandon_on_cancel=False,
)