Skip to content

Commit dc523ef

Browse files
feat: handle private images from gitlab (#996)
Co-authored-by: Mohammad Alisafaee <mohammad.alisafaee@epfl.ch>
1 parent 541db06 commit dc523ef

15 files changed

Lines changed: 302 additions & 49 deletions

File tree

bases/renku_data_services/data_api/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic:
216216
cluster_repo=dm.cluster_repo,
217217
internal_gitlab_authenticator=dm.gitlab_authenticator,
218218
metrics=dm.metrics,
219+
connected_svcs_repo=dm.connected_services_repo,
219220
)
220221
platform_config = PlatformConfigBP(
221222
name="platform_config",

bases/renku_data_services/data_api/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ class Config:
4444
@classmethod
4545
def from_env(cls, db: DBConfig | None = None) -> Self:
4646
"""Load config from environment."""
47-
enable_internal_gitlab = os.getenv("ENABLE_V1_SERVICES", "true").lower() == "true"
47+
enable_internal_gitlab = os.getenv("ENABLE_INTERNAL_GITLAB", "true").lower() == "true"
4848

4949
dummy_stores = os.environ.get("DUMMY_STORES", "false").lower() == "true"
5050
if db is None:

components/renku_data_services/connected_services/api.spec.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,8 @@ components:
272272
$ref: "#/components/schemas/ProviderUrl"
273273
use_pkce:
274274
$ref: "#/components/schemas/UsePKCE"
275+
image_registry_url:
276+
$ref: "#/components/schemas/ImageRegistryUrl"
275277
required:
276278
- id
277279
- kind
@@ -303,6 +305,8 @@ components:
303305
$ref: "#/components/schemas/ProviderUrl"
304306
use_pkce:
305307
$ref: "#/components/schemas/UsePKCE"
308+
image_registry_url:
309+
$ref: "#/components/schemas/ImageRegistryUrl"
306310
required:
307311
- id
308312
- kind
@@ -330,6 +334,8 @@ components:
330334
$ref: "#/components/schemas/ProviderUrl"
331335
use_pkce:
332336
$ref: "#/components/schemas/UsePKCE"
337+
image_registry_url:
338+
$ref: "#/components/schemas/ImageRegistryUrl"
333339
ConnectionList:
334340
type: array
335341
items:
@@ -468,6 +474,12 @@ components:
468474
minimum: 1
469475
maximum: 100
470476
default: 20
477+
ImageRegistryUrl:
478+
type: string
479+
description: |
480+
This should contain no paths, just the domain for the registry and the scheme
481+
(http or https) to access the image registry API.
482+
example: https://registry.gitlab.com
471483
ErrorResponse:
472484
type: object
473485
properties:

components/renku_data_services/connected_services/apispec.py

Lines changed: 18 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: 2025-03-19T10:21:11+00:00
3+
# timestamp: 2025-09-01T13:55:20+00:00
44

55
from __future__ import annotations
66

@@ -120,6 +120,11 @@ class Provider(BaseAPISpec):
120120
description="Whether or not to use PKCE during authorization flows.\n",
121121
examples=[False],
122122
)
123+
image_registry_url: Optional[str] = Field(
124+
None,
125+
description="This should contain no paths, just the domain for the registry and the scheme\n(http or https) to access the image registry API.\n",
126+
examples=["https://registry.gitlab.com"],
127+
)
123128

124129

125130
class ProviderPost(BaseAPISpec):
@@ -155,10 +160,15 @@ class ProviderPost(BaseAPISpec):
155160
examples=["https://example.org"],
156161
)
157162
use_pkce: Optional[bool] = Field(
158-
None,
163+
False,
159164
description="Whether or not to use PKCE during authorization flows.\n",
160165
examples=[False],
161166
)
167+
image_registry_url: Optional[str] = Field(
168+
None,
169+
description="This should contain no paths, just the domain for the registry and the scheme\n(http or https) to access the image registry API.\n",
170+
examples=["https://registry.gitlab.com"],
171+
)
162172

163173

164174
class ProviderPatch(BaseAPISpec):
@@ -189,10 +199,15 @@ class ProviderPatch(BaseAPISpec):
189199
examples=["https://example.org"],
190200
)
191201
use_pkce: Optional[bool] = Field(
192-
None,
202+
False,
193203
description="Whether or not to use PKCE during authorization flows.\n",
194204
examples=[False],
195205
)
206+
image_registry_url: Optional[str] = Field(
207+
None,
208+
description="This should contain no paths, just the domain for the registry and the scheme\n(http or https) to access the image registry API.\n",
209+
examples=["https://registry.gitlab.com"],
210+
)
196211

197212

198213
class Connection(BaseAPISpec):

components/renku_data_services/connected_services/blueprints.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from renku_data_services.base_models.validation import validate_and_dump, validated_json
1919
from renku_data_services.connected_services import apispec
2020
from renku_data_services.connected_services.apispec_base import AuthorizeParams, CallbackParams
21-
from renku_data_services.connected_services.core import validate_oauth2_client_patch
21+
from renku_data_services.connected_services.core import validate_oauth2_client_patch, validate_unsaved_oauth2_client
2222
from renku_data_services.connected_services.db import ConnectedServicesRepository
2323

2424
logger = logging.getLogger(__name__)
@@ -59,7 +59,8 @@ def post(self) -> BlueprintFactoryResponse:
5959
@only_admins
6060
@validate(json=apispec.ProviderPost)
6161
async def _post(_: Request, user: base_models.APIUser, body: apispec.ProviderPost) -> JSONResponse:
62-
client = await self.connected_services_repo.insert_oauth2_client(user=user, new_client=body)
62+
new_client = validate_unsaved_oauth2_client(body)
63+
client = await self.connected_services_repo.insert_oauth2_client(user=user, new_client=new_client)
6364
return validated_json(apispec.Provider, client, 201)
6465

6566
return "/oauth2/providers", ["POST"], _post

components/renku_data_services/connected_services/core.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
"""Business logic for connected services."""
22

3+
from urllib.parse import urlparse
4+
35
from renku_data_services.connected_services import apispec, models
6+
from renku_data_services.errors import errors
47

58

69
def validate_oauth2_client_patch(patch: apispec.ProviderPatch) -> models.OAuth2ClientPatch:
710
"""Validate the update to a OAuth2 Client."""
11+
if patch.image_registry_url is not None and len(patch.image_registry_url) > 0:
12+
validate_image_registry_url(patch.image_registry_url)
813
return models.OAuth2ClientPatch(
914
kind=patch.kind,
1015
app_slug=patch.app_slug,
@@ -14,4 +19,39 @@ def validate_oauth2_client_patch(patch: apispec.ProviderPatch) -> models.OAuth2C
1419
scope=patch.scope,
1520
url=patch.url,
1621
use_pkce=patch.use_pkce,
22+
image_registry_url=patch.image_registry_url,
23+
)
24+
25+
26+
def validate_unsaved_oauth2_client(clnt: apispec.ProviderPost) -> models.UnsavedOAuth2Client:
27+
"""Validate the the creation of a new OAuth2 Client."""
28+
if clnt.image_registry_url is not None:
29+
validate_image_registry_url(clnt.image_registry_url)
30+
return models.UnsavedOAuth2Client(
31+
id=clnt.id,
32+
kind=clnt.kind,
33+
app_slug=clnt.app_slug or "",
34+
client_id=clnt.client_id,
35+
client_secret=clnt.client_secret,
36+
display_name=clnt.display_name,
37+
scope=clnt.scope,
38+
url=clnt.url,
39+
use_pkce=clnt.use_pkce or False,
40+
image_registry_url=clnt.image_registry_url,
1741
)
42+
43+
44+
def validate_image_registry_url(url: str) -> None:
45+
"""Validate an image registry url."""
46+
parsed = urlparse(url)
47+
if not parsed.netloc:
48+
raise errors.ValidationError(
49+
message=f"The host for the image registry url {url} is not valid, expected a non-empty value.",
50+
quiet=True,
51+
)
52+
accepted_schemes = ["https"]
53+
if parsed.scheme not in accepted_schemes:
54+
raise errors.ValidationError(
55+
message=f"The scheme for the image registry url {url} is not valid, expected one of {accepted_schemes}",
56+
quiet=True,
57+
)

components/renku_data_services/connected_services/db.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,20 @@
44
from collections.abc import AsyncGenerator, Callable
55
from contextlib import asynccontextmanager
66
from typing import Any
7-
from urllib.parse import urljoin
7+
from urllib.parse import urljoin, urlparse
88

99
from authlib.integrations.base_client import InvalidTokenError
1010
from authlib.integrations.httpx_client import AsyncOAuth2Client, OAuthError
1111
from sqlalchemy import select
1212
from sqlalchemy.ext.asyncio import AsyncSession
13-
from sqlalchemy.orm import selectinload
13+
from sqlalchemy.orm import joinedload, selectinload
1414
from ulid import ULID
1515

1616
import renku_data_services.base_models as base_models
1717
from renku_data_services import errors
1818
from renku_data_services.app_config import logging
1919
from renku_data_services.base_api.pagination import PaginationRequest
20-
from renku_data_services.connected_services import apispec, models
20+
from renku_data_services.connected_services import models
2121
from renku_data_services.connected_services import orm as schemas
2222
from renku_data_services.connected_services.apispec import ConnectionStatus, ProviderKind
2323
from renku_data_services.connected_services.provider_adapters import (
@@ -26,6 +26,7 @@
2626
get_provider_adapter,
2727
)
2828
from renku_data_services.connected_services.utils import generate_code_verifier
29+
from renku_data_services.notebooks.api.classes.image import Image, ImageRepoDockerAPI
2930
from renku_data_services.utils.cryptography import decrypt_string, encrypt_string
3031

3132
logger = logging.getLogger(__name__)
@@ -68,9 +69,7 @@ async def get_oauth2_client(self, provider_id: str, user: base_models.APIUser) -
6869
return client.dump(user_is_admin=user.is_admin)
6970

7071
async def insert_oauth2_client(
71-
self,
72-
user: base_models.APIUser,
73-
new_client: apispec.ProviderPost,
72+
self, user: base_models.APIUser, new_client: models.UnsavedOAuth2Client
7473
) -> models.OAuth2Client:
7574
"""Insert a new OAuth2 Client environment."""
7675
if user.id is None:
@@ -93,6 +92,7 @@ async def insert_oauth2_client(
9392
url=new_client.url,
9493
use_pkce=new_client.use_pkce or False,
9594
created_by_id=user.id,
95+
image_registry_url=new_client.image_registry_url,
9696
)
9797

9898
async with self.session_maker() as session, session.begin():
@@ -144,6 +144,12 @@ async def update_oauth2_client(
144144
client.url = patch.url
145145
if patch.use_pkce is not None:
146146
client.use_pkce = patch.use_pkce
147+
if patch.image_registry_url:
148+
# Patching with a string of at least length 1 updates the value
149+
client.image_registry_url = patch.image_registry_url
150+
elif patch.image_registry_url == "":
151+
# Patching with "", removes the value
152+
client.image_registry_url = None
147153

148154
await session.flush()
149155
await session.refresh(client)
@@ -272,7 +278,7 @@ async def authorize_callback(self, state: str, raw_url: str, callback_url: str)
272278
adapter.token_endpoint_url, authorization_response=raw_url, code_verifier=code_verifier
273279
)
274280

275-
logger.info(f"Token for client {client.id} has keys: {", ".join(token.keys())}")
281+
logger.info(f"Token for client {client.id} has keys: {', '.join(token.keys())}")
276282

277283
next_url = connection.next_url
278284

@@ -356,6 +362,40 @@ async def get_oauth2_connection_token(
356362
token_model = models.OAuth2TokenSet.from_dict(oauth2_client.token)
357363
return token_model
358364

365+
async def get_docker_client(
366+
self, user: base_models.APIUser, image: Image
367+
) -> tuple[ImageRepoDockerAPI, ULID] | tuple[None, None]:
368+
"""Search for clients and connections that can work with the specific image and return a docker client."""
369+
async with self.session_maker() as session:
370+
registry_urls = [f"http://{image.hostname}", f"https://{image.hostname}"]
371+
stmt = (
372+
select(schemas.OAuth2ConnectionORM)
373+
.where(schemas.OAuth2ConnectionORM.user_id == user.id)
374+
.where(
375+
schemas.OAuth2ConnectionORM.client.has(
376+
schemas.OAuth2ClientORM.image_registry_url.in_(registry_urls)
377+
)
378+
)
379+
.options(joinedload(schemas.OAuth2ConnectionORM.client))
380+
)
381+
conn = await session.scalar(stmt)
382+
if not conn:
383+
return None, None
384+
if conn.client.kind != ProviderKind.gitlab:
385+
# NOTE: Only Gitlab is currently supported for this
386+
return None, None
387+
url = conn.client.image_registry_url
388+
if not url:
389+
return None, None
390+
token_set = await self.get_oauth2_connection_token(conn.id, user)
391+
url_parsed = urlparse(url)
392+
access_token = token_set.access_token
393+
if not access_token:
394+
return None, None
395+
return ImageRepoDockerAPI(
396+
hostname=url_parsed.netloc, scheme=url_parsed.scheme, oauth2_token=access_token
397+
), conn.id
398+
359399
async def get_oauth2_app_installations(
360400
self, connection_id: ULID, user: base_models.APIUser, pagination: PaginationRequest
361401
) -> models.AppInstallationList:

components/renku_data_services/connected_services/models.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111

1212
@dataclass(frozen=True, eq=True, kw_only=True)
13-
class OAuth2Client:
13+
class UnsavedOAuth2Client:
1414
"""OAuth2 Client model."""
1515

1616
id: str
@@ -22,6 +22,13 @@ class OAuth2Client:
2222
scope: str
2323
url: str
2424
use_pkce: bool
25+
image_registry_url: str | None = None
26+
27+
28+
@dataclass(frozen=True, eq=True, kw_only=True)
29+
class OAuth2Client(UnsavedOAuth2Client):
30+
"""OAuth2 Client model."""
31+
2532
created_by_id: str
2633
creation_date: datetime
2734
updated_at: datetime
@@ -39,6 +46,7 @@ class OAuth2ClientPatch:
3946
scope: str | None
4047
url: str | None
4148
use_pkce: bool | None
49+
image_registry_url: str | None
4250

4351

4452
@dataclass(frozen=True, eq=True, kw_only=True)

components/renku_data_services/connected_services/orm.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class OAuth2ClientORM(BaseORM):
4949
onupdate=func.now(),
5050
nullable=False,
5151
)
52+
image_registry_url: Mapped[str | None] = mapped_column(default=None, nullable=True, server_default=None)
5253

5354
def dump(self, user_is_admin: bool = False) -> models.OAuth2Client:
5455
"""Create an OAuth2 Client model from the OAuth2ClientORM.
@@ -68,6 +69,7 @@ def dump(self, user_is_admin: bool = False) -> models.OAuth2Client:
6869
created_by_id=self.created_by_id,
6970
creation_date=self.creation_date,
7071
updated_at=self.updated_at,
72+
image_registry_url=self.image_registry_url,
7173
)
7274

7375

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""add image registry url
2+
3+
Revision ID: 35ea9d8f54e8
4+
Revises: c8061499b966
5+
Create Date: 2025-08-27 14:34:34.190341
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "35ea9d8f54e8"
14+
down_revision = "c8061499b966"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
op.add_column(
22+
"oauth2_clients", sa.Column("image_registry_url", sa.String(), nullable=True), schema="connected_services"
23+
)
24+
# ### end Alembic commands ###
25+
26+
27+
def downgrade() -> None:
28+
# ### commands auto generated by Alembic - please adjust! ###
29+
op.drop_column("oauth2_clients", "image_registry_url", schema="connected_services")
30+
# ### end Alembic commands ###

0 commit comments

Comments
 (0)