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
1569788ea6
commit
a625a2757d
|
|
@ -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
|
Future `open_netns()` creation and owned teardown remain a separate
|
||||||
privileged supervisor change.
|
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
|
`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:
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from tractor.discovery import (
|
||||||
BindspaceSpec,
|
BindspaceSpec,
|
||||||
CURRENT_NETNS,
|
CURRENT_NETNS,
|
||||||
attach_netns,
|
attach_netns,
|
||||||
|
open_netns,
|
||||||
)
|
)
|
||||||
from tractor.discovery import _bindspace
|
from tractor.discovery import _bindspace
|
||||||
from tractor.msg import ProcessLocal
|
from tractor.msg import ProcessLocal
|
||||||
|
|
@ -358,3 +359,190 @@ def test_attach_named_netns_never_creates(
|
||||||
with pytest.raises(FileNotFoundError):
|
with pytest.raises(FileNotFoundError):
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
assert not missing_path.exists()
|
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,
|
BindspaceSpec as BindspaceSpec,
|
||||||
CURRENT_NETNS as CURRENT_NETNS,
|
CURRENT_NETNS as CURRENT_NETNS,
|
||||||
attach_netns as attach_netns,
|
attach_netns as attach_netns,
|
||||||
|
open_netns as open_netns,
|
||||||
)
|
)
|
||||||
from ._multiaddr import (
|
from ._multiaddr import (
|
||||||
parse_endpoints as parse_endpoints,
|
parse_endpoints as parse_endpoints,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ from typing import (
|
||||||
)
|
)
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
import trio
|
||||||
|
|
||||||
from ..msg._local import ProcessLocal
|
from ..msg._local import ProcessLocal
|
||||||
|
|
||||||
|
|
@ -279,3 +280,88 @@ async def attach_netns(
|
||||||
yield handle
|
yield handle
|
||||||
finally:
|
finally:
|
||||||
os.close(namespace_fd)
|
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