diff --git a/create_debug_users.py b/create_debug_users.py deleted file mode 100755 index d61cb124..00000000 --- a/create_debug_users.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -import os -import subprocess -import sys - -if not __file__.endswith("shell.py"): - subprocess.call( - [ - sys.executable, - os.path.join(os.path.dirname(__file__), "manage.py"), - "shell", - "-c", - open(__file__).read(), - ] - ) - exit() - - -from tin.tests.create_users import add_users_to_database - -password = input("Enter password for all users: ") - -add_users_to_database(password=password, verbose=True) diff --git a/docs/source/contributing/setup.rst b/docs/source/contributing/setup.rst index b3324b34..a0b65afa 100644 --- a/docs/source/contributing/setup.rst +++ b/docs/source/contributing/setup.rst @@ -3,13 +3,16 @@ Setting up a development environment ------------------------------------ +Basic Setup +~~~~~~~~~~~ + First, you will need to install the following: * ``python`` * ``pipenv`` * ``git`` -You will also need a Github account. +You will also need a GitHub account. First, `fork `_ tin. Then you can clone tin onto your computer with @@ -30,15 +33,39 @@ After that, install dependencies and follow standard django procedures .. code-block:: bash pipenv install --dev - python3 manage.py migrate - python3 create_debug_users.py + pipenv run python3 manage.py migrate + pipenv run python3 manage.py create_debug_users Now you're all set! Try running the development server .. code-block:: bash - python3 manage.py runserver + pipenv run python3 manage.py runserver Head on over to `http://127.0.0.1:8000 `_, and login as ``admin`` and the password you just entered. + +Submissions +~~~~~~~~~~~ + +In order to actually create a submission, there are some more steps. First, +you'll need to install `redis `_. + +After that, you'll want to start up the development server and create a course, +and an assignment in the course. After saving the assignment, you can hit "Upload grader" +to add a grader - the simplest example of a grader is located in ``scripts/sample_grader.py``. + +Finally, before creating a submission, you'll need to start the celery worker. This can be done +by running the following command in a separate terminal:: + + pipenv run celery -A tin worker --loglevel=info + +Now you can try making a submission, and as long as your submission doesn't throw an error you +should get a 100%! Congrats on your brand new 5.0 GPA! + +.. tip:: + + If you're on a Unix-like system, you can use the ``&`` operator to run the celery worker in the background:: + + pipenv run celery -A tin worker --loglevel=info & pipenv run python3 manage.py runserver diff --git a/pyproject.toml b/pyproject.toml index c89edb08..4a638871 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -174,6 +174,14 @@ extend-ignore-names = [ "FBT", ] +"scripts/*" = [ + "INP001", +] + +"**/management/*" = [ + "INP001", +] + [tool.ruff.format] docstring-code-format = true line-ending = "lf" diff --git a/scripts/grader_wrapper.py b/scripts/grader_wrapper.py new file mode 100644 index 00000000..a17ad94c --- /dev/null +++ b/scripts/grader_wrapper.py @@ -0,0 +1,55 @@ +"""A sample wrapper script for running python submissions. + +The text in this file is read in :func:`run_submission` and +executed if ``settings.USE_SANDBOXING`` is not ``True``. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + + +def parse_args() -> list[str]: + parser = argparse.ArgumentParser() + parser.add_argument("--write", action="append") + parser.add_argument("--read", action="append") + # since we're not being sandboxed, we don't need to do anything + # with the grader arguments + _grader_args, submission_args = parser.parse_known_args() + + if submission_args and submission_args[0] == "--": + return submission_args[1:] + return submission_args + + +def find_python() -> str: + venv = Path("{venv_path}") + if venv.name == "None": + return "{python}" + if (python := venv / "bin" / "python").exists(): + return str(python) + return str(venv / "bin" / "python3") + + +def main() -> int: + args = parse_args() + submission_path = Path("{submission_path}") + + if submission_path.suffix != ".py": + raise NotImplementedError("Only python submissions are supported in DEBUG.") + + python = find_python() + output = subprocess.run( + [python, "--", str(submission_path), *args], + stdout=sys.stdout, + stderr=sys.stderr, + check=False, + ) + return output.returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sample_grader.py b/scripts/sample_grader.py new file mode 100644 index 00000000..2a19e5fd --- /dev/null +++ b/scripts/sample_grader.py @@ -0,0 +1,22 @@ +"""The (second) simplest grader. + +This runs the student submission, and without checking the output +gives them a 100%. However, if the submission crashes the student will get a 0% +""" + +from __future__ import annotations + +import subprocess +import sys + +process = subprocess.run( + [sys.executable, sys.argv[1]], + stdout=sys.stdout, + stderr=subprocess.STDOUT, + check=False, +) + +if process.returncode != 0: + print("Score: 0%") +else: + print("Score: 100%") diff --git a/tin/apps/submissions/tasks.py b/tin/apps/submissions/tasks.py index 50f56156..137af23a 100644 --- a/tin/apps/submissions/tasks.py +++ b/tin/apps/submissions/tasks.py @@ -1,15 +1,17 @@ from __future__ import annotations +import contextlib import logging import os import re import select -import shutil import signal import subprocess +import sys import time import traceback from decimal import Decimal +from pathlib import Path import psutil from asgiref.sync import async_to_sync @@ -62,37 +64,43 @@ def run_submission(submission_id): logger.error("Cannot run processes: %s", e) raise FileNotFoundError from e - python_exe = ( - os.path.join(submission.assignment.venv.path, "bin", "python") - if submission.assignment.venv_fully_created - else "/usr/bin/python3.10" - ) + if submission.assignment.venv_fully_created: + python_exe = os.path.join(submission.assignment.venv.path, "bin", "python") + elif settings.DEBUG: + python_exe = sys.executable + else: # pragma: no cover + python_exe = "/usr/bin/python3.10" + + if settings.USE_SANDBOXING: + wrapper_text = ( + Path(settings.BASE_DIR) + .joinpath( + "sandboxing", + "wrappers", + "sandboxed", + f"{submission.assignment.language}.txt", + ) + .read_text("utf-8") + ) - if not settings.DEBUG or shutil.which("bwrap") is not None: - folder_name = "sandboxed" + elif submission.assignment.language == "P": + wrapper_text = settings.DEBUG_GRADER_WRAPPER_SCRIPT.read_text("utf-8") else: - folder_name = "testing" - - with open( - os.path.join( - settings.BASE_DIR, - "sandboxing", - "wrappers", - folder_name, - f"{submission.assignment.language}.txt", - ) - ) as wrapper_file: - wrapper_text = wrapper_file.read().format( - has_network_access=bool(submission.assignment.has_network_access), - venv_path=( - submission.assignment.venv.path - if submission.assignment.venv_fully_created - else None - ), - submission_path=submission_path, - python=python_exe, + raise NotImplementedError( + f"Unsupported language {submission.assignment.language} in DEBUG" ) + wrapper_text = wrapper_text.format( + has_network_access=bool(submission.assignment.has_network_access), + venv_path=( + submission.assignment.venv.path + if submission.assignment.venv_fully_created + else None + ), + submission_path=submission_path, + python=python_exe, + ) + with open(submission_wrapper_path, "w", encoding="utf-8") as f_obj: f_obj.write(wrapper_text) @@ -130,7 +138,7 @@ def run_submission(submission_id): grader_log_path, ] - if not settings.DEBUG or shutil.which("firejail") is not None: + if settings.USE_SANDBOXING: whitelist = [os.path.dirname(grader_path)] read_only = [grader_path, submission_path, os.path.dirname(submission_wrapper_path)] if submission.assignment.venv_fully_created: @@ -145,7 +153,7 @@ def run_submission(submission_id): read_only=read_only, ) - env = dict(os.environ) + env = os.environ.copy() if submission.assignment.venv_fully_created: env.update(submission.assignment.venv.get_activation_env()) @@ -273,5 +281,5 @@ def run_submission(submission_id): submission.channel_group_name, {"type": "submission.updated"} ) - if os.path.exists(submission_wrapper_path): + with contextlib.suppress(FileNotFoundError): os.remove(submission_wrapper_path) diff --git a/tin/apps/users/management/commands/create_debug_users.py b/tin/apps/users/management/commands/create_debug_users.py new file mode 100644 index 00000000..b888e86c --- /dev/null +++ b/tin/apps/users/management/commands/create_debug_users.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from getpass import getpass + +from django.core.management.base import BaseCommand, no_translations + +import tin.tests.create_users as users + + +class Command(BaseCommand): + help = "Create users for debugging" + + def add_arguments(self, parser): + parser.add_argument("--noinput", action="store_true", help="Do not ask for password") + parser.add_argument("--force", action="store_true", help="Force creation of users") + + @no_translations + def handle(self, *args, **options): + if not options["noinput"]: + pwd = getpass("Enter password for all users: ") + else: + pwd = "jasongrace" + + users.add_users_to_database( + password=pwd, + verbose=options["verbosity"] > 0, + force=options["force"], + ) diff --git a/tin/apps/users/tests.py b/tin/apps/users/tests.py new file mode 100644 index 00000000..33f65f22 --- /dev/null +++ b/tin/apps/users/tests.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import pytest +from django.contrib.auth import get_user_model +from django.core.management import call_command + + +@pytest.mark.no_autocreate_users +def test_create_debug_users(): + admin = get_user_model().objects.filter(username="admin", is_superuser=True) + student = get_user_model().objects.filter(username="student", is_student=True) + teacher = get_user_model().objects.filter(username="teacher", is_teacher=True) + + assert not admin.exists() + assert not teacher.exists() + assert not student.exists() + + call_command("create_debug_users", noinput=True, verbosity=0) + + assert admin.exists() + assert teacher.exists() + assert student.exists() diff --git a/tin/apps/venvs/tasks.py b/tin/apps/venvs/tasks.py index ab7798e3..eab62b1c 100644 --- a/tin/apps/venvs/tasks.py +++ b/tin/apps/venvs/tasks.py @@ -15,6 +15,9 @@ @shared_task def create_venv(venv_id): venv = Venv.objects.get(id=venv_id) + python = settings.SUBMISSION_PYTHON + if settings.DEBUG: + python = sys.executable success = False try: @@ -25,7 +28,7 @@ def create_venv(venv_id): "-m", "virtualenv", "-p", - settings.SUBMISSION_PYTHON, + python, "--", venv.path, ], diff --git a/tin/settings/__init__.py b/tin/settings/__init__.py index 56c9450b..526d3b37 100644 --- a/tin/settings/__init__.py +++ b/tin/settings/__init__.py @@ -12,6 +12,7 @@ from __future__ import annotations import os +from pathlib import Path # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -28,6 +29,11 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True +USE_SANDBOXING = ( + not DEBUG or Path(BASE_DIR).joinpath("sandboxing", "wrappers", "sandboxed", "P.txt").exists() +) + + ALLOWED_HOSTS = [ "127.0.0.1", "localhost", @@ -305,6 +311,10 @@ VENV_FILE_SIZE_LIMIT = 1 * 1000 * 1000 * 1000 # 1 GB +# The wrapper script to use when running submissions outside of production +# We still need this so that it can handle cli arguments to the wrapper script +DEBUG_GRADER_WRAPPER_SCRIPT = Path(BASE_DIR).parent / "scripts" / "grader_wrapper.py" + # Spaces and special characters may not be handled correctly # Not importing correctly - specified directly in apps/submissions/tasks.py # as of 8/3/2022, 2022ldelwich diff --git a/tin/tests/create_users.py b/tin/tests/create_users.py index 35ed5bb7..831ae40c 100644 --- a/tin/tests/create_users.py +++ b/tin/tests/create_users.py @@ -14,7 +14,7 @@ # fmt: on -def add_users_to_database(password: str, *, verbose: bool = True) -> None: +def add_users_to_database(password: str, *, force: bool = False, verbose: bool = True) -> None: User = get_user_model() for ( @@ -26,7 +26,7 @@ def add_users_to_database(password: str, *, verbose: bool = True) -> None: ) in user_data: user, created = User.objects.get_or_create(username=username) - if not created: + if not created and not force: if verbose: print(f"User {username} already exists, skipping...") continue diff --git a/tin/tests/fixtures.py b/tin/tests/fixtures.py index 2cd66aa3..a89bf6c7 100644 --- a/tin/tests/fixtures.py +++ b/tin/tests/fixtures.py @@ -33,8 +33,10 @@ def tin_setup(settings, worker_id: str, testrun_uid: str): @pytest.fixture(autouse=True) -def create_users(): - users.add_users_to_database(password=PASSWORD, verbose=False) +def create_users(request): + marker = request.node.get_closest_marker("no_autocreate_users") + if marker is None: + users.add_users_to_database(password=PASSWORD, verbose=False) @pytest.fixture