From 8fefdc1c92026a7b5b63a1140d358089f50798d5 Mon Sep 17 00:00:00 2001 From: Anatoli Kurtsevich Date: Thu, 6 Jul 2023 17:00:10 -0400 Subject: [PATCH 1/5] added delete_engine_wait function that completes once an engine is deleted, added tests for engine API --- railib/api.py | 32 +++++++++++++++++++++++++++----- test/test_integration.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/railib/api.py b/railib/api.py index aa8990b..159a861 100644 --- a/railib/api.py +++ b/railib/api.py @@ -112,6 +112,7 @@ class Permission(str, Enum): "create_oauth_client", "delete_database", "delete_engine", + "delete_engine_wait", "delete_model", "disable_user", "enable_user", @@ -134,8 +135,13 @@ class Permission(str, Enum): "list_oauth_clients", "load_csv", "update_user", + "ResourceNotFoundError", ] +class ResourceNotFoundError(Exception): + """An error response, typically triggered by a 412 response (for update) or 404 (for get/post)""" + pass + # Context contains the state required to make rAI API calls. class Context(rest.Context): @@ -221,11 +227,15 @@ def _get_resource(ctx: Context, path: str, key=None, **kwargs) -> Dict: url = _mkurl(ctx, path) rsp = rest.get(ctx, url, **kwargs) rsp = json.loads(rsp.read()) + if key: rsp = rsp[key] - if rsp and isinstance(rsp, list): - assert len(rsp) == 1 + + if isinstance(rsp, list): + if len(rsp) == 0: + raise ResourceNotFoundError(f"Resource not found at {url}") return rsp[0] + return rsp @@ -356,8 +366,8 @@ def poll_with_specified_overhead( time.sleep(duration) -def is_engine_term_state(state: str) -> bool: - return state == "PROVISIONED" or ("FAILED" in state) +def is_engine_term_state(state: str, targetState: str) -> bool: + return state == targetState or ("FAILED" in state) def create_engine(ctx: Context, engine: str, size: str = "XS", **kwargs): @@ -370,7 +380,7 @@ def create_engine(ctx: Context, engine: str, size: str = "XS", **kwargs): def create_engine_wait(ctx: Context, engine: str, size: str = "XS", **kwargs): create_engine(ctx, engine, size, **kwargs) poll_with_specified_overhead( - lambda: is_engine_term_state(get_engine(ctx, engine)["state"]), + lambda: is_engine_term_state(get_engine(ctx, engine)["state"], "PROVISIONED"), overhead_rate=0.2, timeout=30 * 60, ) @@ -416,6 +426,18 @@ def delete_engine(ctx: Context, engine: str, **kwargs) -> Dict: return json.loads(rsp.read()) +def delete_engine_wait(ctx: Context, engine: str, **kwargs) -> bool: + rsp = delete_engine(ctx, engine, **kwargs) + rsp = rsp["status"] + + while not is_engine_term_state(rsp["state"], "DELETED"): + try: + rsp = get_engine(ctx, engine) + except ResourceNotFoundError: + break + time.sleep(3) + + def delete_user(ctx: Context, id: str, **kwargs) -> Dict: url = _mkurl(ctx, f"{PATH_USER}/{id}") rsp = rest.delete(ctx, url, None, **kwargs) diff --git a/test/test_integration.py b/test/test_integration.py index 6a0270e..3c9b251 100644 --- a/test/test_integration.py +++ b/test/test_integration.py @@ -83,5 +83,38 @@ def tearDown(self): api.delete_database(ctx, dbname) +class TestEngineAPI(unittest.TestCase): + def test_get_engine(self): + testEngine = f"python-sdk-get-eng-test-{suffix}" + + # ResourceNotFoundError is raised when engine does not exist + with self.assertRaises(api.ResourceNotFoundError): + api.get_engine(ctx, testEngine, headers=custom_headers) + + # an engine with the name is returned when engine exists + api.create_engine_wait(ctx, testEngine, headers=custom_headers) + + rsp = api.get_engine(ctx, testEngine, headers=custom_headers) + self.assertEqual(testEngine, rsp["name"]) + self.assertEqual("PROVISIONED", rsp["state"]) + + api.delete_engine(ctx, testEngine, headers=custom_headers) + + def test_delete_engine(self): + testEngine = f"python-sdk-del-eng-test-{suffix}" + + rsp = api.create_engine_wait(ctx, testEngine, headers=custom_headers) + self.assertEqual("PROVISIONED", rsp["state"]) + rsp = api.delete_engine(ctx, testEngine, headers=custom_headers) + self.assertEqual("DELETING", rsp["status"]["state"]) + + def test_delete_engine_wait(self): + testEngine = f"python-sdk-del-eng-w-test-{suffix}" + + rsp = api.create_engine_wait(ctx, testEngine, headers=custom_headers) + res = api.delete_engine_wait(ctx, testEngine, headers=custom_headers) + self.assertEqual(None, res) + + if __name__ == '__main__': unittest.main() From 9a3fe7d99aedfd78c2d07037c639b714fdae3644 Mon Sep 17 00:00:00 2001 From: Anatoli Kurtsevich Date: Fri, 7 Jul 2023 16:07:44 -0400 Subject: [PATCH 2/5] added engine states enum, updated create and delete engine wait API to properly use engine states to determine when the operation is complete --- railib/api.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/railib/api.py b/railib/api.py index 159a861..6d59eef 100644 --- a/railib/api.py +++ b/railib/api.py @@ -101,6 +101,19 @@ class Permission(str, Enum): LIST_ACCESS_KEYS = "list:accesskey" +@unique +class EngineState(str, Enum): + REQUESTED = "REQUESTED" + UPDATING = "UPDATING" + PROVISIONING = "PROVISIONING" + PROVISIONED = "PROVISIONED" + PROVISION_FAILED = "PROVISION_FAILED" + DELETING = "DELETING" + SUSPENDED = "SUSPENDED" + DEPROVISIONING = "DEPROVISIONING" + UNKNOWN = "UNKNOWN" + + __all__ = [ "Context", "Mode", @@ -366,8 +379,8 @@ def poll_with_specified_overhead( time.sleep(duration) -def is_engine_term_state(state: str, targetState: str) -> bool: - return state == targetState or ("FAILED" in state) +def is_engine_provisioning_term_state(state: str) -> bool: + return state in [EngineState.PROVISIONED, EngineState.PROVISION_FAILED] def create_engine(ctx: Context, engine: str, size: str = "XS", **kwargs): @@ -380,7 +393,7 @@ def create_engine(ctx: Context, engine: str, size: str = "XS", **kwargs): def create_engine_wait(ctx: Context, engine: str, size: str = "XS", **kwargs): create_engine(ctx, engine, size, **kwargs) poll_with_specified_overhead( - lambda: is_engine_term_state(get_engine(ctx, engine)["state"], "PROVISIONED"), + lambda: is_engine_provisioning_term_state(get_engine(ctx, engine)["state"]), overhead_rate=0.2, timeout=30 * 60, ) @@ -430,7 +443,7 @@ def delete_engine_wait(ctx: Context, engine: str, **kwargs) -> bool: rsp = delete_engine(ctx, engine, **kwargs) rsp = rsp["status"] - while not is_engine_term_state(rsp["state"], "DELETED"): + while rsp["state"] in [EngineState.DEPROVISIONING, EngineState.DELETING]: try: rsp = get_engine(ctx, engine) except ResourceNotFoundError: From 612288da1ca0138def174551cf9efe594b5ee550 Mon Sep 17 00:00:00 2001 From: Anatoli Kurtsevich Date: Fri, 7 Jul 2023 16:57:35 -0400 Subject: [PATCH 3/5] modified the delete_engine_wait test to check the engine gets deleted --- test/test_integration.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/test_integration.py b/test/test_integration.py index 3c9b251..aa27134 100644 --- a/test/test_integration.py +++ b/test/test_integration.py @@ -111,10 +111,14 @@ def test_delete_engine(self): def test_delete_engine_wait(self): testEngine = f"python-sdk-del-eng-w-test-{suffix}" - rsp = api.create_engine_wait(ctx, testEngine, headers=custom_headers) + api.create_engine_wait(ctx, testEngine, headers=custom_headers) res = api.delete_engine_wait(ctx, testEngine, headers=custom_headers) self.assertEqual(None, res) + # verifying the engine has been deleted + with self.assertRaises(api.ResourceNotFoundError): + api.get_engine(ctx, testEngine, headers=custom_headers) + if __name__ == '__main__': unittest.main() From 9ac5df6794b503aec607538bd3a0470783ef31fd Mon Sep 17 00:00:00 2001 From: Anatoli Kurtsevich Date: Tue, 18 Jul 2023 16:48:55 +0200 Subject: [PATCH 4/5] converted integration tests for Test API to unit tests --- railib/api.py | 2 +- test/test_integration.py | 36 ----------------- test/test_unit.py | 84 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 37 deletions(-) diff --git a/railib/api.py b/railib/api.py index 6d59eef..80dc440 100644 --- a/railib/api.py +++ b/railib/api.py @@ -439,7 +439,7 @@ def delete_engine(ctx: Context, engine: str, **kwargs) -> Dict: return json.loads(rsp.read()) -def delete_engine_wait(ctx: Context, engine: str, **kwargs) -> bool: +def delete_engine_wait(ctx: Context, engine: str, **kwargs): rsp = delete_engine(ctx, engine, **kwargs) rsp = rsp["status"] diff --git a/test/test_integration.py b/test/test_integration.py index aa27134..043a436 100644 --- a/test/test_integration.py +++ b/test/test_integration.py @@ -83,42 +83,6 @@ def tearDown(self): api.delete_database(ctx, dbname) -class TestEngineAPI(unittest.TestCase): - def test_get_engine(self): - testEngine = f"python-sdk-get-eng-test-{suffix}" - - # ResourceNotFoundError is raised when engine does not exist - with self.assertRaises(api.ResourceNotFoundError): - api.get_engine(ctx, testEngine, headers=custom_headers) - - # an engine with the name is returned when engine exists - api.create_engine_wait(ctx, testEngine, headers=custom_headers) - - rsp = api.get_engine(ctx, testEngine, headers=custom_headers) - self.assertEqual(testEngine, rsp["name"]) - self.assertEqual("PROVISIONED", rsp["state"]) - - api.delete_engine(ctx, testEngine, headers=custom_headers) - - def test_delete_engine(self): - testEngine = f"python-sdk-del-eng-test-{suffix}" - - rsp = api.create_engine_wait(ctx, testEngine, headers=custom_headers) - self.assertEqual("PROVISIONED", rsp["state"]) - rsp = api.delete_engine(ctx, testEngine, headers=custom_headers) - self.assertEqual("DELETING", rsp["status"]["state"]) - - def test_delete_engine_wait(self): - testEngine = f"python-sdk-del-eng-w-test-{suffix}" - - api.create_engine_wait(ctx, testEngine, headers=custom_headers) - res = api.delete_engine_wait(ctx, testEngine, headers=custom_headers) - self.assertEqual(None, res) - - # verifying the engine has been deleted - with self.assertRaises(api.ResourceNotFoundError): - api.get_engine(ctx, testEngine, headers=custom_headers) - if __name__ == '__main__': unittest.main() diff --git a/test/test_unit.py b/test/test_unit.py index cf885d2..ee8c650 100644 --- a/test/test_unit.py +++ b/test/test_unit.py @@ -1,8 +1,12 @@ +import json import unittest +from unittest.mock import patch, MagicMock from railib import api +ctx = MagicMock() + class TestPolling(unittest.TestCase): def test_timeout_exception(self): try: @@ -23,5 +27,85 @@ def test_validation(self): api.poll_with_specified_overhead(lambda: True, overhead_rate=0.1, timeout=1, max_tries=1) +class TestEngineAPI(unittest.TestCase): + @patch('railib.rest.get') + def test_get_engine(self, mock_get): + response_json = { + "computes": [ + { + "name": "test-engine" + } + ] + } + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(response_json).encode() + mock_get.return_value = mock_response + + engine = api.get_engine(ctx, "test-engine") + + self.assertEqual(engine, response_json['computes'][0]) + mock_get.assert_called_once() + + @patch('railib.rest.delete') + def test_delete_engine(self, mock_delete): + response_json = { + "status": { + "name": "test-engine", + "state": api.EngineState.DELETING.value, + "message": "engine \"test-engine\" deleted successfully" + } + } + + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(response_json).encode() + mock_delete.return_value = mock_response + + res = api.delete_engine(ctx, "test-engine") + + self.assertEqual(res, response_json) + mock_delete.assert_called_once() + + @patch('railib.rest.delete') + @patch('railib.rest.get') + def test_delete_engine_wait(self, mock_get, mock_delete): + # mock response for engine delete + response_delete_json = { + "status": { + "name": "test-engine", + "state": "DELETING", + "message": "engine \"test-engine\" deleted successfully" + } + } + mock_response_delete = MagicMock() + mock_response_delete.read.return_value = json.dumps(response_delete_json).encode() + mock_delete.return_value = mock_response_delete + + # mock response for engine get and return an engine in DEPROVISIONING state + response_get_deprovisioning_engine_json = { + "computes": [ + { + "name": "test-engine", + "state": api.EngineState.DEPROVISIONING.value + } + ] + } + mock_response_get_deprovisioning_engine = MagicMock() + mock_response_get_deprovisioning_engine.read.return_value = json.dumps(response_get_deprovisioning_engine_json).encode() + + # mock response for engine get and return empty list as if the engine has been completely deleted + response_get_no_engine_json = { + "computes": [] + } + mock_response_get_no_engine = MagicMock() + mock_response_get_no_engine.read.return_value = json.dumps(response_get_no_engine_json).encode() + + mock_get.side_effect = [mock_response_get_deprovisioning_engine, mock_response_get_no_engine] + + res = api.delete_engine_wait(ctx, "test-engine") + self.assertEqual(res, None) + self.assertEqual(mock_delete.call_count, 1) + self.assertEqual(mock_get.call_count, 2) + + if __name__ == '__main__': unittest.main() From 8e83614e8cbf67f5b137d26513c36f4754f91157 Mon Sep 17 00:00:00 2001 From: Anatoli Kurtsevich Date: Tue, 18 Jul 2023 16:52:06 +0200 Subject: [PATCH 5/5] removed an empty line --- test/test_integration.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/test_integration.py b/test/test_integration.py index 043a436..6a0270e 100644 --- a/test/test_integration.py +++ b/test/test_integration.py @@ -83,6 +83,5 @@ def tearDown(self): api.delete_database(ctx, dbname) - if __name__ == '__main__': unittest.main()