blob: abae08f450efa2ed178619d92bb611c3efa878c2 [file] [log] [blame]
# Copyright 2022 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
from recipe_engine import recipe_api
class NsjailCmdBuilder:
"""NsjailCmdBuilder builds a command to run an nsjail sandbox."""
def __init__(self, api, bin_path, dumb_init_path):
self._api = api
self._path = ""
self._bin_path = bin_path
self._dumb_init_path = str(dumb_init_path)
self._ro_mounts = [self._dumb_init_path]
self._rw_mounts = []
self._symlinks = {}
self._env = {}
def _prepend_to_path(self, val):
if self._path:
self._path = "%s:%s" % (val, self._path)
else:
self._path = val
def build(self, subcmd):
"""
Build an nsjail command that wraps the given subcmd.
Args:
subcmd (list[str]): The subcommand to run inside the sandbox.
Returns: A command, which takes the form list[str].
"""
cmd = [
self._bin_path,
# Disable some of the lesser used namespaces.
"--disable_clone_newipc",
"--disable_clone_newuts",
"--disable_clone_newcgroup",
# Disable rlimits because our builds are single-tenant and can
# safely use all of the system resources.
"--disable_rlimits",
# Increase the time limit to 12 hours.
"--time_limit",
"43200",
"--log",
# Direct nsjail output to a separate log to avoid mixing it in with
# output of the underlying command.
self._api.raw_io.output_text(name="nsjail_log", add_output_log=True),
]
# Initialize the PATH variable.
self._env["PATH"] = self._path
# Create a tmpdir for use by this sandbox.
# Ideally, we would just use a tmpfs, but nsjail limits the size of
# a tmpfs mount to ~16MB, and we write far more than that to the
# directory.
tmp = self._api.path.mkdtemp("tmp")
cmd.extend(["--bindmount", "%s:/tmp" % tmp])
self._env["TMPDIR"] = "/tmp"
# We need to sort mounts to make sure that we mount parent directories
# before mounting their children. This must be done regardless of
# whether the mount point is rw or ro.
is_writable = {}
for m in self._ro_mounts:
is_writable[m] = False
for m in self._rw_mounts:
is_writable[m] = True
for m in sorted(is_writable.keys()):
if is_writable[m]:
cmd.extend(["--bindmount", m])
else:
cmd.extend(["--bindmount_ro", m])
# We sort the dictionaries to maintain stable ordering.
for k in sorted(self._symlinks.keys()):
cmd.extend(["--symlink", "%s:%s" % (k, self._symlinks[k])])
for k in sorted(self._env.keys()):
v = self._env[k]
v = v.replace("$tmpdir", "/tmp")
cmd.extend(["--env", "%s=%s" % (k, v)])
cmd.append("--")
cmd.append(self._dumb_init_path)
cmd.extend(subcmd)
return cmd
def add_ro_mounts(self, mounts):
"""
Adds a set of read-only mounts to the sandbox.
Args:
mounts (list[str]): A list of paths to mount read-only.
"""
self._ro_mounts.extend(mounts)
def add_rw_mounts(self, mounts):
"""
Adds a set of read-write mounts to the sandbox.
Args:
mounts (list[str]): A list of paths to mount read-write.
"""
self._rw_mounts.extend(mounts)
def add_env_vars(self, env_vars):
"""
Adds a set of environment variables to add to the sandbox.
Args:
env_vars (dict{str: str}): A map of key:value pairs.
"""
self._env.update(env_vars)
def add_symlinks(self, symlinks):
"""
Adds a set of symlinks to add to the sandbox.
Args:
symlinks (dict{str: str}): A map of target:link_name pairs to
symlink in the sandbox.
"""
self._symlinks.update(symlinks)
def add_luci_git(self):
"""
Adds the paths containing the git wrapper and git binary provisioned
by LUCI environments to the sandbox.
"""
paths = self._api.nsjail._which(
"find all git binaries",
"git",
include_all=True,
)
# Only mount if this is not system git.
paths = [p for p in paths if not p.startswith(("/usr/bin", "/bin"))]
self.add_ro_mounts(paths)
for p in paths:
self._prepend_to_path(self._api.path.dirname(p))
def add_linux_tools(self):
"""
Adds a set of linux tools we expect most programs in infra to use.
"""
tools = [
"awk",
"basename",
"cat",
"chmod",
"cmp",
"comm",
"cp",
"cut",
"date",
"diff",
"dirname",
"egrep",
"env",
"expr",
"file",
"find",
"grep",
"gzip",
"head",
"ldd",
"ls",
"mkdir",
"mktemp",
"mv",
"readlink",
"rm",
"sed",
"sort",
"stat",
"tail",
"tar",
"tee",
"touch",
"tr",
"uname",
"wc",
"which",
]
mounts = [
# We special case bash here because the fuchsia build often hardcodes
# /bin/bash in shebangs.
"/bin/bash",
# These are required for bash to work.
"/lib",
"/lib64",
]
# The set of directories to add to PATH.
path_additions = {"/bin": True}
tool_paths = self._api.nsjail._which(
"find tool paths",
*tools,
test_output_lines=["/usr/bin/%s" % tool for tool in tools],
)
for tool_path in tool_paths:
mounts.append(tool_path)
path_additions[self._api.path.dirname(tool_path)] = True
self.add_ro_mounts(mounts)
# Sort and add all path additions.
paths = sorted(path_additions.keys())
for p in paths:
self._prepend_to_path(p)
class NsjailApi(recipe_api.RecipeApi):
"""NsjailApi allows callers to run steps in lightweight sandboxes."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Cache repeated calls to `_which()`.
self._which_cache = {}
@property
def _nsjail_tool(self):
"""Fetch and provision nsjail from CIPD."""
return self.m.ensure_tool("nsjail", self.resource("nsjail/tool_manifest.json"))
@property
def _dumb_init_tool(self):
"""Fetch and provision dumb_init from CIPD."""
return self.m.ensure_tool(
"dumb_init", self.resource("dumb_init/tool_manifest.json")
)
def sandboxed_step(
self,
step_name,
cmd,
env=None,
symlinks=None,
ro_mounts=None,
rw_mounts=None,
use_linux_tools=False,
use_luci_git=False,
**kwargs,
):
"""
Constructs and returns a step that runs the given cmd in a sandbox.
Args:
step_name (str): The name of the step.
cmd (list[str]): The command to run inside the sandbox.
env (dict{str: str}): A map of key:value pairs to add to the env.
symlinks (dict{str: str}): A map of target:link_name pairs to add
to the sandbox.
ro_mounts (list[str]): A list of read-only mounts.
rw_mounts (list[str]): A list of read-write mounts.
use_linux_tools (bool): Whether to mount a canonical set of linux
system tools.
use_luci_git (bool): Whether to mount the git wrapper provisioned
by LUCI.
**kwargs (dict): Passed through as kwargs to step.
Returns: A step that runs the given cmd inside an Nsjail sandbox.
"""
b = NsjailCmdBuilder(self.m, self._nsjail_tool, self._dumb_init_tool)
if env:
b.add_env_vars(env)
if symlinks:
b.add_symlinks(symlinks)
if ro_mounts:
b.add_ro_mounts(ro_mounts)
if rw_mounts:
b.add_rw_mounts(rw_mounts)
if use_linux_tools:
b.add_linux_tools()
if use_luci_git:
b.add_luci_git()
sandboxed_cmd = b.build(cmd)
return self.m.step(
step_name,
sandboxed_cmd,
**kwargs,
)
def _which(self, name, *tools, include_all=False, test_output_lines=()):
"""Look up the absolute paths to executables on $PATH."""
cmd = ["which"]
if include_all:
cmd.append("-a")
cmd.extend(tools)
cache_key = tuple(cmd)
if cache_key not in self._which_cache:
output = self.m.step(
name,
cmd,
stdout=self.m.raw_io.output_text(),
step_test_data=lambda: self.m.raw_io.test_api.stream_output_text(
"\n".join(test_output_lines) + "\n"
),
).stdout.strip()
self._which_cache[cache_key] = [
l for l in output.splitlines() if l # Filter empty lines.
]
return self._which_cache[cache_key]