diff --git a/.github/workflows/matrix-slow.yml b/.github/workflows/matrix-slow.yml new file mode 100644 index 0000000..734f9b1 --- /dev/null +++ b/.github/workflows/matrix-slow.yml @@ -0,0 +1,37 @@ +name: Build Matrix (Slow Tests) + +on: + workflow_call: + outputs: + matrix: + description: "Generates the slow test build matrix" + value: ${{ jobs.set-matrix-slow.outputs.matrix }} + +jobs: + set-matrix-slow: + runs-on: "ubuntu-24.04" + permissions: + contents: read + steps: + - name: Check out Repository + id: check-out-repository + uses: actions/checkout@v6 + + - name: Set up Python & Poetry Environment + id: set-up-python-and-poetry-environment + uses: exasol/python-toolbox/.github/actions/python-environment@v7 + with: + python-version: "3.10" + poetry-version: "2.3.0" + + - name: Generate Matrix + id: generate-matrix + run: poetry run -- nox -s matrix:slow + + - name: Set Matrix + id: set-matrix + run: | + echo "matrix=$(poetry run -- nox -s matrix:slow)" >> $GITHUB_OUTPUT + + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} diff --git a/.github/workflows/slow-checks.yml b/.github/workflows/slow-checks.yml index 1e20c46..ff33e3e 100644 --- a/.github/workflows/slow-checks.yml +++ b/.github/workflows/slow-checks.yml @@ -4,12 +4,22 @@ on: workflow_call: jobs: + build-slow-matrix: + name: Build Slow Matrix + uses: ./.github/workflows/matrix-slow.yml + permissions: + contents: read run-integration-tests: - name: Run Integration Tests + name: Run Integration Tests (${{ matrix.test-name }}) + needs: + - build-slow-matrix runs-on: "ubuntu-24.04" permissions: contents: read + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.build-slow-matrix.outputs.matrix) }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: @@ -26,18 +36,21 @@ jobs: - name: Run Integration Tests id: run-integration-tests - run: poetry run -- nox -s test:integration -- --coverage + run: | + poetry run coverage run \ + --rcfile=pyproject.toml \ + -m pytest -v "${{ matrix.test-path }}" env: SAAS_HOST: ${{ secrets.INTEGRATION_TEAM_SAAS_STAGING_HOST }} SAAS_ACCOUNT_ID: ${{ secrets.INTEGRATION_TEAM_SAAS_STAGING_ACCOUNT_ID }} SAAS_PAT: ${{ secrets.INTEGRATION_TEAM_SAAS_STAGING_PAT }} PROJECT_SHORT_TAG: SAPIPY - PYTEST_ADDOPTS: '-o log_cli=true -o log_cli_level=INFO' + PYTEST_ADDOPTS: '-o log_cli=true -o log_cli_level=DEBUG' - name: Upload Artifacts id: upload-artifacts uses: actions/upload-artifact@v7 with: - name: coverage-python3.10-slow + name: coverage-python3.10-slow-${{ matrix.test-name }} path: .coverage include-hidden-files: true diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index 332dfd9..2c86bd8 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -15,6 +15,8 @@ Besides there are many formal and syntactic changes ## Summary +* #147: Added `num_nodes` support to the handwritten database creation helpers. + ## Refactorings * #160: Updated PTB to version 7.0.0 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 2c0d8a4..a17f219 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -1,513 +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, - list_databases, -) -from exasol.saas.client.openapi.api.security import ( - add_allowed_ip, - delete_allowed_ip, - 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__) -LOG.setLevel(logging.INFO) -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) - - -class InternalError(Exception): - """ - Internal error during delete with retry. - """ - - -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) - 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) - 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 - ) - 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, - ) -> 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) - resp = create_database.sync( - self._account_id, - client=self._client, - body=openapi.models.CreateDatabase( - name=name, - initial_cluster=cluster_spec, - provider="aws", - region=region, - stream_type="innovation-release", - ), - ) - 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(seconds=1), - interval: timedelta = timedelta(minutes=1), - ): - @interval_retry(interval, timeout) - def verify_not_listed() -> bool: - if database_id in self.list_database_ids(): - raise TryAgain - return True - - try: - return verify_not_listed() - except (TryAgain, RetryError) as ex: - raise DatabaseDeleteTimeout from ex - - def delete_database( - self, - database_id: str, - ignore_failures: bool = False, - timeout: timedelta = timedelta(minutes=30), - 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, - ) - 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 [] - # actually list[ExasolDatabase] - dbs = ensure_type(list, resp, "Failed to list databases") - return (db.id for db in dbs) - - @contextmanager - def database( - self, - name: str, - keep: bool = False, - ignore_delete_failure: bool = False, - idle_time: timedelta | None = None, - ): - db = None - try: - db = self.create_database(name, idle_time=idle_time) - 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, - ) - return ensure_type( - ExasolDatabase, resp, f"Failed to get database {database_id}" - ) - - 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 [] - ) - # 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, - ) - 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 [] - # actually list[openapi.models.AllowedIP] - ips = ensure_type(list, resp, "Failed to retrieve the list of allowed ips") - return (x.id for x in ips) - - 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, - ) - return ensure_type( - openapi.models.AllowedIP, - resp, - f"Failed to add allowed IP address {cidr_ip}", - ) - - def delete_allowed_ip(self, id: str, ignore_failures=False) -> Any | None: - with self._ignore_failures(ignore_failures) as client: - return delete_allowed_ip.sync(self._account_id, id, client=client) - - @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/exasol/saas/client/openapi/models/api_error.py b/exasol/saas/client/openapi/models/api_error.py index 9159e04..5a595c6 100644 --- a/exasol/saas/client/openapi/models/api_error.py +++ b/exasol/saas/client/openapi/models/api_error.py @@ -82,21 +82,21 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: d = dict(src_dict) - status = d.pop("status") + status = d.pop("status", 0) - message = d.pop("message") + message = d.pop("message", "Unknown API error") - request_id = d.pop("requestId") + request_id = d.pop("requestId", "") - path = d.pop("path") + path = d.pop("path", "") - method = d.pop("method") + method = d.pop("method", "") - log_id = d.pop("logId") + log_id = d.pop("logId", "") - handler = d.pop("handler") + handler = d.pop("handler", "") - timestamp = d.pop("timestamp") + timestamp = d.pop("timestamp", "") causes = d.pop("causes", UNSET) diff --git a/noxfile.py b/noxfile.py index 1cd1f82..da0fa24 100644 --- a/noxfile.py +++ b/noxfile.py @@ -26,6 +26,19 @@ DEST_DIR = "exasol/saas/client/openapi" +def _slow_test_matrix() -> dict[str, list[dict[str, str]]]: + files = sorted(Path("test/integration").glob("test_*.py")) + return { + "include": [ + { + "test-name": path.stem.removeprefix("test_").replace("_", "-"), + "test-path": str(path), + } + for path in files + ] + } + + def _download_openapi_json() -> Path: url = f"{SAAS_HOST}/openapi.json" response = requests.get(url) @@ -100,6 +113,8 @@ def generate_api(session: Session): "--overwrite", "--config", "openapi_config.yml", + "--custom-template-path", + "openapi_templates", "--output-path", "tmp", silent=local_build, @@ -125,3 +140,9 @@ def check_api_outdated(session: Session): """ generate_api(session) session.run("git", "diff", "--exit-code", DEST_DIR) + + +@nox.session(name="matrix:slow", python=False) +def slow_matrix(session: Session): + """Output the build matrix for slow integration tests as JSON.""" + print(json.dumps(_slow_test_matrix())) diff --git a/openapi_templates/model.py.jinja b/openapi_templates/model.py.jinja new file mode 100644 index 0000000..79d5b79 --- /dev/null +++ b/openapi_templates/model.py.jinja @@ -0,0 +1,265 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +{% if model.is_multipart_body %} +import json +from .. import types +{% endif %} + +from ..types import UNSET, Unset + +{% for relative in model.relative_imports | sort %} +{{ relative }} +{% endfor %} + +{% for lazy_import in model.lazy_imports | sort %} +{% if loop.first %} +if TYPE_CHECKING: +{% endif %} + {{ lazy_import }} +{% endfor %} + + +{% if model.additional_properties %} +{% set additional_property_type = 'Any' if model.additional_properties == True else model.additional_properties.get_type_string() %} +{% endif %} + +{% set class_name = model.class_info.name %} +{% set module_name = model.class_info.module_name %} + +{% from "helpers.jinja" import safe_docstring %} + +T = TypeVar("T", bound="{{ class_name }}") + +{% macro class_docstring_content(model) %} + {% if model.title %}{{ model.title | wordwrap(116) }} + + {% endif -%} + {%- if model.description %}{{ model.description | wordwrap(116) }} + + {% endif %} + {% if not model.title and not model.description %} + {# Leave extra space so that a section doesn't start on the first line #} + + {% endif %} + {% if model.example %} + Example: + {{ model.example | string | wordwrap(112) | indent(12) }} + + {% endif %} + {% if (not config.docstrings_on_attributes) and (model.required_properties or model.optional_properties) %} + Attributes: + {% for property in model.required_properties + model.optional_properties %} + {{ property.to_docstring() | wordwrap(112) | indent(12) }} + {% endfor %}{% endif %} +{% endmacro %} + +{% macro declare_property(property) %} +{%- if config.docstrings_on_attributes and property.description -%} +{{ property.to_string() }} +{{ safe_docstring(property.description, omit_if_empty=True) | wordwrap(112) }} +{%- else -%} +{{ property.to_string() }} +{%- endif -%} +{% endmacro %} + +@_attrs_define +class {{ class_name }}: + {{ safe_docstring(class_docstring_content(model), omit_if_empty=config.docstrings_on_attributes) | indent(4) }} + + {% for property in model.required_properties + model.optional_properties %} + {% if property.default is none and property.required %} + {{ declare_property(property) | indent(4) }} + {% endif %} + {% endfor %} + {% for property in model.required_properties + model.optional_properties %} + {% if property.default is not none or not property.required %} + {{ declare_property(property) | indent(4) }} + {% endif %} + {% endfor %} + {% if model.additional_properties %} + additional_properties: dict[str, {{ additional_property_type }}] = _attrs_field(init=False, factory=dict) + {% endif %} + +{% macro _transform_property(property, content) %} +{% import "property_templates/" + property.template as prop_template %} +{%- if prop_template.transform -%} +{{ prop_template.transform(property=property, source=content, destination=property.python_name) }} +{%- else -%} +{{ property.python_name }} = {{ content }} +{%- endif -%} +{% endmacro %} + +{% macro multipart(property, source, destination) %} +{% import "property_templates/" + property.template as prop_template %} +{% if not property.required %} +if not isinstance({{source}}, Unset): + {{ prop_template.multipart(property, source, destination) | indent(4) }} +{% else %} +{{ prop_template.multipart(property, source, destination) }} +{% endif %} +{% endmacro %} + +{% macro _prepare_field_dict() %} +field_dict: dict[str, Any] = {} +{% if model.additional_properties %} +{% import "property_templates/" + model.additional_properties.template as prop_template %} +{% if prop_template.transform %} +for prop_name, prop in self.additional_properties.items(): + {{ prop_template.transform(model.additional_properties, "prop", "field_dict[prop_name]", declare_type=false) | indent(4) }} +{% else %} +field_dict.update(self.additional_properties) +{%- endif -%} +{%- endif -%} +{% endmacro %} + +{% macro _to_dict() %} +{% for property in model.required_properties + model.optional_properties -%} +{{ _transform_property(property, "self." + property.python_name) }} + +{% endfor %} + +{{ _prepare_field_dict() }} +{% if model.required_properties | length > 0 or model.optional_properties | length > 0 %} +field_dict.update({ + {% for property in model.required_properties + model.optional_properties %} + {% if property.required %} + "{{ property.name }}": {{ property.python_name }}, + {% endif %} + {% endfor %} +}) +{% endif %} +{% for property in model.optional_properties %} +{% if not property.required %} +if {{ property.python_name }} is not UNSET: + field_dict["{{ property.name }}"] = {{ property.python_name }} +{% endif %} +{% endfor %} + +return field_dict +{% endmacro %} + + def to_dict(self) -> dict[str, Any]: + {% for lazy_import in model.lazy_imports | sort %} + {{ lazy_import }} + {% endfor %} + {{ _to_dict() | indent(8) }} + +{% if model.is_multipart_body %} + def to_multipart(self) -> types.RequestFiles: + {% for lazy_import in model.lazy_imports | sort %} + {{ lazy_import }} + {% endfor %} + files: types.RequestFiles = [] + + {% for property in model.required_properties + model.optional_properties %} + {% set destination = "\"" + property.name + "\"" %} + {{ multipart(property, "self." + property.python_name, destination) | indent(8) }} + + {% endfor %} + + {% if model.additional_properties %} + for prop_name, prop in self.additional_properties.items(): + {{ multipart(model.additional_properties, "prop", "prop_name") | indent(4) }} + {% endif %} + + return files + +{% endif %} + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + {% for lazy_import in model.lazy_imports | sort %} + {{ lazy_import }} + {% endfor %} +{% if (model.required_properties or model.optional_properties or model.additional_properties) %} + d = dict(src_dict) +{% for property in model.required_properties + model.optional_properties %} + {% if property.required %} + {% if class_name == "ApiError" %} + {% if property.name == "status" %} + {% set property_source = 'd.pop("status", 0)' %} + {% elif property.name == "message" %} + {% set property_source = 'd.pop("message", "Unknown API error")' %} + {% elif property.name == "requestId" %} + {% set property_source = 'd.pop("requestId", "")' %} + {% elif property.name == "path" %} + {% set property_source = 'd.pop("path", "")' %} + {% elif property.name == "method" %} + {% set property_source = 'd.pop("method", "")' %} + {% elif property.name == "logId" %} + {% set property_source = 'd.pop("logId", "")' %} + {% elif property.name == "handler" %} + {% set property_source = 'd.pop("handler", "")' %} + {% elif property.name == "timestamp" %} + {% set property_source = 'd.pop("timestamp", "")' %} + {% else %} + {% set property_source = 'd.pop("' + property.name + '")' %} + {% endif %} + {% else %} + {% set property_source = 'd.pop("' + property.name + '")' %} + {% endif %} + {% else %} + {% set property_source = 'd.pop("' + property.name + '", UNSET)' %} + {% endif %} + {% import "property_templates/" + property.template as prop_template %} + {% if prop_template.construct %} + {{ prop_template.construct(property, property_source) | indent(8) }} + {% else %} + {{ property.python_name }} = {{ property_source }} + {% endif %} + +{% endfor %} +{% endif %} + {{ module_name }} = cls( +{% for property in model.required_properties + model.optional_properties %} + {{ property.python_name }}={{ property.python_name }}, +{% endfor %} + ) + +{% if model.additional_properties %} + {% if model.additional_properties.template %}{# Can be a bool instead of an object #} + {% import "property_templates/" + model.additional_properties.template as prop_template %} + +{% if model.additional_properties.lazy_imports %} + {% for lazy_import in model.additional_properties.lazy_imports | sort %} + {{ lazy_import }} + {% endfor %} +{% endif %} + {% else %} + {% set prop_template = None %} + {% endif %} + {% if prop_template and prop_template.construct %} + additional_properties = {} + for prop_name, prop_dict in d.items(): + {{ prop_template.construct(model.additional_properties, "prop_dict") | indent(12) }} + additional_properties[prop_name] = {{ model.additional_properties.python_name }} + + {{ module_name }}.additional_properties = additional_properties + {% else %} + {{ module_name }}.additional_properties = d + {% endif %} +{% endif %} + return {{ module_name }} + + {% if model.additional_properties %} + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> {{ additional_property_type }}: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: {{ additional_property_type }}) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties + {% endif %} diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 272ba1b..37912e5 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -67,6 +67,15 @@ def database_name(project_short_tag) -> str: return timestamp_name(project_short_tag) +@pytest.fixture +def local_name(project_short_tag: str | None) -> str: + """ + Other than global fixture database_name this fixture uses scope + "function" to generate an individual name for each test case. + """ + return timestamp_name(project_short_tag) + + @pytest.fixture(scope="session") def allow_connection(api_access) -> None: """ diff --git a/test/integration/test_allowed_ip.py b/test/integration/test_allowed_ip.py index 1aa5472..8ea517e 100644 --- a/test/integration/test_allowed_ip.py +++ b/test/integration/test_allowed_ip.py @@ -1,9 +1,28 @@ +from uuid import uuid4 + +from exasol.saas.client.openapi.models.api_error import ApiError + + +def _test_only_allowed_ip_cidr() -> str: + # Avoid 0.0.0.0/0 here because the shared SaaS account accumulates old broad + # rules and the backend does not reliably surface a fresh one by name. + documentation_networks = ("192.0.2", "198.51.100", "203.0.113") + token = uuid4().int + network = documentation_networks[token % len(documentation_networks)] + host = ((token >> 8) % 254) + 1 + return f"{network}.{host}/32" + + def test_lifecycle(api_access): testee = api_access - with testee.allowed_ip(ignore_delete_failure=True) as ip: - # verify allowed ip is listed - assert ip.id in testee.list_allowed_ip_ids() + with testee.allowed_ip( + cidr_ip=_test_only_allowed_ip_cidr(), + keep=True, + ) as ip: + assert testee.get_allowed_ip(ip.id) is not None # delete allowed ip and verify it is not listed anymore testee.delete_allowed_ip(ip.id) - assert ip.id not in testee.list_allowed_ip_ids() + testee.wait_until_allowed_ip_deleted(ip.id) + deleted_ip = testee.get_allowed_ip(ip.id) + assert deleted_ip is None or isinstance(deleted_ip, ApiError) diff --git a/test/integration/test_databases.py b/test/integration/test_database_lifecycle.py similarity index 73% rename from test/integration/test_databases.py rename to test/integration/test_database_lifecycle.py index f338799..95c3b3b 100644 --- a/test/integration/test_databases.py +++ b/test/integration/test_database_lifecycle.py @@ -1,16 +1,10 @@ import logging -from datetime import ( - datetime, - timedelta, -) +from datetime import timedelta import pytest from tenacity import RetryError from exasol.saas.client import PROMISING_STATES -from exasol.saas.client.api_access import ( - timestamp_name, -) from exasol.saas.client.openapi.models.exasol_database import ExasolDatabase LOG = logging.getLogger(__name__) @@ -20,15 +14,6 @@ ) -@pytest.fixture -def local_name(project_short_tag: str | None) -> str: - """ - Other than global fixture database_name this fixture uses scope - "function" to generate an individual name for each test case in this file. - """ - return timestamp_name(project_short_tag) - - def test_lifecycle(api_access, local_name): """ This integration test uses the database created and provided by pytest @@ -52,20 +37,16 @@ def get_connection(db: ExasolDatabase): return api_access.get_connection(db.id, clusters[0].id) with api_access.database(local_name, ignore_delete_failure=True) as db: - start = datetime.now() - # verify state and clusters of created database assert db.status in PROMISING_STATES and db.clusters.total == 1 with pytest.raises(RetryError): wait_until_running_too_short(db) - # verify database is listed assert db.id in api_access.list_database_ids() con = get_connection(db) assert con.db_username is not None and con.port == 8563 - # delete database and verify database is not listed anymore api_access.delete_database(db.id) api_access.wait_until_deleted(db.id) assert db.id not in api_access.list_database_ids() diff --git a/test/integration/test_database_num_nodes.py b/test/integration/test_database_num_nodes.py new file mode 100644 index 0000000..e2f6a45 --- /dev/null +++ b/test/integration/test_database_num_nodes.py @@ -0,0 +1,14 @@ +def test_create_database_with_two_nodes(api_access, local_name): + """ + This integration test verifies that the handwritten helper forwards + `num_nodes` and the created database reports that setting back. + """ + with api_access.database( + local_name, + ignore_delete_failure=True, + num_nodes=2, + ) as db: + settings = api_access.get_database_settings(db.id) + + assert settings is not None + assert settings.num_nodes == 2 diff --git a/test/unit/test_api_access.py b/test/unit/test_api_access.py index 0aa4164..aee67c6 100644 --- a/test/unit/test_api_access.py +++ b/test/unit/test_api_access.py @@ -1,15 +1,32 @@ -from datetime import timedelta +import logging +from datetime import ( + datetime, + timedelta, +) from test.util import not_raises from unittest.mock import Mock import pytest +from tenacity import TryAgain from exasol.saas.client.api_access import ( DatabaseDeleteError, + DatabaseDeleteTimeout, OpenApiAccess, + OpenApiError, + _log_api_output, + ensure_type, timestamp_name, ) +from exasol.saas.client.openapi.models.allowed_ip import AllowedIP from exasol.saas.client.openapi.models.api_error import ApiError +from exasol.saas.client.openapi.models.database_settings import DatabaseSettings +from exasol.saas.client.openapi.models.exasol_database import ExasolDatabase +from exasol.saas.client.openapi.models.exasol_database_clusters import ( + ExasolDatabaseClusters, +) +from exasol.saas.client.openapi.models.status import Status +from exasol.saas.client.openapi.types import UNSET def response(status_code: int, message: str, spec=None): @@ -32,13 +49,98 @@ 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) + return mock + + +def create_database_mock(monkeypatch, side_effect) -> Mock: + from exasol.saas.client._api_access.database_lifecycle import create_database as api + + mock = Mock(side_effect=side_effect) + monkeypatch.setattr(api, "sync", mock) + return mock + + +def get_database_settings_mock(monkeypatch, side_effect) -> Mock: + 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.allowed_ip_lifecycle import ( + list_allowed_i_ps as api, + ) + + mock = Mock(side_effect=side_effect) + monkeypatch.setattr(api, "sync", mock) + return mock + + +def get_allowed_ip_mock(monkeypatch, side_effect) -> Mock: + 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) + return mock + + +def get_database_mock(monkeypatch, side_effect) -> Mock: + from exasol.saas.client._api_access.database_lifecycle import get_database as api + + mock = Mock(side_effect=side_effect) + monkeypatch.setattr(api, "sync", mock) + return mock + + +def database_response(name: str = "db") -> ExasolDatabase: + return ExasolDatabase( + status=Status.CREATING, + id="db-id", + name=name, + clusters=ExasolDatabaseClusters(total=1, running=0), + provider="aws", + region="eu-central-1", + created_at=datetime(2026, 1, 1), + created_by="tester", + ) + + +def allowed_ip_response( + id: str = "ip-1", + *, + deleted_at=UNSET, + deleted_by=UNSET, +) -> AllowedIP: + return AllowedIP( + id=id, + name="test-ip", + cidr_ip="0.0.0.0/0", + created_at=datetime(2026, 1, 1), + created_by="tester", + deleted_at=deleted_at, + deleted_by=deleted_by, + ) + + +def database_settings_response(num_nodes: int = 2) -> DatabaseSettings: + return DatabaseSettings( + offload_enabled=False, + auto_updates_enabled=True, + auto_updates_hard_disabled=False, + num_nodes=num_nodes, + stream_type="innovation-release", + stream_description="Innovation", + ) + + @pytest.fixture def retry_timings() -> dict[str, timedelta]: """ @@ -123,3 +225,509 @@ def test_timestamp_name() -> None: assert len(set(suffixes)) == 3 # the provided tag should follow the hacky timestamp. assert all(tag == "TEST" for tag in tags) + + +@pytest.mark.parametrize( + "num_nodes, expected_num_nodes", + [ + pytest.param(None, UNSET, id="uses_backend_default"), + pytest.param(2, 2, id="forwards_explicit_value"), + ], +) +def test_create_database_num_nodes( + api_mock, monkeypatch, num_nodes, expected_num_nodes +) -> None: + create = create_database_mock( + monkeypatch, + [database_response("db-with-nodes")], + ) + + result = api_mock.create_database("db-with-nodes", num_nodes=num_nodes) + + assert result is not None + assert create.called + body = create.call_args.kwargs["body"] + assert body.num_nodes == expected_num_nodes + + +def test_database_context_forwards_num_nodes(api_mock, monkeypatch) -> None: + create = Mock(return_value=database_response("db-with-context")) + delete = Mock() + monkeypatch.setattr(api_mock, "create_database", create) + monkeypatch.setattr(api_mock, "delete_database", delete) + + with api_mock.database("db-with-context", num_nodes=2) as db: + assert db is not None + assert db.name == "db-with-context" + + assert create.call_args.args == ("db-with-context",) + assert create.call_args.kwargs == {"idle_time": None, "num_nodes": 2} + delete.assert_called_once_with("db-id", False) + + +def test_get_database_settings_retries_transient_not_found( + api_mock, monkeypatch +) -> None: + monkeypatch.setattr( + "exasol.saas.client._api_access.database_lifecycle.interval_retry", + immediate_retry, + ) + get_settings = get_database_settings_mock( + monkeypatch, + [ + api_error(404, "User/Database not found"), + database_settings_response(), + ], + ) + + result = api_mock.get_database_settings("db-id") + + assert result is not None + assert result.num_nodes == 2 + assert get_settings.call_count == 2 + + +def test_get_database_settings_raises_non_retryable_error( + api_mock, monkeypatch +) -> None: + monkeypatch.setattr( + "exasol.saas.client._api_access.database_lifecycle.interval_retry", + lambda *_args, **_kwargs: (lambda func: func), + ) + get_database_settings_mock( + monkeypatch, + [api_error(500, "boom")], + ) + + 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") + + _log_api_output( + "list_allowed_i_ps.sync", + [ + allowed_ip_response("ip-1"), + ApiError.from_dict({"status": 404, "message": "not found"}), + None, + ], + account_id="A1", + ) + + assert "list_allowed_i_ps.sync response {'account_id': 'A1'}" in caplog.text + assert "'id': 'ip-1'" in caplog.text + assert "'status': 404" in caplog.text + assert "None" in caplog.text + + +def test_wait_until_allowed_ip_listed_retries(api_mock, monkeypatch) -> None: + get_allowed_ip_mock( + monkeypatch, + [None, allowed_ip_response("ip-1")], + ) + + api_mock.wait_until_allowed_ip_listed( + "ip-1", + timeout=timedelta(seconds=1), + interval=timedelta(milliseconds=10), + ) + + +def test_wait_until_allowed_ip_deleted_retries(api_mock, monkeypatch) -> None: + get_allowed_ip_mock( + monkeypatch, + [allowed_ip_response("ip-1"), None], + ) + + api_mock.wait_until_allowed_ip_deleted( + "ip-1", + timeout=timedelta(seconds=1), + interval=timedelta(milliseconds=10), + ) + + +def test_wait_until_allowed_ip_listed_logs_visible_ids( + api_mock, monkeypatch, caplog +) -> None: + caplog.set_level(logging.DEBUG, logger="exasol.saas.client.api_access") + get_allowed_ip_mock( + monkeypatch, + [allowed_ip_response("ip-1")], + ) + + api_mock.wait_until_allowed_ip_listed( + "ip-1", + timeout=timedelta(seconds=1), + interval=timedelta(milliseconds=10), + ) + + assert "wait_until_allowed_ip_listed state" in caplog.text + assert "get_allowed_ip.sync response" in caplog.text + + +def test_list_allowed_ip_ids_skips_deleted_entries(api_mock, monkeypatch) -> None: + list_allowed_ips_mock( + monkeypatch, + [ + [ + allowed_ip_response("ip-1"), + allowed_ip_response( + "ip-2", + deleted_at=datetime(2026, 1, 2), + deleted_by="tester", + ), + ] + ], + ) + + assert list(api_mock.list_allowed_ip_ids()) == ["ip-1"] + + +def test_wait_until_allowed_ip_deleted_ignores_soft_deleted_entries( + api_mock, monkeypatch +) -> None: + get_allowed_ip_mock( + monkeypatch, + [ + allowed_ip_response("ip-1"), + allowed_ip_response( + "ip-1", + deleted_at=datetime(2026, 1, 2), + deleted_by="tester", + ), + ], + ) + + api_mock.wait_until_allowed_ip_deleted( + "ip-1", + timeout=timedelta(seconds=1), + interval=timedelta(milliseconds=10), + ) + + +def test_add_allowed_ip_resolves_visible_rule_by_id(api_mock, monkeypatch) -> None: + from exasol.saas.client._api_access.allowed_ip_lifecycle import ( + add_allowed_ip as add_api, + ) + + monkeypatch.setattr( + add_api, + "sync", + Mock(return_value=allowed_ip_response("raw-ip")), + ) + get_allowed_ip_mock( + monkeypatch, + [ + None, + allowed_ip_response("resolved-ip"), + ], + ) + + result = api_mock.add_allowed_ip() + + assert result is not None + assert result.id == "resolved-ip" + + +def test_wait_until_allowed_ip_deleted_treats_api_error_as_deleted( + api_mock, monkeypatch +) -> None: + get_allowed_ip_mock( + monkeypatch, + [ + allowed_ip_response("ip-1"), + ApiError.from_dict({"status": 404, "message": "not found"}), + ], + ) + + api_mock.wait_until_allowed_ip_deleted( + "ip-1", + timeout=timedelta(seconds=1), + interval=timedelta(milliseconds=10), + ) + + +def test_api_error_from_dict_tolerates_missing_fields() -> None: + error = ApiError.from_dict( + { + "status": 500, + "message": "boom", + } + ) + + assert error.status == 500 + assert error.message == "boom" + assert error.request_id == "" + assert error.path == "" + assert error.method == "" + assert error.log_id == "" + assert error.handler == "" + assert error.timestamp == "" + assert error.causes is UNSET + + +def test_ensure_type_raises_open_api_error_for_malformed_error_payload() -> None: + malformed_error = ApiError.from_dict({"message": "backend failed"}) + + with pytest.raises( + OpenApiError, + match="Failed to do something: backend failed\\.", + ): + ensure_type(DatabaseSettings, malformed_error, "Failed to do something") + + +def test_list_database_ids_skips_deleted_databases(api_mock, monkeypatch) -> None: + from exasol.saas.client._api_access.database_lifecycle import list_databases as api + + monkeypatch.setattr( + api, + "sync", + Mock( + return_value=[ + database_response("active-db"), + ExasolDatabase( + status=Status.DELETING, + id="deleted-db-id", + name="deleted-db", + clusters=ExasolDatabaseClusters(total=1, running=0), + provider="aws", + region="eu-central-1", + created_at=datetime(2026, 1, 1), + created_by="tester", + deleted_at=datetime(2026, 1, 2), + deleted_by="tester", + ), + ExasolDatabase( + status=Status.TODELETE, + id="todelete-db-id", + name="todelete-db", + clusters=ExasolDatabaseClusters(total=1, running=0), + provider="aws", + region="eu-central-1", + created_at=datetime(2026, 1, 1), + created_by="tester", + ), + ] + ), + ) + + assert list(api_mock.list_database_ids()) == ["db-id"] + + +def test_list_database_ids_logs_visible_ids(api_mock, monkeypatch, caplog) -> None: + 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( + api, + "sync", + Mock(return_value=[database_response("active-db")]), + ) + + assert list(api_mock.list_database_ids()) == ["db-id"] + assert "list_databases.sync response" in caplog.text + assert "list_database_ids visible IDs: ['db-id']" in caplog.text + + +def immediate_retry(*_args, **_kwargs): + def decorate(func): + def wrapped(): + for _ in range(5): + try: + return func() + except TryAgain: + pass + except Exception: + raise + return func() + + return wrapped + + return decorate + + +def test_wait_until_deleted_uses_get_database_until_not_found( + api_mock, monkeypatch +) -> None: + monkeypatch.setattr( + "exasol.saas.client._api_access.database_lifecycle.interval_retry", + immediate_retry, + ) + get_database_mock( + monkeypatch, + [ + database_response("db-active"), + ExasolDatabase( + status=Status.TODELETE, + id="db-id", + name="db-active", + clusters=ExasolDatabaseClusters(total=1, running=0), + provider="aws", + region="eu-central-1", + created_at=datetime(2026, 1, 1), + created_by="tester", + ), + api_error(404, "User/Database not found"), + ], + ) + list_databases_mock = Mock( + side_effect=[ + [database_response("db-active")], + [], + ] + ) + from exasol.saas.client._api_access.database_lifecycle import ( + list_databases as list_api, + ) + + monkeypatch.setattr(list_api, "sync", list_databases_mock) + + api_mock.wait_until_deleted("db-id") + + +def test_wait_until_deleted_accepts_stale_todelete_when_database_not_listed( + api_mock, monkeypatch +) -> None: + monkeypatch.setattr( + "exasol.saas.client._api_access.database_lifecycle.interval_retry", + immediate_retry, + ) + get_database_mock( + monkeypatch, + [ + ExasolDatabase( + status=Status.TODELETE, + id="db-id", + name="db-active", + clusters=ExasolDatabaseClusters(total=1, running=0), + provider="aws", + region="eu-central-1", + created_at=datetime(2026, 1, 1), + created_by="tester", + ) + ], + ) + monkeypatch.setattr( + api_mock, + "list_database_ids", + Mock(return_value=iter([])), + ) + + api_mock.wait_until_deleted("db-id") + + +def test_wait_until_deleted_accepts_todelete_when_helper_list_filters_it( + api_mock, monkeypatch +) -> None: + monkeypatch.setattr( + "exasol.saas.client._api_access.database_lifecycle.interval_retry", + immediate_retry, + ) + get_database_mock( + monkeypatch, + [ + ExasolDatabase( + status=Status.TODELETE, + id="db-id", + name="db-active", + clusters=ExasolDatabaseClusters(total=1, running=0), + provider="aws", + region="eu-central-1", + created_at=datetime(2026, 1, 1), + created_by="tester", + ) + ], + ) + from exasol.saas.client._api_access.database_lifecycle import list_databases as api + + monkeypatch.setattr( + api, + "sync", + Mock( + return_value=[ + ExasolDatabase( + status=Status.TODELETE, + id="db-id", + name="db-active", + clusters=ExasolDatabaseClusters(total=1, running=0), + provider="aws", + region="eu-central-1", + created_at=datetime(2026, 1, 1), + created_by="tester", + ) + ] + ), + ) + + api_mock.wait_until_deleted("db-id") + + +def test_wait_until_deleted_accepts_soft_deleted_database( + api_mock, monkeypatch +) -> None: + monkeypatch.setattr( + "exasol.saas.client._api_access.database_lifecycle.interval_retry", + immediate_retry, + ) + get_database_mock( + monkeypatch, + [ + ExasolDatabase( + status=Status.DELETED, + id="db-id", + name="db-active", + clusters=ExasolDatabaseClusters(total=1, running=0), + provider="aws", + region="eu-central-1", + created_at=datetime(2026, 1, 1), + created_by="tester", + deleted_at=datetime(2026, 1, 2), + deleted_by="tester", + ) + ], + ) + + api_mock.wait_until_deleted("db-id") + + +def test_wait_until_deleted_retries_when_get_database_returns_none( + api_mock, monkeypatch +) -> None: + monkeypatch.setattr( + "exasol.saas.client._api_access.database_lifecycle.interval_retry", + immediate_retry, + ) + get_database = get_database_mock( + monkeypatch, + [ + None, + api_error(404, "User/Database not found"), + ], + ) + + api_mock.wait_until_deleted("db-id") + + assert get_database.call_count == 2 + + +def test_wait_until_deleted_times_out_for_active_database( + api_mock, monkeypatch +) -> None: + monkeypatch.setattr( + "exasol.saas.client._api_access.database_lifecycle.interval_retry", + lambda *_args, **_kwargs: (lambda func: func), + ) + monkeypatch.setattr( + api_mock, + "list_database_ids", + Mock(return_value=iter(["db-id"])), + ) + get_database_mock( + monkeypatch, + [database_response("db-active")], + ) + + with pytest.raises(DatabaseDeleteTimeout): + api_mock.wait_until_deleted("db-id")