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
fc0049da18
commit
d5c9945a52
|
|
@ -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
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue