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