Provide better internal consistency of installed plugin gems.

Refactors reusable actions into isolated methods. Supports installation/removal
without activation to prevent unintended conflicts during upgrades and cleanup.
Introduced custom resolver set to handle multiple installed versions of gems
which enables proper cleanup.
This commit is contained in:
Chris Roberts 2016-11-16 13:20:32 -08:00
parent 06e1b2f52c
commit 818f7acb7b
1 changed files with 138 additions and 63 deletions

View File

@ -48,28 +48,11 @@ module Vagrant
# Never allow dependencies to be remotely satisfied during init # Never allow dependencies to be remotely satisfied during init
request_set.remote = false request_set.remote = false
# Sets that we can resolve our dependencies from
current_set = Gem::Resolver::CurrentSet.new
plugin_set = Gem::Resolver::VendorSet.new
repair_result = nil repair_result = nil
begin begin
# Register all known plugin specifications to the plugin set
Dir.glob(plugin_gem_path.join('specifications/*.gemspec').to_s).each do |spec_path|
spec = Gem::Specification.load(spec_path)
desired_spec_path = File.join(spec.gem_dir, "#{spec.name}.gemspec")
# Vendor set requires the spec to be within the gem directory. Some gems will package their
# spec file, and that's not what we want to load.
if !File.exist?(desired_spec_path) || !FileUtils.cmp(spec.spec_file, desired_spec_path)
File.write(desired_spec_path, spec.to_ruby)
end
plugin_set.add_vendor_gem(spec.name, spec.gem_dir)
end
# Compose set for resolution # Compose set for resolution
composed_set = Gem::Resolver.compose_sets(current_set, plugin_set) composed_set = generate_vagrant_set
@logger.debug("Composed local RubyGems set for plugin init resolution: #{composed_set}") @logger.debug("Composed local RubyGems set for plugin init resolution: #{composed_set}")
# Resolve the request set to ensure proper activation order # Resolve the request set to ensure proper activation order
solution = request_set.resolve(composed_set) solution = request_set.resolve(composed_set)
rescue Gem::UnsatisfiableDependencyError => failure rescue Gem::UnsatisfiableDependencyError => failure
@ -93,34 +76,7 @@ module Vagrant
end end
# Activate the gems # Activate the gems
retried = false activate_solution(solution)
begin
@logger.debug("Initialization solution set: #{solution.map(&:full_name)}")
solution.each do |activation_request|
unless activation_request.full_spec.activated?
@logger.debug("Activating gem #{activation_request.full_spec.full_name}")
activation_request.full_spec.activate
if(defined?(::Bundler))
@logger.debug("Marking gem #{activation_request.full_spec.full_name} loaded within Bundler.")
::Bundler.rubygems.mark_loaded activation_request.full_spec
end
end
end
rescue Gem::LoadError => e
# Depending on the version of Ruby, the ordering of the solution set
# will be either 0..n (molinillo) or n..0 (pre-molinillo). Instead of
# attempting to determine what's in use, or if it has some how changed
# again, just reverse order on failure and attempt again.
if retried
@logger.error("Failed to load solution set - #{e.class}: #{e}")
raise
else
@logger.debug("Failed to load solution set. Retrying with reverse order.")
retried = true
solution.reverse!
retry
end
end
full_vagrant_spec_list = Gem::Specification.find_all{true} + full_vagrant_spec_list = Gem::Specification.find_all{true} +
solution.map(&:full_spec) solution.map(&:full_spec)
@ -172,7 +128,7 @@ module Vagrant
# empty or nil, all plugins will be updated. # empty or nil, all plugins will be updated.
def update(plugins, specific) def update(plugins, specific)
specific ||= [] specific ||= []
update = { gems: specific } if !specific.empty? update = {gems: specific.empty? ? true : specific}
internal_install(plugins, update) internal_install(plugins, update)
end end
@ -180,7 +136,10 @@ module Vagrant
def clean(plugins) def clean(plugins)
# Generate dependencies for all registered plugins # Generate dependencies for all registered plugins
plugin_deps = plugins.map do |name, info| plugin_deps = plugins.map do |name, info|
Gem::Dependency.new(name, info['gem_version'].to_s.empty? ? '> 0' : info['gem_version']) gem_version = info['installed_gem_version']
gem_version = info['gem_version'] if gem_version.to_s.empty?
gem_version = "> 0" if gem_version.to_s.empty?
Gem::Dependency.new(name, gem_version)
end end
# Load dependencies into a request set for resolution # Load dependencies into a request set for resolution
@ -191,7 +150,7 @@ module Vagrant
# Sets that we can resolve our dependencies from. Note that we only # Sets that we can resolve our dependencies from. Note that we only
# resolve from the current set as all required deps are activated during # resolve from the current set as all required deps are activated during
# init. # init.
current_set = Gem::Resolver::CurrentSet.new current_set = generate_vagrant_set
# Collect all plugin specifications # Collect all plugin specifications
plugin_specs = Dir.glob(plugin_gem_path.join('specifications/*.gemspec').to_s).map do |spec_path| plugin_specs = Dir.glob(plugin_gem_path.join('specifications/*.gemspec').to_s).map do |spec_path|
@ -201,15 +160,19 @@ module Vagrant
# Resolve the request set to ensure proper activation order # Resolve the request set to ensure proper activation order
solution = request_set.resolve(current_set) solution = request_set.resolve(current_set)
solution_specs = solution.map(&:full_spec) solution_specs = solution.map(&:full_spec)
solution_full_names = solution_specs.map(&:full_name)
# Find all specs installed to plugins directory that are not # Find all specs installed to plugins directory that are not
# found within the solution set # found within the solution set
plugin_specs.delete_if do |spec| plugin_specs.delete_if do |spec|
solution.include?(spec) solution_full_names.include?(spec.full_name)
end end
@logger.debug("Specifications to be removed - #{plugin_specs.map(&:full_name)}")
# Now delete all unused specs # Now delete all unused specs
plugin_specs.each do |spec| plugin_specs.each do |spec|
@logger.debug("Uninstalling gem - #{spec.full_name}")
Gem::Uninstaller.new(spec.name, Gem::Uninstaller.new(spec.name,
version: spec.version, version: spec.version,
install_dir: plugin_gem_path, install_dir: plugin_gem_path,
@ -241,13 +204,15 @@ module Vagrant
# Only allow defined Gem sources # Only allow defined Gem sources
Gem.sources.clear Gem.sources.clear
update = {} unless update.is_a?(Hash) update = {} if !update.is_a?(Hash)
skips = []
installer_set = Gem::Resolver::InstallerSet.new(:both) installer_set = Gem::Resolver::InstallerSet.new(:both)
# Generate all required plugin deps # Generate all required plugin deps
plugin_deps = plugins.map do |name, info| plugin_deps = plugins.map do |name, info|
if update == true || (update[:gems].respond_to?(:include?) && update[:gems].include?(name)) if update[:gems] == true || (update[:gems].respond_to?(:include?) && update[:gems].include?(name))
gem_version = '> 0' gem_version = '> 0'
skips << name
else else
gem_version = info['gem_version'].to_s.empty? ? '> 0' : info['gem_version'] gem_version = info['gem_version'].to_s.empty? ? '> 0' : info['gem_version']
end end
@ -277,26 +242,136 @@ module Vagrant
# gems that are incompatible with the core system # gems that are incompatible with the core system
request_set.import(existing_deps) request_set.import(existing_deps)
installer_set = Gem::Resolver.compose_sets(installer_set, generate_plugin_set(skips))
# Generate the required solution set for new plugins # Generate the required solution set for new plugins
solution = request_set.resolve(installer_set) solution = request_set.resolve(installer_set)
activate_solution(solution)
@logger.debug("Generated solution set: #{solution.map(&:full_name)}")
# If any items in the solution set are local but not activated, turn them on
solution.each do |activation_request|
if activation_request.installed? && !activation_request.full_spec.activated?
@logger.debug("Activating gem specification: #{activation_request.full_spec.full_name}")
activation_request.full_spec.activate
end
end
# Install all remote gems into plugin path. Set the installer to ignore dependencies # Install all remote gems into plugin path. Set the installer to ignore dependencies
# as we know the dependencies are satisfied and it will attempt to validate a gem's # as we know the dependencies are satisfied and it will attempt to validate a gem's
# dependencies are satisified by gems in the install directory (which will likely not # dependencies are satisified by gems in the install directory (which will likely not
# be true) # be true)
result = request_set.install_into(plugin_gem_path.to_s, true, ignore_dependencies: true) result = request_set.install_into(plugin_gem_path.to_s, true, ignore_dependencies: true)
result.map(&:full_spec) result = result.map(&:full_spec)
result
end end
# Generate the composite resolver set totally all of vagrant (builtin + plugin set)
def generate_vagrant_set
Gem::Resolver.compose_sets(generate_builtin_set, generate_plugin_set)
end
# Generate the builtin resolver set
def generate_builtin_set
Gem::Resolver::CurrentSet.new
end
# Generate the plugin resolver set. Optionally provide specification names (short or
# full) that should be ignored
def generate_plugin_set(skip=[])
plugin_set = PluginSet.new
@logger.debug("Generating new plugin set instance. Skip gems - #{skip}")
Dir.glob(plugin_gem_path.join('specifications/*.gemspec').to_s).each do |spec_path|
spec = Gem::Specification.load(spec_path)
desired_spec_path = File.join(spec.gem_dir, "#{spec.name}.gemspec")
# Vendor set requires the spec to be within the gem directory. Some gems will package their
# spec file, and that's not what we want to load.
if !File.exist?(desired_spec_path) || !FileUtils.cmp(spec.spec_file, desired_spec_path)
File.write(desired_spec_path, spec.to_ruby)
end
next if skip.include?(spec.name) || skip.include?(spec.full_name)
plugin_set.add_vendor_gem(spec.name, spec.gem_dir)
end
plugin_set
end
# Activate a given solution
def activate_solution(solution)
retried = false
begin
@logger.debug("Activating solution set: #{solution.map(&:full_name)}")
solution.each do |activation_request|
unless activation_request.full_spec.activated?
@logger.debug("Activating gem #{activation_request.full_spec.full_name}")
activation_request.full_spec.activate
if(defined?(::Bundler))
@logger.debug("Marking gem #{activation_request.full_spec.full_name} loaded within Bundler.")
::Bundler.rubygems.mark_loaded activation_request.full_spec
end
end
end
rescue Gem::LoadError => e
# Depending on the version of Ruby, the ordering of the solution set
# will be either 0..n (molinillo) or n..0 (pre-molinillo). Instead of
# attempting to determine what's in use, or if it has some how changed
# again, just reverse order on failure and attempt again.
if retried
@logger.error("Failed to load solution set - #{e.class}: #{e}")
raise
else
@logger.debug("Failed to load solution set. Retrying with reverse order.")
retried = true
solution.reverse!
retry
end
end
end
# This is a custom Gem::Resolver::Set for use with Vagrant plugins. It is
# a modified Gem::Resolver::VendorSet that supports multiple versions of
# a specific gem
class PluginSet < Gem::Resolver::VendorSet
##
# Adds a specification to the set with the given +name+ which has been
# unpacked into the given +directory+.
def add_vendor_gem(name, directory)
gemspec = File.join(directory, "#{name}.gemspec")
spec = Gem::Specification.load(gemspec)
if !spec
raise Gem::GemNotFoundException,
"unable to find #{gemspec} for gem #{name}"
end
spec.full_gem_path = File.expand_path(directory)
@specs[spec.name] ||= []
@specs[spec.name] << spec
@directories[spec] = directory
spec
end
##
# Returns an Array of VendorSpecification objects matching the
# DependencyRequest +req+.
def find_all(req)
@specs.values.flatten.select do |spec|
req.match?(spec)
end.map do |spec|
source = Gem::Source::Vendor.new(@directories[spec])
Gem::Resolver::VendorSpecification.new(self, spec, source)
end
end
##
# Loads a spec with the given +name+. +version+, +platform+ and +source+ are
# ignored.
def load_spec (name, version, platform, source)
version = Gem::Version.new(version) if !version.is_a?(Gem::Version)
@specs.fetch(name, []).detect{|s| s.name == name && s.version = version}
end
end
end
end
# Patch for Ruby 2.2 and Bundler to behave properly when uninstalling plugins
if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('2.3')
if defined?(::Bundler) && !::Bundler::SpecSet.instance_methods.include?(:delete)
class Gem::Specification
def self.remove_spec(spec)
Gem::Specification.reset
end
end
end end
end end