From ef089081db70ca3c08d89a826e984fb7a35021f1 Mon Sep 17 00:00:00 2001 From: Andrei Neagu <5694077+GitHK@users.noreply.github.com> Date: Wed, 25 Oct 2023 07:26:17 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20allows=20frontend=20to=20check=20if?= =?UTF-8?q?=20a=20project=20is=20inactive=20(#4895)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Andrei Neagu --- api/specs/web-server/_projects_crud.py | 14 ++ .../dynamic_services.py | 4 + .../api_schemas_dynamic_sidecar/__init__.py | 0 .../api_schemas_dynamic_sidecar/containers.py | 9 ++ .../src/models_library/callbacks_mapping.py | 37 ++++- ..._api_schemas_dynamic_sidecar_containers.py | 16 ++ .../tests/test_callbacks_mapping.py | 27 ++++ services/director-v2/openapi.json | 139 ++++++++++++++++++ .../api/routes/dynamic_services.py | 116 ++++++++++----- .../dynamic_sidecar/api_client/_public.py | 9 ++ .../dynamic_sidecar/api_client/_thin.py | 9 ++ .../scheduler/_core/_scheduler.py | 8 + .../dynamic_sidecar/scheduler/_task.py | 4 + ...dules_dynamic_sidecar_client_api_public.py | 18 +++ ...modules_dynamic_sidecar_client_api_thin.py | 17 +++ .../test_api_route_dynamic_services.py | 116 +++++++++++++++ ...es_dynamic_sidecar_docker_service_specs.py | 2 +- services/dynamic-sidecar/openapi.json | 32 ++++ .../api/containers.py | 87 ++++++++--- .../modules/long_running_tasksutils.py | 3 +- .../tests/unit/test_api_containers.py | 75 +++++++++- .../api/v0/openapi.yaml | 38 +++++ .../director_v2/_core_dynamic_services.py | 17 +++ .../director_v2/_handlers.py | 2 +- .../director_v2/api.py | 4 +- .../projects/_crud_handlers.py | 18 +++ .../projects/projects_api.py | 32 +++- .../users/_preferences_api.py | 2 +- .../users/_preferences_models.py | 12 +- .../users/preferences_api.py | 10 +- .../02/test_projects_crud_handlers.py | 52 ++++++- .../03/test_users__preferences_api.py | 2 + 32 files changed, 862 insertions(+), 69 deletions(-) create mode 100644 packages/models-library/src/models_library/api_schemas_dynamic_sidecar/__init__.py create mode 100644 packages/models-library/src/models_library/api_schemas_dynamic_sidecar/containers.py create mode 100644 packages/models-library/tests/test_api_schemas_dynamic_sidecar_containers.py create mode 100644 packages/models-library/tests/test_callbacks_mapping.py diff --git a/api/specs/web-server/_projects_crud.py b/api/specs/web-server/_projects_crud.py index ec09ff1208f..64f1a9395b6 100644 --- a/api/specs/web-server/_projects_crud.py +++ b/api/specs/web-server/_projects_crud.py @@ -12,6 +12,9 @@ from typing import Annotated from fastapi import APIRouter, Depends, status +from models_library.api_schemas_directorv2.dynamic_services import ( + GetProjectInactivityResponse, +) from models_library.api_schemas_long_running_tasks.tasks import TaskGet from models_library.api_schemas_webserver.projects import ( ProjectCopyOverride, @@ -109,3 +112,14 @@ async def clone_project( _params: Annotated[ProjectPathParams, Depends()], ): ... + + +@router.get( + "/projects/{project_id}/inactivity", + response_model=Envelope[GetProjectInactivityResponse], + status_code=status.HTTP_200_OK, +) +async def get_project_inactivity( + _params: Annotated[ProjectPathParams, Depends()], +): + ... diff --git a/packages/models-library/src/models_library/api_schemas_directorv2/dynamic_services.py b/packages/models-library/src/models_library/api_schemas_directorv2/dynamic_services.py index c7a5d3423fe..b718645701b 100644 --- a/packages/models-library/src/models_library/api_schemas_directorv2/dynamic_services.py +++ b/packages/models-library/src/models_library/api_schemas_directorv2/dynamic_services.py @@ -78,3 +78,7 @@ class Config: DynamicServiceGet: TypeAlias = RunningDynamicServiceDetails + + +class GetProjectInactivityResponse(BaseModel): + is_inactive: bool diff --git a/packages/models-library/src/models_library/api_schemas_dynamic_sidecar/__init__.py b/packages/models-library/src/models_library/api_schemas_dynamic_sidecar/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/models-library/src/models_library/api_schemas_dynamic_sidecar/containers.py b/packages/models-library/src/models_library/api_schemas_dynamic_sidecar/containers.py new file mode 100644 index 00000000000..657b6190410 --- /dev/null +++ b/packages/models-library/src/models_library/api_schemas_dynamic_sidecar/containers.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, NonNegativeFloat + + +class InactivityResponse(BaseModel): + seconds_inactive: NonNegativeFloat | None = None + + @property + def is_inactive(self) -> bool: + return self.seconds_inactive is not None diff --git a/packages/models-library/src/models_library/callbacks_mapping.py b/packages/models-library/src/models_library/callbacks_mapping.py index f3e70092656..9e4e88214ce 100644 --- a/packages/models-library/src/models_library/callbacks_mapping.py +++ b/packages/models-library/src/models_library/callbacks_mapping.py @@ -1,7 +1,10 @@ from collections.abc import Sequence -from typing import Any, ClassVar +from typing import Any, ClassVar, Final -from pydantic import BaseModel, Extra, Field, NonNegativeFloat +from pydantic import BaseModel, Extra, Field, NonNegativeFloat, validator + +INACTIVITY_TIMEOUT_CAP: Final[NonNegativeFloat] = 5 +TIMEOUT_MIN: Final[NonNegativeFloat] = 1 class UserServiceCommand(BaseModel): @@ -36,6 +39,28 @@ class CallbacksMapping(BaseModel): "user services are allowed" ), ) + inactivity: UserServiceCommand | None = Field( + None, + description=( + "command used to figure out for how much time the " + "user service(s) were inactive for" + ), + ) + + @validator("inactivity") + @classmethod + def ensure_inactivity_timeout_is_capped( + cls, v: UserServiceCommand + ) -> UserServiceCommand: + if v is not None and ( + v.timeout < TIMEOUT_MIN or v.timeout > INACTIVITY_TIMEOUT_CAP + ): + msg = ( + f"Constraint not respected for inactivity timeout={v.timeout}: " + f"interval=({TIMEOUT_MIN}, {INACTIVITY_TIMEOUT_CAP})" + ) + raise ValueError(msg) + return v class Config: extra = Extra.forbid @@ -56,5 +81,13 @@ class Config: UserServiceCommand.Config.schema_extra["examples"][1], ], }, + { + "metrics": UserServiceCommand.Config.schema_extra["examples"][0], + "before_shutdown": [ + UserServiceCommand.Config.schema_extra["examples"][0], + UserServiceCommand.Config.schema_extra["examples"][1], + ], + "inactivity": UserServiceCommand.Config.schema_extra["examples"][0], + }, ] } diff --git a/packages/models-library/tests/test_api_schemas_dynamic_sidecar_containers.py b/packages/models-library/tests/test_api_schemas_dynamic_sidecar_containers.py new file mode 100644 index 00000000000..35a5940c682 --- /dev/null +++ b/packages/models-library/tests/test_api_schemas_dynamic_sidecar_containers.py @@ -0,0 +1,16 @@ +from typing import Any + +import pytest +from models_library.api_schemas_dynamic_sidecar.containers import InactivityResponse + + +@pytest.mark.parametrize( + "data, is_inactive", + [ + pytest.param({"seconds_inactive": None}, False), + pytest.param({"seconds_inactive": 0}, True), + pytest.param({"seconds_inactive": 100}, True), + ], +) +def test_expected(data: dict[str, Any], is_inactive: bool): + assert InactivityResponse.parse_obj(data).is_inactive == is_inactive diff --git a/packages/models-library/tests/test_callbacks_mapping.py b/packages/models-library/tests/test_callbacks_mapping.py new file mode 100644 index 00000000000..e1c0df003c6 --- /dev/null +++ b/packages/models-library/tests/test_callbacks_mapping.py @@ -0,0 +1,27 @@ +from typing import Any + +import pytest +from models_library.callbacks_mapping import ( + INACTIVITY_TIMEOUT_CAP, + TIMEOUT_MIN, + CallbacksMapping, +) +from pydantic import ValidationError, parse_obj_as + + +def _format_with_timeout(timeout: float) -> dict[str, Any]: + return {"inactivity": {"service": "a-service", "command": "", "timeout": timeout}} + + +def test_inactivity_time_out_is_max_capped(): + for in_bounds in [ + TIMEOUT_MIN, + TIMEOUT_MIN + 1, + INACTIVITY_TIMEOUT_CAP - 1, + INACTIVITY_TIMEOUT_CAP, + ]: + parse_obj_as(CallbacksMapping, _format_with_timeout(in_bounds)) + + for out_of_bounds in [INACTIVITY_TIMEOUT_CAP + 1, TIMEOUT_MIN - 1]: + with pytest.raises(ValidationError): + parse_obj_as(CallbacksMapping, _format_with_timeout(out_of_bounds)) diff --git a/services/director-v2/openapi.json b/services/director-v2/openapi.json index f4564345aad..8fd4a958a0b 100644 --- a/services/director-v2/openapi.json +++ b/services/director-v2/openapi.json @@ -714,6 +714,59 @@ } } }, + "/v2/dynamic_services/projects/{project_id}/inactivity": { + "get": { + "tags": [ + "dynamic services" + ], + "summary": "returns if the project is inactive", + "operationId": "get_project_inactivity_v2_dynamic_services_projects__project_id__inactivity_get", + "parameters": [ + { + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Project Id" + }, + "name": "project_id", + "in": "path" + }, + { + "required": true, + "schema": { + "type": "number", + "minimum": 0.0, + "title": "Max Inactivity Seconds" + }, + "name": "max_inactivity_seconds", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetProjectInactivityResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/v2/clusters": { "get": { "tags": [ @@ -2011,6 +2064,24 @@ ], "title": "Wallet Info", "description": "contains information about the wallet used to bill the running service" + }, + "pricing_info": { + "allOf": [ + { + "$ref": "#/components/schemas/PricingInfo" + } + ], + "title": "Pricing Info", + "description": "contains pricing information (ex. pricing plan and unit ids)" + }, + "hardware_info": { + "allOf": [ + { + "$ref": "#/components/schemas/HardwareInfo" + } + ], + "title": "Hardware Info", + "description": "contains harware information (ex. aws_ec2_instances)" } }, "type": "object", @@ -2055,9 +2126,32 @@ "wallet_info": { "wallet_id": 1, "wallet_name": "My Wallet" + }, + "pricing_info": { + "pricing_plan_id": 1, + "pricing_unit_id": 1, + "pricing_unit_cost_id": 1 + }, + "hardware_info": { + "aws_ec2_instances": [ + "c6a.4xlarge" + ] } } }, + "GetProjectInactivityResponse": { + "properties": { + "is_inactive": { + "type": "boolean", + "title": "Is Inactive" + } + }, + "type": "object", + "required": [ + "is_inactive" + ], + "title": "GetProjectInactivityResponse" + }, "HTTPValidationError": { "properties": { "errors": { @@ -2071,6 +2165,22 @@ "type": "object", "title": "HTTPValidationError" }, + "HardwareInfo": { + "properties": { + "aws_ec2_instances": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Aws Ec2 Instances" + } + }, + "type": "object", + "required": [ + "aws_ec2_instances" + ], + "title": "HardwareInfo" + }, "HealthCheckGet": { "properties": { "timestamp": { @@ -2324,6 +2434,35 @@ ], "title": "PipelineDetails" }, + "PricingInfo": { + "properties": { + "pricing_plan_id": { + "type": "integer", + "exclusiveMinimum": true, + "title": "Pricing Plan Id", + "minimum": 0 + }, + "pricing_unit_id": { + "type": "integer", + "exclusiveMinimum": true, + "title": "Pricing Unit Id", + "minimum": 0 + }, + "pricing_unit_cost_id": { + "type": "integer", + "exclusiveMinimum": true, + "title": "Pricing Unit Cost Id", + "minimum": 0 + } + }, + "type": "object", + "required": [ + "pricing_plan_id", + "pricing_unit_id", + "pricing_unit_cost_id" + ], + "title": "PricingInfo" + }, "ResourceValue": { "properties": { "limit": { diff --git a/services/director-v2/src/simcore_service_director_v2/api/routes/dynamic_services.py b/services/director-v2/src/simcore_service_director_v2/api/routes/dynamic_services.py index 371ba7cc387..ab5cbdee89a 100644 --- a/services/director-v2/src/simcore_service_director_v2/api/routes/dynamic_services.py +++ b/services/director-v2/src/simcore_service_director_v2/api/routes/dynamic_services.py @@ -1,7 +1,7 @@ import asyncio import logging from collections.abc import Coroutine -from typing import cast +from typing import Annotated, cast import httpx from fastapi import APIRouter, Depends, Header, HTTPException, Request @@ -9,18 +9,22 @@ from models_library.api_schemas_directorv2.dynamic_services import ( DynamicServiceCreate, DynamicServiceGet, + GetProjectInactivityResponse, RetrieveDataIn, RetrieveDataOutEnveloped, ) -from models_library.projects import ProjectID +from models_library.api_schemas_dynamic_sidecar.containers import InactivityResponse +from models_library.projects import ProjectAtDB, ProjectID from models_library.projects_nodes import NodeID from models_library.service_settings_labels import SimcoreServiceLabels from models_library.services import ServiceKeyVersion from models_library.users import UserID +from pydantic import NonNegativeFloat from servicelib.fastapi.requests_decorators import cancel_on_disconnect from servicelib.json_serialization import json_dumps from servicelib.logging_utils import log_decorator from servicelib.rabbitmq import RabbitMQClient +from servicelib.utils import logged_gather from starlette import status from starlette.datastructures import URL from tenacity import RetryCallState, TryAgain @@ -67,10 +71,10 @@ ), ) async def list_tracked_dynamic_services( + director_v0_client: Annotated[DirectorV0Client, Depends(get_director_v0_client)], + scheduler: Annotated[DynamicSidecarsScheduler, Depends(get_scheduler)], user_id: UserID | None = None, project_id: ProjectID | None = None, - director_v0_client: DirectorV0Client = Depends(get_director_v0_client), - scheduler: DynamicSidecarsScheduler = Depends(get_scheduler), ) -> list[DynamicServiceGet]: legacy_running_services: list[DynamicServiceGet] = cast( list[DynamicServiceGet], @@ -101,14 +105,14 @@ async def list_tracked_dynamic_services( @log_decorator(logger=logger) async def create_dynamic_service( service: DynamicServiceCreate, + director_v0_client: Annotated[DirectorV0Client, Depends(get_director_v0_client)], + dynamic_services_settings: Annotated[ + DynamicServicesSettings, Depends(get_dynamic_services_settings) + ], + scheduler: Annotated[DynamicSidecarsScheduler, Depends(get_scheduler)], x_dynamic_sidecar_request_dns: str = Header(...), x_dynamic_sidecar_request_scheme: str = Header(...), x_simcore_user_agent: str = Header(...), - director_v0_client: DirectorV0Client = Depends(get_director_v0_client), - dynamic_services_settings: DynamicServicesSettings = Depends( - get_dynamic_services_settings - ), - scheduler: DynamicSidecarsScheduler = Depends(get_scheduler), ) -> DynamicServiceGet | RedirectResponse: simcore_service_labels: SimcoreServiceLabels = ( await director_v0_client.get_service_labels( @@ -157,8 +161,8 @@ async def create_dynamic_service( ) async def get_dynamic_sidecar_status( node_uuid: NodeID, - director_v0_client: DirectorV0Client = Depends(get_director_v0_client), - scheduler: DynamicSidecarsScheduler = Depends(get_scheduler), + director_v0_client: Annotated[DirectorV0Client, Depends(get_director_v0_client)], + scheduler: Annotated[DynamicSidecarsScheduler, Depends(get_scheduler)], ) -> DynamicServiceGet | RedirectResponse: try: return await scheduler.get_stack_status(node_uuid) @@ -182,12 +186,13 @@ async def get_dynamic_sidecar_status( async def stop_dynamic_service( request: Request, node_uuid: NodeID, + director_v0_client: Annotated[DirectorV0Client, Depends(get_director_v0_client)], + scheduler: Annotated[DynamicSidecarsScheduler, Depends(get_scheduler)], + dynamic_services_settings: Annotated[ + DynamicServicesSettings, Depends(get_dynamic_services_settings) + ], + *, can_save: bool | None = True, - director_v0_client: DirectorV0Client = Depends(get_director_v0_client), - scheduler: DynamicSidecarsScheduler = Depends(get_scheduler), - dynamic_services_settings: DynamicServicesSettings = Depends( - get_dynamic_services_settings - ), ) -> NoContentResponse | RedirectResponse: assert request # nosec @@ -213,7 +218,6 @@ async def stop_dynamic_service( dynamic_sidecar_settings: DynamicSidecarSettings = ( dynamic_services_settings.DYNAMIC_SIDECAR ) - _STOPPED_CHECK_INTERVAL = 1.0 def _log_error(retry_state: RetryCallState): logger.error( @@ -223,7 +227,7 @@ def _log_error(retry_state: RetryCallState): ) async for attempt in AsyncRetrying( - wait=wait_fixed(_STOPPED_CHECK_INTERVAL), + wait=wait_fixed(1.0), stop=stop_after_delay( dynamic_sidecar_settings.DYNAMIC_SIDECAR_WAIT_FOR_SERVICE_TO_STOP ), @@ -248,12 +252,12 @@ def _log_error(retry_state: RetryCallState): async def service_retrieve_data_on_ports( node_uuid: NodeID, retrieve_settings: RetrieveDataIn, - scheduler: DynamicSidecarsScheduler = Depends(get_scheduler), - dynamic_services_settings: DynamicServicesSettings = Depends( - get_dynamic_services_settings - ), - director_v0_client: DirectorV0Client = Depends(get_director_v0_client), - services_client: ServicesClient = Depends(get_services_client), + scheduler: Annotated[DynamicSidecarsScheduler, Depends(get_scheduler)], + dynamic_services_settings: Annotated[ + DynamicServicesSettings, Depends(get_dynamic_services_settings) + ], + director_v0_client: Annotated[DirectorV0Client, Depends(get_director_v0_client)], + services_client: Annotated[ServicesClient, Depends(get_services_client)], ) -> RetrieveDataOutEnveloped: try: return await scheduler.retrieve_service_inputs( @@ -294,7 +298,8 @@ async def service_retrieve_data_on_ports( ) @log_decorator(logger=logger) async def service_restart_containers( - node_uuid: NodeID, scheduler: DynamicSidecarsScheduler = Depends(get_scheduler) + node_uuid: NodeID, + scheduler: Annotated[DynamicSidecarsScheduler, Depends(get_scheduler)], ) -> NoContentResponse: try: await scheduler.restart_containers(node_uuid) @@ -314,15 +319,15 @@ async def service_restart_containers( @log_decorator(logger=logger) async def update_projects_networks( project_id: ProjectID, - projects_networks_repository: ProjectsNetworksRepository = Depends( - get_repository(ProjectsNetworksRepository) - ), - projects_repository: ProjectsRepository = Depends( - get_repository(ProjectsRepository) - ), - scheduler: DynamicSidecarsScheduler = Depends(get_scheduler), - director_v0_client: DirectorV0Client = Depends(get_director_v0_client), - rabbitmq_client: RabbitMQClient = Depends(get_rabbitmq_client), + projects_networks_repository: Annotated[ + ProjectsNetworksRepository, Depends(get_repository(ProjectsNetworksRepository)) + ], + projects_repository: Annotated[ + ProjectsRepository, Depends(get_repository(ProjectsRepository)) + ], + scheduler: Annotated[DynamicSidecarsScheduler, Depends(get_scheduler)], + director_v0_client: Annotated[DirectorV0Client, Depends(get_director_v0_client)], + rabbitmq_client: Annotated[RabbitMQClient, Depends(get_rabbitmq_client)], ) -> None: await projects_networks.update_from_workbench( projects_networks_repository=projects_networks_repository, @@ -332,3 +337,46 @@ async def update_projects_networks( rabbitmq_client=rabbitmq_client, project_id=project_id, ) + + +@router.get( + "/projects/{project_id}/inactivity", summary="returns if the project is inactive" +) +@log_decorator(logger=logger) +async def get_project_inactivity( + project_id: ProjectID, + max_inactivity_seconds: NonNegativeFloat, + scheduler: Annotated[DynamicSidecarsScheduler, Depends(get_scheduler)], + projects_repository: Annotated[ + ProjectsRepository, Depends(get_repository(ProjectsRepository)) + ], +) -> GetProjectInactivityResponse: + project: ProjectAtDB = await projects_repository.get_project(project_id) + + inactivity_responses: list[InactivityResponse] = await logged_gather( + *[ + scheduler.get_service_inactivity(NodeID(node_id)) + for node_id in project.workbench + # NOTE: only new style services expose service inactivity information + # director-v2 only tracks internally new style services + if scheduler.is_service_tracked(NodeID(node_id)) + ] + ) + + # NOTE: if a service results in being active it means one of the following + # - it does not provide information about it's inactivity state + # - the service is actually used + inactive_services: list[InactivityResponse] = [ + r for r in inactivity_responses if r.is_inactive + ] + + # A project is considered inactive when all it's services are inactive for + # more than `max_inactivity_seconds`. + # A `service` which does not support the inactivity callback is considered + # inactive. + all_inactive_services_over_threshold = all( + r.seconds_inactive > max_inactivity_seconds for r in inactive_services + ) + return GetProjectInactivityResponse( + is_inactive=all_inactive_services_over_threshold + ) diff --git a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/api_client/_public.py b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/api_client/_public.py index b8202f4d6ea..fd431d9396f 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/api_client/_public.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/api_client/_public.py @@ -6,6 +6,7 @@ from fastapi import FastAPI, status from httpx import AsyncClient +from models_library.api_schemas_dynamic_sidecar.containers import InactivityResponse from models_library.basic_types import PortInt from models_library.projects import ProjectID from models_library.projects_networks import DockerNetworkAlias @@ -443,6 +444,14 @@ async def configure_proxy( ) await self._thin_client.proxy_config_load(proxy_endpoint, proxy_configuration) + async def get_service_inactivity( + self, dynamic_sidecar_endpoint: AnyHttpUrl + ) -> InactivityResponse: + response = await self._thin_client.get_containers_inactivity( + dynamic_sidecar_endpoint + ) + return InactivityResponse.parse_obj(response.json()) + def _get_proxy_configuration( entrypoint_container_name: str, service_port: PortInt diff --git a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/api_client/_thin.py b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/api_client/_thin.py index 8ef0492ea73..6af0b48416c 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/api_client/_thin.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/api_client/_thin.py @@ -254,3 +254,12 @@ async def proxy_config_load( ) -> Response: url = self._get_url(proxy_endpoint, "/load", no_api_version=True) return await self.client.post(url, json=proxy_configuration) + + @retry_on_errors + @expect_status(status.HTTP_200_OK) + async def get_containers_inactivity( + self, + dynamic_sidecar_endpoint: AnyHttpUrl, + ) -> Response: + url = self._get_url(dynamic_sidecar_endpoint, "/containers/inactivity") + return await self.client.get(url) diff --git a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_core/_scheduler.py b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_core/_scheduler.py index 5f2a983a072..781f16c6b34 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_core/_scheduler.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_core/_scheduler.py @@ -26,6 +26,7 @@ RetrieveDataOutEnveloped, RunningDynamicServiceDetails, ) +from models_library.api_schemas_dynamic_sidecar.containers import InactivityResponse from models_library.basic_types import PortInt from models_library.projects import ProjectID from models_library.projects_networks import DockerNetworkAlias @@ -472,6 +473,13 @@ async def restart_containers(self, node_uuid: NodeID) -> None: await sidecars_client.restart_containers(scheduler_data.endpoint) + async def get_service_inactivity(self, node_id: NodeID) -> InactivityResponse: + service_name: ServiceName = self._inverse_search_mapping[node_id] + scheduler_data: SchedulerData = self._to_observe[service_name] + + sidecars_client: SidecarsClient = get_sidecars_client(self.app, node_id) + return await sidecars_client.get_service_inactivity(scheduler_data.endpoint) + def _enqueue_observation_from_service_name(self, service_name: str) -> None: self._trigger_observation_queue.put_nowait(service_name) diff --git a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_task.py b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_task.py index 69c0906e0ee..e5876d2b449 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_task.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_task.py @@ -6,6 +6,7 @@ RetrieveDataOutEnveloped, RunningDynamicServiceDetails, ) +from models_library.api_schemas_dynamic_sidecar.containers import InactivityResponse from models_library.basic_types import PortInt from models_library.projects import ProjectID from models_library.projects_networks import DockerNetworkAlias @@ -126,6 +127,9 @@ async def detach_project_network( async def restart_containers(self, node_uuid: NodeID) -> None: return await self._scheduler.restart_containers(node_uuid) + async def get_service_inactivity(self, node_id: NodeID) -> InactivityResponse: + return await self._scheduler.get_service_inactivity(node_id) + async def setup_scheduler(app: FastAPI): settings: DynamicServicesSchedulerSettings = ( diff --git a/services/director-v2/tests/unit/test_modules_dynamic_sidecar_client_api_public.py b/services/director-v2/tests/unit/test_modules_dynamic_sidecar_client_api_public.py index 50410c6d156..258644157a7 100644 --- a/services/director-v2/tests/unit/test_modules_dynamic_sidecar_client_api_public.py +++ b/services/director-v2/tests/unit/test_modules_dynamic_sidecar_client_api_public.py @@ -346,3 +346,21 @@ async def test_update_volume_state( ) is None ) + + +@pytest.mark.parametrize( + "mock_json", + [{"seconds_inactive": 1}, {"seconds_inactive": None}], +) +async def test_get_service_inactivity( + get_patched_client: Callable, + dynamic_sidecar_endpoint: AnyHttpUrl, + mock_json: dict[str, Any], +) -> None: + with get_patched_client( + "get_containers_inactivity", + return_value=Response(status_code=status.HTTP_200_OK, json=mock_json), + ) as client: + assert ( + await client.get_service_inactivity(dynamic_sidecar_endpoint) == mock_json + ) diff --git a/services/director-v2/tests/unit/test_modules_dynamic_sidecar_client_api_thin.py b/services/director-v2/tests/unit/test_modules_dynamic_sidecar_client_api_thin.py index d1df9083312..98f957268fb 100644 --- a/services/director-v2/tests/unit/test_modules_dynamic_sidecar_client_api_thin.py +++ b/services/director-v2/tests/unit/test_modules_dynamic_sidecar_client_api_thin.py @@ -344,3 +344,20 @@ async def test_post_containers_tasks( thin_client_handler = getattr(thin_client, handler_name) response = await thin_client_handler(dynamic_sidecar_endpoint, **extra_kwargs) assert_responses(mock_response, response) + + +async def test_get_containers_inactivity( + thin_client: ThinSidecarsClient, + dynamic_sidecar_endpoint: AnyHttpUrl, + mock_request: MockRequestType, +) -> None: + mock_response = Response(status.HTTP_200_OK, json={}) + mock_request( + "GET", + f"{dynamic_sidecar_endpoint}/{thin_client.API_VERSION}/containers/inactivity", + mock_response, + None, + ) + + response = await thin_client.get_containers_inactivity(dynamic_sidecar_endpoint) + assert_responses(mock_response, response) diff --git a/services/director-v2/tests/unit/with_dbs/test_api_route_dynamic_services.py b/services/director-v2/tests/unit/with_dbs/test_api_route_dynamic_services.py index 1dc0eb96e59..29a011ac312 100644 --- a/services/director-v2/tests/unit/with_dbs/test_api_route_dynamic_services.py +++ b/services/director-v2/tests/unit/with_dbs/test_api_route_dynamic_services.py @@ -1,3 +1,4 @@ +# pylint: disable=no-self-use # pylint: disable=redefined-outer-name # pylint: disable=unused-argument # pylint: disable=unused-variable @@ -9,10 +10,12 @@ from collections.abc import AsyncIterator, Iterator from contextlib import asynccontextmanager from typing import Any, NamedTuple +from unittest.mock import Mock from uuid import UUID import pytest import respx +from faker import Faker from fastapi import FastAPI from httpx import URL, QueryParams from models_library.api_schemas_directorv2.dynamic_services import ( @@ -22,6 +25,8 @@ from models_library.api_schemas_directorv2.dynamic_services_service import ( RunningDynamicServiceDetails, ) +from models_library.api_schemas_dynamic_sidecar.containers import InactivityResponse +from models_library.projects import ProjectAtDB, ProjectID from models_library.projects_nodes_io import NodeID from models_library.service_settings_labels import SimcoreServiceLabels from pytest_mock.plugin import MockerFixture @@ -558,3 +563,114 @@ def test_retrieve( assert ( response.json() == RetrieveDataOutEnveloped.Config.schema_extra["examples"][0] ) + + +@pytest.fixture +def mock_internals_inactivity( + mocker: MockerFixture, faker: Faker, services_inactivity: list[InactivityResponse] +): + module_base = "simcore_service_director_v2.modules.dynamic_sidecar.scheduler" + mocker.patch( + f"{module_base}._core._scheduler_utils.get_dynamic_sidecars_to_observe", + return_value=[], + ) + + service_inactivity_map: dict[str, InactivityResponse] = { + faker.uuid4(): s for s in services_inactivity + } + + mock_project = Mock() + mock_project.workbench = list(service_inactivity_map.keys()) + + class MockProjectRepo: + async def get_project(self, _: ProjectID) -> ProjectAtDB: + return mock_project + + # patch get_project + mocker.patch( + "simcore_service_director_v2.api.dependencies.database.get_base_repository", + return_value=MockProjectRepo(), + ) + + async def get_service_inactivity(node_uuid: NodeID) -> list[InactivityResponse]: + return service_inactivity_map[f"{node_uuid}"] + + mocker.patch( + f"{module_base}.DynamicSidecarsScheduler.get_service_inactivity", + side_effect=get_service_inactivity, + ) + mocker.patch( + f"{module_base}.DynamicSidecarsScheduler.is_service_tracked", return_value=True + ) + + +@pytest.mark.parametrize( + "services_inactivity, max_inactivity_seconds, is_project_inactive", + [ + pytest.param( + [ + InactivityResponse(seconds_inactive=6), + ], + 5, + True, + id="single_new_style_is_inactive", + ), + pytest.param( + [ + InactivityResponse(seconds_inactive=6), + InactivityResponse(seconds_inactive=1), + ], + 5, + False, + id="one_inactive_one_still_not_overt_threshold", + ), + pytest.param( + [ + InactivityResponse(seconds_inactive=6), + InactivityResponse(seconds_inactive=6), + ], + 5, + True, + id="all_services_inactive", + ), + pytest.param( + [], + 5, + True, + id="no_services_in_project_it_results_inactive", + ), + pytest.param( + [InactivityResponse(seconds_inactive=None)], + 5, + True, + id="without_inactivity_support_considered_as_inactive", + ), + pytest.param( + [ + InactivityResponse(seconds_inactive=None), + InactivityResponse(seconds_inactive=6), + InactivityResponse(seconds_inactive=None), + InactivityResponse(seconds_inactive=6), + ], + 5, + True, + id="mixed_supporting_inactivity_are_inactive", + ), + ], +) +def test_get_project_inactivity( + mock_internals_inactivity: None, + mocker: MockerFixture, + client: TestClient, + is_project_inactive: bool, + max_inactivity_seconds: float, + faker: Faker, +): + url = URL(f"/v2/dynamic_services/projects/{faker.uuid4()}/inactivity") + response = client.get( + f"{url}", + params={"max_inactivity_seconds": max_inactivity_seconds}, + follow_redirects=False, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"is_inactive": is_project_inactive} diff --git a/services/director-v2/tests/unit/with_dbs/test_modules_dynamic_sidecar_docker_service_specs.py b/services/director-v2/tests/unit/with_dbs/test_modules_dynamic_sidecar_docker_service_specs.py index 00dc22f382c..dee464fccc0 100644 --- a/services/director-v2/tests/unit/with_dbs/test_modules_dynamic_sidecar_docker_service_specs.py +++ b/services/director-v2/tests/unit/with_dbs/test_modules_dynamic_sidecar_docker_service_specs.py @@ -207,7 +207,7 @@ def expected_dynamic_sidecar_spec( "DY_SIDECAR_CALLBACKS_MAPPING": ( '{"metrics": {"service": "rt-web", "command": "ls", "timeout": 1.0}, "before_shutdown"' ': [{"service": "rt-web", "command": "ls", "timeout": 1.0}, {"service": "s4l-core", ' - '"command": ["ls", "-lah"], "timeout": 1.0}]}' + '"command": ["ls", "-lah"], "timeout": 1.0}], "inactivity": null}' ), "DY_SIDECAR_SERVICE_KEY": "simcore/services/dynamic/3dviewer", "DY_SIDECAR_SERVICE_VERSION": "2.4.5", diff --git a/services/dynamic-sidecar/openapi.json b/services/dynamic-sidecar/openapi.json index 5b2e269316f..59621af9b14 100644 --- a/services/dynamic-sidecar/openapi.json +++ b/services/dynamic-sidecar/openapi.json @@ -157,6 +157,27 @@ } } }, + "/v1/containers/inactivity": { + "get": { + "tags": [ + "containers" + ], + "summary": "Get Containers Inactivity", + "operationId": "get_containers_inactivity_v1_containers_inactivity_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InactivityResponse" + } + } + } + } + } + } + }, "/v1/containers/{id}/logs": { "get": { "tags": [ @@ -1012,6 +1033,17 @@ } } }, + "InactivityResponse": { + "properties": { + "seconds_inactive": { + "type": "number", + "minimum": 0.0, + "title": "Seconds Inactive" + } + }, + "type": "object", + "title": "InactivityResponse" + }, "PatchPortsIOItem": { "properties": { "is_enabled": { diff --git a/services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/api/containers.py b/services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/api/containers.py index 00ad0c0d068..372e5f1d9a3 100644 --- a/services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/api/containers.py +++ b/services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/api/containers.py @@ -3,19 +3,28 @@ import json import logging from asyncio import Lock -from typing import Any +from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException from fastapi import Path as PathParam from fastapi import Query, Request, status +from models_library.api_schemas_dynamic_sidecar.containers import InactivityResponse +from pydantic import parse_raw_as from servicelib.fastapi.requests_decorators import cancel_on_disconnect from ..core.docker_utils import docker_client +from ..core.errors import ( + ContainerExecCommandFailedError, + ContainerExecContainerNotFoundError, + ContainerExecTimeoutError, +) +from ..core.settings import ApplicationSettings from ..core.validation import parse_compose_spec from ..models.shared_store import SharedStore -from ._dependencies import get_container_restart_lock, get_shared_store +from ..modules.container_utils import run_command_in_container +from ._dependencies import get_container_restart_lock, get_settings, get_shared_store -logger = logging.getLogger(__name__) +_logger = logging.getLogger(__name__) def _raise_if_container_is_missing( @@ -23,7 +32,7 @@ def _raise_if_container_is_missing( ) -> None: if container_id not in container_names: message = f"No container '{container_id}' was started. Started containers '{container_names}'" - logger.warning(message) + _logger.warning(message) raise HTTPException(status.HTTP_404_NOT_FOUND, detail=message) @@ -39,17 +48,17 @@ def _raise_if_container_is_missing( @cancel_on_disconnect async def containers_docker_inspect( request: Request, - only_status: bool = Query( - False, description="if True only show the status of the container" + shared_store: Annotated[SharedStore, Depends(get_shared_store)], + container_restart_lock: Annotated[Lock, Depends(get_container_restart_lock)], + only_status: bool = Query( # noqa: FBT001 + default=False, description="if True only show the status of the container" ), - shared_store: SharedStore = Depends(get_shared_store), - container_restart_lock: Lock = Depends(get_container_restart_lock), ) -> dict[str, Any]: """ Returns entire docker inspect data, if only_state is True, the status of the containers is returned """ - assert request # nosec + _ = request def _format_result(container_inspect: dict[str, Any]) -> dict[str, Any]: if only_status: @@ -76,6 +85,44 @@ def _format_result(container_inspect: dict[str, Any]) -> dict[str, Any]: return results +@router.get( + "/containers/inactivity", +) +@cancel_on_disconnect +async def get_containers_inactivity( + request: Request, + settings: Annotated[ApplicationSettings, Depends(get_settings)], + shared_store: Annotated[SharedStore, Depends(get_shared_store)], +) -> InactivityResponse: + _ = request + inactivity_command = settings.DY_SIDECAR_CALLBACKS_MAPPING.inactivity + if inactivity_command is None: + return InactivityResponse(seconds_inactive=None) + + container_name = inactivity_command.service + + try: + inactivity_response = await run_command_in_container( + shared_store.original_to_container_names[inactivity_command.service], + command=inactivity_command.command, + timeout=inactivity_command.timeout, + ) + return parse_raw_as(InactivityResponse, inactivity_response) + except ( + ContainerExecContainerNotFoundError, + ContainerExecCommandFailedError, + ContainerExecTimeoutError, + ): + _logger.warning( + "Could not run inactivity command '%s' in container '%s'", + inactivity_command.command, + container_name, + exc_info=True, + ) + + return InactivityResponse(seconds_inactive=None) + + # Some of the operations and sub-resources on containers are implemented as long-running tasks. # Handlers for these operations can be found in: # @@ -101,33 +148,33 @@ def _format_result(container_inspect: dict[str, Any]) -> dict[str, Any]: @cancel_on_disconnect async def get_container_logs( request: Request, + shared_store: Annotated[SharedStore, Depends(get_shared_store)], container_id: str = PathParam(..., alias="id"), since: int = Query( - 0, + default=0, title="Timestamp", description="Only return logs since this time, as a UNIX timestamp", ), until: int = Query( - 0, + default=0, title="Timestamp", description="Only return logs before this time, as a UNIX timestamp", ), - timestamps: bool = Query( - False, + timestamps: bool = Query( # noqa: FBT001 + default=False, title="Display timestamps", description="Enabling this parameter will include timestamps in logs", ), - shared_store: SharedStore = Depends(get_shared_store), ) -> list[str]: """Returns the logs of a given container if found""" - assert request # nosec + _ = request _raise_if_container_is_missing(container_id, shared_store.container_names) async with docker_client() as docker: container_instance = await docker.containers.get(container_id) - args = dict(stdout=True, stderr=True, since=since, until=until) + args = {"stdout": True, "stderr": True, "since": since, "until": until} if timestamps: args["timestamps"] = True @@ -149,6 +196,7 @@ async def get_container_logs( @cancel_on_disconnect async def get_containers_name( request: Request, + shared_store: Annotated[SharedStore, Depends(get_shared_store)], filters: str = Query( ..., description=( @@ -156,7 +204,6 @@ async def get_containers_name( "allow for dict as type in query parameters" ), ), - shared_store: SharedStore = Depends(get_shared_store), ) -> str | dict[str, Any]: """ Searches for the container's name given the network @@ -168,7 +215,7 @@ async def get_containers_name( exclude: matches if contained in the name of the container; `will exclude` containers """ - assert request # nosec + _ = request filters_dict: dict[str, str] = json.loads(filters) if not isinstance(filters_dict, dict): @@ -219,11 +266,11 @@ async def get_containers_name( @cancel_on_disconnect async def inspect_container( request: Request, + shared_store: Annotated[SharedStore, Depends(get_shared_store)], container_id: str = PathParam(..., alias="id"), - shared_store: SharedStore = Depends(get_shared_store), ) -> dict[str, Any]: """Returns information about the container, like docker inspect command""" - assert request # nosec + _ = request _raise_if_container_is_missing(container_id, shared_store.container_names) diff --git a/services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/modules/long_running_tasksutils.py b/services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/modules/long_running_tasksutils.py index 2a82ddd5236..d533ebc793d 100644 --- a/services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/modules/long_running_tasksutils.py +++ b/services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/modules/long_running_tasksutils.py @@ -35,7 +35,8 @@ async def run_before_shutdown_actions( ContainerExecTimeoutError, ): _logger.warning( - "Could not run before_shutdown in container %s", + "Could not run before_shutdown command %s in container %s", + user_service_command.command, container_name, exc_info=True, ) diff --git a/services/dynamic-sidecar/tests/unit/test_api_containers.py b/services/dynamic-sidecar/tests/unit/test_api_containers.py index c10297e1754..6a4b0d7a267 100644 --- a/services/dynamic-sidecar/tests/unit/test_api_containers.py +++ b/services/dynamic-sidecar/tests/unit/test_api_containers.py @@ -22,10 +22,11 @@ from async_asgi_testclient import TestClient from faker import Faker from fastapi import FastAPI, status +from models_library.api_schemas_dynamic_sidecar.containers import InactivityResponse from models_library.services import ServiceOutput from models_library.services_creation import CreateServiceMetricsAdditionalParams from pytest_mock.plugin import MockerFixture -from pytest_simcore.helpers.utils_envs import EnvVarsDict +from pytest_simcore.helpers.utils_envs import EnvVarsDict, setenvs_from_dict from servicelib.docker_constants import SUFFIX_EGRESS_PROXY_NAME from servicelib.fastapi.long_running_tasks.client import TaskId from simcore_service_dynamic_sidecar._meta import API_VTAG @@ -705,3 +706,75 @@ async def test_attach_detach_container_to_network( container_inspect = await container.show() networks = container_inspect["NetworkSettings"]["Networks"] assert network_id in networks + + +@pytest.fixture +def define_inactivity_command( + mock_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatch +) -> None: + setenvs_from_dict( + monkeypatch, + { + "DY_SIDECAR_CALLBACKS_MAPPING": json.dumps( + { + "inactivity": { + "service": "mock_container_name", + "command": "", + "timeout": 4, + } + } + ) + }, + ) + + +@pytest.fixture +def mock_shared_store(app: FastAPI) -> None: + shared_store: SharedStore = app.state.shared_store + shared_store.original_to_container_names[ + "mock_container_name" + ] = "mock_container_name" + + +async def test_containers_inactivity_command_failed( + define_inactivity_command: None, test_client: TestClient, mock_shared_store: None +): + response = await test_client.get(f"/{API_VTAG}/containers/inactivity") + assert response.status_code == 200, response.text + assert response.json() == InactivityResponse(seconds_inactive=None) + + +async def test_containers_inactivity_no_inactivity_defined( + test_client: TestClient, mock_shared_store: None +): + response = await test_client.get(f"/{API_VTAG}/containers/inactivity") + assert response.status_code == 200, response.text + assert response.json() == InactivityResponse(seconds_inactive=None) + + +@pytest.fixture +def inactivity_response() -> InactivityResponse: + return InactivityResponse(seconds_inactive=10) + + +@pytest.fixture +def mock_inactive_since_command_response( + mocker: MockerFixture, + inactivity_response: InactivityResponse, +) -> None: + mocker.patch( + "simcore_service_dynamic_sidecar.api.containers.run_command_in_container", + return_value=inactivity_response.json(), + ) + + +async def test_containers_inactivity_inactive_since( + define_inactivity_command: None, + mock_inactive_since_command_response: None, + test_client: TestClient, + mock_shared_store: None, + inactivity_response: InactivityResponse, +): + response = await test_client.get(f"/{API_VTAG}/containers/inactivity") + assert response.status_code == 200, response.text + assert response.json() == inactivity_response diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index 897a3a189da..0467caa979e 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -2268,6 +2268,27 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_TaskGet_' + /v0/projects/{project_id}/inactivity: + get: + tags: + - projects + summary: Get Project Inactivity + operationId: get_project_inactivity + parameters: + - required: true + schema: + title: Project Id + type: string + format: uuid + name: project_id + in: path + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_GetProjectInactivityResponse_' /v0/projects/{project_id}/metadata: get: tags: @@ -5318,6 +5339,14 @@ components: $ref: '#/components/schemas/GetProduct' error: title: Error + Envelope_GetProjectInactivityResponse_: + title: Envelope[GetProjectInactivityResponse] + type: object + properties: + data: + $ref: '#/components/schemas/GetProjectInactivityResponse' + error: + title: Error Envelope_GetWalletAutoRecharge_: title: Envelope[GetWalletAutoRecharge] type: object @@ -6392,6 +6421,15 @@ components: title: Creditsperusd minimum: 0.0 type: number + GetProjectInactivityResponse: + title: GetProjectInactivityResponse + required: + - is_inactive + type: object + properties: + is_inactive: + title: Is Inactive + type: boolean GetWalletAutoRecharge: title: GetWalletAutoRecharge required: diff --git a/services/web/server/src/simcore_service_webserver/director_v2/_core_dynamic_services.py b/services/web/server/src/simcore_service_webserver/director_v2/_core_dynamic_services.py index bbbdccb2a6b..c85ce95ca38 100644 --- a/services/web/server/src/simcore_service_webserver/director_v2/_core_dynamic_services.py +++ b/services/web/server/src/simcore_service_webserver/director_v2/_core_dynamic_services.py @@ -311,3 +311,20 @@ async def update_dynamic_service_networks_in_project( await request_director_v2( app, "PATCH", backend_url, expected_status=web.HTTPNoContent ) + + +@log_decorator(logger=_log) +async def get_project_inactivity( + app: web.Application, + project_id: ProjectID, + max_inactivity_seconds: NonNegativeFloat, +) -> DataType: + settings: DirectorV2Settings = get_plugin_settings(app) + backend_url = ( + URL(settings.base_url) / f"dynamic_services/projects/{project_id}/inactivity" + ).update_query(max_inactivity_seconds=max_inactivity_seconds) + result = await request_director_v2( + app, "GET", backend_url, expected_status=web.HTTPOk + ) + assert isinstance(result, dict) # nosec + return result diff --git a/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py b/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py index f97c2bdc07b..f4080126dd5 100644 --- a/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py @@ -114,7 +114,7 @@ async def start_computation(request: web.Request) -> web.Response: request.app, project_id=project_id ) if project_wallet is None: - user_default_wallet_preference = await user_preferences_api.get_user_preference( + user_default_wallet_preference = await user_preferences_api.get_frontend_user_preference( request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, diff --git a/services/web/server/src/simcore_service_webserver/director_v2/api.py b/services/web/server/src/simcore_service_webserver/director_v2/api.py index 71aaf6bb269..9ec5d090d4b 100644 --- a/services/web/server/src/simcore_service_webserver/director_v2/api.py +++ b/services/web/server/src/simcore_service_webserver/director_v2/api.py @@ -25,6 +25,7 @@ ) from ._core_dynamic_services import ( get_dynamic_service, + get_project_inactivity, list_dynamic_services, request_retrieve_dyn_service, restart_dynamic_service, @@ -55,11 +56,12 @@ "get_cluster", "get_computation_task", "get_dynamic_service", - "list_dynamic_services", + "get_project_inactivity", "get_project_run_policy", "is_healthy", "is_pipeline_running", "list_clusters", + "list_dynamic_services", "ping_cluster", "ping_specific_cluster", "request_retrieve_dyn_service", diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py index 6ac58ced36f..c5ca27fd21c 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py @@ -287,6 +287,24 @@ async def get_project(request: web.Request): ) from exc +@routes.get( + f"/{VTAG}/projects/{{project_id}}/inactivity", name="get_project_inactivity" +) +@login_required +@permission_required("project.read") +async def get_project_inactivity(request: web.Request): + req_ctx = RequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(ProjectPathParams, request) + + project_inactivity = await projects_api.get_project_inactivity( + app=request.app, + project_id=path_params.project_id, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + ) + return web.json_response({"data": project_inactivity}, dumps=json_dumps) + + # # - Update https://google.aip.dev/134 # diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_api.py b/services/web/server/src/simcore_service_webserver/projects/projects_api.py index f262d53c392..91778b6bf57 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_api.py @@ -19,7 +19,11 @@ from uuid import UUID, uuid4 from aiohttp import web +from models_library.api_schemas_directorv2.dynamic_services import ( + GetProjectInactivityResponse, +) from models_library.errors import ErrorDict +from models_library.generics import Envelope from models_library.projects import Project, ProjectID, ProjectIDStr from models_library.projects_nodes import Node from models_library.projects_nodes_io import NodeID, NodeIDStr @@ -83,7 +87,8 @@ from ..users.preferences_api import ( PreferredWalletIdFrontendUserPreference, UserDefaultWalletNotFoundError, - get_user_preference, + UserInactivityThresholdFrontendUserPreference, + get_frontend_user_preference, ) from ..wallets import api as wallets_api from ..wallets.errors import WalletNotEnoughCreditsError @@ -317,7 +322,7 @@ async def _start_dynamic_service( request.app, project_id=project_uuid ) if project_wallet is None: - user_default_wallet_preference = await get_user_preference( + user_default_wallet_preference = await get_frontend_user_preference( request.app, user_id=user_id, product_name=product_name, @@ -1387,3 +1392,26 @@ async def lock_with_notification( finally: if notify_users: await retrieve_and_notify_project_locked_state(user_id, project_uuid, app) + + +async def get_project_inactivity( + app: web.Application, project_id: ProjectID, user_id: UserID, product_name: str +) -> Envelope[GetProjectInactivityResponse]: + preference = await get_frontend_user_preference( + app, + user_id=user_id, + product_name=product_name, + preference_class=UserInactivityThresholdFrontendUserPreference, + ) + + # preference not present in the DB, use the default value + if preference is None: + preference = UserInactivityThresholdFrontendUserPreference() + + assert preference.value is not None # nosec + max_inactivity_seconds: int = preference.value + + project_inactivity = await director_v2_api.get_project_inactivity( + app, project_id, max_inactivity_seconds + ) + return parse_obj_as(Envelope[GetProjectInactivityResponse], project_inactivity) diff --git a/services/web/server/src/simcore_service_webserver/users/_preferences_api.py b/services/web/server/src/simcore_service_webserver/users/_preferences_api.py index d293c364875..44b3efb7e2e 100644 --- a/services/web/server/src/simcore_service_webserver/users/_preferences_api.py +++ b/services/web/server/src/simcore_service_webserver/users/_preferences_api.py @@ -57,7 +57,7 @@ async def _get_frontend_user_preferences( ] -async def get_user_preference( +async def get_frontend_user_preference( app: web.Application, user_id: UserID, product_name: ProductName, diff --git a/services/web/server/src/simcore_service_webserver/users/_preferences_models.py b/services/web/server/src/simcore_service_webserver/users/_preferences_models.py index 3dbf9b62621..79d6f00b270 100644 --- a/services/web/server/src/simcore_service_webserver/users/_preferences_models.py +++ b/services/web/server/src/simcore_service_webserver/users/_preferences_models.py @@ -1,9 +1,13 @@ +from typing import Final + from models_library.user_preferences import ( FrontendUserPreference, PreferenceIdentifier, PreferenceName, ) -from pydantic import Field +from pydantic import Field, NonNegativeInt + +_MINUTE: Final[NonNegativeInt] = 60 class ConfirmationBackToDashboardFrontendUserPreference(FrontendUserPreference): @@ -61,6 +65,11 @@ class PreferredWalletIdFrontendUserPreference(FrontendUserPreference): value: int | None = None +class UserInactivityThresholdFrontendUserPreference(FrontendUserPreference): + preference_identifier = "userInactivityThreshold" + value: int | None = 30 * _MINUTE # in seconds + + ALL_FRONTEND_PREFERENCES: list[type[FrontendUserPreference]] = [ ConfirmationBackToDashboardFrontendUserPreference, ConfirmationDeleteStudyFrontendUserPreference, @@ -73,6 +82,7 @@ class PreferredWalletIdFrontendUserPreference(FrontendUserPreference): ThemeNameFrontendUserPreference, LastVcsRefUIFrontendUserPreference, PreferredWalletIdFrontendUserPreference, + UserInactivityThresholdFrontendUserPreference, ] diff --git a/services/web/server/src/simcore_service_webserver/users/preferences_api.py b/services/web/server/src/simcore_service_webserver/users/preferences_api.py index ad1c5c71ffa..95057a982e7 100644 --- a/services/web/server/src/simcore_service_webserver/users/preferences_api.py +++ b/services/web/server/src/simcore_service_webserver/users/preferences_api.py @@ -1,10 +1,14 @@ -from ._preferences_api import get_user_preference, set_frontend_user_preference -from ._preferences_models import PreferredWalletIdFrontendUserPreference +from ._preferences_api import get_frontend_user_preference, set_frontend_user_preference +from ._preferences_models import ( + PreferredWalletIdFrontendUserPreference, + UserInactivityThresholdFrontendUserPreference, +) from .exceptions import UserDefaultWalletNotFoundError __all__ = ( - "get_user_preference", + "get_frontend_user_preference", "PreferredWalletIdFrontendUserPreference", "set_frontend_user_preference", "UserDefaultWalletNotFoundError", + "UserInactivityThresholdFrontendUserPreference", ) diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py index 3a4ce5a0ded..9e22ba4832d 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py @@ -4,8 +4,8 @@ # pylint: disable=unused-argument # pylint: disable=unused-variable - import random +import re import uuid as uuidlib from collections.abc import Awaitable, Callable, Iterator from copy import deepcopy @@ -717,3 +717,53 @@ async def test_replace_project_adding_or_removing_nodes_raises_conflict( random.choice(list(project_update["workbench"].keys())) # noqa: S311 ) await _replace_project(client, project_update, expected) + + +@pytest.fixture +def mock_director_v2_inactivity( + aioresponses_mocker: aioresponses, is_inactive: bool +) -> None: + get_services_pattern = re.compile( + r"^http://[a-z\-_]*director-v2:[0-9]+/v2/dynamic_services/.*/inactivity.*$" + ) + aioresponses_mocker.get( + get_services_pattern, + status=web.HTTPOk.status_code, + repeat=True, + payload={"data": {"is_inactive": is_inactive}}, + ) + + +@pytest.mark.parametrize( + "user_role,expected", + [ + (UserRole.ANONYMOUS, web.HTTPUnauthorized), + *((role, web.HTTPOk) for role in UserRole if role > UserRole.ANONYMOUS), + ], +) +@pytest.mark.parametrize("is_inactive", [True, False]) +async def test_get_project_inactivity( + mock_director_v2_inactivity: None, + logged_user: UserInfoDict, + client: TestClient, + faker: Faker, + user_role: UserRole, + expected: type[web.HTTPException], + is_inactive: bool, +): + mock_project_id = faker.uuid4() + + assert client.app + url = client.app.router["get_project_inactivity"].url_for( + project_id=mock_project_id + ) + assert f"/v0/projects/{mock_project_id}/inactivity" == url.path + response = await client.get(f"{url}") + data, error = await assert_status(response, expected) + if user_role == UserRole.ANONYMOUS: + return + + assert data + assert error is None + + assert data["data"]["is_inactive"] is is_inactive diff --git a/services/web/server/tests/unit/with_dbs/03/test_users__preferences_api.py b/services/web/server/tests/unit/with_dbs/03/test_users__preferences_api.py index 1dd79839f0a..9479a896b73 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users__preferences_api.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users__preferences_api.py @@ -81,6 +81,8 @@ def _get_non_default_value(model_class: type[BaseModel]) -> Any: return {**value, "non_default_key": "non_default_value"} if isinstance(value, list): return [*value, "non_default_value"] + if isinstance(value, int): + return value if value is None: if value_type == int: return 0