unlock ecs_compatibility_support version in plugin update (#13218)

This commit fixes the `logstash-plugin update` command which fail to update plugin
that depends on a new version of logstash-mixin-ecs_compatibility_support.
It resolves logstash-* dependencies and puts them in bundler update command.
Fixed: #13181
This commit is contained in:
kaisecheng 2021-10-04 12:12:32 +02:00 committed by GitHub
parent 2d8abc4597
commit 4187d80bf0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 97 additions and 9 deletions

View file

@ -196,6 +196,44 @@ module LogStash
ENV["DEBUG"] ENV["DEBUG"]
end end
# @param plugin_names [Array] logstash plugin names that are going to update
# @return [Array] gem names that plugins depend on, including logstash plugins
def expand_logstash_mixin_dependencies(plugin_names)
plugin_names = Array(plugin_names) if plugin_names.is_a?(String)
# get gem names in Gemfile.lock. If file doesn't exist, it will be generated
lockfile_gems = ::Bundler::definition.specs.to_a.map { |stub_spec| stub_spec.name }.to_set
# get the array of dependencies which are eligible to update. Bundler unlock these gems in update process
# exclude the gems which are not in lock file. They should not be part of unlock gems.
# The core libs, logstash-core logstash-core-plugin-api, are not expected to update when user do plugins update
# constraining the transitive dependency updates to only those Logstash maintain
unlock_libs = plugin_names.flat_map { |plugin_name| fetch_plugin_dependencies(plugin_name) }
.uniq
.select { |lib_name| lockfile_gems.include?(lib_name) }
.select { |lib_name| lib_name.start_with?("logstash-mixin-") }
unlock_libs + plugin_names
end
# get all dependencies of a single plugin, considering all versions >= current
# @param plugin_name [String] logstash plugin name
# @return [Array] gem names that plugin depends on
def fetch_plugin_dependencies(plugin_name)
old_spec = ::Gem::Specification.find_all_by_name(plugin_name).last
require_version = old_spec ? ">= #{old_spec.version}": nil
dep = ::Gem::Dependency.new(plugin_name, require_version)
new_specs, errors = ::Gem::SpecFetcher.fetcher.spec_for_dependency(dep)
raise(errors.first.error) if errors.length > 0
new_specs.map { |spec, source| spec }
.flat_map(&:dependencies)
.select {|spec| spec.type == :runtime }
.map(&:name)
.uniq
end
# build Bundler::CLI.start arguments array from the given options hash # build Bundler::CLI.start arguments array from the given options hash
# @param option [Hash] the invoke! options hash # @param option [Hash] the invoke! options hash
# @return [Array<String>] Bundler::CLI.start string arguments array # @return [Array<String>] Bundler::CLI.start string arguments array
@ -210,7 +248,7 @@ module LogStash
end end
elsif options[:update] elsif options[:update]
arguments << "update" arguments << "update"
arguments << options[:update] arguments << expand_logstash_mixin_dependencies(options[:update])
arguments << "--local" if options[:local] arguments << "--local" if options[:local]
elsif options[:clean] elsif options[:clean]
arguments << "clean" arguments << "clean"

View file

@ -74,6 +74,7 @@ class LogStash::PluginManager::Install < LogStash::PluginManager::Command
end end
check_for_integrations(gems) check_for_integrations(gems)
update_logstash_mixin_dependencies(gems)
install_gems_list!(gems) install_gems_list!(gems)
remove_unused_locally_installed_gems! remove_unused_locally_installed_gems!
remove_unused_integration_overlaps! remove_unused_integration_overlaps!
@ -175,6 +176,27 @@ class LogStash::PluginManager::Install < LogStash::PluginManager::Command
version ? [plugins_arg << version] : plugins_arg.map { |plugin| [plugin, nil] } version ? [plugins_arg << version] : plugins_arg.map { |plugin| [plugin, nil] }
end 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.setup!
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(', ')}"
options = {:update => unlock_dependencies, :rubygems_source => gemfile.gemset.sources}
LogStash::Bundler.invoke!(options)
end
unlock_dependencies
end
# install_list will be an array of [plugin name, version, options] tuples, version it # 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 # 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 # --version is specified there is only one plugin in plugins_arg

View file

@ -92,20 +92,20 @@ describe LogStash::Bundler do
end end
context 'when generating bundler arguments' do context 'when generating bundler arguments' do
subject { LogStash::Bundler.bundler_arguments(options) } subject(:bundler_arguments) { LogStash::Bundler.bundler_arguments(options) }
let(:options) { {} } let(:options) { {} }
context 'when installing' do context 'when installing' do
let(:options) { { :install => true } } let(:options) { { :install => true } }
it 'should call bundler install' do it 'should call bundler install' do
expect(subject).to include('install') expect(bundler_arguments).to include('install')
end end
context 'with the cleaning option' do context 'with the cleaning option' do
it 'should add the --clean arguments' do it 'should add the --clean arguments' do
options.merge!(:clean => true) options.merge!(:clean => true)
expect(subject).to include('install','--clean') expect(bundler_arguments).to include('install','--clean')
end end
end end
end end
@ -115,14 +115,39 @@ describe LogStash::Bundler do
context 'with a specific plugin' do context 'with a specific plugin' do
it 'should call `bundle update plugin-name`' do it 'should call `bundle update plugin-name`' do
expect(subject).to include('update', 'logstash-input-stdin') expect(bundler_arguments).to include('update', 'logstash-input-stdin')
end end
end end
context 'with the cleaning option' do context 'with the cleaning option' do
it 'should ignore the clean option' do it 'should ignore the clean option' do
options.merge!(:clean => true) options.merge!(:clean => true)
expect(subject).not_to include('--clean') expect(bundler_arguments).not_to include('--clean')
end
end
context 'with ecs_compatibility' do
let(:plugin_name) { 'logstash-output-elasticsearch' }
let(:options) { { :update => plugin_name } }
it "also update dependencies" do
expect(bundler_arguments).to include('logstash-mixin-ecs_compatibility_support', plugin_name)
mixin_libs = bundler_arguments - ["update", plugin_name]
mixin_libs.each do |gem_name|
dep = ::Gem::Dependency.new(gem_name)
expect(dep.type).to eq(:runtime)
expect(gem_name).to start_with('logstash-mixin-')
end
end
it "do not include core lib" do
expect(bundler_arguments).not_to include('logstash-core', 'logstash-core-plugin-api')
end
it "raise error when fetcher failed" do
allow(::Gem::SpecFetcher.fetcher).to receive("spec_for_dependency").with(anything).and_return([nil, [StandardError.new("boom")]])
expect { bundler_arguments }.to raise_error(StandardError, /boom/)
end end
end end
end end
@ -130,7 +155,7 @@ describe LogStash::Bundler do
context "when only specifying clean" do context "when only specifying clean" do
let(:options) { { :clean => true } } let(:options) { { :clean => true } }
it 'should call the `bundle clean`' do it 'should call the `bundle clean`' do
expect(subject).to include('clean') expect(bundler_arguments).to include('clean')
end end
end end
end end

View file

@ -26,8 +26,9 @@ describe LogStash::PluginManager::Install do
let(:sources) { ["https://rubygems.org", "http://localhost:9292"] } let(:sources) { ["https://rubygems.org", "http://localhost:9292"] }
before(:each) do before(:each) do
expect(cmd).to receive(:validate_cli_options!).and_return(nil) expect(cmd).to receive(:validate_cli_options!).at_least(:once).and_return(nil)
expect(cmd).to receive(:plugins_gems).and_return([["dummy", nil]]) expect(cmd).to receive(:plugins_gems).and_return([["dummy", nil]])
expect(cmd).to receive(:update_logstash_mixin_dependencies).and_return(nil)
expect(cmd).to receive(:install_gems_list!).and_return(nil) expect(cmd).to receive(:install_gems_list!).and_return(nil)
expect(cmd).to receive(:remove_unused_locally_installed_gems!).and_return(nil) expect(cmd).to receive(:remove_unused_locally_installed_gems!).and_return(nil)
cmd.verify = true cmd.verify = true
@ -47,6 +48,7 @@ describe LogStash::PluginManager::Install do
expect(cmd).to receive(:validate_cli_options!).and_return(nil) expect(cmd).to receive(:validate_cli_options!).and_return(nil)
# used to pass indirect input to the command under test # used to pass indirect input to the command under test
expect(cmd).to receive(:plugins_gems).and_return([["logstash-input-elastic_agent", nil]]) expect(cmd).to receive(:plugins_gems).and_return([["logstash-input-elastic_agent", nil]])
expect(cmd).to receive(:update_logstash_mixin_dependencies).and_return(nil)
# used to skip Bundler interaction # used to skip Bundler interaction
expect(cmd).to receive(:install_gems_list!).and_return(nil) expect(cmd).to receive(:install_gems_list!).and_return(nil)
# avoid to clean gemfile folder # avoid to clean gemfile folder
@ -73,6 +75,7 @@ describe LogStash::PluginManager::Install do
let(:cmd) { LogStash::PluginManager::Install.new("install my-super-pack") } let(:cmd) { LogStash::PluginManager::Install.new("install my-super-pack") }
before do before do
expect(cmd).to receive(:plugins_arg).and_return(["my-super-pack"]).at_least(:once) expect(cmd).to receive(:plugins_arg).and_return(["my-super-pack"]).at_least(:once)
allow(cmd).to receive(:update_logstash_mixin_dependencies).and_return(nil)
end end
it "reports `FileNotFoundError` exception" do it "reports `FileNotFoundError` exception" do

View file

@ -61,7 +61,7 @@ describe LogStash::PluginManager do
let(:plugin) { "foo" } let(:plugin) { "foo" }
let(:version) { "9.0.0.0" } let(:version) { "9.0.0.0" }
let(:sources) { ["http://source.01", "http://source.02"] } let(:sources) { ["https://rubygems.org", "http://source.02"] }
let(:options) { {:rubygems_source => sources} } let(:options) { {:rubygems_source => sources} }
let(:gemset) { double("gemset") } let(:gemset) { double("gemset") }