]> git.llucax.com Git - software/sercom.git/blob - sercom/tester.py
42d075fd22038c938a26a7923f614d386a25c4a9
[software/sercom.git] / sercom / tester.py
1 # vim: set et sw=4 sts=4 encoding=utf-8 foldmethod=marker:
2
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
15 import logging
16
17 log = logging.getLogger('sercom.tester')
18
19 error_interno = _(u'\n**Error interno al preparar la entrega.**')
20
21 class UserInfo(object): #{{{
22     def __init__(self, user):
23         try:
24             info = pwd.getpwnam(user)
25         except:
26             info = pwd.get(int(user))
27         self.user = info[0]
28         self.uid = info[2]
29         self.gid = info[3]
30         self.name = info[4]
31         self.home = info[5]
32         self.shell = info[6]
33         self.group = grp.getgrgid(self.gid)[0]
34 #}}}
35
36 user_info = UserInfo(config.get('sercom.tester.user', 65534))
37
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)
44
45     The arguments are the same as for the Popen constructor.  Example:
46
47     check_call(["ls", "-l"])
48     """
49     retcode = sp.call(*popenargs, **kwargs)
50     cmd = kwargs.get("args")
51     if cmd is None:
52         cmd = popenargs[0]
53     if retcode:
54         raise sp.CalledProcessError(retcode, cmd)
55     return retcode
56 sp.check_call = check_call
57 #}}}
58
59 #{{{ Excepciones
60
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
67         self.cmd = cmd
68     def __str__(self):
69         return ("Command '%s' returned non-zero exit status %d"
70             % (self.cmd, self.returncode))
71 sp.CalledProcessError = CalledProcessError
72 #}}}
73
74 class Error(StandardError): pass
75
76 class ExecutionFailure(Error, RuntimeError): #{{{
77     def __init__(self, comando, tarea=None, caso_de_prueba=None):
78         self.comando = comando
79         self.tarea = tarea
80         self.caso_de_prueba = caso_de_prueba
81 #}}}
82
83 class RsyncError(Error, EnvironmentError): pass
84
85 #}}}
86
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.
92     """
93     log.debug(_(u'Intentando descomprimir'))
94     if bytes is None:
95         return
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)
101             os.mkdir(dst)
102         else:
103             log.debug(_(u'Descomprimiendo archivo "%s" en "%s"'), f, dst)
104             file(dst, 'w').write(zfile.read(f))
105     zfile.close()
106 #}}}
107
108 class SecureProcess(object): #{{{
109     default = dict(
110         max_tiempo_cpu      = 120,
111         max_memoria         = 16,
112         max_tam_archivo     = 5,
113         max_cant_archivos   = 5,
114         max_cant_procesos   = 0,
115         max_locks_memoria   = 0,
116     )
117     uid = config.get('sercom.tester.chroot.user', 65534)
118     MB = 1048576
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
123         self.chroot = chroot
124         self.cwd = cwd
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])
137     def __call__(self):
138         x2 = lambda x: (x, x)
139         if self.close_stdin:
140             os.close(0)
141         if self.close_stdout:
142             os.close(1)
143         if self.close_stderr:
144             os.close(2)
145         os.chroot(self.chroot)
146         os.chdir(self.cwd)
147         uinfo = UserInfo(self.uid)
148         os.setgid(uinfo.gid)
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
158         import time
159         time.sleep(0)
160 #}}}
161
162 class Tester(object): #{{{
163
164     def __init__(self, name, path, home, queue): #{{{ y properties
165         self.name = name
166         self.path = path
167         self.home = home
168         self.queue = queue
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)
174
175     @property
176     def build_path(self):
177         return join(self.chroot, self.home, 'build')
178
179     @property
180     def test_path(self):
181         return join(self.chroot, self.home, 'test')
182
183     @property
184     def chroot(self):
185         return join(self.path, 'chroot_' + self.name)
186
187     @property
188     def orig_chroot(self):
189         return join(self.path, 'chroot')
190     #}}}
191
192     def run(self): #{{{
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'),
197                 self.name, entrega)
198             self.test(entrega)
199             log.debug(_(u'Fin de pruebas de: %s'), entrega)
200             entrega_id = self.queue.get() # blocking
201     #}}}
202
203     def test(self, entrega): #{{{
204         log.debug(_(u'Tester.test(entrega=%s)'), entrega)
205         entrega.inicio_tareas = datetime.now()
206         try:
207             try:
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)
215             except Exception, e:
216                 if isinstance(e, SystemExit): raise
217                 entrega.observaciones += error_interno
218                 log.exception(_('Hubo una excepcion inesperada')) # FIXME encoding
219             except:
220                 entrega.observaciones += error_interno
221                 log.exception(_('Hubo una excepcion inesperada desconocida')) # FIXME encoding
222             else:
223                 entrega.correcta = True
224                 log.debug(_(u'Entrega correcta: %s'), entrega)
225         finally:
226             entrega.fin_tareas = datetime.now()
227     #}}}
228
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)
236         os.setegid(0)
237         try:
238             sp.check_call(rsync)
239         finally:
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)
245
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
249     #}}}
250
251     def ejecutar_tareas_fuente(self, entrega): #{{{ y tareas_prueba
252         log.debug(_(u'Tester.ejecutar_tareas_fuente(entrega=%s)'),
253             entrega.shortrepr())
254         tareas = [t for t in entrega.instancia.ejercicio.enunciado.tareas
255                     if isinstance(t, TareaFuente)]
256         for tarea in tareas:
257             tarea.ejecutar(self.build_path, entrega)
258
259     def ejecutar_tareas_prueba(self, entrega):
260         log.debug(_(u'Tester.ejecutar_tareas_prueba(entrega=%s)'),
261             entrega.shortrepr())
262         for caso in entrega.instancia.ejercicio.enunciado.casos_de_prueba:
263             caso.ejecutar(self.test_path, entrega)
264     #}}}
265
266 #}}}
267
268 def ejecutar_caso_de_prueba(self, path, entrega): #{{{
269     log.debug(_(u'CasoDePrueba.ejecutar(path=%s, entrega=%s)'), path,
270         entrega.shortrepr())
271     tareas = [t for t in entrega.instancia.ejercicio.enunciado.tareas
272                 if isinstance(t, TareaPrueba)]
273     prueba = entrega.add_prueba(self)
274     try:
275         try:
276             for tarea in tareas:
277                 tarea.ejecutar(path, prueba)
278         except ExecutionFailure, e:
279             prueba.exito = False
280             if self.rechazar_si_falla:
281                 entrega.exito = False
282             if self.terminar_si_falla:
283                 raise ExecutionFailure(e.comando, e.tarea, self)
284         else:
285             prueba.exito = True
286     finally:
287         prueba.fin = datetime.now()
288 CasoDePrueba.ejecutar = ejecutar_caso_de_prueba
289 #}}}
290
291 def ejecutar_tarea_fuente(self, path, entrega): #{{{
292     log.debug(_(u'TareaFuente.ejecutar(path=%s, entrega=%s)'), path,
293         entrega.shortrepr())
294     try:
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
303 #}}}
304
305 def ejecutar_tarea_prueba(self, path, prueba): #{{{
306     log.debug(_(u'TareaPrueba.ejecutar(path=%s, prueba=%s)'), path,
307         prueba.shortrepr())
308     try:
309         for cmd in self.comandos:
310             cmd.ejecutar(path, prueba)
311     except ExecutionFailure, e:
312         if self.rechazar_si_falla:
313             prueba.exito = False
314         if self.terminar_si_falla:
315             raise ExecutionFailure(e.comando, self)
316 TareaPrueba.ejecutar = ejecutar_tarea_prueba
317 #}}}
318
319 def ejecutar_comando_fuente(self, path, entrega): #{{{
320     log.debug(_(u'ComandoFuente.ejecutar(path=%s, entrega=%s)'), path,
321         entrega.shortrepr())
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
325     options = dict(
326         close_fds=True,
327         shell=True,
328         preexec_fn=SecureProcess(self, 'var/chroot_pepe', '/home/sercom/build')
329     )
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
332     else:
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())
338     else:
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
346     else:
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?
350         else:
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?
355         else:
356             options['preexec_fn'].close_stderr = True
357     log.debug(_(u'Ejecutando como root: %s'), self.comando)
358     os.seteuid(0) # Dios! (para chroot)
359     os.setegid(0)
360     try:
361         try:
362             proc = sp.Popen(self.comando, **options)
363         finally:
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)
368     except Exception, e:
369         if hasattr(e, 'child_traceback'):
370             log.error(_(u'Error en el hijo: %s'), e.child_traceback)
371         raise
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 '
382                     u'0).\n')
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)
398             else:
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()
407     if a_guardar:
408         buffer = StringIO()
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)
415         else:
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)
424         # Guardamos otros
425         for f in a_guardar:
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)
434             else:
435                 zip.write(join(path, f), f)
436         zip.close()
437         comando_ejecutado.archivos_guardados = buffer.getvalue()
438     def diff(new, zip_in, zip_out, name, longname=None, origname='correcto',
439              newname='entregado'):
440         if longname is None:
441             longname = name
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)))
446         if udiff:
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")'),
453                 longname, name)
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)
459             return True
460         else:
461             return False
462     if a_comparar:
463         buffer = StringIO()
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'))
471         else:
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'))
480         # Comparamos otros
481         for f in a_comparar:
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)
490             else:
491                 diff(join(path, f), zip_a_comparar, zip, f)
492         zip.close()
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)
498
499 ComandoFuente.ejecutar = ejecutar_comando_fuente
500 #}}}
501
502 def ejecutar_comando_prueba(self, path, prueba): #{{{
503     log.debug(_(u'ComandoPrueba.ejecutar(path=%s, prueba=%s)'), path,
504         prueba.shortrepr())
505     rmtree(path)
506     os.mkdir(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
528 #    else:
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
534 #}}}
535