]> git.llucax.com Git - software/sercom.git/blob - sercom/tester.py
49d89ffb95119c341a7817fa40021a6c67b66f66
[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 zipfile import ZipFile, BadZipfile
6 from cStringIO import StringIO
7 from shutil import rmtree
8 from datetime import datetime
9 from os.path import join
10 from turbogears import config
11 import subprocess as sp
12 import os, sys, pwd, grp
13 import resource as rsrc
14 import logging
15
16 log = logging.getLogger('sercom.tester')
17
18 error_interno = _(u'\n**Error interno al preparar la entrega.**')
19
20 class UserInfo(object): #{{{
21     def __init__(self, user):
22         try:
23             info = pwd.getpwnam(user)
24         except:
25             info = pwd.get(int(user))
26         self.user = info[0]
27         self.uid = info[2]
28         self.gid = info[3]
29         self.name = info[4]
30         self.home = info[5]
31         self.shell = info[6]
32         self.group = grp.getgrgid(self.gid)[0]
33 #}}}
34
35 user_info = UserInfo(config.get('sercom.tester.user', 65534))
36
37 def check_call(*popenargs, **kwargs): #{{{ XXX Python 2.5 forward-compatibility
38     """Run command with arguments.  Wait for command to complete.  If
39     the exit code was zero then return, otherwise raise
40     CalledProcessError.  The CalledProcessError object will have the
41     return code in the returncode attribute.
42     ret = call(*popenargs, **kwargs)
43
44     The arguments are the same as for the Popen constructor.  Example:
45
46     check_call(["ls", "-l"])
47     """
48     retcode = sp.call(*popenargs, **kwargs)
49     cmd = kwargs.get("args")
50     if cmd is None:
51         cmd = popenargs[0]
52     if retcode:
53         raise sp.CalledProcessError(retcode, cmd)
54     return retcode
55 sp.check_call = check_call
56 #}}}
57
58 #{{{ Excepciones
59
60 class CalledProcessError(Exception): #{{{ XXX Python 2.5 forward-compatibility
61     """This exception is raised when a process run by check_call() returns
62     a non-zero exit status.  The exit status will be stored in the
63     returncode attribute."""
64     def __init__(self, returncode, cmd):
65         self.returncode = returncode
66         self.cmd = cmd
67     def __str__(self):
68         return ("Command '%s' returned non-zero exit status %d"
69             % (self.cmd, self.returncode))
70 sp.CalledProcessError = CalledProcessError
71 #}}}
72
73 class Error(StandardError): pass
74
75 class ExecutionFailure(Error, RuntimeError): pass
76
77 class RsyncError(Error, EnvironmentError): pass
78
79 #}}}
80
81 def unzip(bytes, default_dst='.', specific_dst=dict()): # {{{
82     """Descomprime un buffer de datos en formato ZIP.
83     Los archivos se descomprimen en default_dst a menos que exista una entrada
84     en specific_dst cuya clave sea el nombre de archivo a descomprimir, en
85     cuyo caso, se descomprime usando como destino el valor de dicha clave.
86     """
87     log.debug(_(u'Intentando descomprimir'))
88     if bytes is None:
89         return
90     zfile = ZipFile(StringIO(bytes), 'r')
91     for f in zfile.namelist():
92         dst = join(specific_dst.get(f, default_dst), f)
93         if f.endswith(os.sep):
94             log.debug(_(u'Creando directorio "%s" en "%s"'), f, dst)
95             os.mkdir(dst)
96         else:
97             log.debug(_(u'Descomprimiendo archivo "%s" en "%s"'), f, dst)
98             file(dst, 'w').write(zfile.read(f))
99     zfile.close()
100 #}}}
101
102 class SecureProcess(object): #{{{
103     default = dict(
104         max_tiempo_cpu      = 120,
105         max_memoria         = 16,
106         max_tam_archivo     = 5,
107         max_cant_archivos   = 5,
108         max_cant_procesos   = 0,
109         max_locks_memoria   = 0,
110     )
111     uid = config.get('sercom.tester.chroot.user', 65534)
112     MB = 1048576
113     # XXX probar! make de un solo archivo lleva nproc=100 y nofile=15
114     def __init__(self, comando, chroot, cwd):
115         self.comando = comando
116         self.chroot = chroot
117         self.cwd = cwd
118         log.debug('Proceso segurizado: chroot=%s, cwd=%s, user=%s, cpu=%s, '
119             'as=%sMiB, fsize=%sMiB, nofile=%s, nproc=%s, memlock=%s',
120             self.chroot, self.cwd, self.uid, self.max_tiempo_cpu,
121             self.max_memoria, self.max_tam_archivo, self.max_cant_archivos,
122             self.max_cant_procesos, self.max_locks_memoria)
123     def __getattr__(self, name):
124         if getattr(self.comando, name) is not None:
125             return getattr(self.comando, name)
126         return config.get('sercom.tester.limits.' + name, self.default[name])
127     def __call__(self):
128         x2 = lambda x: (x, x)
129         os.chroot(self.chroot)
130         os.chdir(self.cwd)
131         uinfo = UserInfo(self.uid)
132         os.setgid(uinfo.gid)
133         os.setuid(uinfo.uid) # Somos mortales irreversiblemente
134         rsrc.setrlimit(rsrc.RLIMIT_CPU, x2(self.max_tiempo_cpu))
135         rsrc.setrlimit(rsrc.RLIMIT_AS, x2(self.max_memoria*self.MB))
136         rsrc.setrlimit(rsrc.RLIMIT_FSIZE, x2(self.max_tam_archivo*self.MB)) # XXX calcular en base a archivos esperados?
137         rsrc.setrlimit(rsrc.RLIMIT_NOFILE, x2(self.max_cant_archivos)) #XXX Obtener de archivos esperados?
138         rsrc.setrlimit(rsrc.RLIMIT_NPROC, x2(self.max_cant_procesos))
139         rsrc.setrlimit(rsrc.RLIMIT_MEMLOCK, x2(self.max_locks_memoria))
140         rsrc.setrlimit(rsrc.RLIMIT_CORE, x2(0))
141         # Tratamos de forzar un sync para que entre al sleep del padre FIXME
142         import time
143         time.sleep(0)
144 #}}}
145
146 class Tester(object): #{{{
147
148     def __init__(self, name, path, home, queue): #{{{ y properties
149         self.name = name
150         self.path = path
151         self.home = home
152         self.queue = queue
153         # Ahora somos mortales (oid mortales)
154         log.debug(_(u'Cambiando usuario y grupo efectivos a %s:%s (%s:%s)'),
155             user_info.user, user_info.group, user_info.uid, user_info.gid)
156         os.setegid(user_info.gid)
157         os.seteuid(user_info.uid)
158
159     @property
160     def build_path(self):
161         return join(self.chroot, self.home, 'build')
162
163     @property
164     def test_path(self):
165         return join(self.chroot, self.home, 'test')
166
167     @property
168     def chroot(self):
169         return join(self.path, 'chroot_' + self.name)
170
171     @property
172     def orig_chroot(self):
173         return join(self.path, 'chroot')
174     #}}}
175
176     def run(self): #{{{
177         entrega_id = self.queue.get() # blocking
178         while entrega_id is not None:
179             entrega = Entrega.get(entrega_id)
180             log.debug(_(u'Nueva entrega para probar en tester %s: %s'),
181                 self.name, entrega)
182             self.test(entrega)
183             log.debug(_(u'Fin de pruebas de: %s'), entrega)
184             entrega_id = self.queue.get() # blocking
185     #}}}
186
187     def test(self, entrega): #{{{
188         log.debug(_(u'Tester.test(entrega=%s)'), entrega)
189         entrega.inicio_tareas = datetime.now()
190         try:
191             try:
192                 self.setup_chroot(entrega)
193                 self.ejecutar_tareas_fuente(entrega)
194                 self.ejecutar_tareas_prueba(entrega)
195                 self.clean_chroot(entrega)
196             except ExecutionFailure, e:
197                 entrega.correcta = False
198                 log.info(_(u'Entrega incorrecta: %s'), entrega)
199             except Exception, e:
200                 if isinstance(e, SystemExit): raise
201                 entrega.observaciones += error_interno
202                 log.exception(_(u'Hubo una excepción inesperada: %s'), e)
203             except:
204                 entrega.observaciones += error_interno
205                 log.exception(_(u'Hubo una excepción inesperada desconocida'))
206             else:
207                 entrega.correcta = True
208                 log.debug(_(u'Entrega correcta: %s'), entrega)
209         finally:
210             entrega.fin_tareas = datetime.now()
211     #}}}
212
213     def setup_chroot(self, entrega): #{{{ y clean_chroot()
214         log.debug(_(u'Tester.setup_chroot(entrega=%s)'), entrega.shortrepr())
215         rsync = ('rsync', '--stats', '--itemize-changes', '--human-readable',
216             '--archive', '--acls', '--delete-during', '--force', # TODO config
217             join(self.orig_chroot, ''), self.chroot)
218         log.debug(_(u'Ejecutando como root: %s'), ' '.join(rsync))
219         os.seteuid(0) # Dios! (para chroot)
220         os.setegid(0)
221         try:
222             sp.check_call(rsync)
223         finally:
224             log.debug(_(u'Cambiando usuario y grupo efectivos a %s:%s (%s:%s)'),
225                 user_info.user, user_info.group, user_info.uid, user_info.gid)
226             os.setegid(user_info.gid) # Mortal de nuevo
227             os.seteuid(user_info.uid)
228         unzip(entrega.archivos, self.build_path)
229
230     def clean_chroot(self, entrega):
231         log.debug(_(u'Tester.clean_chroot(entrega=%s)'), entrega.shortrepr())
232         pass # Se limpia con el próximo rsync
233     #}}}
234
235     def ejecutar_tareas_fuente(self, entrega): #{{{ y tareas_prueba
236         log.debug(_(u'Tester.ejecutar_tareas_fuente(entrega=%s)'),
237             entrega.shortrepr())
238         tareas = [t for t in entrega.instancia.ejercicio.enunciado.tareas
239                     if isinstance(t, TareaFuente)]
240         for tarea in tareas:
241             tarea.ejecutar(self.build_path, entrega)
242
243     def ejecutar_tareas_prueba(self, entrega):
244         log.debug(_(u'Tester.ejecutar_tareas_prueba(entrega=%s)'),
245             entrega.shortrepr())
246         for caso in entrega.instancia.ejercicio.enunciado.casos_de_prueba:
247             caso.ejecutar(self.test_path, entrega)
248     #}}}
249
250 #}}}
251
252 def ejecutar_caso_de_prueba(self, path, entrega): #{{{
253     log.debug(_(u'CasoDePrueba.ejecutar(path=%s, entrega=%s)'), path,
254         entrega.shortrepr())
255     tareas = [t for t in entrega.instancia.ejercicio.enunciado.tareas
256                 if isinstance(t, TareaPrueba)]
257     prueba = entrega.add_prueba(self)
258     try:
259         try:
260             for tarea in tareas:
261                 tarea.ejecutar(path, prueba)
262         except ExecutionFailure, e:
263             prueba.exito = False
264             if self.rechazar_si_falla:
265                 entrega.exito = False
266             if self.terminar_si_falla:
267                 raise ExecutionError(e.comando, e.tarea, prueba)
268         else:
269             prueba.exito = True
270     finally:
271         prueba.fin = datetime.now()
272 CasoDePrueba.ejecutar = ejecutar_caso_de_prueba
273 #}}}
274
275 def ejecutar_tarea_fuente(self, path, entrega): #{{{
276     log.debug(_(u'TareaFuente.ejecutar(path=%s, entrega=%s)'), path,
277         entrega.shortrepr())
278     try:
279         for cmd in self.comandos:
280             cmd.ejecutar(path, entrega)
281     except ExecutionFailure, e:
282         if self.rechazar_si_falla:
283             entrega.exito = False
284         if self.terminar_si_falla:
285             raise ExecutionError(e.comando, tarea)
286 TareaFuente.ejecutar = ejecutar_tarea_fuente
287 #}}}
288
289 def ejecutar_tarea_prueba(self, path, prueba): #{{{
290     log.debug(_(u'TareaPrueba.ejecutar(path=%s, prueba=%s)'), path,
291         prueba.shortrepr())
292     try:
293         for cmd in self.comandos:
294             cmd.ejecutar(path, prueba)
295     except ExecutionFailure, e:
296         if self.rechazar_si_falla:
297             prueba.exito = False
298         if self.terminar_si_falla:
299             raise ExecutionError(e.comando, tarea)
300 TareaPrueba.ejecutar = ejecutar_tarea_prueba
301 #}}}
302
303 def ejecutar_comando_fuente(self, path, entrega): #{{{
304     log.debug(_(u'ComandoFuente.ejecutar(path=%s, entrega=%s)'), path,
305         entrega.shortrepr())
306     unzip(self.archivos_entrada, path) # TODO try/except
307     comando_ejecutado = entrega.add_comando_ejecutado(self)
308     # Abro archivos para fds básicos (FIXME)
309     options = dict(
310         close_fds=True,
311         stdin=None,
312         shell=True,
313         preexec_fn=SecureProcess(self, 'var/chroot_pepe', '/home/sercom/build')
314     )
315     if self.guardar_stdouterr:
316         options['stdout'] = file('/tmp/sercom.tester.%s.stdouterr'
317             % comando_ejecutado.id, 'w') #TODO /var/lib/sercom?
318         options['stderr'] = sp.STDOUT
319     else:
320         if self.guardar_stdout:
321             options['stdout'] = file('/tmp/sercom.tester.%s.stdout'
322                 % comando_ejecutado.id, 'w') #TODO /var/lib/sercom?
323         if self.guardar_stderr:
324             options['stderr'] = file('/tmp/sercom.tester.%s.stderr'
325                 % comando_ejecutado.id, 'w') #TODO /var/lib/sercom?
326     log.debug(_(u'Ejecutando como root: %s'), self.comando)
327     os.seteuid(0) # Dios! (para chroot)
328     os.setegid(0)
329     try:
330         try:
331             proc = sp.Popen(self.comando, **options)
332         finally:
333             log.debug(_(u'Cambiando usuario y grupo efectivos a %s:%s (%s:%s)'),
334                 user_info.user, user_info.group, user_info.uid, user_info.gid)
335             os.setegid(user_info.gid) # Mortal de nuevo
336             os.seteuid(user_info.uid)
337     except Exception, e:
338         if hasattr(e, 'child_traceback'):
339             log.error(_(u'Error en el hijo: %s'), e.child_traceback)
340         raise
341     proc.wait() #TODO un sleep grande nos caga todo, ver sercom viejo
342     comando_ejecutado.fin = datetime.now()
343     buffer = StringIO()
344     zip = ZipFile(buffer, 'w')
345     if self.guardar_stdouterr:
346         zip.write('/tmp/sercom.tester.%s.stdouterr'
347             % comando_ejecutado.id, '__stdouterr__')
348     else:
349         if self.guardar_stdout:
350             azipwrite('/tmp/sercom.tester.%s.stdout'
351                 % comando_ejecutado.id, '__stdout__')
352         if self.guardar_stderr:
353             zip.write('/tmp/sercom.tester.%s.stderr'
354                 % comando_ejecutado.id, '__stderr__')
355     zip.close()
356     comando_ejecutado.archivos_guardados = buffer.getvalue()
357
358 #    if no_anda_ejecucion: # TODO
359 #        comando_ejecutado.exito = False
360 #        comando_ejecutado.observaciones += 'No anduvo xxx' # TODO mas info
361 #        if self.rechazar_si_falla:
362 #            entrega.exito = False
363 #        if self.terminar_si_falla: # TODO
364 #            raise ExecutionFailure(self)
365     # XXX ESTO EN REALIDAD EN COMANDOS FUENTE NO IRIA
366     # XXX SOLO HABRÍA QUE CAPTURAR stdout/stderr
367     # XXX PODRIA TENER ARCHIVOS DE SALIDA PERO SOLO PARA MOSTRAR COMO RESULTADO
368 #    for archivo in self.archivos_salida:
369 #        pass # TODO hacer diff
370 #    if archivos_mal: # TODO
371 #        comando_ejecutado.exito = False
372 #        comando_ejecutado.observaciones += 'No anduvo xxx' # TODO mas info
373 #        if self.rechazar_si_falla:
374 #            entrega.exito = False
375 #        if self.terminar_si_falla: # TODO
376 #            raise ExecutionFailure(self)
377 #    else:
378 #        comando_ejecutado.exito = True
379 #        comando_ejecutado.observaciones += 'xxx OK' # TODO
380     comando_ejecutado.exito = True
381     comando_ejecutado.observaciones += 'xxx OK' # TODO
382 ComandoFuente.ejecutar = ejecutar_comando_fuente
383 #}}}
384
385 def ejecutar_comando_prueba(self, path, prueba): #{{{
386     log.debug(_(u'ComandoPrueba.ejecutar(path=%s, prueba=%s)'), path,
387         prueba.shortrepr())
388     rmtree(path)
389     os.mkdir(path)
390     unzip(prueba.caso_de_prueba.archivos_entrada, path) # TODO try/except
391     unzip(self.archivos_entrada, path) # TODO try/except
392     comando_ejecutado = prueba.add_comando_ejecutado(self)
393     # TODO ejecutar en chroot (path)
394     comando_ejecutado.fin = datetime.now()
395 #    if no_anda_ejecucion: # TODO
396 #        comando_ejecutado.exito = False
397 #        comando_ejecutado.observaciones += 'No anduvo xxx' # TODO
398 #        if self.rechazar_si_falla:
399 #            entrega.exito = False
400 #        if self.terminar_si_falla: # TODO
401 #            raise ExecutionFailure(self) # TODO info de error
402 #    for archivo in self.archivos_salida:
403 #        pass # TODO hacer diff
404 #    if archivos_mal: # TODO
405 #        comando_ejecutado.exito = False
406 #        comando_ejecutado.observaciones += 'No anduvo xxx' # TODO
407 #        if self.rechazar_si_falla:
408 #            entrega.exito = False
409 #        if self.terminar_si_falla: # TODO
410 #            raise ExecutionFailure(comando=self) # TODO info de error
411 #    else:
412 #        comando_ejecutado.exito = True
413 #        comando_ejecutado.observaciones += 'xxx OK' # TODO
414     comando_ejecutado.exito = True
415     comando_ejecutado.observaciones += 'xxx OK' # TODO
416 ComandoPrueba.ejecutar = ejecutar_comando_prueba
417 #}}}
418