diff --git a/lib/vagrant/environment.rb b/lib/vagrant/environment.rb index 37554a066..fafa7d224 100644 --- a/lib/vagrant/environment.rb +++ b/lib/vagrant/environment.rb @@ -15,7 +15,6 @@ module Vagrant class Environment DEFAULT_HOME = "~/.vagrant.d" DEFAULT_LOCAL_DATA = ".vagrant" - DEFAULT_RC = "~/.vagrantrc" # The `cwd` that this environment represents attr_reader :cwd @@ -411,6 +410,7 @@ module Vagrant :box_collection => boxes, :global_config => config_global, :host => host, + :gems_path => gems_path, :root_path => root_path, :tmp_path => tmp_path, :ui => @ui @@ -591,18 +591,27 @@ module Vagrant # Loads the Vagrant plugins by properly setting up RubyGems so that # our private gem repository is on the path. def load_plugins + if ENV["VAGRANT_NO_PLUGINS"] + # If this key exists, then we don't load any plugins. It is a "safe + # mode" of sorts. + @logger.warn("VAGRANT_NO_PLUGINS is set. Not loading 3rd party plugins.") + return + end + # Add our private gem path to the gem path and reset the paths # that Rubygems knows about. ENV["GEM_PATH"] = "#{@gems_path}#{::File::PATH_SEPARATOR}#{ENV["GEM_PATH"]}" ::Gem.clear_paths # Load the plugins - rc_path = File.expand_path(ENV["VAGRANT_RC"] || DEFAULT_RC) - if File.file?(rc_path) && @@loaded_rc.add?(rc_path) - @logger.debug("Loading RC file: #{rc_path}") - load rc_path - else - @logger.debug("RC file not found. Not loading: #{rc_path}") + plugins_json_file = @home_path.join("plugins.json") + @logger.debug("Loading plugins from: #{plugins_json_file}") + if plugins_json_file.file? + data = JSON.parse(plugins_json_file.read) + data["installed"].each do |plugin| + @logger.info("Loading plugin from JSON: #{plugin}") + Vagrant.require_plugin(plugin) + end end end diff --git a/lib/vagrant/errors.rb b/lib/vagrant/errors.rb index a90c0b21b..22ea2e09e 100644 --- a/lib/vagrant/errors.rb +++ b/lib/vagrant/errors.rb @@ -336,6 +336,10 @@ module Vagrant error_key(:provider_not_found) end + class PluginGemError < VagrantError + error_key(:plugin_gem_error) + end + class PluginLoadError < VagrantError status_code(81) error_key(:plugin_load_error) diff --git a/plugins/commands/gem/command.rb b/plugins/commands/gem/command.rb index f9b47b842..b3c35828e 100644 --- a/plugins/commands/gem/command.rb +++ b/plugins/commands/gem/command.rb @@ -15,7 +15,7 @@ module VagrantPlugins if defined?(Bundler) require 'bundler/shared_helpers' if Bundler::SharedHelpers.in_bundle? - raise Errors::GemCommandInBundler + raise Vagrant::Errors::GemCommandInBundler end end diff --git a/plugins/commands/plugin/action.rb b/plugins/commands/plugin/action.rb new file mode 100644 index 000000000..4f14c2cf6 --- /dev/null +++ b/plugins/commands/plugin/action.rb @@ -0,0 +1,43 @@ +require "pathname" + +require "vagrant/action/builder" + +module VagrantPlugins + module CommandPlugin + module Action + # This middleware sequence will install a plugin. + def self.action_install + Vagrant::Action::Builder.new.tap do |b| + b.use BundlerCheck + b.use InstallGem + b.use PruneGems + end + end + + # This middleware sequence will list all installed plugins. + def self.action_list + Vagrant::Action::Builder.new.tap do |b| + b.use BundlerCheck + b.use ListPlugins + end + end + + # This middleware sequence will uninstall a plugin. + def self.action_uninstall + Vagrant::Action::Builder.new.tap do |b| + b.use BundlerCheck + b.use UninstallPlugin + b.use PruneGems + end + end + + # The autoload farm + action_root = Pathname.new(File.expand_path("../action", __FILE__)) + autoload :BundlerCheck, action_root.join("bundler_check") + autoload :InstallGem, action_root.join("install_gem") + autoload :ListPlugins, action_root.join("list_plugins") + autoload :PruneGems, action_root.join("prune_gems") + autoload :UninstallPlugin, action_root.join("uninstall_plugin") + end + end +end diff --git a/plugins/commands/plugin/action/bundler_check.rb b/plugins/commands/plugin/action/bundler_check.rb new file mode 100644 index 000000000..b53a83c5d --- /dev/null +++ b/plugins/commands/plugin/action/bundler_check.rb @@ -0,0 +1,25 @@ +module VagrantPlugins + module CommandPlugin + module Action + class BundlerCheck + def initialize(app, env) + @app = app + end + + def call(env) + # Bundler sets up its own custom gem load paths such that our + # own gems are never loaded. Therefore, give an error if a user + # tries to install gems while within a Bundler-managed environment. + if defined?(Bundler) + require 'bundler/shared_helpers' + if Bundler::SharedHelpers.in_bundle? + raise Vagrant::Errors::GemCommandInBundler + end + end + + @app.call(env) + end + end + end + end +end diff --git a/plugins/commands/plugin/action/install_gem.rb b/plugins/commands/plugin/action/install_gem.rb new file mode 100644 index 000000000..71be71b0c --- /dev/null +++ b/plugins/commands/plugin/action/install_gem.rb @@ -0,0 +1,34 @@ +require "rubygems" +require "rubygems/gem_runner" + +require "log4r" + +module VagrantPlugins + module CommandPlugin + module Action + # This action takes the `:plugin_name` variable in the environment + # and installs it using the RubyGems API. + class InstallGem + def initialize(app, env) + @app = app + @logger = Log4r::Logger.new("vagrant::plugins::plugincommand::installgem") + end + + def call(env) + plugin_name = env[:plugin_name] + + # Install the gem + env[:ui].info(I18n.t("vagrant.commands.plugin.installing", + :name => plugin_name)) + env[:gem_helper].cli(["install", plugin_name, "--no-ri", "--no-rdoc"]) + + # Mark that we installed the gem + env[:plugin_state_file].add_plugin(plugin_name) + + # Continue + @app.call(env) + end + end + end + end +end diff --git a/plugins/commands/plugin/action/list_plugins.rb b/plugins/commands/plugin/action/list_plugins.rb new file mode 100644 index 000000000..3da2cd5f6 --- /dev/null +++ b/plugins/commands/plugin/action/list_plugins.rb @@ -0,0 +1,56 @@ +require "rubygems" +require "set" + +module VagrantPlugins + module CommandPlugin + module Action + # This middleware lists all the installed plugins. + # + # This is a bit more complicated than simply listing installed + # gems or what is in the state file as installed. Instead, this + # actually compares installed gems with what the state file claims + # is installed, and outputs the appropriate truly installed + # plugins. + class ListPlugins + def initialize(app, env) + @app = app + end + + def call(env) + # Get the list of installed plugins according to the state file + installed = Set.new(env[:plugin_state_file].installed_plugins) + + # Get the actual specifications of installed gems + specs = env[:gem_helper].with_environment do + Gem::Specification.find_all + end + + # Get the latest version of the installed plugins + installed_map = {} + specs.each do |spec| + # Ignore specs that aren't in our installed list + next if !installed.include?(spec.name) + + # If we already have a newer version in our list of installed, + # then ignore it + next if installed_map.has_key?(spec.name) && + installed_map[spec.name].version >= spec.version + + installed_map[spec.name] = spec + end + + # Output! + if installed_map.empty? + env[:ui].info(I18n.t("vagrant.commands.plugin.no_plugins")) + else + installed_map.values.each do |spec| + env[:ui].info "#{spec.name} (#{spec.version})" + end + end + + @app.call(env) + end + end + end + end +end diff --git a/plugins/commands/plugin/action/prune_gems.rb b/plugins/commands/plugin/action/prune_gems.rb new file mode 100644 index 000000000..9d3a76f00 --- /dev/null +++ b/plugins/commands/plugin/action/prune_gems.rb @@ -0,0 +1,156 @@ +require "rubygems" +require "rubygems/user_interaction" +require "rubygems/uninstaller" +require "set" + +require "log4r" + +module VagrantPlugins + module CommandPlugin + module Action + # This class prunes any unnecessary gems from the Vagrant-managed + # gem folder. This keeps the gem folder to the absolute minimum set + # of required gems and doesn't let it blow up out of control. + # + # A high-level description of how this works: + # + # 1. Get the list of installed plugins. Vagrant maintains this + # list on its own. + # 2. Get the list of installed RubyGems. + # 3. Find the latest version of each RubyGem that matches an installed + # plugin. These are our root RubyGems that must be installed. + # 4. Go through each root and mark all dependencies recursively as + # necessary. + # 5. Set subtraction between all gems and necessary gems yields a + # list of gems that aren't needed. Uninstall them. + # + class PruneGems + def initialize(app, env) + @app = app + @logger = Log4r::Logger.new("vagrant::plugins::plugincommand::prune") + end + + def call(env) + @logger.info("Pruning gems...") + + # Get the list of installed plugins according to the state file + installed = Set.new(env[:plugin_state_file].installed_plugins) + + # Get the actual specifications of installed gems + all_specs = env[:gem_helper].with_environment do + result = [] + Gem::Specification.find_all { |s| result << s } + result + end + + # The list of specs to prune initially starts out as all of them + all_specs = Set.new(all_specs) + + # Go through each spec and find the latest version of the installed + # gems, since we want to keep those. + installed_specs = {} + + @logger.debug("Collecting installed plugin gems...") + all_specs.each do |spec| + # If this isn't a spec that we claim is installed, skip it + next if !installed.include?(spec.name) + + # If it is already in the specs, then we need to make sure we + # have the latest version. + if installed_specs.has_key?(spec.name) + if installed_specs[spec.name].version > spec.version + next + end + end + + @logger.debug(" -- #{spec.name} (#{spec.version})") + installed_specs[spec.name] = spec + end + + # Recursive dependency checker to keep all dependencies and remove + # all non-crucial gems from the prune list. + good_specs = Set.new + to_check = installed_specs.values + + while true + # If we're out of gems to check then we break out + break if to_check.empty? + + # Get a random (first) element to check + spec = to_check.shift + + # If we already checked this, then do the next one + next if good_specs.include?(spec) + + # Find all the dependencies and add the latest compliant gem + # to the `to_check` list. + if spec.dependencies.length > 0 + @logger.debug("Finding dependencies for '#{spec.name}' to mark as good...") + spec.dependencies.each do |dep| + # Ignore non-runtime dependencies + next if dep.type != :runtime + @logger.debug("Searching for: '#{dep.name}'") + + latest_matching = nil + + all_specs.each do |prune_spec| + if dep =~ prune_spec + # If we have a matching one already and this one isn't newer + # then we ditch it. + next if latest_matching && + prune_spec.version <= latest_matching.version + + latest_matching = prune_spec + end + end + + if latest_matching.nil? + @logger.error("Missing dependency for '#{spec.name}': #{dep.name}") + next + end + + @logger.debug("Latest matching dep: '#{latest_matching.name}' (#{latest_matching.version})") + to_check << latest_matching + end + end + + # Add ito the list of checked things so we don't accidentally + # re-check it + good_specs.add(spec) + end + + # Figure out the gems we need to prune + prune_specs = all_specs - good_specs + @logger.debug("Gems to prune: #{prune_specs.inspect}") + @logger.info("Pruning #{prune_specs.length} gems.") + + if prune_specs.length > 0 + env[:gem_helper].with_environment do + prune_specs.each do |prune_spec| + uninstaller = Gem::Uninstaller.new(prune_spec.name, { + :executables => true, + :force => true, + :version => prune_spec.version.version + }) + + # This is sad, but there is no way to force this to be true + # so I just monkey-patch here. Vagrant has a pretty strict + # version check on the RubyGems version so this should be okay. + # In the future, let's try to get a pull request into RubyGems + # to fix this. + def uninstaller.ask_if_ok(spec) + true + end + + @logger.info("Uninstalling: #{prune_spec.name} (#{prune_spec.version})") + uninstaller.uninstall + end + end + end + + @app.call(env) + end + end + end + end +end diff --git a/plugins/commands/plugin/action/uninstall_plugin.rb b/plugins/commands/plugin/action/uninstall_plugin.rb new file mode 100644 index 000000000..f86675523 --- /dev/null +++ b/plugins/commands/plugin/action/uninstall_plugin.rb @@ -0,0 +1,23 @@ +module VagrantPlugins + module CommandPlugin + module Action + # This middleware uninstalls a plugin by simply removing it from + # the state file. Running a {PruneGems} after should properly remove + # it from the gem index. + class UninstallPlugin + def initialize(app, env) + @app = app + end + + def call(env) + # Remove it! + env[:ui].info(I18n.t("vagrant.commands.plugin.uninstalling", + :name => env[:plugin_name])) + env[:plugin_state_file].remove_plugin(env[:plugin_name]) + + @app.call(env) + end + end + end + end +end diff --git a/plugins/commands/plugin/command/base.rb b/plugins/commands/plugin/command/base.rb new file mode 100644 index 000000000..5180dfd5d --- /dev/null +++ b/plugins/commands/plugin/command/base.rb @@ -0,0 +1,22 @@ +module VagrantPlugins + module CommandPlugin + module Command + class Base < Vagrant.plugin("2", :command) + # This is a helper for executing an action sequence with the proper + # environment hash setup so that the plugin specific helpers are + # in. + # + # @param [Object] callable the Middleware callable + # @param [Hash] env Extra environment hash that is merged in. + def action(callable, env=nil) + env = { + :gem_helper => GemHelper.new(@env.gems_path), + :plugin_state_file => StateFile.new(@env.home_path.join("plugins.json")) + }.merge(env || {}) + + @env.action_runner.run(callable, env) + end + end + end + end +end diff --git a/plugins/commands/plugin/command/install.rb b/plugins/commands/plugin/command/install.rb new file mode 100644 index 000000000..295285cc2 --- /dev/null +++ b/plugins/commands/plugin/command/install.rb @@ -0,0 +1,28 @@ +require 'optparse' + +require_relative "base" + +module VagrantPlugins + module CommandPlugin + module Command + class Install < Base + def execute + opts = OptionParser.new do |o| + o.banner = "Usage: vagrant plugin install [-h]" + end + + # Parse the options + argv = parse_options(opts) + return if !argv + raise Vagrant::Errors::CLIInvalidUsage, :help => opts.help.chomp if argv.length < 1 + + # Install the gem + action(Action.action_install, :plugin_name => argv[0]) + + # Success, exit status 0 + 0 + end + end + end + end +end diff --git a/plugins/commands/plugin/command/list.rb b/plugins/commands/plugin/command/list.rb new file mode 100644 index 000000000..61027c216 --- /dev/null +++ b/plugins/commands/plugin/command/list.rb @@ -0,0 +1,28 @@ +require 'optparse' + +require_relative "base" + +module VagrantPlugins + module CommandPlugin + module Command + class List < Base + def execute + opts = OptionParser.new do |o| + o.banner = "Usage: vagrant plugin list [-h]" + end + + # Parse the options + argv = parse_options(opts) + return if !argv + raise Vagrant::Errors::CLIInvalidUsage, :help => opts.help.chomp if argv.length > 0 + + # List the installed plugins + action(Action.action_list) + + # Success, exit status 0 + 0 + end + end + end + end +end diff --git a/plugins/commands/plugin/command/root.rb b/plugins/commands/plugin/command/root.rb new file mode 100644 index 000000000..fe7f9b339 --- /dev/null +++ b/plugins/commands/plugin/command/root.rb @@ -0,0 +1,70 @@ +require 'optparse' + +module VagrantPlugins + module CommandPlugin + module Command + class Root < Vagrant.plugin("2", :command) + def initialize(argv, env) + super + + @main_args, @sub_command, @sub_args = split_main_and_subcommand(argv) + + @subcommands = Vagrant::Registry.new + @subcommands.register(:install) do + require_relative "install" + Install + end + + @subcommands.register(:list) do + require_relative "list" + List + end + + @subcommands.register(:uninstall) do + require_relative "uninstall" + Uninstall + end + end + + def execute + if @main_args.include?("-h") || @main_args.include?("--help") + # Print the help for all the sub-commands. + return help + end + + # If we reached this far then we must have a subcommand. If not, + # then we also just print the help and exit. + command_class = @subcommands.get(@sub_command.to_sym) if @sub_command + return help if !command_class || !@sub_command + @logger.debug("Invoking command class: #{command_class} #{@sub_args.inspect}") + + # Initialize and execute the command class + command_class.new(@sub_args, @env).execute + end + + # Prints the help out for this command + def help + opts = OptionParser.new do |o| + o.banner = "Usage: vagrant plugin []" + o.separator "" + o.separator "Available subcommands:" + + # Add the available subcommands as separators in order to print them + # out as well. + keys = [] + @subcommands.each { |key, value| keys << key.to_s } + + keys.sort.each do |key| + o.separator " #{key}" + end + + o.separator "" + o.separator "For help on any individual command run `vagrant plugin COMMAND -h`" + end + + @env.ui.info(opts.help, :prefix => false) + end + end + end + end +end diff --git a/plugins/commands/plugin/command/uninstall.rb b/plugins/commands/plugin/command/uninstall.rb new file mode 100644 index 000000000..bbe78bd1d --- /dev/null +++ b/plugins/commands/plugin/command/uninstall.rb @@ -0,0 +1,28 @@ +require 'optparse' + +require_relative "base" + +module VagrantPlugins + module CommandPlugin + module Command + class Uninstall < Base + def execute + opts = OptionParser.new do |o| + o.banner = "Usage: vagrant plugin uninstall [-h]" + end + + # Parse the options + argv = parse_options(opts) + return if !argv + raise Vagrant::Errors::CLIInvalidUsage, :help => opts.help.chomp if argv.length < 1 + + # Uninstall the gem + action(Action.action_uninstall, :plugin_name => argv[0]) + + # Success, exit status 0 + 0 + end + end + end + end +end diff --git a/plugins/commands/plugin/gem_helper.rb b/plugins/commands/plugin/gem_helper.rb new file mode 100644 index 000000000..2dc260e5e --- /dev/null +++ b/plugins/commands/plugin/gem_helper.rb @@ -0,0 +1,74 @@ +require "rubygems" +require "rubygems/gem_runner" + +require "log4r" + +module VagrantPlugins + module CommandPlugin + # This class provides methods to help with calling out to the + # `gem` command but using the RubyGems API. + class GemHelper + def initialize(gem_home) + @gem_home = gem_home.to_s + @logger = Log4r::Logger.new("vagrant::plugins::plugincommand::gemhelper") + end + + # This executes the `gem` command with the given arguments. Under + # the covers this is actually using the RubyGems API directly, + # instead of shelling out, which allows for more fine-grained control. + # + # @param [Array] argv The arguments to send to the `gem` command. + def cli(argv) + # Initialize the UI to use for RubyGems. This allows us to capture + # the stdout/stderr without actually going to the real STDOUT/STDERR. + # The final "false" here tells RubyGems we're not a TTY, so don't + # ask us things. + gem_ui = Gem::StreamUI.new(StringIO.new, StringIO.new, StringIO.new, false) + + # Set the GEM_HOME so that it is installed into our local gems path + with_environment do + @logger.info("Calling gem with argv: #{argv.inspect}") + Gem::DefaultUserInteraction.use_ui(gem_ui) do + Gem::GemRunner.new.run(argv) + end + end + rescue Gem::SystemExitException => e + # This means that something forced an exit within RubyGems. + # We capture this to check whether it succeeded or not by + # checking the "exit_code" + raise Vagrant::Errors::PluginGemError, + :output => gem_ui.errs.string.chomp if e.exit_code != 0 + ensure + # Log the output properly + @logger.debug("Gem STDOUT: #{gem_ui.outs.string}") + @logger.debug("Gem STDERR: #{gem_ui.errs.string}") + end + + # This will yield the given block with the proper ENV setup so + # that RubyGems only sees the gems in the Vagrant-managed gem + # path. + def with_environment + old_gem_home = ENV["GEM_HOME"] + old_gem_path = ENV["GEM_PATH"] + ENV["GEM_HOME"] = @gem_home + ENV["GEM_PATH"] = @gem_home + @logger.debug("Set GEM_* to: #{ENV["GEM_HOME"]}") + + # Clear paths so that it reads the new GEM_HOME setting + Gem.paths = ENV + + # Use a silent UI so that we have no output + Gem::DefaultUserInteraction.use_ui(Gem::SilentUI.new) do + return yield + end + ensure + # Restore the old GEM_* settings + ENV["GEM_HOME"] = old_gem_home + ENV["GEM_PATH"] = old_gem_path + + # Reset everything + Gem.paths = ENV + end + end + end +end diff --git a/plugins/commands/plugin/plugin.rb b/plugins/commands/plugin/plugin.rb new file mode 100644 index 000000000..db9763319 --- /dev/null +++ b/plugins/commands/plugin/plugin.rb @@ -0,0 +1,22 @@ +require "vagrant" + +module VagrantPlugins + module CommandPlugin + class Plugin < Vagrant.plugin("2") + name "plugin command" + description <<-DESC + This command helps manage and install plugins within the + Vagrant environment. +DESC + + command("plugin") do + require File.expand_path("../command/root", __FILE__) + Command::Root + end + end + + autoload :Action, File.expand_path("../action", __FILE__) + autoload :GemHelper, File.expand_path("../gem_helper", __FILE__) + autoload :StateFile, File.expand_path("../state_file", __FILE__) + end +end diff --git a/plugins/commands/plugin/state_file.rb b/plugins/commands/plugin/state_file.rb new file mode 100644 index 000000000..f57ab9f20 --- /dev/null +++ b/plugins/commands/plugin/state_file.rb @@ -0,0 +1,57 @@ +require "json" + +module VagrantPlugins + module CommandPlugin + # This is a helper to deal with the plugin state file that Vagrant + # uses to track what plugins are installed and activated and such. + class StateFile + def initialize(path) + @path = path + + @data = {} + @data = JSON.parse(@path.read) if @path.exist? + @data["installed"] ||= [] + end + + # Add a plugin that is installed to the state file. + # + # @param [String] name The name of the plugin + def add_plugin(name) + if !@data["installed"].include?(name) + @data["installed"] << name + end + + save! + end + + # This returns a list of installed plugins according to the state + # file. Note that this may _not_ directly match over to actually + # installed gems. + # + # @return [Array] + def installed_plugins + @data["installed"] + end + + # Remove a plugin that is installed from the state file. + # + # @param [String] name The name of the plugin. + def remove_plugin(name) + @data["installed"].delete(name) + save! + end + + # This saves the state back into the state file. + def save! + # Scrub some fields + @data["installed"].sort! + @data["installed"].uniq! + + # Save + @path.open("w+") do |f| + f.write(JSON.dump(@data)) + end + end + end + end +end diff --git a/templates/locales/en.yml b/templates/locales/en.yml index 2c243c2dd..12bc3be6c 100644 --- a/templates/locales/en.yml +++ b/templates/locales/en.yml @@ -114,11 +114,11 @@ en: occurring. Please wait for the other instance of Vagrant to end and then try again. gem_command_in_bundler: |- - You cannot run the `vagrant gem` command while in a bundler environment. - Bundler messes around quite a bit with the RubyGem load paths and gems - installed via `vagrant gem` are excluded by Bundler. - - Instead, please include your Vagrant plugins in your Gemfile itself. + You cannot run the `vagrant plugin` command while in a bundler environment. + This should generally never happen unless Vagrant is installed outside + of the official installers or another gem is wrongly attempting to + use Vagrant internals directly. Please properly install Vagrant to + fix this. If this error persists, please contact support. guest: invalid_class: |- The specified guest class does not inherit from a proper guest @@ -165,6 +165,11 @@ en: no_env: |- A Vagrant environment is required to run this command. Run `vagrant init` to set one up. + plugin_gem_error: |- + An error occurred within RubyGems, the underlying system used to + manage Vagrant plugins. The output of the errors are shown below: + + %{output} plugin_load_error: |- The plugin "%{plugin}" could not be found. Please make sure that it is properly installed via `vagrant gem`. @@ -411,6 +416,13 @@ en: ready to `vagrant up` your first virtual environment! Please read the comments in the Vagrantfile as well as documentation on `vagrantup.com` for more information on using Vagrant. + plugin: + no_plugins: |- + No plugins installed. + installing: |- + Installing the '%{name}' plugin. This can take a few minutes... + uninstalling: |- + Uninstalling the '%{name}' plugin... status: aborted: |- The VM is in an aborted state. This means that it was abruptly