vaguerent/lib/vagrant/box_collection.rb
Chris Roberts 51adb12547 Add architecture support for boxes
Introduce support for handling box architecture. Adds a new
`box_architecture` setting that defaults to `:auto` which will perform
automatic detection of the host system, but can be overridden with a
custom value. Can also be set to `nil` which will result in it fetching
the box flagged with the default architecture within the metadata.

Box collection has been modified to allow existing boxes already
downloaded and unpacked to still function as expected when architecture
information is not available.
2023-09-14 16:15:03 -07:00

527 lines
19 KiB
Ruby

# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: BUSL-1.1
require "digest/sha1"
require "fileutils"
require "monitor"
require "tmpdir"
require "log4r"
require "vagrant/util/platform"
require "vagrant/util/subprocess"
module Vagrant
# Represents a collection a boxes found on disk. This provides methods
# for accessing/finding individual boxes, adding new boxes, or deleting
# boxes.
class BoxCollection
TEMP_PREFIX = "vagrant-box-add-temp-".freeze
VAGRANT_SLASH = "-VAGRANTSLASH-".freeze
VAGRANT_COLON = "-VAGRANTCOLON-".freeze
autoload :Remote, "vagrant/box_collection/remote"
# 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/[ARCHITECTURE]/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.).
# * ARCHITECTURE - Optional. The architecture that the box was built
# for (amd64, arm64, 386, 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 collection.
#
# @param [Pathname] directory The directory that contains the collection
# of boxes.
def initialize(directory, options=nil)
options ||= {}
@directory = directory
@hook = options[:hook]
@lock = Monitor.new
@temp_root = options[:temp_dir_root]
@logger = Log4r::Logger.new("vagrant::box_collection")
end
# 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.
#
# 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 [String] version The version of this box.
# @param [Array<String>] providers The providers that this box can
# be a part of. This will be verified with the `metadata.json` 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, version, **opts)
architecture = opts[:architecture]
providers = opts[:providers]
providers = Array(providers) if providers
provider = nil
# 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_formats, box_architecture|
box = find(name, box_formats, version, box_architecture)
next if !box
if !opts[:force]
@logger.error(
"Box already exists, can't add: #{name} v#{version} #{box_formats.join(", ")}")
raise Errors::BoxAlreadyExists,
name: name,
provider: box_formats.join(", "),
version: version
end
# We're forcing, so just delete the old box
@logger.info(
"Box already exists, but forcing so removing: " +
"#{name} v#{version} #{box_formats.join(", ")}")
box.destroy!
end
with_collection_lock do
log_provider = providers ? providers.join(", ") : "any provider"
@logger.debug("Adding box: #{name} (#{log_provider} - #{architecture.inspect}) 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(providers, architecture) if providers
# 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)
with_temp_dir do |temp_dir|
# Extract the box into a temporary directory.
@logger.debug("Unpacking box into temporary directory: #{temp_dir}")
result = Util::Subprocess.execute(
"bsdtar", "--no-same-owner", "--no-same-permissions", "-v", "-x", "-m", "-S", "-s", "|\\\\\|/|", "-C", temp_dir.to_s, "-f", path.to_s)
if result.exit_code != 0
raise Errors::BoxUnpackageFailure,
output: result.stderr.to_s
end
# 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
# We re-wrap ourselves in the safety net in case we upgraded.
# If we didn't upgrade, then this is still safe because the
# helper will only delete the directory if it exists
with_temp_dir(temp_dir) do |final_temp_dir|
# 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, nil, version, final_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 providers
found = providers.find { |p| p.to_sym == box_provider.to_sym }
if !found
@logger.error("Added box provider doesnt match expected: #{log_provider}")
raise Errors::BoxProviderDoesntMatch,
expected: log_provider, actual: box_provider
end
else
# Verify the box doesn't already exist
check_box_exists.call([box_provider], architecture)
end
# We weren't given a provider, so store this one.
provider = box_provider.to_sym
# Create the directory for this box, not including the provider
root_box_dir = @directory.join(dir_name(name))
box_dir = root_box_dir.join(version)
box_dir.mkpath
@logger.debug("Box directory: #{box_dir}")
# This is the final directory we'll move it to
provider_dir = box_dir.join(provider.to_s)
final_dir = provider_dir
@logger.debug("Provider directory: #{provider_dir}")
# If architecture is set, unpack into architecture specific directory
if architecture
arch = architecture
arch = Util::Platform.architecture if architecture == :auto
final_dir = provider_dir.join(arch)
end
if final_dir.exist?
@logger.debug("Removing existing provider directory...")
final_dir.rmtree
end
# Move to final destination
final_dir.mkpath
# Recursively move individual files from the temporary directory
# to the final location. We do this instead of moving the entire
# directory to avoid issues on Windows. [GH-1424]
copy_pairs = [[final_temp_dir, final_dir]]
while !copy_pairs.empty?
from, to = copy_pairs.shift
from.children(true).each do |f|
dest = to.join(f.basename)
# We don't copy entire directories, so create the
# directory and then add to our list to copy.
if f.directory?
dest.mkpath
copy_pairs << [f, dest]
next
end
# Copy the single file
@logger.debug("Moving: #{f} => #{dest}")
FileUtils.mv(f, dest)
end
end
if opts[:metadata_url]
root_box_dir.join("metadata_url").open("w") do |f|
f.write(opts[:metadata_url])
end
end
end
end
end
# Return the box
find(name, provider, version, architecture)
end
# This returns an array of all the boxes on the system, given by
# their name and their provider.
#
# @return [Array] Array of `[name, version, provider, architecture]` of the boxes
# installed on this system.
def all
results = []
with_collection_lock do
@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 = undir_name(child.basename.to_s)
# Otherwise, traverse the subdirectories and see what versions
# we have.
child.children(true).each do |versiondir|
next if !versiondir.directory?
next if versiondir.basename.to_s.start_with?(".")
version = versiondir.basename.to_s
versiondir.children(true).each do |provider|
# Ensure version of box is correct before continuing
if !Gem::Version.correct?(version)
ui = Vagrant::UI::Prefixed.new(Vagrant::UI::Colored.new, "vagrant")
ui.warn(I18n.t("vagrant.box_version_malformed",
version: version, box_name: box_name))
@logger.debug("Invalid version #{version} for box #{box_name}")
next
end
# 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}, #{version})")
results << [box_name, version, provider_name, nil]
elsif provider.directory?
provider.children(true).each do |architecture|
provider_name = provider.basename.to_s.to_sym
if architecture.directory? && architecture.join("metadata.json").file?
architecture_name = architecture.basename.to_s.to_sym
@logger.debug("Box: #{box_name} (#{provider_name} (#{architecture_name}), #{version})")
results << [box_name, version, provider_name, architecture_name]
end
end
else
@logger.debug("Invalid box #{box_name}, ignoring: #{provider}")
end
end
end
end
end
# Sort the list to group like providers and properly ordered versions
results.sort_by! do |box_result|
[box_result[0], box_result[2], Gem::Version.new(box_result[1]), box_result[3]]
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 [Array] providers Providers that the box implements.
# @param [String] version Version constraints to adhere to. Example:
# "~> 1.0" or "= 1.0, ~> 1.1"
# @return [Box] The box found, or `nil` if not found.
def find(name, providers, version, box_architecture=:auto)
providers = Array(providers)
architecture = box_architecture
architecture = Util::Platform.architecture if architecture == :auto
# Build up the requirements we have
requirements = version.to_s.split(",").map do |v|
begin
Gem::Requirement.new(v.strip)
rescue Gem::Requirement::BadRequirementError
raise Errors::BoxVersionInvalid,
version: v.strip
end
end
with_collection_lock do
box_directory = @directory.join(dir_name(name))
if !box_directory.directory?
@logger.info("Box not found: #{name} (#{providers.join(", ")})")
return nil
end
# Keep a mapping of Gem::Version mangled versions => directories.
# ie. 0.1.0.pre.alpha.2 => 0.1.0-alpha.2
# This is so we can sort version numbers properly here, but still
# refer to the real directory names in path checks below and pass an
# unmangled version string to Box.new
version_dir_map = {}
versions = box_directory.children(true).map do |versiondir|
next if !versiondir.directory?
next if versiondir.basename.to_s.start_with?(".")
version = Gem::Version.new(versiondir.basename.to_s)
version_dir_map[version.to_s] = versiondir.basename.to_s
version
end.compact
# Traverse through versions with the latest version first
versions.sort.reverse.each do |v|
if !requirements.all? { |r| r.satisfied_by?(v) }
# Unsatisfied version requirements
next
end
versiondir = box_directory.join(version_dir_map[v.to_s])
providers.each do |provider|
provider_dir = versiondir.join(provider.to_s)
next if !provider_dir.directory?
# If architecture is defined then the box should be within
# a subdirectory. However, if box_architecture value is :auto
# and the box provider directory exists but the architecture
# directory does not, we will use the box provider directory. This
# allows Vagrant to work correctly with boxes which were added
# prior to the addition of architecture support
final_dir = provider_dir
if architecture
arch_dir = provider_dir.join(architecture.to_s)
next if !arch_dir.directory? && box_architecture != :auto
end
final_dir = arch_dir if arch_dir && arch_dir.directory?
@logger.info("Box found: #{name} (#{provider})")
metadata_url = nil
metadata_url_file = box_directory.join("metadata_url")
metadata_url = metadata_url_file.read if metadata_url_file.file?
if metadata_url && @hook
hook_env = @hook.call(
:authenticate_box_url, box_urls: [metadata_url])
metadata_url = hook_env[:box_urls].first
end
return Box.new(
name, provider, version_dir_map[v.to_s], final_dir,
architecture: architecture, metadata_url: metadata_url, hook: @hook
)
end
end
end
nil
end
# This upgrades a v1.1 - v1.4 box directory structure up to a v1.5
# directory structure. This will raise exceptions if it fails in any
# way.
def upgrade_v1_1_v1_5
with_collection_lock do
temp_dir = Pathname.new(Dir.mktmpdir(TEMP_PREFIX, @temp_root))
@directory.children(true).each do |boxdir|
# Ignore all non-directories because they can't be boxes
next if !boxdir.directory?
box_name = boxdir.basename.to_s
# If it is a v1 box, then we need to upgrade it first
if v1_box?(boxdir)
upgrade_dir = v1_upgrade(boxdir)
FileUtils.mv(upgrade_dir, boxdir.join("virtualbox"))
end
# Create the directory for this box
new_box_dir = temp_dir.join(dir_name(box_name), "0")
new_box_dir.mkpath
# Go through each provider and move it
boxdir.children(true).each do |providerdir|
FileUtils.cp_r(providerdir, new_box_dir.join(providerdir.basename))
end
end
# Move the folder into place
@directory.rmtree
FileUtils.mv(temp_dir.to_s, @directory.to_s)
end
end
# Cleans the directory for a box by removing the folders that are
# empty.
def clean(name)
return false if exists?(name)
path = File.join(directory, dir_name(name))
FileUtils.rm_rf(path)
end
protected
# Returns the directory name for the box of the given name.
#
# @param [String] name
# @return [String]
def dir_name(name)
name = name.dup
name.gsub!(":", VAGRANT_COLON) if Util::Platform.windows?
name.gsub!("/", VAGRANT_SLASH)
name
end
# Returns the directory name for the box cleaned up
def undir_name(name)
name = name.dup
name.gsub!(VAGRANT_COLON, ":")
name.gsub!(VAGRANT_SLASH, "/")
name
end
# 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(TEMP_PREFIX, @temp_root))
@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
# This locks the region given by the block with a lock on this
# collection.
def with_collection_lock
@lock.synchronize do
return yield
end
end
# This is a helper that makes sure that our temporary directories
# are cleaned up no matter what.
#
# @param [String] dir Path to a temporary directory
# @return [Object] The result of whatever the yield is
def with_temp_dir(dir=nil)
dir ||= Dir.mktmpdir(TEMP_PREFIX, @temp_root)
dir = Pathname.new(dir)
yield dir
ensure
FileUtils.rm_rf(dir.to_s)
end
# Checks if a box with a given name exists.
def exists?(box_name)
all.any? { |box| box.first.eql?(box_name) }
end
end
end