vaguerent/lib/vagrant/machine_index.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

531 lines
15 KiB
Ruby

# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: BUSL-1.1
require "json"
require "pathname"
require "securerandom"
require "thread"
require "vagrant/util/silence_warnings"
module Vagrant
# MachineIndex is able to manage the index of created Vagrant environments
# in a central location.
#
# The MachineIndex stores a mapping of UUIDs to basic information about
# a machine. The UUIDs are stored with the Vagrant environment and are
# looked up in the machine index.
#
# The MachineIndex stores information such as the name of a machine,
# the directory it was last seen at, its last known state, etc. Using
# this information, we can load the entire {Machine} object for a machine,
# or we can just display metadata if needed.
#
# The internal format of the data file is currently JSON in the following
# structure:
#
# {
# "version": 1,
# "machines": {
# "uuid": {
# "name": "foo",
# "provider": "vmware_fusion",
# "architecture": "amd64",
# "data_path": "/path/to/data/dir",
# "vagrantfile_path": "/path/to/Vagrantfile",
# "state": "running",
# "updated_at": "2014-03-02 11:11:44 +0100"
# }
# }
# }
#
class MachineIndex
autoload :Remote, "vagrant/machine_index/remote"
include Enumerable
# Initializes a MachineIndex at the given file location.
#
# @param [Pathname] data_dir Path to the directory where data for the
# index can be stored. This folder should exist and must be writable.
def initialize(data_dir)
@data_dir = data_dir
@index_file = data_dir.join("index")
@lock = Monitor.new
@machines = {}
@machine_locks = {}
with_index_lock do
unlocked_reload
end
end
# Deletes a machine by UUID.
#
# The machine being deleted with this UUID must either be locked
# by this index or must be unlocked.
#
# @param [Entry] entry The entry to delete.
# @return [Boolean] true if delete is successful
def delete(entry)
return true if !entry.id
@lock.synchronize do
with_index_lock do
return true if !@machines[entry.id]
# If we don't have the lock, then we need to acquire it.
if !@machine_locks[entry.id]
raise "Unlocked delete on machine: #{entry.id}"
end
# Reload so we have the latest data, then delete and save
unlocked_reload
@machines.delete(entry.id)
unlocked_save
# Release access on this machine
unlocked_release(entry.id)
end
end
true
end
# Iterate over every machine in the index. The yielded {Entry} objects
# will NOT be locked, so you'll have to call {#get} manually to acquire
# the lock on them.
def each(reload=false)
if reload
@lock.synchronize do
with_index_lock do
unlocked_reload
end
end
end
@machines.each do |uuid, data|
yield Entry.new(uuid, data.merge("id" => uuid))
end
end
# Accesses a machine by UUID and returns a {MachineIndex::Entry}
#
# The entry returned is locked and can't be read again or updated by
# this process or any other. To unlock the machine, call {#release}
# with the entry.
#
# You can only {#set} an entry (update) when the lock is held.
#
# @param [String] uuid UUID for the machine to access.
# @return [MachineIndex::Entry]
def get(uuid)
entry = nil
@lock.synchronize do
with_index_lock do
# Reload the data
unlocked_reload
data = find_by_prefix(uuid)
return nil if !data
uuid = data["id"]
entry = Entry.new(uuid, data)
# Lock this machine
lock_file = lock_machine(uuid)
if !lock_file
raise Errors::MachineLocked,
name: entry.name,
provider: entry.provider
end
@machine_locks[uuid] = lock_file
end
end
entry
end
# Tests if the index has the given UUID.
#
# @param [String] uuid
# @return [Boolean]
def include?(uuid)
@lock.synchronize do
with_index_lock do
unlocked_reload
return !!find_by_prefix(uuid)
end
end
end
# Releases an entry, unlocking it.
#
# This is an idempotent operation. It is safe to call this even if you're
# unsure if an entry is locked or not.
#
# After calling this, the previous entry should no longer be used.
#
# @param [Entry] entry
def release(entry)
@lock.synchronize do
unlocked_release(entry.id)
end
end
# Creates/updates an entry object and returns the resulting entry.
#
# If the entry was new (no UUID), then the UUID will be set on the
# resulting entry and can be used. Additionally, the a lock will
# be created for the resulting entry, so you must {#release} it
# if you want others to be able to access it.
#
# If the entry isn't new (has a UUID). then this process must hold
# that entry's lock or else this set will fail.
#
# @param [Entry] entry
# @return [Entry]
def set(entry)
# Get the struct and update the updated_at attribute
struct = entry.to_json_struct
# Set an ID if there isn't one already set
id = entry.id
@lock.synchronize do
with_index_lock do
# Reload so we have the latest machine data. This allows other
# processes to update their own machines without conflicting
# with our own.
unlocked_reload
# If we don't have a machine ID, try to look one up
if !id
self.each do |other|
if entry.name == other.name &&
entry.provider == other.provider &&
entry.vagrantfile_path.to_s == other.vagrantfile_path.to_s
id = other.id
break
end
end
# If we still don't have an ID, generate a random one
id = SecureRandom.uuid.gsub("-", "") if !id
# Get a lock on this machine
lock_file = lock_machine(id)
if !lock_file
raise "Failed to lock new machine: #{entry.name}"
end
@machine_locks[id] = lock_file
end
if !@machine_locks[id]
raise "Unlocked write on machine: #{id}"
end
# Set our machine and save
@machines[id] = struct
unlocked_save
end
end
Entry.new(id, struct)
end
# Reinsert a machine into the global index if it has
# a valid existing uuid but does not currently exist
# in the index.
#
# @param [Entry] entry
# @return [Entry]
def recover(entry)
@lock.synchronize do
with_index_lock do
# Reload the data
unlocked_reload
# Don't recover if entry already exists in the global
return entry if find_by_prefix(entry.id)
lock_file = lock_machine(entry.id)
if !lock_file
raise Errors::MachineLocked,
name: entry.name,
provider: entry.provider
end
@machine_locks[entry.id] = lock_file
end
end
return set(entry)
end
protected
# Finds a machine where the UUID is prefixed by the given string.
#
# @return [Hash]
def find_by_prefix(prefix)
return if !prefix || prefix == ""
@machines.each do |uuid, data|
return data.merge("id" => uuid) if uuid.start_with?(prefix)
end
nil
end
# Locks a machine exclusively to us, returning the file handle
# that holds the lock.
#
# If the lock cannot be acquired, then nil is returned.
#
# This should be called within an index lock.
#
# @return [File]
def lock_machine(uuid)
lock_path = @data_dir.join("#{uuid}.lock")
lock_file = lock_path.open("w+")
if lock_file.flock(File::LOCK_EX | File::LOCK_NB) === false
lock_file.close
lock_file = nil
end
lock_file
end
# Releases a local lock on a machine. This does not acquire any locks
# so make sure to lock around it.
#
# @param [String] id
def unlocked_release(id)
lock_file = @machine_locks[id]
if lock_file
lock_file.close
begin
File.delete(lock_file.path)
rescue Errno::EACCES
# Another process is probably opened it, no problem.
end
@machine_locks.delete(id)
end
end
# This will reload the data without locking the index. It is assumed
# the caller with lock the index outside of this call.
#
# @param [File] f
def unlocked_reload
return if !@index_file.file?
data = nil
begin
data = JSON.load(@index_file.read)
rescue JSON::ParserError
raise Errors::CorruptMachineIndex, path: @index_file.to_s
end
if data
if !data["version"] || data["version"].to_i != 1
raise Errors::CorruptMachineIndex, path: @index_file.to_s
end
@machines = data["machines"] || {}
end
end
# Saves the index.
def unlocked_save
@index_file.open("w") do |f|
f.write(JSON.dump({
"version" => 1,
"machines" => @machines,
}))
end
end
# This will hold a lock to the index so it can be read or updated.
def with_index_lock
lock_path = "#{@index_file}.lock"
File.open(lock_path, "w+") do |f|
f.flock(File::LOCK_EX)
yield
end
end
# An entry in the MachineIndex.
class Entry
autoload :Remote, "vagrant/machine_index/remote"
# The unique ID for this entry. This is _not_ the ID for the
# machine itself (which is provider-specific and in the data directory).
#
# @return [String]
attr_reader :id
# The path for the "local data" directory for the environment.
#
# @return [Pathname]
attr_accessor :local_data_path
# The name of the machine.
#
# @return [String]
attr_accessor :name
# The name of the provider.
#
# @return [String]
attr_accessor :provider
# The name of the architecture.
#
# @return [String]
attr_accessor :architecture
# The last known state of this machine.
#
# @return [String]
attr_accessor :state
# The last known state of this machine.
#
# @return [MachineState]
attr_accessor :full_state
# The valid Vagrantfile filenames for this environment.
#
# @return [Array<String>]
attr_accessor :vagrantfile_name
# The path to the Vagrantfile that manages this machine.
#
# @return [Pathname]
attr_accessor :vagrantfile_path
# The last time this entry was updated.
#
# @return [DateTime]
attr_reader :updated_at
# Extra data to store with the index entry. This can be anything
# and is treated like a general global state bag.
#
# @return [Hash]
attr_accessor :extra_data
# Initializes an entry.
#
# The parameter given should be nil if this is being created
# publicly.
def initialize(id=nil, raw=nil)
@logger = Log4r::Logger.new("vagrant::machine_index::entry")
@extra_data = {}
@id = id
# Do nothing if we aren't given a raw value. Otherwise, parse it.
return if !raw
@local_data_path = raw["local_data_path"]
@name = raw["name"]
@provider = raw["provider"]
@architecture = raw["architecture"]
@state = raw["state"]
@full_state = raw["full_state"]
@vagrantfile_name = raw["vagrantfile_name"]
@vagrantfile_path = raw["vagrantfile_path"]
# TODO(mitchellh): parse into a proper datetime
@updated_at = raw["updated_at"]
@extra_data = raw["extra_data"] || {}
# Be careful with the paths
@local_data_path = nil if @local_data_path == ""
@vagrantfile_path = nil if @vagrantfile_path == ""
# Convert to proper types
@local_data_path = Pathname.new(@local_data_path) if @local_data_path
@vagrantfile_path = Pathname.new(@vagrantfile_path) if @vagrantfile_path
end
# Returns boolean true if this entry appears to be valid.
# The criteria for being valid:
#
# * Vagrantfile directory exists
# * Vagrant environment contains a machine with this
# name and provider.
#
# This method is _slow_. It should be used with care.
#
# @param [Pathname] home_path The home path for the Vagrant
# environment.
# @return [Boolean]
def valid?(home_path)
return false if !vagrantfile_path
return false if !vagrantfile_path.directory?
# Create an environment so we can determine the active
# machines...
found = false
env = vagrant_env(home_path)
env.active_machines.each do |name, provider|
if name.to_s == self.name.to_s &&
provider.to_s == self.provider.to_s
found = true
break
end
end
# If an active machine of the same name/provider was not
# found, it is already false.
return false if !found
# Get the machine
machine = nil
begin
machine = env.machine(self.name.to_sym, self.provider.to_sym)
rescue Errors::MachineNotFound
return false
end
# Refresh the machine state
return false if machine.state.id == MachineState::NOT_CREATED_ID
true
end
# Creates a {Vagrant::Environment} for this entry.
#
# @return [Vagrant::Environment]
def vagrant_env(home_path, opts={})
Vagrant::Util::SilenceWarnings.silence! do
Environment.new({
cwd: @vagrantfile_path,
home_path: home_path,
local_data_path: @local_data_path,
vagrantfile_name: @vagrantfile_name,
}.merge(opts))
end
end
# Converts to the structure used by the JSON
def to_json_struct
{
"local_data_path" => @local_data_path.to_s,
"name" => @name,
"provider" => @provider,
"architecture" => @architecture,
"state" => @state,
"vagrantfile_name" => @vagrantfile_name,
"vagrantfile_path" => @vagrantfile_path.to_s,
"updated_at" => @updated_at,
"extra_data" => @extra_data,
}
end
end
end
end