require 'ipaddr' require 'libvirt' require 'rexml/document' class ExecutionFailedInVM < StandardError end class VMNet attr_reader :net_name, :net 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 begin old_net = @virt.lookup_network_by_name(@net_name) rexml.elements['network'].add_element('uuid') rexml.elements['network/uuid'].text = old_net.uuid old_net.undefine rescue end update(rexml.to_s) update(rexml.to_s) rescue Exception => e destroy_and_undefine raise e end # 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 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 bridge_mac File.open("/sys/class/net/#{bridge_name}/address", "rb").read.chomp end end class VM attr_reader :domain, :domain_name, :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") rexml = REXML::Document.new(default_domain_xml) rexml.elements['domain'].add_element('name') rexml.elements['domain/name'].text = @domain_name begin old_domain = @virt.lookup_domain_by_name(LIBVIRT_DOMAIN_NAME) rexml.elements['domain'].add_element('uuid') rexml.elements['domain/uuid'].text = old_domain.uuid old_domain.undefine rescue end rexml.elements['domain/devices/serial/source'].attributes['service'] = LIBVIRT_REMOTE_SHELL_PORT update(rexml.to_s) @display = Display.new(@domain_name, x_display) set_cdrom_boot(TAILS_ISO) plug_network rescue Exception => e destroy_and_undefine raise e end def update(xml) destroy_and_undefine @domain = @virt.define_domain_xml(xml) end # 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 old_domain = @virt.lookup_domain_by_name(@domain_name) old_domain.destroy if old_domain.active? old_domain.undefine rescue end end def real_mac(alias_name) REXML::Document.new(@domain.xml_desc) .elements["domain/devices/interface[@type='network']/" + "alias[@name='#{alias_name}']"] .parent.elements['mac'].attributes['address'].to_s end def all_real_macs macs = [] REXML::Document.new(@domain.xml_desc) .elements.each("domain/devices/interface[@type='network']") do |nic| macs << nic.elements['mac'].attributes['address'].to_s end macs 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 network_link_state REXML::Document.new(@domain.xml_desc) .elements['domain/devices/interface/link'].attributes['state'] end def set_network_link_state(state) domain_xml = REXML::Document.new(@domain.xml_desc) domain_xml.elements['domain/devices/interface/link'].attributes['state'] = state if is_running? @domain.update_device(domain_xml.elements['domain/devices/interface'].to_s) else update(domain_xml.to_s) end end def plug_network set_network_link_state('up') end def unplug_network set_network_link_state('down') 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_xml.to_s) end def add_cdrom_device if is_running? raise "Can't attach a CDROM device to a running domain" end domain_rexml = REXML::Document.new(@domain.xml_desc) if domain_rexml.elements["domain/devices/disk[@device='cdrom']"] raise "A CDROM device already exists" end cdrom_rexml = REXML::Document.new(File.read("#{@xml_path}/cdrom.xml")).root domain_rexml.elements['domain/devices'].add_element(cdrom_rexml) update(domain_rexml.to_s) end def remove_cdrom_device if is_running? raise "Can't detach a CDROM device to a running domain" end domain_rexml = REXML::Document.new(@domain.xml_desc) cdrom_el = domain_rexml.elements["domain/devices/disk[@device='cdrom']"] if cdrom_el.nil? raise "No CDROM device is present" end domain_rexml.elements["domain/devices"].delete_element(cdrom_el) update(domain_rexml.to_s) end def eject_cdrom execute_successfully('/usr/bin/eject -m') end def remove_cdrom_image domain_rexml = REXML::Document.new(@domain.xml_desc) cdrom_el = domain_rexml.elements["domain/devices/disk[@device='cdrom']"] if cdrom_el.nil? raise "No CDROM device is present" end cdrom_el.delete_element('source') update(domain_rexml.to_s) 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_image(image) if image.nil? or image == '' raise "Can't set cdrom image to an empty string" end remove_cdrom_image domain_rexml = REXML::Document.new(@domain.xml_desc) cdrom_el = domain_rexml.elements["domain/devices/disk[@device='cdrom']"] cdrom_el.add_element('source', { 'file' => image }) update(domain_rexml.to_s) end def set_cdrom_boot(image) if is_running? raise "boot settings can only be set for inactive vms" end domain_rexml = REXML::Document.new(@domain.xml_desc) if not domain_rexml.elements["domain/devices/disk[@device='cdrom']"] add_cdrom_device end set_cdrom_image(image) set_boot_device('cdrom') end def list_disk_devs ret = [] domain_xml = REXML::Document.new(@domain.xml_desc) domain_xml.elements.each('domain/devices/disk') do |e| ret << e.elements['target'].attribute('dev').to_s end return ret end def plug_device(xml) 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_xml.to_s) end 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 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/target'].attributes['dev'] = dev xml.elements['disk/target'].attributes['bus'] = type xml.elements['disk/target'].attributes['removable'] = removable_usb if removable_usb plug_device(xml) end def disk_xml_desc(name) 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) return e.to_s end rescue next end end 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) 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) 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) if not(disk_plugged?(name)) set_boot_device('hd') # XXX:Stretch: since our isotesters upgraded QEMU from # 2.5+dfsg-4~bpo8+1 to 2.6+dfsg-3.1~bpo8+1 it seems we must remove # the CDROM device to allow disk boot. This is not the case with the same # version on Debian Sid. Let's hope we can remove this ugly # workaround when we only support running the automated test suite # on Stretch. domain_rexml = REXML::Document.new(@domain.xml_desc) if domain_rexml.elements["domain/devices/disk[@device='cdrom']"] remove_cdrom_device end end # XXX-9p: Shares don't work together with snapshot save+restore. See # XXX-9p in common_steps.rb for more information. def add_share(source, tag) if is_running? 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_xml.to_s) end def list_shares list = [] domain_xml = REXML::Document.new(@domain.xml_desc) domain_xml.elements.each('domain/devices/filesystem') do |e| list << e.elements['target'].attribute('dir').to_s end return list end def set_os_loader(type) if is_running? 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_xml.to_s) else raise "unsupported OS loader type" end end def is_running? begin return @domain.active? rescue return false end end def execute(cmd, options = {}) options[:user] ||= "root" options[:spawn] = false unless options.has_key?(:spawn) 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 RemoteShell::ShellCommand.new(self, cmd, options) end 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, options = {}) options[:spawn] = true return execute(cmd, options) end def wait_until_remote_shell_is_up(timeout = 180) VMCommand.wait_until_remote_shell_is_up(self, timeout) end def host_to_guest_time_sync host_time= DateTime.now.strftime("%s").to_s execute("date -s '@#{host_time}'").success? end def has_network? nmcli_info = execute('nmcli device show eth0').stdout has_ipv4_addr = /^IP4.ADDRESS(\[\d+\])?:\s*([0-9.\/]+)$/.match(nmcli_info) network_link_state == 'up' && has_ipv4_addr end def has_process?(process) return execute("pidof -x -o '%PPID' " + process).success? end def pidof(process) 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 1 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. # Sadly we have to rely on a lot of sleep() here since there's # little on the screen etc that we truly can rely on. sleep 5 select_virtual_desktop(1) sleep 5 select_virtual_desktop(0) sleep 5 do_focus(window_title, user) end rescue # noop end def file_exist?(file) 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 => user) assert(cmd.success?, "Could not cat '#{file}':\n#{cmd.stdout}\n#{cmd.stderr}") return cmd.stdout end 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 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 @display.start end def reset @domain.reset if is_running? end def power_off @domain.destroy if is_running? @display.stop end def take_screenshot(description) @display.take_screenshot(description) end def get_remote_shell_port domain_xml = REXML::Document.new(@domain.xml_desc) domain_xml.elements.each('domain/devices/serial') do |e| if e.attribute('type').to_s == "tcp" return e.elements['source'].attribute('service').to_s.to_i end end end end