def listen_to_broker(queue_config: Any): """ Listen to chosen message broker. :param queue_config: Module containing queue config. """ logger = get_logger("start_grader") if queue_config.TYPE == "rabbitmq": try: rabbitmq_receive( queue_config.HOST, queue_config.PORT, queue_config.USER, queue_config.PASS, queue_config.QUEUE, ) except pika.exceptions.AMQPConnectionError as exception: logger.error("Failed to connect to RabbitMQ broker. %s", exception) elif queue_config.TYPE == "xqueue": try: xqueue_receive( queue_config.HOST, queue_config.USER, queue_config.PASS, queue_config.QUEUE, queue_config.POLLING_INTERVAL, ) except requests.exceptions.ConnectionError as exception: logger.error("Failed to connect to XQueue broker. %s", exception) else: logger.error("Unknown message broker type: %s", queue_config.TYPE)
def process_answer(submission: dict) -> dict: """ Function which receives answers, proceeds them, and returns results. :param submission: Student submission received from message broker without unique fields like xqueue_header or student_info. :raises ValueError: Invalid student submission. :raises FailedFilesLoadException: Failed to load required files. :raises ModuleNotFoundError: Failed to find required grading script. """ logger: Logger = get_logger("process_answer") submission, script_name = submission_validate(submission) logger.info("Student submission: %s", submission) logger.debug("Script name: %s", script_name) settings: dict = settings_load(script_name) logger.debug("Settings: %s", settings) # Parse settings, load required files and raise FailedFilesLoadException if loading failed prepared_files, docker_profile, docker_limits = settings_proceed( script_name, settings) logger.debug("Docker profile: %s", docker_profile) logger.debug("Docker limits: %s", docker_limits) logger.debug("Prepared files: %s", prepared_files) # Run code in a more-or-less secure Docker container grade: dict = grade_epicbox(submission, script_name, prepared_files, docker_profile, docker_limits) logger.info("Grade: %s", grade) return grade
def start_grader() -> None: """ Start main grader loop. """ logger = get_logger("start_grader") try: queue_config_name = getenv("QUEUE_CONFIG_NAME", QUEUE_CONFIG_NAME) try: queue_config = importlib.import_module("queue_configuration." + queue_config_name) except ModuleNotFoundError: logger.error("Queue config with name: %s not found.", queue_config_name) else: while True: listen_to_broker(queue_config) sleep(CONNECTION_RETRY_TIME) except AttributeError as exception: logger.error(exception, exc_info=True) except epicbox.exceptions.DockerError as exception: logger.error("Docker error: \n%s.", exception) except socket.gaierror: logger.error("Unknown host name in queue configuration file.") except KeyboardInterrupt: logger.info("Program has been stopped manually.") except Exception as exception: logger.error("Unhandled exception: \n%s.", exception, exc_info=True)
def process_submission(session: requests.Session, host: str, user: str, password: str, queue: str) -> None: """ Get submission from XQueue, process it, and put results back. :param session: Session object. :param host: Host of XQueue broker. :param user: Username for basic auth. :param password: Password for basic auth. :param queue: Queue name. """ logger = get_logger("xqueue") xqueue_get_submission_url = host + "/xqueue/get_submission/" xqueue_put_result_url = host + "/xqueue/put_result/" response: requests.Response = session.get( xqueue_get_submission_url, auth=HTTPBasicAuth(user, password), params={"queue_name": queue}, ) logger.debug("GET request response: %s", response.json()) try: message: str = response.json()["content"] logger.debug("Message: %s", message) if message.startswith("Queue"): return message: dict = json.loads(message) logger.debug("Content: %s", message) # XQueue header is a unique dict, so it is removed from message to allow caching xqueue_header = message.pop("xqueue_header", None) reply: dict = { "xqueue_header": xqueue_header, "xqueue_body": json.dumps(process_answer(message)), } logger.debug("Reply message: %s", reply) response: requests.Response = session.post( xqueue_put_result_url, auth=HTTPBasicAuth(user, password), verify=False, data=reply, ) logger.debug("POST request response: %s", response.json()) except Exception as exception: raise exception
def receive_messages(host: str, user: str, password: str, queue: str, polling_interval: int) -> None: """ Start consuming messages from XQueue broker. :param host: Host of XQueue broker. :param user: Username for basic auth. :param password: Password for basic auth. :param queue: Queue name. :param polling_interval: Interval between requests for submissions. """ logger = get_logger("xqueue") session = requests.session() xqueue_login_url = host + "/xqueue/login/" logger.debug("Logging in to: %s with credentials: %s:%s", xqueue_login_url, user, password) response = session.post(xqueue_login_url, auth=None, data={ "username": user, "password": password }) if response.status_code != 200: logger.error("Login failed: %s %s", response.status_code, response.content) else: logger.debug("Login successful: %s", response.json()) try: logger.info("Started consuming messages from XQueue.") while True: process_submission(session, host, user, password, queue) sleep(polling_interval) except KeyboardInterrupt as exception: logger.info("Stopped consuming messages from XQueue.") session.close() raise exception
def receive_messages( host: str, port: int, user: str, password: str, queue: str ) -> None: """ Start consuming messages from RabbitMQ broker. :param host: Host of XQueue broker. :param port: Port of XQueue broker. :param user: Username for basic auth. :param password: Password for basic auth. :param queue: Queue name. """ logger: Logger = get_logger("rabbitmq") connection: BlockingConnection = BlockingConnection( ConnectionParameters( host=host, port=port, credentials=credentials.PlainCredentials(user, password), ) ) ch: channel = connection.channel() # Set durable=True to save messages between RabbitMQ restarts ch.queue_declare(queue=queue, durable=True) # Make RabbitMQ avoid giving more than 1 message at a time to a worker ch.basic_qos(prefetch_count=1) # Start receiving messages ch.basic_consume(queue=queue, on_message_callback=callback_function) try: logger.info("Started consuming messages from RabbitMQ.") ch.start_consuming() except KeyboardInterrupt as exception: logger.info("Stopped consuming messages from RabbitMQ.") ch.stop_consuming() connection.close() raise exception
def settings_load(script_name: str) -> dict: """ Load settings for grading script. :param script_name: Name of the grading script. :return: Settings dictionary. """ logger: Logger = get_logger("process_answer") settings_file: Path = PATH_GRADER_SCRIPTS_DIRECTORY / script_name / "settings.json" logger.debug("Settings file path: %s", settings_file) # Load settings if settings_file.is_file(): with settings_file.open() as file: settings = json.load(file) else: raise InvalidGraderScriptException( "Grading script has no settings file: %s", script_name) return settings
def callback_function( current_channel: channel.Channel, basic_deliver: spec.Basic.Deliver, properties: spec.BasicProperties, body: bytes, ) -> None: """ Callback function which receives and proceeds consumed messages from RabbitMQ broker. :param current_channel: Channel object. :param basic_deliver: Object which has exchange, routing key, delivery tag and a redelivered flag of the message. :param properties: Message properties. :param body: Message body. """ logger: Logger = get_logger("rabbitmq") try: message: dict = json.loads(body.decode("utf8")) logger.debug("Received message: %s", message) except json.decoder.JSONDecodeError as exception: send_reply( logger, current_channel, basic_deliver, properties, {}, False, 0, "Ошибка при декодировании сообщения.", ) logger.info("Failed to decode message: {}.".format(body.decode("utf8"))) return # XQueue header is a unique dict, so it is removed from message to allow caching xqueue_header = message.pop("xqueue_header", None) try: xqueue_body: dict = process_answer(message) send_reply( logger, current_channel, basic_deliver, properties, xqueue_header, xqueue_body=xqueue_body, ) except InvalidSubmissionException: send_reply( logger, current_channel, basic_deliver, properties, xqueue_header, False, 0, "Неверный формат сообщения или ID скрипта проверки.", ) except InvalidGraderScriptException: send_reply( logger, current_channel, basic_deliver, properties, xqueue_header, False, 0, "Неверный скрипт проверки.", ) except Exception as exception: send_reply( logger, current_channel, basic_deliver, properties, xqueue_header, False, 0, "Ошибка при проверке ответа.", ) raise exception logger.debug("Finished handling message.")
def grade_epicbox( submission: dict, script_name: str, prepared_files: list, docker_profile: dict, docker_limits: dict, ) -> dict: """ Running grading script in a separate Docker container. https://github.com/StepicOrg/epicbox :param submission: Student submission received from message broker. :param script_name: Name of the grading script. :param prepared_files: List of files and their paths. :param docker_profile: Epicbox profile. :param docker_limits: Docker container limits. :return: Results of grading. """ logger: Logger = get_logger("process_answer") epicbox.configure(profiles=[ epicbox.Profile( name="python", docker_image=docker_profile["docker_image"], user=docker_profile["user"], read_only=docker_profile["read_only"], network_disabled=docker_profile["network_disabled"], ) ]) # Get all files used during grading # Content field should be bytes files: list = [] # Grading script with Path(PATH_GRADER_SCRIPTS_DIRECTORY / script_name / "grade.py").open("rb") as f: files.append({"name": "grade.py", "content": f.read()}) # Required files for file in prepared_files: with Path(file["path"]).open("rb") as f: files.append({"name": file["name"], "content": f.read()}) # Student submission files.append({ "name": "student_response.txt", "content": submission_get_response(submission).encode(), }) # Script parameters files.append({ "name": "script_parameters.json", "content": submission["xqueue_body"]["grader_payload"].encode(), }) result: dict = epicbox.run("python", "python3 grade.py", files=files, limits=docker_limits) logger.debug("Result: %s", result) grade: dict = {"correct": False, "score": 0, "msg": ""} # Handling result if result["timeout"]: grade["msg"] = "Проверка заняла слишком много времени." elif result["oom_killed"]: grade["msg"] = "Проверка заняла слишком много памяти." else: try: try: grade["score"] = int(result["stdout"].decode().split("\n")[-2]) except ValueError: grade["score"] = float( result["stdout"].decode().split("\n")[-2]) grade["msg"] = result["stderr"].decode() # .split("\n")[-2] + "\n" grade["correct"] = bool(grade["score"]) except ValueError: raise InvalidGraderScriptException( "Grading script returned invalid results: %s", result) return grade
def settings_proceed(script_name: str, settings: dict) -> (list, dict, dict): """ Parse settings for grading script and load required files. :param script_name: Name of the grading script. :param settings: Settings dictionary. :return: List of prepared files, epicbox profile, and container limits. :raises FailedFilesLoadException: Failed to load or find required file. """ logger: Logger = get_logger("process_answer") # Create directory for the script files in data directory data_directory: Path = PATH_DATA_DIRECTORY / "grader_scripts" / script_name data_directory.mkdir(parents=True, exist_ok=True) logger.debug("Data directory path: %s", data_directory) script_directory: Path = PATH_GRADER_SCRIPTS_DIRECTORY / script_name logger.debug("Script directory path: %s", script_directory) docker_profile: dict = EPICBOX_SETTINGS["profile"] docker_limits: dict = EPICBOX_SETTINGS["container_limits"] prepared_files: list = [] # Docker container limits if "container_limits" in settings.keys(): docker_limits = settings["container_limits"] # Docker profile if "profile" in settings.keys(): docker_profile = settings["profile"] # Files if "files" in settings.keys(): all_files = settings["files"] # Check existence of external files if "external" in all_files.keys(): files = all_files["external"] for f in files: file_path: Path = data_directory / f["name"] prepared_files.append({ "type": "external", "name": f["name"], "path": file_path }) if file_path.is_file(): logger.debug("File already downloaded: %s", file_path) else: try: urllib.request.urlretrieve(str(f["link"]), str(file_path)) logger.debug("File downloaded: %s", file_path) except Exception: raise FailedFilesLoadException( "Failed to download file: %s", f["link"]) # Check existence of local files if "local" in all_files.keys(): files = all_files["local"] for f in files: file_path = script_directory / f["path"] prepared_files.append({ "type": "local", "name": f["name"], "path": file_path }) if file_path.is_file(): logger.debug("Local file exists: %s", file_path) else: raise FailedFilesLoadException( "Failed to find local file: %s", file_path) return prepared_files, docker_profile, docker_limits