diff --git a/.gitignore b/.gitignore index 72cffcc6bc57b982c9ad6d4493543bcdd519a48f..cdd667e65391cb80ced01c93e79f32850648ab31 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ components build *.pyz + +*.pem diff --git a/course/admin.py b/course/admin.py index b2e66784febb10899333bded6b963565e765c3d2..5e89a2debf85e230b1eabdaf5de0c10ccf5d7ce6 100644 --- a/course/admin.py +++ b/course/admin.py @@ -295,15 +295,16 @@ class ParticipationAdmin(admin.ModelAdmin): from django.conf import settings return string_concat( - "<a href='%(link)s'>", _("%(last_name)s, %(first_name)s"), - "</a>" - ) % { - "link": reverse( - "admin:%s_change" % settings.AUTH_USER_MODEL.replace(".", "_") - .lower(), - args=(obj.user.id,)), - "last_name": verbose_blank(obj.user.last_name), - "first_name": verbose_blank(obj.user.first_name)} + "<a href='%(link)s'>", _("%(last_name)s, %(first_name)s"), + "</a>" + ) % { + "link": reverse( + "admin:%s_change" + % settings.AUTH_USER_MODEL.replace(".", "_") + .lower(), + args=(obj.user.id,)), + "last_name": verbose_blank(obj.user.last_name), + "first_name": verbose_blank(obj.user.first_name)} get_user.short_description = pgettext("real name of a user", "Name") get_user.admin_order_field = "user__last_name" @@ -389,7 +390,14 @@ admin.site.register(ParticipationPreapproval, ParticipationPreapprovalAdmin) class InstantFlowRequestAdmin(admin.ModelAdmin): - pass + list_display = ("course", "flow_id", "start_time", "end_time", "cancelled") + list_filter = ("course",) + + date_hierarchy = "start_time" + + search_fields = ( + "email", + ) admin.site.register(InstantFlowRequest, InstantFlowRequestAdmin) diff --git a/course/content.py b/course/content.py index b198c21c1855c3527a1df77dc6c91d85af1f557f..f60a6c9fefffbdc14618e5b753d51f0f2760d5a9 100644 --- a/course/content.py +++ b/course/content.py @@ -30,6 +30,7 @@ from django.utils.translation import ugettext as _ import re import datetime import six +import sys from django.utils.timezone import now from django.core.exceptions import ObjectDoesNotExist, ImproperlyConfigured @@ -123,10 +124,12 @@ def get_repo_blob_data_cached(repo, full_name, commit_sha): if isinstance(commit_sha, six.binary_type): from six.moves.urllib.parse import quote_plus - cache_key = "%%%1".join(( + cache_key = "%R%1".join(( quote_plus(repo.controldir()), quote_plus(full_name), - commit_sha.decode())) + commit_sha.decode(), + ".".join(str(s) for s in sys.version_info[:2]), + )) else: cache_key = None @@ -136,7 +139,13 @@ def get_repo_blob_data_cached(repo, full_name, commit_sha): cache_key = None if cache_key is None: - return get_repo_blob(repo, full_name, commit_sha).data + result = get_repo_blob(repo, full_name, commit_sha).data + assert isinstance(result, six.binary_type) + return result + + # Byte string is wrapped in a tuple to force pickling because memcache's + # python wrapper appears to auto-decode/encode string values, thus trying + # to decode our byte strings. Grr. def_cache = cache.caches["default"] @@ -145,12 +154,16 @@ def get_repo_blob_data_cached(repo, full_name, commit_sha): if len(cache_key) < 240: result = def_cache.get(cache_key) if result is not None: + (result,) = result + assert isinstance(result, six.binary_type), cache_key return result result = get_repo_blob(repo, full_name, commit_sha).data if len(result) <= getattr(settings, "RELATE_CACHE_MAX_BYTES", 0): - def_cache.add(cache_key, result, None) + def_cache.add(cache_key, (result,), None) + + assert isinstance(result, six.binary_type) return result @@ -324,7 +337,8 @@ def get_raw_yaml_from_repo(repo, full_name, commit_sha): from six.moves.urllib.parse import quote_plus cache_key = "%RAW%%2".join(( - quote_plus(repo.controldir()), quote_plus(full_name), commit_sha.decode())) + quote_plus(repo.controldir()), quote_plus(full_name), commit_sha.decode(), + )) import django.core.cache as cache def_cache = cache.caches["default"] diff --git a/course/models.py b/course/models.py index 011f62b94e88f7fe395d0697dde357d631291a5e..97314e199ab0efaeed1fb1029e4d1aa7b9fc97dc 100644 --- a/course/models.py +++ b/course/models.py @@ -448,6 +448,18 @@ class InstantFlowRequest(models.Model): verbose_name = _("Instant flow request") verbose_name_plural = _("Instant flow requests") + def __unicode__(self): + return _("Instant flow request for " + "%(flow_id)s in %(course)s at %(start_time)s") \ + % { + "flow_id": self.flow_id, + "course": self.course, + "start_time": self.start_time, + } + + if six.PY3: + __str__ = __unicode__ + # {{{ flow session diff --git a/course/page/code.py b/course/page/code.py index fc0ead65192159da8d9b055373e32631ec4b7fc4..6488eb773eacd06f0067db968d50244fdf9856f7 100644 --- a/course/page/code.py +++ b/course/page/code.py @@ -93,10 +93,15 @@ def request_python_run(run_req, run_timeout, image=None): # DEBUGGING SWITCH: 1 for 'spawn container', 0 for 'static container' if 1: + docker_url = getattr(settings, "RELATE_DOCKER_URL", + "unix://var/run/docker.sock") + docker_tls = getattr(settings, "RELATE_DOCKER_TLS_CONFIG", + None) docker_cnx = docker.Client( - base_url=getattr(settings, "RELATE_DOCKER_URL", - "unix://var/run/docker.sock"), - version='1.12', timeout=docker_timeout) + base_url=docker_url, + tls=docker_tls, + timeout=docker_timeout, + version="1.19") if image is None: image = settings.RELATE_DOCKER_RUNPY_IMAGE @@ -106,22 +111,34 @@ def request_python_run(run_req, run_timeout, image=None): command=[ "/opt/runpy/runpy", "-1"], - mem_limit=256*10**6, + host_config={ + "Memory": 256*10**6, + "MemorySwap": -1, + "PublishAllPorts": True, + "ReadonlyRootfs": True, + }, user="runpy") container_id = dresult["Id"] else: container_id = None + connect_host_ip = 'localhost' + try: # FIXME: Prohibit networking if container_id is not None: - docker_cnx.start( - container_id, - port_bindings={RUNPY_PORT: ('127.0.0.1',)}) + docker_cnx.start(container_id) + + container_props = docker_cnx.inspect_container(container_id) + (port_info,) = (container_props + ["NetworkSettings"]["Ports"]["%d/tcp" % RUNPY_PORT]) + port_host_ip = port_info.get("HostIp") + + if port_host_ip != "0.0.0.0": + connect_host_ip = port_host_ip - port_info, = docker_cnx.port(container_id, RUNPY_PORT) port = int(port_info["HostPort"]) else: port = RUNPY_PORT @@ -133,44 +150,44 @@ def request_python_run(run_req, run_timeout, image=None): from traceback import format_exc + def check_timeout(): + if time() - start_time < docker_timeout: + sleep(0.1) + # and retry + else: + return { + "result": "uncaught_error", + "message": "Timeout waiting for container.", + "traceback": "".join(format_exc()), + } + while True: try: - connection = http_client.HTTPConnection('localhost', port) + connection = http_client.HTTPConnection(connect_host_ip, port) connection.request('GET', '/ping') response = connection.getresponse() - response_data = response.read().decode("utf-8") + response_data = response.read().decode() if response_data != "OK": raise InvalidPingResponse() break + except (http_client.BadStatusLine, InvalidPingResponse): + ct_res = check_timeout() + if ct_res is not None: + return ct_res + except socket.error as e: if e.errno in [errno.ECONNRESET, errno.ECONNREFUSED]: - if time() - start_time < docker_timeout: - sleep(0.1) - # and retry - else: - return { - "result": "uncaught_error", - "message": "Timeout waiting for container.", - "traceback": "".join(format_exc()), - } - else: - raise + ct_res = check_timeout() + if ct_res is not None: + return ct_res - except (http_client.BadStatusLine, InvalidPingResponse): - if time() - start_time < docker_timeout: - sleep(0.1) - # and retry else: - return { - "result": "uncaught_error", - "message": "Timeout waiting for container.", - "traceback": "".join(format_exc()), - } + raise # }}} @@ -178,7 +195,7 @@ def request_python_run(run_req, run_timeout, image=None): try: # Add a second to accommodate 'wire' delays - connection = http_client.HTTPConnection('localhost', port, + connection = http_client.HTTPConnection(connect_host_ip, port, timeout=1 + run_timeout) headers = {'Content-type': 'application/json'} diff --git a/course/versioning.py b/course/versioning.py index 4ca2706d4fb4ca71a2e43fc63b0d379e5eaf9510..ff4888f0a6e2c1b6e2f5b030524b43973a045197 100644 --- a/course/versioning.py +++ b/course/versioning.py @@ -81,46 +81,15 @@ def transfer_remote_refs(repo, remote_refs): del repo[ref] -# {{{ shell quoting - -# Adapted from -# https://github.com/python/cpython/blob/8cd133c63f156451eb3388b9308734f699f4f1af/Lib/shlex.py#L278 - -def is_shell_safe(s): - import re - import sys - - flags = 0 - if sys.version_info >= (3,): - flags = re.ASCII - - unsafe_re = re.compile(br'[^\w@%+=:,./-]', flags) - - return unsafe_re.search(s) is None - - -def shell_quote(s): - """Return a shell-escaped version of the byte string *s*.""" - - # Unconditionally quotes because that's apparently git's behavior, too, - # and some code hosting sites (notably Bitbucket) appear to rely on that. - - if not s: - return b"''" - - # use single quotes, and put single quotes into double quotes - # the string $'b is then quoted as '$'"'"'b' - return b"'" + s.replace(b"'", b"'\"'\"'") + b"'" - -# }}} - - class DulwichParamikoSSHVendor(object): def __init__(self, ssh_kwargs): self.ssh_kwargs = ssh_kwargs def run_command(self, host, command, username=None, port=None, progress_stderr=None): + if not isinstance(command, bytes): + raise TypeError(command) + if port is None: port = 22 @@ -132,15 +101,6 @@ class DulwichParamikoSSHVendor(object): channel = client.get_transport().open_session() - assert command - assert is_shell_safe(command[0]) - - command = ( - command[0] - + b' ' - + b' '.join( - shell_quote(c) for c in command[1:])) - channel.exec_command(command) def progress_stderr(s): diff --git a/local_settings.py.example b/local_settings.py.example index f7c5d35d5326303598e1be3c6c2429c5b3a4e436..32d3db611a1ce02e4ed63402f491bab4e1a37e83 100644 --- a/local_settings.py.example +++ b/local_settings.py.example @@ -83,6 +83,25 @@ RELATE_DOCKER_RUNPY_IMAGE = "inducer/relate-runpy-i386" # to spawn containers for student code. RELATE_DOCKER_URL = "unix://var/run/docker.sock" +RELATE_DOCKER_TLS_CONFIG = None + +# Example setup for targeting remote Docker instances +# with TLS authentication: + +# RELATE_DOCKER_URL = "https://relate.cs.illinois.edu:2375" +# +# import os.path +# pki_base_dir = os.path.dirname(__file__) +# +# import docker.tls +# RELATE_DOCKER_TLS_CONFIG = docker.tls.TLSConfig( +# client_cert=( +# os.path.join(pki_base_dir, "client-cert.pem"), +# os.path.join(pki_base_dir, "client-key.pem"), +# ), +# ca_cert=os.path.join(pki_base_dir, "ca.pem"), +# verify=True) + RELATE_MAINTENANCE_MODE = False # May be set to a string to set a sitewide announcement visible on every page. diff --git a/relate/settings.py b/relate/settings.py index a6253a82b46098c41e471076d035de01e4824ebd..82086092ef215dbea85a51b832a9c9e20d6703db 100644 --- a/relate/settings.py +++ b/relate/settings.py @@ -15,9 +15,12 @@ import os from os.path import join BASE_DIR = os.path.dirname(os.path.dirname(__file__)) -local_settings = {} +_local_settings_file = join(BASE_DIR, "local_settings.py") +local_settings = { + "__file__": _local_settings_file, + } try: - with open(join(BASE_DIR, "local_settings.py")) as inf: + with open(_local_settings_file) as inf: local_settings_contents = inf.read() except IOError: pass diff --git a/requirements.txt b/requirements.txt index 7bdab3f318cf628f57bf3fd4c88fa52ad4ec17ab..6a312cf0ae5b415cce6fccd9668c9dcff2c2ea7b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ pytz pyyaml # dulwich (git for Py3 support) -git+https://github.com/jelmer/dulwich +dulwich>=0.12 ecdsa paramiko