tractor/tests/test_netns_spawn.py

580 lines
16 KiB
Python
Raw Normal View History

'''
Pre-runtime Linux network-namespace entry validation.
'''
from __future__ import annotations
import errno
from functools import partial
import os
from pathlib import Path
from types import SimpleNamespace
from typing import (
Any,
BinaryIO,
)
import pytest
from tractor import _child
from tractor.devx import _proctitle
from tractor.spawn import (
_entry,
_netns,
_spawn,
)
from tractor.trionics import patches
def _assert_fd_closed(namespace_fd: int) -> None:
'''
Assert that bootstrap consumed its child-owned descriptor.
'''
with pytest.raises(OSError) as exc_info:
os.fstat(namespace_fd)
assert exc_info.value.errno == errno.EBADF
def test_enter_netns_rejects_mismatched_inherited_fd(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
A stale inherited FD must not enter a replacement namespace.
Open a real stand-in FD, declare a different expected inode and
replace `os.setns()` with a failure sentinel. The inode check must
reject the capability before any irreversible namespace entry.
'''
token_path: Path = tmp_path / 'netns'
token_path.touch()
def fail_setns(namespace_fd: int, nstype: int) -> None:
raise AssertionError('`setns()` must not be called')
monkeypatch.setattr(_netns.os, 'setns', fail_setns)
namespace_file: BinaryIO
with token_path.open('rb') as namespace_file:
inode: int = token_path.stat().st_ino
with pytest.raises(
ValueError,
match=f'{inode}.*{inode + 1}',
):
_netns.enter_netns(
namespace_file.fileno(),
# Deliberately differ from `token_path`'s inode.
inode + 1,
)
def test_enter_netns_verifies_post_entry_inode(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
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
hooks execute and `CLONE_NEWNET` constrains the namespace type;
the returned inode proves bootstrap observed the expected netns.
'''
token_path: Path = tmp_path / 'netns'
token_path.touch()
setns_calls: list[tuple[int, int]] = []
stat_calls: list[Path] = []
def fake_setns(namespace_fd: int, nstype: int) -> None:
setns_calls.append((namespace_fd, nstype))
def fake_stat(path: Path) -> SimpleNamespace:
stat_calls.append(path)
return SimpleNamespace(st_ino=inode)
namespace_file: BinaryIO
with token_path.open('rb') as namespace_file:
namespace_fd: int = namespace_file.fileno()
inode: int = token_path.stat().st_ino
monkeypatch.setattr(_netns.os, 'setns', fake_setns)
monkeypatch.setattr(
type(_netns._SELF_NETNS),
'stat',
fake_stat,
)
entered_inode: int = _netns.enter_netns(
namespace_fd,
inode,
)
assert setns_calls == [
(namespace_fd, _netns.os.CLONE_NEWNET),
]
assert stat_calls == [_netns._SELF_NETNS]
assert entered_inode == inode
def test_enter_netns_rejects_wrong_post_entry_namespace(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
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
instead of allowing actor runtime sockets to start in the wrong
namespace.
'''
token_path: Path = tmp_path / 'netns'
token_path.touch()
def fake_setns(namespace_fd: int, nstype: int) -> None:
return None
monkeypatch.setattr(
_netns.os,
'setns',
fake_setns,
)
namespace_file: BinaryIO
with token_path.open('rb') as namespace_file:
inode: int = token_path.stat().st_ino
def fake_stat(path: Path) -> SimpleNamespace:
return SimpleNamespace(st_ino=inode + 1)
monkeypatch.setattr(
type(_netns._SELF_NETNS),
'stat',
fake_stat,
)
with pytest.raises(
RuntimeError,
match=f'{inode + 1}.*{inode}',
):
_netns.enter_netns(
namespace_file.fileno(),
# Deliberately differ from `fake_stat()`'s inode + 1.
inode,
)
def test_empty_netns_bootstrap_is_a_noop(
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
Ordinary child startup must not attempt namespace entry.
Leave the optional capability unset and arm `enter_netns()` as a
failure sentinel. The bootstrap boundary must return without any
syscall or descriptor ownership work for existing spawn callers.
'''
def fail_enter_netns(namespace_fd: int, inode: int) -> int:
'''
Reject namespace entry without an explicit capability.
'''
raise AssertionError('empty bootstrap attempted netns entry')
monkeypatch.setattr(_entry, 'enter_netns', fail_enter_netns)
assert _entry._consume_netns_bootstrap(None) is None
@pytest.mark.parametrize('namespace_fd', (-1, True, '1'))
def test_invalid_netns_fd_is_never_closed(
namespace_fd: object,
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
Invalid descriptor values must not reach the OS close boundary.
Feed negative, boolean, and non-integer values through the atomic
capability. Preserve the namespace primitive's validation error
without letting `bool` alias stdout or allowing cleanup to mask the
primary failure.
'''
entry_error = ValueError('invalid netns capability')
def fail_enter_netns(namespace_fd: int, inode: int) -> int:
'''
Raise the primary namespace bootstrap error.
'''
raise entry_error
def fail_close(inherited_fd: int) -> None:
'''
Reject cleanup for a value that cannot be an owned FD.
'''
raise AssertionError('invalid namespace FD reached close')
monkeypatch.setattr(_entry, 'enter_netns', fail_enter_netns)
monkeypatch.setattr(
_entry,
'os',
SimpleNamespace(close=fail_close),
)
with pytest.raises(ValueError) as exc_info:
_entry._consume_netns_bootstrap(
(namespace_fd, 1), # type: ignore[arg-type]
)
assert exc_info.value is entry_error
def test_netns_entry_error_survives_close_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
Descriptor cleanup must not mask the primary bootstrap failure.
Raise a unique entry error for an oversized positive integer whose
cleanup also raises `OverflowError`. The entry error must escape
with cleanup context attached instead of being replaced by the
close failure.
'''
namespace_fd: int = 1 << 100
entry_error = ValueError('invalid netns capability')
def fail_enter_netns(inherited_fd: int, inode: int) -> int:
'''
Raise the primary namespace bootstrap error.
'''
assert inherited_fd == namespace_fd
raise entry_error
monkeypatch.setattr(_entry, 'enter_netns', fail_enter_netns)
with pytest.raises(ValueError) as exc_info:
_entry._consume_netns_bootstrap((namespace_fd, 1))
assert exc_info.value is entry_error
assert entry_error.__notes__
assert 'close inherited namespace FD' in entry_error.__notes__[0]
assert 'OverflowError' in entry_error.__notes__[0]
@pytest.mark.parametrize('backend', ('mp', 'trio'))
def test_child_entry_consumes_netns_before_runtime(
backend: str,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
Child bootstrap must enter its netns before runtime side effects.
Give each child entrypoint an exclusively owned stand-in FD. Fake
only namespace entry and every later bootstrap boundary, requiring
the FD to remain open during entry but be closed before actor state,
logging, multiprocessing setup, frame hiding, or `trio.run()`.
This proves verified entry and capability release are one
synchronous prefix of both child startup paths.
'''
token_path: Path = tmp_path / f'{backend}-netns'
token_path.touch()
namespace_fd: int = os.open(token_path, os.O_RDONLY)
expected_inode: int = os.fstat(namespace_fd).st_ino
events: list[str] = []
def fake_enter_netns(
inherited_fd: int,
inode: int,
) -> int:
'''
Record verified entry while the capability remains open.
'''
assert os.fstat(inherited_fd).st_ino == expected_inode
assert inherited_fd == namespace_fd
assert inode == expected_inode
events.append('enter-netns')
return inode
def record(
event: str,
*args: object,
**kwargs: object,
) -> None:
'''
Record one post-entry operation after proving FD release.
'''
_assert_fd_closed(namespace_fd)
events.append(event)
class ActorSpy:
'''
Record multiprocessing actor-state initialization.
'''
loglevel = None
uid = ('netns-child', 'test')
_infected_aio = False
def __setattr__(
self,
name: str,
value: object,
) -> None:
'''
Observe the first multiprocessing entrypoint mutation.
'''
if name == '_forkserver_info':
record('forkserver-info')
object.__setattr__(self, name, value)
class StateSpy:
'''
Record actor publication into runtime-global state.
'''
def __setattr__(
self,
name: str,
value: object,
) -> None:
'''
Observe `_state._current_actor` publication.
'''
record('runtime-state')
object.__setattr__(self, name, value)
def fake_current_process() -> str:
'''
Return one display value for multiprocessing startup logging.
'''
return 'fake-child-process'
def fake_start_method(start_method: str) -> SimpleNamespace:
'''
Record multiprocessing setup after namespace entry.
'''
record('start-method')
return SimpleNamespace(
current_process=fake_current_process,
)
def fake_actor(**kwargs: object) -> ActorSpy:
'''
Record Trio child actor construction after namespace entry.
'''
record('actor-construction')
return ActorSpy()
monkeypatch.setattr(_entry, 'enter_netns', fake_enter_netns)
monkeypatch.setattr(_entry, '_state', StateSpy())
monkeypatch.setattr(
_entry._frame_stack,
'hide_runtime_frames',
partial(record, 'hide-frames'),
)
monkeypatch.setattr(
_entry.trio,
'run',
partial(record, 'trio-run'),
)
monkeypatch.setattr(
_entry,
'log',
SimpleNamespace(
info=partial(record, 'log'),
cancel=partial(record, 'log'),
error=partial(record, 'log'),
),
)
monkeypatch.setattr(
_spawn,
'try_set_start_method',
fake_start_method,
)
monkeypatch.setattr(
patches,
'apply_all',
partial(record, 'trio-patches'),
)
monkeypatch.setattr(_child, 'Actor', fake_actor)
monkeypatch.setattr(
_proctitle,
'set_actor_proctitle',
partial(record, 'proctitle'),
)
monkeypatch.setattr(
_child,
'_trio_main',
partial(record, 'trio-main'),
)
actor: Any = ActorSpy()
bootstrap: tuple[int, int] = (
namespace_fd,
expected_inode,
)
if backend == 'mp':
_entry._mp_main(
actor,
[],
(None, None, None, None, None),
'mp_spawn',
netns_bootstrap=bootstrap,
)
first_runtime_event: str = 'forkserver-info'
terminal_event: str = 'trio-run'
else:
_child._actor_child_main(
uid=actor.uid,
loglevel=actor.loglevel,
parent_addr=None,
infect_asyncio=False,
netns_bootstrap=bootstrap,
)
first_runtime_event = 'trio-patches'
terminal_event = 'trio-main'
assert events[:2] == [
'enter-netns',
first_runtime_event,
]
assert events.count(terminal_event) == 1
_assert_fd_closed(namespace_fd)
@pytest.mark.parametrize('backend', ('mp', 'trio'))
def test_child_entry_failure_closes_netns_fd_before_runtime(
backend: str,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
Failed namespace entry must close its FD and abort child startup.
Raise a unique error from the namespace boundary while a real
stand-in FD is open. Arm each entrypoint's first later operation as
a failure sentinel, then prove the original error escapes, the
descriptor is closed, and no actor, multiprocessing, frame, or
Trio runtime initialization begins.
'''
token_path: Path = tmp_path / f'{backend}-failed-netns'
token_path.touch()
namespace_fd: int = os.open(token_path, os.O_RDONLY)
expected_inode: int = os.fstat(namespace_fd).st_ino
entry_error = RuntimeError('netns entry failed')
events: list[str] = []
def fail_enter_netns(
inherited_fd: int,
inode: int,
) -> int:
'''
Fail entry while proving the child-owned FD is still open.
'''
assert os.fstat(inherited_fd).st_ino == expected_inode
assert inherited_fd == namespace_fd
assert inode == expected_inode
events.append('enter-netns')
raise entry_error
def fail_after_entry(*args: object, **kwargs: object) -> None:
'''
Reject any runtime operation after failed namespace entry.
'''
raise AssertionError('child runtime started after netns failure')
class ActorSpy:
'''
Reject multiprocessing actor-state initialization.
'''
loglevel = None
uid = ('failed-netns-child', 'test')
def __setattr__(
self,
name: str,
value: object,
) -> None:
'''
Reject the first multiprocessing entrypoint mutation.
'''
if name == '_forkserver_info':
fail_after_entry()
object.__setattr__(self, name, value)
monkeypatch.setattr(_entry, 'enter_netns', fail_enter_netns)
monkeypatch.setattr(
_entry._frame_stack,
'hide_runtime_frames',
fail_after_entry,
)
monkeypatch.setattr(
_spawn,
'try_set_start_method',
fail_after_entry,
)
monkeypatch.setattr(
patches,
'apply_all',
fail_after_entry,
)
monkeypatch.setattr(_child, 'Actor', fail_after_entry)
monkeypatch.setattr(
_proctitle,
'set_actor_proctitle',
fail_after_entry,
)
monkeypatch.setattr(
_child,
'_trio_main',
fail_after_entry,
)
actor: Any = ActorSpy()
bootstrap: tuple[int, int] = (
namespace_fd,
expected_inode,
)
with pytest.raises(RuntimeError) as exc_info:
if backend == 'mp':
_entry._mp_main(
actor,
[],
(None, None, None, None, None),
'mp_spawn',
netns_bootstrap=bootstrap,
)
else:
_child._actor_child_main(
uid=actor.uid,
loglevel=actor.loglevel,
parent_addr=None,
infect_asyncio=False,
netns_bootstrap=bootstrap,
)
assert exc_info.value is entry_error
assert events == ['enter-netns']
_assert_fd_closed(namespace_fd)