Skip to content

Commit 3d2f659

Browse files
committed
fix: docker image check endpoint and gitlab authn (#439)
1 parent 87000a6 commit 3d2f659

File tree

11 files changed

+81
-53
lines changed

11 files changed

+81
-53
lines changed

.devcontainer/devcontainer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"ghcr.io/devcontainers-contrib/features/poetry",
3535
"ghcr.io/devcontainers-contrib/features/bash-command"
3636
],
37-
"postCreateCommand": "poetry install --with dev",
37+
"postCreateCommand": "poetry install --with dev && mkdir -p /home/vscode/.config/k9s",
3838
"customizations": {
3939
"vscode": {
4040
"extensions": [

.devcontainer/docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ services:
2525
POETRY_CACHE_DIR: "/poetry_cache"
2626
NB_SERVER_OPTIONS__DEFAULTS_PATH: /workspace/server_defaults.json
2727
NB_SERVER_OPTIONS__UI_CHOICES_PATH: /workspace/server_options.json
28+
KUBECONFIG: "/workspace/.k3d-config.yaml"
2829
network_mode: service:db
2930
depends_on:
3031
- db

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,5 @@ docker-compose.override.yml
7979
# nix
8080
result
8181
*.qcow2
82+
83+
.k3d-config.yaml

components/renku_data_services/authn/gitlab.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,14 @@ async def authenticate(self, access_token: str, request: Request) -> base_models
4646
async def _get_gitlab_api_user(self, access_token: str, headers: Header) -> base_models.APIUser:
4747
"""Get and validate a Gitlab API User."""
4848
client = gitlab.Gitlab(self.gitlab_url, oauth_token=access_token)
49-
try:
49+
with suppress(gitlab.GitlabAuthenticationError):
5050
client.auth() # needed for the user property to be set
51-
except gitlab.GitlabAuthenticationError:
52-
raise errors.UnauthorizedError(message="User not authorized with Gitlab")
51+
if client.user is None:
52+
# The user is not authenticated with Gitlab so we send out an empty APIUser
53+
# Anonymous Renku users will not be able to authenticate with Gitlab
54+
return base_models.APIUser()
55+
5356
user = client.user
54-
if user is None:
55-
raise errors.UnauthorizedError(message="User not authorized with Gitlab")
5657

5758
if user.state != "active":
5859
raise errors.ForbiddenError(message="User isn't active in Gitlab")

components/renku_data_services/notebooks/api.spec.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ paths:
1919
required: true
2020
schema:
2121
type: string
22+
minLength: 1
2223
responses:
2324
'200':
2425
description: The Docker image is available.
@@ -384,6 +385,7 @@ paths:
384385
required: true
385386
schema:
386387
type: string
388+
minLength: 1
387389
responses:
388390
"200":
389391
description: The docker image can be found

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

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
from pathlib import PurePosixPath
88
from typing import Any, Optional, Self, cast
99

10-
import requests
10+
import httpx
1111
from werkzeug.datastructures import WWWAuthenticate
1212

13-
from ...errors.user import ImageParseError
13+
from renku_data_services.errors import errors
1414

1515

1616
class ManifestTypes(Enum):
@@ -29,16 +29,20 @@ class ImageRepoDockerAPI:
2929

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

33-
def _get_docker_token(self, image: "Image") -> Optional[str]:
37+
async def _get_docker_token(self, image: "Image") -> Optional[str]:
3438
"""Get an authorization token from the docker v2 API.
3539
3640
This will return the token provided by the API (or None if no token was found).
3741
"""
3842
image_digest_url = f"https://{self.hostname}/v2/{image.name}/manifests/{image.tag}"
3943
try:
40-
auth_req = requests.get(image_digest_url, timeout=10)
41-
except requests.ConnectionError:
44+
auth_req = await self.client.get(image_digest_url)
45+
except httpx.ConnectError:
4246
auth_req = None
4347
if auth_req is None or not (auth_req.status_code == 401 and "Www-Authenticate" in auth_req.headers):
4448
# the request status code and header are not what is expected
@@ -54,56 +58,55 @@ def _get_docker_token(self, image: "Image") -> Optional[str]:
5458
if self.oauth2_token:
5559
creds = base64.urlsafe_b64encode(f"oauth2:{self.oauth2_token}".encode()).decode()
5660
headers["Authorization"] = f"Basic {creds}"
57-
token_req = requests.get(realm, params=params, headers=headers, timeout=10)
61+
token_req = await self.client.get(realm, params=params, headers=headers)
5862
return str(token_req.json().get("token"))
5963

60-
def get_image_manifest(self, image: "Image") -> Optional[dict[str, Any]]:
64+
async def get_image_manifest(self, image: "Image") -> Optional[dict[str, Any]]:
6165
"""Query the docker API to get the manifest of an image."""
6266
if image.hostname != self.hostname:
63-
raise ImageParseError(
64-
f"The image hostname {image.hostname} does not match " f"the image repository {self.hostname}"
67+
raise errors.ValidationError(
68+
message=f"The image hostname {image.hostname} does not match the image repository {self.hostname}"
6569
)
66-
token = self._get_docker_token(image)
70+
token = await self._get_docker_token(image)
6771
image_digest_url = f"https://{image.hostname}/v2/{image.name}/manifests/{image.tag}"
6872
headers = {"Accept": ManifestTypes.docker_v2.value}
6973
if token:
7074
headers["Authorization"] = f"Bearer {token}"
71-
res = requests.get(image_digest_url, headers=headers, timeout=10)
75+
res = await self.client.get(image_digest_url, headers=headers)
7276
if res.status_code != 200:
7377
headers["Accept"] = ManifestTypes.oci_v1.value
74-
res = requests.get(image_digest_url, headers=headers, timeout=10)
78+
res = await self.client.get(image_digest_url, headers=headers)
7579
if res.status_code != 200:
7680
return None
7781
return cast(dict[str, Any], res.json())
7882

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

83-
def get_image_config(self, image: "Image") -> Optional[dict[str, Any]]:
87+
async def get_image_config(self, image: "Image") -> Optional[dict[str, Any]]:
8488
"""Query the docker API to get the configuration of an image."""
85-
manifest = self.get_image_manifest(image)
89+
manifest = await self.get_image_manifest(image)
8690
if manifest is None:
8791
return None
8892
config_digest = manifest.get("config", {}).get("digest")
8993
if config_digest is None:
9094
return None
91-
token = self._get_docker_token(image)
92-
res = requests.get(
95+
token = await self._get_docker_token(image)
96+
res = await self.client.get(
9397
f"https://{image.hostname}/v2/{image.name}/blobs/{config_digest}",
9498
headers={
9599
"Accept": "application/json",
96100
"Authorization": f"Bearer {token}",
97101
},
98-
timeout=10,
99102
)
100103
if res.status_code != 200:
101104
return None
102105
return cast(dict[str, Any], res.json())
103106

104-
def image_workdir(self, image: "Image") -> Optional[PurePosixPath]:
107+
async def image_workdir(self, image: "Image") -> Optional[PurePosixPath]:
105108
"""Query the docker API to get the workdir of an image."""
106-
config = self.get_image_config(image)
109+
config = await self.get_image_config(image)
107110
if config is None:
108111
return None
109112
nested_config = config.get("config", {})
@@ -204,9 +207,9 @@ def build_re(*parts: str) -> re.Pattern:
204207
if len(matches) == 1:
205208
return cls(matches[0]["hostname"], matches[0]["image"], matches[0]["tag"])
206209
elif len(matches) > 1:
207-
raise ImageParseError(f"Cannot parse the image {path}, too many interpretations {matches}")
210+
raise errors.ValidationError(message=f"Cannot parse the image {path}, too many interpretations {matches}")
208211
else:
209-
raise ImageParseError(f"Cannot parse the image {path}")
212+
raise errors.ValidationError(message=f"Cannot parse the image {path}")
210213

211214
def repo_api(self) -> ImageRepoDockerAPI:
212215
"""Get the docker API from the image."""

components/renku_data_services/notebooks/apispec.py

Lines changed: 3 additions & 3 deletions
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-24T09:26:37+00:00
3+
# timestamp: 2024-10-07T22:25:48+00:00
44

55
from __future__ import annotations
66

@@ -273,7 +273,7 @@ class SessionCloudStoragePost(BaseAPISpec):
273273

274274

275275
class NotebooksImagesGetParametersQuery(BaseAPISpec):
276-
image_url: str
276+
image_url: str = Field(..., min_length=1)
277277

278278

279279
class NotebooksLogsServerNameGetParametersQuery(BaseAPISpec):
@@ -296,7 +296,7 @@ class SessionsSessionIdLogsGetParametersQuery(BaseAPISpec):
296296

297297

298298
class SessionsImagesGetParametersQuery(BaseAPISpec):
299-
image_url: str
299+
image_url: str = Field(..., min_length=1)
300300

301301

302302
class LaunchNotebookRequest(BaseAPISpec):

components/renku_data_services/notebooks/blueprints.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -315,15 +315,15 @@ async def launch_notebook_helper(
315315
# A specific image was requested
316316
parsed_image = Image.from_path(image)
317317
image_repo = parsed_image.repo_api()
318-
image_exists_publicly = image_repo.image_exists(parsed_image)
318+
image_exists_publicly = await image_repo.image_exists(parsed_image)
319319
image_exists_privately = False
320320
if (
321321
not image_exists_publicly
322322
and parsed_image.hostname == nb_config.git.registry
323323
and internal_gitlab_user.access_token
324324
):
325325
image_repo = image_repo.with_oauth2_token(internal_gitlab_user.access_token)
326-
image_exists_privately = image_repo.image_exists(parsed_image)
326+
image_exists_privately = await image_repo.image_exists(parsed_image)
327327
if not image_exists_privately and not image_exists_publicly:
328328
using_default_image = True
329329
image = nb_config.sessions.default_image
@@ -349,7 +349,7 @@ async def launch_notebook_helper(
349349
image_repo = parsed_image.repo_api()
350350
if is_image_private and internal_gitlab_user.access_token:
351351
image_repo = image_repo.with_oauth2_token(internal_gitlab_user.access_token)
352-
if not image_repo.image_exists(parsed_image):
352+
if not await image_repo.image_exists(parsed_image):
353353
raise errors.MissingResourceError(
354354
message=(
355355
f"Cannot start the session because the following the image {image} does not "
@@ -413,7 +413,7 @@ async def launch_notebook_helper(
413413
if lfs_auto_fetch is not None:
414414
parsed_server_options.lfs_auto_fetch = lfs_auto_fetch
415415

416-
image_work_dir = image_repo.image_workdir(parsed_image) or PurePosixPath("/")
416+
image_work_dir = await image_repo.image_workdir(parsed_image) or PurePosixPath("/")
417417
mount_path = image_work_dir / "work"
418418

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

759759
@authenticate_2(self.authenticator, self.internal_gitlab_authenticator)
760+
@validate(query=apispec.NotebooksImagesGetParametersQuery)
760761
async def _check_docker_image(
761-
request: Request, user: AnonymousAPIUser | AuthenticatedAPIUser, internal_gitlab_user: APIUser
762+
request: Request,
763+
user: AnonymousAPIUser | AuthenticatedAPIUser,
764+
internal_gitlab_user: APIUser,
765+
query: apispec.NotebooksImagesGetParametersQuery,
762766
) -> HTTPResponse:
763-
image_url = request.get_args().get("image_url")
764-
if not isinstance(image_url, str):
765-
raise ValueError("required string of image url")
766-
parsed_image = Image.from_path(image_url)
767+
parsed_image = Image.from_path(query.image_url)
767768
image_repo = parsed_image.repo_api()
768769
if parsed_image.hostname == self.nb_config.git.registry and internal_gitlab_user.access_token:
769770
image_repo = image_repo.with_oauth2_token(internal_gitlab_user.access_token)
770-
if image_repo.image_exists(parsed_image):
771+
if await image_repo.image_exists(parsed_image):
771772
return HTTPResponse(status=200)
772773
else:
773774
return HTTPResponse(status=404)
@@ -1125,3 +1126,25 @@ async def _handler(
11251126
return json(apispec.SessionLogsResponse.model_validate(logs).model_dump(exclude_none=True))
11261127

11271128
return "/sessions/<session_id>/logs", ["GET"], _handler
1129+
1130+
def check_docker_image(self) -> BlueprintFactoryResponse:
1131+
"""Return the availability of the docker image."""
1132+
1133+
@authenticate_2(self.authenticator, self.internal_gitlab_authenticator)
1134+
@validate(query=apispec.SessionsImagesGetParametersQuery)
1135+
async def _check_docker_image(
1136+
request: Request,
1137+
user: AnonymousAPIUser | AuthenticatedAPIUser,
1138+
internal_gitlab_user: APIUser,
1139+
query: apispec.SessionsImagesGetParametersQuery,
1140+
) -> HTTPResponse:
1141+
parsed_image = Image.from_path(query.image_url)
1142+
image_repo = parsed_image.repo_api()
1143+
if parsed_image.hostname == self.nb_config.git.registry and internal_gitlab_user.access_token:
1144+
image_repo = image_repo.with_oauth2_token(internal_gitlab_user.access_token)
1145+
if await image_repo.image_exists(parsed_image):
1146+
return HTTPResponse(status=200)
1147+
else:
1148+
return HTTPResponse(status=404)
1149+
1150+
return "/sessions/images", ["GET"], _check_docker_image

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ ignore = [
150150
"components/renku_data_services/notebooks/crs.py" = ["F401"]
151151

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

155155
[tool.ruff.lint.pydocstyle]
156156
convention = "google"

test/bases/renku_data_services/data_api/test_notebooks.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import asyncio
44
import os
55
import shutil
6-
from collections.abc import AsyncIterator
6+
from collections.abc import AsyncIterator, Iterator
77
from unittest.mock import MagicMock
88
from uuid import uuid4
99

@@ -13,14 +13,12 @@
1313
from sanic_testing.testing import SanicASGITestClient
1414

1515
from renku_data_services.notebooks.api.classes.k8s_client import JupyterServerV1Alpha1Kr8s
16-
17-
from .utils import K3DCluster, setup_amalthea
18-
19-
os.environ["KUBECONFIG"] = ".k3d-config.yaml"
16+
from test.bases.renku_data_services.data_api.utils import K3DCluster, setup_amalthea
2017

2118

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

0 commit comments

Comments
 (0)