1162 lines
46 KiB
Python
Executable File
1162 lines
46 KiB
Python
Executable File
#!/usr/bin/python3
|
|
|
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
|
"""bubblewrap stand-in for pressure-vessel on hosts without user namespaces.
|
|
|
|
Steam runs its client helper and every game through pressure-vessel, which
|
|
builds a bwrap(1) command line describing a container -- bind mounts, symlinks,
|
|
generated files, environment edits, and the command to run -- and executes it.
|
|
bwrap needs a user namespace or CAP_SYS_ADMIN, and a container's default
|
|
seccomp profile grants neither, so pressure-vessel fails at that very last step.
|
|
pressure-vessel consults ``$BWRAP`` when its bundled bwrap does not work; this
|
|
program takes that place, accepts the same command line, and runs the command
|
|
in an equivalent tree without namespaces.
|
|
|
|
The bwrap description is materialized as a root directory under the runtime
|
|
directory: ``--dir``, ``--symlink``, ``--tmpfs``, and the data file options
|
|
become real entries, and the bind mounts are handed to one of two executors.
|
|
|
|
* proot, the default: every bind is a proot ``-b`` binding and the root
|
|
directory is the guest root, which confines paths for every process rather
|
|
than only those that go through libc. It needs ptrace, and it loads a
|
|
program through a loader of its own, whose temp file every released proot
|
|
leaves named in AT_EXECFN -- the multi-call coreutils of Ubuntu 25.10 and
|
|
later read that to decide which tool they are, so nothing that runs them
|
|
works under such a build. A proot is probed for that before it is used.
|
|
* fakechroot, where no proot can do that: an LD_PRELOAD library prefixes every
|
|
path a dynamically linked program opens with the root directory, and binds
|
|
become symlinks in it. The dynamic loader and the kernel are not rewritten,
|
|
so what they follow must resolve on the host: library search paths are given
|
|
as root-prefixed host paths, absolute symlink targets inside the root are
|
|
prefixed with it, and the ld.so cache regeneration is replaced by a complete
|
|
LD_LIBRARY_PATH. The host's glibc is what the container runs on in this mode,
|
|
which pressure-vessel already chooses whenever the host's is newer than the
|
|
runtime's. It also runs the one container proot cannot, the Steam client's
|
|
browser helper, whatever the rest of the launch uses.
|
|
|
|
Real bubblewrap is used whenever it works: pressure-vessel tries its own copy
|
|
first and reads ``$BWRAP`` only after that fails. ``PROOT_BWRAP_BACKEND``
|
|
forces ``proot`` or ``fakechroot``; ``PROOT_BWRAP_DEBUG`` prints the
|
|
executor command line.
|
|
"""
|
|
|
|
import fcntl
|
|
import hashlib
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
|
|
# json and subprocess are imported where they are used. Every Steam container
|
|
# launch runs this several times before anything starts, and neither module is
|
|
# needed on the path that a launch actually takes.
|
|
|
|
VERSION = "bubblewrap 0.9.0 (proot-bwrap)"
|
|
PROGRAM = "proot-bwrap"
|
|
|
|
# Options with no argument.
|
|
FLAGS = {
|
|
"--unshare-all", "--share-net", "--unshare-user", "--unshare-user-try",
|
|
"--unshare-ipc", "--unshare-pid", "--unshare-net", "--unshare-uts",
|
|
"--unshare-cgroup", "--unshare-cgroup-try", "--disable-userns",
|
|
"--assert-userns-disabled", "--clearenv", "--new-session",
|
|
"--die-with-parent", "--as-pid-1", "--level-prefix",
|
|
"--not-a-security-boundary",
|
|
}
|
|
ONE_ARG = {
|
|
"--args", "--argv0", "--userns", "--userns2", "--pidns", "--uid", "--gid",
|
|
"--hostname", "--chdir", "--unsetenv", "--lock-file", "--sync-fd",
|
|
"--remount-ro", "--exec-label", "--file-label", "--proc", "--dev",
|
|
"--tmpfs", "--mqueue", "--dir", "--seccomp", "--add-seccomp-fd",
|
|
"--block-fd", "--userns-block-fd", "--info-fd", "--json-status-fd",
|
|
"--cap-add", "--cap-drop", "--perms", "--size", "--tmp-overlay",
|
|
"--ro-overlay", "--overlay-src",
|
|
}
|
|
TWO_ARGS = {
|
|
"--setenv", "--bind", "--bind-try", "--dev-bind", "--dev-bind-try",
|
|
"--ro-bind", "--ro-bind-try", "--bind-fd", "--ro-bind-fd", "--file",
|
|
"--bind-data", "--ro-bind-data", "--symlink", "--chmod",
|
|
}
|
|
THREE_ARGS = {"--overlay"}
|
|
# Accepted for compatibility with no effect: each needs a namespace, a
|
|
# capability set, a seccomp filter, or a security label, and nothing here has one.
|
|
INERT = {
|
|
"--unshare-all", "--unshare-user", "--unshare-user-try", "--unshare-ipc",
|
|
"--unshare-pid", "--unshare-net", "--unshare-uts", "--unshare-cgroup",
|
|
"--unshare-cgroup-try", "--disable-userns", "--assert-userns-disabled",
|
|
"--as-pid-1", "--userns", "--userns2", "--pidns", "--uid", "--gid",
|
|
"--hostname", "--remount-ro", "--cap-add", "--cap-drop", "--seccomp",
|
|
"--add-seccomp-fd", "--exec-label", "--file-label",
|
|
}
|
|
BINDS = {"--bind", "--bind-try", "--dev-bind", "--dev-bind-try", "--ro-bind",
|
|
"--ro-bind-try", "--bind-fd", "--ro-bind-fd"}
|
|
DATA_FILES = {"--file", "--bind-data", "--ro-bind-data"}
|
|
# Options that create the next filesystem entry, which --perms applies to.
|
|
CREATES = {"--dir", "--tmpfs", "--symlink"} | DATA_FILES
|
|
|
|
HELP = f"""usage: {PROGRAM} [OPTIONS...] [--] COMMAND [ARGS...]
|
|
|
|
bubblewrap-compatible front end that runs COMMAND in the described tree
|
|
through proot or fakechroot instead of namespaces. Accepted options:
|
|
"""
|
|
|
|
|
|
class UsageError(Exception):
|
|
"""A launch bubblewrap itself would refuse."""
|
|
|
|
|
|
def log(message):
|
|
sys.stderr.write(f"{PROGRAM}: {message}\n")
|
|
sys.stderr.flush()
|
|
|
|
|
|
def debug(message):
|
|
if os.environ.get("PROOT_BWRAP_DEBUG"):
|
|
log(message)
|
|
|
|
|
|
def read_fd(fd):
|
|
"""Read an inherited descriptor to its end, from its current position."""
|
|
chunks = []
|
|
while True:
|
|
chunk = os.read(fd, 65536)
|
|
if not chunk:
|
|
return b"".join(chunks)
|
|
chunks.append(chunk)
|
|
|
|
|
|
def parse(argv):
|
|
"""Turn bubblewrap's command line into ordered operations and a command.
|
|
|
|
Returns:
|
|
A tuple of (operations, command): operations are
|
|
(option, arguments, perms) triples in command-line order, perms being
|
|
the octal mode a preceding ``--perms`` requested for entries that
|
|
option creates, or None.
|
|
|
|
Raises:
|
|
UsageError: on an unknown option or a missing argument.
|
|
"""
|
|
args = list(argv)
|
|
ops = []
|
|
perms = None
|
|
i = 0
|
|
while i < len(args):
|
|
arg = args[i]
|
|
if arg == "--":
|
|
i += 1
|
|
break
|
|
if not arg.startswith("--"):
|
|
break
|
|
if arg == "--args":
|
|
if i + 1 >= len(args):
|
|
raise UsageError("--args takes an argument")
|
|
fd = int(args[i + 1])
|
|
spliced = [a.decode("utf-8", "surrogateescape")
|
|
for a in read_fd(fd).split(b"\0") if a]
|
|
os.close(fd)
|
|
args[i:i + 2] = spliced
|
|
continue
|
|
if arg in FLAGS:
|
|
count = 0
|
|
elif arg in ONE_ARG:
|
|
count = 1
|
|
elif arg in TWO_ARGS:
|
|
count = 2
|
|
elif arg in THREE_ARGS:
|
|
count = 3
|
|
else:
|
|
raise UsageError(f"Unknown option {arg}")
|
|
if i + count >= len(args):
|
|
raise UsageError(f"{arg} takes {count} argument(s)")
|
|
values = args[i + 1:i + 1 + count]
|
|
i += 1 + count
|
|
if arg == "--perms":
|
|
perms = int(values[0], 8)
|
|
continue
|
|
ops.append((arg, values, perms if arg in CREATES else None))
|
|
if arg in CREATES:
|
|
perms = None
|
|
command = args[i:]
|
|
return ops, command
|
|
|
|
|
|
def runtime_base():
|
|
"""The per-user directory that holds one root tree per launch.
|
|
|
|
Without a usable ``$XDG_RUNTIME_DIR`` it lives in the shared temporary
|
|
directory, and is used there only as a directory of this user's that no one
|
|
else can write: whoever could would control every tree built in it.
|
|
|
|
Raises:
|
|
UsageError: when that directory is not one.
|
|
"""
|
|
base = os.environ.get("XDG_RUNTIME_DIR")
|
|
if not base or not os.access(base, os.W_OK):
|
|
base = os.path.join(tempfile.gettempdir(), f"{PROGRAM}-{os.getuid()}")
|
|
try:
|
|
os.mkdir(base, 0o700)
|
|
except FileExistsError:
|
|
pass
|
|
info = os.lstat(base)
|
|
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o022:
|
|
raise UsageError(f"{base} is not a directory of this user's alone; set XDG_RUNTIME_DIR")
|
|
base = os.path.join(base, PROGRAM)
|
|
os.makedirs(base, 0o700, exist_ok=True)
|
|
return base
|
|
|
|
|
|
def stage_directory(base, ops):
|
|
"""The directory this launch builds its root in, named for the container.
|
|
|
|
The name is derived from what the container is rather than from when it
|
|
started, because a Wine prefix records the container's root as the path
|
|
behind its Z: drive and is reused by every later launch: a directory named
|
|
per launch would leave that drive dangling. The same container therefore
|
|
comes back to the same directory, and one that is still in use gets a
|
|
fresh directory beside it.
|
|
|
|
Returns:
|
|
A path whose directory is empty and exists.
|
|
"""
|
|
key = hashlib.sha256()
|
|
copy = runtime_copy(ops)
|
|
if copy is not None:
|
|
key.update(os.path.dirname(os.path.dirname(copy)).encode())
|
|
for option, values, _ in ops:
|
|
if option in BINDS or option in ("--dir", "--tmpfs", "--symlink") or option in DATA_FILES:
|
|
key.update(b"\0" + option.encode() + b"\0" + values[-1].encode())
|
|
path = os.path.join(base, key.hexdigest()[:16])
|
|
if in_use(path):
|
|
path = tempfile.mkdtemp(prefix=os.path.basename(path) + ".", dir=base)
|
|
else:
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
os.makedirs(path, 0o700)
|
|
with open(os.path.join(path, "pid"), "w") as f:
|
|
f.write(f"{os.getpid()}\n")
|
|
return path
|
|
|
|
|
|
def in_use(path):
|
|
"""Whether the process that built this root directory is still running."""
|
|
try:
|
|
with open(os.path.join(path, "pid")) as f:
|
|
pid = int(f.read().strip() or "0")
|
|
except (OSError, ValueError):
|
|
return False
|
|
if pid <= 0 or pid == os.getpid():
|
|
return False
|
|
try:
|
|
os.kill(pid, 0)
|
|
except ProcessLookupError:
|
|
return False
|
|
except PermissionError:
|
|
pass
|
|
return True
|
|
|
|
|
|
def collect_garbage(base):
|
|
"""Remove the trees of launches whose supervisor is gone."""
|
|
for name in os.listdir(base):
|
|
path = os.path.join(base, name)
|
|
if os.path.isdir(path) and not in_use(path):
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
|
|
|
|
def proot_candidates():
|
|
"""proot executables to try, best first, each named once.
|
|
|
|
The build beside this program comes first: the installer puts one there
|
|
that names the program in AT_EXECFN, and an image that also carries a
|
|
proot-apps runner has that runner's copy earlier on PATH, which does not.
|
|
Every PATH entry is offered rather than the first, since the one that
|
|
answers is worth more than the one that comes first.
|
|
"""
|
|
override = os.environ.get("PROOT_BWRAP_PROOT")
|
|
if override:
|
|
yield override
|
|
return
|
|
here = os.path.dirname(os.path.realpath(__file__))
|
|
home = os.path.expanduser("~")
|
|
paths = [os.path.join(here, "proot"), os.path.join(home, ".local", "bin", "proot")]
|
|
paths.extend(os.path.join(directory, "proot")
|
|
for directory in (os.environ.get("PATH") or os.defpath).split(":") if directory)
|
|
paths.extend(("/opt/proot-apps-cap/proot", "/opt/proot-apps/proot"))
|
|
seen = set()
|
|
for path in paths:
|
|
real = os.path.realpath(path)
|
|
if real in seen or not os.access(path, os.X_OK):
|
|
continue
|
|
seen.add(real)
|
|
yield path
|
|
|
|
|
|
# Reads AT_EXECFN the way a program does: through prctl(PR_GET_AUXV), and
|
|
# through /proc/self/auxv on the kernels older than 6.4 that have no such
|
|
# prctl.
|
|
EXECFN_PROBE = """\
|
|
import ctypes, struct, sys
|
|
width = struct.calcsize('P')
|
|
form = 'Q' if width == 8 else 'I'
|
|
vector = b''
|
|
try:
|
|
buffer = ctypes.create_string_buffer(4096)
|
|
if ctypes.CDLL(None).prctl(0x41555856, buffer, len(buffer), 0, 0) > 0:
|
|
vector = buffer.raw
|
|
except (OSError, AttributeError, ValueError):
|
|
pass
|
|
if not vector:
|
|
with open('/proc/self/auxv', 'rb') as handle:
|
|
vector = handle.read()
|
|
for start in range(0, len(vector) - 2 * width + 1, 2 * width):
|
|
key, value = struct.unpack(form * 2, vector[start:start + 2 * width])
|
|
if key == 0:
|
|
break
|
|
if key == 31 and value:
|
|
sys.stdout.write(ctypes.string_at(value).decode('utf-8', 'replace'))
|
|
break
|
|
"""
|
|
|
|
|
|
def proot_state(path):
|
|
"""What this proot is good for here: "none", "traces", or "names".
|
|
|
|
Tracing is the first question. The second is whose name a traced program
|
|
finds in AT_EXECFN, since a proot that leaves its own loader there breaks
|
|
every applet of a multi-call coreutils, which is what Ubuntu 25.10 and
|
|
later ship: the name is what they dispatch on.
|
|
"""
|
|
import subprocess
|
|
|
|
def run(argv, capture):
|
|
try:
|
|
return subprocess.run(argv, stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE if capture else subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL, timeout=60, check=False)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
|
|
completed = run([path, sys.executable, "-c", EXECFN_PROBE], True)
|
|
if completed is not None and completed.returncode == 0:
|
|
name = completed.stdout.decode("utf-8", "replace").strip()
|
|
return "names" if name == sys.executable else "traces"
|
|
|
|
completed = run([path, "/bin/true"], False)
|
|
return "traces" if completed is not None and completed.returncode == 0 else "none"
|
|
|
|
|
|
def fakechroot_libraries():
|
|
"""The fakechroot preload library per multiarch tuple, or an empty dict."""
|
|
found = {}
|
|
for tuple_ in ("x86_64-linux-gnu", "i386-linux-gnu", "aarch64-linux-gnu"):
|
|
for prefix in ("/usr/lib", "/usr/local/lib"):
|
|
path = os.path.join(prefix, tuple_, "fakechroot", "libfakechroot.so")
|
|
if os.path.exists(path):
|
|
found[tuple_] = path
|
|
break
|
|
return found
|
|
|
|
|
|
def steam_browser_helper(command):
|
|
"""Whether this container holds the Steam client's browser helper.
|
|
|
|
It is the one container proot cannot run, so it is worth telling apart.
|
|
The name is matched as a prefix because the client runs the helper through
|
|
a per-runtime wrapper, `steamwebhelper_sniper_wrap.sh` today, which execs
|
|
the helper in place. This is the second and last thing here that knows
|
|
anything about Steam.
|
|
"""
|
|
return any(os.path.basename(word).startswith("steamwebhelper")
|
|
for word in command)
|
|
|
|
|
|
def choose_backend(command):
|
|
"""Pick the executor for this container: proot, or fakechroot.
|
|
|
|
proot leads because it confines paths for every process rather than only
|
|
those that go through libc, and it costs a few per cent of a game's own
|
|
work. fakechroot takes over where no proot can trace, or where the only
|
|
proot available leaves its loader named in AT_EXECFN.
|
|
|
|
The choice is per container, because one container cannot use proot at
|
|
all. One proot process traces every thread it runs, so the syscalls it
|
|
translates are served one at a time: 40k a second on one thread and 63k on
|
|
twenty-four, against 805k and 3.6M untraced. The syscalls it ignores are
|
|
free and the bind count barely registers, so nothing tunes this away. The
|
|
Steam client gives its browser helper, two dozen Chromium threads deep in
|
|
path syscalls, about ten seconds to start, and under proot it does not.
|
|
So the helper runs under fakechroot however the rest of the launch runs.
|
|
"""
|
|
forced = os.environ.get("PROOT_BWRAP_BACKEND", "").strip().lower()
|
|
if forced not in ("", "proot", "fakechroot"):
|
|
raise UsageError("PROOT_BWRAP_BACKEND must be proot or fakechroot")
|
|
libraries = fakechroot_libraries()
|
|
if libraries and steam_browser_helper(command):
|
|
if forced == "proot":
|
|
log("the browser helper runs under fakechroot, the only way it "
|
|
"starts in time")
|
|
else:
|
|
debug("the browser helper runs under fakechroot")
|
|
return "fakechroot", libraries
|
|
if forced == "fakechroot":
|
|
if libraries:
|
|
return "fakechroot", libraries
|
|
raise UsageError("libfakechroot.so is not installed")
|
|
stale = None
|
|
for path in proot_candidates():
|
|
state = proot_state(path)
|
|
if state == "names":
|
|
return "proot", path
|
|
if state == "traces":
|
|
debug(f"{path} leaves its own loader named in AT_EXECFN")
|
|
stale = stale or path
|
|
else:
|
|
debug(f"{path} cannot trace processes here")
|
|
# A proot that misnames the loader breaks the multi-call coreutils of
|
|
# current releases, so a working fakechroot is worth more than it is.
|
|
if libraries and forced != "proot":
|
|
return "fakechroot", libraries
|
|
if stale is not None:
|
|
return "proot", stale
|
|
if libraries:
|
|
return "fakechroot", libraries
|
|
raise UsageError("neither proot can trace here nor is libfakechroot.so installed")
|
|
|
|
|
|
class Root:
|
|
"""The container root as a directory tree, built from bubblewrap operations.
|
|
|
|
Attributes:
|
|
path: The tree's top directory.
|
|
binds: (source, destination) pairs for the proot executor, in order.
|
|
"""
|
|
|
|
def __init__(self, stage, fakechroot):
|
|
self.stage = stage
|
|
self.path = os.path.join(stage, "root")
|
|
self.fakechroot = fakechroot
|
|
self.binds = []
|
|
self.excluded = ["/dev", "/proc", "/sys"]
|
|
# Top-level names a later operation writes under, filled in by apply()
|
|
self.built_on = set()
|
|
self.counter = 0
|
|
os.mkdir(self.path, 0o755)
|
|
|
|
def excluded_path(self, dest):
|
|
"""Whether a container path lies in a tree fakechroot passes through.
|
|
|
|
A bind of a host path at its own path is one fakechroot need not
|
|
translate at all, and must not: a symlink in the tree pointing at the
|
|
same path would send every program that canonicalizes paths itself,
|
|
such as ``readlink -f``, round in circles.
|
|
"""
|
|
dest = os.path.normpath(dest)
|
|
return any(dest == e or dest.startswith(e + "/") for e in self.excluded)
|
|
|
|
def translate(self, path):
|
|
"""The host path fakechroot would open for a container path."""
|
|
if self.excluded_path(path):
|
|
return path
|
|
return self.path + os.path.normpath(path)
|
|
|
|
def pass_through(self, dest):
|
|
"""Let fakechroot pass a host path through at its own name.
|
|
|
|
The name is excluded from translation, and the tree still gets a
|
|
symlink to the host path: a program that follows an absolute symlink
|
|
it created inside the container reaches the tree root first, since
|
|
fakechroot prefixes the targets it writes, and the kernel then walks
|
|
on from there.
|
|
|
|
The root directory is the exception. Excluding it would exclude only
|
|
itself, since a pass-through path matches at a directory boundary, so
|
|
the host's own top-level entries are linked into the tree instead and
|
|
the container becomes the host filesystem, which is what binding the
|
|
root asks for. Each of those links is passed through as well, or the
|
|
link and the translation of its target would resolve into each other
|
|
until the path resolver gives up. An entry a later operation builds on
|
|
keeps the link without the pass-through, so that operation still lands.
|
|
"""
|
|
dest = os.path.normpath(dest)
|
|
if dest == "/":
|
|
for name in os.listdir("/"):
|
|
here = os.path.join(self.path, name)
|
|
if not os.path.lexists(here):
|
|
os.symlink("/" + name, here)
|
|
if "/" + name not in self.built_on:
|
|
self.excluded.append("/" + name)
|
|
return
|
|
if self.excluded_path(dest):
|
|
return
|
|
self.excluded.append(dest)
|
|
here = self.place(dest)
|
|
if not os.path.lexists(here):
|
|
os.symlink(dest, here)
|
|
|
|
def scratch(self, kind):
|
|
"""A fresh path under the stage for a tmpfs directory or a data file."""
|
|
self.counter += 1
|
|
directory = os.path.join(self.stage, kind)
|
|
os.makedirs(directory, 0o700, exist_ok=True)
|
|
return os.path.join(directory, str(self.counter))
|
|
|
|
def host_reference(self, link):
|
|
"""Whether a symlink in the tree points at the host rather than the tree."""
|
|
target = os.readlink(link)
|
|
return target.startswith("/") and not target.startswith(self.path + "/")
|
|
|
|
def mirror(self, link):
|
|
"""Replace a symlink to a host directory with a directory of symlinks.
|
|
|
|
A bind is a symlink under fakechroot, so an entry placed inside a bound
|
|
directory would otherwise be written into the host directory itself.
|
|
One level of symlinks keeps the host's entries visible while the tree
|
|
gains its own.
|
|
"""
|
|
target = os.readlink(link)
|
|
entries = os.listdir(target)
|
|
os.unlink(link)
|
|
os.mkdir(link, 0o755)
|
|
for entry in entries:
|
|
os.symlink(os.path.join(target, entry), os.path.join(link, entry))
|
|
|
|
def place(self, dest):
|
|
"""Resolve a container path to its tree path, owning every parent.
|
|
|
|
Symlinks the operations created are followed inside the tree; a
|
|
fakechroot bind on the way is mirrored so the parent is a directory of
|
|
the tree's own.
|
|
|
|
Returns:
|
|
The tree path at which the entry for ``dest`` belongs.
|
|
"""
|
|
parts = [p for p in dest.split("/") if p and p != "."]
|
|
current = self.path
|
|
for name in parts[:-1]:
|
|
nxt = os.path.join(current, name)
|
|
while os.path.islink(nxt):
|
|
if self.host_reference(nxt):
|
|
if not self.fakechroot:
|
|
break
|
|
self.mirror(nxt)
|
|
break
|
|
target = os.readlink(nxt)
|
|
if target.startswith("/"):
|
|
nxt = os.path.normpath(target)
|
|
else:
|
|
nxt = os.path.normpath(os.path.join(current, target))
|
|
if not os.path.lexists(nxt):
|
|
os.mkdir(nxt, 0o755)
|
|
current = nxt
|
|
return os.path.join(current, parts[-1]) if parts else current
|
|
|
|
def remove(self, path):
|
|
"""Drop an entry of the tree's own without following it."""
|
|
if os.path.islink(path) or not os.path.isdir(path):
|
|
os.unlink(path)
|
|
else:
|
|
shutil.rmtree(path)
|
|
|
|
def bind(self, option, source, dest):
|
|
"""Apply a bind mount operation."""
|
|
if option.endswith("-fd"):
|
|
source = os.readlink(f"/proc/self/fd/{int(source)}")
|
|
if not os.path.lexists(source):
|
|
if option.endswith("-try"):
|
|
return
|
|
raise UsageError(f"Can't find source path {source}")
|
|
if self.fakechroot:
|
|
if os.path.normpath(source) == os.path.normpath(dest):
|
|
self.pass_through(dest)
|
|
return
|
|
if self.excluded_path(dest):
|
|
log(f"cannot bind {source} over {dest}, which fakechroot passes through")
|
|
return
|
|
here = self.place(dest)
|
|
if os.path.lexists(here):
|
|
self.remove(here)
|
|
os.symlink(source, here)
|
|
return
|
|
self.placeholder(dest, os.path.isdir(source))
|
|
self.binds.append((source, dest))
|
|
|
|
def placeholder(self, dest, is_dir):
|
|
"""Give a proot binding a mount point.
|
|
|
|
A binding needs its guest path to exist: in the tree, or, when an
|
|
earlier binding already covers the path, inside that binding's source
|
|
if it is a tmpfs directory of the tree's own. A host directory covering
|
|
it is left alone, and proot glues the mount point in itself.
|
|
"""
|
|
covering = None
|
|
for source, bound in self.binds:
|
|
if ((dest == bound or dest.startswith(bound.rstrip("/") + "/"))
|
|
and (covering is None or len(bound) > len(covering[1]))):
|
|
covering = (source, bound)
|
|
if covering is None:
|
|
here = self.place(dest)
|
|
elif covering[0].startswith(self.stage + "/"):
|
|
here = os.path.join(covering[0], os.path.relpath(dest, covering[1]))
|
|
os.makedirs(os.path.dirname(here), exist_ok=True)
|
|
else:
|
|
return
|
|
if os.path.lexists(here):
|
|
return
|
|
if is_dir:
|
|
os.mkdir(here, 0o755)
|
|
else:
|
|
open(here, "w").close()
|
|
|
|
def tmpfs(self, dest, perms):
|
|
"""An empty directory of the tree's own, masking whatever was there."""
|
|
if self.fakechroot:
|
|
if self.excluded_path(dest):
|
|
debug(f"not masking {dest} inside a tree fakechroot passes through")
|
|
return
|
|
here = self.place(dest)
|
|
if os.path.lexists(here):
|
|
self.remove(here)
|
|
os.mkdir(here, perms if perms is not None else 0o755)
|
|
return
|
|
scratch = self.scratch("tmpfs")
|
|
os.mkdir(scratch, perms if perms is not None else 0o755)
|
|
self.placeholder(dest, True)
|
|
self.binds.append((scratch, dest))
|
|
|
|
def directory(self, dest, perms):
|
|
if self.fakechroot and self.excluded_path(dest):
|
|
if not os.path.isdir(dest):
|
|
debug(f"not creating {dest} inside a tree fakechroot passes through")
|
|
return
|
|
here = self.place(dest)
|
|
if os.path.isdir(here):
|
|
if perms is not None and not os.path.islink(here):
|
|
os.chmod(here, perms)
|
|
return
|
|
os.mkdir(here, perms if perms is not None else 0o755)
|
|
|
|
def symlink(self, target, dest):
|
|
if self.fakechroot and self.excluded_path(dest):
|
|
debug(f"not linking {dest} inside a tree fakechroot passes through")
|
|
return
|
|
here = self.place(dest)
|
|
if os.path.lexists(here):
|
|
if os.path.islink(here) and os.readlink(here) == target:
|
|
return
|
|
self.remove(here)
|
|
if self.fakechroot and target.startswith("/"):
|
|
target = self.path + target
|
|
os.symlink(target, here)
|
|
|
|
def data_file(self, option, fd, dest, perms):
|
|
"""Write an inherited descriptor's content at a container path."""
|
|
content = read_fd(int(fd))
|
|
os.close(int(fd))
|
|
mode = perms if perms is not None else 0o644
|
|
if self.fakechroot and self.excluded_path(dest):
|
|
log(f"cannot place {dest} inside a tree fakechroot passes through")
|
|
return
|
|
if self.fakechroot or option == "--file":
|
|
here = self.place(dest)
|
|
if os.path.lexists(here):
|
|
self.remove(here)
|
|
with open(os.open(here, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode), "wb") as f:
|
|
f.write(content)
|
|
os.chmod(here, mode)
|
|
return
|
|
scratch = self.scratch("data")
|
|
with open(os.open(scratch, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode), "wb") as f:
|
|
f.write(content)
|
|
os.chmod(scratch, mode)
|
|
self.placeholder(dest, False)
|
|
self.binds.append((scratch, dest))
|
|
|
|
def apply(self, ops):
|
|
"""Apply the filesystem operations in order; the rest is for the runner."""
|
|
for _, values, _ in ops:
|
|
for value in values:
|
|
if value.startswith("/"):
|
|
parts = [p for p in value.split("/") if p]
|
|
if len(parts) > 1:
|
|
self.built_on.add("/" + parts[0])
|
|
for option, values, perms in ops:
|
|
if option in BINDS:
|
|
self.bind(option, values[0], values[1])
|
|
elif option in ("--proc", "--dev"):
|
|
self.bind("--dev-bind", "/" + option[2:], values[0])
|
|
elif option == "--tmpfs":
|
|
self.tmpfs(values[0], perms)
|
|
elif option in ("--dir", "--mqueue"):
|
|
self.directory(values[0], perms)
|
|
elif option == "--symlink":
|
|
self.symlink(values[0], values[1])
|
|
elif option in DATA_FILES:
|
|
self.data_file(option, values[0], values[1], perms)
|
|
elif option == "--chmod":
|
|
os.chmod(self.place(values[1]), int(values[0], 8))
|
|
elif option in ("--overlay", "--tmp-overlay", "--ro-overlay", "--overlay-src"):
|
|
raise UsageError(f"{option} is not supported here")
|
|
|
|
def prefix_absolute_symlinks(self, directory):
|
|
"""Point a mutable runtime copy's absolute symlinks into the tree.
|
|
|
|
pressure-vessel edits its copy of the runtime in place, linking host
|
|
libraries as ``/run/host/...`` and runtime files by their container
|
|
path. The kernel resolves a symlink target on the host, so under
|
|
fakechroot every absolute target gets the tree prefix, which the
|
|
preload library strips again when a program reads the link.
|
|
"""
|
|
for parent, dirs, files in os.walk(directory):
|
|
for name in dirs + files:
|
|
link = os.path.join(parent, name)
|
|
if not os.path.islink(link):
|
|
continue
|
|
target = os.readlink(link)
|
|
if target.startswith("/") and not target.startswith(self.path + "/"):
|
|
os.unlink(link)
|
|
os.symlink(self.path + target, link)
|
|
|
|
|
|
def runtime_copy(ops):
|
|
"""The mutable runtime copy bound at /usr, if pressure-vessel made one.
|
|
|
|
pressure-vessel's ``--copy-runtime`` places it at ``var/tmp-XXXXXX`` under
|
|
its variable directory and edits it in place; the shared runtime under
|
|
``files/`` has no absolute symlinks and is never touched.
|
|
"""
|
|
for option, values, _ in ops:
|
|
if option in BINDS and values[1] == "/usr":
|
|
copy = os.path.dirname(values[0])
|
|
if (os.path.basename(copy).startswith("tmp-")
|
|
and os.path.basename(os.path.dirname(copy)) == "var"
|
|
and os.access(copy, os.W_OK)):
|
|
return copy
|
|
return None
|
|
|
|
|
|
def ld_so_conf_dirs(root):
|
|
"""Library directories the container's ld.so.conf names, in order."""
|
|
dirs = []
|
|
|
|
def read(path, depth=0):
|
|
if depth > 8:
|
|
return
|
|
try:
|
|
with open(path) as f:
|
|
lines = f.read().splitlines()
|
|
except OSError:
|
|
return
|
|
for line in lines:
|
|
line = line.split("#", 1)[0].strip()
|
|
if not line:
|
|
continue
|
|
if line.startswith("include "):
|
|
pattern = line[len("include "):].strip()
|
|
if not pattern.startswith("/"):
|
|
pattern = os.path.join(os.path.dirname(path), pattern)
|
|
import glob
|
|
for match in sorted(glob.glob(root + pattern)):
|
|
read(match, depth + 1)
|
|
elif line.startswith("/") and line not in dirs:
|
|
dirs.append(line)
|
|
|
|
read(root + "/etc/ld.so.conf")
|
|
for tuple_ in ("x86_64-linux-gnu", "i386-linux-gnu"):
|
|
for prefix in ("/lib", "/usr/lib"):
|
|
path = os.path.join(prefix, tuple_)
|
|
if path not in dirs:
|
|
dirs.append(path)
|
|
for path in ("/lib", "/usr/lib", "/usr/local/lib"):
|
|
if path not in dirs:
|
|
dirs.append(path)
|
|
return dirs
|
|
|
|
|
|
def libdl_lib_tokens(helpers):
|
|
"""What the dynamic loader expands ``$LIB`` to, per multiarch tuple.
|
|
|
|
pressure-vessel ships a helper per architecture that reports it; they run
|
|
on the host here, since pv-adverb's own attempt goes through fakechroot's
|
|
dlopen, which cannot expand loader tokens.
|
|
"""
|
|
import subprocess
|
|
|
|
tokens = {}
|
|
for tuple_ in ("x86_64-linux-gnu", "i386-linux-gnu"):
|
|
helper = os.path.join(helpers, tuple_ + "-detect-lib")
|
|
if not os.access(helper, os.X_OK):
|
|
continue
|
|
try:
|
|
completed = subprocess.run([helper], stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL, timeout=30, check=False)
|
|
except (OSError, subprocess.SubprocessError):
|
|
continue
|
|
token = completed.stdout.decode(errors="replace").strip()
|
|
if completed.returncode == 0 and token:
|
|
tokens[tuple_] = token
|
|
return tokens
|
|
|
|
|
|
def merge_preload_modules(modules, root, tokens):
|
|
"""One loader-expanded path per module name, for a set of per-ABI modules.
|
|
|
|
pv-adverb pairs a 32-bit and a 64-bit copy of a module into a single
|
|
``${PLATFORM}`` entry once it knows the loader's tokens, which it cannot
|
|
learn under fakechroot. The same pairing is done here with ``$LIB``: each
|
|
copy is linked under the tree by its architecture's library directory, and
|
|
the loader picks the right one for every process.
|
|
|
|
Args:
|
|
modules: (option, path, tuple) triples from ``--ld-preload=PATH:abi=T``
|
|
and ``--ld-audit`` arguments.
|
|
|
|
Returns:
|
|
The replacement adverb arguments.
|
|
"""
|
|
directory = os.path.join(root.stage, "modules")
|
|
groups = {}
|
|
for option, path, tuple_ in modules:
|
|
groups.setdefault((option, os.path.basename(path)), []).append((path, tuple_))
|
|
out = []
|
|
for (option, base), copies in groups.items():
|
|
if len(copies) < 2 or any(t not in tokens for _, t in copies):
|
|
out.extend(f"{option}={path}:abi={tuple_}" for path, tuple_ in copies)
|
|
continue
|
|
for path, tuple_ in copies:
|
|
where = os.path.join(directory, tokens[tuple_])
|
|
os.makedirs(where, exist_ok=True)
|
|
link = os.path.join(where, base)
|
|
if os.path.lexists(link):
|
|
os.unlink(link)
|
|
os.symlink(root.path + path if path.startswith("/") else path, link)
|
|
out.append("{}={}".format(option, os.path.join(directory, "$LIB", base)))
|
|
return out
|
|
|
|
|
|
def adjust_adverb(command, root, libraries):
|
|
"""Rewrite pv-adverb's arguments for fakechroot.
|
|
|
|
ldconfig is statically linked, so the cache it would regenerate is neither
|
|
written nor read here; the search path it stood for becomes a complete
|
|
LD_LIBRARY_PATH of tree paths instead. Preload modules become paths the
|
|
dynamic loader can follow on the host, and the fakechroot library itself is
|
|
added as a module so pv-adverb keeps it in the LD_PRELOAD it composes for
|
|
the game.
|
|
|
|
Returns:
|
|
The rewritten command, and the LD_LIBRARY_PATH pv-adverb itself runs
|
|
with.
|
|
"""
|
|
if not command or os.path.basename(command[0]) != "pv-adverb":
|
|
return command, None
|
|
tokens = libdl_lib_tokens(root.path + os.path.dirname(command[0]))
|
|
prefixed = []
|
|
aliases = None
|
|
modules = []
|
|
out = [command[0]]
|
|
i = 1
|
|
while i < len(command):
|
|
arg = command[i]
|
|
if arg == "--":
|
|
break
|
|
if arg == "--regenerate-ld.so-cache":
|
|
i += 2
|
|
continue
|
|
if arg == "--add-ld.so-path":
|
|
prefixed.append(command[i + 1])
|
|
i += 2
|
|
continue
|
|
if arg == "--set-ld-library-path":
|
|
aliases = command[i + 1]
|
|
i += 2
|
|
continue
|
|
if arg.startswith(("--ld-preload=", "--ld-audit=")) and ":abi=" in arg:
|
|
option, value = arg.split("=", 1)
|
|
path, tuple_ = value.rsplit(":abi=", 1)
|
|
modules.append((option, path, tuple_))
|
|
i += 1
|
|
continue
|
|
if arg.startswith(("--ld-preload=/", "--ld-audit=/")):
|
|
option, value = arg.split("=", 1)
|
|
arg = f"{option}={root.path}{value}"
|
|
out.append(arg)
|
|
i += 1
|
|
search = list(prefixed)
|
|
if aliases:
|
|
search.extend(p for p in aliases.split(":") if p and p not in search)
|
|
search.extend(p for p in ld_so_conf_dirs(root.path) if p not in search)
|
|
library_path = ":".join(root.path + p for p in search)
|
|
out.extend(["--set-ld-library-path", library_path])
|
|
out.extend(merge_preload_modules(modules, root, tokens))
|
|
out.append("--ld-preload=" + fakechroot_preload(root, libraries))
|
|
out.extend(command[i:])
|
|
return out, library_path
|
|
|
|
|
|
def fakechroot_preload(root, libraries):
|
|
"""The LD_PRELOAD entry loading fakechroot into either architecture.
|
|
|
|
The dynamic loader expands ``$LIB`` to the multiarch library directory,
|
|
so one path serves 64-bit and 32-bit programs when both libraries sit at
|
|
the same place under it; otherwise only the primary library is named.
|
|
"""
|
|
directory = os.path.join(root.stage, "preload")
|
|
os.makedirs(directory, exist_ok=True)
|
|
common = None
|
|
for tuple_, path in libraries.items():
|
|
marker = f"/{tuple_}/"
|
|
if marker in path:
|
|
form = path.replace(marker, "/$LIB/", 1).replace("/usr/lib/$LIB/", "/usr/$LIB/", 1)
|
|
if common is None:
|
|
common = form
|
|
elif common != form:
|
|
common = False
|
|
if common and len(libraries) > 1:
|
|
return common
|
|
return libraries.get("x86_64-linux-gnu") or next(iter(libraries.values()))
|
|
|
|
|
|
class Runner:
|
|
"""Executes the command in the tree and supervises it like bwrap would."""
|
|
|
|
def __init__(self, ops, command, backend, detail, stage):
|
|
self.ops = ops
|
|
self.command = command
|
|
self.backend = backend
|
|
self.detail = detail
|
|
self.stage = stage
|
|
self.env = dict(os.environ)
|
|
self.cwd = None
|
|
self.argv0 = None
|
|
self.new_session = False
|
|
self.die_with_parent = False
|
|
self.info_fds = []
|
|
self.status_fds = []
|
|
self.keep_fds = []
|
|
self.lock_files = []
|
|
self.locks = []
|
|
for option, values, _ in ops:
|
|
if option == "--setenv":
|
|
self.env[values[0]] = values[1]
|
|
elif option == "--unsetenv":
|
|
self.env.pop(values[0], None)
|
|
elif option == "--clearenv":
|
|
self.env = {}
|
|
elif option == "--chdir":
|
|
self.cwd = values[0]
|
|
elif option == "--argv0":
|
|
self.argv0 = values[0]
|
|
elif option == "--new-session":
|
|
self.new_session = True
|
|
elif option == "--die-with-parent":
|
|
self.die_with_parent = True
|
|
elif option == "--info-fd":
|
|
self.info_fds.append(int(values[0]))
|
|
elif option == "--json-status-fd":
|
|
self.status_fds.append(int(values[0]))
|
|
elif option == "--sync-fd":
|
|
self.keep_fds.append(int(values[0]))
|
|
elif option == "--lock-file":
|
|
self.lock_files.append(values[0])
|
|
elif option in ("--seccomp", "--add-seccomp-fd", "--block-fd",
|
|
"--userns-block-fd", "--userns", "--userns2", "--pidns"):
|
|
if option == "--block-fd":
|
|
read_fd(int(values[0]))
|
|
os.close(int(values[0]))
|
|
|
|
def build(self):
|
|
"""Materialize the tree and return the executor's argv, env, and cwd."""
|
|
root = Root(self.stage, self.backend == "fakechroot")
|
|
root.apply(self.ops)
|
|
self.repoint_wine_drive(root)
|
|
for path in self.lock_files:
|
|
fd = os.open(root.path + path, os.O_RDONLY | os.O_CLOEXEC)
|
|
fcntl.lockf(fd, fcntl.LOCK_SH)
|
|
self.locks.append(fd)
|
|
if self.backend == "proot":
|
|
return self.build_proot(root)
|
|
return self.build_fakechroot(root)
|
|
|
|
def build_proot(self, root):
|
|
argv = [self.detail, "-r", root.path]
|
|
for source, dest in root.binds:
|
|
argv.extend(["-b", f"{source}:{dest}"])
|
|
if self.cwd:
|
|
argv.extend(["-w", self.cwd])
|
|
argv.extend(self.command)
|
|
return self.detail, argv, self.env, None
|
|
|
|
def build_fakechroot(self, root):
|
|
libraries = self.detail
|
|
copy = runtime_copy(self.ops)
|
|
if copy:
|
|
root.prefix_absolute_symlinks(copy)
|
|
command, library_path = adjust_adverb(self.command, root, libraries)
|
|
env = dict(self.env)
|
|
env["FAKECHROOT_BASE"] = root.path
|
|
if len(root.excluded) >= 99:
|
|
log("more than 99 pass-through paths; fakechroot ignores the rest")
|
|
env["FAKECHROOT_EXCLUDE_PATH"] = ":".join([root.path] + root.excluded[:99])
|
|
env.pop("FAKECHROOT", None)
|
|
env["LD_PRELOAD"] = fakechroot_preload(root, libraries)
|
|
if library_path is not None:
|
|
env["LD_LIBRARY_PATH"] = library_path
|
|
elif "LD_LIBRARY_PATH" in env:
|
|
env["LD_LIBRARY_PATH"] = ":".join(
|
|
root.path + p if p.startswith("/") else p
|
|
for p in env["LD_LIBRARY_PATH"].split(":") if p)
|
|
executable = command[0]
|
|
if "/" in executable:
|
|
executable = root.translate(os.path.join(self.cwd or "/", executable))
|
|
else:
|
|
# A bare name is looked up in the tree first and on the host
|
|
# second, so that a container assembled entirely out of
|
|
# pass-through binds still finds its command.
|
|
for translate in (root.translate, lambda path: path):
|
|
found = None
|
|
for directory in (env.get("PATH") or os.defpath).split(":"):
|
|
candidate = translate(os.path.join(directory, executable))
|
|
if os.access(candidate, os.X_OK):
|
|
found = candidate
|
|
break
|
|
if found is not None:
|
|
executable = found
|
|
break
|
|
if self.argv0:
|
|
command = [self.argv0] + command[1:]
|
|
cwd = root.translate(self.cwd) if self.cwd else None
|
|
return executable, command, env, cwd
|
|
|
|
def repoint_wine_drive(self, root):
|
|
"""Point a Wine prefix's Z: drive at this container's root.
|
|
|
|
Wine records the container's root as the Unix path behind Z: when it
|
|
creates a prefix, and every later launch reuses what it recorded.
|
|
That path is the tree under fakechroot and `/` under proot, so a prefix
|
|
one executor created leaves the other unable to reach the game. The
|
|
drive is rewritten to whatever this launch's root is, which is
|
|
Steam-specific knowledge, as `steam_browser_helper` is.
|
|
"""
|
|
data = self.env.get("STEAM_COMPAT_DATA_PATH")
|
|
if not data:
|
|
return
|
|
drive = os.path.join(data, "pfx", "dosdevices", "z:")
|
|
wanted = root.path if self.backend == "fakechroot" else "/"
|
|
try:
|
|
if os.readlink(drive).rstrip("/") == wanted.rstrip("/"):
|
|
return
|
|
os.unlink(drive)
|
|
os.symlink(wanted, drive)
|
|
except OSError as error:
|
|
debug(f"leaving {drive} alone: {error}")
|
|
|
|
def run(self):
|
|
"""Fork the executor, wait for it, and mirror its exit like bwrap."""
|
|
executable, argv, env, cwd = self.build()
|
|
env["PWD"] = self.cwd or "/"
|
|
debug("executing {}: {}".format(executable, " ".join(repr(a) for a in argv)))
|
|
if self.die_with_parent:
|
|
set_pdeathsig(signal.SIGKILL)
|
|
pid = os.fork()
|
|
if pid == 0:
|
|
try:
|
|
if self.new_session:
|
|
os.setsid()
|
|
set_pdeathsig(signal.SIGKILL)
|
|
if cwd:
|
|
os.chdir(cwd)
|
|
os.execve(executable, argv, env)
|
|
except OSError as error:
|
|
log(f"execve {executable}: {error}")
|
|
os._exit(127)
|
|
if self.info_fds or self.status_fds:
|
|
import json
|
|
for fd in self.info_fds + self.status_fds:
|
|
try:
|
|
os.write(fd, (json.dumps({"child-pid": pid}) + "\n").encode())
|
|
except OSError:
|
|
pass
|
|
forwarded = (signal.SIGTERM, signal.SIGINT, signal.SIGHUP, signal.SIGUSR1, signal.SIGUSR2)
|
|
|
|
def forward(signum, _frame):
|
|
try:
|
|
os.kill(pid, signum)
|
|
except ProcessLookupError:
|
|
pass
|
|
|
|
for signum in forwarded:
|
|
signal.signal(signum, forward)
|
|
while True:
|
|
try:
|
|
_, status = os.waitpid(pid, 0)
|
|
break
|
|
except InterruptedError:
|
|
continue
|
|
for fd in self.status_fds:
|
|
try:
|
|
import json
|
|
os.write(fd, (json.dumps({"exit-code": os.waitstatus_to_exitcode(status)}) + "\n").encode())
|
|
except (OSError, ValueError):
|
|
pass
|
|
return status
|
|
|
|
|
|
def set_pdeathsig(signum):
|
|
"""Ask the kernel to deliver signum when the parent process dies."""
|
|
try:
|
|
import ctypes
|
|
libc = ctypes.CDLL(None, use_errno=True)
|
|
libc.prctl(1, signum, 0, 0, 0)
|
|
except (OSError, AttributeError):
|
|
pass
|
|
|
|
|
|
def main(argv):
|
|
if len(argv) == 1 or argv[1] == "--help":
|
|
sys.stdout.write(HELP)
|
|
for name in sorted(FLAGS | ONE_ARG | TWO_ARGS | THREE_ARGS):
|
|
sys.stdout.write(f" {name}\n")
|
|
return 0
|
|
if argv[1] == "--version":
|
|
print(VERSION)
|
|
return 0
|
|
try:
|
|
ops, command = parse(argv[1:])
|
|
if not command:
|
|
raise UsageError("COMMAND is required")
|
|
backend, detail = choose_backend(command)
|
|
base = runtime_base()
|
|
except UsageError as error:
|
|
log(str(error))
|
|
return 1
|
|
collect_garbage(base)
|
|
stage = stage_directory(base, ops)
|
|
debug(f"backend {backend}, tree {stage}")
|
|
inert = sorted({option for option, _, _ in ops if option in INERT})
|
|
if inert:
|
|
debug(f"no effect without namespaces: {' '.join(inert)}")
|
|
try:
|
|
runner = Runner(ops, command, backend, detail, stage)
|
|
status = runner.run()
|
|
except UsageError as error:
|
|
log(str(error))
|
|
shutil.rmtree(stage, ignore_errors=True)
|
|
return 1
|
|
finally:
|
|
pass
|
|
shutil.rmtree(stage, ignore_errors=True)
|
|
if os.WIFSIGNALED(status):
|
|
signal.signal(os.WTERMSIG(status), signal.SIG_DFL)
|
|
os.kill(os.getpid(), os.WTERMSIG(status))
|
|
return 128 + os.WTERMSIG(status)
|
|
return os.WEXITSTATUS(status)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|