"""This verifies zeekclient's ability to ingest cluster configurations, validate their content (excluding deeper validations happening in the cluster controller), and serialize them correctly to INI/JSON. """ import configparser import io import json import unittest from unittest.mock import MagicMock, patch import zeekclient class TestRendering(unittest.TestCase): INI_INPUT = """# A sample ini using all available keys. [instances] agent [manager] instance = agent port = 5000 role = manager metrics_port = 6000 [logger-01] instance = agent port = 5001 role = logger scripts = foo/bar/baz [worker-01] instance = agent role = worker interface = lo env = FOO=BAR BLUM=frub cpu_affinity = 4 [worker-02] instance = agent role = worker interface = enp3s0 cpu_affinity = 8 metrics_port = 6001 """ INI_EXPECTED = """[instances] agent [logger-01] instance = agent role = LOGGER port = 5001 scripts = foo/bar/baz [manager] instance = agent role = MANAGER port = 5000 metrics_port = 6000 [worker-01] instance = agent role = WORKER interface = lo cpu_affinity = 4 env = BLUM=frub FOO=BAR [worker-02] instance = agent role = WORKER interface = enp3s0 cpu_affinity = 8 metrics_port = 6001 """ JSON_EXPECTED = """{ "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "instances": [ { "name": "agent" } ], "nodes": [ { "cpu_affinity": null, "env": {}, "instance": "agent", "interface": null, "metrics_port": null, "name": "logger-01", "options": null, "port": 5001, "role": "LOGGER", "scripts": [ "foo/bar/baz" ] }, { "cpu_affinity": null, "env": {}, "instance": "agent", "interface": null, "metrics_port": 6000, "name": "manager", "options": null, "port": 5000, "role": "MANAGER", "scripts": null }, { "cpu_affinity": 4, "env": { "BLUM": "frub", "FOO": "BAR" }, "instance": "agent", "interface": "lo", "metrics_port": null, "name": "worker-01", "options": null, "port": null, "role": "WORKER", "scripts": null }, { "cpu_affinity": 8, "env": {}, "instance": "agent", "interface": "enp3s0", "metrics_port": 6001, "name": "worker-02", "options": null, "port": null, "role": "WORKER", "scripts": null } ] }""" def assertEqualStripped(self, str1, str2): # noqa: N802 self.assertEqual(str1.strip(), str2.strip()) def parser_from_string(self, content): cfp = configparser.ConfigParser(allow_no_value=True) cfp.read_string(content) return cfp def setUp(self): # A buffer receiving any created log messages, for validation. We could # also assertLogs(), but with the latter it's more work to get exactly # the output the user would see. self.logbuf = io.StringIO() zeekclient.logs.configure(verbosity=3, stream=self.logbuf) def test_full_config_ini(self): # This test parses a feature-complete configuration from an INI file, # and verifies that writing it back out to an INI yields expected # content. # Parse the input into a config parser, and create a Configuration # object from it. cfp = self.parser_from_string(self.INI_INPUT) config = zeekclient.types.Configuration.from_config_parser(cfp) self.assertTrue(config is not None) # Turning that back into a config parser should have expected content: cfp = config.to_config_parser() with io.StringIO() as buf: cfp.write(buf) self.assertEqualStripped(buf.getvalue(), self.INI_EXPECTED) # Another roundtrip: the content should not change. config = zeekclient.types.Configuration.from_config_parser(cfp) self.assertTrue(config is not None) cfp = config.to_config_parser() with io.StringIO() as buf: cfp.write(buf) self.assertEqualStripped(buf.getvalue(), self.INI_EXPECTED) def test_full_config_json(self): # This test parses a feature-complete configuration from an INI file, # and verifies that writing it to JSON yields expected content. # Parse the input into a config parser, and create a Configuration # object from it. cfp = self.parser_from_string(self.INI_INPUT) config = zeekclient.types.Configuration.from_config_parser(cfp) self.assertTrue(config is not None) jdata = config.to_json_data() def canon(c): """Canonicalize the ID""" return "-" if c == "-" else "x" jdata["id"] = "".join([canon(c) for c in jdata["id"]]) self.assertEqual( json.dumps(jdata, sort_keys=True, indent=4), self.JSON_EXPECTED, ) def test_config_addl_key(self): # This test creates a Configuration from an INI file with additional # keys that should get ignored in the instantiated object, but trigger # log warnings. ini_input = """ [instances] agent [manager] instance = agent port = 5000 role = manager not_a_key = mhmmm also_not_a_key = uh oh """ ini_expected = """[instances] agent [manager] instance = agent role = MANAGER port = 5000 """ cfp = self.parser_from_string(ini_input) config = zeekclient.types.Configuration.from_config_parser(cfp) self.assertTrue(config is not None) cfp = config.to_config_parser() with io.StringIO() as buf: cfp.write(buf) self.assertEqualStripped(buf.getvalue(), ini_expected) self.assertEqualStripped( self.logbuf.getvalue(), "warning: ignoring unexpected keys: also_not_a_key, not_a_key", ) def test_config_ipv4_ipv6_instances(self): # This test creates a Configuration from an INI file with various IP addresses # and ports specified for the agents. ini_input = """[instances] agent = 127.0.0.1:2151 agent2 = ::1:2151 agent3 = [::2]:2151 [manager] instance = agent role = MANAGER port = 5000 """ ini_expected = """[instances] agent = 127.0.0.1:2151 agent2 = ::1:2151 agent3 = ::2:2151 [manager] instance = agent role = MANAGER port = 5000 """ cfp = self.parser_from_string(ini_input) config = zeekclient.types.Configuration.from_config_parser(cfp) self.assertTrue(config is not None) # Turning that back into a config parser should have expected content: cfp = config.to_config_parser() with io.StringIO() as buf: cfp.write(buf) self.assertEqualStripped(buf.getvalue(), ini_expected) def test_config_invalid_ipv4_instance(self): # This test creates a Configuration with an invalid IPv4 address ini_input = """[instances] agent = 127.0.0.1.1:2151 [manager] instance = agent role = MANAGER port = 5000 """ cfp = self.parser_from_string(ini_input) with self.assertRaisesRegex( ValueError, "'127.0.0.1.1' does not appear to be an IPv4 or IPv6 address", ): zeekclient.types.Configuration.from_config_parser(cfp) def test_config_invalid_ipv6_instance(self): # This test creates a Configuration with an invalid IPv6 address ini_input = """[instances] agent = ::2/128:2151 [manager] instance = agent role = MANAGER port = 5000 """ cfp = self.parser_from_string(ini_input) with self.assertRaisesRegex( ValueError, "'::2/128' does not appear to be an IPv4 or IPv6 address", ): zeekclient.types.Configuration.from_config_parser(cfp) def test_config_invalid_instances(self): ini_input = """ [instances] agent = foo: [manager] instance = agent port = 80 role = manager """ cfp = self.parser_from_string(ini_input) config = zeekclient.types.Configuration.from_config_parser(cfp) self.assertFalse(config) self.assertEqualStripped( self.logbuf.getvalue(), 'error: invalid spec for instance "agent": "foo:" should be :', ) def test_config_missing_instance(self): ini_input = """ [instances] agent [manager] role = manager """ cfp = self.parser_from_string(ini_input) config = zeekclient.types.Configuration.from_config_parser(cfp) self.assertFalse(config) self.assertEqualStripped( self.logbuf.getvalue(), "error: omit instances section when skipping instances in node definitions", ) def test_config_mixed_instances(self): ini_input = """ [manager] role = manager [worker] role = worker instance = agent1 """ cfp = self.parser_from_string(ini_input) config = zeekclient.types.Configuration.from_config_parser(cfp) self.assertFalse(config) self.assertEqualStripped( self.logbuf.getvalue(), "error: either all or no nodes must state instances", ) def test_config_missing_role(self): ini_input = """ [instances] agent [manager] instance = agent port = 80 """ cfp = self.parser_from_string(ini_input) config = zeekclient.types.Configuration.from_config_parser(cfp) self.assertFalse(config) self.assertEqualStripped( self.logbuf.getvalue(), 'error: invalid node "manager" configuration: node requires a role', ) def test_config_invalid_role(self): ini_input = """ [instances] agent [manager] instance = agent port = 80 role = superintendent """ cfp = self.parser_from_string(ini_input) config = zeekclient.types.Configuration.from_config_parser(cfp) self.assertFalse(config) self.assertEqualStripped( self.logbuf.getvalue(), 'error: invalid node "manager" configuration: role "superintendent" is invalid', ) def test_config_invalid_port_string(self): ini_input = """ [instances] agent [manager] instance = agent port = eighty role = manager """ cfp = self.parser_from_string(ini_input) config = zeekclient.types.Configuration.from_config_parser(cfp) self.assertFalse(config) self.assertEqualStripped( self.logbuf.getvalue(), 'error: invalid node "manager" configuration: ' 'cannot convert "manager.port" value "eighty" to int', ) def test_config_invalid_port_number(self): ini_input = """ [instances] agent [manager] instance = agent port = 70000 role = manager """ cfp = self.parser_from_string(ini_input) config = zeekclient.types.Configuration.from_config_parser(cfp) self.assertFalse(config) self.assertEqualStripped( self.logbuf.getvalue(), 'error: invalid node "manager" configuration: port 70000 outside valid range', ) @patch("zeekclient.types.socket.gethostname", new=MagicMock(return_value="testbox")) def test_config_no_instances(self): ini_input = """ [manager] role = manager """ ini_expected = """ [instances] agent-testbox [manager] instance = agent-testbox role = MANAGER """ cfp = self.parser_from_string(ini_input) config = zeekclient.types.Configuration.from_config_parser(cfp) self.assertTrue(config is not None) cfp = config.to_config_parser() with io.StringIO() as buf: cfp.write(buf) self.assertEqualStripped(buf.getvalue(), ini_expected) def test_config_missing_instance_section(self): ini_input = """ [manager] instance = agent role = manager [logger] instance = agent2 role = logger [worker] instance = agent role = worker """ ini_expected = """ [instances] agent agent2 [logger] instance = agent2 role = LOGGER [manager] instance = agent role = MANAGER [worker] instance = agent role = WORKER """ cfp = self.parser_from_string(ini_input) config = zeekclient.types.Configuration.from_config_parser(cfp) self.assertTrue(config is not None) cfp = config.to_config_parser() with io.StringIO() as buf: cfp.write(buf) self.assertEqualStripped(buf.getvalue(), ini_expected)