1 # vim: set et sw=4 sts=4 encoding=utf-8 foldmethod=marker:
3 from sercom.model import Entrega, CasoDePrueba
4 from sercom.model import TareaFuente, TareaPrueba, ComandoFuente, ComandoPrueba
5 from difflib import unified_diff, HtmlDiff
6 from zipfile import ZipFile, BadZipfile
7 from cStringIO import StringIO
8 from shutil import rmtree
9 from datetime import datetime
10 from os.path import join
11 from turbogears import config
12 import subprocess as sp
13 import resource as rsrc
14 import os, sys, pwd, grp
17 log = logging.getLogger('sercom.tester')
19 error_interno = _(u'\n**Error interno al preparar la entrega.**')
21 class UserInfo(object): #{{{
22 def __init__(self, user):
24 info = pwd.getpwnam(user)
26 info = pwd.get(int(user))
33 self.group = grp.getgrgid(self.gid)[0]
36 user_info = UserInfo(config.get('sercom.tester.user', 65534))
38 def check_call(*popenargs, **kwargs): #{{{ XXX Python 2.5 forward-compatibility
39 """Run command with arguments. Wait for command to complete. If
40 the exit code was zero then return, otherwise raise
41 CalledProcessError. The CalledProcessError object will have the
42 return code in the returncode attribute.
43 ret = call(*popenargs, **kwargs)
45 The arguments are the same as for the Popen constructor. Example:
47 check_call(["ls", "-l"])
49 retcode = sp.call(*popenargs, **kwargs)
50 cmd = kwargs.get("args")
54 raise sp.CalledProcessError(retcode, cmd)
56 sp.check_call = check_call
61 class CalledProcessError(Exception): #{{{ XXX Python 2.5 forward-compatibility
62 """This exception is raised when a process run by check_call() returns
63 a non-zero exit status. The exit status will be stored in the
64 returncode attribute."""
65 def __init__(self, returncode, cmd):
66 self.returncode = returncode
69 return ("Command '%s' returned non-zero exit status %d"
70 % (self.cmd, self.returncode))
71 sp.CalledProcessError = CalledProcessError
74 class Error(StandardError): pass
76 class ExecutionFailure(Error, RuntimeError): #{{{
77 def __init__(self, comando, tarea=None, caso_de_prueba=None):
78 self.comando = comando
80 self.caso_de_prueba = caso_de_prueba
83 class RsyncError(Error, EnvironmentError): pass
87 def unzip(bytes, default_dst='.', specific_dst=dict()): # {{{
88 u"""Descomprime un buffer de datos en formato ZIP.
89 Los archivos se descomprimen en default_dst a menos que exista una entrada
90 en specific_dst cuya clave sea el nombre de archivo a descomprimir, en
91 cuyo caso, se descomprime usando como destino el valor de dicha clave.
93 log.debug(_(u'Intentando descomprimir'))
96 zfile = ZipFile(StringIO(bytes), 'r')
97 for f in zfile.namelist():
98 dst = join(specific_dst.get(f, default_dst), f)
99 if f.endswith(os.sep):
100 log.debug(_(u'Creando directorio "%s" en "%s"'), f, dst)
103 log.debug(_(u'Descomprimiendo archivo "%s" en "%s"'), f, dst)
104 file(dst, 'w').write(zfile.read(f))
108 class SecureProcess(object): #{{{
110 max_tiempo_cpu = 120,
113 max_cant_archivos = 5,
114 max_cant_procesos = 0,
115 max_locks_memoria = 0,
117 uid = config.get('sercom.tester.chroot.user', 65534)
119 # XXX probar! make de un solo archivo lleva nproc=100 y nofile=15
120 def __init__(self, comando, chroot, cwd, close_stdin=False,
121 close_stdout=False, close_stderr=False):
122 self.comando = comando
125 self.close_stdin = close_stdin
126 self.close_stdout = close_stdout
127 self.close_stderr = close_stderr
128 log.debug(_(u'Proceso segurizado: chroot=%s, cwd=%s, user=%s, cpu=%s, '
129 u'as=%sMiB, fsize=%sMiB, nofile=%s, nproc=%s, memlock=%s'),
130 self.chroot, self.cwd, self.uid, self.max_tiempo_cpu,
131 self.max_memoria, self.max_tam_archivo, self.max_cant_archivos,
132 self.max_cant_procesos, self.max_locks_memoria)
133 def __getattr__(self, name):
134 if getattr(self.comando, name) is not None:
135 return getattr(self.comando, name)
136 return config.get('sercom.tester.limits.' + name, self.default[name])
138 x2 = lambda x: (x, x)
141 if self.close_stdout:
143 if self.close_stderr:
145 os.chroot(self.chroot)
147 uinfo = UserInfo(self.uid)
149 os.setuid(uinfo.uid) # Somos mortales irreversiblemente
150 rsrc.setrlimit(rsrc.RLIMIT_CPU, x2(self.max_tiempo_cpu))
151 rsrc.setrlimit(rsrc.RLIMIT_AS, x2(self.max_memoria*self.MB))
152 rsrc.setrlimit(rsrc.RLIMIT_FSIZE, x2(self.max_tam_archivo*self.MB)) # XXX calcular en base a archivos esperados?
153 rsrc.setrlimit(rsrc.RLIMIT_NOFILE, x2(self.max_cant_archivos)) #XXX Obtener de archivos esperados?
154 rsrc.setrlimit(rsrc.RLIMIT_NPROC, x2(self.max_cant_procesos))
155 rsrc.setrlimit(rsrc.RLIMIT_MEMLOCK, x2(self.max_locks_memoria))
156 rsrc.setrlimit(rsrc.RLIMIT_CORE, x2(0))
157 # Tratamos de forzar un sync para que entre al sleep del padre FIXME
162 class Tester(object): #{{{
164 def __init__(self, name, path, home, queue): #{{{ y properties
169 # Ahora somos mortales (oid mortales)
170 log.debug(_(u'Cambiando usuario y grupo efectivos a %s:%s (%s:%s)'),
171 user_info.user, user_info.group, user_info.uid, user_info.gid)
172 os.setegid(user_info.gid)
173 os.seteuid(user_info.uid)
176 def build_path(self):
177 return join(self.chroot, self.home, 'build')
181 return join(self.chroot, self.home, 'test')
185 return join(self.path, 'chroot_' + self.name)
188 def orig_chroot(self):
189 return join(self.path, 'chroot')
193 entrega_id = self.queue.get() # blocking
194 while entrega_id is not None:
195 entrega = Entrega.get(entrega_id)
196 log.debug(_(u'Nueva entrega para probar en tester %s: %s'),
199 log.debug(_(u'Fin de pruebas de: %s'), entrega)
200 entrega_id = self.queue.get() # blocking
203 def test(self, entrega): #{{{
204 log.debug(_(u'Tester.test(entrega=%s)'), entrega)
205 entrega.inicio_tareas = datetime.now()
208 self.setup_chroot(entrega)
209 self.ejecutar_tareas_fuente(entrega)
210 self.ejecutar_tareas_prueba(entrega)
211 self.clean_chroot(entrega)
212 except ExecutionFailure, e:
213 entrega.correcta = False
214 log.info(_(u'Entrega incorrecta: %s'), entrega)
216 if isinstance(e, SystemExit): raise
217 entrega.observaciones += error_interno
218 log.exception(_('Hubo una excepcion inesperada')) # FIXME encoding
220 entrega.observaciones += error_interno
221 log.exception(_('Hubo una excepcion inesperada desconocida')) # FIXME encoding
223 entrega.correcta = True
224 log.debug(_(u'Entrega correcta: %s'), entrega)
226 entrega.fin_tareas = datetime.now()
229 def setup_chroot(self, entrega): #{{{ y clean_chroot()
230 log.debug(_(u'Tester.setup_chroot(entrega=%s)'), entrega.shortrepr())
231 rsync = ('rsync', '--stats', '--itemize-changes', '--human-readable',
232 '--archive', '--acls', '--delete-during', '--force', # TODO config
233 join(self.orig_chroot, ''), self.chroot)
234 log.debug(_(u'Ejecutando como root: %s'), ' '.join(rsync))
235 os.seteuid(0) # Dios! (para chroot)
240 log.debug(_(u'Cambiando usuario y grupo efectivos a %s:%s (%s:%s)'),
241 user_info.user, user_info.group, user_info.uid, user_info.gid)
242 os.setegid(user_info.gid) # Mortal de nuevo
243 os.seteuid(user_info.uid)
244 unzip(entrega.archivos, self.build_path)
246 def clean_chroot(self, entrega):
247 log.debug(_(u'Tester.clean_chroot(entrega=%s)'), entrega.shortrepr())
248 pass # Se limpia con el próximo rsync
251 def ejecutar_tareas_fuente(self, entrega): #{{{ y tareas_prueba
252 log.debug(_(u'Tester.ejecutar_tareas_fuente(entrega=%s)'),
254 tareas = [t for t in entrega.instancia.ejercicio.enunciado.tareas
255 if isinstance(t, TareaFuente)]
257 tarea.ejecutar(self.build_path, entrega)
259 def ejecutar_tareas_prueba(self, entrega):
260 log.debug(_(u'Tester.ejecutar_tareas_prueba(entrega=%s)'),
262 for caso in entrega.instancia.ejercicio.enunciado.casos_de_prueba:
263 caso.ejecutar(self.test_path, entrega)
268 def ejecutar_caso_de_prueba(self, path, entrega): #{{{
269 log.debug(_(u'CasoDePrueba.ejecutar(path=%s, entrega=%s)'), path,
271 tareas = [t for t in entrega.instancia.ejercicio.enunciado.tareas
272 if isinstance(t, TareaPrueba)]
273 prueba = entrega.add_prueba(self)
277 tarea.ejecutar(path, prueba)
278 except ExecutionFailure, e:
280 if self.rechazar_si_falla:
281 entrega.exito = False
282 if self.terminar_si_falla:
283 raise ExecutionFailure(e.comando, e.tarea, self)
287 prueba.fin = datetime.now()
288 CasoDePrueba.ejecutar = ejecutar_caso_de_prueba
291 def ejecutar_tarea_fuente(self, path, entrega): #{{{
292 log.debug(_(u'TareaFuente.ejecutar(path=%s, entrega=%s)'), path,
295 for cmd in self.comandos:
296 cmd.ejecutar(path, entrega)
297 except ExecutionFailure, e:
298 if self.rechazar_si_falla:
299 entrega.exito = False
300 if self.terminar_si_falla:
301 raise ExecutionFailure(e.comando, self)
302 TareaFuente.ejecutar = ejecutar_tarea_fuente
305 def ejecutar_tarea_prueba(self, path, prueba): #{{{
306 log.debug(_(u'TareaPrueba.ejecutar(path=%s, prueba=%s)'), path,
309 for cmd in self.comandos:
310 cmd.ejecutar(path, prueba)
311 except ExecutionFailure, e:
312 if self.rechazar_si_falla:
314 if self.terminar_si_falla:
315 raise ExecutionFailure(e.comando, self)
316 TareaPrueba.ejecutar = ejecutar_tarea_prueba
319 def ejecutar_comando_fuente(self, path, entrega): #{{{
320 log.debug(_(u'ComandoFuente.ejecutar(path=%s, entrega=%s)'), path,
322 comando_ejecutado = entrega.add_comando_ejecutado(self)
323 basetmp = '/tmp/sercom.tester.fuente' # FIXME TODO /var/run/sercom?
324 unzip(self.archivos_entrada, path, # TODO try/except
325 {self.STDIN: '%s.%s.stdin' % (basetmp, comando_ejecutado.id)})
329 preexec_fn=SecureProcess(self, 'var/chroot_pepe', '/home/sercom/build')
331 if os.path.exists('%s.%s.stdin' % (basetmp, comando_ejecutado.id)):
332 options['stdin'] = file('%s.%s.stdin' % (basetmp, comando_ejecutado.id),
335 options['preexec_fn'].close_stdin = True
336 a_guardar = set(self.archivos_a_guardar)
337 if self.archivos_a_comparar:
338 zip_a_comparar = ZipFile(StringIO(self.archivos_a_comparar), 'r')
339 a_comparar = set(zip_a_comparar.namelist())
341 zip_a_comparar = None
342 a_comparar = frozenset()
343 a_usar = frozenset(a_guardar | a_comparar)
344 if self.STDOUTERR in a_usar:
345 options['stdout'] = file('%s.%s.stdouterr' % (basetmp,
346 comando_ejecutado.id), 'w')
347 options['stderr'] = sp.STDOUT
349 if self.STDOUT in a_usar:
350 options['stdout'] = file('%s.%s.stdout' % (basetmp,
351 comando_ejecutado.id), 'w')
353 options['preexec_fn'].close_stdout = True
354 if self.STDERR in a_usar:
355 options['stderr'] = file('%s.%s.stderr' % (basetmp,
356 comando_ejecutado.id), 'w')
358 options['preexec_fn'].close_stderr = True
359 log.debug(_(u'Ejecutando como root: %s'), self.comando)
360 os.seteuid(0) # Dios! (para chroot)
364 proc = sp.Popen(self.comando, **options)
366 log.debug(_(u'Cambiando usuario y grupo efectivos a %s:%s (%s:%s)'),
367 user_info.user, user_info.group, user_info.uid, user_info.gid)
368 os.setegid(user_info.gid) # Mortal de nuevo
369 os.seteuid(user_info.uid)
371 if hasattr(e, 'child_traceback'):
372 log.error(_(u'Error en el hijo: %s'), e.child_traceback)
374 proc.wait() #TODO un sleep grande nos caga todo, ver sercom viejo
375 if self.retorno != self.RET_ANY:
376 if self.retorno == self.RET_FAIL:
377 if proc.returncode == 0:
378 if self.rechazar_si_falla:
379 entrega.correcta = False
380 comando_ejecutado.exito = False
381 comando_ejecutado.observaciones += _(u'Se esperaba que el '
382 u'programa termine con un error (código de retorno '
383 u'distinto de 0) pero terminó bien (código de retorno '
385 log.debug(_(u'Se esperaba que el programa termine '
386 u'con un error (código de retorno distinto de 0) pero '
387 u'terminó bien (código de retorno 0).\n'))
388 elif self.retorno != proc.returncode:
389 if self.rechazar_si_falla:
390 entrega.correcta = False
391 comando_ejecutado.exito = False
392 if proc.returncode < 0:
393 comando_ejecutado.observaciones += _(u'Se esperaba terminar '
394 u'con un código de retorno %s pero se obtuvo una señal %s '
395 u'(%s).\n') % (self.retorno, -proc.returncode,
396 -proc.returncode) # TODO poner con texto
397 log.debug(_(u'Se esperaba terminar con un código '
398 u'de retorno %s pero se obtuvo una señal %s (%s).\n'),
399 self.retorno, -proc.returncode, -proc.returncode)
401 comando_ejecutado.observaciones += _(u'Se esperaba terminar '
402 u'con un código de retorno %s pero se obtuvo %s.\n') \
403 % (self.retorno, proc.returncode)
404 log.debug(_(u'Se esperaba terminar con un código de retorno '
405 u'%s pero se obtuvo %s.\n'), self.retorno, proc.returncode)
406 if comando_ejecutado.exito is None:
407 log.debug(_(u'Código de retorno OK'))
408 comando_ejecutado.fin = datetime.now()
411 zip = ZipFile(buffer, 'w')
412 # Guardamos stdout/stderr
413 if self.STDOUTERR in a_guardar:
414 a_guardar.remove(self.STDOUTERR)
415 zip.write('%s.%s.stdouterr' % (basetmp, comando_ejecutado.id),
418 if self.STDOUT in a_guardar:
419 a_guardar.remove(self.STDOUT)
420 zip.write('%s.%s.stdout' % (basetmp, comando_ejecutado.id),
422 if self.STDERR in a_guardar:
423 a_guardar.remove(self.STDERR)
424 zip.write('%s.%s.stderr' % (basetmp, comando_ejecutado.id),
428 if not os.path.exists(join(path, f)):
429 if self.rechazar_si_falla:
430 entrega.correcta = False
431 comando_ejecutado.exito = False
432 comando_ejecutado.observaciones += _(u'Se esperaba un archivo '
433 u'"%s" para guardar pero no fue encontrado.\n') % f
434 log.debug(_(u'Se esperaba un archivo "%s" para guardar pero '
435 u'no fue encontrado'), f)
437 zip.write(join(path, f), f)
439 comando_ejecutado.archivos_guardados = buffer.getvalue()
440 def diff(new, zip_in, zip_out, name, longname=None, origname='correcto',
441 newname='entregado'):
444 new = file(new, 'r').readlines()
445 orig = zip_in.read(name).split('\n')
446 udiff = ''.join(list(unified_diff(orig, new, fromfile=name+'.'+origname,
447 tofile=name+'.'+newname)))
449 if self.rechazar_si_falla:
450 entrega.correcta = False
451 comando_ejecutado.exito = False
452 comando_ejecutado.observaciones += _(u'%s no coincide con lo '
453 u'esperado (archivo "%s.diff").\n') % (longname, name)
454 log.debug(_(u'%s no coincide con lo esperado (archivo "%s.diff")'),
456 htmldiff = HtmlDiff().make_file(orig, new,
457 fromdesc=name+'.'+origname, todesc=name+'.'+newname,
458 context=True, numlines=3)
459 zip_out.writestr(name + '.diff', udiff)
460 zip_out.writestr(name + '.diff.html', htmldiff)
466 zip = ZipFile(buffer, 'w')
467 # Comparamos stdout/stderr
468 if self.STDOUTERR in a_comparar:
469 a_comparar.remove(self.STDOUTERR)
470 diff('%s.%s.stdouterr' % (basetmp, comando_ejecutado.id),
471 zip_a_comparar, zip, self.STDOUTERR,
472 _(u'La salida estándar y de error combinada'))
474 if self.STDOUT in a_comparar:
475 a_comparar.remove(self.STDOUT)
476 diff('%s.%s.stdout' % (basetmp, comando_ejecutado.id),
477 zip_a_comparar, zip, self.STDOUT, _(u'La salida estándar'))
478 if self.STDERR in a_comparar:
479 a_comparar.remove(self.STDERR)
480 diff('%s.%s.stderr' % (basetmp, comando_ejecutado.id),
481 zip_a_comparar, zip, self.STDERR, _(u'La salida de error'))
484 if not os.path.exists(join(path, f)):
485 if self.rechazar_si_falla:
486 entrega.correcta = False
487 comando_ejecutado.exito = False
488 comando_ejecutado.observaciones += _(u'Se esperaba un archivo '
489 u'"%s" para comparar pero no fue encontrado') % f
490 log.debug(_(u'Se esperaba un archivo "%s" para comparar pero '
491 u'no fue encontrado'), f)
493 diff(join(path, f), zip_a_comparar, zip, f)
495 comando_ejecutado.archivos_guardados = buffer.getvalue()
496 if comando_ejecutado.exito is None:
497 comando_ejecutado.exito = True
498 elif self.terminar_si_falla:
499 raise ExecutionFailure(self)
501 ComandoFuente.ejecutar = ejecutar_comando_fuente
504 def ejecutar_comando_prueba(self, path, prueba): #{{{
505 log.debug(_(u'ComandoPrueba.ejecutar(path=%s, prueba=%s)'), path,
509 unzip(prueba.caso_de_prueba.archivos_entrada, path) # TODO try/except
510 unzip(self.archivos_entrada, path) # TODO try/except
511 comando_ejecutado = prueba.add_comando_ejecutado(self)
512 # TODO ejecutar en chroot (path)
513 comando_ejecutado.fin = datetime.now()
514 # if no_anda_ejecucion: # TODO
515 # comando_ejecutado.exito = False
516 # comando_ejecutado.observaciones += 'No anduvo xxx' # TODO
517 # if self.rechazar_si_falla:
518 # entrega.exito = False
519 # if self.terminar_si_falla: # TODO
520 # raise ExecutionFailure(self) # TODO info de error
521 # for archivo in self.archivos_salida:
522 # pass # TODO hacer diff
523 # if archivos_mal: # TODO
524 # comando_ejecutado.exito = False
525 # comando_ejecutado.observaciones += 'No anduvo xxx' # TODO
526 # if self.rechazar_si_falla:
527 # entrega.exito = False
528 # if self.terminar_si_falla: # TODO
529 # raise ExecutionFailure(comando=self) # TODO info de error
531 # comando_ejecutado.exito = True
532 # comando_ejecutado.observaciones += 'xxx OK' # TODO
533 comando_ejecutado.exito = True
534 comando_ejecutado.observaciones += 'xxx OK' # TODO
535 ComandoPrueba.ejecutar = ejecutar_comando_prueba