| # 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] |