From 7abbb2b5f013c081b71a571396f031735828716b Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 25 Aug 2026 15:11:08 -0400 Subject: [PATCH] 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`)) --- .../20260825T190529Z_e1007547_prompt_io.md | 37 ++++ ...20260825T190529Z_e1007547_prompt_io.raw.md | 26 +++ ai/tpt-backends/03_wg_tunnel_bindspace.md | 7 + tests/discovery/test_bindspace.py | 188 ++++++++++++++++++ tractor/discovery/__init__.py | 1 + tractor/discovery/_bindspace.py | 86 ++++++++ 6 files changed, 345 insertions(+) create mode 100644 ai/prompt-io/opencode/20260825T190529Z_e1007547_prompt_io.md create mode 100644 ai/prompt-io/opencode/20260825T190529Z_e1007547_prompt_io.raw.md diff --git a/ai/prompt-io/opencode/20260825T190529Z_e1007547_prompt_io.md b/ai/prompt-io/opencode/20260825T190529Z_e1007547_prompt_io.md new file mode 100644 index 00000000..4e20eed8 --- /dev/null +++ b/ai/prompt-io/opencode/20260825T190529Z_e1007547_prompt_io.md @@ -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. diff --git a/ai/prompt-io/opencode/20260825T190529Z_e1007547_prompt_io.raw.md b/ai/prompt-io/opencode/20260825T190529Z_e1007547_prompt_io.raw.md new file mode 100644 index 00000000..faf5ab2b --- /dev/null +++ b/ai/prompt-io/opencode/20260825T190529Z_e1007547_prompt_io.raw.md @@ -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. diff --git a/ai/tpt-backends/03_wg_tunnel_bindspace.md b/ai/tpt-backends/03_wg_tunnel_bindspace.md index 771cce0d..1bc89509 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -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: diff --git a/tests/discovery/test_bindspace.py b/tests/discovery/test_bindspace.py index 57ba01a2..6ec5feb0 100644 --- a/tests/discovery/test_bindspace.py +++ b/tests/discovery/test_bindspace.py @@ -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) diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index 299b8e32..d44d8a8c 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -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, diff --git a/tractor/discovery/_bindspace.py b/tractor/discovery/_bindspace.py index f312a48d..7afb9b2d 100644 --- a/tractor/discovery/_bindspace.py +++ b/tractor/discovery/_bindspace.py @@ -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, + )