lib/vagrant/box_collection.rb in vagrantup-1.0.7 vs lib/vagrant/box_collection.rb in vagrantup-1.1.0

- old
+ new

@@ -1,58 +1,324 @@ -require 'forwardable' +require "digest/sha1" +require "tmpdir" +require "log4r" + +require "vagrant/util/subprocess" + module Vagrant - # Represents a collection of boxes, providing helpful methods for - # finding boxes. + # Represents a collection a boxes found on disk. This provides methods + # for accessing/finding individual boxes, adding new boxes, or deleting + # boxes. class BoxCollection - include Enumerable - extend Forwardable - def_delegators :@boxes, :length, :each + TEMP_PREFIX = "vagrant-box-add-temp-" - # The directory that the boxes are being searched for. + # The directory where the boxes in this collection are stored. + # + # A box collection matches a very specific folder structure that Vagrant + # expects in order to easily manage and modify boxes. The folder structure + # is the following: + # + # COLLECTION_ROOT/BOX_NAME/PROVIDER/metadata.json + # + # Where: + # + # * COLLECTION_ROOT - This is the root of the box collection, and is + # the directory given to the initializer. + # * BOX_NAME - The name of the box. This is a logical name given by + # the user of Vagrant. + # * PROVIDER - The provider that the box was built for (VirtualBox, + # VMWare, etc.). + # * metadata.json - A simple JSON file that at the bare minimum + # contains a "provider" key that matches the provider for the + # box. This metadata JSON, however, can contain anything. + # + # @return [Pathname] attr_reader :directory - # Initializes the class to search for boxes in the given directory. - def initialize(directory, action_runner) - @directory = directory - @boxes = [] - @action_runner = action_runner + # Initializes the collection. + # + # @param [Pathname] directory The directory that contains the collection + # of boxes. + def initialize(directory) + @directory = directory + @logger = Log4r::Logger.new("vagrant::box_collection") + end - reload! + # This adds a new box to the system. + # + # There are some exceptional cases: + # * BoxAlreadyExists - The box you're attempting to add already exists. + # * BoxProviderDoesntMatch - If the given box provider doesn't match the + # actual box provider in the untarred box. + # * BoxUnpackageFailure - An invalid tar file. + # * BoxUpgradeRequired - You're attempting to add a box when there is a + # V1 box with the same name that must first be upgraded. + # + # Preconditions: + # * File given in `path` must exist. + # + # @param [Pathname] path Path to the box file on disk. + # @param [String] name Logical name for the box. + # @param [Symbol] provider The provider that the box should be for. This + # will be verified with the `metadata.json` file in the box and is + # meant as a basic check. If this isn't given, then whatever provider + # the box represents will be added. + # @param [Boolean] force If true, any existing box with the same name + # and provider will be replaced. + def add(path, name, provider=nil, force=false) + # A helper to check if a box exists. We store this in a variable + # since we call it multiple times. + check_box_exists = lambda do |box_provider| + box = find(name, box_provider) + next if !box + + if !force + @logger.error("Box already exists, can't add: #{name} #{box_provider}") + raise Errors::BoxAlreadyExists, :name => name, :provider => box_provider + end + + # We're forcing, so just delete the old box + @logger.info("Box already exists, but forcing so removing: #{name} #{box_provider}") + box.destroy! + end + + log_provider = provider ? provider : "any provider" + @logger.debug("Adding box: #{name} (#{log_provider}) from #{path}") + + # Verify the box doesn't exist early if we're given a provider. This + # can potentially speed things up considerably since we don't need + # to unpack any files. + check_box_exists.call(provider) if provider + + # Verify that a V1 box doesn't exist. If it does, then we signal + # to the user that we need an upgrade. + raise Errors::BoxUpgradeRequired, :name => name if v1_box?(@directory.join(name)) + + # Create a temporary directory since we're not sure at this point if + # the box we're unpackaging already exists (if no provider was given) + Dir.mktmpdir(TEMP_PREFIX) do |temp_dir| + temp_dir = Pathname.new(temp_dir) + + # Extract the box into a temporary directory. + @logger.debug("Unpacking box into temporary directory: #{temp_dir}") + result = Util::Subprocess.execute( + "bsdtar", "-v", "-x", "-C", temp_dir.to_s, "-f", path.to_s) + raise Errors::BoxUnpackageFailure, :output => result.stderr.to_s \ + if result.exit_code != 0 + + # If we get a V1 box, we want to update it in place + if v1_box?(temp_dir) + @logger.debug("Added box is a V1 box. Upgrading in place.") + temp_dir = v1_upgrade(temp_dir) + end + + # Get an instance of the box we just added before it is finalized + # in the system so we can inspect and use its metadata. + box = Box.new(name, provider, temp_dir) + + # Get the provider, since we'll need that to at the least add it + # to the system or check that it matches what is given to us. + box_provider = box.metadata["provider"] + + if provider + # Verify that the given provider matches what the box has. + if box_provider.to_sym != provider + @logger.error("Added box provider doesnt match expected: #{box_provider}") + raise Errors::BoxProviderDoesntMatch, :expected => provider, :actual => box_provider + end + else + # We weren't given a provider, so store this one. + provider = box_provider.to_sym + + # Verify the box doesn't already exist + check_box_exists.call(provider) + end + + # Create the directory for this box, not including the provider + box_dir = @directory.join(name) + box_dir.mkpath + @logger.debug("Box directory: #{box_dir}") + + # This is the final directory we'll move it to + final_dir = box_dir.join(provider.to_s) + if final_dir.exist? + @logger.debug("Removing existing provider directory...") + final_dir.rmtree + end + + # Move to final destination + FileUtils.mv(temp_dir.to_s, final_dir.to_s) + + # Recreate the directory. This avoids a bug in Ruby where `mktmpdir` + # cleanup doesn't check if the directory is already gone. Ruby bug + # #6715: http://bugs.ruby-lang.org/issues/6715 + Dir.mkdir(temp_dir, 0700) + end + + # Return the box + find(name, provider) end - # Find a box in the collection by the given name. The name must - # be a string, for now. - def find(name) - @boxes.each do |box| - return box if box.name == name + # This returns an array of all the boxes on the system, given by + # their name and their provider. + # + # @return [Array] Array of `[name, provider]` pairs of the boxes + # installed on this system. An optional third element in the array + # may specify `:v1` if the box is a version 1 box. + def all + results = [] + + @logger.debug("Finding all boxes in: #{@directory}") + @directory.children(true).each do |child| + # Ignore non-directories, since files are not interesting to + # us in our folder structure. + next if !child.directory? + + box_name = child.basename.to_s + + # If this is a V1 box, we still return that name, but specify + # that the box is a V1 box. + if v1_box?(child) + @logger.debug("V1 box found: #{box_name}") + results << [box_name, :virtualbox, :v1] + next + end + + # Otherwise, traverse the subdirectories and see what providers + # we have. + child.children(true).each do |provider| + # Verify this is a potentially valid box. If it looks + # correct enough then include it. + if provider.directory? && provider.join("metadata.json").file? + provider_name = provider.basename.to_s.to_sym + @logger.debug("Box: #{box_name} (#{provider_name})") + results << [box_name, provider_name] + else + @logger.debug("Invalid box, ignoring: #{provider}") + end + end end + results + end + + # Find a box in the collection with the given name and provider. + # + # @param [String] name Name of the box (logical name). + # @Param [String] provider Provider that the box implements. + # @return [Box] The box found, or `nil` if not found. + def find(name, provider) + # First look directly for the box we're asking for. + box_directory = @directory.join(name, provider.to_s, "metadata.json") + @logger.info("Searching for box: #{name} (#{provider}) in #{box_directory}") + if box_directory.file? + @logger.info("Box found: #{name} (#{provider})") + return Box.new(name, provider, box_directory.dirname) + end + + # If we're looking for a VirtualBox box, then we check if there is + # a V1 box. + if provider == :virtualbox + # Check if a V1 version of this box exists, and if so, raise an + # exception notifying the caller that the box exists but needs + # to be upgraded. We don't do the upgrade here because it can be + # a fairly intensive activity and don't want to immediately degrade + # user performance on a find. + # + # To determine if it is a V1 box we just do a simple heuristic + # based approach. + @logger.info("Searching for V1 box: #{name}") + if v1_box?(@directory.join(name)) + @logger.warn("V1 box found: #{name}") + raise Errors::BoxUpgradeRequired, :name => name + end + end + + # Didn't find it, return nil + @logger.info("Box not found: #{name} (#{provider})") nil end - # Adds a box to this collection with the given name and located - # at the given URL. - def add(name, url) - raise Errors::BoxAlreadyExists, :name => name if find(name) + # Upgrades a V1 box with the given name to a V2 box. If a box with the + # given name doesn't exist, then a `BoxNotFound` exception will be raised. + # If the given box is found but is not a V1 box then `true` is returned + # because this just works fine. + # + # @return [Boolean] `true` otherwise an exception is raised. + def upgrade(name) + @logger.debug("Upgrade request for box: #{name}") + box_dir = @directory.join(name) - @action_runner.run(:box_add, - :box_name => name, - :box_url => url, - :box_directory => @directory.join(name)) + # If the box doesn't exist at all, raise an exception + raise Errors::BoxNotFound, :name => name if !box_dir.directory? + + if v1_box?(box_dir) + @logger.debug("V1 box #{name} found. Upgrading!") + + # First we actually perform the upgrade + temp_dir = v1_upgrade(box_dir) + + # Rename the temporary directory to the provider. + FileUtils.mv(temp_dir.to_s, box_dir.join("virtualbox").to_s) + @logger.info("Box '#{name}' upgraded from V1 to V2.") + end + + # We did it! Or the v1 box didn't exist so it doesn't matter. + return true end - # Loads the list of all boxes from the source. This modifies the - # current array. - def reload! - @boxes.clear + protected - Dir.open(@directory) do |dir| - dir.each do |d| - next if d == "." || d == ".." || !@directory.join(d).directory? - @boxes << Box.new(d, @directory.join(d), @action_runner) + # This checks if the given directory represents a V1 box on the + # system. + # + # @param [Pathname] dir Directory where the box is unpacked. + # @return [Boolean] + def v1_box?(dir) + # We detect a V1 box given by whether there is a "box.ovf" which + # is a heuristic but is pretty accurate. + dir.join("box.ovf").file? + end + + # This upgrades the V1 box contained unpacked in the given directory + # and returns the directory of the upgraded version. This is + # _destructive_ to the contents of the old directory. That is, the + # contents of the old V1 box will be destroyed or moved. + # + # Preconditions: + # * `dir` is a valid V1 box. Verify with {#v1_box?} + # + # @param [Pathname] dir Directory where the V1 box is unpacked. + # @return [Pathname] Path to the unpackaged V2 box. + def v1_upgrade(dir) + @logger.debug("Upgrading box in directory: #{dir}") + + temp_dir = Pathname.new(Dir.mktmpdir("vagrant-")) + @logger.debug("Temporary directory for upgrading: #{temp_dir}") + + # Move all the things into the temporary directory + dir.children(true).each do |child| + # Don't move the temp_dir + next if child == temp_dir + + # Move every other directory into the temporary directory + @logger.debug("Copying to upgrade directory: #{child}") + FileUtils.mv(child, temp_dir.join(child.basename)) + end + + # If there is no metadata.json file, make one, since this is how + # we determine if the box is a V2 box. + metadata_file = temp_dir.join("metadata.json") + if !metadata_file.file? + metadata_file.open("w") do |f| + f.write(JSON.generate({ + :provider => "virtualbox" + })) end end + + # Return the temporary directory + temp_dir end end end -