Skip to content

Commit 44df175

Browse files
committed
feat(apps): add list and patch endpoints
1 parent f0df7e0 commit 44df175

4 files changed

Lines changed: 36 additions & 33 deletions

File tree

components/renku_data_services/renku_apps/blueprints.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
"""Renku apps blueprints."""
22

33
from dataclasses import dataclass
4+
from typing import Any
45

56
from sanic import HTTPResponse, Request
6-
from sanic.response import JSONResponse, json
7+
from sanic.response import JSONResponse
78
from sanic_ext import validate
89
from ulid import ULID
910

1011
from renku_data_services import base_models
1112
from renku_data_services.base_api.auth import authenticate, only_authenticated
1213
from renku_data_services.base_api.blueprint import BlueprintFactoryResponse, CustomBlueprint
14+
from renku_data_services.base_models.validation import validated_json
1315
from renku_data_services.renku_apps import apispec
16+
from renku_data_services.renku_apps.models import App
1417
from renku_data_services.renku_apps.repository import RenkuAppsRepository
1518

1619

@@ -21,6 +24,19 @@ class RenkuAppBP(CustomBlueprint):
2124
apps_repo: RenkuAppsRepository
2225
authenticator: base_models.Authenticator
2326

27+
@staticmethod
28+
def _dump_app(app: App) -> dict[str, Any]:
29+
"""Dump an app for API responses."""
30+
return dict(
31+
name=app.name,
32+
launcher_id=str(app.launcher_id),
33+
project_id=str(app.project_id),
34+
status=app.status.value,
35+
url=app.url,
36+
started=app.started.isoformat() if app.started is not None else None,
37+
image=app.image,
38+
)
39+
2440
def post(self) -> BlueprintFactoryResponse:
2541
"""Launch a new app from a session launcher."""
2642

@@ -29,7 +45,7 @@ def post(self) -> BlueprintFactoryResponse:
2945
@validate(json=apispec.AppPostRequest)
3046
async def _post(_: Request, user: base_models.APIUser, body: apispec.AppPostRequest) -> JSONResponse:
3147
app = await self.apps_repo.create_app(user=user, launcher_id=ULID.from_str(body.launcher_id))
32-
return json(app.as_apispec().model_dump(exclude_none=True, mode="json"), status=201)
48+
return validated_json(apispec.AppResponse, self._dump_app(app), status=201)
3349

3450
return "/apps", ["POST"], _post
3551

@@ -39,7 +55,7 @@ def get_one(self) -> BlueprintFactoryResponse:
3955
@authenticate(self.authenticator)
4056
async def _get_one(_: Request, user: base_models.APIUser, app_name: str) -> JSONResponse:
4157
app = await self.apps_repo.get_app(user=user, app_name=app_name)
42-
return json(app.as_apispec().model_dump(exclude_none=True, mode="json"))
58+
return validated_json(apispec.AppResponse, self._dump_app(app))
4359

4460
return "/apps/<app_name>", ["GET"], _get_one
4561

@@ -69,7 +85,7 @@ async def _patch_one(
6985
state=body.state,
7086
resource_class_id=body.resource_class_id,
7187
)
72-
return json(app.as_apispec().model_dump(exclude_none=True, mode="json"))
88+
return validated_json(apispec.AppResponse, self._dump_app(app))
7389

7490
return "/apps/<app_name>", ["PATCH"], _patch_one
7591

@@ -83,6 +99,6 @@ async def _get_all(
8399
) -> JSONResponse:
84100
project_id = ULID.from_str(query.project_id) if query.project_id is not None else None
85101
apps = await self.apps_repo.list_apps(user=user, project_id=project_id)
86-
return json([app.as_apispec().model_dump(exclude_none=True, mode="json") for app in apps])
102+
return validated_json(apispec.AppListResponse, [self._dump_app(app) for app in apps])
87103

88104
return "/apps", ["GET"], _get_all

components/renku_data_services/renku_apps/k8s_client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ async def set_app_deployment_resources(self, app_name: str, resource_class: Reso
135135
gvk=KNATIVE_SERVICE_GVK,
136136
user_id=DUMMY_RENKU_APP_USER_ID,
137137
)
138+
# TODO: This JSON patch targets the container by position (containers/0). Once apps run more
139+
# than one container this becomes fragile and should be a strategic merge patch keyed on the
140+
# container name. Our kr8s wrapper only emits json/merge patches (never strategic merge), so
141+
# switching requires extending the wrapper first.
138142
patch_body: list[dict[str, Any]] = [
139143
{
140144
"op": "replace",

components/renku_data_services/renku_apps/models.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66

77
from ulid import ULID
88

9-
from renku_data_services.renku_apps import apispec
10-
119

1210
class AppStatus(StrEnum):
1311
"""The status of an app."""
@@ -30,18 +28,6 @@ class App:
3028
started: datetime | None = None
3129
image: str | None = None
3230

33-
def as_apispec(self) -> apispec.AppResponse:
34-
"""Convert the app to an API response model."""
35-
return apispec.AppResponse(
36-
name=self.name,
37-
launcher_id=str(self.launcher_id),
38-
status=apispec.AppStatus(self.status.value),
39-
url=self.url,
40-
project_id=str(self.project_id),
41-
started=self.started,
42-
image=self.image,
43-
)
44-
4531

4632
@dataclass(frozen=True, kw_only=True)
4733
class AppRuntimeState:

components/renku_data_services/renku_apps/repository.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
logger = logging.getLogger(__name__)
2121

2222

23+
def _app_not_found_message(app_name: str) -> str:
24+
"""Build the message for a missing or inaccessible app."""
25+
return f"App with name '{app_name}' does not exist or you do not have access to it."
26+
27+
2328
class RenkuAppsRepository:
2429
"""Use-case-focused API for Renku apps, dispatching to k8s rather than SQL."""
2530

@@ -64,9 +69,7 @@ async def get_app(self, user: base_models.APIUser, app_name: str) -> App:
6469
"""Retrieve an app by its name."""
6570
runtime_state = await self.k8s_client.get_app_deployment(app_name)
6671
if runtime_state is None:
67-
raise errors.MissingResourceError(
68-
message=f"App with name '{app_name}' does not exist or you do not have access to it."
69-
)
72+
raise errors.MissingResourceError(message=_app_not_found_message(app_name))
7073

7174
launcher = await self.session_repo.get_launcher(user, runtime_state.launcher_id)
7275
return build_app(launcher, runtime_state)
@@ -85,9 +88,7 @@ async def delete_app(self, user: base_models.APIUser, app_name: str) -> None:
8588

8689
authorized = await self.authz.has_permission(user, ResourceType.project, launcher.project_id, Scope.WRITE)
8790
if not authorized:
88-
raise errors.MissingResourceError(
89-
message=f"App with name '{app_name}' does not exist or you do not have authorization to modify it."
90-
)
91+
raise errors.MissingResourceError(message=_app_not_found_message(app_name))
9192

9293
await self.k8s_client.delete_app_deployment(app_name)
9394
logger.info(f"App with name {app_name} has been deleted.")
@@ -115,25 +116,21 @@ async def update_app(
115116

116117
runtime_state = await self.k8s_client.get_app_deployment(app_name)
117118
if runtime_state is None:
118-
raise errors.MissingResourceError(
119-
message=f"App with name '{app_name}' does not exist or you do not have access to it."
120-
)
119+
raise errors.MissingResourceError(message=_app_not_found_message(app_name))
121120

122121
launcher = await self.session_repo.get_launcher(user, runtime_state.launcher_id)
123122

124123
authorized = await self.authz.has_permission(user, ResourceType.project, launcher.project_id, Scope.WRITE)
125124
if not authorized:
126-
raise errors.MissingResourceError(
127-
message=f"App with name '{app_name}' does not exist or you do not have authorization to modify it."
128-
)
125+
raise errors.MissingResourceError(message=_app_not_found_message(app_name))
129126

130127
latest: AppRuntimeState = runtime_state
131128
if resource_class_id is not None:
132129
resource_class = await self.rp_repo.get_resource_class(user, resource_class_id)
133130
latest = await self.k8s_client.set_app_deployment_resources(app_name, resource_class)
134-
if state == apispec.AppState.hibernated:
131+
if state == apispec.AppState.hibernated and not runtime_state.is_hibernated:
135132
latest = await self.k8s_client.hibernate_app_deployment(app_name)
136-
elif state == apispec.AppState.running:
133+
elif state == apispec.AppState.running and runtime_state.is_hibernated:
137134
latest = await self.k8s_client.resume_app_deployment(app_name)
138135

139136
return build_app(launcher, latest)

0 commit comments

Comments
 (0)