diff --git a/packages/models-library/src/models_library/api_schemas_directorv2/dynamic_services_service.py b/packages/models-library/src/models_library/api_schemas_directorv2/dynamic_services_service.py index bbb57a4d8a14..6dddfa748561 100644 --- a/packages/models-library/src/models_library/api_schemas_directorv2/dynamic_services_service.py +++ b/packages/models-library/src/models_library/api_schemas_directorv2/dynamic_services_service.py @@ -1,5 +1,6 @@ from functools import cached_property from pathlib import Path +from typing import Annotated from pydantic import BaseModel, ConfigDict, Field from pydantic.config import JsonDict @@ -89,6 +90,11 @@ class RunningDynamicServiceDetails(ServiceDetails): alias="service_message", ) + is_collaborative: Annotated[ + bool, + Field(description="True if service allows collaboration (multi-tenant access)"), + ] = False + @staticmethod def _update_json_schema_extra(schema: JsonDict) -> None: schema.update( diff --git a/packages/models-library/tests/test_docker.py b/packages/models-library/tests/test_docker.py index bcbbff12c360..3fc8f5ba3df4 100644 --- a/packages/models-library/tests/test_docker.py +++ b/packages/models-library/tests/test_docker.py @@ -112,9 +112,9 @@ def test_docker_generic_tag(image_name: str, valid: bool): def test_simcore_service_docker_label_keys(obj_data: dict[str, Any]): simcore_service_docker_label_keys = SimcoreContainerLabels.model_validate(obj_data) exported_dict = simcore_service_docker_label_keys.to_simcore_runtime_docker_labels() - assert all( - isinstance(v, str) for v in exported_dict.values() - ), "docker labels must be strings!" + assert all(isinstance(v, str) for v in exported_dict.values()), ( + "docker labels must be strings!" + ) assert all( key.startswith(_SIMCORE_RUNTIME_DOCKER_LABEL_PREFIX) for key in exported_dict ) diff --git a/services/autoscaling/tests/unit/conftest.py b/services/autoscaling/tests/unit/conftest.py index 74c2a0bedc0e..3a2f89d7ab2d 100644 --- a/services/autoscaling/tests/unit/conftest.py +++ b/services/autoscaling/tests/unit/conftest.py @@ -756,9 +756,9 @@ async def _() -> None: assert tasks, f"no tasks available for {found_service['Spec']['Name']}" assert len(tasks) == 1 service_task = tasks[0] - assert ( - service_task["Status"]["State"] in expected_states - ), f"service {found_service['Spec']['Name']}'s task is {service_task['Status']['State']}" + assert service_task["Status"]["State"] in expected_states, ( + f"service {found_service['Spec']['Name']}'s task is {service_task['Status']['State']}" + ) ctx.logger.info( "%s", f"service {found_service['Spec']['Name']} is now {service_task['Status']['State']} {'.' * number_of_success['count']}", @@ -985,7 +985,9 @@ def _creator( assert ( datetime.timedelta(seconds=10) < app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_TIME_BEFORE_TERMINATION - ), "this tests relies on the fact that the time before termination is above 10 seconds" + ), ( + "this tests relies on the fact that the time before termination is above 10 seconds" + ) assert app_settings.AUTOSCALING_EC2_INSTANCES seconds_delta = ( -datetime.timedelta(seconds=10) @@ -1126,12 +1128,12 @@ def ec2_instances_allowed_types_with_only_1_buffered( allowed_ec2_types.items(), ) ) - assert ( - allowed_ec2_types_with_buffer_defined - ), "one type with buffer is needed for the tests!" - assert ( - len(allowed_ec2_types_with_buffer_defined) == 1 - ), "more than one type with buffer is disallowed in this test!" + assert allowed_ec2_types_with_buffer_defined, ( + "one type with buffer is needed for the tests!" + ) + assert len(allowed_ec2_types_with_buffer_defined) == 1, ( + "more than one type with buffer is disallowed in this test!" + ) return { TypeAdapter(InstanceTypeType).validate_python(k): v for k, v in allowed_ec2_types_with_buffer_defined.items() @@ -1155,9 +1157,9 @@ def _by_buffer_count( filter(_by_buffer_count, allowed_ec2_types.items()) ) assert allowed_ec2_types_with_buffer_defined, "you need one type with buffer" - assert ( - len(allowed_ec2_types_with_buffer_defined) == 1 - ), "more than one type with buffer is disallowed in this test!" + assert len(allowed_ec2_types_with_buffer_defined) == 1, ( + "more than one type with buffer is disallowed in this test!" + ) return next(iter(allowed_ec2_types_with_buffer_defined.values())).buffer_count diff --git a/services/autoscaling/tests/unit/test_modules_cluster_scaling_dynamic.py b/services/autoscaling/tests/unit/test_modules_cluster_scaling_dynamic.py index c1108a54ffad..fea09027a191 100644 --- a/services/autoscaling/tests/unit/test_modules_cluster_scaling_dynamic.py +++ b/services/autoscaling/tests/unit/test_modules_cluster_scaling_dynamic.py @@ -522,9 +522,9 @@ async def _test_cluster_scaling_up_and_down( # noqa: PLR0915 all_instances = await ec2_client.describe_instances(Filters=instance_type_filters) assert not all_instances["Reservations"] - assert ( - scale_up_params.expected_num_instances == 1 - ), "This test is not made to work with more than 1 expected instance. so please adapt if needed" + assert scale_up_params.expected_num_instances == 1, ( + "This test is not made to work with more than 1 expected instance. so please adapt if needed" + ) # create the service(s) created_docker_services = await create_services_batch(scale_up_params) @@ -1283,7 +1283,9 @@ async def test_cluster_adapts_machines_on_the_fly( # noqa: PLR0915 assert ( scale_up_params1.num_services >= app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MAX_INSTANCES - ), "this test requires to run a first batch of more services than the maximum number of instances allowed" + ), ( + "this test requires to run a first batch of more services than the maximum number of instances allowed" + ) # we have nothing running now all_instances = await ec2_client.describe_instances() assert not all_instances["Reservations"] @@ -1500,7 +1502,9 @@ async def test_cluster_adapts_machines_on_the_fly( # noqa: PLR0915 assert "Instances" in reservation1 assert len(reservation1["Instances"]) == ( app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MAX_INSTANCES - ), f"expected {app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MAX_INSTANCES} EC2 instances, found {len(reservation1['Instances'])}" + ), ( + f"expected {app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MAX_INSTANCES} EC2 instances, found {len(reservation1['Instances'])}" + ) for instance in reservation1["Instances"]: assert "InstanceType" in instance assert instance["InstanceType"] == scale_up_params1.expected_instance_type @@ -1514,9 +1518,9 @@ async def test_cluster_adapts_machines_on_the_fly( # noqa: PLR0915 reservation2 = all_instances["Reservations"][1] assert "Instances" in reservation2 - assert ( - len(reservation2["Instances"]) == 1 - ), f"expected 1 EC2 instances, found {len(reservation2['Instances'])}" + assert len(reservation2["Instances"]) == 1, ( + f"expected 1 EC2 instances, found {len(reservation2['Instances'])}" + ) for instance in reservation2["Instances"]: assert "InstanceType" in instance assert instance["InstanceType"] == scale_up_params2.expected_instance_type @@ -2243,9 +2247,9 @@ async def test_warm_buffers_only_replace_hot_buffer_if_service_is_started_issue7 # BUG REPRODUCTION # # start a service that imposes same type as the hot buffer - assert ( - hot_buffer_instance_type == "t2.xlarge" - ), "the test is hard-coded for this type and accordingly resource. If this changed then the resource shall be changed too" + assert hot_buffer_instance_type == "t2.xlarge", ( + "the test is hard-coded for this type and accordingly resource. If this changed then the resource shall be changed too" + ) scale_up_params = _ScaleUpParams( imposed_instance_type=hot_buffer_instance_type, service_resources=Resources( diff --git a/services/director-v2/openapi.json b/services/director-v2/openapi.json index b1beee65c408..701fd9b5b544 100644 --- a/services/director-v2/openapi.json +++ b/services/director-v2/openapi.json @@ -2878,6 +2878,12 @@ ], "title": "Service Message", "description": "additional information related to service state" + }, + "is_collaborative": { + "type": "boolean", + "title": "Is Collaborative", + "description": "True if service allows collaboration (multi-tenant access)", + "default": false } }, "type": "object", diff --git a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/docker_compose_specs.py b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/docker_compose_specs.py index 9d3ec4f41a36..5366190f13b0 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/docker_compose_specs.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/docker_compose_specs.py @@ -296,9 +296,7 @@ async def assemble_spec( # pylint: disable=too-many-arguments # noqa: PLR0913 app.state.settings.DIRECTOR_V2_DOCKER_REGISTRY ) - docker_compose_version = ( - app.state.settings.DYNAMIC_SERVICES.DYNAMIC_SCHEDULER.DYNAMIC_SIDECAR_DOCKER_COMPOSE_VERSION - ) + docker_compose_version = app.state.settings.DYNAMIC_SERVICES.DYNAMIC_SCHEDULER.DYNAMIC_SIDECAR_DOCKER_COMPOSE_VERSION egress_proxy_settings: EgressProxySettings = ( app.state.settings.DYNAMIC_SERVICES.DYNAMIC_SIDECAR_EGRESS_PROXY_SETTINGS @@ -348,19 +346,21 @@ async def assemble_spec( # pylint: disable=too-many-arguments # noqa: PLR0913 service_version=service_version, product_name=product_name, ) - simcore_service_labels = await resolve_and_substitute_session_variables_in_model( - app=app, - model=simcore_service_labels, - # NOTE: at this point all OsparcIdentifiers have to be replaced - # an error will be raised otherwise - safe=False, - user_id=user_id, - product_name=product_name, - product_api_base_url=product_api_base_url, - project_id=project_id, - node_id=node_id, - service_run_id=service_run_id, - wallet_id=wallet_id, + simcore_service_labels = ( + await resolve_and_substitute_session_variables_in_model( + app=app, + model=simcore_service_labels, + # NOTE: at this point all OsparcIdentifiers have to be replaced + # an error will be raised otherwise + safe=False, + user_id=user_id, + product_name=product_name, + product_api_base_url=product_api_base_url, + project_id=project_id, + node_id=node_id, + service_run_id=service_run_id, + wallet_id=wallet_id, + ) ) add_egress_configuration( diff --git a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/docker_service_specs/proxy.py b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/docker_service_specs/proxy.py index df08de3192c0..50d0ad43072b 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/docker_service_specs/proxy.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/docker_service_specs/proxy.py @@ -35,9 +35,9 @@ def get_dynamic_proxy_spec( The proxy is used to create network isolation from the rest of the platform. """ - assert ( - scheduler_data.product_name is not None - ), "ONLY for legacy. This function should not be called with product_name==None" # nosec + assert scheduler_data.product_name is not None, ( + "ONLY for legacy. This function should not be called with product_name==None" + ) # nosec proxy_settings: DynamicSidecarProxySettings = ( dynamic_services_settings.DYNAMIC_SIDECAR_PROXY_SETTINGS diff --git a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/docker_service_specs/sidecar.py b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/docker_service_specs/sidecar.py index 001c66671502..b32d01c6522f 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/docker_service_specs/sidecar.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/docker_service_specs/sidecar.py @@ -458,9 +458,9 @@ async def get_dynamic_sidecar_spec( # pylint:disable=too-many-arguments# noqa: dynamic_sidecar_settings=dynamic_sidecar_settings, app_settings=app_settings ) - assert ( - scheduler_data.product_name is not None - ), "ONLY for legacy. This function should not be called with product_name==None" # nosec + assert scheduler_data.product_name is not None, ( + "ONLY for legacy. This function should not be called with product_name==None" + ) # nosec standard_simcore_docker_labels: dict[DockerLabelKey, str] = SimcoreContainerLabels( user_id=scheduler_data.user_id, diff --git a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_core/_scheduler_utils.py b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_core/_scheduler_utils.py index 3bbd06b7c5c0..2c524a4216d9 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_core/_scheduler_utils.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_core/_scheduler_utils.py @@ -88,6 +88,7 @@ def create_model_from_scheduler_data( "service_port": scheduler_data.service_port, "service_state": service_state.value, "service_message": service_message, + "is_collaborative": scheduler_data.is_collaborative, } ) diff --git a/services/director-v2/tests/unit/test_models_dynamic_services.py b/services/director-v2/tests/unit/test_models_dynamic_services.py index 99a22ece3bb7..6981b03c5bb5 100644 --- a/services/director-v2/tests/unit/test_models_dynamic_services.py +++ b/services/director-v2/tests/unit/test_models_dynamic_services.py @@ -153,6 +153,7 @@ def test_running_service_details_make_status( "service_host": scheduler_data.service_name, "user_id": scheduler_data.user_id, "service_port": scheduler_data.service_port, + "is_collaborative": scheduler_data.is_collaborative, } assert running_service_details_dict == expected_running_service_details diff --git a/services/dynamic-scheduler/openapi.json b/services/dynamic-scheduler/openapi.json index 9f6867c68728..8f234eaad54b 100644 --- a/services/dynamic-scheduler/openapi.json +++ b/services/dynamic-scheduler/openapi.json @@ -224,6 +224,12 @@ ], "title": "Service Message", "description": "additional information related to service state" + }, + "is_collaborative": { + "type": "boolean", + "title": "Is Collaborative", + "description": "True if service allows collaboration (multi-tenant access)", + "default": false } }, "type": "object", 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 bdecdf8d20bd..6ba4afa38b03 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 @@ -16915,6 +16915,11 @@ components: - type: 'null' title: Service Message description: additional information related to service state + is_collaborative: + type: boolean + title: Is Collaborative + description: True if service allows collaboration (multi-tenant access) + default: false type: object required: - service_key diff --git a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py index 024b42f7f665..83336ddec8a0 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py +++ b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py @@ -162,9 +162,7 @@ async def open_project(request: web.Request) -> web.Response: # notify users that project is now opened project = await _projects_service.add_project_states_for_user( - user_id=req_ctx.user_id, - project=project, - app=request.app, + user_id=req_ctx.user_id, project=project, app=request.app ) await _projects_service.notify_project_state_update(request.app, project) diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py b/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py index cff404046c93..dbe61e993159 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py @@ -430,9 +430,7 @@ async def create_project( # pylint: disable=too-many-arguments,too-many-branche ) # Appends state new_project = await _projects_service.add_project_states_for_user( - user_id=user_id, - project=new_project, - app=request.app, + user_id=user_id, project=new_project, app=request.app ) await progress.update() diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py b/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py index 6bc5e29f7686..de3c36898e23 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py @@ -73,9 +73,7 @@ async def _aggregate_data_to_projects_from_other_sources( # udpating `project.state` update_state_per_project = [ _projects_service.add_project_states_for_user( - user_id=user_id, - project=prj, - app=app, + user_id=user_id, project=prj, app=app ) for prj in db_projects ] diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py index c3490abbe844..06bdcdee33c5 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py @@ -278,9 +278,7 @@ async def get_project_for_user( # adds state if it is not a template if include_state: project = await add_project_states_for_user( - user_id=user_id, - project=project, - app=app, + user_id=user_id, project=project, app=app ) # adds `trashed_by_primary_gid` @@ -1367,22 +1365,31 @@ async def is_node_id_present_in_any_project_workbench( async def _get_node_share_state( - app: web.Application, *, user_id: UserID, project_uuid: ProjectID, node_id: NodeID + app: web.Application, + *, + user_id: UserID, + project_uuid: ProjectID, + node_id: NodeID, ) -> NodeShareState: node = await _projects_nodes_repository.get( app, project_id=project_uuid, node_id=node_id ) if _is_node_dynamic(node.key): - # if the service is dynamic and running it is locked + # if the service is dynamic and running it is locked if it is not collaborative service = await dynamic_scheduler_service.get_dynamic_service( app, node_id=node_id ) if isinstance(service, DynamicServiceGet | NodeGet): # service is running + is_collaborative_service = False + if isinstance(service, DynamicServiceGet): + # only dynamic-sidecar powered services can be collaborative + is_collaborative_service = service.is_collaborative + return NodeShareState( - locked=True, + locked=not is_collaborative_service, current_user_groupids=[ await users_service.get_user_primary_group_id( app, TypeAdapter(UserID).validate_python(service.user_id)