vagrant/lib/vagrant/machine_index.rb

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