diff --git a/exasol/saas/client/_api_access/__init__.py b/exasol/saas/client/_api_access/__init__.py new file mode 100644 index 0000000..d426984 --- /dev/null +++ b/exasol/saas/client/_api_access/__init__.py @@ -0,0 +1,33 @@ +from exasol.saas.client._api_access.access import OpenApiAccess +from exasol.saas.client._api_access.common import ( + _log_api_output, + create_saas_client, + ensure_type, + interval_retry, + timestamp_name, +) +from exasol.saas.client._api_access.database_ops import ( + get_connection_params, + get_database_id, +) +from exasol.saas.client._api_access.errors import ( + DatabaseDeleteError, + DatabaseDeleteTimeout, + DatabaseStartupFailure, + OpenApiError, +) + +__all__ = [ + "DatabaseDeleteError", + "DatabaseDeleteTimeout", + "DatabaseStartupFailure", + "OpenApiAccess", + "OpenApiError", + "_log_api_output", + "create_saas_client", + "ensure_type", + "get_connection_params", + "get_database_id", + "interval_retry", + "timestamp_name", +] diff --git a/exasol/saas/client/_api_access/access.py b/exasol/saas/client/_api_access/access.py new file mode 100644 index 0000000..0b5a38d --- /dev/null +++ b/exasol/saas/client/_api_access/access.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +from collections.abc import Iterable +from contextlib import contextmanager +from datetime import timedelta +from typing import Any + +from exasol.saas.client import openapi +from exasol.saas.client._api_access.allowed_ip_lifecycle import ( + _add_allowed_ip, + _list_active_allowed_ip_ids, + _resolve_active_allowed_ip, + _wait_until_allowed_ip_deleted, +) +from exasol.saas.client._api_access.common import ( + LOG, + _log_api_output, + ensure_type, +) +from exasol.saas.client._api_access.database_lifecycle import ( + _create_database, + _delete_database_with_retry, + _list_active_database_ids, + _retrieve_database_settings, + _wait_until_database_deleted, + _wait_until_database_running, +) +from exasol.saas.client._api_access.errors import DatabaseDeleteError +from exasol.saas.client.openapi.api.clusters import ( + get_cluster_connection, + list_clusters, +) +from exasol.saas.client.openapi.api.databases import get_database +from exasol.saas.client.openapi.api.security import delete_allowed_ip +from exasol.saas.client.openapi.api.security import get_allowed_ip as get_allowed_ip_api +from exasol.saas.client.openapi.models import ( + ApiError, + ExasolDatabase, +) + + +class OpenApiAccess: + """ + This class is meant to be used only in the context of the API + generator repository while integration tests in other repositories are + planned to only use fixture ``saas_database_id()``. + """ + + def __init__(self, client: openapi.AuthenticatedClient, account_id: str): + self._client = client + self._account_id = account_id + + @contextmanager + def _ignore_failures(self, ignore: bool = False): + before = self._client.raise_on_unexpected_status + self._client.raise_on_unexpected_status = not ignore + yield self._client + self._client.raise_on_unexpected_status = before + + def create_database( + self, + name: str, + cluster_size: str = "XS", + region: str = "eu-central-1", + idle_time: timedelta | None = None, + num_nodes: int | None = None, + ) -> ExasolDatabase | None: + return _create_database( + account_id=self._account_id, + client=self._client, + name=name, + cluster_size=cluster_size, + region=region, + idle_time=idle_time, + num_nodes=num_nodes, + ) + + def wait_until_deleted( + self, + database_id: str, + timeout: timedelta = timedelta(minutes=20), + interval: timedelta = timedelta(seconds=10), + ): + return _wait_until_database_deleted( + account_id=self._account_id, + client=self._client, + database_id=database_id, + list_database_ids=self.list_database_ids, + timeout=timeout, + interval=interval, + ) + + def delete_database( + self, + database_id: str, + ignore_failures: bool = False, + timeout: timedelta = timedelta(minutes=45), + min_interval: timedelta = timedelta(seconds=1), + max_interval: timedelta = timedelta(minutes=2), + ) -> None: + LOG.info("Got request to delete database with ID %s", database_id) + try: + _delete_database_with_retry( + account_id=self._account_id, + client=self._client, + database_id=database_id, + timeout=timeout, + min_interval=min_interval, + max_interval=max_interval, + ) + LOG.info("Successfully deleted database.") + except Exception as ex: + if ignore_failures: + LOG.warning("Ignoring delete failure: %s", ex) + else: + msg = f"Failed to delete database: {ex}" + LOG.error(msg) + raise DatabaseDeleteError(msg) from ex + + def list_database_ids(self) -> Iterable[str]: + return iter(_list_active_database_ids(self._account_id, self._client)) + + @contextmanager + def database( + self, + name: str, + keep: bool = False, + ignore_delete_failure: bool = False, + idle_time: timedelta | None = None, + num_nodes: int | None = None, + ): + db = None + try: + db = self.create_database( + name, + idle_time=idle_time, + num_nodes=num_nodes, + ) + yield db + finally: + db_repr = f"{db.name} with ID {db.id}" if db else None + if not db: + LOG.warning("Cannot delete database None") + elif keep: + LOG.info("Keeping database %s as keep = %s.", db_repr, keep) + else: + self.delete_database(db.id, ignore_delete_failure) + LOG.info("Context assumes database %s as deleted.", db_repr) + + def get_database( + self, + database_id: str, + ) -> ExasolDatabase | None: + resp = get_database.sync( + self._account_id, + database_id, + client=self._client, + ) + _log_api_output( + "get_database.sync", + resp, + account_id=self._account_id, + database_id=database_id, + ) + return ensure_type( + ExasolDatabase, resp, f"Failed to get database {database_id}" + ) + + def get_database_settings( + self, + database_id: str, + ) -> openapi.models.DatabaseSettings | None: + return _retrieve_database_settings( + account_id=self._account_id, + client=self._client, + database_id=database_id, + ) + + def wait_until_running( + self, + database_id: str, + timeout: timedelta = timedelta(minutes=45), + interval: timedelta = timedelta(minutes=2), + ): + _wait_until_database_running( + database_id=database_id, + get_database_by_id=self.get_database, + timeout=timeout, + interval=interval, + ) + + def clusters( + self, + database_id: str, + ) -> list[openapi.models.Cluster] | None: + resp = ( + list_clusters.sync( + self._account_id, + database_id, + client=self._client, + ) + or [] + ) + _log_api_output( + "list_clusters.sync", + resp, + account_id=self._account_id, + database_id=database_id, + ) + return ensure_type( + list, resp, f"Failed to list clusters of database {database_id}" + ) + + def get_connection( + self, + database_id: str, + cluster_id: str, + ) -> openapi.models.ClusterConnection | None: + resp = get_cluster_connection.sync( + self._account_id, + database_id, + cluster_id, + client=self._client, + ) + _log_api_output( + "get_cluster_connection.sync", + resp, + account_id=self._account_id, + database_id=database_id, + cluster_id=cluster_id, + ) + return ensure_type( + openapi.models.ClusterConnection, + resp, + "Failed to retrieve a connection to " + f"database {database_id} cluster {cluster_id}", + ) + + def list_allowed_ip_ids(self) -> Iterable[str]: + return iter(_list_active_allowed_ip_ids(self._account_id, self._client)) + + def get_allowed_ip( + self, + allowed_ip_id: str, + ) -> openapi.models.AllowedIP | ApiError | None: + resp = get_allowed_ip_api.sync( + self._account_id, + allowed_ip_id, + client=self._client, + ) + _log_api_output( + "get_allowed_ip.sync", + resp, + account_id=self._account_id, + allowed_ip_id=allowed_ip_id, + ) + return resp + + def wait_until_allowed_ip_listed( + self, + allowed_ip_id: str, + timeout: timedelta = timedelta(minutes=6), + interval: timedelta = timedelta(seconds=5), + ) -> None: + _resolve_active_allowed_ip( + allowed_ip_id=allowed_ip_id, + get_allowed_ip_by_id=self.get_allowed_ip, + timeout=timeout, + interval=interval, + ) + + def wait_until_allowed_ip_deleted( + self, + allowed_ip_id: str, + timeout: timedelta = timedelta(minutes=10), + interval: timedelta = timedelta(seconds=5), + ) -> None: + _wait_until_allowed_ip_deleted( + allowed_ip_id=allowed_ip_id, + get_allowed_ip_by_id=self.get_allowed_ip, + timeout=timeout, + interval=interval, + ) + + def add_allowed_ip( + self, + cidr_ip: str = "0.0.0.0/0", + ) -> openapi.models.AllowedIP | None: + """ + Suggested values for cidr_ip: + * 185.17.207.78/32 + * 0.0.0.0/0 = all ipv4 + * ::/0 = all ipv6 + """ + return _add_allowed_ip( + account_id=self._account_id, + client=self._client, + cidr_ip=cidr_ip, + get_allowed_ip_by_id=self.get_allowed_ip, + ) + + def delete_allowed_ip(self, id: str, ignore_failures=False) -> Any | None: + with self._ignore_failures(ignore_failures) as client: + resp = delete_allowed_ip.sync(self._account_id, id, client=client) + _log_api_output( + "delete_allowed_ip.sync", + resp, + account_id=self._account_id, + allowed_ip_id=id, + ) + return resp + + @contextmanager + def allowed_ip( + self, + cidr_ip: str = "0.0.0.0/0", + keep: bool = False, + ignore_delete_failure: bool = False, + ): + ip = None + try: + ip = self.add_allowed_ip(cidr_ip) + yield ip + finally: + if ip and not keep: + self.delete_allowed_ip(ip.id, ignore_delete_failure) diff --git a/exasol/saas/client/_api_access/allowed_ip_lifecycle.py b/exasol/saas/client/_api_access/allowed_ip_lifecycle.py new file mode 100644 index 0000000..fbee9f1 --- /dev/null +++ b/exasol/saas/client/_api_access/allowed_ip_lifecycle.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from collections.abc import Callable +from datetime import timedelta +from typing import cast + +from tenacity import TryAgain + +from exasol.saas.client import openapi +from exasol.saas.client._api_access.common import ( + LOG, + _log_api_output, + ensure_type, + interval_retry, + timestamp_name, +) +from exasol.saas.client.openapi.api.security import ( + add_allowed_ip, + list_allowed_i_ps, +) +from exasol.saas.client.openapi.models import ApiError +from exasol.saas.client.openapi.types import UNSET + + +def _build_allowed_ip_rule(cidr_ip: str) -> openapi.models.CreateAllowedIP: + return openapi.models.CreateAllowedIP( + name=timestamp_name(), + cidr_ip=cidr_ip, + ) + + +def _is_active_allowed_ip( + allowed_ip: openapi.models.AllowedIP | ApiError | None, +) -> bool: + if allowed_ip is None or isinstance(allowed_ip, ApiError): + return False + return allowed_ip.deleted_at is UNSET and allowed_ip.deleted_by is UNSET + + +def _list_active_allowed_ip_ids( + account_id: str, + client: openapi.AuthenticatedClient, +) -> list[str]: + resp = list_allowed_i_ps.sync(account_id, client=client) or [] + _log_api_output( + "list_allowed_i_ps.sync", + resp, + account_id=account_id, + ) + ips = ensure_type(list, resp, "Failed to retrieve the list of allowed ips") + visible_allowed_ip_ids = [ + ip.id for ip in ips if ip.deleted_at is UNSET and ip.deleted_by is UNSET + ] + LOG.debug("list_allowed_ip_ids visible IDs: %s", visible_allowed_ip_ids) + return visible_allowed_ip_ids + + +def _resolve_active_allowed_ip( + allowed_ip_id: str, + get_allowed_ip_by_id: Callable[[str], openapi.models.AllowedIP | ApiError | None], + timeout: timedelta, + interval: timedelta, +) -> openapi.models.AllowedIP: + @interval_retry(interval, timeout) + def resolve() -> openapi.models.AllowedIP: + LOG.debug( + "wait_until_allowed_ip_listed state {'allowed_ip_id': %s}", + allowed_ip_id, + ) + allowed_ip = get_allowed_ip_by_id(allowed_ip_id) + if _is_active_allowed_ip(allowed_ip): + return cast(openapi.models.AllowedIP, allowed_ip) + raise TryAgain + + return resolve() + + +def _wait_until_allowed_ip_deleted( + allowed_ip_id: str, + get_allowed_ip_by_id: Callable[[str], openapi.models.AllowedIP | ApiError | None], + timeout: timedelta, + interval: timedelta, +) -> None: + @interval_retry(interval, timeout) + def verify_deleted() -> bool: + LOG.debug( + "wait_until_allowed_ip_deleted state {'allowed_ip_id': %s}", + allowed_ip_id, + ) + allowed_ip = get_allowed_ip_by_id(allowed_ip_id) + if _is_active_allowed_ip(allowed_ip): + raise TryAgain + return True + + verify_deleted() + + +def _add_allowed_ip( + account_id: str, + client: openapi.AuthenticatedClient, + cidr_ip: str, + get_allowed_ip_by_id: Callable[[str], openapi.models.AllowedIP | ApiError | None], +) -> openapi.models.AllowedIP: + resp = add_allowed_ip.sync( + account_id, + client=client, + body=_build_allowed_ip_rule(cidr_ip), + ) + _log_api_output( + "add_allowed_ip.sync", + resp, + account_id=account_id, + cidr_ip=cidr_ip, + ) + created_ip = ensure_type( + openapi.models.AllowedIP, + resp, + f"Failed to add allowed IP address {cidr_ip}", + ) + return _resolve_active_allowed_ip( + created_ip.id, + get_allowed_ip_by_id=get_allowed_ip_by_id, + timeout=timedelta(minutes=6), + interval=timedelta(seconds=5), + ) diff --git a/exasol/saas/client/_api_access/common.py b/exasol/saas/client/_api_access/common.py new file mode 100644 index 0000000..183b692 --- /dev/null +++ b/exasol/saas/client/_api_access/common.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import getpass +import logging +import time +from datetime import ( + datetime, + timedelta, +) +from typing import ( + Any, + TypeVar, + cast, +) + +import tenacity +from tenacity.stop import stop_after_delay +from tenacity.wait import wait_fixed + +from exasol.saas.client import ( + Limits, + openapi, +) +from exasol.saas.client._api_access.errors import OpenApiError +from exasol.saas.client.openapi.models import ApiError + +LOG = logging.getLogger("exasol.saas.client.api_access") +logging.getLogger("httpx").setLevel(logging.WARNING) + +T = TypeVar("T") + + +def interval_retry(interval: timedelta, timeout: timedelta): + return tenacity.retry(wait=wait_fixed(interval), stop=stop_after_delay(timeout)) + + +def timestamp_name(project_short_tag: str | None = None) -> str: + """ + Generates a semi-unique name for a database with the following format: + - 0-4: number of minutes since the start of the year in hex, + - 0-5: a semi-random number, + - provided tag, + - -username. + + Args: + project_short_tag: Abbreviation of your project + """ + now = datetime.now() + year_start = datetime(now.year, 1, 1) + minutes_elapsed = int((now - year_start).total_seconds() // 60) + random_suffix = time.time_ns() % 1048576 + timestamp = f"{minutes_elapsed:05x}{random_suffix:05x}" + + owner = getpass.getuser() + candidate = f"{timestamp}{project_short_tag or ''}-{owner}" + return candidate[: Limits.MAX_DATABASE_NAME_LENGTH] + + +def ensure_type( + expected: type[T], + response: T | ApiError | None, + message: str, +) -> T: + """ + Ensure the passed response is of the expected type and return it with + correct type. Otherwise raise an OpenApiError. + """ + if isinstance(response, expected): + return cast(T, response) + api_error = response if isinstance(response, ApiError) else None + raise OpenApiError(message, api_error) + + +def _serialize_api_output(response: Any) -> Any: + if isinstance(response, list): + return [_serialize_api_output(item) for item in response] + + to_dict = getattr(response, "to_dict", None) + if callable(to_dict): + return to_dict() + + return response + + +def _log_api_output(operation: str, response: Any, **context: Any) -> None: + if not LOG.isEnabledFor(logging.DEBUG): + return + + suffix = f" {context}" if context else "" + LOG.debug( + "%s response%s: %s", + operation, + suffix, + _serialize_api_output(response), + ) + + +def _is_not_found(resp: ApiError, entity: str = "User/Database") -> bool: + return resp.status == 404 and f"{entity} not found" in resp.message + + +def create_saas_client( + host: str, + pat: str, + raise_on_unexpected_status: bool = True, +) -> openapi.AuthenticatedClient: + return openapi.AuthenticatedClient( + base_url=host, + token=pat, + raise_on_unexpected_status=raise_on_unexpected_status, + ) diff --git a/exasol/saas/client/_api_access/database_lifecycle.py b/exasol/saas/client/_api_access/database_lifecycle.py new file mode 100644 index 0000000..660cf79 --- /dev/null +++ b/exasol/saas/client/_api_access/database_lifecycle.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +from collections.abc import ( + Callable, + Iterable, +) +from datetime import timedelta + +from tenacity import ( + RetryError, + TryAgain, + retry, +) +from tenacity.retry import retry_if_exception_type +from tenacity.stop import stop_after_delay +from tenacity.wait import wait_exponential + +from exasol.saas.client import ( + Limits, + openapi, +) +from exasol.saas.client._api_access.common import ( + LOG, + _is_not_found, + _log_api_output, + ensure_type, + interval_retry, +) +from exasol.saas.client._api_access.errors import ( + DatabaseDeleteTimeout, + DatabaseStartupFailure, + InternalError, + OpenApiError, +) +from exasol.saas.client.openapi.api.databases import ( + create_database, + delete_database, + get_database, + get_database_settings, + list_databases, +) +from exasol.saas.client.openapi.models import ( + ApiError, + ExasolDatabase, + Status, +) +from exasol.saas.client.openapi.types import UNSET + + +def _minutes(value: timedelta) -> int: + return value.seconds // 60 + + +def _build_database_spec( + name: str, + cluster_size: str, + region: str, + idle_time: timedelta | None, + num_nodes: int | None, +) -> openapi.models.CreateDatabase: + idle_time = idle_time or Limits.AUTOSTOP_MIN_IDLE_TIME + cluster_spec = openapi.models.CreateDatabaseInitialCluster( + name="my-cluster", + size=cluster_size, + auto_stop=openapi.models.AutoStop( + enabled=True, + idle_time=_minutes(idle_time), + ), + ) + database_spec = openapi.models.CreateDatabase( + name=name, + initial_cluster=cluster_spec, + provider="aws", + region=region, + stream_type="innovation-release", + ) + if num_nodes is not None: + database_spec.num_nodes = num_nodes + return database_spec + + +def _create_database( + account_id: str, + client: openapi.AuthenticatedClient, + name: str, + cluster_size: str, + region: str, + idle_time: timedelta | None, + num_nodes: int | None, +) -> ExasolDatabase: + LOG.info("Creating database %s", name) + resp = create_database.sync( + account_id, + client=client, + body=_build_database_spec( + name=name, + cluster_size=cluster_size, + region=region, + idle_time=idle_time, + num_nodes=num_nodes, + ), + ) + _log_api_output( + "create_database.sync", + resp, + account_id=account_id, + database_name=name, + ) + database = ensure_type(ExasolDatabase, resp, f"Failed to create database {name}") + LOG.info("Created database with ID %s", database.id) + return database + + +def _list_active_database_ids( + account_id: str, + client: openapi.AuthenticatedClient, +) -> list[str]: + resp = list_databases.sync(account_id, client=client) or [] + _log_api_output( + "list_databases.sync", + resp, + account_id=account_id, + ) + dbs = ensure_type(list, resp, "Failed to list databases") + active_database_ids = [ + db.id + for db in dbs + if db.deleted_at is UNSET + and db.deleted_by is UNSET + and db.status not in {Status.DELETING, Status.TODELETE} + ] + LOG.debug("list_database_ids visible IDs: %s", active_database_ids) + return active_database_ids + + +def _wait_until_database_deleted( + account_id: str, + client: openapi.AuthenticatedClient, + database_id: str, + list_database_ids: Callable[[], Iterable[str]], + timeout: timedelta, + interval: timedelta, +) -> None: + terminal = {Status.DELETED} + in_progress = {Status.DELETING, Status.TODELETE} + + @interval_retry(interval, timeout) + def verify_deleted() -> bool: + resp = get_database.sync( + account_id, + database_id, + client=client, + ) + _log_api_output( + "get_database.sync", + resp, + account_id=account_id, + database_id=database_id, + ) + if isinstance(resp, ApiError): + if _is_not_found(resp): + return True + raise OpenApiError( + f"Failed to get database {database_id}", + resp, + ) + + if resp is None: + LOG.info("- Database deletion status: unavailable ...") + raise TryAgain + + if resp.deleted_at is not UNSET or resp.deleted_by is not UNSET: + return True + + if resp.status in terminal: + return True + + visible_database_ids = list(list_database_ids()) + LOG.debug( + "wait_until_deleted visible database IDs {'database_id': %s}: %s", + database_id, + visible_database_ids, + ) + if database_id not in visible_database_ids: + return True + + if resp.status in in_progress: + LOG.info("- Database deletion status: %s ...", resp.status) + raise TryAgain + + if database_id in visible_database_ids: + LOG.info("- Database deletion status: %s ...", resp.status) + raise TryAgain + + return True + + try: + verify_deleted() + except (TryAgain, RetryError) as ex: + raise DatabaseDeleteTimeout from ex + + +def _is_database_delete_retry(resp: ApiError) -> bool: + return resp.status == 400 and "cluster is not in a proper state" in resp.message + + +def _delete_database_with_retry( + account_id: str, + client: openapi.AuthenticatedClient, + database_id: str, + timeout: timedelta, + min_interval: timedelta, + max_interval: timedelta, +) -> None: + @retry( + wait=wait_exponential( + multiplier=1, + min=min_interval, + max=max_interval, + ), + stop=stop_after_delay(timeout), + retry=retry_if_exception_type(TryAgain), + ) + def delete_with_retry() -> None: + LOG.info("- Trying to delete ...") + resp = delete_database.sync( + account_id, + database_id, + client=client, + ) + _log_api_output( + "delete_database.sync", + resp, + account_id=account_id, + database_id=database_id, + ) + if not isinstance(resp, ApiError): + return + if _is_database_delete_retry(resp): + raise TryAgain + raise InternalError(f"HTTP {resp.status}: {resp.message}.") + + delete_with_retry() + + +def _retrieve_database_settings( + account_id: str, + client: openapi.AuthenticatedClient, + database_id: str, +) -> openapi.models.DatabaseSettings: + @interval_retry( + interval=timedelta(seconds=5), + timeout=timedelta(minutes=2), + ) + def retrieve_settings() -> openapi.models.DatabaseSettings: + resp = get_database_settings.sync( + account_id, + database_id, + client=client, + ) + _log_api_output( + "get_database_settings.sync", + resp, + account_id=account_id, + database_id=database_id, + ) + if isinstance(resp, ApiError) and _is_not_found(resp): + raise TryAgain + return ensure_type( + openapi.models.DatabaseSettings, + resp, + f"Failed to get settings of database {database_id}", + ) + + return retrieve_settings() + + +def _wait_until_database_running( + database_id: str, + get_database_by_id: Callable[[str], ExasolDatabase | None], + timeout: timedelta, + interval: timedelta, +) -> None: + success = [Status.RUNNING] + + @interval_retry(interval, timeout) + def poll_status() -> Status: + db = get_database_by_id(database_id) + status = db.status if db else None + if status not in success: + LOG.info("- Database status: %s ...", status) + raise TryAgain + return status + + LOG.info("Waiting for database with ID %s to be available:", database_id) + if poll_status() not in success: + raise DatabaseStartupFailure() diff --git a/exasol/saas/client/_api_access/database_ops.py b/exasol/saas/client/_api_access/database_ops.py new file mode 100644 index 0000000..8b7cf51 --- /dev/null +++ b/exasol/saas/client/_api_access/database_ops.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from typing import Any + +from exasol.saas.client import openapi +from exasol.saas.client._api_access.common import ( + _log_api_output, + create_saas_client, + ensure_type, +) +from exasol.saas.client.openapi.api.clusters import ( + get_cluster_connection, + list_clusters, +) +from exasol.saas.client.openapi.api.databases import list_databases +from exasol.saas.client.openapi.types import UNSET + + +def _get_database_id( + account_id: str, + client: openapi.AuthenticatedClient, + database_name: str, +) -> str: + """ + Finds the database id, given the database name. + """ + dbs = list_databases.sync(account_id, client=client) + _log_api_output( + "list_databases.sync", + dbs, + account_id=account_id, + database_name=database_name, + ) + dbs = list( + filter( + lambda db: (db.name == database_name) # type: ignore + and (db.deleted_at is UNSET) # type: ignore + and (db.deleted_by is UNSET), + dbs, # type: ignore + ) + ) # type: ignore + if not dbs: + raise RuntimeError(f"SaaS database {database_name} was not found.") + return dbs[0].id + + +def get_database_id( + host: str, + account_id: str, + pat: str, + database_name: str, +) -> str: + """ + Finds the database id, given the database name. + + Args: + host: SaaS service URL. + account_id: User account ID + pat: Personal Access Token. + database_name: Database name. + """ + with create_saas_client(host, pat) as client: + return _get_database_id(account_id, client, database_name) + + +def get_connection_params( + host: str, + account_id: str, + pat: str, + database_id: str | None = None, + database_name: str | None = None, +) -> dict[str, Any]: + """ + Gets the database connection parameters, such as those required by pyexasol: + - dns + - user + - password. + Returns the parameters in a dictionary that can be used as kwargs when + creating a connection, like in the code below: + + connection_params = get_connection_params(...) + connection = pyexasol.connect(**connection_params) + + Args: + host: SaaS service URL. + account_id: User account ID + pat: Personal Access Token. + database_id: Database ID, id known. + database_name: Database name, in case the id is unknown. + """ + + with create_saas_client(host, pat) as client: + if not database_id: + if not database_name: + raise ValueError( + "To get SaaS connection parameters, " + "either database name or database id must be provided." + ) + database_id = _get_database_id( + account_id, client, database_name=database_name + ) + clusters = list_clusters.sync(account_id, database_id, client=client) + _log_api_output( + "list_clusters.sync", + clusters, + account_id=account_id, + database_id=database_id, + ) + cluster_id = next( + filter(lambda cl: cl.main_cluster, clusters) # type: ignore + ).id + resp = get_cluster_connection.sync( + account_id, database_id, cluster_id, client=client + ) + _log_api_output( + "get_cluster_connection.sync", + resp, + account_id=account_id, + database_id=database_id, + cluster_id=cluster_id, + ) + connection = ensure_type( + openapi.models.ClusterConnection, + resp, + "Failed to get the connection data to" + f" host {host}, account {account_id}," + f" database with ID {database_id} named {database_name}", + ) + return { + "dsn": f"{connection.dns}:{connection.port}", + "user": connection.db_username, + "password": pat, + } diff --git a/exasol/saas/client/_api_access/errors.py b/exasol/saas/client/_api_access/errors.py new file mode 100644 index 0000000..59320c9 --- /dev/null +++ b/exasol/saas/client/_api_access/errors.py @@ -0,0 +1,32 @@ +from exasol.saas.client.openapi.models import ApiError + + +class DatabaseStartupFailure(Exception): + """ + If a SaaS database instance during startup reports a status other than + successful. + """ + + +class DatabaseDeleteTimeout(Exception): + """ + If deletion of a SaaS database instance was requested but during the + specified timeout it was still reported in the list of existing databases. + """ + + +class DatabaseDeleteError(Exception): + """ + Failed to delete a SaaS database instance. + """ + + +class OpenApiError(Exception): + def __init__(self, message: str, error: ApiError | None): + super().__init__(f"{message}: {error.message}." if error else message) + + +class InternalError(Exception): + """ + Internal error during delete with retry. + """ diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 56ad1ac..a17f219 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -1,797 +1,29 @@ -from __future__ import annotations - -import getpass -import logging -import time -from collections.abc import Iterable -from contextlib import contextmanager -from datetime import ( - datetime, - timedelta, -) -from typing import ( - Any, - TypeVar, - cast, -) - -import tenacity -from tenacity import ( - RetryError, - TryAgain, - retry, -) -from tenacity.retry import retry_if_exception_type -from tenacity.stop import stop_after_delay -from tenacity.wait import ( - wait_exponential, - wait_fixed, -) - -from exasol.saas.client import ( - Limits, - openapi, -) -from exasol.saas.client.openapi.api.clusters import ( - get_cluster_connection, - list_clusters, -) -from exasol.saas.client.openapi.api.databases import ( - create_database, - delete_database, - get_database, - get_database_settings, - list_databases, -) -from exasol.saas.client.openapi.api.security import ( - add_allowed_ip, - delete_allowed_ip, -) -from exasol.saas.client.openapi.api.security import get_allowed_ip as get_allowed_ip_api -from exasol.saas.client.openapi.api.security import ( - list_allowed_i_ps, -) -from exasol.saas.client.openapi.models import ( - ApiError, - ExasolDatabase, - Status, -) -from exasol.saas.client.openapi.types import UNSET - -LOG = logging.getLogger(__name__) -logging.getLogger("httpx").setLevel(logging.WARNING) - - -def interval_retry(interval: timedelta, timeout: timedelta): - return tenacity.retry(wait=wait_fixed(interval), stop=stop_after_delay(timeout)) - - -def timestamp_name(project_short_tag: str | None = None) -> str: - """ - Generates a semi-unique name for a database with the following format: - - 0-4: number of minutes since the start of the year in hex, - - 0-5: a semi-random number, - - provided tag, - - -username. - - Args: - project_short_tag: Abbreviation of your project - """ - now = datetime.now() - year_start = datetime(now.year, 1, 1) - minutes_elapsed = int((now - year_start).total_seconds() // 60) - random_suffix = time.time_ns() % 1048576 - timestamp = f"{minutes_elapsed:05x}{random_suffix:05x}" - - owner = getpass.getuser() - candidate = f"{timestamp}{project_short_tag or ''}-{owner}" - return candidate[: Limits.MAX_DATABASE_NAME_LENGTH] - - -class DatabaseStartupFailure(Exception): - """ - If a SaaS database instance during startup reports a status other than - successful. - """ - - -class DatabaseDeleteTimeout(Exception): - """ - If deletion of a SaaS database instance was requested but during the - specified timeout it was still reported in the list of existing databases. - """ - - -class DatabaseDeleteError(Exception): - """ - Failed to delete a SaaS database instance. - """ - - -class OpenApiError(Exception): - def __init__(self, message: str, error: ApiError | None): - super().__init__(f"{message}: {error.message}." if error else message) - - -T = TypeVar("T") - - -def ensure_type( - expected: type[T], - response: T | ApiError | None, - message: str, -) -> T: - """ - Ensure the passed response is of the expected type and return it with - correct type. Otherwise raise an OpenApiError. - """ - if isinstance(response, expected): - return cast(T, response) - api_error = response if isinstance(response, ApiError) else None - raise OpenApiError(message, api_error) - - -def _serialize_api_output(response: Any) -> Any: - if isinstance(response, list): - return [_serialize_api_output(item) for item in response] - - to_dict = getattr(response, "to_dict", None) - if callable(to_dict): - return to_dict() - - return response - - -def _log_api_output(operation: str, response: Any, **context: Any) -> None: - if not LOG.isEnabledFor(logging.DEBUG): - return - - suffix = f" {context}" if context else "" - LOG.debug( - "%s response%s: %s", - operation, - suffix, - _serialize_api_output(response), - ) - - -class InternalError(Exception): - """ - Internal error during delete with retry. - """ - - -def _is_not_found(resp: ApiError, entity: str = "User/Database") -> bool: - return resp.status == 404 and f"{entity} not found" in resp.message - - -def create_saas_client( - host: str, - pat: str, - raise_on_unexpected_status: bool = True, -) -> openapi.AuthenticatedClient: - return openapi.AuthenticatedClient( - base_url=host, - token=pat, - raise_on_unexpected_status=raise_on_unexpected_status, - ) - - -def _get_database_id( - account_id: str, - client: openapi.AuthenticatedClient, - database_name: str, -) -> str: - """ - Finds the database id, given the database name. - """ - dbs = list_databases.sync(account_id, client=client) - _log_api_output( - "list_databases.sync", - dbs, - account_id=account_id, - database_name=database_name, - ) - dbs = list( - filter( - lambda db: (db.name == database_name) # type: ignore - and (db.deleted_at is UNSET) # type: ignore - and (db.deleted_by is UNSET), - dbs, # type: ignore - ) - ) # type: ignore - if not dbs: - raise RuntimeError(f"SaaS database {database_name} was not found.") - return dbs[0].id - - -def get_database_id( - host: str, - account_id: str, - pat: str, - database_name: str, -) -> str: - """ - Finds the database id, given the database name. - - Args: - host: SaaS service URL. - account_id: User account ID - pat: Personal Access Token. - database_name: Database name. - """ - with create_saas_client(host, pat) as client: - return _get_database_id(account_id, client, database_name) - - -def get_connection_params( - host: str, - account_id: str, - pat: str, - database_id: str | None = None, - database_name: str | None = None, -) -> dict[str, Any]: - """ - Gets the database connection parameters, such as those required by pyexasol: - - dns - - user - - password. - Returns the parameters in a dictionary that can be used as kwargs when - creating a connection, like in the code below: - - connection_params = get_connection_params(...) - connection = pyexasol.connect(**connection_params) - - Args: - host: SaaS service URL. - account_id: User account ID - pat: Personal Access Token. - database_id: Database ID, id known. - database_name: Database name, in case the id is unknown. - """ - - with create_saas_client(host, pat) as client: - if not database_id: - if not database_name: - raise ValueError( - "To get SaaS connection parameters, " - "either database name or database id must be provided." - ) - database_id = _get_database_id( - account_id, client, database_name=database_name - ) - clusters = list_clusters.sync(account_id, database_id, client=client) - _log_api_output( - "list_clusters.sync", - clusters, - account_id=account_id, - database_id=database_id, - ) - cluster_id = next( - filter(lambda cl: cl.main_cluster, clusters) # type: ignore - ).id - resp = get_cluster_connection.sync( - account_id, database_id, cluster_id, client=client - ) - _log_api_output( - "get_cluster_connection.sync", - resp, - account_id=account_id, - database_id=database_id, - cluster_id=cluster_id, - ) - connection = ensure_type( - openapi.models.ClusterConnection, - resp, - "Failed to get the connection data to" - f" host {host}, account {account_id}," - f" database with ID {database_id} named {database_name}", - ) - return { - "dsn": f"{connection.dns}:{connection.port}", - "user": connection.db_username, - "password": pat, - } - - -class OpenApiAccess: - """ - This class is meant to be used only in the context of the API - generator repository while integration tests in other repositories are - planned to only use fixture ``saas_database_id()``. - """ - - def __init__(self, client: openapi.AuthenticatedClient, account_id: str): - self._client = client - self._account_id = account_id - - def create_database( - self, - name: str, - cluster_size: str = "XS", - region: str = "eu-central-1", - idle_time: timedelta | None = None, - num_nodes: int | None = None, - ) -> ExasolDatabase | None: - def minutes(x: timedelta) -> int: - return x.seconds // 60 - - idle_time = idle_time or Limits.AUTOSTOP_MIN_IDLE_TIME - cluster_spec = openapi.models.CreateDatabaseInitialCluster( - name="my-cluster", - size=cluster_size, - auto_stop=openapi.models.AutoStop( - enabled=True, - idle_time=minutes(idle_time), - ), - ) - LOG.info("Creating database %s", name) - database_spec = openapi.models.CreateDatabase( - name=name, - initial_cluster=cluster_spec, - provider="aws", - region=region, - stream_type="innovation-release", - ) - if num_nodes is not None: - database_spec.num_nodes = num_nodes - resp = create_database.sync( - self._account_id, - client=self._client, - body=database_spec, - ) - _log_api_output( - "create_database.sync", - resp, - account_id=self._account_id, - database_name=name, - ) - database = ensure_type( - ExasolDatabase, resp, f"Failed to create database {name}" - ) - LOG.info("Created database with ID %s", database.id) - return database - - @contextmanager - def _ignore_failures(self, ignore: bool = False): - before = self._client.raise_on_unexpected_status - self._client.raise_on_unexpected_status = not ignore - yield self._client - self._client.raise_on_unexpected_status = before - - def wait_until_deleted( - self, - database_id: str, - timeout: timedelta = timedelta(minutes=20), - interval: timedelta = timedelta(seconds=10), - ): - terminal = {Status.DELETED} - in_progress = {Status.DELETING, Status.TODELETE} - - @interval_retry(interval, timeout) - def verify_deleted() -> bool: - resp = get_database.sync( - self._account_id, - database_id, - client=self._client, - ) - _log_api_output( - "get_database.sync", - resp, - account_id=self._account_id, - database_id=database_id, - ) - if isinstance(resp, ApiError): - if _is_not_found(resp): - return True - raise OpenApiError( - f"Failed to get database {database_id}", - resp, - ) - - if resp is None: - LOG.info("- Database deletion status: unavailable ...") - raise TryAgain - - if resp.deleted_at is not UNSET or resp.deleted_by is not UNSET: - return True - - if resp.status in terminal: - return True - - visible_database_ids = list(self.list_database_ids()) - LOG.debug( - "wait_until_deleted visible database IDs {'database_id': %s}: %s", - database_id, - visible_database_ids, - ) - if database_id not in visible_database_ids: - return True - - if resp.status in in_progress: - LOG.info("- Database deletion status: %s ...", resp.status) - raise TryAgain - - if database_id in visible_database_ids: - LOG.info("- Database deletion status: %s ...", resp.status) - raise TryAgain - - return True - - try: - return verify_deleted() - except (TryAgain, RetryError) as ex: - raise DatabaseDeleteTimeout from ex - - def delete_database( - self, - database_id: str, - ignore_failures: bool = False, - timeout: timedelta = timedelta(minutes=45), - min_interval: timedelta = timedelta(seconds=1), - max_interval: timedelta = timedelta(minutes=2), - ) -> None: - def is_retry(resp: ApiError) -> bool: - return ( - resp.status == 400 - and "cluster is not in a proper state" in resp.message - ) - - @retry( - wait=wait_exponential( - multiplier=1, - min=min_interval, - max=max_interval, - ), - stop=stop_after_delay(timeout), - retry=retry_if_exception_type(TryAgain), - ) - def delete_with_retry() -> None: - LOG.info("- Trying to delete ...") - resp = delete_database.sync( - self._account_id, - database_id, - client=self._client, - ) - _log_api_output( - "delete_database.sync", - resp, - account_id=self._account_id, - database_id=database_id, - ) - if not isinstance(resp, ApiError): - # success - return - if is_retry(resp): - raise TryAgain - raise InternalError(f"HTTP {resp.status}: {resp.message}.") - - LOG.info("Got request to delete database with ID %s", database_id) - try: - delete_with_retry() - LOG.info("Successfully deleted database.") - except Exception as ex: - if ignore_failures: - LOG.warning("Ignoring delete failure: %s", ex) - else: - msg = f"Failed to delete database: {ex}" - LOG.error(msg) - raise DatabaseDeleteError(msg) from ex - - def list_database_ids(self) -> Iterable[str]: - resp = list_databases.sync(self._account_id, client=self._client) or [] - _log_api_output( - "list_databases.sync", - resp, - account_id=self._account_id, - ) - # actually list[ExasolDatabase] - dbs = ensure_type(list, resp, "Failed to list databases") - active_database_ids = [ - db.id - for db in dbs - if db.deleted_at is UNSET - and db.deleted_by is UNSET - and db.status not in {Status.DELETING, Status.TODELETE} - ] - LOG.debug("list_database_ids visible IDs: %s", active_database_ids) - return iter(active_database_ids) - - @contextmanager - def database( - self, - name: str, - keep: bool = False, - ignore_delete_failure: bool = False, - idle_time: timedelta | None = None, - num_nodes: int | None = None, - ): - db = None - try: - db = self.create_database( - name, - idle_time=idle_time, - num_nodes=num_nodes, - ) - yield db - finally: - db_repr = f"{db.name} with ID {db.id}" if db else None - if not db: - LOG.warning("Cannot delete database None") - elif keep: - LOG.info("Keeping database %s as keep = %s.", db_repr, keep) - else: - self.delete_database(db.id, ignore_delete_failure) - LOG.info("Context assumes database %s as deleted.", db_repr) - - def get_database( - self, - database_id: str, - ) -> ExasolDatabase | None: - resp = get_database.sync( - self._account_id, - database_id, - client=self._client, - ) - _log_api_output( - "get_database.sync", - resp, - account_id=self._account_id, - database_id=database_id, - ) - return ensure_type( - ExasolDatabase, resp, f"Failed to get database {database_id}" - ) - - def get_database_settings( - self, - database_id: str, - ) -> openapi.models.DatabaseSettings | None: - def is_retry(resp: ApiError) -> bool: - return _is_not_found(resp) - - @interval_retry( - interval=timedelta(seconds=5), - timeout=timedelta(minutes=2), - ) - def retrieve_settings() -> openapi.models.DatabaseSettings: - resp = get_database_settings.sync( - self._account_id, - database_id, - client=self._client, - ) - _log_api_output( - "get_database_settings.sync", - resp, - account_id=self._account_id, - database_id=database_id, - ) - if isinstance(resp, ApiError) and is_retry(resp): - raise TryAgain - return ensure_type( - openapi.models.DatabaseSettings, - resp, - f"Failed to get settings of database {database_id}", - ) - - return retrieve_settings() - - def wait_until_running( - self, - database_id: str, - timeout: timedelta = timedelta(minutes=30), - interval: timedelta = timedelta(minutes=2), - ): - success = [Status.RUNNING] - - @interval_retry(interval, timeout) - def poll_status() -> Status: - db = self.get_database(database_id) - status = db.status if db else None - if status not in success: - LOG.info("- Database status: %s ...", status) - raise TryAgain - return status - - LOG.info("Waiting for database with ID %s to be available:", database_id) - if poll_status() not in success: - raise DatabaseStartupFailure() - - def clusters( - self, - database_id: str, - ) -> list[openapi.models.Cluster] | None: - resp = ( - list_clusters.sync( - self._account_id, - database_id, - client=self._client, - ) - or [] - ) - _log_api_output( - "list_clusters.sync", - resp, - account_id=self._account_id, - database_id=database_id, - ) - # actually list[openapi.models.Cluster] - return ensure_type( - list, resp, f"Failed to list clusters of database {database_id}" - ) - - def get_connection( - self, - database_id: str, - cluster_id: str, - ) -> openapi.models.ClusterConnection | None: - resp = get_cluster_connection.sync( - self._account_id, - database_id, - cluster_id, - client=self._client, - ) - _log_api_output( - "get_cluster_connection.sync", - resp, - account_id=self._account_id, - database_id=database_id, - cluster_id=cluster_id, - ) - return ensure_type( - openapi.models.ClusterConnection, - resp, - "Failed to retrieve a connection to " - f"database {database_id} cluster {cluster_id}", - ) - - def list_allowed_ip_ids(self) -> Iterable[str]: - resp = list_allowed_i_ps.sync(self._account_id, client=self._client) or [] - _log_api_output( - "list_allowed_i_ps.sync", - resp, - account_id=self._account_id, - ) - # actually list[openapi.models.AllowedIP] - ips = ensure_type(list, resp, "Failed to retrieve the list of allowed ips") - visible_allowed_ip_ids = [ - ip.id for ip in ips if ip.deleted_at is UNSET and ip.deleted_by is UNSET - ] - LOG.debug("list_allowed_ip_ids visible IDs: %s", visible_allowed_ip_ids) - return iter(visible_allowed_ip_ids) - - def get_allowed_ip( - self, - allowed_ip_id: str, - ) -> openapi.models.AllowedIP | ApiError | None: - resp = get_allowed_ip_api.sync( - self._account_id, - allowed_ip_id, - client=self._client, - ) - _log_api_output( - "get_allowed_ip.sync", - resp, - account_id=self._account_id, - allowed_ip_id=allowed_ip_id, - ) - return resp - - def wait_until_allowed_ip_listed( - self, - allowed_ip_id: str, - timeout: timedelta = timedelta(minutes=20), - interval: timedelta = timedelta(seconds=5), - ) -> None: - @interval_retry(interval, timeout) - def verify_listed() -> bool: - LOG.debug( - "wait_until_allowed_ip_listed state {'allowed_ip_id': %s}", - allowed_ip_id, - ) - allowed_ip = self.get_allowed_ip(allowed_ip_id) - if not self._is_active_allowed_ip(allowed_ip): - raise TryAgain - return True - - verify_listed() - - def wait_until_allowed_ip_deleted( - self, - allowed_ip_id: str, - timeout: timedelta = timedelta(minutes=10), - interval: timedelta = timedelta(seconds=5), - ) -> None: - @interval_retry(interval, timeout) - def verify_deleted() -> bool: - LOG.debug( - "wait_until_allowed_ip_deleted state {'allowed_ip_id': %s}", - allowed_ip_id, - ) - allowed_ip = self.get_allowed_ip(allowed_ip_id) - if self._is_active_allowed_ip(allowed_ip): - raise TryAgain - return True - - verify_deleted() - - def add_allowed_ip( - self, - cidr_ip: str = "0.0.0.0/0", - ) -> openapi.models.AllowedIP | None: - """ - Suggested values for cidr_ip: - * 185.17.207.78/32 - * 0.0.0.0/0 = all ipv4 - * ::/0 = all ipv6 - """ - rule = openapi.models.CreateAllowedIP( - name=timestamp_name(), - cidr_ip=cidr_ip, - ) - resp = add_allowed_ip.sync( - self._account_id, - client=self._client, - body=rule, - ) - _log_api_output( - "add_allowed_ip.sync", - resp, - account_id=self._account_id, - cidr_ip=cidr_ip, - ) - created_ip = ensure_type( - openapi.models.AllowedIP, - resp, - f"Failed to add allowed IP address {cidr_ip}", - ) - return self._resolve_allowed_ip(created_ip.id) - - def _resolve_allowed_ip( - self, - allowed_ip_id: str, - timeout: timedelta = timedelta(minutes=20), - interval: timedelta = timedelta(seconds=5), - ) -> openapi.models.AllowedIP: - @interval_retry(interval, timeout) - def resolve() -> openapi.models.AllowedIP: - allowed_ip = self.get_allowed_ip(allowed_ip_id) - if self._is_active_allowed_ip(allowed_ip): - return cast(openapi.models.AllowedIP, allowed_ip) - raise TryAgain - - return resolve() - - @staticmethod - def _is_active_allowed_ip( - allowed_ip: openapi.models.AllowedIP | ApiError | None, - ) -> bool: - if allowed_ip is None or isinstance(allowed_ip, ApiError): - return False - return allowed_ip.deleted_at is UNSET and allowed_ip.deleted_by is UNSET - - def delete_allowed_ip(self, id: str, ignore_failures=False) -> Any | None: - with self._ignore_failures(ignore_failures) as client: - resp = delete_allowed_ip.sync(self._account_id, id, client=client) - _log_api_output( - "delete_allowed_ip.sync", - resp, - account_id=self._account_id, - allowed_ip_id=id, - ) - return resp - - @contextmanager - def allowed_ip( - self, - cidr_ip: str = "0.0.0.0/0", - keep: bool = False, - ignore_delete_failure: bool = False, - ): - ip = None - try: - ip = self.add_allowed_ip(cidr_ip) - yield ip - finally: - if ip and not keep: - self.delete_allowed_ip(ip.id, ignore_delete_failure) +from exasol.saas.client._api_access import ( + DatabaseDeleteError, + DatabaseDeleteTimeout, + DatabaseStartupFailure, + OpenApiAccess, + OpenApiError, + _log_api_output, + create_saas_client, + ensure_type, + get_connection_params, + get_database_id, + interval_retry, + timestamp_name, +) + +__all__ = [ + "DatabaseDeleteError", + "DatabaseDeleteTimeout", + "DatabaseStartupFailure", + "OpenApiAccess", + "OpenApiError", + "_log_api_output", + "create_saas_client", + "ensure_type", + "get_connection_params", + "get_database_id", + "interval_retry", + "timestamp_name", +] diff --git a/test/integration/test_allowed_ip.py b/test/integration/test_allowed_ip.py index 47d9d40..8ea517e 100644 --- a/test/integration/test_allowed_ip.py +++ b/test/integration/test_allowed_ip.py @@ -17,9 +17,8 @@ def test_lifecycle(api_access): testee = api_access with testee.allowed_ip( cidr_ip=_test_only_allowed_ip_cidr(), - ignore_delete_failure=True, + keep=True, ) as ip: - testee.wait_until_allowed_ip_listed(ip.id) assert testee.get_allowed_ip(ip.id) is not None # delete allowed ip and verify it is not listed anymore diff --git a/test/unit/test_api_access.py b/test/unit/test_api_access.py index f11dff9..aee67c6 100644 --- a/test/unit/test_api_access.py +++ b/test/unit/test_api_access.py @@ -49,7 +49,7 @@ def api_mock(): def delete_mock(monkeypatch, side_effect) -> Mock: - from exasol.saas.client.api_access import delete_database as api + from exasol.saas.client._api_access.database_lifecycle import delete_database as api mock = Mock(side_effect=side_effect) monkeypatch.setattr(api, "sync", mock) @@ -57,7 +57,7 @@ def delete_mock(monkeypatch, side_effect) -> Mock: def create_database_mock(monkeypatch, side_effect) -> Mock: - from exasol.saas.client.api_access import create_database as api + from exasol.saas.client._api_access.database_lifecycle import create_database as api mock = Mock(side_effect=side_effect) monkeypatch.setattr(api, "sync", mock) @@ -65,13 +65,19 @@ def create_database_mock(monkeypatch, side_effect) -> Mock: def get_database_settings_mock(monkeypatch, side_effect) -> Mock: - from exasol.saas.client.api_access import get_database_settings as api + from exasol.saas.client._api_access.database_lifecycle import ( + get_database_settings as api, + ) mock = Mock(side_effect=side_effect) monkeypatch.setattr(api, "sync", mock) return mock + + def list_allowed_ips_mock(monkeypatch, side_effect) -> Mock: - from exasol.saas.client.api_access import list_allowed_i_ps as api + from exasol.saas.client._api_access.allowed_ip_lifecycle import ( + list_allowed_i_ps as api, + ) mock = Mock(side_effect=side_effect) monkeypatch.setattr(api, "sync", mock) @@ -79,7 +85,7 @@ def list_allowed_ips_mock(monkeypatch, side_effect) -> Mock: def get_allowed_ip_mock(monkeypatch, side_effect) -> Mock: - from exasol.saas.client.api_access import get_allowed_ip_api as api + from exasol.saas.client._api_access.access import get_allowed_ip_api as api mock = Mock(side_effect=side_effect) monkeypatch.setattr(api, "sync", mock) @@ -87,7 +93,7 @@ def get_allowed_ip_mock(monkeypatch, side_effect) -> Mock: def get_database_mock(monkeypatch, side_effect) -> Mock: - from exasol.saas.client.api_access import get_database as api + from exasol.saas.client._api_access.database_lifecycle import get_database as api mock = Mock(side_effect=side_effect) monkeypatch.setattr(api, "sync", mock) @@ -263,7 +269,7 @@ def test_get_database_settings_retries_transient_not_found( api_mock, monkeypatch ) -> None: monkeypatch.setattr( - "exasol.saas.client.api_access.interval_retry", + "exasol.saas.client._api_access.database_lifecycle.interval_retry", immediate_retry, ) get_settings = get_database_settings_mock( @@ -285,7 +291,7 @@ def test_get_database_settings_raises_non_retryable_error( api_mock, monkeypatch ) -> None: monkeypatch.setattr( - "exasol.saas.client.api_access.interval_retry", + "exasol.saas.client._api_access.database_lifecycle.interval_retry", lambda *_args, **_kwargs: (lambda func: func), ) get_database_settings_mock( @@ -295,6 +301,8 @@ def test_get_database_settings_raises_non_retryable_error( with pytest.raises(OpenApiError, match="Failed to get settings of database db-id"): api_mock.get_database_settings("db-id") + + def test_log_api_output_serializes_payloads(caplog) -> None: caplog.set_level(logging.DEBUG, logger="exasol.saas.client.api_access") @@ -400,7 +408,9 @@ def test_wait_until_allowed_ip_deleted_ignores_soft_deleted_entries( def test_add_allowed_ip_resolves_visible_rule_by_id(api_mock, monkeypatch) -> None: - from exasol.saas.client.api_access import add_allowed_ip as add_api + from exasol.saas.client._api_access.allowed_ip_lifecycle import ( + add_allowed_ip as add_api, + ) monkeypatch.setattr( add_api, @@ -469,7 +479,7 @@ def test_ensure_type_raises_open_api_error_for_malformed_error_payload() -> None def test_list_database_ids_skips_deleted_databases(api_mock, monkeypatch) -> None: - from exasol.saas.client.api_access import list_databases as api + from exasol.saas.client._api_access.database_lifecycle import list_databases as api monkeypatch.setattr( api, @@ -507,7 +517,7 @@ def test_list_database_ids_skips_deleted_databases(api_mock, monkeypatch) -> Non def test_list_database_ids_logs_visible_ids(api_mock, monkeypatch, caplog) -> None: - from exasol.saas.client.api_access import list_databases as api + from exasol.saas.client._api_access.database_lifecycle import list_databases as api caplog.set_level(logging.DEBUG, logger="exasol.saas.client.api_access") monkeypatch.setattr( @@ -542,7 +552,7 @@ def test_wait_until_deleted_uses_get_database_until_not_found( api_mock, monkeypatch ) -> None: monkeypatch.setattr( - "exasol.saas.client.api_access.interval_retry", + "exasol.saas.client._api_access.database_lifecycle.interval_retry", immediate_retry, ) get_database_mock( @@ -568,7 +578,9 @@ def test_wait_until_deleted_uses_get_database_until_not_found( [], ] ) - from exasol.saas.client.api_access import list_databases as list_api + from exasol.saas.client._api_access.database_lifecycle import ( + list_databases as list_api, + ) monkeypatch.setattr(list_api, "sync", list_databases_mock) @@ -579,7 +591,7 @@ def test_wait_until_deleted_accepts_stale_todelete_when_database_not_listed( api_mock, monkeypatch ) -> None: monkeypatch.setattr( - "exasol.saas.client.api_access.interval_retry", + "exasol.saas.client._api_access.database_lifecycle.interval_retry", immediate_retry, ) get_database_mock( @@ -610,7 +622,7 @@ def test_wait_until_deleted_accepts_todelete_when_helper_list_filters_it( api_mock, monkeypatch ) -> None: monkeypatch.setattr( - "exasol.saas.client.api_access.interval_retry", + "exasol.saas.client._api_access.database_lifecycle.interval_retry", immediate_retry, ) get_database_mock( @@ -628,7 +640,7 @@ def test_wait_until_deleted_accepts_todelete_when_helper_list_filters_it( ) ], ) - from exasol.saas.client.api_access import list_databases as api + from exasol.saas.client._api_access.database_lifecycle import list_databases as api monkeypatch.setattr( api, @@ -656,7 +668,7 @@ def test_wait_until_deleted_accepts_soft_deleted_database( api_mock, monkeypatch ) -> None: monkeypatch.setattr( - "exasol.saas.client.api_access.interval_retry", + "exasol.saas.client._api_access.database_lifecycle.interval_retry", immediate_retry, ) get_database_mock( @@ -684,7 +696,7 @@ def test_wait_until_deleted_retries_when_get_database_returns_none( api_mock, monkeypatch ) -> None: monkeypatch.setattr( - "exasol.saas.client.api_access.interval_retry", + "exasol.saas.client._api_access.database_lifecycle.interval_retry", immediate_retry, ) get_database = get_database_mock( @@ -704,7 +716,7 @@ def test_wait_until_deleted_times_out_for_active_database( api_mock, monkeypatch ) -> None: monkeypatch.setattr( - "exasol.saas.client.api_access.interval_retry", + "exasol.saas.client._api_access.database_lifecycle.interval_retry", lambda *_args, **_kwargs: (lambda func: func), ) monkeypatch.setattr(