482 lines
13 KiB
Ruby
482 lines
13 KiB
Ruby
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",
|
|
# "data_path": "/path/to/data/dir",
|
|
# "vagrantfile_path": "/path/to/Vagrantfile",
|
|
# "state": "running",
|
|
# "updated_at": "2014-03-02 11:11:44 +0100"
|
|
# }
|
|
# }
|
|
# }
|
|
#
|
|
class MachineIndex
|
|
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
|
|
|
|
protected
|
|
|
|
# Finds a machine where the UUID is prefixed by the given string.
|
|
#
|
|
# @return [Hash]
|
|
def find_by_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
|
|
# 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 last known state of this machine.
|
|
#
|
|
# @return [String]
|
|
attr_accessor :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)
|
|
@extra_data = {}
|
|
|
|
# Do nothing if we aren't given a raw value. Otherwise, parse it.
|
|
return if !raw
|
|
|
|
@id = id
|
|
@local_data_path = raw["local_data_path"]
|
|
@name = raw["name"]
|
|
@provider = raw["provider"]
|
|
@state = raw["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,
|
|
"state" => @state,
|
|
"vagrantfile_name" => @vagrantfile_name,
|
|
"vagrantfile_path" => @vagrantfile_path.to_s,
|
|
"updated_at" => @updated_at,
|
|
"extra_data" => @extra_data,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
end
|