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`))
wkt/wg_pyroute2_read
Gud Boi 2026-08-25 15:00:44 -04:00
parent b9dca728d9
commit 40e96b2991
6 changed files with 349 additions and 10 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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 provisioned. Extend the kind/field union only when a second platform
resource is implemented. 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 `open_bindspace()` is **not** an address factory and does not return a
`TunnelledAddress`. At the declaration layer, listener allocation can `TunnelledAddress`. At the declaration layer, listener allocation can
use the handle to replace an overlay while preserving every tunnel: use the handle to replace an overlay while preserving every tunnel:

View File

@ -5,17 +5,23 @@ Bindspace declaration, identity and live-capability contracts.
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import os
import sys
from typing import BinaryIO from typing import BinaryIO
import msgspec import msgspec
import pytest import pytest
import trio
from tractor.discovery import ( from tractor.discovery import (
BindspaceHandle, BindspaceHandle,
BindspaceIdentity, BindspaceIdentity,
BindspaceOwnership, BindspaceOwnership,
BindspaceSpec, BindspaceSpec,
CURRENT_NETNS,
attach_netns,
) )
from tractor.discovery import _bindspace
from tractor.msg import ProcessLocal from tractor.msg import ProcessLocal
@ -182,6 +188,34 @@ def test_bindspace_handle_rejects_mismatched_identity(
'Unsupported bindspace kind', 'Unsupported bindspace kind',
id='identity-rejects-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( def test_bindspace_models_reject_invalid_values(
@ -199,3 +233,128 @@ def test_bindspace_models_reject_invalid_values(
''' '''
with pytest.raises(ValueError, match=match): with pytest.raises(ValueError, match=match):
model(**kwargs) # type: ignore[arg-type] 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()

View File

@ -30,6 +30,8 @@ from ._bindspace import (
BindspaceKind as BindspaceKind, BindspaceKind as BindspaceKind,
BindspaceOwnership as BindspaceOwnership, BindspaceOwnership as BindspaceOwnership,
BindspaceSpec as BindspaceSpec, BindspaceSpec as BindspaceSpec,
CURRENT_NETNS as CURRENT_NETNS,
attach_netns as attach_netns,
) )
from ._multiaddr import ( from ._multiaddr import (
parse_endpoints as parse_endpoints, parse_endpoints as parse_endpoints,

View File

@ -20,8 +20,13 @@ Serializable bindspace declarations and live capability handles.
''' '''
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
import sys
from typing import ( from typing import (
Final,
get_args, get_args,
Literal, Literal,
TypeAlias, TypeAlias,
@ -40,6 +45,11 @@ BindspaceOwnership: TypeAlias = Literal[
'borrowed', # manager leaves the pre-existing resource intact '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( def _validate_bindspace_kind(
kind: BindspaceKind, 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( class BindspaceSpec(
msgspec.Struct, msgspec.Struct,
frozen=True, frozen=True,
@ -61,9 +105,12 @@ class BindspaceSpec(
''' '''
Serializable declaration of one requested bindspace. 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 kind: BindspaceKind
key: str|None = None key: str|None = CURRENT_NETNS
def __post_init__(self) -> None: def __post_init__(self) -> None:
''' '''
@ -71,9 +118,10 @@ class BindspaceSpec(
''' '''
_validate_bindspace_kind(self.kind) _validate_bindspace_kind(self.kind)
if self.key == '': _validate_bindspace_key(
raise ValueError( self.kind,
'`BindspaceSpec.key` must be non-empty or `None`!' self.key,
'BindspaceSpec.key',
) )
@ -99,10 +147,10 @@ class BindspaceIdentity(
''' '''
_validate_bindspace_kind(self.kind) _validate_bindspace_kind(self.kind)
if self.key == '': _validate_bindspace_key(
raise ValueError( self.kind,
'`BindspaceIdentity.key` must be non-empty ' self.key,
'or `None`!' 'BindspaceIdentity.key',
) )
if ( if (
type(self.inode) is not int type(self.inode) is not int
@ -185,3 +233,49 @@ class BindspaceHandle(
f'ownership={self.ownership!r}, ' f'ownership={self.ownership!r}, '
f'namespace_fd={self.namespace_fd!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)