diff --git a/.github/workflows/slow-checks.yml b/.github/workflows/slow-checks.yml index 1e20c46..70a744c 100644 --- a/.github/workflows/slow-checks.yml +++ b/.github/workflows/slow-checks.yml @@ -32,7 +32,7 @@ jobs: 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 diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 2c0d8a4..21312be 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -45,6 +45,9 @@ 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 ( @@ -55,7 +58,6 @@ from exasol.saas.client.openapi.types import UNSET LOG = logging.getLogger(__name__) -LOG.setLevel(logging.INFO) logging.getLogger("httpx").setLevel(logging.WARNING) @@ -128,12 +130,40 @@ def ensure_type( 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, @@ -155,6 +185,12 @@ def _get_database_id( 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 @@ -224,12 +260,25 @@ def get_connection_params( 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, @@ -275,16 +324,23 @@ def minutes(x: timedelta) -> int: ), ) 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", + ) 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", - ), + 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}" @@ -302,17 +358,64 @@ def _ignore_failures(self, ignore: bool = False): def wait_until_deleted( self, database_id: str, - timeout: timedelta = timedelta(seconds=1), - interval: timedelta = timedelta(minutes=1), + 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_not_listed() -> bool: - if database_id in self.list_database_ids(): + 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_not_listed() + return verify_deleted() except (TryAgain, RetryError) as ex: raise DatabaseDeleteTimeout from ex @@ -320,7 +423,7 @@ def delete_database( self, database_id: str, ignore_failures: bool = False, - timeout: timedelta = timedelta(minutes=30), + timeout: timedelta = timedelta(minutes=45), min_interval: timedelta = timedelta(seconds=1), max_interval: timedelta = timedelta(minutes=2), ) -> None: @@ -346,6 +449,12 @@ def delete_with_retry() -> None: 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 @@ -367,9 +476,22 @@ def delete_with_retry() -> None: 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") - return (db.id for db in dbs) + 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( @@ -381,7 +503,10 @@ def database( ): db = None try: - db = self.create_database(name, idle_time=idle_time) + 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 @@ -402,6 +527,12 @@ def get_database( 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}" ) @@ -439,6 +570,12 @@ def clusters( ) 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}" @@ -455,6 +592,13 @@ def get_connection( 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, @@ -464,9 +608,73 @@ def get_connection( 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") - return (x.id for x in 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, @@ -487,15 +695,52 @@ def add_allowed_ip( client=self._client, body=rule, ) - return ensure_type( + _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: - return delete_allowed_ip.sync(self._account_id, id, client=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( 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..17822bb 100644 --- a/noxfile.py +++ b/noxfile.py @@ -100,6 +100,8 @@ def generate_api(session: Session): "--overwrite", "--config", "openapi_config.yml", + "--custom-template-path", + "openapi_templates", "--output-path", "tmp", silent=local_build, 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/test_allowed_ip.py b/test/integration/test_allowed_ip.py index 1aa5472..47d9d40 100644 --- a/test/integration/test_allowed_ip.py +++ b/test/integration/test_allowed_ip.py @@ -1,9 +1,29 @@ +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(), + ignore_delete_failure=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 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/unit/test_api_access.py b/test/unit/test_api_access.py index 0aa4164..631c810 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): @@ -39,6 +56,71 @@ def delete_mock(monkeypatch, side_effect) -> 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 + + 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 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 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 +205,429 @@ 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) + + +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 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 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 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.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 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.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.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 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.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.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.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")