Skip to content

Commit e369fef

Browse files
committed
feat: env_variable on session launchers and injected into pods
1 parent e013c19 commit e369fef

12 files changed

Lines changed: 267 additions & 11 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""support env variables in sessions
2+
3+
Revision ID: 0c205e28f053
4+
Revises: ca87e5b43a44
5+
Create Date: 2025-03-31 09:12:08.245036
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
from sqlalchemy.dialects import postgresql
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "0c205e28f053"
15+
down_revision = "ca87e5b43a44"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade() -> None:
21+
# ### commands auto generated by Alembic - please adjust! ###
22+
op.add_column(
23+
"launchers",
24+
sa.Column(
25+
"env_variables",
26+
sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"),
27+
nullable=True,
28+
),
29+
schema="sessions",
30+
)
31+
# ### end Alembic commands ###
32+
33+
34+
def downgrade() -> None:
35+
# ### commands auto generated by Alembic - please adjust! ###
36+
op.drop_column("launchers", "env_variables", schema="sessions")
37+
# ### end Alembic commands ###

components/renku_data_services/notebooks/blueprints.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,22 @@ async def _handler(
377377

378378
secrets_to_create.append(auth_secret)
379379

380+
env = [
381+
SessionEnvItem(name="RENKU_BASE_URL_PATH", value=base_server_path),
382+
SessionEnvItem(name="RENKU_BASE_URL", value=base_server_url),
383+
SessionEnvItem(name="RENKU_MOUNT_DIR", value=storage_mount.as_posix()),
384+
SessionEnvItem(name="RENKU_SESSION", value="1"),
385+
SessionEnvItem(name="RENKU_SESSION_IP", value="0.0.0.0"), # nosec B104
386+
SessionEnvItem(name="RENKU_SESSION_PORT", value=f"{environment.port}"),
387+
SessionEnvItem(name="RENKU_WORKING_DIR", value=work_dir.as_posix()),
388+
]
389+
if launcher.env_variables:
390+
env.extend(
391+
SessionEnvItem(name=env_var.name, value=env_var.value)
392+
for env_var in launcher.env_variables
393+
if env_var.value
394+
)
395+
380396
manifest = AmaltheaSessionV1Alpha1(
381397
metadata=Metadata(name=server_name, annotations=annotations),
382398
spec=AmaltheaSessionSpec(
@@ -405,15 +421,7 @@ async def _handler(
405421
command=environment.command,
406422
args=environment.args,
407423
shmSize="1G",
408-
env=[
409-
SessionEnvItem(name="RENKU_BASE_URL_PATH", value=base_server_path),
410-
SessionEnvItem(name="RENKU_BASE_URL", value=base_server_url),
411-
SessionEnvItem(name="RENKU_MOUNT_DIR", value=storage_mount.as_posix()),
412-
SessionEnvItem(name="RENKU_SESSION", value="1"),
413-
SessionEnvItem(name="RENKU_SESSION_IP", value="0.0.0.0"), # nosec B104
414-
SessionEnvItem(name="RENKU_SESSION_PORT", value=f"{environment.port}"),
415-
SessionEnvItem(name="RENKU_WORKING_DIR", value=work_dir.as_posix()),
416-
],
424+
env=env,
417425
),
418426
ingress=Ingress(
419427
host=self.nb_config.sessions.ingress.host,

components/renku_data_services/project/db.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,6 +976,7 @@ async def migrate_v1_project(
976976
is_archived=False,
977977
environment_image_source=session_apispec.EnvironmentImageSourceImage.image,
978978
),
979+
env_variables=None,
979980
)
980981

981982
new_launcher = validate_unsaved_session_launcher(

components/renku_data_services/session/api.spec.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,8 @@ components:
568568
$ref: "#/components/schemas/ResourceClassId"
569569
disk_storage:
570570
$ref: "#/components/schemas/DiskStorage"
571+
env_variables:
572+
$ref: "#/components/schemas/EnvVariables"
571573
required:
572574
- id
573575
- project_id
@@ -590,6 +592,8 @@ components:
590592
$ref: "#/components/schemas/ResourceClassId"
591593
disk_storage:
592594
$ref: "#/components/schemas/DiskStorage"
595+
env_variables:
596+
$ref: "#/components/schemas/EnvVariables"
593597
environment:
594598
oneOf:
595599
- $ref: "#/components/schemas/EnvironmentPostInLauncher"
@@ -616,6 +620,8 @@ components:
616620
$ref: "#/components/schemas/ResourceClassId"
617621
disk_storage:
618622
$ref: "#/components/schemas/DiskStoragePatch"
623+
env_variables:
624+
$ref: "#/components/schemas/EnvVariables"
619625
environment:
620626
oneOf:
621627
- $ref: "#/components/schemas/EnvironmentPatchInLauncher"
@@ -907,6 +913,27 @@ components:
907913
- "failed"
908914
- "cancelled"
909915
example: "succeeded"
916+
EnvVariables:
917+
description: Environment variables for the session pod
918+
type: array
919+
maxItems: 32
920+
items:
921+
$ref: "#/components/schemas/EnvVar"
922+
EnvVar:
923+
description: An environment variable for the session pod
924+
type: object
925+
properties:
926+
name:
927+
type: string
928+
maxLength: 256
929+
# based on https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_235
930+
pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$"
931+
example: MY_VAR
932+
value:
933+
type: string
934+
maxLength: 500
935+
required:
936+
- name
910937
ErrorReason:
911938
description: The reason why a container image build did not succeed, if available.
912939
type: string

components/renku_data_services/session/apispec.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# generated by datamodel-codegen:
22
# filename: api.spec.yaml
3-
# timestamp: 2025-03-19T10:21:07+00:00
3+
# timestamp: 2025-04-10T14:23:45+00:00
44

55
from __future__ import annotations
66

@@ -84,6 +84,13 @@ class BuildStatus(Enum):
8484
cancelled = "cancelled"
8585

8686

87+
class EnvVar(BaseAPISpec):
88+
name: str = Field(
89+
..., examples=["MY_VAR"], max_length=256, pattern="^[a-zA-Z_][a-zA-Z0-9_]*$"
90+
)
91+
value: Optional[str] = Field(None, max_length=500)
92+
93+
8794
class Error(BaseAPISpec):
8895
code: int = Field(..., examples=[1404], gt=0)
8996
detail: Optional[str] = Field(
@@ -469,6 +476,9 @@ class SessionLauncherPost(BaseAPISpec):
469476
examples=[8],
470477
ge=1,
471478
)
479+
env_variables: Optional[List[EnvVar]] = Field(
480+
None, description="Environment variables for the session pod", max_length=32
481+
)
472482
environment: Union[
473483
EnvironmentIdOnlyPost,
474484
Union[EnvironmentPostInLauncherHelper, BuildParametersPost],
@@ -493,6 +503,9 @@ class SessionLauncherPatch(BaseAPISpec):
493503
None, description="The identifier of a resource class"
494504
)
495505
disk_storage: Optional[int] = Field(None, ge=1)
506+
env_variables: Optional[List[EnvVar]] = Field(
507+
None, description="Environment variables for the session pod", max_length=32
508+
)
496509
environment: Optional[Union[EnvironmentPatchInLauncher, EnvironmentIdOnlyPatch]] = (
497510
None
498511
)
@@ -556,6 +569,9 @@ class SessionLauncher(BaseAPISpec):
556569
examples=[8],
557570
ge=1,
558571
)
572+
env_variables: Optional[List[EnvVar]] = Field(
573+
None, description="Environment variables for the session pod", max_length=32
574+
)
559575

560576

561577
class SessionLaunchersList(RootModel[List[SessionLauncher]]):

components/renku_data_services/session/constants.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Constants for sessions environments, session launchers and container image builds."""
22

3+
import re
34
from datetime import timedelta
5+
from re import Pattern
46
from typing import Final
57

68
BUILD_DEFAULT_OUTPUT_IMAGE_PREFIX: Final[str] = "harbor.dev.renku.ch/renku-builds/"
@@ -26,3 +28,12 @@
2628

2729
BUILD_RUN_DEFAULT_TIMEOUT: Final[timedelta] = timedelta(hours=1)
2830
"""The default timeout for build after which they get cancelled."""
31+
32+
# see https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_235
33+
ENV_VARIABLE_REGEX: Final[str] = r"^[a-zA-Z_][a-zA-Z0-9_]*$"
34+
"""The regex to validate environment variable names.
35+
see Name at https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_235
36+
"""
37+
38+
ENV_VARIABLE_NAME_MATCHER: Final[Pattern[str]] = re.compile(ENV_VARIABLE_REGEX)
39+
"""The compiled regex to validate environment variable names."""

components/renku_data_services/session/core.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ def validate_unsaved_session_launcher(
167167
description=launcher.description,
168168
resource_class_id=launcher.resource_class_id,
169169
disk_storage=launcher.disk_storage,
170+
env_variables=models.EnvVar.from_apispec(launcher.env_variables) if launcher.env_variables else None,
170171
# NOTE: When you create an environment with a launcher the environment can only be custom
171172
environment=environment,
172173
)
@@ -297,12 +298,20 @@ def validate_session_launcher_patch(
297298
else:
298299
resource_class_id = patch.resource_class_id
299300
disk_storage = RESET if "disk_storage" in data_dict and data_dict["disk_storage"] is None else patch.disk_storage
301+
env_variables = (
302+
RESET
303+
if "env_variables" in data_dict and data_dict["env_variables"] is None
304+
else models.EnvVar.from_apispec(patch.env_variables)
305+
if patch.env_variables
306+
else None
307+
)
300308
return models.SessionLauncherPatch(
301309
name=patch.name,
302310
description=patch.description,
303311
environment=environment,
304312
resource_class_id=resource_class_id,
305313
disk_storage=disk_storage,
314+
env_variables=env_variables,
306315
)
307316

308317

components/renku_data_services/session/db.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,7 @@ async def insert_launcher(
474474
environment_id=environment_id,
475475
resource_class_id=launcher.resource_class_id,
476476
disk_storage=launcher.disk_storage,
477+
env_variables=models.EnvVar.to_dict(launcher.env_variables) if launcher.env_variables else None,
477478
created_by_id=user.id,
478479
creation_date=datetime.now(UTC).replace(microsecond=0),
479480
)
@@ -521,6 +522,7 @@ async def copy_launcher(
521522
environment_id=environment_id,
522523
resource_class_id=launcher.resource_class_id,
523524
disk_storage=launcher.disk_storage,
525+
env_variables=models.EnvVar.to_dict(launcher.env_variables) if launcher.env_variables else None,
524526
created_by_id=user.id,
525527
creation_date=datetime.now(UTC).replace(microsecond=0),
526528
)
@@ -599,6 +601,10 @@ async def update_launcher(
599601
launcher.disk_storage = patch.disk_storage
600602
elif patch.disk_storage is RESET:
601603
launcher.disk_storage = None
604+
if isinstance(patch.env_variables, list):
605+
launcher.env_variables = models.EnvVar.to_dict(patch.env_variables)
606+
elif patch.env_variables is RESET:
607+
launcher.env_variables = None
602608

603609
if patch.environment is None:
604610
return launcher.dump()

components/renku_data_services/session/models.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
"""Models for sessions."""
22

3+
import typing
34
from dataclasses import dataclass
45
from datetime import datetime, timedelta
56
from enum import StrEnum
67
from pathlib import PurePosixPath
8+
from typing import TYPE_CHECKING
79

810
from ulid import ULID
911

1012
from renku_data_services import errors
1113
from renku_data_services.base_models.core import ResetType
1214
from renku_data_services.session import crs
1315

16+
if TYPE_CHECKING:
17+
from renku_data_services.session import apispec
18+
19+
from .constants import ENV_VARIABLE_NAME_MATCHER, ENV_VARIABLE_REGEX
20+
1421

1522
@dataclass(frozen=True, eq=True, kw_only=True)
1623
class Member:
@@ -142,6 +149,55 @@ class EnvironmentPatch:
142149
environment_image_source: EnvironmentImageSource | None = None
143150

144151

152+
# TODO: Verify that these limits are compatible with k8s
153+
MAX_NUMBER_ENV_VARIABLES: typing.Final[int] = 32
154+
MAX_LENGTH_ENV_VARIABLES_NAME: typing.Final[int] = 256
155+
MAX_LENGTH_ENV_VARIABLES_VALUE: typing.Final[int] = 1000
156+
157+
158+
@dataclass(frozen=True, eq=True, kw_only=True)
159+
class EnvVar:
160+
"""Model for an environment variable."""
161+
162+
name: str
163+
value: str | None = None
164+
165+
@classmethod
166+
def from_dict(cls, env_dict: dict[str, str | None]) -> list["EnvVar"]:
167+
"""Create a list of EnvVar instances from a dictionary."""
168+
return [cls(name=name, value=value) for name, value in env_dict.items()]
169+
170+
@classmethod
171+
def from_apispec(cls, env_variables: list["apispec.EnvVar"]) -> list["EnvVar"]:
172+
"""Create a list of EnvVar instances from apispec objects."""
173+
return [cls(name=env_var.name, value=env_var.value) for env_var in env_variables]
174+
175+
@classmethod
176+
def to_dict(cls, env_variables: list["EnvVar"]) -> dict[str, str | None]:
177+
"""Convert to dict."""
178+
return {var.name: var.value for var in env_variables}
179+
180+
def __post_init__(self) -> None:
181+
error_msgs: list[str] = []
182+
if len(self.name) > MAX_LENGTH_ENV_VARIABLES_NAME:
183+
error_msgs.append(
184+
f"Env variable name '{self.name}' is longer than {MAX_LENGTH_ENV_VARIABLES_NAME} characters."
185+
)
186+
if self.name.upper().startswith("RENKU"):
187+
error_msgs.append(f"Env variable name '{self.name}' should not start with 'RENKU'.")
188+
if ENV_VARIABLE_NAME_MATCHER.match(self.name) is None:
189+
error_msgs.append(f"Env variable name '{self.name}' must match the regex '{ENV_VARIABLE_REGEX}'.")
190+
if self.value and len(self.value) > MAX_LENGTH_ENV_VARIABLES_VALUE:
191+
error_msgs.append(
192+
f"Env variable value for '{self.name}' is longer than {MAX_LENGTH_ENV_VARIABLES_VALUE} characters."
193+
)
194+
195+
if error_msgs:
196+
if len(error_msgs) == 1:
197+
raise errors.ValidationError(message=error_msgs[0])
198+
raise errors.ValidationError(message="\n".join(error_msgs))
199+
200+
145201
@dataclass(frozen=True, eq=True, kw_only=True)
146202
class UnsavedSessionLauncher:
147203
"""Session launcher model that has not been persisted in the DB."""
@@ -151,6 +207,7 @@ class UnsavedSessionLauncher:
151207
description: str | None
152208
resource_class_id: int | None
153209
disk_storage: int | None
210+
env_variables: list[EnvVar] | None
154211
environment: str | UnsavedEnvironment | UnsavedBuildParameters
155212
"""When a string is passed for the environment it should be the ID of an existing environment."""
156213

@@ -176,6 +233,7 @@ class SessionLauncherPatch:
176233
environment: str | EnvironmentPatch | UnsavedEnvironment | UnsavedBuildParameters | None = None
177234
resource_class_id: int | None | ResetType = None
178235
disk_storage: int | None | ResetType = None
236+
env_variables: list[EnvVar] | None | ResetType = None
179237

180238

181239
@dataclass(frozen=True, eq=True, kw_only=True)

components/renku_data_services/session/orm.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ class SessionLauncherORM(BaseORM):
153153
disk_storage: Mapped[int | None] = mapped_column("disk_storage", BigInteger, default=None, nullable=True)
154154
"""Default value for requested disk storage."""
155155

156+
env_variables: Mapped[dict[str, str | None] | None] = mapped_column(
157+
"env_variables", JSONVariant, default=None, nullable=True
158+
)
159+
"""Environment variables to set in the session."""
160+
156161
@classmethod
157162
def load(cls, launcher: models.SessionLauncher) -> "SessionLauncherORM":
158163
"""Create SessionLauncherORM from the session launcher model."""
@@ -165,6 +170,7 @@ def load(cls, launcher: models.SessionLauncher) -> "SessionLauncherORM":
165170
environment_id=launcher.environment.id,
166171
resource_class_id=launcher.resource_class_id,
167172
disk_storage=launcher.disk_storage,
173+
env_variables=models.EnvVar.to_dict(launcher.env_variables) if launcher.env_variables else None,
168174
)
169175

170176
def dump(self) -> models.SessionLauncher:
@@ -178,6 +184,7 @@ def dump(self) -> models.SessionLauncher:
178184
description=self.description,
179185
resource_class_id=self.resource_class_id,
180186
disk_storage=self.disk_storage,
187+
env_variables=models.EnvVar.from_dict(self.env_variables) if self.env_variables else None,
181188
environment=self.environment.dump(),
182189
)
183190

0 commit comments

Comments
 (0)