Skip to content

Commit 1a69158

Browse files
committed
feat: add endpoint to get v1 project properties
1 parent 341c145 commit 1a69158

File tree

8 files changed

+177
-15
lines changed

8 files changed

+177
-15
lines changed

bases/renku_data_services/data_api/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ def register_all_handlers(app: Sanic, config: Config) -> Sanic:
106106
session_repo=config.session_repo,
107107
data_connector_repo=config.data_connector_repo,
108108
project_migration_repo=config.project_migration_repo,
109+
core_scv_url=config.core_svc_url,
110+
gitlab_client=config.gitlab_client,
111+
internal_gitlab_authenticator=config.gitlab_authenticator,
109112
)
110113
project_session_secrets = ProjectSessionSecretBP(
111114
name="project_session_secrets",

components/renku_data_services/app_config/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ class Config:
277277
server_options_file: Optional[str] = None
278278
server_defaults_file: Optional[str] = None
279279
async_oauth2_client_class: type[AsyncOAuth2Client] = AsyncOAuth2Client
280+
core_svc_url: str | None = None
280281
_user_repo: UserRepository | None = field(default=None, repr=False, init=False)
281282
_rp_repo: ResourcePoolRepository | None = field(default=None, repr=False, init=False)
282283
_storage_repo: StorageRepository | None = field(default=None, repr=False, init=False)
@@ -611,6 +612,7 @@ def from_env(cls, prefix: str = "") -> "Config":
611612
kc_api: IKeycloakAPI
612613
secrets_service_public_key: PublicKeyTypes
613614
gitlab_url: str | None
615+
core_svc_url = os.environ.get("CORE_SERVICE_URL")
614616

615617
if os.environ.get(f"{prefix}DUMMY_STORES", "false").lower() == "true":
616618
encryption_key = secrets.token_bytes(32)
@@ -710,4 +712,5 @@ def from_env(cls, prefix: str = "") -> "Config":
710712
gitlab_url=gitlab_url,
711713
nb_config=nb_config,
712714
builds_config=builds_config,
715+
core_svc_url=core_svc_url,
713716
)

components/renku_data_services/base_models/core.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ async def filter_projects_by_access_level(
117117
"""Get a list of projects of which the user is a member with a specific access level."""
118118
...
119119

120+
async def get_project_url_from_path(self, user: APIUser, project_path: str) -> str | None:
121+
"""Get the project ID from the path i.e. from /group1/subgroup2/project3."""
122+
...
123+
120124

121125
class UserStore(Protocol):
122126
"""The interface through which Keycloak or a similar application can be accessed."""

components/renku_data_services/git/gitlab.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ def __post_init__(self) -> None:
2828

2929
self.gitlab_graphql_url = f"{gitlab_url}/api/graphql"
3030

31+
async def _query_gitlab_graphql(self, body: dict[str, Any], header: dict[str, Any]) -> dict[str, Any]:
32+
async with httpx.AsyncClient(verify=get_ssl_context(), timeout=5) as client:
33+
resp = await client.post(self.gitlab_graphql_url, json=body, headers=header, timeout=10)
34+
if resp.status_code != 200:
35+
raise errors.BaseError(message=f"Error querying Gitlab api {self.gitlab_graphql_url}: {resp.text}")
36+
result = cast(dict[str, Any], resp.json())
37+
return result
38+
3139
async def filter_projects_by_access_level(
3240
self, user: APIUser, project_ids: list[str], min_access_level: GitlabAccessLevel
3341
) -> list[str]:
@@ -65,18 +73,9 @@ async def filter_projects_by_access_level(
6573
"""
6674
}
6775

68-
async def _query_gitlab_graphql(body: dict[str, Any], header: dict[str, Any]) -> dict[str, Any]:
69-
async with httpx.AsyncClient(verify=get_ssl_context(), timeout=5) as client:
70-
resp = await client.post(self.gitlab_graphql_url, json=body, headers=header, timeout=10)
71-
if resp.status_code != 200:
72-
raise errors.BaseError(message=f"Error querying Gitlab api {self.gitlab_graphql_url}: {resp.text}")
73-
result = cast(dict[str, Any], resp.json())
74-
75-
if "data" not in result or "projects" not in result["data"]:
76-
raise errors.BaseError(message=f"Got unexpected response from Gitlab: {result}")
77-
return result
78-
79-
resp_body = await _query_gitlab_graphql(body, header)
76+
resp_body = await self._query_gitlab_graphql(body, header)
77+
if "data" not in resp_body or "projects" not in resp_body["data"]:
78+
raise errors.BaseError(message=f"Got unexpected response from Gitlab: {resp_body}")
8079
result: list[str] = []
8180

8281
def _process_projects(
@@ -108,12 +107,28 @@ def _process_projects(
108107
}}
109108
"""
110109
}
111-
resp_body = await _query_gitlab_graphql(body, header)
110+
resp_body = await self._query_gitlab_graphql(body, header)
111+
if "data" not in resp_body or "projects" not in resp_body["data"]:
112+
raise errors.BaseError(message=f"Got unexpected response from Gitlab: {resp_body}")
112113
page_info = resp_body["data"]["projects"]["pageInfo"]
113114
_process_projects(resp_body, min_access_level, result)
114115

115116
return result
116117

118+
async def get_project_url_from_path(self, user: APIUser, project_path: str) -> str | None:
119+
"""Get the project ID from the path i.e. from /group1/subgroup2/project3."""
120+
header = {"Content-Type": "application/json"}
121+
if user.access_token:
122+
header["Authorization"] = f"Bearer {user.access_token}"
123+
body = {
124+
"query": f'{{project(fullPath: "{project_path}") {{httpUrlToRepo}}}}',
125+
}
126+
127+
resp_body = await self._query_gitlab_graphql(body, header)
128+
if "data" not in resp_body or "project" not in resp_body["data"]:
129+
raise errors.BaseError(message=f"Got unexpected response from Gitlab: {resp_body}")
130+
return cast(str | None, resp_body["data"]["project"].get("httpUrlToRepo"))
131+
117132

118133
@dataclass(kw_only=True)
119134
class DummyGitlabAPI:
@@ -139,3 +154,7 @@ async def filter_projects_by_access_level(
139154
return []
140155
user_projects = self._store.get(user.full_name, {}).get(min_access_level, [])
141156
return [p for p in project_ids if p in user_projects]
157+
158+
async def get_project_url_from_path(self, user: APIUser, project_path: str) -> str | None:
159+
"""Get the project ID from the path i.e. from /group1/subgroup2/project3."""
160+
raise NotImplementedError()

components/renku_data_services/project/api.spec.yaml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,33 @@ paths:
201201
$ref: "#/components/responses/Error"
202202
tags:
203203
- projects
204+
/renku_v1_projects/path/{path}:
205+
get:
206+
summary: Try to get information about a v1 project from the core service. The path has to be url encoded.
207+
parameters:
208+
- in: path
209+
name: path
210+
required: true
211+
description: The Gitlab path for the project.
212+
schema:
213+
type: string
214+
responses:
215+
"200":
216+
description: V1 Project keywords and description
217+
content:
218+
application/json:
219+
schema:
220+
$ref: "#/components/schemas/V1Project"
221+
"404":
222+
description: No corresponding project found in Gitlab
223+
content:
224+
application/json:
225+
schema:
226+
$ref: "#/components/schemas/ErrorResponse"
227+
default:
228+
$ref: "#/components/responses/Error"
229+
tags:
230+
- projects
204231
/namespaces/{namespace}/projects/{slug}:
205232
get:
206233
summary: Get a project by namespace and project slug
@@ -1159,6 +1186,26 @@ components:
11591186
maxLength: 5000
11601187
nullable: true
11611188
example: My secret value
1189+
V1Project:
1190+
description: V1 Project properties
1191+
type: object
1192+
additionalProperties: false
1193+
properties:
1194+
id:
1195+
type: string
1196+
name:
1197+
type: string
1198+
keywords:
1199+
type: array
1200+
items:
1201+
type: string
1202+
description:
1203+
type: string
1204+
example:
1205+
id: 1234
1206+
keywords: ["kw1", "kw2"]
1207+
description: This is a sample description for a project.
1208+
name: Some Project
11621209
PaginationRequest:
11631210
type: object
11641211
additionalProperties: false

components/renku_data_services/project/apispec.py

Lines changed: 11 additions & 1 deletion
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-04-04T08:05:01+00:00
3+
# timestamp: 2025-04-09T22:30:09+00:00
44

55
from __future__ import annotations
66

@@ -120,6 +120,16 @@ class SessionSecretPatchExistingSecret(BaseAPISpec):
120120
)
121121

122122

123+
class V1Project(BaseAPISpec):
124+
model_config = ConfigDict(
125+
extra="forbid",
126+
)
127+
id: Optional[str] = None
128+
name: Optional[str] = None
129+
keywords: Optional[List[str]] = None
130+
description: Optional[str] = None
131+
132+
123133
class PaginationRequest(BaseAPISpec):
124134
model_config = ConfigDict(
125135
extra="forbid",

components/renku_data_services/project/blueprints.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from dataclasses import dataclass
44
from typing import Any
5+
from urllib.parse import unquote
56

67
from sanic import HTTPResponse, Request
78
from sanic.response import JSONResponse
@@ -12,6 +13,7 @@
1213
from renku_data_services.authz.models import Member, Role, Visibility
1314
from renku_data_services.base_api.auth import (
1415
authenticate,
16+
authenticate_2,
1517
only_authenticated,
1618
validate_path_user_id,
1719
)
@@ -27,6 +29,7 @@
2729
from renku_data_services.project import models as project_models
2830
from renku_data_services.project.core import (
2931
copy_project,
32+
get_v1_project_info,
3033
validate_project_patch,
3134
validate_session_secret_slot_patch,
3235
validate_session_secrets_patch,
@@ -54,6 +57,9 @@ class ProjectsBP(CustomBlueprint):
5457
session_repo: SessionRepository
5558
data_connector_repo: DataConnectorRepository
5659
project_migration_repo: ProjectMigrationRepository
60+
internal_gitlab_authenticator: base_models.Authenticator
61+
gitlab_client: base_models.GitlabAPIProtocol
62+
core_scv_url: str | None = None
5763

5864
def get_all(self) -> BlueprintFactoryResponse:
5965
"""List all projects."""
@@ -114,6 +120,25 @@ async def _post_migration(
114120

115121
return "/renku_v1_projects/<v1_id:int>/migrations", ["POST"], _post_migration
116122

123+
def get_v1_project_by_path(self) -> BlueprintFactoryResponse:
124+
"""Get information about a v1 project from the path."""
125+
126+
@authenticate_2(self.authenticator, self.internal_gitlab_authenticator)
127+
async def _get_v1_project_by_path(
128+
_: Request, user: base_models.APIUser, internal_gitlab_user: base_models.APIUser, path: str
129+
) -> JSONResponse:
130+
if self.core_scv_url is None:
131+
raise errors.MissingResourceError(
132+
message="The core service url is not defined so we cannot get project information."
133+
)
134+
decoded_path = unquote(path)
135+
output = await get_v1_project_info(
136+
user, internal_gitlab_user, decoded_path, self.gitlab_client, self.core_scv_url
137+
)
138+
return validated_json(apispec.V1Project, output)
139+
140+
return "/renku_v1_projects/path/{path:str}", ["GET"], _get_v1_project_by_path
141+
117142
def get_project_migration_info(self) -> BlueprintFactoryResponse:
118143
"""Get project migration by project v2 id."""
119144

components/renku_data_services/project/core.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
"""Business logic for projects."""
22

33
from pathlib import PurePosixPath
4-
from urllib.parse import urlparse
4+
from typing import cast
5+
from urllib.parse import urljoin, urlparse
56

7+
import httpx
68
from ulid import ULID
79

810
from renku_data_services import errors
911
from renku_data_services.authz.models import Visibility
1012
from renku_data_services.base_models import RESET, APIUser, ResetType, Slug
13+
from renku_data_services.base_models.core import GitlabAPIProtocol
1114
from renku_data_services.data_connectors.db import DataConnectorRepository
1215
from renku_data_services.project import apispec, models
1316
from renku_data_services.project.db import ProjectRepository
@@ -214,3 +217,51 @@ def _validate_session_launcher_secret_slot_filename(filename: str) -> None:
214217
filename_candidate = PurePosixPath(filename)
215218
if filename_candidate.name != filename:
216219
raise errors.ValidationError(message=f"Filename {filename} is not valid.")
220+
221+
222+
async def get_v1_project_info(
223+
user: APIUser,
224+
internal_gitlab_user: APIUser,
225+
project_path: str,
226+
gitlab_client: GitlabAPIProtocol,
227+
core_svc_url: str,
228+
) -> dict[str, str | list[str] | int | None]:
229+
"""Request project information from the core service for a Renku v1 project."""
230+
url = await gitlab_client.get_project_url_from_path(internal_gitlab_user, project_path)
231+
if not url:
232+
raise errors.MissingResourceError(
233+
message=f"The Renku v1 project with path {project_path} cannot be found "
234+
"in Gitlab or you do not have access to it"
235+
)
236+
237+
body = {"git_url": url, "is_delayed": False, "migrate_project": False}
238+
headers = {}
239+
if user.access_token:
240+
headers["Authorization"] = user.access_token
241+
full_url = urljoin(core_svc_url + "/", "project.show")
242+
async with httpx.AsyncClient() as clnt:
243+
res = await clnt.post(full_url, json=body, headers=headers)
244+
if res.status_code != 200:
245+
raise errors.MissingResourceError(
246+
message=f"The core service responded with an unexpected code {res.status_code} when getting "
247+
f"information about project {project_path} and url {url}"
248+
)
249+
res_json = cast(dict[str, dict[str, str | int | list[str]]], res.json())
250+
if res_json.get("error") is not None:
251+
raise errors.MissingResourceError(
252+
message=f"The core service responded with an error when getting "
253+
f"information about project {project_path} and url {url}",
254+
detail=cast(str | None, res_json.get("error", {}).get("userMessage")),
255+
)
256+
257+
kws = res_json.get("result", {}).get("keywords")
258+
desc = res_json.get("result", {}).get("description")
259+
id = res_json.get("result", {}).get("id")
260+
name = res_json.get("result", {}).get("name")
261+
output = {
262+
"name": name,
263+
"id": id,
264+
"keywords": kws,
265+
"description": desc,
266+
}
267+
return output

0 commit comments

Comments
 (0)