Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,20 @@ permissions:
# - package_selector:
# origin:
# local: true
# namespace: "qpy"
# namespace: qpy
# auto_grant_permissions:
# memory: 1 GiB
# override_permissions:
# cpus: 2
#packages:

environment_variables:
# Environment variables that are passed to every worker
#global:

# Package- and request-specific environment variables
#packages:

cache:
# Maximum cache size
#size: 1 GiB
Expand Down
2 changes: 2 additions & 0 deletions docs/qppe-server.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1165,6 +1165,7 @@ components:
type: string
enum:
- PACKAGE_PERMISSION_ERROR
- PACKAGE_ENVIRONMENT_VARIABLES_ERROR
- QUEUE_WAITING_TIMEOUT
- WORKER_TIMEOUT
- OUT_OF_MEMORY
Expand All @@ -1179,6 +1180,7 @@ components:
- SERVER_ERROR
description: >
* `PACKAGE_PERMISSION_ERROR` - The package requested more permissions than allowed.
* `PACKAGE_ENVIRONMENT_VARIABLES_ERROR` - The package requires unprovided environment variables.
* `QUEUE_WAITING_TIMEOUT` - The request has been waiting too long in a job queue. Try again later.
* `WORKER_TIMEOUT` - Question package did not answer in a reasonable amount of time.
* `OUT_OF_MEMORY` - Question package reached its memory limit.
Expand Down
7 changes: 5 additions & 2 deletions questionpy_common/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
# QuestionPy is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <info@isis.tu-berlin.de>
import re
from typing import Final
from typing import Annotated, Final

from pydantic import ByteSize
from pydantic import ByteSize, Field

# General.
KiB: Final[int] = 1024
Expand All @@ -26,3 +26,6 @@
FORM_REFERENCE_PATTERN: Final[re.Pattern[str]] = re.compile(
r"^([a-zA-Z_][a-zA-Z0-9_]*|\.\.)(\[([a-zA-Z_][a-zA-Z0-9_]*|\.\.)?])*$"
)

ENVIRONMENT_VARIABLE_REGEX: Final[str] = r"[a-zA-Z_][a-zA-Z0-9_]*"
ENVIRONMENT_VARIABLE = Annotated[str, Field(pattern=f"^{ENVIRONMENT_VARIABLE_REGEX}$")]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nicht eher EnvironmentVariable bzw. EnvironmentVariableName?

3 changes: 3 additions & 0 deletions questionpy_common/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from pydantic import BaseModel, ByteSize, PositiveInt, StringConstraints, conset, field_validator
from pydantic.fields import Field

from questionpy_common.constants import ENVIRONMENT_VARIABLE


class PackageType(StrEnum):
LIBRARY = "LIBRARY"
Expand Down Expand Up @@ -111,6 +113,7 @@ class SourceManifest(BaseModel):
type: PackageType = DEFAULT_PACKAGETYPE
license: str | None = None
permissions: PartialPackagePermissions | None = None
environment_variables: set[ENVIRONMENT_VARIABLE] | None = None
tags: set[str] = set()
requirements: str | list[str] | None = None

Expand Down
1 change: 1 addition & 0 deletions questionpy_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ class AttemptScoredResponse(AttemptScoredModel, PackageDependenciesModel):

class RequestErrorCode(Enum):
PACKAGE_PERMISSION_ERROR = "PACKAGE_PERMISSION_ERROR"
PACKAGE_ENVIRONMENT_VARIABLES_ERROR = "PACKAGE_ENVIRONMENT_VARIABLES_ERROR"
QUEUE_WAITING_TIMEOUT = "QUEUE_WAITING_TIMEOUT"
WORKER_TIMEOUT = "WORKER_TIMEOUT"
OUT_OF_MEMORY = "OUT_OF_MEMORY"
Expand Down
73 changes: 68 additions & 5 deletions questionpy_server/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,27 @@
# (c) Technische Universität Berlin, innoCampus <info@isis.tu-berlin.de>
import builtins
import logging
import os
import re
from datetime import timedelta
from pathlib import Path
from pydoc import locate
from typing import Any, ClassVar, Final, Literal
from typing import Any, ClassVar, Final, Literal, Self

import semver
import yaml
from pydantic import BaseModel, ByteSize, DirectoryPath, HttpUrl, PositiveInt, conset, field_validator
from pydantic.fields import FieldInfo
from pydantic import (
BaseModel,
ByteSize,
DirectoryPath,
HttpUrl,
PositiveInt,
RootModel,
conset,
field_validator,
model_validator,
)
from pydantic.fields import Field, FieldInfo
from pydantic_settings import (
BaseSettings,
EnvSettingsSource,
Expand All @@ -20,7 +32,7 @@
SettingsConfigDict,
)

from questionpy_common.constants import MAX_PACKAGE_SIZE, GiB, MiB
from questionpy_common.constants import ENVIRONMENT_VARIABLE, ENVIRONMENT_VARIABLE_REGEX, MAX_PACKAGE_SIZE, GiB, MiB
from questionpy_common.manifest import PartialPackagePermissions, ensure_is_valid_name
from questionpy_server.worker import Worker
from questionpy_server.worker.impl.subprocess import SubprocessWorker
Expand Down Expand Up @@ -155,8 +167,11 @@ def validate_name(cls, value: str) -> str:
MainProcessExecutionModeValues = {"container", "trusted"}


class SpecificPackagePermissions(BaseModel):
class Selectable(BaseModel):
package_selector: PackageSelector = PackageSelector()


class SpecificPackagePermissions(Selectable):
auto_grant_permissions: PartialPackagePermissions | None = None
override_permissions: PartialPackagePermissions | None = None

Expand Down Expand Up @@ -195,6 +210,53 @@ class PackagePermissionsSettings(BaseModel):
packages: list[SpecificPackagePermissions] = []


class EnvironmentVariables(RootModel[dict[ENVIRONMENT_VARIABLE, str]]):
interpolation_pattern: ClassVar[re.Pattern] = re.compile(rf"^\$\{{({ENVIRONMENT_VARIABLE_REGEX})}}$")
"""Matches environment variable interpolation syntax for replacement.

Identifies strings in the format ${VAR_NAME} that should be replaced with the
corresponding environment variable value.

Example:
"${MY_VAR}" matches and becomes the value of the environment variable "MY_VAR"

"""

escaped_interpolation_pattern: ClassVar[re.Pattern] = re.compile(rf"^\$(\$+\{{{ENVIRONMENT_VARIABLE_REGEX}}})$")
"""Matches escaped interpolation syntax to prevent variable replacement.

Identifies strings with escaped dollar signs that should be unescaped rather
than treated as environment variable references.

Example:
"$${MY_VAR}" matches and becomes "${MY_VAR}" (literal string)
"""

@model_validator(mode="after")
def check_environment_variables(self) -> Self:
for key, value in self.root.items():
if match := self.interpolation_pattern.match(value):
interpolated_key = match.group(1)
if interpolated_key not in os.environ:
msg = f"Environment variable '{interpolated_key}' not found."
raise ValueError(msg)
self.root[key] = os.environ[interpolated_key]
_log.debug("Interpolated environment variable: %s=%s.", key, self.root[key])
elif match := self.escaped_interpolation_pattern.match(value):
self.root[key] = match.group(1)
_log.debug("Escaped environment variable: %s=%s.", key, self.root[key])
return self


class SpecificPackageEnvironmentVariables(Selectable):
environment_variables: EnvironmentVariables | None = None


class EnvironmentVariablesSettings(BaseModel):
global_: EnvironmentVariables = Field(alias="global", default=EnvironmentVariables({}))
packages: list[SpecificPackageEnvironmentVariables] = []


class CacheSettings(BaseModel):
size: ByteSize = ByteSize(1 * GiB)
directory: DirectoryPath = Path("cache").resolve()
Expand Down Expand Up @@ -274,6 +336,7 @@ class Settings(BaseSettings):
webservice: WebserviceSettings
worker_pool: WorkerPoolSettings
permissions: PackagePermissionsSettings
environment_variables: EnvironmentVariablesSettings
cache: CacheSettings
collector: CollectorSettings
auth: AuthSettings
Expand Down
9 changes: 7 additions & 2 deletions questionpy_server/web/_routes/_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from questionpy_server.web import CURRENT_USER_KEY
from questionpy_server.web._decorators import ensure_package
from questionpy_server.web.app import QPyServer
from questionpy_server.worker.selector import SelectorQuery

file_routes = web.RouteTableDef()

Expand All @@ -26,10 +27,14 @@ async def serve_static_file(request: web.Request, package: Package) -> web.Respo
raise HTTPNotImplemented(text="Static file retrieval from non-main packages is not supported yet.")

current_user = request.get(CURRENT_USER_KEY)
permissions = qpy_server.package_permissions.get_effective_permissions(package, current_user, "files")
selector_query = SelectorQuery(package, current_user, "files")
permissions = qpy_server.package_permissions.get(selector_query)
environment_variables = qpy_server.environment_variables.get(selector_query)
location = await package.get_zip_package_location()

async with qpy_server.worker_pool.get_worker(location, current_user, "files", permissions) as worker:
async with qpy_server.worker_pool.get_worker(
location, current_user, "files", permissions, environment_variables
) as worker:
try:
file = await worker.get_static_file(path)
except FileNotFoundError as e:
Expand Down
11 changes: 9 additions & 2 deletions questionpy_server/web/_worker_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from questionpy_server.web import CURRENT_USER_KEY
from questionpy_server.web.app import QPyServer
from questionpy_server.worker import Worker
from questionpy_server.worker.selector import SelectorQuery


def get_request_info(
Expand All @@ -36,14 +37,20 @@ async def worker_context(request: web.Request, package: Package, data: RequestBa
"""Returns the worker context for the given request."""
qpyserver = request.app[QPyServer.APP_KEY]
current_user = request.get(CURRENT_USER_KEY)
permissions = qpyserver.package_permissions.get_effective_permissions(package, current_user, data.context)

selector_query = SelectorQuery(package, current_user, data.context)
permissions = qpyserver.package_permissions.get(selector_query)
environment_variables = qpyserver.environment_variables.get(selector_query)

location = await package.get_zip_package_location()

lms_provided_attributes = None
if isinstance(data, LmsProvidedAttributesModel):
lms_provided_attributes = data.lms_provided_attributes

async with qpyserver.worker_pool.get_worker(location, current_user, data.context, permissions) as worker:
async with qpyserver.worker_pool.get_worker(
location, current_user, data.context, permissions, environment_variables
) as worker:
yield WorkerContext(
worker,
get_request_info(request, lms_provided_attributes=lms_provided_attributes),
Expand Down
4 changes: 3 additions & 1 deletion questionpy_server/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
from questionpy_server.collector import PackageCollection
from questionpy_server.settings import Settings
from questionpy_server.web.middlewares import middlewares
from questionpy_server.worker.permissions import PackagePermissionsHandler
from questionpy_server.worker.pool import WorkerPool
from questionpy_server.worker.selector.environment_variables import EnvironmentVariablesHandler
from questionpy_server.worker.selector.permissions import PackagePermissionsHandler

_log = logging.getLogger(__name__)

Expand All @@ -36,6 +37,7 @@ def __init__(self, settings: Settings):
settings.worker_pool.max_cpus, settings.worker_pool.max_memory, worker_type=settings.worker_pool.type
)
self.package_permissions = PackagePermissionsHandler(settings.permissions)
self.environment_variables = EnvironmentVariablesHandler(settings.environment_variables)

cache_supervisor = LRUCacheSupervisor(settings.cache.directory, settings.cache.size)
self.package_cache = LRUCache(cache_supervisor, Path("packages"), extension=".qpy")
Expand Down
13 changes: 13 additions & 0 deletions questionpy_server/web/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ def __init__(self, msg: str, body: RequestError) -> None:
web_logger.info(msg)


class PackageEnvironmentVariablesError(web.HTTPForbidden, _ExceptionMixin):
def __init__(self, *, reason: str | None, temporary: bool) -> None:
super().__init__(
msg="Question package requires environment variables that are not provided by the server",
body=RequestError(
error_code=RequestErrorCode.PACKAGE_ENVIRONMENT_VARIABLES_ERROR,
reason=reason,
temporary=temporary,
),
)


class PackagePermissionError(web.HTTPForbidden, _ExceptionMixin):
def __init__(self, *, reason: str | None, temporary: bool) -> None:
super().__init__(
Expand Down Expand Up @@ -156,6 +168,7 @@ def __init__(self, *, reason: str | None, temporary: bool) -> None:

QpyWebError = (
PackagePermissionError
| PackageEnvironmentVariablesError
| WorkerTimeoutError
| OutOfMemoryError
| InvalidAttemptStateError
Expand Down
4 changes: 3 additions & 1 deletion questionpy_server/web/middlewares/_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@
WorkerRealTimeLimitExceededError,
WorkerStartError,
)
from questionpy_server.worker.permissions import PackagePermissionError
from questionpy_server.worker.runtime.messages import WorkerMemoryLimitExceededError, WorkerUnknownError
from questionpy_server.worker.selector.environment_variables import PackageEnvironmentVariablesError
from questionpy_server.worker.selector.permissions import PackagePermissionError

exception_map: dict[type[QPyBaseError], type[web_error.QpyWebError]] = {
InvalidAttemptStateError: web_error.InvalidAttemptStateError,
InvalidQuestionStateError: web_error.InvalidQuestionStateError,
ManifestError: web_error.InvalidPackageError,
PackageEnvironmentVariablesError: web_error.PackageEnvironmentVariablesError,
PackagePermissionError: web_error.PackagePermissionError,
StaticFileSizeMismatchError: web_error.InvalidPackageError,
WorkerCPUTimeLimitExceededError: web_error.WorkerTimeoutError,
Expand Down
3 changes: 3 additions & 0 deletions questionpy_server/worker/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ class WorkerArgs(TypedDict):
"""An existing directory owned by the worker, with the same lifetime as the worker."""
permissions: PackagePermissions
"""The package permissions."""
environment_variables: dict[str, str]
"""Environment variables to be set in the worker."""


class Worker(ABC):
Expand All @@ -72,6 +74,7 @@ def __init__(self, **kwargs: Unpack[WorkerArgs]) -> None:
self.package = kwargs["package"]
self.worker_home = kwargs["worker_home"]
self.permissions = kwargs["permissions"]
self.environment_variables = kwargs["environment_variables"]

self.state = WorkerState.NOT_RUNNING
self.loaded_packages: list[LoadedPackage] = []
Expand Down
17 changes: 8 additions & 9 deletions questionpy_server/worker/impl/subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ class SubprocessWorker(BaseWorker, LimitTimeUsageMixin):

_worker_type = "process"

# Allows to use a patched runtime in tests.
# Allows using a patched runtime in tests.
_runtime_main = ["-m", "questionpy_server.worker.runtime"]

def __init__(self, **kwargs: Unpack[WorkerArgs]):
Expand All @@ -117,26 +117,25 @@ def __init__(self, **kwargs: Unpack[WorkerArgs]):
self._proc: Process | None = None
self._stderr_buffer: _StderrBuffer | None = None

if "OPENBLAS_NUM_THREADS" not in self.environment_variables:
# OpenBLAS is used by NumPy and creates a number of threads on import.
# Each thread allocates a bunch of virtual memory, so more than 2 threads break the default memory limit.
# By default, the number of threads is proportional to the available CPUs.
self.environment_variables["OPENBLAS_NUM_THREADS"] = "2"

async def start(self) -> None:
"""Start the worker process."""
# Turn off the worker's __debug__ flag unless ours is set as well.
python_flags = [] if __debug__ else ["-O"]

env = {
# OpenBLAS is used by NumPy and creates a number of threads on import.
# Each thread allocates a bunch of virtual memory, so more than 2 threads breaks the default memory limit.
# By default, the number of threads is proportional to the available CPUs.
"OPENBLAS_NUM_THREADS": "2"
}

self._proc = await asyncio.create_subprocess_exec(
sys.executable,
*python_flags,
*self._runtime_main,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
env=self.environment_variables,
cwd=self.worker_home,
start_new_session=True,
)
Expand Down
Loading