Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"ghcr.io/devcontainers-contrib/features/poetry",
"ghcr.io/devcontainers-contrib/features/bash-command"
],
"postCreateCommand": "poetry install --with dev",
"postCreateCommand": "poetry install --with dev && mkdir -p /home/vscode/.config/k9s",
"customizations": {
"vscode": {
"extensions": [
Expand Down
1 change: 1 addition & 0 deletions .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ services:
POETRY_CACHE_DIR: "/poetry_cache"
NB_SERVER_OPTIONS__DEFAULTS_PATH: /workspace/server_defaults.json
NB_SERVER_OPTIONS__UI_CHOICES_PATH: /workspace/server_options.json
KUBECONFIG: "/workspace/.k3d-config.yaml"
network_mode: service:db
depends_on:
- db
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,5 @@ docker-compose.override.yml
# nix
result
*.qcow2

.k3d-config.yaml
11 changes: 6 additions & 5 deletions components/renku_data_services/authn/gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,14 @@ async def authenticate(self, access_token: str, request: Request) -> base_models
async def _get_gitlab_api_user(self, access_token: str, headers: Header) -> base_models.APIUser:
"""Get and validate a Gitlab API User."""
client = gitlab.Gitlab(self.gitlab_url, oauth_token=access_token)
try:
with suppress(gitlab.GitlabAuthenticationError):
client.auth() # needed for the user property to be set
except gitlab.GitlabAuthenticationError:
raise errors.UnauthorizedError(message="User not authorized with Gitlab")
if client.user is None:
# The user is not authenticated with Gitlab so we send out an empty APIUser
# Anonymous Renku users will not be able to authenticate with Gitlab
return base_models.APIUser()

user = client.user
if user is None:
raise errors.UnauthorizedError(message="User not authorized with Gitlab")

if user.state != "active":
raise errors.ForbiddenError(message="User isn't active in Gitlab")
Expand Down
2 changes: 2 additions & 0 deletions components/renku_data_services/notebooks/api.spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ paths:
required: true
schema:
type: string
minLength: 1
responses:
'200':
description: The Docker image is available.
Expand Down Expand Up @@ -384,6 +385,7 @@ paths:
required: true
schema:
type: string
minLength: 1
responses:
"200":
description: The docker image can be found
Expand Down
49 changes: 26 additions & 23 deletions components/renku_data_services/notebooks/api/classes/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
from pathlib import PurePosixPath
from typing import Any, Optional, Self, cast

import requests
import httpx
from werkzeug.datastructures import WWWAuthenticate

from ...errors.user import ImageParseError
from renku_data_services.errors import errors


class ManifestTypes(Enum):
Expand All @@ -29,16 +29,20 @@ class ImageRepoDockerAPI:

hostname: str
oauth2_token: Optional[str] = field(default=None, repr=False)
# NOTE: We need to follow redirects so that we can authenticate with the image repositories properly.
# NOTE: If we do not use default_factory to create the client here requests will fail because it can happen
# that the client gets created in the wrong asyncio loop.
client: httpx.AsyncClient = field(default_factory=lambda: httpx.AsyncClient(timeout=10, follow_redirects=True))

def _get_docker_token(self, image: "Image") -> Optional[str]:
async def _get_docker_token(self, image: "Image") -> Optional[str]:
"""Get an authorization token from the docker v2 API.

This will return the token provided by the API (or None if no token was found).
"""
image_digest_url = f"https://{self.hostname}/v2/{image.name}/manifests/{image.tag}"
try:
auth_req = requests.get(image_digest_url, timeout=10)
except requests.ConnectionError:
auth_req = await self.client.get(image_digest_url)
except httpx.ConnectError:
auth_req = None
if auth_req is None or not (auth_req.status_code == 401 and "Www-Authenticate" in auth_req.headers):
# the request status code and header are not what is expected
Expand All @@ -54,56 +58,55 @@ def _get_docker_token(self, image: "Image") -> Optional[str]:
if self.oauth2_token:
creds = base64.urlsafe_b64encode(f"oauth2:{self.oauth2_token}".encode()).decode()
headers["Authorization"] = f"Basic {creds}"
token_req = requests.get(realm, params=params, headers=headers, timeout=10)
token_req = await self.client.get(realm, params=params, headers=headers)
return str(token_req.json().get("token"))

def get_image_manifest(self, image: "Image") -> Optional[dict[str, Any]]:
async def get_image_manifest(self, image: "Image") -> Optional[dict[str, Any]]:
"""Query the docker API to get the manifest of an image."""
if image.hostname != self.hostname:
raise ImageParseError(
f"The image hostname {image.hostname} does not match " f"the image repository {self.hostname}"
raise errors.ValidationError(
message=f"The image hostname {image.hostname} does not match the image repository {self.hostname}"
)
token = self._get_docker_token(image)
token = await self._get_docker_token(image)
image_digest_url = f"https://{image.hostname}/v2/{image.name}/manifests/{image.tag}"
headers = {"Accept": ManifestTypes.docker_v2.value}
if token:
headers["Authorization"] = f"Bearer {token}"
res = requests.get(image_digest_url, headers=headers, timeout=10)
res = await self.client.get(image_digest_url, headers=headers)
if res.status_code != 200:
headers["Accept"] = ManifestTypes.oci_v1.value
res = requests.get(image_digest_url, headers=headers, timeout=10)
res = await self.client.get(image_digest_url, headers=headers)
if res.status_code != 200:
return None
return cast(dict[str, Any], res.json())

def image_exists(self, image: "Image") -> bool:
async def image_exists(self, image: "Image") -> bool:
"""Check the docker repo API if the image exists."""
return self.get_image_manifest(image) is not None
return await self.get_image_manifest(image) is not None

def get_image_config(self, image: "Image") -> Optional[dict[str, Any]]:
async def get_image_config(self, image: "Image") -> Optional[dict[str, Any]]:
"""Query the docker API to get the configuration of an image."""
manifest = self.get_image_manifest(image)
manifest = await self.get_image_manifest(image)
if manifest is None:
return None
config_digest = manifest.get("config", {}).get("digest")
if config_digest is None:
return None
token = self._get_docker_token(image)
res = requests.get(
token = await self._get_docker_token(image)
res = await self.client.get(
f"https://{image.hostname}/v2/{image.name}/blobs/{config_digest}",
headers={
"Accept": "application/json",
"Authorization": f"Bearer {token}",
},
timeout=10,
)
if res.status_code != 200:
return None
return cast(dict[str, Any], res.json())

def image_workdir(self, image: "Image") -> Optional[PurePosixPath]:
async def image_workdir(self, image: "Image") -> Optional[PurePosixPath]:
"""Query the docker API to get the workdir of an image."""
config = self.get_image_config(image)
config = await self.get_image_config(image)
if config is None:
return None
nested_config = config.get("config", {})
Expand Down Expand Up @@ -204,9 +207,9 @@ def build_re(*parts: str) -> re.Pattern:
if len(matches) == 1:
return cls(matches[0]["hostname"], matches[0]["image"], matches[0]["tag"])
elif len(matches) > 1:
raise ImageParseError(f"Cannot parse the image {path}, too many interpretations {matches}")
raise errors.ValidationError(message=f"Cannot parse the image {path}, too many interpretations {matches}")
else:
raise ImageParseError(f"Cannot parse the image {path}")
raise errors.ValidationError(message=f"Cannot parse the image {path}")

def repo_api(self) -> ImageRepoDockerAPI:
"""Get the docker API from the image."""
Expand Down
6 changes: 3 additions & 3 deletions components/renku_data_services/notebooks/apispec.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: api.spec.yaml
# timestamp: 2024-09-24T09:26:37+00:00
# timestamp: 2024-10-07T22:25:48+00:00

from __future__ import annotations

Expand Down Expand Up @@ -273,7 +273,7 @@ class SessionCloudStoragePost(BaseAPISpec):


class NotebooksImagesGetParametersQuery(BaseAPISpec):
image_url: str
image_url: str = Field(..., min_length=1)


class NotebooksLogsServerNameGetParametersQuery(BaseAPISpec):
Expand All @@ -296,7 +296,7 @@ class SessionsSessionIdLogsGetParametersQuery(BaseAPISpec):


class SessionsImagesGetParametersQuery(BaseAPISpec):
image_url: str
image_url: str = Field(..., min_length=1)


class LaunchNotebookRequest(BaseAPISpec):
Expand Down
43 changes: 33 additions & 10 deletions components/renku_data_services/notebooks/blueprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,15 +315,15 @@ async def launch_notebook_helper(
# A specific image was requested
parsed_image = Image.from_path(image)
image_repo = parsed_image.repo_api()
image_exists_publicly = image_repo.image_exists(parsed_image)
image_exists_publicly = await image_repo.image_exists(parsed_image)
image_exists_privately = False
if (
not image_exists_publicly
and parsed_image.hostname == nb_config.git.registry
and internal_gitlab_user.access_token
):
image_repo = image_repo.with_oauth2_token(internal_gitlab_user.access_token)
image_exists_privately = image_repo.image_exists(parsed_image)
image_exists_privately = await image_repo.image_exists(parsed_image)
if not image_exists_privately and not image_exists_publicly:
using_default_image = True
image = nb_config.sessions.default_image
Expand All @@ -349,7 +349,7 @@ async def launch_notebook_helper(
image_repo = parsed_image.repo_api()
if is_image_private and internal_gitlab_user.access_token:
image_repo = image_repo.with_oauth2_token(internal_gitlab_user.access_token)
if not image_repo.image_exists(parsed_image):
if not await image_repo.image_exists(parsed_image):
raise errors.MissingResourceError(
message=(
f"Cannot start the session because the following the image {image} does not "
Expand Down Expand Up @@ -413,7 +413,7 @@ async def launch_notebook_helper(
if lfs_auto_fetch is not None:
parsed_server_options.lfs_auto_fetch = lfs_auto_fetch

image_work_dir = image_repo.image_workdir(parsed_image) or PurePosixPath("/")
image_work_dir = await image_repo.image_workdir(parsed_image) or PurePosixPath("/")
mount_path = image_work_dir / "work"

server_work_dir = mount_path / gl_project_path
Expand Down Expand Up @@ -757,17 +757,18 @@ def check_docker_image(self) -> BlueprintFactoryResponse:
"""Return the availability of the docker image."""

@authenticate_2(self.authenticator, self.internal_gitlab_authenticator)
@validate(query=apispec.NotebooksImagesGetParametersQuery)
async def _check_docker_image(
request: Request, user: AnonymousAPIUser | AuthenticatedAPIUser, internal_gitlab_user: APIUser
request: Request,
user: AnonymousAPIUser | AuthenticatedAPIUser,
internal_gitlab_user: APIUser,
query: apispec.NotebooksImagesGetParametersQuery,
) -> HTTPResponse:
image_url = request.get_args().get("image_url")
if not isinstance(image_url, str):
raise ValueError("required string of image url")
parsed_image = Image.from_path(image_url)
parsed_image = Image.from_path(query.image_url)
image_repo = parsed_image.repo_api()
if parsed_image.hostname == self.nb_config.git.registry and internal_gitlab_user.access_token:
image_repo = image_repo.with_oauth2_token(internal_gitlab_user.access_token)
if image_repo.image_exists(parsed_image):
if await image_repo.image_exists(parsed_image):
return HTTPResponse(status=200)
else:
return HTTPResponse(status=404)
Expand Down Expand Up @@ -1125,3 +1126,25 @@ async def _handler(
return json(apispec.SessionLogsResponse.model_validate(logs).model_dump(exclude_none=True))

return "/sessions/<session_id>/logs", ["GET"], _handler

def check_docker_image(self) -> BlueprintFactoryResponse:
"""Return the availability of the docker image."""

@authenticate_2(self.authenticator, self.internal_gitlab_authenticator)
@validate(query=apispec.SessionsImagesGetParametersQuery)
async def _check_docker_image(
request: Request,
user: AnonymousAPIUser | AuthenticatedAPIUser,
internal_gitlab_user: APIUser,
query: apispec.SessionsImagesGetParametersQuery,
) -> HTTPResponse:
parsed_image = Image.from_path(query.image_url)
image_repo = parsed_image.repo_api()
if parsed_image.hostname == self.nb_config.git.registry and internal_gitlab_user.access_token:
image_repo = image_repo.with_oauth2_token(internal_gitlab_user.access_token)
if await image_repo.image_exists(parsed_image):
return HTTPResponse(status=200)
else:
return HTTPResponse(status=404)

return "/sessions/images", ["GET"], _check_docker_image
31 changes: 29 additions & 2 deletions components/renku_data_services/notebooks/crs.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Custom resource definition with proper names from the autogenerated code."""

from datetime import datetime
from datetime import UTC, datetime
from typing import Any, cast
from urllib.parse import urljoin, urlparse, urlunparse

from kubernetes.utils import parse_quantity
from kubernetes.utils import parse_duration, parse_quantity
from pydantic import BaseModel, Field, field_validator
from sanic.log import logger
from ulid import ULID
Expand All @@ -24,6 +24,7 @@
SecretRef,
Session,
State,
Status,
Storage,
TlsSecret,
)
Expand Down Expand Up @@ -190,6 +191,30 @@ def as_apispec(self) -> apispec.SessionResponse:
else:
state = apispec.State3.starting

will_hibernate_at: datetime | None = None
will_delete_at: datetime | None = None
match self.status, self.spec.culling:
case (
Status(idle=True, idleSince=idle_since),
Culling(maxIdleDuration=max_idle),
) if idle_since and max_idle:
will_hibernate_at = idle_since + parse_duration(max_idle)
case (
Status(state=State.Failed, failingSince=failing_since),
Culling(maxFailedDuration=max_failed),
) if failing_since and max_failed:
will_hibernate_at = failing_since + parse_duration(max_failed)
case (
Status(state=State.NotReady),
Culling(maxAge=max_age),
) if max_age and self.metadata.creationTimestamp:
will_hibernate_at = self.metadata.creationTimestamp + parse_duration(max_age)
case (
Status(state=State.Hibernated, hibernatedSince=hibernated_since),
Culling(maxHibernatedDuration=max_hibernated),
) if hibernated_since and max_hibernated:
will_delete_at = hibernated_since + parse_duration(max_hibernated)

return apispec.SessionResponse(
image=self.spec.session.image,
name=self.metadata.name,
Expand All @@ -205,6 +230,8 @@ def as_apispec(self) -> apispec.SessionResponse:
state=state,
ready_containers=ready_containers,
total_containers=total_containers,
will_hibernate_at=will_hibernate_at,
will_delete_at=will_delete_at,
),
url=url,
project_id=str(self.project_id),
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ ignore = [
"components/renku_data_services/notebooks/crs.py" = ["F401"]

[tool.ruff.lint.isort]
known-first-party = ["renku_data_services"]
known-first-party = ["renku_data_services", "test"]

[tool.ruff.lint.pydocstyle]
convention = "google"
Expand Down
10 changes: 4 additions & 6 deletions test/bases/renku_data_services/data_api/test_notebooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import asyncio
import os
import shutil
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Iterator
from unittest.mock import MagicMock
from uuid import uuid4

Expand All @@ -13,14 +13,12 @@
from sanic_testing.testing import SanicASGITestClient

from renku_data_services.notebooks.api.classes.k8s_client import JupyterServerV1Alpha1Kr8s

from .utils import K3DCluster, setup_amalthea

os.environ["KUBECONFIG"] = ".k3d-config.yaml"
from test.bases.renku_data_services.data_api.utils import K3DCluster, setup_amalthea


@pytest.fixture(scope="module", autouse=True)
def cluster() -> K3DCluster:
def cluster() -> Iterator[K3DCluster]:
os.environ["KUBECONFIG"] = ".k3d-config.yaml"
if shutil.which("k3d") is None:
pytest.skip("Requires k3d for cluster creation")

Expand Down
Loading