Chris Roberts 74b4a2b1f5 Adjust installation for unknown default architecture
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.
2023-09-26 16:20:37 -07:00

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