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`))
parent
349ab98361
commit
1569788ea6
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue