diff --git a/.github/scripts/additional_api_tests.py b/.github/scripts/additional_api_tests.py index 67f0043..08bdebb 100644 --- a/.github/scripts/additional_api_tests.py +++ b/.github/scripts/additional_api_tests.py @@ -9,24 +9,32 @@ import unittest sys.path.append('.') -from zabbix_utils.api import ZabbixAPI, APIVersion +from zabbix_utils.api import ZabbixAPI +from zabbix_utils.types import APIVersion +from zabbix_utils.aioapi import AsyncZabbixAPI + +ZABBIX_URL = 'https://127.0.0.1:443' +ZABBIX_USER = 'Admin' +ZABBIX_PASSWORD = 'zabbix' +HTTP_USER = 'http_user' +HTTP_PASSWORD = 'http_pass' class IntegrationAPITest(unittest.TestCase): - """Test working with a real Zabbix API instance""" + """Test working with a real Zabbix API instance synchronously""" def setUp(self): - self.url = 'https://127.0.0.1:443' - self.user = 'Admin' - self.password = 'zabbix' + self.url = ZABBIX_URL + self.user = ZABBIX_USER + self.password = ZABBIX_PASSWORD self.api = ZabbixAPI( url=self.url, user=self.user, password=self.password, skip_version_check=True, validate_certs=False, - http_user='http_user', - http_password='http_pass' + http_user=HTTP_USER, + http_password=HTTP_PASSWORD ) def tearDown(self): @@ -81,5 +89,79 @@ def test_user_get(self): self.assertEqual(type(users), list, "Request user.get was going wrong") +class IntegrationAsyncAPITest(unittest.IsolatedAsyncioTestCase): + """Test working with a real Zabbix API instance asynchronously""" + + async def asyncSetUp(self): + self.url = ZABBIX_URL + self.user = ZABBIX_USER + self.password = ZABBIX_PASSWORD + self.api = AsyncZabbixAPI( + url=self.url, + skip_version_check=True, + validate_certs=False, + http_user=HTTP_USER, + http_password=HTTP_PASSWORD + ) + await self.api.login( + user=self.user, + password=self.password + ) + + async def asyncTearDown(self): + if self.api: + await self.api.logout() + + async def test_login(self): + """Tests login function works properly""" + + self.assertEqual( + type(self.api), AsyncZabbixAPI, "Login was going wrong") + self.assertEqual( + type(self.api.api_version()), APIVersion, "Version getting was going wrong") + + async def test_basic_auth(self): + """Tests __basic_auth function works properly""" + + basic_auth = self.api.client_session._default_auth + + self.assertEqual( + base64.b64encode(f"{basic_auth.login}:{basic_auth.password}".encode()).decode(), + base64.b64encode(f"{HTTP_USER}:{HTTP_PASSWORD}".encode()).decode(), + "Basic auth credentials generation was going wrong" + ) + + async def test_version_get(self): + """Tests getting version info works properly""" + + version = None + if self.api: + version = await self.api.apiinfo.version() + self.assertEqual( + version, str(self.api.api_version()), "Request apiinfo.version was going wrong") + + async def test_check_auth(self): + """Tests checking authentication state works properly""" + + resp = None + if self.api: + if self.api._AsyncZabbixAPI__session_id == self.api._AsyncZabbixAPI__token: + resp = await self.api.user.checkAuthentication(token=(self.api._AsyncZabbixAPI__session_id or '')) + else: + resp = await self.api.user.checkAuthentication(sessionid=(self.api._AsyncZabbixAPI__session_id or '')) + self.assertEqual( + type(resp), dict, "Request user.checkAuthentication was going wrong") + + async def test_user_get(self): + """Tests getting users info works properly""" + + users = None + if self.api: + users = await self.api.user.get( + output=['userid', 'name'] + ) + self.assertEqual(type(users), list, "Request user.get was going wrong") + + if __name__ == '__main__': unittest.main() diff --git a/.github/scripts/compatibility_api_test_5.py b/.github/scripts/compatibility_api_test_5.py index 33c7cad..b9ef881 100644 --- a/.github/scripts/compatibility_api_test_5.py +++ b/.github/scripts/compatibility_api_test_5.py @@ -9,23 +9,27 @@ import unittest sys.path.append('.') -from zabbix_utils.getter import Getter, AgentResponse -from zabbix_utils.api import ZabbixAPI, APIVersion -from zabbix_utils.sender import ItemValue, Sender, TrapperResponse +from zabbix_utils.api import ZabbixAPI +from zabbix_utils.sender import Sender +from zabbix_utils.getter import Getter +from zabbix_utils.aioapi import AsyncZabbixAPI +from zabbix_utils.aiosender import AsyncSender +from zabbix_utils.aiogetter import AsyncGetter from zabbix_utils.exceptions import APIRequestError, APINotSupported +from zabbix_utils.types import AgentResponse, ItemValue, TrapperResponse, APIVersion -ZABBIX_URL = 'localhost' +ZABBIX_URL = '127.0.0.1' ZABBIX_USER = 'Admin' ZABBIX_PASSWORD = 'zabbix' class CompatibilityAPITest(unittest.TestCase): - """Compatibility test with Zabbix API version 5.0""" + """Compatibility synchronous test with Zabbix API version 5.0""" def setUp(self): - self.url = 'localhost' - self.user = 'Admin' - self.password = 'zabbix' + self.url = ZABBIX_URL + self.user = ZABBIX_USER + self.password = ZABBIX_PASSWORD self.token = 'token' self.zapi = ZabbixAPI( url=self.url @@ -63,7 +67,7 @@ def test_classic_auth(self): with self.assertRaises(APIRequestError, msg="Request user.checkAuthentication after logout was going wrong"): - resp = self.zapi.user.checkAuthentication(sessionid=self.zapi._ZabbixAPI__session_id) + resp = self.zapi.user.checkAuthentication(sessionid=(self.zapi._ZabbixAPI__session_id or '')) def test_token_auth(self): """Tests auth using token""" @@ -74,10 +78,10 @@ def test_token_auth(self): class CompatibilitySenderTest(unittest.TestCase): - """Compatibility test with Zabbix sender version 5.0""" + """Compatibility synchronous test with Zabbix sender version 5.0""" def setUp(self): - self.ip = '127.0.0.1' + self.ip = ZABBIX_URL self.port = 10051 self.chunk_size = 10 self.sender = Sender( @@ -143,7 +147,7 @@ def prepare_items(self): value_type=3 )['itemids'][0] - time.sleep(2) + time.sleep(2) self.assertIsNotNone(hostid, "Creating test item was going wrong") @@ -174,10 +178,10 @@ def test_send_values(self): class CompatibilityGetTest(unittest.TestCase): - """Compatibility test with Zabbix get version 5.0""" + """Compatibility synchronous test with Zabbix get version 5.0""" def setUp(self): - self.host = 'localhost' + self.host = ZABBIX_URL self.port = 10050 self.agent = Getter( host=self.host, @@ -194,5 +198,188 @@ def test_get_values(self): self.assertEqual(type(resp.value), str, "Got value is unexpected") +class CompatibilityAsyncAPITest(unittest.IsolatedAsyncioTestCase): + """Compatibility asynchronous test with Zabbix API version 5.0""" + + async def asyncSetUp(self): + self.url = ZABBIX_URL + self.user = ZABBIX_USER + self.password = ZABBIX_PASSWORD + self.token = 'token' + self.zapi = AsyncZabbixAPI( + url=self.url + ) + + async def asyncTearDown(self): + if self.zapi: + await self.zapi.logout() + + async def test_classic_auth(self): + """Tests auth using username and password""" + + self.assertEqual( + type(self.zapi), AsyncZabbixAPI, "Creating AsyncZabbixAPI object was going wrong") + + self.assertEqual( + type(self.zapi.api_version()), APIVersion, "Version getting was going wrong") + + await self.zapi.login( + user=self.user, + password=self.password + ) + + self.assertIsNotNone(self.zapi._AsyncZabbixAPI__session_id, "Login by user and password was going wrong") + + resp = await self.zapi.user.checkAuthentication(sessionid=self.zapi._AsyncZabbixAPI__session_id) + + self.assertEqual( + type(resp), dict, "Request user.checkAuthentication was going wrong") + + users = await self.zapi.user.get( + output=['userid', 'name'] + ) + self.assertEqual(type(users), list, "Request user.get was going wrong") + + await self.zapi.logout() + + self.assertIsNone(self.zapi._AsyncZabbixAPI__session_id, "Logout was going wrong") + + with self.assertRaises(RuntimeError, + msg="Request user.checkAuthentication after logout was going wrong"): + resp = await self.zapi.user.checkAuthentication(sessionid=(self.zapi._AsyncZabbixAPI__session_id or '')) + + async def test_token_auth(self): + """Tests auth using token""" + + with self.assertRaises(APINotSupported, + msg="Login by token should be not supported"): + await self.zapi.login(token=self.token) + + +class CompatibilityAsyncSenderTest(unittest.IsolatedAsyncioTestCase): + """Compatibility asynchronous test with Zabbix sender version 5.0""" + + async def asyncSetUp(self): + self.ip = ZABBIX_URL + self.port = 10051 + self.chunk_size = 10 + self.sender = AsyncSender( + server=self.ip, + port=self.port, + chunk_size=self.chunk_size + ) + self.hostname = f"{self.__class__.__name__}_host" + self.itemname = f"{self.__class__.__name__}_item" + self.itemkey = f"{self.__class__.__name__}" + await self.prepare_items() + + async def prepare_items(self): + """Creates host and items for sending values later""" + + zapi = AsyncZabbixAPI( + url=ZABBIX_URL, + skip_version_check=True + ) + await zapi.login( + user=ZABBIX_USER, + password=ZABBIX_PASSWORD + ) + + hosts = await zapi.host.get( + filter={'host': self.hostname}, + output=['hostid'] + ) + + hostid = None + if len(hosts) > 0: + hostid = hosts[0].get('hostid') + + if not hostid: + created_host = await zapi.host.create( + host=self.hostname, + interfaces=[{ + "type": 1, + "main": 1, + "useip": 1, + "ip": "127.0.0.1", + "dns": "", + "port": "10050" + }], + groups=[{"groupid": "2"}] + ) + hostid = created_host['hostids'][0] + + self.assertIsNotNone(hostid, "Creating test host was going wrong") + + items = await zapi.item.get( + filter={'key_': self.itemkey}, + output=['itemid'] + ) + + itemid = None + if len(items) > 0: + itemid = items[0].get('itemid') + + if not itemid: + created_item = await zapi.item.create( + name=self.itemname, + key_=self.itemkey, + hostid=hostid, + type=2, + value_type=3 + ) + itemid = created_item['itemids'][0] + + self.assertIsNotNone(hostid, "Creating test item was going wrong") + + await zapi.logout() + + async def test_send_values(self): + """Tests sending item values""" + + time.sleep(2) + + items = [ + ItemValue(self.hostname, self.itemkey, 10), + ItemValue(self.hostname, self.itemkey, 'test message'), + ItemValue(self.hostname, 'item_key1', -1, 1695713666), + ItemValue(self.hostname, 'item_key2', '{"msg":"test message"}'), + ItemValue(self.hostname, self.itemkey, 0, 1695713666, 100), + ItemValue(self.hostname, self.itemkey, 5.5, 1695713666) + ] + resp = await self.sender.send(items) + self.assertEqual(type(resp), TrapperResponse, "Sending item values was going wrong") + self.assertEqual(resp.total, len(items), "Total number of the sent values is unexpected") + self.assertEqual(resp.processed, 4, "Number of the processed values is unexpected") + self.assertEqual(resp.failed, (resp.total - resp.processed), "Number of the failed values is unexpected") + + first_chunk = list(resp.details.values())[0][0] + self.assertEqual(type(first_chunk), TrapperResponse, "Sending item values was going wrong") + self.assertEqual(first_chunk.total, len(items), "Total number of the sent values is unexpected") + self.assertEqual(first_chunk.processed, 4, "Number of the processed values is unexpected") + self.assertEqual(first_chunk.failed, (first_chunk.total - first_chunk.processed), "Number of the failed values is unexpected") + + +class CompatibilityAsyncGetTest(unittest.IsolatedAsyncioTestCase): + """Compatibility asynchronous test with Zabbix get version 5.0""" + + async def asyncSetUp(self): + self.host = ZABBIX_URL + self.port = 10050 + self.agent = AsyncGetter( + host=self.host, + port=self.port + ) + + async def test_get_values(self): + """Tests getting item values""" + + resp = await self.agent.get('system.uname') + + self.assertIsNotNone(resp, "Getting item values was going wrong") + self.assertEqual(type(resp), AgentResponse, "Got value is unexpected") + self.assertEqual(type(resp.value), str, "Got value is unexpected") + + if __name__ == '__main__': unittest.main() diff --git a/.github/scripts/compatibility_api_test_6.py b/.github/scripts/compatibility_api_test_6.py index 03ec550..2219ef8 100644 --- a/.github/scripts/compatibility_api_test_6.py +++ b/.github/scripts/compatibility_api_test_6.py @@ -9,23 +9,27 @@ import unittest sys.path.append('.') -from zabbix_utils.getter import Getter, AgentResponse +from zabbix_utils.api import ZabbixAPI +from zabbix_utils.sender import Sender +from zabbix_utils.getter import Getter +from zabbix_utils.aioapi import AsyncZabbixAPI +from zabbix_utils.aiosender import AsyncSender +from zabbix_utils.aiogetter import AsyncGetter from zabbix_utils.exceptions import APIRequestError -from zabbix_utils.api import ZabbixAPI, APIVersion -from zabbix_utils.sender import ItemValue, Sender, TrapperResponse +from zabbix_utils.types import AgentResponse, ItemValue, TrapperResponse, APIVersion -ZABBIX_URL = 'localhost' +ZABBIX_URL = '127.0.0.1' ZABBIX_USER = 'Admin' ZABBIX_PASSWORD = 'zabbix' class CompatibilityAPITest(unittest.TestCase): - """Compatibility test with Zabbix API version 6.0""" + """Compatibility synchronous test with Zabbix API version 6.0""" def setUp(self): - self.url = 'localhost' - self.user = 'Admin' - self.password = 'zabbix' + self.url = ZABBIX_URL + self.user = ZABBIX_USER + self.password = ZABBIX_PASSWORD self.token_id = None self.token = None self.zapi = ZabbixAPI( @@ -47,7 +51,7 @@ def _create_token(self): password=self.password ) - self.assertIsNotNone(self.zapi._ZabbixAPI__session_id, "Login was going wrong") + self.assertIsNotNone(self.zapi._ZabbixAPI__session_id, "Login by user and password was going wrong") resp = self.zapi.user.checkAuthentication(sessionid=self.zapi._ZabbixAPI__session_id) @@ -79,7 +83,7 @@ def _create_token(self): with self.assertRaises(APIRequestError, msg="Request user.checkAuthentication after logout was going wrong"): - resp = self.zapi.user.checkAuthentication(sessionid=self.zapi._ZabbixAPI__session_id) + resp = self.zapi.user.checkAuthentication(sessionid=(self.zapi._ZabbixAPI__session_id or '')) def test_classic_auth(self): """Tests auth using username and password""" @@ -97,7 +101,7 @@ def test_token_auth(self): self.zapi.login(token=self.token) - self.assertIsNotNone(self.zapi._ZabbixAPI__session_id, "Login was going wrong") + self.assertIsNotNone(self.zapi._ZabbixAPI__session_id, "Login by token was going wrong") resp = self.zapi.user.checkAuthentication(token=self.token) @@ -106,10 +110,10 @@ def test_token_auth(self): class CompatibilitySenderTest(unittest.TestCase): - """Compatibility test with Zabbix sender version 6.0""" + """Compatibility synchronous test with Zabbix sender version 6.0""" def setUp(self): - self.ip = '127.0.0.1' + self.ip = ZABBIX_URL self.port = 10051 self.chunk_size = 10 self.sender = Sender( @@ -175,7 +179,7 @@ def prepare_items(self): value_type=3 )['itemids'][0] - time.sleep(2) + time.sleep(2) self.assertIsNotNone(hostid, "Creating test item was going wrong") @@ -206,10 +210,10 @@ def test_send_values(self): class CompatibilityGetTest(unittest.TestCase): - """Compatibility test with Zabbix get version 6.0""" + """Compatibility synchronous test with Zabbix get version 6.0""" def setUp(self): - self.host = 'localhost' + self.host = ZABBIX_URL self.port = 10050 self.agent = Getter( host=self.host, @@ -226,5 +230,222 @@ def test_get_values(self): self.assertEqual(type(resp.value), str, "Got value is unexpected") +class CompatibilityAsyncAPITest(unittest.IsolatedAsyncioTestCase): + """Compatibility asynchronous test with Zabbix API version 6.0""" + + async def asyncSetUp(self): + self.url = ZABBIX_URL + self.user = ZABBIX_USER + self.password = ZABBIX_PASSWORD + self.token_id = None + self.token = None + self.zapi = AsyncZabbixAPI( + url=self.url + ) + await self._create_token() + + async def asyncTearDown(self): + if self.zapi: + await self.zapi.logout() + + async def _create_token(self): + """Tests auth using username and password""" + + self.assertEqual( + type(self.zapi), AsyncZabbixAPI, "Creating AsyncZabbixAPI object was going wrong") + + self.assertEqual( + type(self.zapi.api_version()), APIVersion, "Version getting was going wrong") + + await self.zapi.login( + user=self.user, + password=self.password + ) + + self.assertIsNotNone(self.zapi._AsyncZabbixAPI__session_id, "Login by user and password was going wrong") + + resp = await self.zapi.user.checkAuthentication(sessionid=self.zapi._AsyncZabbixAPI__session_id) + + self.assertEqual( + type(resp), dict, "Request user.checkAuthentication was going wrong") + + tokens = await self.zapi.token.get( + filter={'name': f"{self.user} [{self.__class__.__name__}]"}, + output=['tokenid'] + ) + + if tokens: + self.token_id = int(tokens[0]['tokenid']) + self.assertEqual( + type(self.token_id), int, "Request token.get was going wrong") + else: + created_token = await self.zapi.token.create( + name=f"{self.user} [{self.__class__.__name__}]" + ) + self.token_id = int(created_token['tokenids'][0]) + self.assertEqual( + type(self.token_id), int, "Request token.create was going wrong") + + generated_token = await self.zapi.token.generate(*[self.token_id]) + self.token = generated_token[0]['token'] + self.assertEqual(type(self.token), str, "Request token.generate was going wrong") + + async def test_classic_auth(self): + """Tests auth using username and password""" + + await self._create_token() + + async def test_token_auth(self): + """Tests auth using token""" + + self.assertEqual( + type(self.zapi), AsyncZabbixAPI, "Creating AsyncZabbixAPI object was going wrong") + + self.assertEqual( + type(self.zapi.api_version()), APIVersion, "Version getting was going wrong") + + await self.zapi.login(token=self.token) + + self.assertIsNotNone(self.zapi._AsyncZabbixAPI__session_id, "Login by token was going wrong") + + resp = await self.zapi.user.checkAuthentication(token=self.token) + + self.assertEqual( + type(resp), dict, "Request user.checkAuthentication was going wrong") + + await self.zapi.logout() + + self.assertIsNone(self.zapi._AsyncZabbixAPI__session_id, "Logout was going wrong") + + with self.assertRaises(RuntimeError, + msg="Request user.checkAuthentication after logout was going wrong"): + resp = await self.zapi.user.checkAuthentication(sessionid=(self.zapi._AsyncZabbixAPI__session_id or '')) + + +class CompatibilityAsyncSenderTest(unittest.IsolatedAsyncioTestCase): + """Compatibility asynchronous test with Zabbix sender version 6.0""" + + async def asyncSetUp(self): + self.ip = ZABBIX_URL + self.port = 10051 + self.chunk_size = 10 + self.sender = AsyncSender( + server=self.ip, + port=self.port, + chunk_size=self.chunk_size + ) + self.hostname = f"{self.__class__.__name__}_host" + self.itemname = f"{self.__class__.__name__}_item" + self.itemkey = f"{self.__class__.__name__}" + await self.prepare_items() + + async def prepare_items(self): + """Creates host and items for sending values later""" + + zapi = AsyncZabbixAPI( + url=ZABBIX_URL, + skip_version_check=True + ) + await zapi.login( + user=ZABBIX_USER, + password=ZABBIX_PASSWORD + ) + + hosts = await zapi.host.get( + filter={'host': self.hostname}, + output=['hostid'] + ) + + hostid = None + if len(hosts) > 0: + hostid = hosts[0].get('hostid') + + if not hostid: + created_host = await zapi.host.create( + host=self.hostname, + interfaces=[{ + "type": 1, + "main": 1, + "useip": 1, + "ip": "127.0.0.1", + "dns": "", + "port": "10050" + }], + groups=[{"groupid": "2"}] + ) + hostid = created_host['hostids'][0] + + self.assertIsNotNone(hostid, "Creating test host was going wrong") + + items = await zapi.item.get( + filter={'key_': self.itemkey}, + output=['itemid'] + ) + + itemid = None + if len(items) > 0: + itemid = items[0].get('itemid') + + if not itemid: + created_item = await zapi.item.create( + name=self.itemname, + key_=self.itemkey, + hostid=hostid, + type=2, + value_type=3 + ) + itemid = created_item['itemids'][0] + + self.assertIsNotNone(hostid, "Creating test item was going wrong") + + await zapi.logout() + + async def test_send_values(self): + """Tests sending item values""" + + time.sleep(2) + + items = [ + ItemValue(self.hostname, self.itemkey, 10), + ItemValue(self.hostname, self.itemkey, 'test message'), + ItemValue(self.hostname, 'item_key1', -1, 1695713666), + ItemValue(self.hostname, 'item_key2', '{"msg":"test message"}'), + ItemValue(self.hostname, self.itemkey, 0, 1695713666, 100), + ItemValue(self.hostname, self.itemkey, 5.5, 1695713666) + ] + resp = await self.sender.send(items) + self.assertEqual(type(resp), TrapperResponse, "Sending item values was going wrong") + self.assertEqual(resp.total, len(items), "Total number of the sent values is unexpected") + self.assertEqual(resp.processed, 4, "Number of the processed values is unexpected") + self.assertEqual(resp.failed, (resp.total - resp.processed), "Number of the failed values is unexpected") + + first_chunk = list(resp.details.values())[0][0] + self.assertEqual(type(first_chunk), TrapperResponse, "Sending item values was going wrong") + self.assertEqual(first_chunk.total, len(items), "Total number of the sent values is unexpected") + self.assertEqual(first_chunk.processed, 4, "Number of the processed values is unexpected") + self.assertEqual(first_chunk.failed, (first_chunk.total - first_chunk.processed), "Number of the failed values is unexpected") + + +class CompatibilityAsyncGetTest(unittest.IsolatedAsyncioTestCase): + """Compatibility asynchronous test with Zabbix get version 6.0""" + + async def asyncSetUp(self): + self.host = ZABBIX_URL + self.port = 10050 + self.agent = AsyncGetter( + host=self.host, + port=self.port + ) + + async def test_get_values(self): + """Tests getting item values""" + + resp = await self.agent.get('system.uname') + + self.assertIsNotNone(resp, "Getting item values was going wrong") + self.assertEqual(type(resp), AgentResponse, "Got value is unexpected") + self.assertEqual(type(resp.value), str, "Got value is unexpected") + + if __name__ == '__main__': unittest.main() diff --git a/.github/scripts/compatibility_api_test_latest.py b/.github/scripts/compatibility_api_test_latest.py index 7101819..48b8da2 100644 --- a/.github/scripts/compatibility_api_test_latest.py +++ b/.github/scripts/compatibility_api_test_latest.py @@ -9,23 +9,27 @@ import unittest sys.path.append('.') -from zabbix_utils.getter import Getter, AgentResponse +from zabbix_utils.api import ZabbixAPI +from zabbix_utils.sender import Sender +from zabbix_utils.getter import Getter +from zabbix_utils.aioapi import AsyncZabbixAPI +from zabbix_utils.aiosender import AsyncSender +from zabbix_utils.aiogetter import AsyncGetter from zabbix_utils.exceptions import APIRequestError -from zabbix_utils.api import ZabbixAPI, APIVersion -from zabbix_utils.sender import ItemValue, Sender, TrapperResponse +from zabbix_utils.types import AgentResponse, ItemValue, TrapperResponse, APIVersion -ZABBIX_URL = 'localhost' +ZABBIX_URL = '127.0.0.1' ZABBIX_USER = 'Admin' ZABBIX_PASSWORD = 'zabbix' class CompatibilityAPITest(unittest.TestCase): - """Compatibility test with the latest Zabbix API version""" + """Compatibility synchronous test with the latest Zabbix API version""" def setUp(self): - self.url = 'localhost' - self.user = 'Admin' - self.password = 'zabbix' + self.url = ZABBIX_URL + self.user = ZABBIX_USER + self.password = ZABBIX_PASSWORD self.token_id = None self.token = None self.zapi = ZabbixAPI( @@ -79,7 +83,7 @@ def _create_token(self): with self.assertRaises(APIRequestError, msg="Request user.checkAuthentication after logout was going wrong"): - resp = self.zapi.user.checkAuthentication(sessionid=self.zapi._ZabbixAPI__session_id) + resp = self.zapi.user.checkAuthentication(sessionid=(self.zapi._ZabbixAPI__session_id or '')) def test_classic_auth(self): """Tests auth using username and password""" @@ -104,24 +108,12 @@ def test_token_auth(self): self.assertEqual( type(resp), dict, "Request user.checkAuthentication was going wrong") - users = self.zapi.user.get( - output=['userid', 'name'] - ) - self.assertEqual(type(users), list, "Request user.get was going wrong") - - self.zapi.logout() - - self.assertIsNone(self.zapi._ZabbixAPI__session_id, "Logout was going wrong") - - self.assertEqual( - type(resp), dict, "Request user.checkAuthentication was going wrong") - class CompatibilitySenderTest(unittest.TestCase): - """Compatibility test with the latest Zabbix sender version""" + """Compatibility synchronous test with the latest Zabbix sender version""" def setUp(self): - self.ip = '127.0.0.1' + self.ip = ZABBIX_URL self.port = 10051 self.chunk_size = 10 self.sender = Sender( @@ -218,10 +210,10 @@ def test_send_values(self): class CompatibilityGetTest(unittest.TestCase): - """Compatibility test with the latest Zabbix get version""" + """Compatibility synchronous test with the latest Zabbix get version""" def setUp(self): - self.host = 'localhost' + self.host = ZABBIX_URL self.port = 10050 self.agent = Getter( host=self.host, @@ -238,5 +230,222 @@ def test_get_values(self): self.assertEqual(type(resp.value), str, "Got value is unexpected") +class CompatibilityAsyncAPITest(unittest.IsolatedAsyncioTestCase): + """Compatibility asynchronous test with the latest Zabbix API version""" + + async def asyncSetUp(self): + self.url = ZABBIX_URL + self.user = ZABBIX_USER + self.password = ZABBIX_PASSWORD + self.token_id = None + self.token = None + self.zapi = AsyncZabbixAPI( + url=self.url + ) + await self._create_token() + + async def asyncTearDown(self): + if self.zapi: + await self.zapi.logout() + + async def _create_token(self): + """Tests auth using username and password""" + + self.assertEqual( + type(self.zapi), AsyncZabbixAPI, "Creating AsyncZabbixAPI object was going wrong") + + self.assertEqual( + type(self.zapi.api_version()), APIVersion, "Version getting was going wrong") + + await self.zapi.login( + user=self.user, + password=self.password + ) + + self.assertIsNotNone(self.zapi._AsyncZabbixAPI__session_id, "Login by user and password was going wrong") + + resp = await self.zapi.user.checkAuthentication(sessionid=self.zapi._AsyncZabbixAPI__session_id) + + self.assertEqual( + type(resp), dict, "Request user.checkAuthentication was going wrong") + + tokens = await self.zapi.token.get( + filter={'name': f"{self.user} [{self.__class__.__name__}]"}, + output=['tokenid'] + ) + + if tokens: + self.token_id = int(tokens[0]['tokenid']) + self.assertEqual( + type(self.token_id), int, "Request token.get was going wrong") + else: + created_token = await self.zapi.token.create( + name=f"{self.user} [{self.__class__.__name__}]" + ) + self.token_id = int(created_token['tokenids'][0]) + self.assertEqual( + type(self.token_id), int, "Request token.create was going wrong") + + generated_token = await self.zapi.token.generate(*[self.token_id]) + self.token = generated_token[0]['token'] + self.assertEqual(type(self.token), str, "Request token.generate was going wrong") + + async def test_classic_auth(self): + """Tests auth using username and password""" + + await self._create_token() + + async def test_token_auth(self): + """Tests auth using token""" + + self.assertEqual( + type(self.zapi), AsyncZabbixAPI, "Creating AsyncZabbixAPI object was going wrong") + + self.assertEqual( + type(self.zapi.api_version()), APIVersion, "Version getting was going wrong") + + await self.zapi.login(token=self.token) + + self.assertIsNotNone(self.zapi._AsyncZabbixAPI__session_id, "Login by token was going wrong") + + resp = await self.zapi.user.checkAuthentication(token=self.token) + + self.assertEqual( + type(resp), dict, "Request user.checkAuthentication was going wrong") + + await self.zapi.logout() + + self.assertIsNone(self.zapi._AsyncZabbixAPI__session_id, "Logout was going wrong") + + with self.assertRaises(RuntimeError, + msg="Request user.checkAuthentication after logout was going wrong"): + resp = await self.zapi.user.checkAuthentication(sessionid=(self.zapi._AsyncZabbixAPI__session_id or '')) + + +class CompatibilityAsyncSenderTest(unittest.IsolatedAsyncioTestCase): + """Compatibility asynchronous test with the latest Zabbix sender version""" + + async def asyncSetUp(self): + self.ip = ZABBIX_URL + self.port = 10051 + self.chunk_size = 10 + self.sender = AsyncSender( + server=self.ip, + port=self.port, + chunk_size=self.chunk_size + ) + self.hostname = f"{self.__class__.__name__}_host" + self.itemname = f"{self.__class__.__name__}_item" + self.itemkey = f"{self.__class__.__name__}" + await self.prepare_items() + + async def prepare_items(self): + """Creates host and items for sending values later""" + + zapi = AsyncZabbixAPI( + url=ZABBIX_URL, + skip_version_check=True + ) + await zapi.login( + user=ZABBIX_USER, + password=ZABBIX_PASSWORD + ) + + hosts = await zapi.host.get( + filter={'host': self.hostname}, + output=['hostid'] + ) + + hostid = None + if len(hosts) > 0: + hostid = hosts[0].get('hostid') + + if not hostid: + created_host = await zapi.host.create( + host=self.hostname, + interfaces=[{ + "type": 1, + "main": 1, + "useip": 1, + "ip": "127.0.0.1", + "dns": "", + "port": "10050" + }], + groups=[{"groupid": "2"}] + ) + hostid = created_host['hostids'][0] + + self.assertIsNotNone(hostid, "Creating test host was going wrong") + + items = await zapi.item.get( + filter={'key_': self.itemkey}, + output=['itemid'] + ) + + itemid = None + if len(items) > 0: + itemid = items[0].get('itemid') + + if not itemid: + created_item = await zapi.item.create( + name=self.itemname, + key_=self.itemkey, + hostid=hostid, + type=2, + value_type=3 + ) + itemid = created_item['itemids'][0] + + self.assertIsNotNone(hostid, "Creating test item was going wrong") + + await zapi.logout() + + async def test_send_values(self): + """Tests sending item values""" + + time.sleep(2) + + items = [ + ItemValue(self.hostname, self.itemkey, 10), + ItemValue(self.hostname, self.itemkey, 'test message'), + ItemValue(self.hostname, 'item_key1', -1, 1695713666), + ItemValue(self.hostname, 'item_key2', '{"msg":"test message"}'), + ItemValue(self.hostname, self.itemkey, 0, 1695713666, 100), + ItemValue(self.hostname, self.itemkey, 5.5, 1695713666) + ] + resp = await self.sender.send(items) + self.assertEqual(type(resp), TrapperResponse, "Sending item values was going wrong") + self.assertEqual(resp.total, len(items), "Total number of the sent values is unexpected") + self.assertEqual(resp.processed, 4, "Number of the processed values is unexpected") + self.assertEqual(resp.failed, (resp.total - resp.processed), "Number of the failed values is unexpected") + + first_chunk = list(resp.details.values())[0][0] + self.assertEqual(type(first_chunk), TrapperResponse, "Sending item values was going wrong") + self.assertEqual(first_chunk.total, len(items), "Total number of the sent values is unexpected") + self.assertEqual(first_chunk.processed, 4, "Number of the processed values is unexpected") + self.assertEqual(first_chunk.failed, (first_chunk.total - first_chunk.processed), "Number of the failed values is unexpected") + + +class CompatibilityAsyncGetTest(unittest.IsolatedAsyncioTestCase): + """Compatibility asynchronous test with the latest Zabbix get version""" + + async def asyncSetUp(self): + self.host = ZABBIX_URL + self.port = 10050 + self.agent = AsyncGetter( + host=self.host, + port=self.port + ) + + async def test_get_values(self): + """Tests getting item values""" + + resp = await self.agent.get('system.uname') + + self.assertIsNotNone(resp, "Getting item values was going wrong") + self.assertEqual(type(resp), AgentResponse, "Got value is unexpected") + self.assertEqual(type(resp.value), str, "Got value is unexpected") + + if __name__ == '__main__': unittest.main() diff --git a/.github/scripts/integration_aioapi_test.py b/.github/scripts/integration_aioapi_test.py new file mode 100644 index 0000000..c0d2427 --- /dev/null +++ b/.github/scripts/integration_aioapi_test.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file under the MIT License. +# See the LICENSE file in the project root for more information. + +import sys +import unittest + +sys.path.append('.') +from zabbix_utils.aioapi import AsyncZabbixAPI +from zabbix_utils.types import APIVersion + + +class IntegrationAPITest(unittest.IsolatedAsyncioTestCase): + """Test working with a real Zabbix API instance""" + + async def asyncSetUp(self): + self.url = 'localhost' + self.user = 'Admin' + self.password = 'zabbix' + self.zapi = AsyncZabbixAPI( + url=self.url, + skip_version_check=True + ) + await self.zapi.login( + user=self.user, + password=self.password + ) + + async def asyncTearDown(self): + if self.zapi: + await self.zapi.logout() + + async def test_login(self): + """Tests login function works properly""" + + self.assertEqual( + type(self.zapi), AsyncZabbixAPI, "Login was going wrong") + self.assertEqual( + type(self.zapi.api_version()), APIVersion, "Version getting was going wrong") + + await self.zapi.logout() + + async def test_version_get(self): + """Tests getting version info works properly""" + + version = None + if self.zapi: + version = await self.zapi.apiinfo.version() + self.assertEqual( + version, str(self.zapi.api_version()), "Request apiinfo.version was going wrong") + + async def test_check_auth(self): + """Tests checking authentication state works properly""" + + resp = None + if self.zapi: + if self.zapi._AsyncZabbixAPI__session_id == self.zapi._AsyncZabbixAPI__token: + resp = await self.zapi.user.checkAuthentication(token=self.zapi._AsyncZabbixAPI__session_id) + else: + resp = await self.zapi.user.checkAuthentication(sessionid=self.zapi._AsyncZabbixAPI__session_id) + self.assertEqual( + type(resp), dict, "Request user.checkAuthentication was going wrong") + + async def test_user_get(self): + """Tests getting users info works properly""" + + users = None + if self.zapi: + users = await self.zapi.user.get( + output=['userid', 'name'] + ) + self.assertEqual(type(users), list, "Request user.get was going wrong") + + async def test_host_get(self): + """Tests getting hosts info works properly using suffix""" + + hosts = None + if self.zapi: + hosts = await self.zapi.host_.get_( + output=['hostid', 'host'] + ) + self.assertEqual(type(hosts), list, "Request host.get was going wrong") + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/integration_aiogetter_test.py b/.github/scripts/integration_aiogetter_test.py new file mode 100644 index 0000000..4bf8f36 --- /dev/null +++ b/.github/scripts/integration_aiogetter_test.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file under the MIT License. +# See the LICENSE file in the project root for more information. + +import sys +import json +import unittest + +sys.path.append('.') +from zabbix_utils.aiogetter import AsyncGetter + + +class IntegrationGetTest(unittest.IsolatedAsyncioTestCase): + """Test working with a real Zabbix agent instance""" + + async def asyncSetUp(self): + self.host = '127.0.0.1' + self.port = 10050 + self.agent = AsyncGetter( + host=self.host, + port=self.port + ) + + async def test_get(self): + """Tests getting item values from Zabbix agent works properly""" + + resp = await self.agent.get('net.if.discovery') + + self.assertIsNotNone(resp, "Getting item values was going wrong") + try: + resp_list = json.loads(resp.value) + except json.decoder.JSONDecodeError: + self.fail(f"raised unexpected Exception while parsing response: {resp}") + + self.assertEqual(type(resp_list), list, "Getting item values was going wrong") + for resp in resp_list: + self.assertEqual(type(resp), dict, "Getting item values was going wrong") + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/integration_aiosender_test.py b/.github/scripts/integration_aiosender_test.py new file mode 100644 index 0000000..9c8a064 --- /dev/null +++ b/.github/scripts/integration_aiosender_test.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file under the MIT License. +# See the LICENSE file in the project root for more information. + +import sys +import unittest + +sys.path.append('.') +from zabbix_utils.aiosender import AsyncSender +from zabbix_utils.types import ItemValue, TrapperResponse, Node + + +class IntegrationSenderTest(unittest.IsolatedAsyncioTestCase): + """Test working with a real Zabbix server/proxy instance""" + + async def asyncSetUp(self): + self.ip = '127.0.0.1' + self.port = 10051 + self.chunk_size = 10 + self.sender = AsyncSender( + server=self.ip, + port=self.port, + chunk_size=self.chunk_size + ) + + async def test_send(self): + """Tests sending item values works properly""" + + items = [ + ItemValue('host1', 'item.key1', 10), + ItemValue('host1', 'item.key2', 'test message'), + ItemValue('host2', 'item.key1', -1, 1695713666), + ItemValue('host3', 'item.key1', '{"msg":"test message"}'), + ItemValue('host2', 'item.key1', 0, 1695713666, 100) + ] + response = await self.sender.send(items) + + self.assertEqual(type(response.details), dict, "Sending item values was going wrong") + for node, resp in response.details.items(): + self.assertEqual(type(node), Node, "Sending item values was going wrong") + for item in resp: + self.assertEqual(type(item), TrapperResponse, "Sending item values was going wrong") + for key in ('processed', 'failed', 'total', 'time', 'chunk'): + try: + self.assertIsNotNone(getattr(item, key), f"There aren't expected '{key}' value") + except AttributeError: + self.fail(f"raised unexpected Exception for attribute: {key}") + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/integration_api_test.py b/.github/scripts/integration_api_test.py index e0041a5..e0a6f38 100644 --- a/.github/scripts/integration_api_test.py +++ b/.github/scripts/integration_api_test.py @@ -8,7 +8,8 @@ import unittest sys.path.append('.') -from zabbix_utils.api import ZabbixAPI, APIVersion +from zabbix_utils.api import ZabbixAPI +from zabbix_utils.types import APIVersion class IntegrationAPITest(unittest.TestCase): diff --git a/.github/scripts/integration_get_test.py b/.github/scripts/integration_getter_test.py similarity index 100% rename from .github/scripts/integration_get_test.py rename to .github/scripts/integration_getter_test.py diff --git a/.github/scripts/integration_sender_test.py b/.github/scripts/integration_sender_test.py index 9ec3a7f..8a6be60 100644 --- a/.github/scripts/integration_sender_test.py +++ b/.github/scripts/integration_sender_test.py @@ -8,7 +8,8 @@ import unittest sys.path.append('.') -from zabbix_utils.sender import ItemValue, Sender, TrapperResponse, Node +from zabbix_utils.sender import Sender +from zabbix_utils.types import ItemValue, TrapperResponse, Node class IntegrationSenderTest(unittest.TestCase): diff --git a/.github/scripts/library_import_tests.sh b/.github/scripts/library_import_tests.sh new file mode 100644 index 0000000..1de8649 --- /dev/null +++ b/.github/scripts/library_import_tests.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +class=$1 +error=$2 + +result=$(python3 -c "import sys; sys.path.append('.'); from zabbix_utils import $class; $class()" 2>&1) +echo "$result" | grep "$error" >/dev/null || echo "$result" | (python3 "./.github/scripts/telegram_msg.py" && echo "Error") diff --git a/.github/scripts/telegram_msg.py b/.github/scripts/telegram_msg.py index 3402ba4..faf9767 100644 --- a/.github/scripts/telegram_msg.py +++ b/.github/scripts/telegram_msg.py @@ -4,14 +4,14 @@ # Zabbix SIA licenses this file under the MIT License. # See the LICENSE file in the project root for more information. -import requests -import sys import os +import sys import json +import requests -chat_id = os.environ.get("TBOT_CHAT") # chat id. env TBOT_CHAT must be set! -token = os.environ.get("TBOT_TOKEN") # bot token. env TBOT_TOKEN must be set! -parse_mode = 'HTML' # HTML, MarkdownV2 or empty +chat_id = os.environ.get("TBOT_CHAT") # chat id. env TBOT_CHAT must be set! +token = os.environ.get("TBOT_TOKEN") # bot token. env TBOT_TOKEN must be set! +parse_mode = os.environ.get("TBOT_FORMAT", '') # HTML, MarkdownV2 or empty for key in ["TBOT_CHAT", "TBOT_TOKEN"]: if not os.environ.get(key): diff --git a/.github/workflows/additional_tests.yaml b/.github/workflows/additional_tests.yaml index 6bd161b..e83a1b2 100644 --- a/.github/workflows/additional_tests.yaml +++ b/.github/workflows/additional_tests.yaml @@ -1,5 +1,5 @@ name: additional_tests -run-name: Run additional tests for API features +run-name: Additional tests for API features on: push: @@ -14,7 +14,50 @@ env: TEST_FILE: additional_api_tests.py jobs: - build: + importing-tests: + name: Importing tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Install Python + run: | + sudo apt update && sudo apt-get install -y python3 python3-pip python-is-python3 + - name: Prepare environment + run: | + touch /tmp/importing.log + - name: Check import of sync without requirements + continue-on-error: true + env: + TBOT_TOKEN: ${{ secrets.TBOT_TOKEN }} + TBOT_CHAT: ${{ vars.TBOT_CHAT }} + SUBJECT: Importing test without requirements FAIL + run: | + bash ./.github/scripts/library_import_tests.sh "ZabbixAPI" "Unable to connect to" > /tmp/importing.log + - name: Check import of async without requirements + continue-on-error: true + env: + TBOT_TOKEN: ${{ secrets.TBOT_TOKEN }} + TBOT_CHAT: ${{ vars.TBOT_CHAT }} + SUBJECT: Importing test without requirements FAIL + run: | + bash ./.github/scripts/library_import_tests.sh "AsyncZabbixAPI" "ModuleNotFoundError:" > /tmp/importing.log + - name: Install requirements + run: | + pip install -r ./requirements.txt + - name: Check import of async with requirements + continue-on-error: true + env: + TBOT_TOKEN: ${{ secrets.TBOT_TOKEN }} + TBOT_CHAT: ${{ vars.TBOT_CHAT }} + SUBJECT: Importing tests with requirements FAIL + run: | + bash ./.github/scripts/library_import_tests.sh "AsyncZabbixAPI" "aiohttp.client.ClientSession" > /tmp/importing.log + - name: Raise an exception + run: | + test $(cat /tmp/importing.log | wc -l) -eq 0 || exit 1 + additional-tests: + name: Additional tests runs-on: ubuntu-latest steps: @@ -65,23 +108,24 @@ jobs: sudo -u postgres createdb -O zabbix -E Unicode -T template0 zabbix cat schema.sql | sudo -u zabbix psql zabbix cat images.sql | sudo -u zabbix psql zabbix - cat data.sql | sudo -u zabbix psql zabbix + cat data.sql | sudo -u zabbix psql zabbix - name: Start Apache & Nginx run: | sudo apache2ctl start sudo nginx -g "daemon on; master_process on;" - name: Install python3 - run: | + run: | sudo apt-get install -y python3 python3-pip python-is-python3 - pip install typing-extensions>=4.0.0 + pip install -r ./requirements.txt - name: Additional tests continue-on-error: true run: | - sleep 5 - python ./.github/scripts/$TEST_FILE 2>/tmp/additional.log >/dev/null + sleep 5 + python ./.github/scripts/$TEST_FILE 2>/tmp/additional.log >/dev/null - name: Send report env: TBOT_TOKEN: ${{ secrets.TBOT_TOKEN }} TBOT_CHAT: ${{ vars.TBOT_CHAT }} - SUBJECT: Zabbix API integration test FAIL - run: tail -n1 /tmp/additional.log | grep "OK" 1>/dev/null || tail /tmp/additional.log | python ./.github/scripts/telegram_msg.py | exit 1 + SUBJECT: Zabbix API additional tests FAIL + run: | + tail -n1 /tmp/additional.log | grep "OK" 1>/dev/null || tail /tmp/additional.log | python ./.github/scripts/telegram_msg.py | exit 1 diff --git a/.github/workflows/check_new_release.yaml b/.github/workflows/check_new_release.yaml index 1b868c8..fe3e9b8 100644 --- a/.github/workflows/check_new_release.yaml +++ b/.github/workflows/check_new_release.yaml @@ -7,24 +7,28 @@ on: workflow_dispatch: jobs: - build-linux: + check-release: + name: Check Zabbix release runs-on: ubuntu-latest + steps: - uses: actions/checkout@v4 - name: Prepare environment run: | - sudo apt-get install -y python3 python3-pip python-is-python3 - pip install typing-extensions>=4.0.0 + sudo apt-get install -y python3 python3-pip python-is-python3 + pip install -r ./requirements.txt - name: Check new Zabbix release env: BRANCHES_URL: ${{ vars.BRANCHES_URL }} LIBREPO_URL: ${{ vars.LIBREPO_URL }} MANUAL_REPO: ${{ vars.MANUAL_REPO }} run: | - python ./.github/scripts/check_new_zabbx_release.py 2>/tmp/check_release.log || echo + python ./.github/scripts/check_new_zabbx_release.py 2>/tmp/check_release.log || echo - name: Send report env: TBOT_TOKEN: ${{ secrets.TBOT_TOKEN }} TBOT_CHAT: ${{ vars.TBOT_CHAT }} + TBOT_FORMAT: html SUBJECT: zabbix_utils repo requires update due new Zabbix release - run: tail /tmp/check_release.log | python ./.github/scripts/telegram_msg.py + run: | + tail /tmp/check_release.log | python ./.github/scripts/telegram_msg.py diff --git a/.github/workflows/compatibility_50.yaml b/.github/workflows/compatibility_50.yaml index abe4d92..d52c882 100644 --- a/.github/workflows/compatibility_50.yaml +++ b/.github/workflows/compatibility_50.yaml @@ -1,5 +1,5 @@ name: zabbix_50 -run-name: Run compatibility with Zabbix 5.0 test +run-name: Compatibility with Zabbix 5.0 test on: push: @@ -15,7 +15,8 @@ env: TEST_FILE: compatibility_api_test_5.py jobs: - build: + compatibility: + name: Compatibility test runs-on: ubuntu-latest steps: @@ -24,7 +25,7 @@ jobs: run: | curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list - sudo apt update && sudo apt install -y git sudo gcc make automake pkg-config postgresql-13 libpostgresql-ocaml-dev libxml2-dev libpcre3-dev libevent-dev apache2 libapache2-mod-php php8.1-pgsql php8.1-bcmath php8.1-xml php8.1-gd php8.1-ldap php8.1-mbstring libzip-dev + sudo apt update && sudo apt install -y git sudo gcc make automake pkg-config postgresql-13 libpostgresql-ocaml-dev libxml2-dev libpcre3-dev libevent-dev apache2 libapache2-mod-php php8.1-pgsql php8.1-bcmath php8.1-xml php8.1-gd php8.1-ldap php8.1-mbstring libzip-dev - name: Build from sources run: | WORKDIR=$(pwd) @@ -63,30 +64,31 @@ jobs: sudo -u postgres createdb -O zabbix -E Unicode -T template0 zabbix cat schema.sql | sudo -u zabbix psql zabbix cat images.sql | sudo -u zabbix psql zabbix - cat data.sql | sudo -u zabbix psql zabbix + cat data.sql | sudo -u zabbix psql zabbix sudo apache2ctl start - name: Start Zabbix server run: | - cd /tmp/zabbix-branch + cd /tmp/zabbix-branch sudo ./src/zabbix_server/zabbix_server -c ./conf/zabbix_server.conf - name: Start Zabbix agent run: | - cd /tmp/zabbix-branch + cd /tmp/zabbix-branch sudo ./src/zabbix_agent/zabbix_agentd -c ./conf/zabbix_agentd.conf - name: Install python3 - run: | + run: | sudo apt-get install -y python3 python3-pip python-is-python3 - pip install typing-extensions>=4.0.0 + pip install -r ./requirements.txt - name: Wait for Zabbix API run: | python ./.github/scripts/wait_instance_zabbix.py - name: Compatibility test continue-on-error: true run: | - python ./.github/scripts/$TEST_FILE 2>/tmp/compatibility.log >/dev/null + python ./.github/scripts/$TEST_FILE 2>/tmp/compatibility.log >/dev/null - name: Send report env: TBOT_TOKEN: ${{ secrets.TBOT_TOKEN }} TBOT_CHAT: ${{ vars.TBOT_CHAT }} SUBJECT: Compatibility with Zabbix ${{ env.ZABBIX_VERSION }} FAIL - run: tail -n1 /tmp/compatibility.log | grep "OK" 1>/dev/null || tail /tmp/compatibility.log | python ./.github/scripts/telegram_msg.py + run: | + tail -n1 /tmp/compatibility.log | grep "OK" 1>/dev/null || tail /tmp/compatibility.log | python ./.github/scripts/telegram_msg.py | exit 1 diff --git a/.github/workflows/compatibility_60.yaml b/.github/workflows/compatibility_60.yaml index ddc4304..2ce68ba 100644 --- a/.github/workflows/compatibility_60.yaml +++ b/.github/workflows/compatibility_60.yaml @@ -1,5 +1,5 @@ name: zabbix_60 -run-name: Run compatibility with Zabbix 6.0 test +run-name: Compatibility with Zabbix 6.0 test on: push: @@ -15,14 +15,15 @@ env: TEST_FILE: compatibility_api_test_6.py jobs: - build: + compatibility: + name: Compatibility test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install packages run: | - sudo apt update && sudo apt install -y git sudo gcc make automake pkg-config postgresql-14 libpostgresql-ocaml-dev libxml2-dev libpcre3-dev libevent-dev apache2 libapache2-mod-php php8.1-pgsql php8.1-bcmath php8.1-xml php8.1-gd php8.1-ldap php8.1-mbstring libzip-dev + sudo apt update && sudo apt install -y git sudo gcc make automake pkg-config postgresql-14 libpostgresql-ocaml-dev libxml2-dev libpcre3-dev libevent-dev apache2 libapache2-mod-php php8.1-pgsql php8.1-bcmath php8.1-xml php8.1-gd php8.1-ldap php8.1-mbstring libzip-dev - name: Build from sources run: | WORKDIR=$(pwd) @@ -59,30 +60,31 @@ jobs: sudo -u postgres createdb -O zabbix -E Unicode -T template0 zabbix cat schema.sql | sudo -u zabbix psql zabbix cat images.sql | sudo -u zabbix psql zabbix - cat data.sql | sudo -u zabbix psql zabbix + cat data.sql | sudo -u zabbix psql zabbix sudo apache2ctl start - name: Start Zabbix server run: | - cd /tmp/zabbix-branch + cd /tmp/zabbix-branch sudo ./src/zabbix_server/zabbix_server -c ./conf/zabbix_server.conf - name: Start Zabbix agent run: | - cd /tmp/zabbix-branch + cd /tmp/zabbix-branch sudo ./src/zabbix_agent/zabbix_agentd -c ./conf/zabbix_agentd.conf - name: Install python3 - run: | + run: | sudo apt-get install -y python3 python3-pip python-is-python3 - pip install typing-extensions>=4.0.0 + pip install -r ./requirements.txt - name: Wait for Zabbix API run: | python ./.github/scripts/wait_instance_zabbix.py - name: Compatibility test continue-on-error: true run: | - python ./.github/scripts/$TEST_FILE 2>/tmp/compatibility.log >/dev/null + python ./.github/scripts/$TEST_FILE 2>/tmp/compatibility.log >/dev/null - name: Send report env: TBOT_TOKEN: ${{ secrets.TBOT_TOKEN }} TBOT_CHAT: ${{ vars.TBOT_CHAT }} SUBJECT: Compatibility with Zabbix ${{ env.ZABBIX_VERSION }} FAIL - run: tail -n1 /tmp/compatibility.log | grep "OK" 1>/dev/null || tail /tmp/compatibility.log | python ./.github/scripts/telegram_msg.py + run: | + tail -n1 /tmp/compatibility.log | grep "OK" 1>/dev/null || tail /tmp/compatibility.log | python ./.github/scripts/telegram_msg.py | exit 1 diff --git a/.github/workflows/compatibility_64.yaml b/.github/workflows/compatibility_64.yaml index d53fea4..e26b192 100644 --- a/.github/workflows/compatibility_64.yaml +++ b/.github/workflows/compatibility_64.yaml @@ -1,5 +1,5 @@ name: zabbix_64 -run-name: Run compatibility with Zabbix 6.4 test +run-name: Compatibility with Zabbix 6.4 test on: push: @@ -15,14 +15,15 @@ env: TEST_FILE: compatibility_api_test_6.py jobs: - build: + compatibility: + name: Compatibility test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install packages run: | - sudo apt update && sudo apt install -y git sudo gcc make automake pkg-config postgresql-14 libpostgresql-ocaml-dev libxml2-dev libpcre3-dev libevent-dev apache2 libapache2-mod-php php8.1-pgsql php8.1-bcmath php8.1-xml php8.1-gd php8.1-ldap php8.1-mbstring libzip-dev + sudo apt update && sudo apt install -y git sudo gcc make automake pkg-config postgresql-14 libpostgresql-ocaml-dev libxml2-dev libpcre3-dev libevent-dev apache2 libapache2-mod-php php8.1-pgsql php8.1-bcmath php8.1-xml php8.1-gd php8.1-ldap php8.1-mbstring libzip-dev - name: Build from sources run: | WORKDIR=$(pwd) @@ -59,30 +60,31 @@ jobs: sudo -u postgres createdb -O zabbix -E Unicode -T template0 zabbix cat schema.sql | sudo -u zabbix psql zabbix cat images.sql | sudo -u zabbix psql zabbix - cat data.sql | sudo -u zabbix psql zabbix + cat data.sql | sudo -u zabbix psql zabbix sudo apache2ctl start - name: Start Zabbix server run: | - cd /tmp/zabbix-branch + cd /tmp/zabbix-branch sudo ./src/zabbix_server/zabbix_server -c ./conf/zabbix_server.conf - name: Start Zabbix agent run: | - cd /tmp/zabbix-branch + cd /tmp/zabbix-branch sudo ./src/zabbix_agent/zabbix_agentd -c ./conf/zabbix_agentd.conf - name: Install python3 - run: | + run: | sudo apt-get install -y python3 python3-pip python-is-python3 - pip install typing-extensions>=4.0.0 + pip install -r ./requirements.txt - name: Wait for Zabbix API run: | python ./.github/scripts/wait_instance_zabbix.py - name: Compatibility test continue-on-error: true run: | - python ./.github/scripts/$TEST_FILE 2>/tmp/compatibility.log >/dev/null + python ./.github/scripts/$TEST_FILE 2>/tmp/compatibility.log >/dev/null - name: Send report env: TBOT_TOKEN: ${{ secrets.TBOT_TOKEN }} TBOT_CHAT: ${{ vars.TBOT_CHAT }} SUBJECT: Compatibility with Zabbix ${{ env.ZABBIX_VERSION }} FAIL - run: tail -n1 /tmp/compatibility.log | grep "OK" 1>/dev/null || tail /tmp/compatibility.log | python ./.github/scripts/telegram_msg.py + run: | + tail -n1 /tmp/compatibility.log | grep "OK" 1>/dev/null || tail /tmp/compatibility.log | python ./.github/scripts/telegram_msg.py | exit 1 diff --git a/.github/workflows/compatibility_latest.yaml b/.github/workflows/compatibility_latest.yaml index 985f0b3..2cc3135 100644 --- a/.github/workflows/compatibility_latest.yaml +++ b/.github/workflows/compatibility_latest.yaml @@ -1,5 +1,5 @@ name: zabbix_latest -run-name: Run compatibility with the latest Zabbix version test +run-name: Compatibility with the latest Zabbix version test on: schedule: - cron: "0 1 * * *" @@ -12,14 +12,15 @@ env: TEST_FILE: compatibility_api_test_latest.py jobs: - build: + compatibility: + name: Compatibility test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install packages run: | - sudo apt update && sudo apt install -y git sudo gcc make automake pkg-config postgresql-14 libpostgresql-ocaml-dev libxml2-dev libpcre3-dev libevent-dev apache2 libapache2-mod-php php8.1-pgsql php8.1-bcmath php8.1-xml php8.1-gd php8.1-ldap php8.1-mbstring libzip-dev + sudo apt update && sudo apt install -y git sudo gcc make automake pkg-config postgresql-14 libpostgresql-ocaml-dev libxml2-dev libpcre3-dev libevent-dev apache2 libapache2-mod-php php8.1-pgsql php8.1-bcmath php8.1-xml php8.1-gd php8.1-ldap php8.1-mbstring libzip-dev - name: Build from sources run: | WORKDIR=$(pwd) @@ -56,7 +57,7 @@ jobs: sudo -u postgres createdb -O zabbix -E Unicode -T template0 zabbix cat schema.sql | sudo -u zabbix psql zabbix cat images.sql | sudo -u zabbix psql zabbix - cat data.sql | sudo -u zabbix psql zabbix + cat data.sql | sudo -u zabbix psql zabbix sudo apache2ctl start - name: Start Zabbix server run: | @@ -64,22 +65,27 @@ jobs: sudo ./src/zabbix_server/zabbix_server -c ./conf/zabbix_server.conf - name: Start Zabbix agent run: | - cd /tmp/zabbix-branch + cd /tmp/zabbix-branch sudo ./src/zabbix_agent/zabbix_agentd -c ./conf/zabbix_agentd.conf - name: Install python3 - run: | + run: | sudo apt-get install -y python3 python3-pip python-is-python3 - pip install typing-extensions>=4.0.0 + pip install -r ./requirements.txt - name: Wait for Zabbix API run: | python ./.github/scripts/wait_instance_zabbix.py + - name: Print Zabbix version + continue-on-error: true + run: | + grep -Po "(?<=Changes for ).*$" /tmp/zabbix-branch/ChangeLog 2>/dev/null | head -n1 - name: Compatibility test continue-on-error: true run: | - python ./.github/scripts/$TEST_FILE 2>/tmp/compatibility.log >/dev/null + python ./.github/scripts/$TEST_FILE 2>/tmp/compatibility.log >/dev/null - name: Send report env: TBOT_TOKEN: ${{ secrets.TBOT_TOKEN }} TBOT_CHAT: ${{ vars.TBOT_CHAT }} SUBJECT: Compatibility with Zabbix ${{ env.ZABBIX_VERSION }} FAIL - run: tail -n1 /tmp/compatibility.log | grep "OK" 1>/dev/null || tail /tmp/compatibility.log | python ./.github/scripts/telegram_msg.py + run: | + tail -n1 /tmp/compatibility.log | grep "OK" 1>/dev/null || tail /tmp/compatibility.log | python ./.github/scripts/telegram_msg.py | exit 1 diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 5b97c68..6601a51 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -9,17 +9,20 @@ on: workflow_dispatch: jobs: - build-linux: + coverage: + name: Check coverage runs-on: ubuntu-latest + steps: - uses: actions/checkout@v4 - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.10" - name: Install dependencies run: | python -m pip install --upgrade pip + pip install -r ./requirements.txt pip install coverage - name: Test with coverage run: | diff --git a/.github/workflows/integration_api.yaml b/.github/workflows/integration_api.yaml index 056f3f8..6660097 100644 --- a/.github/workflows/integration_api.yaml +++ b/.github/workflows/integration_api.yaml @@ -1,5 +1,5 @@ name: api -run-name: Run Zabbix API integration test +run-name: Zabbix API integration test on: push: @@ -15,17 +15,19 @@ on: env: ZABBIX_BRANCH: master CONFIG_PATH: .github/configs/ - TEST_FILE: integration_api_test.py + SYNC_FILE: integration_api_test.py + ASYNC_FILE: integration_aioapi_test.py jobs: - build: + integration: + name: Integration test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install packages run: | - sudo apt update && sudo apt install -y git sudo gcc make automake pkg-config postgresql-14 libpostgresql-ocaml-dev libxml2-dev libpcre3-dev libevent-dev apache2 libapache2-mod-php php8.1-pgsql php8.1-bcmath php8.1-xml php8.1-gd php8.1-ldap php8.1-mbstring libzip-dev + sudo apt update && sudo apt install -y git sudo gcc make automake pkg-config postgresql-14 libpostgresql-ocaml-dev libxml2-dev libpcre3-dev libevent-dev apache2 libapache2-mod-php php8.1-pgsql php8.1-bcmath php8.1-xml php8.1-gd php8.1-ldap php8.1-mbstring libzip-dev - name: Build from sources run: | WORKDIR=$(pwd) @@ -61,22 +63,35 @@ jobs: sudo -u postgres createdb -O zabbix -E Unicode -T template0 zabbix cat schema.sql | sudo -u zabbix psql zabbix cat images.sql | sudo -u zabbix psql zabbix - cat data.sql | sudo -u zabbix psql zabbix + cat data.sql | sudo -u zabbix psql zabbix sudo apache2ctl start - name: Install python3 - run: | + run: | sudo apt-get install -y python3 python3-pip python-is-python3 - pip install typing-extensions>=4.0.0 + pip install -r ./requirements.txt - name: Wait for Zabbix API run: | python ./.github/scripts/wait_instance_zabbix.py - - name: Integration test + - name: Print Zabbix version + continue-on-error: true + run: | + grep -Po "(?<=Changes for ).*$" /tmp/zabbix-branch/ChangeLog 2>/dev/null | head -n1 + - name: Integration synchronous test continue-on-error: true run: | - python ./.github/scripts/$TEST_FILE 2>/tmp/integration.log >/dev/null + python ./.github/scripts/$SYNC_FILE 2>/tmp/integration_sync.log >/dev/null + - name: Integration asynchronous test + continue-on-error: true + run: | + python ./.github/scripts/$ASYNC_FILE 2>/tmp/integration_async.log >/dev/null - name: Send report env: TBOT_TOKEN: ${{ secrets.TBOT_TOKEN }} TBOT_CHAT: ${{ vars.TBOT_CHAT }} SUBJECT: Zabbix API integration test FAIL - run: tail -n1 /tmp/integration.log | grep "OK" 1>/dev/null || tail /tmp/integration.log | python ./.github/scripts/telegram_msg.py | exit 1 + run: | + err=0 + tail -n1 /tmp/integration_sync.log | grep "OK" 1>/dev/null || tail /tmp/integration_sync.log | python ./.github/scripts/telegram_msg.py 2>/dev/null | err=1 + tail -n1 /tmp/integration_async.log | grep "OK" 1>/dev/null || tail /tmp/integration_async.log | python ./.github/scripts/telegram_msg.py 2>/dev/null | err=1 + if [ "$err" = 1 ]; then exit 1; fi + diff --git a/.github/workflows/integration_get.yaml b/.github/workflows/integration_getter.yaml similarity index 55% rename from .github/workflows/integration_get.yaml rename to .github/workflows/integration_getter.yaml index 1870a5d..fa4f0eb 100644 --- a/.github/workflows/integration_get.yaml +++ b/.github/workflows/integration_getter.yaml @@ -1,25 +1,27 @@ name: get -run-name: Run Zabbix get integration test +run-name: Zabbix get integration test on: push: branches: [main] paths: - - '**get.py' + - '**getter.py' pull_request: branches: [main] paths: - - '**get.py' + - '**getter.py' workflow_dispatch: env: ZABBIX_VERSION: '6.0' ZABBIX_BRANCH: master CONFIG_PATH: .github/configs/ - TEST_FILE: integration_get_test.py + SYNC_FILE: integration_getter_test.py + ASYNC_FILE: integration_aiogetter_test.py jobs: - build: + integration: + name: Integration test runs-on: ubuntu-22.04 steps: @@ -37,16 +39,24 @@ jobs: run: | sudo zabbix_agentd -c /etc/zabbix/zabbix_agentd.conf - name: Install python3 - run: | + run: | sudo apt-get install -y python3 python3-pip python-is-python3 - pip install typing-extensions>=4.0.0 - - name: Integration test + pip install -r ./requirements.txt + - name: Integration synchronous test + continue-on-error: true + run: | + python ./.github/scripts/$SYNC_FILE 2>/tmp/integration_sync.log >/dev/null + - name: Integration asynchronous test continue-on-error: true run: | - python ./.github/scripts/$TEST_FILE 2>/tmp/integration.log >/dev/null + python ./.github/scripts/$ASYNC_FILE 2>/tmp/integration_async.log >/dev/null - name: Send report env: TBOT_TOKEN: ${{ secrets.TBOT_TOKEN }} TBOT_CHAT: ${{ vars.TBOT_CHAT }} SUBJECT: Zabbix get integration test FAIL - run: tail -n1 /tmp/integration.log | grep "OK" 1>/dev/null || tail /tmp/integration.log | python ./.github/scripts/telegram_msg.py | exit 1 + run: | + err=0 + tail -n1 /tmp/integration_sync.log | grep "OK" 1>/dev/null || tail /tmp/integration_sync.log | python ./.github/scripts/telegram_msg.py 2>/dev/null | err=1 + tail -n1 /tmp/integration_async.log | grep "OK" 1>/dev/null || tail /tmp/integration_async.log | python ./.github/scripts/telegram_msg.py 2>/dev/null | err=1 + if [ "$err" = 1 ]; then exit 1; fi diff --git a/.github/workflows/integration_sender.yaml b/.github/workflows/integration_sender.yaml index dd9f9aa..07edaef 100644 --- a/.github/workflows/integration_sender.yaml +++ b/.github/workflows/integration_sender.yaml @@ -1,5 +1,5 @@ name: sender -run-name: Run Zabbix sender integration test +run-name: Zabbix sender integration test on: push: @@ -16,10 +16,12 @@ env: ZABBIX_VERSION: '6.0' ZABBIX_BRANCH: master CONFIG_PATH: .github/configs/ - TEST_FILE: integration_sender_test.py + SYNC_FILE: integration_sender_test.py + ASYNC_FILE: integration_aiosender_test.py jobs: - build: + integration: + name: Integration test runs-on: ubuntu-22.04 steps: @@ -38,16 +40,24 @@ jobs: run: | sudo zabbix_proxy -c /etc/zabbix/zabbix_proxy.conf - name: Install python3 - run: | + run: | sudo apt-get install -y python3 python3-pip python-is-python3 - pip install typing-extensions>=4.0.0 - - name: Integration test + pip install -r ./requirements.txt + - name: Integration synchronous test + continue-on-error: true + run: | + python ./.github/scripts/$SYNC_FILE 2>/tmp/integration_sync.log >/dev/null + - name: Integration asynchronous test continue-on-error: true run: | - python ./.github/scripts/$TEST_FILE 2>/tmp/integration.log >/dev/null + python ./.github/scripts/$ASYNC_FILE 2>/tmp/integration_async.log >/dev/null - name: Send report env: TBOT_TOKEN: ${{ secrets.TBOT_TOKEN }} TBOT_CHAT: ${{ vars.TBOT_CHAT }} SUBJECT: Zabbix sender integration test FAIL - run: tail -n1 /tmp/integration.log | grep "OK" 1>/dev/null || tail /tmp/integration.log | python ./.github/scripts/telegram_msg.py | exit 1 + run: | + err=0 + tail -n1 /tmp/integration_sync.log | grep "OK" 1>/dev/null || tail /tmp/integration_sync.log | python ./.github/scripts/telegram_msg.py 2>/dev/null | err=1 + tail -n1 /tmp/integration_async.log | grep "OK" 1>/dev/null || tail /tmp/integration_async.log | python ./.github/scripts/telegram_msg.py 2>/dev/null | err=1 + if [ "$err" = 1 ]; then exit 1; fi diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 714ea90..2a26376 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -10,10 +10,12 @@ on: jobs: release: + name: Release new version runs-on: ubuntu-latest + steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: '3.10' - name: Get pip cache diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index b4b085d..41c3e6c 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -9,7 +9,7 @@ on: workflow_dispatch: jobs: - build-linux: + unit-tests: strategy: matrix: python-version: @@ -26,12 +26,13 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip + pip install -r ./requirements.txt pip install -r ./requirements-dev.txt pip install coverage - name: Lint with flake8 diff --git a/CHANGELOG.md b/CHANGELOG.md index 42874fd..60b7e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [2.0.0](https://github.com/zabbix/python-zabbix-utils/compare/v1.1.1...v2.0.0) (2024-04-12) + +### Features: + +- added asynchronous modules: AsyncZabbixAPI, AsyncSender, AsyncGetter +- added examples of working with asynchronous modules + +### Bug fixes: + +- fixed issue [#7](https://github.com/zabbix/python-zabbix-utils/issues/7) in examples of PSK using on Linux +- fixed small bugs and flaws + ## [1.1.1](https://github.com/zabbix/python-zabbix-utils/compare/v1.1.0...v1.1.1) (2024-03-06) ### Changes: diff --git a/README.md b/README.md index 86cee12..779e0fc 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Tests](https://github.com/zabbix/python-zabbix-utils/actions/workflows/tests.yaml/badge.svg)](https://github.com/zabbix/python-zabbix-utils/actions/workflows/tests.yaml) [![Zabbix API](https://github.com/zabbix/python-zabbix-utils/actions/workflows/integration_api.yaml/badge.svg)](https://github.com/zabbix/python-zabbix-utils/actions/workflows/integration_api.yaml) [![Zabbix sender](https://github.com/zabbix/python-zabbix-utils/actions/workflows/integration_sender.yaml/badge.svg)](https://github.com/zabbix/python-zabbix-utils/actions/workflows/integration_sender.yaml) -[![Zabbix get](https://github.com/zabbix/python-zabbix-utils/actions/workflows/integration_get.yaml/badge.svg)](https://github.com/zabbix/python-zabbix-utils/actions/workflows/integration_get.yaml) +[![Zabbix get](https://github.com/zabbix/python-zabbix-utils/actions/workflows/integration_getter.yaml/badge.svg)](https://github.com/zabbix/python-zabbix-utils/actions/workflows/integration_getter.yaml) [![Zabbix 5.0](https://github.com/zabbix/python-zabbix-utils/actions/workflows/compatibility_50.yaml/badge.svg)](https://github.com/zabbix/python-zabbix-utils/actions/workflows/compatibility_50.yaml) [![Zabbix 6.0](https://github.com/zabbix/python-zabbix-utils/actions/workflows/compatibility_60.yaml/badge.svg)](https://github.com/zabbix/python-zabbix-utils/actions/workflows/compatibility_60.yaml) [![Zabbix 6.4](https://github.com/zabbix/python-zabbix-utils/actions/workflows/compatibility_64.yaml/badge.svg)](https://github.com/zabbix/python-zabbix-utils/actions/workflows/compatibility_64.yaml) @@ -30,6 +30,10 @@ Tested on: * Zabbix 5.0, 6.0, 6.4 and pre-7.0 * Python 3.8, 3.9, 3.10, 3.11 and 3.12 +Dependencies: + +* [aiohttp](https://github.com/aio-libs/aiohttp) (in case of async use) + ## Documentation ### Installation @@ -40,11 +44,17 @@ Install **zabbix_utils** library using pip: $ pip install zabbix_utils ``` +To install the library with dependencies for asynchronous work use the following way: + +```bash +$ pip install zabbix_utils[async] +``` + ### Use cases ##### To work with Zabbix API -To work with Zabbix API you can import and use **zabbix_utils** library as follows: +To work with Zabbix API via synchronous I/O you can import and use **zabbix_utils** library as follows: ```python from zabbix_utils import ZabbixAPI @@ -62,20 +72,38 @@ for user in users: api.logout() ``` -You can also authenticate using an API token (supported since Zabbix 5.4): +To work with Zabbix API via asynchronous I/O you can use the following way: ```python -from zabbix_utils import ZabbixAPI +import asyncio +from zabbix_utils import AsyncZabbixAPI + +async def main(): + api = AsyncZabbixAPI(url="127.0.0.1") + await api.login(user="User", password="zabbix") + + users = await api.user.get( + output=['userid','name'] + ) + + for user in users: + print(user['name']) + + await api.logout() +asyncio.run(main()) +``` + +You can also authenticate using an API token (supported since Zabbix 5.4): + +```python api = ZabbixAPI(url="127.0.0.1") api.login(token="xxxxxxxx") +``` -users = api.user.get( - output=['userid','name'] -) - -for user in users: - print(user['name']) +```python +api = AsyncZabbixAPI(url="127.0.0.1") +await api.login(token="xxxxxxxx") ``` When token is used, calling `api.logout()` is not necessary. @@ -86,14 +114,6 @@ It is possible to specify authentication fields by the following environment var You can compare Zabbix API version with strings and numbers, for example: ```python -from zabbix_utils import ZabbixAPI - -url = "127.0.0.1" -user = "User" -password = "zabbix" - -api = ZabbixAPI(url=url, user=user, password=password) - # Method to get version ver = api.api_version() print(type(ver).__name__, ver) # APIVersion 7.0.0 @@ -111,11 +131,9 @@ print(ver != "7.0.0") # False print(ver.major) # 7.0 print(ver.minor) # 0 print(ver.is_lts()) # True - -api.logout() ``` -In case the API object or method name matches one of Python keywords, you can use the suffix `_` in their name to execute correctly: +In case the API object or method name matches one of Python keywords, you can use the suffix `_` in their name to execute correctly, for example: ```python from zabbix_utils import ZabbixAPI @@ -152,7 +170,23 @@ print(response) # {"processed": 1, "failed": 0, "total": 1, "time": "0.000338", "chunk": 1} ``` -Or you can prepare a list of item values and send all at once: +The asynchronous way: + +```python +import asyncio +from zabbix_utils import AsyncSender + +async def main(): + sender = AsyncSender(server='127.0.0.1', port=10051) + response = await sender.send_value('host', 'item.key', 'value', 1695713666) + + print(response) + # {"processed": 1, "failed": 0, "total": 1, "time": "0.000338", "chunk": 1} + +asyncio.run(main()) +``` + +You can also prepare a list of item values and send all at once: ```python from zabbix_utils import ItemValue, Sender @@ -201,7 +235,7 @@ In such case, the value will be sent to the first available node of each cluster ##### To work via Zabbix get protocol -To get a value by item key from a Zabbix agent or agent 2 you can import and use the library as follows: +To get a value by item key from a Zabbix agent or agent 2 via synchronous I/O the library can be imported and used as follows: ```python from zabbix_utils import Getter @@ -213,6 +247,22 @@ print(resp.value) # Linux test_server 5.15.0-3.60.5.1.el9uek.x86_64 ``` +The library can be used via asynchronous I/O, as in the following example: + +```python +import asyncio +from zabbix_utils import AsyncGetter + +async def main(): + agent = AsyncGetter(host='127.0.0.1', port=10050) + resp = await agent.get('system.uname') + + print(resp.value) + # Linux test_server 5.15.0-3.60.5.1.el9uek.x86_64 + +asyncio.run(main()) +``` + > Please, refer to the [Zabbix agent protocol](https://www.zabbix.com/documentation/current/manual/appendix/protocols/zabbix_agent) and the [using examples](https://github.com/zabbix/python-zabbix-utils/tree/main/examples/get) for more information. ### Enabling debug log diff --git a/examples/api/asynchronous/auth_by_token.py b/examples/api/asynchronous/auth_by_token.py new file mode 100644 index 0000000..eaa9561 --- /dev/null +++ b/examples/api/asynchronous/auth_by_token.py @@ -0,0 +1,42 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import asyncio +from zabbix_utils import AsyncZabbixAPI + + +# Zabbix server URL or IP address. +ZABBIX_SERVER = "127.0.0.1" + +# Use an authentication token generated via the web interface or +# API instead of standard authentication by username and password. +ZABBIX_TOKEN = "8jF7sGh2Rp4TlQ1ZmXo0uYv3Bc6AiD9E" + + +async def main(): + """ + The main function to perform asynchronous tasks. + """ + + # Create an instance of the AsyncZabbixAPI class. + api = AsyncZabbixAPI(ZABBIX_SERVER) + + # Authenticating with Zabbix API using the provided token. + await api.login(token=ZABBIX_TOKEN) + + # Retrieve a list of users, including their user ID and name + users = await api.user.get( + output=['userid', 'name'] + ) + + # Print the names of the retrieved users + for user in users: + print(user['name']) + + # Close asynchronous connection + await api.logout() + +# Run the main coroutine +asyncio.run(main()) diff --git a/examples/api/asynchronous/check_auth_state.py b/examples/api/asynchronous/check_auth_state.py new file mode 100644 index 0000000..42f8c46 --- /dev/null +++ b/examples/api/asynchronous/check_auth_state.py @@ -0,0 +1,49 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import asyncio +from zabbix_utils import AsyncZabbixAPI + + +# Zabbix server URL or IP address +ZABBIX_SERVER = "127.0.0.1" + +# Zabbix server authentication credentials +ZABBIX_AUTH = { + "user": "Admin", # Zabbix user name for authentication + "password": "zabbix" # Zabbix user password for authentication +} + + +async def main(): + """ + The main function to perform asynchronous tasks. + """ + + # Create an instance of the AsyncZabbixAPI class + api = AsyncZabbixAPI(ZABBIX_SERVER) + + # Authenticating with Zabbix API using the provided token. + await api.login(**ZABBIX_AUTH) + + # Some actions when your session can be released + # For example, api.logout() + + # Check if authentication is still valid + if await api.check_auth(): + # Retrieve a list of hosts from the Zabbix server, including their host ID and name + hosts = await api.host.get( + output=['hostid', 'name'] + ) + + # Print the names of the retrieved hosts + for host in hosts: + print(host['name']) + + # Logout to release the Zabbix API session and close asynchronous connection + await api.logout() + +# Run the main coroutine +asyncio.run(main()) diff --git a/examples/api/asynchronous/custom_client_session.py b/examples/api/asynchronous/custom_client_session.py new file mode 100644 index 0000000..20a570b --- /dev/null +++ b/examples/api/asynchronous/custom_client_session.py @@ -0,0 +1,56 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import asyncio +from zabbix_utils import AsyncZabbixAPI +from aiohttp import ClientSession, TCPConnector + + +# Zabbix server URL or IP address +ZABBIX_SERVER = "127.0.0.1" + +# Zabbix server authentication credentials +ZABBIX_AUTH = { + "user": "Admin", # Zabbix user name for authentication + "password": "zabbix" # Zabbix user password for authentication +} + + +async def main(): + """ + The main function to perform asynchronous tasks. + """ + + # Create an asynchronous client session for HTTP requests + client_session = ClientSession( + connector=TCPConnector(ssl=False) + ) + + # Create an instance of the AsyncZabbixAPI class + api = AsyncZabbixAPI( + url=ZABBIX_SERVER, + client_session=client_session + ) + + # Authenticating with Zabbix API using the provided token. + await api.login(**ZABBIX_AUTH) + + # Retrieve a list of hosts from the Zabbix server, including their host ID and name + hosts = await api.host.get( + output=['hostid', 'name'] + ) + + # Print the names of the retrieved hosts + for host in hosts: + print(host['name']) + + # Logout to release the Zabbix API session + await api.logout() + + # Close asynchronous client session + await client_session.close() + +# Run the main coroutine +asyncio.run(main()) diff --git a/examples/api/asynchronous/disabling_validate_certs.py b/examples/api/asynchronous/disabling_validate_certs.py new file mode 100644 index 0000000..145e56e --- /dev/null +++ b/examples/api/asynchronous/disabling_validate_certs.py @@ -0,0 +1,50 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import asyncio +from zabbix_utils import AsyncZabbixAPI + + +# SSL certificate verification will be ignored. +# This can be useful in some cases, but it also poses security risks because +# it makes the connection susceptible to man-in-the-middle attacks. +ZABBIX_PARAMS = { + "url": "127.0.0.1", + "validate_certs": False +} + +# Zabbix server authentication credentials. +ZABBIX_AUTH = { + "user": "Admin", + "password": "zabbix" +} + + +async def main(): + """ + The main function to perform asynchronous tasks. + """ + + # Create an instance of the AsyncZabbixAPI class with the specified authentication details. + # Note: Ignoring SSL certificate validation may expose the connection to security risks. + api = AsyncZabbixAPI(**ZABBIX_PARAMS) + + # Authenticating with Zabbix API using the provided token. + await api.login(**ZABBIX_AUTH) + + # Retrieve a list of users from the Zabbix server, including their user ID and name. + users = await api.user.get( + output=['userid', 'name'] + ) + + # Print the names of the retrieved users. + for user in users: + print(user['name']) + + # Logout to release the Zabbix API session. + await api.logout() + +# Run the main coroutine +asyncio.run(main()) diff --git a/examples/api/asynchronous/export_templates.py b/examples/api/asynchronous/export_templates.py new file mode 100644 index 0000000..72996b8 --- /dev/null +++ b/examples/api/asynchronous/export_templates.py @@ -0,0 +1,58 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import asyncio +from zabbix_utils import AsyncZabbixAPI + + +# Zabbix server URL or IP address +ZABBIX_SERVER = "127.0.0.1" + +# Zabbix server authentication credentials +ZABBIX_AUTH = { + "user": "Admin", # Zabbix user name for authentication + "password": "zabbix" # Zabbix user password for authentication +} + +# Template IDs to be exported +TEMPLATE_IDS = [10050] + +# File path and format for exporting configuration +FILE_PATH = "templates_export_example.{}" + + +async def main(): + """ + The main function to perform asynchronous tasks. + """ + + # Create an instance of the ZabbixAPI class with the specified authentication details + api = AsyncZabbixAPI(ZABBIX_SERVER) + + # Authenticating with Zabbix API using the provided token. + await api.login(**ZABBIX_AUTH) + + # Determine the export file format based on the Zabbix API version + export_format = "yaml" + if api.version < 5.4: + export_format = "xml" + + # Export configuration for specified template IDs + configuration = await api.configuration.export( + options={ + "templates": TEMPLATE_IDS + }, + format=export_format + ) + + # Write the exported configuration to a file + with open(FILE_PATH.format(export_format), mode='w', encoding='utf-8') as f: + f.write(configuration) + + # Logout to release the Zabbix API session + await api.logout() + +# Run the main coroutine +asyncio.run(main()) diff --git a/examples/api/asynchronous/use_context_manager.py b/examples/api/asynchronous/use_context_manager.py new file mode 100644 index 0000000..8b922cd --- /dev/null +++ b/examples/api/asynchronous/use_context_manager.py @@ -0,0 +1,41 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import asyncio +from zabbix_utils import AsyncZabbixAPI + + +# Zabbix server details and authentication credentials +ZABBIX_SERVER = "127.0.0.1" # Zabbix server URL or IP address +ZABBIX_USER = "Admin" # Zabbix user name for authentication +ZABBIX_PASSWORD = "zabbix" # Zabbix user password for authentication + + +async def main(): + """ + The main function to perform asynchronous tasks. + """ + + # Use a context manager for automatic logout and + # close asynchronous session upon completion of the request. + # Each time it's created it performs synchronously "apiinfo.version". + # Highly recommended not to use it many times in a single script. + async with AsyncZabbixAPI(url=ZABBIX_SERVER) as api: + # Authenticate with the Zabbix API using the provided user credentials + await api.login(user=ZABBIX_USER, password=ZABBIX_PASSWORD) + + # Retrieve a list of hosts from the Zabbix server, including their host ID and name + hosts = await api.host.get( + output=['hostid', 'name'] + ) + + # Print the names of the retrieved hosts + for host in hosts: + print(host['name']) + + # Automatic logout occurs when the code block exits due to the context manager. + +# Run the main coroutine +asyncio.run(main()) diff --git a/examples/api/asynchronous/using_http_auth.py b/examples/api/asynchronous/using_http_auth.py new file mode 100644 index 0000000..85631d9 --- /dev/null +++ b/examples/api/asynchronous/using_http_auth.py @@ -0,0 +1,41 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import asyncio +from zabbix_utils import AsyncZabbixAPI + + +async def main(): + """ + The main function to perform asynchronous tasks. + """ + + # Create an instance of the AsyncZabbixAPI class with the Zabbix server URL + # Set Basic Authentication credentials for Zabbix API requests + # Basic Authentication - a simple authentication mechanism used in HTTP. + # It involves sending a username and password with each HTTP request. + api = AsyncZabbixAPI( + url="http://127.0.0.1", + http_user="user", + http_password="p@$sw0rd" + ) + + # Login to the Zabbix API using provided user credentials + await api.login(user="Admin", password="zabbix") + + # Retrieve a list of users from the Zabbix server, including their user ID and name + users = await api.user.get( + output=['userid', 'name'] + ) + + # Print the names of the retrieved users + for user in users: + print(user['name']) + + # Logout to release the Zabbix API session + await api.logout() + +# Run the main coroutine +asyncio.run(main()) diff --git a/examples/api/auth_by_token.py b/examples/api/synchronous/auth_by_token.py similarity index 100% rename from examples/api/auth_by_token.py rename to examples/api/synchronous/auth_by_token.py diff --git a/examples/api/check_auth_state.py b/examples/api/synchronous/check_auth_state.py similarity index 100% rename from examples/api/check_auth_state.py rename to examples/api/synchronous/check_auth_state.py diff --git a/examples/api/disabling_validate_certs.py b/examples/api/synchronous/disabling_validate_certs.py similarity index 100% rename from examples/api/disabling_validate_certs.py rename to examples/api/synchronous/disabling_validate_certs.py diff --git a/examples/api/export_templates.py b/examples/api/synchronous/export_templates.py similarity index 100% rename from examples/api/export_templates.py rename to examples/api/synchronous/export_templates.py diff --git a/examples/api/token_auth_if_supported.py b/examples/api/synchronous/token_auth_if_supported.py similarity index 100% rename from examples/api/token_auth_if_supported.py rename to examples/api/synchronous/token_auth_if_supported.py diff --git a/examples/api/use_context_manager.py b/examples/api/synchronous/use_context_manager.py similarity index 100% rename from examples/api/use_context_manager.py rename to examples/api/synchronous/use_context_manager.py diff --git a/examples/api/using_http_auth.py b/examples/api/synchronous/using_http_auth.py similarity index 100% rename from examples/api/using_http_auth.py rename to examples/api/synchronous/using_http_auth.py diff --git a/examples/get/asynchronous/custom_source_ip.py b/examples/get/asynchronous/custom_source_ip.py new file mode 100644 index 0000000..f9260a4 --- /dev/null +++ b/examples/get/asynchronous/custom_source_ip.py @@ -0,0 +1,31 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import asyncio +from zabbix_utils import AsyncGetter + + +async def main(): + """ + The main function to perform asynchronous tasks. + """ + + # Create a AsyncGetter instance with specified parameters + # Parameters: (host, port, source_ip) + agent = AsyncGetter("127.0.0.1", 10050, source_ip="10.10.1.5") + + # Send a Zabbix agent query for system information (e.g., uname) + resp = await agent.get('system.uname') + + # Check if there was an error in the response + if resp.error: + # Print the error message + print("An error occurred while trying to get the value:", resp.error) + else: + # Print the value obtained for the specified item key item + print("Received value:", resp.value) + +# Run the main coroutine +asyncio.run(main()) diff --git a/examples/get/asynchronous/getting_value.py b/examples/get/asynchronous/getting_value.py new file mode 100644 index 0000000..014e2e4 --- /dev/null +++ b/examples/get/asynchronous/getting_value.py @@ -0,0 +1,43 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import sys +import json +import asyncio +from zabbix_utils import AsyncGetter + + +async def main(): + """ + The main function to perform asynchronous tasks. + """ + + # Create a AsyncGetter instance for querying Zabbix agent + agent = AsyncGetter(host='127.0.0.1', port=10050) + + # Send a Zabbix agent query for network interface discovery + resp = await agent.get('net.if.discovery') + + # Check if there was an error in the response + if resp.error: + # Print the error message + print("An error occurred while trying to get the value:", resp.error) + # Exit the script + sys.exit() + + try: + # Attempt to parse the JSON response + resp_list = json.loads(resp.value) + except json.decoder.JSONDecodeError: + print("Agent response decoding fails") + # Exit the script if JSON decoding fails + sys.exit() + + # Iterate through the discovered network interfaces and print their names + for interface in resp_list: + print(interface['{#IFNAME}']) + +# Run the main coroutine +asyncio.run(main()) diff --git a/examples/get/custom_source_ip.py b/examples/get/synchronous/custom_source_ip.py similarity index 100% rename from examples/get/custom_source_ip.py rename to examples/get/synchronous/custom_source_ip.py diff --git a/examples/get/getting_value.py b/examples/get/synchronous/getting_value.py similarity index 100% rename from examples/get/getting_value.py rename to examples/get/synchronous/getting_value.py diff --git a/examples/get/psk_wrapper.py b/examples/get/synchronous/psk_wrapper.py similarity index 93% rename from examples/get/psk_wrapper.py rename to examples/get/synchronous/psk_wrapper.py index 49fb609..8551088 100644 --- a/examples/get/psk_wrapper.py +++ b/examples/get/synchronous/psk_wrapper.py @@ -17,7 +17,7 @@ # PSK wrapper function for SSL connection def psk_wrapper(sock): # Pre-Shared Key (PSK) and PSK Identity - psk = b'608b0a0049d41fdb35a824ef0a227f24e5099c60aa935e803370a961c937d6f7' + psk = bytes.fromhex('608b0a0049d41fdb35a824ef0a227f24e5099c60aa935e803370a961c937d6f7') psk_identity = b'PSKID' # Wrap the socket using sslpsk to establish an SSL connection with PSK diff --git a/examples/sender/asynchronous/agent_clusters_using.py b/examples/sender/asynchronous/agent_clusters_using.py new file mode 100644 index 0000000..e67cdbd --- /dev/null +++ b/examples/sender/asynchronous/agent_clusters_using.py @@ -0,0 +1,67 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import asyncio +from zabbix_utils import ItemValue, AsyncSender + + +# You can create an instance of AsyncSender specifying server address and port: +# +# sender = AsyncSender(server='127.0.0.1', port=10051) +# +# Or you can create an instance of AsyncSender specifying a list of Zabbix clusters: +zabbix_clusters = [ + ['zabbix.cluster1.node1', 'zabbix.cluster1.node2:10051'], + ['zabbix.cluster2.node1:10051', 'zabbix.cluster2.node2:20051', 'zabbix.cluster2.node3'] +] + +# List of ItemValue instances representing items to be sent +items = [ + ItemValue('host1', 'item.key1', 10), + ItemValue('host1', 'item.key2', 'test message'), + ItemValue('host2', 'item.key1', -1, 1695713666), + ItemValue('host3', 'item.key1', '{"msg":"test message"}'), + ItemValue('host2', 'item.key1', 0, 1695713666, 100) +] + + +async def main(): + """ + The main function to perform asynchronous tasks. + """ + + sender = AsyncSender(clusters=zabbix_clusters) + # You can also specify Zabbix clusters at the same time with server address and port: + # + # sender = AsyncSender(server='127.0.0.1', port=10051, clusters=zabbix_clusters) + # + # In such case, specified server address and port will be appended to the cluster list + # as a cluster of a single node + + # Send multiple items to the Zabbix server/proxy and receive response + response = await sender.send(items) + + # Check if the value sending was successful + if response.failed == 0: + # Print a success message along with the response time + print(f"Value sent successfully in {response.time}") + elif response.details: + # Iterate through the list of responses from Zabbix server/proxy. + for node, chunks in response.details.items(): + # Iterate through the list of chunks. + for resp in chunks: + # Check if the value sending was successful + if resp.failed == 0: + # Print a success message along with the response time + print(f"Value sent successfully to {node} in {resp.time}") + else: + # Print a failure message + print(f"Failed to send value to {node} at chunk step {resp.chunk}") + else: + # Print a failure message + print("Failed to send value") + +# Run the main coroutine +asyncio.run(main()) diff --git a/examples/sender/asynchronous/agent_config_using.py b/examples/sender/asynchronous/agent_config_using.py new file mode 100644 index 0000000..ce259c9 --- /dev/null +++ b/examples/sender/asynchronous/agent_config_using.py @@ -0,0 +1,48 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import asyncio +from zabbix_utils import AsyncSender + + +async def main(): + """ + The main function to perform asynchronous tasks. + """ + + # You can create an instance of AsyncSender using the default configuration file path + # (typically '/etc/zabbix/zabbix_agentd.conf') + # + # sender = AsyncSender(use_config=True) + # + # Or you can create an instance of AsyncSender using a custom configuration file path + sender = AsyncSender(use_config=True, config_path='/etc/zabbix/zabbix_agent2.conf') + + # Send a value to a Zabbix server/proxy with specified parameters + # Parameters: (host, key, value, clock) + response = await sender.send_value('host', 'item.key', 'value', 1695713666) + + # Check if the value sending was successful + if response.failed == 0: + # Print a success message along with the response time + print(f"Value sent successfully in {response.time}") + elif response.details: + # Iterate through the list of responses from Zabbix server/proxy. + for node, chunks in response.details.items(): + # Iterate through the list of chunks. + for resp in chunks: + # Check if the value sending was successful + if resp.failed == 0: + # Print a success message along with the response time + print(f"Value sent successfully to {node} in {resp.time}") + else: + # Print a failure message + print(f"Failed to send value to {node} at chunk step {resp.chunk}") + else: + # Print a failure message + print("Failed to send value") + +# Run the main coroutine +asyncio.run(main()) diff --git a/examples/sender/asynchronous/bulk_sending.py b/examples/sender/asynchronous/bulk_sending.py new file mode 100644 index 0000000..f257629 --- /dev/null +++ b/examples/sender/asynchronous/bulk_sending.py @@ -0,0 +1,52 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import asyncio +from zabbix_utils import ItemValue, AsyncSender + + +# List of ItemValue instances representing items to be sent +items = [ + ItemValue('host1', 'item.key1', 10), + ItemValue('host1', 'item.key2', 'test message'), + ItemValue('host2', 'item.key1', -1, 1695713666), + ItemValue('host3', 'item.key1', '{"msg":"test message"}'), + ItemValue('host2', 'item.key1', 0, 1695713666, 100) +] + + +async def main(): + """ + The main function to perform asynchronous tasks. + """ + + # Create an instance of the AsyncSender class with the specified server details + sender = AsyncSender("127.0.0.1", 10051) + + # Send multiple items to the Zabbix server/proxy and receive response + response = await sender.send(items) + + # Check if the value sending was successful + if response.failed == 0: + # Print a success message along with the response time + print(f"Value sent successfully in {response.time}") + elif response.details: + # Iterate through the list of responses from Zabbix server/proxy. + for node, chunks in response.details.items(): + # Iterate through the list of chunks. + for resp in chunks: + # Check if the value sending was successful + if resp.failed == 0: + # Print a success message along with the response time + print(f"Value sent successfully to {node} in {resp.time}") + else: + # Print a failure message + print(f"Failed to send value to {node} at chunk step {resp.chunk}") + else: + # Print a failure message + print("Failed to send value") + +# Run the main coroutine +asyncio.run(main()) diff --git a/examples/sender/asynchronous/custom_source_ip.py b/examples/sender/asynchronous/custom_source_ip.py new file mode 100644 index 0000000..66383b0 --- /dev/null +++ b/examples/sender/asynchronous/custom_source_ip.py @@ -0,0 +1,32 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import asyncio +from zabbix_utils import AsyncSender + + +async def main(): + """ + The main function to perform asynchronous tasks. + """ + + # Create an instance of the AsyncSender class with specified parameters + # Parameters: (server, port, source_ip) + sender = AsyncSender("127.0.0.1", 10051, source_ip="10.10.1.5") + + # Send a value to a Zabbix server/proxy with specified parameters + # Parameters: (host, key, value, clock) + response = await sender.send_value('host', 'item.key', 'value', 1695713666) + + # Check if the value sending was successful + if response.failed == 0: + # Print a success message along with the response time + print(f"Value sent successfully in {response.time}") + else: + # Print a failure message + print("Failed to send value") + +# Run the main coroutine +asyncio.run(main()) diff --git a/examples/sender/asynchronous/single_sending.py b/examples/sender/asynchronous/single_sending.py new file mode 100644 index 0000000..90775b3 --- /dev/null +++ b/examples/sender/asynchronous/single_sending.py @@ -0,0 +1,38 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import asyncio +from zabbix_utils import AsyncSender + + +# Zabbix server/proxy details for AsyncSender +ZABBIX_SERVER = { + "server": "127.0.0.1", # Zabbix server/proxy IP address or hostname + "port": 10051 # Zabbix server/proxy port for AsyncSender +} + + +async def main(): + """ + The main function to perform asynchronous tasks. + """ + + # Create an instance of the AsyncSender class with the specified server details + sender = AsyncSender(**ZABBIX_SERVER) + + # Send a value to a Zabbix server/proxy with specified parameters + # Parameters: (host, key, value, clock, ns) + response = await sender.send_value('host', 'item.key', 'value', 1695713666, 30) + + # Check if the value sending was successful + if response.failed == 0: + # Print a success message along with the response time + print(f"Value sent successfully in {response.time}") + else: + # Print a failure message + print("Failed to send value") + +# Run the main coroutine +asyncio.run(main()) diff --git a/examples/sender/asynchronous/tls_cert_context.py b/examples/sender/asynchronous/tls_cert_context.py new file mode 100644 index 0000000..56a52d7 --- /dev/null +++ b/examples/sender/asynchronous/tls_cert_context.py @@ -0,0 +1,67 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import ssl +import asyncio +from zabbix_utils import AsyncSender + +# Zabbix server details +ZABBIX_SERVER = "zabbix-server.example.com" +ZABBIX_PORT = 10051 + +# Paths to certificate and key files +CA_PATH = 'path/to/cabundle.pem' +CERT_PATH = 'path/to/agent.crt' +KEY_PATH = 'path/to/agent.key' + + +# Create and configure an SSL context for secure communication with the Zabbix server. +def custom_context(*args, **kwargs) -> ssl.SSLContext: + + # Create an SSL context for TLS client + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + + # Load the client certificate and private key + context.load_cert_chain(CERT_PATH, keyfile=KEY_PATH) + + # Load the certificate authority bundle file + context.load_verify_locations(cafile=CA_PATH) + + # Disable hostname verification + context.check_hostname = False + + # Set the verification mode to require a valid certificate + context.verify_mode = ssl.VerifyMode.CERT_REQUIRED + + # Return created context + return context + + +async def main(): + """ + The main function to perform asynchronous tasks. + """ + + # Create an instance of AsyncSender with SSL context + sender = AsyncSender( + server=ZABBIX_SERVER, + port=ZABBIX_PORT, + ssl_context=custom_context + ) + + # Send a value to a Zabbix server/proxy with specified parameters + # Parameters: (host, key, value, clock, ns) + response = await sender.send_value('host', 'item.key', 'value', 1695713666, 30) + + # Check if the value sending was successful + if response.failed == 0: + # Print a success message along with the response time + print(f"Value sent successfully in {response.time}") + else: + # Print a failure message + print("Failed to send value") + +# Run the main coroutine +asyncio.run(main()) diff --git a/examples/sender/asynchronous/tls_cert_context_from_config.py b/examples/sender/asynchronous/tls_cert_context_from_config.py new file mode 100644 index 0000000..072e6d0 --- /dev/null +++ b/examples/sender/asynchronous/tls_cert_context_from_config.py @@ -0,0 +1,67 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import ssl +import asyncio +from zabbix_utils import AsyncSender + +# Zabbix server details +ZABBIX_SERVER = "zabbix-server.example.com" +ZABBIX_PORT = 10051 + + +# Create and configure an SSL context for secure communication with the Zabbix server. +def custom_context(config) -> ssl.SSLContext: + + # Try to get paths to certificate and key files + ca_path = config.get('tlscafile') + cert_path = config.get('tlscertfile') + key_path = config.get('tlskeyfile') + + # Create an SSL context for TLS client + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + + # Load the client certificate and private key + context.load_cert_chain(cert_path, keyfile=key_path) + + # Load the certificate authority bundle file + context.load_verify_locations(cafile=ca_path) + + # Disable hostname verification + context.check_hostname = False + + # Set the verification mode to require a valid certificate + context.verify_mode = ssl.VerifyMode.CERT_REQUIRED + + # Return created context + return context + + +async def main(): + """ + The main function to perform asynchronous tasks. + """ + + # Create an instance of AsyncSender with SSL context + sender = AsyncSender( + server=ZABBIX_SERVER, + port=ZABBIX_PORT, + ssl_context=custom_context + ) + + # Send a value to a Zabbix server/proxy with specified parameters + # Parameters: (host, key, value, clock, ns) + response = await sender.send_value('host', 'item.key', 'value', 1695713666, 30) + + # Check if the value sending was successful + if response.failed == 0: + # Print a success message along with the response time + print(f"Value sent successfully in {response.time}") + else: + # Print a failure message + print("Failed to send value") + +# Run the main coroutine +asyncio.run(main()) diff --git a/examples/sender/agent_clusters_using.py b/examples/sender/synchronous/agent_clusters_using.py similarity index 100% rename from examples/sender/agent_clusters_using.py rename to examples/sender/synchronous/agent_clusters_using.py diff --git a/examples/sender/agent_config_using.py b/examples/sender/synchronous/agent_config_using.py similarity index 100% rename from examples/sender/agent_config_using.py rename to examples/sender/synchronous/agent_config_using.py diff --git a/examples/sender/bulk_sending.py b/examples/sender/synchronous/bulk_sending.py similarity index 100% rename from examples/sender/bulk_sending.py rename to examples/sender/synchronous/bulk_sending.py diff --git a/examples/sender/custom_source_ip.py b/examples/sender/synchronous/custom_source_ip.py similarity index 100% rename from examples/sender/custom_source_ip.py rename to examples/sender/synchronous/custom_source_ip.py diff --git a/examples/sender/psk_wrapper.py b/examples/sender/synchronous/psk_wrapper.py similarity index 93% rename from examples/sender/psk_wrapper.py rename to examples/sender/synchronous/psk_wrapper.py index 1851bbe..9dea96e 100644 --- a/examples/sender/psk_wrapper.py +++ b/examples/sender/synchronous/psk_wrapper.py @@ -17,7 +17,7 @@ # PSK wrapper function for SSL connection def psk_wrapper(sock, tls): # Pre-Shared Key (PSK) and PSK Identity - psk = b'608b0a0049d41fdb35a824ef0a227f24e5099c60aa935e803370a961c937d6f7' + psk = bytes.fromhex('608b0a0049d41fdb35a824ef0a227f24e5099c60aa935e803370a961c937d6f7') psk_identity = b'PSKID' return sslpsk.wrap_socket( diff --git a/examples/sender/psk_wrapper_from_config.py b/examples/sender/synchronous/psk_wrapper_from_config.py similarity index 89% rename from examples/sender/psk_wrapper_from_config.py rename to examples/sender/synchronous/psk_wrapper_from_config.py index 66f62a7..4ff8cef 100644 --- a/examples/sender/psk_wrapper_from_config.py +++ b/examples/sender/synchronous/psk_wrapper_from_config.py @@ -15,15 +15,15 @@ # PSK wrapper function for SSL connection -def psk_wrapper(sock, tls): +def psk_wrapper(sock, config): psk = None - psk_identity = tls.get('tlspskidentity') - psk_file = tls.get('tlspskfile') + psk_identity = config.get('tlspskidentity').encode('utf-8') + psk_file = config.get('tlspskfile') # Read PSK from file if specified if psk_file: with open(psk_file, encoding='utf-8') as f: - psk = f.read() + psk = bytes.fromhex(f.read()) # Check if both PSK and PSK identity are available if psk and psk_identity: diff --git a/examples/sender/single_sending.py b/examples/sender/synchronous/single_sending.py similarity index 100% rename from examples/sender/single_sending.py rename to examples/sender/synchronous/single_sending.py diff --git a/examples/sender/tls_cert_wrapper.py b/examples/sender/synchronous/tls_cert_wrapper.py similarity index 70% rename from examples/sender/tls_cert_wrapper.py rename to examples/sender/synchronous/tls_cert_wrapper.py index ffe1ad5..601aac3 100644 --- a/examples/sender/tls_cert_wrapper.py +++ b/examples/sender/synchronous/tls_cert_wrapper.py @@ -10,16 +10,29 @@ ZABBIX_SERVER = "zabbix-server.example.com" ZABBIX_PORT = 10051 -# Path to the CA bundle file for verifying the server's certificate -SERT_PATH = 'path/to/cabundle.pem' +# Paths to certificate and key files +CA_PATH = 'path/to/cabundle.pem' +CERT_PATH = 'path/to/agent.crt' +KEY_PATH = 'path/to/agent.key' # Define a function for wrapping the socket with TLS def tls_wrapper(sock, *args, **kwargs): + # Create an SSL context for TLS client context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - # Load the CA bundle file for server certificate verification - context.load_verify_locations(SERT_PATH) + + # Load the client certificate and private key + context.load_cert_chain(CERT_PATH, keyfile=KEY_PATH) + + # Load the certificate authority bundle file + context.load_verify_locations(cafile=CA_PATH) + + # Disable hostname verification + context.check_hostname = False + + # Set the verification mode to require a valid certificate + context.verify_mode = ssl.VerifyMode.CERT_REQUIRED # Wrap the socket with TLS using the created context return context.wrap_socket(sock, server_hostname=ZABBIX_SERVER) diff --git a/examples/sender/synchronous/tls_cert_wrapper_from_config.py b/examples/sender/synchronous/tls_cert_wrapper_from_config.py new file mode 100644 index 0000000..01e8b83 --- /dev/null +++ b/examples/sender/synchronous/tls_cert_wrapper_from_config.py @@ -0,0 +1,59 @@ +# Copyright (C) 2001-2023 Zabbix SIA +# +# Zabbix SIA licenses this file to you under the MIT License. +# See the LICENSE file in the project root for more information. + +import ssl +from zabbix_utils import Sender + +# Zabbix server details +ZABBIX_SERVER = "zabbix-server.example.com" +ZABBIX_PORT = 10051 + + +# Define a function for wrapping the socket with TLS +def tls_wrapper(sock, config): + + # Try to get paths to certificate and key files + ca_path = config.get('tlscafile') + cert_path = config.get('tlscertfile') + key_path = config.get('tlskeyfile') + + # Create an SSL context for TLS client + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + + # Load the client certificate and private key + context.load_cert_chain(cert_path, keyfile=key_path) + + # Load the certificate authority bundle file + context.load_verify_locations(cafile=ca_path) + + # Disable hostname verification + context.check_hostname = False + + # Set the verification mode to require a valid certificate + context.verify_mode = ssl.VerifyMode.CERT_REQUIRED + + # Wrap the socket with TLS using the created context + return context.wrap_socket(sock, server_hostname=ZABBIX_SERVER) + + +# Create an instance of Sender with TLS configuration +sender = Sender( + server=ZABBIX_SERVER, + port=ZABBIX_PORT, + # Use the defined tls_wrapper function for socket wrapping + socket_wrapper=tls_wrapper +) + +# Send a value to a Zabbix server/proxy with specified parameters +# Parameters: (host, key, value, clock, ns) +response = sender.send_value('host', 'item.key', 'value', 1695713666, 30) + +# Check if the value sending was successful +if response.failed == 0: + # Print a success message along with the response time + print(f"Value sent successfully in {response.time}") +else: + # Print a failure message + print("Failed to send value") diff --git a/requirements-dev.txt b/requirements-dev.txt index 14ad8e8..92132fe 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1 +1,3 @@ -flake8>=3.0.0 \ No newline at end of file +flake8>=3.0.0 +coverage +aiohttp[speedups]>=3,<4 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a001fe7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +aiohttp[speedups]>=3,<4 \ No newline at end of file diff --git a/setup.py b/setup.py index 48cb667..5cb887b 100644 --- a/setup.py +++ b/setup.py @@ -46,6 +46,9 @@ packages=["zabbix_utils"], tests_require=["unittest"], install_requires=[], + extras_require={ + "async": ["aiohttp>=3,<4"], + }, python_requires='>=3.8', project_urls={ 'Zabbix': 'https://www.zabbix.com/documentation/current', @@ -57,6 +60,11 @@ "Development Status :: 5 - Production/Stable", "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Topic :: System :: Monitoring", diff --git a/tests/common.py b/tests/common.py new file mode 100644 index 0000000..4446acf --- /dev/null +++ b/tests/common.py @@ -0,0 +1,240 @@ +# zabbix_utils +# +# Copyright (C) 2001-2023 Zabbix SIA +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, +# merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software +# is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +import ssl +import json + +from zabbix_utils.types import ItemValue +from zabbix_utils.version import __min_supported__, __max_supported__ + + +API_DEFAULTS = { + 'user': 'Admin', + 'password': 'zabbix', + 'token': 'oTmtWu', + 'session': 'cc364fb50199c5e305aa91785b7e49a0', + 'max_version': "{}.0".format(__max_supported__ + .2), + 'min_version': "{}.0".format(__min_supported__ - .2) +} + + +GETTER_DEFAULTS = { + 'host': 'localhost', + 'port': 10050, + 'source_ip': '192.168.1.1' +} + +SENDER_DEFAULTS = { + 'server': 'localhost', + 'port': 10051, + 'source_ip': '192.168.1.1', + 'clusters': [ + ['zabbix.cluster.node1','zabbix.cluster.node2:20051'], + ['zabbix.cluster2.node1','zabbix.cluster2.node2'], + ['zabbix.domain'] + ] +} + +ZABBIX_CONFIG = [ + f"""[root] +ServerActive=zabbix.cluster.node1;zabbix.cluster.node2:20051,zabbix.cluster2.node1;zabbix.cluster2.node2,zabbix.domain +Server={SENDER_DEFAULTS['server']} +SourceIP={SENDER_DEFAULTS['source_ip']} +TLSConnect=unencrypted +TLSAccept=unencrypted +""", + f"""[root] +Server={SENDER_DEFAULTS['server']} +SourceIP={SENDER_DEFAULTS['source_ip']} +""", + f"""[root] +SourceIP={SENDER_DEFAULTS['source_ip']} +""" +] + + +class MockBasicAuth(): + login = API_DEFAULTS['user'] + password = API_DEFAULTS['password'] + + +class MockSession(): + def __init__(self, exception=None): + self._default_auth = None + self.EXC = exception + def set_auth(self): + self._default_auth = MockBasicAuth() + def del_auth(self): + self._default_auth = None + def set_exception(self, exception): + self.EXC = exception + def del_exception(self): + self.EXC = None + async def close(self): + pass + async def post(self, *args, **kwargs): + if self.EXC: + raise self.EXC() + return MockAPIResponse() + + +class MockAPIResponse(): + def __init__(self, exception=None): + self.EXC = exception + def set_exception(self, exception): + self.EXC = exception + def del_exception(self): + self.EXC = None + def raise_for_status(self): + pass + async def json(self, *args, **kwargs): + if self.EXC: + raise self.EXC() + return { + "jsonrpc": "2.0", + "result": "{}.0".format(__max_supported__), + "id": "0" + } + def read(self, *args, **kwargs): + if self.EXC: + raise self.EXC() + return json.dumps({ + "jsonrpc": "2.0", + "result": "{}.0".format(__max_supported__), + "id": "0" + }).encode('utf-8') + + +class MockConnector(): + def __init__(self, input_stream, exception=None): + self.STREAM = input_stream + self.EXC = exception + def __raiser(self, *args, **kwargs): + if self.EXC: + raise self.EXC() + def connect(self, *args, **kwargs): + self.__raiser(*args, **kwargs) + def recv(self, bufsize, *args, **kwargs): + self.__raiser(*args, **kwargs) + resp = self.STREAM[0:bufsize] + self.STREAM = self.STREAM[bufsize:] + return resp + def sendall(self, *args, **kwargs): + self.__raiser(*args, **kwargs) + + +class MockReader(): + STREAM = '' + EXC = None + @classmethod + def set_stream(cls, stream): + cls.STREAM = stream + @classmethod + def set_exception(cls, exception): + cls.EXC = exception + @classmethod + async def readexactly(cls, length=0): + if cls.EXC: + raise cls.EXC() + resp = cls.STREAM[0:length] + cls.STREAM = cls.STREAM[length:] + return resp + @classmethod + def close(cls): + cls.EXC = None + + +class MockWriter(): + EXC = None + @classmethod + def set_exception(cls, exception): + cls.EXC = exception + @classmethod + def write(cls, *args, **kwargs): + if cls.EXC: + raise cls.EXC() + @classmethod + async def drain(cls, *args, **kwargs): + pass + @classmethod + def close(cls): + cls.EXC = None + @classmethod + async def wait_closed(cls): + cls.EXC = None + +class MockLogger(): + def debug(self, *args, **kwargs): + pass + def error(self, *args, **kwargs): + pass + def warning(self, *args, **kwargs): + pass + +def mock_send_sync_request(self, method, *args, **kwargs): + result = {} + if method == 'apiinfo.version': + result = f"{__max_supported__}.0" + elif method == 'user.login': + result = API_DEFAULTS['session'] + elif method == 'user.logout': + result = True + elif method == 'user.checkAuthentication': + result = {'userid': 42} + return {'jsonrpc': '2.0', 'result': result, 'id': 1} + +async def mock_send_async_request(self, method, *args, **kwargs): + result = {} + if method == 'user.login': + result = API_DEFAULTS['session'] + elif method == 'user.logout': + result = True + elif method == 'user.checkAuthentication': + result = {'userid': 42} + return {'jsonrpc': '2.0', 'result': result, 'id': 1} + +def socket_wrapper(connection, *args, **kwargs): + return connection + +def ssl_context(*args, **kwargs): + return ssl.create_default_context() + +def response_gen(items): + def items_check(items): + for i, item in enumerate(items): + if isinstance(item, ItemValue): + items[i] = item.to_json() + return items + info = { + 'processed': len([i for i in items_check(items) if json.loads(i['value'])]), + 'failed': len([i for i in items_check(items) if not json.loads(i['value'])]), + 'total': len(items), + 'seconds spent': '0.000100' + } + result = { + 'response': 'success', + 'info': '; '.join([f"{k}: {v}" for k,v in info.items()]) + } + + return result diff --git a/tests/test_zabbix_aioapi.py b/tests/test_zabbix_aioapi.py new file mode 100644 index 0000000..2423d49 --- /dev/null +++ b/tests/test_zabbix_aioapi.py @@ -0,0 +1,435 @@ +# zabbix_utils +# +# Copyright (C) 2001-2023 Zabbix SIA +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, +# merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software +# is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +import aiohttp +import unittest +import urllib.request as ul +from unittest.mock import patch +from urllib.error import URLError + +from tests import common +from zabbix_utils.aioapi import AsyncZabbixAPI +from zabbix_utils.types import APIVersion +from zabbix_utils.exceptions import APIRequestError, APINotSupported, ProcessingError + + +DEFAULT_VALUES = common.API_DEFAULTS + + +class TestAsyncZabbixAPI(unittest.IsolatedAsyncioTestCase): + """Test cases for AsyncZabbixAPI object""" + + def setUp(self): + with patch.multiple( + AsyncZabbixAPI, + send_sync_request=common.mock_send_sync_request): + self.zapi = AsyncZabbixAPI(client_session=common.MockSession()) + + async def test_init(self): + """Tests creating of AsyncZabbixAPI object""" + + test_resp = common.MockAPIResponse() + + def mock_ClientSession(*args, **kwargs): + return common.MockSession() + + def mock_TCPConnector(*args, **kwargs): + return '' + + def mock_BasicAuth(*args, **kwargs): + return '' + + def mock_urlopen(*args, **kwargs): + return test_resp + + with self.assertRaises(AttributeError, + msg="expected AttributeError exception hasn't been raised"): + zapi = AsyncZabbixAPI( + http_user=DEFAULT_VALUES['user'], + http_password=DEFAULT_VALUES['password'], + client_session=common.MockSession() + ) + + with unittest.mock.patch.multiple( + aiohttp, + ClientSession=mock_ClientSession, + TCPConnector=mock_TCPConnector, + BasicAuth=mock_BasicAuth): + + with unittest.mock.patch.multiple( + ul, + urlopen=mock_urlopen): + zapi = AsyncZabbixAPI() + await zapi.login( + user=DEFAULT_VALUES['user'], + password=DEFAULT_VALUES['password'] + ) + + test_resp.set_exception(ValueError) + + with self.assertRaises(ProcessingError, + msg="expected ProcessingError exception hasn't been raised"): + AsyncZabbixAPI() + test_resp.del_exception() + + async def test_login(self): + """Tests login in different auth cases""" + + test_cases = [ + { + 'input': {'token': DEFAULT_VALUES['token']}, + 'output': DEFAULT_VALUES['token'], + 'exception': ProcessingError, + 'raised': False + }, + { + 'input': {'token': DEFAULT_VALUES['token'], 'user': DEFAULT_VALUES['user'], 'password': DEFAULT_VALUES['password']}, + 'output': None, + 'exception': ProcessingError, + 'raised': True + }, + { + 'input': {'token': DEFAULT_VALUES['token'], 'user': DEFAULT_VALUES['user']}, + 'output': None, + 'exception': ProcessingError, + 'raised': True + }, + { + 'input': {'token': DEFAULT_VALUES['token'], 'password': DEFAULT_VALUES['password']}, + 'output': None, + 'exception': ProcessingError, + 'raised': True + }, + { + 'input': {'user': DEFAULT_VALUES['user'], 'password': DEFAULT_VALUES['password']}, + 'output': DEFAULT_VALUES['session'], + 'exception': ProcessingError, + 'raised': False + }, + { + 'input': {'user': DEFAULT_VALUES['user']}, + 'output': None, + 'exception': ProcessingError, + 'raised': True + }, + { + 'input': {'password': DEFAULT_VALUES['password']}, + 'output': None, + 'exception': ProcessingError, + 'raised': True + }, + { + 'input': {}, + 'output': None, + 'exception': ProcessingError, + 'raised': True + } + ] + + for case in test_cases: + with patch.multiple( + AsyncZabbixAPI, + send_sync_request=common.mock_send_sync_request, + send_async_request=common.mock_send_async_request): + + try: + await self.zapi.login(**case['input']) + except case['exception']: + if not case['raised']: + self.fail(f"raised unexpected Exception with input data: {case['input']}") + else: + self.assertEqual(self.zapi._AsyncZabbixAPI__use_token, bool(case['input'].get('token')), + f"unexpected output with input data: {case['input']}") + self.assertEqual(self.zapi._AsyncZabbixAPI__session_id, case['output'], + f"unexpected output with input data: {case['input']}") + await self.zapi.logout() + + async with AsyncZabbixAPI(client_session=common.MockSession()) as zapi: + try: + await zapi.login(**case['input']) + except case['exception']: + if not case['raised']: + self.fail(f"raised unexpected Exception with input data: {case['input']}") + else: + if case['raised']: + self.fail(f"not raised expected Exception with input data: {case['input']}") + + self.assertEqual(zapi._AsyncZabbixAPI__use_token, bool(case['input'].get('token')), + f"unexpected output with input data: {case['input']}") + self.assertEqual(zapi._AsyncZabbixAPI__session_id, case['output'], + f"unexpected output with input data: {case['input']}") + + async def test_logout(self): + """Tests logout in different auth cases""" + + test_cases = [ + { + 'input': {'token': DEFAULT_VALUES['token']}, + 'output': None, + 'exception': ProcessingError, + 'raised': False + }, + { + 'input': {'token': DEFAULT_VALUES['token'], 'user': DEFAULT_VALUES['user'], 'password': DEFAULT_VALUES['password']}, + 'output': None, + 'exception': ProcessingError, + 'raised': True + }, + { + 'input': {'user': DEFAULT_VALUES['user'], 'password': DEFAULT_VALUES['password']}, + 'output': None, + 'exception': ProcessingError, + 'raised': False + } + ] + + for case in test_cases: + with patch.multiple( + AsyncZabbixAPI, + send_async_request=common.mock_send_async_request): + + try: + await self.zapi.login(**case['input']) + except case['exception']: + if not case['raised']: + self.fail(f"raised unexpected Exception with input data: {case['input']}") + await self.zapi.logout() + self.assertEqual(self.zapi._AsyncZabbixAPI__session_id, case['output'], + f"unexpected output with input data: {case['input']}") + + async def test_check_auth(self): + """Tests check_auth method in different auth cases""" + + test_cases = [ + { + 'input': {'token': DEFAULT_VALUES['token']}, + 'output': {'login': True, 'logout': False}, + 'exception': ProcessingError, + 'raised': False + }, + { + 'input': {'token': DEFAULT_VALUES['token'], 'user': DEFAULT_VALUES['user'], 'password': DEFAULT_VALUES['password']}, + 'output': {'login': False, 'logout': False}, + 'exception': ProcessingError, + 'raised': True + }, + { + 'input': {'user': DEFAULT_VALUES['user'], 'password': DEFAULT_VALUES['password']}, + 'output': {'login': True, 'logout': False}, + 'exception': ProcessingError, + 'raised': False + } + ] + + for case in test_cases: + with patch.multiple( + AsyncZabbixAPI, + send_async_request=common.mock_send_async_request): + + try: + await self.zapi.login(**case['input']) + except case['exception']: + if not case['raised']: + self.fail(f"raised unexpected Exception with input data: {case['input']}") + auth = await self.zapi.check_auth() + self.assertEqual(auth, case['output']['login'], + f"unexpected output with input data: {case['input']}") + await self.zapi.logout() + auth = await self.zapi.check_auth() + self.assertEqual(auth, case['output']['logout'], + f"unexpected output with input data: {case['input']}") + + async def test__prepare_request(self): + """Tests __prepare_request method in different cases""" + + with patch.multiple( + AsyncZabbixAPI, + send_async_request=common.mock_send_async_request): + await self.zapi.login(token=DEFAULT_VALUES['token']) + req, headers = self.zapi._AsyncZabbixAPI__prepare_request( + method='user.login', + params={'user': DEFAULT_VALUES['user'], 'password': DEFAULT_VALUES['password']}, + need_auth=False + ) + self.assertEqual(headers.get('Authorization'), None, + "unexpected Authorization header, must be: None") + self.assertEqual(req.get('auth'), None, + "unexpected auth request parameter, must be: None") + req, headers = self.zapi._AsyncZabbixAPI__prepare_request( + method='user.logout', + params={'user': DEFAULT_VALUES['user'], 'password': DEFAULT_VALUES['password']}, + need_auth=True + ) + self.assertEqual(headers.get('Authorization'), 'Bearer ' + DEFAULT_VALUES['token'], + "unexpected Authorization header, must be: Bearer " + DEFAULT_VALUES['token']) + self.zapi.client_session.set_auth() + req, headers = self.zapi._AsyncZabbixAPI__prepare_request( + method='user.logout', + params={'user': DEFAULT_VALUES['user'], 'password': DEFAULT_VALUES['password']}, + need_auth=True + ) + self.assertEqual(req.get('auth'), DEFAULT_VALUES['token'], + "unexpected auth request parameter, must be: " + DEFAULT_VALUES['token']) + self.zapi.client_session.del_auth() + await self.zapi.logout() + + with self.assertRaises(ProcessingError, + msg="expected ProcessingError exception hasn't been raised"): + req, headers = self.zapi._AsyncZabbixAPI__prepare_request( + method='user.logout', + params={}, + need_auth=True + ) + + def test__check_response(self): + """Tests __check_response method in different cases""" + + test_cases = [ + { + 'input': {'method': 'user.login', 'response': {'result': DEFAULT_VALUES['session']}}, + 'output': {'result': DEFAULT_VALUES['session']}, + 'exception': APIRequestError, + 'raised': False + }, + { + 'input': {'method': 'configuration.export', 'response': {'result': '...'}}, + 'output': {'result': '...'}, + 'exception': APIRequestError, + 'raised': False + }, + { + 'input': {'method': 'user.login', 'response': {'error': {'message':'Test API error', 'data':'...'}}}, + 'output': None, + 'exception': APIRequestError, + 'raised': True + } + ] + + for case in test_cases: + response = None + try: + response = self.zapi._AsyncZabbixAPI__check_response(**case['input']) + except case['exception']: + if not case['raised']: + self.fail(f"raised unexpected Exception with input data: {case['input']}") + else: + self.assertEqual(response, case['output'], + f"unexpected output with input data: {case['input']}") + + + def test_check_version(self): + """Tests __check_version method with different versions""" + + with patch.multiple( + AsyncZabbixAPI, + api_version=lambda s: APIVersion(DEFAULT_VALUES['max_version'])): + + with self.assertRaises(APINotSupported, + msg=f"version={DEFAULT_VALUES['max_version']}"): + AsyncZabbixAPI(client_session=common.MockSession()) + + try: + AsyncZabbixAPI(client_session=common.MockSession(), skip_version_check=True) + except Exception: + self.fail(f"raised unexpected Exception for version: {DEFAULT_VALUES['max_version']}") + + with patch.multiple( + AsyncZabbixAPI, + api_version=lambda s: APIVersion(DEFAULT_VALUES['min_version'])): + + with self.assertRaises(APINotSupported, + msg=f"version={DEFAULT_VALUES['min_version']}"): + AsyncZabbixAPI(client_session=common.MockSession()) + + try: + AsyncZabbixAPI(client_session=common.MockSession(), skip_version_check=True) + except Exception: + self.fail(f"raised unexpected Exception for version: {DEFAULT_VALUES['min_version']}") + + async def test_version_conditions(self): + """Tests behavior of ZabbixAPI object depending on different versions""" + + test_cases = [ + { + 'input': {'token': DEFAULT_VALUES['token']}, + 'version': '5.2.0', + 'raised': {'APINotSupported': True, 'ProcessingError': True}, + 'output': DEFAULT_VALUES['session'] + }, + { + 'input': {'token': DEFAULT_VALUES['token'], 'user': DEFAULT_VALUES['user'], 'password': DEFAULT_VALUES['password']}, + 'version': '5.2.0', + 'raised': {'APINotSupported': True, 'ProcessingError': True}, + 'output': DEFAULT_VALUES['session'] + }, + { + 'input': {'user': DEFAULT_VALUES['user'], 'password': DEFAULT_VALUES['password']}, + 'version': '5.2.0', + 'raised': {'APINotSupported': False, 'ProcessingError': False}, + 'output': DEFAULT_VALUES['session'] + }, + { + 'input': {'token': DEFAULT_VALUES['token']}, + 'version': '5.4.0', + 'raised': {'APINotSupported': False, 'ProcessingError': False}, + 'output': DEFAULT_VALUES['token'] + }, + { + 'input': {'token': DEFAULT_VALUES['token'], 'user': DEFAULT_VALUES['user'], 'password': DEFAULT_VALUES['password']}, + 'version': '5.4.0', + 'raised': {'APINotSupported': False, 'ProcessingError': True}, + 'output': DEFAULT_VALUES['token'] + }, + { + 'input': {'user': DEFAULT_VALUES['user'], 'password': DEFAULT_VALUES['password']}, + 'version': '5.4.0', + 'raised': {'APINotSupported': False, 'ProcessingError': False}, + 'output': DEFAULT_VALUES['session'] + } + ] + + for case in test_cases: + with patch.multiple( + AsyncZabbixAPI, + send_async_request=common.mock_send_async_request, + api_version=lambda s: APIVersion(case['version'])): + + try: + await self.zapi.login(**case['input']) + except ProcessingError: + if not case['raised']['ProcessingError']: + self.fail(f"raised unexpected Exception for version: {case['input']}") + except APINotSupported: + if not case['raised']['APINotSupported']: + self.fail(f"raised unexpected Exception for version: {case['input']}") + else: + if case['raised']['ProcessingError'] or case['raised']['APINotSupported']: + self.fail(f"not raised expected Exception for version: {case['version']}") + + self.assertEqual(self.zapi._AsyncZabbixAPI__session_id, case['output'], + f"unexpected output with input data: {case['input']}") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_zabbix_aiogetter.py b/tests/test_zabbix_aiogetter.py new file mode 100644 index 0000000..499e00a --- /dev/null +++ b/tests/test_zabbix_aiogetter.py @@ -0,0 +1,200 @@ +# zabbix_utils +# +# Copyright (C) 2001-2023 Zabbix SIA +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, +# merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software +# is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +import json +import socket +import asyncio +import unittest + +from tests import common +from zabbix_utils import AsyncGetter +from zabbix_utils import ProcessingError + + +DEFAULT_VALUES = common.GETTER_DEFAULTS + + +class TestAsyncGetter(unittest.IsolatedAsyncioTestCase): + """Test cases for AsyncGetter object""" + + def test_init(self): + """Tests creating of AsyncGetter object""" + + test_cases = [ + { + 'input': {'source_ip': DEFAULT_VALUES['source_ip'], 'timeout': 20}, + 'output': json.dumps({ + "host": "127.0.0.1", "port": DEFAULT_VALUES['port'], "timeout": 20, "source_ip": DEFAULT_VALUES['source_ip'], "ssl_context": None + }) + }, + { + 'input': {'host':DEFAULT_VALUES['host']}, + 'output': json.dumps({ + "host": DEFAULT_VALUES['host'], "port": DEFAULT_VALUES['port'], "timeout": 10, "source_ip": None, "ssl_context": None + }) + }, + { + 'input': {'host':DEFAULT_VALUES['host'], 'port': 10150}, + 'output': json.dumps({ + "host": DEFAULT_VALUES['host'], "port": 10150, "timeout": 10, "source_ip": None, "ssl_context": None + }) + } + ] + + for case in test_cases: + + agent = AsyncGetter(**case['input']) + + self.assertEqual(json.dumps(agent.__dict__), case['output'], + f"unexpected output with input data: {case['input']}") + + with self.assertRaises(TypeError, + msg="expected TypeError exception hasn't been raised"): + agent = AsyncGetter(ssl_context='wrapper', **case['input']) + + async def test_get_response(self): + """Tests __get_response method in different cases""" + + async def test_case(input_stream): + getter = AsyncGetter() + reader = common.MockReader() + reader.set_stream(input_stream) + return await getter._AsyncGetter__get_response(reader) + + test_cases = [ + {'input': b'ZBXD\x01\x04\x00\x00\x00\x04\x00\x00\x00test', 'output': 'test'}, + { + 'input': b'ZBXD\x01\x14\x00\x00\x00\x00\x00\x00\x00test_creating_packet', + 'output': 'test_creating_packet' + }, + { + 'input': b'ZBXD\x03\x1d\x00\x00\x00\x15\x00\x00\x00x\x9c+I-.\x89O\xce\xcf-(J-.\xce\xcc\xcf\x8bO\xcbIL\x07\x00a\xd1\x08\xcb', + 'output': 'test_compression_flag' + } + ] + + for case in test_cases: + self.assertEqual(await test_case(case['input']), case['output'], + f"unexpected output with input data: {case['input']}") + + with self.assertRaises(ProcessingError, + msg="expected ProcessingError exception hasn't been raised"): + await test_case(b'test') + + with self.assertRaises(ProcessingError, + msg="expected ProcessingError exception hasn't been raised"): + await test_case(b'ZBXD\x04\x04\x00\x00\x00\x00\x00\x00\x00test') + + with self.assertRaises(ProcessingError, + msg="expected ProcessingError exception hasn't been raised"): + await test_case(b'ZBXD\x00\x04\x00\x00\x00\x00\x00\x00\x00test') + + async def test_get(self): + """Tests get() method in different cases""" + + output = 'test_response' + response = b'ZBXD\x01\r\x00\x00\x00\x00\x00\x00\x00' + output.encode('utf-8') + + test_cases = [ + { + 'connection': {'input_stream': response}, + 'input': {}, + 'output': output, + 'raised': False + }, + { + 'connection': {'input_stream': response}, + 'input': {'source_ip': DEFAULT_VALUES['source_ip']}, + 'output': output, + 'raised': False + }, + { + 'connection': {'input_stream': response}, + 'input': {'ssl_context': common.ssl_context}, + 'output': output, + 'raised': False + }, + { + 'connection': {'input_stream': response, 'exception': TypeError}, + 'input': {'ssl_context': lambda: ''}, + 'output': output, + 'raised': True + }, + { + 'connection': {'input_stream': response, 'exception': ConnectionResetError}, + 'input': {}, + 'output': output, + 'raised': True + }, + { + 'connection': {'input_stream': response, 'exception': socket.error}, + 'input': {}, + 'output': output, + 'raised': True + }, + { + 'connection': {'input_stream': response, 'exception': socket.gaierror}, + 'input': {}, + 'output': output, + 'raised': True + }, + { + 'connection': {'input_stream': response, 'exception': asyncio.TimeoutError}, + 'input': {}, + 'output': output, + 'raised': True + } + ] + + for case in test_cases: + + async def mock_open_connection(*args, **kwargs): + reader = common.MockReader() + reader.set_stream(case['connection'].get('input_stream','')) + writer = common.MockWriter() + writer.set_exception(case['connection'].get('exception')) + return reader, writer + + with unittest.mock.patch.multiple( + asyncio, + open_connection=mock_open_connection): + + try: + getter = AsyncGetter(**case['input']) + except case['connection'].get('exception', Exception): + if not case['raised']: + self.fail(f"raised unexpected Exception with input data: {case['input']}") + + try: + resp = await getter.get('system.uname') + except case['connection'].get('exception', Exception): + if not case['raised']: + self.fail(f"raised unexpected Exception with input data: {case['input']}") + else: + self.assertEqual(resp.value, case['output'], + f"unexpected output with input data: {case['input']}") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_zabbix_aiosender.py b/tests/test_zabbix_aiosender.py new file mode 100644 index 0000000..78b9b9d --- /dev/null +++ b/tests/test_zabbix_aiosender.py @@ -0,0 +1,400 @@ +# zabbix_utils +# +# Copyright (C) 2001-2023 Zabbix SIA +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, +# merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software +# is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +import json +import socket +import asyncio +import unittest +import configparser +from unittest.mock import patch + +from tests import common +from zabbix_utils.types import ItemValue, TrapperResponse +from zabbix_utils.aiosender import AsyncSender +from zabbix_utils.exceptions import ProcessingError +from zabbix_utils.common import ZabbixProtocol + + +DEFAULT_VALUES = common.SENDER_DEFAULTS +ZABBIX_CONFIG = common.ZABBIX_CONFIG + + +class TestAsyncSender(unittest.IsolatedAsyncioTestCase): + """Test cases for AsyncSender object""" + + def test_init(self): + """Tests creating of AsyncSender object""" + + test_cases = [ + { + 'input': {'source_ip': DEFAULT_VALUES['source_ip']}, + 'clusters': json.dumps([[["127.0.0.1", DEFAULT_VALUES['port']]]]), + 'source_ip': DEFAULT_VALUES['source_ip'] + }, + { + 'input': {'server': DEFAULT_VALUES['server'], 'port': 10151}, + 'clusters': json.dumps([[[DEFAULT_VALUES['server'], 10151]]]), + 'source_ip': None + }, + { + 'input': {'server': DEFAULT_VALUES['server'], 'port': 10151, 'clusters': DEFAULT_VALUES['clusters']}, + 'clusters': json.dumps([ + [["zabbix.cluster.node1", 10051], ["zabbix.cluster.node2", 20051]], + [["zabbix.cluster2.node1", 10051], ["zabbix.cluster2.node2", 10051]], + [["zabbix.domain", 10051]], + [["localhost", 10151]] + ]), + 'source_ip': None + }, + { + 'input': {'clusters': DEFAULT_VALUES['clusters']}, + 'clusters': json.dumps([ + [["zabbix.cluster.node1", 10051], ["zabbix.cluster.node2", 20051]], + [["zabbix.cluster2.node1", 10051], ["zabbix.cluster2.node2", 10051]], + [["zabbix.domain", 10051]] + ]), + 'source_ip': None + }, + { + 'input': {'server': DEFAULT_VALUES['server'], 'port': 10151, 'use_config': True, 'config_path': ZABBIX_CONFIG[0]}, + 'clusters': json.dumps([ + [["zabbix.cluster.node1", 10051], ["zabbix.cluster.node2", 20051]], + [["zabbix.cluster2.node1", 10051], ["zabbix.cluster2.node2", 10051]], + [["zabbix.domain", 10051]] + ]), + 'source_ip': DEFAULT_VALUES['source_ip'] + }, + { + 'input': {'use_config': True, 'config_path': ZABBIX_CONFIG[1]}, + 'clusters': json.dumps([[["localhost", 10051]]]), + 'source_ip': DEFAULT_VALUES['source_ip'] + }, + { + 'input': {'use_config': True, 'config_path': ZABBIX_CONFIG[2]}, + 'clusters': json.dumps([[["127.0.0.1", 10051]]]), + 'source_ip': DEFAULT_VALUES['source_ip'] + } + ] + + def mock_load_config(self, filepath): + config = configparser.ConfigParser(strict=False) + config.read_string(filepath) + self._AsyncSender__read_config(config['root']) + + for case in test_cases: + with patch.multiple( + AsyncSender, + _AsyncSender__load_config=mock_load_config): + + sender = AsyncSender(**case['input']) + + self.assertEqual(str(sender.clusters), case['clusters'], + f"unexpected output with input data: {case['input']}") + self.assertEqual(sender.source_ip, case['source_ip'], + f"unexpected output with input data: {case['input']}") + + for cluster in sender.clusters: + for node in cluster.nodes: + self.assertEqual(str(node), repr(node), + f"unexpected node value {node} with input data: {case['input']}") + + with self.assertRaises(TypeError, + msg="expected TypeError exception hasn't been raised"): + sender = AsyncSender(ssl_context='wrapper', **case['input']) + + with self.assertRaises(TypeError, + msg="expected TypeError exception hasn't been raised"): + sender = AsyncSender(server='localhost', port='test') + + async def test_get_response(self): + """Tests __get_response method in different cases""" + + async def test_case(input_stream): + sender = AsyncSender() + reader = common.MockReader() + reader.set_stream(input_stream) + return await sender._AsyncSender__get_response(reader) + + test_cases = [ + { + 'input': b'ZBXD\x01\x53\x00\x00\x00\x00\x00\x00\x00{"request": "sender data", "data": \ +[{"host": "test", "key": "test", "value": "0"}]}', + 'output': '{"request": "sender data", "data": [{"host": "test", "key": "test", "value": "0"}]}' + }, + { + 'input': b'ZBXD\x01\x63\x00\x00\x00\x00\x00\x00\x00{"request": "sender data", "data": \ +[{"host": "test", "key": "test_creating_packet", "value": "0"}]}', + 'output': '{"request": "sender data", "data": [{"host": "test", "key": "test_creating_packet", "value": "0"}]}' + }, + { + 'input': b"ZBXD\x03Q\x00\x00\x00^\x00\x00\x00x\x9c\xabV*J-,M-.Q\ +\xb2RP*N\xcdKI-RHI,IT\xd2QP\x02\xd3V\n\xd1\xd5J\x19\xf9\x10\x05% \x85@\x99\xec\xd4J\x187>)\ +\xbf$#>-'1\xbd\x18$S\x96\x98S\x9a\n\x923P\xaa\x8d\xad\x05\x00\x9e\xb7\x1d\xdd", + 'output': '{"request": "sender data", "data": [{"host": "test", "key": "test_both_flags", "value": "0"}]}' + } + ] + + for case in test_cases: + self.assertEqual(json.dumps(await test_case(case['input'])), case['output'], + f"unexpected output with input data: {case['input']}") + + with self.assertRaises(json.decoder.JSONDecodeError, + msg="expected JSONDecodeError exception hasn't been raised"): + await test_case(b'ZBXD\x01\x04\x00\x00\x00\x04\x00\x00\x00test') + + with self.assertRaises(ProcessingError, + msg="expected ProcessingError exception hasn't been raised"): + await test_case(b'test') + + with self.assertRaises(ProcessingError, + msg="expected ProcessingError exception hasn't been raised"): + await test_case(b'ZBXD\x04\x04\x00\x00\x00\x04\x00\x00\x00test') + + with self.assertRaises(ProcessingError, + msg="expected ProcessingError exception hasn't been raised"): + await test_case(b'ZBXD\x00\x04\x00\x00\x00\x04\x00\x00\x00test') + + # Compression check + try: + await test_case(b'ZBXD\x03\x10\x00\x00\x00\x02\x00\x00\x00x\x9c\xab\xae\x05\x00\x01u\x00\xf9') + except json.decoder.JSONDecodeError: + self.fail(f"raised unexpected JSONDecodeError during the compression check") + + async def test_send(self): + """Tests send method in different cases""" + + test_cases = [ + { + 'input': {}, 'total': 5, 'failed': 2, + 'output': json.dumps({"processed": 3, "failed": 2, "total": 5, "time": "0.000100", "chunk": 1}) + }, + { + 'input': {'chunk_size': 10}, 'total': 25, 'failed': 4, + 'output': json.dumps({"processed": 21, "failed": 4, "total": 25, "time": "0.000300", "chunk": 3}) + } + ] + + async def mock_chunk_send(self, items): + return {"127.0.0.1:10051": common.response_gen(items)} + + for case in test_cases: + with patch.multiple( + AsyncSender, + _AsyncSender__chunk_send=mock_chunk_send): + + items = [] + sender = AsyncSender(**case['input']) + failed_counter = case['failed'] + for _ in range(case['total']): + if failed_counter > 0: + items.append(ItemValue('host', 'key', 'false')) + failed_counter -= 1 + else: + items.append(ItemValue('host', 'key', 'true')) + resp = await sender.send(items) + + self.assertEqual(str(resp), case['output'], + f"unexpected output with input data: {case['input']}") + + self.assertEqual(str(resp), repr(resp), + f"unexpected output with input data: {case['input']}") + + try: + processed = resp.processed + failed = resp.failed + total = resp.total + time = resp.time + chunk = resp.chunk + except Exception: + self.fail(f"raised unexpected Exception for responce: {resp}") + + self.assertEqual(type(resp.details['127.0.0.1:10051']), list, + f"unexpected output with input data: {case['input']}") + + for chunks in resp.details.values(): + for chunk in chunks: + try: + processed = chunk.processed + failed = chunk.failed + total = chunk.total + time = chunk.time + chunk = chunk.chunk + except Exception: + self.fail(f"raised unexpected Exception for responce: {chunk}") + + async def mock_chunk_send_empty(self, items): + result = {"127.0.0.1:10051": { + 'response': 'success', + 'info': 'processed: 1; failed: 0; total: 1; seconds spent: 0.000100' + }} + + return result + + with patch.multiple(AsyncSender, + _AsyncSender__chunk_send=mock_chunk_send_empty): + sender = AsyncSender() + resp = await sender.send_value('test', 'test', 1) + self.assertEqual(str(resp), '{"processed": 1, "failed": 0, "total": 1, "time": "0.000100", "chunk": 1}', + f"unexpected output with input data: {case['input']}") + + async def test_send_value(self): + """Tests send_value method in different cases""" + + request = {"host": "test_host", "key": "test_key", "value": "true", "clock": 1695713666, "ns": 100} + output = common.response_gen([request]) + response = ZabbixProtocol.create_packet(output, common.MockLogger()) + + test_cases = [ + { + 'connection': {'input_stream': response}, + 'input': {'use_ipv6': False}, + 'output': output, + 'raised': False + }, + { + 'connection': {'input_stream': response}, + 'input': {'use_ipv6': True}, + 'output': output, + 'raised': False + }, + { + 'connection': {'input_stream': response}, + 'input': {'source_ip': DEFAULT_VALUES['source_ip']}, + 'output': output, + 'raised': False + }, + { + 'connection': {'input_stream': response}, + 'input': {'ssl_context': common.ssl_context}, + 'output': output, + 'raised': False + }, + { + 'connection': {'input_stream': response, 'exception': TypeError}, + 'input': {'ssl_context': lambda x: ''}, + 'output': output, + 'raised': True + }, + { + 'connection': {'input_stream': response, 'exception': ConnectionResetError}, + 'input': {}, + 'output': output, + 'raised': True + }, + { + 'connection': {'input_stream': response, 'exception': socket.error}, + 'input': {}, + 'output': output, + 'raised': True + }, + { + 'connection': {'input_stream': response, 'exception': asyncio.TimeoutError}, + 'input': {}, + 'output': output, + 'raised': True + } + ] + + for case in test_cases: + + async def mock_open_connection(*args, **kwargs): + reader = common.MockReader() + reader.set_stream(case['connection'].get('input_stream','')) + writer = common.MockWriter() + writer.set_exception(case['connection'].get('exception')) + return reader, writer + + with unittest.mock.patch.multiple( + asyncio, + open_connection=mock_open_connection): + + sender = AsyncSender(**case['input']) + + try: + resp = await sender.send_value(**request) + except case['connection'].get('exception', Exception): + if not case['raised']: + self.fail(f"raised unexpected Exception with input data: {case['input']}") + else: + self.assertEqual(repr(resp), repr(TrapperResponse(1).add(case['output'])), + f"unexpected output with input data: {case['input']}") + + for exc in [asyncio.TimeoutError, socket.gaierror]: + + async def mock_open_connection1(*args, **kwargs): + reader = common.MockReader() + reader.set_stream(response) + reader.set_exception(exc) + writer = common.MockWriter() + return reader, writer + + async def mock_wait_for(conn, *args, **kwargs): + await conn + raise exc + + with unittest.mock.patch.multiple( + asyncio, + wait_for=mock_wait_for, + open_connection=mock_open_connection1): + + sender = AsyncSender(**case['input']) + + with self.assertRaises(ProcessingError, + msg="expected ProcessingError exception hasn't been raised"): + resp = await sender.send_value(**request) + + def test_create_request(self): + """Tests create_packet method in different cases""" + + test_cases = [ + { + 'input': {'items':[ItemValue('test', 'glāžšķūņu rūķīši', 0)]}, + 'compression': False, + 'output': b'ZBXD\x01i\x00\x00\x00\x00\x00\x00\x00{"request": "sender data", "data": \ +[{"host": "test", "key": "gl\xc4\x81\xc5\xbe\xc5\xa1\xc4\xb7\xc5\xab\xc5\x86u r\xc5\xab\xc4\xb7\xc4\xab\xc5\xa1i", "value": "0"}]}' + }, + { + 'input': {'items':[ItemValue('test', 'test_creating_packet', 0)]}, + 'compression': False, + 'output': b'ZBXD\x01\x63\x00\x00\x00\x00\x00\x00\x00{"request": "sender data", "data": \ +[{"host": "test", "key": "test_creating_packet", "value": "0"}]}' + }, + { + 'input': {'items':[ItemValue('test', 'test_compression_flag', 0)]}, + 'compression': True, + 'output': b"ZBXD\x03W\x00\x00\x00d\x00\x00\x00x\x9c\xabV*J-,M-.Q\xb2RP*N\ +\xcdKI-RHI,IT\xd2QP\x02\xd3V\n\xd1\xd5J\x19\xf9\x10\x05% \x85@\x99\xec\xd4J\x187>9?\xb7\xa0\ +(\xb5\xb883?/>-'1\x1d$_\x96\x98S\x9a\nRa\xa0T\x1b[\x0b\x00l\xbf o" + } + ] + + for case in test_cases: + + resp = ZabbixProtocol.create_packet(AsyncSender()._AsyncSender__create_request(**case['input']), common.MockLogger(), case['compression']) + self.assertEqual(resp, case['output'], + f"unexpected output with input data: {case['input']}") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_zabbix_api.py b/tests/test_zabbix_api.py index 908305f..49cfa43 100644 --- a/tests/test_zabbix_api.py +++ b/tests/test_zabbix_api.py @@ -22,50 +22,54 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR # OTHER DEALINGS IN THE SOFTWARE. -import json import unittest +import urllib.request as ul from unittest.mock import patch -from zabbix_utils.api import ZabbixAPI, APIVersion -from zabbix_utils.common import ModuleUtils -from zabbix_utils.version import __min_supported__, __max_supported__ +from tests import common +from zabbix_utils.api import ZabbixAPI +from zabbix_utils.types import APIVersion from zabbix_utils.exceptions import APINotSupported, ProcessingError -DEFAULT_VALUES = { - 'user': 'Admin', - 'password': 'zabbix', - 'token': 'oTmtWu', - 'session': 'cc364fb50199c5e305aa91785b7e49a0', - 'max_version': "{}.0".format(__max_supported__ + .2), - 'min_version': "{}.0".format(__min_supported__ - .2) -} - - -def mock_send_api_request(self, method, *args, **kwargs): - """Mock for send_api_request method - - Args: - method (str): Zabbix API method name. - - params (dict, optional): Params for request body. Defaults to {}. - - need_auth (bool, optional): Authorization using flag. Defaults to False. - """ - result = {} - if method == 'apiinfo.version': - result = f"{__max_supported__}.0" - elif method == 'user.login': - result = DEFAULT_VALUES['session'] - elif method == 'user.logout': - result = True - elif method == 'user.checkAuthentication': - result = {'userid': 42} - return {'jsonrpc': '2.0', 'result': result, 'id': 1} +DEFAULT_VALUES = common.API_DEFAULTS class TestZabbixAPI(unittest.TestCase): """Test cases for ZabbixAPI object""" + + def test_init(self): + """Tests creating of AsyncZabbixAPI object""" + + test_resp = common.MockAPIResponse() + + def mock_urlopen(*args, **kwargs): + return test_resp + + with unittest.mock.patch.multiple( + ul, + urlopen=mock_urlopen): + + zapi = ZabbixAPI( + http_user=DEFAULT_VALUES['user'], + http_password=DEFAULT_VALUES['password'] + ) + with self.assertRaises(ProcessingError, + msg="expected ProcessingError exception hasn't been raised"): + zapi.hosts.get() + + zapi.login( + user=DEFAULT_VALUES['user'], + password=DEFAULT_VALUES['password'] + ) + zapi.hosts.get() + + test_resp.set_exception(ValueError) + + with self.assertRaises(ProcessingError, + msg="expected ProcessingError exception hasn't been raised"): + ZabbixAPI() + test_resp.del_exception() def test_login(self): """Tests login in different auth cases""" @@ -97,7 +101,7 @@ def test_login(self): }, { 'input': {'user': DEFAULT_VALUES['user'], 'password': DEFAULT_VALUES['password']}, - 'output': 'cc364fb50199c5e305aa91785b7e49a0', + 'output': DEFAULT_VALUES['session'], 'exception': ProcessingError, 'raised': False }, @@ -124,7 +128,7 @@ def test_login(self): for case in test_cases: with patch.multiple( ZabbixAPI, - send_api_request=mock_send_api_request): + send_api_request=common.mock_send_sync_request): try: zapi = ZabbixAPI(**case['input']) @@ -134,8 +138,8 @@ def test_login(self): else: self.assertEqual(zapi._ZabbixAPI__use_token, bool(case['input'].get('token')), f"unexpected output with input data: {case['input']}") - self.assertEqual(zapi._ZabbixAPI__session_id, case['output'], - f"unexpected output with input data: {case['input']}") + self.assertEqual(zapi._ZabbixAPI__session_id, case['output'], + f"unexpected output with input data: {case['input']}") with ZabbixAPI() as zapi: try: @@ -147,10 +151,20 @@ def test_login(self): if case['raised']: self.fail(f"not raised expected Exception with input data: {case['input']}") - self.assertEqual(zapi._ZabbixAPI__session_id, case['output'], - f"unexpected output with input data: {case['input']}") self.assertEqual(zapi._ZabbixAPI__use_token, bool(case['input'].get('token')), f"unexpected output with input data: {case['input']}") + self.assertEqual(zapi._ZabbixAPI__session_id, case['output'], + f"unexpected output with input data: {case['input']}") + + with patch.multiple( + ZabbixAPI, + send_api_request=common.mock_send_sync_request): + + zapi = ZabbixAPI(http_user=DEFAULT_VALUES['user'], http_password=DEFAULT_VALUES['password']) + + with self.assertRaises(TypeError, msg="expected TypeError exception hasn't been raised"): + zapi = ZabbixAPI() + zapi.user.login(DEFAULT_VALUES['user'], password=DEFAULT_VALUES['password']) def test_logout(self): """Tests logout in different auth cases""" @@ -179,7 +193,7 @@ def test_logout(self): for case in test_cases: with patch.multiple( ZabbixAPI, - send_api_request=mock_send_api_request): + send_api_request=common.mock_send_sync_request): try: zapi = ZabbixAPI(**case['input']) @@ -217,7 +231,7 @@ def test_check_auth(self): for case in test_cases: with patch.multiple( ZabbixAPI, - send_api_request=mock_send_api_request): + send_api_request=common.mock_send_sync_request): try: zapi = ZabbixAPI(**case['input']) @@ -306,7 +320,7 @@ def test_version_conditions(self): for case in test_cases: with patch.multiple( ZabbixAPI, - send_api_request=mock_send_api_request, + send_api_request=common.mock_send_sync_request, api_version=lambda s: APIVersion(case['version'])): try: @@ -325,229 +339,5 @@ def test_version_conditions(self): f"unexpected output with input data: {case['input']}") -class TestAPIVersion(unittest.TestCase): - """Test cases for APIVersion object""" - - def test_init(self): - """Tests creating of APIVersion object""" - - test_cases = [ - {'input': '7.0.0alpha', 'output': '7.0.0alpha', 'exception': TypeError, 'raised': True}, - {'input': '6.0.0', 'output': '6.0.0', 'exception': TypeError, 'raised': False}, - {'input': '6.0', 'output': None, 'exception': TypeError, 'raised': True}, - {'input': '7', 'output': None, 'exception': TypeError, 'raised': True} - ] - - for case in test_cases: - try: - ver = APIVersion(case['input']) - except ValueError: - if not case['raised']: - self.fail(f"raised unexpected Exception with input data: {case['input']}") - else: - if case['raised']: - self.fail(f"not raised expected Exception with input data: {case['input']}") - self.assertEqual(str(ver), case['output'], - f"unexpected output with input data: {case['input']}") - - def test_major(self): - """Tests getting the major version part of APIVersion""" - - test_cases = [ - {'input': '6.0.10', 'output': 6.0}, - {'input': '6.2.0', 'output': 6.2} - ] - - for case in test_cases: - ver = APIVersion(case['input']) - self.assertEqual(ver.major, case['output'], - f"unexpected output with input data: {case['input']}") - - def test_minor(self): - """Tests getting the minor version part of APIVersion""" - - test_cases = [ - {'input': '6.0.10', 'output': 10}, - {'input': '6.2.0', 'output': 0} - ] - - for case in test_cases: - ver = APIVersion(case['input']) - self.assertEqual(ver.minor, case['output'], - f"unexpected output with input data: {case['input']}") - - def test_is_lts(self): - """Tests is_lts method for different versions""" - - test_cases = [ - {'input': '6.0.10', 'output': True}, - {'input': '6.2.0', 'output': False}, - {'input': '6.4.5', 'output': False}, - {'input': '7.0.0', 'output': True}, - {'input': '7.0.30', 'output': True} - ] - - for case in test_cases: - ver = APIVersion(case['input']) - self.assertEqual(ver.is_lts(), case['output'], - f"unexpected output with input data: {case['input']}") - - def test_compare(self): - """Tests version comparison for different version formats""" - - test_cases = [ - {'input': ['6.0.0','6.0.0'], 'operation': 'eq', 'output': True}, - {'input': ['6.0.0',6.0], 'operation': 'ne', 'output': False}, - {'input': ['6.0.0',6.0], 'operation': 'ge', 'output': True}, - {'input': ['6.0.0',7.0], 'operation': 'lt', 'output': True}, - {'input': ['6.4.1',6.4], 'operation': 'gt', 'output': False} - ] - - for case in test_cases: - ver = APIVersion(case['input'][0]) - result = (getattr(ver, f"__{case['operation']}__")(case['input'][1])) - self.assertEqual(result, case['output'], - f"unexpected output with input data: {case['input']}") - - ver = APIVersion('6.0.0') - with self.assertRaises(TypeError, - msg=f"input data={case['input']}"): - ver > {} - - with self.assertRaises(TypeError, - msg=f"input data={case['input']}"): - ver < [] - - with self.assertRaises(TypeError, - msg=f"input data={case['input']}"): - ver < 6 - - with self.assertRaises(TypeError, - msg=f"input data={case['input']}"): - ver != 7 - - with self.assertRaises(ValueError, - msg=f"input data={case['input']}"): - ver <= '7.0' - - -class TestModuleUtils(unittest.TestCase): - """Test cases for ModuleUtils class""" - - def test_check_url(self): - """Tests check_url method in different cases""" - - filename = ModuleUtils.JSONRPC_FILE - - test_cases = [ - {'input': '127.0.0.1', 'output': f"http://127.0.0.1/{filename}"}, - {'input': 'https://localhost', 'output': f"https://localhost/{filename}"}, - {'input': 'localhost/zabbix', 'output': f"http://localhost/zabbix/{filename}"}, - {'input': 'localhost/', 'output': f"http://localhost/{filename}"}, - {'input': f"127.0.0.1/{filename}", 'output': f"http://127.0.0.1/{filename}"} - ] - - for case in test_cases: - result = ModuleUtils.check_url(case['input']) - self.assertEqual(result, case['output'], - f"unexpected output with input data: {case['input']}") - - def test_mask_secret(self): - """Tests mask_secret method in different cases""" - - mask = ModuleUtils.HIDING_MASK - - test_cases = [ - {'input': {'string': 'lZSwaQ', 'show_len': 5}, 'output': mask}, - {'input': {'string': 'ZWvaGS5SzNGaR990f', 'show_len': 4}, 'output': f"ZWva{mask}990f"}, - {'input': {'string': 'KZneJzgRzdlWcUjJj', 'show_len': 10}, 'output': mask}, - {'input': {'string': 'g5imzEr7TPcBG47fa', 'show_len': 20}, 'output': mask}, - {'input': {'string': 'In8y4eGughjBNSqEGPcqzejToVUT3OA4q5', 'show_len':2}, 'output': f"In{mask}q5"}, - {'input': {'string': 'Z8pZom5EVbRZ0W5wz', 'show_len':0}, 'output': mask} - ] - - for case in test_cases: - result = ModuleUtils.mask_secret(**case['input']) - self.assertEqual(result, case['output'], - f"unexpected output with input data: {case['input']}") - - def test_hide_private(self): - """Tests hide_private method in different cases""" - - mask = ModuleUtils.HIDING_MASK - - test_cases = [ - { - 'input': [{"auth": "q2BTIw85kqmjtXl3","token": "jZAC51wHuWdwvQnx"}], - 'output': {"auth": mask, "token": mask} - }, - { - 'input': [{"token": "jZAC51wHuWdwvQnxwbP2T55vh6R5R2uW"}], - 'output': {"token": f"jZAC{mask}R2uW"} - }, - { - 'input': [{"auth": "q2BTIw85kqmjtXl3zCgSSR26gwCGVFMK"}], - 'output': {"auth": f"q2BT{mask}VFMK"} - }, - { - 'input': [{"sessionid": "p1xqXSf2HhYWa2ml6R5R2uWwbP2T55vh"}], - 'output': {"sessionid": f"p1xq{mask}55vh"} - }, - { - 'input': [{"password": "HlphkcKgQKvofQHP"}], - 'output': {"password": mask} - }, - { - 'input': [{"result": "p1xqXSf2HhYWa2ml6R5R2uWwbP2T55vh"}], - 'output': {"result": f"p1xq{mask}55vh"} - }, - { - 'input': [{"result": "6.0.0"}], - 'output': {"result": "6.0.0"} - }, - { - 'input': [{"result": ["10"]}], - 'output': {"result": ["10"]} - }, - { - 'input': [{"result": [{"token": "jZAC51wHuWdwvQnxwbP2T55vh6R5R2uW"}]}], - 'output': {"result": [{"token": f"jZAC{mask}R2uW"}]} - }, - { - 'input': [{"result": [["10"],["15"]]}], - 'output': {"result": [["10"],["15"]]} - }, - { - 'input': [{"result": [[{"token": "jZAC51wHuWdwvQnxwbP2T55vh6R5R2uW"}]]}], - 'output': {"result": [[{"token": f"jZAC{mask}R2uW"}]]} - }, - { - 'input': [{"result": ["jZAC51wHuWdwvQnxwbP2T55vh6R5R2uW"]}], - 'output': {"result": [f"jZAC{mask}R2uW"]} - }, - { - 'input': [{"result": {"passwords": ["HlphkcKgQKvofQHP"]}}], - 'output': {"result": {"passwords": [mask]}} - }, - { - 'input': [{"result": {"passwords": ["HlphkcKgQKvofQHP"]}}, {}], - 'output': {"result": {"passwords": ["HlphkcKgQKvofQHP"]}} - }, - { - 'input': [{"result": {"tokens": ["jZAC51wHuWdwvQnxwbP2T55vh6R5R2uW"]}}], - 'output': {"result": {"tokens": [f"jZAC{mask}R2uW"]}} - }, - { - 'input': [{"result": ["jZAC51wHuWdwvQnxwbP2T55vh6R5R2uW"]}, {}], - 'output': {"result": [f"jZAC51wHuWdwvQnxwbP2T55vh6R5R2uW"]} - } - ] - - for case in test_cases: - result = ModuleUtils.hide_private(*case['input']) - self.assertEqual(result, case['output'], - f"unexpected output with input data: {case['input']}") - - if __name__ == '__main__': unittest.main() diff --git a/tests/test_zabbix_common.py b/tests/test_zabbix_common.py new file mode 100644 index 0000000..8cb15ba --- /dev/null +++ b/tests/test_zabbix_common.py @@ -0,0 +1,184 @@ +# zabbix_utils +# +# Copyright (C) 2001-2023 Zabbix SIA +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, +# merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software +# is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +import unittest + +from zabbix_utils.common import ModuleUtils, ZabbixProtocol + + +class TestModuleUtils(unittest.TestCase): + """Test cases for ModuleUtils class""" + + def test_check_url(self): + """Tests check_url method in different cases""" + + filename = ModuleUtils.JSONRPC_FILE + + test_cases = [ + {'input': '127.0.0.1', 'output': f"http://127.0.0.1/{filename}"}, + {'input': 'https://localhost', 'output': f"https://localhost/{filename}"}, + {'input': 'localhost/zabbix', 'output': f"http://localhost/zabbix/{filename}"}, + {'input': 'localhost/', 'output': f"http://localhost/{filename}"}, + {'input': f"127.0.0.1/{filename}", 'output': f"http://127.0.0.1/{filename}"} + ] + + for case in test_cases: + result = ModuleUtils.check_url(case['input']) + self.assertEqual(result, case['output'], + f"unexpected output with input data: {case['input']}") + + def test_mask_secret(self): + """Tests mask_secret method in different cases""" + + mask = ModuleUtils.HIDING_MASK + + test_cases = [ + {'input': {'string': 'lZSwaQ', 'show_len': 5}, 'output': mask}, + {'input': {'string': 'ZWvaGS5SzNGaR990f', 'show_len': 4}, 'output': f"ZWva{mask}990f"}, + {'input': {'string': 'KZneJzgRzdlWcUjJj', 'show_len': 10}, 'output': mask}, + {'input': {'string': 'g5imzEr7TPcBG47fa', 'show_len': 20}, 'output': mask}, + {'input': {'string': 'In8y4eGughjBNSqEGPcqzejToVUT3OA4q5', 'show_len':2}, 'output': f"In{mask}q5"}, + {'input': {'string': 'Z8pZom5EVbRZ0W5wz', 'show_len':0}, 'output': mask} + ] + + for case in test_cases: + result = ModuleUtils.mask_secret(**case['input']) + self.assertEqual(result, case['output'], + f"unexpected output with input data: {case['input']}") + + def test_hide_private(self): + """Tests hide_private method in different cases""" + + mask = ModuleUtils.HIDING_MASK + + test_cases = [ + { + 'input': [{"auth": "q2BTIw85kqmjtXl3","token": "jZAC51wHuWdwvQnx"}], + 'output': {"auth": mask, "token": mask} + }, + { + 'input': [{"token": "jZAC51wHuWdwvQnxwbP2T55vh6R5R2uW"}], + 'output': {"token": f"jZAC{mask}R2uW"} + }, + { + 'input': [{"auth": "q2BTIw85kqmjtXl3zCgSSR26gwCGVFMK"}], + 'output': {"auth": f"q2BT{mask}VFMK"} + }, + { + 'input': [{"sessionid": "p1xqXSf2HhYWa2ml6R5R2uWwbP2T55vh"}], + 'output': {"sessionid": f"p1xq{mask}55vh"} + }, + { + 'input': [{"password": "HlphkcKgQKvofQHP"}], + 'output': {"password": mask} + }, + { + 'input': [{"result": "p1xqXSf2HhYWa2ml6R5R2uWwbP2T55vh"}], + 'output': {"result": f"p1xq{mask}55vh"} + }, + { + 'input': [{"result": "6.0.0"}], + 'output': {"result": "6.0.0"} + }, + { + 'input': [{"result": ["10"]}], + 'output': {"result": ["10"]} + }, + { + 'input': [{"result": [{"token": "jZAC51wHuWdwvQnxwbP2T55vh6R5R2uW"}]}], + 'output': {"result": [{"token": f"jZAC{mask}R2uW"}]} + }, + { + 'input': [{"result": [["10"],["15"]]}], + 'output': {"result": [["10"],["15"]]} + }, + { + 'input': [{"result": [[{"token": "jZAC51wHuWdwvQnxwbP2T55vh6R5R2uW"}]]}], + 'output': {"result": [[{"token": f"jZAC{mask}R2uW"}]]} + }, + { + 'input': [{"result": ["jZAC51wHuWdwvQnxwbP2T55vh6R5R2uW"]}], + 'output': {"result": [f"jZAC{mask}R2uW"]} + }, + { + 'input': [{"result": {"passwords": ["HlphkcKgQKvofQHP"]}}], + 'output': {"result": {"passwords": [mask]}} + }, + { + 'input': [{"result": {"passwords": ["HlphkcKgQKvofQHP"]}}, {}], + 'output': {"result": {"passwords": ["HlphkcKgQKvofQHP"]}} + }, + { + 'input': [{"result": {"tokens": ["jZAC51wHuWdwvQnxwbP2T55vh6R5R2uW"]}}], + 'output': {"result": {"tokens": [f"jZAC{mask}R2uW"]}} + }, + { + 'input': [{"result": ["jZAC51wHuWdwvQnxwbP2T55vh6R5R2uW"]}, {}], + 'output': {"result": [f"jZAC51wHuWdwvQnxwbP2T55vh6R5R2uW"]} + } + ] + + for case in test_cases: + result = ModuleUtils.hide_private(*case['input']) + self.assertEqual(result, case['output'], + f"unexpected output with input data: {case['input']}") + + +class TestZabbixProtocol(unittest.TestCase): + """Test cases for ZabbixProtocol object""" + + def test_create_packet(self): + """Tests create_packet method in different cases""" + + class Logger(): + def debug(self, *args, **kwargs): + pass + + test_cases = [ + { + 'input': {'payload':'test', 'log':Logger()}, + 'output': b'ZBXD\x01\x04\x00\x00\x00\x00\x00\x00\x00test' + }, + { + 'input': {'payload':'test_creating_packet', 'log':Logger()}, + 'output': b'ZBXD\x01\x14\x00\x00\x00\x00\x00\x00\x00test_creating_packet' + }, + { + 'input': {'payload':'test_compression_flag', 'log':Logger()}, + 'output': b'ZBXD\x01\x15\x00\x00\x00\x00\x00\x00\x00test_compression_flag' + }, + { + 'input': {'payload':'glāžšķūņu rūķīši', 'log':Logger()}, + 'output': b'ZBXD\x01\x1a\x00\x00\x00\x00\x00\x00\x00gl\xc4\x81\xc5\xbe\xc5\xa1\xc4\xb7\xc5\xab\xc5\x86u r\xc5\xab\xc4\xb7\xc4\xab\xc5\xa1i' + } + ] + + for case in test_cases: + resp = ZabbixProtocol.create_packet(**case['input']) + self.assertEqual(resp, case['output'], + f"unexpected output with input data: {case['input']}") + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_zabbix_get.py b/tests/test_zabbix_getter.py similarity index 53% rename from tests/test_zabbix_get.py rename to tests/test_zabbix_getter.py index 20956b8..e20908a 100644 --- a/tests/test_zabbix_get.py +++ b/tests/test_zabbix_getter.py @@ -26,16 +26,13 @@ import socket import unittest +from tests import common from zabbix_utils import Getter from zabbix_utils import ProcessingError -from zabbix_utils.common import ZabbixProtocol -DEFAULT_VALUES = { - 'host': 'localhost', - 'port': 10050, - 'source_ip': '192.168.1.1' -} +DEFAULT_VALUES = common.GETTER_DEFAULTS + class TestGetter(unittest.TestCase): """Test cases for Getter object""" @@ -45,21 +42,21 @@ def test_init(self): test_cases = [ { - 'input': {'source_ip': '10.10.0.0', 'timeout': 20}, + 'input': {'source_ip': DEFAULT_VALUES['source_ip'], 'timeout': 20}, 'output': json.dumps({ - "host": "127.0.0.1", "port": 10050, "timeout": 20, "use_ipv6": False, "source_ip": "10.10.0.0", "socket_wrapper": None + "host": "127.0.0.1", "port": DEFAULT_VALUES['port'], "timeout": 20, "use_ipv6": False, "source_ip": DEFAULT_VALUES['source_ip'], "socket_wrapper": None }) }, { - 'input': {'host':'localhost', 'use_ipv6': True}, + 'input': {'host':DEFAULT_VALUES['host']}, 'output': json.dumps({ - "host": "localhost", "port": 10050, "timeout": 10, "use_ipv6": True, "source_ip": None, "socket_wrapper": None + "host": DEFAULT_VALUES['host'], "port": DEFAULT_VALUES['port'], "timeout": 10, "use_ipv6": False, "source_ip": None, "socket_wrapper": None }) }, { - 'input': {'host':'localhost', 'port': 10150}, + 'input': {'host':DEFAULT_VALUES['host'], 'port': 10150}, 'output': json.dumps({ - "host": "localhost", "port": 10150, "timeout": 10, "use_ipv6": False, "source_ip": None, "socket_wrapper": None + "host": DEFAULT_VALUES['host'], "port": 10150, "timeout": 10, "use_ipv6": False, "source_ip": None, "socket_wrapper": None }) } ] @@ -70,7 +67,7 @@ def test_init(self): self.assertEqual(json.dumps(agent.__dict__), case['output'], f"unexpected output with input data: {case['input']}") - + with self.assertRaises(TypeError, msg="expected TypeError exception hasn't been raised"): agent = Getter(socket_wrapper='wrapper', **case['input']) @@ -90,21 +87,10 @@ def test_get_response(self): } ] - class ConnectTest(): - def __init__(self, input): - self.input = input - self.stream = input - def recv(self, len): - resp = self.stream[0:len] - self.stream = self.stream[len:] - return resp - def close(self): - raise socket.error("test error") - for case in test_cases: getter = Getter() - conn = ConnectTest(case['input']) + conn = common.MockConnector(case['input']) self.assertEqual(getter._Getter__get_response(conn), case['output'], f"unexpected output with input data: {case['input']}") @@ -112,55 +98,87 @@ def close(self): with self.assertRaises(ProcessingError, msg="expected ProcessingError exception hasn't been raised"): getter = Getter() - conn = ConnectTest(b'test') + conn = common.MockConnector(b'test') getter._Getter__get_response(conn) with self.assertRaises(ProcessingError, msg="expected ProcessingError exception hasn't been raised"): getter = Getter() - conn = ConnectTest(b'ZBXD\x04\x04\x00\x00\x00\x00\x00\x00\x00test') + conn = common.MockConnector(b'ZBXD\x04\x04\x00\x00\x00\x00\x00\x00\x00test') getter._Getter__get_response(conn) with self.assertRaises(ProcessingError, msg="expected ProcessingError exception hasn't been raised"): getter = Getter() - conn = ConnectTest(b'ZBXD\x00\x04\x00\x00\x00\x00\x00\x00\x00test') + conn = common.MockConnector(b'ZBXD\x00\x04\x00\x00\x00\x00\x00\x00\x00test') getter._Getter__get_response(conn) + def test_get(self): + """Tests get() method in different cases""" -class TestZabbixProtocol(unittest.TestCase): - """Test cases for ZabbixProtocol object""" - - def test_create_packet(self): - """Tests create_packet method in different cases""" - - class Logger(): - def debug(self, *args, **kwargs): - pass + output = 'test_response' + response = b'ZBXD\x01\r\x00\x00\x00\x00\x00\x00\x00' + output.encode('utf-8') test_cases = [ { - 'input': {'payload':'test', 'log':Logger()}, - 'output': b'ZBXD\x01\x04\x00\x00\x00\x00\x00\x00\x00test' + 'connection': {'input_stream': response}, + 'input': {'use_ipv6': False}, + 'output': output, + 'raised': False + }, + { + 'connection': {'input_stream': response}, + 'input': {'use_ipv6': True}, + 'output': output, + 'raised': False + }, + { + 'connection': {'input_stream': response}, + 'input': {'source_ip': '127.0.0.1'}, + 'output': output, + 'raised': False }, { - 'input': {'payload':'test_creating_packet', 'log':Logger()}, - 'output': b'ZBXD\x01\x14\x00\x00\x00\x00\x00\x00\x00test_creating_packet' + 'connection': {'input_stream': response}, + 'input': {'socket_wrapper': common.socket_wrapper}, + 'output': output, + 'raised': False }, { - 'input': {'payload':'test_compression_flag', 'log':Logger()}, - 'output': b'ZBXD\x01\x15\x00\x00\x00\x00\x00\x00\x00test_compression_flag' + 'connection': {'input_stream': response, 'exception': socket.error}, + 'input': {}, + 'output': output, + 'raised': True }, { - 'input': {'payload':'glāžšķūņu rūķīši', 'log':Logger()}, - 'output': b'ZBXD\x01\x1a\x00\x00\x00\x00\x00\x00\x00gl\xc4\x81\xc5\xbe\xc5\xa1\xc4\xb7\xc5\xab\xc5\x86u r\xc5\xab\xc4\xb7\xc4\xab\xc5\xa1i' + 'connection': {'input_stream': response, 'exception': socket.gaierror}, + 'input': {}, + 'output': output, + 'raised': True + }, + { + 'connection': {'input_stream': response, 'exception': socket.timeout}, + 'input': {}, + 'output': output, + 'raised': True } ] for case in test_cases: - resp = ZabbixProtocol.create_packet(**case['input']) - self.assertEqual(resp, case['output'], - f"unexpected output with input data: {case['input']}") + with unittest.mock.patch('socket.socket') as mock_socket: + test_connector = common.MockConnector(**case['connection']) + mock_socket.return_value.recv = test_connector.recv + mock_socket.return_value.sendall = test_connector.sendall + getter = Getter(**case['input']) + + try: + resp = getter.get('system.uname') + except case['connection'].get('exception', Exception): + if not case['raised']: + self.fail(f"raised unexpected Exception with input data: {case['input']}") + else: + self.assertEqual(resp.value, case['output'], + f"unexpected output with input data: {case['input']}") if __name__ == '__main__': diff --git a/tests/test_zabbix_sender.py b/tests/test_zabbix_sender.py index 4ea1759..e9914c9 100644 --- a/tests/test_zabbix_sender.py +++ b/tests/test_zabbix_sender.py @@ -28,38 +28,16 @@ import configparser from unittest.mock import patch -from zabbix_utils.sender import Sender, Cluster, ItemValue +from tests import common +from zabbix_utils.sender import Sender +from zabbix_utils.types import ItemValue, TrapperResponse from zabbix_utils.exceptions import ProcessingError from zabbix_utils.common import ZabbixProtocol -DEFAULT_VALUES = { - 'server': 'localhost', - 'port': 10051, - 'source_ip': '192.168.1.1', - 'clusters': [ - ['zabbix.cluster.node1','zabbix.cluster.node2:20051'], - ['zabbix.cluster2.node1','zabbix.cluster2.node2'], - ['zabbix.domain'] - ] -} - -ZABBIX_CONFIG = [ - f"""[root] -ServerActive=zabbix.cluster.node1;zabbix.cluster.node2:20051,zabbix.cluster2.node1;zabbix.cluster2.node2,zabbix.domain -Server={DEFAULT_VALUES['server']} -SourceIP={DEFAULT_VALUES['source_ip']} -TLSConnect=unencrypted -TLSAccept=unencrypted -""", - f"""[root] -Server={DEFAULT_VALUES['server']} -SourceIP={DEFAULT_VALUES['source_ip']} -""", - f"""[root] -SourceIP={DEFAULT_VALUES['source_ip']} -""" -] +DEFAULT_VALUES = common.SENDER_DEFAULTS +ZABBIX_CONFIG = common.ZABBIX_CONFIG + class TestSender(unittest.TestCase): """Test cases for Sender object""" @@ -69,17 +47,17 @@ def test_init(self): test_cases = [ { - 'input': {'source_ip': '10.10.0.0'}, - 'clusters': json.dumps([[["127.0.0.1", 10051]]]), - 'source_ip': '10.10.0.0' + 'input': {'source_ip': DEFAULT_VALUES['source_ip']}, + 'clusters': json.dumps([[["127.0.0.1", DEFAULT_VALUES['port']]]]), + 'source_ip': DEFAULT_VALUES['source_ip'] }, { - 'input': {'server':'localhost', 'port': 10151}, - 'clusters': json.dumps([[["localhost", 10151]]]), + 'input': {'server': DEFAULT_VALUES['server'], 'port': 10151}, + 'clusters': json.dumps([[[DEFAULT_VALUES['server'], 10151]]]), 'source_ip': None }, { - 'input': {'server':'localhost', 'port': 10151, 'clusters': DEFAULT_VALUES['clusters']}, + 'input': {'server': DEFAULT_VALUES['server'], 'port': 10151, 'clusters': DEFAULT_VALUES['clusters']}, 'clusters': json.dumps([ [["zabbix.cluster.node1", 10051], ["zabbix.cluster.node2", 20051]], [["zabbix.cluster2.node1", 10051], ["zabbix.cluster2.node2", 10051]], @@ -98,7 +76,7 @@ def test_init(self): 'source_ip': None }, { - 'input': {'server':'localhost', 'port': 10151, 'use_config': True, 'config_path': ZABBIX_CONFIG[0]}, + 'input': {'server': DEFAULT_VALUES['server'], 'port': 10151, 'use_config': True, 'config_path': ZABBIX_CONFIG[0]}, 'clusters': json.dumps([ [["zabbix.cluster.node1", 10051], ["zabbix.cluster.node2", 20051]], [["zabbix.cluster2.node1", 10051], ["zabbix.cluster2.node2", 10051]], @@ -170,21 +148,10 @@ def test_get_response(self): } ] - class ConnectTest(): - def __init__(self, input): - self.input = input - self.stream = input - def recv(self, len): - resp = self.stream[0:len] - self.stream = self.stream[len:] - return resp - def close(self): - raise socket.error("test error") - for case in test_cases: sender = Sender() - conn = ConnectTest(case['input']) + conn = common.MockConnector(case['input']) self.assertEqual(json.dumps(sender._Sender__get_response(conn)), case['output'], f"unexpected output with input data: {case['input']}") @@ -192,31 +159,31 @@ def close(self): with self.assertRaises(json.decoder.JSONDecodeError, msg="expected JSONDecodeError exception hasn't been raised"): sender = Sender() - conn = ConnectTest(b'ZBXD\x01\x04\x00\x00\x00\x04\x00\x00\x00test') + conn = common.MockConnector(b'ZBXD\x01\x04\x00\x00\x00\x04\x00\x00\x00test') sender._Sender__get_response(conn) with self.assertRaises(ProcessingError, msg="expected ProcessingError exception hasn't been raised"): sender = Sender() - conn = ConnectTest(b'test') + conn = common.MockConnector(b'test') sender._Sender__get_response(conn) with self.assertRaises(ProcessingError, msg="expected ProcessingError exception hasn't been raised"): sender = Sender() - conn = ConnectTest(b'ZBXD\x04\x04\x00\x00\x00\x04\x00\x00\x00test') + conn = common.MockConnector(b'ZBXD\x04\x04\x00\x00\x00\x04\x00\x00\x00test') sender._Sender__get_response(conn) with self.assertRaises(ProcessingError, msg="expected ProcessingError exception hasn't been raised"): sender = Sender() - conn = ConnectTest(b'ZBXD\x00\x04\x00\x00\x00\x04\x00\x00\x00test') + conn = common.MockConnector(b'ZBXD\x00\x04\x00\x00\x00\x04\x00\x00\x00test') sender._Sender__get_response(conn) # Compression check try: sender = Sender() - conn = ConnectTest(b'ZBXD\x03\x10\x00\x00\x00\x02\x00\x00\x00x\x9c\xab\xae\x05\x00\x01u\x00\xf9') + conn = common.MockConnector(b'ZBXD\x03\x10\x00\x00\x00\x02\x00\x00\x00x\x9c\xab\xae\x05\x00\x01u\x00\xf9') sender._Sender__get_response(conn) except json.decoder.JSONDecodeError: self.fail(f"raised unexpected JSONDecodeError during the compression check") @@ -236,18 +203,7 @@ def test_send(self): ] def mock_chunk_send(self, items): - info = { - 'processed': len([json.loads(i.value) for i in items if json.loads(i.value)]), - 'failed': len([json.loads(i.value) for i in items if not json.loads(i.value)]), - 'total': len(items), - 'seconds spent': '0.000100' - } - result = {"127.0.0.1:10051": { - 'response': 'success', - 'info': '; '.join([f"{k}: {v}" for k,v in info.items()]) - }} - - return result + return {"127.0.0.1:10051": common.response_gen(items)} for case in test_cases: with patch.multiple( @@ -312,131 +268,90 @@ def mock_chunk_send_empty(self, items): def test_send_value(self): """Tests send_value method in different cases""" - test_cases = [ - { - 'input': {'host':'test_host', 'key':'test_key', 'value': 0, 'clock': 1695713666, 'ns': 100}, - 'output': json.dumps( - {"processed": 1, "failed": 0, "total": 1, "time": "0.000100", "chunk": 1} - ) - } - ] - - def mock_chunk_send(self, items): - info = { - 'processed': len([i for i in items if i]), - 'failed': len([i for i in items if not i]), - 'total': len(items), - 'seconds spent': '0.000100' - } - result = {"127.0.0.1:10051": { - 'response': 'success', - 'info': '; '.join([f"{k}: {v}" for k,v in info.items()]) - }} - - return result - - for case in test_cases: - with patch.multiple( - Sender, - _Sender__chunk_send=mock_chunk_send): - - sender = Sender() - resp = sender.send_value(**case['input']) - - self.assertEqual(str(resp), case['output'], - f"unexpected output with input data: {case['input']}") - - -class TestCluster(unittest.TestCase): - """Test cases for Zabbix Cluster object""" - - def test_parsing(self): - """Tests creating of Zabbix Cluster object""" + request = {"host": "test_host", "key": "test_key", "value": "true", "clock": 1695713666, "ns": 100} + output = common.response_gen([request]) + response = ZabbixProtocol.create_packet(output, common.MockLogger()) test_cases = [ { - 'input': ['127.0.0.1'], - 'clusters': json.dumps([["127.0.0.1", 10051]]) + 'connection': {'input_stream': response}, + 'input': {'use_ipv6': False}, + 'output': output, + 'raised': False }, { - 'input': ['localhost:10151'], - 'clusters': json.dumps([["localhost", 10151]]) + 'connection': {'input_stream': response}, + 'input': {'use_ipv6': True}, + 'output': output, + 'raised': False }, { - 'input': ['zabbix.cluster.node1','zabbix.cluster.node2:20051','zabbix.cluster.node3:30051'], - 'clusters': json.dumps([ - ["zabbix.cluster.node1", 10051], ["zabbix.cluster.node2", 20051], ["zabbix.cluster.node3", 30051] - ]) - } - ] - - for case in test_cases: - cluster = Cluster(case['input']) - - self.assertEqual(str(cluster), case['clusters'], - f"unexpected output with input data: {case['input']}") - - -class TestItemValue(unittest.TestCase): - """Test cases for Zabbix Item object""" - - def test_parsing(self): - """Tests creating of Zabbix Item object""" - - test_cases = [ - { - 'input': {'host':'test_host', 'key':'test_key', 'value': 0}, - 'output': json.dumps({"host": "test_host", "key": "test_key", "value": "0"}), - 'exception': ValueError, + 'connection': {'input_stream': response}, + 'input': {'source_ip': DEFAULT_VALUES['source_ip']}, + 'output': output, 'raised': False }, { - 'input': {'host':'test_host', 'key':'test_key', 'value': 0, 'clock': 1695713666}, - 'output': json.dumps({"host": "test_host", "key": "test_key", "value": "0", "clock": 1695713666}), - 'exception': ValueError, + 'connection': {'input_stream': response}, + 'input': {'socket_wrapper': common.socket_wrapper}, + 'output': output, 'raised': False }, { - 'input': {'host':'test_host', 'key':'test_key', 'value': 0, 'clock': '123abc'}, - 'output': json.dumps({"host": "test_host", "key": "test_key", "value": "0", "clock": '123abc'}), - 'exception': ValueError, + 'connection': {'input_stream': response, 'exception': socket.error}, + 'input': {}, + 'output': output, 'raised': True }, { - 'input': {'host':'test_host', 'key':'test_key', 'value': 0, 'clock': 1695713666, 'ns': 100}, - 'output': json.dumps({"host": "test_host", "key": "test_key", "value": "0", "clock": 1695713666, "ns": 100}), - 'exception': ValueError, - 'raised': False + 'connection': {'input_stream': response, 'exception': socket.gaierror}, + 'input': {}, + 'output': output, + 'raised': True + }, + { + 'connection': {'input_stream': response, 'exception': socket.timeout}, + 'input': {}, + 'output': output, + 'raised': True }, { - 'input': {'host':'test_host', 'key':'test_key', 'value': 0, 'ns': '123abc'}, - 'output': json.dumps({"host": "test_host", "key": "test_key", "value": "0", "ns": '123abc'}), - 'exception': ValueError, + 'connection': {'input_stream': response, 'exception': ConnectionResetError}, + 'input': {}, + 'output': output, 'raised': True } ] for case in test_cases: - try: - item = ItemValue(**case['input']) - except ValueError: - if not case['raised']: - self.fail(f"raised unexpected ValueError for input data: {case['input']}") - else: - if case['raised']: - self.fail(f"not raised expected ValueError for input data: {case['input']}") - - self.assertEqual(str(item), case['output'], - f"unexpected output with input data: {case['input']}") - - self.assertEqual(str(item), repr(item), - f"unexpected output with input data: {case['input']}") + with unittest.mock.patch('socket.socket') as mock_socket: + test_connector = common.MockConnector(**case['connection']) + mock_socket.return_value.recv = test_connector.recv + mock_socket.return_value.sendall = test_connector.sendall + sender = Sender(**case['input']) + try: + resp = sender.send_value(**request) + except case['connection'].get('exception', Exception): + if not case['raised']: + self.fail(f"raised unexpected Exception with input data: {case['input']}") + else: + self.assertEqual(repr(resp), repr(TrapperResponse(1).add(case['output'])), + f"unexpected output with input data: {case['input']}") + + for exc in [socket.timeout, socket.gaierror]: + with unittest.mock.patch('socket.socket') as mock_socket: + test_connector = common.MockConnector(response, exception=exc) + mock_socket.return_value.recv = test_connector.recv + mock_socket.return_value.sendall = test_connector.sendall + mock_socket.return_value.connect = test_connector.connect + sender = Sender(**case['input']) -class TestZabbixProtocol(unittest.TestCase): - """Test cases for ZabbixProtocol object""" + with self.assertRaises(ProcessingError, + msg="expected ProcessingError exception hasn't been raised"): + resp = sender.send_value(**request) - def test_create_packet(self): + def test_create_request(self): """Tests create_packet method in different cases""" test_cases = [ @@ -460,14 +375,10 @@ def test_create_packet(self): (\xb5\xb883?/>-'1\x1d$_\x96\x98S\x9a\nRa\xa0T\x1b[\x0b\x00l\xbf o" } ] - - class Logger(): - def debug(self, *args, **kwargs): - pass for case in test_cases: - resp = ZabbixProtocol.create_packet(Sender()._Sender__create_request(**case['input']), Logger(), case['compression']) + resp = ZabbixProtocol.create_packet(Sender()._Sender__create_request(**case['input']), common.MockLogger(), case['compression']) self.assertEqual(resp, case['output'], f"unexpected output with input data: {case['input']}") diff --git a/tests/test_zabbix_types.py b/tests/test_zabbix_types.py new file mode 100644 index 0000000..c46e231 --- /dev/null +++ b/tests/test_zabbix_types.py @@ -0,0 +1,224 @@ +# zabbix_utils +# +# Copyright (C) 2001-2023 Zabbix SIA +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, +# merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software +# is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +import json +import unittest + +from zabbix_utils.types import APIVersion, Cluster, ItemValue + + +class TestAPIVersion(unittest.TestCase): + """Test cases for APIVersion object""" + + def test_init(self): + """Tests creating of APIVersion object""" + + test_cases = [ + {'input': '7.0.0alpha', 'output': '7.0.0alpha', 'exception': TypeError, 'raised': True}, + {'input': '6.0.0', 'output': '6.0.0', 'exception': TypeError, 'raised': False}, + {'input': '6.0', 'output': None, 'exception': TypeError, 'raised': True}, + {'input': '7', 'output': None, 'exception': TypeError, 'raised': True} + ] + + for case in test_cases: + try: + ver = APIVersion(case['input']) + except ValueError: + if not case['raised']: + self.fail(f"raised unexpected Exception with input data: {case['input']}") + else: + if case['raised']: + self.fail(f"not raised expected Exception with input data: {case['input']}") + self.assertEqual(str(ver), case['output'], + f"unexpected output with input data: {case['input']}") + + def test_major(self): + """Tests getting the major version part of APIVersion""" + + test_cases = [ + {'input': '6.0.10', 'output': 6.0}, + {'input': '6.2.0', 'output': 6.2} + ] + + for case in test_cases: + ver = APIVersion(case['input']) + self.assertEqual(ver.major, case['output'], + f"unexpected output with input data: {case['input']}") + + def test_minor(self): + """Tests getting the minor version part of APIVersion""" + + test_cases = [ + {'input': '6.0.10', 'output': 10}, + {'input': '6.2.0', 'output': 0} + ] + + for case in test_cases: + ver = APIVersion(case['input']) + self.assertEqual(ver.minor, case['output'], + f"unexpected output with input data: {case['input']}") + + def test_is_lts(self): + """Tests is_lts method for different versions""" + + test_cases = [ + {'input': '6.0.10', 'output': True}, + {'input': '6.2.0', 'output': False}, + {'input': '6.4.5', 'output': False}, + {'input': '7.0.0', 'output': True}, + {'input': '7.0.30', 'output': True} + ] + + for case in test_cases: + ver = APIVersion(case['input']) + self.assertEqual(ver.is_lts(), case['output'], + f"unexpected output with input data: {case['input']}") + + def test_compare(self): + """Tests version comparison for different version formats""" + + test_cases = [ + {'input': ['6.0.0','6.0.0'], 'operation': 'eq', 'output': True}, + {'input': ['6.0.0',6.0], 'operation': 'ne', 'output': False}, + {'input': ['6.0.0',6.0], 'operation': 'ge', 'output': True}, + {'input': ['6.0.0',7.0], 'operation': 'lt', 'output': True}, + {'input': ['6.4.1',6.4], 'operation': 'gt', 'output': False} + ] + + for case in test_cases: + ver = APIVersion(case['input'][0]) + result = (getattr(ver, f"__{case['operation']}__")(case['input'][1])) + self.assertEqual(result, case['output'], + f"unexpected output with input data: {case['input']}") + + ver = APIVersion('6.0.0') + with self.assertRaises(TypeError, + msg=f"input data={case['input']}"): + ver > {} + + with self.assertRaises(TypeError, + msg=f"input data={case['input']}"): + ver < [] + + with self.assertRaises(TypeError, + msg=f"input data={case['input']}"): + ver < 6 + + with self.assertRaises(TypeError, + msg=f"input data={case['input']}"): + ver != 7 + + with self.assertRaises(ValueError, + msg=f"input data={case['input']}"): + ver <= '7.0' + + +class TestCluster(unittest.TestCase): + """Test cases for Zabbix Cluster object""" + + def test_parsing(self): + """Tests creating of Zabbix Cluster object""" + + test_cases = [ + { + 'input': ['127.0.0.1'], + 'clusters': json.dumps([["127.0.0.1", 10051]]) + }, + { + 'input': ['localhost:10151'], + 'clusters': json.dumps([["localhost", 10151]]) + }, + { + 'input': ['zabbix.cluster.node1','zabbix.cluster.node2:20051','zabbix.cluster.node3:30051'], + 'clusters': json.dumps([ + ["zabbix.cluster.node1", 10051], ["zabbix.cluster.node2", 20051], ["zabbix.cluster.node3", 30051] + ]) + } + ] + + for case in test_cases: + cluster = Cluster(case['input']) + + self.assertEqual(str(cluster), case['clusters'], + f"unexpected output with input data: {case['input']}") + + +class TestItemValue(unittest.TestCase): + """Test cases for Zabbix Item object""" + + def test_parsing(self): + """Tests creating of Zabbix Item object""" + + test_cases = [ + { + 'input': {'host':'test_host', 'key':'test_key', 'value': 0}, + 'output': json.dumps({"host": "test_host", "key": "test_key", "value": "0"}), + 'exception': ValueError, + 'raised': False + }, + { + 'input': {'host':'test_host', 'key':'test_key', 'value': 0, 'clock': 1695713666}, + 'output': json.dumps({"host": "test_host", "key": "test_key", "value": "0", "clock": 1695713666}), + 'exception': ValueError, + 'raised': False + }, + { + 'input': {'host':'test_host', 'key':'test_key', 'value': 0, 'clock': '123abc'}, + 'output': json.dumps({"host": "test_host", "key": "test_key", "value": "0", "clock": '123abc'}), + 'exception': ValueError, + 'raised': True + }, + { + 'input': {'host':'test_host', 'key':'test_key', 'value': 0, 'clock': 1695713666, 'ns': 100}, + 'output': json.dumps({"host": "test_host", "key": "test_key", "value": "0", "clock": 1695713666, "ns": 100}), + 'exception': ValueError, + 'raised': False + }, + { + 'input': {'host':'test_host', 'key':'test_key', 'value': 0, 'ns': '123abc'}, + 'output': json.dumps({"host": "test_host", "key": "test_key", "value": "0", "ns": '123abc'}), + 'exception': ValueError, + 'raised': True + } + ] + + for case in test_cases: + try: + item = ItemValue(**case['input']) + except ValueError: + if not case['raised']: + self.fail(f"raised unexpected ValueError for input data: {case['input']}") + else: + if case['raised']: + self.fail(f"not raised expected ValueError for input data: {case['input']}") + + self.assertEqual(str(item), case['output'], + f"unexpected output with input data: {case['input']}") + + self.assertEqual(str(item), repr(item), + f"unexpected output with input data: {case['input']}") + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/zabbix_utils/__init__.py b/zabbix_utils/__init__.py index 56f5213..5b728dc 100644 --- a/zabbix_utils/__init__.py +++ b/zabbix_utils/__init__.py @@ -22,17 +22,32 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR # OTHER DEALINGS IN THE SOFTWARE. -from .api import ZabbixAPI, APIVersion -from .sender import Sender, ItemValue +from .api import ZabbixAPI +from .sender import Sender from .getter import Getter +from .types import ItemValue, APIVersion from .exceptions import ModuleBaseException, APIRequestError, APINotSupported, ProcessingError +from .aiosender import AsyncSender +from .aiogetter import AsyncGetter +try: + __import__('aiohttp') +except ModuleNotFoundError: + class AsyncZabbixAPI(): + def __init__(self, *args, **kwargs): + raise ModuleNotFoundError("No module named 'aiohttp'") +else: + from .aioapi import AsyncZabbixAPI + __all__ = ( 'ZabbixAPI', + 'AsyncZabbixAPI', 'APIVersion', 'Sender', + 'AsyncSender', 'ItemValue', 'Getter', + 'AsyncGetter', 'ModuleBaseException', 'APIRequestError', 'APINotSupported', diff --git a/zabbix_utils/aioapi.py b/zabbix_utils/aioapi.py new file mode 100644 index 0000000..9d2bbf8 --- /dev/null +++ b/zabbix_utils/aioapi.py @@ -0,0 +1,466 @@ +# zabbix_utils +# +# Copyright (C) 2001-2023 Zabbix SIA +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, +# merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software +# is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +import ssl +import json +import base64 +import aiohttp +import logging + +from uuid import uuid4 +import urllib.request as ul +from textwrap import shorten +from os import environ as env + +from urllib.error import URLError +from typing import Callable, Union, Optional, Any, List +from aiohttp.client_exceptions import ContentTypeError + +from .types import APIVersion +from .common import ModuleUtils +from .logger import EmptyHandler, SensitiveFilter +from .exceptions import APIRequestError, APINotSupported, ProcessingError +from .version import __version__, __min_supported__, __max_supported__ + +log = logging.getLogger(__name__) +log.addHandler(EmptyHandler()) +log.addFilter(SensitiveFilter()) + + +class APIObject(): + """Zabbix API object. + + Args: + name (str): Zabbix API object name. + parent (class): Zabbix API parent of the object. + """ + + def __init__(self, name: str, parent: Callable): + self.object = name + self.parent = parent + + def __getattr__(self, name: str) -> Callable: + """Dynamic creation of an API method. + + Args: + name (str): Zabbix API object method name. + + Raises: + TypeError: Raises if gets unexpected arguments. + + Returns: + Callable: Zabbix API method. + """ + + # For compatibility with Python less 3.9 versions + def removesuffix(string: str, suffix: str) -> str: + return str(string[:-len(suffix)]) if suffix and string.endswith(suffix) else string + + async def func(*args: Any, **kwargs: Any) -> Any: + if args and kwargs: + await self.__exception(TypeError("Only args or kwargs should be used.")) + + # Support '_' suffix to avoid conflicts with python keywords + method = removesuffix(self.object, '_') + "." + removesuffix(name, '_') + + log.debug("Executing %s method", method) + + need_auth = method not in ModuleUtils.UNAUTH_METHODS + + response = await self.parent.send_async_request( + method, + args or kwargs, + need_auth + ) + return response.get('result') + + return func + + +class AsyncZabbixAPI(): + """Provide asynchronous interface for working with Zabbix API. + + Args: + url (str, optional): Zabbix API URL. Defaults to `http://localhost/zabbix/api_jsonrpc.php`. + http_user (str, optional): Basic Authentication username. Defaults to `None`. + http_password (str, optional): Basic Authentication password. Defaults to `None`. + skip_version_check (bool, optional): Skip version compatibility check. Defaults to `False`. + validate_certs (bool, optional): Specifying certificate validation. Defaults to `True`. + client_session (aiohttp.ClientSession, optional): Client's session. Defaults to `None`. + timeout (int, optional): Connection timeout to Zabbix API. Defaults to `30`. + """ + + __version = None + __use_token = False + __session_id = None + __internal_client = None + + def __init__(self, url: Optional[str] = None, + http_user: Optional[str] = None, http_password: Optional[str] = None, + skip_version_check: bool = False, validate_certs: bool = True, + client_session: Optional[aiohttp.ClientSession] = None, timeout: int = 30): + + url = url or env.get('ZABBIX_URL') or 'http://localhost/zabbix/api_jsonrpc.php' + + self.url = ModuleUtils.check_url(url) + self.validate_certs = validate_certs + self.timeout = timeout + + client_params: dict = {} + + if client_session is None: + client_params["connector"] = aiohttp.TCPConnector( + ssl=self.validate_certs + ) + if http_user and http_password: + client_params["auth"] = aiohttp.BasicAuth( + login=http_user, + password=http_password + ) + self.__internal_client = aiohttp.ClientSession(**client_params) + self.client_session = self.__internal_client + else: + if http_user and http_password: + raise AttributeError( + "Parameters http_user/http_password shouldn't be used with client_session" + ) + self.client_session = client_session + + self.__check_version(skip_version_check) + + def __getattr__(self, name: str) -> Callable: + """Dynamic creation of an API object. + + Args: + name (str): Zabbix API method name. + + Returns: + APIObject: Zabbix API object instance. + """ + + return APIObject(name, self) + + async def __aenter__(self) -> Callable: + return self + + async def __aexit__(self, *args) -> None: + await self.logout() + + async def __close_session(self) -> None: + if self.__internal_client: + await self.__internal_client.close() + + async def __exception(self, exc) -> None: + await self.__close_session() + raise exc from exc + + def api_version(self) -> APIVersion: + """Return object of Zabbix API version. + + Returns: + APIVersion: Object of Zabbix API version + """ + + if self.__version is None: + self.__version = APIVersion( + self.send_sync_request('apiinfo.version', {}, False).get('result') + ) + return self.__version + + @property + def version(self) -> APIVersion: + """Return object of Zabbix API version. + + Returns: + APIVersion: Object of Zabbix API version. + """ + + return self.api_version() + + async def login(self, token: Optional[str] = None, user: Optional[str] = None, + password: Optional[str] = None) -> None: + """Login to Zabbix API. + + Args: + token (str, optional): Zabbix API token. Defaults to `None`. + user (str, optional): Zabbix API username. Defaults to `None`. + password (str, optional): Zabbix API user's password. Defaults to `None`. + """ + + user = user or env.get('ZABBIX_USER') or None + password = password or env.get('ZABBIX_PASSWORD') or None + token = token or env.get('ZABBIX_TOKEN') or None + + if token: + if self.version < 5.4: + await self.__exception(APINotSupported( + message="Token usage", + version=self.version + )) + if user or password: + await self.__exception( + ProcessingError("Token cannot be used with username and password") + ) + self.__use_token = True + self.__session_id = token + return + + if not user: + await self.__exception(ProcessingError("Username is missing")) + if not password: + await self.__exception(ProcessingError("User password is missing")) + + if self.version < 5.4: + user_cred = { + "user": user, + "password": password + } + else: + user_cred = { + "username": user, + "password": password + } + + log.debug( + "Login to Zabbix API using username:%s password:%s", user, ModuleUtils.HIDING_MASK + ) + self.__use_token = False + self.__session_id = await self.user.login(**user_cred) + + log.debug("Connected to Zabbix API version %s: %s", self.version, self.url) + + async def logout(self) -> None: + """Logout from Zabbix API.""" + + if self.__session_id: + if self.__use_token: + self.__session_id = None + self.__use_token = False + await self.__close_session() + return + + log.debug("Logout from Zabbix API") + await self.user.logout() + self.__session_id = None + await self.__close_session() + else: + log.debug("You're not logged in Zabbix API") + + async def check_auth(self) -> bool: + """Check authentication status in Zabbix API. + + Returns: + bool: User authentication status (`True`, `False`) + """ + + if not self.__session_id: + log.debug("You're not logged in Zabbix API") + return False + + if self.__use_token: + log.debug("Check auth session using token in Zabbix API") + refresh_resp = await self.user.checkAuthentication(token=self.__session_id) + else: + log.debug("Check auth session using sessionid in Zabbix API") + refresh_resp = await self.user.checkAuthentication(sessionid=self.__session_id) + + return bool(refresh_resp.get('userid')) + + def __prepare_request(self, method: str, params: Optional[dict] = None, + need_auth=True) -> Union[dict, dict]: + request = { + 'jsonrpc': '2.0', + 'method': method, + 'params': params or {}, + 'id': str(uuid4()), + } + + headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json-rpc', + 'User-Agent': f"{__name__}/{__version__}" + } + + if need_auth: + if not self.__session_id: + raise ProcessingError("You're not logged in Zabbix API") + if self.version < 6.4 or self.client_session._default_auth is not None: + request['auth'] = self.__session_id + else: + headers["Authorization"] = f"Bearer {self.__session_id}" + + log.debug( + "Sending request to %s with body: %s", + self.url, + request + ) + + return (request, headers) + + def __check_response(self, method: str, response: dict) -> dict: + if method not in ModuleUtils.FILES_METHODS: + log.debug( + "Received response body: %s", + response + ) + else: + debug_json = response.copy() + if debug_json.get('result'): + debug_json['result'] = shorten(debug_json['result'], 200, placeholder='...') + log.debug( + "Received response body (clipped): %s", + json.dumps(debug_json, indent=4, separators=(',', ': ')) + ) + + if 'error' in response: + err = response['error'].copy() + err['body'] = response.copy() + raise APIRequestError(err) + + return response + + async def send_async_request(self, method: str, params: Optional[dict] = None, + need_auth=True) -> dict: + """Function for sending asynchronous request to Zabbix API. + + Args: + method (str): Zabbix API method name. + params (dict, optional): Params for request body. Defaults to `None`. + need_auth (bool, optional): Authorization using flag. Defaults to `False`. + + Raises: + ProcessingError: Wrapping built-in exceptions during request processing. + APIRequestError: Wrapping errors from Zabbix API. + + Returns: + dict: Dictionary with Zabbix API response. + """ + + try: + request_json, headers = self.__prepare_request(method, params, need_auth) + except ProcessingError as err: + await self.__exception(err) + + resp = await self.client_session.post( + self.url, + json=request_json, + headers=headers, + timeout=self.timeout + ) + resp.raise_for_status() + + try: + resp_json = await resp.json() + except ContentTypeError as err: + await self.__exception(ProcessingError(f"Unable to connect to {self.url}:", err)) + except ValueError as err: + await self.__exception(ProcessingError("Unable to parse json:", err)) + + try: + return self.__check_response(method, resp_json) + except APIRequestError as err: + await self.__exception(err) + + def send_sync_request(self, method: str, params: Optional[dict] = None, + need_auth=True) -> dict: + """Function for sending synchronous request to Zabbix API. + + Args: + method (str): Zabbix API method name. + params (dict, optional): Params for request body. Defaults to `None`. + need_auth (bool, optional): Authorization using flag. Defaults to `False`. + + Raises: + ProcessingError: Wrapping built-in exceptions during request processing. + APIRequestError: Wrapping errors from Zabbix API. + + Returns: + dict: Dictionary with Zabbix API response. + """ + + request_json, headers = self.__prepare_request(method, params, need_auth) + + basic_auth = self.client_session._default_auth + if basic_auth is not None: + headers["Authorization"] = "Basic " + base64.b64encode( + f"{basic_auth.login}:{basic_auth.password}".encode() + ).decode() + + req = ul.Request( + self.url, + data=json.dumps(request_json).encode("utf-8"), + headers=headers, + method='POST' + ) + req.timeout = self.timeout + + # Disable SSL certificate validation if needed. + if not self.validate_certs: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + else: + ctx = None + + try: + resp = ul.urlopen(req, context=ctx) + resp_json = json.loads(resp.read().decode('utf-8')) + except URLError as err: + raise ProcessingError(f"Unable to connect to {self.url}:", err) from None + except ValueError as err: + raise ProcessingError("Unable to parse json:", err) from None + + return self.__check_response(method, resp_json) + + def __check_version(self, skip_check: bool) -> None: + + skip_check_help = "If you're sure zabbix_utils will work properly with your current \ +Zabbix version you can skip this check by \ +specifying skip_version_check=True when create ZabbixAPI object." + + if self.version < __min_supported__: + if skip_check: + log.debug( + "Version of Zabbix API [%s] is less than the library supports. %s", + self.version, + "Further library use at your own risk!" + ) + else: + raise APINotSupported( + f"Version of Zabbix API [{self.version}] is not supported by the library. " + + f"The oldest supported version is {__min_supported__}.0. " + skip_check_help + ) + + if self.version > __max_supported__: + if skip_check: + log.debug( + "Version of Zabbix API [%s] is more than the library was tested on. %s", + self.version, + "Recommended to update the library. Further library use at your own risk!" + ) + else: + raise APINotSupported( + f"Version of Zabbix API [{self.version}] was not tested with the library. " + + f"The latest tested version is {__max_supported__}.0. " + skip_check_help + ) diff --git a/zabbix_utils/aiogetter.py b/zabbix_utils/aiogetter.py new file mode 100644 index 0000000..abaa370 --- /dev/null +++ b/zabbix_utils/aiogetter.py @@ -0,0 +1,140 @@ +# zabbix_utils +# +# Copyright (C) 2001-2023 Zabbix SIA +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, +# merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software +# is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +import ssl +import socket +import asyncio +import logging +from typing import Callable, Optional + +from .logger import EmptyHandler +from .types import AgentResponse +from .common import ZabbixProtocol +from .exceptions import ProcessingError + +log = logging.getLogger(__name__) +log.addHandler(EmptyHandler()) + + +class AsyncGetter(): + """Zabbix get asynchronous implementation. + + Args: + host (str, optional): Zabbix agent address. Defaults to `'127.0.0.1'`. + + port (int, optional): Zabbix agent port. Defaults to `10050`. + + timeout (int, optional): Connection timeout value. Defaults to `10`. + + source_ip (str, optional): IP from which to establish connection. Defaults to `None`. + + ssl_context (Callable, optional): Func(), returned prepared ssl.SSLContext. \ +Defaults to `None`. + """ + + def __init__(self, host: str = '127.0.0.1', port: int = 10050, timeout: int = 10, + source_ip: Optional[str] = None, ssl_context: Optional[Callable] = None): + self.host = host + self.port = port + self.timeout = timeout + self.source_ip = source_ip + + self.ssl_context = ssl_context + if self.ssl_context: + if not isinstance(self.ssl_context, Callable): + raise TypeError('Value "ssl_context" should be a function.') + + async def __get_response(self, reader: asyncio.StreamReader) -> Optional[str]: + result = await ZabbixProtocol.parse_async_packet(reader, log, ProcessingError) + + log.debug('Received data: %s', result) + + return result + + async def get(self, key: str) -> Optional[str]: + """Gets item value from Zabbix agent by specified key. + + Args: + key (str): Zabbix item key. + + Returns: + str: Value from Zabbix agent for specified key. + """ + + packet = ZabbixProtocol.create_packet(key, log) + + connection_params = { + "host": self.host, + "port": self.port + } + + if self.source_ip: + connection_params['local_addr'] = (self.source_ip, 0) + + if self.ssl_context: + connection_params['ssl'] = self.ssl_context() + if not isinstance(connection_params['ssl'], ssl.SSLContext): + raise TypeError( + 'Function "ssl_context" must return "ssl.SSLContext".') from None + + connection = asyncio.open_connection(**connection_params) + + try: + reader, writer = await asyncio.wait_for(connection, timeout=self.timeout) + writer.write(packet) + await writer.drain() + except asyncio.TimeoutError as err: + log.error( + 'The connection to %s timed out after %d seconds', + f"{self.host}:{self.port}", + self.timeout + ) + raise err + except (ConnectionRefusedError, socket.gaierror) as err: + log.error( + 'An error occurred while trying to connect to %s: %s', + f"{self.host}:{self.port}", + getattr(err, 'msg', str(err)) + ) + raise err + except (OSError, socket.error) as err: + log.warning( + 'An error occurred while trying to send to %s: %s', + f"{self.host}:{self.port}", + getattr(err, 'msg', str(err)) + ) + raise err + + try: + response = await self.__get_response(reader) + except (ConnectionResetError, asyncio.exceptions.IncompleteReadError) as err: + log.debug('Get value error: %s', err) + log.warning('Check access restrictions in Zabbix agent configuration.') + raise err + log.debug('Response from [%s:%s]: %s', self.host, self.port, response) + + writer.close() + await writer.wait_closed() + + return AgentResponse(response) diff --git a/zabbix_utils/aiosender.py b/zabbix_utils/aiosender.py new file mode 100644 index 0000000..7748c56 --- /dev/null +++ b/zabbix_utils/aiosender.py @@ -0,0 +1,297 @@ +# zabbix_utils +# +# Copyright (C) 2001-2023 Zabbix SIA +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, +# merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software +# is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +import ssl +import json +import socket +import asyncio +import logging +import configparser + +from typing import Callable, Union, Optional + +from .logger import EmptyHandler +from .common import ZabbixProtocol +from .exceptions import ProcessingError +from .types import TrapperResponse, ItemValue, Cluster + +log = logging.getLogger(__name__) +log.addHandler(EmptyHandler()) + + +class AsyncSender(): + """Zabbix sender asynchronous implementation. + + Args: + server (str, optional): Zabbix server address. Defaults to `'127.0.0.1'`. + port (int, optional): Zabbix server port. Defaults to `10051`. + use_config (bool, optional): Specifying configuration use. Defaults to `False`. + timeout (int, optional): Connection timeout value. Defaults to `10`. + use_ipv6 (bool, optional): Specifying IPv6 use instead of IPv4. Defaults to `False`. + source_ip (str, optional): IP from which to establish connection. Defaults to `None`. + chunk_size (int, optional): Number of packets in one chunk. Defaults to `250`. + clusters (tuple|list, optional): List of Zabbix clusters. Defaults to `None`. + ssl_context (Callable, optional): Func(`tls`), returned prepared ssl.SSLContext. \ +Defaults to `None`. + compression (bool, optional): Specifying compression use. Defaults to `False`. + config_path (str, optional): Path to Zabbix agent configuration file. Defaults to \ +`/etc/zabbix/zabbix_agentd.conf`. + """ + + def __init__(self, server: Optional[str] = None, port: int = 10051, + use_config: bool = False, timeout: int = 10, + use_ipv6: bool = False, source_ip: Optional[str] = None, + chunk_size: int = 250, clusters: Union[tuple, list] = None, + ssl_context: Optional[Callable] = None, compression: bool = False, + config_path: Optional[str] = '/etc/zabbix/zabbix_agentd.conf'): + self.timeout = timeout + self.use_ipv6 = use_ipv6 + self.tls = {} + + self.source_ip = None + self.chunk_size = chunk_size + self.compression = compression + + if ssl_context is not None: + if not isinstance(ssl_context, Callable): + raise TypeError('Value "ssl_context" should be a function.') from None + self.ssl_context = ssl_context + + if source_ip is not None: + self.source_ip = source_ip + + if use_config: + self.clusters = [] + self.__load_config(config_path) + return + + if clusters is not None: + if not (isinstance(clusters, tuple) or isinstance(clusters, list)): + raise TypeError('Value "clusters" should be a tuple or a list.') from None + + clusters = clusters.copy() + + if server is not None: + clusters.append([f"{server}:{port}"]) + + self.clusters = [Cluster(c) for c in clusters] + else: + self.clusters = [Cluster([f"{server or '127.0.0.1'}:{port}"])] + + def __read_config(self, config: configparser.SectionProxy) -> None: + server_row = config.get('ServerActive') or config.get('Server') or '127.0.0.1:10051' + + for cluster in server_row.split(','): + self.clusters.append(Cluster(cluster.strip().split(';'))) + + if 'SourceIP' in config: + self.source_ip = config.get('SourceIP') + + for key in config: + if key.startswith('tls'): + self.tls[key] = config.get(key) + + def __load_config(self, filepath: str) -> None: + config = configparser.ConfigParser(strict=False) + + with open(filepath, 'r', encoding='utf-8') as cfg: + config.read_string('[root]\n' + cfg.read()) + self.__read_config(config['root']) + + async def __get_response(self, reader: asyncio.StreamReader) -> Optional[str]: + try: + result = json.loads( + await ZabbixProtocol.parse_async_packet(reader, log, ProcessingError) + ) + except json.decoder.JSONDecodeError as err: + log.debug('Unexpected response was received from Zabbix.') + raise err + + log.debug('Received data: %s', result) + + return result + + def __create_request(self, items: list) -> dict: + return { + "request": "sender data", + "data": [i.to_json() for i in items] + } + + async def __chunk_send(self, items: list) -> dict: + responses = {} + + packet = ZabbixProtocol.create_packet(self.__create_request(items), log, self.compression) + + for cluster in self.clusters: + active_node = None + + for i, node in enumerate(cluster.nodes): + + log.debug('Trying to send data to %s', node) + + connection_params = { + "host": node.address, + "port": node.port + } + + if self.source_ip: + connection_params['local_addr'] = (self.source_ip, 0) + + if self.ssl_context is not None: + connection_params['ssl'] = self.ssl_context(self.tls) + if not isinstance(connection_params['ssl'], ssl.SSLContext): + raise TypeError( + 'Function "ssl_context" must return "ssl.SSLContext".') from None + + connection = asyncio.open_connection(**connection_params) + + try: + reader, writer = await asyncio.wait_for(connection, timeout=self.timeout) + except asyncio.TimeoutError: + log.debug( + 'The connection to %s timed out after %d seconds', + node, + self.timeout + ) + except (ConnectionRefusedError, socket.gaierror) as err: + log.debug( + 'An error occurred while trying to connect to %s: %s', + node, + getattr(err, 'msg', str(err)) + ) + else: + if i > 0: + cluster.nodes[0], cluster.nodes[i] = cluster.nodes[i], cluster.nodes[0] + active_node = node + break + + if active_node is None: + log.error( + 'Couldn\'t connect to all of cluster nodes: %s', + str(list(cluster.nodes)) + ) + raise ProcessingError( + f"Couldn't connect to all of cluster nodes: {list(cluster.nodes)}" + ) + + try: + writer.write(packet) + send_data = writer.drain() + await asyncio.wait_for(send_data, timeout=self.timeout) + except (asyncio.TimeoutError, socket.timeout) as err: + log.error( + 'The connection to %s timed out after %d seconds while trying to send', + active_node, + self.timeout + ) + writer.close() + await writer.wait_closed() + raise err + except (OSError, socket.error) as err: + log.warning( + 'An error occurred while trying to send to %s: %s', + active_node, + getattr(err, 'msg', str(err)) + ) + writer.close() + await writer.wait_closed() + raise err + try: + response = await self.__get_response(reader) + except (ConnectionResetError, asyncio.exceptions.IncompleteReadError) as err: + log.debug('Get value error: %s', err) + raise err + log.debug('Response from %s: %s', active_node, response) + + if response and response.get('response') != 'success': + raise ProcessingError(response) from None + + responses[active_node] = response + + writer.close() + await writer.wait_closed() + + return responses + + async def send(self, items: list) -> TrapperResponse: + """Sends packets and receives an answer from Zabbix. + + Args: + items (list): List of ItemValue objects. + + Returns: + TrapperResponse: Response from Zabbix server/proxy. + """ + + # Split the list of items into chunks of size self.chunk_size. + chunks = [items[i:i + self.chunk_size] for i in range(0, len(items), self.chunk_size)] + + # Merge responses into a single TrapperResponse object. + result = TrapperResponse() + + # TrapperResponse details for each node and chunk. + result.details = {} + + for i, chunk in enumerate(chunks): + + if not all(isinstance(item, ItemValue) for item in chunk): + log.debug('Received unexpected item list. It must be a list of \ +ItemValue objects: %s', json.dumps(chunk)) + raise ProcessingError(f"Received unexpected item list. \ +It must be a list of ItemValue objects: {json.dumps(chunk)}") + + resp_by_node = await self.__chunk_send(chunk) + + node_step = 1 + for node, resp in resp_by_node.items(): + try: + result.add(resp, (i + 1) * node_step) + except ProcessingError as err: + log.debug(err) + raise ProcessingError(err) from None + node_step += 1 + + if node not in result.details: + result.details[node] = [] + result.details[node].append(TrapperResponse(i+1).add(resp)) + + return result + + async def send_value(self, host: str, key: str, + value: str, clock: Optional[int] = None, + ns: Optional[int] = None) -> TrapperResponse: + """Sends one value and receives an answer from Zabbix. + + Args: + host (str): Specify host name the item belongs to (as registered in Zabbix frontend). + key (str): Specify item key to send value to. + value (str): Specify item value. + clock (int, optional): Specify time in Unix timestamp format. Defaults to `None`. + ns (int, optional): Specify time expressed in nanoseconds. Defaults to `None`. + + Returns: + TrapperResponse: Response from Zabbix server/proxy. + """ + + return await self.send([ItemValue(host, key, value, clock, ns)]) diff --git a/zabbix_utils/api.py b/zabbix_utils/api.py index 7f7a6ee..c440262 100644 --- a/zabbix_utils/api.py +++ b/zabbix_utils/api.py @@ -22,7 +22,6 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR # OTHER DEALINGS IN THE SOFTWARE. -import re import ssl import json import base64 @@ -34,8 +33,9 @@ from os import environ as env from urllib.error import URLError -from typing import Callable, Union, Any, List +from typing import Callable, Union, Optional, Any +from .types import APIVersion from .common import ModuleUtils from .logger import EmptyHandler, SensitiveFilter from .exceptions import APIRequestError, APINotSupported, ProcessingError @@ -96,107 +96,6 @@ def func(*args: Any, **kwargs: Any) -> Any: return func -class APIVersion(): - """Zabbix API version object. - - Args: - apiver (str): Raw version in string format. - """ - - def __init__(self, apiver: str): - self.__raw = apiver - self.__first, self.__second, self.__third = self.__parse_version(self.__raw) - - def __getitem__(self, index: int) -> Any: - # Get a symbol from the raw version string by index - # For compatibility with using Zabbix version as a string - return self.__raw[index] - - def is_lts(self) -> bool: - """Check if the current version is LTS. - - Returns: - bool: `True` if the current version is LTS. - """ - - return self.__second == 0 - - @property - def major(self) -> float: - """Get major version number. - - Returns: - float: A major version number. - """ - - return float(f"{self.__first}.{self.__second}") - - @property - def minor(self) -> int: - """Get minor version number. - - Returns: - int: A minor version number. - """ - - return self.__third - - def __parse_version(self, ver: str) -> List[Any]: - # Parse the version string into a list of integers. - match = re.fullmatch(r'(\d+)\.(\d+)\.(\d+)', ver) - if match is None: - raise ValueError( - f"Unable to parse version of Zabbix API: {ver}. " + - f"Default '{__max_supported__}.0' format is expected." - ) from None - return list(map(int, match.groups())) - - def __str__(self) -> str: - return self.__raw - - def __repr__(self) -> str: - return self.__raw - - def __eq__(self, other: Union[float, str]) -> bool: - if isinstance(other, float): - return self.major == other - if isinstance(other, str): - return [self.__first, self.__second, self.__third] == self.__parse_version(other) - raise TypeError( - f"'==' not supported between instances of '{type(self).__name__}' and \ -'{type(other).__name__}', only 'float' or 'str' is expected" - ) - - def __gt__(self, other: Union[float, str]) -> bool: - if isinstance(other, float): - return self.major > other - if isinstance(other, str): - return [self.__first, self.__second, self.__third] > self.__parse_version(other) - raise TypeError( - f"'>' not supported between instances of '{type(self).__name__}' and \ -'{type(other).__name__}', only 'float' or 'str' is expected" - ) - - def __lt__(self, other: Union[float, str]) -> bool: - if isinstance(other, float): - return self.major < other - if isinstance(other, str): - return [self.__first, self.__second, self.__third] < self.__parse_version(other) - raise TypeError( - f"'<' not supported between instances of '{type(self).__name__}' and \ -'{type(other).__name__}', only 'float' or 'str' is expected" - ) - - def __ne__(self, other: Any) -> bool: - return not self.__eq__(other) - - def __ge__(self, other: Any) -> bool: - return not self.__lt__(other) - - def __le__(self, other: Any) -> bool: - return not self.__gt__(other) - - class ZabbixAPI(): """Provide interface for working with Zabbix API. @@ -217,9 +116,9 @@ class ZabbixAPI(): __session_id = None __basic_cred = None - def __init__(self, url: Union[str, None] = None, token: Union[str, None] = None, - user: Union[str, None] = None, password: Union[str, None] = None, - http_user: Union[str, None] = None, http_password: Union[str, None] = None, + def __init__(self, url: Optional[str] = None, token: Optional[str] = None, + user: Optional[str] = None, password: Optional[str] = None, + http_user: Optional[str] = None, http_password: Optional[str] = None, skip_version_check: bool = False, validate_certs: bool = True, timeout: int = 30): url = url or env.get('ZABBIX_URL') or 'http://localhost/zabbix/api_jsonrpc.php' @@ -296,8 +195,8 @@ def version(self) -> APIVersion: return self.api_version() - def login(self, token: Union[str, None] = None, user: Union[str, None] = None, - password: Union[str, None] = None) -> None: + def login(self, token: Optional[str] = None, user: Optional[str] = None, + password: Optional[str] = None) -> None: """Login to Zabbix API. Args: @@ -378,7 +277,7 @@ def check_auth(self) -> bool: return bool(refresh_resp.get('userid')) - def send_api_request(self, method: str, params: Union[dict, None] = None, + def send_api_request(self, method: str, params: Optional[dict] = None, need_auth=True) -> dict: """Function for sending request to Zabbix API. diff --git a/zabbix_utils/common.py b/zabbix_utils/common.py index 5034bbf..4cb2a32 100644 --- a/zabbix_utils/common.py +++ b/zabbix_utils/common.py @@ -26,11 +26,14 @@ import json import zlib import struct -from typing import Match, Union +import asyncio + from textwrap import shorten from logging import Logger from socket import socket +from typing import Match, Union + class ModuleUtils(): @@ -236,8 +239,8 @@ def receive_packet(cls, conn: socket, size: int, log: Logger) -> bytes: return buf @classmethod - def parse_packet(cls, conn: socket, log: Logger, exception) -> str: - """Parse a received Zabbix protocol packet. + def parse_sync_packet(cls, conn: socket, log: Logger, exception) -> str: + """Parse a received synchronously Zabbix protocol packet. Args: conn (socket): Opened socket connection @@ -280,3 +283,49 @@ def parse_packet(cls, conn: socket, log: Logger, exception) -> str: response_body = cls.receive_packet(conn, datalen, log) return response_body.decode("utf-8") + + @classmethod + async def parse_async_packet(cls, reader: asyncio.StreamReader, log: Logger, exception) -> str: + """Parse a received asynchronously Zabbix protocol packet. + + Args: + reader (StreamReader): Created asyncio.StreamReader + log (Logger): Logger object + exception: Exception type + + Raises: + exception: Depends on input exception type + + Returns: + str: Body of the received packet + """ + + response_header = await reader.readexactly(cls.HEADER_SIZE) + log.debug('Zabbix response header: %s', response_header) + + if (not response_header.startswith(cls.ZABBIX_PROTOCOL) or + len(response_header) != cls.HEADER_SIZE): + log.debug('Unexpected response was received from Zabbix.') + raise exception('Unexpected response was received from Zabbix.') + + flags, datalen, reserved = struct.unpack(' str: - return json.dumps({ - 'error': self.error, - 'raw': self.raw, - 'value': self.value, - }) - - class Getter(): - """Zabbix get implementation. + """Zabbix get synchronous implementation. Args: host (str, optional): Zabbix agent address. Defaults to `'127.0.0.1'`. @@ -99,7 +67,7 @@ def __init__(self, host: str = '127.0.0.1', port: int = 10050, timeout: int = 10 raise TypeError('Value "socket_wrapper" should be a function.') def __get_response(self, conn: socket) -> Union[str, None]: - result = ZabbixProtocol.parse_packet(conn, log, ProcessingError) + result = ZabbixProtocol.parse_sync_packet(conn, log, ProcessingError) log.debug('Received data: %s', result) diff --git a/zabbix_utils/sender.py b/zabbix_utils/sender.py index a9cc668..ec21b6f 100644 --- a/zabbix_utils/sender.py +++ b/zabbix_utils/sender.py @@ -22,272 +22,24 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR # OTHER DEALINGS IN THE SOFTWARE. -import re import json import socket import logging import configparser -from decimal import Decimal -from typing import Callable, Union +from typing import Callable, Optional, Union from .logger import EmptyHandler from .common import ZabbixProtocol from .exceptions import ProcessingError +from .types import TrapperResponse, ItemValue, Cluster log = logging.getLogger(__name__) log.addHandler(EmptyHandler()) -class TrapperResponse(): - """Contains response from Zabbix server/proxy. - - Args: - chunk (int, optional): Current chunk number. Defaults to `1`. - """ - - def __init__(self, chunk: int = 1): - self.__processed = 0 - self.__failed = 0 - self.__total = 0 - self.__time = 0 - self.__chunk = chunk - self.details = None - - def __repr__(self) -> str: - result = {} - for key, value in self.__dict__.items(): - if key == 'details': - continue - result[ - key[len(f"_{self.__class__.__name__}__"):] - ] = str(value) if isinstance(value, Decimal) else value - - return json.dumps(result) - - def parse(self, response: dict) -> dict: - """Parse response from Zabbix. - - Args: - response (dict): Raw response from Zabbix. - - Raises: - ProcessingError: Raises if unexpected response received - """ - - fields = { - "processed": ('[Pp]rocessed', r'\d+'), - "failed": ('[Ff]ailed', r'\d+'), - "total": ('[Tt]otal', r'\d+'), - "time": ('[Ss]econds spent', r'\d+\.\d+') - } - - pattern = re.compile( - r";\s+?".join([rf"{r[0]}:\s+?(?P<{k}>{r[1]})" for k, r in fields.items()]) - ) - - info = response.get('info') - if not info: - log.debug('Received unexpected response: %s', response) - raise ProcessingError(f"Received unexpected response: {response}") - - res = pattern.search(info).groupdict() - - return res - - def add(self, response: dict, chunk: Union[int, None] = None): - """Add and merge response data from Zabbix. - - Args: - response (dict): Raw response from Zabbix. - chunk (int, optional): Chunk number. Defaults to `None`. - """ - - resp = self.parse(response) - - def add_value(cls, key, value): - setattr( - cls, - key, - getattr(cls, key) + value - ) - - for k, v in resp.items(): - add_value( - self, - f"_{self.__class__.__name__}__{k}", - Decimal(v) if '.' in v else int(v) - ) - if chunk is not None: - self.__chunk = chunk - - return self - - @property - def processed(self) -> int: - """Returns number of processed packets. - - Returns: - int: Number of processed packets. - """ - - return self.__processed - - @property - def failed(self) -> int: - """Returns number of failed packets. - - Returns: - int: Number of failed packets. - """ - - return self.__failed - - @property - def total(self) -> int: - """Returns total number of packets. - - Returns: - int: Total number of packets. - """ - - return self.__total - - @property - def time(self) -> int: - """Returns value of spent time. - - Returns: - int: Spent time for the packets sending. - """ - - return self.__time - - @property - def chunk(self) -> int: - """Returns current chunk number. - - Returns: - int: Number of the current chunk. - """ - - return self.__chunk - - -class ItemValue(): - """Contains data of a single item value. - - Args: - host (str): Specify host name the item belongs to (as registered in Zabbix frontend). - key (str): Specify item key to send value to. - value (str): Specify item value. - clock (int, optional): Specify time in Unix timestamp format. Defaults to `None`. - ns (int, optional): Specify time expressed in nanoseconds. Defaults to `None`. - """ - - def __init__(self, host: str, key: str, value: str, - clock: Union[int, None] = None, ns: Union[int, None] = None): - self.host = str(host) - self.key = str(key) - self.value = str(value) - self.clock = None - self.ns = None - - if clock is not None: - try: - self.clock = int(clock) - except ValueError: - raise ValueError( - 'The clock value must be expressed in the Unix Timestamp format') from None - - if ns is not None: - try: - self.ns = int(ns) - except ValueError: - raise ValueError( - 'The ns value must be expressed in the integer value of nanoseconds') from None - - def __str__(self) -> str: - return json.dumps(self.to_json(), ensure_ascii=False) - - def __repr__(self) -> str: - return self.__str__() - - def to_json(self) -> dict: - """Represents ItemValue object in dictionary for json. - - Returns: - dict: Object attributes in dictionary. - """ - - return {k: v for k, v in self.__dict__.items() if v is not None} - - -class Node(): - """Contains one Zabbix node object. - - Args: - addr (str): Listen address of Zabbix server. - port (int, str): Listen port of Zabbix server. - - Raises: - TypeError: Raises if not integer value was received. - """ - - def __init__(self, addr: str, port: Union[int, str]): - self.address = addr if addr != '0.0.0.0/0' else '127.0.0.1' - try: - self.port = int(port) - except ValueError: - raise TypeError('Port must be an integer value') from None - - def __str__(self) -> str: - return f"{self.address}:{self.port}" - - def __repr__(self) -> str: - return self.__str__() - - -class Cluster(): - """Contains Zabbix node objects in a cluster object. - - Args: - addr (list): Raw list of node addresses. - """ - - def __init__(self, addr: list): - self.__nodes = self.__parse_ha_node(addr) - - def __parse_ha_node(self, node_list: list) -> list: - nodes = [] - for node_item in node_list: - node_item = node_item.strip() - if ':' in node_item: - nodes.append(Node(*node_item.split(':'))) - else: - nodes.append(Node(node_item, '10051')) - - return nodes - - def __str__(self) -> str: - return json.dumps([(node.address, node.port) for node in self.__nodes]) - - def __repr__(self) -> str: - return self.__str__() - - @property - def nodes(self) -> list: - """Returns list of Node objects. - - Returns: - list: List of Node objects - """ - - return self.__nodes - - class Sender(): - """Zabbix sender implementation. + """Zabbix sender synchronous implementation. Args: server (str, optional): Zabbix server address. Defaults to `'127.0.0.1'`. @@ -304,12 +56,12 @@ class Sender(): `/etc/zabbix/zabbix_agentd.conf`. """ - def __init__(self, server: Union[str, None] = None, port: int = 10051, + def __init__(self, server: Optional[str] = None, port: int = 10051, use_config: bool = False, timeout: int = 10, - use_ipv6: bool = False, source_ip: Union[str, None] = None, - chunk_size: int = 250, clusters: Union[tuple, list, None] = None, - socket_wrapper: Union[Callable, None] = None, compression: bool = False, - config_path: Union[str, None] = '/etc/zabbix/zabbix_agentd.conf'): + use_ipv6: bool = False, source_ip: Optional[str] = None, + chunk_size: int = 250, clusters: Union[tuple, list] = None, + socket_wrapper: Optional[Callable] = None, compression: bool = False, + config_path: Optional[str] = '/etc/zabbix/zabbix_agentd.conf'): self.timeout = timeout self.use_ipv6 = use_ipv6 self.tls = {} @@ -364,10 +116,10 @@ def __load_config(self, filepath: str) -> None: config.read_string('[root]\n' + cfg.read()) self.__read_config(config['root']) - def __get_response(self, conn: socket) -> Union[str, None]: + def __get_response(self, conn: socket) -> Optional[str]: try: result = json.loads( - ZabbixProtocol.parse_packet(conn, log, ProcessingError) + ZabbixProtocol.parse_sync_packet(conn, log, ProcessingError) ) except json.decoder.JSONDecodeError as err: log.debug('Unexpected response was received from Zabbix.') @@ -493,7 +245,11 @@ def send(self, items: list) -> TrapperResponse: chunks = [items[i:i + self.chunk_size] for i in range(0, len(items), self.chunk_size)] # Merge responses into a single TrapperResponse object. - result = TrapperResponse() + try: + result = TrapperResponse() + except ProcessingError as err: + log.debug(err) + raise ProcessingError(err) from err # TrapperResponse details for each node and chunk. result.details = {} @@ -510,7 +266,11 @@ def send(self, items: list) -> TrapperResponse: node_step = 1 for node, resp in resp_by_node.items(): - result.add(resp, (i + 1) * node_step) + try: + result.add(resp, (i + 1) * node_step) + except ProcessingError as err: + log.debug(err) + raise ProcessingError(err) from None node_step += 1 if node not in result.details: @@ -520,8 +280,8 @@ def send(self, items: list) -> TrapperResponse: return result def send_value(self, host: str, key: str, - value: str, clock: Union[int, None] = None, - ns: Union[int, None] = None) -> TrapperResponse: + value: str, clock: Optional[int] = None, + ns: Optional[int] = None) -> TrapperResponse: """Sends one value and receives an answer from Zabbix. Args: diff --git a/zabbix_utils/types.py b/zabbix_utils/types.py new file mode 100644 index 0000000..2a4274f --- /dev/null +++ b/zabbix_utils/types.py @@ -0,0 +1,411 @@ +# zabbix_utils +# +# Copyright (C) 2001-2023 Zabbix SIA +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, +# merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software +# is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +import re +import json +from typing import Union, Any, List +from decimal import Decimal + +from .exceptions import ProcessingError + +from .version import __max_supported__ + + +class APIVersion(): + """Zabbix API version object. + + Args: + apiver (str): Raw version in string format. + """ + + def __init__(self, apiver: str): + self.__raw = apiver + self.__first, self.__second, self.__third = self.__parse_version(self.__raw) + + def __getitem__(self, index: int) -> Any: + # Get a symbol from the raw version string by index + # For compatibility with using Zabbix version as a string + return self.__raw[index] + + def is_lts(self) -> bool: + """Check if the current version is LTS. + + Returns: + bool: `True` if the current version is LTS. + """ + + return self.__second == 0 + + @property + def major(self) -> float: + """Get major version number. + + Returns: + float: A major version number. + """ + + return float(f"{self.__first}.{self.__second}") + + @property + def minor(self) -> int: + """Get minor version number. + + Returns: + int: A minor version number. + """ + + return self.__third + + def __parse_version(self, ver: str) -> List[Any]: + # Parse the version string into a list of integers. + match = re.fullmatch(r'(\d+)\.(\d+)\.(\d+)', ver) + if match is None: + raise ValueError( + f"Unable to parse version of Zabbix API: {ver}. " + + f"Default '{__max_supported__}.0' format is expected." + ) from None + return list(map(int, match.groups())) + + def __str__(self) -> str: + return self.__raw + + def __repr__(self) -> str: + return self.__raw + + def __eq__(self, other: Union[float, str]) -> bool: + if isinstance(other, float): + return self.major == other + if isinstance(other, str): + return [self.__first, self.__second, self.__third] == self.__parse_version(other) + raise TypeError( + f"'==' not supported between instances of '{type(self).__name__}' and \ +'{type(other).__name__}', only 'float' or 'str' is expected" + ) + + def __gt__(self, other: Union[float, str]) -> bool: + if isinstance(other, float): + return self.major > other + if isinstance(other, str): + return [self.__first, self.__second, self.__third] > self.__parse_version(other) + raise TypeError( + f"'>' not supported between instances of '{type(self).__name__}' and \ +'{type(other).__name__}', only 'float' or 'str' is expected" + ) + + def __lt__(self, other: Union[float, str]) -> bool: + if isinstance(other, float): + return self.major < other + if isinstance(other, str): + return [self.__first, self.__second, self.__third] < self.__parse_version(other) + raise TypeError( + f"'<' not supported between instances of '{type(self).__name__}' and \ +'{type(other).__name__}', only 'float' or 'str' is expected" + ) + + def __ne__(self, other: Any) -> bool: + return not self.__eq__(other) + + def __ge__(self, other: Any) -> bool: + return not self.__lt__(other) + + def __le__(self, other: Any) -> bool: + return not self.__gt__(other) + + +class TrapperResponse(): + """Contains response from Zabbix server/proxy. + + Args: + chunk (int, optional): Current chunk number. Defaults to `1`. + """ + + def __init__(self, chunk: int = 1): + self.__processed = 0 + self.__failed = 0 + self.__total = 0 + self.__time = 0 + self.__chunk = chunk + self.details = None + + def __repr__(self) -> str: + result = {} + for key, value in self.__dict__.items(): + if key == 'details': + continue + result[ + key[len(f"_{self.__class__.__name__}__"):] + ] = str(value) if isinstance(value, Decimal) else value + + return json.dumps(result) + + def parse(self, response: dict) -> dict: + """Parse response from Zabbix. + + Args: + response (dict): Raw response from Zabbix. + + Raises: + ProcessingError: Raises if unexpected response received + """ + + fields = { + "processed": ('[Pp]rocessed', r'\d+'), + "failed": ('[Ff]ailed', r'\d+'), + "total": ('[Tt]otal', r'\d+'), + "time": ('[Ss]econds spent', r'\d+\.\d+') + } + + pattern = re.compile( + r";\s+?".join([rf"{r[0]}:\s+?(?P<{k}>{r[1]})" for k, r in fields.items()]) + ) + + info = response.get('info') + if not info: + raise ProcessingError(f"Received unexpected response: {response}") + + res = pattern.search(info).groupdict() + + return res + + def add(self, response: dict, chunk: Union[int, None] = None): + """Add and merge response data from Zabbix. + + Args: + response (dict): Raw response from Zabbix. + chunk (int, optional): Chunk number. Defaults to `None`. + """ + + resp = self.parse(response) + + def add_value(cls, key, value): + setattr( + cls, + key, + getattr(cls, key) + value + ) + + for k, v in resp.items(): + add_value( + self, + f"_{self.__class__.__name__}__{k}", + Decimal(v) if '.' in v else int(v) + ) + if chunk is not None: + self.__chunk = chunk + + return self + + @property + def processed(self) -> int: + """Returns number of processed packets. + + Returns: + int: Number of processed packets. + """ + + return self.__processed + + @property + def failed(self) -> int: + """Returns number of failed packets. + + Returns: + int: Number of failed packets. + """ + + return self.__failed + + @property + def total(self) -> int: + """Returns total number of packets. + + Returns: + int: Total number of packets. + """ + + return self.__total + + @property + def time(self) -> int: + """Returns value of spent time. + + Returns: + int: Spent time for the packets sending. + """ + + return self.__time + + @property + def chunk(self) -> int: + """Returns current chunk number. + + Returns: + int: Number of the current chunk. + """ + + return self.__chunk + + +class ItemValue(): + """Contains data of a single item value. + + Args: + host (str): Specify host name the item belongs to (as registered in Zabbix frontend). + key (str): Specify item key to send value to. + value (str): Specify item value. + clock (int, optional): Specify time in Unix timestamp format. Defaults to `None`. + ns (int, optional): Specify time expressed in nanoseconds. Defaults to `None`. + """ + + def __init__(self, host: str, key: str, value: str, + clock: Union[int, None] = None, ns: Union[int, None] = None): + self.host = str(host) + self.key = str(key) + self.value = str(value) + self.clock = None + self.ns = None + + if clock is not None: + try: + self.clock = int(clock) + except ValueError: + raise ValueError( + 'The clock value must be expressed in the Unix Timestamp format') from None + + if ns is not None: + try: + self.ns = int(ns) + except ValueError: + raise ValueError( + 'The ns value must be expressed in the integer value of nanoseconds') from None + + def __str__(self) -> str: + return json.dumps(self.to_json(), ensure_ascii=False) + + def __repr__(self) -> str: + return self.__str__() + + def to_json(self) -> dict: + """Represents ItemValue object in dictionary for json. + + Returns: + dict: Object attributes in dictionary. + """ + + return {k: v for k, v in self.__dict__.items() if v is not None} + + +class Node(): + """Contains one Zabbix node object. + + Args: + addr (str): Listen address of Zabbix server. + port (int, str): Listen port of Zabbix server. + + Raises: + TypeError: Raises if not integer value was received. + """ + + def __init__(self, addr: str, port: Union[int, str]): + self.address = addr if addr != '0.0.0.0/0' else '127.0.0.1' + try: + self.port = int(port) + except ValueError: + raise TypeError('Port must be an integer value') from None + + def __str__(self) -> str: + return f"{self.address}:{self.port}" + + def __repr__(self) -> str: + return self.__str__() + + +class Cluster(): + """Contains Zabbix node objects in a cluster object. + + Args: + addr (list): Raw list of node addresses. + """ + + def __init__(self, addr: list): + self.__nodes = self.__parse_ha_node(addr) + + def __parse_ha_node(self, node_list: list) -> list: + nodes = [] + for node_item in node_list: + node_item = node_item.strip() + if ':' in node_item: + nodes.append(Node(*node_item.split(':'))) + else: + nodes.append(Node(node_item, '10051')) + + return nodes + + def __str__(self) -> str: + return json.dumps([(node.address, node.port) for node in self.__nodes]) + + def __repr__(self) -> str: + return self.__str__() + + @property + def nodes(self) -> list: + """Returns list of Node objects. + + Returns: + list: List of Node objects + """ + + return self.__nodes + + +class AgentResponse: + """Contains response from Zabbix agent/agent2. + + Args: + response (string): Raw response from Zabbix. + """ + + def __init__(self, response: str): + error_code = 'ZBX_NOTSUPPORTED' + self.raw = response + if response == error_code: + self.value = None + self.error = 'Not supported by Zabbix Agent' + elif response.startswith(error_code + '\0'): + self.value = None + self.error = response[len(error_code)+1:] + else: + idx = response.find('\0') + if idx == -1: + self.value = response + else: + self.value = response[:idx] + self.error = None + + def __repr__(self) -> str: + return json.dumps({ + 'error': self.error, + 'raw': self.raw, + 'value': self.value, + }) diff --git a/zabbix_utils/version.py b/zabbix_utils/version.py index 4d17b80..509bc53 100644 --- a/zabbix_utils/version.py +++ b/zabbix_utils/version.py @@ -22,7 +22,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR # OTHER DEALINGS IN THE SOFTWARE. -__version__ = "1.1.1" +__version__ = "2.0.0" __min_supported__ = 5.0 __max_supported__ = 7.0