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.

RESUME_DELAY

This is the amount of time to “resume” downloads if a partial box file already exists.

Public Class Methods

new(app, env) click to toggle source
# File lib/vagrant/action/builtin/box_add.rb, line 26
def initialize(app, env)
  @app    = app
  @logger = Log4r::Logger.new("vagrant::action::builtin::box_add")
  @parser = URI::RFC2396_Parser.new
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 141
def add_direct(urls, env)
  env[:ui].output(I18n.t("vagrant.box_adding_direct"))

  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 Atlas server URL.
# File lib/vagrant/action/builtin/box_add.rb, line 177
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

  display_original_url = Util::CredentialScrubber.scrub(Array(original_url).first)
  display_url = Util::CredentialScrubber.scrub(url)

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

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

    File.open(metadata_path) do |f|
      metadata = BoxMetadata.new(f, url: authenticated_url)
    end
  rescue Errors::DownloaderError => e
    raise if !expanded
    raise Errors::BoxAddShortNotFound,
      error: e.extra_data[:message],
      name: display_original_url,
      url: display_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 && !metadata.version(">= 0", provider: provider)
      raise Errors::BoxAddNoMatchingProvider,
        name: metadata.name,
        requested: provider,
        url: display_url
    else
      raise Errors::BoxAddNoMatchingVersion,
        constraints: version || ">= 0",
        name: metadata.name,
        url: display_url,
        versions: metadata.versions.join(", ")
    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 provider_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 32
def call(env)
  @download_interrupted = false

  unless env[:box_name].nil?
    begin
      if URI.parse(env[:box_name]).kind_of?(URI::HTTP)
        env[:ui].warn(I18n.t("vagrant.box_add_url_warn"))
      end
    rescue URI::InvalidURIError
      # do nothing
    end
  end

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

    if u =~ /^[a-z0-9]+:.*$/i && !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(@parser.unescape(u.gsub(/^file:\/\//, "")))
    p = Util::Platform.cygwin_windows_path(p)
    next "file://#{@parser.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 env[:box_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_error = is_metadata_results.find do |b|
    b.is_a?(Errors::DownloaderError)
  end
  if is_error
    raise Errors::BoxMetadataDownloadError,  
      message: is_error.extra_data[:message]
  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(authed_urls, 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 316
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]
      if opts[:checksum].to_s.strip.empty?
        @logger.warn("Given checksum is empty, cannot validate checksum for box")
      elsif opts[:checksum_type].to_s.strip.empty?
        @logger.warn("Given checksum type is empty, cannot validate checksum for box")
      else
        env[:ui].detail(I18n.t("vagrant.actions.box.add.checksumming"))
        validate_checksum(
          opts[:checksum_type], opts[:checksum], box_url)
      end
    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 453
def download(url, env, **opts)
  opts[:ui] = true if !opts.key?(:ui)

  d = downloader(url, env, **opts)
  env[:hook].call(:authenticate_box_downloader, downloader: d)

  # 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
    display_url = Util::CredentialScrubber.scrub(show_url)

    translation = "vagrant.box_downloading"

    # Adjust status message when 'downloading' a local box.
    if show_url.start_with?("file://")
      translation = "vagrant.box_unpacking"
    end

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

  begin
    mutex_path = d.destination + ".lock"
    Util::FileMutex.new(mutex_path).with_lock do
      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"))
      end
    end
  rescue Errors::VagrantLocked
    raise Errors::DownloadAlreadyInProgress,
      dest_path: d.destination,
      lock_file_path: mutex_path
  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 407
def downloader(url, env, **opts)
  opts[:ui] = true if !opts.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]+:.*$/i
    @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)
    file_path = file_path.gsub("\\", "/")
    file_path = "/#{file_path}" if !file_path.start_with?("/")
    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 - RESUME_DELAY)
      @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[:ca_path] = env[:box_download_ca_path]
  downloader_options[:continue] = true
  downloader_options[:insecure] = env[:box_download_insecure]
  downloader_options[:client_cert] = env[:box_download_client_cert]
  downloader_options[:headers] = ["Accept: application/json"] if opts[:json]
  downloader_options[:ui] = env[:ui] if opts[:ui]
  downloader_options[:location_trusted] = env[:box_download_location_trusted]
  downloader_options[:box_extra_download_options] = env[:box_extra_download_options]

  d = Util::Downloader.new(url, temp_path, downloader_options)
  env[:hook].call(:authenticate_box_downloader, downloader: d)
  d
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 508
def metadata_url?(url, env)
  d = downloader(url, env, json: true, ui: false)
  env[:hook].call(:authenticate_box_downloader, downloader: d)

  # 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
    #7570 Strip leading slash left in front of drive letter by uri.path
    Util::Platform.windows? && url.gsub!(/^\/([a-zA-Z]:)/, '\1')
    url = @parser.unescape(url)

    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, url: url)
      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::EISDIR
      return false
    rescue Errno::ENOENT
      return false
    end
  end

  # If this isn't HTTP, then don't do the HEAD request
  if !uri.scheme.downcase.start_with?("http")
    @logger.info("not checking metadata since box URI isn't HTTP")
    return false
  end

  output = d.head
  match  = output.scan(/^Content-Type: (.+?)$/i).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 559
def validate_checksum(checksum_type, _checksum, path)
  checksum = _checksum.strip()
  @logger.info("Validating checksum with #{checksum_type}")
  @logger.info("Expected checksum: #{checksum}")

  _actual = FileChecksum.new(path, checksum_type).checksum
  actual = _actual.strip()
  @logger.info("Actual checksum: #{actual}")
  if actual.casecmp(checksum) != 0
    raise Errors::BoxChecksumMismatch,
      actual: actual,
      expected: checksum
  end
end