]> git.llucax.com Git - software/sercom.git/blob - sercom/tester.py
5915182ad6114acb13e467f628e517c99faf51b2
[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.pasada = 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.pasada = 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(close_fds=True, stdin=None, stdout=None, stderr=None,
302         preexec_fn=SecureProcess(self, 'var/chroot_pepe', '/home/sercom/build'))
303     log.debug(_(u'Ejecutando como root: %s'), ' '.join(self.comando))
304     os.seteuid(0) # Dios! (para chroot)
305     os.setegid(0)
306     try:
307         try:
308             proc = Popen(self.comando, **options)
309         finally:
310             log.debug(_(u'Cambiando usuario y grupo efectivos a %s:%s (%s:%s)'),
311                 user_info.user, user_info.group, user_info.uid, user_info.gid)
312             os.setegid(user_info.gid) # Mortal de nuevo
313             os.seteuid(user_info.uid)
314     except Exception, e: # FIXME poner en el manejo de exceptiones estandar
315         if hasattr(e, 'child_traceback'):
316             log.error(_(u'Error en el hijo: %s'), e.child_traceback)
317         raise
318     proc.wait()
319     comando_ejecutado.fin = datetime.now()
320 #    if no_anda_ejecucion: # TODO
321 #        comando_ejecutado.exito = False
322 #        comando_ejecutado.observaciones += 'No anduvo xxx' # TODO mas info
323 #        if self.rechazar_si_falla:
324 #            entrega.exito = False
325 #        if self.terminar_si_falla: # TODO
326 #            raise ExecutionFailure(self)
327     # XXX ESTO EN REALIDAD EN COMANDOS FUENTE NO IRIA
328     # XXX SOLO HABRÍA QUE CAPTURAR stdout/stderr
329     # XXX PODRIA TENER ARCHIVOS DE SALIDA PERO SOLO PARA MOSTRAR COMO RESULTADO
330 #    for archivo in self.archivos_salida:
331 #        pass # TODO hacer diff
332 #    if archivos_mal: # TODO
333 #        comando_ejecutado.exito = False
334 #        comando_ejecutado.observaciones += 'No anduvo xxx' # TODO mas info
335 #        if self.rechazar_si_falla:
336 #            entrega.exito = False
337 #        if self.terminar_si_falla: # TODO
338 #            raise ExecutionFailure(self)
339 #    else:
340 #        comando_ejecutado.exito = True
341 #        comando_ejecutado.observaciones += 'xxx OK' # TODO
342     comando_ejecutado.exito = True
343     comando_ejecutado.observaciones += 'xxx OK' # TODO
344 ComandoFuente.ejecutar = ejecutar_comando_fuente
345 #}}}
346
347 def ejecutar_comando_prueba(self, path, prueba): #{{{
348     log.debug(_(u'ComandoPrueba.ejecutar(path=%s, prueba=%s)'), path,
349         prueba.shortrepr())
350     rmtree(path)
351     os.mkdir(path)
352     unzip(prueba.caso_de_prueba.archivos_entrada, path) # TODO try/except
353     unzip(self.archivos_entrada, path) # TODO try/except
354     comando_ejecutado = prueba.add_comando_ejecutado(self)
355     # TODO ejecutar en chroot (path)
356     comando_ejecutado.fin = datetime.now()
357 #    if no_anda_ejecucion: # TODO
358 #        comando_ejecutado.exito = False
359 #        comando_ejecutado.observaciones += 'No anduvo xxx' # TODO
360 #        if self.rechazar_si_falla:
361 #            entrega.exito = False
362 #        if self.terminar_si_falla: # TODO
363 #            raise ExecutionFailure(self) # TODO info de error
364 #    for archivo in self.archivos_salida:
365 #        pass # TODO hacer diff
366 #    if archivos_mal: # TODO
367 #        comando_ejecutado.exito = False
368 #        comando_ejecutado.observaciones += 'No anduvo xxx' # TODO
369 #        if self.rechazar_si_falla:
370 #            entrega.exito = False
371 #        if self.terminar_si_falla: # TODO
372 #            raise ExecutionFailure(comando=self) # TODO info de error
373 #    else:
374 #        comando_ejecutado.exito = True
375 #        comando_ejecutado.observaciones += 'xxx OK' # TODO
376     comando_ejecutado.exito = True
377     comando_ejecutado.observaciones += 'xxx OK' # TODO
378 ComandoPrueba.ejecutar = ejecutar_comando_prueba
379 #}}}
380