]> git.llucax.com Git - software/sercom.git/blob - sercom/tester.py
35bf2e2ee8a25c1c446162105459293129d76d78
[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, dst): # {{{
82     log.debug(_(u'Intentando descomprimir en %s'), dst)
83     if bytes is None:
84         return
85     zfile = ZipFile(StringIO(bytes), 'r')
86     for f in zfile.namelist():
87         if f.endswith(os.sep):
88             log.debug(_(u'Creando directorio %s'), f)
89             os.mkdir(join(dst, f))
90         else:
91             log.debug(_(u'Descomprimiendo archivo %s'), f)
92             file(join(dst, f), 'w').write(zfile.read(f))
93 #}}}
94
95 class SecureProcess(object): #{{{
96     default = dict(
97         max_tiempo_cpu      = 120,
98         max_memoria         = 16,
99         max_tam_archivo     = 5,
100         max_cant_archivos   = 5,
101         max_cant_procesos   = 0,
102         max_locks_memoria   = 0,
103     )
104     uid = config.get('sercom.tester.chroot.user', 65534)
105     MB = 1048576
106     # XXX probar! make de un solo archivo lleva nproc=100 y nofile=15
107     def __init__(self, comando, chroot, cwd):
108         self.comando = comando
109         self.chroot = chroot
110         self.cwd = cwd
111         log.debug('Proceso segurizado: chroot=%s, cwd=%s, user=%s, cpu=%s, '
112             'as=%sMiB, fsize=%sMiB, nofile=%s, nproc=%s, memlock=%s',
113             self.chroot, self.cwd, self.uid, self.max_tiempo_cpu,
114             self.max_memoria, self.max_tam_archivo, self.max_cant_archivos,
115             self.max_cant_procesos, self.max_locks_memoria)
116     def __getattr__(self, name):
117         if getattr(self.comando, name) is not None:
118             return getattr(self.comando, name)
119         return config.get('sercom.tester.limits.' + name, self.default[name])
120     def __call__(self):
121         x2 = lambda x: (x, x)
122         os.chroot(self.chroot)
123         os.chdir(self.cwd)
124         uinfo = UserInfo(self.uid)
125         os.setgid(uinfo.gid)
126         os.setuid(uinfo.uid) # Somos mortales irreversiblemente
127         rsrc.setrlimit(rsrc.RLIMIT_CPU, x2(self.max_tiempo_cpu))
128         rsrc.setrlimit(rsrc.RLIMIT_AS, x2(self.max_memoria*self.MB))
129         rsrc.setrlimit(rsrc.RLIMIT_FSIZE, x2(self.max_tam_archivo*self.MB)) # XXX calcular en base a archivos esperados?
130         rsrc.setrlimit(rsrc.RLIMIT_NOFILE, x2(self.max_cant_archivos)) #XXX Obtener de archivos esperados?
131         rsrc.setrlimit(rsrc.RLIMIT_NPROC, x2(self.max_cant_procesos))
132         rsrc.setrlimit(rsrc.RLIMIT_MEMLOCK, x2(self.max_locks_memoria))
133         rsrc.setrlimit(rsrc.RLIMIT_CORE, x2(0))
134         # Tratamos de forzar un sync para que entre al sleep del padre FIXME
135         import time
136         time.sleep(0)
137 #}}}
138
139 class Tester(object): #{{{
140
141     def __init__(self, name, path, home, queue): #{{{ y properties
142         self.name = name
143         self.path = path
144         self.home = home
145         self.queue = queue
146         # Ahora somos mortales (oid mortales)
147         log.debug(_(u'Cambiando usuario y grupo efectivos a %s:%s (%s:%s)'),
148             user_info.user, user_info.group, user_info.uid, user_info.gid)
149         os.setegid(user_info.gid)
150         os.seteuid(user_info.uid)
151
152     @property
153     def build_path(self):
154         return join(self.chroot, self.home, 'build')
155
156     @property
157     def test_path(self):
158         return join(self.chroot, self.home, 'test')
159
160     @property
161     def chroot(self):
162         return join(self.path, 'chroot_' + self.name)
163     #}}}
164
165     @property
166     def orig_chroot(self):
167         return join(self.path, 'chroot')
168
169     def run(self): #{{{
170         entrega_id = self.queue.get() # blocking
171         while entrega_id is not None:
172             entrega = Entrega.get(entrega_id)
173             log.debug(_(u'Nueva entrega para probar en tester %s: %s'),
174                 self.name, entrega)
175             self.test(entrega)
176             log.debug(_(u'Fin de pruebas de: %s'), entrega)
177             entrega_id = self.queue.get() # blocking
178     #}}}
179
180     def test(self, entrega): #{{{
181         log.debug(_(u'Tester.test(entrega=%s)'), entrega)
182         entrega.inicio_tareas = datetime.now()
183         try:
184             try:
185                 self.setup_chroot(entrega)
186                 self.ejecutar_tareas_fuente(entrega)
187                 self.ejecutar_tareas_prueba(entrega)
188                 self.clean_chroot(entrega)
189             except ExecutionFailure, e:
190                 entrega.correcta = False
191                 log.info(_(u'Entrega incorrecta: %s'), entrega)
192             except Exception, e:
193                 if isinstance(e, SystemExit): raise
194                 entrega.observaciones += error_interno
195                 log.exception(_(u'Hubo una excepción inesperada: %s'), e)
196             except:
197                 entrega.observaciones += error_interno
198                 log.exception(_(u'Hubo una excepción inesperada desconocida'))
199             else:
200                 entrega.correcta = True
201                 log.debug(_(u'Entrega correcta: %s'), entrega)
202         finally:
203             entrega.fin_tareas = datetime.now()
204     #}}}
205
206     def setup_chroot(self, entrega): #{{{ y clean_chroot()
207         log.debug(_(u'Tester.setup_chroot(entrega=%s)'), entrega.shortrepr())
208         rsync = ('rsync', '--stats', '--itemize-changes', '--human-readable',
209             '--archive', '--acls', '--delete-during', '--force', # TODO config
210             join(self.orig_chroot, ''), self.chroot)
211         log.debug(_(u'Ejecutando como root: %s'), ' '.join(rsync))
212         os.seteuid(0) # Dios! (para chroot)
213         os.setegid(0)
214         try:
215             sp.check_call(rsync)
216         finally:
217             log.debug(_(u'Cambiando usuario y grupo efectivos a %s:%s (%s:%s)'),
218                 user_info.user, user_info.group, user_info.uid, user_info.gid)
219             os.setegid(user_info.gid) # Mortal de nuevo
220             os.seteuid(user_info.uid)
221         unzip(entrega.archivos, self.build_path)
222
223     def clean_chroot(self, entrega):
224         log.debug(_(u'Tester.clean_chroot(entrega=%s)'), entrega.shortrepr())
225         pass # Se limpia con el próximo rsync
226     #}}}
227
228     def ejecutar_tareas_fuente(self, entrega): #{{{ y tareas_prueba
229         log.debug(_(u'Tester.ejecutar_tareas_fuente(entrega=%s)'),
230             entrega.shortrepr())
231         tareas = [t for t in entrega.instancia.ejercicio.enunciado.tareas
232                     if isinstance(t, TareaFuente)]
233         for tarea in tareas:
234             tarea.ejecutar(self.build_path, entrega)
235
236     def ejecutar_tareas_prueba(self, entrega):
237         log.debug(_(u'Tester.ejecutar_tareas_prueba(entrega=%s)'),
238             entrega.shortrepr())
239         for caso in entrega.instancia.ejercicio.enunciado.casos_de_prueba:
240             caso.ejecutar(self.test_path, entrega)
241     #}}}
242
243 #}}}
244
245 def ejecutar_caso_de_prueba(self, path, entrega): #{{{
246     log.debug(_(u'CasoDePrueba.ejecutar(path=%s, entrega=%s)'), path,
247         entrega.shortrepr())
248     tareas = [t for t in entrega.instancia.ejercicio.enunciado.tareas
249                 if isinstance(t, TareaPrueba)]
250     prueba = entrega.add_prueba(self)
251     try:
252         try:
253             for tarea in tareas:
254                 tarea.ejecutar(path, prueba)
255         except ExecutionFailure, e:
256             prueba.exito = False
257             if self.rechazar_si_falla:
258                 entrega.exito = False
259             if self.terminar_si_falla:
260                 raise ExecutionError(e.comando, e.tarea, prueba)
261         else:
262             prueba.exito = True
263     finally:
264         prueba.fin = datetime.now()
265 CasoDePrueba.ejecutar = ejecutar_caso_de_prueba
266 #}}}
267
268 def ejecutar_tarea_fuente(self, path, entrega): #{{{
269     log.debug(_(u'TareaFuente.ejecutar(path=%s, entrega=%s)'), path,
270         entrega.shortrepr())
271     try:
272         for cmd in self.comandos:
273             cmd.ejecutar(path, entrega)
274     except ExecutionFailure, e:
275         if self.rechazar_si_falla:
276             entrega.exito = False
277         if self.terminar_si_falla:
278             raise ExecutionError(e.comando, tarea)
279 TareaFuente.ejecutar = ejecutar_tarea_fuente
280 #}}}
281
282 def ejecutar_tarea_prueba(self, path, prueba): #{{{
283     log.debug(_(u'TareaPrueba.ejecutar(path=%s, prueba=%s)'), path,
284         prueba.shortrepr())
285     try:
286         for cmd in self.comandos:
287             cmd.ejecutar(path, prueba)
288     except ExecutionFailure, e:
289         if self.rechazar_si_falla:
290             prueba.exito = False
291         if self.terminar_si_falla:
292             raise ExecutionError(e.comando, tarea)
293 TareaPrueba.ejecutar = ejecutar_tarea_prueba
294 #}}}
295
296 def ejecutar_comando_fuente(self, path, entrega): #{{{
297     log.debug(_(u'ComandoFuente.ejecutar(path=%s, entrega=%s)'), path,
298         entrega.shortrepr())
299     unzip(self.archivos_entrada, path) # TODO try/except
300     comando_ejecutado = entrega.add_comando_ejecutado(self)
301     # Abro archivos para fds básicos (FIXME)
302     options = dict(
303         close_fds=True,
304         stdin=None,
305         stdout=None,
306         stderr=None,
307         shell=True,
308         preexec_fn=SecureProcess(self, 'var/chroot_pepe', '/home/sercom/build')
309     )
310     log.debug(_(u'Ejecutando como root: %s'), self.comando)
311     os.seteuid(0) # Dios! (para chroot)
312     os.setegid(0)
313     try:
314         try:
315             proc = sp.Popen(self.comando, **options)
316         finally:
317             log.debug(_(u'Cambiando usuario y grupo efectivos a %s:%s (%s:%s)'),
318                 user_info.user, user_info.group, user_info.uid, user_info.gid)
319             os.setegid(user_info.gid) # Mortal de nuevo
320             os.seteuid(user_info.uid)
321     except Exception, e: # FIXME poner en el manejo de exceptiones estandar
322         if hasattr(e, 'child_traceback'):
323             log.error(_(u'Error en el hijo: %s'), e.child_traceback)
324         raise
325     proc.wait()
326     comando_ejecutado.fin = datetime.now()
327 #    if no_anda_ejecucion: # TODO
328 #        comando_ejecutado.exito = False
329 #        comando_ejecutado.observaciones += 'No anduvo xxx' # TODO mas info
330 #        if self.rechazar_si_falla:
331 #            entrega.exito = False
332 #        if self.terminar_si_falla: # TODO
333 #            raise ExecutionFailure(self)
334     # XXX ESTO EN REALIDAD EN COMANDOS FUENTE NO IRIA
335     # XXX SOLO HABRÍA QUE CAPTURAR stdout/stderr
336     # XXX PODRIA TENER ARCHIVOS DE SALIDA PERO SOLO PARA MOSTRAR COMO RESULTADO
337 #    for archivo in self.archivos_salida:
338 #        pass # TODO hacer diff
339 #    if archivos_mal: # TODO
340 #        comando_ejecutado.exito = False
341 #        comando_ejecutado.observaciones += 'No anduvo xxx' # TODO mas info
342 #        if self.rechazar_si_falla:
343 #            entrega.exito = False
344 #        if self.terminar_si_falla: # TODO
345 #            raise ExecutionFailure(self)
346 #    else:
347 #        comando_ejecutado.exito = True
348 #        comando_ejecutado.observaciones += 'xxx OK' # TODO
349     comando_ejecutado.exito = True
350     comando_ejecutado.observaciones += 'xxx OK' # TODO
351 ComandoFuente.ejecutar = ejecutar_comando_fuente
352 #}}}
353
354 def ejecutar_comando_prueba(self, path, prueba): #{{{
355     log.debug(_(u'ComandoPrueba.ejecutar(path=%s, prueba=%s)'), path,
356         prueba.shortrepr())
357     rmtree(path)
358     os.mkdir(path)
359     unzip(prueba.caso_de_prueba.archivos_entrada, path) # TODO try/except
360     unzip(self.archivos_entrada, path) # TODO try/except
361     comando_ejecutado = prueba.add_comando_ejecutado(self)
362     # TODO ejecutar en chroot (path)
363     comando_ejecutado.fin = datetime.now()
364 #    if no_anda_ejecucion: # TODO
365 #        comando_ejecutado.exito = False
366 #        comando_ejecutado.observaciones += 'No anduvo xxx' # TODO
367 #        if self.rechazar_si_falla:
368 #            entrega.exito = False
369 #        if self.terminar_si_falla: # TODO
370 #            raise ExecutionFailure(self) # TODO info de error
371 #    for archivo in self.archivos_salida:
372 #        pass # TODO hacer diff
373 #    if archivos_mal: # TODO
374 #        comando_ejecutado.exito = False
375 #        comando_ejecutado.observaciones += 'No anduvo xxx' # TODO
376 #        if self.rechazar_si_falla:
377 #            entrega.exito = False
378 #        if self.terminar_si_falla: # TODO
379 #            raise ExecutionFailure(comando=self) # TODO info de error
380 #    else:
381 #        comando_ejecutado.exito = True
382 #        comando_ejecutado.observaciones += 'xxx OK' # TODO
383     comando_ejecutado.exito = True
384     comando_ejecutado.observaciones += 'xxx OK' # TODO
385 ComandoPrueba.ejecutar = ejecutar_comando_prueba
386 #}}}
387