diff --git a/.coveragerc b/.coveragerc index 3299a90950a..fb3d7c12624 100644 --- a/.coveragerc +++ b/.coveragerc @@ -26,5 +26,8 @@ exclude_lines = # Don't complain about abstract methods, they aren't run: @(abc\.)?abstract(((class|static)?method)|property) + # Don't complain about type checking + if TYPE_CHECKING: + ignore_errors = True show_missing = True diff --git a/Makefile b/Makefile index 6938cfc0afd..66b904bdb7d 100644 --- a/Makefile +++ b/Makefile @@ -124,11 +124,8 @@ __check_defined = \ .PHONY: help help: ## help on rule's targets -ifeq ($(IS_WIN),) - @awk --posix 'BEGIN {FS = ":.*?## "} /^[[:alpha:][:space:]_-]+:.*?## / {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) -else - @awk --posix 'BEGIN {FS = ":.*?## "} /^[[:alpha:][:space:]_-]+:.*?## / {printf "%-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST) -endif + @awk 'BEGIN {FS = ":.*?## "}; /^[^.[:space:]].*?:.*?## / {if ($$1 != "help" && NF == 2) {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}}' $(MAKEFILE_LIST) + test_python_version: ## Check Python version, throw error if compilation would fail with the installed version python ./scripts/test_python_version.py @@ -459,14 +456,13 @@ push-version: tag-version .check-uv-installed: @echo "Checking if 'uv' is installed..." @if ! command -v uv >/dev/null 2>&1; then \ - printf "\033[31mError: 'uv' is not installed.\033[0m\n"; \ - printf "To install 'uv', run the following command:\n"; \ - printf "\033[34mcurl -LsSf https://astral.sh/uv/install.sh | sh\033[0m\n"; \ - exit 1; \ + curl -LsSf https://astral.sh/uv/install.sh | sh; \ else \ printf "\033[32m'uv' is installed. Version: \033[0m"; \ uv --version; \ fi + # upgrading uv + -@uv self --quiet update .venv: .check-uv-installed diff --git a/packages/aws-library/requirements/_base.in b/packages/aws-library/requirements/_base.in index d47899b621b..1abbdbca6b0 100644 --- a/packages/aws-library/requirements/_base.in +++ b/packages/aws-library/requirements/_base.in @@ -9,5 +9,5 @@ aioboto3 aiocache pydantic[email] -types-aiobotocore[ec2,s3] +types-aiobotocore[ec2,s3,ssm] sh diff --git a/packages/aws-library/requirements/_base.txt b/packages/aws-library/requirements/_base.txt index 590b802f30a..3d89e550ba9 100644 --- a/packages/aws-library/requirements/_base.txt +++ b/packages/aws-library/requirements/_base.txt @@ -184,11 +184,13 @@ typer==0.12.3 # -r requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in # -r requirements/../../../packages/settings-library/requirements/_base.in # faststream -types-aiobotocore==2.12.3 +types-aiobotocore==2.13.0 # via -r requirements/_base.in -types-aiobotocore-ec2==2.12.3 +types-aiobotocore-ec2==2.13.0 # via types-aiobotocore -types-aiobotocore-s3==2.12.3 +types-aiobotocore-s3==2.13.0 + # via types-aiobotocore +types-aiobotocore-ssm==2.13.0 # via types-aiobotocore types-awscrt==0.20.9 # via botocore-stubs @@ -205,6 +207,7 @@ typing-extensions==4.11.0 # types-aiobotocore # types-aiobotocore-ec2 # types-aiobotocore-s3 + # types-aiobotocore-ssm urllib3==2.2.1 # via # -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt diff --git a/packages/aws-library/src/aws_library/ec2/client.py b/packages/aws-library/src/aws_library/ec2/client.py index 907595762af..8f19d2ef52d 100644 --- a/packages/aws-library/src/aws_library/ec2/client.py +++ b/packages/aws-library/src/aws_library/ec2/client.py @@ -1,6 +1,6 @@ import contextlib import logging -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from dataclasses import dataclass from typing import cast @@ -22,6 +22,7 @@ EC2TooManyInstancesError, ) from .models import ( + AWSTagKey, EC2InstanceConfig, EC2InstanceData, EC2InstanceType, @@ -274,6 +275,24 @@ async def get_instances( ) return all_instances + async def stop_instances(self, instance_datas: Iterable[EC2InstanceData]) -> None: + try: + with log_context( + _logger, + logging.INFO, + msg=f"stopping instances {[i.id for i in instance_datas]}", + ): + await self.client.stop_instances( + InstanceIds=[i.id for i in instance_datas] + ) + except botocore.exceptions.ClientError as exc: + if ( + exc.response.get("Error", {}).get("Code", "") + == "InvalidInstanceID.NotFound" + ): + raise EC2InstanceNotFoundError from exc + raise # pragma: no cover + async def terminate_instances( self, instance_datas: Iterable[EC2InstanceData] ) -> None: @@ -295,7 +314,7 @@ async def terminate_instances( raise # pragma: no cover async def set_instances_tags( - self, instances: list[EC2InstanceData], *, tags: EC2Tags + self, instances: Sequence[EC2InstanceData], *, tags: EC2Tags ) -> None: try: with log_context( @@ -314,3 +333,21 @@ async def set_instances_tags( if exc.response.get("Error", {}).get("Code", "") == "InvalidID": raise EC2InstanceNotFoundError from exc raise # pragma: no cover + + async def remove_instances_tags( + self, instances: Sequence[EC2InstanceData], *, tag_keys: Iterable[AWSTagKey] + ) -> None: + try: + with log_context( + _logger, + logging.DEBUG, + msg=f"removing {tag_keys=} of instances '[{[i.id for i in instances]}]'", + ): + await self.client.delete_tags( + Resources=[i.id for i in instances], + Tags=[{"Key": tag_key} for tag_key in tag_keys], + ) + except botocore.exceptions.ClientError as exc: + if exc.response.get("Error", {}).get("Code", "") == "InvalidID": + raise EC2InstanceNotFoundError from exc + raise # pragma: no cover diff --git a/packages/aws-library/src/aws_library/ec2/models.py b/packages/aws-library/src/aws_library/ec2/models.py index f5b4a2a6540..590890d9dab 100644 --- a/packages/aws-library/src/aws_library/ec2/models.py +++ b/packages/aws-library/src/aws_library/ec2/models.py @@ -10,7 +10,6 @@ BaseModel, ByteSize, ConstrainedStr, - Extra, Field, NonNegativeFloat, validator, @@ -142,7 +141,6 @@ class EC2InstanceBootSpecific(BaseModel): ) class Config: - extra = Extra.forbid schema_extra: ClassVar[dict[str, Any]] = { "examples": [ { diff --git a/packages/aws-library/src/aws_library/ssm/__init__.py b/packages/aws-library/src/aws_library/ssm/__init__.py new file mode 100644 index 00000000000..29127128a12 --- /dev/null +++ b/packages/aws-library/src/aws_library/ssm/__init__.py @@ -0,0 +1,21 @@ +from ._client import SimcoreSSMAPI +from ._errors import ( + SSMAccessError, + SSMCommandExecutionError, + SSMInvalidCommandError, + SSMNotConnectedError, + SSMRuntimeError, + SSMSendCommandInstancesNotReadyError, +) + +__all__: tuple[str, ...] = ( + "SimcoreSSMAPI", + "SSMAccessError", + "SSMNotConnectedError", + "SSMRuntimeError", + "SSMSendCommandInstancesNotReadyError", + "SSMInvalidCommandError", + "SSMCommandExecutionError", +) + +# nopycln: file diff --git a/packages/aws-library/src/aws_library/ssm/_client.py b/packages/aws-library/src/aws_library/ssm/_client.py new file mode 100644 index 00000000000..9105180b859 --- /dev/null +++ b/packages/aws-library/src/aws_library/ssm/_client.py @@ -0,0 +1,159 @@ +import contextlib +import logging +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Final, cast + +import aioboto3 +import botocore +import botocore.exceptions +from aiobotocore.session import ClientCreatorContext +from servicelib.logging_utils import log_decorator +from settings_library.ssm import SSMSettings +from types_aiobotocore_ssm import SSMClient +from types_aiobotocore_ssm.literals import CommandStatusType + +from ._error_handler import ssm_exception_handler +from ._errors import SSMCommandExecutionError + +_logger = logging.getLogger(__name__) + +_AWS_WAIT_MAX_DELAY: Final[int] = 5 +_AWS_WAIT_NUM_RETRIES: Final[int] = 3 + + +@dataclass(frozen=True) +class SSMCommand: + name: str + command_id: str + instance_ids: Sequence[str] + status: CommandStatusType + message: str | None = None + + +@dataclass(frozen=True) +class SimcoreSSMAPI: + _client: SSMClient + _session: aioboto3.Session + _exit_stack: contextlib.AsyncExitStack + + @classmethod + async def create(cls, settings: SSMSettings) -> "SimcoreSSMAPI": + session = aioboto3.Session() + session_client = session.client( + "ssm", + endpoint_url=f"{settings.SSM_ENDPOINT}", + aws_access_key_id=settings.SSM_ACCESS_KEY_ID.get_secret_value(), + aws_secret_access_key=settings.SSM_SECRET_ACCESS_KEY.get_secret_value(), + region_name=settings.SSM_REGION_NAME, + ) + assert isinstance(session_client, ClientCreatorContext) # nosec + exit_stack = contextlib.AsyncExitStack() + ec2_client = cast( + SSMClient, await exit_stack.enter_async_context(session_client) + ) + return cls(ec2_client, session, exit_stack) + + async def close(self) -> None: + await self._exit_stack.aclose() + + async def ping(self) -> bool: + try: + await self._client.list_commands(MaxResults=1) + return True + except Exception: # pylint: disable=broad-except + return False + + # a function to send a command via ssm + @log_decorator(_logger, logging.DEBUG) + @ssm_exception_handler(_logger) + async def send_command( + self, instance_ids: Sequence[str], *, command: str, command_name: str + ) -> SSMCommand: + # NOTE: using Targets instead of instances as this is limited to 50 instances + # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ssm.html#SSM.Client.send_command + response = await self._client.send_command( + Targets=[{"Key": "InstanceIds", "Values": instance_ids}], + DocumentName="AWS-RunShellScript", + Comment=command_name, + Parameters={"commands": [command]}, + ) + assert response["Command"] # nosec + assert "Comment" in response["Command"] # nosec + assert "CommandId" in response["Command"] # nosec + assert "Status" in response["Command"] # nosec + + return SSMCommand( + name=response["Command"]["Comment"], + command_id=response["Command"]["CommandId"], + status=response["Command"]["Status"], + instance_ids=instance_ids, + ) + + @log_decorator(_logger, logging.DEBUG) + @ssm_exception_handler(_logger) + async def get_command(self, instance_id: str, *, command_id: str) -> SSMCommand: + + response = await self._client.get_command_invocation( + CommandId=command_id, InstanceId=instance_id + ) + + return SSMCommand( + name=response["Comment"], + command_id=response["CommandId"], + instance_ids=[response["InstanceId"]], + status=response["Status"] if response["Status"] != "Delayed" else "Pending", + message=response["StatusDetails"], + ) + + @log_decorator(_logger, logging.DEBUG) + @ssm_exception_handler(_logger) + async def is_instance_connected_to_ssm_server(self, instance_id: str) -> bool: + response = await self._client.describe_instance_information( + InstanceInformationFilterList=[ + { + "key": "InstanceIds", + "valueSet": [ + instance_id, + ], + } + ], + ) + assert response["InstanceInformationList"] # nosec + assert len(response["InstanceInformationList"]) == 1 # nosec + assert "PingStatus" in response["InstanceInformationList"][0] # nosec + return bool(response["InstanceInformationList"][0]["PingStatus"] == "Online") + + @log_decorator(_logger, logging.DEBUG) + @ssm_exception_handler(_logger) + async def wait_for_has_instance_completed_cloud_init( + self, instance_id: str + ) -> bool: + cloud_init_status_command = await self.send_command( + (instance_id,), + command="cloud-init status", + command_name="cloud-init status", + ) + # wait for command to complete + waiter = self._client.get_waiter( # pylint: disable=assignment-from-no-return + "command_executed" + ) + try: + await waiter.wait( + CommandId=cloud_init_status_command.command_id, + InstanceId=instance_id, + WaiterConfig={ + "Delay": _AWS_WAIT_MAX_DELAY, + "MaxAttempts": _AWS_WAIT_NUM_RETRIES, + }, + ) + except botocore.exceptions.WaiterError as exc: + msg = f"Timed-out waiting for {instance_id} to complete cloud-init" + raise SSMCommandExecutionError(details=msg) from exc + response = await self._client.get_command_invocation( + CommandId=cloud_init_status_command.command_id, InstanceId=instance_id + ) + if response["Status"] != "Success": + raise SSMCommandExecutionError(details=response["StatusDetails"]) + # check if cloud-init is done + return bool("status: done" in response["StandardOutputContent"]) diff --git a/packages/aws-library/src/aws_library/ssm/_error_handler.py b/packages/aws-library/src/aws_library/ssm/_error_handler.py new file mode 100644 index 00000000000..ef2ae026c92 --- /dev/null +++ b/packages/aws-library/src/aws_library/ssm/_error_handler.py @@ -0,0 +1,85 @@ +import functools +import logging +from collections.abc import Callable, Coroutine +from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar + +from botocore import exceptions as botocore_exc + +from ._errors import ( + SSMAccessError, + SSMInvalidCommandError, + SSMNotConnectedError, + SSMRuntimeError, + SSMSendCommandInstancesNotReadyError, + SSMTimeoutError, +) + +if TYPE_CHECKING: + # NOTE: TYPE_CHECKING is True when static type checkers are running, + # allowing for circular imports only for them (mypy, pylance, ruff) + from ._client import SimcoreSSMAPI + + +def _map_botocore_client_exception( + botocore_error: botocore_exc.ClientError, **kwargs +) -> SSMAccessError: + status_code = int( + botocore_error.response.get("ResponseMetadata", {}).get("HTTPStatusCode") + or botocore_error.response.get("Error", {}).get("Code", -1) + ) + operation_name = botocore_error.operation_name + match status_code, operation_name: + case 400, "SendCommand": + return SSMSendCommandInstancesNotReadyError() + case 400, "GetCommandInvocation": + assert "Error" in botocore_error.response # nosec + assert "Message" in botocore_error.response["Error"] # nosec + return SSMInvalidCommandError(command_id=kwargs["command_id"]) + + case _: + return SSMAccessError( + operation_name=operation_name, + code=status_code, + error=f"{botocore_error}", + ) + + +P = ParamSpec("P") +R = TypeVar("R") +T = TypeVar("T") +Self = TypeVar("Self", bound="SimcoreSSMAPI") + + +def ssm_exception_handler( + logger: logging.Logger, +) -> Callable[ + [Callable[Concatenate[Self, P], Coroutine[Any, Any, R]]], + Callable[Concatenate[Self, P], Coroutine[Any, Any, R]], +]: + """ + Raises: + SSMAccessError: + """ + + def decorator( + func: Callable[Concatenate[Self, P], Coroutine[Any, Any, R]] + ) -> Callable[Concatenate[Self, P], Coroutine[Any, Any, R]]: + @functools.wraps(func) + async def wrapper(self: Self, *args: P.args, **kwargs: P.kwargs) -> R: + try: + return await func(self, *args, **kwargs) + except botocore_exc.ClientError as exc: + raise _map_botocore_client_exception(exc, **kwargs) from exc + except botocore_exc.WaiterError as exc: + raise SSMTimeoutError(details=f"{exc}") from exc + except botocore_exc.EndpointConnectionError as exc: + raise SSMNotConnectedError from exc + except botocore_exc.BotoCoreError as exc: + logger.exception("Unexpected error in SSM client: ") + raise SSMRuntimeError from exc + + wrapper.__doc__ = f"{func.__doc__}\n\n{ssm_exception_handler.__doc__}" + + return wrapper + + return decorator diff --git a/packages/aws-library/src/aws_library/ssm/_errors.py b/packages/aws-library/src/aws_library/ssm/_errors.py new file mode 100644 index 00000000000..1be0842ab49 --- /dev/null +++ b/packages/aws-library/src/aws_library/ssm/_errors.py @@ -0,0 +1,31 @@ +from pydantic.errors import PydanticErrorMixin + + +class SSMRuntimeError(PydanticErrorMixin, RuntimeError): + msg_template: str = "SSM client unexpected error" + + +class SSMNotConnectedError(SSMRuntimeError): + msg_template: str = "Cannot connect with SSM server" + + +class SSMAccessError(SSMRuntimeError): + msg_template: str = ( + "Unexpected error while accessing SSM backend: {operation_name}:{code}:{error}" + ) + + +class SSMTimeoutError(SSMAccessError): + msg_template: str = "Timeout while accessing SSM backend: {details}" + + +class SSMSendCommandInstancesNotReadyError(SSMAccessError): + msg_template: str = "Instance not ready to receive commands" + + +class SSMCommandExecutionError(SSMAccessError): + msg_template: str = "Command execution error: {details}" + + +class SSMInvalidCommandError(SSMAccessError): + msg_template: str = "Invalid command ID: {command_id}" diff --git a/packages/aws-library/tests/conftest.py b/packages/aws-library/tests/conftest.py index a43c3c7ec2b..47fcdd327e3 100644 --- a/packages/aws-library/tests/conftest.py +++ b/packages/aws-library/tests/conftest.py @@ -5,11 +5,13 @@ import aws_library import pytest +from settings_library.ec2 import EC2Settings pytest_plugins = [ "pytest_simcore.aws_ec2_service", "pytest_simcore.aws_s3_service", "pytest_simcore.aws_server", + "pytest_simcore.aws_ssm_service", "pytest_simcore.environment_configs", "pytest_simcore.file_extra", "pytest_simcore.pydantic_models", @@ -23,3 +25,8 @@ def package_dir() -> Path: pdir = Path(aws_library.__file__).resolve().parent assert pdir.exists() return pdir + + +@pytest.fixture +def ec2_settings(mocked_ec2_server_settings: EC2Settings) -> EC2Settings: + return mocked_ec2_server_settings diff --git a/packages/aws-library/tests/test_ec2_client.py b/packages/aws-library/tests/test_ec2_client.py index e13c3cf4f31..7e444931d37 100644 --- a/packages/aws-library/tests/test_ec2_client.py +++ b/packages/aws-library/tests/test_ec2_client.py @@ -3,6 +3,7 @@ # pylint:disable=redefined-outer-name +import random from collections.abc import AsyncIterator, Callable from typing import cast, get_args @@ -15,6 +16,7 @@ EC2TooManyInstancesError, ) from aws_library.ec2.models import ( + AWSTagKey, EC2InstanceConfig, EC2InstanceData, EC2InstanceType, @@ -341,6 +343,53 @@ async def test_get_instances( assert not instance_received +async def test_stop_instances( + simcore_ec2_api: SimcoreEC2API, + ec2_client: EC2Client, + faker: Faker, + ec2_instance_config: EC2InstanceConfig, +): + # we have nothing running now in ec2 + await _assert_no_instances_in_ec2(ec2_client) + # create some instance + _NUM_INSTANCES = 10 + num_instances = faker.pyint(min_value=1, max_value=_NUM_INSTANCES) + created_instances = await simcore_ec2_api.start_aws_instance( + ec2_instance_config, + min_number_of_instances=num_instances, + number_of_instances=num_instances, + ) + await _assert_instances_in_ec2( + ec2_client, + expected_num_reservations=1, + expected_num_instances=num_instances, + expected_instance_type=ec2_instance_config.type, + expected_tags=ec2_instance_config.tags, + expected_state="running", + ) + # stop the instances + await simcore_ec2_api.stop_instances(created_instances) + await _assert_instances_in_ec2( + ec2_client, + expected_num_reservations=1, + expected_num_instances=num_instances, + expected_instance_type=ec2_instance_config.type, + expected_tags=ec2_instance_config.tags, + expected_state="stopped", + ) + + # stop again is ok + await simcore_ec2_api.stop_instances(created_instances) + await _assert_instances_in_ec2( + ec2_client, + expected_num_reservations=1, + expected_num_instances=num_instances, + expected_instance_type=ec2_instance_config.type, + expected_tags=ec2_instance_config.tags, + expected_state="stopped", + ) + + async def test_terminate_instance( simcore_ec2_api: SimcoreEC2API, ec2_client: EC2Client, @@ -388,6 +437,16 @@ async def test_terminate_instance( ) +async def test_stop_instance_not_existing_raises( + simcore_ec2_api: SimcoreEC2API, + ec2_client: EC2Client, + fake_ec2_instance_data: Callable[..., EC2InstanceData], +): + await _assert_no_instances_in_ec2(ec2_client) + with pytest.raises(EC2InstanceNotFoundError): + await simcore_ec2_api.stop_instances([fake_ec2_instance_data()]) + + async def test_terminate_instance_not_existing_raises( simcore_ec2_api: SimcoreEC2API, ec2_client: EC2Client, @@ -433,6 +492,33 @@ async def test_set_instance_tags( expected_state="running", ) + # now remove some, this should do nothing + await simcore_ec2_api.remove_instances_tags( + created_instances, tag_keys=[AWSTagKey("whatever_i_dont_exist")] + ) + await _assert_instances_in_ec2( + ec2_client, + expected_num_reservations=1, + expected_num_instances=num_instances, + expected_instance_type=ec2_instance_config.type, + expected_tags=ec2_instance_config.tags | new_tags, + expected_state="running", + ) + # now remove some real ones + tag_key_to_remove = random.choice(list(new_tags)) # noqa: S311 + await simcore_ec2_api.remove_instances_tags( + created_instances, tag_keys=[tag_key_to_remove] + ) + new_tags.pop(tag_key_to_remove) + await _assert_instances_in_ec2( + ec2_client, + expected_num_reservations=1, + expected_num_instances=num_instances, + expected_instance_type=ec2_instance_config.type, + expected_tags=ec2_instance_config.tags | new_tags, + expected_state="running", + ) + async def test_set_instance_tags_not_existing_raises( simcore_ec2_api: SimcoreEC2API, @@ -442,3 +528,15 @@ async def test_set_instance_tags_not_existing_raises( await _assert_no_instances_in_ec2(ec2_client) with pytest.raises(EC2InstanceNotFoundError): await simcore_ec2_api.set_instances_tags([fake_ec2_instance_data()], tags={}) + + +async def test_remove_instance_tags_not_existing_raises( + simcore_ec2_api: SimcoreEC2API, + ec2_client: EC2Client, + fake_ec2_instance_data: Callable[..., EC2InstanceData], +): + await _assert_no_instances_in_ec2(ec2_client) + with pytest.raises(EC2InstanceNotFoundError): + await simcore_ec2_api.remove_instances_tags( + [fake_ec2_instance_data()], tag_keys=[] + ) diff --git a/packages/aws-library/tests/test_ssm_client.py b/packages/aws-library/tests/test_ssm_client.py new file mode 100644 index 00000000000..d5f7329ede7 --- /dev/null +++ b/packages/aws-library/tests/test_ssm_client.py @@ -0,0 +1,211 @@ +# pylint:disable=unused-variable +# pylint:disable=unused-argument +# pylint:disable=redefined-outer-name +# pylint:disable=protected-access + + +import dataclasses +from collections.abc import AsyncIterator + +import botocore.exceptions +import pytest +from aws_library.ssm import ( + SimcoreSSMAPI, + SSMCommandExecutionError, + SSMInvalidCommandError, + SSMNotConnectedError, +) +from aws_library.ssm._client import _AWS_WAIT_NUM_RETRIES +from faker import Faker +from moto.server import ThreadedMotoServer +from pytest_mock.plugin import MockerFixture +from settings_library.ssm import SSMSettings +from types_aiobotocore_ssm import SSMClient + + +@pytest.fixture +async def simcore_ssm_api( + mocked_ssm_server_settings: SSMSettings, +) -> AsyncIterator[SimcoreSSMAPI]: + ec2 = await SimcoreSSMAPI.create(settings=mocked_ssm_server_settings) + assert ec2 + assert ec2._client + assert ec2._exit_stack + assert ec2._session + yield ec2 + await ec2.close() + + +async def test_ssm_client_lifespan(simcore_ssm_api: SimcoreSSMAPI): + ... + + +async def test_aiobotocore_ssm_client_when_ssm_server_goes_up_and_down( + mocked_aws_server: ThreadedMotoServer, + ssm_client: SSMClient, +): + # passes without exception + await ssm_client.list_commands(MaxResults=1) + mocked_aws_server.stop() + with pytest.raises(botocore.exceptions.EndpointConnectionError): + await ssm_client.list_commands(MaxResults=1) + + # restart + mocked_aws_server.start() + # passes without exception + await ssm_client.list_commands(MaxResults=1) + + +@pytest.fixture +def fake_command_id(faker: Faker) -> str: + return faker.pystr(min_chars=36, max_chars=36) + + +async def test_ping( + mocked_aws_server: ThreadedMotoServer, + simcore_ssm_api: SimcoreSSMAPI, + fake_command_id: str, + faker: Faker, +): + assert await simcore_ssm_api.ping() is True + mocked_aws_server.stop() + assert await simcore_ssm_api.ping() is False + with pytest.raises(SSMNotConnectedError): + await simcore_ssm_api.get_command(faker.pystr(), command_id=fake_command_id) + mocked_aws_server.start() + assert await simcore_ssm_api.ping() is True + + +async def test_get_command( + mocked_aws_server: ThreadedMotoServer, + simcore_ssm_api: SimcoreSSMAPI, + faker: Faker, + fake_command_id: str, +): + with pytest.raises(SSMInvalidCommandError): + await simcore_ssm_api.get_command(faker.pystr(), command_id=fake_command_id) + + +async def test_send_command( + mocked_aws_server: ThreadedMotoServer, + simcore_ssm_api: SimcoreSSMAPI, + faker: Faker, + fake_command_id: str, +): + command_name = faker.word() + target_instance_id = faker.pystr() + sent_command = await simcore_ssm_api.send_command( + instance_ids=[target_instance_id], + command=faker.text(), + command_name=command_name, + ) + assert sent_command + assert sent_command.command_id + assert sent_command.name == command_name + assert sent_command.instance_ids == [target_instance_id] + assert sent_command.status == "Success" + + got = await simcore_ssm_api.get_command( + target_instance_id, command_id=sent_command.command_id + ) + assert dataclasses.asdict(got) == { + **dataclasses.asdict(sent_command), + "message": "Success", + } + with pytest.raises(SSMInvalidCommandError): + await simcore_ssm_api.get_command( + faker.pystr(), command_id=sent_command.command_id + ) + with pytest.raises(SSMInvalidCommandError): + await simcore_ssm_api.get_command( + target_instance_id, command_id=fake_command_id + ) + + +async def test_is_instance_connected_to_ssm_server( + mocked_aws_server: ThreadedMotoServer, + simcore_ssm_api: SimcoreSSMAPI, + faker: Faker, + mocker: MockerFixture, +): + # NOTE: moto does not provide that mock functionality and therefore we mock it ourselves + mock = mocker.patch( + "pytest_simcore.helpers.moto._patch_describe_instance_information", + autospec=True, + return_value={"InstanceInformationList": [{"PingStatus": "Inactive"}]}, + ) + assert ( + await simcore_ssm_api.is_instance_connected_to_ssm_server(faker.pystr()) + is False + ) + mock.return_value = {"InstanceInformationList": [{"PingStatus": "Online"}]} + assert ( + await simcore_ssm_api.is_instance_connected_to_ssm_server(faker.pystr()) is True + ) + + +async def test_wait_for_has_instance_completed_cloud_init( + mocked_aws_server: ThreadedMotoServer, + simcore_ssm_api: SimcoreSSMAPI, + faker: Faker, + mocker: MockerFixture, +): + assert ( + await simcore_ssm_api.wait_for_has_instance_completed_cloud_init(faker.pystr()) + is False + ) + original_get_command_invocation = ( + simcore_ssm_api._client.get_command_invocation # noqa: SLF001 + ) + + # NOTE: wait_for_has_instance_completed_cloud_init calls twice get_command_invocation + async def mock_send_command_timesout(*args, **kwargs): + return {"Status": "Failure", "StatusDetails": faker.text()} + + mocked_command_invocation = mocker.patch.object( + simcore_ssm_api._client, # noqa: SLF001 + "get_command_invocation", + side_effect=mock_send_command_timesout, + ) + with pytest.raises(SSMCommandExecutionError, match="Timed-out"): + await simcore_ssm_api.wait_for_has_instance_completed_cloud_init(faker.pystr()) + + assert mocked_command_invocation.call_count == _AWS_WAIT_NUM_RETRIES + + mocked_command_invocation.reset_mock() + call_count = 0 + + async def mock_wait_command_failed(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 2: + return {"Status": "Failure", "StatusDetails": faker.text()} + return await original_get_command_invocation(*args, **kwargs) + + mocked_command_invocation.side_effect = mock_wait_command_failed + with pytest.raises(SSMCommandExecutionError): + await simcore_ssm_api.wait_for_has_instance_completed_cloud_init(faker.pystr()) + assert mocked_command_invocation.call_count == 2 + + # NOTE: default will return False as we need to mock the return value of the cloud-init function + assert ( + await simcore_ssm_api.wait_for_has_instance_completed_cloud_init(faker.pystr()) + is False + ) + + mocked_command_invocation.reset_mock() + call_count = 0 + + async def mock_wait_command_successful(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 2: + return {"Status": "Success", "StandardOutputContent": "status: done\n"} + return await original_get_command_invocation(*args, **kwargs) + + mocked_command_invocation.side_effect = mock_wait_command_successful + assert ( + await simcore_ssm_api.wait_for_has_instance_completed_cloud_init(faker.pystr()) + is True + ) + assert mocked_command_invocation.call_count == 2 diff --git a/packages/pytest-simcore/src/pytest_simcore/aws_ec2_service.py b/packages/pytest-simcore/src/pytest_simcore/aws_ec2_service.py index 641664a5292..3c4b24c385e 100644 --- a/packages/pytest-simcore/src/pytest_simcore/aws_ec2_service.py +++ b/packages/pytest-simcore/src/pytest_simcore/aws_ec2_service.py @@ -20,16 +20,16 @@ @pytest.fixture async def ec2_client( - mocked_ec2_server_settings: EC2Settings, + ec2_settings: EC2Settings, ) -> AsyncIterator[EC2Client]: session = aioboto3.Session() exit_stack = contextlib.AsyncExitStack() session_client = session.client( "ec2", - endpoint_url=mocked_ec2_server_settings.EC2_ENDPOINT, - aws_access_key_id=mocked_ec2_server_settings.EC2_ACCESS_KEY_ID, - aws_secret_access_key=mocked_ec2_server_settings.EC2_SECRET_ACCESS_KEY, - region_name=mocked_ec2_server_settings.EC2_REGION_NAME, + endpoint_url=ec2_settings.EC2_ENDPOINT, + aws_access_key_id=ec2_settings.EC2_ACCESS_KEY_ID, + aws_secret_access_key=ec2_settings.EC2_SECRET_ACCESS_KEY, + region_name=ec2_settings.EC2_REGION_NAME, ) assert isinstance(session_client, ClientCreatorContext) ec2_client = cast(EC2Client, await exit_stack.enter_async_context(session_client)) diff --git a/packages/pytest-simcore/src/pytest_simcore/aws_server.py b/packages/pytest-simcore/src/pytest_simcore/aws_server.py index 96e05999abf..60338ecf5a7 100644 --- a/packages/pytest-simcore/src/pytest_simcore/aws_server.py +++ b/packages/pytest-simcore/src/pytest_simcore/aws_server.py @@ -3,6 +3,7 @@ # pylint: disable=unused-import from collections.abc import Iterator +from unittest import mock import pytest import requests @@ -10,11 +11,14 @@ from faker import Faker from moto.server import ThreadedMotoServer from pydantic import AnyHttpUrl, parse_obj_as +from pytest_mock.plugin import MockerFixture from settings_library.ec2 import EC2Settings from settings_library.s3 import S3Settings +from settings_library.ssm import SSMSettings from .helpers.host import get_localhost_ip from .helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict +from .helpers.moto import patched_aiobotocore_make_api_call @pytest.fixture(scope="module") @@ -73,6 +77,42 @@ def mocked_ec2_server_envs( return setenvs_from_dict(monkeypatch, changed_envs) +@pytest.fixture +def with_patched_ssm_server( + mocker: MockerFixture, external_envfile_dict: EnvVarsDict +) -> mock.Mock: + if external_envfile_dict: + # NOTE: we run against AWS. so no need to mock + return mock.Mock() + return mocker.patch( + "aiobotocore.client.AioBaseClient._make_api_call", + side_effect=patched_aiobotocore_make_api_call, + autospec=True, + ) + + +@pytest.fixture +def mocked_ssm_server_settings( + mocked_aws_server: ThreadedMotoServer, + with_patched_ssm_server: mock.Mock, + reset_aws_server_state: None, +) -> SSMSettings: + return SSMSettings( + SSM_ACCESS_KEY_ID="xxx", + SSM_ENDPOINT=f"http://{mocked_aws_server._ip_address}:{mocked_aws_server._port}", # pylint: disable=protected-access # noqa: SLF001 + SSM_SECRET_ACCESS_KEY="xxx", # noqa: S106 + ) + + +@pytest.fixture +def mocked_ssm_server_envs( + mocked_ssm_server_settings: SSMSettings, + monkeypatch: pytest.MonkeyPatch, +) -> EnvVarsDict: + changed_envs: EnvVarsDict = mocked_ssm_server_settings.dict() + return setenvs_from_dict(monkeypatch, changed_envs) + + @pytest.fixture def mocked_s3_server_settings( mocked_aws_server: ThreadedMotoServer, reset_aws_server_state: None, faker: Faker diff --git a/packages/pytest-simcore/src/pytest_simcore/aws_ssm_service.py b/packages/pytest-simcore/src/pytest_simcore/aws_ssm_service.py new file mode 100644 index 00000000000..7e2844ea5d1 --- /dev/null +++ b/packages/pytest-simcore/src/pytest_simcore/aws_ssm_service.py @@ -0,0 +1,36 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-import + +import contextlib +from collections.abc import AsyncIterator +from typing import cast + +import aioboto3 +import pytest +from aiobotocore.session import ClientCreatorContext +from pytest_mock.plugin import MockerFixture +from settings_library.ssm import SSMSettings +from types_aiobotocore_ssm.client import SSMClient + + +@pytest.fixture +async def ssm_client( + mocked_ssm_server_settings: SSMSettings, + mocker: MockerFixture, +) -> AsyncIterator[SSMClient]: + session = aioboto3.Session() + exit_stack = contextlib.AsyncExitStack() + session_client = session.client( + "ssm", + endpoint_url=f"{mocked_ssm_server_settings.SSM_ENDPOINT}", + aws_access_key_id=mocked_ssm_server_settings.SSM_ACCESS_KEY_ID.get_secret_value(), + aws_secret_access_key=mocked_ssm_server_settings.SSM_SECRET_ACCESS_KEY.get_secret_value(), + region_name=mocked_ssm_server_settings.SSM_REGION_NAME, + ) + assert isinstance(session_client, ClientCreatorContext) + ec2_client = cast(SSMClient, await exit_stack.enter_async_context(session_client)) + + yield ec2_client + + await exit_stack.aclose() diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/aws_ec2.py b/packages/pytest-simcore/src/pytest_simcore/helpers/aws_ec2.py new file mode 100644 index 00000000000..43da3b1a074 --- /dev/null +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/aws_ec2.py @@ -0,0 +1,142 @@ +import base64 +from typing import Sequence + +from types_aiobotocore_ec2 import EC2Client +from types_aiobotocore_ec2.literals import InstanceStateNameType, InstanceTypeType +from types_aiobotocore_ec2.type_defs import FilterTypeDef, InstanceTypeDef + + +async def assert_autoscaled_computational_ec2_instances( + ec2_client: EC2Client, + *, + expected_num_reservations: int, + expected_num_instances: int, + expected_instance_type: InstanceTypeType, + expected_instance_state: InstanceStateNameType, +) -> list[InstanceTypeDef]: + return await assert_ec2_instances( + ec2_client, + expected_num_reservations=expected_num_reservations, + expected_num_instances=expected_num_instances, + expected_instance_type=expected_instance_type, + expected_instance_state=expected_instance_state, + expected_instance_tag_keys=[ + "io.simcore.autoscaling.dask-scheduler_url", + "user_id", + "wallet_id", + "osparc-tag", + ], + expected_user_data=["docker swarm join"], + ) + + +async def assert_autoscaled_dynamic_ec2_instances( + ec2_client: EC2Client, + *, + expected_num_reservations: int, + expected_num_instances: int, + expected_instance_type: InstanceTypeType, + expected_instance_state: InstanceStateNameType, +) -> list[InstanceTypeDef]: + return await assert_ec2_instances( + ec2_client, + expected_num_reservations=expected_num_reservations, + expected_num_instances=expected_num_instances, + expected_instance_type=expected_instance_type, + expected_instance_state=expected_instance_state, + expected_instance_tag_keys=[ + "io.simcore.autoscaling.monitored_nodes_labels", + "io.simcore.autoscaling.monitored_services_labels", + "user_id", + "wallet_id", + "osparc-tag", + ], + expected_user_data=["docker swarm join"], + ) + + +async def assert_autoscaled_dynamic_warm_pools_ec2_instances( + ec2_client: EC2Client, + *, + expected_num_reservations: int, + expected_num_instances: int, + expected_instance_type: InstanceTypeType, + expected_instance_state: InstanceStateNameType, + expected_additional_tag_keys: list[str], + instance_filters: Sequence[FilterTypeDef] | None, +) -> list[InstanceTypeDef]: + return await assert_ec2_instances( + ec2_client, + expected_num_reservations=expected_num_reservations, + expected_num_instances=expected_num_instances, + expected_instance_type=expected_instance_type, + expected_instance_state=expected_instance_state, + expected_instance_tag_keys=[ + "io.simcore.autoscaling.monitored_nodes_labels", + "io.simcore.autoscaling.monitored_services_labels", + "buffer-machine", + *expected_additional_tag_keys, + ], + expected_user_data=[], + instance_filters=instance_filters, + ) + + +async def assert_ec2_instances( + ec2_client: EC2Client, + *, + expected_num_reservations: int, + expected_num_instances: int, + expected_instance_type: InstanceTypeType, + expected_instance_state: InstanceStateNameType, + expected_instance_tag_keys: list[str], + expected_user_data: list[str], + instance_filters: Sequence[FilterTypeDef] | None = None, +) -> list[InstanceTypeDef]: + list_instances: list[InstanceTypeDef] = [] + all_instances = await ec2_client.describe_instances(Filters=instance_filters or []) + assert len(all_instances["Reservations"]) == expected_num_reservations + for reservation in all_instances["Reservations"]: + assert "Instances" in reservation + assert ( + len(reservation["Instances"]) == expected_num_instances + ), f"expected {expected_num_instances}, found {len(reservation['Instances'])}" + for instance in reservation["Instances"]: + assert "InstanceType" in instance + assert instance["InstanceType"] == expected_instance_type + assert "Tags" in instance + assert instance["Tags"] + expected_tag_keys = [ + *expected_instance_tag_keys, + "io.simcore.autoscaling.version", + "Name", + ] + instance_tag_keys = [tag["Key"] for tag in instance["Tags"] if "Key" in tag] + for tag_key in instance_tag_keys: + assert ( + tag_key in expected_tag_keys + ), f"instance has additional unexpected {tag_key=} vs {expected_tag_keys=}" + for tag in expected_instance_tag_keys: + assert ( + tag in instance_tag_keys + ), f"instance missing {tag=} vs {instance_tag_keys=}" + + assert "PrivateDnsName" in instance + instance_private_dns_name = instance["PrivateDnsName"] + assert instance_private_dns_name.endswith(".ec2.internal") + assert "State" in instance + state = instance["State"] + assert "Name" in state + assert state["Name"] == expected_instance_state + + assert "InstanceId" in instance + user_data = await ec2_client.describe_instance_attribute( + Attribute="userData", InstanceId=instance["InstanceId"] + ) + assert "UserData" in user_data + assert "Value" in user_data["UserData"] + user_data = base64.b64decode(user_data["UserData"]["Value"]).decode() + for user_data_string in expected_user_data: + assert user_data.count(user_data_string) == 1 + list_instances.append(instance) + return list_instances diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/moto.py b/packages/pytest-simcore/src/pytest_simcore/helpers/moto.py new file mode 100644 index 00000000000..65c589ba1b9 --- /dev/null +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/moto.py @@ -0,0 +1,70 @@ +# pylint:disable=unused-variable +# pylint:disable=unused-argument +# pylint:disable=redefined-outer-name +# pylint:disable=protected-access + + +import warnings +from copy import deepcopy +from typing import Any + +import aiobotocore.client + +# Original botocore _make_api_call function +orig = aiobotocore.client.AioBaseClient._make_api_call # noqa: SLF001 + + +def _patch_send_command(self, operation_name, api_params) -> Any: + # NOTE: send_command is not completely patched by moto, therefore we need this specific mock + # https://docs.getmoto.org/en/latest/docs/services/patching_other_services.html + # this might change with new versions of moto + warnings.warn( + "moto is missing SendCommand mock with InstanceIds as Targets, therefore it is manually mocked." + " TIP: periodically check if it gets updated https://docs.getmoto.org/en/latest/docs/services/ssm.html#ssm", + UserWarning, + stacklevel=1, + ) + + assert "Targets" in api_params, "Targets is missing in the API call" + assert ( + len(api_params["Targets"]) == 1 + ), "Targets for patched SendCommand should have only one item" + target_data = api_params["Targets"][0] + assert "Key" in target_data + assert "Values" in target_data + target_key = target_data["Key"] + assert ( + target_key == "InstanceIds" + ), "Targets for patched SendCommand should have InstanceIds as key" + instance_ids = target_data["Values"] + new_api_params = deepcopy(api_params) + new_api_params.pop("Targets") + new_api_params["InstanceIds"] = instance_ids + return orig(self, operation_name, new_api_params) + + +def _patch_describe_instance_information( + self, operation_name, api_params +) -> dict[str, Any]: + warnings.warn( + "moto is missing the describe_instance_information function, therefore it is manually mocked." + "TIP: periodically check if it gets updated https://docs.getmoto.org/en/latest/docs/services/ssm.html#ssm", + UserWarning, + stacklevel=1, + ) + return {"InstanceInformationList": [{"PingStatus": "Online"}]} + + +# Mocked aiobotocore _make_api_call function +async def patched_aiobotocore_make_api_call(self, operation_name, api_params): + # For example for the Access Analyzer service + # As you can see the operation_name has the list_analyzers snake_case form but + # we are using the ListAnalyzers form. + # Rationale -> https://github.com/boto/botocore/blob/develop/botocore/client.py#L810:L816 + if operation_name == "SendCommand": + return await _patch_send_command(self, operation_name, api_params) + if operation_name == "DescribeInstanceInformation": + return _patch_describe_instance_information(self, operation_name, api_params) + + # If we don't want to patch the API call + return await orig(self, operation_name, api_params) diff --git a/packages/service-library/src/servicelib/logging_utils.py b/packages/service-library/src/servicelib/logging_utils.py index 45f556a3169..3e1476fc59e 100644 --- a/packages/service-library/src/servicelib/logging_utils.py +++ b/packages/service-library/src/servicelib/logging_utils.py @@ -4,10 +4,10 @@ SEE also https://github.com/Delgan/loguru for a future alternative """ + import asyncio import functools import logging -import sys from asyncio import iscoroutinefunction from collections.abc import Callable from contextlib import contextmanager @@ -169,7 +169,9 @@ def _log_arguments( return extra_args -def log_decorator(logger=None, level: int = logging.DEBUG, log_traceback: bool = False): +def log_decorator( + logger=None, level: int = logging.DEBUG, *, log_traceback: bool = False +): # Build logger object logger_obj = logger or _logger @@ -187,9 +189,9 @@ async def log_decorator_wrapper(*args, **kwargs): ) except: # log exception if occurs in function - logger_obj.error( + logger_obj.log( + level, "Exception: %s", - sys.exc_info()[1], extra=extra_args, exc_info=log_traceback, ) @@ -210,9 +212,9 @@ def log_decorator_wrapper(*args, **kwargs): ) except: # log exception if occurs in function - logger_obj.error( + logger_obj.log( + level, "Exception: %s", - sys.exc_info()[1], extra=extra_args, exc_info=log_traceback, ) diff --git a/packages/service-library/tests/test_logging_utils.py b/packages/service-library/tests/test_logging_utils.py index 3284fe041ea..c32031dbeb0 100644 --- a/packages/service-library/tests/test_logging_utils.py +++ b/packages/service-library/tests/test_logging_utils.py @@ -22,9 +22,10 @@ async def test_error_regression_async_def( caplog: pytest.LogCaptureFixture, logger: logging.Logger | None, log_traceback: bool ): - @log_decorator(logger, log_traceback=log_traceback) + @log_decorator(logger, logging.ERROR, log_traceback=log_traceback) async def _raising_error() -> None: - raise RuntimeError("Raising as expected") + msg = "Raising as expected" + raise RuntimeError(msg) caplog.clear() @@ -41,9 +42,10 @@ async def _raising_error() -> None: async def test_error_regression_def( caplog: pytest.LogCaptureFixture, logger: logging.Logger | None, log_traceback: bool ): - @log_decorator(logger, log_traceback=log_traceback) + @log_decorator(logger, logging.ERROR, log_traceback=log_traceback) def _raising_error() -> None: - raise RuntimeError("Raising as expected") + msg = "Raising as expected" + raise RuntimeError(msg) caplog.clear() diff --git a/packages/settings-library/src/settings_library/ssm.py b/packages/settings-library/src/settings_library/ssm.py new file mode 100644 index 00000000000..32b965fa123 --- /dev/null +++ b/packages/settings-library/src/settings_library/ssm.py @@ -0,0 +1,26 @@ +from typing import Any, ClassVar + +from pydantic import AnyHttpUrl, Field, SecretStr + +from .base import BaseCustomSettings + + +class SSMSettings(BaseCustomSettings): + SSM_ACCESS_KEY_ID: SecretStr + SSM_ENDPOINT: AnyHttpUrl | None = Field( + default=None, description="do not define if using standard AWS" + ) + SSM_REGION_NAME: str = "us-east-1" + SSM_SECRET_ACCESS_KEY: SecretStr + + class Config(BaseCustomSettings.Config): + schema_extra: ClassVar[dict[str, Any]] = { # type: ignore[misc] + "examples": [ + { + "SSM_ACCESS_KEY_ID": "my_access_key_id", + "SSM_ENDPOINT": "https://my_ssm_endpoint.com", + "SSM_REGION_NAME": "us-east-1", + "SSM_SECRET_ACCESS_KEY": "my_secret_access_key", + } + ], + } diff --git a/scripts/maintenance/migrate_project/Makefile b/scripts/maintenance/migrate_project/Makefile index f78666a677b..252d4eda777 100644 --- a/scripts/maintenance/migrate_project/Makefile +++ b/scripts/maintenance/migrate_project/Makefile @@ -14,11 +14,7 @@ help: ## help on rule's targets # 💣 No postgres database version migration is performed at the moment. This migration **only works for **identical databases**: source and target. # 🚨 If a file's or project's UUID already exist in the destination database (collision), this script will fail with an error. # ✅ Supported S3 providers are `CEPH`, `AWS`, `MINIO` -ifeq ($(IS_WIN),) - @awk 'BEGIN {FS = ":.*?## "} /^[[:alpha:][:space:]_-]+:.*?## / {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) -else @awk 'BEGIN {FS = ":.*?## "} /^[[:alpha:][:space:]_-]+:.*?## / {printf "%-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST) -endif .PHONY: build build: ## Builds docker container for the migration. Run this first. diff --git a/services/autoscaling/tests/unit/conftest.py b/services/autoscaling/tests/unit/conftest.py index 5a1fb3d1d64..1b01f6e018f 100644 --- a/services/autoscaling/tests/unit/conftest.py +++ b/services/autoscaling/tests/unit/conftest.py @@ -52,6 +52,7 @@ from simcore_service_autoscaling.core.settings import ( AUTOSCALING_ENV_PREFIX, ApplicationSettings, + AutoscalingEC2Settings, EC2Settings, ) from simcore_service_autoscaling.models import ( @@ -143,6 +144,11 @@ def with_labelize_drain_nodes( ) +@pytest.fixture +def ec2_settings() -> EC2Settings: + return AutoscalingEC2Settings.create_from_envs() + + @pytest.fixture def app_environment( mock_env_devel_environment: EnvVarsDict, diff --git a/services/clusters-keeper/tests/unit/conftest.py b/services/clusters-keeper/tests/unit/conftest.py index 31d9575a2bd..2fe76b7f66e 100644 --- a/services/clusters-keeper/tests/unit/conftest.py +++ b/services/clusters-keeper/tests/unit/conftest.py @@ -86,6 +86,11 @@ def mocked_ec2_server_envs( return setenvs_from_dict(monkeypatch, changed_envs) +@pytest.fixture +def ec2_settings(mocked_ec2_server_settings: EC2Settings) -> EC2Settings: + return mocked_ec2_server_settings + + @pytest.fixture def app_environment( mock_env_devel_environment: EnvVarsDict,