diff --git a/src/vmm_mad/remotes/kvm/migrate b/src/vmm_mad/remotes/kvm/migrate index 9beb4af8b2..2e52c377b4 100755 --- a/src/vmm_mad/remotes/kvm/migrate +++ b/src/vmm_mad/remotes/kvm/migrate @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env ruby # -------------------------------------------------------------------------- # # Copyright 2002-2023, OpenNebula Project, OpenNebula Systems # @@ -15,296 +15,248 @@ # See the License for the specific language governing permissions and # # limitations under the License. # #--------------------------------------------------------------------------- # +ONE_LOCATION = ENV['ONE_LOCATION'] -DRIVER_PATH=$(dirname $0) -source "$DRIVER_PATH/../../etc/vmm/kvm/kvmrc" -source "$DRIVER_PATH/../../scripts_common.sh" -XPATH="$DRIVER_PATH/../../datastore/xpath.rb" - -get_qemu_img_version() { - qemu-img --version | head -1 | awk '{print $3}' | \ - sed -e 's/[^0-9\.]//' | awk -F. '{ printf("%d%03d%03d\n", $1,$2,$3); }' -} - -is_readonly() { - local DOMAIN=$1 - local DISK=$2 - - READ_ONLY=$(virsh --connect $LIBVIRT_URI dumpxml $DOMAIN | \ - $XPATH --stdin --subtree \ - "//domain/devices/disk[source/@file='$DISK']/readonly") - - [[ "$READ_ONLY" =~ '' ]] -} - -get_size_and_format_of_disk_img() { - local QEMU_IMG_PATH="$1" - local PARAM="$2" - - if [[ "$QEMU_IMG_PATH" =~ disk.[0-9]*.snap/ ]]; then - #Disk lives in snap dir it is a link (includes replica) - echo unknown qcow2-symlink - return - elif [ -L "$QEMU_IMG_PATH" ]; then - #Disk is a synlink, assume network-disk - echo unknown network-disk - return - fi - - IMG_INFO=$(qemu-img info $PARAM "$QEMU_IMG_PATH" --output json) - - if [ -z "$IMG_INFO" ]; then - echo "Failed to get image info for $QEMU_IMG_PATH" - exit 1 - fi - - SIZE=$(echo $IMG_INFO | sed -nE 's/^.*virtual-size.: ([0-9]+).*/\1/p') - FORMAT=$(echo $IMG_INFO | sed -nE 's/^.*format.: "([a-z0-9]+)".*/\1/p') - - if [ -z "$SIZE" ] || [ -z "$FORMAT" ]; then - echo "Failed to get image $QEMU_IMG_PATH size or format" - exit 1 - fi - - echo $SIZE $FORMAT -} - -create_target_disk_img() { - local DEST_HOST=$1 - local QEMU_IMG_PATH="$2" - local SIZE="$3" - local DISK_DIR="$(dirname $QEMU_IMG_PATH)" - - ssh_monitor_and_log "$DEST_HOST" \ - "mkdir -p '$DISK_DIR' && qemu-img create -f qcow2 '$QEMU_IMG_PATH' '$SIZE'" \ - "Failed to create new qcow image for $QEMU_IMG_PATH" -} - -STDIN=$(cat -) -DEPLOY_ID=$1 -DEST_HOST=$2 -DISKS=$(virsh --connect $LIBVIRT_URI domblklist "$DEPLOY_ID" \ - | tail -n+3 | grep -v "^$" | awk '{print $1 "," $2}') - - -unset i j XPATH_ELEMENTS -while IFS= read -r -d '' element; do - XPATH_ELEMENTS[i++]="$element" -done < <(echo $STDIN| $XPATH \ - /VMM_DRIVER_ACTION_DATA/DATASTORE/TEMPLATE/SHARED \ - /VMM_DRIVER_ACTION_DATA/DISK_TARGET_PATH) - -SHARED="${XPATH_ELEMENTS[j++]}" -VM_DIR="${XPATH_ELEMENTS[j++]}" - -# use "force-share" param for qemu >= 2.10 -[ "$(get_qemu_img_version)" -ge 2010000 ] && QEMU_IMG_PARAM="-U" - -# migration can't be done with domain snapshots, drop them first but save current snapshot for redefine -SNAP_CUR=$(virsh --connect $LIBVIRT_URI snapshot-current --name $DEPLOY_ID 2>/dev/null) - -SNAPS=$(monitor_and_log \ - "virsh --connect $LIBVIRT_URI snapshot-list $DEPLOY_ID --name 2>/dev/null" \ - "Failed to get snapshots for $DEPLOY_ID") - -for SNAP in $SNAPS; do - exec_and_log \ - "virsh --connect $LIBVIRT_URI snapshot-delete $DEPLOY_ID --snapshotname $SNAP --metadata" \ - "Failed to delete snapshot $SNAP from $DEPLOY_ID" -done - -# Compact memory -if [ "x$CLEANUP_MEMORY_ON_START" = "xyes" ]; then - ssh_exec_and_log "$DEST_HOST" "(sudo -l | grep -q sysctl) && sudo -n sysctl vm.drop_caches=3 vm.compact_memory=1 >/dev/null || true" \ - "Failed compact memory on $DEST_HOST" -fi - -if [ "$SHARED" = "YES" ]; then - retry_if_no_error "active block job" 3 5 virsh --connect $LIBVIRT_URI migrate \ - --live $MIGRATE_OPTIONS $DEPLOY_ID $QEMU_PROTOCOL://$DEST_HOST/system - - RC=$? +if !ONE_LOCATION + RUBY_LIB_LOCATION = '/usr/lib/one/ruby' + GEMS_LOCATION = '/usr/share/one/gems' + VMDIR = '/var/lib/one' + CONFIG_FILE = '/var/lib/one/config' else - if [[ -z "$DISKS" ]]; then - error_message "No disks discovered on the VM" - exit 1 - fi + RUBY_LIB_LOCATION = ONE_LOCATION + '/lib/ruby' + GEMS_LOCATION = ONE_LOCATION + '/share/gems' + VMDIR = ONE_LOCATION + '/var' + CONFIG_FILE = ONE_LOCATION + '/var/config' +end + +# %%RUBYGEMS_SETUP_BEGIN%% +if File.directory?(GEMS_LOCATION) + real_gems_path = File.realpath(GEMS_LOCATION) + if !defined?(Gem) || Gem.path != [real_gems_path] + $LOAD_PATH.reject! {|l| l =~ /vendor_ruby/ } + + # Suppress warnings from Rubygems + # https://github.com/OpenNebula/one/issues/5379 + begin + verb = $VERBOSE + $VERBOSE = nil + require 'rubygems' + Gem.use_paths(real_gems_path) + ensure + $VERBOSE = verb + end + end +end +# %%RUBYGEMS_SETUP_END%% + +$LOAD_PATH << RUBY_LIB_LOCATION + +require 'pathname' + +require_relative 'opennebula_vm' +require_relative '../lib/command' +require_relative '../lib/xmlparser' + +include VirtualMachineManagerKVM +include Command + +load_remote_env + +# ------------------------------------------------------------------------------ +# HELPER FUNCTIONS. +# Note input parameters are defined as instance variables +# - @deploy_id +# - @dst_host +# - @vm_dir +# - @shared +# ------------------------------------------------------------------------------ + +# sync paths to the destination VM folder +# +# @param paths [Array/String] Array of paths to sync +# +def rsync_paths(paths, raise_error = true) + return if paths.empty? + + opts = '-az' + paths = paths.join(' ') if paths.class == Array + dpath = "#{@dst_host}:#{@vm_dir}/" + + tini = Time.now + + rc, _o, e = Command.execute_log("rsync #{opts} #{paths} #{dpath}") + + STDERR.puts "rsync time #{Time.now-tini}s" + + raise StandardError, "Cannot rsync files: #{e}" if rc != 0 && raise_error +end + +# In case of error this function is used to remove migration leftovers. For non +# shared storage configuration it will remove the destination VM folder +# +# @param kvm_vm [KvmDomain] libvirt domain class +# @param error[String] message for the StandardError raised by the function +# +def cleanup_host(kvm_vm, error) + kvm_vm.destroy @dst_host + + kvm_vm.undefine @dst_host + + if !@shared + Command.ssh(:host => @dst_host, :cmds => "rm -rf #{@vm_dir}") + end + + raise StandardError, error +end + +# Migrate VMs running on local storage, using the copy-storage feature of libvirt +# Disks are scanned and classified as: +# - Regular disks are copied during migration (devs array). A place holder needs +# to be created in the destination host +# +# - Readonly disks are copied before starting the migration (pre_sync array) +# +# - Snapshots and other ancialliary files are also copied in the pre_sync phase +# +# - Network disks are assumed to be shared and not copied +# +# - qcow2 disks with system snapshots are rsync after migration to transfer +# the snapshots (wiped out during the migration) +# +# To allow this post sync phase the VM is paused after migration (--suspend) +# +# @param kvm_vm [KvmDomain] libvirt domain class +def local_migration(kvm_vm) + devs = [] + + pre_sync = ["#{@vm_dir}/*.xml"] + post_sync = [] + + kvm_vm.disks.each do |disk| + dev = disk[0] + path = disk[1] + + if !File.symlink? path #qcow2 & raw disks, regular files + qimg = QemuImg.new path + + format = qimg['format'] + size = qimg['virtual-size'] + snaps = qimg['snapshots'] && !qimg['snapshots'].empty? + + if format == 'raw' && kvm_vm.readonly?(path) + pre_sync << path + else + devs << dev + post_sync << path if format == 'qcow2' && snaps + + cmds =<<~EOS + mkdir -p #{File.dirname(path)} + qemu-img create -f #{format} #{path} #{size} + EOS + + Command.ssh(:host => @dst_host, :cmds => cmds, + :emsg => 'Cannot create disk') + end + elsif path.match(/disk.[0-9]*.snap\//) #qcow2-symlink, replica + devs << dev + #else + #network-disk, symlinks are assumed to be network disks + end + + # Add disk snapshots dir to the list of paths to sync + if File.directory? "#{path}.snap" + pre_sync << Pathname.new("#{path}.snap").cleanpath + elsif (m = path.match(/(disk.[0-9]*.snap)\//)) #replica + pre_sync << Pathname.new("#{@vm_dir}/#{m[1]}").cleanpath + end + + #recreate disk symlinks + if File.symlink? path + target = File.readlink(path) + lname = path + elsif (m = path.match(/(disk.([0-9]*).snap)\/.*/)) #replica + target = m[1] + lname = "disk.#{m[2]}" + else + next + end - ssh_monitor_and_log "$DEST_HOST" "mkdir -p '$VM_DIR'" \ - "Failed to make remote directory $VM_DIR image" + cmds =<<~EOS + cd #{@vm_dir} + [ -L "#{lname}" ] || ln -s "#{target}" "#{lname}" + EOS - MIGRATE_DISKS="" + Command.ssh(:host => @dst_host, :cmds => cmds, + :emsg => 'Cannot symlink disk') + end - for DISK_STR in $DISKS; do + rsync_paths(pre_sync) - DISK_DEV=${DISK_STR/,*/} - DISK_PATH=${DISK_STR/*,/} + rc, _out, err = kvm_vm.live_migrate_disks(@dst_host, devs) - read -r SIZE FORMAT <<<"$(get_size_and_format_of_disk_img "$DISK_PATH" "$QEMU_IMG_PARAM")" + cleanup_host(err) if rc != 0 - if [ "$FORMAT" = "raw" ]; then - if ! is_readonly $DEPLOY_ID $DISK_PATH; then - RAW_DISKS+=" $DISK_PATH" - MIGRATE_DISKS+="${MIGRATE_DISKS:+,}${DISK_DEV}" - fi + rsync_paths(post_sync) - # do initial rsync - multiline_exec_and_log "$TAR -cSf - $DISK_PATH | $SSH $DEST_HOST '$TAR -xSf - -C / '" \ - "Failed to rsync disk $DISK_PATH to $DEST_HOST:$DISK_PATH" - - elif [ "$FORMAT" = "qcow2" ]; then - create_target_disk_img "$DEST_HOST" "$DISK_PATH" "$SIZE" - MIGRATE_DISKS+="${MIGRATE_DISKS:+,}${DISK_DEV}" - - elif [ "$FORMAT" = "qcow2-symlink" ]; then - # don't create disk, .snap dir will be copied anyway - MIGRATE_DISKS+="${MIGRATE_DISKS:+,}${DISK_DEV}" - - elif [ "$FORMAT" = "network-disk" ]; then - true # skip - fi - - # copy disk snapshots - if [[ "$DISK_PATH" =~ (disk\.[0-9]*\.snap)/. ]]; then - SNAP_DIR="$VM_DIR/${BASH_REMATCH[1]}" - elif [ -d "${DISK_PATH}.snap" ]; then #it should be included in the clause above - SNAP_DIR="${DISK_PATH}.snap" - fi - - if [ -n "${SNAP_DIR}" ]; then - multiline_exec_and_log "$TAR -cSf - ${SNAP_DIR} | $SSH $DEST_HOST '$TAR -xSf - -C / '" \ - "Failed to rsync disk snapshot ${SNAP_DIR} to $DEST_HOST" - fi - - # recreate symlinks - if [ -L "$DISK_PATH" ]; then - TARGET=$(readlink $DISK_PATH) - LNAME=$DISK_PATH - elif [[ "$DISK_PATH" =~ (disk\.([0-9]*)\.snap/.*) ]]; then - TARGET=${BASH_REMATCH[1]} - LNAME="disk.${BASH_REMATCH[2]}" - else - continue - fi - - ssh_exec_and_log "$DEST_HOST" "cd ${VM_DIR}; [ -L \"$LNAME\" ] || ln -s \"$TARGET\" \"$LNAME\"" - "Failed to create symlink $TARGET -> $LNAME on $DEST_HOST" - done - - # copy vm.xml and ds.xml from the $VM_DIR - if ls $VM_DIR/*.xml > /dev/null; then - multiline_exec_and_log "$TAR -cSf - $VM_DIR/*.xml | $SSH $DEST_HOST '$TAR -xSf - -C / '" \ - "Failed to copy xml files to $DEST_HOST" - fi - - # freeze/suspend domain and rsync raw disks again - if [ -n "$RAW_DISKS" ]; then - if timeout ${VIRSH_TIMEOUT:-60} virsh --connect $LIBVIRT_URI domfsfreeze $DEPLOY_ID; then - # local domfsthaw for the case migration fails - trap "virsh --connect $LIBVIRT_URI domfsthaw $DEPLOY_ID" EXIT - FREEZE="yes" - else - if virsh --connect $LIBVIRT_URI suspend $DEPLOY_ID; then - # local resume for the case migration fails - trap "virsh --connect $LIBVIRT_URI resume $DEPLOY_ID" EXIT - SUSPEND="yes" + kvm_vm.resume(@dst_host) +end + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + +begin + @deploy_id = ARGV[0] + @dst_host = ARGV[1] + + action_xml = XMLElement.new_s(STDIN.read) + + @vm_dir = Pathname.new(action_xml['/VMM_DRIVER_ACTION_DATA/DISK_TARGET_PATH']).cleanpath + @shared = action_xml['/VMM_DRIVER_ACTION_DATA/DATASTORE/TEMPLATE/SHARED'].casecmp('YES') == 0 + + kvm_vm = KvmDomain.new(@deploy_id) + + # Migration can't be done with domain snapshots, drop them first + kvm_vm.snapshots_delete + + # Migrate VMs using shared/local storage + if @shared + rc, _out, err = kvm_vm.live_migrate(@dst_host) + + cleanup_host(kvm_vm, err) if rc != 0 + else + local_migration(kvm_vm) + end + + # Redefine system snapshots on the destination libvirtd + kvm_vm.snapshots_redefine(@dst_host, @vm_dir) + + # Sync guest time + if ENV['SYNC_TIME'].upcase == 'YES' + cmds =<<~EOS + ( + for I in $(seq 4 -1 1); do + if #{virsh} --readonly dominfo #{@deploy_id}; then + #{virsh} domtime --sync #{@deploy_id} && exit + [ "\$I" -gt 1 ] && sleep 5 else - error_message "Could not freeze or suspend the domain" - exit 1 + exit fi - fi - - for DISK in $RAW_DISKS; do - multiline_exec_and_log "$TAR -cSf - $DISK | $SSH $DEST_HOST '$TAR -xSf - -C / '" \ - "Failed to rsync disk $DISK to $DEST_HOST:$DISK" - done - fi - - # Enumerate disks to copy - if [ -n "$MIGRATE_DISKS" ]; then - DISK_OPTS="--copy-storage-all --migrate-disks ${MIGRATE_DISKS}" - fi - - retry_if_no_error "active block job" 3 5 \ - virsh --connect $LIBVIRT_URI migrate \ - --live $MIGRATE_OPTIONS $DEPLOY_ID $QEMU_PROTOCOL://$DEST_HOST/system \ - $DISK_OPTS - RC=$? - - # remote domfsthaw/resume, give it time - if [ $RC -eq 0 ]; then - if [ "$FREEZE" = "yes" ]; then - for I in $(seq 5); do - virsh --connect $QEMU_PROTOCOL://$DEST_HOST/system domfsthaw $DEPLOY_ID \ - && break - sleep 2 - done - elif [ "$SUSPEND" = "yes" ]; then - for I in $(seq 5); do - virsh --connect $QEMU_PROTOCOL://$DEST_HOST/system resume $DEPLOY_ID \ - && break - sleep 2 - done - fi - fi -fi - -# cleanup target host in case of error -if [ $RC -ne 0 ]; then - for CLEAN_OP in destroy undefine; do - virsh --connect $QEMU_PROTOCOL://$DEST_HOST/system "${CLEAN_OP}" $DEPLOY_ID >/dev/null 2>&1 - done - - if [ "$SHARED" != "YES" ]; then - ssh $DEST_HOST "rm -rf $VM_DIR" - fi - - error_message "Could not migrate $DEPLOY_ID to $DEST_HOST" - exit $RC -fi - -# redefine potential snapshots after live migration -if [ "$SHARED" = "YES" ]; then - UUID=$(virsh --connect $QEMU_PROTOCOL://$DEST_HOST/system dominfo $DEPLOY_ID | awk '/UUID:/ {print $2}') - DISK_PATH=$(virsh --connect $QEMU_PROTOCOL://$DEST_HOST/system domblklist $DEPLOY_ID | awk '/disk.0/ {print $2}') - DISK_DIR=$(dirname $DISK_PATH) - - if [[ "${DISK_DIR}" =~ .*/disk\.[0-9]+.snap$ ]]; then - DISK_DIR=$(dirname $DISK_DIR) - fi - - for SNAPSHOT_MD_XML in $(ls -v ${DISK_DIR}/snap-*.xml 2>/dev/null); do - # replace uuid in the snapshot metadata xml - sed -i "s%[[:alnum:]-]*%$UUID%" $SNAPSHOT_MD_XML - - # redefine the snapshot using the xml metadata file - virsh --connect $QEMU_PROTOCOL://$DEST_HOST/system snapshot-create $DEPLOY_ID $SNAPSHOT_MD_XML --redefine > /dev/null || true - done - - [ -n "$SNAP_CUR" ] && virsh --connect $QEMU_PROTOCOL://$DEST_HOST/system snapshot-current $DEPLOY_ID $SNAP_CUR -fi - -# Synchronize VM time on background on remote host -if [ "$SYNC_TIME" = "yes" ]; then - SYNC_TIME_CMD=$(cat </dev/null & -EOF -) - ssh_exec_and_log_no_error "${DEST_HOST}" \ - "${SYNC_TIME_CMD}" \ - "Failed to synchronize VM time" -fi - -# Compact memory -if [ "x$CLEANUP_MEMORY_ON_STOP" = "xyes" ]; then - (sudo -l | grep -q sysctl) && sudo -n sysctl vm.drop_caches=3 vm.compact_memory=1 &>/dev/null & -fi + done + ) &>/dev/null & + EOS + + rc, _o, e = Command.ssh(:host => @dst_host, :cmds => cmds, :emsg => '') + + STDERR.puts "Failed to synchronize VM time: #{e}" if rc != 0 + end + + # Compact memory + if ENV['CLEANUP_MEMORY_ON_STOP'].upcase == 'YES' + `(sudo -l | grep -q sysctl) && sudo -n sysctl vm.drop_caches=3 vm.compact_memory=1 &>/dev/null &` + end + +rescue StandardError => e + STDERR.puts "Error mirgating VM #{@deploy_id} to host #{@dst_host}: #{e.message}" + STDERR.puts "#{e.backtrace}" + exit(1) +end diff --git a/src/vmm_mad/remotes/lib/command.rb b/src/vmm_mad/remotes/lib/command.rb index 9d0daaa2ed..05ee0fbcc1 100644 --- a/src/vmm_mad/remotes/lib/command.rb +++ b/src/vmm_mad/remotes/lib/command.rb @@ -29,14 +29,28 @@ def self.execute(cmd, block, verbose = 0, opts = {}) begin fd = lock if block - STDERR.puts "Running command #{cmd}" if verbose >= 1 - stdout, stderr, s = Open3.capture3(cmd, opts) ensure unlock(fd) if block end - STDERR.puts "#{stdout}\n#{stderr}" if verbose == 2 + return [s.exitstatus, stdout, stderr] if verbose <= 0 + + stdin = if opts[:stdin_data] + opts[:stdin_data].lines.map { |l| "[stdin]: #{l}" }.join + else + '' + end + + if s.exitstatus == 0 + STDERR.puts cmd + STDERR.puts "#{stdin}" unless stdin.empty? + else + STDERR.puts "Error executing: #{cmd}" + STDERR.puts "#{stdin}" unless stdin.empty? + STDERR.puts "\t[stdout]: #{stdout}" unless stdout.empty? + STDERR.puts "\t[stderr]: #{stderr}" unless stderr.empty? + end [s.exitstatus, stdout, stderr] end @@ -47,20 +61,14 @@ def self.execute_once(cmd, lock) # Returns true/false if status is 0/!=0 and logs error if needed def self.execute_rc_log(cmd, lock = false) - rc, _stdout, stderr = execute(cmd, lock, 1) - - STDERR.puts stderr unless rc.zero? + rc = execute(cmd, lock, 1) - rc.zero? + rc[0] == 0 end # Execute cmd and logs error if needed def self.execute_log(cmd, lock = false) - rc = execute(cmd, lock, 1) - - STDERR.puts rc[2] unless rc[0].zero? - - rc + execute(cmd, lock, 1) end def self.execute_detach(cmd) @@ -87,4 +95,35 @@ def self.unlock(lfd) lfd.close end + def self.ssh(options = {}) + opt = { + :cmds => '', + :host => '', + :forward => false, + :nostdout => false, + :nostderr => false, + :verbose => 1, + :block => false, + :emsg => '' + }.merge!(options) + + script = <<~EOS + export LANG=C + export LC_ALL=C + #{opt[:cmds]} + EOS + + cmd = 'ssh -o ControlMaster=no -o ControlPath=none' + cmd << ' -o ForwardAgent=yes' if opt[:forward] + cmd << " #{opt[:host]} bash -s " + cmd << ' 1>/dev/null' if opt[:nostdout] + cmd << ' 2>/dev/null' if opt[:nostderr] + + r, o, e = execute(cmd, opt[:block], opt[:verbose], :stdin_data => script) + + return [r, o, e] if r == 0 || emsg.empty? + + raise StandardError, "#{emsg}: #{e}" + end + end diff --git a/src/vmm_mad/remotes/lib/kvm/opennebula_vm.rb b/src/vmm_mad/remotes/lib/kvm/opennebula_vm.rb index a10857d404..22014c0775 100644 --- a/src/vmm_mad/remotes/lib/kvm/opennebula_vm.rb +++ b/src/vmm_mad/remotes/lib/kvm/opennebula_vm.rb @@ -14,8 +14,11 @@ # limitations under the License. # #--------------------------------------------------------------------------- # require_relative '../lib/xmlparser' +require_relative '../lib/command' require_relative '../lib/opennebula_vm' +require 'json' + # rubocop:disable Style/ClassAndModuleChildren # rubocop:disable Style/ClassVars @@ -51,7 +54,14 @@ def load_env(path) next unless m - ENV[m[2]] = m[3].delete("\n") if m[2] && m[3] + k,v = m[2],m[3].strip + + # remove single or double quotes + if !v.empty? && v[0] == v[-1] && ["'", '"'].include?(v[0]) + v = v.slice(1, v.length-2) + end + + ENV[k] = v.delete("\n") if k && v end rescue StandardError end @@ -64,6 +74,323 @@ def virsh "virsh --connect #{uri}" end + # -------------------------------------------------------------------------- + # This class abstracts the information and several methods to operate over + # qcow2 disk images files + # -------------------------------------------------------------------------- + class QemuImg + + attr_reader :path + + def initialize(path) + @_info = nil + @path = path + end + + #@return[Array] with major, minor and micro qemu-img version numbers + def self.version + out, _err, _rc = Open3.capture3("qemu-img --version") + + m = out.lines.first.match(/([0-9]+)\.([0-9]+)\.([0-9]+)/) + + return "0000000" unless m + + [m[1], m[2], m[3]] + end + + # qemu-img command methods + # @param args[String] non option argument + # @param opts[Hash] options arguments: + # -keys options as symbols, e.g. + # :o '-o' + # :disks '--disks' + # :disk_only '--disk-only' + # :map= '--map= ' + # -values option values, can be empty + QEMU_IMG_COMMANDS = [ + 'convert', + 'create', + 'rebase', + 'info', + 'bitmap', + 'commit' + ] + + QEMU_IMG_COMMANDS.each do |command| + define_method(command.to_sym) do |args = '', opts| + cmd_str = "qemu-img #{command}" + + opts.each do |key, value| + next if key == :stdin_data + + if key.length == 1 + cmd_str << " -#{key}" + else + cmd_str << " --#{key.to_s.gsub('_', '-')}" + end + + if value && !value.empty? + cmd_str << ' ' if key[-1] != '=' + cmd_str << value.to_s + end + end + + out, err, rc = Open3.capture3("#{cmd_str} #{@path} #{args}", + :stdin_data => opts[:stdin_data]) + + if rc.exitstatus != 0 + msg = "Error executing: #{cmd_str} #{@path} #{args}\n" + msg << "\t[stderr]: #{err}" unless err.empty? + msg << "\t[stdout]: #{out}" unless out.empty? + + raise StandardError, msg + end + + out + end + end + + # Access image attribute + def [](key) + if !@_info + out = info(:output => 'json', :force_share => '') + @_info = JSON.parse(out) + end + + @_info[key] + end + end + + # + # This class provides abstractions to access KVM/Qemu libvirt domains + # + class KvmDomain + def initialize(domain) + @domain = domain + @xml = nil + + out, err, rc = Open3.capture3("#{virsh} dumpxml #{@domain}") + + if out.nil? || out.empty? || rc.exitstatus != 0 + raise StandardError, "Error getting domain info #{err}" + end + + @xml = XMLElement.new_s out + + @snapshots = [] + @snapshots_current = nil + + @disks = nil + end + + def [](xpath) + @xml[xpath] + end + + def exist?(xpath) + @xml.exist?(xpath) + end + + # --------------------------------------------------------------------- + # VM system snapshots interface + # --------------------------------------------------------------------- + + # Get the system snapshots of a domain + # + # @param query[Boolean] refresh the snapshot information by querying + # libvirtd daemon + # + # @return [Array] the array of snapshots name, and the current snapshot + # (if any) + def snapshots(query = true) + return [@snapshots, @snapshots_current] unless query + + o, e, rc = Open3.capture3("#{virsh} snapshot-list #{@domain} --name") + + if rc.exitstatus != 0 + raise StandardError, "Error getting domain snapshots #{e}" + end + + @snapshots = o.lines.map { |l| l.strip! } + @snapshots.reject! {|s| s.empty? } + + return [@snapshots, nil] if @snapshots.empty? + + o, e, rc = Open3.capture3("#{virsh} snapshot-current #{@domain} --name") + + @snapshots_current = o.strip if rc.exitstatus == 0 + + [@snapshots, @snapshots_current] + end + + # Delete domain metadata snapshots. + # @param query[Boolean] update the snapshot list of the domain + def snapshots_delete(query = true) + snapshots(query) + + delete = "#{virsh} snapshot-delete #{@domain} --metadata --snapshotname" + + @snapshots.each do |snap| + Command.execute_log("#{delete} #{snap}") + end + end + + # Redefine system snapshots on the destination libvirtd, the internal + # list needs to be bootstraped by using the snapshots or snapshots_delete + # + # @param host[String] where the snapshots will be defined + # @param dir[String] VM folder path to look for the XML snapshot + # metadata files + def snapshots_redefine(host, dir) + define = "#{virsh_cmd(host)} snapshot-create --redefine #{@domain}" + current = "#{virsh_cmd(host)} snapshot-current #{@domain}" + + @snapshots.each do |snap| + Command.execute_log("#{define} #{dir}/#{snap}.xml") + end + + if @snapshots_current + Command.execute_log("#{current} #{@snapshots_current}") + end + end + + # --------------------------------------------------------------------- + # vm disk interface + # --------------------------------------------------------------------- + + # Gets the list of disks of a domain as an Array of [dev, path] pairs + # - dev is the device name of the blk, e.g. vda, sdb... + # - path of the file for the virtual disk + def disks + if !@disks + o, e, rc = Open3.capture3("#{virsh} domblklist #{@domain}") + + if rc.exitstatus != 0 + raise StandardError, "Error getting domain snapshots #{e}" + end + + @disks = o.lines[2..].map { |l| l.split } + @disks.reject! {|s| s.empty? } + end + + @disks + end + + # @return [Boolean] true if the disk (by path) is readonly + def readonly?(disk_path) + exist? "//domain/devices/disk[source/@file='#{disk_path}']/readonly" + end + + # --------------------------------------------------------------------- + # domain operations + # --------------------------------------------------------------------- + + # Live migrate the domain to the target host (SHARED STORAGE variant) + # @param host[String] name of the target host + def live_migrate(host) + cmd = "migrate --live #{ENV['MIGRATE_OPTIONS']} #{@domain}" + cmd << " #{virsh_uri(host)}" + + virsh_retry(cmd, 'active block job', virsh_tries) + end + + # Live migrate the domain to the target host (LOCAL STORAGE variant) + # @param host[String] name of the target host + # @param devs[Array] of the disks that will be copied + def live_migrate_disks(host, devs) + cmd = "migrate --live #{ENV['MIGRATE_OPTIONS']} --suspend" + cmd << " #{@domain} #{virsh_uri(host)}" + + if !devs.empty? + cmd << " --copy-storage-all --migrate-disks #{devs.join(',')}" + end + + virsh_retry(cmd, 'active block job', virsh_tries) + end + + # Basic domain operations (does not require additional parameters) + VIRSH_COMMANDS = [ + 'resume', + 'destroy', + 'undefine' + ] + + VIRSH_COMMANDS.each do |command| + define_method(command.to_sym) do |host = nil| + Command.execute_log("#{virsh_cmd(host)} #{command} #{@domain}") + end + end + + # --------------------------------------------------------------------- + # Private function helpers + # --------------------------------------------------------------------- + private + + # @return [Integer] number of retries for virsh operations + def virsh_tries + vt = ENV['VIRSH_TRIES'].to_i + vt = 3 if vt == 0 + + vt + end + + # @return [String] including the --connect attribute to run virsh commands + def virsh_cmd(host = nil) + if host + "virsh --connect #{virsh_uri(host)}" + else + virsh + end + end + + # @return [String] to contact libvirtd in a host + def virsh_uri(host) + proto = ENV['QEMU_PROTOCOL'] + proto ||= 'qemu+ssh' + + "#{proto}://#{host}/system" + end + + # Retries a virsh operation if the returned error matches the provided + # one, + # + # @param cmd[String] the virsh command arguments + # @param no_error_str[String] when stderr matches the string the operation + # will be retried + # @param tries[Integer] number of tries + # @param secs[Integer] seconds to wait between tries + def virsh_retry(cmd, no_error_str, tries = 1, secs = 5) + out, err, rc = nil + + tini = Time.now + + loop do + tries = tries - 1 + + out, err, rc = Open3.capture3("#{virsh} #{cmd}") + + break if rc.exitstatus == 0 + + match = err.match(/#{no_error_str}/) + + break if tries == 0 || !match + + sleep(secs) + end + + if rc.exitstatus == 0 + STDERR.puts "#{virsh} #{cmd} (#{Time.now-tini}s)" + else + STDERR.puts "Error executing: #{virsh} #{cmd} (#{Time.now-tini}s)" + STDERR.puts "\t[stdout]: #{out}" unless out.empty? + STDERR.puts "\t[stderr]: #{err}" unless err.empty? + end + + [rc.exitstatus, out, err] + end + + end + #--------------------------------------------------------------------------- # OpenNebula KVM Virtual Machine #---------------------------------------------------------------------------