hpc-sandbox 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ """Rootless sandboxes for HPC clusters, on Singularity/Apptainer or enroot.
2
+
3
+ The names in ``__all__`` are the public API. ``hpc_sandbox.__version__`` is the running
4
+ hpc-sandbox's version: a source checkout's ``pyproject.toml``, else the installed distribution's.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from .enroot import EnrootProvider, EnrootRuntime, EnrootSandbox, EnrootService, SquashfsImages
11
+ from .images import ImageFormat, ImageStore, ResolvedImage, SifImages
12
+ from .pods import PodService, SessionPod
13
+ from .runtime import ContainerRuntime
14
+ from .sessions import JanitorReport
15
+ from .singularity import SingularityProvider, SingularitySandbox, SingularityService
16
+ from .types import (
17
+ CWD_ERROR_EXIT_CODE,
18
+ CWD_ERROR_PREFIX,
19
+ CWD_ERROR_REASONS,
20
+ DownloadReport,
21
+ DroppedLink,
22
+ ExecResult,
23
+ HealthCheck,
24
+ Mount,
25
+ Pod,
26
+ PodNetwork,
27
+ PodProvider,
28
+ PodSpec,
29
+ Sandbox,
30
+ SandboxError,
31
+ SandboxLostError,
32
+ SandboxProvider,
33
+ SandboxSnapshot,
34
+ SandboxSpec,
35
+ SandboxStartError,
36
+ ServiceHealth,
37
+ ServiceSpec,
38
+ )
39
+
40
+ __all__ = [
41
+ "CWD_ERROR_EXIT_CODE",
42
+ "CWD_ERROR_PREFIX",
43
+ "CWD_ERROR_REASONS",
44
+ "ContainerRuntime",
45
+ "DownloadReport",
46
+ "DroppedLink",
47
+ "EnrootProvider",
48
+ "EnrootRuntime",
49
+ "EnrootSandbox",
50
+ "EnrootService",
51
+ "ExecResult",
52
+ "HealthCheck",
53
+ "ImageFormat",
54
+ "ImageStore",
55
+ "JanitorReport",
56
+ "Mount",
57
+ "Pod",
58
+ "PodNetwork",
59
+ "PodProvider",
60
+ "PodService",
61
+ "PodSpec",
62
+ "ResolvedImage",
63
+ "Sandbox",
64
+ "SandboxError",
65
+ "SandboxLostError",
66
+ "SandboxProvider",
67
+ "SandboxSnapshot",
68
+ "SandboxSpec",
69
+ "SandboxStartError",
70
+ "ServiceHealth",
71
+ "ServiceSpec",
72
+ "SessionPod",
73
+ "SifImages",
74
+ "SingularityProvider",
75
+ "SingularitySandbox",
76
+ "SingularityService",
77
+ "SquashfsImages",
78
+ ]
79
+
80
+ # Where a source checkout keeps its pyproject.toml: this file is src/hpc_sandbox/__init__.py.
81
+ # Not parents[2], which raises IndexError when the package sits right below / (/hpc_sandbox).
82
+ _CHECKOUT_PYPROJECT = Path(__file__).resolve().parent.parent.parent / "pyproject.toml"
83
+
84
+
85
+ def _running_version(pyproject: Path = _CHECKOUT_PYPROJECT) -> str:
86
+ """The version of the running hpc-sandbox: that of ``pyproject`` when it is hpc-sandbox's (a
87
+ source checkout, which may shadow an installed copy whose metadata is stale), else the installed
88
+ distribution's, else ``"0+unknown"``."""
89
+ import importlib.metadata
90
+ import tomllib
91
+
92
+ try:
93
+ project = tomllib.loads(pyproject.read_text(encoding="utf-8")).get("project")
94
+ except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
95
+ project = None
96
+ if (
97
+ isinstance(project, dict)
98
+ and project.get("name") == "hpc-sandbox"
99
+ and project.get("version")
100
+ ):
101
+ return str(project["version"])
102
+ try:
103
+ return importlib.metadata.version("hpc-sandbox")
104
+ except importlib.metadata.PackageNotFoundError:
105
+ return "0+unknown"
106
+
107
+
108
+ def __getattr__(name: str) -> str:
109
+ if name == "__version__": # read on first use, then kept as a plain attribute
110
+ version = globals()["__version__"] = _running_version()
111
+ return version
112
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
113
+
114
+
115
+ if TYPE_CHECKING:
116
+ __version__: str
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
hpc_sandbox/anchor.py ADDED
@@ -0,0 +1,322 @@
1
+ """Create a sandbox's namespaces and run its container as their PID 1.
2
+
3
+ :mod:`hpc_sandbox.enroot` runs this file as a script in a fresh interpreter::
4
+
5
+ python -I -S anchor.py PARENT_PID NETWORK -- COMMAND [ARG...]
6
+ python -I -S anchor.py PARENT_PID join HOLDER_PID [OPTION...] -- COMMAND [ARG...]
7
+
8
+ The first form starts a sandbox of its own. It
9
+
10
+ 1. arranges to be SIGKILLed when its parent (the provider process) dies, and
11
+ exits at once if that already happened;
12
+ 2. creates new user, mount, PID and IPC namespaces, mapping the calling user
13
+ and group to themselves, plus a network namespace whose only interface is
14
+ an up loopback when NETWORK is ``none``;
15
+ 3. forks the PID namespace's init, which arranges to be SIGKILLed if this
16
+ process dies, mounts a fresh ``/proc`` and executes COMMAND (``enroot
17
+ start ...`` with a keep-alive loop as the container's command);
18
+ 4. waits for it and exits with its status.
19
+
20
+ When the init process dies, the kernel kills every process in its PID
21
+ namespace, so killing it stops the whole sandbox. Only unprivileged user
22
+ namespaces are needed: the ``unshare`` command line is avoided because the
23
+ options this needs are missing from older util-linux releases.
24
+
25
+ The ``join`` form starts a service of a pod (see :mod:`hpc_sandbox.holder`):
26
+ it joins the user and network namespaces of the pod's holder, creates mount,
27
+ PID, IPC and UTS namespaces, and forks an init of its own as their PID 1. The
28
+ init sets the service's hostname (``--hostname NAME``), resource limits
29
+ (``--rlimit NAME=SOFT:HARD``, within the hard limits it has) and an optional
30
+ sized tmpfs (``--shm DIR:BYTES``, for the runtime to bind at ``/dev/shm``),
31
+ then runs COMMAND as its child, in a nested user namespace mapping root to
32
+ root with ``--userns``, with its output through a pipe this process copies to
33
+ its own (so the service cannot truncate the log, and no file size limit of the
34
+ service applies to it). Root in a nested user namespace holds no capability
35
+ over the pod's network. The init forwards SIGTERM, SIGINT and SIGHUP to the
36
+ child, reaps orphans, and exits with the child's status; the runtime is never
37
+ PID 1, so killing the init always ends the service.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import ctypes
43
+ import fcntl
44
+ import os
45
+ import resource
46
+ import select
47
+ import signal
48
+ import socket
49
+ import struct
50
+ import sys
51
+
52
+ CLONE_NEWNS = 0x00020000
53
+ CLONE_NEWUTS = 0x04000000
54
+ CLONE_NEWIPC = 0x08000000
55
+ CLONE_NEWUSER = 0x10000000
56
+ CLONE_NEWPID = 0x20000000
57
+ CLONE_NEWNET = 0x40000000
58
+ MS_NOSUID = 0x2
59
+ MS_NODEV = 0x4
60
+ MS_NOEXEC = 0x8
61
+ MS_REC = 0x4000
62
+ MS_PRIVATE = 0x40000
63
+ PR_SET_PDEATHSIG = 1
64
+ SIOCGIFFLAGS = 0x8913
65
+ SIOCSIFFLAGS = 0x8914
66
+ IFF_UP = 0x1
67
+
68
+ _libc = ctypes.CDLL(None, use_errno=True)
69
+
70
+
71
+ def _check(result: int, what: str) -> None:
72
+ if result != 0:
73
+ errno = ctypes.get_errno()
74
+ raise OSError(errno, f"{what}: {os.strerror(errno)}")
75
+
76
+
77
+ def _write(path: str, text: str) -> None:
78
+ with open(path, "w") as stream:
79
+ stream.write(text)
80
+
81
+
82
+ def _die_with_parent() -> None:
83
+ _check(_libc.prctl(PR_SET_PDEATHSIG, signal.SIGKILL, 0, 0, 0), "prctl(PR_SET_PDEATHSIG)")
84
+
85
+
86
+ def _loopback_up() -> None:
87
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
88
+ request = struct.pack("16sh", b"lo", 0)
89
+ flags = struct.unpack("16sh", fcntl.ioctl(sock, SIOCGIFFLAGS, request))[1]
90
+ fcntl.ioctl(sock, SIOCSIFFLAGS, struct.pack("16sh", b"lo", flags | IFF_UP))
91
+
92
+
93
+ def _set_rlimit(name: str, soft: int, hard: int) -> None:
94
+ """Set a limit (-1: unlimited) within the hard limit this process already has."""
95
+ which = getattr(resource, f"RLIMIT_{name.upper()}", None)
96
+ if which is None:
97
+ raise OSError(22, f"unknown resource limit {name}")
98
+ infinity = resource.RLIM_INFINITY
99
+ soft, hard = (infinity if value < 0 else value for value in (soft, hard))
100
+ current = resource.getrlimit(which)[1]
101
+ if current != infinity and (hard == infinity or hard > current):
102
+ hard = current
103
+ if soft == infinity or (hard != infinity and soft > hard):
104
+ soft = hard
105
+ try:
106
+ resource.setrlimit(which, (soft, hard))
107
+ except ValueError as error:
108
+ raise OSError(22, f"limit {name}: {error}") from None
109
+
110
+
111
+ def _setns(path: str, nstype: int) -> None:
112
+ fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC)
113
+ try:
114
+ _check(_libc.setns(fd, nstype), f"setns({path})")
115
+ finally:
116
+ os.close(fd)
117
+
118
+
119
+ def _join(argv: list[str]) -> int:
120
+ """The ``join`` form: a pod service below the holder ``argv[0]``."""
121
+ holder, options, command = argv[0], argv[1 : argv.index("--")], argv[argv.index("--") + 1 :]
122
+ hostname, rlimits, shm, nested = None, [], None, False
123
+ while options:
124
+ option = options.pop(0)
125
+ if option == "--hostname":
126
+ hostname = options.pop(0)
127
+ elif option == "--rlimit":
128
+ name, _, values = options.pop(0).partition("=")
129
+ soft, _, hard = values.partition(":")
130
+ rlimits.append((name, int(soft), int(hard)))
131
+ elif option == "--shm":
132
+ directory, _, size = options.pop(0).rpartition(":")
133
+ shm = (directory, int(size))
134
+ elif option == "--userns":
135
+ nested = True
136
+ else:
137
+ raise OSError(22, f"unknown option {option}")
138
+ # The user namespace first: it owns the network namespace.
139
+ _setns(f"/proc/{holder}/ns/user", CLONE_NEWUSER)
140
+ _setns(f"/proc/{holder}/ns/net", CLONE_NEWNET)
141
+ _check(_libc.unshare(CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS), "unshare")
142
+ _check(_libc.mount(b"none", b"/", None, MS_REC | MS_PRIVATE, None), "make / private")
143
+ # The service writes to a pipe this process copies to its own output, as
144
+ # Docker's shim does: what the service opens as /dev/stdout is the pipe.
145
+ output_read, output_write = os.pipe()
146
+ alive_read, alive_write = os.pipe()
147
+ init = os.fork()
148
+ if init == 0:
149
+ os.close(alive_write)
150
+ os.close(output_read)
151
+ try:
152
+ _die_with_parent()
153
+ if select.select([alive_read], [], [], 0)[0]:
154
+ os._exit(1)
155
+ os.close(alive_read)
156
+ os.dup2(output_write, 1)
157
+ os.dup2(output_write, 2)
158
+ os.close(output_write)
159
+ os._exit(_service_init(hostname, rlimits, shm, nested, command))
160
+ except OSError as error:
161
+ print(f"hpc-sandbox: {error}", file=sys.stderr)
162
+ os._exit(1)
163
+ os.close(alive_read)
164
+ os.close(output_write)
165
+ # Until everything in the service is gone (no threads here: this process
166
+ # can no longer start any, its children being in another PID namespace).
167
+ _copy(output_read, 1)
168
+ return _wait(init)
169
+
170
+
171
+ def _copy(source: int, target: int) -> None:
172
+ """Copy ``source`` to ``target`` until its end, dropping what cannot be written."""
173
+ while chunk := os.read(source, 65536):
174
+ view = memoryview(chunk)
175
+ while view:
176
+ try:
177
+ written = os.write(target, view)
178
+ except OSError:
179
+ break
180
+ view = view[written:]
181
+
182
+
183
+ def _service_init(
184
+ hostname: str | None,
185
+ rlimits: list[tuple[str, int, int]],
186
+ shm: tuple[str, int] | None,
187
+ nested: bool,
188
+ command: list[str],
189
+ ) -> int:
190
+ """PID 1 of a service: set it up, run COMMAND as a child, pass on signals, reap."""
191
+ _check(
192
+ _libc.mount(b"proc", b"/proc", b"proc", MS_NOSUID | MS_NODEV | MS_NOEXEC, None),
193
+ "mount /proc",
194
+ )
195
+ if hostname:
196
+ name = hostname.encode()
197
+ _check(_libc.sethostname(name, len(name)), "sethostname")
198
+ for name, soft, hard in rlimits:
199
+ _set_rlimit(name, soft, hard)
200
+ if shm is not None:
201
+ directory, size = shm
202
+ options = f"size={size},mode=1777".encode()
203
+ _check(
204
+ _libc.mount(b"tmpfs", directory.encode(), b"tmpfs", MS_NOSUID | MS_NODEV, options),
205
+ f"mount tmpfs at {directory}",
206
+ )
207
+ child = os.fork()
208
+ if child == 0:
209
+ try:
210
+ _die_with_parent()
211
+ if nested:
212
+ _check(_libc.unshare(CLONE_NEWUSER), "unshare (nested user namespace)")
213
+ _write("/proc/self/setgroups", "deny")
214
+ _write("/proc/self/uid_map", "0 0 1")
215
+ _write("/proc/self/gid_map", "0 0 1")
216
+ for ignored in (signal.SIGPIPE, signal.SIGXFSZ):
217
+ signal.signal(ignored, signal.SIG_DFL)
218
+ os.execvp(command[0], command)
219
+ except OSError as error:
220
+ print(f"hpc-sandbox: cannot run {command[0]}: {error}", file=sys.stderr)
221
+ os._exit(127)
222
+ for forwarded in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
223
+ signal.signal(forwarded, lambda signum, _frame: _forward(child, signum))
224
+ status = 0
225
+ while True: # reap orphans until the command itself exits
226
+ try:
227
+ pid, raw = os.waitpid(-1, 0)
228
+ except InterruptedError:
229
+ continue
230
+ except ChildProcessError:
231
+ break
232
+ if pid == child:
233
+ status = 128 + os.WTERMSIG(raw) if os.WIFSIGNALED(raw) else os.WEXITSTATUS(raw)
234
+ break
235
+ return status
236
+
237
+
238
+ def _forward(child: int, signum: int) -> None:
239
+ try:
240
+ os.kill(child, signum)
241
+ except ProcessLookupError:
242
+ pass
243
+
244
+
245
+ def _wait(child: int) -> int:
246
+ while True:
247
+ try:
248
+ _, status = os.waitpid(child, 0)
249
+ break
250
+ except InterruptedError:
251
+ continue
252
+ if os.WIFSIGNALED(status):
253
+ return 128 + os.WTERMSIG(status)
254
+ return os.WEXITSTATUS(status)
255
+
256
+
257
+ def main(argv: list[str]) -> int:
258
+ if len(argv) >= 5 and argv[1] == "join" and "--" in argv[3:]:
259
+ _die_with_parent()
260
+ if os.getppid() != int(argv[0]):
261
+ return 1 # the parent died before PR_SET_PDEATHSIG took effect
262
+ return _join(argv[2:])
263
+ if len(argv) < 4 or argv[2] != "--" or argv[1] not in ("host", "none"):
264
+ print(
265
+ "usage: anchor.py PARENT_PID host|none -- COMMAND [ARG...]\n"
266
+ " anchor.py PARENT_PID join HOLDER_PID [OPTION...] -- COMMAND [ARG...]",
267
+ file=sys.stderr,
268
+ )
269
+ return 2
270
+ parent, network, command = int(argv[0]), argv[1], argv[3:]
271
+ _die_with_parent()
272
+ if os.getppid() != parent:
273
+ return 1 # the parent died before PR_SET_PDEATHSIG took effect
274
+ uid, gid = os.getuid(), os.getgid()
275
+ flags = CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWIPC
276
+ if network == "none":
277
+ flags |= CLONE_NEWNET
278
+ _check(_libc.unshare(flags), "unshare (are unprivileged user namespaces enabled?)")
279
+ _write("/proc/self/setgroups", "deny")
280
+ _write("/proc/self/uid_map", f"{uid} {uid} 1")
281
+ _write("/proc/self/gid_map", f"{gid} {gid} 1")
282
+ _check(_libc.mount(b"none", b"/", None, MS_REC | MS_PRIVATE, None), "make / private")
283
+ if network == "none":
284
+ _loopback_up()
285
+ # The init process watches this pipe: EOF means this process is already gone.
286
+ alive_read, alive_write = os.pipe()
287
+ child = os.fork()
288
+ if child == 0:
289
+ os.close(alive_write)
290
+ _die_with_parent()
291
+ if select.select([alive_read], [], [], 0)[0]:
292
+ os._exit(1)
293
+ os.close(alive_read)
294
+ _check(
295
+ _libc.mount(b"proc", b"/proc", b"proc", MS_NOSUID | MS_NODEV | MS_NOEXEC, None),
296
+ "mount /proc",
297
+ )
298
+ for ignored in (signal.SIGPIPE, signal.SIGXFSZ): # ignored by Python, not by containers
299
+ signal.signal(ignored, signal.SIG_DFL)
300
+ try:
301
+ os.execvp(command[0], command)
302
+ except OSError as error:
303
+ print(f"hpc-sandbox: cannot run {command[0]}: {error}", file=sys.stderr)
304
+ os._exit(127)
305
+ os.close(alive_read)
306
+ while True:
307
+ try:
308
+ _, status = os.waitpid(child, 0)
309
+ break
310
+ except InterruptedError:
311
+ continue
312
+ if os.WIFSIGNALED(status):
313
+ return 128 + os.WTERMSIG(status)
314
+ return os.WEXITSTATUS(status)
315
+
316
+
317
+ if __name__ == "__main__":
318
+ try:
319
+ sys.exit(main(sys.argv[1:]))
320
+ except OSError as error:
321
+ print(f"hpc-sandbox: {error}", file=sys.stderr)
322
+ sys.exit(1)