Skip to content

Commit

Permalink
Revamp virtual environments (now reusable!)
Browse files Browse the repository at this point in the history
Venvs are no longer associated with assignments; instead, assignments can now use a venv to run submissions
  • Loading branch information
krishnans2006 committed Mar 29, 2024
1 parent 29b07b4 commit 0575701
Show file tree
Hide file tree
Showing 18 changed files with 334 additions and 206 deletions.
3 changes: 3 additions & 0 deletions tin/apps/assignments/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class Meta:
"folder",
"language",
"filename",
"venv",
"points_possible",
"due",
"hidden",
Expand All @@ -43,6 +44,7 @@ class Meta:
"submission_limit_cooldown",
]
labels = {
"venv": "Virtual environment",
"hidden": "Hide assignment from students?",
"enable_grader_timeout": "Set a timeout for the grader?",
"grader_timeout": "Grader timeout (seconds):",
Expand All @@ -57,6 +59,7 @@ class Meta:
"filename": "Clarify which file students need to upload (including the file "
"extension). For Java assignments, this also sets the name of the "
"saved submission file.",
"venv": "If set, Tin will run the student's code in this virtual environment.",
"grader_has_network_access": 'If unset, this effectively disables "Give submissions '
'internet access" below. If set, it increases the amount '
"of time it takes to start up the grader (to about 1.5 "
Expand Down
20 changes: 20 additions & 0 deletions tin/apps/assignments/migrations/0028_assignment_venv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 3.2.25 on 2024-03-29 05:12

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('venvs', '0005_auto_20240328_0033'),
('assignments', '0027_mossresult'),
]

operations = [
migrations.AddField(
model_name='assignment',
name='venv',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assignments', to='venvs.venv'),
),
]
17 changes: 11 additions & 6 deletions tin/apps/assignments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from ...sandboxing import get_action_sandbox_args, get_assignment_sandbox_args
from ..courses.models import Course, Period
from ..submissions.models import Submission
from ..venvs.models import Virtualenv
from ..venvs.models import Venv

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -85,6 +85,15 @@ class Assignment(models.Model):
"courses.Course", on_delete=models.CASCADE, related_name="assignments"
)

venv = models.ForeignKey(
Venv,
on_delete=models.SET_NULL,
default=None,
null=True,
blank=True,
related_name="assignments",
)

points_possible = models.DecimalField(
max_digits=6, decimal_places=3, validators=[MinValueValidator(1)]
)
Expand Down Expand Up @@ -256,13 +265,9 @@ def check_rate_limit(self, student) -> None:
current_cooldown.delete()
CooldownPeriod.objects.create(assignment=self, student=student)

@property
def venv_object_created(self):
return Virtualenv.objects.filter(assignment=self).exists()

@property
def venv_fully_created(self):
return Virtualenv.objects.filter(assignment=self, fully_created=True).exists()
return self.venv and self.venv.fully_created

@property
def grader_log_filename(self):
Expand Down
8 changes: 4 additions & 4 deletions tin/apps/submissions/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def run_submission(submission_id):
raise FileNotFoundError from e

python_exe = (
os.path.join(submission.assignment.venv.get_full_path(), "bin/python")
os.path.join(submission.assignment.venv.path, "bin", "python")
if submission.assignment.venv_fully_created
else "/usr/bin/python3.10"
)
Expand All @@ -84,7 +84,7 @@ def run_submission(submission_id):
wrapper_text = wrapper_file.read().format(
has_network_access=bool(submission.assignment.has_network_access),
venv_path=(
submission.assignment.venv.get_full_path()
submission.assignment.venv.path
if submission.assignment.venv_fully_created
else None
),
Expand Down Expand Up @@ -132,8 +132,8 @@ def run_submission(submission_id):
if not settings.DEBUG or shutil.which("firejail") is not None:
whitelist = [os.path.dirname(grader_path)]
read_only = [grader_path, submission_path, os.path.dirname(submission_wrapper_path)]
if submission.assignment.venv_object_created:
read_only.append(submission.assignment.venv.get_full_path())
if submission.assignment.venv_fully_created:
read_only.append(submission.assignment.venv.path)

args = sandboxing.get_assignment_sandbox_args(
args,
Expand Down
11 changes: 5 additions & 6 deletions tin/apps/venvs/admin.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
from django.contrib import admin

from .models import Virtualenv
from .models import Venv

# Register your models here.


@admin.register(Virtualenv)
class VirtualenvAdmin(admin.ModelAdmin):
list_display = ("assignment", "fully_created", "installing_packages")
@admin.register(Venv)
class VenvAdmin(admin.ModelAdmin):
list_display = ("name", "fully_created", "installing_packages")
list_filter = ("fully_created", "installing_packages")
save_as = True
search_fields = ("assignment__name",)
autocomplete_fields = ("assignment",)
search_fields = ("name",)
9 changes: 9 additions & 0 deletions tin/apps/venvs/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django import forms

from .models import Venv


class VenvForm(forms.ModelForm):
class Meta:
model = Venv
fields = ["name"]
26 changes: 26 additions & 0 deletions tin/apps/venvs/migrations/0005_auto_20240328_0033.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 3.2.25 on 2024-03-28 04:33

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('venvs', '0004_alter_virtualenv_id'),
]

operations = [
migrations.CreateModel(
name='Venv',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('fully_created', models.BooleanField()),
('installing_packages', models.BooleanField(default=False)),
('package_installation_output', models.CharField(blank=True, default='', max_length=16384)),
],
),
migrations.DeleteModel(
name='Virtualenv',
),
]
94 changes: 19 additions & 75 deletions tin/apps/venvs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,111 +12,55 @@
logger = logging.getLogger(__name__)


class VirtualenvCreationError(Exception):
class VenvCreationError(Exception):
pass


class VirtualenvExistsError(VirtualenvCreationError):
class VenvExistsError(VenvCreationError):
pass


class VenvQuerySet(models.query.QuerySet):
def filter_visible(self, user):
if user.is_superuser:
if user.is_superuser or user.is_teacher:
return self.all()
else:
return self.filter(assignment__course__teacher=user).distinct()
return self.none()

def filter_editable(self, user):
if user.is_superuser:
return self.all()
else:
return self.filter(assignment__course__teacher=user).distinct()
return self.none()


class Virtualenv(models.Model):
class Venv(models.Model):
objects = VenvQuerySet.as_manager()

assignment = models.OneToOneField(
"assignments.Assignment", on_delete=models.CASCADE, related_name="venv", null=False
)
name = models.CharField(max_length=255, null=False, blank=False)

fully_created = models.BooleanField(null=False)

installing_packages = models.BooleanField(default=False, null=False)

OUTPUT_MAX_LENGTH = 16 * 1024
package_installation_output = models.CharField(
max_length=16 * 1024, default="", null=False, blank=True
max_length=OUTPUT_MAX_LENGTH, default="", null=False, blank=True
)

def __str__(self):
return f"Virtualenv for {self.assignment}"
return self.name

def __repr__(self):
return f"Virtualenv for {self.assignment}"
return f"<Virtualenv: {self.name}>"

def get_absolute_url(self):
return reverse("venvs:show", args=[self.id])

def get_full_path(self):
assert self.assignment.grader_file.name
return os.path.join(
settings.MEDIA_ROOT, os.path.dirname(self.assignment.grader_file.name), "venv"
)

@classmethod
def create_venv_for_assignment(cls, assignment):
try:
venv = cls.objects.create(assignment=assignment, fully_created=False)
except IntegrityError:
raise VirtualenvExistsError

success = False

try:
if os.path.exists(venv.get_full_path()):
raise VirtualenvCreationError(
"Virtualenv directory for assignment #{} exists".format(assignment.id)
)

try:
res = subprocess.run(
[
sys.executable,
"-m",
"virtualenv",
"-p",
settings.SUBMISSION_PYTHON,
"--",
venv.get_full_path(),
],
check=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
except FileNotFoundError as e:
logger.error("Cannot run processes: %s", e)
raise FileNotFoundError from e

if res.returncode != 0:
raise VirtualenvCreationError(
"Error creating virtual environment (return code {}): {}".format(
res.returncode, res.stdout
)
)

venv.fully_created = True
venv.save()
success = True
finally:
if not success:
venv.delete()

return venv
@property
def path(self):
return os.path.join(settings.MEDIA_ROOT, "venvs", f"venv-{self.id}")

def get_activation_env(self):
venv_path = self.get_full_path()
venv_path = self.path

return {
"VIRTUAL_ENV": venv_path,
Expand All @@ -130,7 +74,7 @@ def list_packages(self):
args = sandboxing.get_assignment_sandbox_args(
["pip", "freeze"],
network_access=False,
read_only=[self.get_full_path()],
read_only=[self.path],
extra_firejail_args=["--rlimit-fsize=209715200"],
)

Expand Down Expand Up @@ -168,7 +112,7 @@ def install_packages(self, pkgs):
args = sandboxing.get_assignment_sandbox_args(
["pip", "install", "--upgrade", "--", *pkgs],
network_access=True,
whitelist=[self.get_full_path()],
whitelist=[self.path],
extra_firejail_args=["--rlimit-fsize=209715200"],
)

Expand All @@ -186,9 +130,9 @@ def install_packages(self, pkgs):
raise FileNotFoundError from e

try:
self.package_installation_output = res.stdout.decode()[-16 * 1024:]
self.package_installation_output = res.stdout.decode()[-self.OUTPUT_MAX_LENGTH:]
except UnicodeDecodeError:
self.package_installation_output = str(res.stdout)[-16 * 1024:]
self.package_installation_output = str(res.stdout)[-self.OUTPUT_MAX_LENGTH:]
finally:
self.installing_packages = False
self.save()
57 changes: 46 additions & 11 deletions tin/apps/venvs/tasks.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,59 @@
import logging
import subprocess
import sys

from celery import shared_task

from ..assignments.models import Assignment
from .models import Virtualenv, VirtualenvCreationError
from django.conf import settings

from .models import Venv, VenvCreationError, VenvExistsError

@shared_task
def create_virtualenv(assignment_id):
assignment = Assignment.objects.get(id=assignment_id)
logger = logging.getLogger(__name__)

if assignment.venv_object_created:
return

@shared_task
def create_venv(venv_id):
venv = Venv.objects.get(id=venv_id)

success = False
try:
Virtualenv.create_venv_for_assignment(assignment)
except VirtualenvCreationError:
pass
try:
res = subprocess.run(
[
sys.executable,
"-m",
"virtualenv",
"-p",
settings.SUBMISSION_PYTHON,
"--",
venv.path,
],
check=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
except FileNotFoundError as e:
logger.error("Cannot run processes: %s", e)
raise FileNotFoundError from e

if res.returncode != 0:
raise VenvCreationError(
"Error creating virtual environment (return code {}): {}".format(
res.returncode, res.stdout
)
)

venv.fully_created = True
venv.save()
success = True
finally:
if not success:
venv.delete()


@shared_task
def install_packages(venv_id, package_names):
venv = Virtualenv.objects.get(id=venv_id)
venv = Venv.objects.get(id=venv_id)

venv.install_packages(package_names)
Loading

0 comments on commit 0575701

Please sign in to comment.