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