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.py b/exasol/saas/client/api_access.py index 21312be..56ad1ac 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -40,6 +40,7 @@ create_database, delete_database, get_database, + get_database_settings, list_databases, ) from exasol.saas.client.openapi.api.security import ( @@ -310,6 +311,7 @@ def create_database( cluster_size: str = "XS", region: str = "eu-central-1", idle_time: timedelta | None = None, + num_nodes: int | None = None, ) -> ExasolDatabase | None: def minutes(x: timedelta) -> int: return x.seconds // 60 @@ -331,6 +333,8 @@ def minutes(x: timedelta) -> int: region=region, stream_type="innovation-release", ) + if num_nodes is not None: + database_spec.num_nodes = num_nodes resp = create_database.sync( self._account_id, client=self._client, @@ -500,12 +504,14 @@ def database( 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: @@ -537,6 +543,39 @@ def get_database( ExasolDatabase, resp, f"Failed to get database {database_id}" ) + def get_database_settings( + self, + database_id: str, + ) -> openapi.models.DatabaseSettings | None: + def is_retry(resp: ApiError) -> bool: + return _is_not_found(resp) + + @interval_retry( + interval=timedelta(seconds=5), + timeout=timedelta(minutes=2), + ) + def retrieve_settings() -> openapi.models.DatabaseSettings: + resp = get_database_settings.sync( + self._account_id, + database_id, + client=self._client, + ) + _log_api_output( + "get_database_settings.sync", + resp, + account_id=self._account_id, + database_id=database_id, + ) + if isinstance(resp, ApiError) and is_retry(resp): + raise TryAgain + return ensure_type( + openapi.models.DatabaseSettings, + resp, + f"Failed to get settings of database {database_id}", + ) + + return retrieve_settings() + def wait_until_running( self, database_id: str, 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_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 631c810..3e5e527 100644 --- a/test/unit/test_api_access.py +++ b/test/unit/test_api_access.py @@ -56,6 +56,22 @@ def delete_mock(monkeypatch, side_effect) -> Mock: return mock +def create_database_mock(monkeypatch, side_effect) -> Mock: + from exasol.saas.client.api_access 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 import get_database_settings as api + + mock = Mock(side_effect=side_effect) + monkeypatch.setattr(api, "sync", mock) + return mock + + def list_allowed_ips_mock(monkeypatch, side_effect) -> Mock: from exasol.saas.client.api_access import list_allowed_i_ps as api @@ -207,6 +223,82 @@ def test_timestamp_name() -> None: 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.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.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")