From da080c472fc415b0ce918f4dd4a1ab143bb1bca4 Mon Sep 17 00:00:00 2001 From: Philip Hands Date: Mon, 14 Mar 2016 15:36:16 +0100 Subject: rough attempt to grab the good cucumber bits from recent tails --- features/support/helpers/vm_helper.rb | 532 +++++++++++++++++++++++++--------- 1 file changed, 391 insertions(+), 141 deletions(-) (limited to 'features/support/helpers/vm_helper.rb') diff --git a/features/support/helpers/vm_helper.rb b/features/support/helpers/vm_helper.rb index 2b5ad291..6d7204d4 100644 --- a/features/support/helpers/vm_helper.rb +++ b/features/support/helpers/vm_helper.rb @@ -1,79 +1,122 @@ require 'libvirt' require 'rexml/document' -class VM +class ExecutionFailedInVM < StandardError +end + +class VMNet - # These class attributes will be lazily initialized during the first - # instantiation: - # This is the libvirt connection, of which we only want one and - # which can persist for different VM instances (even in parallel) - @@virt = nil - # This is a storage helper that deals with volume manipulation. The - # storage it deals with persists across VMs, by necessity. - @@storage = nil + attr_reader :net_name, :net - def VM.storage - return @@storage + def initialize(virt, xml_path) + @virt = virt + @net_name = LIBVIRT_NETWORK_NAME + net_xml = File.read("#{xml_path}/default_net.xml") + rexml = REXML::Document.new(net_xml) + rexml.elements['network'].add_element('name') + rexml.elements['network/name'].text = @net_name + rexml.elements['network'].add_element('uuid') + rexml.elements['network/uuid'].text = LIBVIRT_NETWORK_UUID + update(rexml.to_s) + rescue Exception => e + destroy_and_undefine + raise e end - def storage - return @@storage + # We lookup by name so we also catch networks from previous test + # suite runs that weren't properly cleaned up (e.g. aborted). + def destroy_and_undefine + begin + old_net = @virt.lookup_network_by_name(@net_name) + old_net.destroy if old_net.active? + old_net.undefine + rescue + end end - attr_reader :domain, :display, :ip, :net + def update(xml) + destroy_and_undefine + @net = @virt.define_network_xml(xml) + @net.create + end + + def bridge_name + @net.bridge_name + end + + def bridge_ip_addr + net_xml = REXML::Document.new(@net.xml_desc) + IPAddr.new(net_xml.elements['network/ip'].attributes['address']).to_s + end + + def guest_real_mac + net_xml = REXML::Document.new(@net.xml_desc) + net_xml.elements['network/ip/dhcp/host/'].attributes['mac'] + end - def initialize(xml_path, x_display) - @@virt ||= Libvirt::open("qemu:///system") + def bridge_mac + File.open("/sys/class/net/#{bridge_name}/address", "rb").read.chomp + end +end + + +class VM + + attr_reader :domain, :display, :vmnet, :storage + + def initialize(virt, xml_path, vmnet, storage, x_display) + @virt = virt @xml_path = xml_path + @vmnet = vmnet + @storage = storage + @domain_name = LIBVIRT_DOMAIN_NAME default_domain_xml = File.read("#{@xml_path}/default.xml") - update_domain(default_domain_xml) - default_net_xml = File.read("#{@xml_path}/default_net.xml") - update_net(default_net_xml) + rexml = REXML::Document.new(default_domain_xml) + rexml.elements['domain'].add_element('name') + rexml.elements['domain/name'].text = @domain_name + rexml.elements['domain'].add_element('uuid') + rexml.elements['domain/uuid'].text = LIBVIRT_DOMAIN_UUID + update(rexml.to_s) @display = Display.new(@domain_name, x_display) - set_cdrom_boot($tails_iso) + set_cdrom_boot(TAILS_ISO) plug_network - # unlike the domain and net the storage pool should survive VM - # teardown (so a new instance can use e.g. a previously created - # USB drive), so we only create a new one if there is none. - @@storage ||= VMStorage.new(@@virt, xml_path) rescue Exception => e - clean_up_net - clean_up_domain + destroy_and_undefine raise e end - def update_domain(xml) - domain_xml = REXML::Document.new(xml) - @domain_name = domain_xml.elements['domain/name'].text - clean_up_domain - @domain = @@virt.define_domain_xml(xml) - end - - def update_net(xml) - net_xml = REXML::Document.new(xml) - @net_name = net_xml.elements['network/name'].text - @ip = net_xml.elements['network/ip/dhcp/host/'].attributes['ip'] - clean_up_net - @net = @@virt.define_network_xml(xml) - @net.create + def update(xml) + destroy_and_undefine + @domain = @virt.define_domain_xml(xml) end - def clean_up_domain + # We lookup by name so we also catch domains from previous test + # suite runs that weren't properly cleaned up (e.g. aborted). + def destroy_and_undefine + @display.stop if @display && @display.active? begin - domain = @@virt.lookup_domain_by_name(@domain_name) - domain.destroy if domain.active? - domain.undefine + old_domain = @virt.lookup_domain_by_name(@domain_name) + old_domain.destroy if old_domain.active? + old_domain.undefine rescue end end - def clean_up_net - begin - net = @@virt.lookup_network_by_name(@net_name) - net.destroy if net.active? - net.undefine - rescue - end + def real_mac + @vmnet.guest_real_mac + end + + def set_hardware_clock(time) + assert(not(is_running?), 'The hardware clock cannot be set when the ' + + 'VM is running') + assert(time.instance_of?(Time), "Argument must be of type 'Time'") + adjustment = (time - Time.now).to_i + domain_rexml = REXML::Document.new(@domain.xml_desc) + clock_rexml_element = domain_rexml.elements['domain'].add_element('clock') + clock_rexml_element.add_attributes('offset' => 'variable', + 'basis' => 'utc', + 'adjustment' => adjustment.to_s) + update(domain_rexml.to_s) end def set_network_link_state(state) @@ -82,7 +125,7 @@ class VM if is_running? @domain.update_device(domain_xml.elements['domain/devices/interface'].to_s) else - update_domain(domain_xml.to_s) + update(domain_xml.to_s) end end @@ -94,97 +137,101 @@ class VM set_network_link_state('down') end - def set_cdrom_tray_state(state) - domain_xml = REXML::Document.new(@domain.xml_desc) - domain_xml.elements.each('domain/devices/disk') do |e| - if e.attribute('device').to_s == "cdrom" - e.elements['target'].attributes['tray'] = state - if is_running? - @domain.update_device(e.to_s) - else - update_domain(domain_xml.to_s) - end - end - end - end - - def eject_cdrom - set_cdrom_tray_state('open') - end - - def close_cdrom - set_cdrom_tray_state('closed') - end - def set_boot_device(dev) if is_running? raise "boot settings can only be set for inactive vms" end domain_xml = REXML::Document.new(@domain.xml_desc) domain_xml.elements['domain/os/boot'].attributes['dev'] = dev - update_domain(domain_xml.to_s) + update(domain_xml.to_s) end def set_cdrom_image(image) + image = nil if image == '' domain_xml = REXML::Document.new(@domain.xml_desc) domain_xml.elements.each('domain/devices/disk') do |e| if e.attribute('device').to_s == "cdrom" - if ! e.elements['source'] - e.add_element('source') + if image.nil? + e.elements.delete('source') + else + if ! e.elements['source'] + e.add_element('source') + end + e.elements['source'].attributes['file'] = image end - e.elements['source'].attributes['file'] = image if is_running? - @domain.update_device(e.to_s, Libvirt::Domain::DEVICE_MODIFY_FORCE) + @domain.update_device(e.to_s) else - update_domain(domain_xml.to_s) + update(domain_xml.to_s) end end end end def remove_cdrom - set_cdrom_image('') + set_cdrom_image(nil) + rescue Libvirt::Error => e + # While the CD-ROM is removed successfully we still get this + # error, so let's ignore it. + acceptable_error = + "Call to virDomainUpdateDeviceFlags failed: internal error: unable to " + + "execute QEMU command 'eject': (Tray of device '.*' is not open|" + + "Device '.*' is locked)" + raise e if not(Regexp.new(acceptable_error).match(e.to_s)) end def set_cdrom_boot(image) if is_running? - raise "boot settings can only be set for inactice vms" + raise "boot settings can only be set for inactive vms" end set_boot_device('cdrom') set_cdrom_image(image) - close_cdrom end - def plug_drive(name, type) - # Get the next free /dev/sdX on guest - used_devs = [] + def list_disk_devs + ret = [] domain_xml = REXML::Document.new(@domain.xml_desc) - domain_xml.elements.each('domain/devices/disk/target') do |e| - used_devs <<= e.attribute('dev').to_s + domain_xml.elements.each('domain/devices/disk') do |e| + ret << e.elements['target'].attribute('dev').to_s + end + return ret + end + + def plug_drive(name, type) + if disk_plugged?(name) + raise "disk '#{name}' already plugged" end + removable_usb = nil + case type + when "removable usb", "usb" + type = "usb" + removable_usb = "on" + when "non-removable usb" + type = "usb" + removable_usb = "off" + end + # Get the next free /dev/sdX on guest letter = 'a' dev = "sd" + letter - while used_devs.include? dev + while list_disk_devs.include?(dev) letter = (letter[0].ord + 1).chr dev = "sd" + letter end assert letter <= 'z' xml = REXML::Document.new(File.read("#{@xml_path}/disk.xml")) - xml.elements['disk/source'].attributes['file'] = @@storage.disk_path(name) - xml.elements['disk/driver'].attributes['type'] = @@storage.disk_format(name) + xml.elements['disk/source'].attributes['file'] = @storage.disk_path(name) + xml.elements['disk/driver'].attributes['type'] = @storage.disk_format(name) xml.elements['disk/target'].attributes['dev'] = dev xml.elements['disk/target'].attributes['bus'] = type - if type == "usb" - xml.elements['disk/target'].attributes['removable'] = 'on' - end + xml.elements['disk/target'].attributes['removable'] = removable_usb if removable_usb if is_running? @domain.attach_device(xml.to_s) else domain_xml = REXML::Document.new(@domain.xml_desc) domain_xml.elements['domain/devices'].add_element(xml) - update_domain(domain_xml.to_s) + update(domain_xml.to_s) end end @@ -192,7 +239,7 @@ class VM domain_xml = REXML::Document.new(@domain.xml_desc) domain_xml.elements.each('domain/devices/disk') do |e| begin - if e.elements['source'].attribute('file').to_s == @@storage.disk_path(name) + if e.elements['source'].attribute('file').to_s == @storage.disk_path(name) return e.to_s end rescue @@ -202,25 +249,64 @@ class VM return nil end + def disk_rexml_desc(name) + xml = disk_xml_desc(name) + if xml + return REXML::Document.new(xml) + else + return nil + end + end + def unplug_drive(name) xml = disk_xml_desc(name) @domain.detach_device(xml) end + def disk_type(dev) + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements.each('domain/devices/disk') do |e| + if e.elements['target'].attribute('dev').to_s == dev + return e.elements['driver'].attribute('type').to_s + end + end + raise "No such disk device '#{dev}'" + end + def disk_dev(name) - xml = REXML::Document.new(disk_xml_desc(name)) - return "/dev/" + xml.elements['disk/target'].attribute('dev').to_s + rexml = disk_rexml_desc(name) or return nil + return "/dev/" + rexml.elements['disk/target'].attribute('dev').to_s + end + + def disk_name(dev) + dev = File.basename(dev) + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements.each('domain/devices/disk') do |e| + if /^#{e.elements['target'].attribute('dev').to_s}/.match(dev) + return File.basename(e.elements['source'].attribute('file').to_s) + end + end + raise "No such disk device '#{dev}'" + end + + def udisks_disk_dev(name) + return disk_dev(name).gsub('/dev/', '/org/freedesktop/UDisks/devices/') end def disk_detected?(name) - return execute("test -b #{disk_dev(name)}").success? + dev = disk_dev(name) or return false + return execute("test -b #{dev}").success? + end + + def disk_plugged?(name) + return not(disk_xml_desc(name).nil?) end def set_disk_boot(name, type) if is_running? raise "boot settings can only be set for inactive vms" end - plug_drive(name, type) + plug_drive(name, type) if not(disk_plugged?(name)) set_boot_device('hd') # For some reason setting the boot device doesn't prevent cdrom # boot unless it's empty @@ -231,14 +317,19 @@ class VM # XXX-9p in common_steps.rb for more information. def add_share(source, tag) if is_running? - raise "shares can only be added to inactice vms" + raise "shares can only be added to inactive vms" end + # The complete source directory must be group readable by the user + # running the virtual machine, and world readable so the user inside + # the VM can access it (since we use the passthrough security model). + FileUtils.chown_R(nil, "libvirt-qemu", source) + FileUtils.chmod_R("go+rX", source) xml = REXML::Document.new(File.read("#{@xml_path}/fs_share.xml")) xml.elements['filesystem/source'].attributes['dir'] = source xml.elements['filesystem/target'].attributes['dir'] = tag domain_xml = REXML::Document.new(@domain.xml_desc) domain_xml.elements['domain/devices'].add_element(xml) - update_domain(domain_xml.to_s) + update(domain_xml.to_s) end def list_shares @@ -251,13 +342,13 @@ class VM end def set_ram_size(size, unit = "KiB") - raise "System memory can only be added to inactice vms" if is_running? + raise "System memory can only be added to inactive vms" if is_running? domain_xml = REXML::Document.new(@domain.xml_desc) domain_xml.elements['domain/memory'].text = size domain_xml.elements['domain/memory'].attributes['unit'] = unit domain_xml.elements['domain/currentMemory'].text = size domain_xml.elements['domain/currentMemory'].attributes['unit'] = unit - update_domain(domain_xml.to_s) + update(domain_xml.to_s) end def get_ram_size_in_bytes @@ -268,24 +359,24 @@ class VM end def set_arch(arch) - raise "System architecture can only be set to inactice vms" if is_running? + raise "System architecture can only be set to inactive vms" if is_running? domain_xml = REXML::Document.new(@domain.xml_desc) domain_xml.elements['domain/os/type'].attributes['arch'] = arch - update_domain(domain_xml.to_s) + update(domain_xml.to_s) end def add_hypervisor_feature(feature) - raise "Hypervisor features can only be added to inactice vms" if is_running? + raise "Hypervisor features can only be added to inactive vms" if is_running? domain_xml = REXML::Document.new(@domain.xml_desc) domain_xml.elements['domain/features'].add_element(feature) - update_domain(domain_xml.to_s) + update(domain_xml.to_s) end def drop_hypervisor_feature(feature) - raise "Hypervisor features can only be fropped from inactice vms" if is_running? + raise "Hypervisor features can only be fropped from inactive vms" if is_running? domain_xml = REXML::Document.new(@domain.xml_desc) domain_xml.elements['domain/features'].delete_element(feature) - update_domain(domain_xml.to_s) + update(domain_xml.to_s) end def disable_pae_workaround @@ -295,24 +386,24 @@ class VM xml = < - + EOF domain_xml = REXML::Document.new(@domain.xml_desc) domain_xml.elements['domain'].add_element(REXML::Document.new(xml)) - update_domain(domain_xml.to_s) + update(domain_xml.to_s) end def set_os_loader(type) if is_running? - raise "boot settings can only be set for inactice vms" + raise "boot settings can only be set for inactive vms" end if type == 'UEFI' domain_xml = REXML::Document.new(@domain.xml_desc) domain_xml.elements['domain/os'].add_element(REXML::Document.new( '/usr/share/ovmf/OVMF.fd' )) - update_domain(domain_xml.to_s) + update(domain_xml.to_s) else raise "unsupported OS loader type" end @@ -326,21 +417,38 @@ EOF end end - def execute(cmd, user = "root") - return VMCommand.new(self, cmd, { :user => user, :spawn => false }) + def execute(cmd, options = {}) + options[:user] ||= "root" + options[:spawn] ||= false + if options[:libs] + libs = options[:libs] + options.delete(:libs) + libs = [libs] if not(libs.methods.include? :map) + cmds = libs.map do |lib_name| + ". /usr/local/lib/tails-shell-library/#{lib_name}.sh" + end + cmds << cmd + cmd = cmds.join(" && ") + end + return VMCommand.new(self, cmd, options) end - def execute_successfully(cmd, user = "root") - p = execute(cmd, user) - assert_vmcommand_success(p) + def execute_successfully(*args) + p = execute(*args) + begin + assert_vmcommand_success(p) + rescue Test::Unit::AssertionFailedError => e + raise ExecutionFailedInVM.new(e) + end return p end - def spawn(cmd, user = "root") - return VMCommand.new(self, cmd, { :user => user, :spawn => true }) + def spawn(cmd, options = {}) + options[:spawn] = true + return execute(cmd, options) end - def wait_until_remote_shell_is_up(timeout = 30) + def wait_until_remote_shell_is_up(timeout = 90) VMCommand.wait_until_remote_shell_is_up(self, timeout) end @@ -361,32 +469,182 @@ EOF return execute("pidof -x -o '%PPID' " + process).stdout.chomp.split end + def select_virtual_desktop(desktop_number, user = LIVE_USER) + assert(desktop_number >= 0 && desktop_number <=3, + "Only values between 0 and 3 are valid virtual desktop numbers") + execute_successfully( + "xdotool set_desktop '#{desktop_number}'", + :user => user + ) + end + + def focus_window(window_title, user = LIVE_USER) + def do_focus(window_title, user) + execute_successfully( + "xdotool search --name '#{window_title}' windowactivate --sync", + :user => user + ) + end + + begin + do_focus(window_title, user) + rescue ExecutionFailedInVM + # Often when xdotool fails to focus a window it'll work when retried + # after redrawing the screen. Switching to a new virtual desktop then + # back seems to be a reliable way to handle this. + select_virtual_desktop(3) + select_virtual_desktop(0) + sleep 5 # there aren't any visual indicators which can be used here + do_focus(window_title, user) + end + end + def file_exist?(file) - execute("test -e #{file}").success? + execute("test -e '#{file}'").success? + end + + def directory_exist?(directory) + execute("test -d '#{directory}'").success? end def file_content(file, user = 'root') # We don't quote #{file} on purpose: we sometimes pass environment variables # or globs that we want to be interpreted by the shell. - cmd = execute("cat #{file}", user) + cmd = execute("cat #{file}", :user => user) assert(cmd.success?, "Could not cat '#{file}':\n#{cmd.stdout}\n#{cmd.stderr}") return cmd.stdout end - def save_snapshot(path) - @domain.save(path) - @display.stop + def file_append(file, lines, user = 'root') + lines = lines.split("\n") if lines.class == String + lines.each do |line| + cmd = execute("echo '#{line}' >> '#{file}'", :user => user) + assert(cmd.success?, + "Could not append to '#{file}':\n#{cmd.stdout}\n#{cmd.stderr}") + end + end + + def set_clipboard(text) + execute_successfully("echo -n '#{text}' | xsel --input --clipboard", + :user => LIVE_USER) end - def restore_snapshot(path) - # Clean up current domain so its snapshot can be restored - clean_up_domain - Libvirt::Domain::restore(@@virt, path) - @domain = @@virt.lookup_domain_by_name(@domain_name) + def get_clipboard + execute_successfully("xsel --output --clipboard", :user => LIVE_USER).stdout + end + + def internal_snapshot_xml(name) + disk_devs = list_disk_devs + disks_xml = " \n" + for dev in disk_devs + snapshot_type = disk_type(dev) == "qcow2" ? 'internal' : 'no' + disks_xml += + " \n" + end + disks_xml += " " + return <<-EOF + + #{name} + Snapshot for #{name} +#{disks_xml} + +EOF + end + + def VM.ram_only_snapshot_path(name) + return "#{$config["TMPDIR"]}/#{name}-snapshot.memstate" + end + + def save_snapshot(name) + # If we have no qcow2 disk device, we'll use "memory state" + # snapshots, and if we have at least one qcow2 disk device, we'll + # use internal "system checkpoint" (memory + disks) snapshots. We + # have to do this since internal snapshots don't work when no + # such disk is available. We can do this with external snapshots, + # which are better in many ways, but libvirt doesn't know how to + # restore (revert back to) them yet. + # WARNING: If only transient disks, i.e. disks that were plugged + # after starting the domain, are used then the memory state will + # be dropped. External snapshots would also fix this. + internal_snapshot = false + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements.each('domain/devices/disk') do |e| + if e.elements['driver'].attribute('type').to_s == "qcow2" + internal_snapshot = true + break + end + end + + # Note: In this case the "opposite" of `internal_snapshot` is not + # anything relating to external snapshots, but actually "memory + # state"(-only) snapshots. + if internal_snapshot + xml = internal_snapshot_xml(name) + @domain.snapshot_create_xml(xml) + else + snapshot_path = VM.ram_only_snapshot_path(name) + @domain.save(snapshot_path) + # For consistency with the internal snapshot case (which is + # "live", so the domain doesn't go down) we immediately restore + # the snapshot. + # Assumption: that *immediate* save + restore doesn't mess up + # with network state and similar, and is fast enough to not make + # the clock drift too much. + restore_snapshot(name) + end + end + + def restore_snapshot(name) + @domain.destroy if is_running? + @display.stop if @display and @display.active? + # See comment in save_snapshot() for details on why we use two + # different type of snapshots. + potential_ram_only_snapshot_path = VM.ram_only_snapshot_path(name) + if File.exist?(potential_ram_only_snapshot_path) + Libvirt::Domain::restore(@virt, potential_ram_only_snapshot_path) + @domain = @virt.lookup_domain_by_name(@domain_name) + else + begin + potential_internal_snapshot = @domain.lookup_snapshot_by_name(name) + @domain.revert_to_snapshot(potential_internal_snapshot) + rescue Libvirt::RetrieveError + raise "No such (internal nor external) snapshot #{name}" + end + end @display.start end + def VM.remove_snapshot(name) + old_domain = $virt.lookup_domain_by_name(LIBVIRT_DOMAIN_NAME) + potential_ram_only_snapshot_path = VM.ram_only_snapshot_path(name) + if File.exist?(potential_ram_only_snapshot_path) + File.delete(potential_ram_only_snapshot_path) + else + snapshot = old_domain.lookup_snapshot_by_name(name) + snapshot.delete + end + end + + def VM.snapshot_exists?(name) + return true if File.exist?(VM.ram_only_snapshot_path(name)) + old_domain = $virt.lookup_domain_by_name(LIBVIRT_DOMAIN_NAME) + snapshot = old_domain.lookup_snapshot_by_name(name) + return snapshot != nil + rescue Libvirt::RetrieveError + return false + end + + def VM.remove_all_snapshots + Dir.glob("#{$config["TMPDIR"]}/*-snapshot.memstate").each do |file| + File.delete(file) + end + old_domain = $virt.lookup_domain_by_name(LIBVIRT_DOMAIN_NAME) + old_domain.list_all_snapshots.each { |snapshot| snapshot.delete } + rescue Libvirt::RetrieveError + # No such domain, so no snapshots either. + end + def start return if is_running? @domain.create @@ -394,9 +652,7 @@ EOF end def reset - # ruby-libvirt 0.4 does not support the reset method. - # XXX: Once we use Jessie, use @domain.reset instead. - system("virsh -c qemu:///system reset " + @domain_name) if is_running? + @domain.reset if is_running? end def power_off @@ -404,12 +660,6 @@ EOF @display.stop end - def destroy - clean_up_domain - clean_up_net - power_off - end - def take_screenshot(description) @display.take_screenshot(description) end -- cgit v1.2.3-70-g09d2