diff --git a/ai/prompt-io/opencode/20260830T025202Z_b1f6ade8_prompt_io.md b/ai/prompt-io/opencode/20260830T025202Z_b1f6ade8_prompt_io.md new file mode 100644 index 00000000..147d0870 --- /dev/null +++ b/ai/prompt-io/opencode/20260830T025202Z_b1f6ade8_prompt_io.md @@ -0,0 +1,52 @@ +--- +model: gpt-5.6-sol +service: opencode +session: root-bindspace-bootstrap +timestamp: 2026-08-30T02:52:02Z +git_ref: b1f6ade8 +scope: code +substantive: true +raw_file: 20260830T025202Z_b1f6ade8_prompt_io.raw.md +--- + +## Prompt + +The human chose to defer multiprocessing bindspace FD transport because +that backend may be removed, selected root/single-actor namespace +bootstrap as the next stage, approved caller-thread restoration and +public `tractor.net.open_wg_bindspace()` composition, and authorized +implementation after the stack rebase. + +## Response summary + +Added `bindspace=` to `tractor.open_root_actor()`. Root startup now +duplicates and validates the live namespace FD, enters before any +debugger, registry, IPC, or actor-runtime work, and synchronously +restores the caller thread's original namespace after complete root +teardown. Added deterministic failure/cancellation coverage, a real UDS +root actor E2E across two network namespaces, and an executable public +WireGuard-bindspace/root composition regression. Bound roots reject the +persistent `mp_forkserver` backend because a helper from an earlier +runtime may retain a stale namespace; the public annotation also remains +runtime-resolvable without importing `tractor.net` eagerly. + +## Files changed + +- `tractor/_root.py` - public root bindspace lifecycle integration. +- `tractor/spawn/_netns.py` - temporary thread-local netns enter/restore. +- `tests/test_netns_spawn.py` - deterministic and real root regressions. +- `tests/net/test_wg_iface_lifecycle.py` - public root composition test. +- `docs/api/net.rst` - root ownership and restoration contract. + +## Human edits + +The human explicitly deferred multiprocessing support, selected root +bootstrap as the next implementation stage, approved the requirement to +restore the caller's original namespace so owned teardown can complete, +and approved composition through public `tractor.net` contexts rather +than a new one-shot convenience API. The human later rewrote +`_enter_netns_temporarily()` to separate restoration from FD cleanup and +make the transition flow easier to inspect. The agent retained that +intent with nested lexical FD ownership while fixing setup-error masking, +conditional restoration, and duplicate-FD cleanup found during the +requested audit. diff --git a/ai/prompt-io/opencode/20260830T025202Z_b1f6ade8_prompt_io.raw.md b/ai/prompt-io/opencode/20260830T025202Z_b1f6ade8_prompt_io.raw.md new file mode 100644 index 00000000..7596c404 --- /dev/null +++ b/ai/prompt-io/opencode/20260830T025202Z_b1f6ade8_prompt_io.raw.md @@ -0,0 +1,31 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-30T02:52:02Z +git_ref: b1f6ade8 +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - bootstrap the root actor in a bindspace + +The human deferred multiprocessing descriptor transport, selected root +namespace bootstrap as the next Layer C stage, approved enter/restore +semantics and public `tractor.net` composition, and authorized +implementation. + +## Generated code + +> `git diff HEAD~1..HEAD -- tractor/_root.py tractor/spawn/_netns.py` + +Add an optional realized `Bindspace` to `open_root_actor()`. Duplicate +its namespace FD, enter before debugger, registry, IPC, or runtime +startup, and restore the caller thread's original namespace after root +teardown. Preserve caller FD ownership and primary errors across +restoration and close failures. + +> `git diff HEAD~1..HEAD -- tests/test_netns_spawn.py tests/net/test_wg_iface_lifecycle.py docs/api/net.rst` + +Exercise same-netns behavior, body errors, cancellation, restoration +failures, missing and closed FDs, real root entry/restoration under an +unprivileged user namespace, and public WireGuard-bindspace/root +composition. diff --git a/docs/api/net.rst b/docs/api/net.rst index 04e7faff..de9ef0a5 100644 --- a/docs/api/net.rst +++ b/docs/api/net.rst @@ -32,6 +32,39 @@ Bindspaces .. autofunction:: open_bindspace +Root actor composition +---------------------- + +A live :class:`Bindspace` can scope the root actor itself. Compose the +bindspace manager outside :func:`tractor.open_root_actor` so its network +namespace remains pinned for the complete actor runtime:: + + async with tractor.net.open_wg_bindspace( + bindspace_spec=bindspace_spec, + layers=layers, + role='listen', + ) as bindspace: + async with tractor.open_root_actor( + bindspace=bindspace, + enable_transports=['uds'], + ) as root_actor: + ... + +Root entry happens before registry probes, IPC listeners, runtime sockets, +or actor startup. On every exit, including cancellation or a body error, +the calling thread is restored to its original network namespace before +``open_root_actor()`` returns. The root context duplicates the live +``Bindspace.namespace_fd`` and never consumes or closes the descriptor +owned by ``open_wg_bindspace()``. Default child processes inherit the root +namespace naturally; passing an explicit alternate child ``bindspace`` +continues to use that spawn backend's existing behavior. Bound roots reject +the persistent ``mp_forkserver`` backend because a server started by an +earlier runtime may retain that runtime's network namespace. + +This is the current low-level composition API. A future convenience API +may accept a tunnel-bearing multiaddr, realize its WireGuard bindspace +internally, and supply that live capability to root startup. + Tunnels and WireGuard --------------------- diff --git a/examples/multihost/wg_lan/README.md b/examples/multihost/wg_lan/README.md index 222e86b7..2ee59047 100644 --- a/examples/multihost/wg_lan/README.md +++ b/examples/multihost/wg_lan/README.md @@ -164,9 +164,23 @@ Four corrections, all from (1:1 proto-key↔type) and `_addr_to_transport` wants a `MsgTransport` per addr-type, which `wg` doesn't have. -## next +## root composition The `TunnelledAddress`, native maddr parser, bindspace lifecycle, and -explicit pyroute2 verification APIs live in `tractor.net`. Root actor -bindspace integration remains future work; callers compose these -lifecycles explicitly for now. +explicit pyroute2 verification APIs live in `tractor.net`. Keep the +owning bindspace context outside the root actor so its namespace FD +remains live through complete actor teardown: + +```python +async with tractor.net.open_wg_bindspace( + bindspace_spec, + layers, + role='listen', +) as bindspace: + async with tractor.open_root_actor(bindspace=bindspace): + ... +``` + +The root actor enters before registry or IPC setup and restores the +calling thread's original namespace before the outer bindspace context +removes owned WireGuard and netns resources. diff --git a/tests/net/test_wg_iface_lifecycle.py b/tests/net/test_wg_iface_lifecycle.py index e7d018b8..f9c78b79 100644 --- a/tests/net/test_wg_iface_lifecycle.py +++ b/tests/net/test_wg_iface_lifecycle.py @@ -8,11 +8,13 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager as acm import os from pathlib import Path +import sys from typing import BinaryIO import pytest import trio +import tractor from tractor.net import ( Bindspace, BindspaceRef, @@ -376,3 +378,99 @@ def test_open_wg_bindspace_nests_resource_lifetimes( 'wg-outer-exit', 'bindspace-exit', ] + + +@pytest.mark.skipif( + sys.platform != 'linux', + reason='network namespaces are Linux-only', +) +def test_public_wg_bindspace_scopes_root_actor( + monkeypatch: pytest.MonkeyPatch, + tpt_proto: str, +) -> None: + ''' + Public network contexts must fully enclose the root runtime. + + Attach the real current netns through `tractor.net`, fake only WG + interface provisioning, and open a real root actor with the yielded + `Bindspace`. The trace and inode checks prove interface setup wraps + actor startup, the runtime occupies the realized bindspace, and root + restoration finishes before network-resource teardown. + + ''' + events: list[str] = [] + bindspace_spec: BindspaceSpec = BindspaceSpec( + kind='netns', + lifecycle='attach', + ) + tunnel_spec: WGTunnelSpec = WGTunnelSpec( + peer_pubkey=_PEER_KEY, + iface='wg-root', + ) + config: WGInterfaceConfig = WGInterfaceConfig( + private_key=_LOCAL_KEY, + ) + + @acm + async def fake_open_wg_iface( + spec: WGTunnelSpec, + iface_config: WGInterfaceConfig, + bindspace: Bindspace, + role: _tunnel.WGRole, + ) -> AsyncIterator[WGTunnelSpec]: + ''' + Trace one WG layer around the real root actor lifetime. + + ''' + assert spec is tunnel_spec + assert iface_config is config + assert role == 'listen' + assert bindspace.namespace_fd is not None + events.append('wg-enter') + try: + yield spec + finally: + events.append('wg-exit') + + monkeypatch.setattr( + _tunnel, + 'open_wg_iface', + fake_open_wg_iface, + ) + + async def main() -> None: + ''' + Compose the public network and root actor context managers. + + ''' + async with tractor.net.open_wg_bindspace( + bindspace_spec=bindspace_spec, + layers=((tunnel_spec, config),), + role='listen', + ) as bindspace: + events.append('bindspace-open') + async with tractor.open_root_actor( + bindspace=bindspace, + enable_transports=[tpt_proto], + ): + events.append('root-open') + assert bindspace.namespace_fd is not None + assert os.fstat( + bindspace.namespace_fd, + ).st_ino == bindspace.ref.inode + assert Path( + '/proc/thread-self/ns/net' + ).stat().st_ino == bindspace.ref.inode + events.append('root-closed') + + events.append('bindspace-closed') + + trio.run(main) + assert events == [ + 'wg-enter', + 'bindspace-open', + 'root-open', + 'root-closed', + 'wg-exit', + 'bindspace-closed', + ] diff --git a/tests/test_lazy_imports.py b/tests/test_lazy_imports.py index 7ed691d3..b2b61f35 100644 --- a/tests/test_lazy_imports.py +++ b/tests/test_lazy_imports.py @@ -12,6 +12,7 @@ from typing import ( get_type_hints, ) +import tractor from tractor.discovery import ( _addr, _multiaddr, @@ -280,4 +281,7 @@ def test_lazy_annotation_names_resolve(): assert get_type_hints(_addr.Address.get_random)[ 'current_actor' ] is Any + assert get_type_hints(tractor.open_root_actor)[ + 'bindspace' + ] == Any|None assert _addr.__annotations__['_address_types'].startswith('dict') diff --git a/tests/test_netns_spawn.py b/tests/test_netns_spawn.py index 6e206d1b..99cef1c0 100644 --- a/tests/test_netns_spawn.py +++ b/tests/test_netns_spawn.py @@ -22,7 +22,10 @@ import pytest import trio import tractor -from tractor import _child +from tractor import ( + _child, + _root, +) from tractor.devx import _proctitle from tractor.net._bindspace import ( Bindspace, @@ -40,7 +43,7 @@ from tractor.spawn import ( from tractor.trionics import patches -_SELF_NETNS_PATH = Path('/proc/self/ns/net') +_SELF_NETNS_PATH = Path('/proc/thread-self/ns/net') _SELF_FD_DIR = Path('/proc/self/fd') @@ -108,6 +111,87 @@ def _bindspace_for_fd(namespace_fd: int) -> Bindspace: ) +def _run_in_unshared_netns( + test_name: str, + reexec_var: str, +) -> bool: + ''' + Re-exec one E2E test with disposable user and net namespaces. + + Return `True` in the outer pytest process after nested pytest + succeeds. Return `False` inside that nested process so the caller + performs the privileged namespace transitions itself. + ''' + if os.environ.get(reexec_var) == '1': + return False + + unshare_path: str|None = shutil.which('unshare') + if unshare_path is None: + pytest.skip('`unshare` is unavailable') + + # Give nested pytest `CAP_SYS_ADMIN` only inside a disposable + # user namespace. Probe separately so hosts disabling + # unprivileged user namespaces skip cleanly. + probe = subprocess.run( + [ + unshare_path, + '--user', + '--map-root-user', + '--net', + 'true', + ], + capture_output=True, + text=True, + check=False, + ) + if probe.returncode: + reason: str = probe.stderr.strip() + pytest.skip( + f'unprivileged user/net namespaces unavailable: ' + f'{reason}' + ) + + nested_env: dict[str, str] = dict(os.environ) + nested_env[reexec_var] = '1' + nested_env['VIRTUAL_ENV'] = sys.prefix + nested_rt_dir: Path = Path( + tempfile.mkdtemp(prefix='tne-') + ) + nested_env['XDG_RUNTIME_DIR'] = str(nested_rt_dir) + python_bin: str = str(Path(sys.executable).parent) + nested_env['PATH'] = ( + python_bin + + os.pathsep + + nested_env['PATH'] + ) + test_id: str = f'tests/test_netns_spawn.py::{test_name}' + try: + subprocess.run( + [ + unshare_path, + '--user', + '--map-root-user', + '--net', + sys.executable, + '-m', + 'pytest', + test_id, + '--spawn-backend=trio', + '--tpt-proto=uds', + '-x', + '--tb=short', + '--no-header', + '--timeout=30', + ], + env=nested_env, + check=True, + ) + finally: + shutil.rmtree(nested_rt_dir) + + return True + + class _MockIpcServer: ''' Provide the peer-event state used by `trio_proc()` tests. @@ -211,6 +295,339 @@ async def _report_child_netns( os.close(child_netns_fd) +def test_root_netns_same_namespace_skips_setns( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Root entry into the current netns must not need privilege. + + Pin the real current namespace and arm `enter_netns()` as a + failure sentinel. The context validates through a duplicate, + yield the current inode without calling `setns()`, preserve the + source capability, and close the duplicates on normal exit. + ''' + namespace_fd: int = os.open( + _SELF_NETNS_PATH, + os.O_RDONLY, + ) + bindspace: Bindspace = _bindspace_for_fd(namespace_fd) + initial_fds: set[int] = _fds_referencing(namespace_fd) + + def fail_enter_netns( + inherited_fd: int, + inode: int, + ) -> int: + ''' + Reject a privileged transition for the already-current netns. + + ''' + raise AssertionError('same-netns entry called `setns()`') + + monkeypatch.setattr(_netns, 'enter_netns', fail_enter_netns) + try: + with _netns._enter_netns_temporarily( + bindspace, + ) as entered_inode: + assert entered_inode == bindspace.ref.inode + assert os.fstat(namespace_fd).st_ino == entered_inode + # The target duplicate and original-netns snapshot both + # reference the already-current namespace. + assert len(_fds_referencing(namespace_fd)) == ( + len(initial_fds) + 2 + ) + + assert _fds_referencing(namespace_fd) == initial_fds + finally: + os.close(namespace_fd) + + +def test_root_netns_restores_after_body_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + A root-body failure must restore netns before it escapes. + + Use distinct regular files as deterministic namespace stand-ins, + replace only `enter_netns()`, and raise a unique error from the + context body. The recorded transitions prove target entry then + original restoration. FD snapshots prove neither temporary handle + leaks, while the caller-owned target FD remains live. + ''' + original_path: Path = tmp_path / 'original-netns' + target_path: Path = tmp_path / 'target-netns' + original_path.touch() + target_path.touch() + namespace_fd: int = os.open(target_path, os.O_RDONLY) + bindspace: Bindspace = _bindspace_for_fd(namespace_fd) + initial_fds: set[int] = _fds_referencing(namespace_fd) + transitions: list[int] = [] + body_error = RuntimeError('root body failed') + + def fake_enter_netns( + inherited_fd: int, + inode: int, + ) -> int: + ''' + Record each verified target or restoration descriptor. + + ''' + assert os.fstat(inherited_fd).st_ino == inode + transitions.append(inode) + return inode + + monkeypatch.setattr(_netns, '_SELF_NETNS', original_path) + monkeypatch.setattr(_netns, 'enter_netns', fake_enter_netns) + try: + with pytest.raises(RuntimeError) as exc_info: + with _netns._enter_netns_temporarily(bindspace): + raise body_error + + assert exc_info.value is body_error + # The fake records target entry before the body, then original + # restoration during context exit. + assert transitions == [ + bindspace.ref.inode, + original_path.stat().st_ino, + ] + assert _fds_referencing(namespace_fd) == initial_fds + assert os.fstat(namespace_fd).st_ino == bindspace.ref.inode + finally: + os.close(namespace_fd) + + +def test_root_netns_restores_on_trio_cancellation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Trio cancellation must not interrupt root-netns restoration. + + Use deterministic namespace stand-ins and cancel the task inside + `_enter_root_bindspace()` immediately before an explicit Trio + checkpoint. The enclosing `CancelScope` catches cancellation only + after async-context exit. Two recorded sync transitions and exact + FD state then prove restoration and close completed first. + ''' + original_path: Path = tmp_path / 'cancel-original-netns' + target_path: Path = tmp_path / 'cancel-target-netns' + original_path.touch() + target_path.touch() + namespace_fd: int = os.open(target_path, os.O_RDONLY) + bindspace: Bindspace = _bindspace_for_fd(namespace_fd) + initial_fds: set[int] = _fds_referencing(namespace_fd) + transitions: list[int] = [] + + def fake_enter_netns( + inherited_fd: int, + inode: int, + ) -> int: + ''' + Record target entry and original-netns restoration. + + ''' + assert os.fstat(inherited_fd).st_ino == inode + transitions.append(inode) + return inode + + monkeypatch.setattr(_netns, '_SELF_NETNS', original_path) + monkeypatch.setattr(_netns, 'enter_netns', fake_enter_netns) + + async def main() -> None: + ''' + Deliver cancellation at a checkpoint inside the netns scope. + + ''' + with trio.CancelScope() as cancel_scope: + async with _root._enter_root_bindspace(bindspace): + cancel_scope.cancel() + await trio.lowlevel.checkpoint() + + assert cancel_scope.cancelled_caught + + try: + trio.run(main) + # Target entry is recorded first; original-netns restoration + # is recorded when `_enter_root_bindspace()` exits. + assert transitions == [ + bindspace.ref.inode, + original_path.stat().st_ino, + ] + assert _fds_referencing(namespace_fd) == initial_fds + assert os.fstat(namespace_fd).st_ino == bindspace.ref.inode + finally: + os.close(namespace_fd) + + +@pytest.mark.parametrize('body_fails', (False, True)) +def test_root_netns_restore_error_precedence( + body_fails: bool, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Netns restoration failure must not hide a root-body failure. + + Model target entry as successful and fail the second transition, + which is restoration. The normal-body case must propagate that + restoration error. The failing-body case must instead preserve + its unique error and attach restoration failure as a note. In both + schedules an exact target-FD snapshot proves cleanup still closes + the context's duplicates. + ''' + original_path: Path = tmp_path / 'failed-restore-original' + target_path: Path = tmp_path / 'failed-restore-target' + original_path.touch() + target_path.touch() + namespace_fd: int = os.open(target_path, os.O_RDONLY) + bindspace: Bindspace = _bindspace_for_fd(namespace_fd) + initial_fds: set[int] = _fds_referencing(namespace_fd) + body_error = RuntimeError('root body failed first') + restore_error = RuntimeError('root netns restore failed') + transitions: int = 0 + + def fail_restore( + inherited_fd: int, + inode: int, + ) -> int: + ''' + Enter the target once, then fail original-netns restoration. + + ''' + nonlocal transitions + assert os.fstat(inherited_fd).st_ino == inode + transitions += 1 + if transitions == 2: + raise restore_error + return inode + + monkeypatch.setattr(_netns, '_SELF_NETNS', original_path) + monkeypatch.setattr(_netns, 'enter_netns', fail_restore) + expected_error: RuntimeError = ( + body_error + if body_fails + else restore_error + ) + try: + with pytest.raises(RuntimeError) as exc_info: + with _netns._enter_netns_temporarily(bindspace): + if body_fails: + raise body_error + + assert exc_info.value is expected_error + assert transitions == 2 + if body_fails: + assert body_error.__notes__ + assert 'restore the original' in body_error.__notes__[0] + assert repr(restore_error) in body_error.__notes__[0] + assert _fds_referencing(namespace_fd) == initial_fds + finally: + os.close(namespace_fd) + + +def test_root_netns_requires_live_bindspace_fd() -> None: + ''' + Root entry cannot use `BindspaceRef.inode` without a live FD. + + Construct a valid ref-only `Bindspace` and enter the real root + namespace scope directly. The concrete live-FD error must occur + before namespace capture, probes, sockets, or actor runtime work. + ''' + key: str = 'missing-root-netns' + bindspace = Bindspace( + spec=BindspaceSpec( + kind='netns', + key=key, + ), + ref=BindspaceRef( + kind='netns', + key=key, + inode=1, + ), + # A stored inode cannot authorize namespace entry. + namespace_fd=None, + ownership='borrowed', + ) + + with pytest.raises( + ValueError, + match='bindspace.namespace_fd.*live netns FD', + ): + # Scope entry must reject the missing live handle. + with _netns._enter_netns_temporarily(bindspace): + pytest.fail('root scope accepted a ref-only bindspace') + + +def test_root_netns_rejects_closed_bindspace_fd() -> None: + ''' + A stale integer is not a live root-netns capability. + + Construct a `Bindspace` while its real current-netns FD is open, + close that caller-owned descriptor, then attempt root entry. The + concrete live-FD error proves `os.dup()` validates the descriptor + at entry time instead of trusting construction-time metadata. + ''' + namespace_fd: int = os.open( + _SELF_NETNS_PATH, + os.O_RDONLY, + ) + bindspace: Bindspace = _bindspace_for_fd(namespace_fd) + os.close(namespace_fd) + + with pytest.raises( + ValueError, + match='bindspace.namespace_fd.*live FD', + ): + with _netns._enter_netns_temporarily(bindspace): + pytest.fail('root scope accepted a closed bindspace FD') + + +def test_bound_root_rejects_persistent_forkserver( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + A persistent multiprocessing forkserver process can retain a + previous root's netns. + + Select `mp_forkserver` before opening a later bound root, modeling + reuse of the forkserver process which `multiprocessing` creates + once and uses for later child starts. The root API must reject that + backend before namespace entry or runtime startup, preventing + default children from silently inheriting its stale namespace. + + ''' + namespace_fd: int = os.open( + _SELF_NETNS_PATH, + os.O_RDONLY, + ) + bindspace: Bindspace = _bindspace_for_fd(namespace_fd) + monkeypatch.setattr( + _spawn, + '_spawn_method', + 'mp_forkserver', + ) + + async def main() -> None: + ''' + Reject the unsafe backend at root-context entry. + + ''' + with pytest.raises( + NotImplementedError, + match='persistent forkserver', + ): + async with tractor.open_root_actor( + bindspace=bindspace, + ): + pytest.fail('bound root started under mp_forkserver') + + try: + trio.run(main) + assert os.fstat(namespace_fd).st_ino == bindspace.ref.inode + finally: + os.close(namespace_fd) + + def test_enter_netns_rejects_mismatched_inherited_fd( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -252,7 +669,7 @@ def test_enter_netns_verifies_post_entry_inode( Successful `setns()` is insufficient without post-entry proof. Use a real inherited FD and fake only the privileged syscall and - `/proc/self/ns/net` observation. The recorded calls prove both + `/proc/thread-self/ns/net` observation. The calls prove both hooks execute and `CLONE_NEWNET` constrains the namespace type; the returned inode proves bootstrap observed the expected netns. @@ -300,7 +717,7 @@ def test_enter_netns_rejects_wrong_post_entry_namespace( Bootstrap must stop when the process lands in an unexpected netns. Let the inherited FD check and fake syscall succeed, then report a - different `/proc/self/ns/net` inode. The post-entry guard must raise + different `/proc/thread-self/ns/net` inode. The guard must raise instead of allowing actor runtime sockets to start in the wrong namespace. @@ -562,76 +979,12 @@ def test_trio_spawn_relays_bindspace_to_child_actor( if start_method != 'trio': pytest.skip('bindspace FD relay is implemented by Trio spawn') - reexec_var: str = 'TRACTOR_TEST_NETNS_E2E_REEXEC' - if os.environ.get(reexec_var) != '1': - unshare_path: str|None = shutil.which('unshare') - if unshare_path is None: - pytest.skip('`unshare` is unavailable') - - # Give nested pytest `CAP_SYS_ADMIN` only inside a disposable - # user namespace. The first `--net` creates the target netns; - # the nested test retains its FD before creating a second netns - # for the parent and child to inherit at spawn. Probe separately - # so hosts disabling unprivileged user namespaces skip cleanly. - probe = subprocess.run( - [ - unshare_path, - '--user', - '--map-root-user', - '--net', - 'true', - ], - capture_output=True, - text=True, - check=False, - ) - if probe.returncode: - reason: str = probe.stderr.strip() - pytest.skip( - f'unprivileged user/net namespaces unavailable: ' - f'{reason}' - ) - - nested_env: dict[str, str] = dict(os.environ) - nested_env[reexec_var] = '1' - nested_env['VIRTUAL_ENV'] = sys.prefix - nested_rt_dir: Path = Path( - tempfile.mkdtemp(prefix='tne-') - ) - nested_env['XDG_RUNTIME_DIR'] = str(nested_rt_dir) - python_bin: str = str(Path(sys.executable).parent) - nested_env['PATH'] = ( - python_bin - + os.pathsep - + nested_env['PATH'] - ) - test_id: str = ( - 'tests/test_netns_spawn.py::' + if _run_in_unshared_netns( + test_name=( 'test_trio_spawn_relays_bindspace_to_child_actor' - ) - try: - subprocess.run( - [ - unshare_path, - '--user', - '--map-root-user', - '--net', - sys.executable, - '-m', - 'pytest', - test_id, - '--spawn-backend=trio', - '--tpt-proto=uds', - '-x', - '--tb=short', - '--no-header', - '--timeout=30', - ], - env=nested_env, - check=True, - ) - finally: - shutil.rmtree(nested_rt_dir) + ), + reexec_var='TRACTOR_TEST_NETNS_E2E_REEXEC', + ): return assert start_method == 'trio' @@ -708,6 +1061,74 @@ def test_trio_spawn_relays_bindspace_to_child_actor( os.close(inherited_fd) +def test_root_actor_enters_and_restores_bindspace( + tpt_proto: str, +) -> None: + ''' + `open_root_actor()` must enter its supplied networking bindspace. + + Re-exec under an unprivileged user/net namespace, retain that + first netns as the target, then move nested pytest into a second. + A real UDS root actor must run its body in the target inode and + keep the source capability open. After full actor teardown, exact + FD and inode assertions prove its duplicate did not leak and the + caller thread returned to the second/original netns. + ''' + if _run_in_unshared_netns( + test_name='test_root_actor_enters_and_restores_bindspace', + reexec_var='TRACTOR_TEST_ROOT_NETNS_E2E_REEXEC', + ): + return + + assert tpt_proto == 'uds' + target_netns_fd: int = os.open( + _SELF_NETNS_PATH, + os.O_RDONLY, + ) + target_netns_inode: int = os.fstat(target_netns_fd).st_ino + reffed_tgt_fds: set[int] = _fds_referencing( + target_netns_fd, + ) + bindspace: Bindspace = _bindspace_for_fd(target_netns_fd) + + # Pin the first disposable netns through `target_netns_fd`, then + # move the caller into a distinct second netns. This gives the root + # one real target to enter and one real caller netns to restore. + os.unshare(os.CLONE_NEWNET) + original_netns_fd: int = os.open( + _SELF_NETNS_PATH, + os.O_RDONLY, + ) + original_netns_inode: int = os.fstat(original_netns_fd).st_ino + assert original_netns_inode != target_netns_inode + + async def main() -> None: + ''' + Inspect the real root runtime inside the target netns. + + ''' + async with tractor.open_root_actor( + bindspace=bindspace, + enable_transports=['uds'], + ): + body_netns_inode: int = _SELF_NETNS_PATH.stat().st_ino + assert body_netns_inode == target_netns_inode + assert os.fstat(target_netns_fd).st_ino == ( + target_netns_inode + ) + + try: + trio.run(main) + assert _SELF_NETNS_PATH.stat().st_ino == original_netns_inode + assert _fds_referencing( + target_netns_fd, + ) == reffed_tgt_fds + assert os.fstat(target_netns_fd).st_ino == target_netns_inode + finally: + os.close(original_netns_fd) + os.close(target_netns_fd) + + def test_trio_spawn_failure_closes_child_netns_fd_in_parent( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tractor/_root.py b/tractor/_root.py index c0524666..13c9132a 100644 --- a/tractor/_root.py +++ b/tractor/_root.py @@ -18,6 +18,9 @@ Root actor runtime ignition(s). ''' +from __future__ import annotations + +from collections.abc import AsyncIterator from contextlib import ( asynccontextmanager as acm, ) @@ -31,6 +34,7 @@ import sys from typing import ( Any, Callable, + TYPE_CHECKING, ) import warnings @@ -63,6 +67,11 @@ from ._exceptions import ( RuntimeFailure, ) +if TYPE_CHECKING: + from .net._bindspace import Bindspace +else: + Bindspace = Any + logger = log.get_logger('tractor') @@ -153,9 +162,30 @@ async def maybe_block_bp( os.environ.pop('PYTHONBREAKPOINT', None) +@acm +async def _enter_root_bindspace( + bindspace: Bindspace|None, +) -> AsyncIterator[None]: + ''' + Adapt synchronous root netns entry to the outer async lifecycle. + + The wrapped context has no checkpoints, so `trio` cancellation + cannot interrupt thread-local namespace restoration. + + ''' + from .spawn._netns import _enter_netns_temporarily + + with _enter_netns_temporarily(bindspace): + yield + + @acm async def open_root_actor( *, + # Low-level realized scope. A future tunnelled-address bootstrap + # may open and supply this capability internally. + bindspace: Bindspace|None = None, + tpt_bind_addrs: list[ Address # `Address.get_random()` case |UnwrappedAddress # registrar case `= uw_reg_addrs` @@ -219,6 +249,10 @@ async def open_root_actor( All (disjoint) actor-process-trees-as-programs are created via this entrypoint. + When `bindspace` is provided, enter its network namespace before + any registry or IPC activity and restore the calling thread's + original namespace after complete actor teardown. + ''' # XXX NEVER allow nested actor-trees! if already_actor := _state.current_actor( @@ -239,10 +273,29 @@ async def open_root_actor( f'_registry_addrs: {registry_addrs!r}\n' ) + effective_start_method: str = ( + os.environ.get('TRACTOR_SPAWN_METHOD') + or start_method + or _spawn._spawn_method + ) + if ( + bindspace is not None + and + effective_start_method == 'mp_forkserver' + ): + raise NotImplementedError( + 'Root actor bindspaces are not supported by the ' + '`mp_forkserver` spawn backend because a persistent ' + 'forkserver may retain its original network namespace!' + ) + # debug.mk_pdb().set_trace() - async with maybe_block_bp( - debug_mode=debug_mode, - maybe_enable_greenback=maybe_enable_greenback, + async with ( + _enter_root_bindspace(bindspace), + maybe_block_bp( + debug_mode=debug_mode, + maybe_enable_greenback=maybe_enable_greenback, + ), ): if enable_transports is None: enable_transports: list[str] = _state.current_ipc_protos() diff --git a/tractor/spawn/_netns.py b/tractor/spawn/_netns.py index 5251fb49..9c3d0f00 100644 --- a/tractor/spawn/_netns.py +++ b/tractor/spawn/_netns.py @@ -20,13 +20,29 @@ Linux network-namespace actor-bootstrap primitives. ''' from __future__ import annotations -from collections.abc import Callable +from collections.abc import ( + Callable, + Iterator, +) +from contextlib import contextmanager as cm +import errno from pathlib import Path import os import sys +from typing import TYPE_CHECKING -_SELF_NETNS: Path = Path('/proc/self/ns/net') +if TYPE_CHECKING: + from ..net._bindspace import Bindspace + + +# `setns(2)` mutates only the calling thread's namespace: +# https://man7.org/linux/man-pages/man2/setns.2.html +# `/proc/thread-self` addresses the caller's current task: +# https://man7.org/linux/man-pages/man5/proc_pid_task.5.html +# Do not use `/proc/self` because it resolves through the process +# leader. +_SELF_NETNS: Path = Path('/proc/thread-self/ns/net') def enter_netns( @@ -36,7 +52,7 @@ def enter_netns( ''' Enter and verify one inherited Linux network namespace. - The future spawn-bootstrap caller owns and closes `namespace_fd`. + The caller owns and closes `namespace_fd`. ''' if sys.platform != 'linux': @@ -102,3 +118,167 @@ def enter_netns( ) return entered_inode + + +@cm +def close_fd( + owned_fd: int, + fd_name: str, +) -> Iterator[None]: + ''' + Close one owned netns FD without masking a prior error. + + ''' + operation: str = ( + f'close owned {fd_name} netns FD {owned_fd}' + ) + try: + yield + except BaseException as primary_error: + try: + os.close(owned_fd) + except BaseException as close_error: + primary_error.add_note( + f'Also failed to {operation}: {close_error!r}' + ) + raise primary_error + else: + try: + os.close(owned_fd) + except BaseException as close_error: + close_error.add_note( + f'Failed to {operation} during root netns cleanup.' + ) + raise close_error + + +@cm +def dup_fd( + source_fd: int, +) -> Iterator[int]: + ''' + Duplicate and own the target netns FD for this context. + + ''' + try: + owned_fd: int = os.dup(source_fd) + except OSError as dup_error: + if dup_error.errno != errno.EBADF: + raise dup_error + raise ValueError( + '`bindspace.namespace_fd` does not reference ' + 'a live FD!' + ) from dup_error + + with close_fd(owned_fd, 'target'): + yield owned_fd + + +@cm +def _enter_netns_temporarily( + bindspace: Bindspace|None, +) -> Iterator[int|None]: + ''' + Enter a root bindspace and restore the caller thread's netns. + + `_root._enter_root_bindspace()` adapts this synchronous scope to + the root actor's async lifecycle. + + Only descriptors opened or duplicated by this context are used + for validation, entry and restoration. Since `setns()` is + thread-local, this synchronous context performs no checkpoints + around either transition. + + ''' + if bindspace is None: + yield None + return + + if sys.platform != 'linux': + raise RuntimeError( + 'Network namespace entry is Linux-only!' + ) + + namespace_fd: int|None = bindspace.namespace_fd + if namespace_fd is None: + raise ValueError( + '`bindspace.namespace_fd` must be a live netns FD for ' + 'root actor entry!' + ) + if ( + type(namespace_fd) is not int + or + namespace_fd < 0 + ): + raise ValueError( + '`bindspace.namespace_fd` must be a live ' + 'non-negative FD!' + ) + + # Borrow `Bindspace.namespace_fd`; duplicate it so this context + # owns target cleanup and cannot close the caller's capability. + # Nested FD scopes aggregate later close failures as notes on the + # first body, restoration or cleanup error. + with dup_fd(namespace_fd) as tgt_fd: + tgt_stat: os.stat_result = os.fstat(tgt_fd) + tgt_inode: int = bindspace.ref.inode + if tgt_stat.st_ino != tgt_inode: + raise ValueError( + f'Target namespace FD inode ' + f'{tgt_stat.st_ino} does ' + f'not match bindspace inode {tgt_inode}!' + ) + + # Capture the calling thread's current netns before any + # transition. It need not be the process's initial netns. This + # context owns the snapshot FD even when no transition is + # needed, so keep it live through restoration and always close + # it afterward. + orig_fd = os.open( + _SELF_NETNS, + os.O_RDONLY | os.O_CLOEXEC, + ) + with close_fd(orig_fd, 'original'): + orig_stat: os.stat_result = os.fstat(orig_fd) + orig_inode: int = orig_stat.st_ino + restore_needed: bool = ( + tgt_stat.st_dev != orig_stat.st_dev + or + tgt_inode != orig_inode + ) + try: + if restore_needed: + enter_netns( + tgt_fd, + tgt_inode, + ) + + yield tgt_inode + + except BaseException as primary_error: + if restore_needed: + try: + enter_netns( + orig_fd, + orig_inode, + ) + except BaseException as restore_error: + primary_error.add_note( + 'Also failed to restore the original ' + 'network namespace: ' + f'{restore_error!r}' + ) + raise primary_error + + if restore_needed: + try: + enter_netns( + orig_fd, + orig_inode, + ) + except BaseException as restore_error: + restore_error.add_note( + 'Failed to restore the original network ' + 'namespace during root netns cleanup.' + ) + raise restore_error