class Support::CloneVm

Attributes

guest_auth[R]
ip[R]
options[R]
src_vm[R]
ssl_verify[R]
username[R]
vem[R]
vim[R]
vm[R]
vm_name[R]

Public Class Methods

new(conn_opts, options) click to toggle source
# File lib/support/clone_vm.rb, line 15
def initialize(conn_opts, options)
  @options = options
  @vm_name = options[:vm_name]
  @ssl_verify = !conn_opts[:insecure]

  # Connect to vSphere
  @vim ||= RbVmomi::VIM.connect conn_opts
  @vem ||= vim.serviceContent.eventManager

  @username = options[:vm_username]
  password = options[:vm_password]
  @guest_auth = RbVmomi::VIM::NamePasswordAuthentication(interactiveSession: false, username: username, password: password)

  @benchmark_data = {}
end

Public Instance Methods

active_discovery?() click to toggle source
# File lib/support/clone_vm.rb, line 31
def active_discovery?
  options[:active_discovery] == true
end
active_ip_discovery(prefix_commands = []) click to toggle source
# File lib/support/clone_vm.rb, line 275
def active_ip_discovery(prefix_commands = [])
  # Instant clone needs this to have synchronous reply on the new IP
  return unless active_discovery? || instant_clone?

  Kitchen.logger.info "Attempting active IP discovery"
  begin
    tools = Support::GuestOperations.new(vim, vm, guest_auth, ssl_verify)

    commands = []
    commands << rescan_commands if instant_clone?
    # commands << trigger_tools # deactivated for now, as benefit is doubtful
    commands << discovery_commands
    script = commands.flatten.join(command_separator)

    stdout = tools.run_shell_capture_output(script, :auto, 20)

    # Windows returns wrongly encoded UTF-8 for some reason
    stdout = stdout.bytes.map { |b| (32..126).cover?(b.ord) ? b.chr : nil }.join unless stdout.ascii_only?
    @ip = stdout.match(/([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})/m)&.captures&.first

    Kitchen.logger.debug format("Script output: %s", stdout)
    raise Support::CloneError.new(format("Could not find IP in script output, fallback to standard discovery")) if ip.nil?
    raise Support::CloneError.new(format("Error getting accessible IP address, got %s. Check DHCP server, scope exhaustion or timing issues", ip)) if ip =~ /^169\.254\./
  rescue RbVmomi::Fault => e
    if e.fault.class.wsdl_name == "InvalidGuestLogin"
      message = format('Error authenticating to guest OS as "%s", check configuration of "vm_username"/"vm_password"', username)
    else
      message = e.message
    end

    raise Support::CloneError.new(message)
  rescue ::StandardError => e
    Kitchen.logger.info format("Active discovery failed: %s", e.message)
    return false
  end

  true
end
benchmark?() click to toggle source
# File lib/support/clone_vm.rb, line 103
def benchmark?
  options[:benchmark] == true
end
benchmark_checkpoint(title) click to toggle source
# File lib/support/clone_vm.rb, line 123
def benchmark_checkpoint(title)
  timestamp = Time.new
  checkpoints = @benchmark_data[:checkpoints]

  total = timestamp - checkpoints.first.fetch(:value)
  Kitchen.logger.debug format(
    'Benchmark: Step "%s" at %d (%.1f since start)',
    title, timestamp, total.to_f
  )

  @benchmark_data[:checkpoints] << {
    title: title.to_sym,
    value: total,
  }
end
benchmark_file() click to toggle source
# File lib/support/clone_vm.rb, line 107
def benchmark_file
  options[:benchmark_file]
end
benchmark_persist() click to toggle source
# File lib/support/clone_vm.rb, line 139
def benchmark_persist
  # Add total time spent as well
  checkpoints = @benchmark_data[:checkpoints]
  checkpoints << {
    title: :total,
    value: Time.new - checkpoints.first.fetch(:value),
  }

  # Include CSV headers
  unless File.exist?(benchmark_file)
    header = "template, clonetype, active_discovery, "
    header += checkpoints.map { |entry| entry[:title] }.join(", ") + "\n"
    File.write(benchmark_file, header)
  end

  active_discovery = options[:active_discovery] || instant_clone?
  data = [@benchmark_data[:template], @benchmark_data[:clonetype], active_discovery.to_s]
  data << checkpoints.map { |entry| format("%.1f", entry[:value]) }

  file = File.new(benchmark_file, "a")
  file.puts(data.join(", ") + "\n")

  Kitchen.logger.debug format("Benchmark: Appended data to file %s", benchmark_file)
end
benchmark_start() click to toggle source
# File lib/support/clone_vm.rb, line 111
def benchmark_start
  Kitchen.logger.debug("Starting benchmark data collection.")

  @benchmark_data = {
    template: options[:template],
    clonetype: options[:clone_type],
    checkpoints: [
      { title: "timestamp", value: Time.new.to_f },
    ],
  }
end
check_add_disk_config(disk_config) click to toggle source
# File lib/support/clone_vm.rb, line 314
def check_add_disk_config(disk_config)
  valid_types = %w{thin flat flat_lazy flat_eager}

  unless valid_types.include? disk_config[:type].to_s
    message = format("Unknown disk type in add_disks: %s. Allowed: %s",
      disk_config[:type].to_s,
      valid_types.join(", "))

    raise Support::CloneError.new(message)
  end
end
clone() click to toggle source
# File lib/support/clone_vm.rb, line 458
def clone
  benchmark_start if benchmark?

  # set the datacenter name
  dc = find_datacenter

  # reference template using full inventory path
  inventory_path = format("/%s/vm/%s", datacenter, options[:template])
  @src_vm = root_folder.findByInventoryPath(inventory_path)
  raise Support::CloneError.new(format("Unable to find template: %s", options[:template])) if src_vm.nil?

  if src_vm.config.template && !full_clone?
    Kitchen.logger.warn "Source is a template, thus falling back to full clone. Reference a VM for linked/instant clones."
    options[:clone_type] = :full
  end

  if src_vm.snapshot.nil? && !full_clone?
    Kitchen.logger.warn "Source VM has no snapshot available, thus falling back to full clone. Create a snapshot for linked/instant clones."
    options[:clone_type] = :full
  end

  # Autodetect OS, if none given
  if options[:vm_os].nil?
    os = detect_os(src_vm)
    Kitchen.logger.debug format('OS for VM not configured, got "%s" from VMware', os.to_s.capitalize)
    options[:vm_os] = os
  end

  # Specify where the machine is going to be created
  relocate_spec = RbVmomi::VIM.VirtualMachineRelocateSpec

  # Setting the host is not allowed for instant clone due to VM memory sharing
  relocate_spec.host = options[:targethost].host unless instant_clone?

  # Change to delta disks for linked clones
  relocate_spec.diskMoveType = :moveChildMostDiskBacking if linked_clone?

  # Set the resource pool
  relocate_spec.pool = options[:resource_pool]

  # Change network, if wanted
  unless options[:network_name].nil?
    networks = dc.network.select { |n| n.name == options[:network_name] }
    raise Support::CloneError.new(format("Could not find network named %s", options[:network_name])) if networks.empty?

    Kitchen.logger.warn format("Found %d networks named %s, picking first one", networks.count, options[:network_name]) if networks.count > 1
    network_obj = networks.first
    network_device = network_device(src_vm)

    if network_obj.is_a? RbVmomi::VIM::DistributedVirtualPortgroup
      Kitchen.logger.info format("Assigning network %s...", network_obj.pretty_path)

      vds_obj = network_obj.config.distributedVirtualSwitch
      Kitchen.logger.info format("Using vDS '%s' for network connectivity...", vds_obj.name)

      network_device.backing = RbVmomi::VIM.VirtualEthernetCardDistributedVirtualPortBackingInfo(
        port: RbVmomi::VIM.DistributedVirtualSwitchPortConnection(
          portgroupKey: network_obj.key,
          switchUuid: vds_obj.uuid
        )
      )
    elsif network_obj.is_a? RbVmomi::VIM::Network
      Kitchen.logger.info format("Assigning network %s...", options[:network_name])

      network_device.backing = RbVmomi::VIM.VirtualEthernetCardNetworkBackingInfo(
        deviceName: options[:network_name]
      )
    else
      raise Support::CloneError.new(format("Unknown network type %s for network name %s", network_obj.class.to_s, options[:network_name]))
    end

    relocate_spec.deviceChange = [
      RbVmomi::VIM.VirtualDeviceConfigSpec(
        operation: RbVmomi::VIM::VirtualDeviceConfigSpecOperation("edit"),
        device: network_device
      ),
    ]
  end

  # Set the folder to use
  dest_folder = options[:folder].nil? ? dc.vmFolder : options[:folder][:id]

  Kitchen.logger.info format("Cloning '%s' to create the VM...", options[:template])
  if instant_clone?
    vcenter_data = vim.serviceInstance.content.about
    raise Support::CloneError.new("Instant clones only supported with vCenter 6.7 or higher") unless vcenter_data.version.to_f >= 6.7

    Kitchen.logger.debug format("Detected %s", vcenter_data.fullName)

    resources = dc.hostFolder.children
    hosts = resources.select { |resource| resource.class.to_s =~ /ComputeResource$/ }.map(&:host).flatten
    targethost = hosts.select { |host| host.summary.config.name == options[:targethost].name }.first
    raise Support::CloneError.new("No matching ComputeResource found in host folder") if targethost.nil?

    esx_data = targethost.summary.config.product
    raise Support::CloneError.new("Instant clones only supported with ESX 6.7 or higher") unless esx_data.version.to_f >= 6.7

    Kitchen.logger.debug format("Detected %s", esx_data.fullName)

    # Other tools check for VMWare Tools status, but that will be toolsNotRunning on frozen VMs
    raise Support::CloneError.new("Need a running VM for instant clones") unless src_vm.runtime.powerState == "poweredOn"

    # In first iterations, only support the Frozen Source VM workflow. This is more efficient
    #   but needs preparations (freezing the source VM). Running Source VM support is to be
    #   added later
    raise Support::CloneError.new("Need a frozen VM for instant clones, running source VM not supported yet") unless src_vm.runtime.instantCloneFrozen

    # Swapping NICs not needed anymore (blog posts mention this), instant clones get a new
    # MAC at least with 6.7.0 build 9433931

    # Disconnect network device, so wo don't get IP collisions on start
    network_device = network_device(src_vm)
    network_device.connectable = RbVmomi::VIM.VirtualDeviceConnectInfo(
      allowGuestControl: true,
      startConnected: true,
      connected: false,
      migrateConnect: "disconnect"
    )
    relocate_spec.deviceChange = [
      RbVmomi::VIM.VirtualDeviceConfigSpec(
        operation: RbVmomi::VIM::VirtualDeviceConfigSpecOperation("edit"),
        device: network_device
      ),
    ]

    clone_spec = RbVmomi::VIM.VirtualMachineInstantCloneSpec(location: relocate_spec,
                                                             name: vm_name)

    benchmark_checkpoint("initialized") if benchmark?
    task = src_vm.InstantClone_Task(spec: clone_spec)
  else
    clone_spec = RbVmomi::VIM.VirtualMachineCloneSpec(
      location: relocate_spec,
      powerOn: options[:poweron] && options[:vm_customization].nil?,
      template: false
    )

    clone_spec.customization = guest_customization_spec if options[:guest_customization]

    benchmark_checkpoint("initialized") if benchmark?
    task = src_vm.CloneVM_Task(spec: clone_spec, folder: dest_folder, name: vm_name)
  end
  task.wait_for_completion

  benchmark_checkpoint("cloned") if benchmark?

  # get the IP address of the machine for bootstrapping
  # machine name is based on the path, e.g. that includes the folder
  path = options[:folder].nil? ? vm_name : format("%s/%s", options[:folder][:name], vm_name)
  @vm = dc.find_vm(path)
  raise Support::CloneError.new(format("Unable to find machine: %s", path)) if vm.nil?

  # Reconnect network device after Instant Clone is ready
  if instant_clone?
    Kitchen.logger.info "Reconnecting network adapter"
    reconnect_network_device(vm)
  end

  vm_customization if options[:vm_customization]

  # Start only if specified or customizations wanted; no need for instant clones as they start in running state
  if options[:poweron] && !options[:vm_customization].nil? && !instant_clone?
    task = vm.PowerOnVM_Task
    task.wait_for_completion
  end
  benchmark_checkpoint("powered_on") if benchmark?

  # Windows customization takes a while, so check for its completion
  guest_customization_wait if options[:guest_customization]

  Kitchen.logger.info format("Waiting for VMware tools to become available (timeout: %d seconds)...", options[:wait_timeout])
  wait_for_tools(options[:wait_timeout], options[:wait_interval])

  active_ip_discovery || standard_ip_discovery
  benchmark_checkpoint("ip_detected") if benchmark?

  benchmark_persist if benchmark?
  Kitchen.logger.info format("Created machine %s with IP %s", vm_name, ip)
end
command_separator() click to toggle source
# File lib/support/clone_vm.rb, line 213
def command_separator
  case options[:vm_os].downcase.to_sym
  when :linux
    " && "
  when :windows
    " & "
  end
end
datacenter() click to toggle source

@return [String]

# File lib/support/clone_vm.rb, line 423
def datacenter
  options[:datacenter]
end
detect_os(vm_or_template) click to toggle source
# File lib/support/clone_vm.rb, line 164
def detect_os(vm_or_template)
  vm_or_template.config&.guestId&.match(/^win/) ? :windows : :linux
end
discovery_commands() click to toggle source

Retrieve IP via OS commands

# File lib/support/clone_vm.rb, line 259
def discovery_commands
  if options[:active_discovery_command].nil?
    case options[:vm_os].downcase.to_sym
    when :linux
      "ip address show scope global | grep global | cut -b10- | cut -d/ -f1"
    when :windows
      ["sleep 5", "ipconfig"]
      # "ipconfig /renew"
      # "wmic nicconfig get IPAddress",
      # "netsh interface ip show ipaddress #{options[:vm_win_network]}"
    end
  else
    options[:active_discovery_command]
  end
end
find_datacenter() click to toggle source

@return [RbVmomi::VIM::Datacenter]

# File lib/support/clone_vm.rb, line 430
def find_datacenter
  vim.serviceInstance.find_datacenter(datacenter)
rescue RbVmomi::Fault
  dc = root_folder.findByInventoryPath(datacenter)
  return dc if dc.is_a?(RbVmomi::VIM::Datacenter)

  raise Support::CloneError.new("Unable to locate datacenter at '#{datacenter}'")
end
full_clone?() click to toggle source
# File lib/support/clone_vm.rb, line 412
def full_clone?
  options[:clone_type] == :full
end
instant_clone?() click to toggle source
# File lib/support/clone_vm.rb, line 404
def instant_clone?
  options[:clone_type] == :instant
end
ip?(string) click to toggle source
# File lib/support/clone_vm.rb, line 439
def ip?(string)
  IPAddr.new(string)
  true
rescue IPAddr::InvalidAddressError
  false
end
ip_from_tools() click to toggle source
# File lib/support/clone_vm.rb, line 35
def ip_from_tools
  return if vm.guest.net.empty?

  # Don't simply use vm.guest.ipAddress to allow specifying a different interface
  nics = vm.guest.net
  if options[:interface]
    nics.select! { |nic| nic.network == options[:interface] }

    raise Support::CloneError.new(format("No interfaces found on VM which are attached to network '%s'", options[:interface])) if nics.empty?
  end

  vm_ip = nil
  nics.each do |net|
    vm_ip = net.ipConfig.ipAddress.detect { |addr| addr.origin != "linklayer" }
    break unless vm_ip.nil?
  end

  vm_ip&.ipAddress
end
linked_clone?() click to toggle source
# File lib/support/clone_vm.rb, line 408
def linked_clone?
  options[:clone_type] == :linked
end
linux?() click to toggle source
# File lib/support/clone_vm.rb, line 172
def linux?
  options[:vm_os].downcase.to_sym == :linux
end
network_device(vm) click to toggle source
# File lib/support/clone_vm.rb, line 176
def network_device(vm)
  all_network_devices = vm.config.hardware.device.select do |device|
    device.is_a?(RbVmomi::VIM::VirtualEthernetCard)
  end

  # Only support for first NIC so far
  all_network_devices.first
end
reconnect_network_device(vm) click to toggle source
# File lib/support/clone_vm.rb, line 185
def reconnect_network_device(vm)
  network_device = network_device(vm)
  network_device.connectable = RbVmomi::VIM.VirtualDeviceConnectInfo(
    allowGuestControl: true,
    startConnected: true,
    connected: true
  )

  config_spec = RbVmomi::VIM.VirtualMachineConfigSpec(
    deviceChange: [
      RbVmomi::VIM.VirtualDeviceConfigSpec(
        operation: RbVmomi::VIM::VirtualDeviceConfigSpecOperation("edit"),
        device: network_device
      ),
    ]
  )

  task = vm.ReconfigVM_Task(spec: config_spec)
  task.wait_for_completion

  benchmark_checkpoint("nic_reconfigured") if benchmark?
end
rescan_commands() click to toggle source

Rescan network adapters for MAC/IP changes

# File lib/support/clone_vm.rb, line 223
def rescan_commands
  Kitchen.logger.info "Refreshing network interfaces in OS"

  case options[:vm_os].downcase.to_sym
  when :linux
    # @todo: allow override if no dhclient
    [
      "/sbin/modprobe -r vmxnet3",
      "/sbin/modprobe vmxnet3",
      "/sbin/dhclient",
    ]
  when :windows
    [
      "netsh interface set Interface #{options[:vm_win_network]} disable",
      "netsh interface set Interface #{options[:vm_win_network]} enable",
      "ipconfig /renew",
    ]
  end
end
root_folder() click to toggle source
# File lib/support/clone_vm.rb, line 416
def root_folder
  @root_folder ||= vim.serviceInstance.content.rootFolder
end
standard_ip_discovery() click to toggle source
# File lib/support/clone_vm.rb, line 208
def standard_ip_discovery
  Kitchen.logger.info format("Waiting for IP (timeout: %d seconds)...", options[:wait_timeout])
  wait_for_ip(options[:wait_timeout], options[:wait_interval])
end
trigger_tools() click to toggle source

Available from VMware Tools 10.1.0 this pushes the IP instead of the standard 30 second poll This will be used to provide a quick fallback, if active discovery fails.

# File lib/support/clone_vm.rb, line 245
def trigger_tools
  case options[:vm_os].downcase.to_sym
  when :linux
    [
      "/usr/bin/vmware-toolbox-cmd info update network",
    ]
  when :windows
    [
      '"C:\Program Files\VMware\VMware Tools\VMwareToolboxCmd.exe" info update network',
    ]
  end
end
vm_customization() click to toggle source
# File lib/support/clone_vm.rb, line 326
def vm_customization
  Kitchen.logger.info "Waiting for VM customization..."

  # Pass some contents right through
  # https://pubs.vmware.com/vsphere-6-5/index.jsp?topic=%2Fcom.vmware.wssdk.smssdk.doc%2Fvim.vm.ConfigSpec.html
  config = options[:vm_customization].select { |key, _| %i{annotation memoryMB numCPUs}.include? key }

  add_disks = options[:vm_customization]&.fetch(:add_disks, nil)
  unless add_disks.nil?
    config[:deviceChange] = []

    # Will create a stem like "default-ubuntu-12345678/default-ubuntu-12345678"
    filename_base = vm.disks.first.backing.fileName.gsub(/(-[0-9]+)?.vmdk/, "")

    # Storage Controller and ID mapping
    controller = vm.config.hardware.device.select { |device| device.is_a? RbVmomi::VIM::VirtualSCSIController }.first

    add_disks.each_with_index do |disk_config, idx|
      # Default to Thin Provisioning and 10GB disk size
      disk_config[:type]    ||= :thin
      disk_config[:size_mb] ||= 10240

      check_add_disk_config(disk_config)

      disk_spec = RbVmomi::VIM.VirtualDeviceConfigSpec(
        fileOperation: "create",
        operation: RbVmomi::VIM::VirtualDeviceConfigSpecOperation("add"),
        device: RbVmomi::VIM.VirtualDisk(
          backing: RbVmomi::VIM.VirtualDiskFlatVer2BackingInfo(
            thinProvisioned: true,
            diskMode: "persistent",
            fileName: format("%s_disk%03d.vmdk", filename_base, idx + 1),
            datastore: vm.disks.first.backing.datastore
          ),
          deviceInfo: RbVmomi::VIM::Description(
            label: format("Additional disk %d", idx + 1),
            summary: format("%d MB", disk_config[:size_mb])
          )
        )
      )

      # capacityInKB is marked a deprecated in 6.7 but still a required parameter
      disk_spec.device.capacityInBytes = disk_config[:size_mb] * 1024**2
      disk_spec.device.capacityInKB = disk_config[:size_mb] * 1024

      disk_spec.device.controllerKey = controller.key

      highest_id = vm.disks.map(&:unitNumber).max
      next_id = highest_id + idx + 1

      # Avoid the SCSI controller ID
      next_id += 1 if next_id == controller.scsiCtlrUnitNumber

      # Theoretically could add another SCSI controller, but there are limits to what kitchen should support
      if next_id > 14
        raise Support::CloneError.new(format("Ran out of SCSI IDs while trying to assign new disk %d", idx + 1))
      end

      disk_spec.device.unitNumber = next_id

      device_keys = vm.config.hardware.device.map(&:key).sort
      disk_spec.device.key = device_keys.last + (idx + 1) * 1000

      disk_spec.device.backing.eagerlyScrub = true if disk_config[:type].to_s == "flat_eager"
      disk_spec.device.backing.thinProvisioned = false if disk_config[:type].to_s =~ /^flat/

      config[:deviceChange] << disk_spec
    end
  end

  config_spec = RbVmomi::VIM.VirtualMachineConfigSpec(config)

  task = vm.ReconfigVM_Task(spec: config_spec)
  task.wait_for_completion

  benchmark_checkpoint("reconfigured") if benchmark?
end
vm_events(event_types = []) click to toggle source
# File lib/support/clone_vm.rb, line 446
def vm_events(event_types = [])
  raise Support::CloneError.new("`vm_events` called before VM clone") unless vm

  vem.QueryEvents(filter: RbVmomi::VIM::EventFilterSpec(
    entity: RbVmomi::VIM::EventFilterSpecByEntity(
      entity: vm,
      recursion: RbVmomi::VIM::EventFilterSpecRecursionOption(:self)
    ),
    eventTypeId: event_types
  ))
end
wait_for_ip(timeout = 60.0, interval = 2.0) click to toggle source
# File lib/support/clone_vm.rb, line 73
def wait_for_ip(timeout = 60.0, interval = 2.0)
  start = Time.new

  ip = nil
  loop do
    ip = ip_from_tools
    if ip || (Time.new - start) >= timeout
      Kitchen.logger.debug format("IP retrieved after %.1f seconds", Time.new - start) if ip
      break
    end
    sleep interval
  end

  raise Support::CloneError.new("Timeout waiting for IP address") if ip.nil?
  raise Support::CloneError.new(format("Error getting accessible IP address, got %s. Check DHCP server and scope exhaustion", ip)) if ip =~ /^169\.254\./

  # Allow IP rewriting (e.g. for 1:1 NAT)
  if options[:transform_ip]
    Kitchen.logger.info format("Received IP: %s", ip)

    # rubocop:disable Security/Eval
    ip = lambda { eval options[:transform_ip] }.call
    # rubocop:enable Security/Eval

    Kitchen.logger.info format("Transformed to IP: %s", ip)
  end

  @ip = ip
end
wait_for_tools(timeout = 30.0, interval = 2.0) click to toggle source
# File lib/support/clone_vm.rb, line 55
def wait_for_tools(timeout = 30.0, interval = 2.0)
  start = Time.new

  loop do
    if vm.guest.toolsRunningStatus == "guestToolsRunning"
      benchmark_checkpoint("tools_detected") if benchmark?

      Kitchen.logger.debug format("Tools detected after %.1f seconds", Time.new - start)
      return
    end
    break if (Time.new - start) >= timeout

    sleep interval
  end

  raise Support::CloneError.new("Timeout waiting for VMware Tools")
end
windows?() click to toggle source
# File lib/support/clone_vm.rb, line 168
def windows?
  options[:vm_os].downcase.to_sym == :windows
end