Skip to content

Commit 0cd274c

Browse files
committed
feat: handle requested environment variables
1 parent a9f052d commit 0cd274c

File tree

22 files changed

+275
-104
lines changed

22 files changed

+275
-104
lines changed

config.example.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,20 @@ permissions:
6464
# - package_selector:
6565
# origin:
6666
# local: true
67-
# namespace: "qpy"
67+
# namespace: qpy
6868
# auto_grant_permissions:
6969
# memory: 1 GiB
7070
# override_permissions:
7171
# cpus: 2
7272
#packages:
7373

74+
environment_variables:
75+
# Environment variables that are passed every worker
76+
#global:
77+
78+
# Package- and request-specific environment variables
79+
#packages:
80+
7481
cache:
7582
# Maximum cache size
7683
#size: 1 GiB

docs/qppe-server.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,6 +1165,7 @@ components:
11651165
type: string
11661166
enum:
11671167
- PACKAGE_PERMISSION_ERROR
1168+
- PACKAGE_ENVIRONMENT_VARIABLES_ERROR
11681169
- QUEUE_WAITING_TIMEOUT
11691170
- WORKER_TIMEOUT
11701171
- OUT_OF_MEMORY
@@ -1179,6 +1180,7 @@ components:
11791180
- SERVER_ERROR
11801181
description: >
11811182
* `PACKAGE_PERMISSION_ERROR` - The package requested more permissions than allowed.
1183+
* `PACKAGE_ENVIRONMENT_VARIABLES_ERROR` - The package requires unprovided environment variables.
11821184
* `QUEUE_WAITING_TIMEOUT` - The request has been waiting too long in a job queue. Try again later.
11831185
* `WORKER_TIMEOUT` - Question package did not answer in a reasonable amount of time.
11841186
* `OUT_OF_MEMORY` - Question package reached its memory limit.

questionpy_common/constants.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
# QuestionPy is free software released under terms of the MIT license. See LICENSE.md.
33
# (c) Technische Universität Berlin, innoCampus <info@isis.tu-berlin.de>
44
import re
5-
from typing import Final
5+
from typing import Annotated, Final
66

7-
from pydantic import ByteSize
7+
from pydantic import ByteSize, Field
88

99
# General.
1010
KiB: Final[int] = 1024
@@ -26,3 +26,6 @@
2626
FORM_REFERENCE_PATTERN: Final[re.Pattern[str]] = re.compile(
2727
r"^([a-zA-Z_][a-zA-Z0-9_]*|\.\.)(\[([a-zA-Z_][a-zA-Z0-9_]*|\.\.)?])*$"
2828
)
29+
30+
ENVIRONMENT_VARIABLE_REGEX: Final[str] = r"[a-zA-Z_][a-zA-Z0-9_]*"
31+
ENVIRONMENT_VARIABLE = Annotated[str, Field(pattern=f"^{ENVIRONMENT_VARIABLE_REGEX}$")]

questionpy_common/manifest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from pydantic import BaseModel, ByteSize, PositiveInt, StringConstraints, conset, field_validator
1111
from pydantic.fields import Field
1212

13+
from questionpy_common.constants import ENVIRONMENT_VARIABLE
14+
1315

1416
class PackageType(StrEnum):
1517
LIBRARY = "LIBRARY"
@@ -111,6 +113,7 @@ class SourceManifest(BaseModel):
111113
type: PackageType = DEFAULT_PACKAGETYPE
112114
license: str | None = None
113115
permissions: PartialPackagePermissions | None = None
116+
environment_variables: set[ENVIRONMENT_VARIABLE] | None = None
114117
tags: set[str] = set()
115118
requirements: str | list[str] | None = None
116119

questionpy_server/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ class AttemptScoredResponse(AttemptScoredModel, PackageDependenciesModel):
117117

118118
class RequestErrorCode(Enum):
119119
PACKAGE_PERMISSION_ERROR = "PACKAGE_PERMISSION_ERROR"
120+
PACKAGE_ENVIRONMENT_VARIABLES_ERROR = "PACKAGE_ENVIRONMENT_VARIABLES_ERROR"
120121
QUEUE_WAITING_TIMEOUT = "QUEUE_WAITING_TIMEOUT"
121122
WORKER_TIMEOUT = "WORKER_TIMEOUT"
122123
OUT_OF_MEMORY = "OUT_OF_MEMORY"

questionpy_server/settings.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,27 @@
33
# (c) Technische Universität Berlin, innoCampus <info@isis.tu-berlin.de>
44
import builtins
55
import logging
6+
import os
7+
import re
68
from datetime import timedelta
79
from pathlib import Path
810
from pydoc import locate
9-
from typing import Any, ClassVar, Final, Literal
11+
from typing import Any, ClassVar, Final, Literal, Self
1012

1113
import semver
1214
import yaml
13-
from pydantic import BaseModel, ByteSize, DirectoryPath, HttpUrl, PositiveInt, conset, field_validator
14-
from pydantic.fields import FieldInfo
15+
from pydantic import (
16+
BaseModel,
17+
ByteSize,
18+
DirectoryPath,
19+
HttpUrl,
20+
PositiveInt,
21+
RootModel,
22+
conset,
23+
field_validator,
24+
model_validator,
25+
)
26+
from pydantic.fields import Field, FieldInfo
1527
from pydantic_settings import (
1628
BaseSettings,
1729
EnvSettingsSource,
@@ -20,7 +32,7 @@
2032
SettingsConfigDict,
2133
)
2234

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

157169

158-
class SpecificPackagePermissions(BaseModel):
170+
class Selectable(BaseModel):
159171
package_selector: PackageSelector = PackageSelector()
172+
173+
174+
class SpecificPackagePermissions(Selectable):
160175
auto_grant_permissions: PartialPackagePermissions | None = None
161176
override_permissions: PartialPackagePermissions | None = None
162177

@@ -195,6 +210,35 @@ class PackagePermissionsSettings(BaseModel):
195210
packages: list[SpecificPackagePermissions] = []
196211

197212

213+
class EnvironmentVariables(RootModel[dict[ENVIRONMENT_VARIABLE, str]]):
214+
interpolation_pattern: ClassVar[re.Pattern] = re.compile(rf"^\$\{{({ENVIRONMENT_VARIABLE_REGEX})}}$")
215+
escaped_interpolation_pattern: ClassVar[re.Pattern] = re.compile(rf"^\$(\$+\{{{ENVIRONMENT_VARIABLE_REGEX}}})$")
216+
217+
@model_validator(mode="after")
218+
def check_environment_variables(self) -> Self:
219+
for key, value in self.root.items():
220+
if match := self.interpolation_pattern.match(value):
221+
interpolated_key = match.group(1)
222+
if interpolated_key not in os.environ:
223+
msg = f"Environment variable '{interpolated_key}' not found."
224+
raise ValueError(msg)
225+
self.root[key] = os.environ[interpolated_key]
226+
_log.debug("Interpolated environment variable: %s=%s.", key, self.root[key])
227+
elif match := self.escaped_interpolation_pattern.match(value):
228+
self.root[key] = match.group(1)
229+
_log.debug("Escaped environment variable: %s=%s.", key, self.root[key])
230+
return self
231+
232+
233+
class SpecificPackageEnvironmentVariables(Selectable):
234+
environment_variables: EnvironmentVariables | None = None
235+
236+
237+
class EnvironmentVariablesSettings(BaseModel):
238+
global_: EnvironmentVariables = Field(alias="global", default=EnvironmentVariables({}))
239+
packages: list[SpecificPackageEnvironmentVariables] = []
240+
241+
198242
class CacheSettings(BaseModel):
199243
size: ByteSize = ByteSize(1 * GiB)
200244
directory: DirectoryPath = Path("cache").resolve()
@@ -274,6 +318,7 @@ class Settings(BaseSettings):
274318
webservice: WebserviceSettings
275319
worker_pool: WorkerPoolSettings
276320
permissions: PackagePermissionsSettings
321+
environment_variables: EnvironmentVariablesSettings
277322
cache: CacheSettings
278323
collector: CollectorSettings
279324
auth: AuthSettings

questionpy_server/web/_routes/_files.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from questionpy_server.web import CURRENT_USER_KEY
1010
from questionpy_server.web._decorators import ensure_package
1111
from questionpy_server.web.app import QPyServer
12+
from questionpy_server.worker.selector import SelectorQuery
1213

1314
file_routes = web.RouteTableDef()
1415

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

2829
current_user = request.get(CURRENT_USER_KEY)
29-
permissions = qpy_server.package_permissions.get_effective_permissions(package, current_user, "files")
30+
selector_query = SelectorQuery(package, current_user, "files")
31+
permissions = qpy_server.package_permissions.get(selector_query)
32+
environment_variables = qpy_server.environment_variables.get(selector_query)
3033
location = await package.get_zip_package_location()
3134

32-
async with qpy_server.worker_pool.get_worker(location, current_user, "files", permissions) as worker:
35+
async with qpy_server.worker_pool.get_worker(
36+
location, current_user, "files", permissions, environment_variables
37+
) as worker:
3338
try:
3439
file = await worker.get_static_file(path)
3540
except FileNotFoundError as e:

questionpy_server/web/_worker_context.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from questionpy_server.web import CURRENT_USER_KEY
1313
from questionpy_server.web.app import QPyServer
1414
from questionpy_server.worker import Worker
15+
from questionpy_server.worker.selector import SelectorQuery
1516

1617

1718
def get_request_info(
@@ -36,14 +37,20 @@ async def worker_context(request: web.Request, package: Package, data: RequestBa
3637
"""Returns the worker context for the given request."""
3738
qpyserver = request.app[QPyServer.APP_KEY]
3839
current_user = request.get(CURRENT_USER_KEY)
39-
permissions = qpyserver.package_permissions.get_effective_permissions(package, current_user, data.context)
40+
41+
selector_query = SelectorQuery(package, current_user, data.context)
42+
permissions = qpyserver.package_permissions.get(selector_query)
43+
environment_variables = qpyserver.environment_variables.get(selector_query)
44+
4045
location = await package.get_zip_package_location()
4146

4247
lms_provided_attributes = None
4348
if isinstance(data, LmsProvidedAttributesModel):
4449
lms_provided_attributes = data.lms_provided_attributes
4550

46-
async with qpyserver.worker_pool.get_worker(location, current_user, data.context, permissions) as worker:
51+
async with qpyserver.worker_pool.get_worker(
52+
location, current_user, data.context, permissions, environment_variables
53+
) as worker:
4754
yield WorkerContext(
4855
worker,
4956
get_request_info(request, lms_provided_attributes=lms_provided_attributes),

questionpy_server/web/app.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
from questionpy_server.collector import PackageCollection
1515
from questionpy_server.settings import Settings
1616
from questionpy_server.web.middlewares import middlewares
17-
from questionpy_server.worker.permissions import PackagePermissionsHandler
1817
from questionpy_server.worker.pool import WorkerPool
18+
from questionpy_server.worker.selector.environment_variables import EnvironmentVariablesHandler
19+
from questionpy_server.worker.selector.permissions import PackagePermissionsHandler
1920

2021
_log = logging.getLogger(__name__)
2122

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

4042
cache_supervisor = LRUCacheSupervisor(settings.cache.directory, settings.cache.size)
4143
self.package_cache = LRUCache(cache_supervisor, Path("packages"), extension=".qpy")

questionpy_server/web/errors.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ def __init__(self, msg: str, body: RequestError) -> None:
2323
web_logger.info(msg)
2424

2525

26+
class PackageEnvironmentVariablesError(web.HTTPForbidden, _ExceptionMixin):
27+
def __init__(self, *, reason: str | None, temporary: bool) -> None:
28+
super().__init__(
29+
msg="Question package requires environment variables that are not provided by the server",
30+
body=RequestError(
31+
error_code=RequestErrorCode.PACKAGE_ENVIRONMENT_VARIABLES_ERROR,
32+
reason=reason,
33+
temporary=temporary,
34+
),
35+
)
36+
37+
2638
class PackagePermissionError(web.HTTPForbidden, _ExceptionMixin):
2739
def __init__(self, *, reason: str | None, temporary: bool) -> None:
2840
super().__init__(
@@ -156,6 +168,7 @@ def __init__(self, *, reason: str | None, temporary: bool) -> None:
156168

157169
QpyWebError = (
158170
PackagePermissionError
171+
| PackageEnvironmentVariablesError
159172
| WorkerTimeoutError
160173
| OutOfMemoryError
161174
| InvalidAttemptStateError

0 commit comments

Comments
 (0)