1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-03-28 14:50:08 +03:00

F #5516: Add onevmdump tool (TP) (#1929)

This commit is contained in:
Christian González 2022-04-12 15:16:19 +02:00 committed by GitHub
parent 0aad48afbe
commit ee683bac6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1303 additions and 2 deletions

View File

@ -323,7 +323,11 @@ LIB_DIRS="$LIB_LOCATION/ruby \
$LIB_LOCATION/onecfg/lib/config/type \
$LIB_LOCATION/onecfg/lib/config/type/augeas \
$LIB_LOCATION/onecfg/lib/config/type/yaml \
$LIB_LOCATION/onecfg/lib/patch"
$LIB_LOCATION/onecfg/lib/patch \
$LIB_LOCATION/ruby/onevmdump \
$LIB_LOCATION/ruby/onevmdump/lib \
$LIB_LOCATION/ruby/onevmdump/lib/exporters \
$LIB_LOCATION/ruby/onevmdump/lib/restorers"
VAR_DIRS="$VAR_LOCATION/remotes \
$VAR_LOCATION/remotes/etc \
@ -774,6 +778,10 @@ INSTALL_FILES=(
CONTEXT_SHARE:$SHARE_LOCATION/context
DOCKERFILE_TEMPLATE:$SHARE_LOCATION/dockerhub
DOCKERFILES_TEMPLATES:$SHARE_LOCATION/dockerhub/dockerfiles
ONEVMDUMP_FILES:$LIB_LOCATION/ruby/onevmdump
ONEVMDUMP_LIB_FILES:$LIB_LOCATION/ruby/onevmdump/lib
ONEVMDUMP_LIB_EXPORTERS_FILES:$LIB_LOCATION/ruby/onevmdump/lib/exporters
ONEVMDUMP_LIB_RESTORERS_FILES:$LIB_LOCATION/ruby/onevmdump/lib/restorers
)
INSTALL_CLIENT_FILES=(
@ -984,6 +992,7 @@ BIN_FILES="src/nebula/oned \
src/cli/onevntemplate \
src/cli/onehook \
src/onedb/onedb \
src/onevmdump/onevmdump \
share/scripts/qemu-kvm-one-gen \
share/scripts/one"
@ -2235,6 +2244,22 @@ ONEDB_FILES="src/onedb/fsck.rb \
ONEDB_PATCH_FILES="src/onedb/patches/4.14_monitoring.rb \
src/onedb/patches/history_times.rb"
#-------------------------------------------------------------------------------
# onevmdump command, to be installed under $LIB_LOCATION
#-------------------------------------------------------------------------------
ONEVMDUMP_FILES="src/onevmdump/onevmdump.rb"
ONEVMDUMP_LIB_FILES="src/onevmdump/lib/command.rb \
src/onevmdump/lib/commons.rb"
ONEVMDUMP_LIB_EXPORTERS_FILES="src/onevmdump/lib/exporters/base.rb \
src/onevmdump/lib/exporters/file.rb \
src/onevmdump/lib/exporters/lv.rb \
src/onevmdump/lib/exporters/rbd.rb"
ONEVMDUMP_LIB_RESTORERS_FILES="src/onevmdump/lib/restorers/base.rb"
#-------------------------------------------------------------------------------
# Configuration files for OpenNebula, to be installed under $ETC_LOCATION
#-------------------------------------------------------------------------------
@ -3023,7 +3048,8 @@ MAN_FILES="share/man/oneacct.1.gz \
share/man/onemarket.1.gz \
share/man/onemarketapp.1.gz \
share/man/onevmgroup.1.gz \
share/man/onevntemplate.1.gz"
share/man/onevntemplate.1.gz \
share/man/onevmdump.1.gz"
#-----------------------------------------------------------------------------
# Docs Files

View File

@ -52,6 +52,7 @@ COMMANDS=(
'oneflow' 'Manage oneFlow Services'
'oneflow-template' 'Manage oneFlow Templates'
'onevmdump' 'Dumps VM content'
)
DIR_BUILD=$(mktemp -d)

View File

@ -825,6 +825,10 @@ module OpenNebula
self['DEPLOY_ID']
end
def get_history_record(seq)
retrieve_xmlelements('//HISTORY')[seq].to_xml
end
def wait_state(state, timeout=120)
require 'opennebula/wait_ext'

View File

@ -0,0 +1,146 @@
# -------------------------------------------------------------------------- #
# Copyright 2002-2021, OpenNebula Project, OpenNebula Systems #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may #
# not use this file except in compliance with the License. You may obtain #
# a copy of the License at #
# #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
#--------------------------------------------------------------------------- #
require 'open3'
# Command helper class
class Command
def initialize(remote_user = nil, remote_host = nil)
@local = true
@host = nil
@user = nil
# Set up host if provided
if !remote_host.nil? && !remote_host.empty?
@host = remote_host
@local = false
end
# Return if no user provided
return if remote_user.nil? || remote_user.empty?
# Fail if user provided but not host
raise 'Remote user provided, but host is empty.' unless @host
@user = remote_user
end
# Executes a command either locally on remotely depending on the
# configuration.
#
# The comand and each arg will be wraped by single quotes to avoid
# injections attacks.
#
# @return [String, String, Process::Status] the standard output,
# standard error and
# status returned by
def run(cmd, *args)
if cmd.split.size > 1
raise 'Cannot run cmd for security reasons.' \
'Check run_insecure method'
end
cmd = "'#{cmd}'"
args = quote_args(args)
run_insecure(cmd, args)
end
# Executes a command either locally on remotely depending on the
# configuration. And redirect the stdout and stderr to the path
# contained in the corresponding variables
#
# The comand and each arg will be wraped by single quotes to avoid
# injections attacks.
#
# @return [String, String, Process::Status] the standard output,
# standard error and
# status returned by
def run_redirect_output(cmd, stdout, stderr, *args)
cmd = "'#{cmd}'"
args = quote_args(args)
args << "> #{stdout}" if !stdout.nil? && !stdout.empty?
args << "2> #{stderr}" if !stderr.nil? && !stderr.empty?
run_insecure(cmd, args)
end
# Executes a command either locally on remotely depending on the
# configuration. And redirect the stdout and stderr to the path
# contained in the corresponding variables
#
# This method won't validate the input, hence the command execution
# is prone to injection attacks. Ensure both cmd and arguments have
# been validated before using this method and when possible use secure
# methods instead.
#
# @return [String, String, Process::Status] the standard output,
# standard error and
# status returned by
def run_insecure(cmd, *args)
if @local
run_local(cmd, args)
else
run_ssh(@user, @host, cmd, args)
end
end
private
# Executes a command locally
# @return [String, String, Process::Status] the standard output,
# standard error and
# status returned by
# Open3.capture3
def run_local(cmd, *args)
cmd_str = "#{cmd} #{args.join(' ')}"
Open3.capture3(cmd_str)
end
# Executes a command remotely via SSH
# @return [String, String, Process::Status] the standard output,
# standard error and
# status returned by
# Open3.capture3
def run_ssh(user, host, cmd, *args)
ssh_usr = ''
ssh_usr = "-l \"#{user}\"" if !user.nil? && !user.empty?
# TODO, should we make this configurabe?
ssh_opts = '-o ForwardAgent=yes -o ControlMaster=no ' \
'-o ControlPath=none -o StrictHostKeyChecking=no'
cmd_str = "ssh #{ssh_opts} #{ssh_usr} '#{host}' "
cmd_str << "bash -s <<EOF\n"
cmd_str << "export LANG=C\n"
cmd_str << "export LC_ALL=C\n"
cmd_str << "#{cmd} #{args.join(' ')}\n"
cmd_str << 'EOF'
Open3.capture3(cmd_str)
end
def quote_args(args)
args.map do |arg|
"'#{arg}'"
end
end
end

View File

@ -0,0 +1,51 @@
# -------------------------------------------------------------------------- #
# Copyright 2002-2021, OpenNebula Project, OpenNebula Systems #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may #
# not use this file except in compliance with the License. You may obtain #
# a copy of the License at #
# #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
#--------------------------------------------------------------------------- #
# Module containing commnon functions for Exporter and Restorer classes
module Commons
private
def create_tmp_folder(base_path)
prefix = 'onevmdump'
# Create temporal folder
rc = @cmd.run('mktemp', '-d', '-p', base_path, "#{prefix}.XXX")
unless rc[2].success?
raise "Error creating temporal directory: #{rc[1]}"
end
# Return STDOUT
rc[0].strip
end
def check_state(vm)
state = vm.state_str
lcm = vm.lcm_state_str
msg = "Invalid state: #{state}"
raise msg unless self.class::VALID_STATES.include?(state)
msg = "Invalid LCM state: #{lcm}"
raise msg unless self.class::VALID_LCM_STATES.include?(lcm)
end
def running?
@vm.lcm_state_str == 'RUNNING' || @vm.lcm_state_str == 'BACKUP'
end
end

View File

@ -0,0 +1,133 @@
# -------------------------------------------------------------------------- #
# Copyright 2002-2021, OpenNebula Project, OpenNebula Systems #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may #
# not use this file except in compliance with the License. You may obtain #
# a copy of the License at #
# #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
#--------------------------------------------------------------------------- #
require_relative '../command'
require_relative '../commons'
# Base class with the exporters interface
class BaseExporter
# --------------------------------------------------------------------------
# Default configuration options
# --------------------------------------------------------------------------
DEFAULT_CONF = {
# workaround: using "//" as libvirt needs the path to match exactly
:ds_location => '/var/lib/one//datastores',
:remote_host => nil,
:remote_user => nil,
:destination_path => nil,
:destination_host => nil,
:destination_user => nil
}
VALID_STATES = %w[ACTIVE
POWEROFF
UNDEPLOYED]
VALID_LCM_STATES = %w[LCM_INIT
RUNNING
BACKUP
BACKUP_POWEROFF
BACKUP_UNDEPLOYED]
def initialize(vm, config)
@vm = vm
@config = DEFAULT_CONF.merge(config)
# Will raise an error if invalid state/lcm_state
check_state(@vm)
# Check if the action needs to be live
@live = running?
# Get System DS ID from last history record
begin
last_hist_rec = Nokogiri.XML(@vm.get_history_record(-1))
@sys_ds_id = Integer(last_hist_rec.xpath('//DS_ID').text)
rescue StandardError
raise 'Cannot retrieve system DS ID. The last history record' \
' might be corrupted or it might not exists.'
end
# Get Command
@cmd = Command.new(@config[:remote_user], @config[:remote_host])
# Build VM folder path
@vm_path = "#{@config[:ds_location]}/#{@sys_ds_id}/#{@vm.id}"
@tmp_path = create_tmp_folder(@vm_path)
end
def export
# Export disks
@vm.retrieve_xmlelements('//DISK/DISK_ID').each do |disk_id|
disk_id = Integer(disk_id.text)
if @live
export_disk_live(disk_id)
else
export_disk_cold(disk_id)
end
end
# Dump VM xml to include it in the final bundle
@cmd.run_redirect_output('echo', "#{@tmp_path}/vm.xml", nil, @vm.to_xml)
create_bundle
ensure
cleanup
end
def cleanup
@cmd.run('rm', '-rf', @tmp_path)
end
private
include Commons
def gen_bundle_name
return "'#{@config[:destination_path]}'" if @config[:destination_path]
timestamp = Time.now.strftime('%s')
"/tmp/onevmdump-#{@vm.id}-#{timestamp}.tar.gz"
end
def create_bundle
bundle_name = gen_bundle_name
cmd = "tar -C #{@tmp_path} -czS"
if @config[:destination_host].nil? || @config[:destination_host].empty?
dst = " -f #{bundle_name} ."
else
destination_user = @config[:destination_user]
if !destination_user.nil? && !destination_user.empty?
usr = "-l '#{destination_user}'"
end
dst = " - . | ssh '#{@config[:destination_host]}' #{usr}" \
" \"cat - > #{bundle_name}\""
end
cmd << dst
rc = @cmd.run_insecure(cmd)
raise "Error creating bundle file: #{rc[1]}" unless rc[2].success?
bundle_name
end
end

View File

@ -0,0 +1,137 @@
# -------------------------------------------------------------------------- #
# Copyright 2002-2021, OpenNebula Project, OpenNebula Systems #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may #
# not use this file except in compliance with the License. You may obtain #
# a copy of the License at #
# #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
#--------------------------------------------------------------------------- #
require_relative 'base'
# OneVMDump module
#
# Module for exporting VM content into a bundle file
module OneVMDump
# FileExporter class
class FileExporter < BaseExporter
private
#######################################################################
# Export methods used by base
#######################################################################
def export_disk_live(disk_id)
type = file_type(disk_path(disk_id))
case type
when :qcow2
export_qcow2_live(disk_id)
when :raw, :cdrom
export_raw_live(disk_id)
end
end
def export_disk_cold(disk_id)
type = file_type(disk_path(disk_id))
case type
when :qcow2
export_qcow2_cold(disk_id)
when :raw, :cdrom
export_raw_cold(disk_id)
end
end
#######################################################################
# RAW export methods
#######################################################################
def export_raw_cold(disk_id)
path = disk_path(disk_id)
dst_path = "#{@tmp_path}/backup.#{File.basename(path)}"
@cmd.run('cp', path, dst_path)
end
alias export_raw_live export_raw_cold
#######################################################################
# QCOWw export methods
#######################################################################
def export_qcow2_live(disk_id)
path = disk_path(disk_id)
# blockcopy:
# Copy a disk backing image chain to a destination.
dst_path = "#{@tmp_path}/backup.#{File.basename(path)}"
@cmd.run('touch', dst_path) # Create file to set ownership
rc = @cmd.run('virsh', '-c', 'qemu:///system', 'blockcopy',
"one-#{@vm.id}", '--path', path, '--dest',
dst_path, '--wait', '--finish')
raise "Error exporting '#{path}': #{rc[1]}" unless rc[2].success?
end
def export_qcow2_cold(disk_id)
path = disk_path(disk_id)
dst_path = "#{@tmp_path}/backup.#{File.basename(path)}"
rc = @cmd.run('qemu-img', 'convert', '-q', '-O', 'qcow2',
path, dst_path)
raise "Error exporting '#{path}': #{rc[1]}" unless rc[2].success?
end
#######################################################################
# Helpers
#######################################################################
# Retruns the file type of the given path (it will follow sym links)
#
# Supported types:
# - :qcow2
# - :cdrom
# - :raw
#
def file_type(path)
real_path = path
real_path = "#{@vm_path}/#{readlink(path)}" if symlink?(path)
raw_type = @cmd.run('file', real_path)[0].strip
case raw_type
when /^.*QEMU QCOW2 Image.*$/
:qcow2
when /^.*CD-ROM.*$/
:cdrom
else
:raw
end
end
def disk_path(disk_id)
"#{@vm_path}/disk.#{disk_id}"
end
def symlink?(path)
@cmd.run('test', '-L', path)[2].success?
end
def readlink(path)
@cmd.run('readlink', path)[0].strip
end
end
end

View File

@ -0,0 +1,112 @@
# -------------------------------------------------------------------------- #
# Copyright 2002-2021, OpenNebula Project, OpenNebula Systems #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may #
# not use this file except in compliance with the License. You may obtain #
# a copy of the License at #
# #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
#--------------------------------------------------------------------------- #
require_relative 'base'
# OneVMDump module
#
# Module for exporting VM content into a bundle file
module OneVMDump
# BlockExporer class
# It exports the content from LVs into a bundle
class LVExporter < BaseExporter
private
def export_disk_live(disk_id)
# Freeze filesystem
rc = @cmd.run('virsh', '-c', 'qemu:///system', 'domfsfreeze',
"one-#{@vm.id}")
unless rc[2].success?
raise "Error freezing domain: #{rc[1]}"
end
# Take LV snapshot
# TODO: Crete snapshot of proportional size of the disk?
begin
lv = get_device(disk_id)
snap_name = "#{File.basename(lv)}-backup-snap"
rc = @cmd.run('sudo', 'lvcreate', '-s', '-L', '1G', '-n',
snap_name, lv)
ensure
@cmd.run('virsh', '-c', 'qemu:///system', 'domfsthaw',
"one-#{@vm.id}")
end
unless rc[2].success?
raise "Error creating snapshot for #{lv}: #{rc[1]}"
end
# Dump content
snap_lv = "#{File.dirname(lv)}/#{snap_name}"
dst_path = dst_path(disk_id)
rc = @cmd.run('dd', "if=#{snap_lv}", "of=#{dst_path}")
unless rc[2].success?
raise "Error writting '#{snap_lv}' content into #{dst_path}:" \
" #{rc[1]}"
end
ensure
@cmd.run('sudo', 'lvremove', '-f', snap_lv) if snap_lv
end
def export_disk_cold(disk_id)
device = get_device(disk_id)
dst_path = dst_path(disk_id)
active = check_active(device)
# Activate LV
if !active
rc = @cmd.run('lvchange', '-ay', device)
msg = "Error activating '#{device}': #{rc[1]}"
raise msg unless rc[2].success?
end
# Dump content
rc = @cmd.run('dd', "if=#{device}", "of=#{dst_path}")
unless rc[2].success?
raise "Error writting '#{device}' content into" \
" #{dst_path}: #{rc[1]}"
end
ensure
# Ensure LV is in the same state as before
@cmd.run('lvchange', '-an', device) unless active
end
########################################################################
# Helpers
########################################################################
def get_device(disk_id)
"/dev/vg-one-#{@sys_ds_id}/lv-one-#{@vm.id}-#{disk_id}"
end
def check_active(device)
@cmd.run('test', '-e', device)[2].success?
end
def dst_path(disk_id)
"#{@tmp_path}/backup.disk.#{disk_id}"
end
end
end

View File

@ -0,0 +1,90 @@
# -------------------------------------------------------------------------- #
# Copyright 2002-2021, OpenNebula Project, OpenNebula Systems #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may #
# not use this file except in compliance with the License. You may obtain #
# a copy of the License at #
# #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
#--------------------------------------------------------------------------- #
require_relative 'base'
# OneVMDump module
#
# Module for exporting VM content into a bundle file
module OneVMDump
# RBDExporter class
class RBDExporter < BaseExporter
private
def export_disk_live(disk_id)
# Freeze filesystem
rc = @cmd.run('virsh', '-c', 'qemu:///system', 'domfsfreeze',
"one-#{@vm.id}")
unless rc[2].success?
raise "Error freezing domain: #{rc[1]}"
end
export_disk_cold(disk_id)
ensure
@cmd.run('virsh', '-c', 'qemu:///system', 'domfsthaw',
"one-#{@vm.id}")
end
def export_disk_cold(disk_id)
path = disk_path(disk_id)
# Export rbd
# Assume rbd version 2
cmd = rbd_cmd(disk_id)
cmd.append('export', path, "#{@tmp_path}/backup.disk.#{disk_id}")
rc = @cmd.run(cmd[0], *cmd[1..-1])
raise "Error exporting '#{path}': #{rc[1]}" unless rc[2].success?
end
########################################################################
# Helpers
########################################################################
def rbd_cmd(disk_id)
cmd = ['rbd']
disk_xpath = "//DISK[DISK_ID = #{disk_id}]"
# rubocop:disable Layout/LineLength
ceph_user = @vm.retrieve_xmlelements("#{disk_xpath}/CEPH_USER")[0].text rescue nil
ceph_key = @vm.retrieve_xmlelements("#{disk_xpath}/CEPH_KEY")[0].text rescue nil
ceph_conf = @vm.retrieve_xmlelements("#{disk_xpath}/CEPH_CONF")[0].text rescue nil
cmd.append('--id', ceph_user) if !ceph_user.nil? && !ceph_user.empty?
cmd.append('--keyfile', ceph_key) if !ceph_key.nil? && !ceph_key.empty?
cmd.append('--conf', ceph_conf) if !ceph_conf.nil? && !ceph_conf.empty?
# rubocop:enable Layout/LineLength
cmd
end
def disk_path(disk_id)
disk_xpath = "//DISK[DISK_ID = #{disk_id}]"
source = @vm.retrieve_xmlelements("#{disk_xpath}/SOURCE")[0].text
if source.nil? || source.empty?
raise "Error retrieving source from disk #{disk_id}"
end
"#{source}-#{@vm.id}-#{disk_id}"
end
end
end

View File

@ -0,0 +1,284 @@
# -------------------------------------------------------------------------- #
# Copyright 2002-2021, OpenNebula Project, OpenNebula Systems #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may #
# not use this file except in compliance with the License. You may obtain #
# a copy of the License at #
# #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
#--------------------------------------------------------------------------- #
require 'uri'
require 'ffi-rzmq'
require 'opennebula'
require_relative '../command'
require_relative '../commons'
# Base class with the restorer interface
class BaseRestorer
# --------------------------------------------------------------------------
# Constants
# --------------------------------------------------------------------------
VALID_STATES = %w[DONE
POWEROFF
UNDEPLOYED]
VALID_LCM_STATES = %w[LCM_INIT]
# --------------------------------------------------------------------------
# Default configuration options
# --------------------------------------------------------------------------
DEFAULT_CONF = {
:tmp_location => '/var/tmp/one',
:restore_nics => false
}
def initialize(bundle_path, config)
@config = DEFAULT_CONF.merge(config)
@bundle_path = bundle_path
@bundle_name = File.basename(bundle_path)
@cmd = Command.new(nil, nil)
end
def restore
client = OpenNebula::Client.new(nil, @config[:endpoint])
tmp_path = decompress_bundle
vm_xml = OpenNebula::XMLElement.build_xml(
File.read("#{tmp_path}/vm.xml"),
'VM'
)
vm = OpenNebula::VirtualMachine.new(vm_xml, client)
@config[:tmpl_name] = "backup-#{vm.id}" unless @config[:tmpl_name]
new_disks = []
# Register each bundle disk as a new image
Dir.glob("#{tmp_path}/backup.disk.*") do |disk|
disk_id = Integer(disk.split('.')[-1])
disk_xpath = "/VM//DISK[DISK_ID = #{disk_id}]"
ds_id = Integer(vm_xml.xpath("#{disk_xpath}/DATASTORE_ID").text)
type = vm_xml.xpath("#{disk_xpath}/IMAGE_TYPE").text
img_tmpl = ''
img_tmpl << "NAME=\"#{@config[:tmpl_name]}-disk-#{disk_id}\"\n"
img_tmpl << "TYPE=\"#{type}\"\n"
img_tmpl << "PATH=\"#{disk}\"\n"
img = OpenNebula::Image.new(OpenNebula::Image.build_xml, client)
rc = img.allocate(img_tmpl, ds_id)
img = nil if OpenNebula.is_error?(rc)
new_disks << {
:id => disk_id,
:img => img
}
end
wait_disks_ready(new_disks, client)
# Important to use XML from the backup
tmpl_info = generate_template(vm_xml, new_disks)
tmpl = OpenNebula::Template.new(OpenNebula::Template.build_xml, client)
rc = tmpl.allocate(tmpl_info)
if OpenNebula.is_error?(rc)
# roll back image creation if one fails
new_disks.each do |new_disk|
OpenNebula::Image.new_with_id(new_disk[:img_id], client).delete
end
raise "Error creating VM Template from backup: #{rc.message}"
end
tmpl.id
ensure
# cleanup
@cmd.run('rm', '-rf', tmp_path) if !tmp_path.nil? && !tmp_path.empty?
end
private
include Commons
def decompress_bundle
tmp_path = create_tmp_folder(@config[:tmp_location])
rc = @cmd.run('tar', '-C', tmp_path, '-xf', @bundle_path)
raise "Error decompressing bundle file: #{rc[1]}" unless rc[2].success?
tmp_path
end
def generate_template(vm_xml, new_disks)
template = ''
begin
template << "NAME = \"#{@config[:tmpl_name]}\"\n"
template << "CPU = \"#{vm_xml.xpath('TEMPLATE/CPU').text}\"\n"
template << "VCPU = \"#{vm_xml.xpath('TEMPLATE/VCPU').text}\"\n"
template << "MEMORY = \"#{vm_xml.xpath('TEMPLATE/MEMORY').text}\"\n"
template << "DESCRIPTION = \"VM restored from backup.\"\n"
# Add disks
disk_black_list = Set.new(%w[ALLOW_ORPHANS CLONE CLONE_TARGET
CLUSTER_ID DATASTORE DATASTORE_ID
DEV_PREFIX DISK_ID
DISK_SNAPSHOT_TOTAL_SIZE DISK_TYPE
DRIVER IMAGE IMAGE_ID IMAGE_STATE
IMAGE_UID IMAGE_UNAME LN_TARGET
OPENNEBULA_MANAGED ORIGINAL_SIZE
PERSISTENT READONLY SAVE SIZE SOURCE
TARGET TM_MAD TYPE])
new_disks.each do |disk|
disk_xpath = "/VM//DISK[DISK_ID = #{disk[:id]}]/*"
disk_tmpl = "DISK = [\n"
vm_xml.xpath(disk_xpath).each do |item|
# Add every attribute but image related ones
next if disk_black_list.include?(item.name)
disk_tmpl << "#{item.name} = \"#{item.text}\",\n"
end
disk_tmpl << "IMAGE_ID = #{disk[:img].id}]\n"
template << disk_tmpl
end
# Add NICs
nic_black_list = Set.new(%w[AR_ID BRIDGE BRIDGE_TYPE CLUSTER_ID IP
IP6 IP6_ULA IP6_GLOBAL NAME NETWORK_ID
NIC_ID TARGET VLAN_ID VN_MAD])
if @config[:restore_nics]
%w[NIC NIC_ALIAS].each do |type|
vm_xml.xpath("/VM//#{type}").each do |nic|
nic_tmpl = "#{type} = [\n"
nic.xpath('./*').each do |item|
next if nic_black_list.include?(item.name)
nic_tmpl << "#{item.name} = \"#{item.text}\",\n"
end
# remove ',\n' for last elem
template << nic_tmpl[0..-3] << "]\n"
end
end
end
###########################################################
# TODO, evaluate what else should be copy from original VM
###########################################################
rescue StandardError => e
msg = 'Error parsing VM information. '
msg << "#{e.message}\n#{e.backtrace}" if @config[:debug]
raise msg
end
template
end
def wait_disks_ready(disks, client)
context = ZMQ::Context.new(1)
subscriber = context.socket(ZMQ::SUB)
poller = ZMQ::Poller.new
poller.register(subscriber, ZMQ::POLLIN)
uri = URI(@config[:endpoint])
error = false
subscriber.connect("tcp://#{uri.host}:2101")
# Subscribe for every IMAGE
imgs_set = Set.new
disks.each do |disk|
if disk[:img].nil?
error = true
next
end
# subscribe to wait until every image is ready
img_id = disk[:img].id
%w[READY ERROR].each do |i|
key = "EVENT IMAGE #{img_id}/#{i}/"
subscriber.setsockopt(ZMQ::SUBSCRIBE, key)
end
imgs_set.add(Integer(img_id))
end
# Wait until every image is ready (or limit retries)
retries = 60
key = ''
content = ''
while !imgs_set.empty? && retries > 0
if retries % 10 == 0
# Check manually in case the event is missed
imgs_set.clone.each do |id|
img = OpenNebula::Image.new_with_id(id, client)
img.info
next unless %w[READY ERROR].include?(img.state_str)
error = true if img.state_str.upcase == 'ERROR'
imgs_set.delete(id)
end
end
break if imgs_set.empty?
# 60 retries * 60 secs timeout (select) 1h timeout worst case
if !poller.poll(60 * 1000).zero?
subscriber.recv_string(key)
subscriber.recv_string(content)
match = key.match(%r{EVENT IMAGE (?<img_id>\d+)/(?<state>\S+)/})
img_id = Integer(match[:img_id])
%w[READY ERROR].each do |i|
key = "EVENT IMAGE #{img_id}/#{i}/"
subscriber.setsockopt(ZMQ::UNSUBSCRIBE, key)
end
error = true if match[:state] == 'ERROR'
imgs_set.delete(img_id)
else
retries -= 1
end
end
raise 'Error allocating new images.' if error
ensure
# Close socket
subscriber.close
# Rollback - remove every image if error
if error
disks.each do |disk|
disk[:img].delete unless disk[:img].nil?
end
end
end
end

261
src/onevmdump/onevmdump Executable file
View File

@ -0,0 +1,261 @@
#!/usr/bin/ruby
# -------------------------------------------------------------------------- #
# Copyright 2002-2021, OpenNebula Project, OpenNebula Systems #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may #
# not use this file except in compliance with the License. You may obtain #
# a copy of the License at #
# #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
#--------------------------------------------------------------------------- #
############################################################################
# Set up Frontend libraries location
############################################################################
ONE_LOCATION = ENV['ONE_LOCATION']
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'
LOG_FILE = '/var/log/one/host_error.log'
else
RUBY_LIB_LOCATION = ONE_LOCATION + '/lib/ruby'
GEMS_LOCATION = ONE_LOCATION + '/share/gems'
VMDIR = ONE_LOCATION + '/var'
CONFIG_FILE = ONE_LOCATION + '/var/config'
LOG_FILE = ONE_LOCATION + '/var/host_error.log'
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
$LOAD_PATH << RUBY_LIB_LOCATION + '/onevmdump'
############################################################################
# Required libraries
############################################################################
require 'nokogiri'
require 'optparse'
require 'opennebula'
require 'onevmdump'
############################################################################
# Constants
############################################################################
HELP_MSG = <<~EOF
COMMANDS
\texport\t\tExports VM into a bundle file.
\trestore\t\tRestore a VM from a bundle file.
\tCheck 'onevmdump COMMAND --help' for more information on an specific command.
EOF
############################################################################
# Parameters initalization
############################################################################
options = {
:endpoint => 'http://localhost:2633/RPC2'
}
############################################################################
# General optinos parser
############################################################################
global_parser = OptionParser.new do |opts|
opts.banner = 'Usage: onevmdump [options] [COMMAND [options]]'
desc = 'Run it on debug mode'
opts.on('-D', '--debug', desc) do |v|
options[:debug] = v
end
desc = 'OpenNebula endpoint (default http://localhost:2633/RPC2)'
opts.on('--endpoint=ENDPOINT', desc) do |v|
options[:endpoint] = v
end
opts.separator ''
opts.separator HELP_MSG
end
############################################################################
# Commands optinos parser
############################################################################
commands_parsers = {
'export' => OptionParser.new do |opts|
opts.banner = 'Usage: onevmdump export [options] <vm_id>'
options[:lock] = true
desc = 'Path where the bundle will be created (default /tmp)'
opts.on('-dPATH', '--destination-path=PATH', desc) do |v|
options[:destination_path] = v
end
desc = 'Destination host for the bundle'
opts.on('--destination-host=HOST', desc) do |v|
options[:destination_host] = v
end
desc = 'Avoid locking the VM while doing the backup (another security' \
' messures should be taken to avoid un expected status changes.)'
opts.on('-L', '--no-lock', desc) do |v|
options[:lock] = v # as parameter is *no*-lock if set v == false
end
desc = 'Remote user for accessing destination host via SSH'
opts.on('--destination-user=USER', desc) do |v|
options[:destination_user] = v
end
desc = 'Remote host, used when the VM storage is not available from ' \
'the curren node'
opts.on('-hHOST', '--remote-host=HOST', desc) do |v|
options[:remote_host] = v
end
desc = 'Remote user for accessing remote host via SSH'
opts.on('-lUSER', '--remote-user=USER', desc) do |v|
options[:remote_user] = v
end
desc = 'Instead of retrieving the VM XML querying the endpoint, it ' \
'will be readed from STDIN. The VM id will be automatically ' \
'retrieved from the XML'
opts.on(nil, '--stdin', desc) do |v|
options[:stdin] = v
end
end,
'restore' => OptionParser.new do |opts|
opts.banner = 'Usage: onevmdump restore [options] <backup_file>'
desc = 'Instantiate backup resulting template automatically'
opts.on('--instantiate', desc) do |v|
options[:instantiate] = v
end
desc = 'Name for the resulting VM Template'
opts.on('-nNAME', '--name=NAME', desc) do |v|
options[:tmpl_name] = v
end
desc = 'Force restore of original VM NICs'
opts.on('--restore-nics', desc) do |v|
options[:restore_nics] = v
end
end
}
############################################################################
# Options parsing
############################################################################
begin
global_parser.order!
command = ARGV.shift
raise 'A valid comman must be provided' if command.nil? || command.empty?
raise "Invalid command: #{command}" if commands_parsers[command].nil?
commands_parsers[command].parse!
rescue StandardError => e
STDERR.puts "ERROR parsing commands: #{e.message}"
exit(-1)
end
############################################################################
# Main Program
#
# TODO
# - Multithreading for multiple disks (async/await?)
# - Add incremental
############################################################################
begin
client = OpenNebula::Client.new(nil, options[:endpoint])
case command
when 'export'
if options[:stdin]
vm_xml = OpenNebula::XMLElement.build_xml(STDIN.read, 'VM')
vm = OpenNebula::VirtualMachine.new(vm_xml, client)
vm.lock(4) if options[:lock]
else
begin
vm_id = Integer(ARGV[0])
rescue ArgumentError, TypeError
raise 'A VM ID must be provided.'
end
vm = OpenNebula::VirtualMachine.new_with_id(vm_id, client)
vm.lock(4) if options[:lock]
rc = vm.info
if OpenNebula.is_error?(rc)
raise "Error getting VM info: #{rc.message}"
end
end
# Export VM. VM folder will be used as temporal storage
exporter = OneVMDump.get_exporter(vm, options)
bundle_location = exporter.export
puts bundle_location
when 'restore'
raise 'Bundle path must be provided' if ARGV[0].nil? || ARGV[0].empty?
tmpl = OneVMDump.get_restorer(ARGV[0], options).restore
if options[:instantiate]
rc = OpenNebula::Template.new_with_id(tmpl, client).instantiate
if OpenNebula.is_error?(rc)
raise "Error instantiating VM template: #{rc.message}"
end
puts "VM Restored: #{rc}"
else
puts "VM Template restored: #{tmpl}"
end
end
rescue StandardError => e
STDERR.puts e.message
STDERR.puts e.backtrace if options[:debug]
exit(-1)
ensure
vm.unlock if options[:lock] && !vm.nil?
end
exit(0)

View File

@ -0,0 +1,56 @@
# -------------------------------------------------------------------------- #
# Copyright 2002-2021, OpenNebula Project, OpenNebula Systems #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may #
# not use this file except in compliance with the License. You may obtain #
# a copy of the License at #
# #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
#--------------------------------------------------------------------------- #
require 'nokogiri'
require 'lib/exporters/file'
require 'lib/exporters/rbd'
require 'lib/exporters/lv'
require 'lib/restorers/base'
# OneVMDump module
#
# Module for exporting VM content into a bundle file
module OneVMDump
def self.get_exporter(vm, config)
# Get TM_MAD from last history record
begin
last_hist_rec = Nokogiri.XML(vm.get_history_record(-1))
tm_mad = last_hist_rec.xpath('//TM_MAD').text
rescue StandardError
raise 'Cannot retrieve TM_MAD. The last history record' \
' might be corrupted or it might not exists.'
end
case tm_mad
when 'ceph'
self::RBDExporter.new(vm, config)
when 'ssh', 'shared', 'qcow2'
self::FileExporter.new(vm, config)
when 'fs_lvm', 'fs_lvm_ssh'
self::LVExporter.new(vm, config)
else
raise "Unsopported TM_MAD: '#{tm_mad}'"
end
end
def self.get_restorer(bundle_path, options)
BaseRestorer.new(bundle_path, options)
end
end