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`))
parent
e100754709
commit
5b2a064ae8
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue