X-Git-Url: https://git.llucax.com/software/sercom-old.git/blobdiff_plain/5174fa9bd71e4f671d9788707e106296638a3c63..8c3359a287dd86c03b874aef2bbfcf2e95c4ad4b:/src/sc_test diff --git a/src/sc_test b/src/sc_test index 24d2369..25a4b98 100755 --- a/src/sc_test +++ b/src/sc_test @@ -1,298 +1,421 @@ -#!/usr/bin/php -proximo_a_probar())) { - logs('No hay intento para probar', DEBUG); - sleep($gconf['intervalo']); - continue; - } - logs('Nuevo intento a probar (' . $intento->to_line() . ')'); +def preparar(intento): + # Creo chroot - TODO copiarlo de algún lado donde ande el valgrind? + os.mkdir(intento.chrootPath) + shutil.copy(os.path.join(intento.path, 'tp'), + os.path.join(intento.chrootPath, 'tp')) - $intento_dir = "{$gconf['data_dir']}/" . $intento->path(); - $entrega_dir = "{$gconf['data_dir']}/" . $intento->base_path('entregas'); +def limpiar(intento): + # Borro chroot entero + shutil.rmtree(intento.chrootPath) - if (is_readable("$entrega_dir/Makefile")) { - $makefile = "$entrega_dir/Makefile"; -//XXX @copy("$entrega_dir/Makefile", "$intento_dir/Makefile") -// or logserr("Error al copiar Makefile (de '$entrega_dir/Makefile' a '$intento_dir/Makefile'"); - // Fallback - } elseif (is_readable("{$gconf['data_dir']}/Makefile")) { - $makefile = "{$gconf['data_dir']}/Makefile"; -//XXX if (!@copy("{$gconf['data_dir']}/Makefile", "$intento_dir/Makefile")) { - } else { - enviar_respuesta_error_log($mail, 'No hay un Makefile disponible', $intento); - continue; +def probar(intento, caso_de_prueba, mail): + def diff(prueba, mail, orig, new, name, origname='alumno', newname='catedra', longname=None): + from difflib import unified_diff, HtmlDiff + #TODO diferenciar pruebas públicas de privadas + if longname is None: longname = name + diff = ''.join(list(unified_diff(orig, new, fromfile=name+'.'+origname, tofile=name+'.'+newname))) + nota = '' + if diff: + prueba.pasada = False + nota = "- %s no coincide con lo esperado (archivo '%s.diff').\n" % (longname, name) + if not prueba.casoDePrueba.privado: + mail.attachText(diff, name + '.diff') + diff = HtmlDiff().make_file(orig, new, fromdesc=name+'.'+origname, todesc=name+'.'+newname, context=True, numlines=3) + mail.attachText(diff, name + '.diff.html', 'html') + return nota + # Cosas útiles + global log, conn, conf, uid, gid + # Para manejo de SIGCHLD + global hijo_muerto + hijo_muerto = False + # Obtengo datos útiles del caso de prueba + tiempo_cpu = caso_de_prueba.tiempoCpu + if tiempo_cpu is None: + tiempo_cpu = conf.get('general', 'tiempo_cpu') + tiempo_cpu = int(tiempo_cpu) + # Creo prueba nueva + prueba = Prueba(intento=intento, casoDePrueba=caso_de_prueba, + inicio=datetime.datetime.now(), connection=conn) + log.debug('Iniciando prueba: %s', prueba) + log.debug('Caso de prueba: %s', prueba.casoDePrueba) + # Abro archivos para fds básicos + options = { + 'cwd': intento.chrootPath, + 'close_fds': True, + 'preexec_fn': secure_process(intento.chrootPath, uid, gid, tiempo_cpu), } + if os.path.exists(os.path.join(caso_de_prueba.path, 'stdin')): + options['stdin'] = file(os.path.join(caso_de_prueba.path, 'stdin'), 'r') + if os.path.exists(os.path.join(caso_de_prueba.path, 'stdout')): + options['stdout'] = subprocess.PIPE + if os.path.exists(os.path.join(caso_de_prueba.path, 'stderr')): + options['stderr'] = subprocess.PIPE + # Ejecuto programa + params = ['/tp'] + if caso_de_prueba.parametros: + params += params2seq(caso_de_prueba.parametros) + log.debug('Ejecutando %s', ' '.join(params)) + os.seteuid(0) # Dios! (para chroot) + try: + proc = subprocess.Popen(params, **options) + except Exception, e: # FIXME poner en el manejo de exceptiones estandar + try: + print e.child_traceback + raise + except: + raise + os.seteuid(uid) # Mortal de nuevo + if not hijo_muerto: # Recibido por el sigchld, para saber si murió + time.sleep(tiempo_cpu) # Controlo que no tarde mucho + # Si el proceso sigue andando lo tenemos que matar + if not hijo_muerto: + log.debug('La prueba tardó más del tiempo permitido (%d segundos)', + tiempo_cpu) + os.seteuid(0) # Dios! (corre como Dios, hay que matarlo como Dios) + os.kill(proc.pid, signal.SIGKILL) + os.seteuid(uid) # Mortal de nuevo + log.debug('Prueba cancelada (kill)') + proc.wait() + prueba.observaciones = 'Excedió el límite de tiempo de ejecución ' \ + '(%d seg)' % tiempo_cpu + prueba.fin = datetime.datetime.now() + prueba.pasada = False + mail.agregarResultado(prueba) + return prueba + proc.wait() # Para que no queden zombies + prueba.fin = datetime.datetime.now() + # Salió con una señal? + if proc.returncode < 0: + sig = -proc.returncode + log.debug('El programa salió con la señal %d', sig) + prueba.pasada = False + #TODO otras señales conocidas + if sig == signal.SIGXCPU: + prueba.observaciones = 'Excedió el límite de tiempo de CPU ' \ + '(%d seg)' % tiempo_cpu + else: + prueba.observaciones = 'Salió con la señal %d' % sig + mail.agregarResultado(prueba) + return prueba + # Si tenemos que verificar el código de retorno + if caso_de_prueba.codigoRetorno is not None: + #FIXME trucho lo de 256 + # Si el código de error esperado es 256 => el código de error debe ser != 0 + # Si no el código de error esperado debe ser igual al obtenido + if caso_de_prueba.codigoRetorno != proc.returncode \ + or caso_de_prueba.codigoRetorno == 256 \ + and proc.returncode != 0: + log.debug('Código de retorno incorrecto (debía ser %d y se obtuvo %d)', + caso_de_prueba.codigoRetorno, proc.returncode) + prueba.pasada = False + prueba.observaciones = 'Código de retorno incorrecto (debía ' \ + 'ser %d y se obtuvo %d)' % (caso_de_prueba.codigoRetorno, + proc.returncode) + mail.agregarResultado(prueba) + return prueba + # Verifico salidas estándar/de error + prueba.pasada = True # Asumo que está bien, ya habrá tiempo para cambiarlo + obs = diff(prueba, mail, proc.stdout.readlines(), + file(os.path.join(caso_de_prueba.path, 'stdout')).readlines(), + caso_de_prueba.nombre + '.stdout', longname='La salida estándar') + obs += diff(prueba, mail, proc.stderr.readlines(), + file(os.path.join(caso_de_prueba.path, 'stderr')).readlines(), + caso_de_prueba.nombre + '.stderr', longname='La salida de error') + for f in caso_de_prueba.archivosSalida: + if f not in prueba.archivosSalida: + #TODO agregar error FALTA ARCHIVO + log.debug('Falta el archivo %s y no lo hizo.', f) + prueba.pasada = False + obs += "- Falta el archivo de salida '%s'.\n" % f + continue + obs += diff(prueba, mail, + file(os.path.join(intento.chrootPath, f)).readlines(), + file(os.path.join(caso_de_prueba.pathSalidas, f)).readlines(), + caso_de_prueba.nombre + '.' + f) + for f in prueba.archivosSalida - (caso_de_prueba.archivosSalida | caso_de_prueba.archivosEntrada): + #TODO agregar error SOBRA ARCHIVO + log.debug('El programa debía generar el archivo %s y no lo hizo.', f) + prueba.pasada = False + obs += "- Se generó un archivo ('%s') que no se esperaba.\n" % f + log.debug('Fin de ejecución de caso de prueba (hijo: %d, ret: %d)', + proc.pid, proc.returncode) + if obs: + prueba.observaciones = obs + mail.agregarResultado(prueba) + log.debug('Resultado de la prueba: %s', prueba) + return prueba - $currdir = getcwd(); - if (!@chdir($intento_dir)) { - enviar_respuesta_error_log($mail, 'Error al cambiar al directorio del tp', $intento); - continue; - } - logs("Cambio de directorio '$currdir' -> '$intento_dir'", DEBUG); - logs('Ejecutando el comando: make -f '.escapeshellarg($makefile), DEBUG); - if (exec_get_info('make -f '.escapeshellarg($makefile), $ret, $err, $out)) { - if ($ret) { - logs('Error al compilar'); - logs("Código de retorno: $ret, mensaje: $err)", DEBUG); - //XXX $intento->informar_compilacion(false); - enviar_respuesta(R_ERR, $mail, "ERROR AL COMPILAR!\n\n$err\n\nCódigo de retorno: $ret\n", $intento); - continue; - } else { - logs('Compilado OK'); - //XXX $intento->informar_compilacion(true); - // TODO mail acumulativo - // enviar_respuesta(R_OK, $mail, 'El intento fue compilado con éxito!', $intento); - } - } else { - //XXX $intento->informar_compilacion(false); - enviar_respuesta_error_log($mail, 'No se pudo ejecutar make', $intento); - continue; - } - if (!@mkdir('chroot')) { - enviar_respuesta_error_log($mail, 'Error al crear directorio para chroot', $intento); - continue; - } - if (!@rename('tp', 'chroot/tp')) { - enviar_respuesta_error_log($mail, 'Error al mover el tp al chroot', $intento); - continue; - } - if (!@copy("{$gconf['data_dir']}/redir", 'chroot/redir')) { - enviar_respuesta_error_log($mail, 'Error al copiar redireccionador al chroot', $intento); - continue; - } - if ($err = chmod_own_grp('chroot', 02770, $UID, $GID)) { - enviar_respuesta_error_log($mail, $err, $intento); - continue; - } - if ($err = chmod_own_grp('chroot/tp', 0550, $UID, $GID)) { - enviar_respuesta_error_log($mail, $err, $intento); - continue; - } - if ($err = chmod_own_grp('chroot/redir', 0550, 0, 0)) { - enviar_respuesta_error_log($mail, $err, $intento); - continue; - } +def params2seq(params): + r"""Parsea un string de forma similar al bash, separando por espacios y + teniendo en cuenta comillas simples y dobles para agrupar. Para poner + comillas se puede usar el \ como caracter de escape (\' y \") y también + interpreta \n y \t. Devuelve una lista con los parámetros encontrados.""" + # Constantes + SEP, TOKEN, DQUOTE, SQUOTE = ' ', None, '"', "'" + seq = [] + buff = '' + escape = False + state = SEP + for c in params: + # Es un caracter escapado + if escape: + if c == 'n': + buff += '\n' + elif c == 't': + buff += '\t' + else: + buff += c + escape = False + continue + # Es una secuencia de escape + if c == '\\': + escape = True + continue + # Si está buscando espacios + if state == SEP: + if c == SEP: + continue + else: + state = TOKEN # Encontró + if state == TOKEN: + if c == DQUOTE: + state = DQUOTE + continue + if c == SQUOTE: + state = SQUOTE + continue + if c == SEP: + state = SEP + seq.append(buff) + buff = '' + continue + buff += c + continue + if state == DQUOTE: + if c == DQUOTE: + state = TOKEN + continue + buff += c + continue + if state == SQUOTE: + if c == SQUOTE: + state = TOKEN + continue + buff += c + continue + raise Exception, 'No tiene sentido' + if state == DQUOTE or state == SQUOTE: + raise Exception, 'Parse error, falta cerrar comilla (%s)' % state + if buff: + seq.append(buff) + return seq - // comienza la ejecución de casos de prueba - while ($prueba = $intento->pedir_caso_de_prueba()) { - logs('Prueba: ' . $prueba->to_line(), DEBUG); - // ejecuta con fork - $pid = pcntl_fork(); - if ($pid == -1) { - // Error al forkear - $intento->resultado_de_prueba($prueba, false, 'Error al forkear proceso'); - enviar_respuesta_error_log($mail, 'Error al forkear proceso', $intento); - continue 2; - } elseif ($pid) { - // Estamos en el padre, controlamos el tiempo. - logs("En el padre (hijo: $pid)", DEBUG); - // TODO controlar tiempo. - pcntl_waitpid($pid, $exitcode); - logs("Fin de ejecución de caso de prueba (hijo: $pid, ret: $exitcode)", DEBUG); - $stderr = false; // FIXME ver si salida de error es vacia. - if ($exitcode or $stderr) { - logs('Comando ejecutado ERROR', DEBUG); - if ($ret) { - $msg = "El programa salió con código de error $ret"; - $msgs[] = $msg; - logs($msg, DEBUG); - } - if ($stderr) { - $msg = "El programa imprimió mensajes en la salida de error: '$stderr'"; - $msgs[] = $msg; - logs($msg, DEBUG); - } - $msg = join("\n", $msgs); - $intento->resultado_de_prueba($prueba, false, $msg); - enviar_respuesta(R_ERR, $mail, $msg, $intento); - exit(0); // salgo ok del hijo - } else { // Sin errores en la ejecución. - logs('Comando ejecutado OK', DEBUG); - // TODO - $salidas = array(); - foreach ($prueba->salidas as $salida) { - // TODO hacer diffs. - if ($salida == 'stdout') { - $salidas[$salida] = $stdout; - } else { - $salidas[$salida] = file_get_contents($salida); - } - } - logs('Salidas: ' . var_export($salidas, true), DEBUG); - $intento->resultado_de_prueba($prueba, true, 'Salidas: ' . var_export($salidas, true)); - enviar_respuesta(R_ERR, $mail, 'Salidas: ' . var_export($salidas, true), $intento); - exit(0); // salgo ok del hijo - } - } else { - // Estamos en el hijo, corremos en chroot. - logs('En el hijo', DEBUG); - // Hago chroot. - if (!@chroot('chroot')) { - $intento->resultado_de_prueba($prueba, false, 'Error al hacer chroot'); - enviar_respuesta_error_log($mail, 'Error al hacer chroot', $intento); - exit(1); // salgo del hijo - } - logs('Chrooteado, cwd = '.getcwd(), DEBUG); - if (!@posix_setuid($usrinfo['uid'])) { - $intento->resultado_de_prueba($prueba, false, "Error al cambiar al uid '{$usrinfo['uid']}'"); - enviar_respuesta_error_log($mail, "Error al cambiar al uid '{$usrinfo['uid']}'", $intento); - exit(2); // salgo del hijo - } - // TODO poner stdout y stderr en archivos para hacer diff o hacer diffs en memoria - $stdout = null; - if (in_array('stdout', $prueba->salidas)) $stdout = ''; - logs('Se ejecutará: chroot chroot /tp ' . escapeshellarg($prueba->params), DEBUG); - if (!pcntl_exec('/redir', array('tp', 'stdin', 'stdout', 'stderr', '/tp', '0', $prueba->params))) { - logserr('No se pudo ejecutar el comando'); - $intento->resultado_de_prueba($prueba, false, 'No se pudo ejecutar el tp'); - enviar_respuesta_error_log($mail, 'No se pudo ejecutar el tp', $intento); - exit(4); // salgo del hijo - } - exit(100); // salgo del hijo (no deberia llegar nunca aca). - } - break; // FIXME - } +class MailIntento(email.MIMEMultipart.MIMEMultipart, object): + def __init__(self, intento): + global conf + from email.MIMEMultipart import MIMEMultipart + from email.MIMEMessage import MIMEMessage + from email.MIMEText import MIMEText + MIMEMultipart.__init__(self) + self.subject = '[%s] Resultado del intento %d (ejercicio %d.%d)' % \ + (conf.get('mail', 'prefijo'), intento.numero, + intento.entrega.nroEjercicio, intento.entrega.entrega) + self['From'] = conf.get('mail', 'from') + self['To'] = intento.mailRespuesta + self['Reply-To'] = conf.get('mail', 'admin') + self['Return-Path'] = conf.get('mail', 'admin') + self['X-Mailer'] = 'sercom ' + sercom.VERSION + self['X-Priority'] = '5' + self.epilogue = 'Para ver correctamente este e-mail su cliente debe ' \ + 'soportar MIME.\n\n' + self.prologue = '' # Garantiza que termine en \n el mensaje + self.attach(MIMEMessage(MIMEText('', 'plain', 'iso-8859-1'))) + self.resultado = None + def __set_body(self, body): + self.get_payload(0).get_payload(0).set_payload(body) + def __get_body(self): + return self.get_payload(0).get_payload(0).get_payload() + body = property(__get_body, __set_body, doc='Cuerpo del mensaje.') + def attachText(self, text, nombre=None, subtype='plain'): + from email.MIMEText import MIMEText + attach = MIMEText(text, subtype, 'iso-8859-1') + if nombre: + attach.add_header('Content-Disposition', 'attachment', filename=nombre) + self.attach(attach) + def send(self, resultado=None): + import smtplib + global conf + smtp = smtplib.SMTP(conf.get('mail', 'smtp')) + if resultado: + self.subject += ': ' + resultado + self['Subject'] = self.subject + smtp.sendmail(self['From'], self['To'], self.as_string()) + smtp.close() + def agregarResultado(self, prueba): + if not prueba.casoDePrueba.privado: + if prueba.pasada: + result = 'BIEN' + else: + result = 'ERROR' + self.body += ''' +Prueba '%s': %s +%s +''' % (prueba.casoDePrueba.nombre, result, prueba.observaciones or '') + pass - // TODO make clean -} +# Manejadores de señales +signal.signal(signal.SIGTERM, sigterm) +signal.signal(signal.SIGINT, sigterm) +signal.signal(signal.SIGCHLD, sigchld) +hijo_muerto = False # Cambia con SIGCHLD +continuar = True # Cambia con SIGTERM o SIGINT -/** - * Ejecuta un comando devolviendo el código de error y las salidas. - * - * @param cmd Comando a ejecutar. - * @param ret Código de retorno. - * @param err Salida de error (se obtiene sólo si es != null). - * @param out Salida estándar (se obtiene sólo si es != null). - * @param in Entrada a enviarle al comando (null si no se le envía nada). - * @return true si se ejecutó el comando, false si no. - */ -function exec_fds($cmd, &$ret, $in = false, $out = false, $in = false) { - $descriptors = array(); - if ($in) $descriptors[0] = get_fd_type($in, 'r'); - if ($out) $descriptors[1] = get_fd_type($out, 'w'); - if ($err) $descriptors[2] = get_fd_type($err, 'w'); -var_dump($descriptors); - $proc = proc_open($cmd, $descriptors, $pipes); - if (is_resource($proc)) { - $ret = proc_close($proc); - return true; - } - return false; -} +# Inicializo +conf, conn, log = sercom.init('test') +log.info('Iniciado') -/** - * Obtiene un tipo de descriptor para proc_open(). - * - * @param fd Tipo de descriptor: si es true, se usa un pipe, si es un array - * se usa el array y si es otra cosa se usa un archivo con este - * nombre. - * @param mode Modo de apertura, sólo se usa si es true o string. - * @return Array a pasar a proc_open(). - */ -function get_fd_type($fd, $mode = false) { - if ($fd === true) { - return array('pipe', $mode); - } elseif (is_array($fd)) { - return $fd; - } - return array('file', $fd, $mode); -} +# Obtengo id de usuario con el cual ejecutar las pruebas +uid, gid = pwd.getpwnam(conf.get('general', 'user'))[2:4] -/** - * Ejecuta un comando devolviendo el código de error y las salidas. - * - * @param cmd Comando a ejecutar. - * @param ret Código de retorno. - * @param err Salida de error (se obtiene sólo si es != null). - * @param out Salida estándar (se obtiene sólo si es != null). - * @param in Entrada a enviarle al comando (null si no se le envía nada). - * @return true si se ejecutó el comando, false si no. - */ -function exec_get_info($cmd, &$ret, &$err, &$out, $in = null) { - $descriptors = array(array('pipe', 'r'), array('pipe', 'w'), array('pipe', 'w')); - $proc = proc_open($cmd, $descriptors, $pipes); - if (is_resource($proc)) { - if (is_null($in)) { - fputs($pipes[0], $in); - } - if (is_null($out)) { - $out = stream_get_contents($pipes[1]); - } - if (is_null($err)) { - $err = stream_get_contents($pipes[2]); - } - foreach (array(0,1,2) as $i) { - fclose($pipes[$i]); - } - $ret = proc_close($proc); - return true; - } - return false; -} +# Cambio UID efectivo +os.seteuid(uid) -function enviar_respuesta($tipo, $to, $mensaje = '', $intento = null) { - $mconf = $GLOBALS['CONF']['mail']; - $subject = '[' . $NAME . '] Prueba '; - if ($tipo == R_OK) $estado = 'OK'; - else $estado = 'ERROR'; - $subject .= $estado; - $body .= "Estado: $estado\n"; - if ($mensaje) $body .= "\n$mensaje\n"; - if ($intento) $body .= "\n" . $intento->__toString() . "\n"; - logs("Envío de mail '$subject' a '$to'\n$body\n", DEBUG); - $headers = << \ No newline at end of file