diff --git a/docs/reference/commands.rst b/docs/reference/commands.rst index 63530959f7..386745fab1 100644 --- a/docs/reference/commands.rst +++ b/docs/reference/commands.rst @@ -196,6 +196,13 @@ Renku Command Line .. automodule:: renku.ui.cli.githooks +``renku session`` +----------------- + +.. automodule:: renku.ui.cli.session + +.. _cli-session: + Error Tracking -------------- diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 4e8158cb44..721751581d 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -1,4 +1,5 @@ acyclic +adhoc admin Amalthea analytics @@ -243,6 +244,7 @@ Unlink unlinking unmapped unmerged +unpushed untracked untracked updatable diff --git a/renku/core/errors.py b/renku/core/errors.py index e4c9578d42..8ba5eaa72f 100644 --- a/renku/core/errors.py +++ b/renku/core/errors.py @@ -593,6 +593,18 @@ def __init__(self, reason: str): super().__init__(f"Docker failed: {reason}") +class RenkulabSessionError(RenkuException): + """Raised when an error occurs trying to start sessions with the notebook service.""" + + +class NotebookSessionNotReadyError(RenkuException): + """Raised when a user attempts to open a session that is not ready.""" + + +class NotebookSessionImageNotExistError(RenkuException): + """Raised when a user attempts to start a session with an image that does not exist.""" + + class MetadataMergeError(RenkuException): """Raise when merging of metadata failed.""" diff --git a/renku/core/plugin/implementations/__init__.py b/renku/core/plugin/implementations/__init__.py index 64d2937346..089c1f8175 100644 --- a/renku/core/plugin/implementations/__init__.py +++ b/renku/core/plugin/implementations/__init__.py @@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, List, Type from renku.core.session.docker import DockerSessionProvider +from renku.core.session.renkulab import RenkulabSessionProvider from renku.core.workflow.converters.cwl import CWLExporter from renku.core.workflow.providers.cwltool import CWLToolProvider @@ -30,7 +31,7 @@ __all__: List[str] = [] -session_providers: "List[Type[ISessionProvider]]" = [DockerSessionProvider] +session_providers: "List[Type[ISessionProvider]]" = [DockerSessionProvider, RenkulabSessionProvider] workflow_exporters: "List[Type[IWorkflowConverter]]" = [CWLExporter] workflow_providers: "List[Type[IWorkflowProvider]]" = [CWLToolProvider] diff --git a/renku/core/session/renkulab.py b/renku/core/session/renkulab.py new file mode 100644 index 0000000000..5d8cf794fc --- /dev/null +++ b/renku/core/session/renkulab.py @@ -0,0 +1,379 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2018-2022 - Swiss Data Science Center (SDSC) +# A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and +# Eidgenössische Technische Hochschule Zürich (ETHZ). +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Docker based interactive session provider.""" + +import urllib +from pathlib import Path +from time import monotonic, sleep +from typing import Any, Dict, List, Optional, Tuple, Union + +from lazy_object_proxy import Proxy + +from renku.command.command_builder import inject +from renku.core import errors +from renku.core.interface.client_dispatcher import IClientDispatcher +from renku.core.management.client import LocalClient +from renku.core.plugin import hookimpl +from renku.core.session.utils import get_renku_project_name, get_renku_url +from renku.core.util import communication, requests +from renku.core.util.git import get_remote +from renku.domain_model.session import ISessionProvider, Session + + +def _get_token(client: LocalClient, renku_url: str) -> Tuple[str, bool]: + """Get a token for authenticating with renku. + + If the user is logged in then the JWT token from renku login will be used. + Otherwise the anonymous user token will be used. Returns the token and a flag to + indicate if the user is registered (true) or anonymous(false). + """ + registered_token = client.get_value(section="http", key=urllib.parse.urlparse(renku_url).netloc) + if not registered_token: + return _get_anonymous_credentials(client=client, renku_url=renku_url), False + return registered_token, True + + +def _get_anonymous_credentials(client: LocalClient, renku_url: str) -> str: + def _get_anonymous_token() -> Optional[str]: + import requests + + with requests.Session() as session: + url = urllib.parse.urljoin(renku_url, "api/user") + try: + session.get( + url, + headers={ + "X-Requested-With": "XMLHttpRequest", + "X-Forwarded-Uri": "/api/user", + }, + ) + except (requests.exceptions.RequestException, requests.exceptions.ConnectionError): + pass + return session.cookies.get("anon-id") + + renku_host = urllib.parse.urlparse(renku_url).netloc + anon_token = client.get_value(section="anonymous_token", key=renku_host) + if not anon_token: + anon_token = _get_anonymous_token() + if not anon_token: + raise errors.AuthenticationError( + "Could not get anonymous user token from Renku. " + f"Ensure the Renku deployment at {renku_url} supports anonymous sessions." + ) + client.set_value(section="anonymous_token", key=renku_host, value=anon_token, global_only=True) + return anon_token + + +class RenkulabSessionProvider(ISessionProvider): + """A session provider that uses the notebook service API to launch sessions.""" + + DEFAULT_TIMEOUT_SECONDS = 300 + + def __init__(self): + self.__renku_url = None + self.__notebooks_url = None + self.__client_dispatcher = None + + def _client_dispatcher(self): + """Get (and if required set) the client dispatcher class variable.""" + if not self.__client_dispatcher: + self.__client_dispatcher = Proxy(lambda: inject.instance(IClientDispatcher)) + return self.__client_dispatcher + + def _renku_url(self) -> str: + """Get the URL of the renku instance.""" + if not self.__renku_url: + renku_url = get_renku_url() + if not renku_url: + raise errors.UsageError( + "Cannot determine the renku URL to launch a session. " + "Ensure your current project is a valid Renku project." + ) + self.__renku_url = renku_url + return self.__renku_url + + def _notebooks_url(self) -> str: + """Get the url of the notebooks API.""" + if not self.__notebooks_url: + url = urllib.parse.urljoin(self._renku_url(), "api/notebooks") + self.__notebooks_url = url + return self.__notebooks_url + + def _token(self) -> str: + """Get the JWT token used to authenticate against Renku.""" + token, _ = _get_token(client=self._client_dispatcher().current_client, renku_url=self._renku_url()) + if token is None: + raise errors.AuthenticationError("Please run the renku login command to authenticate with Renku.") + return token + + def _is_user_registered(self) -> bool: + _, is_user_registered = _get_token(client=self._client_dispatcher().current_client, renku_url=self._renku_url()) + return is_user_registered + + def _auth_header(self) -> Dict[str, str]: + """Get the authentication header with the JWT token or cookie needed to authenticate with Renku.""" + if self._is_user_registered(): + return {"Authorization": f"Bearer {self._token()}"} + return {"Cookie": f"anon-id={self._token()}"} + + def _get_renku_project_name_parts(self) -> Dict[str, str]: + client = self._client_dispatcher().current_client + if client.remote["name"]: + if get_remote(client.repository, name="renku-backup-origin") and client.remote["owner"].startswith( + "repos/" + ): + owner = client.remote["owner"].replace("repos/", "", 1) + else: + owner = client.remote["owner"] + return { + "namespace": owner, + "project": client.remote["name"], + } + else: + # INFO: In this case the owner/name split is not available. The project name is then + # derived from the combined name of the remote and has to be split up in the two parts. + parts = get_renku_project_name().split("/") + return { + "namespace": "/".join(parts[:-1]), + "project": parts[:-1], + } + + def _wait_for_session_status( + self, + name: Optional[str], + status: str, + ): + if not name: + return + start = monotonic() + while monotonic() - start < self.DEFAULT_TIMEOUT_SECONDS: + res = self._send_renku_request( + "get", f"{self._notebooks_url()}/servers/{name}", headers=self._auth_header() + ) + if res.status_code == 404 and status == "stopping": + return + if res.status_code == 200 and status != "stopping": + if res.json().get("status", {}).get("state") == status: + return + sleep(5) + raise errors.RenkulabSessionError(f"Waiting for the session {name} to reach status {status} timed out.") + + def _wait_for_image( + self, + image_name: str, + config: Optional[Dict[str, Any]], + ): + """Check if an image exists, and if it does not wait for it to appear. + + Timeout after a specific period of time. + """ + start = monotonic() + while monotonic() - start < self.DEFAULT_TIMEOUT_SECONDS: + if self.find_image(image_name, config): + return + sleep(5) + raise errors.RenkulabSessionError( + f"Waiting for the image {image_name} to be built timed out." + "Are you sure that the image was successfully built? This could be the result " + "of problems with your Dockerfile." + ) + + def pre_start_checks(self): + """Check if the state of the repository is as expected before starting a session.""" + if not self._is_user_registered(): + return + if self._client_dispatcher().current_client.repository.is_dirty(untracked_files=True): + communication.confirm( + "You have new uncommitted or untracked changes to your repository. " + "Renku can automatically commit these changes so that it builds " + "the correct environment for your session. Do you wish to proceed?", + abort=True, + ) + self._client_dispatcher().current_client.repository.add(all=True) + self._client_dispatcher().current_client.repository.commit("Automated commit by Renku CLI.") + + def _remote_head_hexsha(self): + return get_remote(self._client_dispatcher().current_client.repository).head + + @staticmethod + def _send_renku_request(req_type: str, *args, **kwargs): + res = getattr(requests, req_type)(*args, **kwargs) + if res.status_code == 401: + raise errors.AuthenticationError( + "Please run the renku login command to authenticate with Renku or to refresh your expired credentials." + ) + return res + + def build_image(self, image_descriptor: Path, image_name: str, config: Optional[Dict[str, Any]]): + """Builds the container image.""" + if self.find_image(image_name, config=config): + return + if not self._is_user_registered(): + raise errors.NotebookSessionImageNotExistError( + f"Renku cannot find the image {image_name} and use it in an anonymous session." + ) + if self._client_dispatcher().current_client.repository.head.commit.hexsha != self._remote_head_hexsha(): + self._client_dispatcher().current_client.repository.push() + self._wait_for_image(image_name=image_name, config=config) + + def find_image(self, image_name: str, config: Optional[Dict[str, Any]]) -> bool: + """Find the given container image.""" + return ( + self._send_renku_request( + "get", + f"{self._notebooks_url()}/images", + headers=self._auth_header(), + params={"image_url": image_name}, + ).status_code + == 200 + ) + + @hookimpl + def session_provider(self) -> Tuple[ISessionProvider, str]: + """Supported session provider. + + Returns: + a tuple of ``self`` and provider name. + """ + return (self, "renkulab") + + def session_list(self, project_name: str, config: Optional[Dict[str, Any]]) -> List[Session]: + """Lists all the sessions currently running by the given session provider. + + Returns: + list: a list of sessions. + """ + sessions_res = self._send_renku_request( + "get", + f"{self._notebooks_url()}/servers", + headers=self._auth_header(), + params=self._get_renku_project_name_parts(), + ) + if sessions_res.status_code == 200: + return [ + Session( + session["name"], + session.get("status", {}).get("state", "unknown"), + self.session_url(session["name"]), + ) + for session in sessions_res.json().get("servers", {}).values() + ] + return [] + + def session_start( + self, + image_name: str, + project_name: str, + config: Optional[Dict[str, Any]], + client: LocalClient, + cpu_request: Optional[float] = None, + mem_request: Optional[str] = None, + disk_request: Optional[str] = None, + gpu_request: Optional[str] = None, + ) -> str: + """Creates an interactive session. + + Returns: + str: a unique id for the created interactive sesssion. + """ + session_commit = client.repository.head.commit.hexsha + if not self._is_user_registered(): + communication.warn( + "You are starting a session as an anonymous user. " + "None of the local changes in this project will be reflected in your session. " + "In addition, any changes you make in the new session will be lost when " + "the session is shut down." + ) + else: + if client.repository.head.commit.hexsha != self._remote_head_hexsha(): + # INFO: The user is registered, the image is pinned or already available + # but the local repository is not fully in sync with the remote + communication.confirm( + "You have unpushed commits that will not be present in your session. " + "Renku can automatically push these commits so that they are present " + "in the session you are launching. Do you wish to proceed?", + abort=True, + ) + client.repository.push() + server_options: Dict[str, Union[str, float]] = {} + if cpu_request: + server_options["cpu_request"] = cpu_request + if mem_request: + server_options["mem_request"] = mem_request + if gpu_request: + server_options["gpu_request"] = int(gpu_request) + if disk_request: + server_options["disk_request"] = disk_request + payload = { + "image": image_name, + "commit_sha": session_commit, + "serverOptions": server_options, + **self._get_renku_project_name_parts(), + } + res = self._send_renku_request( + "post", + f"{self._notebooks_url()}/servers", + headers=self._auth_header(), + json=payload, + ) + if res.status_code in [200, 201]: + session_name = res.json()["name"] + self._wait_for_session_status(session_name, "running") + return session_name + raise errors.RenkulabSessionError("Cannot start session via the notebook service because " + res.text) + + def session_stop(self, project_name: str, session_name: Optional[str], stop_all: bool) -> bool: + """Stops all sessions (for the given project) or a specific interactive session.""" + responses = [] + if stop_all: + sessions = self.session_list(project_name=project_name, config=None) + for session in sessions: + responses.append( + self._send_renku_request( + "delete", f"{self._notebooks_url()}/servers/{session.id}", headers=self._auth_header() + ) + ) + self._wait_for_session_status(session.id, "stopping") + else: + responses.append( + self._send_renku_request( + "delete", f"{self._notebooks_url()}/servers/{session_name}", headers=self._auth_header() + ) + ) + self._wait_for_session_status(session_name, "stopping") + return all([response.status_code == 204 for response in responses]) + + def session_url(self, session_name: str) -> str: + """Get the URL of the interactive session.""" + if self._is_user_registered(): + project_name_parts = self._get_renku_project_name_parts() + session_url_parts = [ + "projects", + project_name_parts["namespace"], + project_name_parts["project"], + "sessions/show", + session_name, + ] + return urllib.parse.urljoin(self._renku_url(), "/".join(session_url_parts)) + else: + # NOTE: The sessions/show logic of the UI expects a cookie to already be present + # with the anonymous user ID, but in this case we need to open a new browser window + # and need to pass the token in the URL, that is why anonymous sessions will be shown + # and openeed in the full session view not in the i-frame view like registered sessions + session_url_parts = ["sessions", f"{session_name}?token={self._token()}"] + return urllib.parse.urljoin(self._renku_url(), "/".join(session_url_parts)) diff --git a/renku/core/session/session.py b/renku/core/session/session.py index 17647bb928..ad131bae54 100644 --- a/renku/core/session/session.py +++ b/renku/core/session/session.py @@ -25,17 +25,12 @@ from renku.core import errors from renku.core.interface.client_dispatcher import IClientDispatcher from renku.core.plugin.session import supported_session_providers +from renku.core.session.utils import get_image_repository_host, get_renku_project_name from renku.core.util import communication from renku.core.util.os import safe_read_yaml from renku.domain_model.session import ISessionProvider -@inject.autoparams() -def _get_renku_project_name(client_dispatcher: IClientDispatcher) -> str: - client = client_dispatcher.current_client - return f"{client.remote['owner']}/{client.remote['name']}" if client.remote["name"] else f"{client.path.name}" - - def _safe_get_provider(provider: str) -> ISessionProvider: providers = supported_session_providers() p = next(filter(lambda x: x[1] == provider, providers), None) @@ -47,7 +42,7 @@ def _safe_get_provider(provider: str) -> ISessionProvider: def session_list(config_path: str, provider: Optional[str] = None): """List interactive sessions.""" - project_name = _get_renku_project_name() + project_name = get_renku_project_name() config = safe_read_yaml(config_path) if config_path else dict() providers = supported_session_providers() @@ -81,19 +76,24 @@ def session_start( provider_api = _safe_get_provider(provider) config = safe_read_yaml(config_path) if config_path else dict() - project_name = _get_renku_project_name() + provider_api.pre_start_checks() + + project_name = get_renku_project_name() if image_name is None: tag = client.repository.head.commit.hexsha[:7] + repo_host = get_image_repository_host() image_name = f"{project_name}:{tag}" + if repo_host: + image_name = f"{repo_host}/{image_name}" if not provider_api.find_image(image_name, config): communication.confirm( f"The container image '{image_name}' does not exists. Would you like to build it?", abort=True, ) - - with communication.busy(msg="Building image"): + with communication.busy(msg=f"Building image {image_name}"): _ = provider_api.build_image(client.docker_path.parent, image_name, config) + communication.echo(f"Image {image_name} built successfully.") else: if not provider_api.find_image(image_name, config): raise errors.ParameterError(f"Cannot find the provided container image '{image_name}'!") @@ -104,32 +104,40 @@ def session_start( mem_limit = mem_request or client.get_value("interactive", "mem_request") gpu = gpu_request or client.get_value("interactive", "gpu_request") - return provider_api.session_start( - config=config, - project_name=project_name, - image_name=image_name, - client=client, - cpu_request=cpu_limit, - mem_request=mem_limit, - disk_request=disk_limit, - gpu_request=gpu, - ) + with communication.busy(msg="Waiting for session to start..."): + session_name = provider_api.session_start( + config=config, + project_name=project_name, + image_name=image_name, + client=client, + cpu_request=cpu_limit, + mem_request=mem_limit, + disk_request=disk_limit, + gpu_request=gpu, + ) + communication.echo(msg=f"Session {session_name} successfully started") + return session_name def session_stop(session_name: str, stop_all: bool = False, provider: Optional[str] = None): """Stop interactive session.""" - project_name = _get_renku_project_name() + session_detail = "all sessions" if stop_all else f"session {session_name}" + project_name = get_renku_project_name() if provider: p = _safe_get_provider(provider) - is_stopped = p.session_stop(project_name=project_name, session_name=session_name, stop_all=stop_all) + with communication.busy(msg=f"Waiting for {session_detail} to stop..."): + is_stopped = p.session_stop(project_name=project_name, session_name=session_name, stop_all=stop_all) else: providers = supported_session_providers() - is_stopped = any( - map( - lambda x: x[0].session_stop(project_name=project_name, session_name=session_name, stop_all=stop_all), - providers, + with communication.busy(msg=f"Waiting for {session_detail} to stop..."): + is_stopped = any( + map( + lambda x: x[0].session_stop( + project_name=project_name, session_name=session_name, stop_all=stop_all + ), + providers, + ) ) - ) if not is_stopped: raise errors.ParameterError(f"Could not find '{session_name}' among the running sessions.") diff --git a/renku/core/session/utils.py b/renku/core/session/utils.py new file mode 100644 index 0000000000..6f32c19673 --- /dev/null +++ b/renku/core/session/utils.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2018-2022- Swiss Data Science Center (SDSC) +# A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and +# Eidgenössische Technische Hochschule Zürich (ETHZ). +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Utility functions used for sessions.""" +import urllib +from typing import Optional + +from renku.command.command_builder import inject +from renku.core.interface.client_dispatcher import IClientDispatcher +from renku.core.util.git import get_remote +from renku.core.util.urls import parse_authentication_endpoint + + +@inject.autoparams() +def get_renku_project_name(client_dispatcher: IClientDispatcher) -> str: + """Get the full name of a renku project.""" + client = client_dispatcher.current_client + project_name = ( + f"{client.remote['owner']}/{client.remote['name']}" if client.remote["name"] else f"{client.path.name}" + ) + if get_remote(client.repository, name="renku-backup-origin") and project_name.startswith("repos/"): + project_name = project_name.replace("repos/", "", 1) + return project_name + + +def get_renku_url() -> Optional[str]: + """Derive the URL of the Renku deployment.""" + renku_url = parse_authentication_endpoint(use_remote=True) + if renku_url: + renku_url = urllib.parse.urlunparse(renku_url) + return renku_url + + +def get_image_repository_host() -> Optional[str]: + """Derive the hostname for the gitlab container registry.""" + renku_url = get_renku_url() + if not renku_url: + return None + return "registry." + urllib.parse.urlparse(renku_url).netloc diff --git a/renku/core/util/requests.py b/renku/core/util/requests.py index 0d7ae447f2..c2280c7f8d 100644 --- a/renku/core/util/requests.py +++ b/renku/core/util/requests.py @@ -60,9 +60,9 @@ def delete(url, headers=None): return _request("delete", url=url, headers=headers) -def get(url, headers=None): +def get(url, headers=None, params=None): """Send a GET request.""" - return _request("get", url=url, headers=headers) + return _request("get", url=url, headers=headers, params=params) def head(url, *, allow_redirects=False, headers=None): diff --git a/renku/core/util/urls.py b/renku/core/util/urls.py index 14668445d7..90b26d11e2 100644 --- a/renku/core/util/urls.py +++ b/renku/core/util/urls.py @@ -72,8 +72,10 @@ def get_path(url: str) -> str: return urllib.parse.urlparse(url).path -@inject.autoparams() -def parse_authentication_endpoint(endpoint: str, client_dispatcher: IClientDispatcher, use_remote=False): +@inject.autoparams("client_dispatcher") +def parse_authentication_endpoint( + client_dispatcher: IClientDispatcher, endpoint: Optional[str] = None, use_remote: bool = False +): """Return a parsed url. If an endpoint is provided then use it, otherwise, look for a configured endpoint. If no configured endpoint exists diff --git a/renku/domain_model/session.py b/renku/domain_model/session.py index b98f59e561..b3de8abf03 100644 --- a/renku/domain_model/session.py +++ b/renku/domain_model/session.py @@ -142,3 +142,12 @@ def session_url(self, session_name: str) -> Optional[str]: URL of the interactive session. """ pass + + def pre_start_checks(self): + """Perform any required checks on the state of the repository prior to starting a session. + + The expectation is that this method will abort the + session start if the checks are not successful or will take corrective actions to + make sure that the session launches successfully. By default this method does not do any checks. + """ + return None diff --git a/renku/infrastructure/repository.py b/renku/infrastructure/repository.py index fa5f984a1a..d271b6b7e0 100644 --- a/renku/infrastructure/repository.py +++ b/renku/infrastructure/repository.py @@ -1369,6 +1369,12 @@ def set_url(self, url: str): """Change URL of a remote.""" _run_git_command(self._repository, "remote", "set-url", self.name, url) + @property + def head(self) -> str: + """The head commit of the remote.""" + self._remote.fetch() + return _run_git_command(self._repository, "rev-parse", f"{self._remote.name}/{self._repository.active_branch}") + class RemoteManager: """Manage remotes of a Repository.""" diff --git a/renku/ui/cli/session.py b/renku/ui/cli/session.py index 0becb41367..449f5b55a0 100644 --- a/renku/ui/cli/session.py +++ b/renku/ui/cli/session.py @@ -15,7 +15,94 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""Manage interactive sessions.""" +"""Manage interactive sessions. + +Interactive sessions can be started via the command line interface with different +providers. Currently two providers are supported: ``docker`` and ``renkulab``. + +Docker provider +~~~~~~~~~~~~~~~ + +The ``docker`` provider will take the current state of the repository, build a ``docker`` +image (if one does not already exist) and then launch a session with this image. In +addition to this the ``docker`` provider will mount the local repository inside +the ``docker`` container so that changes made in the session are immediately reflected +on the host where the session was originally started from. + +Please note that in order to use this provider `Docker `_ +is expected to be installed and available on your computer. In addition, using +this command from within a Renku interactive session started from the Renku website +is not possible. This command is envisioned as a means for users to quickly test +and check their sessions locally without going to a Renku deployment and launching +a session there, or in the case where they simply have no access to a Renku deployment. + +.. code-block:: console + + $ renku session start -p docker + +Renkulab provider +~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``renkulab`` provider will launch a regular interactive session +in the Renku deployment that hosts the current project. If the project has not +been uploaded/created in a Renku deployment then this provider will not be able +to launch a session. This provider is identical to going through the Renku website +and launching a session "manually" by selecting the project, commit, branch, etc. + +Please note that there are a few limitations with the ``renkulab`` provider: + +* If the user is not logged in (using the ``renku login`` command) then sessions + can only be launched if the specific Renku deployment supports anonymous sessions. + +* When launching anonymous sessions local changes cannot be reflected in them and + changes made inside the session cannot be saved nor downloaded locally. This feature + should be used only for adhoc exploration or work that can be discarded when + the session is closed. The CLI will print a warning every time an anonymous session + is launched. + +* Changes made inside the interactive session are not immediately reflected locally, + users should ``git pull`` any changes made inside an interactive session to get the + same changes locally. + +* Local changes can only be reflected in the interactive session if they are committed + and pushed to the git repository. When launching a session and uncommitted or unpushed + changes are present, the user will be prompted to confirm whether Renku should + commit and push the changes before a session is launched. The session will launch + only if the changes are committed and pushed. + +.. code-block:: console + + $ renku session start -p renkulab + +Managing active sessions +~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``session`` command can be used to also list, stop and open active sessions. +In order to see active sessions (from any provider) run the following command: + +.. code-block:: console + + $ renku session start -p renkulab + ID STATUS URL + ------------------- -------- ------------------------------------------------- + renku-test-e4fe76cc running https://dev.renku.ch/sessions/renku-test-e4fe76cc + +An active session can be opened by using its ``ID`` from the list above. For example, the ``open`` +command below will open the single active session in the browser. + +.. code-block:: console + + $ renku session open renku-test-e4fe76cc + +An active session can be stopped by using the ``stop`` command and the ``ID`` from the list of +active sessions. + +.. code-block:: console + + $ renku session stop renku-test-e4fe76cc + +The command ``renku session stop --all`` will stop all active sessions regardless of the provider. +""" import click from lazy_object_proxy import Proxy @@ -123,11 +210,14 @@ def stop(session_name, stop_all, provider): if not stop_all and session_name is None: raise errors.ParameterError("Please specify either a session ID or the '--all' flag.") - session_stop_command().build().execute(session_name=session_name, stop_all=stop_all, provider=provider) + communicator = ClickCallback() + session_stop_command().with_communicator(communicator).build().execute( + session_name=session_name, stop_all=stop_all, provider=provider + ) if stop_all: click.echo("All running interactive sessions for this project have been stopped.") else: - click.echo(f"Interactive '{session_name}' has been successfully stopped.") + click.echo(f"Interactive session '{session_name}' has been successfully stopped.") @session.command("open") diff --git a/tests/core/plugins/test_session.py b/tests/core/plugins/test_session.py index 32e9749716..2ea983c67b 100644 --- a/tests/core/plugins/test_session.py +++ b/tests/core/plugins/test_session.py @@ -24,6 +24,7 @@ from renku.core.errors import ParameterError from renku.core.plugin.session import supported_session_providers from renku.core.session.docker import DockerSessionProvider +from renku.core.session.renkulab import RenkulabSessionProvider from renku.core.session.session import session_list, session_start, session_stop @@ -61,7 +62,17 @@ def fake_session_list(self, project_name, config): return ["0xdeadbeef"] -@pytest.mark.parametrize("provider", ["docker"]) +def fake_pre_start_checks(self): + pass + + +@pytest.mark.parametrize( + "provider_name,session_provider,provider_patches", + [ + ("docker", DockerSessionProvider, {}), + ("renkulab", RenkulabSessionProvider, {}), + ], +) @pytest.mark.parametrize( "parameters,result", [ @@ -70,22 +81,42 @@ def fake_session_list(self, project_name, config): ({"image_name": "missing_image"}, ParameterError), ], ) -@patch.multiple( - DockerSessionProvider, session_start=fake_start, find_image=fake_find_image, build_image=fake_build_image -) -def test_session_start(run_shell, client, provider, parameters, result, client_database_injection_manager): - provider_implementation = next(filter(lambda x: x[1] == provider, supported_session_providers()), None) - assert provider_implementation is not None - - with client_database_injection_manager(client): - if not isinstance(result, str) and issubclass(result, Exception): - with pytest.raises(result): - session_start(provider=provider, config_path=None, **parameters) - else: - assert session_start(provider=provider, config_path=None, **parameters) == result +def test_session_start( + run_shell, + client, + provider_name, + session_provider, + provider_patches, + parameters, + result, + client_database_injection_manager, +): + with patch.multiple( + session_provider, + session_start=fake_start, + find_image=fake_find_image, + build_image=fake_build_image, + pre_start_checks=fake_pre_start_checks, + **provider_patches, + ): + provider_implementation = next(filter(lambda x: x[1] == provider_name, supported_session_providers()), None) + assert provider_implementation is not None + + with client_database_injection_manager(client): + if not isinstance(result, str) and issubclass(result, Exception): + with pytest.raises(result): + session_start(provider=provider_name, config_path=None, **parameters) + else: + assert session_start(provider=provider_name, config_path=None, **parameters) == result -@pytest.mark.parametrize("provider", ["docker"]) +@pytest.mark.parametrize( + "provider_name,session_provider,provider_patches", + [ + ("docker", DockerSessionProvider, {}), + ("renkulab", RenkulabSessionProvider, {}), + ], +) @pytest.mark.parametrize( "parameters,result", [ @@ -94,25 +125,53 @@ def test_session_start(run_shell, client, provider, parameters, result, client_d ({"session_name": "missing_session"}, ParameterError), ], ) -@patch.object(DockerSessionProvider, "session_stop", fake_stop) -def test_session_stop(run_shell, client, provider, parameters, result, client_database_injection_manager): - provider_implementation = next(filter(lambda x: x[1] == provider, supported_session_providers()), None) - assert provider_implementation is not None - - with client_database_injection_manager(client): - if result is not None and issubclass(result, Exception): - with pytest.raises(result): - session_stop(provider=provider, **parameters) - else: - session_stop(provider=provider, **parameters) - - -@pytest.mark.parametrize("provider,result", [("docker", ["0xdeadbeef"]), ("no_provider", ParameterError)]) -@patch.object(DockerSessionProvider, "session_list", fake_session_list) -def test_session_list(run_shell, client, provider, result, client_database_injection_manager): - with client_database_injection_manager(client): - if not isinstance(result, list) and issubclass(result, Exception): - with pytest.raises(result): - session_list(provider=provider, config_path=None) - else: - assert session_list(provider=provider, config_path=None) == result +def test_session_stop( + run_shell, + client, + session_provider, + provider_name, + parameters, + provider_patches, + result, + client_database_injection_manager, +): + with patch.multiple(session_provider, session_stop=fake_stop, **provider_patches): + provider_implementation = next(filter(lambda x: x[1] == provider_name, supported_session_providers()), None) + assert provider_implementation is not None + + with client_database_injection_manager(client): + if result is not None and issubclass(result, Exception): + with pytest.raises(result): + session_stop(provider=provider_name, **parameters) + else: + session_stop(provider=provider_name, **parameters) + + +@pytest.mark.parametrize( + "provider_name,session_provider,provider_patches", + [ + ("docker", DockerSessionProvider, {}), + ("renkulab", RenkulabSessionProvider, {}), + ], +) +@pytest.mark.parametrize("provider_exists,result", [(True, ["0xdeadbeef"]), (False, ParameterError)]) +def test_session_list( + run_shell, + client, + provider_name, + session_provider, + provider_patches, + provider_exists, + result, + client_database_injection_manager, +): + with patch.multiple(session_provider, session_list=fake_session_list, **provider_patches): + with client_database_injection_manager(client): + if not isinstance(result, list) and issubclass(result, Exception): + with pytest.raises(result): + session_list(provider=provider_name if provider_exists else "no_provider", config_path=None) + else: + assert ( + session_list(provider=provider_name if provider_exists else "no_provider", config_path=None) + == result + )