From 1569788ea6bdbea3902fc3c21665402577ce16aa Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 25 Aug 2026 15:00:44 -0400 Subject: [PATCH] Attach existing network namespaces Add `attach_netns()` to pin a current or named Linux netns in a borrowed `BindspaceHandle` without creating or entering it. Deats, - name the current-namespace default `CURRENT_NETNS` - derive stable identity from the opened FD with `fstat()` - open descriptors with `O_CLOEXEC` and close them on context exit - constrain named lookup beneath the standard iproute2 run directory - report field-specific validation and missing-resource errors Prompt-IO: ai/prompt-io/opencode/20260825T045557Z_fdccfd7e_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`)) --- .../20260825T045557Z_fdccfd7e_prompt_io.md | 48 ++++++ ...20260825T045557Z_fdccfd7e_prompt_io.raw.md | 27 +++ ai/tpt-backends/03_wg_tunnel_bindspace.md | 9 + tests/discovery/test_bindspace.py | 159 ++++++++++++++++++ tractor/discovery/__init__.py | 2 + tractor/discovery/_bindspace.py | 114 +++++++++++-- 6 files changed, 349 insertions(+), 10 deletions(-) create mode 100644 ai/prompt-io/opencode/20260825T045557Z_fdccfd7e_prompt_io.md create mode 100644 ai/prompt-io/opencode/20260825T045557Z_fdccfd7e_prompt_io.raw.md diff --git a/ai/prompt-io/opencode/20260825T045557Z_fdccfd7e_prompt_io.md b/ai/prompt-io/opencode/20260825T045557Z_fdccfd7e_prompt_io.md new file mode 100644 index 00000000..827e82f5 --- /dev/null +++ b/ai/prompt-io/opencode/20260825T045557Z_fdccfd7e_prompt_io.md @@ -0,0 +1,48 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-addr-unpacking +timestamp: 2026-08-25T04:55:57Z +git_ref: fdccfd7e +scope: code +substantive: true +raw_file: 20260825T045557Z_fdccfd7e_prompt_io.raw.md +--- + +## Prompt + +After committing the foundational bindspace capability model, the +human authorized the next incremental Layer C change. + +## Response summary + +Added a borrow-only existing-netns context manager which pins stable +identity through an open FD and always releases that local descriptor. +The API does not create, enter or remove namespaces and rejects named +path traversal. +Staged review renamed the context to `attach_netns()`, introduced the +readable `CURRENT_NETNS` alias and made key validation errors identify +their exact owning field. +Follow-up review made the empty-key error spell out that `None` is +written as `CURRENT_NETNS` for `BindspaceSpec.key` declarations. + +## Files changed + +- `tractor/discovery/_bindspace.py` - existing-netns lifecycle and key + validation. +- `tractor/discovery/__init__.py` - public lifecycle export. +- `tests/discovery/test_bindspace.py` - current, named, missing and + traversal coverage. +- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - borrow-only lifecycle + contract. + +## Human edits + +The human selected the previously deferred borrow-only netns lifecycle +as the next incremental Layer C change. The agent implemented the +source changes. During staged review, the human selected +`attach_netns()` terminology, requested explicit +`BindspaceSpec.key = CURRENT_NETNS` semantics and field-specific key +validation. Follow-up review requested the validation error itself +connect `None` to `CURRENT_NETNS`. The agent applied those +human-directed edits; no direct manual source edits were observed. diff --git a/ai/prompt-io/opencode/20260825T045557Z_fdccfd7e_prompt_io.raw.md b/ai/prompt-io/opencode/20260825T045557Z_fdccfd7e_prompt_io.raw.md new file mode 100644 index 00000000..bf4b032b --- /dev/null +++ b/ai/prompt-io/opencode/20260825T045557Z_fdccfd7e_prompt_io.raw.md @@ -0,0 +1,27 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-25T04:55:57Z +git_ref: fdccfd7e +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - borrow existing netns bindspaces + +The human reported the bindspace capability model 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_existing_netns()` as a borrow-only context manager. +It opens the current process netns or a named entry under the standard +iproute2 run directory, derives stable identity from the opened FD, +and yields a borrowed process-local `BindspaceHandle`. + +The context uses `O_CLOEXEC`, never creates, enters or removes a +namespace, and synchronously closes only its FD on exit. Netns keys +reject paths to keep named lookup beneath the run directory. + +Added current, named, missing and traversal tests. Ruff and lock checks +passed; discovery plus message coverage passed 132 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 582ef70b..771cce0d 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -451,6 +451,15 @@ survives rename/unlink, and identifies the exact namespace the parent provisioned. Extend the kind/field union only when a second platform resource is implemented. +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 +`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 +privileged supervisor change. + `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 ddebc915..57ba01a2 100644 --- a/tests/discovery/test_bindspace.py +++ b/tests/discovery/test_bindspace.py @@ -5,17 +5,23 @@ Bindspace declaration, identity and live-capability contracts. from __future__ import annotations from pathlib import Path +import os +import sys from typing import BinaryIO import msgspec import pytest +import trio from tractor.discovery import ( BindspaceHandle, BindspaceIdentity, BindspaceOwnership, BindspaceSpec, + CURRENT_NETNS, + attach_netns, ) +from tractor.discovery import _bindspace from tractor.msg import ProcessLocal @@ -182,6 +188,34 @@ def test_bindspace_handle_rejects_mismatched_identity( 'Unsupported bindspace kind', id='identity-rejects-kind', ), + pytest.param( + BindspaceSpec, + { + 'kind': 'netns', + 'key': '../outside', + }, + 'Invalid netns name', + id='spec-rejects-path', + ), + pytest.param( + BindspaceSpec, + { + 'kind': 'netns', + 'key': '', + }, + 'BindspaceSpec.key', + id='spec-rejects-empty-key', + ), + pytest.param( + BindspaceIdentity, + { + 'kind': 'netns', + 'key': '', + 'inode': 1234, + }, + 'BindspaceIdentity.key', + id='identity-rejects-empty-key', + ), ), ) def test_bindspace_models_reject_invalid_values( @@ -199,3 +233,128 @@ def test_bindspace_models_reject_invalid_values( ''' with pytest.raises(ValueError, match=match): model(**kwargs) # type: ignore[arg-type] + + +@pytest.mark.skipif( + sys.platform != 'linux', + reason='Linux netns API', +) +def test_attach_current_netns_borrows_and_closes() -> None: + ''' + The unnamed spec must borrow and pin the caller's current netns. + + Open `/proc/self/ns/net`, prove the yielded handle records its + stable inode and borrowed ownership, then exit the context and + prove the exact descriptor was closed without altering the + namespace itself. + + ''' + async def main() -> int: + ''' + Borrow the current netns and return its descriptor number. + + ''' + spec: BindspaceSpec = BindspaceSpec( + kind='netns', + ) + assert spec.key is CURRENT_NETNS + async with attach_netns(spec) as handle: + namespace_fd: int|None = handle.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( + namespace_fd + ).st_ino + assert handle.ownership == 'borrowed' + return namespace_fd + + namespace_fd: int = trio.run(main) + with pytest.raises(OSError): + os.fstat(namespace_fd) + + +@pytest.mark.skipif( + sys.platform != 'linux', + reason='Linux netns API', +) +def test_attach_named_netns_uses_run_directory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + ''' + A named spec must resolve only beneath the configured netns dir. + + Replace the run directory with a temporary stand-in, borrow its + named inode, and prove the context neither deletes the existing + resource nor leaves its descriptor open after exit. + + ''' + netns_path: Path = tmp_path / 'tractor-wg0' + netns_path.touch() + monkeypatch.setattr( + _bindspace, + '_NETNS_RUN_DIR', + tmp_path, + ) + + async def main() -> int: + ''' + Borrow the named stand-in and return its descriptor number. + + ''' + spec: BindspaceSpec = BindspaceSpec( + kind='netns', + key='tractor-wg0', + ) + async with attach_netns(spec) as handle: + namespace_fd: int|None = handle.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' + return namespace_fd + + namespace_fd: int = trio.run(main) + assert netns_path.exists() + with pytest.raises(OSError): + os.fstat(namespace_fd) + + +@pytest.mark.skipif( + sys.platform != 'linux', + reason='Linux netns API', +) +def test_attach_named_netns_never_creates( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + ''' + Borrow-only lookup must fail without creating a missing resource. + + Point the run directory at an empty location, request one named + netns, and prove the open error propagates while no path appears. + + ''' + monkeypatch.setattr( + _bindspace, + '_NETNS_RUN_DIR', + tmp_path, + ) + missing_path: Path = tmp_path / 'missing' + + async def main() -> None: + ''' + Attempt to borrow one absent named netns. + + ''' + spec: BindspaceSpec = BindspaceSpec( + kind='netns', + key='missing', + ) + async with attach_netns(spec): + raise AssertionError('Missing netns unexpectedly opened') + + with pytest.raises(FileNotFoundError): + trio.run(main) + assert not missing_path.exists() diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index a765cae9..299b8e32 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -30,6 +30,8 @@ from ._bindspace import ( BindspaceKind as BindspaceKind, BindspaceOwnership as BindspaceOwnership, BindspaceSpec as BindspaceSpec, + CURRENT_NETNS as CURRENT_NETNS, + attach_netns as attach_netns, ) from ._multiaddr import ( parse_endpoints as parse_endpoints, diff --git a/tractor/discovery/_bindspace.py b/tractor/discovery/_bindspace.py index dcb3650c..f312a48d 100644 --- a/tractor/discovery/_bindspace.py +++ b/tractor/discovery/_bindspace.py @@ -20,8 +20,13 @@ Serializable bindspace declarations and live capability handles. ''' from __future__ import annotations +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager as acm import os +from pathlib import Path +import sys from typing import ( + Final, get_args, Literal, TypeAlias, @@ -40,6 +45,11 @@ BindspaceOwnership: TypeAlias = Literal[ 'borrowed', # manager leaves the pre-existing resource intact ] +_NETNS_RUN_DIR: Path = Path('/var/run/netns') +_SELF_NETNS: Path = Path('/proc/self/ns/net') + +CURRENT_NETNS: Final[None] = None + def _validate_bindspace_kind( kind: BindspaceKind, @@ -54,6 +64,40 @@ def _validate_bindspace_kind( ) +def _validate_bindspace_key( + kind: BindspaceKind, + key: str|None, + field: str, +) -> None: + ''' + Reject empty or path-like platform-resource names. + + `None` is valid. Spell it `CURRENT_NETNS` for + `BindspaceSpec.key`; `BindspaceIdentity.key = None` records an + unnamed realized netns. + + ''' + if key == '': + raise ValueError( + f'`{field}` must be a non-empty name or `None` ' + f'(`CURRENT_NETNS` for `BindspaceSpec.key`)!' + ) + if ( + kind == 'netns' + and + key is not None + and + ( + Path(key).name != key + or + key in ('.', '..') + ) + ): + raise ValueError( + f'Invalid netns name: {key!r}' + ) + + class BindspaceSpec( msgspec.Struct, frozen=True, @@ -61,9 +105,12 @@ class BindspaceSpec( ''' Serializable declaration of one requested bindspace. + For a netns spec, `.key = CURRENT_NETNS` selects the calling + process's current namespace without a named-path lookup. + ''' kind: BindspaceKind - key: str|None = None + key: str|None = CURRENT_NETNS def __post_init__(self) -> None: ''' @@ -71,10 +118,11 @@ class BindspaceSpec( ''' _validate_bindspace_kind(self.kind) - if self.key == '': - raise ValueError( - '`BindspaceSpec.key` must be non-empty or `None`!' - ) + _validate_bindspace_key( + self.kind, + self.key, + 'BindspaceSpec.key', + ) class BindspaceIdentity( @@ -99,11 +147,11 @@ class BindspaceIdentity( ''' _validate_bindspace_kind(self.kind) - if self.key == '': - raise ValueError( - '`BindspaceIdentity.key` must be non-empty ' - 'or `None`!' - ) + _validate_bindspace_key( + self.kind, + self.key, + 'BindspaceIdentity.key', + ) if ( type(self.inode) is not int or @@ -185,3 +233,49 @@ class BindspaceHandle( f'ownership={self.ownership!r}, ' f'namespace_fd={self.namespace_fd!r})' ) + + +@acm +async def attach_netns( + spec: BindspaceSpec, +) -> AsyncIterator[BindspaceHandle]: + ''' + Borrow and pin one existing Linux network namespace. + + `BindspaceSpec.key = CURRENT_NETNS` selects the calling process's + current netns. A named key resolves beneath the standard iproute2 + netns run directory. "Attach" pins an existing namespace FD; this + context never calls `setns()` or creates/removes a namespace. + + ''' + if sys.platform != 'linux': + raise NotImplementedError( + 'Network namespace bindspaces are Linux-only!' + ) + + key: str|None = spec.key + namespace_path: Path = ( + _SELF_NETNS + if key is CURRENT_NETNS + else _NETNS_RUN_DIR / key + ) + namespace_fd: int = os.open( + namespace_path, + os.O_RDONLY | os.O_CLOEXEC, + ) + try: + inode: int = os.fstat(namespace_fd).st_ino + identity: BindspaceIdentity = BindspaceIdentity( + kind='netns', + key=key, + inode=inode, + ) + handle: BindspaceHandle = BindspaceHandle( + spec=spec, + identity=identity, + namespace_fd=namespace_fd, + ownership='borrowed', + ) + yield handle + finally: + os.close(namespace_fd)