Skip to content

Commit c2196d8

Browse files
committed
fix: docker image check endpoint and gitlab authn
1 parent 3a75082 commit c2196d8

3 files changed

Lines changed: 54 additions & 32 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: 22 additions & 22 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,17 @@ class ImageRepoDockerAPI:
2929

3030
hostname: str
3131
oauth2_token: Optional[str] = field(default=None, repr=False)
32+
client: httpx.AsyncClient = httpx.AsyncClient(timeout=10)
3233

33-
def _get_docker_token(self, image: "Image") -> Optional[str]:
34+
async def _get_docker_token(self, image: "Image") -> Optional[str]:
3435
"""Get an authorization token from the docker v2 API.
3536
3637
This will return the token provided by the API (or None if no token was found).
3738
"""
3839
image_digest_url = f"https://{self.hostname}/v2/{image.name}/manifests/{image.tag}"
3940
try:
40-
auth_req = requests.get(image_digest_url, timeout=10)
41-
except requests.ConnectionError:
41+
auth_req = await self.client.get(image_digest_url)
42+
except httpx.ConnectError:
4243
auth_req = None
4344
if auth_req is None or not (auth_req.status_code == 401 and "Www-Authenticate" in auth_req.headers):
4445
# the request status code and header are not what is expected
@@ -54,56 +55,55 @@ def _get_docker_token(self, image: "Image") -> Optional[str]:
5455
if self.oauth2_token:
5556
creds = base64.urlsafe_b64encode(f"oauth2:{self.oauth2_token}".encode()).decode()
5657
headers["Authorization"] = f"Basic {creds}"
57-
token_req = requests.get(realm, params=params, headers=headers, timeout=10)
58+
token_req = await self.client.get(realm, params=params, headers=headers)
5859
return str(token_req.json().get("token"))
5960

60-
def get_image_manifest(self, image: "Image") -> Optional[dict[str, Any]]:
61+
async def get_image_manifest(self, image: "Image") -> Optional[dict[str, Any]]:
6162
"""Query the docker API to get the manifest of an image."""
6263
if image.hostname != self.hostname:
63-
raise ImageParseError(
64-
f"The image hostname {image.hostname} does not match " f"the image repository {self.hostname}"
64+
raise errors.ValidationError(
65+
message=f"The image hostname {image.hostname} does not match " f"the image repository {self.hostname}"
6566
)
6667
token = self._get_docker_token(image)
6768
image_digest_url = f"https://{image.hostname}/v2/{image.name}/manifests/{image.tag}"
6869
headers = {"Accept": ManifestTypes.docker_v2.value}
6970
if token:
7071
headers["Authorization"] = f"Bearer {token}"
71-
res = requests.get(image_digest_url, headers=headers, timeout=10)
72+
res = await self.client.get(image_digest_url, headers=headers)
7273
if res.status_code != 200:
7374
headers["Accept"] = ManifestTypes.oci_v1.value
74-
res = requests.get(image_digest_url, headers=headers, timeout=10)
75+
res = await self.client.get(image_digest_url, headers=headers)
7576
if res.status_code != 200:
7677
return None
7778
return cast(dict[str, Any], res.json())
7879

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

83-
def get_image_config(self, image: "Image") -> Optional[dict[str, Any]]:
84+
async def get_image_config(self, image: "Image") -> Optional[dict[str, Any]]:
8485
"""Query the docker API to get the configuration of an image."""
85-
manifest = self.get_image_manifest(image)
86+
manifest = await self.get_image_manifest(image)
8687
if manifest is None:
8788
return None
8889
config_digest = manifest.get("config", {}).get("digest")
8990
if config_digest is None:
9091
return None
91-
token = self._get_docker_token(image)
92-
res = requests.get(
92+
token = await self._get_docker_token(image)
93+
res = await self.client.get(
9394
f"https://{image.hostname}/v2/{image.name}/blobs/{config_digest}",
9495
headers={
9596
"Accept": "application/json",
9697
"Authorization": f"Bearer {token}",
9798
},
98-
timeout=10,
9999
)
100100
if res.status_code != 200:
101101
return None
102102
return cast(dict[str, Any], res.json())
103103

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

211211
def repo_api(self) -> ImageRepoDockerAPI:
212212
"""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 "/notebooks/images", ["GET"], _check_docker_image

0 commit comments

Comments
 (0)