Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions bases/renku_data_services/data_api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -466,6 +456,26 @@ 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,
default_affinity=config.nb_config.sessions.affinity_model,
default_tolerations=config.nb_config.sessions.tolerations_model,
)
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,
Expand Down
2 changes: 1 addition & 1 deletion components/renku_data_services/project/blueprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 3 additions & 40 deletions components/renku_data_services/renku_apps/api.spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Comment on lines +106 to +108

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: should we add this to the help field rather than just in a comment?

maxLength: 50
pattern: "^[a-z]([-a-z0-9]*[a-z0-9])?$"
example: d185e68d-d43-renku-2-b9ac279a4e8a85ac28d08
Expand Down Expand Up @@ -181,12 +150,6 @@ components:
required:
- launcher_id

AppPatchRequest:
type: object
properties:
state:
$ref: "#/components/schemas/AppState"

ErrorResponse:
type: object
properties:
Expand Down
10 changes: 0 additions & 10 deletions components/renku_data_services/renku_apps/apispec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand Down
18 changes: 0 additions & 18 deletions components/renku_data_services/renku_apps/blueprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,24 +70,6 @@ async def _delete_one(_: Request, user: base_models.APIUser, app_name: str) -> H

return "/apps/<app_name>", ["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/<app_name>", ["PATCH"], _patch_one

def get_all(self) -> BlueprintFactoryResponse:
"""Get all apps, optionally filtered by project ID."""

Expand Down
34 changes: 31 additions & 3 deletions components/renku_data_services/renku_apps/core.py
Original file line number Diff line number Diff line change
@@ -1,8 +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}"
Comment on lines +23 to +26

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a few test cases for this to make sure it works as expected. It should not take long to add them.



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("-")
Comment on lines +29 to +34

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: I understand the outermost re.sub call - that one replaces repeated - in the name with a single -. But the second I am confused about. It replaces the first character or number of slug with a dash? Is this because you are trying to join with dash in f"app-{label}".

Also you have this: re.sub(r"[^a-z0-9]", "-", slug.lower()) but I suspect you want re.sub(r"^[a-z0-9]", "-", slug.lower()), the beggining of string character should be outside of the set.



def build_app(launcher: SessionLauncher, runtime: AppRuntimeState) -> App:
"""Compose an App from its launcher and the runtime state observed in the cluster."""
Expand All @@ -20,10 +50,8 @@ 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":
if runtime.ready_status == "False" and runtime.ready_reason in _TERMINAL_READY_REASONS:
return AppStatus.FAILED
return AppStatus.PENDING
94 changes: 94 additions & 0 deletions components/renku_data_services/renku_apps/data_connectors.py
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +1 to +8

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: Do we really need a separate file for these 2 functions in this file? Why not move them to core.py?


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
Comment on lines +65 to +67

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: please handle the specific exception or exceptions, not all exceptions, this can hide real bugs or issues



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
Comment on lines +70 to +94

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: If this is supposed to select dcs for apps and we know we want public dcs only why allow user to be passed? It seems like we want user to always be the the one for anonymous user. So just hardcode it inside the function.

Loading
Loading