• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SwissDataScienceCenter / renku-data-services / 12318338392

13 Dec 2024 03:21PM UTC coverage: 86.388% (-0.001%) from 86.389%
12318338392

Pull #572

github

web-flow
Merge 0eb4f5dce into 9cb5d8146
Pull Request #572: feat: manage v1 sessions

17 of 32 new or added lines in 5 files covered. (53.13%)

4 existing lines in 4 files now uncovered.

14641 of 16948 relevant lines covered (86.39%)

1.52 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

61.95
/components/renku_data_services/notebooks/core.py
1
"""Notebooks service core implementation."""
2

3
import json as json_lib
2✔
4
from datetime import UTC, datetime
2✔
5
from math import floor
2✔
6
from pathlib import PurePosixPath
2✔
7
from typing import Any
2✔
8

9
import escapism
2✔
10
import requests
2✔
11
from gitlab.const import Visibility as GitlabVisibility
2✔
12
from gitlab.v4.objects.projects import Project as GitlabProject
2✔
13
from sanic.log import logger
2✔
14
from sanic.response import JSONResponse
2✔
15

16
from renku_data_services.base_models import AnonymousAPIUser, APIUser, AuthenticatedAPIUser
2✔
17
from renku_data_services.base_models.validation import validated_json
2✔
18
from renku_data_services.errors import errors
2✔
19
from renku_data_services.notebooks import apispec
2✔
20
from renku_data_services.notebooks.api.classes.auth import GitlabToken, RenkuTokens
2✔
21
from renku_data_services.notebooks.api.classes.image import Image
2✔
22
from renku_data_services.notebooks.api.classes.repository import Repository
2✔
23
from renku_data_services.notebooks.api.classes.server import Renku1UserServer, UserServer
2✔
24
from renku_data_services.notebooks.api.classes.server_manifest import UserServerManifest
2✔
25
from renku_data_services.notebooks.api.classes.user import NotebooksGitlabClient
2✔
26
from renku_data_services.notebooks.api.schemas.cloud_storage import RCloneStorage
2✔
27
from renku_data_services.notebooks.api.schemas.secrets import K8sUserSecrets
2✔
28
from renku_data_services.notebooks.api.schemas.server_options import ServerOptions
2✔
29
from renku_data_services.notebooks.api.schemas.servers_get import NotebookResponse
2✔
30
from renku_data_services.notebooks.api.schemas.servers_patch import PatchServerStatusEnum
2✔
31
from renku_data_services.notebooks.config import NotebooksConfig
2✔
32
from renku_data_services.notebooks.errors import intermittent
2✔
33
from renku_data_services.notebooks.errors import user as user_errors
2✔
34
from renku_data_services.notebooks.util import repository
2✔
35
from renku_data_services.notebooks.util.kubernetes_ import find_container, renku_1_make_server_name
2✔
36

37

38
def notebooks_info(config: NotebooksConfig) -> dict:
2✔
39
    """Returns notebooks configuration information."""
40

41
    culling = config.sessions.culling
1✔
42
    info = {
1✔
43
        "name": "renku-notebooks",
44
        "versions": [
45
            {
46
                "version": config.version,
47
                "data": {
48
                    "anonymousSessionsEnabled": config.anonymous_sessions_enabled,
49
                    "cloudstorageEnabled": config.cloud_storage.enabled,
50
                    "cloudstorageClass": config.cloud_storage.storage_class,
51
                    "sshEnabled": config.ssh_enabled,
52
                    "defaultCullingThresholds": {
53
                        "registered": {
54
                            "idle": culling.registered.idle_seconds,
55
                            "hibernation": culling.registered.hibernated_seconds,
56
                        },
57
                        "anonymous": {
58
                            "idle": culling.anonymous.idle_seconds,
59
                            "hibernation": culling.anonymous.hibernated_seconds,
60
                        },
61
                    },
62
                },
63
            }
64
        ],
65
    }
66
    return info
1✔
67

68

69
async def user_servers(
2✔
70
    config: NotebooksConfig, user: AnonymousAPIUser | AuthenticatedAPIUser, filter_attrs: list[dict]
71
) -> dict[str, UserServerManifest]:
72
    """Returns a filtered list of servers for the given user."""
73

74
    servers = [
1✔
75
        UserServerManifest(s, config.sessions.default_image) for s in await config.k8s_client.list_servers(user.id)
76
    ]
77
    filtered_servers = {}
1✔
78
    ann_prefix = config.session_get_endpoint_annotations.renku_annotation_prefix
1✔
79
    for server in servers:
1✔
80
        if all([server.annotations.get(f"{ann_prefix}{key}") == value for key, value in filter_attrs]):
1✔
81
            filtered_servers[server.server_name] = server
1✔
82
    return filtered_servers
1✔
83

84

85
async def user_server(
2✔
86
    config: NotebooksConfig, user: AnonymousAPIUser | AuthenticatedAPIUser, server_name: str
87
) -> UserServerManifest:
88
    """Returns the requested server for the user."""
89

90
    server = await config.k8s_client.get_server(server_name, user.id)
×
91
    if server is None:
×
92
        raise errors.MissingResourceError(message=f"The server {server_name} does not exist.")
×
93
    return UserServerManifest(server, config.sessions.default_image)
×
94

95

96
async def patch_server(
2✔
97
    config: NotebooksConfig,
98
    user: AnonymousAPIUser | AuthenticatedAPIUser,
99
    internal_gitlab_user: APIUser,
100
    server_name: str,
101
    patch_body: apispec.PatchServerRequest,
102
) -> UserServerManifest:
103
    """Applies patch to the given server."""
104

105
    if not config.sessions.storage.pvs_enabled:
1✔
106
        raise intermittent.PVDisabledError()
×
107

108
    server = await config.k8s_client.get_server(server_name, user.id)
1✔
109
    if server is None:
1✔
110
        raise errors.MissingResourceError(message=f"The server with name {server_name} cannot be found")
1✔
111
    if server.spec is None:
1✔
112
        raise errors.ProgrammingError(message="The server manifest is absent")
×
113

114
    new_server = server
1✔
115
    currently_hibernated = server.spec.jupyterServer.hibernated
1✔
116
    currently_failing = server.status.get("state", "running") == "failed"
1✔
117
    state = PatchServerStatusEnum.from_api_state(patch_body.state) if patch_body.state is not None else None
1✔
118
    resource_class_id = patch_body.resource_class_id
1✔
119
    if server and not (currently_hibernated or currently_failing) and resource_class_id:
1✔
120
        raise user_errors.UserInputError(
×
121
            message="The resource class can be changed only if the server is hibernated or failing"
122
        )
123

124
    if resource_class_id:
1✔
125
        parsed_server_options = await config.crc_validator.validate_class_storage(
×
126
            user,
127
            resource_class_id,
128
            storage=None,  # we do not care about validating storage
129
        )
130
        js_patch: list[dict[str, Any]] = [
×
131
            {
132
                "op": "replace",
133
                "path": "/spec/jupyterServer/resources",
134
                "value": parsed_server_options.to_k8s_resources(config.sessions.enforce_cpu_limits),
135
            },
136
            {
137
                "op": "replace",
138
                # NOTE: ~1 is how you escape '/' in json-patch
139
                "path": "/metadata/annotations/renku.io~1resourceClassId",
140
                "value": str(resource_class_id),
141
            },
142
        ]
143
        if parsed_server_options.priority_class:
×
144
            js_patch.append(
×
145
                {
146
                    "op": "replace",
147
                    # NOTE: ~1 is how you escape '/' in json-patch
148
                    "path": "/metadata/labels/renku.io~1quota",
149
                    "value": parsed_server_options.priority_class,
150
                }
151
            )
152
        elif server.metadata.labels.get("renku.io/quota"):
×
153
            js_patch.append(
×
154
                {
155
                    "op": "remove",
156
                    # NOTE: ~1 is how you escape '/' in json-patch
157
                    "path": "/metadata/labels/renku.io~1quota",
158
                }
159
            )
160
        new_server = await config.k8s_client.patch_server(
×
161
            server_name=server_name, safe_username=user.id, patch=js_patch
162
        )
163
        ss_patch: list[dict[str, Any]] = [
×
164
            {
165
                "op": "replace",
166
                "path": "/spec/template/spec/priorityClassName",
167
                "value": parsed_server_options.priority_class,
168
            }
169
        ]
170
        await config.k8s_client.patch_statefulset(server_name=server_name, patch=ss_patch)
×
171

172
    if state == PatchServerStatusEnum.Hibernated:
1✔
173
        # NOTE: Do nothing if server is already hibernated
174
        currently_hibernated = server.spec.jupyterServer.hibernated
1✔
175
        if server and currently_hibernated:
1✔
176
            logger.warning(f"Server {server_name} is already hibernated.")
×
177

178
            return UserServerManifest(server, config.sessions.default_image)
×
179

180
        hibernation: dict[str, str | bool] = {"branch": "", "commit": "", "dirty": "", "synchronized": ""}
1✔
181

182
        sidecar_patch = find_container(server.spec.patches, "git-sidecar")
1✔
183
        status = (
1✔
184
            repository.get_status(
185
                server_name=server_name,
186
                access_token=user.access_token,
187
                hostname=config.sessions.ingress.host,
188
            )
189
            if sidecar_patch is not None
190
            else None
191
        )
192
        if status:
1✔
193
            hibernation = {
×
194
                "branch": status.get("branch", ""),
195
                "commit": status.get("commit", ""),
196
                "dirty": not status.get("clean", True),
197
                "synchronized": status.get("ahead", 0) == status.get("behind", 0) == 0,
198
            }
199

200
        hibernation["date"] = datetime.now(UTC).isoformat(timespec="seconds")
1✔
201

202
        patch = {
1✔
203
            "metadata": {
204
                "annotations": {
205
                    "renku.io/hibernation": json_lib.dumps(hibernation),
206
                    "renku.io/hibernationBranch": hibernation["branch"],
207
                    "renku.io/hibernationCommitSha": hibernation["commit"],
208
                    "renku.io/hibernationDirty": str(hibernation["dirty"]).lower(),
209
                    "renku.io/hibernationSynchronized": str(hibernation["synchronized"]).lower(),
210
                    "renku.io/hibernationDate": hibernation["date"],
211
                },
212
            },
213
            "spec": {
214
                "jupyterServer": {
215
                    "hibernated": True,
216
                },
217
            },
218
        }
219

220
        new_server = await config.k8s_client.patch_server(server_name=server_name, safe_username=user.id, patch=patch)
1✔
221
    elif state == PatchServerStatusEnum.Running:
×
222
        # NOTE: We clear hibernation annotations in Amalthea to avoid flickering in the UI (showing
223
        # the repository as dirty when resuming a session for a short period of time).
224
        patch = {
×
225
            "spec": {
226
                "jupyterServer": {
227
                    "hibernated": False,
228
                },
229
            },
230
        }
231
        # NOTE: The tokens in the session could expire if the session is hibernated long enough,
232
        # here we inject new ones to make sure everything is valid when the session starts back up.
233
        if user.access_token is None or user.refresh_token is None or internal_gitlab_user.access_token is None:
×
234
            raise errors.UnauthorizedError(message="Cannot patch the server if the user is not fully logged in.")
×
235
        renku_tokens = RenkuTokens(access_token=user.access_token, refresh_token=user.refresh_token)
×
236
        gitlab_token = GitlabToken(
×
237
            access_token=internal_gitlab_user.access_token,
238
            expires_at=(
239
                floor(user.access_token_expires_at.timestamp()) if user.access_token_expires_at is not None else -1
240
            ),
241
        )
242
        await config.k8s_client.patch_tokens(server_name, renku_tokens, gitlab_token)
×
243
        new_server = await config.k8s_client.patch_server(server_name=server_name, safe_username=user.id, patch=patch)
×
244

245
    return UserServerManifest(new_server, config.sessions.default_image)
1✔
246

247

248
async def stop_server(config: NotebooksConfig, user: AnonymousAPIUser | AuthenticatedAPIUser, server_name: str) -> None:
2✔
249
    """Stops / deletes the requested server."""
250

251
    await config.k8s_client.delete_server(server_name, safe_username=user.id)
1✔
252

253

254
def server_options(config: NotebooksConfig) -> dict:
2✔
255
    """Returns the server's options configured."""
256

257
    return {
1✔
258
        **config.server_options.ui_choices,
259
        "cloudstorage": {
260
            "enabled": config.cloud_storage.enabled,
261
        },
262
    }
263

264

265
async def server_logs(
2✔
266
    config: NotebooksConfig, user: AnonymousAPIUser | AuthenticatedAPIUser, server_name: str, max_lines: int
267
) -> dict:
268
    """Returns the logs of the given server."""
269

270
    return await config.k8s_client.get_server_logs(
1✔
271
        server_name=server_name,
272
        safe_username=user.id,
273
        max_log_lines=max_lines,
274
    )
275

276

277
async def docker_image_exists(config: NotebooksConfig, image_url: str, internal_gitlab_user: APIUser) -> bool:
2✔
278
    """Returns whether the passed docker image url exists.
279

280
    If the user is logged in the internal GitLab (Renku V1), set the
281
    credentials for the check.
282
    """
283

284
    parsed_image = Image.from_path(image_url)
1✔
285
    image_repo = parsed_image.repo_api().maybe_with_oauth2_token(config.git.registry, internal_gitlab_user.access_token)
1✔
286
    return await image_repo.image_exists(parsed_image)
1✔
287

288

289
async def docker_image_workdir(
2✔
290
    config: NotebooksConfig, image_url: str, internal_gitlab_user: APIUser
291
) -> PurePosixPath | None:
292
    """Returns the working directory for the image.
293

294
    If the user is logged in the internal GitLab (Renku V1), set the
295
    credentials for the check.
296
    """
297

298
    parsed_image = Image.from_path(image_url)
×
299
    image_repo = parsed_image.repo_api().maybe_with_oauth2_token(config.git.registry, internal_gitlab_user.access_token)
×
300
    return await image_repo.image_workdir(parsed_image)
×
301

302

303
async def launch_notebook_helper(
2✔
304
    nb_config: NotebooksConfig,
305
    server_name: str,
306
    server_class: type[UserServer],
307
    user: AnonymousAPIUser | AuthenticatedAPIUser,
308
    image: str | None,
309
    resource_class_id: int | None,
310
    storage: int | None,
311
    environment_variables: dict[str, str],
312
    user_secrets: apispec.UserSecrets | None,
313
    default_url: str,
314
    lfs_auto_fetch: bool,
315
    cloudstorage: list[apispec.RCloneStorageRequest],
316
    server_options: ServerOptions | dict | None,
317
    namespace: str | None,  # Renku 1.0
318
    project: str | None,  # Renku 1.0
319
    branch: str | None,  # Renku 1.0
320
    commit_sha: str | None,  # Renku 1.0
321
    notebook: str | None,  # Renku 1.0
322
    gl_project: GitlabProject | None,  # Renku 1.0
323
    gl_project_path: str | None,  # Renku 1.0
324
    project_id: str | None,  # Renku 2.0
325
    launcher_id: str | None,  # Renku 2.0
326
    repositories: list[apispec.LaunchNotebookRequestRepository] | None,  # Renku 2.0
327
    internal_gitlab_user: APIUser,
328
) -> tuple[UserServerManifest, int]:
329
    """Helper function to launch a Jupyter server."""
330

331
    server = await nb_config.k8s_client.get_server(server_name, user.id)
1✔
332

333
    if server:
1✔
334
        return UserServerManifest(server, nb_config.sessions.default_image, nb_config.sessions.storage.pvs_enabled), 200
×
335

336
    gl_project_path = gl_project_path if gl_project_path is not None else ""
1✔
337

338
    # Add annotation for old and new notebooks
339
    is_image_private = False
1✔
340
    using_default_image = False
1✔
341
    if image:
1✔
342
        # A specific image was requested
343
        parsed_image = Image.from_path(image)
1✔
344
        image_repo = parsed_image.repo_api()
1✔
345
        image_exists_publicly = await image_repo.image_exists(parsed_image)
1✔
346
        image_exists_privately = False
1✔
347
        if (
1✔
348
            not image_exists_publicly
349
            and parsed_image.hostname == nb_config.git.registry
350
            and internal_gitlab_user.access_token
351
        ):
352
            image_repo = image_repo.with_oauth2_token(internal_gitlab_user.access_token)
×
353
            image_exists_privately = await image_repo.image_exists(parsed_image)
×
354
        if not image_exists_privately and not image_exists_publicly:
1✔
355
            using_default_image = True
×
356
            image = nb_config.sessions.default_image
×
357
            parsed_image = Image.from_path(image)
×
358
        if image_exists_privately:
1✔
359
            is_image_private = True
×
360
    elif gl_project is not None:
×
361
        # An image was not requested specifically, use the one automatically built for the commit
362
        if commit_sha is None:
×
363
            raise errors.ValidationError(
×
364
                message="Cannot run a session with an image based on a commit sha if the commit sha is not known."
365
            )
366
        image = f"{nb_config.git.registry}/{gl_project.path_with_namespace.lower()}:{commit_sha[:7]}"
×
367
        parsed_image = Image(
×
368
            nb_config.git.registry,
369
            gl_project.path_with_namespace.lower(),
370
            commit_sha[:7],
371
        )
372
        # NOTE: a project pulled from the Gitlab API without credentials has no visibility attribute
373
        # and by default it can only be public since only public projects are visible to
374
        # non-authenticated users. Also, a nice footgun from the Gitlab API Python library.
375
        is_image_private = getattr(gl_project, "visibility", GitlabVisibility.PUBLIC) != GitlabVisibility.PUBLIC
×
376
        image_repo = parsed_image.repo_api().maybe_with_oauth2_token(
×
377
            nb_config.git.registry, internal_gitlab_user.access_token
378
        )
NEW
379
        if not await image_repo.image_exists(parsed_image):
×
380
            raise errors.MissingResourceError(
×
381
                message=(
382
                    f"Cannot start the session because the following the image {image} does not "
383
                    "exist or the user does not have the permissions to access it."
384
                )
385
            )
386
    else:
387
        raise user_errors.UserInputError(message="Cannot determine which Docker image to use.")
×
388

389
    parsed_server_options: ServerOptions | None = None
1✔
390
    if resource_class_id is not None:
1✔
391
        # A resource class ID was passed in, validate with CRC service
392
        parsed_server_options = await nb_config.crc_validator.validate_class_storage(user, resource_class_id, storage)
×
393
    elif server_options is not None:
1✔
394
        if isinstance(server_options, dict):
×
395
            requested_server_options = ServerOptions(
×
396
                memory=server_options["mem_request"],
397
                storage=server_options["disk_request"],
398
                cpu=server_options["cpu_request"],
399
                gpu=server_options["gpu_request"],
400
                lfs_auto_fetch=server_options["lfs_auto_fetch"],
401
                default_url=server_options["defaultUrl"],
402
            )
403
        elif isinstance(server_options, ServerOptions):
×
404
            requested_server_options = server_options
×
405
        else:
406
            raise errors.ProgrammingError(
×
407
                message="Got an unexpected type of server options when " f"launching sessions: {type(server_options)}"
408
            )
409
        # The old style API was used, try to find a matching class from the CRC service
410
        parsed_server_options = await nb_config.crc_validator.find_acceptable_class(user, requested_server_options)
×
411
        if parsed_server_options is None:
×
412
            raise user_errors.UserInputError(
×
413
                message="Cannot find suitable server options based on your request and "
414
                "the available resource classes.",
415
                detail="You are receiving this error because you are using the old API for "
416
                "selecting resources. Updating to the new API which includes specifying only "
417
                "a specific resource class ID and storage is preferred and more convenient.",
418
            )
419
    else:
420
        # No resource class ID specified or old-style server options, use defaults from CRC
421
        default_resource_class = await nb_config.crc_validator.get_default_class()
1✔
422
        max_storage_gb = default_resource_class.max_storage
1✔
423
        if storage is not None and storage > max_storage_gb:
1✔
424
            raise user_errors.UserInputError(
×
425
                message="The requested storage amount is higher than the "
426
                f"allowable maximum for the default resource class of {max_storage_gb}GB."
427
            )
428
        if storage is None:
1✔
429
            storage = default_resource_class.default_storage
×
430
        parsed_server_options = ServerOptions.from_resource_class(default_resource_class)
1✔
431
        # Storage in request is in GB
432
        parsed_server_options.set_storage(storage, gigabytes=True)
1✔
433

434
    if default_url is not None:
1✔
435
        parsed_server_options.default_url = default_url
1✔
436

437
    if lfs_auto_fetch is not None:
1✔
438
        parsed_server_options.lfs_auto_fetch = lfs_auto_fetch
1✔
439

440
    image_work_dir = await image_repo.image_workdir(parsed_image) or PurePosixPath("/")
1✔
441
    mount_path = image_work_dir / "work"
1✔
442

443
    server_work_dir = mount_path / gl_project_path
1✔
444

445
    storages: list[RCloneStorage] = []
1✔
446
    if cloudstorage:
1✔
447
        gl_project_id = gl_project.id if gl_project is not None else 0
×
448
        try:
×
449
            for cstorage in cloudstorage:
×
450
                storages.append(
×
451
                    await RCloneStorage.storage_from_schema(
452
                        cstorage.model_dump(),
453
                        user=user,
454
                        project_id=gl_project_id,
455
                        work_dir=server_work_dir,
456
                        config=nb_config,
457
                        internal_gitlab_user=internal_gitlab_user,
458
                    )
459
                )
460
        except errors.ValidationError as e:
×
461
            raise user_errors.UserInputError(message=f"Couldn't load cloud storage config: {str(e)}")
×
462
        mount_points = set(s.mount_folder for s in storages if s.mount_folder and s.mount_folder != "/")
×
463
        if len(mount_points) != len(storages):
×
464
            raise user_errors.UserInputError(
×
465
                "Storage mount points must be set, can't be at the root of the project and must be unique."
466
            )
467
        if any(s1.mount_folder.startswith(s2.mount_folder) for s1 in storages for s2 in storages if s1 != s2):
×
468
            raise user_errors.UserInputError(
×
469
                message="Cannot mount a cloud storage into the mount point of another cloud storage."
470
            )
471

472
    repositories = repositories or []
1✔
473

474
    k8s_user_secret = None
1✔
475
    if user_secrets:
1✔
476
        k8s_user_secret = K8sUserSecrets(f"{server_name}-secret", **user_secrets.model_dump())
×
477

478
    extra_kwargs: dict = dict(
1✔
479
        commit_sha=commit_sha,
480
        branch=branch,
481
        project=project,
482
        namespace=namespace,
483
        launcher_id=launcher_id,
484
        project_id=project_id,
485
        notebook=notebook,
486
        internal_gitlab_user=internal_gitlab_user,  # Renku 1
487
        gitlab_project=gl_project,  # Renku 1
488
    )
489
    server = server_class(
1✔
490
        user=user,
491
        image=image,
492
        server_name=server_name,
493
        server_options=parsed_server_options,
494
        environment_variables=environment_variables,
495
        user_secrets=k8s_user_secret,
496
        cloudstorage=storages,
497
        k8s_client=nb_config.k8s_client,
498
        workspace_mount_path=mount_path,
499
        work_dir=server_work_dir,
500
        using_default_image=using_default_image,
501
        is_image_private=is_image_private,
502
        repositories=[Repository.from_dict(r.model_dump()) for r in repositories],
503
        config=nb_config,
504
        **extra_kwargs,
505
    )
506

507
    if len(server.safe_username) > 63:
1✔
508
        raise user_errors.UserInputError(
×
509
            message="A username cannot be longer than 63 characters, "
510
            f"your username is {len(server.safe_username)} characters long.",
511
            detail="This can occur if your username has been changed manually or by an admin.",
512
        )
513

514
    manifest = await server.start()
1✔
515
    if manifest is None:
1✔
516
        raise errors.ProgrammingError(message="Failed to start server.")
×
517

518
    logger.debug(f"Server {server.server_name} has been started")
1✔
519

520
    if k8s_user_secret is not None:
1✔
521
        owner_reference = {
×
522
            "apiVersion": "amalthea.dev/v1alpha1",
523
            "kind": "JupyterServer",
524
            "name": server.server_name,
525
            "uid": manifest.metadata.uid,
526
        }
527
        request_data = {
×
528
            "name": k8s_user_secret.name,
529
            "namespace": server.k8s_client.preferred_namespace,
530
            "secret_ids": [str(id_) for id_ in k8s_user_secret.user_secret_ids],
531
            "owner_references": [owner_reference],
532
        }
533
        headers = {"Authorization": f"bearer {user.access_token}"}
×
534

535
        async def _on_error(server_name: str, error_msg: str) -> None:
×
536
            await nb_config.k8s_client.delete_server(server_name, safe_username=user.id)
×
537
            raise RuntimeError(error_msg)
×
538

539
        try:
×
540
            response = requests.post(
×
541
                nb_config.user_secrets.secrets_storage_service_url + "/api/secrets/kubernetes",
542
                json=request_data,
543
                headers=headers,
544
                timeout=10,
545
            )
546
        except requests.exceptions.ConnectionError:
×
547
            await _on_error(server.server_name, "User secrets storage service could not be contacted {exc}")
×
548

549
        if response.status_code != 201:
×
550
            await _on_error(server.server_name, f"User secret could not be created {response.json()}")
×
551

552
    return UserServerManifest(manifest, nb_config.sessions.default_image), 201
1✔
553

554

555
async def launch_notebook(
2✔
556
    config: NotebooksConfig,
557
    user: AnonymousAPIUser | AuthenticatedAPIUser,
558
    internal_gitlab_user: APIUser,
559
    launch_request: apispec.LaunchNotebookRequestOld,
560
) -> tuple[UserServerManifest, int]:
561
    """Starts a server using the old operator."""
562
    if isinstance(user, AnonymousAPIUser):
1✔
NEW
563
        safe_username = escapism.escape(user.id, escape_char="-").lower()
×
564
    else:
565
        safe_username = escapism.escape(user.email, escape_char="-").lower()
1✔
566
    server_name = renku_1_make_server_name(
1✔
567
        safe_username,
568
        launch_request.namespace,
569
        launch_request.project,
570
        launch_request.branch,
571
        launch_request.commit_sha,
572
    )
573
    project_slug = f"{launch_request.namespace}/{launch_request.project}"
1✔
574
    gitlab_client = NotebooksGitlabClient(config.git.url, internal_gitlab_user.access_token)
1✔
575
    gl_project = gitlab_client.get_renku_project(project_slug)
1✔
576
    if gl_project is None:
1✔
577
        raise errors.MissingResourceError(message=f"Cannot find gitlab project with slug {project_slug}")
×
578
    gl_project_path = gl_project.path
1✔
579
    server_class = Renku1UserServer
1✔
580
    server_options = (
1✔
581
        ServerOptions.from_server_options_request_schema(
582
            launch_request.serverOptions.model_dump(),
583
            config.server_options.default_url_default,
584
            config.server_options.lfs_auto_fetch_default,
585
        )
586
        if launch_request.serverOptions is not None
587
        else None
588
    )
589

590
    return await launch_notebook_helper(
1✔
591
        nb_config=config,
592
        server_name=server_name,
593
        server_class=server_class,
594
        user=user,
595
        image=launch_request.image,
596
        resource_class_id=launch_request.resource_class_id,
597
        storage=launch_request.storage,
598
        environment_variables=launch_request.environment_variables,
599
        user_secrets=launch_request.user_secrets,
600
        default_url=launch_request.default_url,
601
        lfs_auto_fetch=launch_request.lfs_auto_fetch,
602
        cloudstorage=launch_request.cloudstorage,
603
        server_options=server_options,
604
        namespace=launch_request.namespace,
605
        project=launch_request.project,
606
        branch=launch_request.branch,
607
        commit_sha=launch_request.commit_sha,
608
        notebook=launch_request.notebook,
609
        gl_project=gl_project,
610
        gl_project_path=gl_project_path,
611
        project_id=None,
612
        launcher_id=None,
613
        repositories=None,
614
        internal_gitlab_user=internal_gitlab_user,
615
    )
616

617

618
def serialize_v1_server(manifest: UserServerManifest, nb_config: NotebooksConfig, status: int = 200) -> JSONResponse:
2✔
619
    """Format and serialize a Renku v1 JupyterServer manifest."""
620
    data = NotebookResponse().dump(NotebookResponse.format_user_pod_data(manifest, nb_config))
1✔
621
    return validated_json(apispec.NotebookResponse, data, status=status, model_dump_kwargs=dict(by_alias=True))
1✔
622

623

624
def serialize_v1_servers(
2✔
625
    manifests: dict[str, UserServerManifest], nb_config: NotebooksConfig, status: int = 200
626
) -> JSONResponse:
627
    """Format and serialize many Renku v1 JupyterServer manifests."""
628
    data = {
1✔
629
        manifest.server_name: NotebookResponse().dump(NotebookResponse.format_user_pod_data(manifest, nb_config))
630
        for manifest in sorted(manifests.values(), key=lambda x: x.server_name)
631
    }
632
    return validated_json(
1✔
633
        apispec.ServersGetResponse, {"servers": data}, status=status, model_dump_kwargs=dict(by_alias=True)
634
    )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc