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
|
|
|
|