Skip to content

Commit 4742fab

Browse files
committed
fix: allow session launcher parameters to be reset (#434)
Allows the API to accept None as input for args, command and the session launcher resource class ID so that they can be reset to their defaults in patch endpoints.
1 parent 308e6a3 commit 4742fab

5 files changed

Lines changed: 131 additions & 5 deletions

File tree

components/renku_data_services/base_models/core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,4 +220,4 @@ async def authenticate(self, access_token: str, request: Request) -> AnyAPIUser:
220220
"""
221221

222222
RESET: ResetType = ResetType(object())
223-
"""The single instance of the ResetType, can be compared to similar to None, i.e. `if value is RESET`"""
223+
"""The single instance of the ResetType, can be compared to similar to None, i.e. `if value is RESET`"""
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Code used to convert from/to apispec and models."""
2+
3+
from pathlib import PurePosixPath
4+
5+
from renku_data_services.base_models.core import RESET, ResetType
6+
from renku_data_services.session import apispec, models
7+
8+
9+
def environment_update_from_patch(data: apispec.EnvironmentPatch) -> models.EnvironmentUpdate:
10+
"""Create an update object from an apispec or any other pydantic model."""
11+
data_dict = data.model_dump(exclude_unset=True, mode="json")
12+
working_directory: PurePosixPath | None = None
13+
if data.working_directory is not None:
14+
working_directory = PurePosixPath(data.working_directory)
15+
mount_directory: PurePosixPath | None = None
16+
if data.mount_directory is not None:
17+
mount_directory = PurePosixPath(data.mount_directory)
18+
# NOTE: If the args or command are present in the data_dict and they are None they were passed in by the user.
19+
# The None specifically passed by the user indicates that the value should be removed from the DB.
20+
args = RESET if "args" in data_dict and data_dict["args"] is None else data.args
21+
command = RESET if "command" in data_dict and data_dict["command"] is None else data.command
22+
return models.EnvironmentUpdate(
23+
name=data.name,
24+
description=data.description,
25+
container_image=data.container_image,
26+
default_url=data.default_url,
27+
port=data.port,
28+
working_directory=working_directory,
29+
mount_directory=mount_directory,
30+
uid=data.uid,
31+
gid=data.gid,
32+
args=args,
33+
command=command,
34+
)
35+
36+
37+
def launcher_update_from_patch(
38+
data: apispec.SessionLauncherPatch,
39+
current_launcher: models.SessionLauncher | None = None,
40+
) -> models.SessionLauncherUpdate:
41+
"""Create an update object from an apispec or any other pydantic model."""
42+
data_dict = data.model_dump(exclude_unset=True, mode="json")
43+
environment: str | models.EnvironmentUpdate | models.UnsavedEnvironment | None = None
44+
if (
45+
isinstance(data.environment, apispec.EnvironmentPatchInLauncher)
46+
and current_launcher is not None
47+
and current_launcher.environment.environment_kind == models.EnvironmentKind.GLOBAL
48+
and data.environment.environment_kind == apispec.EnvironmentKind.CUSTOM
49+
):
50+
# This means that the global environment is being swapped for a custom one,
51+
# so we have to create a brand new environment, but we have to validate here.
52+
validated_env = apispec.EnvironmentPostInLauncher.model_validate(data_dict["environment"])
53+
environment = models.UnsavedEnvironment(
54+
name=validated_env.name,
55+
description=validated_env.description,
56+
container_image=validated_env.container_image,
57+
default_url=validated_env.default_url,
58+
port=validated_env.port,
59+
working_directory=PurePosixPath(validated_env.working_directory),
60+
mount_directory=PurePosixPath(validated_env.mount_directory),
61+
uid=validated_env.uid,
62+
gid=validated_env.gid,
63+
environment_kind=models.EnvironmentKind(validated_env.environment_kind.value),
64+
args=validated_env.args,
65+
command=validated_env.command,
66+
)
67+
elif isinstance(data.environment, apispec.EnvironmentPatchInLauncher):
68+
environment = environment_update_from_patch(data.environment)
69+
elif isinstance(data.environment, apispec.EnvironmentIdOnlyPatch):
70+
environment = data.environment.id
71+
resource_class_id: int | None | ResetType = None
72+
if "resource_class_id" in data_dict and data_dict["resource_class_id"] is None:
73+
# NOTE: This means that the resource class set in the DB should be removed so that the
74+
# default resource class currently set in the CRC will be used.
75+
resource_class_id = RESET
76+
else:
77+
resource_class_id = data_dict.get("resource_class_id")
78+
return models.SessionLauncherUpdate(
79+
name=data_dict.get("name"),
80+
description=data_dict.get("description"),
81+
environment=environment,
82+
resource_class_id=resource_class_id,
83+
)

components/renku_data_services/session/db.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ async def update_environment(
147147
res = await session.scalars(
148148
select(schemas.EnvironmentORM)
149149
.where(schemas.EnvironmentORM.id == str(environment_id))
150-
.where(schemas.EnvironmentORM.environment_kind == models.EnvironmentKind.GLOBAL.value)
150+
.where(schemas.EnvironmentORM.environment_kind == models.EnvironmentKind.GLOBAL)
151151
)
152152
environment = res.one_or_none()
153153
if environment is None:

components/renku_data_services/session/models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,23 @@ class Environment(UnsavedEnvironment):
7373
gid: int
7474

7575

76+
@dataclass(kw_only=True, frozen=True, eq=True)
77+
class EnvironmentUpdate:
78+
"""Model for the update of some or all parts of an environment."""
79+
80+
name: str | None = None
81+
description: str | None = None
82+
container_image: str | None = None
83+
default_url: str | None = None
84+
port: int | None = None
85+
working_directory: PurePosixPath | None = None
86+
mount_directory: PurePosixPath | None = None
87+
uid: int | None = None
88+
gid: int | None = None
89+
args: list[str] | None | ResetType = None
90+
command: list[str] | None | ResetType = None
91+
92+
7693
@dataclass(frozen=True, eq=True, kw_only=True)
7794
class EnvironmentPatch:
7895
"""Model for changes requested on a session environment."""

test/bases/renku_data_services/data_api/test_sessions.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import shutil
55
from asyncio import AbstractEventLoop
6+
from collections.abc import Iterator
67
from typing import Any
78

89
import pytest
@@ -18,8 +19,8 @@
1819
os.environ["KUBECONFIG"] = ".k3d-config.yaml"
1920

2021

21-
@pytest.fixture(scope="module", autouse=True)
22-
def cluster() -> K3DCluster:
22+
@pytest.fixture(scope="module")
23+
def cluster() -> Iterator[K3DCluster]:
2324
if shutil.which("k3d") is None:
2425
pytest.skip("Requires k3d for cluster creation")
2526

@@ -172,10 +173,14 @@ async def test_patch_session_environment(
172173
env = await create_session_environment("Environment 1")
173174
environment_id = env["id"]
174175

176+
command = ["python", "test.py"]
177+
args = ["arg1", "arg2"]
175178
payload = {
176179
"name": "New name",
177180
"description": "New description.",
178181
"container_image": "new_image:new_tag",
182+
"command": command,
183+
"args": args,
179184
}
180185

181186
_, res = await sanic_client.patch(f"/api/data/environments/{environment_id}", headers=admin_headers, json=payload)
@@ -185,6 +190,14 @@ async def test_patch_session_environment(
185190
assert res.json.get("name") == "New name"
186191
assert res.json.get("description") == "New description."
187192
assert res.json.get("container_image") == "new_image:new_tag"
193+
assert res.json.get("args") == args
194+
assert res.json.get("command") == command
195+
196+
# Test that patching with None will reset the command and args
197+
payload = {"args": None, "command": None}
198+
_, res = await sanic_client.patch(f"/api/data/environments/{environment_id}", headers=admin_headers, json=payload)
199+
assert res.json.get("args") is None
200+
assert res.json.get("command") is None
188201

189202

190203
@pytest.mark.asyncio
@@ -515,13 +528,25 @@ async def test_patch_session_launcher_environment(
515528

516529
# Should be able to patch some fields of the custom environment
517530
patch_payload = {
518-
"environment": {"container_image": "nginx:latest"},
531+
"environment": {"container_image": "nginx:latest", "args": ["a", "b", "c"]},
519532
}
520533
_, res = await sanic_client.patch(
521534
f"/api/data/session_launchers/{launcher_id}", headers=user_headers, json=patch_payload
522535
)
523536
assert res.status_code == 200, res.text
524537
assert res.json["environment"]["container_image"] == "nginx:latest"
538+
assert res.json["environment"]["args"] == ["a", "b", "c"]
539+
540+
# Should be able to reset args by patching in None, pathcing a null field should do nothing
541+
patch_payload = {
542+
"environment": {"args": None, "command": None},
543+
}
544+
_, res = await sanic_client.patch(
545+
f"/api/data/session_launchers/{launcher_id}", headers=user_headers, json=patch_payload
546+
)
547+
assert res.status_code == 200, res.text
548+
assert res.json["environment"].get("args") is None
549+
assert res.json["environment"].get("command") is None
525550

526551

527552
@pytest.fixture
@@ -540,6 +565,7 @@ async def test_starting_session_anonymous(
540565
admin_headers,
541566
launch_session,
542567
anonymous_user_headers,
568+
cluster,
543569
) -> None:
544570
_, res = await sanic_client.post(
545571
"/api/data/resource_pools",

0 commit comments

Comments
 (0)