blob: b0aee2bb1ebebd1c749ee6b28b8430fe866ccac6 [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",
]
# 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.
"""
s = self._api.step(
"find all git binaries",
["which", "-a", "git"],
stdout=self._api.raw_io.output_text(),
)
paths = s.stdout.strip().split("\n")
# Only mount if this is not system git.
paths = [
p
for p in paths
if p and not p.startswith("/usr/bin") and not p.startswith("/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",
"diff",
"dirname",
"egrep",
"env",
"expr",
"find",
"grep",
"gzip",
"head",
"ldd",
"ls",
"mkdir",
"mktemp",
"readlink",
"rm",
"sed",
"sort",
"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}
with self._api.step.nest("find tool paths"):
for tool in tools:
tool_path = self._find_abs_path(tool)
if tool_path:
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)
def _find_abs_path(self, tool):
return self._api.step(
"find absolute path of %s" % tool,
["which", tool],
stdout=self._api.raw_io.output_text(),
step_test_data=lambda: self._api.raw_io.test_api.stream_output_text(
"/usr/bin/%s" % tool
),
).stdout.strip()
class NsjailApi(recipe_api.RecipeApi):
"""NsjailApi allows callers to run steps in lightweight sandboxes."""
@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.
ok_ret (str): Allowed return codes from the step.
timeout (int): The timeout for the step.
**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,
)