Esempio n. 1
0
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)
Esempio n. 2
0
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
Esempio n. 3
0
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)
Esempio n. 4
0
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
Esempio n. 5
0
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
Esempio n. 6
0
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
Esempio n. 7
0
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
Esempio n. 8
0
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.")
Esempio n. 9
0
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
Esempio n. 10
0
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