diff --git a/components/renku_data_services/renku_apps/api.spec.yaml b/components/renku_data_services/renku_apps/api.spec.yaml index 4e95330e7..19d38d7b1 100644 --- a/components/renku_data_services/renku_apps/api.spec.yaml +++ b/components/renku_data_services/renku_apps/api.spec.yaml @@ -48,6 +48,22 @@ paths: $ref: "#/components/responses/Error" tags: - apps + delete: + summary: Delete an app + parameters: + - in: path + name: app_name + required: true + schema: + $ref: "#/components/schemas/AppName" + description: The name of the app to delete + responses: + "204": + description: The app was successfully deleted or did not exist + default: + $ref: "#/components/responses/Error" + tags: + - apps components: schemas: Ulid: diff --git a/components/renku_data_services/renku_apps/blueprints.py b/components/renku_data_services/renku_apps/blueprints.py index 75a4bdf07..62d09688c 100644 --- a/components/renku_data_services/renku_apps/blueprints.py +++ b/components/renku_data_services/renku_apps/blueprints.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -from sanic import Request +from sanic import HTTPResponse, Request from sanic.response import JSONResponse, json from sanic_ext import validate from ulid import ULID @@ -42,3 +42,14 @@ async def _get_one(_: Request, user: base_models.APIUser, app_name: str) -> JSON return json(app.as_apispec().model_dump(exclude_none=True, mode="json")) return "/apps/", ["GET"], _get_one + + def delete_one(self) -> BlueprintFactoryResponse: + """Delete an app by name.""" + + @authenticate(self.authenticator) + @only_authenticated + async def _delete_one(_: Request, user: base_models.APIUser, app_name: str) -> HTTPResponse: + await self.apps_repo.delete_app(user=user, app_name=app_name) + return HTTPResponse(status=204) + + return "/apps/", ["DELETE"], _delete_one diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index 737aae1fd..a3a1ff8c7 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -84,8 +84,16 @@ async def get_app_deployment_for_project( return await self.get_app_deployment(_generate_app_name(project, session_launcher)) async def delete_app_deployment(self, app_name: str) -> None: - """Delete the deployment for the given app name. NOT IMPLEMENTED.""" - raise NotImplementedError("Deleting app deployment is not implemented yet") + """Delete the deployment for the given app name.""" + cluster = await self.__client.cluster_by_id(self.__cluster_id) + meta = K8sObjectMeta( + name=app_name, + namespace=cluster.namespace, + cluster=cluster.id, + gvk=KNATIVE_SERVICE_GVK, + user_id=DUMMY_RENKU_APP_USER_ID, + ) + await self.__client.delete(meta) async def list_app_deployments(self) -> AsyncGenerator[AppRuntimeState, None]: """List all app deployments. NOT IMPLEMENTED.""" diff --git a/components/renku_data_services/renku_apps/repository.py b/components/renku_data_services/renku_apps/repository.py index 4f120ec58..a1ed32b63 100644 --- a/components/renku_data_services/renku_apps/repository.py +++ b/components/renku_data_services/renku_apps/repository.py @@ -4,6 +4,7 @@ import renku_data_services.base_models as base_models from renku_data_services import errors +from renku_data_services.app_config import logging from renku_data_services.authz.authz import Authz, ResourceType from renku_data_services.authz.models import Scope from renku_data_services.crc.db import ResourcePoolRepository @@ -14,6 +15,8 @@ from renku_data_services.renku_apps.models import App from renku_data_services.session.db import SessionRepository +logger = logging.getLogger(__name__) + class RenkuAppsRepository: """Use-case-focused API for Renku apps, dispatching to k8s rather than SQL.""" @@ -65,3 +68,25 @@ async def get_app(self, user: base_models.APIUser, app_name: str) -> App: launcher = await self.session_repo.get_launcher(user, runtime_state.launcher_id) return build_app(launcher, runtime_state) + + async def delete_app(self, user: base_models.APIUser, app_name: str) -> None: + """Delete an app by its name.""" + if not user.is_authenticated or user.id is None: + raise errors.UnauthorizedError(message="You do not have the required permissions for this operation.") + + runtime_state = await self.k8s_client.get_app_deployment(app_name) + if runtime_state is None: + logger.info(f"App with name {app_name} was not found.") + return None + + launcher = await self.session_repo.get_launcher(user, runtime_state.launcher_id) + + authorized = await self.authz.has_permission(user, ResourceType.project, launcher.project_id, Scope.WRITE) + if not authorized: + raise errors.MissingResourceError( + message=f"App with name '{app_name}' does not exist or you do not have authorization to modify it." + ) + + await self.k8s_client.delete_app_deployment(app_name) + logger.info(f"App with name {app_name} has been deleted.") + return None