diff --git a/plugins/providers/hyperv/cap/cleanup_disks.rb b/plugins/providers/hyperv/cap/cleanup_disks.rb new file mode 100644 index 000000000..00255f2ba --- /dev/null +++ b/plugins/providers/hyperv/cap/cleanup_disks.rb @@ -0,0 +1,54 @@ +require "log4r" +require "vagrant/util/experimental" + +module VagrantPlugins + module Hyperv + module Cap + module CleanupDisks + LOGGER = Log4r::Logger.new("vagrant::plugins::hyperv::cleanup_disks") + + # @param [Vagrant::Machine] machine + # @param [VagrantPlugins::Kernel_V2::VagrantConfigDisk] defined_disks + # @param [Hash] disk_meta_file - A hash of all the previously defined disks from the last configure_disk action + def self.cleanup_disks(machine, defined_disks, disk_meta_file) + return if disk_meta_file.values.flatten.empty? + + return if !Vagrant::Util::Experimental.feature_enabled?("disks") + + handle_cleanup_disk(machine, defined_disks, disk_meta_file["disk"]) + # TODO: Floppy and DVD disks + end + + protected + + # @param [Vagrant::Machine] machine + # @param [VagrantPlugins::Kernel_V2::VagrantConfigDisk] defined_disks + # @param [Hash] disk_meta - A hash of all the previously defined disks from the last configure_disk action + def self.handle_cleanup_disk(machine, defined_disks, disk_meta) + vm_info = machine.provider.driver.show_vm_info + primary_disk = vm_info["SATA Controller-ImageUUID-0-0"] + + disk_meta.each do |d| + dsk = defined_disks.select { |dk| dk.name == d["name"] } + if !dsk.empty? || d["uuid"] == primary_disk + next + else + LOGGER.warn("Found disk not in Vagrantfile config: '#{d["name"]}'. Removing disk from guest #{machine.name}") + disk_info = machine.provider.driver.get_port_and_device(d["uuid"]) + + machine.ui.warn("Disk '#{d["name"]}' no longer exists in Vagrant config. Removing and closing medium from guest...", prefix: true) + + if disk_info.empty? + LOGGER.warn("Disk '#{d["name"]}' not attached to guest, but still exists.") + else + machine.provider.driver.remove_disk(disk_info[:port], disk_info[:device]) + end + + machine.provider.driver.close_medium(d["uuid"]) + end + end + end + end + end + end +end diff --git a/plugins/providers/hyperv/cap/configure_disks.rb b/plugins/providers/hyperv/cap/configure_disks.rb new file mode 100644 index 000000000..70fd1ab72 --- /dev/null +++ b/plugins/providers/hyperv/cap/configure_disks.rb @@ -0,0 +1,287 @@ +require "log4r" +require "fileutils" +require "vagrant/util/numeric" +require "vagrant/util/experimental" + +module VagrantPlugins + module HyperV + module Cap + module ConfigureDisks + LOGGER = Log4r::Logger.new("vagrant::plugins::hyperv::configure_disks") + + # The max amount of disks that can be attached to a single device in a controller + MAX_DISK_NUMBER = 30.freeze + + # @param [Vagrant::Machine] machine + # @param [VagrantPlugins::Kernel_V2::VagrantConfigDisk] defined_disks + # @return [Hash] configured_disks - A hash of all the current configured disks + def self.configure_disks(machine, defined_disks) + return {} if defined_disks.empty? + + return {} if !Vagrant::Util::Experimental.feature_enabled?("disks") + + if defined_disks.size > MAX_DISK_NUMBER + # you can only attach up to 30 disks per controller, INCLUDING the primary disk + raise Vagrant::Errors::VirtualBoxDisksDefinedExceedLimit + end + + machine.ui.info(I18n.t("vagrant.cap.configure_disks.start")) + + current_disks = machine.provider.driver.list_hdds + + configured_disks = {disk: [], floppy: [], dvd: []} + + defined_disks.each do |disk| + if disk.type == :disk + disk_data = handle_configure_disk(machine, disk, current_disks) + configured_disks[:disk] << disk_data unless disk_data.empty? + elsif disk.type == :floppy + # TODO: Write me + machine.ui.info(I18n.t("vagrant.cap.configure_disks.floppy_not_supported", name: disk.name)) + elsif disk.type == :dvd + # TODO: Write me + machine.ui.info(I18n.t("vagrant.cap.configure_disks.dvd_not_supported", name: disk.name)) + end + end + + configured_disks + end + + protected + + # @param [Vagrant::Machine] machine - the current machine + # @param [Config::Disk] disk - the current disk to configure + # @param [Array] all_disks - A list of all currently defined disks in VirtualBox + # @return [Hash] current_disk - Returns the current disk. Returns nil if it doesn't exist + def self.get_current_disk(machine, disk, all_disks) + current_disk = nil + if disk.primary + # Ensure we grab the proper primary disk + # We can't rely on the order of `all_disks`, as they will not + # always come in port order, but primary is always Port 0 Device 0. + vm_info = machine.provider.driver.show_vm_info + primary_uuid = vm_info["SATA Controller-ImageUUID-0-0"] + + current_disk = all_disks.select { |d| d["UUID"] == primary_uuid }.first + else + current_disk = all_disks.select { |d| d["Disk Name"] == disk.name}.first + end + + current_disk + end + + # Handles all disk configs of type `:disk` + # + # @param [Vagrant::Machine] machine - the current machine + # @param [Config::Disk] disk - the current disk to configure + # @param [Array] all_disks - A list of all currently defined disks in VirtualBox + # @return [Hash] - disk_metadata + def self.handle_configure_disk(machine, disk, all_disks) + disk_metadata = {} + + # Grab the existing configured disk, if it exists + current_disk = get_current_disk(machine, disk, all_disks) + + # Configure current disk + if !current_disk + # create new disk and attach + disk_metadata = create_disk(machine, disk) + elsif compare_disk_size(machine, disk, current_disk) + disk_metadata = resize_disk(machine, disk, current_disk) + else + # TODO: What if it needs to be resized? + + disk_info = machine.provider.driver.get_port_and_device(current_disk["UUID"]) + if disk_info.empty? + LOGGER.warn("Disk '#{disk.name}' is not connected to guest '#{machine.name}', Vagrant will attempt to connect disk to guest") + dsk_info = get_next_port(machine) + machine.provider.driver.attach_disk(dsk_info[:port], + dsk_info[:device], + current_disk["Location"]) + else + LOGGER.info("No further configuration required for disk '#{disk.name}'") + end + + disk_metadata = {uuid: current_disk["UUID"], name: disk.name} + end + + disk_metadata + end + + # Check to see if current disk is configured based on defined_disks + # + # @param [Kernel_V2::VagrantConfigDisk] disk_config + # @param [Hash] defined_disk + # @return [Boolean] + def self.compare_disk_size(machine, disk_config, defined_disk) + requested_disk_size = Vagrant::Util::Numeric.bytes_to_megabytes(disk_config.size) + defined_disk_size = defined_disk["Capacity"].split(" ").first.to_f + + if defined_disk_size > requested_disk_size + machine.ui.warn(I18n.t("vagrant.cap.configure_disks.shrink_size_not_supported", name: disk_config.name)) + return false + elsif defined_disk_size < requested_disk_size + return true + else + return false + end + end + + # Creates and attaches a disk to a machine + # + # @param [Vagrant::Machine] machine + # @param [Kernel_V2::VagrantConfigDisk] disk_config + def self.create_disk(machine, disk_config) + machine.ui.detail(I18n.t("vagrant.cap.configure_disks.create_disk", name: disk_config.name)) + # NOTE: At the moment, there are no provider specific configs for VirtualBox + # but we grab it anyway for future use. + disk_provider_config = disk_config.provider_config[:virtualbox] if disk_config.provider_config + + guest_info = machine.provider.driver.show_vm_info + guest_folder = File.dirname(guest_info["CfgFile"]) + + disk_ext = disk_config.disk_ext + disk_file = File.join(guest_folder, disk_config.name) + ".#{disk_ext}" + + LOGGER.info("Attempting to create a new disk file '#{disk_file}' of size '#{disk_config.size}' bytes") + + disk_var = machine.provider.driver.create_disk(disk_file, disk_config.size, disk_ext.upcase) + disk_metadata = {uuid: disk_var.split(':').last.strip, name: disk_config.name} + + dsk_controller_info = get_next_port(machine) + machine.provider.driver.attach_disk(dsk_controller_info[:port], dsk_controller_info[:device], disk_file) + + disk_metadata + end + + # Finds the next available port + # + # SATA Controller-ImageUUID-0-0 (sub out ImageUUID) + # - Controller: SATA Controller + # - Port: 0 + # - Device: 0 + # + # Note: Virtualbox returns the string above with the port and device info + # disk_info = key.split("-") + # port = disk_info[2] + # device = disk_info[3] + # + # @param [Vagrant::Machine] machine + # @return [Hash] dsk_info - The next available port and device on a given controller + def self.get_next_port(machine) + vm_info = machine.provider.driver.show_vm_info + dsk_info = {device: "0", port: "0"} + + disk_images = vm_info.select { |v| v.include?("ImageUUID") && v.include?("SATA Controller") } + used_ports = disk_images.keys.map { |k| k.split('-') }.map {|v| v[2].to_i} + next_available_port = ((0..(MAX_DISK_NUMBER-1)).to_a - used_ports).first + + dsk_info[:port] = next_available_port.to_s + if dsk_info[:port].empty? + # This likely only occurs if additional disks have been added outside of Vagrant configuration + LOGGER.warn("There are no more available ports to attach disks to for the SATA Controller. Clear up some space on the SATA controller to attach new disks.") + raise Vagrant::Errors::VirtualBoxDisksDefinedExceedLimit + end + + dsk_info + end + + # @param [Vagrant::Machine] machine + # @param [Config::Disk] disk_config - the current disk to configure + # @param [Hash] defined_disk - current disk as represented by VirtualBox + # @return [Hash] - disk_metadata + def self.resize_disk(machine, disk_config, defined_disk) + machine.ui.detail(I18n.t("vagrant.cap.configure_disks.resize_disk", name: disk_config.name), prefix: true) + + if defined_disk["Storage format"] == "VMDK" + LOGGER.warn("Disk type VMDK cannot be resized in VirtualBox. Vagrant will convert disk to VDI format to resize first, and then convert resized disk back to VMDK format") + + # grab disk to be resized port and device number + disk_info = machine.provider.driver.get_port_and_device(defined_disk["UUID"]) + # original disk information in case anything goes wrong during clone/resize + original_disk = defined_disk + backup_disk_location = "#{original_disk["Location"]}.backup" + + # clone disk to vdi formatted disk + vdi_disk_file = machine.provider.driver.vmdk_to_vdi(defined_disk["Location"]) + # resize vdi + machine.provider.driver.resize_disk(vdi_disk_file, disk_config.size.to_i) + + begin + # Danger Zone + + # remove and close original volume + machine.provider.driver.remove_disk(disk_info[:port], disk_info[:device]) + # Create a backup of the original disk if something goes wrong + LOGGER.warn("Making a backup of the original disk at #{defined_disk["Location"]}") + FileUtils.mv(defined_disk["Location"], backup_disk_location) + + # we have to close here, otherwise we can't re-clone after + # resizing the vdi disk + machine.provider.driver.close_medium(defined_disk["UUID"]) + + # clone back to original vmdk format and attach resized disk + vmdk_disk_file = machine.provider.driver.vdi_to_vmdk(vdi_disk_file) + machine.provider.driver.attach_disk(disk_info[:port], disk_info[:device], vmdk_disk_file, "hdd") + rescue ScriptError, SignalException, StandardError + LOGGER.warn("Vagrant encountered an error while trying to resize a disk. Vagrant will now attempt to reattach and preserve the original disk...") + machine.ui.error(I18n.t("vagrant.cap.configure_disks.recovery_from_resize", + location: original_disk["Location"], + name: machine.name)) + recover_from_resize(machine, disk_info, backup_disk_location, original_disk, vdi_disk_file) + + raise + ensure + # Remove backup disk file if all goes well + FileUtils.remove(backup_disk_location, force: true) + end + + # Remove cloned resized volume format + machine.provider.driver.close_medium(vdi_disk_file) + + # Get new updated disk UUID for vagrant disk_meta file + new_disk_info = machine.provider.driver.list_hdds.select { |h| h["Location"] == defined_disk["Location"] }.first + defined_disk = new_disk_info + else + machine.provider.driver.resize_disk(defined_disk["Location"], disk_config.size.to_i) + end + + disk_metadata = {uuid: defined_disk["UUID"], name: disk_config.name} + + disk_metadata + end + + # Recovery method for when an exception occurs during the process of resizing disks + # + # It attempts to move back the backup disk into place, and reattach it to the guest before + # raising the original error + # + # @param [Vagrant::Machine] machine + # @param [Hash] disk_info - The disk device and port number to attach back to + # @param [String] backup_disk_location - The place on disk where vagrant made a backup of the original disk being resized + # @param [Hash] original_disk - The disk information from VirtualBox + # @param [String] vdi_disk_file - The place on disk where vagrant made a clone of the original disk being resized + def self.recover_from_resize(machine, disk_info, backup_disk_location, original_disk, vdi_disk_file) + begin + # move backup to original name + FileUtils.mv(backup_disk_location, original_disk["Location"], force: true) + # Attach disk + machine.provider.driver. + attach_disk(disk_info[:port], disk_info[:device], original_disk["Location"], "hdd") + + # Remove cloned disk if still hanging around + if vdi_disk_file + machine.provider.driver.close_medium(vdi_disk_file) + end + + # We recovered! + machine.ui.warn(I18n.t("vagrant.cap.configure_disks.recovery_attached_disks")) + rescue => e + LOGGER.error("Vagrant encountered an error while trying to recover. It will now show the original error and continue...") + LOGGER.error(e) + end + end + end + end + end +end diff --git a/plugins/providers/hyperv/cap/validate_disk_ext.rb b/plugins/providers/hyperv/cap/validate_disk_ext.rb new file mode 100644 index 000000000..248d7b6a6 --- /dev/null +++ b/plugins/providers/hyperv/cap/validate_disk_ext.rb @@ -0,0 +1,27 @@ +require "log4r" + +module VagrantPlugins + module HyperV + module Cap + module ValidateDiskExt + LOGGER = Log4r::Logger.new("vagrant::plugins::hyperv::validate_disk_ext") + + # The default set of disk formats that VirtualBox supports + DEFAULT_DISK_EXT = ["vdi", "vmdk", "vhd"].map(&:freeze).freeze + + # @param [Vagrant::Machine] machine + # @param [String] disk_ext + # @return [Bool] + def self.validate_disk_ext(machine, disk_ext) + DEFAULT_DISK_EXT.include?(disk_ext) + end + + # @param [Vagrant::Machine] machine + # @return [Array] + def self.get_default_disk_ext(machine) + DEFAULT_DISK_EXT + end + end + end + end +end diff --git a/test/unit/plugins/providers/hyperv/cap/cleanup_disks_test.rb b/test/unit/plugins/providers/hyperv/cap/cleanup_disks_test.rb new file mode 100644 index 000000000..18ec85d7c --- /dev/null +++ b/test/unit/plugins/providers/hyperv/cap/cleanup_disks_test.rb @@ -0,0 +1,42 @@ +require_relative "../base" + +require Vagrant.source_root.join("plugins/providers/hyperv/cap/cleanup_disks") + +describe VagrantPlugins::HyperV::Cap::CleanupDisks do + include_context "unit" + + let(:iso_env) do + # We have to create a Vagrantfile so there is a root path + env = isolated_environment + env.vagrantfile("") + env.create_vagrant_env + end + + let(:driver) { double("driver") } + + let(:machine) do + iso_env.machine(iso_env.machine_names[0], :dummy).tap do |m| + allow(m.provider).to receive(:driver).and_return(driver) + allow(m).to receive(:state).and_return(state) + end + end + + let(:state) do + double(:state) + end + + let(:subject) { described_class } + + let(:disk_meta_file) { {disk: [], floppy: [], dvd: []} } + let(:defined_disks) { {} } + + before do + allow(Vagrant::Util::Experimental).to receive(:feature_enabled?).and_return(true) + end + + context "#cleanup_disks" do + end + + context "#handle_cleanup_disk" do + end +end diff --git a/test/unit/plugins/providers/hyperv/cap/configure_disks_test.rb b/test/unit/plugins/providers/hyperv/cap/configure_disks_test.rb new file mode 100644 index 000000000..1c076d897 --- /dev/null +++ b/test/unit/plugins/providers/hyperv/cap/configure_disks_test.rb @@ -0,0 +1,41 @@ +require_relative "../base" + +require Vagrant.source_root.join("plugins/providers/hyperv/cap/configure_disks") + +describe VagrantPlugins::HyperV::Cap::ConfigureDisks do + include_context "unit" + + let(:iso_env) do + # We have to create a Vagrantfile so there is a root path + env = isolated_environment + env.vagrantfile("") + env.create_vagrant_env + end + + let(:driver) { double("driver") } + + let(:machine) do + iso_env.machine(iso_env.machine_names[0], :dummy).tap do |m| + allow(m.provider).to receive(:driver).and_return(driver) + allow(m).to receive(:state).and_return(state) + end + end + + let(:state) do + double(:state) + end + + let(:defined_disks) { [double("disk", name: "vagrant_primary", size: "5GB", primary: true, type: :disk), + double("disk", name: "disk-0", size: "5GB", primary: false, type: :disk), + double("disk", name: "disk-1", size: "5GB", primary: false, type: :disk), + double("disk", name: "disk-2", size: "5GB", primary: false, type: :disk)] } + + let(:subject) { described_class } + + before do + allow(Vagrant::Util::Experimental).to receive(:feature_enabled?).and_return(true) + end + + context "#configure_disks" do + end +end