diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4d6aba9f..c1c06226 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,8 +23,8 @@ jobs: markdown-link-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@master - - uses: gaurav-nelson/github-action-markdown-link-check@v1 + - uses: actions/checkout@v4 + - uses: tcort/github-action-markdown-link-check@v1 with: use-quiet-mode: 'yes' folder-path: 'docs' diff --git a/aws_advanced_python_wrapper/aws_credentials_manager.py b/aws_advanced_python_wrapper/aws_credentials_manager.py new file mode 100644 index 00000000..82c4b2b1 --- /dev/null +++ b/aws_advanced_python_wrapper/aws_credentials_manager.py @@ -0,0 +1,99 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from threading import Lock +from typing import TYPE_CHECKING, Any, Callable, Optional + +from boto3 import Session + +from aws_advanced_python_wrapper.utils.messages import Messages +from aws_advanced_python_wrapper.utils.properties import WrapperProperties + +if TYPE_CHECKING: + from aws_advanced_python_wrapper.hostinfo import HostInfo + from aws_advanced_python_wrapper.utils.properties import Properties + + +class AwsCredentialsManager: + _handler: Optional[Callable[[HostInfo, Properties], Optional[Session]]] = None + _lock = Lock() + _sessions: dict[str, Session] = {} + _clients: dict[str, Any] = {} + + @staticmethod + def set_custom_handler(custom_handler: Callable[[HostInfo, Properties], Optional[Session]]) -> None: + if not callable(custom_handler): + raise TypeError("custom_handler must be callable") + with AwsCredentialsManager._lock: + AwsCredentialsManager._handler = custom_handler + + @staticmethod + def reset_custom_handler() -> None: + with AwsCredentialsManager._lock: + AwsCredentialsManager._handler = None + + @staticmethod + def get_session(host_info: HostInfo, props: Properties, region: str) -> Session: + host_key = f'{host_info.as_alias()}{region}' + + handler = None + with AwsCredentialsManager._lock: + if host_key in AwsCredentialsManager._sessions: + return AwsCredentialsManager._sessions[host_key] + handler = AwsCredentialsManager._handler + + # Initialize session outside of lock. + session = handler(host_info, props) if handler else None + + if session is not None and not isinstance(session, type(Session())): + raise TypeError(Messages.get_formatted("AwsCredentialsManager.InvalidHandler", type(session).__name__)) + + if session is None: + profile_name = WrapperProperties.AWS_PROFILE.get(props) + session = Session(profile_name=profile_name, region_name=region) if profile_name else Session(region_name=region) + + with AwsCredentialsManager._lock: + if host_key not in AwsCredentialsManager._sessions: + AwsCredentialsManager._sessions[host_key] = session + return AwsCredentialsManager._sessions[host_key] + + @staticmethod + def get_client(service_name: str, session: Session, host: Optional[str], region: Optional[str], endpoint_url: Optional[str] = None): + key = f'{host}{region}{service_name}{endpoint_url}' + + with AwsCredentialsManager._lock: + if key in AwsCredentialsManager._clients: + return AwsCredentialsManager._clients[key] + + # Initialize client outside of lock. + if endpoint_url: + client = session.client(service_name=service_name, endpoint_url=endpoint_url) # type: ignore[call-overload] + else: + client = session.client(service_name=service_name) # type: ignore[call-overload] + + with AwsCredentialsManager._lock: + if key not in AwsCredentialsManager._clients: + AwsCredentialsManager._clients[key] = client + return AwsCredentialsManager._clients[key] + + @staticmethod + def release_resources() -> None: + with AwsCredentialsManager._lock: + for key, client in AwsCredentialsManager._clients.items(): + client.close() + AwsCredentialsManager._clients.clear() + AwsCredentialsManager._sessions.clear() + return None diff --git a/aws_advanced_python_wrapper/aws_secrets_manager_plugin.py b/aws_advanced_python_wrapper/aws_secrets_manager_plugin.py index b225c92a..6ae7e00d 100644 --- a/aws_advanced_python_wrapper/aws_secrets_manager_plugin.py +++ b/aws_advanced_python_wrapper/aws_secrets_manager_plugin.py @@ -19,9 +19,10 @@ from types import SimpleNamespace from typing import TYPE_CHECKING, Callable, Optional, Set, Tuple -import boto3 from botocore.exceptions import ClientError, EndpointConnectionError +from aws_advanced_python_wrapper.aws_credentials_manager import \ + AwsCredentialsManager from aws_advanced_python_wrapper.utils.cache_map import CacheMap if TYPE_CHECKING: @@ -86,7 +87,7 @@ def connect( props: Properties, is_initial_connection: bool, connect_func: Callable) -> Connection: - return self._connect(props, connect_func) + return self._connect(host_info, props, connect_func) def force_connect( self, @@ -96,16 +97,16 @@ def force_connect( props: Properties, is_initial_connection: bool, force_connect_func: Callable) -> Connection: - return self._connect(props, force_connect_func) + return self._connect(host_info, props, force_connect_func) - def _connect(self, props: Properties, connect_func: Callable) -> Connection: + def _connect(self, host_info: HostInfo, props: Properties, connect_func: Callable) -> Connection: token_expiration_sec: int = WrapperProperties.SECRETS_MANAGER_EXPIRATION.get_int(props) # if value is less than 0, default to one year if token_expiration_sec < 0: token_expiration_sec = AwsSecretsManagerPlugin._ONE_YEAR_IN_SECONDS token_expiration_ns = token_expiration_sec * 1_000_000_000 - secret_fetched: bool = self._update_secret(token_expiration_ns=token_expiration_ns) + secret_fetched: bool = self._update_secret(host_info, props, token_expiration_ns=token_expiration_ns) try: self._apply_secret_to_properties(props) @@ -116,7 +117,7 @@ def _connect(self, props: Properties, connect_func: Callable) -> Connection: raise AwsWrapperError( Messages.get_formatted("AwsSecretsManagerPlugin.ConnectException", e)) from e - secret_fetched = self._update_secret(token_expiration_ns=token_expiration_ns, force_refetch=True) + secret_fetched = self._update_secret(host_info, props, token_expiration_ns=token_expiration_ns, force_refetch=True) if secret_fetched: try: @@ -128,7 +129,7 @@ def _connect(self, props: Properties, connect_func: Callable) -> Connection: unhandled_error)) from unhandled_error raise AwsWrapperError(Messages.get_formatted("AwsSecretsManagerPlugin.FailedLogin", e)) from e - def _update_secret(self, token_expiration_ns: int, force_refetch: bool = False) -> bool: + def _update_secret(self, host_info: HostInfo, props: Properties, token_expiration_ns: int, force_refetch: bool = False) -> bool: """ Called to update credentials from the cache, or from the AWS Secrets Manager service. :param token_expiration_ns: Expiration time in nanoseconds for secret stored in cache. @@ -146,7 +147,7 @@ def _update_secret(self, token_expiration_ns: int, force_refetch: bool = False) endpoint = self._secret_key[2] if not self._secret or force_refetch: try: - self._secret = self._fetch_latest_credentials() + self._secret = self._fetch_latest_credentials(host_info, props) if self._secret: AwsSecretsManagerPlugin._secrets_cache.put(self._secret_key, self._secret, token_expiration_ns) fetched = True @@ -177,26 +178,19 @@ def _update_secret(self, token_expiration_ns: int, force_refetch: bool = False) if context is not None: context.close_context() - def _fetch_latest_credentials(self): + def _fetch_latest_credentials(self, host_info: HostInfo, props: Properties): """ Fetches the current credentials from AWS Secrets Manager service. :return: a Secret object containing the credentials fetched from the AWS Secrets Manager service. """ - session = self._session if self._session else boto3.Session() - - client = session.client( - 'secretsmanager', - region_name=self._secret_key[1], - endpoint_url=self._secret_key[2], - ) + session = AwsCredentialsManager.get_session(host_info, props, self._secret_key[1]) + client = AwsCredentialsManager.get_client("secretsmanager", session, host_info.host, self._secret_key[1], self._secret_key[2]) secret = client.get_secret_value( SecretId=self._secret_key[0], ) - client.close() - return loads(secret.get("SecretString"), object_hook=lambda d: SimpleNamespace(**d)) def _apply_secret_to_properties(self, properties: Properties): diff --git a/aws_advanced_python_wrapper/cleanup.py b/aws_advanced_python_wrapper/cleanup.py index 1327bc47..775a2ae4 100644 --- a/aws_advanced_python_wrapper/cleanup.py +++ b/aws_advanced_python_wrapper/cleanup.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from aws_advanced_python_wrapper.aws_credentials_manager import \ + AwsCredentialsManager from aws_advanced_python_wrapper.host_monitoring_plugin import \ MonitoringThreadContainer from aws_advanced_python_wrapper.thread_pool_container import \ @@ -22,3 +24,4 @@ def release_resources() -> None: """Release all global resources used by the wrapper.""" MonitoringThreadContainer.clean_up() ThreadPoolContainer.release_resources() + AwsCredentialsManager.release_resources() diff --git a/aws_advanced_python_wrapper/credentials_provider_factory.py b/aws_advanced_python_wrapper/credentials_provider_factory.py index 71a76e6f..443c4892 100644 --- a/aws_advanced_python_wrapper/credentials_provider_factory.py +++ b/aws_advanced_python_wrapper/credentials_provider_factory.py @@ -16,32 +16,29 @@ from typing import TYPE_CHECKING, Dict, Optional, Protocol -import boto3 - if TYPE_CHECKING: + from aws_advanced_python_wrapper.hostinfo import HostInfo from aws_advanced_python_wrapper.utils.properties import Properties from abc import abstractmethod +from aws_advanced_python_wrapper.aws_credentials_manager import \ + AwsCredentialsManager from aws_advanced_python_wrapper.utils.properties import WrapperProperties class CredentialsProviderFactory(Protocol): @abstractmethod - def get_aws_credentials(self, region: str, props: Properties) -> Optional[Dict[str, str]]: + def get_aws_credentials(self, region: str, props: Properties, host_info: HostInfo) -> Optional[Dict[str, str]]: ... class SamlCredentialsProviderFactory(CredentialsProviderFactory): - def get_aws_credentials(self, region: str, props: Properties) -> Optional[Dict[str, str]]: + def get_aws_credentials(self, region: str, props: Properties, host_info: HostInfo) -> Optional[Dict[str, str]]: saml_assertion: str = self.get_saml_assertion(props) - session = boto3.Session() - - sts_client = session.client( - 'sts', - region_name=region - ) + session = AwsCredentialsManager.get_session(host_info, props, region) + sts_client = AwsCredentialsManager.get_client("sts", session, host_info.host, region) response: Dict[str, Dict[str, str]] = sts_client.assume_role_with_saml( RoleArn=WrapperProperties.IAM_ROLE_ARN.get(props), diff --git a/aws_advanced_python_wrapper/custom_endpoint_plugin.py b/aws_advanced_python_wrapper/custom_endpoint_plugin.py index 2db46ae5..6684dc06 100644 --- a/aws_advanced_python_wrapper/custom_endpoint_plugin.py +++ b/aws_advanced_python_wrapper/custom_endpoint_plugin.py @@ -232,7 +232,7 @@ class CustomEndpointPlugin(Plugin): or removing an instance in the custom endpoint. """ _SUBSCRIBED_METHODS: ClassVar[Set[str]] = {DbApiMethod.CONNECT.method_name} - _CACHE_CLEANUP_RATE_NS: ClassVar[int] = 6 * 10 ^ 10 # 1 minute + _CACHE_CLEANUP_RATE_NS: ClassVar[int] = 60_000_000_000 # 1 minute _monitors: ClassVar[SlidingExpirationCacheWithCleanupThread[str, CustomEndpointMonitor]] = \ SlidingExpirationCacheWithCleanupThread(_CACHE_CLEANUP_RATE_NS, should_dispose_func=lambda _: True, diff --git a/aws_advanced_python_wrapper/fastest_response_strategy_plugin.py b/aws_advanced_python_wrapper/fastest_response_strategy_plugin.py index da963fcf..a12f2579 100644 --- a/aws_advanced_python_wrapper/fastest_response_strategy_plugin.py +++ b/aws_advanced_python_wrapper/fastest_response_strategy_plugin.py @@ -59,7 +59,7 @@ def __init__(self, plugin_service: PluginService, props: Properties): self._properties = props self._host_response_time_service: HostResponseTimeService = \ HostResponseTimeService(plugin_service, props, WrapperProperties.RESPONSE_MEASUREMENT_INTERVAL_MS.get_int(props)) - self._cache_expiration_nanos = WrapperProperties.RESPONSE_MEASUREMENT_INTERVAL_MS.get_int(props) * 10 ^ 6 + self._cache_expiration_nanos = WrapperProperties.RESPONSE_MEASUREMENT_INTERVAL_MS.get_int(props) * 1_000_000 self._random_host_selector = RandomHostSelector() self._cached_fastest_response_host_by_role: CacheMap[str, HostInfo] = CacheMap() self._hosts: Tuple[HostInfo, ...] = () @@ -278,8 +278,8 @@ def _open_connection(self): class HostResponseTimeService: - _CACHE_EXPIRATION_NS: int = 6 * 10 ^ 11 # 10 minutes - _CACHE_CLEANUP_NS: int = 6 * 10 ^ 10 # 1 minute + _CACHE_EXPIRATION_NS: int = 10 * 60_000_000_000 # 10 minutes + _CACHE_CLEANUP_NS: int = 60_000_000_000 # 1 minute _lock: Lock = Lock() _monitoring_hosts: ClassVar[SlidingExpirationCacheWithCleanupThread[str, HostResponseTimeMonitor]] = \ SlidingExpirationCacheWithCleanupThread(_CACHE_CLEANUP_NS, diff --git a/aws_advanced_python_wrapper/federated_plugin.py b/aws_advanced_python_wrapper/federated_plugin.py index a83b3652..c37e0246 100644 --- a/aws_advanced_python_wrapper/federated_plugin.py +++ b/aws_advanced_python_wrapper/federated_plugin.py @@ -14,11 +14,14 @@ from __future__ import annotations +from copy import deepcopy from html import unescape from re import DOTALL, findall, search from typing import TYPE_CHECKING, List from urllib.parse import urlencode +from aws_advanced_python_wrapper.aws_credentials_manager import \ + AwsCredentialsManager from aws_advanced_python_wrapper.credentials_provider_factory import ( CredentialsProviderFactory, SamlCredentialsProviderFactory) from aws_advanced_python_wrapper.utils.iam_utils import IamAuthUtils, TokenInfo @@ -26,7 +29,6 @@ from aws_advanced_python_wrapper.utils.saml_utils import SamlUtils if TYPE_CHECKING: - from boto3 import Session from aws_advanced_python_wrapper.driver_dialect import DriverDialect from aws_advanced_python_wrapper.hostinfo import HostInfo from aws_advanced_python_wrapper.pep249 import Connection @@ -55,10 +57,9 @@ class FederatedAuthPlugin(Plugin): _rds_utils: RdsUtils = RdsUtils() _token_cache: Dict[str, TokenInfo] = {} - def __init__(self, plugin_service: PluginService, credentials_provider_factory: CredentialsProviderFactory, session: Optional[Session] = None): + def __init__(self, plugin_service: PluginService, credentials_provider_factory: CredentialsProviderFactory): self._plugin_service = plugin_service self._credentials_provider_factory = credentials_provider_factory - self._session = session self._region_utils = RegionUtils() telemetry_factory = self._plugin_service.get_telemetry_factory() @@ -100,11 +101,13 @@ def _connect(self, host_info: HostInfo, props: Properties, connect_func: Callabl token_info: Optional[TokenInfo] = FederatedAuthPlugin._token_cache.get(cache_key) + token_host_info = deepcopy(host_info) + token_host_info.host = host if token_info is not None and not token_info.is_expired(): logger.debug("FederatedAuthPlugin.UseCachedToken", token_info.token) self._plugin_service.driver_dialect.set_password(props, token_info.token) else: - self._update_authentication_token(host_info, props, user, region, cache_key) + self._update_authentication_token(token_host_info, props, user, region, cache_key) WrapperProperties.USER.set(props, WrapperProperties.DB_USER.get(props)) @@ -114,7 +117,7 @@ def _connect(self, host_info: HostInfo, props: Properties, connect_func: Callabl if token_info is None or token_info.is_expired() or not self._plugin_service.is_login_exception(e): raise e - self._update_authentication_token(host_info, props, user, region, cache_key) + self._update_authentication_token(token_host_info, props, user, region, cache_key) try: return connect_func() @@ -142,18 +145,19 @@ def _update_authentication_token(self, token_expiration_sec: int = WrapperProperties.IAM_TOKEN_EXPIRATION.get_int(props) token_expiry: datetime = datetime.now() + timedelta(seconds=token_expiration_sec) port: int = IamAuthUtils.get_port(props, host_info, self._plugin_service.database_dialect.default_port) - credentials: Optional[Dict[str, str]] = self._credentials_provider_factory.get_aws_credentials(region, props) + credentials: Optional[Dict[str, str]] = self._credentials_provider_factory.get_aws_credentials(region, props, host_info) if self._fetch_token_counter is not None: self._fetch_token_counter.inc() + session = AwsCredentialsManager.get_session(host_info, props, region) token: str = IamAuthUtils.generate_authentication_token( self._plugin_service, user, host_info.host, port, region, - credentials, - self._session) + session, + credentials) WrapperProperties.PASSWORD.set(props, token) FederatedAuthPlugin._token_cache[cache_key] = TokenInfo(token, token_expiry) diff --git a/aws_advanced_python_wrapper/iam_plugin.py b/aws_advanced_python_wrapper/iam_plugin.py index f42ef92c..2293b7c6 100644 --- a/aws_advanced_python_wrapper/iam_plugin.py +++ b/aws_advanced_python_wrapper/iam_plugin.py @@ -14,20 +14,22 @@ from __future__ import annotations +from copy import deepcopy from typing import TYPE_CHECKING +from aws_advanced_python_wrapper.aws_credentials_manager import \ + AwsCredentialsManager from aws_advanced_python_wrapper.utils.iam_utils import IamAuthUtils, TokenInfo from aws_advanced_python_wrapper.utils.region_utils import RegionUtils if TYPE_CHECKING: - from boto3 import Session from aws_advanced_python_wrapper.driver_dialect import DriverDialect from aws_advanced_python_wrapper.hostinfo import HostInfo from aws_advanced_python_wrapper.pep249 import Connection from aws_advanced_python_wrapper.plugin_service import PluginService from datetime import datetime, timedelta -from typing import Callable, Dict, Optional, Set +from typing import Callable, Dict, Set from aws_advanced_python_wrapper.errors import AwsWrapperError from aws_advanced_python_wrapper.pep249_methods import DbApiMethod @@ -49,9 +51,8 @@ class IamAuthPlugin(Plugin): _rds_utils: RdsUtils = RdsUtils() _token_cache: Dict[str, TokenInfo] = {} - def __init__(self, plugin_service: PluginService, session: Optional[Session] = None): + def __init__(self, plugin_service: PluginService): self._plugin_service = plugin_service - self._session = session self._region_utils = RegionUtils() telemetry_factory = self._plugin_service.get_telemetry_factory() @@ -104,7 +105,17 @@ def _connect(self, host_info: HostInfo, props: Properties, connect_func: Callabl token_expiry = datetime.now() + timedelta(seconds=token_expiration_sec) if self._fetch_token_counter is not None: self._fetch_token_counter.inc() - token: str = IamAuthUtils.generate_authentication_token(self._plugin_service, user, host, port, region, client_session=self._session) + + session_host_info = deepcopy(host_info) + session_host_info.host = host + session = AwsCredentialsManager.get_session(host_info, props, region) + token: str = IamAuthUtils.generate_authentication_token( + self._plugin_service, + user, + host, + port, + region, + session) self._plugin_service.driver_dialect.set_password(props, token) IamAuthPlugin._token_cache[cache_key] = TokenInfo(token, token_expiry) @@ -123,7 +134,11 @@ def _connect(self, host_info: HostInfo, props: Properties, connect_func: Callabl token_expiry = datetime.now() + timedelta(seconds=token_expiration_sec) if self._fetch_token_counter is not None: self._fetch_token_counter.inc() - token = IamAuthUtils.generate_authentication_token(self._plugin_service, user, host, port, region, client_session=self._session) + + session_host_info = deepcopy(host_info) + session_host_info.host = host + session = AwsCredentialsManager.get_session(session_host_info, props, region) + token = IamAuthUtils.generate_authentication_token(self._plugin_service, user, host, port, region, session) self._plugin_service.driver_dialect.set_password(props, token) IamAuthPlugin._token_cache[cache_key] = TokenInfo(token, token_expiry) diff --git a/aws_advanced_python_wrapper/limitless_plugin.py b/aws_advanced_python_wrapper/limitless_plugin.py index 85cb15da..48e0e8dc 100644 --- a/aws_advanced_python_wrapper/limitless_plugin.py +++ b/aws_advanced_python_wrapper/limitless_plugin.py @@ -312,7 +312,7 @@ def is_any_router_available(self): class LimitlessRouterService: - _CACHE_CLEANUP_NS: int = 6 * 10 ^ 10 # 1 minute + _CACHE_CLEANUP_NS: int = 60_000_000_000 # 1 minute _limitless_router_cache: ClassVar[SlidingExpirationCacheWithCleanupThread[str, List[HostInfo]]] = \ SlidingExpirationCacheWithCleanupThread(_CACHE_CLEANUP_NS) diff --git a/aws_advanced_python_wrapper/okta_plugin.py b/aws_advanced_python_wrapper/okta_plugin.py index d9caf6e2..d2b0cecc 100644 --- a/aws_advanced_python_wrapper/okta_plugin.py +++ b/aws_advanced_python_wrapper/okta_plugin.py @@ -14,11 +14,14 @@ from __future__ import annotations +from copy import deepcopy from datetime import datetime, timedelta from html import unescape from re import search from typing import TYPE_CHECKING, Callable, Dict, Optional, Set +from aws_advanced_python_wrapper.aws_credentials_manager import \ + AwsCredentialsManager from aws_advanced_python_wrapper.credentials_provider_factory import ( CredentialsProviderFactory, SamlCredentialsProviderFactory) from aws_advanced_python_wrapper.utils.iam_utils import IamAuthUtils, TokenInfo @@ -26,7 +29,6 @@ from aws_advanced_python_wrapper.utils.saml_utils import SamlUtils if TYPE_CHECKING: - from boto3 import Session from aws_advanced_python_wrapper.driver_dialect import DriverDialect from aws_advanced_python_wrapper.hostinfo import HostInfo from aws_advanced_python_wrapper.pep249 import Connection @@ -51,10 +53,9 @@ class OktaAuthPlugin(Plugin): _rds_utils: RdsUtils = RdsUtils() _token_cache: Dict[str, TokenInfo] = {} - def __init__(self, plugin_service: PluginService, credentials_provider_factory: CredentialsProviderFactory, session: Optional[Session] = None): + def __init__(self, plugin_service: PluginService, credentials_provider_factory: CredentialsProviderFactory): self._plugin_service = plugin_service self._credentials_provider_factory = credentials_provider_factory - self._session = session self._region_utils = RegionUtils() telemetry_factory = self._plugin_service.get_telemetry_factory() @@ -96,11 +97,13 @@ def _connect(self, host_info: HostInfo, props: Properties, connect_func: Callabl token_info: Optional[TokenInfo] = OktaAuthPlugin._token_cache.get(cache_key) + token_host_info = deepcopy(host_info) + token_host_info.host = host if token_info is not None and not token_info.is_expired(): logger.debug("OktaAuthPlugin.UseCachedToken", token_info.token) self._plugin_service.driver_dialect.set_password(props, token_info.token) else: - self._update_authentication_token(host_info, props, user, region, cache_key) + self._update_authentication_token(token_host_info, props, user, region, cache_key) WrapperProperties.USER.set(props, WrapperProperties.DB_USER.get(props)) @@ -110,7 +113,7 @@ def _connect(self, host_info: HostInfo, props: Properties, connect_func: Callabl if token_info is None or token_info.is_expired() or not self._plugin_service.is_login_exception(e): raise e - self._update_authentication_token(host_info, props, user, region, cache_key) + self._update_authentication_token(token_host_info, props, user, region, cache_key) try: return connect_func() @@ -138,18 +141,18 @@ def _update_authentication_token(self, token_expiration_sec: int = WrapperProperties.IAM_TOKEN_EXPIRATION.get_int(props) token_expiry: datetime = datetime.now() + timedelta(seconds=token_expiration_sec) port: int = IamAuthUtils.get_port(props, host_info, self._plugin_service.database_dialect.default_port) - credentials: Optional[Dict[str, str]] = self._credentials_provider_factory.get_aws_credentials(region, props) + credentials: Optional[Dict[str, str]] = self._credentials_provider_factory.get_aws_credentials(region, props, host_info) if self._fetch_token_counter: self._fetch_token_counter.inc() + session = AwsCredentialsManager.get_session(host_info, props, region) token: str = IamAuthUtils.generate_authentication_token( self._plugin_service, user, host_info.host, port, region, - credentials, - self._session - ) + session, + credentials) WrapperProperties.PASSWORD.set(props, token) OktaAuthPlugin._token_cache[cache_key] = TokenInfo(token, token_expiry) diff --git a/aws_advanced_python_wrapper/resources/aws_advanced_python_wrapper_messages.properties b/aws_advanced_python_wrapper/resources/aws_advanced_python_wrapper_messages.properties index ecb5fcab..bafd1aa2 100644 --- a/aws_advanced_python_wrapper/resources/aws_advanced_python_wrapper_messages.properties +++ b/aws_advanced_python_wrapper/resources/aws_advanced_python_wrapper_messages.properties @@ -26,6 +26,8 @@ AdfsCredentialsProviderFactory.SignOnPagePostActionUrl=[AdfsCredentialsProviderF AdfsCredentialsProviderFactory.SignOnPagePostActionRequestFailed=[AdfsCredentialsProviderFactory] ADFS SignOn Page POST action failed with HTTP status '{}', reason phrase '{}', and response '{}' AdfsCredentialsProviderFactory.SignOnPageUrl=[AdfsCredentialsProviderFactory] ADFS SignOn URL: '{}' +AwsCredentialsManager.InvalidHandler=[AwsCredentialsManager] Custom credentials provider set via AwsCredentialsManager.set_custom_handler must return a boto3.Session or None, got '{}'. + AwsSdk.UnsupportedRegion=[AwsSdk] Unsupported AWS region {}. For supported regions please read https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html AwsSecretsManagerPlugin.ConnectException=[AwsSecretsManagerPlugin] Error occurred while opening a connection: {} diff --git a/aws_advanced_python_wrapper/utils/iam_utils.py b/aws_advanced_python_wrapper/utils/iam_utils.py index 3eb312ca..9cc11670 100644 --- a/aws_advanced_python_wrapper/utils/iam_utils.py +++ b/aws_advanced_python_wrapper/utils/iam_utils.py @@ -17,8 +17,8 @@ from datetime import datetime from typing import TYPE_CHECKING, Dict, Optional -import boto3 - +from aws_advanced_python_wrapper.aws_credentials_manager import \ + AwsCredentialsManager from aws_advanced_python_wrapper.errors import AwsWrapperError from aws_advanced_python_wrapper.utils.log import Logger from aws_advanced_python_wrapper.utils.messages import Messages @@ -77,16 +77,14 @@ def generate_authentication_token( host_name: Optional[str], port: Optional[int], region: Optional[str], - credentials: Optional[Dict[str, str]] = None, - client_session: Optional[Session] = None) -> str: + client_session: Session, + credentials: Optional[Dict[str, str]] = None) -> str: telemetry_factory = plugin_service.get_telemetry_factory() context = telemetry_factory.open_telemetry_context("fetch authentication token", TelemetryTraceLevel.NESTED) try: - session = client_session if client_session else boto3.Session() - if credentials is not None: - client = session.client( + client = client_session.client( 'rds', region_name=region, aws_access_key_id=credentials.get('AccessKeyId'), @@ -94,10 +92,7 @@ def generate_authentication_token( aws_session_token=credentials.get('SessionToken') ) else: - client = session.client( - 'rds', - region_name=region - ) + client = AwsCredentialsManager.get_client("rds", client_session, host_name, region) token = client.generate_db_auth_token( DBHostname=host_name, diff --git a/aws_advanced_python_wrapper/utils/properties.py b/aws_advanced_python_wrapper/utils/properties.py index e4dfcdf5..d62f923b 100644 --- a/aws_advanced_python_wrapper/utils/properties.py +++ b/aws_advanced_python_wrapper/utils/properties.py @@ -153,6 +153,10 @@ class WrapperProperties: pattern will be automatically created for AWS RDS clusters.""", ) + AWS_PROFILE = WrapperProperty( + "aws_profile", "Name of the AWS Profile to use for AWS authentication." + ) + IAM_HOST = WrapperProperty( "iam_host", "Overrides the host that is used to generate the IAM token." ) diff --git a/docs/examples/PGAwsCredentialsManager.py b/docs/examples/PGAwsCredentialsManager.py new file mode 100644 index 00000000..63f4397e --- /dev/null +++ b/docs/examples/PGAwsCredentialsManager.py @@ -0,0 +1,59 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +import boto3 +import psycopg + +from aws_advanced_python_wrapper import AwsWrapperConnection, release_resources +from aws_advanced_python_wrapper.aws_credentials_manager import \ + AwsCredentialsManager + + +def custom_credentials_handler(host_info, props): + return boto3.Session( + aws_access_key_id=os.environ.get('AWS_ACCESS_KEY_ID'), + aws_secret_access_key=os.environ.get('AWS_SECRET_ACCESS_KEY'), + aws_session_token=os.environ.get('AWS_SESSION_TOKEN') + ) + + +if __name__ == "__main__": + + # Use a custom boto3 Session with a specific set of credentials + AwsCredentialsManager.set_custom_handler(custom_credentials_handler) + + try: + with AwsWrapperConnection.connect( + psycopg.Connection.connect, + host="database.cluster-xyz.us-east-1.rds.amazonaws.com", + dbname="postgres", + user="john", + plugins="iam", + wrapper_dialect="aurora-pg", + autocommit=True + ) as awsconn, awsconn.cursor() as awscursor: + awscursor.execute("CREATE TABLE IF NOT EXISTS bank_test (id int primary key, name varchar(40), account_balance int)") + awscursor.execute("INSERT INTO bank_test VALUES (%s, %s, %s)", (0, "Jane Doe", 200)) + awscursor.execute("INSERT INTO bank_test VALUES (%s, %s, %s)", (1, "John Smith", 200)) + awscursor.execute("SELECT * FROM bank_test") + + res = awscursor.fetchall() + for record in res: + print(record) + awscursor.execute("DROP TABLE bank_test") + finally: + # Clean up global resources created by wrapper + release_resources() diff --git a/poetry.lock b/poetry.lock index 0ced2492..596a61ef 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "aws-xray-sdk" @@ -58,6 +58,433 @@ s3transfer = ">=0.11.0,<0.12.0" [package.extras] crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] +[[package]] +name = "boto3-stubs" +version = "1.37.38" +description = "Type annotations for boto3 1.37.38 generated with mypy-boto3-builder 8.10.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "boto3_stubs-1.37.38-py3-none-any.whl", hash = "sha256:78418c10b43f1b45d877213a085acac7bcdb23e9c0ab294af04dffe9fc4310b5"}, + {file = "boto3_stubs-1.37.38.tar.gz", hash = "sha256:d78c2de88e9f1a60bef05cfad5b8edc051f1762be0865c83bebe716448f56510"}, +] + +[package.dependencies] +botocore-stubs = "*" +types-s3transfer = "*" +typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.12\""} + +[package.extras] +accessanalyzer = ["mypy-boto3-accessanalyzer (>=1.37.0,<1.38.0)"] +account = ["mypy-boto3-account (>=1.37.0,<1.38.0)"] +acm = ["mypy-boto3-acm (>=1.37.0,<1.38.0)"] +acm-pca = ["mypy-boto3-acm-pca (>=1.37.0,<1.38.0)"] +all = ["mypy-boto3-accessanalyzer (>=1.37.0,<1.38.0)", "mypy-boto3-account (>=1.37.0,<1.38.0)", "mypy-boto3-acm (>=1.37.0,<1.38.0)", "mypy-boto3-acm-pca (>=1.37.0,<1.38.0)", "mypy-boto3-amp (>=1.37.0,<1.38.0)", "mypy-boto3-amplify (>=1.37.0,<1.38.0)", "mypy-boto3-amplifybackend (>=1.37.0,<1.38.0)", "mypy-boto3-amplifyuibuilder (>=1.37.0,<1.38.0)", "mypy-boto3-apigateway (>=1.37.0,<1.38.0)", "mypy-boto3-apigatewaymanagementapi (>=1.37.0,<1.38.0)", "mypy-boto3-apigatewayv2 (>=1.37.0,<1.38.0)", "mypy-boto3-appconfig (>=1.37.0,<1.38.0)", "mypy-boto3-appconfigdata (>=1.37.0,<1.38.0)", "mypy-boto3-appfabric (>=1.37.0,<1.38.0)", "mypy-boto3-appflow (>=1.37.0,<1.38.0)", "mypy-boto3-appintegrations (>=1.37.0,<1.38.0)", "mypy-boto3-application-autoscaling (>=1.37.0,<1.38.0)", "mypy-boto3-application-insights (>=1.37.0,<1.38.0)", "mypy-boto3-application-signals (>=1.37.0,<1.38.0)", "mypy-boto3-applicationcostprofiler (>=1.37.0,<1.38.0)", "mypy-boto3-appmesh (>=1.37.0,<1.38.0)", "mypy-boto3-apprunner (>=1.37.0,<1.38.0)", "mypy-boto3-appstream (>=1.37.0,<1.38.0)", "mypy-boto3-appsync (>=1.37.0,<1.38.0)", "mypy-boto3-apptest (>=1.37.0,<1.38.0)", "mypy-boto3-arc-zonal-shift (>=1.37.0,<1.38.0)", "mypy-boto3-artifact (>=1.37.0,<1.38.0)", "mypy-boto3-athena (>=1.37.0,<1.38.0)", "mypy-boto3-auditmanager (>=1.37.0,<1.38.0)", "mypy-boto3-autoscaling (>=1.37.0,<1.38.0)", "mypy-boto3-autoscaling-plans (>=1.37.0,<1.38.0)", "mypy-boto3-b2bi (>=1.37.0,<1.38.0)", "mypy-boto3-backup (>=1.37.0,<1.38.0)", "mypy-boto3-backup-gateway (>=1.37.0,<1.38.0)", "mypy-boto3-backupsearch (>=1.37.0,<1.38.0)", "mypy-boto3-batch (>=1.37.0,<1.38.0)", "mypy-boto3-bcm-data-exports (>=1.37.0,<1.38.0)", "mypy-boto3-bcm-pricing-calculator (>=1.37.0,<1.38.0)", "mypy-boto3-bedrock (>=1.37.0,<1.38.0)", "mypy-boto3-bedrock-agent (>=1.37.0,<1.38.0)", "mypy-boto3-bedrock-agent-runtime (>=1.37.0,<1.38.0)", "mypy-boto3-bedrock-data-automation (>=1.37.0,<1.38.0)", "mypy-boto3-bedrock-data-automation-runtime (>=1.37.0,<1.38.0)", "mypy-boto3-bedrock-runtime (>=1.37.0,<1.38.0)", "mypy-boto3-billing (>=1.37.0,<1.38.0)", "mypy-boto3-billingconductor (>=1.37.0,<1.38.0)", "mypy-boto3-braket (>=1.37.0,<1.38.0)", "mypy-boto3-budgets (>=1.37.0,<1.38.0)", "mypy-boto3-ce (>=1.37.0,<1.38.0)", "mypy-boto3-chatbot (>=1.37.0,<1.38.0)", "mypy-boto3-chime (>=1.37.0,<1.38.0)", "mypy-boto3-chime-sdk-identity (>=1.37.0,<1.38.0)", "mypy-boto3-chime-sdk-media-pipelines (>=1.37.0,<1.38.0)", "mypy-boto3-chime-sdk-meetings (>=1.37.0,<1.38.0)", "mypy-boto3-chime-sdk-messaging (>=1.37.0,<1.38.0)", "mypy-boto3-chime-sdk-voice (>=1.37.0,<1.38.0)", "mypy-boto3-cleanrooms (>=1.37.0,<1.38.0)", "mypy-boto3-cleanroomsml (>=1.37.0,<1.38.0)", "mypy-boto3-cloud9 (>=1.37.0,<1.38.0)", "mypy-boto3-cloudcontrol (>=1.37.0,<1.38.0)", "mypy-boto3-clouddirectory (>=1.37.0,<1.38.0)", "mypy-boto3-cloudformation (>=1.37.0,<1.38.0)", "mypy-boto3-cloudfront (>=1.37.0,<1.38.0)", "mypy-boto3-cloudfront-keyvaluestore (>=1.37.0,<1.38.0)", "mypy-boto3-cloudhsm (>=1.37.0,<1.38.0)", "mypy-boto3-cloudhsmv2 (>=1.37.0,<1.38.0)", "mypy-boto3-cloudsearch (>=1.37.0,<1.38.0)", "mypy-boto3-cloudsearchdomain (>=1.37.0,<1.38.0)", "mypy-boto3-cloudtrail (>=1.37.0,<1.38.0)", "mypy-boto3-cloudtrail-data (>=1.37.0,<1.38.0)", "mypy-boto3-cloudwatch (>=1.37.0,<1.38.0)", "mypy-boto3-codeartifact (>=1.37.0,<1.38.0)", "mypy-boto3-codebuild (>=1.37.0,<1.38.0)", "mypy-boto3-codecatalyst (>=1.37.0,<1.38.0)", "mypy-boto3-codecommit (>=1.37.0,<1.38.0)", "mypy-boto3-codeconnections (>=1.37.0,<1.38.0)", "mypy-boto3-codedeploy (>=1.37.0,<1.38.0)", "mypy-boto3-codeguru-reviewer (>=1.37.0,<1.38.0)", "mypy-boto3-codeguru-security (>=1.37.0,<1.38.0)", "mypy-boto3-codeguruprofiler (>=1.37.0,<1.38.0)", "mypy-boto3-codepipeline (>=1.37.0,<1.38.0)", "mypy-boto3-codestar-connections (>=1.37.0,<1.38.0)", "mypy-boto3-codestar-notifications (>=1.37.0,<1.38.0)", "mypy-boto3-cognito-identity (>=1.37.0,<1.38.0)", "mypy-boto3-cognito-idp (>=1.37.0,<1.38.0)", "mypy-boto3-cognito-sync (>=1.37.0,<1.38.0)", "mypy-boto3-comprehend (>=1.37.0,<1.38.0)", "mypy-boto3-comprehendmedical (>=1.37.0,<1.38.0)", "mypy-boto3-compute-optimizer (>=1.37.0,<1.38.0)", "mypy-boto3-config (>=1.37.0,<1.38.0)", "mypy-boto3-connect (>=1.37.0,<1.38.0)", "mypy-boto3-connect-contact-lens (>=1.37.0,<1.38.0)", "mypy-boto3-connectcampaigns (>=1.37.0,<1.38.0)", "mypy-boto3-connectcampaignsv2 (>=1.37.0,<1.38.0)", "mypy-boto3-connectcases (>=1.37.0,<1.38.0)", "mypy-boto3-connectparticipant (>=1.37.0,<1.38.0)", "mypy-boto3-controlcatalog (>=1.37.0,<1.38.0)", "mypy-boto3-controltower (>=1.37.0,<1.38.0)", "mypy-boto3-cost-optimization-hub (>=1.37.0,<1.38.0)", "mypy-boto3-cur (>=1.37.0,<1.38.0)", "mypy-boto3-customer-profiles (>=1.37.0,<1.38.0)", "mypy-boto3-databrew (>=1.37.0,<1.38.0)", "mypy-boto3-dataexchange (>=1.37.0,<1.38.0)", "mypy-boto3-datapipeline (>=1.37.0,<1.38.0)", "mypy-boto3-datasync (>=1.37.0,<1.38.0)", "mypy-boto3-datazone (>=1.37.0,<1.38.0)", "mypy-boto3-dax (>=1.37.0,<1.38.0)", "mypy-boto3-deadline (>=1.37.0,<1.38.0)", "mypy-boto3-detective (>=1.37.0,<1.38.0)", "mypy-boto3-devicefarm (>=1.37.0,<1.38.0)", "mypy-boto3-devops-guru (>=1.37.0,<1.38.0)", "mypy-boto3-directconnect (>=1.37.0,<1.38.0)", "mypy-boto3-discovery (>=1.37.0,<1.38.0)", "mypy-boto3-dlm (>=1.37.0,<1.38.0)", "mypy-boto3-dms (>=1.37.0,<1.38.0)", "mypy-boto3-docdb (>=1.37.0,<1.38.0)", "mypy-boto3-docdb-elastic (>=1.37.0,<1.38.0)", "mypy-boto3-drs (>=1.37.0,<1.38.0)", "mypy-boto3-ds (>=1.37.0,<1.38.0)", "mypy-boto3-ds-data (>=1.37.0,<1.38.0)", "mypy-boto3-dsql (>=1.37.0,<1.38.0)", "mypy-boto3-dynamodb (>=1.37.0,<1.38.0)", "mypy-boto3-dynamodbstreams (>=1.37.0,<1.38.0)", "mypy-boto3-ebs (>=1.37.0,<1.38.0)", "mypy-boto3-ec2 (>=1.37.0,<1.38.0)", "mypy-boto3-ec2-instance-connect (>=1.37.0,<1.38.0)", "mypy-boto3-ecr (>=1.37.0,<1.38.0)", "mypy-boto3-ecr-public (>=1.37.0,<1.38.0)", "mypy-boto3-ecs (>=1.37.0,<1.38.0)", "mypy-boto3-efs (>=1.37.0,<1.38.0)", "mypy-boto3-eks (>=1.37.0,<1.38.0)", "mypy-boto3-eks-auth (>=1.37.0,<1.38.0)", "mypy-boto3-elasticache (>=1.37.0,<1.38.0)", "mypy-boto3-elasticbeanstalk (>=1.37.0,<1.38.0)", "mypy-boto3-elastictranscoder (>=1.37.0,<1.38.0)", "mypy-boto3-elb (>=1.37.0,<1.38.0)", "mypy-boto3-elbv2 (>=1.37.0,<1.38.0)", "mypy-boto3-emr (>=1.37.0,<1.38.0)", "mypy-boto3-emr-containers (>=1.37.0,<1.38.0)", "mypy-boto3-emr-serverless (>=1.37.0,<1.38.0)", "mypy-boto3-entityresolution (>=1.37.0,<1.38.0)", "mypy-boto3-es (>=1.37.0,<1.38.0)", "mypy-boto3-events (>=1.37.0,<1.38.0)", "mypy-boto3-evidently (>=1.37.0,<1.38.0)", "mypy-boto3-finspace (>=1.37.0,<1.38.0)", "mypy-boto3-finspace-data (>=1.37.0,<1.38.0)", "mypy-boto3-firehose (>=1.37.0,<1.38.0)", "mypy-boto3-fis (>=1.37.0,<1.38.0)", "mypy-boto3-fms (>=1.37.0,<1.38.0)", "mypy-boto3-forecast (>=1.37.0,<1.38.0)", "mypy-boto3-forecastquery (>=1.37.0,<1.38.0)", "mypy-boto3-frauddetector (>=1.37.0,<1.38.0)", "mypy-boto3-freetier (>=1.37.0,<1.38.0)", "mypy-boto3-fsx (>=1.37.0,<1.38.0)", "mypy-boto3-gamelift (>=1.37.0,<1.38.0)", "mypy-boto3-gameliftstreams (>=1.37.0,<1.38.0)", "mypy-boto3-geo-maps (>=1.37.0,<1.38.0)", "mypy-boto3-geo-places (>=1.37.0,<1.38.0)", "mypy-boto3-geo-routes (>=1.37.0,<1.38.0)", "mypy-boto3-glacier (>=1.37.0,<1.38.0)", "mypy-boto3-globalaccelerator (>=1.37.0,<1.38.0)", "mypy-boto3-glue (>=1.37.0,<1.38.0)", "mypy-boto3-grafana (>=1.37.0,<1.38.0)", "mypy-boto3-greengrass (>=1.37.0,<1.38.0)", "mypy-boto3-greengrassv2 (>=1.37.0,<1.38.0)", "mypy-boto3-groundstation (>=1.37.0,<1.38.0)", "mypy-boto3-guardduty (>=1.37.0,<1.38.0)", "mypy-boto3-health (>=1.37.0,<1.38.0)", "mypy-boto3-healthlake (>=1.37.0,<1.38.0)", "mypy-boto3-iam (>=1.37.0,<1.38.0)", "mypy-boto3-identitystore (>=1.37.0,<1.38.0)", "mypy-boto3-imagebuilder (>=1.37.0,<1.38.0)", "mypy-boto3-importexport (>=1.37.0,<1.38.0)", "mypy-boto3-inspector (>=1.37.0,<1.38.0)", "mypy-boto3-inspector-scan (>=1.37.0,<1.38.0)", "mypy-boto3-inspector2 (>=1.37.0,<1.38.0)", "mypy-boto3-internetmonitor (>=1.37.0,<1.38.0)", "mypy-boto3-invoicing (>=1.37.0,<1.38.0)", "mypy-boto3-iot (>=1.37.0,<1.38.0)", "mypy-boto3-iot-data (>=1.37.0,<1.38.0)", "mypy-boto3-iot-jobs-data (>=1.37.0,<1.38.0)", "mypy-boto3-iot-managed-integrations (>=1.37.0,<1.38.0)", "mypy-boto3-iotanalytics (>=1.37.0,<1.38.0)", "mypy-boto3-iotdeviceadvisor (>=1.37.0,<1.38.0)", "mypy-boto3-iotevents (>=1.37.0,<1.38.0)", "mypy-boto3-iotevents-data (>=1.37.0,<1.38.0)", "mypy-boto3-iotfleethub (>=1.37.0,<1.38.0)", "mypy-boto3-iotfleetwise (>=1.37.0,<1.38.0)", "mypy-boto3-iotsecuretunneling (>=1.37.0,<1.38.0)", "mypy-boto3-iotsitewise (>=1.37.0,<1.38.0)", "mypy-boto3-iotthingsgraph (>=1.37.0,<1.38.0)", "mypy-boto3-iottwinmaker (>=1.37.0,<1.38.0)", "mypy-boto3-iotwireless (>=1.37.0,<1.38.0)", "mypy-boto3-ivs (>=1.37.0,<1.38.0)", "mypy-boto3-ivs-realtime (>=1.37.0,<1.38.0)", "mypy-boto3-ivschat (>=1.37.0,<1.38.0)", "mypy-boto3-kafka (>=1.37.0,<1.38.0)", "mypy-boto3-kafkaconnect (>=1.37.0,<1.38.0)", "mypy-boto3-kendra (>=1.37.0,<1.38.0)", "mypy-boto3-kendra-ranking (>=1.37.0,<1.38.0)", "mypy-boto3-keyspaces (>=1.37.0,<1.38.0)", "mypy-boto3-kinesis (>=1.37.0,<1.38.0)", "mypy-boto3-kinesis-video-archived-media (>=1.37.0,<1.38.0)", "mypy-boto3-kinesis-video-media (>=1.37.0,<1.38.0)", "mypy-boto3-kinesis-video-signaling (>=1.37.0,<1.38.0)", "mypy-boto3-kinesis-video-webrtc-storage (>=1.37.0,<1.38.0)", "mypy-boto3-kinesisanalytics (>=1.37.0,<1.38.0)", "mypy-boto3-kinesisanalyticsv2 (>=1.37.0,<1.38.0)", "mypy-boto3-kinesisvideo (>=1.37.0,<1.38.0)", "mypy-boto3-kms (>=1.37.0,<1.38.0)", "mypy-boto3-lakeformation (>=1.37.0,<1.38.0)", "mypy-boto3-lambda (>=1.37.0,<1.38.0)", "mypy-boto3-launch-wizard (>=1.37.0,<1.38.0)", "mypy-boto3-lex-models (>=1.37.0,<1.38.0)", "mypy-boto3-lex-runtime (>=1.37.0,<1.38.0)", "mypy-boto3-lexv2-models (>=1.37.0,<1.38.0)", "mypy-boto3-lexv2-runtime (>=1.37.0,<1.38.0)", "mypy-boto3-license-manager (>=1.37.0,<1.38.0)", "mypy-boto3-license-manager-linux-subscriptions (>=1.37.0,<1.38.0)", "mypy-boto3-license-manager-user-subscriptions (>=1.37.0,<1.38.0)", "mypy-boto3-lightsail (>=1.37.0,<1.38.0)", "mypy-boto3-location (>=1.37.0,<1.38.0)", "mypy-boto3-logs (>=1.37.0,<1.38.0)", "mypy-boto3-lookoutequipment (>=1.37.0,<1.38.0)", "mypy-boto3-lookoutmetrics (>=1.37.0,<1.38.0)", "mypy-boto3-lookoutvision (>=1.37.0,<1.38.0)", "mypy-boto3-m2 (>=1.37.0,<1.38.0)", "mypy-boto3-machinelearning (>=1.37.0,<1.38.0)", "mypy-boto3-macie2 (>=1.37.0,<1.38.0)", "mypy-boto3-mailmanager (>=1.37.0,<1.38.0)", "mypy-boto3-managedblockchain (>=1.37.0,<1.38.0)", "mypy-boto3-managedblockchain-query (>=1.37.0,<1.38.0)", "mypy-boto3-marketplace-agreement (>=1.37.0,<1.38.0)", "mypy-boto3-marketplace-catalog (>=1.37.0,<1.38.0)", "mypy-boto3-marketplace-deployment (>=1.37.0,<1.38.0)", "mypy-boto3-marketplace-entitlement (>=1.37.0,<1.38.0)", "mypy-boto3-marketplace-reporting (>=1.37.0,<1.38.0)", "mypy-boto3-marketplacecommerceanalytics (>=1.37.0,<1.38.0)", "mypy-boto3-mediaconnect (>=1.37.0,<1.38.0)", "mypy-boto3-mediaconvert (>=1.37.0,<1.38.0)", "mypy-boto3-medialive (>=1.37.0,<1.38.0)", "mypy-boto3-mediapackage (>=1.37.0,<1.38.0)", "mypy-boto3-mediapackage-vod (>=1.37.0,<1.38.0)", "mypy-boto3-mediapackagev2 (>=1.37.0,<1.38.0)", "mypy-boto3-mediastore (>=1.37.0,<1.38.0)", "mypy-boto3-mediastore-data (>=1.37.0,<1.38.0)", "mypy-boto3-mediatailor (>=1.37.0,<1.38.0)", "mypy-boto3-medical-imaging (>=1.37.0,<1.38.0)", "mypy-boto3-memorydb (>=1.37.0,<1.38.0)", "mypy-boto3-meteringmarketplace (>=1.37.0,<1.38.0)", "mypy-boto3-mgh (>=1.37.0,<1.38.0)", "mypy-boto3-mgn (>=1.37.0,<1.38.0)", "mypy-boto3-migration-hub-refactor-spaces (>=1.37.0,<1.38.0)", "mypy-boto3-migrationhub-config (>=1.37.0,<1.38.0)", "mypy-boto3-migrationhuborchestrator (>=1.37.0,<1.38.0)", "mypy-boto3-migrationhubstrategy (>=1.37.0,<1.38.0)", "mypy-boto3-mq (>=1.37.0,<1.38.0)", "mypy-boto3-mturk (>=1.37.0,<1.38.0)", "mypy-boto3-mwaa (>=1.37.0,<1.38.0)", "mypy-boto3-neptune (>=1.37.0,<1.38.0)", "mypy-boto3-neptune-graph (>=1.37.0,<1.38.0)", "mypy-boto3-neptunedata (>=1.37.0,<1.38.0)", "mypy-boto3-network-firewall (>=1.37.0,<1.38.0)", "mypy-boto3-networkflowmonitor (>=1.37.0,<1.38.0)", "mypy-boto3-networkmanager (>=1.37.0,<1.38.0)", "mypy-boto3-networkmonitor (>=1.37.0,<1.38.0)", "mypy-boto3-notifications (>=1.37.0,<1.38.0)", "mypy-boto3-notificationscontacts (>=1.37.0,<1.38.0)", "mypy-boto3-oam (>=1.37.0,<1.38.0)", "mypy-boto3-observabilityadmin (>=1.37.0,<1.38.0)", "mypy-boto3-omics (>=1.37.0,<1.38.0)", "mypy-boto3-opensearch (>=1.37.0,<1.38.0)", "mypy-boto3-opensearchserverless (>=1.37.0,<1.38.0)", "mypy-boto3-opsworks (>=1.37.0,<1.38.0)", "mypy-boto3-opsworkscm (>=1.37.0,<1.38.0)", "mypy-boto3-organizations (>=1.37.0,<1.38.0)", "mypy-boto3-osis (>=1.37.0,<1.38.0)", "mypy-boto3-outposts (>=1.37.0,<1.38.0)", "mypy-boto3-panorama (>=1.37.0,<1.38.0)", "mypy-boto3-partnercentral-selling (>=1.37.0,<1.38.0)", "mypy-boto3-payment-cryptography (>=1.37.0,<1.38.0)", "mypy-boto3-payment-cryptography-data (>=1.37.0,<1.38.0)", "mypy-boto3-pca-connector-ad (>=1.37.0,<1.38.0)", "mypy-boto3-pca-connector-scep (>=1.37.0,<1.38.0)", "mypy-boto3-pcs (>=1.37.0,<1.38.0)", "mypy-boto3-personalize (>=1.37.0,<1.38.0)", "mypy-boto3-personalize-events (>=1.37.0,<1.38.0)", "mypy-boto3-personalize-runtime (>=1.37.0,<1.38.0)", "mypy-boto3-pi (>=1.37.0,<1.38.0)", "mypy-boto3-pinpoint (>=1.37.0,<1.38.0)", "mypy-boto3-pinpoint-email (>=1.37.0,<1.38.0)", "mypy-boto3-pinpoint-sms-voice (>=1.37.0,<1.38.0)", "mypy-boto3-pinpoint-sms-voice-v2 (>=1.37.0,<1.38.0)", "mypy-boto3-pipes (>=1.37.0,<1.38.0)", "mypy-boto3-polly (>=1.37.0,<1.38.0)", "mypy-boto3-pricing (>=1.37.0,<1.38.0)", "mypy-boto3-privatenetworks (>=1.37.0,<1.38.0)", "mypy-boto3-proton (>=1.37.0,<1.38.0)", "mypy-boto3-qapps (>=1.37.0,<1.38.0)", "mypy-boto3-qbusiness (>=1.37.0,<1.38.0)", "mypy-boto3-qconnect (>=1.37.0,<1.38.0)", "mypy-boto3-qldb (>=1.37.0,<1.38.0)", "mypy-boto3-qldb-session (>=1.37.0,<1.38.0)", "mypy-boto3-quicksight (>=1.37.0,<1.38.0)", "mypy-boto3-ram (>=1.37.0,<1.38.0)", "mypy-boto3-rbin (>=1.37.0,<1.38.0)", "mypy-boto3-rds (>=1.37.0,<1.38.0)", "mypy-boto3-rds-data (>=1.37.0,<1.38.0)", "mypy-boto3-redshift (>=1.37.0,<1.38.0)", "mypy-boto3-redshift-data (>=1.37.0,<1.38.0)", "mypy-boto3-redshift-serverless (>=1.37.0,<1.38.0)", "mypy-boto3-rekognition (>=1.37.0,<1.38.0)", "mypy-boto3-repostspace (>=1.37.0,<1.38.0)", "mypy-boto3-resiliencehub (>=1.37.0,<1.38.0)", "mypy-boto3-resource-explorer-2 (>=1.37.0,<1.38.0)", "mypy-boto3-resource-groups (>=1.37.0,<1.38.0)", "mypy-boto3-resourcegroupstaggingapi (>=1.37.0,<1.38.0)", "mypy-boto3-robomaker (>=1.37.0,<1.38.0)", "mypy-boto3-rolesanywhere (>=1.37.0,<1.38.0)", "mypy-boto3-route53 (>=1.37.0,<1.38.0)", "mypy-boto3-route53-recovery-cluster (>=1.37.0,<1.38.0)", "mypy-boto3-route53-recovery-control-config (>=1.37.0,<1.38.0)", "mypy-boto3-route53-recovery-readiness (>=1.37.0,<1.38.0)", "mypy-boto3-route53domains (>=1.37.0,<1.38.0)", "mypy-boto3-route53profiles (>=1.37.0,<1.38.0)", "mypy-boto3-route53resolver (>=1.37.0,<1.38.0)", "mypy-boto3-rum (>=1.37.0,<1.38.0)", "mypy-boto3-s3 (>=1.37.0,<1.38.0)", "mypy-boto3-s3control (>=1.37.0,<1.38.0)", "mypy-boto3-s3outposts (>=1.37.0,<1.38.0)", "mypy-boto3-s3tables (>=1.37.0,<1.38.0)", "mypy-boto3-sagemaker (>=1.37.0,<1.38.0)", "mypy-boto3-sagemaker-a2i-runtime (>=1.37.0,<1.38.0)", "mypy-boto3-sagemaker-edge (>=1.37.0,<1.38.0)", "mypy-boto3-sagemaker-featurestore-runtime (>=1.37.0,<1.38.0)", "mypy-boto3-sagemaker-geospatial (>=1.37.0,<1.38.0)", "mypy-boto3-sagemaker-metrics (>=1.37.0,<1.38.0)", "mypy-boto3-sagemaker-runtime (>=1.37.0,<1.38.0)", "mypy-boto3-savingsplans (>=1.37.0,<1.38.0)", "mypy-boto3-scheduler (>=1.37.0,<1.38.0)", "mypy-boto3-schemas (>=1.37.0,<1.38.0)", "mypy-boto3-sdb (>=1.37.0,<1.38.0)", "mypy-boto3-secretsmanager (>=1.37.0,<1.38.0)", "mypy-boto3-security-ir (>=1.37.0,<1.38.0)", "mypy-boto3-securityhub (>=1.37.0,<1.38.0)", "mypy-boto3-securitylake (>=1.37.0,<1.38.0)", "mypy-boto3-serverlessrepo (>=1.37.0,<1.38.0)", "mypy-boto3-service-quotas (>=1.37.0,<1.38.0)", "mypy-boto3-servicecatalog (>=1.37.0,<1.38.0)", "mypy-boto3-servicecatalog-appregistry (>=1.37.0,<1.38.0)", "mypy-boto3-servicediscovery (>=1.37.0,<1.38.0)", "mypy-boto3-ses (>=1.37.0,<1.38.0)", "mypy-boto3-sesv2 (>=1.37.0,<1.38.0)", "mypy-boto3-shield (>=1.37.0,<1.38.0)", "mypy-boto3-signer (>=1.37.0,<1.38.0)", "mypy-boto3-simspaceweaver (>=1.37.0,<1.38.0)", "mypy-boto3-sms (>=1.37.0,<1.38.0)", "mypy-boto3-sms-voice (>=1.37.0,<1.38.0)", "mypy-boto3-snow-device-management (>=1.37.0,<1.38.0)", "mypy-boto3-snowball (>=1.37.0,<1.38.0)", "mypy-boto3-sns (>=1.37.0,<1.38.0)", "mypy-boto3-socialmessaging (>=1.37.0,<1.38.0)", "mypy-boto3-sqs (>=1.37.0,<1.38.0)", "mypy-boto3-ssm (>=1.37.0,<1.38.0)", "mypy-boto3-ssm-contacts (>=1.37.0,<1.38.0)", "mypy-boto3-ssm-incidents (>=1.37.0,<1.38.0)", "mypy-boto3-ssm-quicksetup (>=1.37.0,<1.38.0)", "mypy-boto3-ssm-sap (>=1.37.0,<1.38.0)", "mypy-boto3-sso (>=1.37.0,<1.38.0)", "mypy-boto3-sso-admin (>=1.37.0,<1.38.0)", "mypy-boto3-sso-oidc (>=1.37.0,<1.38.0)", "mypy-boto3-stepfunctions (>=1.37.0,<1.38.0)", "mypy-boto3-storagegateway (>=1.37.0,<1.38.0)", "mypy-boto3-sts (>=1.37.0,<1.38.0)", "mypy-boto3-supplychain (>=1.37.0,<1.38.0)", "mypy-boto3-support (>=1.37.0,<1.38.0)", "mypy-boto3-support-app (>=1.37.0,<1.38.0)", "mypy-boto3-swf (>=1.37.0,<1.38.0)", "mypy-boto3-synthetics (>=1.37.0,<1.38.0)", "mypy-boto3-taxsettings (>=1.37.0,<1.38.0)", "mypy-boto3-textract (>=1.37.0,<1.38.0)", "mypy-boto3-timestream-influxdb (>=1.37.0,<1.38.0)", "mypy-boto3-timestream-query (>=1.37.0,<1.38.0)", "mypy-boto3-timestream-write (>=1.37.0,<1.38.0)", "mypy-boto3-tnb (>=1.37.0,<1.38.0)", "mypy-boto3-transcribe (>=1.37.0,<1.38.0)", "mypy-boto3-transfer (>=1.37.0,<1.38.0)", "mypy-boto3-translate (>=1.37.0,<1.38.0)", "mypy-boto3-trustedadvisor (>=1.37.0,<1.38.0)", "mypy-boto3-verifiedpermissions (>=1.37.0,<1.38.0)", "mypy-boto3-voice-id (>=1.37.0,<1.38.0)", "mypy-boto3-vpc-lattice (>=1.37.0,<1.38.0)", "mypy-boto3-waf (>=1.37.0,<1.38.0)", "mypy-boto3-waf-regional (>=1.37.0,<1.38.0)", "mypy-boto3-wafv2 (>=1.37.0,<1.38.0)", "mypy-boto3-wellarchitected (>=1.37.0,<1.38.0)", "mypy-boto3-wisdom (>=1.37.0,<1.38.0)", "mypy-boto3-workdocs (>=1.37.0,<1.38.0)", "mypy-boto3-workmail (>=1.37.0,<1.38.0)", "mypy-boto3-workmailmessageflow (>=1.37.0,<1.38.0)", "mypy-boto3-workspaces (>=1.37.0,<1.38.0)", "mypy-boto3-workspaces-thin-client (>=1.37.0,<1.38.0)", "mypy-boto3-workspaces-web (>=1.37.0,<1.38.0)", "mypy-boto3-xray (>=1.37.0,<1.38.0)"] +amp = ["mypy-boto3-amp (>=1.37.0,<1.38.0)"] +amplify = ["mypy-boto3-amplify (>=1.37.0,<1.38.0)"] +amplifybackend = ["mypy-boto3-amplifybackend (>=1.37.0,<1.38.0)"] +amplifyuibuilder = ["mypy-boto3-amplifyuibuilder (>=1.37.0,<1.38.0)"] +apigateway = ["mypy-boto3-apigateway (>=1.37.0,<1.38.0)"] +apigatewaymanagementapi = ["mypy-boto3-apigatewaymanagementapi (>=1.37.0,<1.38.0)"] +apigatewayv2 = ["mypy-boto3-apigatewayv2 (>=1.37.0,<1.38.0)"] +appconfig = ["mypy-boto3-appconfig (>=1.37.0,<1.38.0)"] +appconfigdata = ["mypy-boto3-appconfigdata (>=1.37.0,<1.38.0)"] +appfabric = ["mypy-boto3-appfabric (>=1.37.0,<1.38.0)"] +appflow = ["mypy-boto3-appflow (>=1.37.0,<1.38.0)"] +appintegrations = ["mypy-boto3-appintegrations (>=1.37.0,<1.38.0)"] +application-autoscaling = ["mypy-boto3-application-autoscaling (>=1.37.0,<1.38.0)"] +application-insights = ["mypy-boto3-application-insights (>=1.37.0,<1.38.0)"] +application-signals = ["mypy-boto3-application-signals (>=1.37.0,<1.38.0)"] +applicationcostprofiler = ["mypy-boto3-applicationcostprofiler (>=1.37.0,<1.38.0)"] +appmesh = ["mypy-boto3-appmesh (>=1.37.0,<1.38.0)"] +apprunner = ["mypy-boto3-apprunner (>=1.37.0,<1.38.0)"] +appstream = ["mypy-boto3-appstream (>=1.37.0,<1.38.0)"] +appsync = ["mypy-boto3-appsync (>=1.37.0,<1.38.0)"] +apptest = ["mypy-boto3-apptest (>=1.37.0,<1.38.0)"] +arc-zonal-shift = ["mypy-boto3-arc-zonal-shift (>=1.37.0,<1.38.0)"] +artifact = ["mypy-boto3-artifact (>=1.37.0,<1.38.0)"] +athena = ["mypy-boto3-athena (>=1.37.0,<1.38.0)"] +auditmanager = ["mypy-boto3-auditmanager (>=1.37.0,<1.38.0)"] +autoscaling = ["mypy-boto3-autoscaling (>=1.37.0,<1.38.0)"] +autoscaling-plans = ["mypy-boto3-autoscaling-plans (>=1.37.0,<1.38.0)"] +b2bi = ["mypy-boto3-b2bi (>=1.37.0,<1.38.0)"] +backup = ["mypy-boto3-backup (>=1.37.0,<1.38.0)"] +backup-gateway = ["mypy-boto3-backup-gateway (>=1.37.0,<1.38.0)"] +backupsearch = ["mypy-boto3-backupsearch (>=1.37.0,<1.38.0)"] +batch = ["mypy-boto3-batch (>=1.37.0,<1.38.0)"] +bcm-data-exports = ["mypy-boto3-bcm-data-exports (>=1.37.0,<1.38.0)"] +bcm-pricing-calculator = ["mypy-boto3-bcm-pricing-calculator (>=1.37.0,<1.38.0)"] +bedrock = ["mypy-boto3-bedrock (>=1.37.0,<1.38.0)"] +bedrock-agent = ["mypy-boto3-bedrock-agent (>=1.37.0,<1.38.0)"] +bedrock-agent-runtime = ["mypy-boto3-bedrock-agent-runtime (>=1.37.0,<1.38.0)"] +bedrock-data-automation = ["mypy-boto3-bedrock-data-automation (>=1.37.0,<1.38.0)"] +bedrock-data-automation-runtime = ["mypy-boto3-bedrock-data-automation-runtime (>=1.37.0,<1.38.0)"] +bedrock-runtime = ["mypy-boto3-bedrock-runtime (>=1.37.0,<1.38.0)"] +billing = ["mypy-boto3-billing (>=1.37.0,<1.38.0)"] +billingconductor = ["mypy-boto3-billingconductor (>=1.37.0,<1.38.0)"] +boto3 = ["boto3 (==1.37.38)"] +braket = ["mypy-boto3-braket (>=1.37.0,<1.38.0)"] +budgets = ["mypy-boto3-budgets (>=1.37.0,<1.38.0)"] +ce = ["mypy-boto3-ce (>=1.37.0,<1.38.0)"] +chatbot = ["mypy-boto3-chatbot (>=1.37.0,<1.38.0)"] +chime = ["mypy-boto3-chime (>=1.37.0,<1.38.0)"] +chime-sdk-identity = ["mypy-boto3-chime-sdk-identity (>=1.37.0,<1.38.0)"] +chime-sdk-media-pipelines = ["mypy-boto3-chime-sdk-media-pipelines (>=1.37.0,<1.38.0)"] +chime-sdk-meetings = ["mypy-boto3-chime-sdk-meetings (>=1.37.0,<1.38.0)"] +chime-sdk-messaging = ["mypy-boto3-chime-sdk-messaging (>=1.37.0,<1.38.0)"] +chime-sdk-voice = ["mypy-boto3-chime-sdk-voice (>=1.37.0,<1.38.0)"] +cleanrooms = ["mypy-boto3-cleanrooms (>=1.37.0,<1.38.0)"] +cleanroomsml = ["mypy-boto3-cleanroomsml (>=1.37.0,<1.38.0)"] +cloud9 = ["mypy-boto3-cloud9 (>=1.37.0,<1.38.0)"] +cloudcontrol = ["mypy-boto3-cloudcontrol (>=1.37.0,<1.38.0)"] +clouddirectory = ["mypy-boto3-clouddirectory (>=1.37.0,<1.38.0)"] +cloudformation = ["mypy-boto3-cloudformation (>=1.37.0,<1.38.0)"] +cloudfront = ["mypy-boto3-cloudfront (>=1.37.0,<1.38.0)"] +cloudfront-keyvaluestore = ["mypy-boto3-cloudfront-keyvaluestore (>=1.37.0,<1.38.0)"] +cloudhsm = ["mypy-boto3-cloudhsm (>=1.37.0,<1.38.0)"] +cloudhsmv2 = ["mypy-boto3-cloudhsmv2 (>=1.37.0,<1.38.0)"] +cloudsearch = ["mypy-boto3-cloudsearch (>=1.37.0,<1.38.0)"] +cloudsearchdomain = ["mypy-boto3-cloudsearchdomain (>=1.37.0,<1.38.0)"] +cloudtrail = ["mypy-boto3-cloudtrail (>=1.37.0,<1.38.0)"] +cloudtrail-data = ["mypy-boto3-cloudtrail-data (>=1.37.0,<1.38.0)"] +cloudwatch = ["mypy-boto3-cloudwatch (>=1.37.0,<1.38.0)"] +codeartifact = ["mypy-boto3-codeartifact (>=1.37.0,<1.38.0)"] +codebuild = ["mypy-boto3-codebuild (>=1.37.0,<1.38.0)"] +codecatalyst = ["mypy-boto3-codecatalyst (>=1.37.0,<1.38.0)"] +codecommit = ["mypy-boto3-codecommit (>=1.37.0,<1.38.0)"] +codeconnections = ["mypy-boto3-codeconnections (>=1.37.0,<1.38.0)"] +codedeploy = ["mypy-boto3-codedeploy (>=1.37.0,<1.38.0)"] +codeguru-reviewer = ["mypy-boto3-codeguru-reviewer (>=1.37.0,<1.38.0)"] +codeguru-security = ["mypy-boto3-codeguru-security (>=1.37.0,<1.38.0)"] +codeguruprofiler = ["mypy-boto3-codeguruprofiler (>=1.37.0,<1.38.0)"] +codepipeline = ["mypy-boto3-codepipeline (>=1.37.0,<1.38.0)"] +codestar-connections = ["mypy-boto3-codestar-connections (>=1.37.0,<1.38.0)"] +codestar-notifications = ["mypy-boto3-codestar-notifications (>=1.37.0,<1.38.0)"] +cognito-identity = ["mypy-boto3-cognito-identity (>=1.37.0,<1.38.0)"] +cognito-idp = ["mypy-boto3-cognito-idp (>=1.37.0,<1.38.0)"] +cognito-sync = ["mypy-boto3-cognito-sync (>=1.37.0,<1.38.0)"] +comprehend = ["mypy-boto3-comprehend (>=1.37.0,<1.38.0)"] +comprehendmedical = ["mypy-boto3-comprehendmedical (>=1.37.0,<1.38.0)"] +compute-optimizer = ["mypy-boto3-compute-optimizer (>=1.37.0,<1.38.0)"] +config = ["mypy-boto3-config (>=1.37.0,<1.38.0)"] +connect = ["mypy-boto3-connect (>=1.37.0,<1.38.0)"] +connect-contact-lens = ["mypy-boto3-connect-contact-lens (>=1.37.0,<1.38.0)"] +connectcampaigns = ["mypy-boto3-connectcampaigns (>=1.37.0,<1.38.0)"] +connectcampaignsv2 = ["mypy-boto3-connectcampaignsv2 (>=1.37.0,<1.38.0)"] +connectcases = ["mypy-boto3-connectcases (>=1.37.0,<1.38.0)"] +connectparticipant = ["mypy-boto3-connectparticipant (>=1.37.0,<1.38.0)"] +controlcatalog = ["mypy-boto3-controlcatalog (>=1.37.0,<1.38.0)"] +controltower = ["mypy-boto3-controltower (>=1.37.0,<1.38.0)"] +cost-optimization-hub = ["mypy-boto3-cost-optimization-hub (>=1.37.0,<1.38.0)"] +cur = ["mypy-boto3-cur (>=1.37.0,<1.38.0)"] +customer-profiles = ["mypy-boto3-customer-profiles (>=1.37.0,<1.38.0)"] +databrew = ["mypy-boto3-databrew (>=1.37.0,<1.38.0)"] +dataexchange = ["mypy-boto3-dataexchange (>=1.37.0,<1.38.0)"] +datapipeline = ["mypy-boto3-datapipeline (>=1.37.0,<1.38.0)"] +datasync = ["mypy-boto3-datasync (>=1.37.0,<1.38.0)"] +datazone = ["mypy-boto3-datazone (>=1.37.0,<1.38.0)"] +dax = ["mypy-boto3-dax (>=1.37.0,<1.38.0)"] +deadline = ["mypy-boto3-deadline (>=1.37.0,<1.38.0)"] +detective = ["mypy-boto3-detective (>=1.37.0,<1.38.0)"] +devicefarm = ["mypy-boto3-devicefarm (>=1.37.0,<1.38.0)"] +devops-guru = ["mypy-boto3-devops-guru (>=1.37.0,<1.38.0)"] +directconnect = ["mypy-boto3-directconnect (>=1.37.0,<1.38.0)"] +discovery = ["mypy-boto3-discovery (>=1.37.0,<1.38.0)"] +dlm = ["mypy-boto3-dlm (>=1.37.0,<1.38.0)"] +dms = ["mypy-boto3-dms (>=1.37.0,<1.38.0)"] +docdb = ["mypy-boto3-docdb (>=1.37.0,<1.38.0)"] +docdb-elastic = ["mypy-boto3-docdb-elastic (>=1.37.0,<1.38.0)"] +drs = ["mypy-boto3-drs (>=1.37.0,<1.38.0)"] +ds = ["mypy-boto3-ds (>=1.37.0,<1.38.0)"] +ds-data = ["mypy-boto3-ds-data (>=1.37.0,<1.38.0)"] +dsql = ["mypy-boto3-dsql (>=1.37.0,<1.38.0)"] +dynamodb = ["mypy-boto3-dynamodb (>=1.37.0,<1.38.0)"] +dynamodbstreams = ["mypy-boto3-dynamodbstreams (>=1.37.0,<1.38.0)"] +ebs = ["mypy-boto3-ebs (>=1.37.0,<1.38.0)"] +ec2 = ["mypy-boto3-ec2 (>=1.37.0,<1.38.0)"] +ec2-instance-connect = ["mypy-boto3-ec2-instance-connect (>=1.37.0,<1.38.0)"] +ecr = ["mypy-boto3-ecr (>=1.37.0,<1.38.0)"] +ecr-public = ["mypy-boto3-ecr-public (>=1.37.0,<1.38.0)"] +ecs = ["mypy-boto3-ecs (>=1.37.0,<1.38.0)"] +efs = ["mypy-boto3-efs (>=1.37.0,<1.38.0)"] +eks = ["mypy-boto3-eks (>=1.37.0,<1.38.0)"] +eks-auth = ["mypy-boto3-eks-auth (>=1.37.0,<1.38.0)"] +elasticache = ["mypy-boto3-elasticache (>=1.37.0,<1.38.0)"] +elasticbeanstalk = ["mypy-boto3-elasticbeanstalk (>=1.37.0,<1.38.0)"] +elastictranscoder = ["mypy-boto3-elastictranscoder (>=1.37.0,<1.38.0)"] +elb = ["mypy-boto3-elb (>=1.37.0,<1.38.0)"] +elbv2 = ["mypy-boto3-elbv2 (>=1.37.0,<1.38.0)"] +emr = ["mypy-boto3-emr (>=1.37.0,<1.38.0)"] +emr-containers = ["mypy-boto3-emr-containers (>=1.37.0,<1.38.0)"] +emr-serverless = ["mypy-boto3-emr-serverless (>=1.37.0,<1.38.0)"] +entityresolution = ["mypy-boto3-entityresolution (>=1.37.0,<1.38.0)"] +es = ["mypy-boto3-es (>=1.37.0,<1.38.0)"] +essential = ["mypy-boto3-cloudformation (>=1.37.0,<1.38.0)", "mypy-boto3-dynamodb (>=1.37.0,<1.38.0)", "mypy-boto3-ec2 (>=1.37.0,<1.38.0)", "mypy-boto3-lambda (>=1.37.0,<1.38.0)", "mypy-boto3-rds (>=1.37.0,<1.38.0)", "mypy-boto3-s3 (>=1.37.0,<1.38.0)", "mypy-boto3-sqs (>=1.37.0,<1.38.0)"] +events = ["mypy-boto3-events (>=1.37.0,<1.38.0)"] +evidently = ["mypy-boto3-evidently (>=1.37.0,<1.38.0)"] +finspace = ["mypy-boto3-finspace (>=1.37.0,<1.38.0)"] +finspace-data = ["mypy-boto3-finspace-data (>=1.37.0,<1.38.0)"] +firehose = ["mypy-boto3-firehose (>=1.37.0,<1.38.0)"] +fis = ["mypy-boto3-fis (>=1.37.0,<1.38.0)"] +fms = ["mypy-boto3-fms (>=1.37.0,<1.38.0)"] +forecast = ["mypy-boto3-forecast (>=1.37.0,<1.38.0)"] +forecastquery = ["mypy-boto3-forecastquery (>=1.37.0,<1.38.0)"] +frauddetector = ["mypy-boto3-frauddetector (>=1.37.0,<1.38.0)"] +freetier = ["mypy-boto3-freetier (>=1.37.0,<1.38.0)"] +fsx = ["mypy-boto3-fsx (>=1.37.0,<1.38.0)"] +full = ["boto3-stubs-full (>=1.37.0,<1.38.0)"] +gamelift = ["mypy-boto3-gamelift (>=1.37.0,<1.38.0)"] +gameliftstreams = ["mypy-boto3-gameliftstreams (>=1.37.0,<1.38.0)"] +geo-maps = ["mypy-boto3-geo-maps (>=1.37.0,<1.38.0)"] +geo-places = ["mypy-boto3-geo-places (>=1.37.0,<1.38.0)"] +geo-routes = ["mypy-boto3-geo-routes (>=1.37.0,<1.38.0)"] +glacier = ["mypy-boto3-glacier (>=1.37.0,<1.38.0)"] +globalaccelerator = ["mypy-boto3-globalaccelerator (>=1.37.0,<1.38.0)"] +glue = ["mypy-boto3-glue (>=1.37.0,<1.38.0)"] +grafana = ["mypy-boto3-grafana (>=1.37.0,<1.38.0)"] +greengrass = ["mypy-boto3-greengrass (>=1.37.0,<1.38.0)"] +greengrassv2 = ["mypy-boto3-greengrassv2 (>=1.37.0,<1.38.0)"] +groundstation = ["mypy-boto3-groundstation (>=1.37.0,<1.38.0)"] +guardduty = ["mypy-boto3-guardduty (>=1.37.0,<1.38.0)"] +health = ["mypy-boto3-health (>=1.37.0,<1.38.0)"] +healthlake = ["mypy-boto3-healthlake (>=1.37.0,<1.38.0)"] +iam = ["mypy-boto3-iam (>=1.37.0,<1.38.0)"] +identitystore = ["mypy-boto3-identitystore (>=1.37.0,<1.38.0)"] +imagebuilder = ["mypy-boto3-imagebuilder (>=1.37.0,<1.38.0)"] +importexport = ["mypy-boto3-importexport (>=1.37.0,<1.38.0)"] +inspector = ["mypy-boto3-inspector (>=1.37.0,<1.38.0)"] +inspector-scan = ["mypy-boto3-inspector-scan (>=1.37.0,<1.38.0)"] +inspector2 = ["mypy-boto3-inspector2 (>=1.37.0,<1.38.0)"] +internetmonitor = ["mypy-boto3-internetmonitor (>=1.37.0,<1.38.0)"] +invoicing = ["mypy-boto3-invoicing (>=1.37.0,<1.38.0)"] +iot = ["mypy-boto3-iot (>=1.37.0,<1.38.0)"] +iot-data = ["mypy-boto3-iot-data (>=1.37.0,<1.38.0)"] +iot-jobs-data = ["mypy-boto3-iot-jobs-data (>=1.37.0,<1.38.0)"] +iot-managed-integrations = ["mypy-boto3-iot-managed-integrations (>=1.37.0,<1.38.0)"] +iotanalytics = ["mypy-boto3-iotanalytics (>=1.37.0,<1.38.0)"] +iotdeviceadvisor = ["mypy-boto3-iotdeviceadvisor (>=1.37.0,<1.38.0)"] +iotevents = ["mypy-boto3-iotevents (>=1.37.0,<1.38.0)"] +iotevents-data = ["mypy-boto3-iotevents-data (>=1.37.0,<1.38.0)"] +iotfleethub = ["mypy-boto3-iotfleethub (>=1.37.0,<1.38.0)"] +iotfleetwise = ["mypy-boto3-iotfleetwise (>=1.37.0,<1.38.0)"] +iotsecuretunneling = ["mypy-boto3-iotsecuretunneling (>=1.37.0,<1.38.0)"] +iotsitewise = ["mypy-boto3-iotsitewise (>=1.37.0,<1.38.0)"] +iotthingsgraph = ["mypy-boto3-iotthingsgraph (>=1.37.0,<1.38.0)"] +iottwinmaker = ["mypy-boto3-iottwinmaker (>=1.37.0,<1.38.0)"] +iotwireless = ["mypy-boto3-iotwireless (>=1.37.0,<1.38.0)"] +ivs = ["mypy-boto3-ivs (>=1.37.0,<1.38.0)"] +ivs-realtime = ["mypy-boto3-ivs-realtime (>=1.37.0,<1.38.0)"] +ivschat = ["mypy-boto3-ivschat (>=1.37.0,<1.38.0)"] +kafka = ["mypy-boto3-kafka (>=1.37.0,<1.38.0)"] +kafkaconnect = ["mypy-boto3-kafkaconnect (>=1.37.0,<1.38.0)"] +kendra = ["mypy-boto3-kendra (>=1.37.0,<1.38.0)"] +kendra-ranking = ["mypy-boto3-kendra-ranking (>=1.37.0,<1.38.0)"] +keyspaces = ["mypy-boto3-keyspaces (>=1.37.0,<1.38.0)"] +kinesis = ["mypy-boto3-kinesis (>=1.37.0,<1.38.0)"] +kinesis-video-archived-media = ["mypy-boto3-kinesis-video-archived-media (>=1.37.0,<1.38.0)"] +kinesis-video-media = ["mypy-boto3-kinesis-video-media (>=1.37.0,<1.38.0)"] +kinesis-video-signaling = ["mypy-boto3-kinesis-video-signaling (>=1.37.0,<1.38.0)"] +kinesis-video-webrtc-storage = ["mypy-boto3-kinesis-video-webrtc-storage (>=1.37.0,<1.38.0)"] +kinesisanalytics = ["mypy-boto3-kinesisanalytics (>=1.37.0,<1.38.0)"] +kinesisanalyticsv2 = ["mypy-boto3-kinesisanalyticsv2 (>=1.37.0,<1.38.0)"] +kinesisvideo = ["mypy-boto3-kinesisvideo (>=1.37.0,<1.38.0)"] +kms = ["mypy-boto3-kms (>=1.37.0,<1.38.0)"] +lakeformation = ["mypy-boto3-lakeformation (>=1.37.0,<1.38.0)"] +lambda = ["mypy-boto3-lambda (>=1.37.0,<1.38.0)"] +launch-wizard = ["mypy-boto3-launch-wizard (>=1.37.0,<1.38.0)"] +lex-models = ["mypy-boto3-lex-models (>=1.37.0,<1.38.0)"] +lex-runtime = ["mypy-boto3-lex-runtime (>=1.37.0,<1.38.0)"] +lexv2-models = ["mypy-boto3-lexv2-models (>=1.37.0,<1.38.0)"] +lexv2-runtime = ["mypy-boto3-lexv2-runtime (>=1.37.0,<1.38.0)"] +license-manager = ["mypy-boto3-license-manager (>=1.37.0,<1.38.0)"] +license-manager-linux-subscriptions = ["mypy-boto3-license-manager-linux-subscriptions (>=1.37.0,<1.38.0)"] +license-manager-user-subscriptions = ["mypy-boto3-license-manager-user-subscriptions (>=1.37.0,<1.38.0)"] +lightsail = ["mypy-boto3-lightsail (>=1.37.0,<1.38.0)"] +location = ["mypy-boto3-location (>=1.37.0,<1.38.0)"] +logs = ["mypy-boto3-logs (>=1.37.0,<1.38.0)"] +lookoutequipment = ["mypy-boto3-lookoutequipment (>=1.37.0,<1.38.0)"] +lookoutmetrics = ["mypy-boto3-lookoutmetrics (>=1.37.0,<1.38.0)"] +lookoutvision = ["mypy-boto3-lookoutvision (>=1.37.0,<1.38.0)"] +m2 = ["mypy-boto3-m2 (>=1.37.0,<1.38.0)"] +machinelearning = ["mypy-boto3-machinelearning (>=1.37.0,<1.38.0)"] +macie2 = ["mypy-boto3-macie2 (>=1.37.0,<1.38.0)"] +mailmanager = ["mypy-boto3-mailmanager (>=1.37.0,<1.38.0)"] +managedblockchain = ["mypy-boto3-managedblockchain (>=1.37.0,<1.38.0)"] +managedblockchain-query = ["mypy-boto3-managedblockchain-query (>=1.37.0,<1.38.0)"] +marketplace-agreement = ["mypy-boto3-marketplace-agreement (>=1.37.0,<1.38.0)"] +marketplace-catalog = ["mypy-boto3-marketplace-catalog (>=1.37.0,<1.38.0)"] +marketplace-deployment = ["mypy-boto3-marketplace-deployment (>=1.37.0,<1.38.0)"] +marketplace-entitlement = ["mypy-boto3-marketplace-entitlement (>=1.37.0,<1.38.0)"] +marketplace-reporting = ["mypy-boto3-marketplace-reporting (>=1.37.0,<1.38.0)"] +marketplacecommerceanalytics = ["mypy-boto3-marketplacecommerceanalytics (>=1.37.0,<1.38.0)"] +mediaconnect = ["mypy-boto3-mediaconnect (>=1.37.0,<1.38.0)"] +mediaconvert = ["mypy-boto3-mediaconvert (>=1.37.0,<1.38.0)"] +medialive = ["mypy-boto3-medialive (>=1.37.0,<1.38.0)"] +mediapackage = ["mypy-boto3-mediapackage (>=1.37.0,<1.38.0)"] +mediapackage-vod = ["mypy-boto3-mediapackage-vod (>=1.37.0,<1.38.0)"] +mediapackagev2 = ["mypy-boto3-mediapackagev2 (>=1.37.0,<1.38.0)"] +mediastore = ["mypy-boto3-mediastore (>=1.37.0,<1.38.0)"] +mediastore-data = ["mypy-boto3-mediastore-data (>=1.37.0,<1.38.0)"] +mediatailor = ["mypy-boto3-mediatailor (>=1.37.0,<1.38.0)"] +medical-imaging = ["mypy-boto3-medical-imaging (>=1.37.0,<1.38.0)"] +memorydb = ["mypy-boto3-memorydb (>=1.37.0,<1.38.0)"] +meteringmarketplace = ["mypy-boto3-meteringmarketplace (>=1.37.0,<1.38.0)"] +mgh = ["mypy-boto3-mgh (>=1.37.0,<1.38.0)"] +mgn = ["mypy-boto3-mgn (>=1.37.0,<1.38.0)"] +migration-hub-refactor-spaces = ["mypy-boto3-migration-hub-refactor-spaces (>=1.37.0,<1.38.0)"] +migrationhub-config = ["mypy-boto3-migrationhub-config (>=1.37.0,<1.38.0)"] +migrationhuborchestrator = ["mypy-boto3-migrationhuborchestrator (>=1.37.0,<1.38.0)"] +migrationhubstrategy = ["mypy-boto3-migrationhubstrategy (>=1.37.0,<1.38.0)"] +mq = ["mypy-boto3-mq (>=1.37.0,<1.38.0)"] +mturk = ["mypy-boto3-mturk (>=1.37.0,<1.38.0)"] +mwaa = ["mypy-boto3-mwaa (>=1.37.0,<1.38.0)"] +neptune = ["mypy-boto3-neptune (>=1.37.0,<1.38.0)"] +neptune-graph = ["mypy-boto3-neptune-graph (>=1.37.0,<1.38.0)"] +neptunedata = ["mypy-boto3-neptunedata (>=1.37.0,<1.38.0)"] +network-firewall = ["mypy-boto3-network-firewall (>=1.37.0,<1.38.0)"] +networkflowmonitor = ["mypy-boto3-networkflowmonitor (>=1.37.0,<1.38.0)"] +networkmanager = ["mypy-boto3-networkmanager (>=1.37.0,<1.38.0)"] +networkmonitor = ["mypy-boto3-networkmonitor (>=1.37.0,<1.38.0)"] +notifications = ["mypy-boto3-notifications (>=1.37.0,<1.38.0)"] +notificationscontacts = ["mypy-boto3-notificationscontacts (>=1.37.0,<1.38.0)"] +oam = ["mypy-boto3-oam (>=1.37.0,<1.38.0)"] +observabilityadmin = ["mypy-boto3-observabilityadmin (>=1.37.0,<1.38.0)"] +omics = ["mypy-boto3-omics (>=1.37.0,<1.38.0)"] +opensearch = ["mypy-boto3-opensearch (>=1.37.0,<1.38.0)"] +opensearchserverless = ["mypy-boto3-opensearchserverless (>=1.37.0,<1.38.0)"] +opsworks = ["mypy-boto3-opsworks (>=1.37.0,<1.38.0)"] +opsworkscm = ["mypy-boto3-opsworkscm (>=1.37.0,<1.38.0)"] +organizations = ["mypy-boto3-organizations (>=1.37.0,<1.38.0)"] +osis = ["mypy-boto3-osis (>=1.37.0,<1.38.0)"] +outposts = ["mypy-boto3-outposts (>=1.37.0,<1.38.0)"] +panorama = ["mypy-boto3-panorama (>=1.37.0,<1.38.0)"] +partnercentral-selling = ["mypy-boto3-partnercentral-selling (>=1.37.0,<1.38.0)"] +payment-cryptography = ["mypy-boto3-payment-cryptography (>=1.37.0,<1.38.0)"] +payment-cryptography-data = ["mypy-boto3-payment-cryptography-data (>=1.37.0,<1.38.0)"] +pca-connector-ad = ["mypy-boto3-pca-connector-ad (>=1.37.0,<1.38.0)"] +pca-connector-scep = ["mypy-boto3-pca-connector-scep (>=1.37.0,<1.38.0)"] +pcs = ["mypy-boto3-pcs (>=1.37.0,<1.38.0)"] +personalize = ["mypy-boto3-personalize (>=1.37.0,<1.38.0)"] +personalize-events = ["mypy-boto3-personalize-events (>=1.37.0,<1.38.0)"] +personalize-runtime = ["mypy-boto3-personalize-runtime (>=1.37.0,<1.38.0)"] +pi = ["mypy-boto3-pi (>=1.37.0,<1.38.0)"] +pinpoint = ["mypy-boto3-pinpoint (>=1.37.0,<1.38.0)"] +pinpoint-email = ["mypy-boto3-pinpoint-email (>=1.37.0,<1.38.0)"] +pinpoint-sms-voice = ["mypy-boto3-pinpoint-sms-voice (>=1.37.0,<1.38.0)"] +pinpoint-sms-voice-v2 = ["mypy-boto3-pinpoint-sms-voice-v2 (>=1.37.0,<1.38.0)"] +pipes = ["mypy-boto3-pipes (>=1.37.0,<1.38.0)"] +polly = ["mypy-boto3-polly (>=1.37.0,<1.38.0)"] +pricing = ["mypy-boto3-pricing (>=1.37.0,<1.38.0)"] +privatenetworks = ["mypy-boto3-privatenetworks (>=1.37.0,<1.38.0)"] +proton = ["mypy-boto3-proton (>=1.37.0,<1.38.0)"] +qapps = ["mypy-boto3-qapps (>=1.37.0,<1.38.0)"] +qbusiness = ["mypy-boto3-qbusiness (>=1.37.0,<1.38.0)"] +qconnect = ["mypy-boto3-qconnect (>=1.37.0,<1.38.0)"] +qldb = ["mypy-boto3-qldb (>=1.37.0,<1.38.0)"] +qldb-session = ["mypy-boto3-qldb-session (>=1.37.0,<1.38.0)"] +quicksight = ["mypy-boto3-quicksight (>=1.37.0,<1.38.0)"] +ram = ["mypy-boto3-ram (>=1.37.0,<1.38.0)"] +rbin = ["mypy-boto3-rbin (>=1.37.0,<1.38.0)"] +rds = ["mypy-boto3-rds (>=1.37.0,<1.38.0)"] +rds-data = ["mypy-boto3-rds-data (>=1.37.0,<1.38.0)"] +redshift = ["mypy-boto3-redshift (>=1.37.0,<1.38.0)"] +redshift-data = ["mypy-boto3-redshift-data (>=1.37.0,<1.38.0)"] +redshift-serverless = ["mypy-boto3-redshift-serverless (>=1.37.0,<1.38.0)"] +rekognition = ["mypy-boto3-rekognition (>=1.37.0,<1.38.0)"] +repostspace = ["mypy-boto3-repostspace (>=1.37.0,<1.38.0)"] +resiliencehub = ["mypy-boto3-resiliencehub (>=1.37.0,<1.38.0)"] +resource-explorer-2 = ["mypy-boto3-resource-explorer-2 (>=1.37.0,<1.38.0)"] +resource-groups = ["mypy-boto3-resource-groups (>=1.37.0,<1.38.0)"] +resourcegroupstaggingapi = ["mypy-boto3-resourcegroupstaggingapi (>=1.37.0,<1.38.0)"] +robomaker = ["mypy-boto3-robomaker (>=1.37.0,<1.38.0)"] +rolesanywhere = ["mypy-boto3-rolesanywhere (>=1.37.0,<1.38.0)"] +route53 = ["mypy-boto3-route53 (>=1.37.0,<1.38.0)"] +route53-recovery-cluster = ["mypy-boto3-route53-recovery-cluster (>=1.37.0,<1.38.0)"] +route53-recovery-control-config = ["mypy-boto3-route53-recovery-control-config (>=1.37.0,<1.38.0)"] +route53-recovery-readiness = ["mypy-boto3-route53-recovery-readiness (>=1.37.0,<1.38.0)"] +route53domains = ["mypy-boto3-route53domains (>=1.37.0,<1.38.0)"] +route53profiles = ["mypy-boto3-route53profiles (>=1.37.0,<1.38.0)"] +route53resolver = ["mypy-boto3-route53resolver (>=1.37.0,<1.38.0)"] +rum = ["mypy-boto3-rum (>=1.37.0,<1.38.0)"] +s3 = ["mypy-boto3-s3 (>=1.37.0,<1.38.0)"] +s3control = ["mypy-boto3-s3control (>=1.37.0,<1.38.0)"] +s3outposts = ["mypy-boto3-s3outposts (>=1.37.0,<1.38.0)"] +s3tables = ["mypy-boto3-s3tables (>=1.37.0,<1.38.0)"] +sagemaker = ["mypy-boto3-sagemaker (>=1.37.0,<1.38.0)"] +sagemaker-a2i-runtime = ["mypy-boto3-sagemaker-a2i-runtime (>=1.37.0,<1.38.0)"] +sagemaker-edge = ["mypy-boto3-sagemaker-edge (>=1.37.0,<1.38.0)"] +sagemaker-featurestore-runtime = ["mypy-boto3-sagemaker-featurestore-runtime (>=1.37.0,<1.38.0)"] +sagemaker-geospatial = ["mypy-boto3-sagemaker-geospatial (>=1.37.0,<1.38.0)"] +sagemaker-metrics = ["mypy-boto3-sagemaker-metrics (>=1.37.0,<1.38.0)"] +sagemaker-runtime = ["mypy-boto3-sagemaker-runtime (>=1.37.0,<1.38.0)"] +savingsplans = ["mypy-boto3-savingsplans (>=1.37.0,<1.38.0)"] +scheduler = ["mypy-boto3-scheduler (>=1.37.0,<1.38.0)"] +schemas = ["mypy-boto3-schemas (>=1.37.0,<1.38.0)"] +sdb = ["mypy-boto3-sdb (>=1.37.0,<1.38.0)"] +secretsmanager = ["mypy-boto3-secretsmanager (>=1.37.0,<1.38.0)"] +security-ir = ["mypy-boto3-security-ir (>=1.37.0,<1.38.0)"] +securityhub = ["mypy-boto3-securityhub (>=1.37.0,<1.38.0)"] +securitylake = ["mypy-boto3-securitylake (>=1.37.0,<1.38.0)"] +serverlessrepo = ["mypy-boto3-serverlessrepo (>=1.37.0,<1.38.0)"] +service-quotas = ["mypy-boto3-service-quotas (>=1.37.0,<1.38.0)"] +servicecatalog = ["mypy-boto3-servicecatalog (>=1.37.0,<1.38.0)"] +servicecatalog-appregistry = ["mypy-boto3-servicecatalog-appregistry (>=1.37.0,<1.38.0)"] +servicediscovery = ["mypy-boto3-servicediscovery (>=1.37.0,<1.38.0)"] +ses = ["mypy-boto3-ses (>=1.37.0,<1.38.0)"] +sesv2 = ["mypy-boto3-sesv2 (>=1.37.0,<1.38.0)"] +shield = ["mypy-boto3-shield (>=1.37.0,<1.38.0)"] +signer = ["mypy-boto3-signer (>=1.37.0,<1.38.0)"] +simspaceweaver = ["mypy-boto3-simspaceweaver (>=1.37.0,<1.38.0)"] +sms = ["mypy-boto3-sms (>=1.37.0,<1.38.0)"] +sms-voice = ["mypy-boto3-sms-voice (>=1.37.0,<1.38.0)"] +snow-device-management = ["mypy-boto3-snow-device-management (>=1.37.0,<1.38.0)"] +snowball = ["mypy-boto3-snowball (>=1.37.0,<1.38.0)"] +sns = ["mypy-boto3-sns (>=1.37.0,<1.38.0)"] +socialmessaging = ["mypy-boto3-socialmessaging (>=1.37.0,<1.38.0)"] +sqs = ["mypy-boto3-sqs (>=1.37.0,<1.38.0)"] +ssm = ["mypy-boto3-ssm (>=1.37.0,<1.38.0)"] +ssm-contacts = ["mypy-boto3-ssm-contacts (>=1.37.0,<1.38.0)"] +ssm-incidents = ["mypy-boto3-ssm-incidents (>=1.37.0,<1.38.0)"] +ssm-quicksetup = ["mypy-boto3-ssm-quicksetup (>=1.37.0,<1.38.0)"] +ssm-sap = ["mypy-boto3-ssm-sap (>=1.37.0,<1.38.0)"] +sso = ["mypy-boto3-sso (>=1.37.0,<1.38.0)"] +sso-admin = ["mypy-boto3-sso-admin (>=1.37.0,<1.38.0)"] +sso-oidc = ["mypy-boto3-sso-oidc (>=1.37.0,<1.38.0)"] +stepfunctions = ["mypy-boto3-stepfunctions (>=1.37.0,<1.38.0)"] +storagegateway = ["mypy-boto3-storagegateway (>=1.37.0,<1.38.0)"] +sts = ["mypy-boto3-sts (>=1.37.0,<1.38.0)"] +supplychain = ["mypy-boto3-supplychain (>=1.37.0,<1.38.0)"] +support = ["mypy-boto3-support (>=1.37.0,<1.38.0)"] +support-app = ["mypy-boto3-support-app (>=1.37.0,<1.38.0)"] +swf = ["mypy-boto3-swf (>=1.37.0,<1.38.0)"] +synthetics = ["mypy-boto3-synthetics (>=1.37.0,<1.38.0)"] +taxsettings = ["mypy-boto3-taxsettings (>=1.37.0,<1.38.0)"] +textract = ["mypy-boto3-textract (>=1.37.0,<1.38.0)"] +timestream-influxdb = ["mypy-boto3-timestream-influxdb (>=1.37.0,<1.38.0)"] +timestream-query = ["mypy-boto3-timestream-query (>=1.37.0,<1.38.0)"] +timestream-write = ["mypy-boto3-timestream-write (>=1.37.0,<1.38.0)"] +tnb = ["mypy-boto3-tnb (>=1.37.0,<1.38.0)"] +transcribe = ["mypy-boto3-transcribe (>=1.37.0,<1.38.0)"] +transfer = ["mypy-boto3-transfer (>=1.37.0,<1.38.0)"] +translate = ["mypy-boto3-translate (>=1.37.0,<1.38.0)"] +trustedadvisor = ["mypy-boto3-trustedadvisor (>=1.37.0,<1.38.0)"] +verifiedpermissions = ["mypy-boto3-verifiedpermissions (>=1.37.0,<1.38.0)"] +voice-id = ["mypy-boto3-voice-id (>=1.37.0,<1.38.0)"] +vpc-lattice = ["mypy-boto3-vpc-lattice (>=1.37.0,<1.38.0)"] +waf = ["mypy-boto3-waf (>=1.37.0,<1.38.0)"] +waf-regional = ["mypy-boto3-waf-regional (>=1.37.0,<1.38.0)"] +wafv2 = ["mypy-boto3-wafv2 (>=1.37.0,<1.38.0)"] +wellarchitected = ["mypy-boto3-wellarchitected (>=1.37.0,<1.38.0)"] +wisdom = ["mypy-boto3-wisdom (>=1.37.0,<1.38.0)"] +workdocs = ["mypy-boto3-workdocs (>=1.37.0,<1.38.0)"] +workmail = ["mypy-boto3-workmail (>=1.37.0,<1.38.0)"] +workmailmessageflow = ["mypy-boto3-workmailmessageflow (>=1.37.0,<1.38.0)"] +workspaces = ["mypy-boto3-workspaces (>=1.37.0,<1.38.0)"] +workspaces-thin-client = ["mypy-boto3-workspaces-thin-client (>=1.37.0,<1.38.0)"] +workspaces-web = ["mypy-boto3-workspaces-web (>=1.37.0,<1.38.0)"] +xray = ["mypy-boto3-xray (>=1.37.0,<1.38.0)"] + [[package]] name = "botocore" version = "1.37.38" @@ -84,7 +511,7 @@ version = "1.38.30" description = "Type annotations and code completion for botocore" optional = false python-versions = ">=3.8" -groups = ["test"] +groups = ["main", "test"] files = [ {file = "botocore_stubs-1.38.30-py3-none-any.whl", hash = "sha256:2efb8bdf36504aff596c670d875d8f7dd15205277c15c4cea54afdba8200c266"}, {file = "botocore_stubs-1.38.30.tar.gz", hash = "sha256:291d7bf39a316c00a8a55b7255489b02c0cea1a343482e7784e8d1e235bae995"}, @@ -1662,7 +2089,7 @@ version = "0.27.2" description = "Type annotations and code completion for awscrt" optional = false python-versions = ">=3.8" -groups = ["test"] +groups = ["main", "test"] files = [ {file = "types_awscrt-0.27.2-py3-none-any.whl", hash = "sha256:49a045f25bbd5ad2865f314512afced933aed35ddbafc252e2268efa8a787e4e"}, {file = "types_awscrt-0.27.2.tar.gz", hash = "sha256:acd04f57119eb15626ab0ba9157fc24672421de56e7bd7b9f61681fedee44e91"}, @@ -2103,7 +2530,7 @@ version = "0.13.0" description = "Type annotations and code completion for s3transfer" optional = false python-versions = ">=3.8" -groups = ["test"] +groups = ["main", "test"] files = [ {file = "types_s3transfer-0.13.0-py3-none-any.whl", hash = "sha256:79c8375cbf48a64bff7654c02df1ec4b20d74f8c5672fc13e382f593ca5565b3"}, {file = "types_s3transfer-0.13.0.tar.gz", hash = "sha256:203dadcb9865c2f68fb44bc0440e1dc05b79197ba4a641c0976c26c9af75ef52"}, @@ -2255,4 +2682,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.10.0" -content-hash = "5c9676388fe69de1cd60d813b75285505ccbc9e872168e83f9af0d7130d7cb75" +content-hash = "75a449db2de5ff514b394398af51662fbe85da4e20782af6aac77d79ae3ca633" diff --git a/pyproject.toml b/pyproject.toml index 84ba6836..5f819b78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ types_aws_xray_sdk = "^2.13.0" opentelemetry-api = "^1.22.0" opentelemetry-sdk = "^1.22.0" requests = "^2.32.2" +boto3-stubs = "~=1.37.38" [tool.poetry.group.dev.dependencies] mypy = "^1.9.0" diff --git a/tests/__init__.py b/tests/__init__.py index bd4acb2b..d3b3936f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -11,3 +11,5 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + +__version__ = "2.0.0" diff --git a/tests/unit/test_aws_credentials_manager.py b/tests/unit/test_aws_credentials_manager.py new file mode 100644 index 00000000..0da2207f --- /dev/null +++ b/tests/unit/test_aws_credentials_manager.py @@ -0,0 +1,429 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from time import sleep + +import pytest +from boto3 import Session + +from aws_advanced_python_wrapper.aws_credentials_manager import \ + AwsCredentialsManager +from aws_advanced_python_wrapper.hostinfo import HostInfo +from aws_advanced_python_wrapper.utils.atomic import AtomicInt +from aws_advanced_python_wrapper.utils.properties import Properties + + +@pytest.fixture(autouse=True) +def cleanup(): + AwsCredentialsManager.release_resources() + AwsCredentialsManager.reset_custom_handler() + yield + AwsCredentialsManager.release_resources() + AwsCredentialsManager.reset_custom_handler() + + +@pytest.fixture +def host_info(): + return HostInfo("foo.us-east-1.rds.amazonaws.com", 5432) + + +@pytest.fixture +def props(): + return Properties({}) + + +@pytest.fixture +def region(): + return "us-east-1" + + +@pytest.fixture +def concurrent_counter(): + return AtomicInt() + + +@pytest.fixture +def counter(): + return AtomicInt() + + +@pytest.fixture +def num_threads(): + return 20 + + +@pytest.fixture +def regions(): + return ["us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1", "ca-central-1"] + + +@pytest.fixture +def mock_session(mocker): + session = mocker.MagicMock(spec=Session) + mocker.patch('aws_advanced_python_wrapper.aws_credentials_manager.Session', return_value=session) + return session + + +@pytest.fixture +def mock_client(mocker, mock_session): + mock_client = mocker.MagicMock() + mocker.patch.object(mock_session, 'client', return_value=mock_client) + return mock_client + + +class TestAwsCredentialsManagerBasic: + def test_get_session_creates_default_session(self, mock_session, host_info, props, region): + mock_session.region_name = region + + session = AwsCredentialsManager.get_session(host_info, props, region) + + assert session is not None + assert session is mock_session + assert session.region_name == region + + def test_get_session_caches_session(self, mock_session, host_info, props, region): + session1 = AwsCredentialsManager.get_session(host_info, props, region) + session2 = AwsCredentialsManager.get_session(host_info, props, region) + + assert session1 is session2 + + def test_get_session_different_regions(self, host_info, props, mocker): + mock_session1 = mocker.MagicMock(spec=Session) + mock_session1.region_name = "us-east-1" + mock_session2 = mocker.MagicMock(spec=Session) + mock_session2.region_name = "us-west-2" + + mock_session_class = mocker.patch('aws_advanced_python_wrapper.aws_credentials_manager.Session') + mock_session_class.side_effect = [mock_session1, mock_session2] + + session1 = AwsCredentialsManager.get_session(host_info, props, "us-east-1") + session2 = AwsCredentialsManager.get_session(host_info, props, "us-west-2") + + assert session1 is not session2 + assert session1.region_name == "us-east-1" + assert session2.region_name == "us-west-2" + + def test_get_session_different_hosts(self, props, region, mocker): + host1 = HostInfo("host1.rds.amazonaws.com", 5432) + host2 = HostInfo("host2.rds.amazonaws.com", 5432) + + mock_session1 = mocker.MagicMock(spec=Session) + mock_session2 = mocker.MagicMock(spec=Session) + + mock_session_class = mocker.patch('aws_advanced_python_wrapper.aws_credentials_manager.Session') + mock_session_class.side_effect = [mock_session1, mock_session2] + + session1 = AwsCredentialsManager.get_session(host1, props, region) + session2 = AwsCredentialsManager.get_session(host2, props, region) + + assert session1 is not session2 + + def test_get_session_with_custom_handler(self, mock_session, host_info, props, region, mocker): + custom_session = mock_session + custom_session.region_name = "custom-region" + custom_handler = mocker.MagicMock(return_value=custom_session) + + AwsCredentialsManager.set_custom_handler(custom_handler) + session = AwsCredentialsManager.get_session(host_info, props, region) + + assert session is custom_session + custom_handler.assert_called_once_with(host_info, props) + + def test_reset_custom_handler(self, host_info, props, region, mocker): + custom_session = mocker.MagicMock(spec=Session) + custom_handler = mocker.MagicMock(return_value=custom_session) + + mock_default_session = mocker.MagicMock(spec=Session) + mock_default_session.region_name = region + mocker.patch('aws_advanced_python_wrapper.aws_credentials_manager.Session', return_value=mock_default_session) + + AwsCredentialsManager.set_custom_handler(custom_handler) + AwsCredentialsManager.reset_custom_handler() + session = AwsCredentialsManager.get_session(host_info, props, region) + + assert session is not custom_session + assert session is mock_default_session + assert session.region_name == region + + def test_get_client_creates_client(self, host_info, props, region, mocker): + session = AwsCredentialsManager.get_session(host_info, props, region) + mock_client = mocker.MagicMock() + mocker.patch.object(session, "client", return_value=mock_client) + + client = AwsCredentialsManager.get_client("rds", session, host_info.host, region) + + assert client is mock_client + session.client.assert_called_once_with(service_name="rds") + + def test_get_client_caches_client(self, mock_client, host_info, props, region): + session = AwsCredentialsManager.get_session(host_info, props, region) + + client1 = AwsCredentialsManager.get_client("rds", session, host_info.host, region) + client2 = AwsCredentialsManager.get_client("rds", session, host_info.host, region) + + assert client1 is client2 + session.client.assert_called_once() + + def test_get_client_different_services(self, host_info, props, region, mocker): + session = AwsCredentialsManager.get_session(host_info, props, region) + mock_rds_client = mocker.MagicMock() + mock_secrets_client = mocker.MagicMock() + + def client_side_effect(service_name): + if service_name == "rds": + return mock_rds_client + elif service_name == "secretsmanager": + return mock_secrets_client + + mocker.patch.object(session, "client", side_effect=client_side_effect) + + rds_client = AwsCredentialsManager.get_client("rds", session, host_info.host, region) + secrets_client = AwsCredentialsManager.get_client("secretsmanager", session, host_info.host, region) + + assert rds_client is mock_rds_client + assert secrets_client is mock_secrets_client + assert rds_client is not secrets_client + + def test_release_resources_clears_caches(self, host_info, props, region, mocker): + mock_session1 = mocker.MagicMock(spec=Session) + mock_session2 = mocker.MagicMock(spec=Session) + mock_client = mocker.MagicMock() + + mock_session_class = mocker.patch('aws_advanced_python_wrapper.aws_credentials_manager.Session') + mock_session_class.side_effect = [mock_session1, mock_session2] + + mock_session1.client.return_value = mock_client + + session = AwsCredentialsManager.get_session(host_info, props, region) + AwsCredentialsManager.get_client("rds", session, host_info.host, region) + + AwsCredentialsManager.release_resources() + + new_session = AwsCredentialsManager.get_session(host_info, props, region) + assert new_session is not mock_session1 + assert new_session is mock_session2 + + def test_concurrent_get_session_same_host(self, mock_session, host_info, props, region, counter, concurrent_counter, num_threads): + barrier = Barrier(num_threads) + sessions = [] + + def get_session_thread(): + barrier.wait() + val = counter.get_and_increment() + if val != 0: + concurrent_counter.get_and_increment() + + session = AwsCredentialsManager.get_session(host_info, props, region) + sessions.append(session) + + sleep(0.001) + counter.get_and_decrement() + + with ThreadPoolExecutor(num_threads) as executor: + futures = [executor.submit(get_session_thread) for _ in range(num_threads)] + for future in futures: + future.result() + + assert len(sessions) == num_threads + assert all(session is sessions[0] for session in sessions) + assert concurrent_counter.get() > 0 + + def test_concurrent_get_session_different_hosts(self, props, region, counter, concurrent_counter, mocker, num_threads): + barrier = Barrier(num_threads) + results = [] + + # One session per host + mock_sessions = [mocker.MagicMock(spec=Session) for _ in range(num_threads)] + mock_session_class = mocker.patch('aws_advanced_python_wrapper.aws_credentials_manager.Session') + mock_session_class.side_effect = mock_sessions + + def get_session_thread(thread_id): + barrier.wait() + val = counter.get_and_increment() + if val != 0: + concurrent_counter.get_and_increment() + + host = HostInfo(f"host-{thread_id}.rds.amazonaws.com", 5432) + session = AwsCredentialsManager.get_session(host, props, region) + results.append((thread_id, session)) + + sleep(0.001) + counter.get_and_decrement() + + with ThreadPoolExecutor(num_threads) as executor: + futures = [executor.submit(get_session_thread, i) for i in range(num_threads)] + for future in futures: + future.result() + + assert len(results) == num_threads + sessions_by_id = {thread_id: session for thread_id, session in results} + assert len(set(id(session) for session in sessions_by_id.values())) == num_threads + assert concurrent_counter.get() > 0 + + def test_concurrent_get_session_different_regions(self, num_threads, host_info, props, counter, concurrent_counter, regions, mocker): + barrier = Barrier(num_threads) + results = [] + + # One session per region + mock_sessions = {region: mocker.MagicMock(spec=Session) for region in regions} + + def session_factory(region_name=None, **kwargs): + return mock_sessions[region_name] + + mocker.patch('aws_advanced_python_wrapper.aws_credentials_manager.Session', side_effect=session_factory) + + def get_session_thread(thread_id): + barrier.wait() + val = counter.get_and_increment() + if val != 0: + concurrent_counter.get_and_increment() + + region = regions[thread_id % len(regions)] + session = AwsCredentialsManager.get_session(host_info, props, region) + results.append((region, session)) + + sleep(0.001) + counter.get_and_decrement() + + with ThreadPoolExecutor(num_threads) as executor: + futures = [executor.submit(get_session_thread, i) for i in range(num_threads)] + for future in futures: + future.result() + + # Group sessions by region + sessions_by_region = {} + for region, session in results: + if region not in sessions_by_region: + sessions_by_region[region] = [] + sessions_by_region[region].append(session) + + # All sessions for the same region should be identical + for region, sessions in sessions_by_region.items(): + assert all(session is sessions[0] for session in sessions) + + # Sessions for different regions should be different + unique_sessions = [sessions[0] for sessions in sessions_by_region.values()] + assert len(set(id(s) for s in unique_sessions)) == len(regions) + assert concurrent_counter.get() > 0 + + def test_concurrent_get_client_same_parameters(self, host_info, props, region, counter, concurrent_counter, mocker): + num_threads = 20 + barrier = Barrier(num_threads) + clients = [] + + session = AwsCredentialsManager.get_session(host_info, props, region) + mock_client = mocker.MagicMock() + mocker.patch.object(session, 'client', return_value=mock_client) + + def get_client_thread(): + barrier.wait() + val = counter.get_and_increment() + if val != 0: + concurrent_counter.get_and_increment() + + client = AwsCredentialsManager.get_client("rds", session, host_info.host, region) + clients.append(client) + + sleep(0.001) + counter.get_and_decrement() + + with ThreadPoolExecutor(num_threads) as executor: + futures = [executor.submit(get_client_thread) for _ in range(num_threads)] + for future in futures: + future.result() + + assert len(clients) == num_threads + assert all(client is clients[0] for client in clients) + session.client.assert_called_once_with(service_name="rds") + assert concurrent_counter.get() > 0 + + def test_concurrent_get_client_different_services(self, host_info, props, region, counter, concurrent_counter, mocker): + num_threads = 20 + services = ["rds", "secretsmanager", "sts", "iam"] + barrier = Barrier(num_threads) + results = [] + + session = AwsCredentialsManager.get_session(host_info, props, region) + + def client_side_effect(service_name): + return mocker.MagicMock(name=f"{service_name}_client") + + mocker.patch.object(session, 'client', side_effect=client_side_effect) + + def get_client_thread(thread_id): + barrier.wait() + val = counter.get_and_increment() + if val != 0: + concurrent_counter.get_and_increment() + + service = services[thread_id % len(services)] + client = AwsCredentialsManager.get_client(service, session, host_info.host, region) + results.append((service, client)) + + sleep(0.001) + counter.get_and_decrement() + + with ThreadPoolExecutor(num_threads) as executor: + futures = [executor.submit(get_client_thread, i) for i in range(num_threads)] + for future in futures: + future.result() + + # Group clients by service + clients_by_service = {} + for service, client in results: + if service not in clients_by_service: + clients_by_service[service] = [] + clients_by_service[service].append(client) + + # All clients for the same service should be identical + for service, clients in clients_by_service.items(): + assert all(client is clients[0] for client in clients) + + # Clients for different services should be different + unique_clients = [clients[0] for clients in clients_by_service.values()] + assert len(set(id(c) for c in unique_clients)) == len(services) + assert concurrent_counter.get() > 0 + + def test_release_resources_closes_all_clients(self, host_info, props, region, mocker): + session = AwsCredentialsManager.get_session(host_info, props, region) + + mock_rds_client = mocker.MagicMock() + mock_secrets_client = mocker.MagicMock() + mock_sts_client = mocker.MagicMock() + + def client_side_effect(service_name): + if service_name == "rds": + return mock_rds_client + elif service_name == "secretsmanager": + return mock_secrets_client + elif service_name == "sts": + return mock_sts_client + + mocker.patch.object(session, "client", side_effect=client_side_effect) + + rds_client = AwsCredentialsManager.get_client("rds", session, host_info.host, region) + secrets_client = AwsCredentialsManager.get_client("secretsmanager", session, host_info.host, region) + sts_client = AwsCredentialsManager.get_client("sts", session, host_info.host, region) + + assert rds_client is mock_rds_client + assert secrets_client is mock_secrets_client + assert sts_client is mock_sts_client + + AwsCredentialsManager.release_resources() + + mock_rds_client.close.assert_called_once() + mock_secrets_client.close.assert_called_once() + mock_sts_client.close.assert_called_once() diff --git a/tests/unit/test_federated_auth_plugin.py b/tests/unit/test_federated_auth_plugin.py index 1c3a77e3..450972b5 100644 --- a/tests/unit/test_federated_auth_plugin.py +++ b/tests/unit/test_federated_auth_plugin.py @@ -19,7 +19,10 @@ from unittest.mock import patch import pytest +from boto3 import Session +from aws_advanced_python_wrapper.aws_credentials_manager import \ + AwsCredentialsManager from aws_advanced_python_wrapper.federated_plugin import FederatedAuthPlugin from aws_advanced_python_wrapper.hostinfo import HostInfo from aws_advanced_python_wrapper.iam_plugin import TokenInfo @@ -40,11 +43,12 @@ @pytest.fixture(autouse=True) def clear_cache(): _token_cache.clear() + AwsCredentialsManager.release_resources() @pytest.fixture def mock_session(mocker): - return mocker.MagicMock() + return mocker.MagicMock(spec=Session) @pytest.fixture @@ -91,6 +95,13 @@ def mock_default_behavior(mock_session, mock_client, mock_func, mock_connection, "SecretAccessKey": "test-secret-access", "SessionToken": "test-session-token"} + def custom_handler(host_info: HostInfo, props: Properties) -> Session: + return mock_session + + AwsCredentialsManager.set_custom_handler(custom_handler) + yield + AwsCredentialsManager.reset_custom_handler() + @patch("aws_advanced_python_wrapper.federated_plugin.FederatedAuthPlugin._token_cache", _token_cache) def test_pg_connect_valid_token_in_cache(mocker, mock_plugin_service, mock_session, mock_func, mock_client, mock_dialect): @@ -129,7 +140,7 @@ def test_expired_cached_token(mocker, mock_plugin_service, mock_session, mock_fu initial_token = TokenInfo(_TEST_TOKEN, datetime.now() - timedelta(minutes=5)) _token_cache[_PG_CACHE_KEY] = initial_token - target_plugin: FederatedAuthPlugin = FederatedAuthPlugin(mock_plugin_service, mock_credentials_provider_factory, mock_session) + target_plugin: FederatedAuthPlugin = FederatedAuthPlugin(mock_plugin_service, mock_credentials_provider_factory) target_plugin.connect( target_driver_func=mocker.MagicMock(), @@ -154,7 +165,7 @@ def test_no_cached_token(mocker, mock_plugin_service, mock_session, mock_func, m test_props: Properties = Properties({"plugins": "federated_auth", "user": "postgresqlUser", "idp_username": "user", "idp_password": "password"}) WrapperProperties.DB_USER.set(test_props, _DB_USER) - target_plugin: FederatedAuthPlugin = FederatedAuthPlugin(mock_plugin_service, mock_credentials_provider_factory, mock_session) + target_plugin: FederatedAuthPlugin = FederatedAuthPlugin(mock_plugin_service, mock_credentials_provider_factory) target_plugin.connect( target_driver_func=mocker.MagicMock(), @@ -183,8 +194,7 @@ def test_no_cached_token_raises_exception(mocker, mock_plugin_service, mock_sess exception_message = "generic exception" mock_func.side_effect = Exception(exception_message) - target_plugin: FederatedAuthPlugin = FederatedAuthPlugin(mock_plugin_service, mock_credentials_provider_factory, - mock_session) + target_plugin: FederatedAuthPlugin = FederatedAuthPlugin(mock_plugin_service, mock_credentials_provider_factory) with pytest.raises(Exception) as e_info: target_plugin.connect( target_driver_func=mocker.MagicMock(), @@ -229,11 +239,11 @@ def test_connect_with_specified_iam_host_port_region(mocker, mock_client.generate_db_auth_token.return_value = f"{_TEST_TOKEN}:{expected_region}" - target_plugin: FederatedAuthPlugin = FederatedAuthPlugin(mock_plugin_service, mock_credentials_provider_factory, mock_session) + target_plugin: FederatedAuthPlugin = FederatedAuthPlugin(mock_plugin_service, mock_credentials_provider_factory) target_plugin.connect( target_driver_func=mocker.MagicMock(), driver_dialect=mock_dialect, - host_info=HostInfo(expected_host), + host_info=HostInfo("foo.com"), props=properties, is_initial_connection=False, connect_func=mock_func) diff --git a/tests/unit/test_iam_plugin.py b/tests/unit/test_iam_plugin.py index 04273698..352c2ec0 100644 --- a/tests/unit/test_iam_plugin.py +++ b/tests/unit/test_iam_plugin.py @@ -20,7 +20,10 @@ from unittest.mock import patch import pytest +from boto3 import Session +from aws_advanced_python_wrapper.aws_credentials_manager import \ + AwsCredentialsManager from aws_advanced_python_wrapper.errors import AwsWrapperError from aws_advanced_python_wrapper.hostinfo import HostInfo from aws_advanced_python_wrapper.iam_plugin import IamAuthPlugin, TokenInfo @@ -44,16 +47,17 @@ @pytest.fixture(autouse=True) def clear_caches(): _token_cache.clear() + AwsCredentialsManager.release_resources() @pytest.fixture -def mock_session(mocker): +def mock_client(mocker): return mocker.MagicMock() @pytest.fixture -def mock_client(mocker): - return mocker.MagicMock() +def mock_session(mocker, mock_client): + return mocker.MagicMock(spec=Session) @pytest.fixture @@ -86,6 +90,13 @@ def mock_default_behavior(mock_session, mock_client, mock_func, mock_connection, mock_plugin_service.database_dialect = mock_dialect mock_dialect.default_port = _DEFAULT_PG_PORT + def custom_handler(host_info: HostInfo, props: Properties) -> Session: + return mock_session + + AwsCredentialsManager.set_custom_handler(custom_handler) + yield + AwsCredentialsManager.reset_custom_handler() + @pytest.fixture def pg_properties(): @@ -98,8 +109,7 @@ def test_pg_connect_valid_token_in_cache(mocker, mock_plugin_service, mock_sessi initial_token = TokenInfo(_TEST_TOKEN, datetime.now() + timedelta(minutes=5)) _token_cache[_PG_CACHE_KEY] = initial_token - target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service, - mock_session) + target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service) target_plugin.connect( target_driver_func=mocker.MagicMock(), driver_dialect=mock_dialect, @@ -126,8 +136,7 @@ def test_pg_connect_with_invalid_port_fall_backs_to_host_port( # Assert no password has been set assert test_props.get("password") is None - target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service, - mock_session) + target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service) target_plugin.connect( target_driver_func=mocker.MagicMock(), driver_dialect=mock_dialect, @@ -162,8 +171,7 @@ def test_pg_connect_with_invalid_port_and_no_host_port_fall_backs_to_host_port( # Assert no password has been set assert test_props.get("password") is None - target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service, - mock_session) + target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service) target_plugin.connect( target_driver_func=mocker.MagicMock(), driver_dialect=mock_dialect, @@ -195,7 +203,7 @@ def test_connect_expired_token_in_cache(mocker, mock_plugin_service, mock_sessio _token_cache[_PG_CACHE_KEY] = initial_token mock_func.side_effect = Exception("generic exception") - target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service, mock_session) + target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service) with pytest.raises(Exception): target_plugin.connect( target_driver_func=mocker.MagicMock(), @@ -220,7 +228,7 @@ def test_connect_expired_token_in_cache(mocker, mock_plugin_service, mock_sessio @patch("aws_advanced_python_wrapper.iam_plugin.IamAuthPlugin._token_cache", _token_cache) def test_connect_empty_cache(mocker, mock_plugin_service, mock_connection, mock_session, mock_func, mock_client, mock_dialect): test_props: Properties = Properties({"user": "postgresqlUser"}) - target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service, mock_session) + target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service) actual_connection = target_plugin.connect( target_driver_func=mocker.MagicMock(), driver_dialect=mock_dialect, @@ -251,7 +259,7 @@ def test_connect_with_specified_port(mocker, mock_plugin_service, mock_session, # Assert no password has been set assert test_props.get("password") is None - target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service, mock_session) + target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service) target_plugin.connect( target_driver_func=mocker.MagicMock(), driver_dialect=mock_dialect, @@ -285,7 +293,7 @@ def test_connect_with_specified_iam_default_port(mocker, mock_plugin_service, mo # Assert no password has been set assert test_props.get("password") is None - target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service, mock_session) + target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service) target_plugin.connect( target_driver_func=mocker.MagicMock(), driver_dialect=mock_dialect, @@ -323,7 +331,7 @@ def test_connect_with_specified_region(mocker, mock_plugin_service, mock_session assert test_props.get("password") is None mock_client.generate_db_auth_token.return_value = f"{_TEST_TOKEN}:{iam_region}" - target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service, mock_session) + target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service) target_plugin.connect( target_driver_func=mocker.MagicMock(), driver_dialect=mock_dialect, @@ -332,10 +340,7 @@ def test_connect_with_specified_region(mocker, mock_plugin_service, mock_session is_initial_connection=False, connect_func=mock_func) - mock_session.client.assert_called_with( - "rds", - region_name=iam_region - ) + mock_session.client.assert_called_with(service_name="rds") mock_client.generate_db_auth_token.assert_called_with( DBHostname="pg.testdb.us-east-2.rds.amazonaws.com", Port=5432, @@ -369,7 +374,7 @@ def test_connect_with_specified_host(iam_host: str, mocker, mock_plugin_service, assert test_props.get("password") is None mock_client.generate_db_auth_token.return_value = f"{_TEST_TOKEN}:{iam_host}" - target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service, mock_session) + target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service) target_plugin.connect( target_driver_func=mocker.MagicMock(), driver_dialect=mock_dialect, @@ -411,7 +416,7 @@ def test_aws_supported_regions_url_exists(): def test_invalid_iam_host(host, mocker, mock_plugin_service, mock_session, mock_func, mock_client, mock_dialect): test_props: Properties = Properties({"user": "postgresqlUser"}) with pytest.raises(AwsWrapperError): - target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service, mock_session) + target_plugin: IamAuthPlugin = IamAuthPlugin(mock_plugin_service) target_plugin.connect( target_driver_func=mocker.MagicMock(), driver_dialect=mock_dialect, diff --git a/tests/unit/test_okta_plugin.py b/tests/unit/test_okta_plugin.py index 72f9727a..93e490ff 100644 --- a/tests/unit/test_okta_plugin.py +++ b/tests/unit/test_okta_plugin.py @@ -19,7 +19,10 @@ from unittest.mock import patch import pytest +from boto3 import Session +from aws_advanced_python_wrapper.aws_credentials_manager import \ + AwsCredentialsManager from aws_advanced_python_wrapper.hostinfo import HostInfo from aws_advanced_python_wrapper.iam_plugin import TokenInfo from aws_advanced_python_wrapper.okta_plugin import OktaAuthPlugin @@ -40,11 +43,12 @@ @pytest.fixture(autouse=True) def clear_cache(): _token_cache.clear() + AwsCredentialsManager.release_resources() @pytest.fixture def mock_session(mocker): - return mocker.MagicMock() + return mocker.MagicMock(spec=Session) @pytest.fixture @@ -91,6 +95,13 @@ def mock_default_behavior(mock_session, mock_client, mock_func, mock_connection, "SecretAccessKey": "test-secret-access", "SessionToken": "test-session-token"} + def custom_handler(host_info: HostInfo, props: Properties) -> Session: + return mock_session + + AwsCredentialsManager.set_custom_handler(custom_handler) + yield + AwsCredentialsManager.reset_custom_handler() + @patch("aws_advanced_python_wrapper.okta_plugin.OktaAuthPlugin._token_cache", _token_cache) def test_pg_connect_valid_token_in_cache(mocker, mock_plugin_service, mock_session, mock_func, mock_client, mock_dialect): @@ -127,7 +138,7 @@ def test_expired_cached_token(mocker, mock_plugin_service, mock_session, mock_fu initial_token = TokenInfo(_TEST_TOKEN, datetime.now() - timedelta(minutes=5)) _token_cache[_PG_CACHE_KEY] = initial_token - target_plugin: OktaAuthPlugin = OktaAuthPlugin(mock_plugin_service, mock_credentials_provider_factory, mock_session) + target_plugin: OktaAuthPlugin = OktaAuthPlugin(mock_plugin_service, mock_credentials_provider_factory) target_plugin.connect( target_driver_func=mocker.MagicMock(), @@ -151,7 +162,7 @@ def test_no_cached_token(mocker, mock_plugin_service, mock_session, mock_func, m test_props: Properties = Properties({"plugins": "okta", "user": "postgresqlUser", "idp_username": "user", "idp_password": "password"}) WrapperProperties.DB_USER.set(test_props, _DB_USER) - target_plugin: OktaAuthPlugin = OktaAuthPlugin(mock_plugin_service, mock_credentials_provider_factory, mock_session) + target_plugin: OktaAuthPlugin = OktaAuthPlugin(mock_plugin_service, mock_credentials_provider_factory) target_plugin.connect( target_driver_func=mocker.MagicMock(), @@ -179,7 +190,7 @@ def test_no_cached_token_raises_exception(mocker, mock_plugin_service, mock_sess exception_message = "generic exception" mock_func.side_effect = Exception(exception_message) - target_plugin: OktaAuthPlugin = OktaAuthPlugin(mock_plugin_service, mock_credentials_provider_factory, mock_session) + target_plugin: OktaAuthPlugin = OktaAuthPlugin(mock_plugin_service, mock_credentials_provider_factory) with pytest.raises(Exception) as e_info: target_plugin.connect( @@ -225,11 +236,11 @@ def test_connect_with_specified_iam_host_port_region(mocker, mock_client.generate_db_auth_token.return_value = f"{_TEST_TOKEN}:{expected_region}" - target_plugin: OktaAuthPlugin = OktaAuthPlugin(mock_plugin_service, mock_credentials_provider_factory, mock_session) + target_plugin: OktaAuthPlugin = OktaAuthPlugin(mock_plugin_service, mock_credentials_provider_factory) target_plugin.connect( target_driver_func=mocker.MagicMock(), driver_dialect=mock_dialect, - host_info=HostInfo(expected_host), + host_info=HostInfo("foo.com"), props=properties, is_initial_connection=False, connect_func=mock_func) diff --git a/tests/unit/test_secrets_manager_plugin.py b/tests/unit/test_secrets_manager_plugin.py index 87a15987..586baaf9 100644 --- a/tests/unit/test_secrets_manager_plugin.py +++ b/tests/unit/test_secrets_manager_plugin.py @@ -11,242 +11,275 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -# -# Licensed under the Apache License, Version 2.0 (the "License"). -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. from __future__ import annotations from types import SimpleNamespace from typing import Tuple -from unittest import TestCase -from unittest.mock import MagicMock, patch +from unittest.mock import patch +import pytest +from boto3 import Session from botocore.exceptions import ClientError -from parameterized import param, parameterized +from aws_advanced_python_wrapper.aws_credentials_manager import \ + AwsCredentialsManager from aws_advanced_python_wrapper.aws_secrets_manager_plugin import \ AwsSecretsManagerPlugin from aws_advanced_python_wrapper.errors import AwsWrapperError from aws_advanced_python_wrapper.hostinfo import HostInfo from aws_advanced_python_wrapper.utils.cache_map import CacheMap -from aws_advanced_python_wrapper.utils.messages import Messages from aws_advanced_python_wrapper.utils.properties import Properties +_TEST_REGION = "us-east-2" +_TEST_SECRET_ID = "secretId" +_TEST_ENDPOINT = None +_TEST_USERNAME = "testUser" +_TEST_PASSWORD = "testPassword" +_TEST_USERNAME_KEY = "testUserKey" +_TEST_PASSWORD_KEY = "testPasswordKey" +_TEST_PORT = 5432 +_VALID_SECRET_STRING = {'SecretString': f'{{"username":"{_TEST_USERNAME}","password":"{_TEST_PASSWORD}"}}'} +_INVALID_SECRET_STRING = {'SecretString': {"username": "invalid", "password": "invalid"}} +_TEST_HOST = "test-domain" +_SECRET_CACHE_KEY = (_TEST_SECRET_ID, _TEST_REGION, _TEST_ENDPOINT) +_TEST_HOST_INFO = HostInfo(_TEST_HOST, _TEST_PORT) +_TEST_SECRET = SimpleNamespace(username="testUser", password="testPassword") +_ONE_YEAR_IN_NANOSECONDS = 60 * 60 * 24 * 365 * 1000 + +_MYSQL_HOST_INFO = HostInfo("mysql.testdb.us-east-2.rds.amazonaws.com") +_PG_HOST_INFO = HostInfo("pg.testdb.us-east-2.rds.amazonaws.com") +_PG_HOST_INFO_WITH_PORT = HostInfo("pg.testdb.us-east-2.rds.amazonaws.com", port=1234) +_PG_HOST_INFO_WITH_REGION = HostInfo("pg.testdb.us-west-1.rds.amazonaws.com") + +_GENERIC_CLIENT_ERROR = ClientError({ + 'Error': { + 'Code': 'SomeServiceException', + 'Message': 'Details/context around the exception or error' + }, + 'ResponseMetadata': { + 'HTTPStatusCode': 400, + 'RequestId': 'test-request-id', + 'HostId': 'test-host-id', + 'HTTPHeaders': {}, + 'RetryAttempts': 0 + } +}, "some_operation") -class TestAwsSecretsManagerPlugin(TestCase): - _TEST_REGION = "us-east-2" - _TEST_SECRET_ID = "secretId" - _TEST_ENDPOINT = None - _TEST_USERNAME = "testUser" - _TEST_PASSWORD = "testPassword" - _TEST_USERNAME_KEY = "testUserKey" - _TEST_PASSWORD_KEY = "testPasswordKey" - _TEST_PORT = 5432 - _VALID_SECRET_STRING = {'SecretString': f'{{"username":"{_TEST_USERNAME}","password":"{_TEST_PASSWORD}"}}'} - _INVALID_SECRET_STRING = {'SecretString': {"username": "invalid", "password": "invalid"}} - _TEST_HOST = "test-domain" - _SECRET_CACHE_KEY = (_TEST_SECRET_ID, _TEST_REGION, _TEST_ENDPOINT) - _TEST_HOST_INFO = HostInfo(_TEST_HOST, _TEST_PORT) - _TEST_SECRET = SimpleNamespace(username="testUser", password="testPassword") - _ONE_YEAR_IN_NANOSECONDS = 60 * 60 * 24 * 365 * 1000 - - _MYSQL_HOST_INFO = HostInfo("mysql.testdb.us-east-2.rds.amazonaws.com") - _PG_HOST_INFO = HostInfo("pg.testdb.us-east-2.rds.amazonaws.com") - _PG_HOST_INFO_WITH_PORT = HostInfo("pg.testdb.us-east-2.rds.amazonaws.com", port=1234) - _PG_HOST_INFO_WITH_REGION = HostInfo("pg.testdb.us-west-1.rds.amazonaws.com") - - _GENERIC_CLIENT_ERROR = ClientError({ - 'Error': { - 'Code': 'SomeServiceException', - 'Message': 'Details/context around the exception or error' - }, - 'ResponseMetadata': { - 'HTTPStatusCode': 400, - 'RequestId': 'test-request-id', - 'HostId': 'test-host-id', - 'HTTPHeaders': {}, - 'RetryAttempts': 0 - } - }, "some_operation") - - _secrets_cache: CacheMap[Tuple, SimpleNamespace] = CacheMap() - - def setUp(self): - self._mock_func = MagicMock() - self._mock_plugin_service = MagicMock() - self._mock_dialect = MagicMock() - self._mock_session = MagicMock() - self._mock_client = MagicMock() - self._mock_connection = MagicMock() - - self._secrets_cache.clear() - self._mock_session.client.return_value = self._mock_client - self._mock_client.get_secret_value.return_value = self._VALID_SECRET_STRING - self._mock_session.get_available_regions.return_value = ["us-east-1", "us-east-2", "us-west-1", "us-west-2", - "us-iso-east-1"] - self._mock_func.return_value = self._mock_connection - self._properties = Properties({ - "secrets_manager_region": self._TEST_REGION, - "secrets_manager_secret_id": self._TEST_SECRET_ID, - }) - - @patch("aws_advanced_python_wrapper.aws_secrets_manager_plugin.AwsSecretsManagerPlugin._secrets_cache", _secrets_cache) - def test_connect_with_cached_secrets(self): - self._secrets_cache.put(self._SECRET_CACHE_KEY, self._TEST_SECRET, self._ONE_YEAR_IN_NANOSECONDS) - target_plugin: AwsSecretsManagerPlugin = AwsSecretsManagerPlugin(self._mock_plugin_service, - self._properties, - self._mock_session) +_secrets_cache: CacheMap[Tuple, SimpleNamespace] = CacheMap() - target_plugin.connect( - MagicMock(), MagicMock(), self._TEST_HOST_INFO, self._properties, True, self._mock_func) - assert 1 == len(self._secrets_cache) - self._mock_client.get_secret_value.assert_not_called() - self._mock_func.assert_called_once() - assert self._TEST_USERNAME == self._properties.get("user") - assert self._TEST_PASSWORD == self._properties.get("password") - @patch("aws_advanced_python_wrapper.aws_secrets_manager_plugin.AwsSecretsManagerPlugin._secrets_cache", _secrets_cache) - def test_connect_with_new_secrets(self): - assert 0 == len(self._secrets_cache) +@pytest.fixture(autouse=True) +def clear_caches(): + _secrets_cache.clear() + AwsCredentialsManager.release_resources() + + +@pytest.fixture +def mock_client(mocker): + client = mocker.MagicMock() + return client + + +@pytest.fixture +def mock_session(mocker): + session = mocker.MagicMock(spec=Session) + return session + + +@pytest.fixture +def mock_connection(mocker): + return mocker.MagicMock() - target_plugin: AwsSecretsManagerPlugin = AwsSecretsManagerPlugin(self._mock_plugin_service, - self._properties, - self._mock_session) - target_plugin.connect( - MagicMock(), MagicMock(), self._TEST_HOST_INFO, self._properties, True, self._mock_func) - - assert 1 == len(self._secrets_cache) - self._mock_client.get_secret_value.assert_called_once() - self._mock_func.assert_called_once() - assert self._TEST_USERNAME == self._properties.get("user") - assert self._TEST_PASSWORD == self._properties.get("password") - - @parameterized.expand([ - param(Properties({"secrets_manager_region": "us-east-2"})), - param(Properties({"secrets_manager_secret_id": "foo"})) - ]) - def test_missing_required_params(self, test_value: Properties): - with self.assertRaises(AwsWrapperError) as e: - AwsSecretsManagerPlugin(self._mock_plugin_service, - test_value, - self._mock_session) - self.assertTrue(Messages.get("AwsSecretsManagerPlugin.FailedToFetchDbCredentials") in str(e)) - - @patch("aws_advanced_python_wrapper.aws_secrets_manager_plugin.AwsSecretsManagerPlugin._secrets_cache", _secrets_cache) - def test_failed_initial_connection_with_unhandled_error(self): - ... - - @patch("aws_advanced_python_wrapper.aws_secrets_manager_plugin.AwsSecretsManagerPlugin._secrets_cache", _secrets_cache) - def test_connect_with_new_secrets_after_trying_with_cached_secrets(self): - ... - - @patch("aws_advanced_python_wrapper.aws_secrets_manager_plugin.AwsSecretsManagerPlugin._secrets_cache", _secrets_cache) - def test_failed_to_read_secrets(self): - self._mock_client.get_secret_value.return_value = "foo" - - target_plugin: AwsSecretsManagerPlugin = AwsSecretsManagerPlugin( - self._mock_plugin_service, - self._properties, - self._mock_session) - - self.assertRaises(AwsWrapperError, - target_plugin.connect, - MagicMock(), - MagicMock(), - self._TEST_HOST_INFO, - self._properties, - True, - self._mock_func) - - @patch("aws_advanced_python_wrapper.aws_secrets_manager_plugin.AwsSecretsManagerPlugin._secrets_cache", _secrets_cache) - def test_failed_to_get_secrets(self): - self._mock_client.get_secret_value.side_effect = self._GENERIC_CLIENT_ERROR - target_plugin: AwsSecretsManagerPlugin = AwsSecretsManagerPlugin( - self._mock_plugin_service, - self._properties, - self._mock_session) - - self.assertRaises(AwsWrapperError, - target_plugin.connect, - MagicMock(), - MagicMock(), - self._TEST_HOST_INFO, - self._properties, - True, - self._mock_func) - self._mock_client.get_secret_value.assert_called_once() - self._mock_func.assert_not_called() - - @parameterized.expand([ - param("arn:aws:secretsmanager:us-east-2:123456789012:secret:foo", "us-east-2"), - param("arn:aws:secretsmanager:us-west-1:123456789012:secret:boo", "us-west-1"), - param("arn:aws:secretsmanager:us-east-2:123456789012:secret:rds!cluster-bar-foo", "us-east-2") - ]) - def test_connect_via_arn(self, arn: str, region: str): - props: Properties = Properties({"secrets_manager_secret_id": arn}) - - target_plugin: AwsSecretsManagerPlugin = AwsSecretsManagerPlugin( - self._mock_plugin_service, - props, - self._mock_session) +@pytest.fixture +def mock_func(mocker): + return mocker.MagicMock() - target_plugin.connect( - MagicMock(), MagicMock(), self._TEST_HOST_INFO, props, True, self._mock_func) - self._mock_session.client.assert_called_with('secretsmanager', region_name=region, endpoint_url=None) - self._mock_client.get_secret_value.assert_called_with(SecretId=arn) +@pytest.fixture +def mock_plugin_service(mocker): + return mocker.MagicMock() - @parameterized.expand([ - param("arn:aws:secretsmanager:us-east-2:123456789012:secret:foo", "us-east-2"), - param("arn:aws:secretsmanager:us-west-1:123456789012:secret:boo", "us-west-1"), - param("arn:aws:secretsmanager:us-east-2:123456789012:secret:rds!cluster-bar-foo", "us-east-2"), - ]) - def test_connection_with_region_parameter_and_arn(self, arn: str, parsed_region: str): - expected_region: str = "us-iso-east-1" - props: Properties = Properties( - {"secrets_manager_secret_id": arn, - "secrets_manager_region": expected_region}) +@pytest.fixture +def mock_dialect(mocker): + return mocker.MagicMock() - target_plugin: AwsSecretsManagerPlugin = AwsSecretsManagerPlugin( - self._mock_plugin_service, - props, - self._mock_session) +@pytest.fixture(autouse=True) +def mock_default_behavior(mock_session, mock_client, mock_func, mock_connection): + mock_session.client.return_value = mock_client + mock_client.get_secret_value.return_value = _VALID_SECRET_STRING + mock_session.get_available_regions.return_value = [ + "us-east-1", "us-east-2", "us-west-1", "us-west-2", "us-iso-east-1" + ] + mock_func.return_value = mock_connection + + def custom_handler(host_info: HostInfo, props: Properties) -> Session: + return mock_session + + AwsCredentialsManager.set_custom_handler(custom_handler) + yield + AwsCredentialsManager.reset_custom_handler() + + +@pytest.fixture +def test_properties(): + return Properties({ + "secrets_manager_region": _TEST_REGION, + "secrets_manager_secret_id": _TEST_SECRET_ID, + }) + + +@patch("aws_advanced_python_wrapper.aws_secrets_manager_plugin.AwsSecretsManagerPlugin._secrets_cache", _secrets_cache) +def test_connect_with_cached_secrets( + mocker, mock_plugin_service, mock_session, mock_func, mock_client, test_properties): + _secrets_cache.put(_SECRET_CACHE_KEY, _TEST_SECRET, _ONE_YEAR_IN_NANOSECONDS) + target_plugin: AwsSecretsManagerPlugin = AwsSecretsManagerPlugin( + mock_plugin_service, test_properties, mock_session) + + target_plugin.connect( + mocker.MagicMock(), mocker.MagicMock(), _TEST_HOST_INFO, test_properties, True, mock_func) + assert 1 == len(_secrets_cache) + mock_client.get_secret_value.assert_not_called() + mock_func.assert_called_once() + assert _TEST_USERNAME == test_properties.get("user") + assert _TEST_PASSWORD == test_properties.get("password") + + +@patch("aws_advanced_python_wrapper.aws_secrets_manager_plugin.AwsSecretsManagerPlugin._secrets_cache", _secrets_cache) +def test_connect_with_new_secrets( + mocker, mock_plugin_service, mock_session, mock_func, mock_client, test_properties): + assert 0 == len(_secrets_cache) + + target_plugin: AwsSecretsManagerPlugin = AwsSecretsManagerPlugin( + mock_plugin_service, test_properties, mock_session) + + target_plugin.connect( + mocker.MagicMock(), mocker.MagicMock(), _TEST_HOST_INFO, test_properties, True, mock_func) + + assert 1 == len(_secrets_cache) + mock_client.get_secret_value.assert_called_once() + mock_func.assert_called_once() + assert _TEST_USERNAME == test_properties.get("user") + assert _TEST_PASSWORD == test_properties.get("password") + + +@pytest.mark.parametrize("key", [ + pytest.param("secrets_manager_region"), + pytest.param("secrets_manager_secret_id") +]) +def test_missing_required_params(key: str, mock_plugin_service, mock_session): + test_props = Properties({"secrets_manager_region": "us-east-2", "secrets_manager_secret_id": "foo"}) + test_props.pop(key) + with pytest.raises(AwsWrapperError) as exc_info: + AwsSecretsManagerPlugin(mock_plugin_service, test_props, mock_session) + # The error message should mention the missing parameter + assert "required" in str(exc_info.value).lower() + + +@patch("aws_advanced_python_wrapper.aws_secrets_manager_plugin.AwsSecretsManagerPlugin._secrets_cache", _secrets_cache) +def test_failed_initial_connection_with_unhandled_error( + mocker, mock_plugin_service, mock_session, mock_func, mock_client, test_properties): + ... + + +@patch("aws_advanced_python_wrapper.aws_secrets_manager_plugin.AwsSecretsManagerPlugin._secrets_cache", _secrets_cache) +def test_connect_with_new_secrets_after_trying_with_cached_secrets( + mocker, mock_plugin_service, mock_session, mock_func, mock_client, test_properties): + ... + + +@patch("aws_advanced_python_wrapper.aws_secrets_manager_plugin.AwsSecretsManagerPlugin._secrets_cache", _secrets_cache) +def test_failed_to_read_secrets( + mocker, mock_plugin_service, mock_session, mock_func, mock_client, test_properties): + mock_client.get_secret_value.return_value = "foo" + + target_plugin: AwsSecretsManagerPlugin = AwsSecretsManagerPlugin( + mock_plugin_service, test_properties, mock_session) + + with pytest.raises(AwsWrapperError): target_plugin.connect( - MagicMock(), MagicMock(), self._TEST_HOST_INFO, props, True, self._mock_func) - - # The region specified in `secrets_manager_region` should override the region parsed from ARN. - self._mock_session.client.assert_called_with('secretsmanager', region_name=expected_region, endpoint_url=None) - self._mock_client.get_secret_value.assert_called_with(SecretId=arn) - - @patch("aws_advanced_python_wrapper.aws_secrets_manager_plugin.AwsSecretsManagerPlugin._secrets_cache", _secrets_cache) - def test_connect_with_different_secret_keys(self): - self._properties["secrets_manager_secret_username_key"] = self._TEST_USERNAME_KEY - self._properties["secrets_manager_secret_password_key"] = self._TEST_PASSWORD_KEY - self._mock_client.get_secret_value.return_value = { - 'SecretString': f'{{"{self._TEST_USERNAME_KEY}":"{self._TEST_USERNAME}","{self._TEST_PASSWORD_KEY}":"{self._TEST_PASSWORD}"}}' - } - - target_plugin: AwsSecretsManagerPlugin = AwsSecretsManagerPlugin(self._mock_plugin_service, - self._properties, - self._mock_session) - target_plugin.connect( - MagicMock(), MagicMock(), self._TEST_HOST_INFO, self._properties, True, self._mock_func) + mocker.MagicMock(), mocker.MagicMock(), _TEST_HOST_INFO, test_properties, True, mock_func) + - assert 1 == len(self._secrets_cache) - self._mock_client.get_secret_value.assert_called_once() - self._mock_func.assert_called_once() - assert self._TEST_USERNAME == self._properties.get("user") - assert self._TEST_PASSWORD == self._properties.get("password") +@patch("aws_advanced_python_wrapper.aws_secrets_manager_plugin.AwsSecretsManagerPlugin._secrets_cache", _secrets_cache) +def test_failed_to_get_secrets( + mocker, mock_plugin_service, mock_session, mock_func, mock_client, test_properties): + mock_client.get_secret_value.side_effect = _GENERIC_CLIENT_ERROR + target_plugin: AwsSecretsManagerPlugin = AwsSecretsManagerPlugin( + mock_plugin_service, test_properties, mock_session) + + with pytest.raises(AwsWrapperError): + target_plugin.connect( + mocker.MagicMock(), mocker.MagicMock(), _TEST_HOST_INFO, test_properties, True, mock_func) + mock_client.get_secret_value.assert_called_once() + mock_func.assert_not_called() + + +@pytest.mark.parametrize("arn,region", [ + pytest.param("arn:aws:secretsmanager:us-east-2:123456789012:secret:foo", "us-east-2"), + pytest.param("arn:aws:secretsmanager:us-west-1:123456789012:secret:boo", "us-west-1"), + pytest.param("arn:aws:secretsmanager:us-east-2:123456789012:secret:rds!cluster-bar-foo", "us-east-2") +]) +def test_connect_via_arn( + arn: str, region: str, mocker, mock_plugin_service, mock_session, mock_func, mock_client): + props: Properties = Properties({"secrets_manager_secret_id": arn}) + + target_plugin: AwsSecretsManagerPlugin = AwsSecretsManagerPlugin( + mock_plugin_service, props, mock_session) + + target_plugin.connect( + mocker.MagicMock(), mocker.MagicMock(), _TEST_HOST_INFO, props, True, mock_func) + + mock_session.client.assert_called_with(service_name="secretsmanager") + mock_client.get_secret_value.assert_called_with(SecretId=arn) + + +@pytest.mark.parametrize("arn,parsed_region", [ + pytest.param("arn:aws:secretsmanager:us-east-2:123456789012:secret:foo", "us-east-2"), + pytest.param("arn:aws:secretsmanager:us-west-1:123456789012:secret:boo", "us-west-1"), + pytest.param("arn:aws:secretsmanager:us-east-2:123456789012:secret:rds!cluster-bar-foo", "us-east-2"), +]) +def test_connection_with_region_parameter_and_arn( + arn: str, parsed_region: str, mocker, mock_plugin_service, mock_session, mock_func, mock_client): + expected_region: str = "us-iso-east-1" + + props: Properties = Properties({ + "secrets_manager_secret_id": arn, + "secrets_manager_region": expected_region + }) + + target_plugin: AwsSecretsManagerPlugin = AwsSecretsManagerPlugin( + mock_plugin_service, props, mock_session) + + target_plugin.connect( + mocker.MagicMock(), mocker.MagicMock(), _TEST_HOST_INFO, props, True, mock_func) + + # The region specified in `secrets_manager_region` should override the region parsed from ARN. + mock_session.client.assert_called_with(service_name="secretsmanager") + mock_client.get_secret_value.assert_called_with(SecretId=arn) + + +@patch("aws_advanced_python_wrapper.aws_secrets_manager_plugin.AwsSecretsManagerPlugin._secrets_cache", _secrets_cache) +def test_connect_with_different_secret_keys( + mocker, mock_plugin_service, mock_session, mock_func, mock_client, test_properties): + test_properties["secrets_manager_secret_username_key"] = _TEST_USERNAME_KEY + test_properties["secrets_manager_secret_password_key"] = _TEST_PASSWORD_KEY + secret_string = ( + f'{{"{_TEST_USERNAME_KEY}":"{_TEST_USERNAME}",' + f'"{_TEST_PASSWORD_KEY}":"{_TEST_PASSWORD}"}}' + ) + mock_client.get_secret_value.return_value = {'SecretString': secret_string} + + target_plugin: AwsSecretsManagerPlugin = AwsSecretsManagerPlugin( + mock_plugin_service, test_properties, mock_session) + target_plugin.connect( + mocker.MagicMock(), mocker.MagicMock(), _TEST_HOST_INFO, test_properties, True, mock_func) + + assert 1 == len(_secrets_cache) + mock_client.get_secret_value.assert_called_once() + mock_func.assert_called_once() + assert _TEST_USERNAME == test_properties.get("user") + assert _TEST_PASSWORD == test_properties.get("password")