From 11a2eaefaba6d2b547d35afbee3e85b18520afd2 Mon Sep 17 00:00:00 2001 From: Prabhu Ramachandran Date: Thu, 24 Nov 2011 02:11:40 +0530 Subject: ENH/TMP: Preliminary support for bash scripts. - Changing the Question model to add a language attribute. - Moving python_server.py -> code_server.py. - Adding functionality to test for Shell scripts. This is still incomplete since the shell code checker seems to have some problems. - Modified the xmlrpc_clients to support multiple languages and right now two. - Using setgid/setuid instead of setegid/seteuid in the code_server.py.. - Adding a bash example to the sample_questions.py. The shell script support doesn't quite work yet but this is really a code_server/checking issue. --- README.txt | 2 +- code_server.py | 179 +++++++++++++++++++++++++ docs/sample_questions.py | 15 +++ docs/sample_questions.xml | 2 + exam/management/commands/load_questions_xml.py | 4 + exam/models.py | 8 ++ exam/views.py | 5 +- exam/xmlrpc_clients.py | 37 ++++- python_server.py | 117 ---------------- 9 files changed, 242 insertions(+), 127 deletions(-) create mode 100755 code_server.py delete mode 100755 python_server.py diff --git a/README.txt b/README.txt index 8c1c9c1..1d7821b 100644 --- a/README.txt +++ b/README.txt @@ -21,7 +21,7 @@ To install/deploy this app follow the steps below: 4. First run the python server provided. This ensures that the code is executed in a safe environment. Do this like so:: - $ sudo python python_server.py + $ sudo python code_server.py Put this in the background once it has started since this will not return back the prompt. It is important that the server be running diff --git a/code_server.py b/code_server.py new file mode 100755 index 0000000..63f3073 --- /dev/null +++ b/code_server.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python +"""This server runs an XMLRPC server that can be submitted code and tests +and returns the output. It *should* be run as root and will run as the user +'nobody' so as to minimize any damange by errant code. This can be configured +by editing settings.py to run as many servers as desired. One can also +specify the ports on the command line. Here are examples:: + + $ sudo ./code_server.py + # Runs servers based on settings.py:SERVER_PORTS one server per port given. + +or:: + + $ sudo ./code_server.py 8001 8002 8003 8004 8005 + # Runs 5 servers on ports specified. + +All these servers should be running as nobody. +""" +import sys +import traceback +from SimpleXMLRPCServer import SimpleXMLRPCServer +import pwd +import os +import stat +from os.path import isdir, dirname, abspath, join +import signal +from multiprocessing import Process +import subprocess + +# Local imports. +from settings import SERVER_PORTS, SERVER_TIMEOUT + +MY_DIR = abspath(dirname(__file__)) + +def run_as_nobody(): + """Runs the current process as nobody.""" + # Set the uid and to that of nobody. + nobody = pwd.getpwnam('nobody') + os.setgid(nobody.pw_gid) + os.setuid(nobody.pw_uid) + +################################################################################ +# Python related code. + +# Raised when the code times-out. +# c.f. http://pguides.net/python/timeout-a-function +class TimeoutException(Exception): + pass + +def timeout_handler(signum, frame): + """A handler for the ALARM signal.""" + raise TimeoutException('Code took too long to run.') + +def run_python_code(answer, test_code, in_dir=None): + """Tests given Python function (`answer`) with the `test_code` supplied. + If the optional `in_dir` keyword argument is supplied it changes the + directory to that directory (it does not change it back to the original when + done). This function also timesout when the function takes more than + SERVER_TIMEOUT seconds to run to prevent runaway code. + + Returns + ------- + + A tuple: (success, error message). + + """ + if in_dir is not None and isdir(in_dir): + os.chdir(in_dir) + + # Add a new signal handler for the execution of this code. + old_handler = signal.signal(signal.SIGALRM, timeout_handler) + signal.alarm(SERVER_TIMEOUT) + + success = False + tb = None + try: + submitted = compile(answer, '', mode='exec') + g = {} + exec submitted in g + _tests = compile(test_code, '', mode='exec') + exec _tests in g + except TimeoutException: + err = 'Code took more than %s seconds to run.'%SERVER_TIMEOUT + except AssertionError: + type, value, tb = sys.exc_info() + info = traceback.extract_tb(tb) + fname, lineno, func, text = info[-1] + text = str(test_code).splitlines()[lineno-1] + err = "{0} {1} in: {2}".format(type.__name__, str(value), text) + except: + type, value = sys.exc_info()[:2] + err = "Error: {0}".format(repr(value)) + else: + success = True + err = 'Correct answer' + finally: + del tb + # Set back any original signal handler. + signal.signal(signal.SIGALRM, old_handler) + + # Cancel the signal if any, see signal.alarm documentation. + signal.alarm(0) + + return success, err + +################################################################################ +# Run code for Bash. +def run_bash_code(answer, test_code, in_dir=None): + + """Tests given Bash code (`answer`) with the `test_code` supplied. It + assumes that there are two parts to the test_code separated by '#++++++'. + + If the optional `in_dir` keyword argument is supplied it changes the + directory to that directory (it does not change it back to the original when + done). + + Returns + ------- + + A tuple: (success, error message). + + """ + if in_dir is not None and isdir(in_dir): + os.chdir(in_dir) + + def _set_exec(fname): + os.chmod(ref_fname, stat.S_IRUSR|stat.S_IWUSR|stat.S_IXUSR + |stat.S_IRGRP|stat.S_IWGRP|stat.S_IXGRP + |stat.S_IROTH|stat.S_IWOTH|stat.S_IXOTH) + + # XXX: fix this to not hardcode it to 6 +'s! + reference, args = test_code.split('#++++++') + ref_f = open('reference.sh', 'w') + ref_f.write(reference); ref_f.close() + ref_fname = abspath(ref_f.name) + _set_exec(ref_fname) + args_f = open('reference.args', 'w') + args_f.write(args); args_f.close() + _set_exec(args_f.name) + submit_f = open('submit.sh', 'w') + submit_f.write(answer); submit_f.close() + submit_fname = submit_f.name + _set_exec(submit_fname) + + tester = join(MY_DIR, 'shell_script_tester.sh') + + # Run the shell code in a subprocess. + try: + output = subprocess.check_output([tester, ref_fname, submit_fname], + stderr=subprocess.STDOUT) + except subprocess.CalledProcessError, exc: + success = False + err = 'Error: exist status: %d, message: %s'%(exc.returncode, + exc.output) + else: + success = True + err = 'Correct answer' + + return success, err + + +def run_server(port): + server = SimpleXMLRPCServer(("localhost", port)) + server.register_function(run_python_code) + server.register_function(run_bash_code) + server.serve_forever() + +def main(): + run_as_nobody() + if len(sys.argv) == 1: + ports = SERVER_PORTS + else: + ports = [int(x) for x in sys.argv[1:]] + + for port in ports: + p = Process(target=run_server, args=(port,)) + p.start() + +if __name__ == '__main__': + main() diff --git a/docs/sample_questions.py b/docs/sample_questions.py index 2a9eaf8..eac9479 100644 --- a/docs/sample_questions.py +++ b/docs/sample_questions.py @@ -4,6 +4,7 @@ questions = [ Question( summary='Factorial', points=2, + language="python", description=''' Write a function called fact which takes a single integer argument (say n) and returns the factorial of the number. @@ -18,6 +19,7 @@ assert fact(5) == 120 Question( summary='Simple function', points=1, + language="python", description='''Create a simple function called sqr which takes a single argument and returns the square of the argument. For example:
sqr(3) -> 9.''', @@ -26,6 +28,19 @@ import math assert sqr(3) == 9 assert abs(sqr(math.sqrt(2)) - 2.0) < 1e-14 '''), +Question( + summary='Bash addition', + points=2, + language="bash", + description='''Write a shell script which takes two arguments on the + command line and prints the sum of the two on the output.''', + test='''\ +#!/bin/bash +[[ $# -eq 2 ]] && echo $(( $1 + $2 )) && exit $(( $1 + $2 )) +#++++++ +1 2 +2 1 +'''), ] quiz = Quiz(start_date=date.today(), diff --git a/docs/sample_questions.xml b/docs/sample_questions.xml index 104ea32..cad205b 100644 --- a/docs/sample_questions.xml +++ b/docs/sample_questions.xml @@ -10,6 +10,7 @@ and returns the factorial of the number. For example fact(3) -> 6 2 +python assert fact(0) == 1 assert fact(5) == 120 @@ -26,6 +27,7 @@ returns the square of the argument For example sqr(3) -> 9. 1 +python import math assert sqr(3) == 9 diff --git a/exam/management/commands/load_questions_xml.py b/exam/management/commands/load_questions_xml.py index aa403dd..b4151ae 100644 --- a/exam/management/commands/load_questions_xml.py +++ b/exam/management/commands/load_questions_xml.py @@ -35,6 +35,9 @@ def load_questions_xml(filename): desc_node = question.getElementsByTagName("description")[0] description = (desc_node.childNodes[0].data).strip() + lang_node = question.getElementsByTagName("language")[0] + language = (lang_node.childNodes[0].data).strip() + points_node = question.getElementsByTagName("points")[0] points = int((points_node.childNodes[0].data).strip()) \ if points_node else 1 @@ -45,6 +48,7 @@ def load_questions_xml(filename): new_question = Question(summary=summary, description=description, points=points, + language=language, test=test) new_question.save() diff --git a/exam/models.py b/exam/models.py index d433c7c..ef4312f 100644 --- a/exam/models.py +++ b/exam/models.py @@ -12,6 +12,11 @@ class Profile(models.Model): position = models.CharField(max_length=64) +LANGUAGE_CHOICES = ( + ("python", "Python"), + ("bash", "Bash"), + ) + ################################################################################ class Question(models.Model): """A question in the database.""" @@ -29,6 +34,9 @@ class Question(models.Model): # This is simple Python code. test = models.TextField() + # The language being tested. + language = models.CharField(max_length=10, choices=LANGUAGE_CHOICES) + # Is this question active or not. If it is inactive it will not be used # when creating a QuestionPaper. active = models.BooleanField(default=True) diff --git a/exam/views.py b/exam/views.py index bafd0be..1f92553 100644 --- a/exam/views.py +++ b/exam/views.py @@ -13,7 +13,7 @@ from django.http import Http404 # Local imports. from exam.models import Quiz, Question, QuestionPaper, Profile, Answer, User from exam.forms import UserRegisterForm, UserLoginForm -from exam.xmlrpc_clients import python_server +from exam.xmlrpc_clients import code_server from settings import URL_ROOT # The directory where user data can be saved. @@ -203,7 +203,8 @@ def check(request, q_id): # with the code executed safely in a separate process (the python_server.py) # running as nobody. user_dir = get_user_dir(user) - success, err_msg = python_server.run_code(answer, question.test, user_dir) + success, err_msg = code_server.run_code(answer, question.test, + user_dir, question.language) new_answer.error = err_msg if success: diff --git a/exam/xmlrpc_clients.py b/exam/xmlrpc_clients.py index 115ee6e..01172d7 100644 --- a/exam/xmlrpc_clients.py +++ b/exam/xmlrpc_clients.py @@ -4,7 +4,7 @@ import random import socket -class PythonServer(object): +class CodeServer(object): """A class that manages accesing the farm of Python servers and making calls to them such that no one XMLRPC server is overloaded. """ @@ -12,19 +12,41 @@ class PythonServer(object): servers = [ServerProxy('http://localhost:%d'%(x)) for x in SERVER_PORTS] self.servers = servers self.indices = range(len(SERVER_PORTS)) + self.methods = {"python": 'run_python_code', + "bash": 'run_bash_code'} - def run_code(self, answer, test_code, user_dir): - """See the documentation of the method of the same name in - python_server.py. + def run_code(self, answer, test_code, user_dir, language): + """Tests given code (`answer`) with the `test_code` supplied. If the + optional `in_dir` keyword argument is supplied it changes the directory + to that directory (it does not change it back to the original when + done). The parameter language specifies which language to use for the + tests. + + Parameters + ---------- + answer : str + The user's answer for the question. + test_code : str + The test code to check the user code with. + user_dir : str (directory) + The directory to run the tests inside. + language : str + The programming language to use. + + Returns + ------- + A tuple: (success, error message). """ + method_name = self.methods[language] done = False - result = [False, 'Unable to connect to any Python servers!'] + result = [False, 'Unable to connect to any code servers!'] # Try to connect a few times if not, quit. count = 5 while (not done) and (count > 0): try: server = self._get_server() - result = server.run_code(answer, test_code, user_dir) + method = getattr(server, method_name) + result = method(answer, test_code, user_dir) except socket.error: count -= 1 else: @@ -38,4 +60,5 @@ class PythonServer(object): # views.py calls this Python server which forwards the request to one # of the running servers. -python_server = PythonServer() +code_server = CodeServer() + diff --git a/python_server.py b/python_server.py deleted file mode 100755 index d33ee47..0000000 --- a/python_server.py +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env python -"""This server runs an XMLRPC server that can be submitted code and tests -and returns the output. It *should* be run as root and will run as the user -'nobody' so as to minimize any damange by errant code. This can be configured -by editing settings.py to run as many servers as desired. One can also -specify the ports on the command line. Here are examples:: - - $ sudo ./python_server.py - # Runs servers based on settings.py:SERVER_PORTS one server per port given. - -or:: - - $ sudo ./python_server.py 8001 8002 8003 8004 8005 - # Runs 5 servers on ports specified. - -All these servers should be running as nobody. -""" -import sys -import traceback -from SimpleXMLRPCServer import SimpleXMLRPCServer -import pwd -import os -from os.path import isdir -import signal -from multiprocessing import Process - -# Local imports. -from settings import SERVER_PORTS, SERVER_TIMEOUT - - -def run_as_nobody(): - """Runs the current process as nobody.""" - # Set the effective uid to that of nobody. - nobody = pwd.getpwnam('nobody') - os.setegid(nobody.pw_gid) - os.seteuid(nobody.pw_uid) - -# Raised when the code times-out. -# c.f. http://pguides.net/python/timeout-a-function -class TimeoutException(Exception): - pass - -def timeout_handler(signum, frame): - """A handler for the ALARM signal.""" - raise TimeoutException('Code took too long to run.') - - -def run_code(answer, test_code, in_dir=None): - """Tests given Python function (`answer`) with the `test_code` supplied. - If the optional `in_dir` keyword argument is supplied it changes the - directory to that directory (it does not change it back to the original when - done). This function also timesout when the function takes more than - SERVER_TIMEOUT seconds to run to prevent runaway code. - - Returns - ------- - - A tuple: (success, error message). - - """ - if in_dir is not None and isdir(in_dir): - os.chdir(in_dir) - - # Add a new signal handler for the execution of this code. - old_handler = signal.signal(signal.SIGALRM, timeout_handler) - signal.alarm(SERVER_TIMEOUT) - - success = False - tb = None - try: - submitted = compile(answer, '', mode='exec') - g = {} - exec submitted in g - _tests = compile(test_code, '', mode='exec') - exec _tests in g - except TimeoutException: - err = 'Code took more than %s seconds to run.'%SERVER_TIMEOUT - except AssertionError: - type, value, tb = sys.exc_info() - info = traceback.extract_tb(tb) - fname, lineno, func, text = info[-1] - text = str(test_code).splitlines()[lineno-1] - err = "{0} {1} in: {2}".format(type.__name__, str(value), text) - except: - type, value = sys.exc_info()[:2] - err = "Error: {0}".format(repr(value)) - else: - success = True - err = 'Correct answer' - finally: - del tb - # Set back any original signal handler. - signal.signal(signal.SIGALRM, old_handler) - - # Cancel the signal if any, see signal.alarm documentation. - signal.alarm(0) - - return success, err - -def run_server(port): - server = SimpleXMLRPCServer(("localhost", port)) - server.register_function(run_code) - server.serve_forever() - -def main(): - run_as_nobody() - if len(sys.argv) == 1: - ports = SERVER_PORTS - else: - ports = [int(x) for x in sys.argv[1:]] - - for port in ports: - p = Process(target=run_server, args=(port,)) - p.start() - -if __name__ == '__main__': - main() -- cgit