From b1f4e37a2e759503c0b21f478dc6533d2646e716 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Thu, 9 Jul 2026 09:25:03 +0200 Subject: [PATCH 1/7] fix(apps): inject RENKU_SESSION_PORT so apps bind the port Knative probes --- .../renku_apps/k8s_client.py | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index 5c93453a3..289d0458a 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -207,8 +207,25 @@ def _build_app_deployment_manifest( } if resource_class is not None: container["resources"] = _resources_from_resource_class(resource_class) - if session_launcher.env_variables: - container["env"] = [{"name": var.name, "value": var.value} for var in session_launcher.env_variables] + + # Runtime contract: the app must bind to the same port Knative routes/probes + # (``containerPort`` above). We surface it as $RENKU_SESSION_PORT so the app can + # read it (e.g. from a Procfile). This mirrors the AmaltheaSession path in + # ``notebooks.core_sessions``; without it the buildpack default (8000) wins and + # the app binds a port Knative never probes, so the revision never becomes ready. + # System-controlled vars come first; user-defined vars are appended and are not + # allowed to override them. + env: list[dict[str, str]] = [ + {"name": "RENKU_SESSION_IP", "value": "0.0.0.0"}, # nosec B104 + {"name": "RENKU_SESSION_PORT", "value": str(environment.port)}, + ] + reserved_names = {var["name"] for var in env} + env.extend( + {"name": var.name, "value": var.value} + for var in session_launcher.env_variables or [] + if var.name not in reserved_names + ) + container["env"] = env if environment.command: container["command"] = environment.command if environment.args: From d49886f2769cd59f4f673be5a60bd839b22c0822 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Thu, 9 Jul 2026 09:25:03 +0200 Subject: [PATCH 2/7] feat(apps): support custom bring-your-own frontend for built environments --- components/renku_data_services/session/db.py | 2 +- .../renku_data_services/session/models.py | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/components/renku_data_services/session/db.py b/components/renku_data_services/session/db.py index b0f93c659..8e9d0e209 100644 --- a/components/renku_data_services/session/db.py +++ b/components/renku_data_services/session/db.py @@ -1230,7 +1230,7 @@ async def _get_buildrun_params( tolerations=tolerations, labels=labels, annotations=annotations, - frontend=build_parameters.frontend_variant, + frontend=build_parameters.build_frontend, git_repository_revision=git_repository_revision, context_dir=context_dir, insecure_registries=self.builds_config.build_insecure_registries, diff --git a/components/renku_data_services/session/models.py b/components/renku_data_services/session/models.py index dc579e3c3..c47713ab5 100644 --- a/components/renku_data_services/session/models.py +++ b/components/renku_data_services/session/models.py @@ -71,6 +71,11 @@ class FrontendVariant(StrEnum): jupyterlab = "jupyterlab" ttyd = "ttyd" rstudio = "rstudio" + # No RenkuLab-provided frontend: the built image runs the user's own web app. + # The start command comes from a Procfile ("web:" process) in the repository and + # the app must bind to $RENKU_SESSION_PORT. Mapped to the buildpack token "none" + # (see BuildParameters.build_frontend) when the build is dispatched. + custom = "custom" VALID_BUILDER_FRONTEND_COMBINATIONS: typing.Final[set[tuple[BuilderVariant, FrontendVariant]]] = { @@ -78,6 +83,8 @@ class FrontendVariant(StrEnum): (BuilderVariant.python, FrontendVariant.vscodium), (BuilderVariant.python, FrontendVariant.jupyterlab), (BuilderVariant.python, FrontendVariant.ttyd), + (BuilderVariant.python, FrontendVariant.custom), + (BuilderVariant.r, FrontendVariant.custom), } @@ -92,6 +99,19 @@ class UnsavedBuildParameters: repository_revision: str | None = None context_dir: str | None = None + @property + def build_frontend(self) -> str: + """The frontend token passed to the buildpacks via BP_RENKU_FRONTENDS. + + This is the persisted ``frontend_variant`` for every RenkuLab-provided frontend. + The "custom" frontend has no built-in UI, so it maps to the buildpack token + "none": the buildpacks then produce a runnable image without injecting a + frontend and defer the launch process to the repository Procfile. + """ + if self.frontend_variant == FrontendVariant.custom: + return "none" + return self.frontend_variant + @dataclass(kw_only=True, frozen=True, eq=True) class BuildParameters(UnsavedBuildParameters): @@ -473,4 +493,22 @@ class ShipwrightBuildStatusUpdate: environment_image_source=EnvironmentImageSource.build, strip_path_prefix=False, ), + FrontendVariant.custom.value: UnsavedEnvironment( + name="custom", + default_url="/", + # The user's app binds to $RENKU_SESSION_PORT, which the session runtime + # sets to this port. Keep it aligned with the other build frontends. + port=BUILD_PORT, + container_image="image:unknown-at-the-moment", + working_directory=BUILD_WORKING_DIRECTORY, + mount_directory=BUILD_MOUNT_DIRECTORY, + uid=BUILD_UID, + gid=BUILD_GID, + environment_kind=EnvironmentKind.CUSTOM, + environment_image_source=EnvironmentImageSource.build, + # A bring-your-own app is unlikely to be aware of the session's URL prefix, + # so strip it (like RStudio) and serve the app at "/". Apps that *are* + # prefix-aware can read $RENKU_BASE_URL_PATH instead. + strip_path_prefix=True, + ), } From 8b5dffa4b3fc0b21309d32e95a304ba512bc95b1 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Mon, 13 Jul 2026 17:02:37 +0200 Subject: [PATCH 3/7] fix(apps): enforce one app per project via project-id label --- .../renku_data_services/renku_apps/k8s_client.py | 10 +++++----- .../renku_data_services/renku_apps/repository.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index 289d0458a..210262a1e 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -85,11 +85,11 @@ async def get_app_deployment(self, app_name: str) -> AppRuntimeState | None: return None return _extract_runtime_state(KnativeService.model_validate(obj.manifest)) - async def get_app_deployment_for_project( - self, project: Project, session_launcher: SessionLauncher - ) -> AppRuntimeState | None: - """Get the runtime state for the given project's app, or None if it does not exist.""" - return await self.get_app_deployment(_generate_app_name(project, session_launcher)) + async def get_app_deployment_for_project(self, project_id: ULID) -> AppRuntimeState | None: + """Get an existing app in the project (matched by project-id label), or None if none exist.""" + async for runtime_state in self.list_app_deployments(project_id): + return runtime_state + return None async def get_app_deployment_for_launcher(self, launcher_id: ULID) -> AppRuntimeState | None: """Get the runtime state for the launcher's app via its label, or None if it does not exist.""" diff --git a/components/renku_data_services/renku_apps/repository.py b/components/renku_data_services/renku_apps/repository.py index e90c3ef49..87b85d31a 100644 --- a/components/renku_data_services/renku_apps/repository.py +++ b/components/renku_data_services/renku_apps/repository.py @@ -60,7 +60,7 @@ async def create_app(self, user: base_models.APIUser, launcher_id: ULID) -> App: resource_class = await self.rp_repo.get_resource_class(user, launcher.resource_class_id) project = await self.project_repo.get_project(user, launcher.project_id) - if await self.k8s_client.get_app_deployment_for_project(project, launcher) is not None: + if await self.k8s_client.get_app_deployment_for_project(launcher.project_id) is not None: raise errors.ConflictError(message=f"An app already exists for project '{launcher.project_id}'.") runtime_state = await self.k8s_client.create_app_deployment(launcher, resource_class, project) return build_app(launcher, runtime_state) From 3adb1926ab579c0d8e107bbe0978bebc507bf0ce Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Thu, 16 Jul 2026 14:00:40 +0200 Subject: [PATCH 4/7] feat(apps): mount public credential-free data connectors into apps --- .../data_api/dependencies.py | 30 ++-- .../renku_apps/data_connectors.py | 94 ++++++++++ .../renku_apps/k8s_client.py | 168 ++++++++++++++++-- .../renku_apps/repository.py | 15 +- 4 files changed, 281 insertions(+), 26 deletions(-) create mode 100644 components/renku_data_services/renku_apps/data_connectors.py diff --git a/bases/renku_data_services/data_api/dependencies.py b/bases/renku_data_services/data_api/dependencies.py index 4c3f94bb2..fc92c8628 100644 --- a/bases/renku_data_services/data_api/dependencies.py +++ b/bases/renku_data_services/data_api/dependencies.py @@ -81,6 +81,7 @@ from renku_data_services.session.db import SessionRepository from renku_data_services.session.k8s_client import ShipwrightClient from renku_data_services.storage.db import StorageRepository +from renku_data_services.storage.rclone import RCloneValidator from renku_data_services.users.db import UserPreferencesRepository from renku_data_services.users.db import UserRepo as KcUserRepo from renku_data_services.users.dummy_kc_api import DummyKeycloakAPI @@ -402,17 +403,6 @@ def from_env(cls) -> DependencyManager: builds_config=config.builds, git_repositories_repo=git_repositories_repo, ) - apps_k8s_client: RenkuAppsK8sClient | None = None - apps_repo: RenkuAppsRepository | None = None - if config.apps.enabled: - apps_k8s_client = RenkuAppsK8sClient(client=client, cluster_repo=cluster_repo) - apps_repo = RenkuAppsRepository( - authz=authz, - session_repo=session_repo, - rp_repo=rp_repo, - project_repo=project_repo, - k8s_client=apps_k8s_client, - ) project_migration_repo = ProjectMigrationRepository( session_maker=config.db.async_session_maker, authz=authz, @@ -466,6 +456,24 @@ def from_env(cls) -> DependencyManager: oauth_client_factory=oauth_http_client_factory, internal_token_mint=internal_token_mint, ) + validator = RCloneValidator() + apps_k8s_client: RenkuAppsK8sClient | None = None + apps_repo: RenkuAppsRepository | None = None + if config.apps.enabled: + apps_k8s_client = RenkuAppsK8sClient( + client=client, + cluster_repo=cluster_repo, + storage_class=config.nb_config.cloud_storage.storage_class, + ) + apps_repo = RenkuAppsRepository( + authz=authz, + session_repo=session_repo, + rp_repo=rp_repo, + project_repo=project_repo, + k8s_client=apps_k8s_client, + dc_secret_repo=data_connector_secret_repo, + validator=validator, + ) image_check_repo = ImageCheckRepository( nb_config=config.nb_config, connected_services_repo=connected_services_repo, diff --git a/components/renku_data_services/renku_apps/data_connectors.py b/components/renku_data_services/renku_apps/data_connectors.py new file mode 100644 index 000000000..eaa646ecc --- /dev/null +++ b/components/renku_data_services/renku_apps/data_connectors.py @@ -0,0 +1,94 @@ +"""Launch-time selection of the data connectors an app may mount. + +Apps are publicly and anonymously reachable and run as ``DUMMY_RENKU_APP_USER_ID`` +rather than a real user. The set of data connectors an app mounts is therefore a +**security boundary**: mounting a connector that carries a user's decrypted +credentials would expose that user's data to anonymous visitors. This module +defines the fail-closed filter that decides which of a project's linked connectors +are safe to expose through an app. + +A connector is mounted iff **all** of: + +1. it is public (owner intent); +2. it needs no static credentials (the UI's "Credentials" box -- ``get_private_fields``); +3. it needs no OAuth integration (the UI's "Integration" box -- ``_OAUTH2_INTEGRATION_STORAGE_TYPES``). + +Conditions 2 and 3 are the two halves of "requires nothing from a user"; together +they are what actually guarantee the underlying data is anonymously reachable. +Condition 3 is load-bearing: an OAuth connector set up via connect-account has an +*empty* set of static credential fields, so without it a user's private Google +Drive would pass condition 2 and be mounted into an anonymous public app. + +Any error while evaluating the predicate excludes the connector -- fail closed. + +See ``docs/adr/0001-mount-data-connectors-into-apps.md``. +""" + +from ulid import ULID + +from renku_data_services import base_models +from renku_data_services.app_config import logging +from renku_data_services.authz.models import Visibility +from renku_data_services.data_connectors.db import DataConnectorSecretRepository +from renku_data_services.data_connectors.models import ( + DataConnector, + DataConnectorWithSecrets, + GlobalDataConnector, +) +from renku_data_services.storage.rclone import RCloneValidator + +logger = logging.getLogger(__name__) + +_OAUTH2_INTEGRATION_STORAGE_TYPES = frozenset({"drive", "dropbox"}) +"""rclone storage types whose credentials come from an OAuth2 integration. + +SECURITY: this is the app-side copy of the drive/dropbox -> provider mapping that +``notebooks.data_sources.DataSourceRepository`` uses for sessions. If a new OAuth-backed +storage type is added there, it must be added here too -- otherwise a private connector of +that type would clear this filter and be mounted into an anonymous public app. +""" + + +def is_app_mountable(dc: DataConnector | GlobalDataConnector, validator: RCloneValidator) -> bool: + """Return whether a data connector is safe to mount into a public, anonymous app. + + Fail-closed: returns ``False`` on any error evaluating the predicate, so a + connector is only ever mounted when it has been positively shown to be public + and to require nothing from a user. + """ + try: + return ( + dc.visibility == Visibility.PUBLIC + and not list(validator.get_private_fields(dc.storage.configuration)) + and dc.storage.configuration.get("type") not in _OAUTH2_INTEGRATION_STORAGE_TYPES + ) + except Exception: + logger.warning("Excluding data connector %s from app: predicate error", getattr(dc, "id", "?"), exc_info=True) + return False + + +async def select_mountable_connectors( + user: base_models.APIUser, + project_id: ULID, + dc_secret_repo: DataConnectorSecretRepository, + validator: RCloneValidator, +) -> list[DataConnectorWithSecrets]: + """Enumerate the project's linked connectors and keep only those safe for an app. + + ``user`` should be the anonymous app identity (``DUMMY_RENKU_APP_USER_ID``): the + authz layer then only returns publicly-readable connectors, and downstream config + resolution mints no user tokens -- the same property that makes anonymous sessions + safe. Enumeration reuses the project-membership model sessions use + (``DataConnectorToProjectLinkORM``); there is no app-specific connector list. + """ + survivors: list[DataConnectorWithSecrets] = [] + async for dc in dc_secret_repo.get_data_connectors_with_secrets(user, project_id): + if is_app_mountable(dc.data_connector, validator): + survivors.append(dc) + + logger.info( + "Selected %d data connector(s) to mount for project %s", + len(survivors), + project_id, + ) + return survivors diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index 210262a1e..dbfa99797 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -3,16 +3,21 @@ from __future__ import annotations from collections.abc import AsyncGenerator +from dataclasses import dataclass from datetime import datetime +from hashlib import md5 +from pathlib import PurePosixPath from typing import Any from ulid import ULID from renku_data_services.crc.db import ClusterRepository from renku_data_services.crc.models import ResourceClass +from renku_data_services.data_connectors.models import DataConnectorWithSecrets from renku_data_services.k8s.clients import K8sClusterClientsPool from renku_data_services.k8s.constants import DEFAULT_K8S_CLUSTER, DUMMY_RENKU_APP_USER_ID, ClusterId -from renku_data_services.k8s.models import GVK, K8sObjectFilter, K8sObjectMeta +from renku_data_services.k8s.models import GVK, ClusterConnection, K8sObjectFilter, K8sObjectMeta, sanitizer +from renku_data_services.notebooks.api.schemas.cloud_storage import RCloneStorage from renku_data_services.project.models import Project from renku_data_services.renku_apps.cr_knative_service import Condition from renku_data_services.renku_apps.crs import KnativeService @@ -20,6 +25,10 @@ from renku_data_services.session.models import SessionLauncher KNATIVE_SERVICE_GVK = GVK(kind="Service", group="serving.knative.dev", version="v1") +_SECRET_GVK = GVK(kind="Secret", version="v1") +_PVC_GVK = GVK(kind="PersistentVolumeClaim", version="v1") + +_DEFAULT_WORK_DIR = PurePosixPath("/home/jovyan") _MAX_SCALE_ANNOTATION = "autoscaling.knative.dev/max-scale" _MAX_SCALE_RUNNING = "3" @@ -38,6 +47,59 @@ def _generate_app_name(project: Project, session_launcher: SessionLauncher) -> s return f"{project.slug.lower()[:54]}-{launcher_id_slice}" +def _data_source_base_name(app_name: str) -> str: + """Bounded, DNS-1123-safe base for an app's data-connector resource names.""" + digest = md5(app_name.encode(), usedforsecurity=False).hexdigest() + return f"{app_name[:12].rstrip('-')}-{digest[:12]}" + + +@dataclass +class _AppDataConnectorResources: + """The k8s manifests needed to mount one data connector into an app.""" + + name: str + secret: dict[str, Any] + pvc: dict[str, Any] + volume: dict[str, Any] + volume_mount: dict[str, Any] + + +def _build_data_connector_resources( + data_connectors: list[DataConnectorWithSecrets], + base_name: str, + namespace: str, + work_dir: PurePosixPath, + storage_class: str, + labels: dict[str, str], +) -> list[_AppDataConnectorResources]: + """Build the csi-rclone Secret/PVC/volume manifests for each mountable connector.""" + resources: list[_AppDataConnectorResources] = [] + for dc_with_secrets in data_connectors: + dc = dc_with_secrets.data_connector + resource_name = f"{base_name}-ds-{str(dc.id).lower()}" + target_path = PurePosixPath(dc.storage.target_path) + mount_folder = target_path.as_posix() if target_path.is_absolute() else (work_dir / target_path).as_posix() + storage = RCloneStorage( + source_path=dc.storage.source_path, + configuration=dict(dc.storage.configuration), + readonly=True, + mount_folder=mount_folder, + name=dc.name, + secrets={}, + storage_class=storage_class, + ) + resources.append( + _AppDataConnectorResources( + name=resource_name, + secret=sanitizer(storage.secret(resource_name, namespace, labels=labels)), + pvc=sanitizer(storage.pvc(resource_name, namespace, labels=labels)), + volume=sanitizer(storage.volume(resource_name)), + volume_mount=sanitizer(storage.volume_mount(resource_name)), + ) + ) + return resources + + class RenkuAppsK8sClient: """K8s client for Renku apps operations.""" @@ -45,19 +107,45 @@ def __init__( self, client: K8sClusterClientsPool, cluster_repo: ClusterRepository, + storage_class: str, cluster_id: ClusterId = DEFAULT_K8S_CLUSTER, ) -> None: self.__client = client self.__cluster_repo = cluster_repo + self.__storage_class = storage_class self.__cluster_id = cluster_id async def create_app_deployment( - self, session_launcher: SessionLauncher, resource_class: ResourceClass | None, project: Project + self, + session_launcher: SessionLauncher, + resource_class: ResourceClass | None, + project: Project, + data_connectors: list[DataConnectorWithSecrets], ) -> AppRuntimeState: """Create a deployment for the given app and return its observed runtime state.""" cluster = await self.__client.cluster_by_id(self.__cluster_id) app_name = _generate_app_name(project, session_launcher) - manifest = _build_app_deployment_manifest(session_launcher, app_name, resource_class, project) + labels = _app_labels(session_launcher, project) + + work_dir = session_launcher.environment.working_directory or _DEFAULT_WORK_DIR + dc_resources = _build_data_connector_resources( + data_connectors, + base_name=_data_source_base_name(app_name), + namespace=cluster.namespace, + work_dir=work_dir, + storage_class=self.__storage_class, + labels=labels, + ) + + manifest = _build_app_deployment_manifest( + session_launcher, + app_name, + resource_class, + project, + labels=labels, + volumes=[r.volume for r in dc_resources], + volume_mounts=[r.volume_mount for r in dc_resources], + ) meta = K8sObjectMeta( name=app_name, namespace=cluster.namespace, @@ -68,8 +156,33 @@ async def create_app_deployment( created = await self.__client.create( meta.with_manifest(manifest.model_dump(exclude_none=True, mode="json")), refresh=True ) + + owner_reference = _service_owner_reference(app_name, str(created.manifest.metadata.uid)) + for resource in dc_resources: + await self._create_owned(resource.secret, _SECRET_GVK, resource.name, cluster, owner_reference) + await self._create_owned(resource.pvc, _PVC_GVK, resource.name, cluster, owner_reference) + return _extract_runtime_state(KnativeService.model_validate(created.manifest)) + async def _create_owned( + self, + manifest: dict[str, Any], + gvk: GVK, + name: str, + cluster: ClusterConnection, + owner_reference: dict[str, Any], + ) -> None: + """Create a Service-owned resource (its PVC/Secret) with the owner reference set.""" + manifest.setdefault("metadata", {})["ownerReferences"] = [owner_reference] + meta = K8sObjectMeta( + name=name, + namespace=cluster.namespace, + cluster=cluster.id, + gvk=gvk, + user_id=DUMMY_RENKU_APP_USER_ID, + ) + await self.__client.create(meta.with_manifest(manifest), refresh=False) + async def get_app_deployment(self, app_name: str) -> AppRuntimeState | None: """Get the runtime state for the given app name, or None if it does not exist.""" cluster = await self.__client.cluster_by_id(self.__cluster_id) @@ -191,8 +304,38 @@ def _resources_from_resource_class(resource_class: ResourceClass) -> dict[str, A } +def _app_labels(session_launcher: SessionLauncher, project: Project) -> dict[str, str]: + """Labels shared by an app's Knative Service and its owned data-connector resources.""" + return { + "renku.io/safe-username": DUMMY_RENKU_APP_USER_ID, + "renku.io/project-slug": project.slug.lower(), + "renku.io/project-namespace": project.namespace.path.serialize().replace("/", "-").lower(), + "renku.io/project-id": str(project.id), + "renku.io/project-id-slug": str(project.id)[18:26].lower(), + "renku.io/launcher-id": str(session_launcher.id), + } + + +def _service_owner_reference(app_name: str, uid: str) -> dict[str, Any]: + """Owner reference making the app's Knative Service the owner of its PVC/Secret.""" + return { + "apiVersion": KNATIVE_SERVICE_GVK.group_version, + "kind": KNATIVE_SERVICE_GVK.kind, + "name": app_name, + "uid": uid, + "controller": True, + "blockOwnerDeletion": True, + } + + def _build_app_deployment_manifest( - session_launcher: SessionLauncher, app_name: str, resource_class: ResourceClass | None, project: Project + session_launcher: SessionLauncher, + app_name: str, + resource_class: ResourceClass | None, + project: Project, + labels: dict[str, str], + volumes: list[dict[str, Any]], + volume_mounts: list[dict[str, Any]], ) -> KnativeService: """Build a Knative Service manifest derived from the session launcher.""" environment = session_launcher.environment @@ -221,7 +364,7 @@ def _build_app_deployment_manifest( ] reserved_names = {var["name"] for var in env} env.extend( - {"name": var.name, "value": var.value} + {"name": var.name, "value": var.value or ""} for var in session_launcher.env_variables or [] if var.name not in reserved_names ) @@ -232,15 +375,12 @@ def _build_app_deployment_manifest( container["args"] = environment.args if environment.working_directory is not None: container["workingDir"] = str(environment.working_directory) + if volume_mounts: + container["volumeMounts"] = volume_mounts - labels = { - "renku.io/safe-username": DUMMY_RENKU_APP_USER_ID, - "renku.io/project-slug": project.slug.lower(), - "renku.io/project-namespace": project.namespace.path.serialize().replace("/", "-").lower(), - "renku.io/project-id": str(project.id), - "renku.io/project-id-slug": str(project.id)[18:26].lower(), - "renku.io/launcher-id": str(session_launcher.id), - } + pod_spec: dict[str, Any] = {"containers": [container]} + if volumes: + pod_spec["volumes"] = volumes return KnativeService.model_validate( { @@ -256,7 +396,7 @@ def _build_app_deployment_manifest( "labels": labels, "annotations": _APP_AUTOSCALING_ANNOTATIONS, }, - "spec": {"containers": [container]}, + "spec": pod_spec, }, }, } diff --git a/components/renku_data_services/renku_apps/repository.py b/components/renku_data_services/renku_apps/repository.py index 87b85d31a..62f1e90b2 100644 --- a/components/renku_data_services/renku_apps/repository.py +++ b/components/renku_data_services/renku_apps/repository.py @@ -9,13 +9,17 @@ from renku_data_services.authz.models import Scope, Visibility from renku_data_services.crc.db import ResourcePoolRepository from renku_data_services.crc.models import ResourceClass +from renku_data_services.data_connectors.db import DataConnectorSecretRepository +from renku_data_services.k8s.constants import DUMMY_RENKU_APP_USER_ID from renku_data_services.project.db import ProjectRepository from renku_data_services.renku_apps import apispec from renku_data_services.renku_apps.core import build_app +from renku_data_services.renku_apps.data_connectors import select_mountable_connectors from renku_data_services.renku_apps.k8s_client import RenkuAppsK8sClient from renku_data_services.renku_apps.models import App, AppRuntimeState from renku_data_services.session.db import SessionRepository from renku_data_services.session.models import SessionLauncher +from renku_data_services.storage.rclone import RCloneValidator logger = logging.getLogger(__name__) @@ -35,12 +39,16 @@ def __init__( rp_repo: ResourcePoolRepository, project_repo: ProjectRepository, k8s_client: RenkuAppsK8sClient, + dc_secret_repo: DataConnectorSecretRepository, + validator: RCloneValidator, ) -> None: self.authz = authz self.session_repo = session_repo self.rp_repo = rp_repo self.project_repo = project_repo self.k8s_client = k8s_client + self.dc_secret_repo = dc_secret_repo + self.validator = validator async def create_app(self, user: base_models.APIUser, launcher_id: ULID) -> App: """Launch a new app from a session launcher.""" @@ -62,7 +70,12 @@ async def create_app(self, user: base_models.APIUser, launcher_id: ULID) -> App: project = await self.project_repo.get_project(user, launcher.project_id) if await self.k8s_client.get_app_deployment_for_project(launcher.project_id) is not None: raise errors.ConflictError(message=f"An app already exists for project '{launcher.project_id}'.") - runtime_state = await self.k8s_client.create_app_deployment(launcher, resource_class, project) + + app_user = base_models.AnonymousAPIUser(id=DUMMY_RENKU_APP_USER_ID) + data_connectors = await select_mountable_connectors( + app_user, launcher.project_id, self.dc_secret_repo, self.validator + ) + runtime_state = await self.k8s_client.create_app_deployment(launcher, resource_class, project, data_connectors) return build_app(launcher, runtime_state) async def get_app(self, user: base_models.APIUser, app_name: str) -> App: From 1cd828048d935f84cbd62146d961f605d4f85989 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Thu, 16 Jul 2026 16:05:22 +0200 Subject: [PATCH 5/7] apply resource-class affinity/tolerations --- .../data_api/dependencies.py | 2 + .../renku_apps/k8s_client.py | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/bases/renku_data_services/data_api/dependencies.py b/bases/renku_data_services/data_api/dependencies.py index fc92c8628..81addd6b4 100644 --- a/bases/renku_data_services/data_api/dependencies.py +++ b/bases/renku_data_services/data_api/dependencies.py @@ -464,6 +464,8 @@ def from_env(cls) -> DependencyManager: client=client, cluster_repo=cluster_repo, storage_class=config.nb_config.cloud_storage.storage_class, + default_affinity=config.nb_config.sessions.affinity_model, + default_tolerations=config.nb_config.sessions.tolerations_model, ) apps_repo = RenkuAppsRepository( authz=authz, diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index dbfa99797..093fb372a 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -18,6 +18,11 @@ from renku_data_services.k8s.constants import DEFAULT_K8S_CLUSTER, DUMMY_RENKU_APP_USER_ID, ClusterId from renku_data_services.k8s.models import GVK, ClusterConnection, K8sObjectFilter, K8sObjectMeta, sanitizer from renku_data_services.notebooks.api.schemas.cloud_storage import RCloneStorage +from renku_data_services.notebooks.crs import Affinity, Toleration +from renku_data_services.notebooks.utils import ( + node_affinity_from_resource_class, + tolerations_from_resource_class, +) from renku_data_services.project.models import Project from renku_data_services.renku_apps.cr_knative_service import Condition from renku_data_services.renku_apps.crs import KnativeService @@ -108,11 +113,15 @@ def __init__( client: K8sClusterClientsPool, cluster_repo: ClusterRepository, storage_class: str, + default_affinity: Affinity, + default_tolerations: list[Toleration], cluster_id: ClusterId = DEFAULT_K8S_CLUSTER, ) -> None: self.__client = client self.__cluster_repo = cluster_repo self.__storage_class = storage_class + self.__default_affinity = default_affinity + self.__default_tolerations = default_tolerations self.__cluster_id = cluster_id async def create_app_deployment( @@ -137,6 +146,9 @@ async def create_app_deployment( labels=labels, ) + affinity, tolerations = _scheduling_from_resource_class( + resource_class, self.__default_affinity, self.__default_tolerations + ) manifest = _build_app_deployment_manifest( session_launcher, app_name, @@ -145,6 +157,8 @@ async def create_app_deployment( labels=labels, volumes=[r.volume for r in dc_resources], volume_mounts=[r.volume_mount for r in dc_resources], + affinity=affinity, + tolerations=tolerations, ) meta = K8sObjectMeta( name=app_name, @@ -328,6 +342,24 @@ def _service_owner_reference(app_name: str, uid: str) -> dict[str, Any]: } +def _scheduling_from_resource_class( + resource_class: ResourceClass | None, + default_affinity: Affinity, + default_tolerations: list[Toleration], +) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: + """Derive the app pod's node affinity and tolerations from the resource class, mirroring sessions.""" + if resource_class is not None: + affinity = node_affinity_from_resource_class(resource_class, default_affinity) + tolerations = tolerations_from_resource_class(resource_class, default_tolerations) + else: + affinity = default_affinity + tolerations = default_tolerations + + affinity_dict = None if affinity.is_empty else affinity.model_dump(exclude_none=True, mode="json") + toleration_dicts = [tol.model_dump(exclude_none=True, mode="json") for tol in tolerations] + return affinity_dict, toleration_dicts + + def _build_app_deployment_manifest( session_launcher: SessionLauncher, app_name: str, @@ -336,6 +368,8 @@ def _build_app_deployment_manifest( labels: dict[str, str], volumes: list[dict[str, Any]], volume_mounts: list[dict[str, Any]], + affinity: dict[str, Any] | None, + tolerations: list[dict[str, Any]], ) -> KnativeService: """Build a Knative Service manifest derived from the session launcher.""" environment = session_launcher.environment @@ -381,6 +415,10 @@ def _build_app_deployment_manifest( pod_spec: dict[str, Any] = {"containers": [container]} if volumes: pod_spec["volumes"] = volumes + if affinity: + pod_spec["affinity"] = affinity + if tolerations: + pod_spec["tolerations"] = tolerations return KnativeService.model_validate( { From a974c4718efe8d33af113da14b67007d8c1437e8 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Fri, 17 Jul 2026 11:02:50 +0200 Subject: [PATCH 6/7] only mark an app failed on terminal Knative reasons --- components/renku_data_services/renku_apps/core.py | 5 ++++- components/renku_data_services/renku_apps/k8s_client.py | 1 + components/renku_data_services/renku_apps/models.py | 5 ++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/components/renku_data_services/renku_apps/core.py b/components/renku_data_services/renku_apps/core.py index 329943b08..412afacca 100644 --- a/components/renku_data_services/renku_apps/core.py +++ b/components/renku_data_services/renku_apps/core.py @@ -3,6 +3,9 @@ from renku_data_services.renku_apps.models import App, AppRuntimeState, AppStatus from renku_data_services.session.models import SessionLauncher +_TERMINAL_READY_REASONS = frozenset({"ProgressDeadlineExceeded", "RevisionFailed"}) +"""Knative Ready=False reasons that mean the revision has definitively failed (others are transient).""" + def build_app(launcher: SessionLauncher, runtime: AppRuntimeState) -> App: """Compose an App from its launcher and the runtime state observed in the cluster.""" @@ -24,6 +27,6 @@ def derive_app_status(runtime: AppRuntimeState) -> AppStatus: return AppStatus.HIBERNATED if runtime.ready_status == "True": return AppStatus.READY - if runtime.ready_status == "False": + if runtime.ready_status == "False" and runtime.ready_reason in _TERMINAL_READY_REASONS: return AppStatus.FAILED return AppStatus.PENDING diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index 093fb372a..7ab11577d 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -496,6 +496,7 @@ def _extract_runtime_state(knative_service: KnativeService) -> AppRuntimeState: launcher_id=knative_service.launcher_id, project_id=knative_service.project_id, ready_status=ready.status if ready is not None else None, + ready_reason=ready.reason if ready is not None else None, is_hibernated=_is_hibernated(knative_service), image=_container_image(knative_service), url=_url(knative_service), diff --git a/components/renku_data_services/renku_apps/models.py b/components/renku_data_services/renku_apps/models.py index 2d3ab9b17..d6cb3a828 100644 --- a/components/renku_data_services/renku_apps/models.py +++ b/components/renku_data_services/renku_apps/models.py @@ -36,13 +36,16 @@ class AppRuntimeState: Carries the primitives that the K8s adapter extracts from a Knative Service so that domain logic can compose an App without depending on K8s types. The ready_status field holds the raw Kubernetes Ready-condition status value - ("True", "False", "Unknown", or None if the condition is absent). + ("True", "False", "Unknown", or None if the condition is absent); ready_reason + holds that condition's reason, needed to tell a terminal failure from a + transient not-ready (e.g. a pod waiting for a node to scale up). """ name: str launcher_id: ULID project_id: ULID ready_status: str | None + ready_reason: str | None is_hibernated: bool image: str | None url: str | None From 7b277092d23af4f04dce6820e10e895171712e13 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Tue, 28 Jul 2026 13:20:01 +0200 Subject: [PATCH 7/7] bound generated app names to the AppName contract --- .../renku_data_services/project/blueprints.py | 2 +- .../renku_apps/api.spec.yaml | 43 +---------- .../renku_data_services/renku_apps/apispec.py | 10 --- .../renku_apps/blueprints.py | 18 ----- .../renku_data_services/renku_apps/core.py | 29 +++++++- .../renku_apps/k8s_client.py | 71 +------------------ .../renku_data_services/renku_apps/models.py | 2 - .../renku_apps/repository.py | 46 ++---------- 8 files changed, 38 insertions(+), 183 deletions(-) diff --git a/components/renku_data_services/project/blueprints.py b/components/renku_data_services/project/blueprints.py index a1d1a70ae..4f2e9a09f 100644 --- a/components/renku_data_services/project/blueprints.py +++ b/components/renku_data_services/project/blueprints.py @@ -310,7 +310,7 @@ async def _patch( ) if project_patch.visibility == Visibility.PRIVATE and self.apps_repo is not None: - await self.apps_repo.hibernate_apps_for_project(project_id=project_id) + await self.apps_repo.delete_apps_for_project(project_id=project_id) if len(project_update.new.repositories) > len(project_update.old.repositories): await self.metrics.code_repo_linked_to_project(user) diff --git a/components/renku_data_services/renku_apps/api.spec.yaml b/components/renku_data_services/renku_apps/api.spec.yaml index e62fc359e..33b5d6c05 100644 --- a/components/renku_data_services/renku_apps/api.spec.yaml +++ b/components/renku_data_services/renku_apps/api.spec.yaml @@ -68,32 +68,6 @@ paths: $ref: "#/components/responses/Error" tags: - apps - patch: - summary: Update an app - parameters: - - in: path - name: app_name - required: true - schema: - $ref: "#/components/schemas/AppName" - description: The name of the app to update - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AppPatchRequest" - responses: - "200": - description: The updated app - content: - application/json: - schema: - $ref: "#/components/schemas/AppResponse" - default: - $ref: "#/components/responses/Error" - tags: - - apps delete: summary: Delete an app parameters: @@ -125,18 +99,13 @@ components: - pending - ready - failed - - hibernated - - AppState: - description: The desired state of an app. `hibernated` scales the deployment to zero. - type: string - enum: - - running - - hibernated AppName: type: string minLength: 5 + # The k8s limit is 63 characters (the name becomes a hostname label) but we leave + # some leeway. Must match APP_NAME_MAX_LENGTH in renku_apps/core.py, which is what + # generate_app_name() truncates to. maxLength: 50 pattern: "^[a-z]([-a-z0-9]*[a-z0-9])?$" example: d185e68d-d43-renku-2-b9ac279a4e8a85ac28d08 @@ -181,12 +150,6 @@ components: required: - launcher_id - AppPatchRequest: - type: object - properties: - state: - $ref: "#/components/schemas/AppState" - ErrorResponse: type: object properties: diff --git a/components/renku_data_services/renku_apps/apispec.py b/components/renku_data_services/renku_apps/apispec.py index 3b585c781..36616e270 100644 --- a/components/renku_data_services/renku_apps/apispec.py +++ b/components/renku_data_services/renku_apps/apispec.py @@ -16,12 +16,6 @@ class AppStatus(Enum): pending = "pending" ready = "ready" failed = "failed" - hibernated = "hibernated" - - -class AppState(Enum): - running = "running" - hibernated = "hibernated" class AppResponse(BaseAPISpec): @@ -66,10 +60,6 @@ class AppPostRequest(BaseAPISpec): ) -class AppPatchRequest(BaseAPISpec): - state: Optional[AppState] = None - - class Error(BaseAPISpec): code: int = Field(..., examples=[1404], gt=0) detail: Optional[str] = Field( diff --git a/components/renku_data_services/renku_apps/blueprints.py b/components/renku_data_services/renku_apps/blueprints.py index 372bc4e86..93b26a55d 100644 --- a/components/renku_data_services/renku_apps/blueprints.py +++ b/components/renku_data_services/renku_apps/blueprints.py @@ -70,24 +70,6 @@ async def _delete_one(_: Request, user: base_models.APIUser, app_name: str) -> H return "/apps/", ["DELETE"], _delete_one - def patch_one(self) -> BlueprintFactoryResponse: - """Patch an app.""" - - @authenticate(self.authenticator) - @only_authenticated - @validate(json=apispec.AppPatchRequest) - async def _patch_one( - _: Request, user: base_models.APIUser, body: apispec.AppPatchRequest, app_name: str - ) -> JSONResponse: - app = await self.apps_repo.update_app( - user=user, - app_name=app_name, - state=body.state, - ) - return validated_json(apispec.AppResponse, self._dump_app(app)) - - return "/apps/", ["PATCH"], _patch_one - def get_all(self) -> BlueprintFactoryResponse: """Get all apps, optionally filtered by project ID.""" diff --git a/components/renku_data_services/renku_apps/core.py b/components/renku_data_services/renku_apps/core.py index 412afacca..3ca020f22 100644 --- a/components/renku_data_services/renku_apps/core.py +++ b/components/renku_data_services/renku_apps/core.py @@ -1,11 +1,38 @@ """Business logic for Renku apps.""" +import re +from typing import Final + +from ulid import ULID + from renku_data_services.renku_apps.models import App, AppRuntimeState, AppStatus from renku_data_services.session.models import SessionLauncher _TERMINAL_READY_REASONS = frozenset({"ProgressDeadlineExceeded", "RevisionFailed"}) """Knative Ready=False reasons that mean the revision has definitively failed (others are transient).""" +APP_NAME_MAX_LENGTH: Final[int] = 50 +"""Upper bound on a generated app name; must match AppName.maxLength in api.spec.yaml.""" + +_LAUNCHER_ID_SUFFIX_LENGTH: Final[int] = 8 +"""Trailing characters of the launcher ULID that make an app name unique per launcher.""" + +_SLUG_MAX_LENGTH: Final[int] = APP_NAME_MAX_LENGTH - _LAUNCHER_ID_SUFFIX_LENGTH - 1 + + +def generate_app_name(project_slug: str, launcher_id: ULID) -> str: + """Generate a DNS-1035 label name for an app, bounded to APP_NAME_MAX_LENGTH.""" + suffix = str(launcher_id)[-_LAUNCHER_ID_SUFFIX_LENGTH:].lower() + return f"{_slug_label(project_slug)}-{suffix}" + + +def _slug_label(slug: str) -> str: + """Coerce a project slug (which may hold dots, underscores or a leading digit) into a DNS-1035 label.""" + label = re.sub(r"-+", "-", re.sub(r"[^a-z0-9]", "-", slug.lower())) + if not label[:1].isalpha(): + label = f"app-{label}" + return label[:_SLUG_MAX_LENGTH].rstrip("-") + def build_app(launcher: SessionLauncher, runtime: AppRuntimeState) -> App: """Compose an App from its launcher and the runtime state observed in the cluster.""" @@ -23,8 +50,6 @@ def build_app(launcher: SessionLauncher, runtime: AppRuntimeState) -> App: def derive_app_status(runtime: AppRuntimeState) -> AppStatus: """Derive an app status from the runtime state.""" - if runtime.is_hibernated: - return AppStatus.HIBERNATED if runtime.ready_status == "True": return AppStatus.READY if runtime.ready_status == "False" and runtime.ready_reason in _TERMINAL_READY_REASONS: diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index 7ab11577d..52d6b476f 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -24,6 +24,7 @@ tolerations_from_resource_class, ) from renku_data_services.project.models import Project +from renku_data_services.renku_apps.core import generate_app_name from renku_data_services.renku_apps.cr_knative_service import Condition from renku_data_services.renku_apps.crs import KnativeService from renku_data_services.renku_apps.models import AppRuntimeState @@ -37,7 +38,6 @@ _MAX_SCALE_ANNOTATION = "autoscaling.knative.dev/max-scale" _MAX_SCALE_RUNNING = "3" -_MAX_SCALE_HIBERNATED = "0" _APP_AUTOSCALING_ANNOTATIONS = { "autoscaling.knative.dev/min-scale": "0", @@ -46,12 +46,6 @@ } -def _generate_app_name(project: Project, session_launcher: SessionLauncher) -> str: - """Generate a DNS-1035 label name for an app.""" - launcher_id_slice = str(session_launcher.id)[18:26].lower() - return f"{project.slug.lower()[:54]}-{launcher_id_slice}" - - def _data_source_base_name(app_name: str) -> str: """Bounded, DNS-1123-safe base for an app's data-connector resource names.""" digest = md5(app_name.encode(), usedforsecurity=False).hexdigest() @@ -133,7 +127,7 @@ async def create_app_deployment( ) -> AppRuntimeState: """Create a deployment for the given app and return its observed runtime state.""" cluster = await self.__client.cluster_by_id(self.__cluster_id) - app_name = _generate_app_name(project, session_launcher) + app_name = generate_app_name(project.slug, session_launcher.id) labels = _app_labels(session_launcher, project) work_dir = session_launcher.environment.working_directory or _DEFAULT_WORK_DIR @@ -259,53 +253,6 @@ async def list_app_deployments(self, project_id: ULID | None = None) -> AsyncGen async for obj in self.__client.list(obj_filter): yield _extract_runtime_state(KnativeService.model_validate(obj.manifest)) - async def hibernate_app_deployment(self, app_name: str) -> AppRuntimeState: - """Hibernate the app by patching its max-scale annotation to zero.""" - return await self._patch_max_scale(app_name, _MAX_SCALE_HIBERNATED) - - async def resume_app_deployment(self, app_name: str) -> AppRuntimeState: - """Resume the app by restoring the default max-scale annotation.""" - return await self._patch_max_scale(app_name, _MAX_SCALE_RUNNING) - - async def set_app_deployment_resources(self, app_name: str, resource_class: ResourceClass) -> AppRuntimeState: - """Update the container resources of the app to match the given resource class.""" - 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, - ) - # TODO: This JSON patch targets the container by position (containers/0). Once apps run more - # than one container this becomes fragile and should be a strategic merge patch keyed on the - # container name. Our kr8s wrapper only emits json/merge patches (never strategic merge), so - # switching requires extending the wrapper first. - patch_body: list[dict[str, Any]] = [ - { - "op": "replace", - "path": "/spec/template/spec/containers/0/resources", - "value": _resources_from_resource_class(resource_class), - } - ] - updated = await self.__client.patch(meta, patch_body) - return _extract_runtime_state(KnativeService.model_validate(updated.manifest)) - - async def _patch_max_scale(self, app_name: str, max_scale: str) -> AppRuntimeState: - 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, - ) - patch_body: dict[str, Any] = { - "spec": {"template": {"metadata": {"annotations": {_MAX_SCALE_ANNOTATION: max_scale}}}} - } - updated = await self.__client.patch(meta, patch_body) - return _extract_runtime_state(KnativeService.model_validate(updated.manifest)) - def _resources_from_resource_class(resource_class: ResourceClass) -> dict[str, Any]: """Build a k8s container resources block from a resource class.""" @@ -463,19 +410,6 @@ def _started_at(knative_service: KnativeService) -> datetime | None: return datetime.fromisoformat(ready.lastTransitionTime) -def _is_hibernated(knative_service: KnativeService) -> bool: - """Determine if the Knative service is hibernated based on its annotations.""" - if ( - knative_service.spec is None - or knative_service.spec.template is None - or knative_service.spec.template.metadata is None - or knative_service.spec.template.metadata.annotations is None - ): - return False - max_scale = knative_service.spec.template.metadata.annotations.get(_MAX_SCALE_ANNOTATION) - return max_scale == _MAX_SCALE_HIBERNATED - - def _container_image(knative_service: KnativeService) -> str | None: """Get the container image actually configured on the Knative service, or None if absent.""" if ( @@ -497,7 +431,6 @@ def _extract_runtime_state(knative_service: KnativeService) -> AppRuntimeState: project_id=knative_service.project_id, ready_status=ready.status if ready is not None else None, ready_reason=ready.reason if ready is not None else None, - is_hibernated=_is_hibernated(knative_service), image=_container_image(knative_service), url=_url(knative_service), started_at=_started_at(knative_service), diff --git a/components/renku_data_services/renku_apps/models.py b/components/renku_data_services/renku_apps/models.py index d6cb3a828..a88dff15c 100644 --- a/components/renku_data_services/renku_apps/models.py +++ b/components/renku_data_services/renku_apps/models.py @@ -13,7 +13,6 @@ class AppStatus(StrEnum): PENDING = "pending" READY = "ready" FAILED = "failed" - HIBERNATED = "hibernated" @dataclass(frozen=True, eq=True, kw_only=True) @@ -46,7 +45,6 @@ class AppRuntimeState: project_id: ULID ready_status: str | None ready_reason: str | None - is_hibernated: bool image: str | None url: str | None started_at: datetime | None diff --git a/components/renku_data_services/renku_apps/repository.py b/components/renku_data_services/renku_apps/repository.py index 62f1e90b2..d99a6e159 100644 --- a/components/renku_data_services/renku_apps/repository.py +++ b/components/renku_data_services/renku_apps/repository.py @@ -6,17 +6,16 @@ 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, Visibility +from renku_data_services.authz.models import Scope from renku_data_services.crc.db import ResourcePoolRepository from renku_data_services.crc.models import ResourceClass from renku_data_services.data_connectors.db import DataConnectorSecretRepository from renku_data_services.k8s.constants import DUMMY_RENKU_APP_USER_ID from renku_data_services.project.db import ProjectRepository -from renku_data_services.renku_apps import apispec from renku_data_services.renku_apps.core import build_app from renku_data_services.renku_apps.data_connectors import select_mountable_connectors from renku_data_services.renku_apps.k8s_client import RenkuAppsK8sClient -from renku_data_services.renku_apps.models import App, AppRuntimeState +from renku_data_services.renku_apps.models import App from renku_data_services.session.db import SessionRepository from renku_data_services.session.models import SessionLauncher from renku_data_services.storage.rclone import RCloneValidator @@ -115,45 +114,10 @@ async def delete_app_for_launcher(self, user: base_models.APIUser, launcher: Ses await self.delete_app(user, runtime_state.name) return None - async def update_app( - self, - user: base_models.APIUser, - app_name: str, - state: apispec.AppState | None = None, - ) -> App: - """Update an app.""" - 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: - raise errors.MissingResourceError(message=_app_not_found_message(app_name)) - - 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=_app_not_found_message(app_name)) - - latest: AppRuntimeState = runtime_state - if state == apispec.AppState.hibernated and not runtime_state.is_hibernated: - latest = await self.k8s_client.hibernate_app_deployment(app_name) - elif state == apispec.AppState.running and runtime_state.is_hibernated: - project = await self.project_repo.get_project(user, launcher.project_id) - if project.visibility != Visibility.PUBLIC: - raise errors.ValidationError(message="This app cannot be resumed because its project is not public.") - if launcher.resource_class_id is not None: - resource_class = await self.rp_repo.get_resource_class(user, launcher.resource_class_id) - await self.k8s_client.set_app_deployment_resources(app_name, resource_class) - latest = await self.k8s_client.resume_app_deployment(app_name) - - return build_app(launcher, latest) - - async def hibernate_apps_for_project(self, project_id: ULID) -> None: - """Hibernate every running app belonging to the given project.""" + async def delete_apps_for_project(self, project_id: ULID) -> None: + """Delete every app belonging to the given project (e.g. when it becomes private).""" async for runtime_state in self.k8s_client.list_app_deployments(project_id): - if not runtime_state.is_hibernated: - await self.k8s_client.hibernate_app_deployment(runtime_state.name) + await self.k8s_client.delete_app_deployment(runtime_state.name) async def list_apps(self, user: base_models.APIUser, project_id: ULID | None = None) -> list[App]: """List all apps, optionally filtered by project."""