When the reported architecture is unknown and the provider is listed as the default architecture, add the box without architecture information so it is installed without architecture information on the path within the collection.
639 lines
22 KiB
Ruby
639 lines
22 KiB
Ruby
# Copyright (c) HashiCorp, Inc.
|
|
# SPDX-License-Identifier: BUSL-1.1
|
|
|
|
require "digest/sha1"
|
|
require "log4r"
|
|
require "pathname"
|
|
require "uri"
|
|
|
|
require "vagrant/box_metadata"
|
|
require "vagrant/util/downloader"
|
|
require "vagrant/util/file_checksum"
|
|
require "vagrant/util/file_mutex"
|
|
require "vagrant/util/platform"
|
|
|
|
module Vagrant
|
|
module Action
|
|
module Builtin
|
|
# This middleware will download a remote box and add it to the
|
|
# given box collection.
|
|
class BoxAdd
|
|
# This is the size in bytes that if a file exceeds, is considered
|
|
# to NOT be metadata.
|
|
METADATA_SIZE_LIMIT = 20971520
|
|
|
|
# This is the amount of time to "resume" downloads if a partial box
|
|
# file already exists.
|
|
RESUME_DELAY = 24 * 60 * 60
|
|
|
|
def initialize(app, env)
|
|
@app = app
|
|
@logger = Log4r::Logger.new("vagrant::action::builtin::box_add")
|
|
@parser = URI::RFC2396_Parser.new
|
|
end
|
|
|
|
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
|
|
# Mark if only a single url entry was provided
|
|
single_entry = url.size == 1
|
|
|
|
url = url.map do |url_entry|
|
|
if url_entry =~ /^[^\/]+\/[^\/]+$/ && !File.file?(url_entry)
|
|
server = Vagrant.server_url env[:box_server_url]
|
|
raise Errors::BoxServerNotSet if !server
|
|
|
|
expanded = true
|
|
# If only a single entry, expand to both the API endpoint and
|
|
# the direct shorthand endpoint.
|
|
if single_entry
|
|
url_entry = [
|
|
"#{server}/api/v2/vagrant/#{url_entry}",
|
|
"#{server}/#{url_entry}"
|
|
]
|
|
else
|
|
url_entry = "#{server}/#{url_entry}"
|
|
end
|
|
end
|
|
|
|
url_entry
|
|
end.flatten
|
|
|
|
# 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 only a single entry was provided, and it was expanded,
|
|
# inspect the metadata check results and extract the one that
|
|
# was successful, with preference to the API endpoint
|
|
if single_entry && expanded
|
|
idx = is_metadata_results.index { |v| v === true }
|
|
# If none of the urls were successful, set the index
|
|
# as the last entry
|
|
idx = is_metadata_results.size - 1 if idx.nil?
|
|
|
|
# Now reset collections with single value
|
|
is_metadata_results = [is_metadata_results[idx]]
|
|
authed_urls = [authed_urls[idx]]
|
|
url = [url[idx]]
|
|
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
|
|
|
|
# Adds a box file directly (no metadata component, versioning,
|
|
# etc.)
|
|
#
|
|
# @param [Array<String>] urls
|
|
# @param [Hash] env
|
|
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],
|
|
architecture: env[:architecture]
|
|
)
|
|
end
|
|
|
|
# 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.
|
|
def add_from_metadata(url, env, expanded)
|
|
original_url = env[:box_url]
|
|
architecture = env[:box_architecture]
|
|
display_architecture = architecture == :auto ?
|
|
Util::Platform.architecture : architecture
|
|
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,
|
|
architecture: architecture,
|
|
)
|
|
if !metadata_version
|
|
if provider && !metadata.version(">= 0", provider: provider, architecture: architecture)
|
|
raise Errors::BoxAddNoMatchingProvider,
|
|
name: metadata.name,
|
|
requested: [provider,
|
|
display_architecture ? "(#{display_architecture})" : nil
|
|
].compact.join(" "),
|
|
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, architecture)
|
|
break if metadata_provider
|
|
end
|
|
elsif metadata_version.providers(architecture).length == 1
|
|
# If we have only one provider in the metadata, just use that
|
|
# provider.
|
|
metadata_provider = metadata_version.provider(
|
|
metadata_version.providers.first, architecture)
|
|
else
|
|
providers = metadata_version.providers(architecture).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], architecture)
|
|
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
|
|
|
|
# The architecture name used when adding the box should be
|
|
# the value extracted from the metadata provider
|
|
arch_name = metadata_provider.architecture
|
|
|
|
# In the special case where the architecture name is "unknown" and
|
|
# it is listed as the default architecture, unset the architecture
|
|
# name so it is installed without architecture information
|
|
if arch_name == "unknown" && metadata_provider.default_architecture
|
|
arch_name = nil
|
|
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,
|
|
architecture: arch_name,
|
|
)
|
|
end
|
|
|
|
protected
|
|
|
|
# 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]
|
|
def box_add(urls, name, version, provider, md_url, env, **opts)
|
|
display_architecture = opts[:architecture] == :auto ?
|
|
Util::Platform.architecture : opts[:architecture]
|
|
env[:ui].output(I18n.t(
|
|
"vagrant.box_add_with_version",
|
|
name: name,
|
|
version: version,
|
|
providers: [
|
|
provider,
|
|
display_architecture ? "(#{display_architecture})" : nil
|
|
].compact.join(" ")))
|
|
|
|
# Verify the box we're adding doesn't already exist
|
|
if provider && !env[:box_force]
|
|
box = env[:box_collection].find(
|
|
name, provider, version, opts[:architecture])
|
|
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,
|
|
architecture: opts[:architecture]
|
|
)
|
|
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: [
|
|
provider,
|
|
display_architecture ? "(#{display_architecture})" : nil
|
|
].compact.join(" ")))
|
|
|
|
# Store the added box in the env for future middleware
|
|
env[:box_added] = box
|
|
|
|
box
|
|
end
|
|
|
|
# Returns the download options for the download.
|
|
#
|
|
# @return [Hash]
|
|
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[:disable_ssl_revoke_best_effort] = env[:box_download_disable_ssl_revoke_best_effort]
|
|
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
|
|
|
|
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
|
|
|
|
# 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
|
|
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
|
|
|
|
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
|
|
end
|
|
end
|
|
end
|
|
end
|