diff --git a/backend/capellacollab/sessions/injection.py b/backend/capellacollab/sessions/injection.py index 4526a337a5..87741d4212 100644 --- a/backend/capellacollab/sessions/injection.py +++ b/backend/capellacollab/sessions/injection.py @@ -2,15 +2,12 @@ # SPDX-License-Identifier: Apache-2.0 import logging -import re import requests from capellacollab import core from capellacollab.config import config -from . import operators - log = logging.getLogger(__name__) @@ -45,20 +42,3 @@ def _get_last_seen(idletime: int | float) -> str: return f"{round(idlehours, 2)} hrs ago" return f"{idletime:.0f} mins ago" - - -def determine_session_state(session_id: str) -> str: - state = operators.get_operator().get_session_state(session_id) - - if state in ("Started", "BackOff"): - try: - logs = operators.get_operator().get_session_logs( - session_id, container="session-preparation" - ) - logs += operators.get_operator().get_session_logs(session_id) - res = re.search(r"(?s:.*)^---(.*?)---$", logs, re.MULTILINE) - if res: - return res.group(1) - except Exception: - log.exception("Could not parse log") - return state diff --git a/backend/capellacollab/sessions/models.py b/backend/capellacollab/sessions/models.py index 566811145e..6d4bbb78ff 100644 --- a/backend/capellacollab/sessions/models.py +++ b/backend/capellacollab/sessions/models.py @@ -15,6 +15,7 @@ from capellacollab.core import database from capellacollab.core import models as core_models from capellacollab.core import pydantic as core_pydantic +from capellacollab.sessions import operators from capellacollab.tools import models as tools_models from capellacollab.users import models as users_models @@ -69,6 +70,23 @@ class SessionSharing(core_pydantic.BaseModel): created_at: datetime.datetime +class SessionPreparationState(enum.Enum): + RUNNING = "Running" + COMPLETED = "Completed" + FAILED = "Failed" + PENDING = "Pending" + NOT_FOUND = "NotFound" + UNKNOWN = "Unknown" + + +class SessionState(enum.Enum): + RUNNING = "Running" + TERMINATED = "Terminated" + PENDING = "Pending" + NOT_FOUND = "NotFound" + UNKNOWN = "Unknown" + + class Session(core_pydantic.BaseModel): id: str type: SessionType @@ -77,7 +95,10 @@ class Session(core_pydantic.BaseModel): version: tools_models.ToolVersionWithTool - state: str = pydantic.Field(default="UNKNOWN") + preparation_state: SessionPreparationState = pydantic.Field( + default=SessionPreparationState.UNKNOWN + ) + state: SessionState = pydantic.Field(default=SessionState.UNKNOWN) warnings: list[core_models.Message] = pydantic.Field(default=[]) last_seen: str = pydantic.Field(default="UNKNOWN") @@ -105,7 +126,9 @@ def resolve_connection_method(self) -> t.Any: @pydantic.model_validator(mode="after") def add_warnings_and_last_seen(self) -> t.Any: self.last_seen = injection.get_last_seen(self.id) - self.state = injection.determine_session_state(self.id) + self.preparation_state, self.state = ( + operators.get_operator().get_session_state(self.id) + ) return self diff --git a/backend/capellacollab/sessions/operators/k8s.py b/backend/capellacollab/sessions/operators/k8s.py index bf733fc392..0b6a292dcf 100644 --- a/backend/capellacollab/sessions/operators/k8s.py +++ b/backend/capellacollab/sessions/operators/k8s.py @@ -116,7 +116,7 @@ def start_session( version_name=version.name, ) - deployment = self._create_deployment( + pod = self._create_pod( image=image, name=session_id, environment=environment, @@ -129,18 +129,17 @@ def start_session( labels=labels, ) - self._create_disruption_budget( - name=session_id, - deployment_name=session_id, + self._create_session_disruption_budget( + session_id=session_id, ) - service = self._create_service( - name=session_id, - deployment_name=session_id, + service = self._create_session_service( + session_id=session_id, ports=ports, prometheus_path=prometheus_path, prometheus_port=prometheus_port, annotations=annotations, + labels=labels, ) log.info( @@ -151,73 +150,95 @@ def start_session( ) SESSIONS_STARTED.labels(session_type).inc() - return self._export_attrs(deployment, service, ports) + return self._export_attrs(pod, service, ports) def kill_session(self, _id: str): log.info("Terminating session %s", _id) - if dep_status := self._delete_deployment(name=_id): - log.info( - "Deleted deployment %s with status %s", _id, dep_status.status - ) - - if disrupt_status := self._delete_disruptionbudget(name=_id): - log.info( - "Deleted Pod discruption budget %s with status %s", - _id, - disrupt_status.status, - ) - - if loki_enabled and (conf_status := self._delete_config_map(name=_id)): - log.info( - "Deleted config map %s with status %s", _id, conf_status.status - ) + self._delete_pod(name=_id) + self._delete_disruptionbudget(name=_id) + self._delete_service(name=_id) - if svc_status := self._delete_service(name=_id): - log.info( - "Deleted service %s with status %s", _id, svc_status.status - ) + if loki_enabled: + self._delete_config_map(name=_id) SESSIONS_KILLED.inc() def get_job_by_name(self, name: str) -> client.V1Job: return self.v1_batch.read_namespaced_job(name, namespace=namespace) - def get_session_state(self, _id: str) -> str: - return self._get_pod_state(label_selector=f"app={_id}") - - def _get_pod_state(self, label_selector: str) -> str: + def get_session_state(self, session_id: str) -> tuple[ + sessions_models.SessionPreparationState, + sessions_models.SessionState, + ]: try: - pods = self.get_pods(label_selector=label_selector) - if not pods: - return "NOT_FOUND" - pod = pods[0] - pod_name = pod.metadata.name - - log.debug("Received k8s pod: %s", pod_name) - log.debug("Fetching k8s events for pod: %s", pod_name) - - events: list[client.CoreV1Event] = ( - self.v1_core.list_namespaced_event( - namespace=namespace, - field_selector=f"involvedObject.name={pod_name}", - ).items + pods = self.get_pods( + label_selector=f"capellacollab/session-id={session_id}" + ) + except exceptions.ApiException: + log.warning("Error while getting session pod", exc_info=True) + return ( + sessions_models.SessionPreparationState.UNKNOWN, + sessions_models.SessionState.UNKNOWN, + ) + + if not pods: + return ( + sessions_models.SessionPreparationState.NOT_FOUND, + sessions_models.SessionState.NOT_FOUND, ) - events = list(filter(self._is_non_promtail_event, events)) - if events: - return events[-1].reason + status: client.V1PodStatus = pods[0].status + return ( + self._get_init_container_state(status), + self._get_container_state(status), + ) - # Fallback if no event is available - return pod.status.phase + def _get_init_container_state( + self, + status: client.V1PodStatus, + ) -> sessions_models.SessionPreparationState: + if status.init_container_statuses: + state: client.V1ContainerState = status.init_container_statuses[ + 0 + ].state + if state.running: + return sessions_models.SessionPreparationState.RUNNING + if state.waiting: + return sessions_models.SessionPreparationState.PENDING + if state.terminated: + terminated: client.V1ContainerStateTerminated = ( + state.terminated + ) + if terminated.reason == "Completed": + return sessions_models.SessionPreparationState.COMPLETED + if terminated.reason == "Error": + return sessions_models.SessionPreparationState.FAILED - except exceptions.ApiException as e: - log.warning("Kubernetes error", exc_info=True) - return f"error-{str(e.status)}" - except Exception: - log.exception("Error getting the session state") + return sessions_models.SessionPreparationState.UNKNOWN + + def _get_container_state( + self, + status: client.V1PodStatus, + ) -> sessions_models.SessionState: + container_statuses = [ + container_status + for container_status in status.container_statuses + if container_status.name == "session" + ] + if not container_statuses: + return sessions_models.SessionState.UNKNOWN + + state: client.V1ContainerState = container_statuses[0].state + + if state.running: + return sessions_models.SessionState.RUNNING + if state.waiting: + return sessions_models.SessionState.PENDING + if state.terminated: + return sessions_models.SessionState.TERMINATED - return "unknown" + return sessions_models.SessionState.UNKNOWN def _is_non_promtail_event(self, event: client.CoreV1Event) -> bool: if not (event.involved_object and event.involved_object.field_path): @@ -227,14 +248,14 @@ def _is_non_promtail_event(self, event: client.CoreV1Event) -> bool: def get_session_logs( self, - _id: str, + session_id: str, container: ( t.Literal["session"] | t.Literal["session-preparation"] ) = "session", ) -> str: try: return self.v1_core.read_namespaced_pod_log( - name=self._get_pod_name(_id), + name=session_id, container=container, namespace=namespace, ) @@ -294,8 +315,8 @@ def create_cronjob( image=image, job_labels=labels | { - "workload": "cronjob", - "app.capellacollab/parent": _id, + "capellacollab/workload": "cronjob", + "capellacollab/parent": _id, }, environment=environment, tool_resources=tool_resources, @@ -326,7 +347,7 @@ def create_job( spec=self._create_job_spec( name=_id, image=image, - job_labels={"workload": "job", **labels}, + job_labels={"capellacollab/workload": "job", **labels}, environment=environment, tool_resources=tool_resources, args=[command], @@ -388,7 +409,7 @@ def _generate_id(self) -> str: def _export_attrs( self, - deployment: client.V1Deployment, + pod: client.V1Pod, service: client.V1Service, ports: dict[str, int], ) -> Session: @@ -402,9 +423,9 @@ def _export_attrs( ) return Session( - id=deployment.to_dict()["metadata"]["name"], + id=pod.to_dict()["metadata"]["name"], port=port, - created_at=deployment.to_dict()["metadata"]["creation_timestamp"], + created_at=pod.to_dict()["metadata"]["creation_timestamp"], host=service.to_dict()["metadata"]["name"] + "." + namespace, ) @@ -500,7 +521,7 @@ def delete_network_policy(self, name: str): return raise - def _create_deployment( + def _create_pod( self, image: str, name: str, @@ -512,7 +533,7 @@ def _create_deployment( tool_resources: tools_models.Resources, annotations: dict[str, str], labels: dict[str, str], - ) -> client.V1Deployment: + ) -> client.V1Pod: k8s_volumes, k8s_volume_mounts = self._map_volumes_to_k8s_volumes( volumes ) @@ -592,46 +613,35 @@ def _create_deployment( ) ) - deployment: client.V1Deployment = client.V1Deployment( - kind="Deployment", - api_version="apps/v1", - metadata=client.V1ObjectMeta(name=name, annotations=annotations), - spec=client.V1DeploymentSpec( - replicas=1, - strategy=client.V1DeploymentStrategy(type="Recreate"), - selector=client.V1LabelSelector(match_labels={"app": name}), - template=client.V1PodTemplateSpec( - metadata=client.V1ObjectMeta( - labels={"app": name, "workload": "session"} | labels, - annotations=annotations, - ), - spec=client.V1PodSpec( - automount_service_account_token=False, - security_context=pod_security_context, - node_selector=cfg.cluster.node_selector, - containers=containers, - init_containers=[ - client.V1Container( - name="session-preparation", - image=config.docker.registry - + "/session-preparation:" - + config.docker.tag, - env=self._transform_env_to_k8s_env( - init_environment - ), - resources=resources, - volume_mounts=init_k8s_volume_mounts, - image_pull_policy=image_pull_policy, - ) - ], - volumes=k8s_volumes, - restart_policy="Always", - ), - ), + pod: client.V1Pod = client.V1Pod( + metadata=client.V1ObjectMeta( + name=name, + labels={"capellacollab/workload": "session"} | labels, + annotations=annotations, + ), + spec=client.V1PodSpec( + automount_service_account_token=False, + security_context=pod_security_context, + node_selector=cfg.cluster.node_selector, + containers=containers, + init_containers=[ + client.V1Container( + name="session-preparation", + image=config.docker.registry + + "/session-preparation:" + + config.docker.tag, + env=self._transform_env_to_k8s_env(init_environment), + resources=resources, + volume_mounts=init_k8s_volume_mounts, + image_pull_policy=image_pull_policy, + ) + ], + volumes=k8s_volumes, + restart_policy="Never", ), ) - return self.v1_apps.create_namespaced_deployment(namespace, deployment) + return self.v1_core.create_namespaced_pod(namespace, pod) def create_secret( self, name: str, content: dict[str, bytes], overwrite: bool = False @@ -652,14 +662,12 @@ def create_secret( self.delete_secret(name) return self.v1_core.create_namespaced_secret(cfg.namespace, secret) - def _create_disruption_budget( - self, - name: str, - deployment_name: str, + def _create_session_disruption_budget( + self, session_id: str ) -> client.V1PodDisruptionBudget: - """Disallow any pod description for the deployment + """Disallow any pod description for the Pod - If the deployment uses the recreate strategy together with + If the Pod uses the recreate strategy together with this budget, the cluster operator shall consult the administrator before termination of the deployment. @@ -667,41 +675,41 @@ def _create_disruption_budget( https://kubernetes.io/docs/tasks/run-application/configure-pdb/ """ - discruption_budget: client.V1PodDisruptionBudget = ( + disruption_budget: client.V1PodDisruptionBudget = ( client.V1PodDisruptionBudget( kind="PodDisruptionBudget", api_version="policy/v1", metadata=client.V1ObjectMeta( - name=name, - labels={"app": name}, + name=session_id, + labels={"capellacollab/session-id": session_id}, ), spec=client.V1PodDisruptionBudgetSpec( max_unavailable=0, selector=client.V1LabelSelector( - match_labels={"app": deployment_name} + match_labels={"capellacollab/session-id": session_id} ), ), ) ) return self.v1_policy.create_namespaced_pod_disruption_budget( - namespace, discruption_budget + namespace, disruption_budget ) - def _create_service( + def _create_session_service( self, - name: str, - deployment_name: str, + session_id: str, ports: dict[str, int], prometheus_path: str, prometheus_port: int, annotations: dict[str, str], + labels: dict[str, str], ) -> client.V1Service: service: client.V1Service = client.V1Service( kind="Service", api_version="v1", metadata=client.V1ObjectMeta( - name=name, - labels={"app": name}, + name=session_id, + labels=labels, annotations={ "prometheus.io/scrape": "true", "prometheus.io/path": prometheus_path, @@ -719,7 +727,7 @@ def _create_service( ) for name, port in ports.items() ], - selector={"app": deployment_name}, + selector={"capellacollab/session-id": session_id}, type="ClusterIP", ), ) @@ -903,11 +911,15 @@ def _create_promtail_configmap( ) return self.v1_core.create_namespaced_config_map(namespace, config_map) - def _delete_deployment(self, name: str) -> client.V1Status | None: + def _delete_pod(self, name: str) -> client.V1Status | None: try: - return self.v1_apps.delete_namespaced_deployment(name, namespace) + status: client.V1Status = self.v1_core.delete_namespaced_pod( + name, namespace + ) + log.debug("Deleted Pod %s with status %s", name, status.status) + return status except exceptions.ApiException as e: - # Deployment doesn't exist or was already deleted + # Pod doesn't exist or was already deleted # Nothing to do if e.status == http.HTTPStatus.NOT_FOUND: return None @@ -925,7 +937,13 @@ def delete_secret(self, name: str) -> kubernetes.client.V1Status | None: def _delete_config_map(self, name: str) -> client.V1Status | None: try: - return self.v1_core.delete_namespaced_config_map(name, namespace) + status: client.V1Status = ( + self.v1_core.delete_namespaced_config_map(name, namespace) + ) + log.debug( + "Deleted config map %s with status %s", name, status.status + ) + return status except exceptions.ApiException as e: # Config map doesn't exist or was already deleted # Nothing to do @@ -935,7 +953,11 @@ def _delete_config_map(self, name: str) -> client.V1Status | None: def _delete_service(self, name: str) -> client.V1Status | None: try: - return self.v1_core.delete_namespaced_service(name, namespace) + status: client.V1Status = self.v1_core.delete_namespaced_service( + name, namespace + ) + log.debug("Deleted service %s with status %s", name, status.status) + return status except exceptions.ApiException as e: # Service doesn't exist or was already deleted # Nothing to do @@ -945,9 +967,17 @@ def _delete_service(self, name: str) -> client.V1Status | None: def _delete_disruptionbudget(self, name: str) -> client.V1Status | None: try: - return self.v1_policy.delete_namespaced_pod_disruption_budget( - name, namespace + status: client.V1Status = ( + self.v1_policy.delete_namespaced_pod_disruption_budget( + name, namespace + ) + ) + log.debug( + "Deleted Pod disruption budget %s with status %s", + name, + status.status, ) + return status except exceptions.ApiException as e: # Pod disruption budget doesn't exist or was already deleted # Nothing to do @@ -955,15 +985,12 @@ def _delete_disruptionbudget(self, name: str) -> client.V1Status | None: return None raise - def _get_pod_name(self, _id: str) -> str: - return self.get_pods(label_selector=f"app={_id}")[0].metadata.name - def get_pods(self, label_selector: str | None) -> list[client.V1Pod]: return self.v1_core.list_namespaced_pod( namespace=namespace, label_selector=label_selector ).items - def list_files(self, _id: str, directory: str, show_hidden: bool): + def list_files(self, session_id: str, directory: str, show_hidden: bool): def print_file_tree_as_json(): import json # pylint: disable=redefined-outer-name,reimported import pathlib @@ -1009,7 +1036,6 @@ def get_files(dir: pathlib.Path, show_hidden: bool): source = helper.get_source_of_python_function(print_file_tree_as_json) - pod_name = self._get_pod_name(_id) # We have to use subprocess to get it running until this issue is solved: # https://github.com/kubernetes/kubernetes/issues/89899 # Python doesn't start evaluating the code before EOF, but there is no way to close stdin @@ -1023,7 +1049,7 @@ def get_files(dir: pathlib.Path, show_hidden: bool): namespace, "exec", "--stdin", - pod_name, + session_id, "--container", "session", "--", @@ -1039,12 +1065,12 @@ def get_files(dir: pathlib.Path, show_hidden: bool): except subprocess.CalledProcessError as e: log.error( "Loading files of session '%s' failed - STDOUT: %s", - pod_name, + session_id, e.stdout.decode(), ) log.error( "Loading files of session '%s' failed - STDERR: %s", - pod_name, + session_id, e.stderr.decode(), ) raise @@ -1053,16 +1079,14 @@ def get_files(dir: pathlib.Path, show_hidden: bool): def upload_files( self, - _id: str, + session_id: str, content: bytes, ): - pod_name = self._get_pod_name(_id) - try: exec_command = ["tar", "xf", "-", "-C", "/"] stream = kubernetes.stream.stream( self.v1_core.connect_get_namespaced_pod_exec, - pod_name, + session_id, container="session", namespace=namespace, command=exec_command, @@ -1077,21 +1101,24 @@ def upload_files( stream.update(timeout=5) if stream.peek_stdout(): log.debug( - "Upload into %s - STDOUT: %s", _id, stream.read_stdout() + "Upload into %s - STDOUT: %s", + session_id, + stream.read_stdout(), ) if stream.peek_stderr(): log.debug( - "Upload into %s - STDERR: %s", _id, stream.read_stderr() + "Upload into %s - STDERR: %s", + session_id, + stream.read_stderr(), ) except exceptions.ApiException: log.exception( - "Exception when copying file to the pod with id %s", _id + "Exception when copying file to the pod with id %s", session_id ) raise - def download_file(self, _id: str, path: str) -> t.Iterable[bytes]: - pod_name = self._get_pod_name(_id) + def download_file(self, session_id: str, path: str) -> t.Iterable[bytes]: try: exec_command = [ "bash", @@ -1100,7 +1127,7 @@ def download_file(self, _id: str, path: str) -> t.Iterable[bytes]: ] stream = kubernetes.stream.stream( self.v1_core.connect_get_namespaced_pod_exec, - pod_name, + session_id, container="session", namespace=cfg.namespace, command=exec_command, @@ -1121,7 +1148,7 @@ def reader(): except kubernetes.client.exceptions.ApiException: log.exception( - "Exception when copying file to the pod with id %s", _id + "Exception when copying file to the pod with id %s", session_id ) raise diff --git a/backend/tests/sessions/fixtures.py b/backend/tests/sessions/fixtures.py index bd880019ee..582b3c7231 100644 --- a/backend/tests/sessions/fixtures.py +++ b/backend/tests/sessions/fixtures.py @@ -11,6 +11,7 @@ from capellacollab.sessions import crud as sessions_crud from capellacollab.sessions import injection as sessions_injection from capellacollab.sessions import models as sessions_models +from capellacollab.sessions.operators import k8s as k8s_operator from capellacollab.tools import models as tools_models from capellacollab.users import models as users_models @@ -61,5 +62,10 @@ def fixture_mock_session_injection(monkeypatch: pytest.MonkeyPatch): sessions_injection, "get_last_seen", lambda _: "UNKNOWN" ) monkeypatch.setattr( - sessions_injection, "determine_session_state", lambda _: "UNKNOWN" + k8s_operator.KubernetesOperator, + "get_session_state", + lambda self, session_id: ( + sessions_models.SessionPreparationState.UNKNOWN, + sessions_models.SessionState.UNKNOWN, + ), ) diff --git a/backend/tests/sessions/k8s_operator/test_session_k8s_download.py b/backend/tests/sessions/k8s_operator/test_session_k8s_download.py index 1ddc977290..0244fdb669 100644 --- a/backend/tests/sessions/k8s_operator/test_session_k8s_download.py +++ b/backend/tests/sessions/k8s_operator/test_session_k8s_download.py @@ -33,10 +33,6 @@ def test_download_file(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "kubernetes.stream.stream", lambda *a, **ka: mock_stream ) - monkeypatch.setattr( - "capellacollab.sessions.operators.k8s.KubernetesOperator._get_pod_name", - lambda *a: "", - ) oper = KubernetesOperator() download_iter = oper.download_file("some-id", "filename") diff --git a/backend/tests/sessions/k8s_operator/test_session_k8s_operator.py b/backend/tests/sessions/k8s_operator/test_session_k8s_operator.py index 4ea1afadfd..d181357747 100644 --- a/backend/tests/sessions/k8s_operator/test_session_k8s_operator.py +++ b/backend/tests/sessions/k8s_operator/test_session_k8s_operator.py @@ -20,24 +20,24 @@ def test_start_session(monkeypatch: pytest.MonkeyPatch): name = "testname" creation_timestamp = datetime.datetime.now() - deployment_counter = 0 + pod_counter = 0 service_counter = 0 disruption_budget_counter = 0 # pylint: disable=unused-argument - def create_namespaced_deployment(namespace, deployment): - nonlocal deployment_counter - deployment_counter += 1 - return client.V1Deployment( + def create_namespaced_pod(namespace, deployment): + nonlocal pod_counter + pod_counter += 1 + return client.V1Pod( metadata=client.V1ObjectMeta( name=name, creation_timestamp=creation_timestamp ) ) monkeypatch.setattr( - operator.v1_apps, - "create_namespaced_deployment", - create_namespaced_deployment, + operator.v1_core, + "create_namespaced_pod", + create_namespaced_pod, ) # pylint: disable=unused-argument @@ -79,7 +79,7 @@ def create_namespaced_pod_disruption_budget(namespace, budget): annotations={}, ) - assert deployment_counter == 1 + assert pod_counter == 1 assert service_counter == 1 assert disruption_budget_counter == 1 @@ -91,8 +91,8 @@ def test_kill_session(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(k8s, "loki_enabled", False) monkeypatch.setattr( - operator.v1_apps, - "delete_namespaced_deployment", + operator.v1_core, + "delete_namespaced_pod", lambda namespace, name: client.V1Status(), ) diff --git a/backend/tests/sessions/k8s_operator/test_session_state.py b/backend/tests/sessions/k8s_operator/test_session_state.py new file mode 100644 index 0000000000..6ccbc9bd67 --- /dev/null +++ b/backend/tests/sessions/k8s_operator/test_session_state.py @@ -0,0 +1,202 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from kubernetes import client + +from capellacollab.sessions import models as sessions_models +from capellacollab.sessions import operators + + +@pytest.fixture( + name="init_container_status", +) +def fixture_init_container_status( + request: pytest.FixtureRequest, +) -> client.V1ContainerState: + return request.param + + +@pytest.fixture( + name="container_status", +) +def fixture_container_status( + request: pytest.FixtureRequest, +) -> client.V1ContainerState: + return request.param + + +@pytest.fixture( + name="expected", +) +def fixture_expected( + request: pytest.FixtureRequest, +) -> tuple[ + sessions_models.SessionPreparationState, sessions_models.SessionState +]: + return request.param + + +@pytest.fixture(name="pod") +def fixture_pod( + init_container_status: client.V1ContainerState, + container_status: client.V1ContainerState, + monkeypatch: pytest.MonkeyPatch, +) -> client.V1Pod: + pod = client.V1Pod( + status=client.V1PodStatus( + container_statuses=[ + client.V1ContainerStatus( + name="session", + state=container_status, + image="hello-world", + image_id="hello-world", + ready=True, + restart_count=0, + ), + ], + init_container_statuses=[ + client.V1ContainerStatus( + name="session-preparation", + state=init_container_status, + image="hello-world", + image_id="hello-world", + ready=True, + restart_count=0, + ), + ], + ), + ) + + monkeypatch.setattr( + client.CoreV1Api, + "list_namespaced_pod", + lambda *args, **kwargs: client.V1PodList(items=[pod]), + ) + + +@pytest.mark.parametrize( + "init_container_status, container_status, expected", + [ + ( + client.V1ContainerState( + running=None, terminated=None, waiting=None + ), + client.V1ContainerState( + running=None, terminated=None, waiting=None + ), + ( + sessions_models.SessionPreparationState.UNKNOWN, + sessions_models.SessionState.UNKNOWN, + ), + ), + ( + client.V1ContainerState( + running=None, terminated=None, waiting=True + ), + client.V1ContainerState( + running=None, terminated=None, waiting=True + ), + ( + sessions_models.SessionPreparationState.PENDING, + sessions_models.SessionState.PENDING, + ), + ), + ( + client.V1ContainerState( + running=True, terminated=None, waiting=None + ), + client.V1ContainerState( + running=None, terminated=None, waiting=True + ), + ( + sessions_models.SessionPreparationState.RUNNING, + sessions_models.SessionState.PENDING, + ), + ), + ( + client.V1ContainerState( + running=None, + terminated=client.V1ContainerStateTerminated( + reason="Error", exit_code=1 + ), + waiting=None, + ), + client.V1ContainerState( + running=None, terminated=None, waiting=True + ), + ( + sessions_models.SessionPreparationState.FAILED, + sessions_models.SessionState.PENDING, + ), + ), + ( + client.V1ContainerState( + running=None, + terminated=client.V1ContainerStateTerminated( + reason="Completed", exit_code=0 + ), + waiting=None, + ), + client.V1ContainerState( + running=None, terminated=None, waiting=True + ), + ( + sessions_models.SessionPreparationState.COMPLETED, + sessions_models.SessionState.PENDING, + ), + ), + ( + client.V1ContainerState( + running=None, + terminated=client.V1ContainerStateTerminated( + reason="Completed", exit_code=0 + ), + waiting=None, + ), + client.V1ContainerState( + running=True, terminated=None, waiting=None + ), + ( + sessions_models.SessionPreparationState.COMPLETED, + sessions_models.SessionState.RUNNING, + ), + ), + ( + client.V1ContainerState( + running=None, + terminated=client.V1ContainerStateTerminated( + reason="Completed", exit_code=0 + ), + waiting=None, + ), + client.V1ContainerState( + running=None, terminated=True, waiting=None + ), + ( + sessions_models.SessionPreparationState.COMPLETED, + sessions_models.SessionState.TERMINATED, + ), + ), + ], +) +@pytest.mark.usefixtures("pod") +def test_session_state( + expected: tuple[ + sessions_models.SessionPreparationState, sessions_models.SessionState + ] +): + assert operators.get_operator().get_session_state("test") == expected + + +def test_session_state_not_found(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + client.CoreV1Api, + "list_namespaced_pod", + lambda *args, **kwargs: client.V1PodList(items=[]), + ) + + assert operators.get_operator().get_session_state("test") == ( + sessions_models.SessionPreparationState.NOT_FOUND, + sessions_models.SessionState.NOT_FOUND, + ) diff --git a/backend/tests/sessions/test_session_injection.py b/backend/tests/sessions/test_session_injection.py index 2a1e03fbb2..88e61fed3c 100644 --- a/backend/tests/sessions/test_session_injection.py +++ b/backend/tests/sessions/test_session_injection.py @@ -2,8 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 import pytest -from kubernetes import client -from kubernetes.client import exceptions as kubernetes_exceptions from capellacollab import core from capellacollab.sessions import injection @@ -14,78 +12,3 @@ def test_get_last_seen_disabled_in_development_mode( ): monkeypatch.setattr(core, "LOCAL_DEVELOPMENT_MODE", True) assert injection.get_last_seen("test") == "Disabled in development mode" - - -def test_started_session_state(monkeypatch: pytest.MonkeyPatch): - """Test the session state with the following conditions: - - - The session preparation is finished. - - The session container is started, but the logs are not yet available. - - The expected result is "FINISH_PREPARE_WORKSPACE". - """ - - def mock_list_namespaced_pod( - # pylint: disable=unused-argument - self, - namespace: str, - label_selector: str, - ) -> client.V1PodList: - return client.V1PodList( - items=[ - client.V1Pod( - metadata=client.V1ObjectMeta(name="test"), - ) - ] - ) - - def mock_list_namespaced_event( - # pylint: disable=unused-argument - self, - namespace: str, - field_selector: str, - ) -> client.V1PodList: - return client.CoreV1EventList( - items=[ - client.CoreV1Event( - metadata=client.V1ObjectMeta(name="test"), - involved_object=client.V1ObjectReference(name="test"), - reason="Started", - ) - ] - ) - - def mock_read_namespaced_pod_log( - # pylint: disable=unused-argument - self, - name: str, - container: str, - namespace: str, - ) -> str: - if container == "session-preparation": - return "---FINISH_PREPARE_WORKSPACE---" - - # Finished session preparation, but container hasn't started yet. - raise kubernetes_exceptions.ApiException(status=400) - - monkeypatch.setattr( - client.CoreV1Api, - "list_namespaced_event", - mock_list_namespaced_event, - ) - - monkeypatch.setattr( - client.CoreV1Api, - "list_namespaced_pod", - mock_list_namespaced_pod, - ) - - monkeypatch.setattr( - client.CoreV1Api, - "read_namespaced_pod_log", - mock_read_namespaced_pod_log, - ) - - assert "FINISH_PREPARE_WORKSPACE" == injection.determine_session_state( - "test" - ) diff --git a/backend/tests/sessions/test_session_routes.py b/backend/tests/sessions/test_session_routes.py index ce3178b204..aa8982226b 100644 --- a/backend/tests/sessions/test_session_routes.py +++ b/backend/tests/sessions/test_session_routes.py @@ -108,7 +108,7 @@ def test_request_session_with_invalid_connection_id( assert len(sessions_crud.get_sessions(db)) == 0 -@pytest.mark.usefixtures("user") +@pytest.mark.usefixtures("user", "mock_session_injection") def test_request_session_with_provisioning( db: orm.Session, client: testclient.TestClient, @@ -117,6 +117,8 @@ def test_request_session_with_provisioning( ): """Test that a session with provisioning is accepted""" + assert capella_model.version + response = client.post( "/api/v1/sessions", json={ @@ -149,6 +151,7 @@ def test_request_session_with_provisioning( assert session.type == sessions_models.SessionType.READONLY +@pytest.mark.usefixtures("mock_session_injection") def test_create_session_without_provisioning( client: testclient.TestClient, db: orm.Session, @@ -178,6 +181,7 @@ def test_create_session_without_provisioning( assert kubernetes.sessions +@pytest.mark.usefixtures("mock_session_injection") def test_get_all_sessions( db: orm.Session, client: testclient.TestClient, @@ -202,7 +206,7 @@ def test_get_all_sessions( assert len(response.json()) == 1 -@pytest.mark.usefixtures("user") +@pytest.mark.usefixtures("user", "mock_session_injection") def test_get_session_by_id( client: testclient.TestClient, session: sessions_models.DatabaseSession, @@ -212,6 +216,7 @@ def test_get_session_by_id( assert response.json()["id"] == session.id +@pytest.mark.usefixtures("mock_session_injection") def test_own_sessions( db: orm.Session, client: testclient.TestClient, diff --git a/backend/tests/sessions/test_session_sharing.py b/backend/tests/sessions/test_session_sharing.py index 46121ba933..152795e055 100644 --- a/backend/tests/sessions/test_session_sharing.py +++ b/backend/tests/sessions/test_session_sharing.py @@ -164,7 +164,9 @@ def test_terminate_session_not_owned( assert response.json()["detail"]["err_code"] == "SESSION_NOT_OWNED" -@pytest.mark.usefixtures("enable_tool_session_sharing") +@pytest.mark.usefixtures( + "enable_tool_session_sharing", "mock_session_injection" +) def test_share_session( session: sessions_models.DatabaseSession, client: testclient.TestClient, @@ -220,7 +222,7 @@ def test_connect_to_unshared_session_fails( assert response.json()["detail"]["err_code"] == "SESSION_NOT_OWNED" -@pytest.mark.usefixtures("act_as_shared_with_user") +@pytest.mark.usefixtures("act_as_shared_with_user", "mock_session_injection") def test_shared_session_in_user_sessions( shared_session: sessions_models.DatabaseSession, client: testclient.TestClient, diff --git a/frontend/src/app/openapi/.openapi-generator/FILES b/frontend/src/app/openapi/.openapi-generator/FILES index a55073549f..78d68d43ae 100644 --- a/frontend/src/app/openapi/.openapi-generator/FILES +++ b/frontend/src/app/openapi/.openapi-generator/FILES @@ -146,8 +146,10 @@ model/session-connection-information.ts model/session-monitoring-input.ts model/session-monitoring-output.ts model/session-ports.ts +model/session-preparation-state.ts model/session-provisioning-request.ts model/session-sharing.ts +model/session-state.ts model/session-tool-configuration-input.ts model/session-tool-configuration-output.ts model/session-type.ts diff --git a/frontend/src/app/openapi/model/models.ts b/frontend/src/app/openapi/model/models.ts index 402c61cc6b..3a259984a2 100644 --- a/frontend/src/app/openapi/model/models.ts +++ b/frontend/src/app/openapi/model/models.ts @@ -125,8 +125,10 @@ export * from './session-connection-information'; export * from './session-monitoring-input'; export * from './session-monitoring-output'; export * from './session-ports'; +export * from './session-preparation-state'; export * from './session-provisioning-request'; export * from './session-sharing'; +export * from './session-state'; export * from './session-tool-configuration-input'; export * from './session-tool-configuration-output'; export * from './session-type'; diff --git a/frontend/src/app/openapi/model/session-preparation-state.ts b/frontend/src/app/openapi/model/session-preparation-state.ts new file mode 100644 index 0000000000..7fea352082 --- /dev/null +++ b/frontend/src/app/openapi/model/session-preparation-state.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + + + +export type SessionPreparationState = 'Running' | 'Completed' | 'Failed' | 'Pending' | 'NotFound' | 'Unknown'; + +export const SessionPreparationState = { + Running: 'Running' as SessionPreparationState, + Completed: 'Completed' as SessionPreparationState, + Failed: 'Failed' as SessionPreparationState, + Pending: 'Pending' as SessionPreparationState, + NotFound: 'NotFound' as SessionPreparationState, + Unknown: 'Unknown' as SessionPreparationState +}; + diff --git a/frontend/src/app/openapi/model/session-state.ts b/frontend/src/app/openapi/model/session-state.ts new file mode 100644 index 0000000000..4969f045b2 --- /dev/null +++ b/frontend/src/app/openapi/model/session-state.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + + + +export type SessionState = 'Running' | 'Terminated' | 'Pending' | 'NotFound' | 'Unknown'; + +export const SessionState = { + Running: 'Running' as SessionState, + Terminated: 'Terminated' as SessionState, + Pending: 'Pending' as SessionState, + NotFound: 'NotFound' as SessionState, + Unknown: 'Unknown' as SessionState +}; + diff --git a/frontend/src/app/openapi/model/session.ts b/frontend/src/app/openapi/model/session.ts index 4a2a348356..4754e9e9c8 100644 --- a/frontend/src/app/openapi/model/session.ts +++ b/frontend/src/app/openapi/model/session.ts @@ -9,10 +9,12 @@ + To generate a new version, run `make openapi` in the root directory of this repository. */ +import { SessionState } from './session-state'; import { BaseUser } from './base-user'; import { SessionType } from './session-type'; import { Message } from './message'; import { ToolVersionWithTool } from './tool-version-with-tool'; +import { SessionPreparationState } from './session-preparation-state'; import { ToolSessionConnectionMethod } from './tool-session-connection-method'; import { SessionSharing } from './session-sharing'; @@ -23,7 +25,8 @@ export interface Session { created_at: string; owner: BaseUser; version: ToolVersionWithTool; - state: string; + preparation_state: SessionPreparationState; + state: SessionState; warnings: Array; last_seen: string; connection_method_id: string; diff --git a/frontend/src/app/sessions/feedback/feedback-dialog/feedback-dialog.stories.ts b/frontend/src/app/sessions/feedback/feedback-dialog/feedback-dialog.stories.ts index cb4d2820f8..54eeea726e 100644 --- a/frontend/src/app/sessions/feedback/feedback-dialog/feedback-dialog.stories.ts +++ b/frontend/src/app/sessions/feedback/feedback-dialog/feedback-dialog.stories.ts @@ -6,7 +6,7 @@ import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; import { userEvent, within } from '@storybook/test'; import { dialogWrapper } from 'src/storybook/decorators'; -import { createPersistentSessionWithState } from '../../../../storybook/session'; +import { mockPersistentSession } from '../../../../storybook/session'; import { FeedbackDialogComponent } from './feedback-dialog.component'; const meta: Meta = { @@ -59,7 +59,7 @@ export const OneSession: Story = { { provide: MAT_DIALOG_DATA, useValue: { - sessions: [createPersistentSessionWithState('running')], + sessions: [mockPersistentSession], trigger: 'storybook', }, }, @@ -76,7 +76,7 @@ export const OneSessionWithUserInformation: Story = { { provide: MAT_DIALOG_DATA, useValue: { - sessions: [createPersistentSessionWithState('running')], + sessions: [mockPersistentSession], trigger: 'storybook', }, }, @@ -107,10 +107,7 @@ export const TwoSessions: Story = { { provide: MAT_DIALOG_DATA, useValue: { - sessions: [ - createPersistentSessionWithState('running'), - createPersistentSessionWithState('running'), - ], + sessions: [mockPersistentSession, mockPersistentSession], trigger: 'storybook', }, }, diff --git a/frontend/src/app/sessions/service/session.service.ts b/frontend/src/app/sessions/service/session.service.ts index b9852b1928..d69f722621 100644 --- a/frontend/src/app/sessions/service/session.service.ts +++ b/frontend/src/app/sessions/service/session.service.ts @@ -10,6 +10,8 @@ import { SessionsService, SessionConnectionInformation, FileTree, + SessionPreparationState, + SessionState, } from 'src/app/openapi'; import { SessionHistoryService } from 'src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/session-history.service'; @@ -65,165 +67,103 @@ export class SessionService { } } - beautifyState(state: string | undefined): SessionState { - /* Possible states are (and a few more states): - https://github.com/kubernetes/kubernetes/blob/master/pkg/kubelet/events/event.go */ - - let text = state; - let css = 'warning'; - let icon = 'pending'; - let success = false; - switch (state) { - case 'Created': - text = 'Session created'; - css = 'warning'; - break; - case 'Started': - text = 'Session started'; - css = 'success'; - success = true; - icon = 'check'; - break; - case 'Failed': - case 'FailedCreatePodContainer': - text = 'Failed to create session'; - css = 'error'; - icon = 'error'; - break; - case 'Killing': - text = 'Stopping session'; - css = 'error'; - icon = 'close'; - break; - case 'Preempting': - text = 'Session is waiting in the queue'; - css = 'error'; - icon = 'timer_pause'; - break; - case 'BackOff': - text = 'Session crashed unexpectedly'; - css = 'error'; - icon = 'error'; - break; - case 'ExceededGracePeriod': - text = 'The session stopped.'; - css = 'error'; - icon = 'cancel'; - break; - - case 'FailedKillPod': - text = 'Failed to stop session'; - css = 'error'; - icon = 'error'; - break; - case 'NetworkNotReady': - text = 'Backend network issues'; - css = 'error'; - icon = 'cloud_off'; - break; - case 'Pulling': - text = 'Preparation of the session'; - css = 'warning'; - icon = 'downloading'; - break; - case 'Pulled': - text = 'Preparation finished'; - css = 'warning'; - icon = 'download_done'; - break; - - // Some additional reasons that came up - case 'Scheduled': - text = 'Your session is scheduled'; - css = 'warning'; - icon = 'schedule'; - break; - case 'FailedScheduling': - text = 'High demand. Please wait a moment.'; - css = 'warning'; - icon = 'timer_pause'; - break; - - // OpenShift specific - case 'AddedInterface': - text = 'Preparation of the session'; - css = 'warning'; - break; - - // Pod phases (that are not handled before) - case 'Pending': - text = 'Your session is scheduled'; - css = 'warning'; - icon = 'schedule'; - break; - case 'Running': - text = 'Session is running'; - css = 'success'; - success = true; - icon = 'check'; - break; + beautifyState( + preparationState: SessionPreparationState, + state: SessionState, + ): DisplaySessionState { + if ( + preparationState === SessionPreparationState.NotFound || + state === SessionState.NotFound + ) { + return { + text: 'Session not found', + css: 'error', + icon: 'error', + success: false, + }; + } + switch (preparationState) { + case SessionPreparationState.Pending: + return { + text: 'Session preparation is pending', + css: 'warning', + icon: 'timer', + success: false, + }; + case SessionPreparationState.Failed: + return { + text: 'Session preparation failed', + info: [ + 'The session preparation has failed. We will not continue to start the session.', + 'A failed preparation usually indicates a failed provisioning,', + 'e. g., due to an unreachable Git Server or invalid credentials.', + 'Contact support for more details.', + ].join(' '), + css: 'error', + icon: 'error', + success: false, + }; + case SessionPreparationState.Running: + return { + text: 'Session preparation is running', + info: 'During session preparation, we provision the workspace and configure the environment.', + css: 'warning', + icon: 'timer', + success: false, + }; + case SessionPreparationState.Completed: + // Switch to session state - // Cases for starting containers - case 'START_LOAD_MODEL': - text = 'Modelloading started'; - css = 'warning'; - icon = 'downloading'; - break; - case 'FINISH_LOAD_MODEL': - text = 'Modelloading finished'; - css = 'warning'; - icon = 'download_done'; - break; - case 'FAILURE_LOAD_MODEL': - text = 'Error during loading of the model'; - css = 'error'; - icon = 'error'; - break; - case 'START_PREPARE_WORKSPACE': - text = 'Started workspace preparation'; - css = 'warning'; - icon = 'downloading'; - break; - case 'FINISH_PREPARE_WORKSPACE': - text = 'Workspace preparation finished'; - css = 'warning'; - icon = 'download_done'; - break; - case 'FAILURE_PREPARE_WORKSPACE': - text = 'Error during workspace preparation'; - css = 'error'; - icon = 'error'; - break; - case 'START_SESSION': - text = 'Session started'; - css = 'success'; - success = true; - icon = 'check'; - break; - case 'NOT_FOUND': - text = 'Session container not found'; - css = 'error'; - icon = 'error'; - break; - case 'unknown': - case 'Unknown': - text = 'Unknown State'; - css = 'primary'; - icon = 'help'; - break; + switch (state) { + case SessionState.Running: + return { + text: 'Session is up & running', + css: 'success', + icon: 'check', + success: true, + }; + case SessionState.Terminated: + return { + text: 'Session is terminated', + info: [ + "The session is terminated and can't be accessed anymore.", + 'Contact support for further information.', + ].join(' '), + css: 'error', + icon: 'close', + success: false, + }; + case SessionState.Pending: + return { + text: 'Session is pending', + info: [ + 'The session preparation is completed, but the session is pending.', + 'Depending on the load and the infrastructure, it may take until all necessary information for the session is available.', + 'A good opportunity to make a tea and come back.', + ].join(' '), + css: 'warning', + icon: 'hourglass', + success: false, + }; + } } return { - text: text || '', - css: css, - icon: icon, - success: success, + text: 'Session state is unknown', + info: [ + "We're not sure what happened here.", + "Contact support if the state doesn't change.", + ].join(' '), + css: 'primary', + icon: 'help', + success: false, }; } } -export interface SessionState { +export interface DisplaySessionState { text: string; + info?: string | undefined; css: string; icon: string; success: boolean; diff --git a/frontend/src/app/sessions/session-overview/session-overview.component.html b/frontend/src/app/sessions/session-overview/session-overview.component.html index 42e9868739..1f9a040023 100644 --- a/frontend/src/app/sessions/session-overview/session-overview.component.html +++ b/frontend/src/app/sessions/session-overview/session-overview.component.html @@ -39,8 +39,15 @@ + + Session Preparation + + {{ element.preparation_state }} + + + - State of Container + Session State {{ element.state }} diff --git a/frontend/src/app/sessions/session-overview/session-overview.component.ts b/frontend/src/app/sessions/session-overview/session-overview.component.ts index edad75b48b..31dca7ab6d 100644 --- a/frontend/src/app/sessions/session-overview/session-overview.component.ts +++ b/frontend/src/app/sessions/session-overview/session-overview.component.ts @@ -66,6 +66,7 @@ export class SessionOverviewComponent implements OnInit { 'id', 'user', 'created_at', + 'preparation_state', 'state', 'last_seen', 'tool', diff --git a/frontend/src/app/sessions/session-overview/session-overview.stories.ts b/frontend/src/app/sessions/session-overview/session-overview.stories.ts index 90f6fe925b..6af5210c35 100644 --- a/frontend/src/app/sessions/session-overview/session-overview.stories.ts +++ b/frontend/src/app/sessions/session-overview/session-overview.stories.ts @@ -5,9 +5,15 @@ import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; import { userEvent, within } from '@storybook/test'; import { of } from 'rxjs'; -import { Session, SessionsService } from 'src/app/openapi'; +import { + Session, + SessionPreparationState, + SessionsService, + SessionState, +} from 'src/app/openapi'; import { createPersistentSessionWithState, + mockPersistentSession, mockReadonlySession, } from 'src/storybook/session'; import { mockUser } from 'src/storybook/user'; @@ -44,10 +50,13 @@ export const NoSessions: Story = { }; const sessions = [ - createPersistentSessionWithState('Running'), + mockPersistentSession, { ...mockReadonlySession, id: 'vjmczglcgeltbfcronujtelwx' }, { - ...createPersistentSessionWithState('Failed'), + ...createPersistentSessionWithState( + SessionPreparationState.Failed, + SessionState.Pending, + ), owner: { ...mockUser, name: 'anotherUser', diff --git a/frontend/src/app/sessions/session/session.component.html b/frontend/src/app/sessions/session/session.component.html index 2e7e771b86..36e399be09 100644 --- a/frontend/src/app/sessions/session/session.component.html +++ b/frontend/src/app/sessions/session/session.component.html @@ -34,9 +34,15 @@ Read-only session

{{ - sessionService.beautifyState(session.state).icon + sessionService.beautifyState( + session.preparation_state, + session.state + ).icon }} - {{ sessionService.beautifyState(session.state).text }} + {{ + sessionService.beautifyState( + session.preparation_state, + session.state + ).text + }}

The session was created @@ -126,7 +145,12 @@

Read-only session

mat-button color="primary" (click)="openConnectDialog(session)" - [disabled]="!sessionService.beautifyState(session.state).success" + [disabled]=" + !sessionService.beautifyState( + session.preparation_state, + session.state + ).success + " > Connect open_in_browser @@ -146,7 +170,12 @@

Read-only session

mat-button color="primary" (click)="uploadFileDialog(session)" - [disabled]="!sessionService.beautifyState(session.state).success" + [disabled]=" + !sessionService.beautifyState( + session.preparation_state, + session.state + ).success + " > File browser insert_drive_file diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts index 287806f6b5..2edd240dc8 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts @@ -9,7 +9,11 @@ import { moduleMetadata, } from '@storybook/angular'; import { Observable, of } from 'rxjs'; -import { Session } from 'src/app/openapi'; +import { + Session, + SessionPreparationState, + SessionState, +} from 'src/app/openapi'; import { OwnUserWrapperService } from 'src/app/services/user/user.service'; import { FeedbackWrapperService } from 'src/app/sessions/feedback/feedback.service'; import { @@ -18,6 +22,7 @@ import { } from 'src/storybook/feedback'; import { createPersistentSessionWithState, + mockPersistentSession, mockReadonlySession, } from 'src/storybook/session'; import { mockHttpConnectionMethod } from 'src/storybook/tool'; @@ -58,7 +63,7 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const LoadingStory: Story = { +export const Loading: Story = { args: {}, decorators: [ moduleMetadata({ @@ -86,58 +91,7 @@ export const NoActiveStories: Story = { ], }; -export const SessionSuccessStateStory: Story = { - args: {}, - decorators: [ - moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService( - createPersistentSessionWithState('Started'), - ), - }, - ], - }), - ], -}; - -export const SessionWarningStateStory: Story = { - args: {}, - decorators: [ - moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService( - createPersistentSessionWithState('Created'), - ), - }, - ], - }), - ], -}; - -export const SessionErrorStateStory: Story = { - args: {}, - decorators: [ - moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService( - createPersistentSessionWithState('Failed'), - ), - }, - ], - }), - ], -}; - -export const SessionKillingStateStory: Story = { +export const SessionNotFoundState: Story = { args: {}, decorators: [ moduleMetadata({ @@ -146,7 +100,10 @@ export const SessionKillingStateStory: Story = { provide: UserSessionService, useFactory: () => new MockUserSessionService( - createPersistentSessionWithState('Killing'), + createPersistentSessionWithState( + SessionPreparationState.NotFound, + SessionState.NotFound, + ), ), }, ], @@ -154,7 +111,7 @@ export const SessionKillingStateStory: Story = { ], }; -export const SessionStoppedStateStory: Story = { +export const SessionPreparationPendingState: Story = { args: {}, decorators: [ moduleMetadata({ @@ -163,7 +120,10 @@ export const SessionStoppedStateStory: Story = { provide: UserSessionService, useFactory: () => new MockUserSessionService( - createPersistentSessionWithState('ExceededGracePeriod'), + createPersistentSessionWithState( + SessionPreparationState.Pending, + SessionState.Pending, + ), ), }, ], @@ -171,7 +131,7 @@ export const SessionStoppedStateStory: Story = { ], }; -export const SessionQueuedStateStory: Story = { +export const SessionPreparationRunningState: Story = { args: {}, decorators: [ moduleMetadata({ @@ -180,7 +140,10 @@ export const SessionQueuedStateStory: Story = { provide: UserSessionService, useFactory: () => new MockUserSessionService( - createPersistentSessionWithState('Preempting'), + createPersistentSessionWithState( + SessionPreparationState.Running, + SessionState.Pending, + ), ), }, ], @@ -188,7 +151,7 @@ export const SessionQueuedStateStory: Story = { ], }; -export const SessionNetworkIssuesStateStory: Story = { +export const SessionRunningState: Story = { args: {}, decorators: [ moduleMetadata({ @@ -197,7 +160,10 @@ export const SessionNetworkIssuesStateStory: Story = { provide: UserSessionService, useFactory: () => new MockUserSessionService( - createPersistentSessionWithState('NetworkNotReady'), + createPersistentSessionWithState( + SessionPreparationState.Completed, + SessionState.Running, + ), ), }, ], @@ -205,7 +171,7 @@ export const SessionNetworkIssuesStateStory: Story = { ], }; -export const SessionPullingStateStory: Story = { +export const SessionTerminatedState: Story = { args: {}, decorators: [ moduleMetadata({ @@ -214,7 +180,10 @@ export const SessionPullingStateStory: Story = { provide: UserSessionService, useFactory: () => new MockUserSessionService( - createPersistentSessionWithState('Pulling'), + createPersistentSessionWithState( + SessionPreparationState.Completed, + SessionState.Terminated, + ), ), }, ], @@ -222,7 +191,7 @@ export const SessionPullingStateStory: Story = { ], }; -export const SessionPulledStateStory: Story = { +export const SessionPendingState: Story = { args: {}, decorators: [ moduleMetadata({ @@ -231,7 +200,10 @@ export const SessionPulledStateStory: Story = { provide: UserSessionService, useFactory: () => new MockUserSessionService( - createPersistentSessionWithState('Pulled'), + createPersistentSessionWithState( + SessionPreparationState.Completed, + SessionState.Pending, + ), ), }, ], @@ -239,7 +211,7 @@ export const SessionPulledStateStory: Story = { ], }; -export const SessionScheduledStateStory: Story = { +export const SessionUnknownState: Story = { args: {}, decorators: [ moduleMetadata({ @@ -248,41 +220,10 @@ export const SessionScheduledStateStory: Story = { provide: UserSessionService, useFactory: () => new MockUserSessionService( - createPersistentSessionWithState('Scheduled'), - ), - }, - ], - }), - ], -}; - -export const SessionFailedSchedulingStateStory: Story = { - args: {}, - decorators: [ - moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService( - createPersistentSessionWithState('FailedScheduling'), - ), - }, - ], - }), - ], -}; - -export const SessionUnknownStateStory: Story = { - args: {}, - decorators: [ - moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService( - createPersistentSessionWithState('unknown'), + createPersistentSessionWithState( + SessionPreparationState.Completed, + SessionState.Unknown, + ), ), }, ], @@ -297,10 +238,7 @@ export const SessionWithFeedbackEnabled: Story = { providers: [ { provide: UserSessionService, - useFactory: () => - new MockUserSessionService( - createPersistentSessionWithState('Started'), - ), + useFactory: () => new MockUserSessionService(mockPersistentSession), }, { provide: FeedbackWrapperService, @@ -311,7 +249,7 @@ export const SessionWithFeedbackEnabled: Story = { ], }; -export const ReadonlySessionSuccessStateStory: Story = { +export const ReadonlySessionSuccessState: Story = { args: {}, decorators: [ moduleMetadata({ @@ -334,7 +272,7 @@ export const SessionSharingEnabled: Story = { provide: UserSessionService, useFactory: () => new MockUserSessionService({ - ...createPersistentSessionWithState('Started'), + ...mockPersistentSession, connection_method: { ...mockHttpConnectionMethod, sharing: { enabled: true }, @@ -355,7 +293,7 @@ export const SessionSharedWithUser: Story = { provide: UserSessionService, useFactory: () => new MockUserSessionService({ - ...createPersistentSessionWithState('Started'), + ...mockPersistentSession, connection_method: { ...mockHttpConnectionMethod, sharing: { enabled: true }, @@ -396,10 +334,7 @@ export const SharedSession: Story = { providers: [ { provide: UserSessionService, - useFactory: () => - new MockUserSessionService({ - ...createPersistentSessionWithState('Started'), - }), + useFactory: () => new MockUserSessionService(mockPersistentSession), }, { provide: OwnUserWrapperService, diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.stories.ts index 67feab7a46..7f05b8fc35 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.stories.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.stories.ts @@ -6,7 +6,7 @@ import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; import { OwnUserWrapperService } from 'src/app/services/user/user.service'; import { dialogWrapper } from 'src/storybook/decorators'; -import { startedSession } from 'src/storybook/session'; +import { mockPersistentSession } from 'src/storybook/session'; import { mockTool } from 'src/storybook/tool'; import { MockOwnUserWrapperService, mockUser } from 'src/storybook/user'; import { ConnectionDialogComponent } from './connection-dialog.component'; @@ -19,7 +19,7 @@ const meta: Meta = { providers: [ { provide: MAT_DIALOG_DATA, - useValue: startedSession, + useValue: mockPersistentSession, }, ], }), @@ -44,9 +44,9 @@ export const WithoutTeamForCapella: Story = { { provide: MAT_DIALOG_DATA, useValue: { - ...startedSession, + ...mockPersistentSession, version: { - ...startedSession.version, + ...mockPersistentSession.version, tool: { ...mockTool, integrations: { t4c: false } }, }, }, @@ -70,7 +70,7 @@ export const SharedSession: Story = { { provide: MAT_DIALOG_DATA, useValue: { - ...startedSession, + ...mockPersistentSession, owner: { ...mockUser, id: '2' }, }, }, diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.stories.ts index c806f7cb00..84c7b0936d 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.stories.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.stories.ts @@ -9,7 +9,7 @@ import { userEvent, within } from '@storybook/test'; import { PathNode } from 'src/app/sessions/service/session.service'; import { FileBrowserDialogComponent } from 'src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component'; import { dialogWrapper } from 'src/storybook/decorators'; -import { startedSession } from 'src/storybook/session'; +import { mockPersistentSession } from 'src/storybook/session'; const meta: Meta = { title: 'Session Components/File Browser', @@ -19,7 +19,7 @@ const meta: Meta = { providers: [ { provide: MAT_DIALOG_DATA, - useValue: { session: startedSession }, + useValue: { session: mockPersistentSession }, }, ], }), @@ -165,7 +165,7 @@ export const DownloadPreparation: Story = { { provide: MAT_DIALOG_DATA, useValue: { - ...startedSession, + ...mockPersistentSession, download_in_progress: true, }, }, diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.stories.ts index 4d8a074da0..d49744869f 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.stories.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.stories.ts @@ -5,7 +5,7 @@ import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; import { dialogWrapper } from 'src/storybook/decorators'; -import { createPersistentSessionWithState } from 'src/storybook/session'; +import { mockPersistentSession } from 'src/storybook/session'; import { SessionSharingDialogComponent } from './session-sharing-dialog.component'; const meta: Meta = { @@ -16,7 +16,7 @@ const meta: Meta = { providers: [ { provide: MAT_DIALOG_DATA, - useValue: createPersistentSessionWithState('running'), + useValue: mockPersistentSession, }, ], }), diff --git a/frontend/src/storybook/session.ts b/frontend/src/storybook/session.ts index 450e432145..7fc274e721 100644 --- a/frontend/src/storybook/session.ts +++ b/frontend/src/storybook/session.ts @@ -2,19 +2,35 @@ * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors * SPDX-License-Identifier: Apache-2.0 */ -import { Session } from 'src/app/openapi'; +import { + Session, + SessionPreparationState, + SessionState, +} from 'src/app/openapi'; import { mockHttpConnectionMethod, mockToolVersionWithTool } from './tool'; import { mockUser } from './user'; -export const startedSession = createPersistentSessionWithState('Started'); +export const mockPersistentSession = createPersistentSessionWithState( + SessionPreparationState.Completed, + SessionState.Running, +); -export function createPersistentSessionWithState(state: string): Session { +export const mockReadonlySession: Readonly = { + ...mockPersistentSession, + type: 'readonly', +}; + +export function createPersistentSessionWithState( + preparationState: SessionPreparationState, + state: SessionState, +): Session { return { id: 'vfurvsrldxfwwsqdiqvnufonh', created_at: '2024-04-29T15:00:00Z', last_seen: '2024-04-29T15:30:00Z', type: 'persistent', version: mockToolVersionWithTool, + preparation_state: preparationState, state: state, owner: mockUser, connection_method: mockHttpConnectionMethod, @@ -23,8 +39,3 @@ export function createPersistentSessionWithState(state: string): Session { shared_with: [], }; } - -export const mockReadonlySession: Readonly = { - ...createPersistentSessionWithState('Started'), - type: 'readonly', -};