diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cd2c48b6..76b7df21 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,12 +5,12 @@ Brewtils Changelog ------ TBD +- Updated plugin class to accept version contraints for required dependencies. Contraints follow python packaging version specifiers. - Added new annotation/configuration support for shutdown functions. These functions will be executed at the start of the shutdown process. - Added new annotation/configuration support for startup functions. These functions will be executed after `Plugin().run()` has completed startup processes - 3.28.0 ------ 10/9/24 diff --git a/brewtils/plugin.py b/brewtils/plugin.py index c7cfd1f4..0d0e0213 100644 --- a/brewtils/plugin.py +++ b/brewtils/plugin.py @@ -12,6 +12,7 @@ import appdirs from box import Box +from packaging.requirements import Requirement from packaging.version import Version from requests import ConnectionError as RequestsConnectionError @@ -495,17 +496,44 @@ def get_timestamp(add_time: int = None): return current_timestamp + add_time return current_timestamp + def get_system_matching_version(self, require, **kwargs): + system = None + req = Requirement(require) + require_name = req.name + require_version = req.specifier + systems = self._ez_client.find_systems(name=require_name, **kwargs) + if require_version: + valid_versions = list( + require_version.filter( + [str(Version(system.version)) for system in systems] + ) + ) + else: + valid_versions = [str(Version(system.version)) for system in systems] + + if valid_versions: + system_candidates = [ + system + for system in systems + if str(Version(system.version)) in valid_versions + ] + system = system_candidates[0] + for system_candidate in system_candidates: + if Version(system_candidate.version) > Version(system.version): + system = system_candidate + + return system + def get_system_dependency(self, require, timeout=300): wait_time = 0.1 while timeout > 0: - system = self._ez_client.find_unique_system(name=require, local=True) - if ( - system - and system.instances - and any("RUNNING" == instance.status for instance in system.instances) - ): + system = self.get_system_matching_version( + require, filter_running=True, local=True + ) + if system: + self._logger.debug(f"Found system: {system}") return system - self.logger.error( + self._logger.error( f"Waiting {wait_time:.1f} seconds before next attempt for {self._system} " f"dependency for {require}" ) @@ -520,7 +548,7 @@ def get_system_dependency(self, require, timeout=300): def await_dependencies(self, requires, config): for req in requires: system = self.get_system_dependency(req, config.requires_timeout) - self.logger.debug( + self._logger.debug( f"Resolved system {system} for {req}: {config.name} {config.instance_name}" ) diff --git a/brewtils/test/fixtures.py b/brewtils/test/fixtures.py index 367335e7..645fc4fe 100644 --- a/brewtils/test/fixtures.py +++ b/brewtils/test/fixtures.py @@ -294,6 +294,46 @@ def bg_system_2(system_dict, bg_instance, bg_command, bg_command_2): return System(**dict_copy) +@pytest.fixture +def bg_system_3(system_dict, bg_instance, bg_command, bg_command_2): + """A system with a different version.""" + dict_copy = copy.deepcopy(system_dict) + dict_copy["version"] = "2.1.0" + dict_copy["instances"] = [bg_instance] + dict_copy["commands"] = [bg_command, bg_command_2] + return System(**dict_copy) + + +@pytest.fixture +def bg_system_4(system_dict, bg_instance, bg_command, bg_command_2): + """A system with a different version.""" + dict_copy = copy.deepcopy(system_dict) + dict_copy["version"] = "2.1.1" + dict_copy["instances"] = [bg_instance] + dict_copy["commands"] = [bg_command, bg_command_2] + return System(**dict_copy) + + +@pytest.fixture +def bg_system_5(system_dict, bg_instance, bg_command, bg_command_2): + """A system with a different version.""" + dict_copy = copy.deepcopy(system_dict) + dict_copy["version"] = "3.0.0" + dict_copy["instances"] = [bg_instance] + dict_copy["commands"] = [bg_command, bg_command_2] + return System(**dict_copy) + + +@pytest.fixture +def bg_system_6(system_dict, bg_instance, bg_command, bg_command_2): + """A system with a different version.""" + dict_copy = copy.deepcopy(system_dict) + dict_copy["version"] = "3.0.0.dev0" + dict_copy["instances"] = [bg_instance] + dict_copy["commands"] = [bg_command, bg_command_2] + return System(**dict_copy) + + @pytest.fixture def child_request_dict(ts_epoch): """A child request represented as a dictionary.""" diff --git a/test/plugin_test.py b/test/plugin_test.py index d71a0f90..eae93048 100644 --- a/test/plugin_test.py +++ b/test/plugin_test.py @@ -1,8 +1,11 @@ # -*- coding: utf-8 -*- +import copy import logging import logging.config import os import warnings +from packaging.requirements import InvalidRequirement +from packaging.version import InvalidVersion import pytest from mock import ANY, MagicMock, Mock @@ -369,6 +372,7 @@ def test_success( return_value=(admin_processor, request_processor) ) plugin._ez_client.find_unique_system = Mock(return_value=bg_system) + plugin._ez_client.find_systems = Mock(return_value=[bg_system]) plugin._startup() assert admin_processor.startup.called is True @@ -390,6 +394,7 @@ def test_success_no_ns( return_value=(admin_processor, request_processor) ) plugin._ez_client.find_unique_system = Mock(return_value=bg_system) + plugin._ez_client.find_systems = Mock(return_value=[bg_system]) plugin._startup() assert admin_processor.startup.called is True @@ -960,3 +965,147 @@ def test_remote_plugin(self): assert "'RemotePlugin'" in str(warning) assert "'Plugin'" in str(warning) assert "4.0" in str(warning) + + +class TestDependencies(object): + # 1.0.0 bg_system + # 2.0.0 bg_system_2 + # 2.1.0 bg_system_3 + # 2.1.1 bg_system_4 + # 3.0.0 bg_system_5 + # 3.0.0.dev0 bg_system_6 + @pytest.mark.parametrize( + "latest,versions", + [ + ("1.0.0", ["1.0.0"]), + ("2.0.0", ["1.0.0", "2.0.0"]), + ("1.2.0", ["1.0.0", "1.2.0"]), + ("1.0.0", ["1.0.0", "0.2.1rc1"]), + ("1.0.0rc1", ["1.0.0rc1", "0.2.1"]), + ("1.0.0rc1", ["1.0.0rc1", "0.2.1rc1"]), + ("1.0", ["1.0", "0.2.1"]), + ("1.0.0", ["1.0.0rc1", "1.0.0"]), + ("3.0.0.dev0", ["3.0.0.dev0", "3.0.0.dev"]), + ("3.0.0.dev", ["3.0.0.dev", "2.0.0"]), + ], + ) + def test_determine_latest(client, bg_system, versions, latest): + p = Plugin(bg_host="localhost", system=bg_system) + system_versions = [] + for version in versions: + s = copy.deepcopy(bg_system) + s.version = version + system_versions.append(s) + p._ez_client.find_systems.return_value = system_versions + assert p.get_system_dependency("system").version == latest + + @pytest.mark.parametrize( + "latest,versions", + [ + ("b", ["a", "b"]), + ("1.0.0", ["a", "b", "1.0.0"]), + ], + ) + def test_determine_latest_failures(client, bg_system, versions, latest): + p = Plugin(bg_host="localhost", system=bg_system) + system_versions = [] + for version in versions: + s = copy.deepcopy(bg_system) + s.version = version + system_versions.append(s) + p._ez_client.find_systems.return_value = system_versions + with pytest.raises(InvalidVersion): + assert p.get_system_dependency("system").version == latest + + @pytest.mark.parametrize( + "version_spec,latest", + [ + ("system", "3.0.0"), # test no specifier ignores pre-release + ("system==3.0.0.dev", "3.0.0.dev0"), # test version parsing + ("system==2.1.0", "2.1.0"), # test equals + ("system==3", "3.0.0"), # test equals no dev + ("system~=2.1.0", "2.1.1"), # test compatible release + ("system==2.*", "2.1.1"), # test minor wildcard + ("system==2.1.*", "2.1.1"), # test patch wildcard + ("system!=2.1.0", "3.0.0"), # test excludes + ("system>2.1.0", "3.0.0"), # test greater than + ("system>=2.1.0", "3.0.0"), # test greater than or equal + ("system<2.1.0", "2.0.0"), # test less than + ("system<=2.1.0", "2.1.0"), # test less than or equal + ("system<2.0.0,>=1", "1.0.0"), # test range + ("system==2.*,<2.1.1,!=2.1.0", "2.0.0"), # test combination + ], + ) + def test_version_specifier( + plugin, + bg_system, + bg_system_2, + bg_system_3, + bg_system_4, + bg_system_5, + bg_system_6, + version_spec, + latest, + ): + p = Plugin(bg_host="localhost", system=bg_system) + p._ez_client.find_systems.return_value = [ + bg_system, + bg_system_2, + bg_system_3, + bg_system_4, + bg_system_5, + bg_system_6, + ] + assert p.get_system_dependency(version_spec).version == latest + + def test_no_match( + plugin, + bg_system, + bg_system_2, + bg_system_3, + bg_system_4, + bg_system_5, + bg_system_6, + ): + p = Plugin(bg_host="localhost", system=bg_system) + p._ez_client.find_systems.return_value = [ + bg_system, + bg_system_2, + bg_system_3, + bg_system_4, + bg_system_5, + bg_system_6, + ] + p._wait = Mock(return_value=None) + with pytest.raises(PluginValidationError): + assert p.get_system_dependency("system==3.0.1.dev0").version + + @pytest.mark.parametrize( + "version_spec", + [ + "system==*", + "system==a", + "system$$3.0.0", + ], + ) + def test_invalid_requirement( + plugin, + bg_system, + bg_system_2, + bg_system_3, + bg_system_4, + bg_system_5, + bg_system_6, + version_spec, + ): + p = Plugin(bg_host="localhost", system=bg_system) + p._ez_client.find_systems.return_value = [ + bg_system, + bg_system_2, + bg_system_3, + bg_system_4, + bg_system_5, + bg_system_6, + ] + with pytest.raises(InvalidRequirement): + p.get_system_dependency(version_spec)