-
Notifications
You must be signed in to change notification settings - Fork 2
feat(apps): add build-from-code custom frontend, data connectors, etc #1391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: add-apps-launcher-handling
Are you sure you want to change the base?
Changes from all commits
b1f4e37
d49886f
8b5dffa
3adb192
1cd8280
a974c47
7b27709
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: I understand the outermost re.sub call - that one replaces repeated Also you have this: |
||
|
|
||
|
|
||
| def build_app(launcher: SessionLauncher, runtime: AppRuntimeState) -> App: | ||
| """Compose an App from its launcher and the runtime state observed in the cluster.""" | ||
|
|
@@ -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 | ||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
There was a problem hiding this comment.
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?