Skip to content

Commit 00f1571

Browse files
sgaistolevski
andcommitted
feat: ensure notebook endpoints do their job (#388)
This work brings the notebook related endpoints to a working state to serve as replacement for the renku-notebooks external service. Short summary: - API tests have been added - Code has been fixed to answer / handle exception - Automated test cluster creation has been added using k3d --------- Co-authored-by: Tasko Olevski <[email protected]>
1 parent b39396e commit 00f1571

File tree

27 files changed

+654
-89
lines changed

27 files changed

+654
-89
lines changed

.devcontainer/.poetry_cache/.keep

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +0,0 @@
1-

.devcontainer/devcontainer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
"command": "poetry self add poetry-polylith-plugin"
1212
},
1313
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
14-
"ghcr.io/mpriscella/features/kind:1": {},
1514
"ghcr.io/devcontainers-contrib/features/gh-release:1": {
1615
"repo": "authzed/zed",
1716
"binaryNames": "zed"
@@ -28,7 +27,8 @@
2827
"ghcr.io/EliiseS/devcontainer-features/bash-profile:1": {
2928
"command": "alias k=kubectl"
3029
},
31-
"ghcr.io/devcontainers-contrib/features/rclone:1": {}
30+
"ghcr.io/devcontainers-contrib/features/rclone:1": {},
31+
"./k3d": {}
3232
},
3333
"overrideFeatureInstallOrder": [
3434
"ghcr.io/devcontainers-contrib/features/poetry",
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"id": "k3d",
3+
"version": "1.0.0",
4+
"name": "k3s based kubernetes cluster in docker",
5+
"postCreateCommand": "k3d --version",
6+
"installsAfter": [
7+
"ghcr.io/devcontainers-contrib/features/bash-command"
8+
],
9+
"options": {
10+
"k3d_version": {
11+
"type": "string",
12+
"description": "k3d version to install",
13+
"proposals": ["latest", "5.7.4"],
14+
"default": "latest"
15+
}
16+
}
17+
}

.devcontainer/k3d/install.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
if [ "${K3D_VERSION}" != "none" ]; then
2+
echo "Downloading k3d..."
3+
if [ "${K3D_VERSION}" = "latest" ]; then
4+
# Install and check the hash
5+
curl -sSL https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
6+
else
7+
find_version_from_git_tags K3D_VERSION https://github.com/kubernetes/K3D
8+
if [ "${K3D_VERSION::1}" != "v" ]; then
9+
K3D_VERSION="v${K3D_VERSION}"
10+
fi
11+
# Install and check the hash
12+
curl -sSL https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | TAG="${K3D_VERSION}" bash
13+
fi
14+
fi

.github/workflows/test_publish.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ jobs:
9595
- uses: actions/checkout@v4
9696
with:
9797
fetch-depth: 0
98+
- uses: actions/cache/restore@v3
99+
name: Restore cache
100+
with:
101+
path: ${{ env.CACHE_PATH }}
102+
key: ${{ env.CACHE_KEY }}
98103
- name: Set Git config
99104
shell: bash
100105
run: |

.pre-commit-config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ repos:
1818
- id: check-toml
1919
- id: debug-statements
2020
- id: end-of-file-fixer
21+
exclude: 'components/renku_data_services/message_queue/(avro_models|schemas)'
2122
- id: mixed-line-ending
2223
- id: trailing-whitespace
24+
exclude: 'components/renku_data_services/message_queue/(avro_models|schemas)'
2325
- repo: https://github.com/asottile/yesqa
2426
rev: v1.5.0
2527
hooks:

Makefile

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
.PHONY: schemas tests test_setup main_tests schemathesis_tests collect_coverage style_checks pre_commit_checks run download_avro check_avro avro_models update_avro kind_cluster install_amaltheas all
1+
.PHONY: schemas tests test_setup main_tests schemathesis_tests collect_coverage style_checks pre_commit_checks run download_avro check_avro avro_models update_avro k3d_cluster install_amaltheas all
22

33
AMALTHEA_JS_VERSION ?= 0.12.2
4-
AMALTHEA_SESSIONS_VERSION ?= 0.0.9-new-operator-chart
4+
AMALTHEA_SESSIONS_VERSION ?= 0.0.10-new-operator-chart
55
codegen_params = --input-file-type openapi --output-model-type pydantic_v2.BaseModel --use-double-quotes --target-python-version 3.12 --collapse-root-models --field-constraints --strict-nullable --set-default-enum-member --openapi-scopes schemas paths parameters --set-default-enum-member --use-one-literal-as-default --use-default
66

77
define test_apispec_up_to_date
@@ -151,21 +151,15 @@ help: ## Display this help.
151151

152152
##@ Helm/k8s
153153

154-
kind_cluster: ## Creates a kind cluster for testing
155-
kind delete cluster
156-
docker network rm -f kind
157-
docker network create -d=bridge -o com.docker.network.bridge.enable_ip_masquerade=true -o com.docker.network.driver.mtu=1500 --ipv6=false kind
158-
kind create cluster --config kind_config.yaml
159-
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
160-
echo "Waiting for ingress controller to initialize"
161-
sleep 15
162-
kubectl wait --namespace ingress-nginx --for=condition=ready pod --selector=app.kubernetes.io/component=controller --timeout=90s
154+
k3d_cluster: ## Creates a k3d cluster for testing
155+
k3d cluster delete
156+
k3d cluster create --agents 1 --k3s-arg --disable=metrics-server@server:0
163157

164158
install_amaltheas: ## Installs both version of amalthea in the. NOTE: It uses the currently active k8s context.
165159
helm repo add renku https://swissdatasciencecenter.github.io/helm-charts
166160
helm repo update
167161
helm upgrade --install amalthea-js renku/amalthea --version $(AMALTHEA_JS_VERSION)
168-
helm upgrade --install amalthea-sessions amalthea-sessions-0.0.9-new-operator-chart.tgz --version $(AMALTHEA_SESSIONS_VERSION)
162+
helm upgrade --install amalthea-se renku/amalthea-sessions --version ${AMALTHEA_SESSIONS_VERSION}
169163

170164
# TODO: Add the version variables from the top of the file here when the charts are fully published
171165
amalthea_schema: ## Updates generates pydantic classes from CRDs

components/renku_data_services/base_api/auth.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,30 @@ async def decorated_function(*args: _P.args, **kwargs: _P.kwargs) -> _T:
180180
return response
181181

182182
return decorated_function
183+
184+
185+
def internal_gitlab_authenticate(
186+
authenticator: Authenticator,
187+
) -> Callable[
188+
[Callable[Concatenate[Request, APIUser, APIUser, _P], Coroutine[Any, Any, _T]]],
189+
Callable[Concatenate[Request, APIUser, _P], Coroutine[Any, Any, _T]],
190+
]:
191+
"""Decorator for a Sanic handler that that adds a user for the internal gitlab user."""
192+
193+
def decorator(
194+
f: Callable[Concatenate[Request, APIUser, APIUser, _P], Coroutine[Any, Any, _T]],
195+
) -> Callable[Concatenate[Request, APIUser, _P], Coroutine[Any, Any, _T]]:
196+
@wraps(f)
197+
async def decorated_function(
198+
request: Request,
199+
user: APIUser,
200+
*args: _P.args,
201+
**kwargs: _P.kwargs,
202+
) -> _T:
203+
access_token = str(request.headers.get("Gitlab-Access-Token"))
204+
internal_gitlab_user = await authenticator.authenticate(access_token, request)
205+
return await f(request, user, internal_gitlab_user, *args, **kwargs)
206+
207+
return decorated_function
208+
209+
return decorator

components/renku_data_services/notebooks/api.spec.yaml

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -454,11 +454,8 @@ components:
454454
message:
455455
type: string
456456
example: "Something went wrong - please try again later"
457-
required:
458-
- "code"
459-
- "message"
460-
required:
461-
- "error"
457+
required: ["code", "message"]
458+
required: ["error"]
462459
Generated:
463460
properties:
464461
enabled:
@@ -881,7 +878,7 @@ components:
881878
type: integer
882879
description: The size of disk storage for the session, in gigabytes
883880
resource_class_id:
884-
default:
881+
default:
885882
nullable: true
886883
type: integer
887884
cloudstorage:

components/renku_data_services/notebooks/api/amalthea_patches/general.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def session_tolerations(server: "UserServer") -> list[dict[str, Any]]:
3030
"op": "add",
3131
"path": "/statefulset/spec/template/spec/tolerations",
3232
"value": default_tolerations
33-
+ [i.json_match_expression() for i in server.server_options.tolerations],
33+
+ [toleration.json_match_expression() for toleration in server.server_options.tolerations],
3434
}
3535
],
3636
}

components/renku_data_services/notebooks/api/amalthea_patches/init_containers.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -312,9 +312,9 @@ def certificates_container(config: _NotebooksConfig) -> tuple[client.V1Container
312312
projected=client.V1ProjectedVolumeSource(
313313
default_mode=440,
314314
sources=[
315-
{"secret": {"name": i.get("secret")}}
316-
for i in config.sessions.ca_certs.secrets
317-
if isinstance(i, dict) and i.get("secret") is not None
315+
{"secret": {"name": secret.get("secret")}}
316+
for secret in config.sessions.ca_certs.secrets
317+
if isinstance(secret, dict) and secret.get("secret") is not None
318318
],
319319
),
320320
)

components/renku_data_services/notebooks/api/classes/k8s_client.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,11 @@ async def get_pod_logs(self, name: str, max_log_lines: Optional[int] = None) ->
7979
"""Get the logs of all containers in the session."""
8080
pod = cast(Pod, await Pod.get(name=name, namespace=self.namespace))
8181
logs: dict[str, str] = {}
82-
containers = [i.name for i in pod.spec.containers] + [i.name for i in pod.spec.initContainers]
82+
containers = [container.name for container in pod.spec.containers + pod.spec.get("initContainers", [])]
8383
for container in containers:
8484
try:
8585
# NOTE: calling pod.logs without a container name set crashes the library
86-
clogs: list[str] = [i async for i in pod.logs(container=container, tail_lines=max_log_lines)]
86+
clogs: list[str] = [clog async for clog in pod.logs(container=container, tail_lines=max_log_lines)]
8787
except NotFoundError:
8888
raise errors.MissingResourceError(message=f"The session pod {name} does not exist.")
8989
except ServerError as err:
@@ -243,8 +243,10 @@ async def patch_statefulset_tokens(self, name: str, renku_tokens: RenkuTokens) -
243243
except NotFoundError:
244244
return None
245245

246-
containers: list[V1Container] = [V1Container(**i) for i in sts.spec.template.spec.containers]
247-
init_containers: list[V1Container] = [V1Container(**i) for i in sts.spec.template.spec.init_containers]
246+
containers: list[V1Container] = [V1Container(**container) for container in sts.spec.template.spec.containers]
247+
init_containers: list[V1Container] = [
248+
V1Container(**container) for container in sts.spec.template.spec.init_containers
249+
]
248250

249251
git_proxy_container_index, git_proxy_container = next(
250252
((i, c) for i, c in enumerate(containers) if c.name == "git-proxy"),
@@ -368,7 +370,7 @@ async def list_servers(self, safe_username: str) -> list[_SessionType]:
368370
)
369371
raise JSCacheError(f"The JSCache produced an unexpected status code: {res.status_code}")
370372

371-
return [self.server_type.model_validate(i) for i in res.json()]
373+
return [self.server_type.model_validate(server) for server in res.json()]
372374

373375
async def get_server(self, name: str) -> _SessionType | None:
374376
"""Get a specific jupyter server."""
@@ -441,7 +443,11 @@ async def get_server_logs(
441443
) -> dict[str, str]:
442444
"""Get the logs from the server."""
443445
# NOTE: this get_server ensures the user has access to the server without it you could read someone elses logs
444-
_ = await self.get_server(server_name, safe_username)
446+
server = await self.get_server(server_name, safe_username)
447+
if not server:
448+
raise MissingResourceError(
449+
f"Cannot find server {server_name} for user " f"{safe_username} to retrieve logs."
450+
)
445451
pod_name = f"{server_name}-0"
446452
return await self.renku_ns_client.get_pod_logs(pod_name, max_log_lines)
447453

@@ -481,9 +487,10 @@ async def delete_server(self, server_name: str, safe_username: str) -> None:
481487
"""Delete the server."""
482488
server = await self.get_server(server_name, safe_username)
483489
if not server:
484-
return None
485-
await self.renku_ns_client.delete_server(server_name)
486-
return None
490+
raise MissingResourceError(
491+
f"Cannot find server {server_name} for user " f"{safe_username} in order to delete it."
492+
)
493+
return await self.renku_ns_client.delete_server(server_name)
487494

488495
async def patch_tokens(self, server_name: str, renku_tokens: RenkuTokens, gitlab_token: GitlabToken) -> None:
489496
"""Patch the Renku and Gitlab access tokens used in a session."""

components/renku_data_services/notebooks/api/classes/server.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Any
88
from urllib.parse import urljoin, urlparse
99

10+
from gitlab.v4.objects.projects import Project
1011
from sanic.log import logger
1112

1213
from renku_data_services.base_models import AnonymousAPIUser, AuthenticatedAPIUser
@@ -22,7 +23,6 @@
2223
from renku_data_services.notebooks.api.classes.cloud_storage import ICloudStorageRequest
2324
from renku_data_services.notebooks.api.classes.k8s_client import JupyterServerV1Alpha1Kr8s, K8sClient
2425
from renku_data_services.notebooks.api.classes.repository import GitProvider, Repository
25-
from renku_data_services.notebooks.api.classes.user import NotebooksGitlabClient
2626
from renku_data_services.notebooks.api.schemas.secrets import K8sUserSecrets
2727
from renku_data_services.notebooks.api.schemas.server_options import ServerOptions
2828
from renku_data_services.notebooks.config import _NotebooksConfig
@@ -155,7 +155,8 @@ async def start(self) -> JupyterServerV1Alpha1 | None:
155155
f"or Docker resources are missing: {', '.join(errors)}"
156156
)
157157
)
158-
manifest = JupyterServerV1Alpha1.model_validate(await self._get_session_manifest())
158+
session_manifest = await self._get_session_manifest()
159+
manifest = JupyterServerV1Alpha1.model_validate(session_manifest)
159160
return await self._k8s_client.create_server(manifest, self.safe_username)
160161

161162
@staticmethod
@@ -321,7 +322,9 @@ def get_labels(self) -> dict[str, str | None]:
321322
f"{prefix}commit-sha": None,
322323
f"{prefix}gitlabProjectId": None,
323324
f"{prefix}safe-username": self.safe_username,
324-
f"{prefix}quota": self.server_options.priority_class,
325+
f"{prefix}quota": self.server_options.priority_class
326+
if self.server_options.priority_class is not None
327+
else "",
325328
f"{prefix}userId": self._user.id,
326329
}
327330
return labels
@@ -378,23 +381,23 @@ def __init__(
378381
workspace_mount_path: Path,
379382
work_dir: Path,
380383
config: _NotebooksConfig,
381-
gitlab_client: NotebooksGitlabClient,
384+
gitlab_project: Project | None,
382385
internal_gitlab_user: APIUser,
383386
using_default_image: bool = False,
384387
is_image_private: bool = False,
388+
**_: dict,
385389
):
386-
self.gitlab_client = gitlab_client
390+
self.gitlab_project = gitlab_project
387391
self.internal_gitlab_user = internal_gitlab_user
388-
gitlab_project_name = f"{namespace}/{project}"
389-
gitlab_project = self.gitlab_client.get_renku_project(gitlab_project_name)
392+
self.gitlab_project_name = f"{namespace}/{project}"
390393
single_repository = (
391394
Repository(
392-
url=gitlab_project.http_url_to_repo,
393-
dirname=gitlab_project.path,
395+
url=self.gitlab_project.http_url_to_repo,
396+
dirname=self.gitlab_project.path,
394397
branch=branch,
395398
commit_sha=commit_sha,
396399
)
397-
if gitlab_project is not None
400+
if self.gitlab_project is not None
398401
else None
399402
)
400403

@@ -422,8 +425,6 @@ def __init__(
422425
self.commit_sha = commit_sha
423426
self.notebook = notebook
424427
self.git_host = urlparse(config.git.url).netloc
425-
self.gitlab_project_name = gitlab_project_name
426-
self.gitlab_project = gitlab_project
427428
self.single_repository = single_repository
428429

429430
def _get_start_errors(self) -> list[str]:
@@ -509,6 +510,7 @@ def __init__(
509510
internal_gitlab_user: APIUser,
510511
using_default_image: bool = False,
511512
is_image_private: bool = False,
513+
**_: dict,
512514
):
513515
super().__init__(
514516
user=user,
@@ -531,11 +533,27 @@ def __init__(
531533
self.project_id = project_id
532534
self.launcher_id = launcher_id
533535

536+
def get_labels(self) -> dict[str, str | None]:
537+
"""Get the labels of the jupyter server."""
538+
prefix = self._get_renku_annotation_prefix()
539+
labels = super().get_labels()
540+
541+
# for validation purpose
542+
for item in ["commit-sha", "gitlabProjectId"]:
543+
labels[f"{prefix}{item}"] = ""
544+
545+
return labels
546+
534547
def get_annotations(self) -> dict[str, str | None]:
535548
"""Get the annotations of the session."""
536549
prefix = self._get_renku_annotation_prefix()
537550
annotations = super().get_annotations()
538551
annotations[f"{prefix}renkuVersion"] = "2.0"
539552
annotations[f"{prefix}projectId"] = self.project_id
540553
annotations[f"{prefix}launcherId"] = self.launcher_id
554+
555+
# for validation purpose
556+
for item in ["commit-sha", "branch", "git-host", "namespace", "projectName", "gitlabProjectId", "repository"]:
557+
annotations[f"{prefix}{item}"] = ""
558+
541559
return annotations

components/renku_data_services/notebooks/api/classes/server_manifest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def server_name(self) -> str:
9797
@property
9898
def hibernation(self) -> Optional[dict[str, Any]]:
9999
"""Return hibernation annotation."""
100-
hibernation = self.manifest.metadata.annotations.get("hibernation")
100+
hibernation = self.manifest.metadata.annotations.get("renku.io/hibernation")
101101
return json.loads(hibernation) if hibernation else None
102102

103103
@property

components/renku_data_services/notebooks/api/schemas/server_options.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,12 @@ def __post_init__(self) -> None:
6464
self.storage = 1
6565
elif self.storage is None and not self.gigabytes:
6666
self.storage = 1_000_000_000
67-
if not all([isinstance(i, NodeAffinity) for i in self.node_affinities]):
67+
if not all([isinstance(affinity, NodeAffinity) for affinity in self.node_affinities]):
6868
raise ProgrammingError(
6969
message="Cannot create a ServerOptions dataclass with node "
7070
"affinities that are not of type NodeAffinity"
7171
)
72-
if not all([isinstance(i, Toleration) for i in self.tolerations]):
72+
if not all([isinstance(toleration, Toleration) for toleration in self.tolerations]):
7373
raise ProgrammingError(
7474
message="Cannot create a ServerOptions dataclass with tolerations that are not of type Toleration"
7575
)

components/renku_data_services/notebooks/apispec.py

Lines changed: 1 addition & 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: 2024-09-23T08:31:51+00:00
3+
# timestamp: 2024-09-24T09:26:37+00:00
44

55
from __future__ import annotations
66

0 commit comments

Comments
 (0)