Merge branch 'plugin-command'

This introduces a new `vagrant plugin` command and removes the old
`vagrant gem` command. Technically, `vagrant plugin` is still just using
RubyGems underneath to manage plugins, but it masks the entire RubyGem
experience away from the end user. This merge introduces three commands;
`list`, `install`, and `uninstall`. They do what they sound like they do.

Future enhancements will add more information such as what components a
plugin installs and activation/deactivation of plugins  that you want
to keep installed but don't want to run.
This commit is contained in:
Mitchell Hashimoto 2013-02-03 13:19:05 -08:00
commit c8260162d4
18 changed files with 704 additions and 13 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 <name> [-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

View File

@ -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

View File

@ -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 <command> [<args>]"
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

View File

@ -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 <name> [-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

View File

@ -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<String>] 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

View File

@ -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

View File

@ -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<String>]
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

View File

@ -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