Passed
Push — master ( 4f13ed...1de556 )
by Peter
01:47
created

Job.find_keywords()   A

Complexity

Conditions 1

Size

Total Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
c 4
b 0
f 0
dl 0
loc 8
rs 9.4285
cc 1
1
'''
2
The official executor API for validation test and full test scripts.
3
'''
4
5
import os
6
import sys
7
import importlib
8
import shutil
9
import os.path
10
import glob
11
import json
12
import re
13
14
from .compiler import compiler_cmdline, GCC
15
from .config import read_config
16
from .running import kill_longrunning, RunningProgram
17
from .exceptions import *
18
from .filesystem import has_file, create_working_dir, prepare_working_directory
19
from .hostinfo import all_host_infos, ipaddress
20
21
from urllib.request import urlopen, urlretrieve
22
from urllib.error import HTTPError, URLError
23
from urllib.parse import urlencode
24
25
import logging
26
logger = logging.getLogger('opensubmitexec')
27
28
UNSPECIFIC_ERROR = -9999
29
30
31
class Job():
32
    '''
33
    A OpenSubmit job to be run by the test machine.
34
    '''
35
36
    # The current executor configuration.
37
    _config = None
38
    # Talk to the configured OpenSubmit server?
39
    _online = None
40
41
    # Download source for the student sub
42
    submission_url = None
43
    # Download source for the validator
44
    validator_url = None
45
    # The working directory for this job
46
    working_dir = None
47
    # The timeout for execution, as demanded by the server
48
    timeout = None
49
    # The OpenSubmit submission ID
50
    submission_id = None
51
    # The OpenSubmit submission file ID
52
    file_id = None
53
    # Did the validator script sent a result to the server?
54
    result_sent = False
55
    # Action requested by the server (legacy)
56
    action = None
57
    # Name of the submitting student
58
    submitter_name = None
59
    # Student ID of the submitting student
60
    submitter_student_id = None
61
    # Names of the submission authors
62
    author_names = None
63
    # Name of the study program of the submitter
64
    submitter_studyprogram = None
65
    # Name of the course where this submission was done
66
    course = None
67
    # Name of the assignment where this job was done
68
    assignment = None
69
70
    # The base name of the validation / full test script
71
    # on disk, for importing.
72
    _validator_import_name = 'validator'
73
74
    @property
75
    # The file name of the validation / full test script
76
    # on disk, after unpacking / renaming.
77
    def validator_script_name(self):
78
        return self.working_dir + self._validator_import_name + '.py'
79
80
    def __init__(self, config=None, online=True):
81
        if config:
82
            self._config = config
83
        else:
84
            self._config = read_config()
85
        self._online = online
86
87
    def __str__(self):
88
        '''
89
        Nicer logging of job objects.
90
        '''
91
        return str(vars(self))
92
93
    def _run_validate(self):
94
        '''
95
        Execute the validate() method in the test script belonging to this job.
96
        '''
97
        assert(os.path.exists(self.validator_script_name))
98
        old_path = sys.path
99
        sys.path = [self.working_dir] + old_path
100
        # logger.debug('Python search path is now {0}.'.format(sys.path))
101
102
        try:
103
            module = importlib.import_module(self._validator_import_name)
104
        except Exception as e:
105
            text_student = "Internal validation problem, please contact your course responsible."
106
            text_tutor = "Exception while loading the validator: " + str(e)
107
            self._send_result(text_student, text_tutor, UNSPECIFIC_ERROR)
108
            return
109
110
        # Looped validator loading in the test suite demands this
111
        importlib.reload(module)
112
113
        # make the call
114
        try:
115
            module.validate(self)
116
        except Exception as e:
117
            # get more info
118
            text_student = None
119
            text_tutor = None
120
            if type(e) is TerminationException:
121
                text_student = "The execution of '{0}' terminated unexpectely.".format(
122
                    e.instance.name)
123
                text_tutor = "The execution of '{0}' terminated unexpectely.".format(
124
                    e.instance.name)
125
                text_student += "\n\nOutput so far:\n" + e.output
126
                text_tutor += "\n\nOutput so far:\n" + e.output
127
            elif type(e) is TimeoutException:
128
                text_student = "The execution of '{0}' was cancelled, since it took too long.".format(
129
                    e.instance.name)
130
                text_tutor = "The execution of '{0}' was cancelled due to timeout.".format(
131
                    e.instance.name)
132
                text_student += "\n\nOutput so far:\n" + e.output
133
                text_tutor += "\n\nOutput so far:\n" + e.output
134
            elif type(e) is NestedException:
135
                text_student = "Unexpected problem during the execution of '{0}'. {1}".format(
136
                    e.instance.name,
137
                    str(e.real_exception))
138
                text_tutor = "Unkown exception during the execution of '{0}'. {1}".format(
139
                    e.instance.name,
140
                    str(e.real_exception))
141
                text_student += "\n\nOutput so far:\n" + e.output
142
                text_tutor += "\n\nOutput so far:\n" + e.output
143
            elif type(e) is WrongExitStatusException:
144
                text_student = "The execution of '{0}' resulted in the unexpected exit status {1}.".format(
145
                    e.instance.name,
146
                    e.got)
147
                text_tutor = "The execution of '{0}' resulted in the unexpected exit status {1}.".format(
148
                    e.instance.name,
149
                    e.got)
150
                text_student += "\n\nOutput so far:\n" + e.output
151
                text_tutor += "\n\nOutput so far:\n" + e.output
152
            elif type(e) is JobException:
153
                # Some problem with our own code
154
                text_student = e.info_student
155
                text_tutor = e.info_tutor
156
            elif type(e) is FileNotFoundError:
157
                text_student = "A file is missing: {0}".format(
158
                    str(e))
159
                text_tutor = "Missing file: {0}".format(
160
                    str(e))
161
            elif type(e) is AssertionError:
162
                # Need this harsh approach to kill the
163
                # test suite execution at this point
164
                # Otherwise, the problem gets lost in
165
                # the log storm
166
                logger.error(
167
                    "Failed assertion in validation script. Should not happen in production.")
168
                exit(-1)
169
            else:
170
                # Something really unexpected
171
                text_student = "Internal problem while validating your submission. Please contact the course responsible."
172
                text_tutor = "Unknown exception while running the validator: {0}".format(
173
                    str(e))
174
            # We got the text. Report the problem.
175
            self._send_result(text_student, text_tutor, UNSPECIFIC_ERROR)
176
            return
177
        # no unhandled exception during the execution of the validator
178
        if not self.result_sent:
179
            logger.debug("Validation script forgot result sending.")
180
            self.send_pass_result()
181
        # roll back
182
        sys.path = old_path
183
184
    def _send_result(self, info_student, info_tutor, error_code):
185
        post_data = [("SubmissionFileId", self.file_id),
186
                     ("Message", info_student),
187
                     ("Action", self.action),
188
                     ("MessageTutor", info_tutor),
189
                     ("ExecutorDir", self.working_dir),
190
                     ("ErrorCode", error_code),
191
                     ("Secret", self._config.get("Server", "secret")),
192
                     ("UUID", self._config.get("Server", "uuid"))
193
                     ]
194
        logger.info(
195
            'Sending result to OpenSubmit Server: ' + str(post_data))
196
        if self._online:
197
            send_post(self._config, "/jobs/", post_data)
198
        self.result_sent = True
199
200
    def send_fail_result(self, info_student, info_tutor):
201
        self._send_result(info_student, info_tutor, UNSPECIFIC_ERROR)
202
203
    def send_pass_result(self,
204
                         info_student="All tests passed. Awesome!",
205
                         info_tutor="All tests passed."):
206
        self._send_result(info_student, info_tutor, 0)
207
208
    def delete_binaries(self):
209
        '''
210
        Scans the submission files in the self.working_dir for
211
        binaries and deletes them.
212
        Returns the list of deleted files.
213
        '''
214
        raise NotImplementedError
215
216
    def run_configure(self, mandatory=True):
217
        '''
218
        Runs the configure tool configured for the machine in self.working_dir.
219
        '''
220
        if not has_file(self.working_dir, 'configure'):
221
            if mandatory:
222
                raise FileNotFoundError(
223
                    "Could not find a configure script for execution.")
224
            else:
225
                return
226
        try:
227
            prog = RunningProgram(self, 'configure')
228
            prog.expect_exit_status(0)
229
        except Exception:
230
            if mandatory:
231
                raise
232
233
    def run_make(self, mandatory=True):
234
        '''
235
        Runs the make tool configured for the machine in self.working_dir.
236
        '''
237
        if not has_file(self.working_dir, 'Makefile'):
238
            if mandatory:
239
                raise FileNotFoundError("Could not find a Makefile.")
240
            else:
241
                return
242
        try:
243
            prog = RunningProgram(self, 'make')
244
            prog.expect_exit_status(0)
245
        except Exception:
246
            if mandatory:
247
                raise
248
249
    def run_compiler(self, compiler=GCC, inputs=None, output=None):
250
        '''
251
        Runs the compiler in self.working_dir.
252
        '''
253
        # Let exceptions travel through
254
        prog = RunningProgram(self, *compiler_cmdline(compiler=compiler,
255
                                                      inputs=inputs,
256
                                                      output=output))
257
        prog.expect_exit_status(0)
258
259
    def run_build(self, compiler=GCC, inputs=None, output=None):
260
        logger.info("Running build steps ...")
261
        self.run_configure(mandatory=False)
262
        self.run_make(mandatory=False)
263
        self.run_compiler(compiler=compiler,
264
                          inputs=inputs,
265
                          output=output)
266
267
    def spawn_program(self, name, arguments=[], timeout=30, exclusive=False):
268
        '''
269
        Spawns a program in the working directory and allows
270
        interaction with it. Returns a RunningProgram object.
271
272
        The caller can demand exclusive execution on this machine.
273
        '''
274
        logger.debug("Spawning program for interaction ...")
275
        if exclusive:
276
            kill_longrunning(self.config)
277
278
        return RunningProgram(self, name, arguments, timeout)
279
280
    def run_program(self, name, arguments=[], timeout=30, exclusive=False):
281
        '''
282
        Runs a program in the working directory.
283
        The result is a tuple of exit code and output.
284
285
        The caller can demand exclusive execution on this machine.
286
        '''
287
        logger.debug("Running program ...")
288
        if exclusive:
289
            kill_longrunning(self.config)
290
291
        prog = RunningProgram(self, name, arguments, timeout)
292
        return prog.expect_end()
293
294
    def grep(self, regex):
295
        '''
296
        Searches the student files in self.working_dir for files
297
        containing a specific regular expression.
298
299
        Returns the names of the matching files as list.
300
        '''
301
        matches = []
302
        logger.debug("Searching student files for '{0}'".format(regex))
303
        for fname in self.student_files:
304
            if os.path.isfile(self.working_dir + fname):
305
                for line in open(self.working_dir + fname, 'br'):
306
                    if re.search(regex.encode(), line):
307
                        logger.debug("{0} contains '{1}'".format(fname, regex))
308
                        matches.append(fname)
309
        return matches
310
311
    def ensure_files(self, filenames):
312
        '''
313
        Searches the student submission for specific files.
314
        Expects a list of filenames. Returns a boolean indicator.
315
        '''
316
        logger.debug("Testing {0} for the following files: {1}".format(
317
            self.working_dir, filenames))
318
        dircontent = os.listdir(self.working_dir)
319
        for fname in filenames:
320
            if fname not in dircontent:
321
                return False
322
        return True
323
324
325
def fetch(url, fullpath):
326
    '''
327
    Fetch data from an URL and save it under the given target name.
328
    '''
329
    logger.debug("Fetching %s from %s" % (fullpath, url))
330
331
    tmpfile, headers = urlretrieve(url)
332
    if os.path.exists(fullpath):
333
        os.remove(fullpath)
334
    shutil.move(tmpfile, fullpath)
335
336
337
def send_post(config, urlpath, post_data):
338
    '''
339
    Send POST data to an OpenSubmit server url path,
340
    according to the configuration.
341
    '''
342
    server = config.get("Server", "url")
343
    post_data = urlencode(post_data)
344
    post_data = post_data.encode("utf-8", errors="ignore")
345
    url = server + urlpath
346
    try:
347
        urlopen(url, post_data)
348
    except Exception as e:
349
        logger.error('Error while sending data to server: ' + str(e))
350
351
352
def send_hostinfo(config):
353
    '''
354
    Register this host on OpenSubmit test machine.
355
    '''
356
    info = all_host_infos()
357
    logger.debug("Sending host information: " + str(info))
358
    post_data = [("Config", json.dumps(info)),
359
                 ("Action", "get_config"),
360
                 ("UUID", config.get("Server", "uuid")),
361
                 ("Address", ipaddress()),
362
                 ("Secret", config.get("Server", "secret"))
363
                 ]
364
365
    send_post(config, "/machines/", post_data)
366
367
368
def compatible_api_version(server_version):
369
    '''
370
    Check if this server API version is compatible to us.
371
    '''
372
    try:
373
        semver = server_version.split('.')
374
        if semver[0] != '1':
375
            logger.error(
376
                'Server API version (%s) is too new for us. Please update the executor installation.' % server_version)
377
            return False
378
        else:
379
            return True
380
    except Exception:
381
        logger.error(
382
            'Cannot understand the server API version (%s). Please update the executor installation.' % server_version)
383
        return False
384
385
386
def fetch_job(config):
387
    '''
388
    Fetch any available work from the OpenSubmit server and
389
    return an according job object.
390
391
    Returns None if no work is available.
392
393
    Errors are reported by this function directly.
394
    '''
395
    url = "%s/jobs/?Secret=%s&UUID=%s" % (config.get("Server", "url"),
396
                                          config.get("Server", "secret"),
397
                                          config.get("Server", "uuid"))
398
399
    try:
400
        # Fetch information from server
401
        result = urlopen(url)
402
        headers = result.info()
403
        if not compatible_api_version(headers["APIVersion"]):
404
            # No proper reporting possible, so only logging.
405
            logger.error("Incompatible API version. Please update OpenSubmit.")
406
            return None
407
408
        if headers["Action"] == "get_config":
409
            # The server does not know us,
410
            # so it demands registration before hand.
411
            logger.info("Machine unknown on server, sending registration ...")
412
            send_hostinfo(config)
413
            return None
414
415
        # Create job object with information we got
416
        job = Job(config)
417
418
        job.submitter_name = headers['SubmitterName']
419
        job.author_names = headers['AuthorNames']
420
        job.submitter_studyprogram = headers['SubmitterStudyProgram']
421
        job.course = headers['Course']
422
        job.assignment = headers['Assignment']
423
        job.action = headers["Action"]
424
        job.file_id = headers["SubmissionFileId"]
425
        job.sub_id = headers["SubmissionId"]
426
        job.file_name = headers["SubmissionOriginalFilename"]
427
        job.submitter_student_id = headers["SubmitterStudentId"]
428
        if "Timeout" in headers:
429
            job.timeout = int(headers["Timeout"])
430
        if "PostRunValidation" in headers:
431
            job.validator_url = headers["PostRunValidation"]
432
        job.working_dir = create_working_dir(config, job.sub_id)
433
434
        # Store submission in working directory
435
        submission_fname = job.working_dir + job.file_name
436
        with open(submission_fname, 'wb') as target:
437
            target.write(result.read())
438
        assert(os.path.exists(submission_fname))
439
440
        # Store validator package in working directory
441
        validator_fname = job.working_dir + 'download.validator'
442
        fetch(job.validator_url, validator_fname)
443
444
        try:
445
            prepare_working_directory(job, submission_fname, validator_fname)
446
        except JobException as e:
447
            job.send_fail_result(e.info_student, e.info_tutor)
448
            return None
449
        logger.debug("Got job: " + str(job))
450
        return job
451
    except HTTPError as e:
452
        if e.code == 404:
453
            logger.debug("Nothing to do.")
454
            return None
455
    except URLError as e:
456
        logger.error("Error while contacting {0}: {1}".format(url, str(e)))
457
        return None
458
459
460
def fake_fetch_job(config, src_dir):
461
    '''
462
    Act like fetch_job, but take the validator file and the student
463
    submission files directly from a directory.
464
465
    Intended for testing purposes when developing test scripts.
466
467
    Check also cmdline.py.
468
    '''
469
    logger.debug("Creating fake job from " + src_dir)
470
    job = Job(config, online=False)
471
    job.working_dir = create_working_dir(config, '42')
472
    for fname in glob.glob(src_dir + os.sep + '*'):
473
        logger.debug("Copying {0} to {1} ...".format(fname, job.working_dir))
474
        shutil.copy(fname, job.working_dir)
475
    case_files = glob.glob(job.working_dir + os.sep + '*')
476
    assert(len(case_files) == 2)
477
    if os.path.basename(case_files[0]) in ['validator.py', 'validator.zip']:
478
        validator = case_files[0]
479
        submission = case_files[1]
480
    else:
481
        validator = case_files[1]
482
        submission = case_files[0]
483
    logger.debug('{0} is the validator.'.format(validator))
484
    logger.debug('{0} the submission.'.format(submission))
485
    try:
486
        prepare_working_directory(job,
487
                                  submission_path=submission,
488
                                  validator_path=validator)
489
    except JobException as e:
490
        job.send_fail_result(e.info_student, e.info_tutor)
491
        return None
492
    logger.debug("Got fake job: " + str(job))
493
    return job
494