Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Plugin installer migration #173

Merged
merged 7 commits into from
Apr 5, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions osbenchmark/builder/installers/opensearch_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ def __init__(self, provision_config_instance, executor, hook_handler_class=Boots
self.config_applier = ConfigApplier(executor, self.template_renderer, self.path_manager)
self.host_cleaner = HostCleaner(self.path_manager)

def install(self, host, binaries, all_node_ips):
# pylint: disable=arguments-differ
def install(self, host, binaries, all_node_ips, config_vars=None):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should config_vars just be added to the Installer.install interface?

Is there a reason that disabling the linter is preferred?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config_vars shouldn't be part of this method's signature at all IMO. An installer should be responsible for owning and defining its own config_vars rather than having some passed into it.

The trouble comes when both OS and plugin(s) are set to be installed. In this case, the config_vars from all sources must be considered. Adding config_vars to the signature allows the CompositeInstaller to pass in the finalized config_vars from all sources without changing the general flow of the sub-installers. OpenSearchInstaller can be consumed as a standalone Installer implementation or utilized by the CompositeInstaller via the same install method.

I feel that I am missing a design pattern that would eliminate the need to add config_vars to this method. I will think on it some more and try to come up with a better solution.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with your assessment that config_vars shouldn't be a part of this method. Its essentially an implementation detail that the user of this method shouldn't care about. Also, that's why the Installer interface shouldn't & doesn't have it.

Please correct me if I'm wrong, but it seems like each installer already just makes a single install call in its lifetime. Constructor is instance specific and can be different for classes implementing the same interface, have you considered having the config_vars as an optional constructor param for OpenSearchInstaller and making it an class level variable?

I believe this way the CompositeInstaller can have multiple sub-installers, and not break their general flow. However, if you mean the CompositeInstaller needs results from sub-installers to come up with the final config_vars, I'd still recommend the same approach, but using a factory class the generates the OS installer instance after the config_vars are ready.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CompositeInstaller does need results from the sub-installers to come up with the final config_vars. Specifically, it needs the Node object generated by the OpenSearchInstaller's install method.

With the current implementation there's no way to generate the config_vars without first instantiating an instance of OpenSearchInstaller and invoking the install method to get a Node. I think a further layer of abstraction to separate preparing the installation and applying the config data is the solution here: create new OpenSearchPreparer and PluginPreparer classes with prepare and get_config_vars methods.

Then the OpenSearch and PluginInstallers would take in a respective instance of these Preparer classes and perform the whole installation flow (including applying the config) within the install method. The CompositeInstaller would have instances of both OpenSearchPreparer and PluginPreparer, with the flow being: call prepare methods -> call get_config_vars methods -> generate final config_vars -> apply configs

I plan to make heavy use of the factory pattern when selecting the correct builder components for the ClusterBuilder :)

node = self._create_node()
self._prepare_node(host, node, binaries[OpenSearchInstaller.OPENSEARCH_BINARY_KEY], all_node_ips)
self._prepare_node(host, node, binaries[OpenSearchInstaller.OPENSEARCH_BINARY_KEY], all_node_ips, config_vars)

return node

Expand All @@ -50,14 +51,14 @@ def _create_node(self):
data_paths=None,
telemetry=None)

def _prepare_node(self, host, node, binary, all_node_ips):
def _prepare_node(self, host, node, binary, all_node_ips, config_vars):
self._prepare_directories(host, node)
self._extract_opensearch(host, node, binary)
self._update_node_binary_path(node)
self._set_node_data_paths(node)
# we need to immediately delete the prebundled config files as plugins may copy their configuration during installation.
self._delete_prebundled_config_files(host, node)
self._prepare_config_files(host, node, all_node_ips)
self._prepare_config_files(host, node, all_node_ips, config_vars)

def _prepare_directories(self, host, node):
directories_to_create = [node.binary_path, node.log_path, node.heap_dump_path]
Expand All @@ -79,12 +80,13 @@ def _delete_prebundled_config_files(self, host, node):
self.logger.info("Deleting pre-bundled OpenSearch configuration at [%s]", config_path)
self.path_manager.delete_path(host, config_path)

def _prepare_config_files(self, host, node, all_node_ips):
config_vars = self.get_config_vars(host, node, all_node_ips)
def _prepare_config_files(self, host, node, all_node_ips, config_vars):
if not config_vars:
config_vars = self.get_config_vars(host, node, all_node_ips)
self.config_applier.apply_configs(host, node, self.provision_config_instance.config_paths, config_vars)

def get_config_vars(self, host, node, all_node_ips):
provisioner_defaults = {
installer_defaults = {
"cluster_name": self.provision_config_instance.variables["cluster_name"],
"node_name": node.name,
"data_paths": node.data_paths[0],
Expand All @@ -103,7 +105,7 @@ def get_config_vars(self, host, node, all_node_ips):
}
config_vars = {}
config_vars.update(self.provision_config_instance.variables)
config_vars.update(provisioner_defaults)
config_vars.update(installer_defaults)
return config_vars

def invoke_install_hook(self, phase, variables, env):
Expand Down
50 changes: 50 additions & 0 deletions osbenchmark/builder/installers/plugin_installer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import logging
import os

from osbenchmark.builder.installers.installer import Installer
from osbenchmark.builder.provision_config import BootstrapHookHandler
from osbenchmark.builder.utils.config_applier import ConfigApplier
from osbenchmark.builder.utils.path_manager import PathManager
from osbenchmark.builder.utils.template_renderer import TemplateRenderer


class PluginInstaller(Installer):
def __init__(self, plugin, executor, hook_handler_class=BootstrapHookHandler):
super().__init__(executor)
self.logger = logging.getLogger(__name__)
self.plugin = plugin
self.hook_handler = hook_handler_class(self.plugin)
if self.hook_handler.can_load():
self.hook_handler.load()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to happen during the object construction itself? Would it make sense to move this to the install logic?

I'm not fundamentally against it but prefer lightweight constructors. Think of scenarios where these fail and what stack trace they leave - I feel it makes more sense if we say the plugin failed to install /load. Instead if the constructor throws an exception, I suspect there's things like class not found exception or something which isn't a good description of what happened.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could go in the install logic. The rationale for having it here is to fail fast if an exception is thrown when loading the hook_handler.

If this logic is moved to the install method, then the exception it creates on failure would only be thrown after the infrastructure has been provisioned and the necessary data downloaded and installed on the host. By failing in the constructor, no resources are created or work done for a scenario that couldn't possibly succeed.

Perhaps wrapping the exception with some documentation on the failure would be a solution to the vague description. Given that this hook_handler logic is used in multiple Installers, adding that handling into the factory generating Installer instances would be a good approach.

self.template_renderer = TemplateRenderer()
self.path_manager = PathManager(executor)
self.config_applier = ConfigApplier(executor, self.template_renderer, self.path_manager)

# pylint: disable=arguments-differ
def install(self, host, binaries, all_node_ips, config_vars=None):
install_cmd = self._get_install_command(host, binaries)
self.executor.execute(host, install_cmd)

if not config_vars:
config_vars = self.get_config_vars()
self.config_applier.apply_configs(host, host.node, self.plugin.config_paths, config_vars)

def _get_install_command(self, host, binaries):
installer_binary_path = os.path.join(host.node.binary_path, "bin", "opensearch-plugin")
plugin_binary_path = binaries.get(self.plugin.name)

if plugin_binary_path:
self.logger.info("Installing [%s] into [%s] from [%s]", self.plugin.name, host.node.binary_path, plugin_binary_path)
return '%s install --batch "%s"' % (installer_binary_path, plugin_binary_path)
else:
self.logger.info("Installing [%s] into [%s]", self.plugin.name, host.node.binary_path)
return '%s install --batch "%s"' % (installer_binary_path, self.plugin.name)

def get_config_vars(self):
return self.plugin.variables

def invoke_install_hook(self, phase, variables, env):
self.hook_handler.invoke(phase.name, variables=variables, env=env)

def cleanup(self, host):
pass
52 changes: 52 additions & 0 deletions tests/builder/installers/plugin_installer_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from unittest import TestCase, mock
from unittest.mock import Mock

from osbenchmark.builder.installers.plugin_installer import PluginInstaller
from osbenchmark.builder.models.host import Host
from osbenchmark.builder.models.node import Node
from osbenchmark.builder.provision_config import PluginDescriptor


class PluginInstallerTest(TestCase):
def setUp(self):
self.node = Node(binary_path="/fake_binary_path", data_paths=["/fake1", "/fake2"], name=None,
pid=None, telemetry=None, port=None, root_dir=None, log_path=None, heap_dump_path=None)
self.host = Host(name="fake", address="10.17.22.23", metadata={}, node=self.node)
self.binaries = {"unit-test-plugin": "/data/builds/distributions"}
self.all_node_ips = []

self.executor = Mock()
self.plugin = PluginDescriptor(name="unit-test-plugin", config_paths=["default"], variables={"active": True})

self.plugin_installer = PluginInstaller(self.plugin, self.executor)
self.plugin_installer.config_applier = Mock()

def test_plugin_install_with_binary_path(self):
self.plugin_installer.install(self.host, self.binaries, self.all_node_ips)

self.executor.execute.assert_has_calls([
mock.call(self.host, "/fake_binary_path/bin/opensearch-plugin install --batch \"/data/builds/distributions\"")
])
self.plugin_installer.config_applier.apply_configs.assert_has_calls([
mock.call(self.host, self.node, ["default"], {"active": True})
])

def test_plugin_install_without_binary_path(self):
self.plugin_installer.install(self.host, {}, self.all_node_ips)

self.executor.execute.assert_has_calls([
mock.call(self.host, "/fake_binary_path/bin/opensearch-plugin install --batch \"unit-test-plugin\"")
])
self.plugin_installer.config_applier.apply_configs.assert_has_calls([
mock.call(self.host, self.node, ["default"], {"active": True})
])

def test_config_vars_override(self):
self.plugin_installer.install(self.host, {}, self.all_node_ips, {"my": "override"})

self.executor.execute.assert_has_calls([
mock.call(self.host, "/fake_binary_path/bin/opensearch-plugin install --batch \"unit-test-plugin\"")
])
self.plugin_installer.config_applier.apply_configs.assert_has_calls([
mock.call(self.host, self.node, ["default"], {"my": "override"})
])