diff --git a/lib/vagrant/errors.rb b/lib/vagrant/errors.rb index 8a66f436b..77296d8a9 100644 --- a/lib/vagrant/errors.rb +++ b/lib/vagrant/errors.rb @@ -316,6 +316,10 @@ module Vagrant error_key(:copy_private_key_failed) end + class CorruptMachineIndex < VagrantError + error_key(:corrupt_machine_index) + end + class DarwinNFSMountFailed < VagrantError error_key(:darwin_nfs_mount_failed) end diff --git a/lib/vagrant/machine_index.rb b/lib/vagrant/machine_index.rb new file mode 100644 index 000000000..636318484 --- /dev/null +++ b/lib/vagrant/machine_index.rb @@ -0,0 +1,169 @@ +require "json" +require "pathname" +require "securerandom" + +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 + # Initializes a MachineIndex at the given file location. + # + # @param [Pathname] data_file Path to the file that should be used + # to maintain the machine index. This file doesn't have to exist + # but this location must be writable. + def initialize(data_file) + @data_file = data_file + @machines = {} + + if @data_file.file? + data = nil + begin + data = JSON.load(@data_file.read) + rescue JSON::ParserError + raise Errors::CorruptMachineIndex, path: data_file.to_s + end + + if data + if !data["version"] || data["version"].to_i != 1 + raise Errors::CorruptMachineIndex, path: data_file.to_s + end + + @machines = data["machines"] || {} + end + end + end + + # Accesses a machine by UUID and returns a {MachineIndex::Entry} + # + # @param [String] uuid UUID for the machine to access. + # @return [MachineIndex::Entry] + def [](uuid) + return nil if !@machines[uuid] + Entry.new(uuid, @machines[uuid].merge("id" => uuid)) + 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. + # + # @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 + id ||= SecureRandom.uuid + + # Store the data + @machines[id] = struct + save + + Entry.new(id, struct) + end + + # Saves the index. + # + # This doesn't usually need to be called because {#set} will + # automatically save as well. + def save + @data_file.open("w") do |f| + f.write(JSON.dump({ + "version" => 1, + "machines" => @machines, + })) + 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 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 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 + + # Initializes an entry. + # + # The parameter given should be nil if this is being created + # publicly. + def initialize(id=nil, raw=nil) + # Do nothing if we aren't given a raw value. Otherwise, parse it. + return if !raw + + @id = id + @name = raw["name"] + @provider = raw["provider"] + @state = raw["state"] + @vagrantfile_path = Pathname.new(raw["vagrantfile_path"]) + # TODO(mitchellh): parse into a proper datetime + @updated_at = raw["updated_at"] + end + + # Converts to the structure used by the JSON + def to_json_struct + { + "name" => @name, + "provider" => @provider, + "state" => @state, + "vagrantfile_path" => @vagrantfile_path, + "updated_at" => @updated_at, + } + end + end + end +end diff --git a/templates/locales/en.yml b/templates/locales/en.yml index 8af58badf..b37e29214 100644 --- a/templates/locales/en.yml +++ b/templates/locales/en.yml @@ -598,6 +598,18 @@ en: Source: %{source} Destination: %{destination} + corrupt_machine_index: |- + The machine index which stores all required information about + running Vagrant environments has become corrupt. This is usually + caused by external tampering of the Vagrant data folder. + + Vagrant cannot manage any Vagrant environments if the index is + corrupt. Please attempt to manually correct it. If you are unable + to manually correct it, then remove the data file at the path below. + This will leave all existing Vagrant environments "orphaned" and + they'll have to be destroyed manually. + + Path: %{path} destroy_requires_force: |- Destroy doesn't have a TTY to ask for confirmation. Please pass the `--force` flag to force a destroy, otherwise attach a TTY so that diff --git a/test/unit/vagrant/machine_directory_test.rb b/test/unit/vagrant/machine_directory_test.rb new file mode 100644 index 000000000..f401985be --- /dev/null +++ b/test/unit/vagrant/machine_directory_test.rb @@ -0,0 +1,110 @@ +require "json" +require "pathname" +require "tempfile" + +require File.expand_path("../../base", __FILE__) + +require "vagrant/machine_index" + +describe Vagrant::MachineIndex do + include_context "unit" + + let(:data_file) { temporary_file } + + subject { described_class.new(data_file) } + + it "raises an exception if the data file is corrupt" do + data_file.open("w") do |f| + f.write(JSON.dump({})) + end + + expect { subject }. + to raise_error(Vagrant::Errors::CorruptMachineIndex) + end + + it "raises an exception if the JSON is invalid" do + data_file.open("w") do |f| + f.write("foo") + end + + expect { subject }. + to raise_error(Vagrant::Errors::CorruptMachineIndex) + end + + describe "#[]" do + before do + data = { + "version" => 1, + "machines" => { + "bar" => { + "name" => "default", + "provider" => "vmware", + "vagrantfile_path" => "/foo/bar/baz", + "state" => "running", + "updated_at" => "foo", + } + } + } + + data_file.open("w") do |f| + f.write(JSON.dump(data)) + end + end + + it "returns nil if the machine doesn't exist" do + expect(subject["foo"]).to be_nil + end + + it "returns a valid entry if the machine exists" do + result = subject["bar"] + + expect(result.id).to eq("bar") + expect(result.name).to eq("default") + expect(result.provider).to eq("vmware") + expect(result.vagrantfile_path).to eq(Pathname.new("/foo/bar/baz")) + expect(result.state).to eq("running") + expect(result.updated_at).to eq("foo") + end + end + + describe "#set and #[]" do + let(:entry_klass) { Vagrant::MachineIndex::Entry } + + it "adds a new entry" do + entry = entry_klass.new + entry.name = "foo" + entry.vagrantfile_path = "/bar" + + result = subject.set(entry) + expect(result.id).to_not be_empty + + # Get it froma new class and check the results + subject = described_class.new(data_file) + entry = subject[result.id] + expect(entry).to_not be_nil + expect(entry.name).to eq("foo") + + # TODO: test that updated_at is set + end + + it "updates an existing entry" do + entry = entry_klass.new + entry.name = "foo" + entry.vagrantfile_path = "/bar" + + result = subject.set(entry) + expect(result.id).to_not be_empty + + result.name = "bar" + + nextresult = subject.set(result) + expect(nextresult.id).to eq(result.id) + + # Get it froma new class and check the results + subject = described_class.new(data_file) + entry = subject[result.id] + expect(entry).to_not be_nil + expect(entry.name).to eq("bar") + end + end +end