Skip to content

Commit efa09c1

Browse files
committed
fix: docker image check endpoint and gitlab authn
1 parent 4c4b1d7 commit efa09c1

3 files changed

Lines changed: 56 additions & 33 deletions

File tree

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/classes/image.py

Lines changed: 24 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,18 @@ 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+
client: httpx.AsyncClient = httpx.AsyncClient(timeout=10, follow_redirects=True)
3234

33-
def _get_docker_token(self, image: "Image") -> Optional[str]:
35+
async def _get_docker_token(self, image: "Image") -> Optional[str]:
3436
"""Get an authorization token from the docker v2 API.
3537
3638
This will return the token provided by the API (or None if no token was found).
3739
"""
3840
image_digest_url = f"https://{self.hostname}/v2/{image.name}/manifests/{image.tag}"
3941
try:
40-
auth_req = requests.get(image_digest_url, timeout=10)
41-
except requests.ConnectionError:
42+
auth_req = await self.client.get(image_digest_url)
43+
except httpx.ConnectError:
4244
auth_req = None
4345
if auth_req is None or not (auth_req.status_code == 401 and "Www-Authenticate" in auth_req.headers):
4446
# the request status code and header are not what is expected
@@ -54,56 +56,55 @@ def _get_docker_token(self, image: "Image") -> Optional[str]:
5456
if self.oauth2_token:
5557
creds = base64.urlsafe_b64encode(f"oauth2:{self.oauth2_token}".encode()).decode()
5658
headers["Authorization"] = f"Basic {creds}"
57-
token_req = requests.get(realm, params=params, headers=headers, timeout=10)
59+
token_req = await self.client.get(realm, params=params, headers=headers)
5860
return str(token_req.json().get("token"))
5961

60-
def get_image_manifest(self, image: "Image") -> Optional[dict[str, Any]]:
62+
async def get_image_manifest(self, image: "Image") -> Optional[dict[str, Any]]:
6163
"""Query the docker API to get the manifest of an image."""
6264
if image.hostname != self.hostname:
63-
raise ImageParseError(
64-
f"The image hostname {image.hostname} does not match " f"the image repository {self.hostname}"
65+
raise errors.ValidationError(
66+
message=f"The image hostname {image.hostname} does not match " f"the image repository {self.hostname}"
6567
)
66-
token = self._get_docker_token(image)
68+
token = await self._get_docker_token(image)
6769
image_digest_url = f"https://{image.hostname}/v2/{image.name}/manifests/{image.tag}"
6870
headers = {"Accept": ManifestTypes.docker_v2.value}
6971
if token:
7072
headers["Authorization"] = f"Bearer {token}"
71-
res = requests.get(image_digest_url, headers=headers, timeout=10)
73+
res = await self.client.get(image_digest_url, headers=headers)
7274
if res.status_code != 200:
7375
headers["Accept"] = ManifestTypes.oci_v1.value
74-
res = requests.get(image_digest_url, headers=headers, timeout=10)
76+
res = await self.client.get(image_digest_url, headers=headers)
7577
if res.status_code != 200:
7678
return None
7779
return cast(dict[str, Any], res.json())
7880

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

83-
def get_image_config(self, image: "Image") -> Optional[dict[str, Any]]:
85+
async def get_image_config(self, image: "Image") -> Optional[dict[str, Any]]:
8486
"""Query the docker API to get the configuration of an image."""
85-
manifest = self.get_image_manifest(image)
87+
manifest = await self.get_image_manifest(image)
8688
if manifest is None:
8789
return None
8890
config_digest = manifest.get("config", {}).get("digest")
8991
if config_digest is None:
9092
return None
91-
token = self._get_docker_token(image)
92-
res = requests.get(
93+
token = await self._get_docker_token(image)
94+
res = await self.client.get(
9395
f"https://{image.hostname}/v2/{image.name}/blobs/{config_digest}",
9496
headers={
9597
"Accept": "application/json",
9698
"Authorization": f"Bearer {token}",
9799
},
98-
timeout=10,
99100
)
100101
if res.status_code != 200:
101102
return None
102103
return cast(dict[str, Any], res.json())
103104

104-
def image_workdir(self, image: "Image") -> Optional[PurePosixPath]:
105+
async def image_workdir(self, image: "Image") -> Optional[PurePosixPath]:
105106
"""Query the docker API to get the workdir of an image."""
106-
config = self.get_image_config(image)
107+
config = await self.get_image_config(image)
107108
if config is None:
108109
return None
109110
nested_config = config.get("config", {})
@@ -204,9 +205,9 @@ def build_re(*parts: str) -> re.Pattern:
204205
if len(matches) == 1:
205206
return cls(matches[0]["hostname"], matches[0]["image"], matches[0]["tag"])
206207
elif len(matches) > 1:
207-
raise ImageParseError(f"Cannot parse the image {path}, too many interpretations {matches}")
208+
raise errors.ValidationError(message=f"Cannot parse the image {path}, too many interpretations {matches}")
208209
else:
209-
raise ImageParseError(f"Cannot parse the image {path}")
210+
raise errors.ValidationError(message=f"Cannot parse the image {path}")
210211

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

components/renku_data_services/notebooks/blueprints.py

Lines changed: 26 additions & 5 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
@@ -767,7 +767,7 @@ async def _check_docker_image(
767767
image_repo = parsed_image.repo_api()
768768
if parsed_image.hostname == self.nb_config.git.registry and internal_gitlab_user.access_token:
769769
image_repo = image_repo.with_oauth2_token(internal_gitlab_user.access_token)
770-
if image_repo.image_exists(parsed_image):
770+
if await image_repo.image_exists(parsed_image):
771771
return HTTPResponse(status=200)
772772
else:
773773
return HTTPResponse(status=404)
@@ -1125,3 +1125,24 @@ async def _handler(
11251125
return json(apispec.SessionLogsResponse.model_validate(logs).model_dump(exclude_none=True))
11261126

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

0 commit comments

Comments
 (0)