diff --git a/services/autoscaling/requirements/_base.txt b/services/autoscaling/requirements/_base.txt index dd5f8f0ed20..fbf37db9739 100644 --- a/services/autoscaling/requirements/_base.txt +++ b/services/autoscaling/requirements/_base.txt @@ -230,7 +230,7 @@ markupsafe==2.1.5 # jinja2 mdurl==0.1.2 # via markdown-it-py -msgpack==1.0.8 +msgpack==1.1.0 # via # -c requirements/../../../services/dask-sidecar/requirements/_dask-distributed.txt # distributed diff --git a/services/catalog/requirements/_base.txt b/services/catalog/requirements/_base.txt index e5aeb9fc368..e33aee23b8f 100644 --- a/services/catalog/requirements/_base.txt +++ b/services/catalog/requirements/_base.txt @@ -206,7 +206,7 @@ markupsafe==2.1.5 # mako mdurl==0.1.2 # via markdown-it-py -msgpack==1.0.8 +msgpack==1.1.0 # via aiocache multidict==6.0.5 # via diff --git a/services/clusters-keeper/requirements/_base.txt b/services/clusters-keeper/requirements/_base.txt index 8d2bc14dbcc..508174d8d15 100644 --- a/services/clusters-keeper/requirements/_base.txt +++ b/services/clusters-keeper/requirements/_base.txt @@ -228,7 +228,7 @@ markupsafe==2.1.5 # jinja2 mdurl==0.1.2 # via markdown-it-py -msgpack==1.0.8 +msgpack==1.1.0 # via # -c requirements/../../../services/dask-sidecar/requirements/_dask-distributed.txt # distributed diff --git a/services/dask-sidecar/requirements/_base.txt b/services/dask-sidecar/requirements/_base.txt index 33eff58ebae..159f18f1ad2 100644 --- a/services/dask-sidecar/requirements/_base.txt +++ b/services/dask-sidecar/requirements/_base.txt @@ -199,7 +199,7 @@ markupsafe==2.1.5 # via jinja2 mdurl==0.1.2 # via markdown-it-py -msgpack==1.0.8 +msgpack==1.1.0 # via distributed multidict==6.0.5 # via diff --git a/services/dask-sidecar/requirements/_dask-distributed.txt b/services/dask-sidecar/requirements/_dask-distributed.txt index e9ebbb2a0f5..e1b822b67bb 100644 --- a/services/dask-sidecar/requirements/_dask-distributed.txt +++ b/services/dask-sidecar/requirements/_dask-distributed.txt @@ -46,7 +46,7 @@ markupsafe==2.1.5 # via # -c requirements/./_base.txt # jinja2 -msgpack==1.0.8 +msgpack==1.1.0 # via # -c requirements/./_base.txt # distributed diff --git a/services/director-v2/requirements/_base.txt b/services/director-v2/requirements/_base.txt index 42c06dd93de..7e548a9fdcb 100644 --- a/services/director-v2/requirements/_base.txt +++ b/services/director-v2/requirements/_base.txt @@ -314,7 +314,7 @@ markupsafe==2.1.5 # mako mdurl==0.1.2 # via markdown-it-py -msgpack==1.0.8 +msgpack==1.1.0 # via # -r requirements/../../../services/dask-sidecar/requirements/_dask-distributed.txt # aiocache diff --git a/services/director-v2/requirements/_test.txt b/services/director-v2/requirements/_test.txt index 0b0bcda2630..ccfb429b50f 100644 --- a/services/director-v2/requirements/_test.txt +++ b/services/director-v2/requirements/_test.txt @@ -171,7 +171,7 @@ markupsafe==2.1.5 # -c requirements/_base.txt # jinja2 # mako -msgpack==1.0.8 +msgpack==1.1.0 # via # -c requirements/_base.txt # distributed diff --git a/services/dynamic-scheduler/requirements/_base.in b/services/dynamic-scheduler/requirements/_base.in index ceb76bbb30f..fa6e19b5a14 100644 --- a/services/dynamic-scheduler/requirements/_base.in +++ b/services/dynamic-scheduler/requirements/_base.in @@ -21,4 +21,5 @@ httpx packaging python-socketio typer[all] +u-msgpack-python uvicorn[standard] diff --git a/services/dynamic-scheduler/requirements/_base.txt b/services/dynamic-scheduler/requirements/_base.txt index 8ecd0fef578..7493081203d 100644 --- a/services/dynamic-scheduler/requirements/_base.txt +++ b/services/dynamic-scheduler/requirements/_base.txt @@ -460,7 +460,9 @@ typing-extensions==4.12.2 # pydantic # pydantic-core # typer -urllib3==2.2.3 +u-msgpack-python==2.8.0 + # via -r requirements/_base.in +urllib3==2.2.2 # via # -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt diff --git a/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/services/service_tracker/_models.py b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/services/service_tracker/_models.py index 985ca8feef5..6b1b5b1a75d 100644 --- a/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/services/service_tracker/_models.py +++ b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/services/service_tracker/_models.py @@ -1,17 +1,39 @@ -import pickle -from dataclasses import dataclass, field from datetime import timedelta +from decimal import Decimal from enum import auto +from typing import Any, Callable, Final +from uuid import UUID import arrow +import umsgpack # type: ignore[import-untyped] from models_library.api_schemas_dynamic_scheduler.dynamic_services import ( DynamicServiceStart, ) from models_library.projects import ProjectID from models_library.users import UserID from models_library.utils.enums import StrAutoEnum +from pydantic import BaseModel, Field from servicelib.deferred_tasks import TaskUID +# `umsgpack.Ext`` extension types are part of the msgpack specification +# allows to define serialization and deserialization rules for custom types +# see https://github.com/msgpack/msgpack/blob/master/spec.md#extension-types + +_UUID_TYPE: Final[int] = 0x00 +_DECIMAL_TYPE: Final[int] = 0x01 + +_PACKB_EXTENSION_TYPES: Final[dict[type[Any], Callable[[Any], umsgpack.Ext]]] = { + # helpers to serialize an object to bytes + UUID: lambda obj: umsgpack.Ext(_UUID_TYPE, obj.bytes), + Decimal: lambda obj: umsgpack.Ext(_DECIMAL_TYPE, f"{obj}".encode()), +} + +_UNPACKB_EXTENSION_TYPES: Final[dict[int, Callable[[umsgpack.Ext], Any]]] = { + # helpers to deserialize an object from bytes + _UUID_TYPE: lambda ext: UUID(bytes=ext.data), + _DECIMAL_TYPE: lambda ext: Decimal(ext.data.decode()), +} + class UserRequestedState(StrAutoEnum): RUNNING = auto() @@ -35,74 +57,67 @@ class SchedulerServiceState(StrAutoEnum): UNKNOWN = auto() -@dataclass -class TrackedServiceModel: # pylint:disable=too-many-instance-attributes +class TrackedServiceModel(BaseModel): # pylint:disable=too-many-instance-attributes - dynamic_service_start: DynamicServiceStart | None = field( - metadata={ - "description": ( - "used to create the service in any given moment if the requested_state is RUNNING" - "can be set to None only when stopping the service" - ) - } + dynamic_service_start: DynamicServiceStart | None = Field( + description=( + "used to create the service in any given moment if the requested_state is RUNNING" + "can be set to None only when stopping the service" + ) ) - user_id: UserID | None = field( - metadata={ - "description": "required for propagating status changes to the frontend" - } + user_id: UserID | None = Field( + description="required for propagating status changes to the frontend" ) - project_id: ProjectID | None = field( - metadata={ - "description": "required for propagating status changes to the frontend" - } + project_id: ProjectID | None = Field( + description="required for propagating status changes to the frontend" ) - requested_state: UserRequestedState = field( - metadata={ - "description": ( - "status of the service desidered by the user RUNNING or STOPPED" - ) - } + requested_state: UserRequestedState = Field( + description=("status of the service desidered by the user RUNNING or STOPPED") ) - current_state: SchedulerServiceState = field( + current_state: SchedulerServiceState = Field( default=SchedulerServiceState.UNKNOWN, - metadata={ - "description": "to set after parsing the incoming state via the API calls" - }, + description="to set after parsing the incoming state via the API calls", + ) + + def __setattr__(self, name, value): + if name == "current_state" and value != self.current_state: + self.last_state_change = arrow.utcnow().timestamp() + super().__setattr__(name, value) + + last_state_change: float = Field( + default_factory=lambda: arrow.utcnow().timestamp(), + metadata={"description": "keeps track when the current_state was last updated"}, ) ############################# ### SERVICE STATUS UPDATE ### ############################# - scheduled_to_run: bool = field( + scheduled_to_run: bool = Field( default=False, - metadata={"description": "set when a job will be immediately scheduled"}, + description="set when a job will be immediately scheduled", ) - service_status: str = field( + service_status: str = Field( default="", - metadata={ - "description": "stored for debug mainly this is used to compute ``current_state``" - }, + description="stored for debug mainly this is used to compute ``current_state``", ) - service_status_task_uid: TaskUID | None = field( + service_status_task_uid: TaskUID | None = Field( default=None, - metadata={"description": "uid of the job currently fetching the status"}, + description="uid of the job currently fetching the status", ) - check_status_after: float = field( + check_status_after: float = Field( default_factory=lambda: arrow.utcnow().timestamp(), - metadata={"description": "used to determine when to poll the status again"}, + description="used to determine when to poll the status again", ) - last_status_notification: float = field( + last_status_notification: float = Field( default=0, - metadata={ - "description": "used to determine when was the last time the status was notified" - }, + description="used to determine when was the last time the status was notified", ) def set_check_status_after_to(self, delay_from_now: timedelta) -> None: @@ -116,8 +131,10 @@ def set_last_status_notification_to_now(self) -> None: ##################### def to_bytes(self) -> bytes: - return pickle.dumps(self) + result: bytes = umsgpack.packb(self.dict(), ext_handlers=_PACKB_EXTENSION_TYPES) + return result @classmethod def from_bytes(cls, data: bytes) -> "TrackedServiceModel": - return pickle.loads(data) # type: ignore # noqa: S301 + unpacked_data = umsgpack.unpackb(data, ext_handlers=_UNPACKB_EXTENSION_TYPES) + return cls(**unpacked_data) diff --git a/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/services/status_monitor/_monitor.py b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/services/status_monitor/_monitor.py index 0d8b5a2723f..8ba70997a93 100644 --- a/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/services/status_monitor/_monitor.py +++ b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/services/status_monitor/_monitor.py @@ -23,6 +23,8 @@ _INTERVAL_BETWEEN_CHECKS: Final[timedelta] = timedelta(seconds=1) _MAX_CONCURRENCY: Final[NonNegativeInt] = 10 +_REMOVE_AFTER_IDLE_FOR: Final[timedelta] = timedelta(minutes=5) + async def _start_get_status_deferred( app: FastAPI, node_id: NodeID, *, next_check_delay: timedelta @@ -31,6 +33,34 @@ async def _start_get_status_deferred( await DeferredGetStatus.start(node_id=node_id) +def _can_be_removed(model: TrackedServiceModel) -> bool: + + # requested **as** `STOPPED` + # service **reports** `IDLE` + if ( + model.current_state == SchedulerServiceState.IDLE + and model.requested_state == UserRequestedState.STOPPED + ): + return True + + # NOTE: currently dynamic-scheduler does nto automatically start a + # service reported who's requested_state is STARTED + # to avoid monitoring services which no longer exist, + # the service has to be removed. + + # requested as `STARTED` + # service **reports** `IDLE` since `_REMOVE_AFTER_IDLE_FOR` + if ( # noqa: SIM103 + model.current_state == SchedulerServiceState.IDLE + and model.requested_state == UserRequestedState.RUNNING + and arrow.utcnow().timestamp() - model.last_state_change + > _REMOVE_AFTER_IDLE_FOR.total_seconds() + ): + return True + + return False + + class Monitor: def __init__(self, app: FastAPI, status_worker_interval: timedelta) -> None: self.app = app @@ -44,7 +74,7 @@ async def _worker_start_get_status_requests(self) -> None: """ Check if a service requires it's status to be polled. Note that the interval at which the status is polled can vary. - This is a relatively low resoruce check. + This is a relatively low resource check. """ # NOTE: this worker runs on only once across all instances of the scheduler @@ -59,11 +89,7 @@ async def _worker_start_get_status_requests(self) -> None: current_timestamp = arrow.utcnow().timestamp() for node_id, model in models.items(): - # check if service is idle and status polling should stop - if ( - model.current_state == SchedulerServiceState.IDLE - and model.requested_state == UserRequestedState.STOPPED - ): + if _can_be_removed(model): to_remove.append(node_id) continue diff --git a/services/dynamic-scheduler/tests/assets/legacy_tracked_service_model.bin b/services/dynamic-scheduler/tests/assets/legacy_tracked_service_model.bin new file mode 100644 index 00000000000..8c26d4e8ba5 Binary files /dev/null and b/services/dynamic-scheduler/tests/assets/legacy_tracked_service_model.bin differ diff --git a/services/dynamic-scheduler/tests/unit/service_tracker/test__models.py b/services/dynamic-scheduler/tests/unit/service_tracker/test__models.py index 6b8e31321b3..077da84dcc7 100644 --- a/services/dynamic-scheduler/tests/unit/service_tracker/test__models.py +++ b/services/dynamic-scheduler/tests/unit/service_tracker/test__models.py @@ -1,8 +1,17 @@ +# pylint: disable=redefined-outer-name + +from copy import deepcopy from datetime import timedelta +from pathlib import Path +from uuid import uuid4 import arrow import pytest from faker import Faker +from models_library.api_schemas_dynamic_scheduler.dynamic_services import ( + DynamicServiceStart, +) +from models_library.projects import ProjectID from servicelib.deferred_tasks import TaskUID from simcore_service_dynamic_scheduler.services.service_tracker._models import ( SchedulerServiceState, @@ -38,11 +47,23 @@ def test_serialization( assert TrackedServiceModel.from_bytes(as_bytes) == tracked_model -async def test_set_check_status_after_to(): +@pytest.mark.parametrize( + "dynamic_service_start", + [ + None, + DynamicServiceStart.parse_obj( + DynamicServiceStart.Config.schema_extra["example"] + ), + ], +) +@pytest.mark.parametrize("project_id", [None, uuid4()]) +async def test_set_check_status_after_to( + dynamic_service_start: DynamicServiceStart | None, project_id: ProjectID | None +): model = TrackedServiceModel( - dynamic_service_start=None, + dynamic_service_start=dynamic_service_start, user_id=None, - project_id=None, + project_id=project_id, requested_state=UserRequestedState.RUNNING, ) assert model.check_status_after < arrow.utcnow().timestamp() @@ -55,3 +76,43 @@ async def test_set_check_status_after_to(): assert model.check_status_after assert before < model.check_status_after < after + + +async def test_legacy_format_compatibility(project_slug_dir: Path): + legacy_format_path = ( + project_slug_dir / "tests" / "assets" / "legacy_tracked_service_model.bin" + ) + assert legacy_format_path.exists() + + model_from_disk = TrackedServiceModel.from_bytes(legacy_format_path.read_bytes()) + + model = TrackedServiceModel( + dynamic_service_start=None, + user_id=None, + project_id=None, + requested_state=UserRequestedState.RUNNING, + # assume same dates are coming in + check_status_after=model_from_disk.check_status_after, + last_state_change=model_from_disk.last_state_change, + ) + + assert model_from_disk == model + + +def test_current_state_changes_updates_last_state_change(): + model = TrackedServiceModel( + dynamic_service_start=None, + user_id=None, + project_id=None, + requested_state=UserRequestedState.RUNNING, + ) + + last_changed = deepcopy(model.last_state_change) + model.current_state = SchedulerServiceState.IDLE + assert last_changed != model.last_state_change + + last_changed_2 = deepcopy(model.last_state_change) + model.current_state = SchedulerServiceState.IDLE + assert last_changed_2 == model.last_state_change + + assert last_changed != last_changed_2 diff --git a/services/dynamic-scheduler/tests/unit/status_monitor/test_services_status_monitor__monitor.py b/services/dynamic-scheduler/tests/unit/status_monitor/test_services_status_monitor__monitor.py index b1dfd7c0d1f..2578114e541 100644 --- a/services/dynamic-scheduler/tests/unit/status_monitor/test_services_status_monitor__monitor.py +++ b/services/dynamic-scheduler/tests/unit/status_monitor/test_services_status_monitor__monitor.py @@ -2,10 +2,12 @@ # pylint:disable=too-many-positional-arguments # pylint:disable=unused-argument +import itertools import json import re from collections.abc import AsyncIterable, Callable from copy import deepcopy +from datetime import timedelta from typing import Any from unittest.mock import AsyncMock from uuid import uuid4 @@ -32,11 +34,19 @@ set_request_as_running, set_request_as_stopped, ) +from simcore_service_dynamic_scheduler.services.service_tracker._models import ( + SchedulerServiceState, + TrackedServiceModel, + UserRequestedState, +) from simcore_service_dynamic_scheduler.services.status_monitor import _monitor from simcore_service_dynamic_scheduler.services.status_monitor._deferred_get_status import ( DeferredGetStatus, ) -from simcore_service_dynamic_scheduler.services.status_monitor._monitor import Monitor +from simcore_service_dynamic_scheduler.services.status_monitor._monitor import ( + Monitor, + _can_be_removed, +) from simcore_service_dynamic_scheduler.services.status_monitor._setup import get_monitor from tenacity import AsyncRetrying from tenacity.retry import retry_if_exception_type @@ -420,3 +430,72 @@ async def test_expected_calls_to_notify_frontend( # pylint:disable=too-many-arg # pylint:disable=protected-access await monitor._worker_start_get_status_requests() # noqa: SLF001 assert remove_tracked_spy.call_count == remove_tracked_count + + +@pytest.fixture +def mock_tracker_remove_after_idle_for(mocker: MockerFixture) -> None: + mocker.patch( + "simcore_service_dynamic_scheduler.services.status_monitor._monitor._REMOVE_AFTER_IDLE_FOR", + timedelta(seconds=0.1), + ) + + +@pytest.mark.parametrize( + "requested_state, current_state, immediate_can_be_removed, can_be_removed", + [ + pytest.param( + UserRequestedState.RUNNING, + SchedulerServiceState.IDLE, + False, + True, + id="can_remove_after_an_interval", + ), + pytest.param( + UserRequestedState.STOPPED, + SchedulerServiceState.IDLE, + True, + True, + id="can_remove_no_interval", + ), + *[ + pytest.param( + requested_state, + service_state, + False, + False, + id=f"not_removed_{requested_state=}_{service_state=}", + ) + for requested_state, service_state in itertools.product( + set(UserRequestedState), + {x for x in SchedulerServiceState if x != SchedulerServiceState.IDLE}, + ) + ], + ], +) +async def test__can_be_removed( + mock_tracker_remove_after_idle_for: None, + requested_state: UserRequestedState, + current_state: SchedulerServiceState, + immediate_can_be_removed: bool, + can_be_removed: bool, +): + model = TrackedServiceModel( + dynamic_service_start=None, + user_id=None, + project_id=None, + requested_state=requested_state, + ) + + # This also triggers the setter and updates the last state change timer + model.current_state = current_state + + assert _can_be_removed(model) is immediate_can_be_removed + + async for attempt in AsyncRetrying( + wait=wait_fixed(0.1), + stop=stop_after_delay(2), + reraise=True, + retry=retry_if_exception_type(AssertionError), + ): + with attempt: + assert _can_be_removed(model) is can_be_removed diff --git a/services/efs-guardian/src/simcore_service_efs_guardian/core/application.py b/services/efs-guardian/src/simcore_service_efs_guardian/core/application.py index c0c99bd5a94..217a3d0a1bd 100644 --- a/services/efs-guardian/src/simcore_service_efs_guardian/core/application.py +++ b/services/efs-guardian/src/simcore_service_efs_guardian/core/application.py @@ -15,6 +15,7 @@ from ..api.rpc.routes import setup_rpc_routes from ..services.background_tasks_setup import setup as setup_background_tasks from ..services.efs_manager_setup import setup as setup_efs_manager +from ..services.fire_and_forget_setup import setup as setup_fire_and_forget from ..services.modules.db import setup as setup_db from ..services.modules.rabbitmq import setup as setup_rabbitmq from ..services.modules.redis import setup as setup_redis @@ -56,6 +57,8 @@ def create_app(settings: ApplicationSettings | None = None) -> FastAPI: setup_background_tasks(app) # requires Redis, DB setup_process_messages(app) # requires Rabbit + setup_fire_and_forget(app) + # EVENTS async def _on_startup() -> None: print(APP_STARTED_BANNER_MSG, flush=True) # noqa: T201 diff --git a/services/efs-guardian/src/simcore_service_efs_guardian/services/efs_manager.py b/services/efs-guardian/src/simcore_service_efs_guardian/services/efs_manager.py index a09e4ce16cd..1963003232b 100644 --- a/services/efs-guardian/src/simcore_service_efs_guardian/services/efs_manager.py +++ b/services/efs-guardian/src/simcore_service_efs_guardian/services/efs_manager.py @@ -83,6 +83,31 @@ async def get_project_node_data_size( return await efs_manager_utils.get_size_bash_async(_dir_path) + async def list_project_node_state_names( + self, project_id: ProjectID, node_id: NodeID + ) -> list[str]: + """ + These are currently state volumes that are mounted via docker volume to dynamic sidecar and user services + (ex. ".data_assets" and "home_user_workspace") + """ + _dir_path = ( + self._efs_mounted_path + / self._project_specific_data_base_directory + / f"{project_id}" + / f"{node_id}" + ) + + project_node_states = [] + for child in _dir_path.iterdir(): + if child.is_dir(): + project_node_states.append(child.name) + else: + _logger.error( + "This is not a directory. This should not happen! %s", + _dir_path / child.name, + ) + return project_node_states + async def remove_project_node_data_write_permissions( self, project_id: ProjectID, node_id: NodeID ) -> None: diff --git a/services/efs-guardian/src/simcore_service_efs_guardian/services/fire_and_forget_setup.py b/services/efs-guardian/src/simcore_service_efs_guardian/services/fire_and_forget_setup.py new file mode 100644 index 00000000000..379e46753ae --- /dev/null +++ b/services/efs-guardian/src/simcore_service_efs_guardian/services/fire_and_forget_setup.py @@ -0,0 +1,37 @@ +import logging +from collections.abc import Awaitable, Callable + +from fastapi import FastAPI +from servicelib.logging_utils import log_catch, log_context + +_logger = logging.getLogger(__name__) + + +def _on_app_startup(_app: FastAPI) -> Callable[[], Awaitable[None]]: + async def _startup() -> None: + with log_context( + _logger, logging.INFO, msg="Efs Guardian setup fire and forget tasks.." + ), log_catch(_logger, reraise=False): + _app.state.efs_guardian_fire_and_forget_tasks = set() + + return _startup + + +def _on_app_shutdown( + _app: FastAPI, +) -> Callable[[], Awaitable[None]]: + async def _stop() -> None: + with log_context( + _logger, logging.INFO, msg="Efs Guardian fire and forget tasks shutdown.." + ), log_catch(_logger, reraise=False): + assert _app # nosec + if _app.state.efs_guardian_fire_and_forget_tasks: + for task in _app.state.efs_guardian_fire_and_forget_tasks: + task.cancel() + + return _stop + + +def setup(app: FastAPI) -> None: + app.add_event_handler("startup", _on_app_startup(app)) + app.add_event_handler("shutdown", _on_app_shutdown(app)) diff --git a/services/efs-guardian/src/simcore_service_efs_guardian/services/modules/rabbitmq.py b/services/efs-guardian/src/simcore_service_efs_guardian/services/modules/rabbitmq.py index 82ef1aae84c..f94c5dbf418 100644 --- a/services/efs-guardian/src/simcore_service_efs_guardian/services/modules/rabbitmq.py +++ b/services/efs-guardian/src/simcore_service_efs_guardian/services/modules/rabbitmq.py @@ -29,12 +29,17 @@ async def on_startup() -> None: app.state.rabbitmq_rpc_server = await RabbitMQRPCClient.create( client_name="efs_guardian_rpc_server", settings=settings ) + app.state.rabbitmq_rpc_client = await RabbitMQRPCClient.create( + client_name="efs_guardian_rpc_client", settings=settings + ) async def on_shutdown() -> None: if app.state.rabbitmq_client: await app.state.rabbitmq_client.close() if app.state.rabbitmq_rpc_server: await app.state.rabbitmq_rpc_server.close() + if app.state.rabbitmq_rpc_client: + await app.state.rabbitmq_rpc_client.close() app.add_event_handler("startup", on_startup) app.add_event_handler("shutdown", on_shutdown) @@ -53,4 +58,9 @@ def get_rabbitmq_rpc_server(app: FastAPI) -> RabbitMQRPCClient: return cast(RabbitMQRPCClient, app.state.rabbitmq_rpc_server) +def get_rabbitmq_rpc_client(app: FastAPI) -> RabbitMQRPCClient: + assert app.state.rabbitmq_rpc_client # nosec + return cast(RabbitMQRPCClient, app.state.rabbitmq_rpc_client) + + __all__ = ("RabbitMQClient",) diff --git a/services/efs-guardian/src/simcore_service_efs_guardian/services/process_messages.py b/services/efs-guardian/src/simcore_service_efs_guardian/services/process_messages.py index d1e3b67353a..4de25a56c03 100644 --- a/services/efs-guardian/src/simcore_service_efs_guardian/services/process_messages.py +++ b/services/efs-guardian/src/simcore_service_efs_guardian/services/process_messages.py @@ -1,12 +1,19 @@ import logging from fastapi import FastAPI +from models_library.api_schemas_dynamic_sidecar.telemetry import DiskUsage from models_library.rabbitmq_messages import DynamicServiceRunningMessage from servicelib.logging_utils import log_context -from simcore_service_efs_guardian.services.modules.redis import get_redis_lock_client +from servicelib.rabbitmq import RabbitMQRPCClient +from servicelib.rabbitmq.rpc_interfaces.dynamic_sidecar.disk_usage import ( + update_disk_usage, +) +from servicelib.utils import fire_and_forget_task from ..core.settings import get_application_settings from ..services.efs_manager import EfsManager +from ..services.modules.rabbitmq import get_rabbitmq_rpc_client +from ..services.modules.redis import get_redis_lock_client _logger = logging.getLogger(__name__) @@ -49,6 +56,23 @@ async def process_dynamic_service_running_message(app: FastAPI, data: bytes) -> rabbit_message.user_id, ) + project_node_state_names = await efs_manager.list_project_node_state_names( + rabbit_message.project_id, node_id=rabbit_message.node_id + ) + rpc_client: RabbitMQRPCClient = get_rabbitmq_rpc_client(app) + _used = min(size, settings.EFS_DEFAULT_USER_SERVICE_SIZE_BYTES) + usage: dict[str, DiskUsage] = {} + for name in project_node_state_names: + usage[name] = DiskUsage.from_efs_guardian( + used=_used, total=settings.EFS_DEFAULT_USER_SERVICE_SIZE_BYTES + ) + + fire_and_forget_task( + update_disk_usage(rpc_client, node_id=rabbit_message.node_id, usage=usage), + task_suffix_name=f"update_disk_usage_efs_user_id{rabbit_message.user_id}_node_id{rabbit_message.node_id}", + fire_and_forget_tasks_collection=app.state.efs_guardian_fire_and_forget_tasks, + ) + if size > settings.EFS_DEFAULT_USER_SERVICE_SIZE_BYTES: msg = f"Removing write permissions inside of EFS starts for project ID: {rabbit_message.project_id}, node ID: {rabbit_message.node_id}, current user: {rabbit_message.user_id}, size: {size}, upper limit: {settings.EFS_DEFAULT_USER_SERVICE_SIZE_BYTES}" with log_context(_logger, logging.WARNING, msg=msg): diff --git a/services/efs-guardian/tests/conftest.py b/services/efs-guardian/tests/conftest.py index 4309ea7b078..260e5a74026 100644 --- a/services/efs-guardian/tests/conftest.py +++ b/services/efs-guardian/tests/conftest.py @@ -21,6 +21,7 @@ "pytest_simcore.environment_configs", "pytest_simcore.faker_projects_data", "pytest_simcore.faker_users_data", + "pytest_simcore.faker_products_data", "pytest_simcore.faker_projects_data", "pytest_simcore.pydantic_models", "pytest_simcore.pytest_global_environs", diff --git a/services/efs-guardian/tests/unit/test_efs_manager.py b/services/efs-guardian/tests/unit/test_efs_manager.py index fad03fcaa44..35c2535de94 100644 --- a/services/efs-guardian/tests/unit/test_efs_manager.py +++ b/services/efs-guardian/tests/unit/test_efs_manager.py @@ -91,6 +91,11 @@ async def test_remove_write_access_rights( is False ) + with pytest.raises(FileNotFoundError): + await efs_manager.list_project_node_state_names( + project_id=project_id, node_id=node_id + ) + with patch( "simcore_service_efs_guardian.services.efs_manager.os.chown" ) as mocked_chown: @@ -108,6 +113,11 @@ async def test_remove_write_access_rights( is True ) + project_node_state_names = await efs_manager.list_project_node_state_names( + project_id=project_id, node_id=node_id + ) + assert project_node_state_names == [_storage_directory_name] + size_before = await efs_manager.get_project_node_data_size( project_id=project_id, node_id=node_id ) diff --git a/services/efs-guardian/tests/unit/test_process_messages.py b/services/efs-guardian/tests/unit/test_process_messages.py new file mode 100644 index 00000000000..32b439777f0 --- /dev/null +++ b/services/efs-guardian/tests/unit/test_process_messages.py @@ -0,0 +1,111 @@ +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +from unittest.mock import AsyncMock, patch + +import pytest +from faker import Faker +from fastapi import FastAPI +from models_library.products import ProductName +from models_library.rabbitmq_messages import DynamicServiceRunningMessage +from models_library.users import UserID +from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict +from pytest_simcore.helpers.typing_env import EnvVarsDict +from simcore_service_efs_guardian.services.efs_manager import NodeID, ProjectID +from simcore_service_efs_guardian.services.process_messages import ( + process_dynamic_service_running_message, +) + +pytest_simcore_core_services_selection = ["rabbit"] +pytest_simcore_ops_services_selection = [] + + +@pytest.fixture +def app_environment( + monkeypatch: pytest.MonkeyPatch, + app_environment: EnvVarsDict, + rabbit_env_vars_dict: EnvVarsDict, + with_disabled_redis_and_background_tasks: None, + with_disabled_postgres: None, +) -> EnvVarsDict: + return setenvs_from_dict( + monkeypatch, + { + **app_environment, + **rabbit_env_vars_dict, + "EFS_DEFAULT_USER_SERVICE_SIZE_BYTES": "10000", + }, + ) + + +@patch("simcore_service_efs_guardian.services.process_messages.update_disk_usage") +async def test_process_msg( + mock_update_disk_usage, + faker: Faker, + app: FastAPI, + efs_cleanup: None, + project_id: ProjectID, + node_id: NodeID, + user_id: UserID, + product_name: ProductName, +): + # Create mock data for the message + model_instance = DynamicServiceRunningMessage( + project_id=project_id, + node_id=node_id, + user_id=user_id, + product_name=product_name, + ) + json_str = model_instance.json() + model_bytes = json_str.encode("utf-8") + + _expected_project_node_states = [".data_assets", "home_user_workspace"] + # Mock efs_manager and its methods + mock_efs_manager = AsyncMock() + app.state.efs_manager = mock_efs_manager + mock_efs_manager.check_project_node_data_directory_exits.return_value = True + mock_efs_manager.get_project_node_data_size.return_value = 4000 + mock_efs_manager.list_project_node_state_names.return_value = ( + _expected_project_node_states + ) + + result = await process_dynamic_service_running_message(app, data=model_bytes) + + # Check the actual arguments passed to notify_service_efs_disk_usage + _, kwargs = mock_update_disk_usage.call_args + assert kwargs["usage"] + assert len(kwargs["usage"]) == 2 + for key, value in kwargs["usage"].items(): + assert key in _expected_project_node_states + assert value.used == 4000 + assert value.free == 6000 + assert value.total == 10000 + assert value.used_percent == 40.0 + + assert result is True + + +async def test_process_msg__dir_not_exists( + app: FastAPI, + efs_cleanup: None, + project_id: ProjectID, + node_id: NodeID, + user_id: UserID, + product_name: ProductName, +): + # Create mock data for the message + model_instance = DynamicServiceRunningMessage( + project_id=project_id, + node_id=node_id, + user_id=user_id, + product_name=product_name, + ) + json_str = model_instance.json() + model_bytes = json_str.encode("utf-8") + + result = await process_dynamic_service_running_message(app, data=model_bytes) + assert result is True diff --git a/services/osparc-gateway-server/requirements/_test.txt b/services/osparc-gateway-server/requirements/_test.txt index 797b272793e..a9fff835004 100644 --- a/services/osparc-gateway-server/requirements/_test.txt +++ b/services/osparc-gateway-server/requirements/_test.txt @@ -89,7 +89,7 @@ markupsafe==2.1.5 # via # -c requirements/../../dask-sidecar/requirements/_dask-distributed.txt # jinja2 -msgpack==1.0.8 +msgpack==1.1.0 # via # -c requirements/../../dask-sidecar/requirements/_dask-distributed.txt # distributed diff --git a/services/osparc-gateway-server/tests/system/requirements/_test.txt b/services/osparc-gateway-server/tests/system/requirements/_test.txt index 410339df3c6..0977f99f778 100644 --- a/services/osparc-gateway-server/tests/system/requirements/_test.txt +++ b/services/osparc-gateway-server/tests/system/requirements/_test.txt @@ -83,7 +83,7 @@ markupsafe==2.1.5 # via # -c requirements/../../../../dask-sidecar/requirements/_dask-distributed.txt # jinja2 -msgpack==1.0.8 +msgpack==1.1.0 # via # -c requirements/../../../../dask-sidecar/requirements/_dask-distributed.txt # distributed diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ResourceFilter.js b/services/static-webserver/client/source/class/osparc/dashboard/ResourceFilter.js index 92d0b1dc1b3..0cbb3357737 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/ResourceFilter.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/ResourceFilter.js @@ -65,6 +65,7 @@ qx.Class.define("osparc.dashboard.ResourceFilter", { /* WORKSPACES AND FOLDERS */ __createWorkspacesAndFoldersTree: function() { const workspacesAndFoldersTree = this.__workspacesAndFoldersTree = new osparc.dashboard.WorkspacesAndFoldersTree(); + osparc.utils.Utils.setIdToWidget(workspacesAndFoldersTree, "contextTree"); // Height needs to be calculated manually to make it flexible workspacesAndFoldersTree.set({ minHeight: 60, @@ -137,6 +138,7 @@ qx.Class.define("osparc.dashboard.ResourceFilter", { /* TAGS */ __createTagsFilterLayout: function() { const layout = new qx.ui.container.Composite(new qx.ui.layout.VBox(5)); + osparc.utils.Utils.setIdToWidget(layout, "tagsFilter"); this.__populateTags(layout, []); osparc.store.Store.getInstance().addListener("changeTags", () => { @@ -157,6 +159,7 @@ qx.Class.define("osparc.dashboard.ResourceFilter", { layout.removeAll(); osparc.store.Store.getInstance().getTags().forEach((tag, idx) => { const button = new qx.ui.form.ToggleButton(tag.name, "@FontAwesome5Solid/tag/18"); + osparc.utils.Utils.setIdToWidget(button, "tagFilterItem"); button.id = tag.id; button.set({ appearance: "filter-toggle-button", diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ServiceBrowser.js b/services/static-webserver/client/source/class/osparc/dashboard/ServiceBrowser.js index a84549d1a5b..15278743e54 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/ServiceBrowser.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/ServiceBrowser.js @@ -58,27 +58,10 @@ qx.Class.define("osparc.dashboard.ServiceBrowser", { }, __loadServices: function() { - osparc.store.Services.getServicesLatest() - .then(servicesLatest => { - const servicesList = []; - Object.keys(servicesLatest).forEach(key => { - const serviceLatest = servicesLatest[key]; - // do not show frontend services - if (key.includes("simcore/services/frontend/")) { - return; - } - // do not show retired services - if (servicesLatest[key]["retired"]) { - return; - } - servicesList.push(serviceLatest); - }); - this.__setServicesToList(servicesList); - }) - .catch(err => { - console.error(err); - this.__setServicesToList([]); - }); + const excludeFrontend = true; + const excludeDeprecated = true + osparc.store.Services.getServicesLatestList(excludeFrontend, excludeDeprecated) + .then(servicesList => this.__setServicesToList(servicesList)); }, _updateServiceData: function(serviceData) { diff --git a/services/static-webserver/client/source/class/osparc/dashboard/WorkspacesAndFoldersTreeItem.js b/services/static-webserver/client/source/class/osparc/dashboard/WorkspacesAndFoldersTreeItem.js index f643218243b..071ba7e3d6d 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/WorkspacesAndFoldersTreeItem.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/WorkspacesAndFoldersTreeItem.js @@ -32,6 +32,8 @@ qx.Class.define("osparc.dashboard.WorkspacesAndFoldersTreeItem", { this.setNotHoveredStyle(); this.__attachEventHandlers(); + + osparc.utils.Utils.setIdToWidget(this, "workspacesAndFoldersTreeItem"); }, members: { diff --git a/services/static-webserver/client/source/class/osparc/navigation/UserMenu.js b/services/static-webserver/client/source/class/osparc/navigation/UserMenu.js index 025c7e5024c..b18f0a4d7b3 100644 --- a/services/static-webserver/client/source/class/osparc/navigation/UserMenu.js +++ b/services/static-webserver/client/source/class/osparc/navigation/UserMenu.js @@ -116,6 +116,7 @@ qx.Class.define("osparc.navigation.UserMenu", { } case "license": control = new qx.ui.menu.Button(this.tr("License")); + osparc.utils.Utils.setIdToWidget(control, "userMenuLicenseBtn"); osparc.store.Support.getLicenseURL() .then(licenseURL => control.addListener("execute", () => window.open(licenseURL))); this.add(control); diff --git a/services/static-webserver/client/source/class/osparc/store/Services.js b/services/static-webserver/client/source/class/osparc/store/Services.js index 1fb2c1b0959..569f5ce08e4 100644 --- a/services/static-webserver/client/source/class/osparc/store/Services.js +++ b/services/static-webserver/client/source/class/osparc/store/Services.js @@ -24,7 +24,7 @@ qx.Class.define("osparc.store.Services", { getServicesLatest: function(useCache = true) { return new Promise(resolve => { if (useCache && Object.keys(this.servicesCached)) { - // give latest only + // return latest only const latest = this.__getLatestCached(); resolve(latest); return; @@ -48,6 +48,45 @@ qx.Class.define("osparc.store.Services", { }); }, + getServicesLatestList: function(excludeFrontend = true, excludeDeprecated = true) { + return new Promise(resolve => { + const servicesList = []; + this.getServicesLatest() + .then(async servicesLatest => { + const serviceKeys = Object.keys(servicesLatest); + for (let i=0; i { + console.error(err); + }) + .finally(() => resolve(servicesList)); + }); + }, + getService: function(key, version, useCache = true) { return new Promise(resolve => { if (useCache && this.__isInCache(key, version)) { diff --git a/services/static-webserver/client/source/class/osparc/utils/Utils.js b/services/static-webserver/client/source/class/osparc/utils/Utils.js index ab86392270c..ead285bccab 100644 --- a/services/static-webserver/client/source/class/osparc/utils/Utils.js +++ b/services/static-webserver/client/source/class/osparc/utils/Utils.js @@ -1033,9 +1033,9 @@ qx.Class.define("osparc.utils.Utils", { return null; }, - setMoreToWidget: (qWidget, id) => { + setKeyToWidget: (qWidget, id) => { if (qWidget.getContentElement) { - qWidget.getContentElement().setAttribute("osparc-test-more", id); + qWidget.getContentElement().setAttribute("osparc-test-key", id); } }, diff --git a/services/static-webserver/client/source/class/osparc/widget/NodeTreeItem.js b/services/static-webserver/client/source/class/osparc/widget/NodeTreeItem.js index 7ce4509cc56..d424973944f 100644 --- a/services/static-webserver/client/source/class/osparc/widget/NodeTreeItem.js +++ b/services/static-webserver/client/source/class/osparc/widget/NodeTreeItem.js @@ -107,7 +107,7 @@ qx.Class.define("osparc.widget.NodeTreeItem", { __applyStudy: function(study) { const label = this.getChildControl("label"); - osparc.utils.Utils.setMoreToWidget(label, "root"); + osparc.utils.Utils.setKeyToWidget(label, "root"); study.bind("name", this, "toolTipText"); @@ -116,7 +116,7 @@ qx.Class.define("osparc.widget.NodeTreeItem", { __applyNode: function(node) { const label = this.getChildControl("label"); - osparc.utils.Utils.setMoreToWidget(label, node.getNodeId()); + osparc.utils.Utils.setKeyToWidget(label, node.getNodeId()); node.bind("label", this, "toolTipText"); diff --git a/services/static-webserver/client/source/class/osparc/workbench/DiskUsageController.js b/services/static-webserver/client/source/class/osparc/workbench/DiskUsageController.js index fd15b5cc688..5510906f2bb 100644 --- a/services/static-webserver/client/source/class/osparc/workbench/DiskUsageController.js +++ b/services/static-webserver/client/source/class/osparc/workbench/DiskUsageController.js @@ -34,7 +34,7 @@ qx.Class.define("osparc.workbench.DiskUsageController", { this.__socket.on("serviceDiskUsage", data => { if (data["node_id"] && this.__callbacks[data["node_id"]]) { // notify - this.setDiskUsageNotificationToUI(data); + this.__evaluateDisplayMessage(data); this.__callbacks[data["node_id"]].forEach(cb => { cb(data); }) @@ -65,7 +65,7 @@ qx.Class.define("osparc.workbench.DiskUsageController", { } }, - getDiskUsage: function(freeSpace) { + __getWarningLevel: function(freeSpace) { const lowDiskSpacePreferencesSettings = osparc.Preferences.getInstance(); this.__lowDiskThreshold = lowDiskSpacePreferencesSettings.getLowDiskSpaceThreshold(); const warningSize = osparc.utils.Utils.gBToBytes(this.__lowDiskThreshold); // 5 GB Default @@ -81,13 +81,12 @@ qx.Class.define("osparc.workbench.DiskUsageController", { return warningLevel }, - setDiskUsageNotificationToUI: function(data) { + __evaluateDisplayMessage: function(data) { const id = data["node_id"]; if (!this.__callbacks[id]) { return; } - const diskUsage = data.usage["HOST"] function isMatchingNodeId({nodeId}) { return nodeId === id; } @@ -96,31 +95,41 @@ qx.Class.define("osparc.workbench.DiskUsageController", { } let prevDiskUsageState = this.__prevDiskUsageStateList.find(isMatchingNodeId); - - const warningLevel = this.getDiskUsage(diskUsage.free); if (prevDiskUsageState === undefined) { + // Initialize it this.__prevDiskUsageStateList.push({ nodeId: id, state: "NORMAL" }) } - const freeSpace = osparc.utils.Utils.bytesToSize(diskUsage.free); const store = osparc.store.Store.getInstance(); const currentStudy = store.getCurrentStudy(); if (!currentStudy) { return; } - const node = currentStudy.getWorkbench().getNode(id); + const node = currentStudy.getWorkbench().getNode(id); const nodeName = node ? node.getLabel() : null; if (nodeName === null) { return; } - let message; + const diskHostUsage = data.usage["HOST"] + let freeSpace = osparc.utils.Utils.bytesToSize(diskHostUsage.free); + let warningLevel = this.__getWarningLevel(diskHostUsage.free); + + if ("STATE_VOLUMES" in data.usage) { + const diskVolsUsage = data.usage["STATE_VOLUMES"]; + if (diskVolsUsage["used_percent"] > diskHostUsage["used_percent"]) { + // "STATE_VOLUMES" is more critical so it takes over + freeSpace = osparc.utils.Utils.bytesToSize(diskVolsUsage.free); + warningLevel = this.__getWarningLevel(diskVolsUsage.free); + } + } const objIndex = this.__prevDiskUsageStateList.findIndex((obj => obj.nodeId === id)); + let message; switch (warningLevel) { case "CRITICAL": if (shouldDisplayMessage(prevDiskUsageState, warningLevel)) { diff --git a/services/static-webserver/client/source/class/osparc/workbench/DiskUsageIndicator.js b/services/static-webserver/client/source/class/osparc/workbench/DiskUsageIndicator.js index d844cfcd5e9..496b94079cb 100644 --- a/services/static-webserver/client/source/class/osparc/workbench/DiskUsageIndicator.js +++ b/services/static-webserver/client/source/class/osparc/workbench/DiskUsageIndicator.js @@ -74,7 +74,6 @@ qx.Class.define("osparc.workbench.DiskUsageIndicator", { allowShrinkY: false, allowGrowX: true, allowGrowY: false, - toolTipText: this.tr("Disk usage") }); this._add(control) break; @@ -156,10 +155,23 @@ qx.Class.define("osparc.workbench.DiskUsageIndicator", { return; } - const usage = diskUsage["usage"]["HOST"] - const color1 = this.__getIndicatorColor(usage.free); - const progress = `${usage["used_percent"]}%`; - const labelDiskSize = osparc.utils.Utils.bytesToSize(usage.free); + const diskHostUsage = diskUsage["usage"]["HOST"] + let color1 = this.__getIndicatorColor(diskHostUsage.free); + let progress = `${diskHostUsage["used_percent"]}%`; + let labelDiskSize = osparc.utils.Utils.bytesToSize(diskHostUsage.free); + let toolTipText = this.tr("Disk usage"); + if ("STATE_VOLUMES" in diskUsage["usage"]) { + const diskVolsUsage = diskUsage["usage"]["STATE_VOLUMES"]; + if (diskVolsUsage["used_percent"] > diskHostUsage["used_percent"]) { + // "STATE_VOLUMES" is more critical so it takes over + color1 = this.__getIndicatorColor(diskVolsUsage.free); + progress = `${diskVolsUsage["used_percent"]}%`; + labelDiskSize = osparc.utils.Utils.bytesToSize(diskVolsUsage.free); + } + toolTipText = this.tr("Disk usage") + "
"; + toolTipText += this.tr("Data storage: ") + osparc.utils.Utils.bytesToSize(diskVolsUsage.free) + "
"; + toolTipText += this.tr("I/O storage: ") + osparc.utils.Utils.bytesToSize(diskHostUsage.free) + "
"; + } const bgColor = qx.theme.manager.Color.getInstance().resolve("tab_navigation_bar_background_color"); const color2 = qx.theme.manager.Color.getInstance().resolve("progressive-progressbar-background"); indicator.getContentElement().setStyles({ @@ -167,6 +179,9 @@ qx.Class.define("osparc.workbench.DiskUsageIndicator", { "background": `linear-gradient(90deg, ${color1} ${progress}, ${color2} ${progress})`, "border-color": color1 }); + indicator.set({ + toolTipText + }); const indicatorLabel = this.getChildControl("disk-indicator-label"); indicatorLabel.setValue(`${labelDiskSize} Free`); diff --git a/services/static-webserver/client/source/class/osparc/workbench/ServiceCatalog.js b/services/static-webserver/client/source/class/osparc/workbench/ServiceCatalog.js index a1b4db10cb1..a82a513cd3d 100644 --- a/services/static-webserver/client/source/class/osparc/workbench/ServiceCatalog.js +++ b/services/static-webserver/client/source/class/osparc/workbench/ServiceCatalog.js @@ -197,11 +197,11 @@ qx.Class.define("osparc.workbench.ServiceCatalog", { __populateList: function() { this.__servicesLatest = []; - osparc.store.Services.getServicesLatest() - .then(servicesLatest => { - Object.keys(servicesLatest).forEach(key => { - this.__servicesLatest.push(servicesLatest[key]); - }); + const excludeFrontend = false; + const excludeDeprecated = true; + osparc.store.Services.getServicesLatestList(excludeFrontend, excludeDeprecated) + .then(servicesList => { + this.__servicesLatest = servicesList; this.__updateList(); }); }, diff --git a/services/web/server/requirements/_base.txt b/services/web/server/requirements/_base.txt index df540ac4b88..d566b8d9112 100644 --- a/services/web/server/requirements/_base.txt +++ b/services/web/server/requirements/_base.txt @@ -255,7 +255,7 @@ markupsafe==2.1.1 # mako mdurl==0.1.2 # via markdown-it-py -msgpack==1.0.7 +msgpack==1.1.0 # via -r requirements/_base.in multidict==6.1.0 # via diff --git a/tests/e2e-frontend/tests/fixtures/loginPage.js b/tests/e2e-frontend/tests/fixtures/loginPage.js index ffee8c3fc58..04e29e66b90 100644 --- a/tests/e2e-frontend/tests/fixtures/loginPage.js +++ b/tests/e2e-frontend/tests/fixtures/loginPage.js @@ -17,6 +17,8 @@ export class LoginPage { * @param {string} password */ async login(email, password) { + await this.page.goto(this.productUrl); + const usernameField = this.page.getByTestId("loginUserEmailFld"); const passwordField = this.page.getByTestId("loginPasswordFld"); const submitButton = this.page.getByTestId("loginSubmitBtn"); @@ -24,6 +26,10 @@ export class LoginPage { await usernameField.fill(email); await passwordField.fill(password); await submitButton.click(); + + const response = await this.page.waitForResponse('**/me'); + const meData = await response.json(); + return meData["data"]["role"]; } async logout() { diff --git a/tests/e2e-frontend/tests/navigationBar/navigationBar.spec.js b/tests/e2e-frontend/tests/navigationBar/navigationBar.spec.js index 27bb724cef8..0c981ef8777 100644 --- a/tests/e2e-frontend/tests/navigationBar/navigationBar.spec.js +++ b/tests/e2e-frontend/tests/navigationBar/navigationBar.spec.js @@ -153,15 +153,9 @@ for (const product in products) { test.beforeAll(async ({ browser }) => { page = await browser.newPage(); - loginPageFixture = new LoginPage(page, productUrl); - await loginPageFixture.goto(); - - await loginPageFixture.login(user.email, user.password); - - const response = await page.waitForResponse('**/me'); - const meData = await response.json(); - expect(meData["data"]["role"]).toBe(role); + const role = await loginPageFixture.login(user.email, user.password); + expect(role).toBe(user.role); }); test.afterAll(async ({ browser }) => { diff --git a/tests/e2e-frontend/tests/studyBrowser/leftFilters.spec.js b/tests/e2e-frontend/tests/studyBrowser/leftFilters.spec.js new file mode 100644 index 00000000000..2a8dfa724d7 --- /dev/null +++ b/tests/e2e-frontend/tests/studyBrowser/leftFilters.spec.js @@ -0,0 +1,54 @@ +/* eslint-disable no-undef */ + +const { test, expect } = require('@playwright/test'); + +import { LoginPage } from '../fixtures/loginPage'; + +import products from '../products.json'; +import users from '../users.json'; + +const product = "osparc"; +const productUrl = products[product]; +const user = users[product][0]; + +test.describe.serial(`Left Filters:`, () => { + let page = null; + let loginPageFixture = null; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + loginPageFixture = new LoginPage(page, productUrl); + const role = await loginPageFixture.login(user.email, user.password); + expect(role).toBe(user.role); + }); + + test.afterAll(async ({ browser }) => { + await loginPageFixture.logout(); + await page.close(); + await browser.close(); + }); + + test(`Context`, async () => { + const contextTree = page.getByTestId("contextTree"); + await expect(contextTree).toBeVisible({ + timeout: 30000 // it will take some time to load the Study Browser + }); + + const workspacesAndFoldersTreeItems = page.getByTestId("workspacesAndFoldersTreeItem"); + const count = await workspacesAndFoldersTreeItems.count(); + // at least two: My Workspace and Shared Workspaces + expect(count > 1).toBeTruthy(); + }); + + test(`Tags`, async () => { + const tagsFilter = page.getByTestId("tagsFilter"); + await expect(tagsFilter).toBeVisible({ + timeout: 30000 // it will take some time to load the Study Browser + }); + + const tagFilterItems = page.getByTestId("tagFilterItem"); + const count = await tagFilterItems.count(); + // at least two and less than 6 (max five are shown) + expect(count > 1 && count < 6).toBeTruthy(); + }); +}); diff --git a/tests/e2e-frontend/tests/studyBrowser/mainView.spec.js b/tests/e2e-frontend/tests/studyBrowser/mainView.spec.js new file mode 100644 index 00000000000..ff8890af572 --- /dev/null +++ b/tests/e2e-frontend/tests/studyBrowser/mainView.spec.js @@ -0,0 +1,81 @@ +/* eslint-disable no-undef */ + +const { test, expect } = require('@playwright/test'); + +import { LoginPage } from '../fixtures/loginPage'; + +import products from '../products.json'; +import users from '../users.json'; + +const expectedElements = { + "osparc": { + "plusButton": { + "id": "newStudyBtn", + }, + }, + "s4l": { + "plusButton": { + "id": "startS4LButton", + }, + }, + "s4lacad": { + "plusButton": { + "id": "startS4LButton", + }, + }, + "s4llite": { + "plusButton": { + "id": "startS4LButton", + }, + }, + "tis": { + "plusButton": { + "id": "newStudyBtn", + }, + }, + "tiplite": { + "plusButton": { + "id": "newStudyBtn", + }, + }, +}; + +for (const product in products) { + if (product in users) { + const productUrl = products[product]; + const productUsers = users[product]; + for (const user of productUsers) { + // expected roles for users: "USER" + const role = "USER"; + expect(user.role).toBe(role); + + test.describe.serial(`Main View: ${product}`, () => { + let page = null; + let loginPageFixture = null; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + loginPageFixture = new LoginPage(page, productUrl); + const role = await loginPageFixture.login(user.email, user.password); + expect(role).toBe(user.role); + }); + + test.afterAll(async ({ browser }) => { + await loginPageFixture.logout(); + await page.close(); + await browser.close(); + }); + + test(`Plus button`, async () => { + expect(expectedElements[product]["plusButton"]).toBeDefined(); + + const plusButtonId = expectedElements[product]["plusButton"]["id"]; + const plusButton = page.getByTestId(plusButtonId); + await expect(plusButton).toBeVisible({ + timeout: 30000 // it will take some time to load the Study Browser + }); + }); + }); + } + } +} diff --git a/tests/e2e-frontend/tests/userMenu/userMenuButtons.spec.js b/tests/e2e-frontend/tests/userMenu/userMenuButtons.spec.js index d590d4429a5..053bc38f6e3 100644 --- a/tests/e2e-frontend/tests/userMenu/userMenuButtons.spec.js +++ b/tests/e2e-frontend/tests/userMenu/userMenuButtons.spec.js @@ -16,6 +16,7 @@ const userMenuButtons = { "userMenuThemeSwitcherBtn": true, "userMenuAboutBtn": true, "userMenuAboutProductBtn": true, + "userMenuLicenseBtn": true, "userMenuAccessTIPBtn": false, "userMenuLogoutBtn": true, }, @@ -27,6 +28,7 @@ const userMenuButtons = { "userMenuThemeSwitcherBtn": true, "userMenuAboutBtn": true, "userMenuAboutProductBtn": true, + "userMenuLicenseBtn": true, "userMenuAccessTIPBtn": false, "userMenuLogoutBtn": true, }, @@ -38,6 +40,7 @@ const userMenuButtons = { "userMenuThemeSwitcherBtn": true, "userMenuAboutBtn": true, "userMenuAboutProductBtn": true, + "userMenuLicenseBtn": true, "userMenuAccessTIPBtn": false, "userMenuLogoutBtn": true, }, @@ -49,6 +52,7 @@ const userMenuButtons = { "userMenuThemeSwitcherBtn": true, "userMenuAboutBtn": true, "userMenuAboutProductBtn": true, + "userMenuLicenseBtn": true, "userMenuAccessTIPBtn": false, "userMenuLogoutBtn": true, }, @@ -60,6 +64,7 @@ const userMenuButtons = { "userMenuThemeSwitcherBtn": true, "userMenuAboutBtn": true, "userMenuAboutProductBtn": true, + "userMenuLicenseBtn": true, "userMenuAccessTIPBtn": false, "userMenuLogoutBtn": true, }, @@ -71,6 +76,7 @@ const userMenuButtons = { "userMenuThemeSwitcherBtn": true, "userMenuAboutBtn": true, "userMenuAboutProductBtn": true, + "userMenuLicenseBtn": true, "userMenuAccessTIPBtn": true, "userMenuLogoutBtn": true, }, @@ -108,15 +114,9 @@ for (const product in products) { test.beforeAll(async ({ browser }) => { page = await browser.newPage(); - loginPageFixture = new LoginPage(page, productUrl); - await loginPageFixture.goto(); - - await loginPageFixture.login(user.email, user.password); - - const response = await page.waitForResponse('**/me'); - const meData = await response.json(); - expect(meData["data"]["role"]).toBe(role); + const role = await loginPageFixture.login(user.email, user.password); + expect(role).toBe(user.role); }); test.afterAll(async ({ browser }) => { diff --git a/tests/e2e-frontend/tests/userMenu/userMenuWindows.spec.js b/tests/e2e-frontend/tests/userMenu/userMenuWindows.spec.js index 4a50cd45153..6303528c8c0 100644 --- a/tests/e2e-frontend/tests/userMenu/userMenuWindows.spec.js +++ b/tests/e2e-frontend/tests/userMenu/userMenuWindows.spec.js @@ -9,7 +9,7 @@ import users from '../users.json'; const product = "osparc"; const productUrl = products[product]; -const user = users[product]; +const user = users[product][0]; test.describe.serial(`User Menu Windows: ${product}`, () => { let page = null; @@ -17,15 +17,9 @@ test.describe.serial(`User Menu Windows: ${product}`, () => { test.beforeAll(async ({ browser }) => { page = await browser.newPage(); - loginPageFixture = new LoginPage(page, productUrl); - await loginPageFixture.goto(); - - await loginPageFixture.login(user.email, user.password); - - const response = await page.waitForResponse('**/me'); - const meData = await response.json(); - expect(meData["data"]["role"]).toBe(user.role); + const role = await loginPageFixture.login(user.email, user.password); + expect(role).toBe(user.role); }); test.afterAll(async ({ browser }) => { @@ -51,4 +45,18 @@ test.describe.serial(`User Menu Windows: ${product}`, () => { // close window await page.getByTestId("organizationsWindowCloseBtn").click(); }); + + test(`License pop up`, async () => { + // open user menu + await page.getByTestId("userMenuBtn").click(); + + // open license in new tab + await page.getByTestId("userMenuLicenseBtn").click(); + const newTabPromise = page.waitForEvent("popup"); + const newTab = await newTabPromise; + await newTab.waitForLoadState(); + + // close tab + await newTab.close(); + }); }); diff --git a/tests/e2e/utils/auto.js b/tests/e2e/utils/auto.js index 8c7a45e3d44..3a745adaccd 100644 --- a/tests/e2e/utils/auto.js +++ b/tests/e2e/utils/auto.js @@ -334,7 +334,7 @@ async function openNode(page, pos) { } const nodeId = children[pos]; - const childId = '[osparc-test-more="' + nodeId + '"]'; + const childId = '[osparc-test-key="' + nodeId + '"]'; await utils.waitAndClick(page, childId); return nodeId; diff --git a/tests/e2e/utils/utils.js b/tests/e2e/utils/utils.js index c2d976dad97..43226419615 100644 --- a/tests/e2e/utils/utils.js +++ b/tests/e2e/utils/utils.js @@ -231,7 +231,7 @@ async function getNodeTreeItemIDs(page) { const children = []; const nodeTreeItems = document.querySelectorAll(selector); nodeTreeItems.forEach(nodeTreeItem => { - const nodeId = nodeTreeItem.getAttribute("osparc-test-more") + const nodeId = nodeTreeItem.getAttribute("osparc-test-key") if (nodeId !== "root") { children.push(nodeId); }