]> git.llucax.com Git - software/sercom.git/blob - sercom/tester.py
Especificar con más detalle TODO del backend.
[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 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): #{{{ 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 = call(*popenargs, **kwargs)
49     cmd = kwargs.get("args")
50     if cmd is None:
51         cmd = popenargs[0]
52     if retcode:
53         raise CalledProcessError(retcode, cmd)
54     return retcode
55 #}}}
56
57 #{{{ Excepciones
58
59 class CalledProcessError(Exception): #{{{ Python 2.5 forward-compatibility
60     """This exception is raised when a process run by check_call() returns
61     a non-zero exit status.  The exit status will be stored in the
62     returncode attribute."""
63     def __init__(self, returncode, cmd):
64         self.returncode = returncode
65         self.cmd = cmd
66     def __str__(self):
67         return ("Command '%s' returned non-zero exit status %d"
68             % (self.cmd, self.returncode))
69 #}}}
70
71 class Error(StandardError): pass
72
73 class ExecutionFailure(Error, RuntimeError): pass
74
75 class RsyncError(Error, EnvironmentError): pass
76
77 #}}}
78
79 def unzip(bytes, dst): # {{{
80     log.debug(_(u'Intentando descomprimir en %s'), dst)
81     if bytes is None:
82         return
83     zfile = ZipFile(StringIO(bytes), 'r')
84     for f in zfile.namelist():
85         if f.endswith(os.sep):
86             log.debug(_(u'Creando directorio %s'), f)
87             os.mkdir(join(dst, f))
88         else:
89             log.debug(_(u'Descomprimiendo archivo %s'), f)
90             file(join(dst, f), 'w').write(zfile.read(f))
91 #}}}
92
93 class SecureProcess(object): #{{{
94     default = dict(
95         max_tiempo_cpu      = 120,
96         max_memoria         = 16,
97         max_tam_archivo     = 5,
98         max_cant_archivos   = 5,
99         max_cant_procesos   = 0,
100         max_locks_memoria   = 0,
101     )
102     uid = config.get('sercom.tester.chroot.user', 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         uinfo = UserInfo(self.uid)
118         os.setgid(uinfo.gid)
119         os.setuid(uinfo.uid) # Somos mortales irreversiblemente
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, uinfo.user, uinfo.uid,
130             uinfo.group, uinfo.gid, self.max_tiempo_cpu, self.max_memoria,
131             self.max_tam_archivo, self.max_cant_archivos,
132             self.max_cant_procesos, 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         log.debug(_(u'Cambiando usuario y grupo efectivos a %s:%s (%s:%s)'),
147             user_info.user, user_info.group, user_info.uid, user_info.gid)
148         os.setegid(user_info.gid)
149         os.seteuid(user_info.uid)
150
151     @property
152     def build_path(self):
153         return join(self.chroot, self.home, 'build')
154
155     @property
156     def test_path(self):
157         return join(self.chroot, self.home, 'test')
158
159     @property
160     def chroot(self):
161         return join(self.path, 'chroot_' + self.name)
162     #}}}
163
164     @property
165     def orig_chroot(self):
166         return join(self.path, 'chroot')
167
168     def run(self): #{{{
169         entrega_id = self.queue.get() # blocking
170         while entrega_id is not None:
171             entrega = Entrega.get(entrega_id)
172             log.debug(_(u'Nueva entrega para probar en tester %s: %s'),
173                 self.name, entrega)
174             self.test(entrega)
175             log.debug(_(u'Fin de pruebas de: %s'), entrega)
176             entrega_id = self.queue.get() # blocking
177     #}}}
178
179     def test(self, entrega): #{{{
180         log.debug(_(u'Tester.test(entrega=%s)'), entrega)
181         entrega.inicio_tareas = datetime.now()
182         try:
183             try:
184                 self.setup_chroot(entrega)
185                 self.ejecutar_tareas_fuente(entrega)
186                 self.ejecutar_tareas_prueba(entrega)
187                 self.clean_chroot(entrega)
188             except ExecutionFailure, e:
189                 entrega.correcta = False
190                 log.info(_(u'Entrega incorrecta: %s'), entrega)
191             except Exception, e:
192                 if isinstance(e, SystemExit): raise
193                 entrega.observaciones += error_interno
194                 log.exception(_(u'Hubo una excepción inesperada: %s'), e)
195             except:
196                 entrega.observaciones += error_interno
197                 log.exception(_(u'Hubo una excepción inesperada desconocida'))
198             else:
199                 entrega.correcta = True
200                 log.debug(_(u'Entrega correcta: %s'), entrega)
201         finally:
202             entrega.fin_tareas = datetime.now()
203     #}}}
204
205     def setup_chroot(self, entrega): #{{{ y clean_chroot()
206         log.debug(_(u'Tester.setup_chroot(entrega=%s)'), entrega.shortrepr())
207         rsync = ('rsync', '--stats', '--itemize-changes', '--human-readable',
208             '--archive', '--acls', '--delete-during', '--force', # TODO config
209             join(self.orig_chroot, ''), self.chroot)
210         log.debug(_(u'Ejecutando como root: %s'), ' '.join(rsync))
211         os.seteuid(0) # Dios! (para chroot)
212         os.setegid(0)
213         try:
214             check_call(rsync)
215         finally:
216             log.debug(_(u'Cambiando usuario y grupo efectivos a %s:%s (%s:%s)'),
217                 user_info.user, user_info.group, user_info.uid, user_info.gid)
218             os.setegid(user_info.gid) # Mortal de nuevo
219             os.seteuid(user_info.uid)
220         unzip(entrega.archivos, self.build_path)
221
222     def clean_chroot(self, entrega):
223         log.debug(_(u'Tester.clean_chroot(entrega=%s)'), entrega.shortrepr())
224         pass # Se limpia con el próximo rsync
225     #}}}
226
227     def ejecutar_tareas_fuente(self, entrega): #{{{ y tareas_prueba
228         log.debug(_(u'Tester.ejecutar_tareas_fuente(entrega=%s)'),
229             entrega.shortrepr())
230         tareas = [t for t in entrega.instancia.ejercicio.enunciado.tareas
231                     if isinstance(t, TareaFuente)]
232         for tarea in tareas:
233             tarea.ejecutar(self.build_path, entrega)
234
235     def ejecutar_tareas_prueba(self, entrega):
236         log.debug(_(u'Tester.ejecutar_tareas_prueba(entrega=%s)'),
237             entrega.shortrepr())
238         for caso in entrega.instancia.ejercicio.enunciado.casos_de_prueba:
239             caso.ejecutar(self.test_path, entrega)
240     #}}}
241
242 #}}}
243
244 def ejecutar_caso_de_prueba(self, path, entrega): #{{{
245     log.debug(_(u'CasoDePrueba.ejecutar(path=%s, entrega=%s)'), path,
246         entrega.shortrepr())
247     tareas = [t for t in entrega.instancia.ejercicio.enunciado.tareas
248                 if isinstance(t, TareaPrueba)]
249     prueba = entrega.add_prueba(self)
250     try:
251         try:
252             for tarea in tareas:
253                 tarea.ejecutar(path, prueba)
254         except ExecutionFailure, e:
255             prueba.exito = False
256             if self.rechazar_si_falla:
257                 entrega.exito = False
258             if self.terminar_si_falla:
259                 raise ExecutionError(e.comando, e.tarea, prueba)
260         else:
261             prueba.exito = True
262     finally:
263         prueba.fin = datetime.now()
264 CasoDePrueba.ejecutar = ejecutar_caso_de_prueba
265 #}}}
266
267 def ejecutar_tarea_fuente(self, path, entrega): #{{{
268     log.debug(_(u'TareaFuente.ejecutar(path=%s, entrega=%s)'), path,
269         entrega.shortrepr())
270     try:
271         for cmd in self.comandos:
272             cmd.ejecutar(path, entrega)
273     except ExecutionFailure, e:
274         if self.rechazar_si_falla:
275             entrega.exito = False
276         if self.terminar_si_falla:
277             raise ExecutionError(e.comando, tarea)
278 TareaFuente.ejecutar = ejecutar_tarea_fuente
279 #}}}
280
281 def ejecutar_tarea_prueba(self, path, prueba): #{{{
282     log.debug(_(u'TareaPrueba.ejecutar(path=%s, prueba=%s)'), path,
283         prueba.shortrepr())
284     try:
285         for cmd in self.comandos:
286             cmd.ejecutar(path, prueba)
287     except ExecutionFailure, e:
288         if self.rechazar_si_falla:
289             prueba.exito = False
290         if self.terminar_si_falla:
291             raise ExecutionError(e.comando, tarea)
292 TareaPrueba.ejecutar = ejecutar_tarea_prueba
293 #}}}
294
295 def ejecutar_comando_fuente(self, path, entrega): #{{{
296     log.debug(_(u'ComandoFuente.ejecutar(path=%s, entrega=%s)'), path,
297         entrega.shortrepr())
298     unzip(self.archivos_entrada, path) # TODO try/except
299     comando_ejecutado = entrega.add_comando_ejecutado(self)
300     # Abro archivos para fds básicos (FIXME)
301     options = dict(
302         close_fds=True,
303         stdin=None,
304         stdout=None,
305         stderr=None,
306         shell=True,
307         preexec_fn=SecureProcess(self, 'var/chroot_pepe', '/home/sercom/build')
308     )
309     log.debug(_(u'Ejecutando como root: %s'), self.comando)
310     os.seteuid(0) # Dios! (para chroot)
311     os.setegid(0)
312     try:
313         try:
314             proc = Popen(self.comando, **options)
315         finally:
316             log.debug(_(u'Cambiando usuario y grupo efectivos a %s:%s (%s:%s)'),
317                 user_info.user, user_info.group, user_info.uid, user_info.gid)
318             os.setegid(user_info.gid) # Mortal de nuevo
319             os.seteuid(user_info.uid)
320     except Exception, e: # FIXME poner en el manejo de exceptiones estandar
321         if hasattr(e, 'child_traceback'):
322             log.error(_(u'Error en el hijo: %s'), e.child_traceback)
323         raise
324     proc.wait()
325     comando_ejecutado.fin = datetime.now()
326 #    if no_anda_ejecucion: # TODO
327 #        comando_ejecutado.exito = False
328 #        comando_ejecutado.observaciones += 'No anduvo xxx' # TODO mas info
329 #        if self.rechazar_si_falla:
330 #            entrega.exito = False
331 #        if self.terminar_si_falla: # TODO
332 #            raise ExecutionFailure(self)
333     # XXX ESTO EN REALIDAD EN COMANDOS FUENTE NO IRIA
334     # XXX SOLO HABRÍA QUE CAPTURAR stdout/stderr
335     # XXX PODRIA TENER ARCHIVOS DE SALIDA PERO SOLO PARA MOSTRAR COMO RESULTADO
336 #    for archivo in self.archivos_salida:
337 #        pass # TODO hacer diff
338 #    if archivos_mal: # TODO
339 #        comando_ejecutado.exito = False
340 #        comando_ejecutado.observaciones += 'No anduvo xxx' # TODO mas info
341 #        if self.rechazar_si_falla:
342 #            entrega.exito = False
343 #        if self.terminar_si_falla: # TODO
344 #            raise ExecutionFailure(self)
345 #    else:
346 #        comando_ejecutado.exito = True
347 #        comando_ejecutado.observaciones += 'xxx OK' # TODO
348     comando_ejecutado.exito = True
349     comando_ejecutado.observaciones += 'xxx OK' # TODO
350 ComandoFuente.ejecutar = ejecutar_comando_fuente
351 #}}}
352
353 def ejecutar_comando_prueba(self, path, prueba): #{{{
354     log.debug(_(u'ComandoPrueba.ejecutar(path=%s, prueba=%s)'), path,
355         prueba.shortrepr())
356     rmtree(path)
357     os.mkdir(path)
358     unzip(prueba.caso_de_prueba.archivos_entrada, path) # TODO try/except
359     unzip(self.archivos_entrada, path) # TODO try/except
360     comando_ejecutado = prueba.add_comando_ejecutado(self)
361     # TODO ejecutar en chroot (path)
362     comando_ejecutado.fin = datetime.now()
363 #    if no_anda_ejecucion: # TODO
364 #        comando_ejecutado.exito = False
365 #        comando_ejecutado.observaciones += 'No anduvo xxx' # TODO
366 #        if self.rechazar_si_falla:
367 #            entrega.exito = False
368 #        if self.terminar_si_falla: # TODO
369 #            raise ExecutionFailure(self) # TODO info de error
370 #    for archivo in self.archivos_salida:
371 #        pass # TODO hacer diff
372 #    if archivos_mal: # TODO
373 #        comando_ejecutado.exito = False
374 #        comando_ejecutado.observaciones += 'No anduvo xxx' # TODO
375 #        if self.rechazar_si_falla:
376 #            entrega.exito = False
377 #        if self.terminar_si_falla: # TODO
378 #            raise ExecutionFailure(comando=self) # TODO info de error
379 #    else:
380 #        comando_ejecutado.exito = True
381 #        comando_ejecutado.observaciones += 'xxx OK' # TODO
382     comando_ejecutado.exito = True
383     comando_ejecutado.observaciones += 'xxx OK' # TODO
384 ComandoPrueba.ejecutar = ejecutar_comando_prueba
385 #}}}
386