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 unzip(self.archivos_entrada, path, # TODO try/except
324 {self.STDIN: '/tmp/sercom.tester.%s.stdin' % comando_ejecutado.id}) # TODO /var/run/sercom
328 preexec_fn=SecureProcess(self, 'var/chroot_pepe', '/home/sercom/build')
330 if os.path.exists('/tmp/sercom.tester.%s.stdin' % comando_ejecutado.id): # TODO
331 options['stdin'] = file('/tmp/sercom.tester.%s.stdin' % comando_ejecutado.id, 'r') # TODO
333 options['preexec_fn'].close_stdin = True
334 a_guardar = set(self.archivos_a_guardar)
335 if self.archivos_a_comparar:
336 zip_a_comparar = ZipFile(StringIO(self.archivos_a_comparar), 'r')
337 a_comparar = set(zip_a_comparar.namelist())
339 zip_a_comparar = None
340 a_comparar = frozenset()
341 a_usar = frozenset(a_guardar | a_comparar)
342 if self.STDOUTERR in a_usar:
343 options['stdout'] = file('/tmp/sercom.tester.%s.stdouterr'
344 % comando_ejecutado.id, 'w') #TODO /var/run/sercom?
345 options['stderr'] = sp.STDOUT
347 if self.STDOUT in a_usar:
348 options['stdout'] = file('/tmp/sercom.tester.%s.stdout'
349 % comando_ejecutado.id, 'w') #TODO /run/lib/sercom?
351 options['preexec_fn'].close_stdout = True
352 if self.STDERR in a_usar:
353 options['stderr'] = file('/tmp/sercom.tester.%s.stderr'
354 % comando_ejecutado.id, 'w') #TODO /var/run/sercom?
356 options['preexec_fn'].close_stderr = True
357 log.debug(_(u'Ejecutando como root: %s'), self.comando)
358 os.seteuid(0) # Dios! (para chroot)
362 proc = sp.Popen(self.comando, **options)
364 log.debug(_(u'Cambiando usuario y grupo efectivos a %s:%s (%s:%s)'),
365 user_info.user, user_info.group, user_info.uid, user_info.gid)
366 os.setegid(user_info.gid) # Mortal de nuevo
367 os.seteuid(user_info.uid)
369 if hasattr(e, 'child_traceback'):
370 log.error(_(u'Error en el hijo: %s'), e.child_traceback)
372 proc.wait() #TODO un sleep grande nos caga todo, ver sercom viejo
373 if self.retorno != self.RET_ANY:
374 if self.retorno == self.RET_FAIL:
375 if proc.returncode == 0:
376 if self.rechazar_si_falla:
377 entrega.correcta = False
378 comando_ejecutado.exito = False
379 comando_ejecutado.observaciones += _(u'Se esperaba que el '
380 u'programa termine con un error (código de retorno '
381 u'distinto de 0) pero terminó bien (código de retorno '
383 log.debug(_(u'Se esperaba que el programa termine '
384 u'con un error (código de retorno distinto de 0) pero '
385 u'terminó bien (código de retorno 0).\n'))
386 elif self.retorno != proc.returncode:
387 if self.rechazar_si_falla:
388 entrega.correcta = False
389 comando_ejecutado.exito = False
390 if proc.returncode < 0:
391 comando_ejecutado.observaciones += _(u'Se esperaba terminar '
392 u'con un código de retorno %s pero se obtuvo una señal %s '
393 u'(%s).\n') % (self.retorno, -proc.returncode,
394 -proc.returncode) # TODO poner con texto
395 log.debug(_(u'Se esperaba terminar con un código '
396 u'de retorno %s pero se obtuvo una señal %s (%s).\n'),
397 self.retorno, -proc.returncode, -proc.returncode)
399 comando_ejecutado.observaciones += _(u'Se esperaba terminar '
400 u'con un código de retorno %s pero se obtuvo %s.\n') \
401 % (self.retorno, proc.returncode)
402 log.debug(_(u'Se esperaba terminar con un código de retorno '
403 u'%s pero se obtuvo %s.\n'), self.retorno, proc.returncode)
404 if comando_ejecutado.exito is None:
405 log.debug(_(u'Código de retorno OK'))
406 comando_ejecutado.fin = datetime.now()
409 zip = ZipFile(buffer, 'w')
410 # Guardamos stdout/stderr
411 if self.STDOUTERR in a_guardar:
412 a_guardar.remove(self.STDOUTERR)
413 zip.write('/tmp/sercom.tester.%s.stdouterr'
414 % comando_ejecutado.id, self.STDOUTERR)
416 if self.STDOUT in a_guardar:
417 a_guardar.remove(self.STDOUT)
418 zip.write('/tmp/sercom.tester.%s.stdout'
419 % comando_ejecutado.id, self.STDOUT)
420 if self.STDERR in a_guardar:
421 a_guardar.remove(self.STDERR)
422 zip.write('/tmp/sercom.tester.%s.stderr'
423 % comando_ejecutado.id, self.STDERR)
426 if not os.path.exists(join(path, f)):
427 if self.rechazar_si_falla:
428 entrega.correcta = False
429 comando_ejecutado.exito = False
430 comando_ejecutado.observaciones += _(u'Se esperaba un archivo '
431 u'"%s" para guardar pero no fue encontrado.\n') % f
432 log.debug(_(u'Se esperaba un archivo "%s" para guardar pero '
433 u'no fue encontrado'), f)
435 zip.write(join(path, f), f)
437 comando_ejecutado.archivos_guardados = buffer.getvalue()
438 def diff(new, zip_in, zip_out, name, longname=None, origname='correcto',
439 newname='entregado'):
442 new = file(new, 'r').readlines()
443 orig = zip_in.read(name).split('\n')
444 udiff = ''.join(list(unified_diff(orig, new, fromfile=name+'.'+origname,
445 tofile=name+'.'+newname)))
447 if self.rechazar_si_falla:
448 entrega.correcta = False
449 comando_ejecutado.exito = False
450 comando_ejecutado.observaciones += _(u'%s no coincide con lo '
451 u'esperado (archivo "%s.diff").\n') % (longname, name)
452 log.debug(_(u'%s no coincide con lo esperado (archivo "%s.diff")'),
454 htmldiff = HtmlDiff().make_file(orig, new,
455 fromdesc=name+'.'+origname, todesc=name+'.'+newname,
456 context=True, numlines=3)
457 zip_out.writestr(name + '.diff', udiff)
458 zip_out.writestr(name + '.diff.html', htmldiff)
464 zip = ZipFile(buffer, 'w')
465 # Comparamos stdout/stderr
466 if self.STDOUTERR in a_comparar:
467 a_comparar.remove(self.STDOUTERR)
468 diff('/tmp/sercom.tester.%s.stdouterr' % comando_ejecutado.id,
469 zip_a_comparar, zip, self.STDOUTERR,
470 _(u'La salida estándar y de error combinada'))
472 if self.STDOUT in a_comparar:
473 a_comparar.remove(self.STDOUT)
474 diff('/tmp/sercom.tester.%s.stdout' % comando_ejecutado.id,
475 zip_a_comparar, zip, self.STDOUT, _(u'La salida estándar'))
476 if self.STDERR in a_comparar:
477 a_comparar.remove(self.STDERR)
478 diff('/tmp/sercom.tester.%s.stderr' % comando_ejecutado.id,
479 zip_a_comparar, zip, self.STDERR, _(u'La salida de error'))
482 if not os.path.exists(join(path, f)):
483 if self.rechazar_si_falla:
484 entrega.correcta = False
485 comando_ejecutado.exito = False
486 comando_ejecutado.observaciones += _(u'Se esperaba un archivo '
487 u'"%s" para comparar pero no fue encontrado') % f
488 log.debug(_(u'Se esperaba un archivo "%s" para comparar pero '
489 u'no fue encontrado'), f)
491 diff(join(path, f), zip_a_comparar, zip, f)
493 comando_ejecutado.archivos_guardados = buffer.getvalue()
494 if comando_ejecutado.exito is None:
495 comando_ejecutado.exito = True
496 elif self.terminar_si_falla:
497 raise ExecutionFailure(self)
499 ComandoFuente.ejecutar = ejecutar_comando_fuente
502 def ejecutar_comando_prueba(self, path, prueba): #{{{
503 log.debug(_(u'ComandoPrueba.ejecutar(path=%s, prueba=%s)'), path,
507 unzip(prueba.caso_de_prueba.archivos_entrada, path) # TODO try/except
508 unzip(self.archivos_entrada, path) # TODO try/except
509 comando_ejecutado = prueba.add_comando_ejecutado(self)
510 # TODO ejecutar en chroot (path)
511 comando_ejecutado.fin = datetime.now()
512 # if no_anda_ejecucion: # TODO
513 # comando_ejecutado.exito = False
514 # comando_ejecutado.observaciones += 'No anduvo xxx' # TODO
515 # if self.rechazar_si_falla:
516 # entrega.exito = False
517 # if self.terminar_si_falla: # TODO
518 # raise ExecutionFailure(self) # TODO info de error
519 # for archivo in self.archivos_salida:
520 # pass # TODO hacer diff
521 # if archivos_mal: # TODO
522 # comando_ejecutado.exito = False
523 # comando_ejecutado.observaciones += 'No anduvo xxx' # TODO
524 # if self.rechazar_si_falla:
525 # entrega.exito = False
526 # if self.terminar_si_falla: # TODO
527 # raise ExecutionFailure(comando=self) # TODO info de error
529 # comando_ejecutado.exito = True
530 # comando_ejecutado.observaciones += 'xxx OK' # TODO
531 comando_ejecutado.exito = True
532 comando_ejecutado.observaciones += 'xxx OK' # TODO
533 ComandoPrueba.ejecutar = ejecutar_comando_prueba