tractor/tractor/discovery/_bindspace.py

282 lines
7.3 KiB
Python

# tractor: structured concurrent "actors".
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or
# modify it under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation, either version 3 of
# the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public
# License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
'''
Serializable bindspace declarations and live capability handles.
'''
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager as acm
import os
from pathlib import Path
import sys
from typing import (
Final,
get_args,
Literal,
TypeAlias,
)
import msgspec
from ..msg._local import ProcessLocal
BindspaceKind: TypeAlias = Literal[
'netns',
]
BindspaceOwnership: TypeAlias = Literal[
'owned', # manager tears down the resource after final release
'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(
kind: BindspaceKind,
) -> None:
'''
Reject platform-resource kinds without an implementation.
'''
if kind not in get_args(BindspaceKind):
raise ValueError(
f'Unsupported bindspace kind: {kind!r}'
)
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(
msgspec.Struct,
frozen=True,
):
'''
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
key: str|None = CURRENT_NETNS
def __post_init__(self) -> None:
'''
Reject an empty platform-resource key.
'''
_validate_bindspace_kind(self.kind)
_validate_bindspace_key(
self.kind,
self.key,
'BindspaceSpec.key',
)
class BindspaceIdentity(
msgspec.Struct,
frozen=True,
):
'''
Serializable stable identity of one realized bindspace.
`.key` is an optional, mutable namespace name. `.inode` is the
required kernel identity which remains stable after rename or
unlink.
'''
kind: BindspaceKind
key: str|None
inode: int
def __post_init__(self) -> None:
'''
Require a stable platform identity and an optional name.
'''
_validate_bindspace_kind(self.kind)
_validate_bindspace_key(
self.kind,
self.key,
'BindspaceIdentity.key',
)
if (
type(self.inode) is not int
or
self.inode <= 0
):
raise ValueError(
'`BindspaceIdentity.inode` must be a positive `int`!'
)
class BindspaceHandle(
ProcessLocal,
):
'''
Process-local capability for one live realized bindspace.
`ProcessLocal` provides compact typed storage plus a default
wire-encoding guard. Explicit FD transfer and handle construction
belong to the supervisor's spawn/bootstrap path.
'''
spec: BindspaceSpec
identity: BindspaceIdentity
namespace_fd: int|None
ownership: BindspaceOwnership
def __post_init__(self) -> None:
'''
Validate and retain one scoped bindspace capability.
'''
spec: BindspaceSpec = self.spec
identity: BindspaceIdentity = self.identity
namespace_fd: int|None = self.namespace_fd
ownership: BindspaceOwnership = self.ownership
if spec.kind != identity.kind:
raise ValueError(
'`BindspaceSpec.kind` does not match '
'`BindspaceIdentity.kind`!'
)
if (
spec.key is not None
and
spec.key != identity.key
):
raise ValueError(
'`BindspaceSpec.key` does not match '
'`BindspaceIdentity.key`!'
)
if ownership not in get_args(BindspaceOwnership):
raise ValueError(
f'Invalid bindspace ownership: {ownership!r}'
)
if namespace_fd is not None:
if (
type(namespace_fd) is not int
or
namespace_fd < 0
):
raise ValueError(
'`namespace_fd` must be non-negative or `None`!'
)
fd_inode: int = os.fstat(namespace_fd).st_ino
if identity.inode != fd_inode:
raise ValueError(
f'Namespace FD inode {fd_inode} does not match '
f'identity inode {identity.inode}!'
)
def __repr__(self) -> str:
'''
Render capability identity without dereferencing its FD.
'''
return (
f'{type(self).__name__}('
f'identity={self.identity!r}, '
f'ownership={self.ownership!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)