diff --git a/services/dynamic-scheduler/VERSION b/services/dynamic-scheduler/VERSION index 3eefcb9dd5b3..9084fa2f716a 100644 --- a/services/dynamic-scheduler/VERSION +++ b/services/dynamic-scheduler/VERSION @@ -1 +1 @@ -1.0.0 +1.1.0 diff --git a/services/dynamic-scheduler/openapi.json b/services/dynamic-scheduler/openapi.json index 1f05e29ea25e..9f6867c68728 100644 --- a/services/dynamic-scheduler/openapi.json +++ b/services/dynamic-scheduler/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "simcore-service-dynamic-scheduler web API", "description": "Service that manages lifecycle of dynamic services", - "version": "1.0.0" + "version": "1.1.0" }, "paths": { "/health": { @@ -44,6 +44,32 @@ } } } + }, + "/v1/ops/running-services": { + "get": { + "tags": [ + "ops" + ], + "summary": "Running Services", + "description": "returns all running dynamic services. Used by ops internall to determine\nwhen it is safe to shutdown the platform", + "operationId": "running_services_v1_ops_running_services_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/RunningDynamicServiceDetails" + }, + "type": "array", + "title": "Response Running Services V1 Ops Running Services Get" + } + } + } + } + } + } } }, "components": { @@ -90,6 +116,150 @@ "docs_url" ], "title": "Meta" + }, + "RunningDynamicServiceDetails": { + "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": { + "anyOf": [ + { + "type": "string", + "format": "path" + }, + { + "type": "null" + } + ], + "title": "Service Basepath", + "description": "predefined path where the dynamic service should be served. If empty, the service shall use the root endpoint." + }, + "boot_type": { + "$ref": "#/components/schemas/ServiceBootType", + "description": "Describes how the dynamic services was started (legacy=V0, modern=V2).Since legacy services do not have this label it defaults to V0.", + "default": "V0" + }, + "service_host": { + "type": "string", + "title": "Service Host", + "description": "the service swarm internal host name" + }, + "service_port": { + "type": "integer", + "exclusiveMaximum": true, + "exclusiveMinimum": true, + "title": "Service Port", + "description": "the service swarm internal port", + "maximum": 65535, + "minimum": 0 + }, + "published_port": { + "anyOf": [ + { + "type": "integer", + "exclusiveMaximum": true, + "exclusiveMinimum": true, + "maximum": 65535, + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "Published Port", + "description": "the service swarm published port if any", + "deprecated": true + }, + "entry_point": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Entry Point", + "description": "if empty the service entrypoint is on the root endpoint.", + "deprecated": true + }, + "service_state": { + "$ref": "#/components/schemas/ServiceState", + "description": "service current state" + }, + "service_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Service Message", + "description": "additional information related to service state" + } + }, + "type": "object", + "required": [ + "service_key", + "service_version", + "user_id", + "project_id", + "service_uuid", + "service_host", + "service_port", + "service_state" + ], + "title": "RunningDynamicServiceDetails" + }, + "ServiceBootType": { + "type": "string", + "enum": [ + "V0", + "V2" + ], + "title": "ServiceBootType" + }, + "ServiceState": { + "type": "string", + "enum": [ + "failed", + "pending", + "pulling", + "starting", + "running", + "stopping", + "complete", + "idle" + ], + "title": "ServiceState" } } } diff --git a/services/dynamic-scheduler/setup.cfg b/services/dynamic-scheduler/setup.cfg index 7f6905877344..33bc10725639 100644 --- a/services/dynamic-scheduler/setup.cfg +++ b/services/dynamic-scheduler/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.0.0 +current_version = 1.1.0 commit = True message = services/dynamic-scheduler version: {current_version} → {new_version} tag = False @@ -9,9 +9,9 @@ commit_args = --no-verify [tool:pytest] asyncio_mode = auto -markers = +markers = testit: "marks test to run during development" [mypy] -plugins = +plugins = pydantic.mypy diff --git a/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/rest/_ops.py b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/rest/_ops.py new file mode 100644 index 000000000000..6af7b8f88ca7 --- /dev/null +++ b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/rest/_ops.py @@ -0,0 +1,24 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, FastAPI +from models_library.api_schemas_directorv2.dynamic_services import ( + DynamicServiceGet, +) + +from ...services import scheduler_interface +from ._dependencies import ( + get_app, +) + +router = APIRouter() + + +@router.get("/ops/running-services") +async def running_services( + app: Annotated[FastAPI, Depends(get_app)], +) -> list[DynamicServiceGet]: + """returns all running dynamic services. Used by ops internall to determine + when it is safe to shutdown the platform""" + return await scheduler_interface.list_tracked_dynamic_services( + app, user_id=None, project_id=None + ) 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 9d0b45de9811..51cabd88ecb1 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, _ops def initialize_rest_api(app: FastAPI) -> None: @@ -13,6 +13,7 @@ def initialize_rest_api(app: FastAPI) -> None: api_router = APIRouter(prefix=f"/{API_VTAG}") api_router.include_router(_meta.router, tags=["meta"]) + api_router.include_router(_ops.router, tags=["ops"]) 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__ops.py b/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__ops.py new file mode 100644 index 000000000000..985cc86a4a35 --- /dev/null +++ b/services/dynamic-scheduler/tests/unit/api_rest/test_api_rest__ops.py @@ -0,0 +1,47 @@ +# pylint:disable=redefined-outer-name +# pylint:disable=unused-argument +import json +from collections.abc import Iterator + +import pytest +import respx +from fastapi import status +from fastapi.encoders import jsonable_encoder +from httpx import AsyncClient +from models_library.api_schemas_directorv2.dynamic_services import ( + DynamicServiceGet, +) +from pydantic import TypeAdapter +from simcore_service_dynamic_scheduler._meta import API_VTAG + + +@pytest.fixture +def mock_director_v2_service( + running_services: list[DynamicServiceGet], +) -> Iterator[None]: + with respx.mock( + base_url="http://director-v2:8000/v2", + assert_all_called=False, + assert_all_mocked=True, # IMPORTANT: KEEP always True! + ) as mock: + mock.get("/dynamic_services").respond( + status.HTTP_200_OK, + text=json.dumps(jsonable_encoder(running_services)), + ) + + yield None + + +@pytest.mark.parametrize( + "running_services", + [ + DynamicServiceGet.model_json_schema()["examples"], + [], + ], +) +async def test_running_services(mock_director_v2_service: None, client: AsyncClient): + response = await client.get(f"/{API_VTAG}/ops/running-services") + assert response.status_code == status.HTTP_200_OK + assert isinstance( + TypeAdapter(list[DynamicServiceGet]).validate_python(response.json()), list + )