Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multiple binary support api #196

Merged
merged 16 commits into from
Oct 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ ignore =
# Type Annotations
ANN002,ANN003,ANN101,ANN102,ANN204,ANN206

per-file-ignores = tests/*:D1,ANN
per-file-ignores = tests/*:D1,ANN,E202,E231,E241,E272,E702
4 changes: 2 additions & 2 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ Other things to look out for are breaking changes to NsJail's config format, its

## Adding and Updating Python Interpreters

Python interpreters are built using pyenv via the `scripts/build_python.sh` helper script. This script accepts a pyenv version specifier (`pyenv install --list`) and builds the interpreter in a version-specific directory under `/lang/python`. In the image, each minor version of a Python interpreter should have its own build stage and the resulting `/lang/python` directory can be copied from that stage into the `base` stage.
Python interpreters are built using pyenv via the `scripts/build_python.sh` helper script. This script accepts a pyenv version specifier (`pyenv install --list`) and builds the interpreter in a version-specific directory under `/snekbin/python`. In the image, each minor version of a Python interpreter should have its own build stage and the resulting `/snekbin/python` directory can be copied from that stage into the `base` stage.

When updating a patch version (e.g. 3.11.3 to 3.11.4), edit the existing build stage in the image for the minor version (3.11); do not add a new build stage. To have access to a new version, pyenv likely needs to be updated. To do so, change the tag in the `git clone` command in the image, but only for the build stage that needs access to the new version. Updating pyenv for all build stages will just cause unnecessary build cache invalidations.

To change the default interpreter used by NsJail, update the target of the `/lang/python/default` symlink created in the `base` stage.
To change the default interpreter used by NsJail, update the target of the `/snekbin/python/default` symlink created in the `base` stage.

[readme]: ../README.md
[Dockerfile]: ../Dockerfile
Expand Down
8 changes: 4 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ RUN apt-get -y update \
&& rm -rf /var/lib/apt/lists/*

COPY --link --from=builder-nsjail /nsjail/nsjail /usr/sbin/
COPY --link --from=builder-py-3_12 /lang/ /lang/
COPY --link --from=builder-py-3_13 /lang/ /lang/
COPY --link --from=builder-py-3_12 /snekbin/ /snekbin/
COPY --link --from=builder-py-3_13 /snekbin/ /snekbin/

RUN chmod +x /usr/sbin/nsjail \
&& ln -s /lang/python/3.12/ /lang/python/default
&& ln -s /snekbin/python/3.12/ /snekbin/python/default

# ------------------------------------------------------------------------------
FROM base as venv
Expand All @@ -79,7 +79,7 @@ RUN if [ -n "${DEV}" ]; \
then \
pip install -U -r requirements/coverage.pip \
&& export PYTHONUSERBASE=/snekbox/user_base \
&& /lang/python/default/bin/python -m pip install --user numpy~=1.19; \
&& /snekbin/python/default/bin/python -m pip install --user numpy~=1.19; \
fi

# At the end to avoid re-installing dependencies when only a config changes.
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ To run it in the background, use the `-d` option. See the documentation on [`doc

The above command will make the API accessible on the host via `http://localhost:8060/`. Currently, there's only one endpoint: `http://localhost:8060/eval`.

### Python multi-version support

By default, the executable that runs within nsjail is defined by `DEFAULT_EXECUTABLE_PATH` at the top of [`nsjail.py`]. This can be overridden by specifying `executable_path` in the request body of calls to `POST /eval` or by setting the `executable_path` kwarg if calling `NSJail.python3()` directly.

Any executable that exists within the container is a valid value for `executable_path`. The main use case of this feature is currently to specify the version of Python to use.

Python versions currently available can be found in the [`Dockerfile`] by looking for build stages that match `builder-py-*`. These binaries are then copied into the `base` build stage further down.

## Configuration

Configuration files can be edited directly. However, this requires rebuilding the image. Alternatively, a Docker volume or bind mounts can be used to override the configuration files at their default locations.
Expand Down Expand Up @@ -105,7 +113,7 @@ To expose third-party Python packages during evaluation, install them to a custo

```sh
docker exec snekbox /bin/sh -c \
'PYTHONUSERBASE=/snekbox/user_base /lang/python/default/bin/python -m pip install --user numpy'
'PYTHONUSERBASE=/snekbox/user_base /snekbin/python/default/bin/python -m pip install --user numpy'
```

In the above command, `snekbox` is the name of the running container. The name may be different and can be checked with `docker ps`.
Expand All @@ -126,9 +134,11 @@ See [CONTRIBUTING.md](.github/CONTRIBUTING.md).
[7]: https://github.com/google/nsjail/blob/master/config.proto
[`gunicorn.conf.py`]: config/gunicorn.conf.py
[`snekbox.cfg`]: config/snekbox.cfg
[`nsjail.py`]: snekbox/nsjail.py
[`snekapi.py`]: snekbox/api/snekapi.py
[`resources`]: snekbox/api/resources
[`docker-compose.yml`]: docker-compose.yml
[`Dockerfile`]: Dockerfile
[`docker run`]: https://docs.docker.com/engine/reference/commandline/run/
[nsjail]: https://github.com/google/nsjail
[falcon]: https://falconframework.org/
Expand Down
9 changes: 2 additions & 7 deletions config/snekbox.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ mount {
}

mount {
src: "/lang"
dst: "/lang"
src: "/snekbin"
dst: "/snekbin"
is_bind: true
rw: false
}
Expand All @@ -103,8 +103,3 @@ cgroup_pids_max: 6
cgroup_pids_mount: "/sys/fs/cgroup/pids"

iface_no_lo: true

exec_bin {
path: "/lang/python/default/bin/python"
arg: ""
}
8 changes: 4 additions & 4 deletions scripts/build_python.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ shopt -s inherit_errexit

py_version="${1}"

# Install Python interpreter under e.g. /lang/python/3.11/ (no patch version).
# Install Python interpreter under e.g. /snekbin/python/3.11/ (no patch version).
"${PYENV_ROOT}/plugins/python-build/bin/python-build" \
"${py_version}" \
"/lang/python/${py_version%.*}"
"/lang/python/${py_version%.*}/bin/python" -m pip install -U pip
"/snekbin/python/${py_version%[-.]*}"
"/snekbin/python/${py_version%[-.]*}/bin/python" -m pip install -U pip

# Clean up some unnecessary files to reduce image size bloat.
find /lang/python/ -depth \
find /snekbin/python/ -depth \
\( \
\( -type d -a \( \
-name test -o -name tests -o -name idle_test \
Expand Down
2 changes: 1 addition & 1 deletion scripts/install_eval_deps.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
set -euo pipefail

export PYTHONUSERBASE=/snekbox/user_base
find /lang/python -mindepth 1 -maxdepth 1 -type d -print0 | xargs -0I{} bash -c \
find /snekbin/python -mindepth 1 -maxdepth 1 -type d -print0 | xargs -0I{} bash -c \
'{}/bin/python -m pip install --user -U -r requirements/eval-deps.pip' \;
19 changes: 18 additions & 1 deletion snekbox/api/resources/eval.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from __future__ import annotations

import logging
from pathlib import Path

import falcon
from falcon.media.validators.jsonschema import validate

from snekbox.nsjail import NsJail
from snekbox.nsjail import DEFAULT_EXECUTABLE_PATH, NsJail
from snekbox.snekio import FileAttachment, ParsingError

__all__ = ("EvalResource",)
Expand Down Expand Up @@ -43,6 +44,7 @@ class EvalResource:
"required": ["path"],
},
},
"executable_path": {"type": "string"},
},
"anyOf": [
{"required": ["input"]},
Expand Down Expand Up @@ -122,10 +124,25 @@ def on_post(self, req: falcon.Request, resp: falcon.Response) -> None:
if "input" in body:
body.setdefault("args", ["-c"])
body["args"].append(body["input"])

executable_path = body.get("executable_path")
if not executable_path:
executable_path = DEFAULT_EXECUTABLE_PATH
else:
executable_path = Path(executable_path)
if not executable_path.exists():
raise falcon.HTTPBadRequest(title="executable_path does not exist")
if not executable_path.is_file():
raise falcon.HTTPBadRequest(title="executable_path is not a file")
if not executable_path.stat().st_mode & 0o100 == 0o100:
raise falcon.HTTPBadRequest(title="executable_path is not executable")
executable_path = executable_path.resolve().as_posix()

try:
result = self.nsjail.python3(
py_args=body["args"],
files=[FileAttachment.from_dict(file) for file in body.get("files", [])],
executable_path=executable_path,
)
except ParsingError as e:
raise falcon.HTTPBadRequest(title="Request file is invalid", description=str(e))
Expand Down
25 changes: 18 additions & 7 deletions snekbox/nsjail.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
LOG_PATTERN = re.compile(
r"\[(?P<level>(I)|[DWEF])\]\[.+?\](?(2)|(?P<func>\[\d+\] .+?:\d+ )) ?(?P<msg>.+)"
)
DEFAULT_EXECUTABLE_PATH = "/snekbin/python/default/bin/python"


class NsJail:
Expand Down Expand Up @@ -168,7 +169,12 @@ def _consume_stdout(self, nsjail: subprocess.Popen) -> str:
return "".join(output)

def _build_args(
self, py_args: Iterable[str], nsjail_args: Iterable[str], log_path: str, fs_home: str
self,
py_args: Iterable[str],
nsjail_args: Iterable[str],
log_path: str,
fs_home: str,
executable_path: str,
) -> Sequence[str]:
if self.cgroup_version == 2:
nsjail_args = ("--use_cgroupv2", *nsjail_args)
Expand All @@ -185,7 +191,7 @@ def _build_args(
nsjail_args = (
# Mount `home` with Read/Write access
"--bindmount",
f"{fs_home}:home",
f"{fs_home}:home", # noqa: E231
*nsjail_args,
)

Expand All @@ -197,10 +203,7 @@ def _build_args(
log_path,
*nsjail_args,
"--",
self.config.exec_bin.path,
# Filter out empty strings at start of Python args
# (causes issues with python cli)
*iter_lstrip(self.config.exec_bin.arg),
executable_path,
*iter_lstrip(py_args),
]

Expand Down Expand Up @@ -259,6 +262,7 @@ def python3(
py_args: Iterable[str],
files: Iterable[FileAttachment] = (),
nsjail_args: Iterable[str] = (),
executable_path: Path = DEFAULT_EXECUTABLE_PATH,
) -> EvalResult:
"""
Execute Python 3 code in an isolated environment and return the completed process.
Expand All @@ -267,13 +271,20 @@ def python3(
py_args: Arguments to pass to Python.
files: FileAttachments to write to the sandbox prior to running Python.
nsjail_args: Overrides for the NsJail configuration.
executable_path: The path to the executable to run within nsjail.
"""
with NamedTemporaryFile() as nsj_log, MemFS(
instance_size=self.memfs_instance_size,
home=self.memfs_home,
output=self.memfs_output,
) as fs:
args = self._build_args(py_args, nsjail_args, nsj_log.name, str(fs.home))
args = self._build_args(
py_args,
nsjail_args,
nsj_log.name,
str(fs.home),
executable_path,
)
try:
files_written = self._write_files(fs.home, files)

Expand Down
54 changes: 54 additions & 0 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,60 @@ def test_memory_limit_separate_per_process(self):
self.assertTrue(all(status == 200 for status in statuses))
self.assertTrue(all(json.loads(response)["returncode"] == 0 for response in responses))

def test_alternate_executable_support(self):
"""Test eval requests with different executable paths set."""
with run_gunicorn():
get_python_version_body = {
"input": "import sys; print('.'.join(map(str, sys.version_info[:2])))"
}
cases = [
(
get_python_version_body,
"3.12\n",
"test default executable is used when executable_path not specified",
),
(
get_python_version_body
| {"executable_path": "/snekbin/python/3.12/bin/python"},
"3.12\n",
"test default executable is used when explicitly set",
),
(
get_python_version_body
| {"executable_path": "/snekbin/python/3.13/bin/python"},
"3.13\n",
"test alternative executable is used when set",
),
]
for body, expected, msg in cases:
with self.subTest(msg=msg, body=body, expected=expected):
response, status = snekbox_request(body)
self.assertEqual(status, 200)
self.assertEqual(json.loads(response)["stdout"], expected)

def invalid_executable_paths(self):
"""Test that passing invalid executable paths result in no code execution."""
with run_gunicorn():
cases = [
(
"/abc/def",
"test non-existent files are not run",
"executable_path does not exist",
),
("/snekbin", "test directories are not run", "executable_path is not a file"),
(
"/etc/hostname",
"test non-executable files are not run",
"executable_path is not executable",
),
]
for path, msg, expected in cases:
with self.subTest(msg=msg, path=path, expected=expected):
body = {"args": ["-c", "echo", "hi"], "executable_path": path}
response, status = snekbox_request(body)
self.assertEqual(status, 400)
self.assertEqual(json.loads(response)["stdout"], expected)

def test_eval(self):
"""Test normal eval requests without files."""
with run_gunicorn():
Expand Down
6 changes: 3 additions & 3 deletions tests/test_nsjail.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from pathlib import Path
from textwrap import dedent

from snekbox.nsjail import NsJail
from snekbox.nsjail import DEFAULT_EXECUTABLE_PATH, NsJail
from snekbox.snekio import FileAttachment
from snekbox.snekio.filesystem import Size

Expand Down Expand Up @@ -82,7 +82,7 @@ def test_subprocess_resource_unavailable(self):
for _ in range({max_pids}):
print(subprocess.Popen(
[
'/lang/python/default/bin/python',
'/snekbin/python/default/bin/python',
'-c',
'import time; time.sleep(1)'
],
Expand Down Expand Up @@ -547,7 +547,7 @@ def test_py_args(self):
for args, expected in cases:
with self.subTest(args=args):
result = self.nsjail.python3(py_args=args)
idx = result.args.index(self.nsjail.config.exec_bin.path)
idx = result.args.index(DEFAULT_EXECUTABLE_PATH)
self.assertEqual(result.args[idx + 1 :], expected)
self.assertEqual(result.returncode, 0)

Expand Down