From 4af631a10648130d6ff61d859dd340e665a6046e Mon Sep 17 00:00:00 2001 From: Andrei Neagu Date: Sat, 5 Oct 2024 08:35:53 +0200 Subject: [PATCH 1/3] refactor tests --- .../tests/unit/api_rest/conftest.py | 13 ---------- .../unit/api_rest/test_api_rest__health.py | 24 ++++++++++++------- .../unit/api_rest/test_api_rest__meta.py | 18 +++++++++++++- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/services/dynamic-scheduler/tests/unit/api_rest/conftest.py b/services/dynamic-scheduler/tests/unit/api_rest/conftest.py index efef4241d98..e469e151125 100644 --- a/services/dynamic-scheduler/tests/unit/api_rest/conftest.py +++ b/services/dynamic-scheduler/tests/unit/api_rest/conftest.py @@ -9,19 +9,6 @@ from pytest_simcore.helpers.typing_env import EnvVarsDict -@pytest.fixture -def app_environment( - disable_rabbitmq_setup: None, - disable_redis_setup: None, - disable_service_tracker_setup: None, - disable_deferred_manager_setup: None, - disable_notifier_setup: None, - disable_status_monitor_setup: None, - app_environment: EnvVarsDict, -) -> EnvVarsDict: - return app_environment - - @pytest.fixture async def client( app_environment: EnvVarsDict, app: FastAPI diff --git a/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__health.py b/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__health.py index 9b5648e12b4..c38a3c78467 100644 --- a/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__health.py +++ b/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__health.py @@ -19,6 +19,21 @@ def __init__(self, is_ok: bool) -> None: self.is_healthy: bool = is_ok +@pytest.fixture +def app_environment( + disable_rabbitmq_setup: None, + disable_redis_setup: None, + disable_service_tracker_setup: None, + disable_deferred_manager_setup: None, + disable_notifier_setup: None, + disable_status_monitor_setup: None, + mock_rabbitmq_clients: None, + mock_redis_client: None, + app_environment: EnvVarsDict, +) -> EnvVarsDict: + return app_environment + + @pytest.fixture def mock_rabbitmq_clients( mocker: MockerFixture, @@ -48,15 +63,6 @@ def mock_redis_client( ) -@pytest.fixture -def app_environment( - mock_rabbitmq_clients: None, - mock_redis_client: None, - app_environment: EnvVarsDict, -) -> EnvVarsDict: - return app_environment - - @pytest.mark.parametrize( "rabbit_client_ok, rabbit_rpc_server_ok, redis_client_ok, is_ok", [ diff --git a/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__meta.py b/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__meta.py index 8d986dfe60e..07d463a3825 100644 --- a/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__meta.py +++ b/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__meta.py @@ -1,12 +1,28 @@ +import pytest + # pylint:disable=redefined-outer-name # pylint:disable=unused-argument from fastapi import status from httpx import AsyncClient +from pytest_simcore.helpers.typing_env import EnvVarsDict from simcore_service_dynamic_scheduler._meta import API_VTAG from simcore_service_dynamic_scheduler.models.schemas.meta import Meta -async def test_health(client: AsyncClient): +@pytest.fixture +def app_environment( + disable_rabbitmq_setup: None, + disable_redis_setup: None, + disable_service_tracker_setup: None, + disable_deferred_manager_setup: None, + disable_notifier_setup: None, + disable_status_monitor_setup: None, + app_environment: EnvVarsDict, +) -> EnvVarsDict: + return app_environment + + +async def test_meta(client: AsyncClient): response = await client.get(f"/{API_VTAG}/meta") assert response.status_code == status.HTTP_200_OK assert Meta.parse_raw(response.text) From 065da97e7eef025c3b75e0e3c4a86914cbb08a6b Mon Sep 17 00:00:00 2001 From: Andrei Neagu Date: Sat, 5 Oct 2024 08:36:13 +0200 Subject: [PATCH 2/3] fixed imports --- .../tests/unit/api_rest/test_api_rest__meta.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__meta.py b/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__meta.py index 07d463a3825..92169dd4e85 100644 --- a/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__meta.py +++ b/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__meta.py @@ -1,7 +1,7 @@ -import pytest - # pylint:disable=redefined-outer-name # pylint:disable=unused-argument + +import pytest from fastapi import status from httpx import AsyncClient from pytest_simcore.helpers.typing_env import EnvVarsDict From d1cb3a64ce90d523387c15f054bdba80bccbd2b5 Mon Sep 17 00:00:00 2001 From: Andrei Neagu Date: Sat, 5 Oct 2024 09:08:34 +0200 Subject: [PATCH 3/3] add API to manage internal state --- services/dynamic-scheduler/openapi.json | 551 +++++++++++++++++- .../api/rest/_services.py | 32 + .../api/rest/routes.py | 4 +- .../unit/api_rest/test_api_rest__services.py | 80 +++ 4 files changed, 665 insertions(+), 2 deletions(-) create mode 100644 services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/rest/_services.py create mode 100644 services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__services.py diff --git a/services/dynamic-scheduler/openapi.json b/services/dynamic-scheduler/openapi.json index b375bb8729e..0fe2dca78d8 100644 --- a/services/dynamic-scheduler/openapi.json +++ b/services/dynamic-scheduler/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "simcore-service-dynamic-scheduler web API", - "description": " Service that manages lifecycle of dynamic services", + "description": "Service that manages lifecycle of dynamic services", "version": "1.0.0" }, "paths": { @@ -44,10 +44,322 @@ } } } + }, + "/v1/services": { + "get": { + "tags": [ + "services" + ], + "summary": "List Services", + "operationId": "list_services_v1_services_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "$ref": "#/components/schemas/TrackedServiceModel" + }, + "type": "object", + "title": "Response List Services V1 Services Get" + } + } + } + } + } + } + }, + "/v1/services/{node_id}": { + "delete": { + "tags": [ + "services" + ], + "summary": "Remove Service", + "operationId": "remove_service_v1_services__node_id__delete", + "parameters": [ + { + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Node Id" + }, + "name": "node_id", + "in": "path" + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": { "schemas": { + "BootMode": { + "type": "string", + "enum": [ + "CPU", + "GPU", + "MPI" + ], + "title": "BootMode", + "description": "An enumeration." + }, + "DynamicServiceStart": { + "properties": { + "service_key": { + "type": "string", + "pattern": "^simcore/services/dynamic/([a-z0-9][a-z0-9_.-]*/)*([a-z0-9-_]+[a-z0-9])$", + "title": "Service Key", + "description": "distinctive name for the node based on the docker registry path" + }, + "service_version": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)(\\.(0|[1-9]\\d*)){2}(-(0|[1-9]\\d*|\\d*[-a-zA-Z][-\\da-zA-Z]*)(\\.(0|[1-9]\\d*|\\d*[-a-zA-Z][-\\da-zA-Z]*))*)?(\\+[-\\da-zA-Z]+(\\.[-\\da-zA-Z-]+)*)?$", + "title": "Service Version", + "description": "semantic version number of the node" + }, + "user_id": { + "type": "integer", + "exclusiveMinimum": true, + "title": "User Id", + "minimum": 0 + }, + "project_id": { + "type": "string", + "format": "uuid", + "title": "Project Id" + }, + "service_uuid": { + "type": "string", + "format": "uuid", + "title": "Service Uuid" + }, + "service_basepath": { + "type": "string", + "format": "path", + "title": "Service Basepath", + "description": "predefined path where the dynamic service should be served. If empty, the service shall use the root endpoint." + }, + "service_resources": { + "additionalProperties": { + "$ref": "#/components/schemas/ImageResources" + }, + "type": "object", + "title": "Service Resources" + }, + "product_name": { + "type": "string", + "title": "Product Name", + "description": "Current product name" + }, + "can_save": { + "type": "boolean", + "title": "Can Save", + "description": "the service data must be saved when closing" + }, + "wallet_info": { + "allOf": [ + { + "$ref": "#/components/schemas/WalletInfo" + } + ], + "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)" + }, + "request_dns": { + "type": "string", + "title": "Request Dns" + }, + "request_scheme": { + "type": "string", + "title": "Request Scheme" + }, + "simcore_user_agent": { + "type": "string", + "title": "Simcore User Agent" + } + }, + "type": "object", + "required": [ + "service_key", + "service_version", + "user_id", + "project_id", + "service_uuid", + "service_resources", + "product_name", + "can_save", + "request_dns", + "request_scheme", + "simcore_user_agent" + ], + "title": "DynamicServiceStart", + "example": { + "product_name": "osparc", + "can_save": true, + "user_id": 234, + "project_id": "dd1d04d9-d704-4f7e-8f0f-1ca60cc771fe", + "service_key": "simcore/services/dynamic/3dviewer", + "service_version": "2.4.5", + "service_uuid": "75c7f3f4-18f9-4678-8610-54a2ade78eaa", + "request_dns": "some.local", + "request_scheme": "http", + "simcore_user_agent": "", + "service_resources": { + "container": { + "image": "simcore/services/dynamic/jupyter-math:2.0.5", + "resources": { + "CPU": { + "limit": 0.1, + "reservation": 0.1 + }, + "RAM": { + "limit": 2147483648, + "reservation": 2147483648 + } + }, + "boot_modes": [ + "CPU" + ] + } + }, + "wallet_info": { + "wallet_id": 1, + "wallet_name": "My Wallet", + "wallet_credit_amount": 10 + }, + "pricing_info": { + "pricing_plan_id": 1, + "pricing_unit_id": 1, + "pricing_unit_cost_id": 1 + }, + "hardware_info": { + "aws_ec2_instances": [ + "c6a.4xlarge" + ] + } + } + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "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" + }, + "ImageResources": { + "properties": { + "image": { + "type": "string", + "pattern": "^(?:([a-z0-9-]+(?:\\.[a-z0-9-]+)+(?::\\d+)?|[a-z0-9-]+:\\d+)/)?((?:[a-z0-9][a-z0-9_.-]*/)*[a-z0-9-_]+[a-z0-9])(?::([\\w][\\w.-]{0,127}))?(\\@sha256:[a-fA-F0-9]{32,64})?$", + "title": "Image", + "description": "Used by the frontend to provide a context for the users.Services with a docker-compose spec will have multiple entries.Using the `image:version` instead of the docker-compose spec is more helpful for the end user." + }, + "resources": { + "additionalProperties": { + "$ref": "#/components/schemas/ResourceValue" + }, + "type": "object", + "title": "Resources" + }, + "boot_modes": { + "items": { + "$ref": "#/components/schemas/BootMode" + }, + "type": "array", + "description": "describe how a service shall be booted, using CPU, MPI, openMP or GPU", + "default": [ + "CPU" + ] + } + }, + "type": "object", + "required": [ + "image", + "resources" + ], + "title": "ImageResources", + "example": { + "image": "simcore/service/dynamic/pretty-intense:1.0.0", + "resources": { + "CPU": { + "limit": 4, + "reservation": 0.1 + }, + "RAM": { + "limit": 103079215104, + "reservation": 536870912 + }, + "VRAM": { + "limit": 1, + "reservation": 1 + }, + "AIRAM": { + "limit": 1, + "reservation": 1 + }, + "ANY_resource": { + "limit": "some_value", + "reservation": "some_value" + } + } + } + }, "Meta": { "properties": { "name": { @@ -59,6 +371,15 @@ "pattern": "^(0|[1-9]\\d*)(\\.(0|[1-9]\\d*)){2}(-(0|[1-9]\\d*|\\d*[-a-zA-Z][-\\da-zA-Z]*)(\\.(0|[1-9]\\d*|\\d*[-a-zA-Z][-\\da-zA-Z]*))*)?(\\+[-\\da-zA-Z]+(\\.[-\\da-zA-Z-]+)*)?$", "title": "Version" }, + "released": { + "additionalProperties": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)(\\.(0|[1-9]\\d*)){2}(-(0|[1-9]\\d*|\\d*[-a-zA-Z][-\\da-zA-Z]*)(\\.(0|[1-9]\\d*|\\d*[-a-zA-Z][-\\da-zA-Z]*))*)?(\\+[-\\da-zA-Z]+(\\.[-\\da-zA-Z-]+)*)?$" + }, + "type": "object", + "title": "Released", + "description": "Maps every route's path tag with a released version" + }, "docs_url": { "type": "string", "maxLength": 2083, @@ -79,6 +400,234 @@ "version": "2.4.45", "docs_url": "https://foo.io/doc" } + }, + "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": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "Limit" + }, + "reservation": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "Reservation" + } + }, + "type": "object", + "required": [ + "limit", + "reservation" + ], + "title": "ResourceValue" + }, + "SchedulerServiceState": { + "type": "string", + "enum": [ + "RUNNING", + "IDLE", + "UNEXPECTED_OUTCOME", + "STARTING", + "STOPPING", + "UNKNOWN" + ], + "title": "SchedulerServiceState", + "description": "An enumeration." + }, + "TrackedServiceModel": { + "properties": { + "dynamic_service_start": { + "allOf": [ + { + "$ref": "#/components/schemas/DynamicServiceStart" + } + ], + "title": "Dynamic Service Start", + "description": "used to create the service in any given moment if the requested_state is RUNNINGcan be set to None only when stopping the service" + }, + "user_id": { + "type": "integer", + "exclusiveMinimum": true, + "title": "User Id", + "description": "required for propagating status changes to the frontend", + "minimum": 0 + }, + "project_id": { + "type": "string", + "format": "uuid", + "title": "Project Id", + "description": "required for propagating status changes to the frontend" + }, + "requested_state": { + "allOf": [ + { + "$ref": "#/components/schemas/UserRequestedState" + } + ], + "description": "status of the service desidered by the user RUNNING or STOPPED" + }, + "current_state": { + "allOf": [ + { + "$ref": "#/components/schemas/SchedulerServiceState" + } + ], + "description": "to set after parsing the incoming state via the API calls", + "default": "UNKNOWN" + }, + "scheduled_to_run": { + "type": "boolean", + "title": "Scheduled To Run", + "description": "set when a job will be immediately scheduled", + "default": false + }, + "service_status": { + "type": "string", + "title": "Service Status", + "description": "stored for debug mainly this is used to compute ``current_state``", + "default": "" + }, + "service_status_task_uid": { + "type": "string", + "maxLength": 100, + "minLength": 1, + "title": "Service Status Task Uid", + "description": "uid of the job currently fetching the status" + }, + "check_status_after": { + "type": "number", + "title": "Check Status After", + "description": "used to determine when to poll the status again" + }, + "last_status_notification": { + "type": "number", + "title": "Last Status Notification", + "description": "used to determine when was the last time the status was notified", + "default": 0 + } + }, + "type": "object", + "required": [ + "dynamic_service_start", + "user_id", + "project_id", + "requested_state" + ], + "title": "TrackedServiceModel" + }, + "UserRequestedState": { + "type": "string", + "enum": [ + "RUNNING", + "STOPPED" + ], + "title": "UserRequestedState", + "description": "An enumeration." + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + }, + "WalletInfo": { + "properties": { + "wallet_id": { + "type": "integer", + "exclusiveMinimum": true, + "title": "Wallet Id", + "minimum": 0 + }, + "wallet_name": { + "type": "string", + "title": "Wallet Name" + }, + "wallet_credit_amount": { + "type": "number", + "title": "Wallet Credit Amount" + } + }, + "type": "object", + "required": [ + "wallet_id", + "wallet_name", + "wallet_credit_amount" + ], + "title": "WalletInfo" } } } diff --git a/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/rest/_services.py b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/rest/_services.py new file mode 100644 index 00000000000..6991faf6508 --- /dev/null +++ b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/rest/_services.py @@ -0,0 +1,32 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, FastAPI, status +from models_library.projects_nodes_io import NodeID +from simcore_service_dynamic_scheduler.services.service_tracker._api import ( + get_all_tracked_services, + remove_tracked_service, +) +from simcore_service_dynamic_scheduler.services.service_tracker._models import ( + TrackedServiceModel, +) + +from ._dependencies import get_app + +router = APIRouter() + + +@router.get("/services", response_model=dict[NodeID, TrackedServiceModel]) +async def list_services( + app: Annotated[FastAPI, Depends(get_app)] +) -> dict[NodeID, TrackedServiceModel]: + return await get_all_tracked_services(app) + + +@router.delete( + "/services/{node_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def remove_service( + node_id: NodeID, app: Annotated[FastAPI, Depends(get_app)] +) -> None: + await remove_tracked_service(app, node_id) diff --git a/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/rest/routes.py b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/rest/routes.py index 8c1d3e21ed8..3dcdf2d7a9b 100644 --- a/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/rest/routes.py +++ b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/rest/routes.py @@ -5,7 +5,7 @@ ) from ..._meta import API_VTAG -from . import _health, _meta +from . import _health, _meta, _services def setup_rest_api(app: FastAPI): @@ -13,6 +13,8 @@ def setup_rest_api(app: FastAPI): api_router = APIRouter(prefix=f"/{API_VTAG}") api_router.include_router(_meta.router, tags=["meta"]) + api_router.include_router(_services.router, tags=["services"]) + app.include_router(api_router) app.add_exception_handler(Exception, handle_errors_as_500) diff --git a/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__services.py b/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__services.py new file mode 100644 index 00000000000..305f1b102a1 --- /dev/null +++ b/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__services.py @@ -0,0 +1,80 @@ +# pylint:disable=redefined-outer-name +# pylint:disable=unused-argument +from collections.abc import Callable +from typing import Any + +import pytest +from fastapi import FastAPI, status +from httpx import AsyncClient +from models_library.api_schemas_dynamic_scheduler.dynamic_services import ( + DynamicServiceStart, + DynamicServiceStop, +) +from models_library.projects_nodes_io import NodeID +from pytest_simcore.helpers.typing_env import EnvVarsDict +from settings_library.redis import RedisSettings +from simcore_service_dynamic_scheduler._meta import API_VTAG +from simcore_service_dynamic_scheduler.services.service_tracker import ( + set_request_as_running, + set_request_as_stopped, +) +from simcore_service_dynamic_scheduler.services.service_tracker._models import ( + UserRequestedState, +) + +pytest_simcore_core_services_selection = [ + "redis", +] + + +@pytest.fixture +def app_environment( + disable_rabbitmq_setup: None, + disable_deferred_manager_setup: None, + disable_notifier_setup: None, + app_environment: EnvVarsDict, + redis_service: RedisSettings, + remove_redis_data: None, +) -> EnvVarsDict: + return app_environment + + +async def _get_services(client: AsyncClient) -> dict[str, dict[str, Any]]: + response = await client.get(f"/{API_VTAG}/services") + assert response.status_code == status.HTTP_200_OK + return response.json() + + +async def _remove_service(client: AsyncClient, node_id: NodeID) -> None: + response = await client.delete(f"/{API_VTAG}/services/{node_id}") + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.text == "" + + +async def test_services_api_workflow( + client: AsyncClient, + app: FastAPI, + node_id: NodeID, + get_dynamic_service_start: Callable[[NodeID], DynamicServiceStart], + get_dynamic_service_stop: Callable[[NodeID], DynamicServiceStop], +): + str_node_id = f"{node_id}" + + # request as running then as stopped + assert await _get_services(client) == {} + + # SET AS RUNNING + await set_request_as_running(app, get_dynamic_service_start(node_id)) + services = await _get_services(client) + assert len(services) == 1 + assert services[str_node_id]["requested_state"] == UserRequestedState.RUNNING + + # SET AS STOPPED + await set_request_as_stopped(app, get_dynamic_service_stop(node_id)) + services = await _get_services(client) + assert len(services) == 1 + assert services[str_node_id]["requested_state"] == UserRequestedState.STOPPED + + # REMOVE SERVICE + await _remove_service(client, node_id) + assert await _get_services(client) == {}