logstash/lib/pluginmanager/install.rb
Ry Biesemeyer a736178d59
Pluginmanager install preserve (#17267)
* tests: integration tests for pluginmanager install --preserve

* fix regression where pluginmanager's install --preserve flag didn't
2025-03-05 20:38:59 -08:00

287 lines
12 KiB
Ruby

# Licensed to Elasticsearch B.V. under one or more contributor
# license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright
# ownership. Elasticsearch B.V. licenses this file to you under
# the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
require "pluginmanager/command"
require "pluginmanager/install_strategy_factory"
require "pluginmanager/ui"
require "pluginmanager/errors"
require "jar-dependencies"
require "jar_install_post_install_hook"
require "fileutils"
class LogStash::PluginManager::Install < LogStash::PluginManager::Command
parameter "[PLUGIN] ...", "plugin name(s) or file", :attribute_name => :plugins_arg
option "--version", "VERSION", "version of the plugin to install"
option "--[no-]verify", :flag, "verify plugin validity before installation", :default => true
option "--preserve", :flag, "preserve current gem options", :default => false
option "--development", :flag, "install all development dependencies of currently installed plugins", :default => false
option "--local", :flag, "force local-only plugin installation. see bin/logstash-plugin package|unpack", :default => false
option "--[no-]conservative", :flag, "do a conservative update of plugin's dependencies", :default => true
# the install logic below support installing multiple plugins with each a version specification
# but the argument parsing does not support it for now so currently if specifying --version only
# one plugin name can be also specified.
def execute
# Turn off any jar dependencies lookup when running with `--local`
ENV["JARS_SKIP"] = "true" if local?
# This is a special flow for PACK related plugins,
# if we dont detect an pack we will just use the normal `Bundle install` Strategy`
# this could be refactored into his own strategy
begin
if strategy = LogStash::PluginManager::InstallStrategyFactory.create(plugins_arg)
LogStash::PluginManager.ui.debug("Installing with strategy: #{strategy.class}")
strategy.execute
return
end
rescue LogStash::PluginManager::InstallError => e
report_exception("An error occured when installing the: #{plugins_args_human}, to have more information about the error add a DEBUG=1 before running the command.", e.original_exception)
return
rescue LogStash::PluginManager::FileNotFoundError => e
report_exception("File not found for: #{plugins_args_human}", e)
return
rescue LogStash::PluginManager::InvalidPackError => e
report_exception("Invalid pack for: #{plugins_args_human}, reason: #{e.message}", e)
return
rescue => e
report_exception("Something went wrong when installing #{plugins_args_human}", e)
return
end
# TODO(ph): refactor this into his own strategy
validate_cli_options!
if local_gems?
gems = extract_local_gems_plugins
elsif development?
gems = plugins_development_gems
else
gems = plugins_gems
gems = verify_remote!(gems) if !local? && verify?
end
check_for_integrations(gems)
update_logstash_mixin_dependencies(gems)
install_gems_list!(gems)
remove_unused_locally_installed_gems!
remove_unused_integration_overlaps!
remove_orphan_dependencies!
end
private
def remove_unused_integration_overlaps!
installed_plugin_specs = plugins_arg.flat_map do |plugin_arg|
if LogStash::PluginManager.plugin_file?(plugin_arg)
LogStash::PluginManager.plugin_file_spec(plugin_arg)
else
LogStash::PluginManager.find_plugins_gem_specs(plugin_arg)
end
end.select do |spec|
LogStash::PluginManager.integration_plugin_spec?(spec)
end.flat_map do |spec|
LogStash::PluginManager.integration_plugin_provides(spec)
end.select do |plugin_name|
LogStash::PluginManager.installed_plugin?(plugin_name, gemfile)
end.each do |plugin_name|
puts "Removing '#{plugin_name}' since it is provided by an integration plugin"
::Bundler::LogstashUninstall.uninstall!(plugin_name)
end
end
def check_for_integrations(gems)
gems.each do |plugin, _version|
integration_plugin = LogStash::PluginManager.which_integration_plugin_provides(plugin, gemfile)
if integration_plugin
signal_error("Installation aborted, plugin '#{plugin}' is already provided by '#{integration_plugin.name}'")
end
end
end
def validate_cli_options!
if development?
signal_usage_error("Cannot specify plugin(s) with --development, it will add the development dependencies of the currently installed plugins") unless plugins_arg.empty?
else
signal_usage_error("No plugin specified") if plugins_arg.empty? && verify?
# TODO: find right syntax to allow specifying list of plugins with optional version specification for each
signal_usage_error("Only 1 plugin name can be specified with --version") if version && plugins_arg.size > 1
end
signal_error("File #{LogStash::Environment::GEMFILE_PATH} does not exist or is not writable, aborting") unless ::File.writable?(LogStash::Environment::GEMFILE_PATH)
end
# Check if the specified gems contains
# the logstash `metadata`
def verify_remote!(gems)
gems_swap = {}
options = { :rubygems_source => gemfile.gemset.sources }
gems.each do |plugin, version|
puts("Validating #{[plugin, version].compact.join("-")}")
next if validate_plugin(plugin, version, options)
signal_usage_error("Installs of an alias doesn't require version specification --version") if version
# if the plugin is an alias then fallback to the original name
if LogStash::PluginManager::ALIASES.has_key?(plugin)
resolved_plugin = LogStash::PluginManager::ALIASES[plugin]
if validate_plugin(resolved_plugin, version, options)
puts "Remapping alias #{plugin} to #{resolved_plugin}"
gems_swap[plugin] = resolved_plugin
next
end
end
signal_error("Installation aborted, verification failed for #{plugin} #{version}")
end
# substitute in gems the list the alias plugin with the original
gems.collect do |plugin, version|
[gems_swap.fetch(plugin, plugin), version]
end
end
def validate_plugin(plugin, version, options)
LogStash::PluginManager.logstash_plugin?(plugin, version, options)
rescue SocketError
false
end
def plugins_development_gems
# Get currently defined gems and their dev dependencies
specs = []
specs = LogStash::PluginManager.all_installed_plugins_gem_specs(gemfile)
# Construct the list of dependencies to add to the current gemfile
specs.each_with_object([]) do |spec, install_list|
dependencies = spec.dependencies
.select { |dep| dep.type == :development }
.map { |dep| [dep.name] + dep.requirement.as_list }
install_list.concat(dependencies)
end
end
def plugins_gems
version ? [plugins_arg << version] : plugins_arg.map { |plugin| [plugin, nil] }
end
def local_gem?
plugins_arg.any? { |plugin_arg| LogStash::PluginManager.plugin_file?(plugin_arg) }
end
def update_logstash_mixin_dependencies(install_list)
return if !verify? || preserve? || development? || local? || local_gem?
puts "Resolving mixin dependencies"
LogStash::Bundler.prepare
plugins_to_update = install_list.map(&:first)
unlock_dependencies = LogStash::Bundler.expand_logstash_mixin_dependencies(plugins_to_update) - plugins_to_update
if unlock_dependencies.any?
puts "Updating mixin dependencies #{unlock_dependencies.join(', ')}"
LogStash::Bundler.invoke! update: unlock_dependencies,
rubygems_source: gemfile.gemset.sources,
conservative: conservative?
end
unlock_dependencies
end
# install_list will be an array of [plugin name, version, options] tuples, version it
# can be nil at this point we know that plugins_arg is not empty and if the
# --version is specified there is only one plugin in plugins_arg
#
def install_gems_list!(install_list)
# If something goes wrong during the installation `LogStash::Gemfile` will restore a backup version.
install_list = LogStash::PluginManager.merge_duplicates(install_list)
# Add plugins/gems to the current gemfile
puts("Installing" + (install_list.empty? ? "..." : " " + install_list.collect(&:first).join(", ")))
install_list.each do |plugin, version, options|
plugin_gem = gemfile.find(plugin)
if preserve?
puts("Preserving Gemfile gem options for plugin #{plugin}") if plugin_gem && !plugin_gem.options.empty?
# if the plugin exists and no version was specified, keep the existing requirements
requirements = (plugin_gem && version.nil? ? plugin_gem.requirements : [version]).compact
gemfile.update(plugin, *requirements, options)
else
gemfile.overwrite(plugin, version, options)
end
end
# Sync gemfiles changes to disk to make them available to the `bundler install`'s API
gemfile.save
bundler_options = {:install => true}
bundler_options[:without] = [] if development?
bundler_options[:rubygems_source] = gemfile.gemset.sources
bundler_options[:local] = true if local?
output = nil
# Unfreeze the bundle when installing gems
Bundler.settings.temporary({:frozen => false}) do
output = LogStash::Bundler.invoke!(bundler_options)
output << LogStash::Bundler.genericize_platform.to_s
end
puts("Installation successful")
rescue => exception
gemfile.restore!
report_exception("Installation Aborted", exception)
ensure
display_bundler_output(output)
end
# Extract the specified local gems in a predefined local path
# Update the gemfile to use a relative path to this plugin and run
# Bundler, this will mark the gem not updatable by `bin/logstash-plugin update`
# This is the most reliable way to make it work in bundler without
# hacking with `how bundler works`
#
# Bundler 2.0, will have support for plugins source we could create a .gem source
# to support it.
def extract_local_gems_plugins
FileUtils.mkdir_p(LogStash::Environment::CACHE_PATH)
plugins_arg.collect do |plugin|
# We do the verify before extracting the gem so we dont have to deal with unused path
if verify?
puts("Validating #{plugin}")
signal_error("Installation aborted, verification failed for #{plugin}") unless LogStash::PluginManager.logstash_plugin?(plugin, version)
end
# Make the original .gem available for the prepare-offline-pack,
# paquet will lookup in the cache directory before going to rubygems.
FileUtils.cp(plugin, ::File.join(LogStash::Environment::CACHE_PATH, ::File.basename(plugin)))
package, path = LogStash::Rubygems.unpack(plugin, LogStash::Environment::LOCAL_GEM_PATH)
[package.spec.name, package.spec.version, { :path => relative_path(path) }, package.spec]
end
end
# We cannot install both .gem and normal plugin in one call of `plugin install`
def local_gems?
return false if plugins_arg.empty?
local_gem = plugins_arg.collect { |plugin| ::File.extname(plugin) == ".gem" }.uniq
if local_gem.size == 1
return local_gem.first
else
signal_usage_error("Mixed source of plugins, you can't mix local `.gem` and remote gems")
end
end
def plugins_args_human
plugins_arg.join(", ")
end
end # class Logstash::PluginManager