diff --git a/lib/vagrant-parallels/driver/base.rb b/lib/vagrant-parallels/driver/base.rb index e99e77d..3f6ac2c 100644 --- a/lib/vagrant-parallels/driver/base.rb +++ b/lib/vagrant-parallels/driver/base.rb @@ -78,7 +78,26 @@ def clear_shared_folders # @return [String] UUID of the new VM. def clone_vm(src_name, options = {}) dst_name = "vagrant_temp_#{(Time.now.to_f * 1000.0).to_i}_#{rand(100000)}" + src_vm = json { execute_prlctl('list', '--json', '-i', src_name) }.first + if options[:linked] || !Util::Common.is_apfs?(src_vm.fetch('Home')) + # If linked clone is an option, or path to src is not on APFS, then do the normal clone. + prlctl_clone_vm(src_name, dst_name, options) + else + # We can use clonefile on APFS to do a fast CoW clone of the VM source and then register + copy_clone_vm(src_name, dst_name, options) + end + read_vms[dst_name] + end + + # Uses prlctl to clone an existing registered VM + # + # @param [String] src_name Name or UUID of the source VM or template. + # @param [String] dst_name Name of the destination VM. + # @param [ String>] options Options to clone virtual machine. + def prlctl_clone_vm(src_name, dst_name, options = {}) + list_args = ['list', '--json', '-i', src_name] + src_vm = json { execute_prlctl(*list_args) }.first args = ['clone', src_name, '--name', dst_name] args.concat(['--dst', options[:dst]]) if options[:dst] @@ -97,7 +116,41 @@ def clone_vm(src_name, options = {}) yield $1.to_i if block_given? end end - read_vms[dst_name] + end + + # Uses cp with clonefile flag to clone an existing registered VM + # + # @param [String] src_name Name or UUID of the source VM or template. + # @param [String] dst_name Name of the destination VM. + # @param [ String>] options Options to clone virtual machine. + def copy_clone_vm(src_name, dst_name, options = {}) + list_args = ['list', '--json', '-i', src_name] + src_vm = json { execute_prlctl(*list_args) }.first + basepath = File.dirname(src_vm.fetch('Home')).delete_suffix('/') + extension = File.basename(src_vm.fetch('Home')).delete_suffix('/').split('.').last + clonepath = File.join(ENV['HOME'], "Parallels", "#{dst_name}.#{extension}") + execute('cp', '-c', '-R', '-p', src_vm.fetch('Home'), clonepath) + + # Update config.pvs with dst_name as this is what Parallels uses when registering + update_vm_name(File.join(clonepath, 'config.pvs'), dst_name) + + # Register the cloned path as a new VM + args = ['register', clonepath] + # Regenerate SourceVmUuid of the cloned VM + args << '--regenerate-src-uuid' if options[:regenerate_src_uuid] + + # Regenerate SourceVmUuid of the cloned VM + execute_prlctl(*args) + + # Don't need the box hanging around in Parallels + execute_prlctl('unregister', src_name) + end + + def update_vm_name(config_pvs_path, name) + xml = Nokogiri::XML(File.read(config_pvs_path)) + elem = xml.at_xpath('//ParallelsVirtualMachine/Identification/VmName') + elem.content = name + File.write(config_pvs_path, xml.to_xml) end # Compacts the specified virtual disk image diff --git a/lib/vagrant-parallels/util/common.rb b/lib/vagrant-parallels/util/common.rb index 5f3e8a0..0eb141f 100644 --- a/lib/vagrant-parallels/util/common.rb +++ b/lib/vagrant-parallels/util/common.rb @@ -1,3 +1,5 @@ +require 'shellwords' + module VagrantPlugins module Parallels module Util @@ -9,6 +11,23 @@ def self.is_macvm(machine) return !machine.box.nil? && !!Dir.glob(machine.box.directory.join('*.macvm')).first end + # Determines if the box directory is on an APFS filesystem + def self.is_apfs?(path, &block) + output = {stdout: '', stderr: ''} + df_command = %w[df -T apfs] + df_command << Shellwords.escape(path) + execute(*df_command, &block).exit_code == 0 + end + + private + + def self.execute(*command, &block) + command << { notify: [:stdout, :stderr] } + + Vagrant::Util::Busy.busy(lambda {}) do + Vagrant::Util::Subprocess.execute(*command, &block) + end + end end end end diff --git a/test/unit/support/shared/pd_driver_examples.rb b/test/unit/support/shared/pd_driver_examples.rb index ad3a316..d673863 100644 --- a/test/unit/support/shared/pd_driver_examples.rb +++ b/test/unit/support/shared/pd_driver_examples.rb @@ -112,7 +112,24 @@ end describe 'clone_vm' do - it 'clones VM to the new one' do + before do + expect(subprocess).to receive(:execute).twice. + with('prlctl', 'list', '--json', '-i', an_instance_of(String), + an_instance_of(Hash)). + and_return(subprocess_result(stdout: '[{"Home": "/home/some/path"}]', exit_code: 0)) + expect(subprocess).to receive(:execute). + with('prlctl', 'list', '--all', '--no-header', '--json', '-o', 'name,uuid', {:notify=>[:stdout, :stderr]}). + and_return(subprocess_result(stdout: '[]', exit_code: 0)) + expect(subprocess).to receive(:execute). + with('prlctl', 'list', '--all', '--no-header', '--json', '-o', 'name,uuid', '--template', {:notify=>[:stdout, :stderr]}). + and_return(subprocess_result(stdout: '[]', exit_code: 0)) + end + + it 'clones VM to the new one when not on APFS' do + expect(subprocess).to receive(:execute). + with('df', '-T', 'apfs', '/home/some/path', + an_instance_of(Hash)). + and_return(subprocess_result(exit_code: 1)) expect(subprocess).to receive(:execute). with('prlctl', 'clone', tpl_uuid, '--name', an_instance_of(String), an_instance_of(Hash)). @@ -120,7 +137,31 @@ subject.clone_vm(tpl_uuid) end + it 'uses cp to clone VM to the new one when on APFS' do + expect(subprocess).to receive(:execute). + with('df', '-T', 'apfs', '/home/some/path', + an_instance_of(Hash)). + and_return(subprocess_result(exit_code: 0)) + expect(subprocess).to receive(:execute). + with('cp', '-c', '-R', '-p', '/home/some/path', an_instance_of(String), + an_instance_of(Hash)). + and_return(subprocess_result(exit_code: 0)) + expect(subprocess).to receive(:execute). + with('prlctl', 'register', an_instance_of(String), + an_instance_of(Hash)). + and_return(subprocess_result(exit_code: 0)) + expect(subprocess).to receive(:execute). + with('prlctl', 'unregister', tpl_uuid, an_instance_of(Hash)). + and_return(subprocess_result(exit_code: 0)) + expect(driver).to receive(:update_vm_name).and_return(true) + subject.clone_vm(tpl_uuid) + end + it 'clones VM to the exported VM' do + expect(subprocess).to receive(:execute). + with('df', '-T', 'apfs', '/home/some/path', + an_instance_of(Hash)). + and_return(subprocess_result(exit_code: 1)) expect(subprocess).to receive(:execute). with('prlctl', 'clone', uuid, '--name', an_instance_of(String), '--dst', an_instance_of(String), an_instance_of(Hash)).