diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 7678669b..72d28c4f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -2,7 +2,7 @@ on: workflow_call: outputs: artifact: - description: The name of the uploaded image aretfact. + description: The name of the uploaded image artefact. value: ${{ jobs.build.outputs.artifact }} version: description: The package's version. @@ -10,13 +10,18 @@ on: jobs: build: - name: Build snekbox-venv image + name: Build snekbox-integration image runs-on: ubuntu-latest + services: + registry: + image: registry:2 + ports: + - 5000:5000 outputs: artifact: ${{ env.artifact }} version: ${{ steps.version.outputs.version }} env: - artifact: image_artifact_snekbox-venv + artifact: image_artifact_snekbox-integration steps: - name: Checkout code @@ -38,6 +43,8 @@ jobs: # the builds. See https://github.com/docker/build-push-action - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + with: + driver-opts: network=host - name: Log in to GitHub Container Registry uses: docker/login-action@v3 @@ -78,16 +85,30 @@ jobs: with: context: . file: ./Dockerfile - push: false + push: true target: venv build-args: DEV=1 - outputs: type=docker,dest=${{ env.artifact }}.tar cache-from: | ${{ steps.cache_config.outputs.cache_from }} ghcr.io/python-discord/snekbox-base:latest ghcr.io/python-discord/snekbox-venv:latest cache-to: ${{ steps.cache_config.outputs.cache_to }} - tags: ghcr.io/python-discord/snekbox-venv:${{ steps.version.outputs.version }} + tags: localhost:5000/local/snekbox-venv:${{ steps.version.outputs.version }} + + - name: Build integration image for testing + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile.pydis + push: false + pull: false + outputs: type=docker,dest=${{ env.artifact }}.tar + build-args: SNEKBOX_IMAGE=localhost:5000/local/snekbox-venv:${{ steps.version.outputs.version }} + cache-from: | + ${{ steps.cache_config.outputs.cache_from }} + ghcr.io/python-discord/snekbox-venv:latest + cache-to: ${{ steps.cache_config.outputs.cache_to }} + tags: ghcr.io/python-discord/snekbox-integration:${{ steps.version.outputs.version }} # Make the image available as an artifact so other jobs will be able to # download it. diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 23c76e90..2dfb4a62 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -15,6 +15,11 @@ jobs: deploy: name: Build, push, & deploy runs-on: ubuntu-latest + services: + registry: + image: registry:2 + ports: + - 5000:5000 steps: - name: Download image artifact @@ -58,6 +63,21 @@ jobs: tags: | ghcr.io/python-discord/snekbox:latest ghcr.io/python-discord/snekbox:${{ inputs.version }} + localhost:5000/local/snekbox:${{ inputs.version }} + + - name: Build PyDis final image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile.pydis + push: true + cache-from: | + ghcr.io/python-discord/snekbox:latest-pydis + build-args: SNEKBOX_IMAGE=localhost:5000/local/snekbox:${{ inputs.version }} + cache-to: type=inline + tags: | + ghcr.io/python-discord/snekbox:latest-pydis + ghcr.io/python-discord/snekbox:${{ inputs.version }}-pydis # Deploy to Kubernetes. - name: Install kubectl @@ -74,7 +94,7 @@ jobs: with: namespace: snekbox manifests: deployment.yaml - images: 'ghcr.io/python-discord/snekbox:${{ inputs.version }}' + images: 'ghcr.io/python-discord/snekbox:${{ inputs.version }}-pydis' # Push the base image to GHCR, with an inline cache manifest. - name: Push base image diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a7d66a16..74fffc07 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -34,7 +34,7 @@ jobs: - name: Run tests id: run_tests run: | - export IMAGE_SUFFIX='-venv:${{ inputs.version }}' + export IMAGE_SUFFIX='-integration:${{ inputs.version }}' docker compose run \ --rm -T -e COVERAGE_DATAFILE=.coverage \ --entrypoint coverage \ @@ -110,5 +110,5 @@ jobs: # This is to ensure that deployment won't fail at that step - name: Install eval deps run: | - export IMAGE_SUFFIX='-venv:${{ inputs.version }}' + export IMAGE_SUFFIX='-integration:${{ inputs.version }}' docker compose run --rm -T --entrypoint /bin/bash snekbox scripts/install_eval_deps.sh diff --git a/Dockerfile b/Dockerfile index 2c73bb7b..0c607d6d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,47 +16,9 @@ RUN git clone -b master --single-branch https://github.com/google/nsjail.git . \ && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800 RUN make -# ------------------------------------------------------------------------------ -FROM buildpack-deps:bookworm AS builder-py-base - -ENV PYENV_ROOT=/pyenv \ - PYTHON_CONFIGURE_OPTS='--disable-test-modules --enable-optimizations \ - --with-lto --without-ensurepip' - -RUN apt-get -y update \ - && apt-get install -y --no-install-recommends \ - libxmlsec1-dev \ - tk-dev \ - lsb-release \ - software-properties-common \ - gnupg \ - && rm -rf /var/lib/apt/lists/* - -RUN git clone -b v2.6.9 --depth 1 https://github.com/pyenv/pyenv.git $PYENV_ROOT - -COPY --link scripts/build_python.sh / +FROM python:3.14-slim-bookworm AS base -# ------------------------------------------------------------------------------ -FROM builder-py-base AS builder-py-3_13 -RUN /build_python.sh 3.13.8 -# ------------------------------------------------------------------------------ -FROM builder-py-base AS builder-py-3_14 -RUN /build_python.sh 3.14.0 -# ------------------------------------------------------------------------------ -FROM builder-py-base AS builder-py-3_14t -RUN /build_python.sh 3.14.0t -# ------------------------------------------------------------------------------ -FROM builder-py-base AS builder-py-3_14j - -# Following guidance from https://github.com/python/cpython/blob/main/Tools/jit/README.md -RUN curl -o /tmp/llvm.sh https://apt.llvm.org/llvm.sh \ - && chmod +x /tmp/llvm.sh \ - && /tmp/llvm.sh 19 \ - && rm /tmp/llvm.sh - -RUN /build_python.sh 3.14.0j -# ------------------------------------------------------------------------------ -FROM python:3.13-slim-bookworm AS base +RUN mkdir -p /snekbin/python/ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ PIP_NO_CACHE_DIR=false @@ -70,13 +32,10 @@ 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_13 /snekbin/ /snekbin/ -COPY --link --from=builder-py-3_14 /snekbin/ /snekbin/ -COPY --link --from=builder-py-3_14t /snekbin/ /snekbin/ -COPY --link --from=builder-py-3_14j /snekbin/ /snekbin/ +# Snekbox defaults to using system Python unless additional versions are added. RUN chmod +x /usr/sbin/nsjail \ - && ln -s /snekbin/python/3.14/ /snekbin/python/default + && ln -s /usr/local /snekbin/python/default # ------------------------------------------------------------------------------ FROM base AS venv @@ -97,7 +56,7 @@ RUN if [ -n "${DEV}" ]; \ then \ pip install -U -r requirements/coverage.pip \ && export PYTHONUSERBASE=/snekbox/user_base \ - && /snekbin/python/default/bin/python -m pip install --user numpy~=2.3.3; \ + && /snekbin/python/default/bin/python -m pip install --user numpy~=2.3.4; \ fi # At the end to avoid re-installing dependencies when only a config changes. diff --git a/Dockerfile.pydis b/Dockerfile.pydis new file mode 100644 index 00000000..46a51684 --- /dev/null +++ b/Dockerfile.pydis @@ -0,0 +1,14 @@ +# This is a custom additional build of Snekbox that includes multiple Python versions. + +# The Python versions are now pulled from pre-built images to reduce build time +# and complexity. The images are built in the python-builds repository. + +ARG SNEKBOX_IMAGE=ghcr.io/python-discord/snekbox:latest +FROM ${SNEKBOX_IMAGE} + +COPY --link --from=ghcr.io/python-discord/python-builds:3.13 /snekbin/ /snekbin/ +COPY --link --from=ghcr.io/python-discord/python-builds:3.14 /snekbin/ /snekbin/ +COPY --link --from=ghcr.io/python-discord/python-builds:3.14t /snekbin/ /snekbin/ +COPY --link --from=ghcr.io/python-discord/python-builds:3.14j /snekbin/ /snekbin/ + +RUN rm /snekbin/python/default && ln -s /snekbin/python/3.14/ /snekbin/python/default diff --git a/README.md b/README.md index 52cc7f15..492c805f 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,24 @@ Name | Description `SNEKBOX_DEBUG` | Enable debug logging if set to a non-empty value. `SNEKBOX_SENTRY_DSN` | [Data Source Name] for Sentry. Sentry is disabled if left unset. +### Additional Interpreters + +By default, snekbox will use and make available the system Python (the version used to run snekbox itself). Additional interpreters or binaries should be placed in `/snekbin/` which is mounted to the nsjail container. + +You can use the `executable_path` parameter in `POST` requests to `/eval` to select an interpreter, e.g. + +```js +{ + "executable_path": "/snekbin/python/3.14/bin/python", + // Rest of /eval body + ... +} +``` + +The default interpreter is at `/snekbin/python/default/bin/python`, you can symlink `/snekbin/python/default` to another interpreter such as `/snekbin/python/3.14` to change this default. + +See [`Dockerfile.pydis`](Dockerfile.pydis) for an example using additional prebuilt interpreters and how to change the defaults.. This uses images built from [`python-discord/python-builds`](https://github.com/python-discord/python-builds). + ## Third-party Packages By default, the Python interpreter has no access to any packages besides the standard library. Even snekbox's own dependencies like Falcon and Gunicorn are not exposed. diff --git a/deployment.yaml b/deployment.yaml index 7351570b..5a650721 100644 --- a/deployment.yaml +++ b/deployment.yaml @@ -15,7 +15,7 @@ spec: spec: initContainers: - name: deps-install - image: ghcr.io/python-discord/snekbox:latest + image: ghcr.io/python-discord/snekbox:latest-pydis imagePullPolicy: Always volumeMounts: - name: snekbox-user-base-volume @@ -25,7 +25,7 @@ spec: - scripts/install_eval_deps.sh containers: - name: snekbox - image: ghcr.io/python-discord/snekbox:latest + image: ghcr.io/python-discord/snekbox:latest-pydis imagePullPolicy: Always ports: - containerPort: 8060 diff --git a/requirements/coverage.pip b/requirements/coverage.pip index 6fef2d18..b14c4372 100644 --- a/requirements/coverage.pip +++ b/requirements/coverage.pip @@ -4,7 +4,5 @@ # # pip-compile --output-file=requirements/coverage.pip requirements/coverage.in # -coverage[toml]==7.6.9 - # via - # -r requirements/coverage.in - # coverage +coverage[toml]==7.11.0 + # via -r requirements/coverage.in diff --git a/requirements/lint.pip b/requirements/lint.pip index c885feaf..8dbc08dd 100644 --- a/requirements/lint.pip +++ b/requirements/lint.pip @@ -6,19 +6,19 @@ # cfgv==3.4.0 # via pre-commit -distlib==0.3.9 +distlib==0.4.0 # via virtualenv -filelock==3.16.1 +filelock==3.20.0 # via virtualenv -identify==2.6.3 +identify==2.6.15 # via pre-commit nodeenv==1.9.1 # via pre-commit -platformdirs==4.3.6 +platformdirs==4.5.0 # via virtualenv -pre-commit==4.0.1 +pre-commit==4.3.0 # via -r requirements/lint.in -pyyaml==6.0.2 +pyyaml==6.0.3 # via pre-commit -virtualenv==20.28.0 +virtualenv==20.35.3 # via pre-commit diff --git a/requirements/pip-tools.pip b/requirements/pip-tools.pip index 1948f7dc..9f9a3257 100644 --- a/requirements/pip-tools.pip +++ b/requirements/pip-tools.pip @@ -4,19 +4,15 @@ # # pip-compile --output-file=requirements/pip-tools.pip requirements/pip-tools.in # -build==1.2.2.post1 +build==1.3.0 # via pip-tools -click==8.1.7 +click==8.3.0 # via pip-tools -colorama==0.4.6 +packaging==25.0 # via + # -c /Users/joe/Projects/python-discord/snekbox/requirements/requirements.pip # build - # click -packaging==24.2 - # via - # -c C:\Users\chris\src\snekbox\requirements\requirements.pip - # build -pip-tools==7.4.1 +pip-tools==7.5.1 # via -r requirements/pip-tools.in pyproject-hooks==1.2.0 # via diff --git a/requirements/requirements.pip b/requirements/requirements.pip index 653c263d..229f8f0e 100644 --- a/requirements/requirements.pip +++ b/requirements/requirements.pip @@ -4,37 +4,35 @@ # # pip-compile --extra=gunicorn --extra=sentry --output-file=requirements/requirements.pip pyproject.toml # -attrs==24.3.0 +attrs==25.4.0 # via # jsonschema # referencing -certifi==2024.12.14 +certifi==2025.10.5 # via sentry-sdk -falcon==4.0.2 +falcon==4.1.0 # via # sentry-sdk # snekbox (pyproject.toml) gunicorn==23.0.0 # via snekbox (pyproject.toml) -jsonschema==4.23.0 +jsonschema==4.25.1 # via snekbox (pyproject.toml) -jsonschema-specifications==2024.10.1 +jsonschema-specifications==2025.9.1 # via jsonschema -packaging==24.2 +packaging==25.0 # via gunicorn -protobuf==5.29.2 +protobuf==6.33.0 # via snekbox (pyproject.toml) -referencing==0.35.1 +referencing==0.37.0 # via # jsonschema # jsonschema-specifications -rpds-py==0.22.3 +rpds-py==0.27.1 # via # jsonschema # referencing -sentry-sdk[falcon]==2.19.2 - # via - # sentry-sdk - # snekbox (pyproject.toml) -urllib3==2.2.3 +sentry-sdk[falcon]==2.42.0 + # via snekbox (pyproject.toml) +urllib3==2.5.0 # via sentry-sdk diff --git a/snekbox/__main__.py b/snekbox/__main__.py index 1e5bceca..dbc2ab6a 100644 --- a/snekbox/__main__.py +++ b/snekbox/__main__.py @@ -13,18 +13,19 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument("code", help="the Python code to evaluate") parser.add_argument( - "nsjail_args", - action="store_const", - const=[], + "--nsjail-args", + nargs="*", + default=[], + dest="nsjail_args", help="override configured NsJail options (default: [])", ) parser.add_argument( - "py_args", - action="store_const", - const=["-c"], + "--py-args", + nargs="*", + default=["-c"], + dest="py_args", help="arguments to pass to the Python process (default: ['-c'])", ) - # nsjail_args and py_args are just dummies for documentation purposes. # Their actual values come from all the unknown arguments. # There doesn't seem to be a better solution with argparse. diff --git a/tests/test_nsjail.py b/tests/test_nsjail.py index 035c94cf..9bc3cf1c 100644 --- a/tests/test_nsjail.py +++ b/tests/test_nsjail.py @@ -90,7 +90,7 @@ def test_subprocess_resource_unavailable(self): """ ).strip() - result = self.eval_file(code) + result = self.eval_file(code, nsjail_args=("--cgroup_mem_max", "0")) self.assertEqual(result.returncode, 1) self.assertIn("Resource temporarily unavailable", result.stdout) # Expect n-1 processes to be opened by the presence of string like "2\n3\n4\n" @@ -220,13 +220,6 @@ def test_forkbomb_resource_unavailable(self): # limit so that the only reason the test code should be killed is due to # PID exhaustion. - previous_pids_max, previous_mem_max = ( - self.nsjail.config.cgroup_pids_max, - self.nsjail.config.cgroup_mem_max, - ) - self.nsjail.config.cgroup_pids_max = 5 - self.nsjail.config.cgroup_mem_max = 0 - code = dedent( """ import os @@ -235,14 +228,12 @@ def test_forkbomb_resource_unavailable(self): """ ).strip() - try: - result = self.eval_file(code) - self.assertEqual(result.returncode, 1) - self.assertIn("Resource temporarily unavailable", result.stdout) - self.assertEqual(result.stderr, None) - finally: - self.nsjail.config.cgroup_pids_max = previous_pids_max - self.nsjail.config.cgroup_mem_max = previous_mem_max + result = self.eval_file( + code, nsjail_args=("--cgroup_mem_max", "0", "--cgroup_pids_max", "5") + ) + self.assertEqual(result.returncode, 1) + self.assertIn("Resource temporarily unavailable", result.stdout) + self.assertEqual(result.stderr, None) def test_file_parsing_timeout(self): code = dedent(