class Vagrant::Action::Builtin::BoxAdd

This middleware will download a remote box and add it to the given box collection.

Constants

METADATA_SIZE_LIMIT

This is the size in bytes that if a file exceeds, is considered to NOT be metadata.

Public Class Methods

new(app, env) click to toggle source
# File lib/vagrant/action/builtin/box_add.rb, line 21
def initialize(app, env)
  @app    = app
  @logger = Log4r::Logger.new("vagrant::action::builtin::box_add")
end

Public Instance Methods

add_direct(urls, env) click to toggle source

Adds a box file directly (no metadata component, versioning, etc.)

@param [Array<String>] urls @param [Hash] env

# File lib/vagrant/action/builtin/box_add.rb, line 123
def add_direct(urls, env)
  name = env[:box_name]
  if !name || name == ""
    raise Errors::BoxAddNameRequired
  end

  if env[:box_version]
    raise Errors::BoxAddDirectVersion
  end

  provider = env[:box_provider]
  provider = Array(provider) if provider

  box_add(
    urls,
    name,
    "0",
    provider,
    nil,
    env,
    checksum: env[:box_checksum],
    checksum_type: env[:box_checksum_type],
  )
end
add_from_metadata(url, env, expanded) click to toggle source

Adds a box given that the URL is a metadata document.

@param [String | Array<String>] url The URL of the metadata for

the box to add. If this is an array, then it must be a two-element
array where the first element is the original URL and the second
element is an authenticated URL.

@param [Hash] env @param [Bool] expanded True if the metadata URL was expanded with

a Vagrant Cloud server URL.
# File lib/vagrant/action/builtin/box_add.rb, line 157
def add_from_metadata(url, env, expanded)
  original_url = env[:box_url]
  provider = env[:box_provider]
  provider = Array(provider) if provider
  version = env[:box_version]

  authenticated_url = url
  if url.is_a?(Array)
    # We have both a normal URL and "authenticated" URL. Split
    # them up.
    authenticated_url = url[1]
    url               = url[0]
  end

  env[:ui].output(I18n.t(
    "vagrant.box_loading_metadata",
    name: Array(original_url).first))
  if original_url != url
    env[:ui].detail(I18n.t(
      "vagrant.box_expanding_url", url: url))
  end

  metadata = nil
  begin
    metadata_path = download(
      authenticated_url, env, json: true, ui: false)

    File.open(metadata_path) do |f|
      metadata = BoxMetadata.new(f)
    end
  rescue Errors::DownloaderError => e
    raise if !expanded
    raise Errors::BoxAddShortNotFound,
      error: e.extra_data[:message],
      name: original_url,
      url: url
  ensure
    metadata_path.delete if metadata_path && metadata_path.file?
  end

  if env[:box_name] && metadata.name != env[:box_name]
    raise Errors::BoxAddNameMismatch,
      actual_name: metadata.name,
      requested_name: env[:box_name]
  end

  metadata_version  = metadata.version(
    version || ">= 0", provider: provider)
  if !metadata_version
    if !provider
      raise Errors::BoxAddNoMatchingVersion,
        constraints: version || ">= 0",
        name: metadata.name,
        url: url,
        versions: metadata.versions.join(", ")
    else
      # TODO: show supported providers
      raise Errors::BoxAddNoMatchingProvider,
        name: metadata.name,
        requested: provider,
        url: url
    end
  end

  metadata_provider = nil
  if provider
    # If a provider was specified, make sure we get that specific
    # version.
    provider.each do |p|
      metadata_provider = metadata_version.provider(p)
      break if metadata_provider
    end
  elsif metadata_version.providers.length == 1
    # If we have only one provider in the metadata, just use that
    # provider.
    metadata_provider = metadata_version.provider(
      metadata_version.providers.first)
  else
    providers = metadata_version.providers.sort

    choice = 0
    options = providers.map do |p|
      choice += 1
      "#{choice}) #{p}"
    end.join("\n")

    # We have more than one provider, ask the user what they want
    choice = env[:ui].ask(I18n.t(
      "vagrant.box_add_choose_provider",
      options: options) + " ", prefix: false)
    choice = choice.to_i if choice
    while !choice || choice <= 0 || choice > providers.length
      choice = env[:ui].ask(I18n.t(
        "vagrant.box_add_choose_provider_again") + " ",
        prefix: false)
      choice = choice.to_i if choice
    end

    metadata_provider = metadata_version.provider(
      providers[choice-1])
  end

  provider_url = metadata_provider.url
  if url != authenticated_url
    # Authenticate the provider URL since we're using auth
    hook_env    = env[:hook].call(:authenticate_box_url, box_urls: [provider_url])
    authed_urls = hook_env[:box_urls]
    if !authed_urls || authed_urls.length != 1
      raise "Bad box authentication hook, did not generate proper results."
    end
    provider_url = authed_urls[0]
  end

  box_add(
    [[provider_url, metadata_provider.url]],
    metadata.name,
    metadata_version.version,
    metadata_provider.name,
    url,
    env,
    checksum: metadata_provider.checksum,
    checksum_type: metadata_provider.checksum_type,
  )
end
call(env) click to toggle source
# File lib/vagrant/action/builtin/box_add.rb, line 26
def call(env)
  @download_interrupted = false

  url = Array(env[:box_url]).map do |u|
    u = u.gsub("\\", "/")
    if Util::Platform.windows? && u =~ /^[a-z]:/
      # On Windows, we need to be careful about drive letters
      u = "file://#{URI.escape(u)}"
    end

    if u =~ /^[a-z0-9]+:.*$/ && !u.start_with?("file://")
      # This is not a file URL... carry on
      next u
    end

    # Expand the path and try to use that, if possible
    p = File.expand_path(URI.unescape(u.gsub(/^file:\/\//, "")))
    p = Util::Platform.cygwin_windows_path(p)

    # If this is a network share on Windows then show the user an error
    if Util::Platform.windows? && (p.start_with?("//") || p.start_with?("\\\\"))
      raise Errors::BoxUrlIsNetworkShare, url: p
    end

    next "file://#{URI.escape(p.gsub("\\", "/"))}" if File.file?(p)

    u
  end

  # If we received a shorthand URL ("mitchellh/precise64"),
  # then expand it properly.
  expanded = false
  url.each_index do |i|
    next if url[i] !~ /^[^\/]+\/[^\/]+$/

    if !File.file?(url[i])
      server   = Vagrant.server_url
      raise Errors::BoxServerNotSet if !server

      expanded = true
      url[i] = "#{server}/#{url[i]}"
    end
  end

  # Call the hook to transform URLs into authenticated URLs.
  # In the case we don't have a plugin that does this, then it
  # will just return the same URLs.
  hook_env    = env[:hook].call(
    :authenticate_box_url, box_urls: url.dup)
  authed_urls = hook_env[:box_urls]
  if !authed_urls || authed_urls.length != url.length
    raise "Bad box authentication hook, did not generate proper results."
  end

  # Test if any of our URLs point to metadata
  is_metadata_results = authed_urls.map do |u|
    begin
      metadata_url?(u, env)
    rescue Errors::DownloaderError => e
      e
    end
  end

  if expanded && url.length == 1
    is_error = is_metadata_results.find do |b|
      b.is_a?(Errors::DownloaderError)
    end

    if is_error
      raise Errors::BoxAddShortNotFound,
        error: is_error.extra_data[:message],
        name: env[:box_url],
        url: url
    end
  end

  is_metadata = is_metadata_results.any? { |b| b === true }
  if is_metadata && url.length > 1
    raise Errors::BoxAddMetadataMultiURL,
      urls: url.join(", ")
  end

  if is_metadata
    url = [url.first, authed_urls.first]
    add_from_metadata(url, env, expanded)
  else
    add_direct(url, env)
  end

  @app.call(env)
end

Protected Instance Methods

box_add(urls, name, version, provider, md_url, env, **opts) click to toggle source

Shared helper to add a box once you know various details about it. Shared between adding via metadata or by direct.

@param [Array<String>] urls @param [String] name @param [String] version @param [String] provider @param [Hash] env @return [Box]

# File lib/vagrant/action/builtin/box_add.rb, line 293
def box_add(urls, name, version, provider, md_url, env, **opts)
  env[:ui].output(I18n.t(
    "vagrant.box_add_with_version",
    name: name,
    version: version,
    providers: Array(provider).join(", ")))

  # Verify the box we're adding doesn't already exist
  if provider && !env[:box_force]
    box = env[:box_collection].find(
      name, provider, version)
    if box
      raise Errors::BoxAlreadyExists,
        name: name,
        provider: provider,
        version: version
    end
  end

  # Now we have a URL, we have to download this URL.
  box = nil
  begin
    box_url = nil

    urls.each do |url|
      show_url = nil
      if url.is_a?(Array)
        show_url = url[1]
        url      = url[0]
      end

      begin
        box_url = download(url, env, show_url: show_url)
        break
      rescue Errors::DownloaderError => e
        # If we don't have multiple URLs, just raise the error
        raise if urls.length == 1

        env[:ui].error(I18n.t(
          "vagrant.box_download_error",  message: e.message))
        box_url = nil
      end
    end

    if opts[:checksum] && opts[:checksum_type]
      validate_checksum(
        opts[:checksum_type], opts[:checksum], box_url)
    end

    # Add the box!
    box = env[:box_collection].add(
      box_url, name, version,
      force: env[:box_force],
      metadata_url: md_url,
      providers: provider)
  ensure
    # Make sure we delete the temporary file after we add it,
    # unless we were interrupted, in which case we keep it around
    # so we can resume the download later.
    if !@download_interrupted
      @logger.debug("Deleting temporary box: #{box_url}")
      begin
        box_url.delete if box_url
      rescue Errno::ENOENT
        # Not a big deal, the temp file may not actually exist
      end
    end
  end

  env[:ui].success(I18n.t(
    "vagrant.box_added",
    name: box.name,
    version: box.version,
    provider: box.provider))

  # Store the added box in the env for future middleware
  env[:box_added] = box

  box
end
download(url, env, **opts) click to toggle source
# File lib/vagrant/action/builtin/box_add.rb, line 416
def download(url, env, **opts)
  opts[:ui] = true if !opts.has_key?(:ui)

  d = downloader(url, env, **opts)

  # Download the box to a temporary path. We store the temporary
  # path as an instance variable so that the `#recover` method can
  # access it.
  if opts[:ui]
    show_url = opts[:show_url]
    show_url ||= url

    env[:ui].detail(I18n.t(
      "vagrant.box_downloading",
      url: show_url))
    if File.file?(d.destination)
      env[:ui].info(I18n.t("vagrant.actions.box.download.resuming"))
    end
  end

  begin
    d.download!
  rescue Errors::DownloaderInterrupted
    # The downloader was interrupted, so just return, because that
    # means we were interrupted as well.
    @download_interrupted = true
    env[:ui].info(I18n.t("vagrant.actions.box.download.interrupted"))
  rescue Errors::DownloaderError
    # The download failed for some reason, clean out the temp path
    File.unlink(d.destination) if File.file?(d.destination)
    raise
  end

  Pathname.new(d.destination)
end
downloader(url, env, **opts) click to toggle source

Returns the download options for the download.

@return [Hash]

# File lib/vagrant/action/builtin/box_add.rb, line 377
def downloader(url, env, **opts)
  opts[:ui] = true if !opts.has_key?(:ui)

  temp_path = env[:tmp_path].join("box" + Digest::SHA1.hexdigest(url))
  @logger.info("Downloading box: #{url} => #{temp_path}")

  if File.file?(url) || url !~ /^[a-z0-9]+:.*$/
    @logger.info("URL is a file or protocol not found and assuming file.")
    file_path = File.expand_path(url)
    file_path = Util::Platform.cygwin_windows_path(file_path)
    url = "file:#{file_path}"
  end

  # If the temporary path exists, verify it is not too old. If its
  # too old, delete it first because the data may have changed.
  if temp_path.file?
    delete = false
    if env[:box_clean]
      @logger.info("Cleaning existing temp box file.")
      delete = true
    elsif temp_path.mtime.to_i < (Time.now.to_i - 6 * 60 * 60)
      @logger.info("Existing temp file is too old. Removing.")
      delete = true
    end

    temp_path.unlink if delete
  end

  downloader_options = {}
  downloader_options[:ca_cert] = env[:box_download_ca_cert]
  downloader_options[:continue] = true
  downloader_options[:insecure] = env[:box_download_insecure]
  downloader_options[:client_cert] = env[:box_client_cert]
  downloader_options[:headers] = ["Accept: application/json"] if opts[:json]
  downloader_options[:ui] = env[:ui] if opts[:ui]

  Util::Downloader.new(url, temp_path, downloader_options)
end
metadata_url?(url, env) click to toggle source

Tests whether the given URL points to a metadata file or a box file without completely downloading the file.

@param [String] url @return [Boolean] true if metadata

# File lib/vagrant/action/builtin/box_add.rb, line 457
def metadata_url?(url, env)
  d = downloader(url, env, json: true, ui: false)

  # If we're downloading a file, cURL just returns no
  # content-type (makes sense), so we just test if it is JSON
  # by trying to parse JSON!
  uri = URI.parse(d.source)
  if uri.scheme == "file"
    url = uri.path
    url ||= uri.opaque

    begin
      File.open(url, "r") do |f|
        if f.size > METADATA_SIZE_LIMIT
          # Quit early, don't try to parse the JSON of gigabytes
          # of box files...
          return false
        end

        BoxMetadata.new(f)
      end
      return true
    rescue Errors::BoxMetadataMalformed
      return false
    rescue Errno::EINVAL
      # Actually not sure what causes this, but its always
      # in a case that isn't true.
      return false
    rescue Errno::ENOENT
      return false
    end
  end

  output = d.head
  match  = output.scan(/^Content-Type: (.+?)$/).last
  return false if !match
  match.last.chomp == "application/json"
end
validate_checksum(checksum_type, checksum, path) click to toggle source
# File lib/vagrant/action/builtin/box_add.rb, line 496
def validate_checksum(checksum_type, checksum, path)
  checksum_klass = case checksum_type.to_sym
  when :md5
    Digest::MD5
  when :sha1
    Digest::SHA1
  when :sha256
    Digest::SHA2
  else
    raise Errors::BoxChecksumInvalidType,
      type: env[:box_checksum_type].to_s
  end

  @logger.info("Validating checksum with #{checksum_klass}")
  @logger.info("Expected checksum: #{checksum}")

  actual = FileChecksum.new(path, checksum_klass).checksum
  if actual != checksum
    raise Errors::BoxChecksumMismatch,
      actual: actual,
      expected: checksum
  end
end