#!/bin/bash
show_help()
{
cat << end_of_help
USAGE: [SSH_OPTIONS=...] $(basename $0) [ -h | --help ]
$(basename $0) -- Replicates a given virtual machine onto a local or remote endpoint
REQUIREMENTS:
- Libvirt service running (in all involved hosts)
- Qemu Utils (this host)
- SSH, SCP and SSHFS (this host)
- SSH server (remote host)
- Both hosts must be set with proper SSH keys based authentication, in ways this host can connect with the other directly
- If run standalone (e.g. outside the container), env var SSH_OPTIONS must be set, as of for vm-babysitter defaults.
INSTRUCTIONS:
- Run without arguments. A series of options and questions will be presented, and must be answered
- You will be asked for confirmation before to start the process
- Once completed, it will display the final result about the operation
- To exit anytime, press 'Ctrl+C' (might leave leftovers once the replication process has begun).
end_of_help
}
# Load functions:
source /usr/local/bin/vm-functions
if [[ -z $@ ]]; then
# =============================================================================
# Interactive mode:
# =============================================================================
echo -e "\nScanning for available Domains..."
src_domains_list=($(domains_list))
# -----------------------------------------------------------------------------
# Show all available domains:
# -----------------------------------------------------------------------------
if [[ -n ${src_domains_list[@]} ]]; then
# Proceeds if libvirt doesn't report an error, and if there is, at least, one non-trasient domain:
screen_header "-"
printf "%3s %-31s %-19s %s \n" "#" "Domain" "Drives" "MAC Addresses"
screen_header "-"
for ((i=0; i<${#src_domains_list[@]}; i++)); do
# Get lists with drives and MAC addresses in each VM that can be restored:
src_domains_drives_list+=("$(domain_drives_list ${src_domains_list[$i]})")
src_macaddr_list+=("$(domain_macaddr_list ${src_domains_list[$i]})")
# Show each domain found, including restorable drives and MACs:
printf "%3s %-31s %-19s %s \n" "$(($i+1))" "${src_domains_list[$i]}" "${src_domains_drives_list[$i]:-"None"}" "${src_macaddr_list[$i]:-"None"}"
done
# -----------------------------------------------------------------------------
# Asks the user to choose a domain to clone:
# -----------------------------------------------------------------------------
echo ""
user_choice=$(($(choose_number "Choose a domain to be replicated" "1" "${#src_domains_list[@]}") - 1))
# Saves domain specs that will be used for restoration:
src_domain=${src_domains_list[$user_choice]} # Domain name
src_domain_drives=(${src_domains_drives_list[$user_choice]}) # List of drives (targets)
src_macaddrs=(${src_macaddr_list[$user_choice]}) # List of NIC's MAC address(es)
src_domain_img_paths=($(domain_img_paths_list $src_domain)) # Path(s) to disk image file(s)
echo -e "Domain '$src_domain' selected for replication\n"
# -----------------------------------------------------------------------------
# Asks the user where to replicate the chosen domain. If local, or remotely:
# -----------------------------------------------------------------------------
screen_header "-"
echo "Select an endpoint where to replicate domain '$src_domain'"
screen_header "-"
echo -e "1. This host (local)"
echo -e "2. Another host (via SSH)\n"
user_choice=$(choose_number "Choose an endpoint" "1" "2" "1")
case $user_choice in
1) chosen_endpoint="local"
dest_domains_list=(${src_domains_list[@]})
echo -e "Destination endpoint will be this same host\n"
;;
2) chosen_endpoint="remote"
echo -e "Destination endpoint will be on another host\n"
# Ask for SSH login:
while true; do
read -p "Provide an SSH login to the host where domain '$src_domain' will be replicated (e.g. user@hostname): " remote_server_login
if [[ $remote_server_login == *@* ]]; then
# Grabs server separately:
remote_server_name=$(echo $remote_server_login | cut -d'@' -f2)
# Test remote host connectivity and libvirt:
ssh $SSH_OPTIONS $remote_server_login "exit 0"
remote_server_login_exit_code=$?
if [[ $remote_server_login_exit_code -eq 0 ]]; then
# Server returns the command exit_code sent. Looks up for libvirt API:
remote_api_version=$(ssh $SSH_OPTIONS $remote_server_login 'echo $(virsh --version)')
remote_api_exit_code=$?
if [[ $remote_api_exit_code -eq 0 ]]; then
echo -e "Host '$remote_server_name' contacted successfully (Libvirt version: $remote_api_version)\n"
# Gets the list of VMs from remote_server_login to discard duplicates on the remote endpoint:
dest_domains_list=($(ssh $SSH_OPTIONS $remote_server_login "virsh list --all --persistent --name"))
# Breaks the loop:
break
else
echo -e "WARNING: Cannot connect with Libvirt service located at server '$remote_server_name' (Libvirt exit code: $remote_api_exit_code)\n"
fi
else
echo -e "WARNING: Cannot connect with host '$remote_server_name' (SSH exit code: $remote_server_login_exit_code)\n"
fi
else
echo -e "Incorrect syntax for endpoint '$remote_server_login'. It must be an SSH valid login\n"
fi
done
;;
esac
# -----------------------------------------------------------------------------
# Asks the user for a name for the new domain:
# -----------------------------------------------------------------------------
read -p "Insert a name for the new domain that will be replicated from '$src_domain': " dest_domain
while [[ $dest_domain == $(grep -o -e "$dest_domain" <<< "${dest_domains_list[@]}") ]]; do
# Ask for a different name if the domain already exists on the list
read -p "Domain '$dest_domain' already exists at the $chosen_endpoint chosen endpoint or is invalid. Choose a different name: " dest_domain
done
echo -e "New domain will be created as: '$dest_domain'\n"
# -----------------------------------------------------------------------------
# MAC Adress(es) section:
# -----------------------------------------------------------------------------
for ((i=0; i<${#src_macaddrs[@]}; i++)); do
screen_header "-"
echo "Select an action for NIC #$(($i + 1)) -- MAC Address: ${src_macaddrs[$i]}"
screen_header "-"
echo -e "1. Set a custom MAC address"
echo -e "2. Assign a random MAC address"
echo -e "3. Keep the same MAC address as in domain '$src_domain' (choose at your own risk)\n"
user_choice=$(choose_number "Choose an action" "1" "3" "2")
case $user_choice in
1)
read -p "Insert the 3 last hexadecimal values for the custom MAC address, separated by colons (e.g. 0f:1e:2d): " custom_mac
while [[ -z $(grep -E "^([0-9A-Fa-f]{2}[:]){2}([0-9A-Fa-f]{2})$" <<< $custom_mac) ]]; do
read -p "Invalid syntax for '$custom_mac'. Insert 3 hexadecimal values separated by colons: " custom_mac
done
dest_macaddrs+=("52:54:00:${custom_mac}")
echo -e "NIC #${#dest_macaddrs[@]} manually set to '${dest_macaddrs[-1]}'\n"
;;
2)
dest_macaddrs+=($(gen_random_macaddr))
echo -e "NIC #${#dest_macaddrs[@]} automatically set to '${dest_macaddrs[-1]}'\n"
;;
3)
dest_macaddrs+=(${src_macaddrs[$i]})
echo -e "NIC #${#dest_macaddrs[@]} will be the same as for domain '$src_domain'\n"
;;
esac
done
# -----------------------------------------------------------------------------
# Disk image(s) section:
# -----------------------------------------------------------------------------
if [[ -n ${src_domain_drives[@]} ]]; then
# -----------------------------------------------------------------------------
# Asks the user for copying disks from src_domain for dest_domain
copy_all_disks=$(yes_no "Replicate disk image(s) '${src_domain_drives[@]// /,}' from '$src_domain' into domain '$dest_domain'?" "yes")
if [[ $copy_all_disks == yes ]]; then
# Looks for the VM state and notifies about a sudden shutdown (necessary to perform disk replication):
src_domain_state=$(domain_state $src_domain)
if [[ $src_domain_state != "shut off" ]]; then
# Ask confirmation again before to proceed, since domain has to be shut down for disk image provisioning:
echo -e "\nNOTICE: Domain '$src_domain' is currently $src_domain_state!\nIn order to proceed with disk replication, it will be automatically shut down during the process, and restarted once this had finished\n"
copy_all_disks=$(yes_no "Are you agree with this action to continue with disk replication?" "no")
fi
fi
case $copy_all_disks in
yes) echo -e "All disk images will be provisioned\n"
;;
no) echo -e "No disk images will be provisioned (manual provision will be required)\n"
# Asks the user for checking / modifying paths:
edit_dest_img_paths=$(yes_no "Check anyway (and possibly redefine) disk image paths for the new domain '$dest_domain' before diskless replication?" "yes")
echo ""
esac
if [[ $copy_all_disks == yes ]] || [[ $edit_dest_img_paths == yes ]]; then
# -----------------------------------------------------------------------------
# Shows the possible options and ways to modify them:
i=0
for path in ${src_domain_img_paths[@]}; do
proposed_dest_path=""
# Number of options present in the below dialog:
menu_option=1
screen_header "-"
echo "Select an action for drive '${src_domain_drives[$i]}' (source: $path)"
screen_header "-"
echo "$menu_option. Set the disk image path to a custom location and name"
# Attempts to find a NEW path matching by VM name:
if [[ $path != ${path//$src_domain/$dest_domain} ]]; then
# If this action is possible, proposes it as an option:
proposed_dest_path=${path//$src_domain/$dest_domain}
((menu_option++))
echo "$menu_option. Set the disk image path automatically to: $proposed_dest_path"
fi
if [[ $copy_all_disks == no ]] || [[ $chosen_endpoint == remote ]]; then
# Only when user has chosen to not copy disks or the VM will be replicated on another host, it shows the 3rd option:
((menu_option++))
echo "$menu_option. Keep the disk image path unchanged: $path"
fi
# Default choice is always '2' if exists, otherwise it's '1':
[[ $menu_option -ge 2 ]] \
&& default_option=2 \
|| default_option=1
echo ""
user_choice=$(choose_number "Choose an option" "1" "$menu_option" "$default_option")
case $user_choice in
1) # User has chosen to set path manually:
echo ""
read -p "Insert the absolute path to the destination disk image: " manual_path
while [[ $manual_path != /* ]]; do
read -p "Invalid path: '$manual_path'. It must be an absolute path: " manual_path
done
dest_domain_img_paths+=($manual_path)
echo -e "Disk image path for drive '${src_domain_drives[$i]}' set manually to '${dest_domain_img_paths[$i]}'\n"
;;
2)
if [[ -n $proposed_dest_path ]]; then
# proposed_dest_path is plausible and has been chosen:
dest_domain_img_paths+=($proposed_dest_path)
echo -e "Disk image path for drive '${src_domain_drives[$i]}' set automatically to '${dest_domain_img_paths[$i]}'\n"
elif [[ $copy_all_disks == yes ]] || [[ $chosen_endpoint == remote ]]; then
# proposed_dest_path isn't plausible. copy_all_disks was set to 'no' or chosen_endpoint set to 'remote'
# User has set to keep path:
dest_domain_img_paths+=($path)
echo -e "Disk image path for drive '${src_domain_drives[$i]}' will be the same as for domain '$src_domain'\n"
fi
;;
3)
# proposed_dest_path it's plausible. copy_all_disks was set to 'no' or chosen_endpoint set to 'remote'
# User has set to keep path:
dest_domain_img_paths+=($path)
echo -e "Disk image path for drive '${src_domain_drives[$i]}' will be the same as for domain '$src_domain'\n"
;;
esac
done
fi
fi
read -n1 -s -r -p $'Press any key to start the replication process (or Ctrl+C to Cancel...)' key
# =============================================================================
# End of interactive part:
# =============================================================================
echo -e "\n"
screen_header "="
echo -e "Dumping '$src_domain' config'..."
# Temporal path to store files and mount remote folders.
# It must have read and write permissions, or the entire process will fail:
workdir_path="/tmp/$(basename $0)"
# Create the temporary folder to be used:
mkdir -p $workdir_path
# Path to xml definitions file to be processed:
dest_domain_definitions_xml_file=$workdir_path/$dest_domain.xml
# Dumps current VMs XML file to a temporal location and grab exit code of operation:
virsh dumpxml --inactive --security-info $src_domain > $dest_domain_definitions_xml_file
dumpxml_exit_code=$?
if [[ $dumpxml_exit_code -eq 0 ]]; then
# With dest_domain_definitions_xml_file, it's plausible to continue the process:
# -----------------------------------------------------------------------------
# Modification on dest_domain_definitions_xml_file
# -----------------------------------------------------------------------------
echo -e "Merging all user defined parameters for domain '$dest_domain' into new config...\n"
# Replaces VM name (src_domain by dest_domain):
# -----------------------------------------------------------------------------
sed -i -e "s|$src_domain|$dest_domain|" $dest_domain_definitions_xml_file
# -----------------------------------------------------------------------------
# Replaces VM UUID for a randomized one:
# UUID of src_domain:
src_domain_uuid=$(domain_id $src_domain)
# Random UUID to replace the original one:
dest_domain_uuid=$(uuidgen)
# Replaces all matches of src_domain_uuid for dest_domain_uuid.
# At least on Unraid OS, Nvram file, if exists, its name matches with uuid:
sed -i -e "s|$src_domain_uuid|$dest_domain_uuid|g" $dest_domain_definitions_xml_file
# -----------------------------------------------------------------------------
# Replaces MAC addresses for (qemu/kvm) randomized ones:
i=0
for mac in ${src_macaddrs[@]}; do
# Replaces matching listed mac for a random one:
sed -i -e "s|||" $dest_domain_definitions_xml_file
((i++))
done
# -----------------------------------------------------------------------------
# When defined by the user, Replaces disk image paths:
i=0
for path in ${dest_domain_img_paths[@]}; do
# Replaces src_domain paths for a dest_domain paths:
sed -i -e "s|||" $dest_domain_definitions_xml_file
((i++))
done
# -----------------------------------------------------------------------------
# Disk image(s) replication:
# -----------------------------------------------------------------------------
if [[ $copy_all_disks == yes ]]; then
# -----------------------------------------------------------------------------
# Turn off src_domain (if running):
# -----------------------------------------------------------------------------
if [[ $(domain_state $src_domain) != "shut off" ]]; then
# Shut down the domain and await for it, for an exit code:
domain_shutdown $src_domain --wait $VM_WAIT_TIME
shutdown_exit_code=$?
echo ""
fi
if [[ -z $shutdown_exit_code || $shutdown_exit_code -eq 0 ]]; then
# -----------------------------------------------------------------------------
# Beginning of file copy:
for ((i=0; i<${#dest_domain_img_paths[@]}; i++)); do
# Copies the image disk only when conditions apply:
echo -e "Replicating '${src_domain_img_paths[$i]}' as '${dest_domain_img_paths[$i]}' onto $chosen_endpoint endpoint..."
if [[ $chosen_endpoint == local ]]; then
# Creates folder and dumps a copy of the disk image with qemu-img convert:
mkdir -p $(dirname ${dest_domain_img_paths[$i]})
if [[ $? -eq 0 ]]; then
# Will only proceed if destination folder exists or could be created:
qemu-img convert -p -O qcow2 ${src_domain_img_paths[$i]} ${dest_domain_img_paths[$i]}
qemu_img_exit_code=$?
fi
elif [[ $chosen_endpoint == remote ]]; then
# Creates a temporal folder to mount the remote endpoint next:
mkdir -p $workdir_path/remote
# Creates the remote folder, and mounts it via sshfs.
# (sshfs is not compatible with option '-q', so must be filtered from SSH_OPTIONS):
ssh $SSH_OPTIONS $remote_server_login "mkdir -p $(dirname ${dest_domain_img_paths[$i]})" && \
sshfs $remote_server_login:$(dirname ${dest_domain_img_paths[$i]}) $workdir_path/remote ${SSH_OPTIONS/-q /}
if [[ $? -eq 0 ]]; then
# Will only replicate the disk if mount of remote endpoint is successful:
qemu-img convert -p -O qcow2 ${src_domain_img_paths[$i]} $workdir_path/remote/$(basename ${dest_domain_img_paths[$i]})
qemu_img_exit_code=$?
fi
fi
# Notifies about success or fail of each disk image provisioning for dest_domain:
case $qemu_img_exit_code in
'') echo -e "An error occurred before to start provisioning disk image '${dest_domain_img_paths[$i]}'\n"
# Mark the process as partial (or totally failed):
copy_failed=true
;;
0) echo -e "Disk image '${dest_domain_img_paths[$i]}' was provisioned successfully\n"
;;
*) echo -e "An unexpected error occurred while provisioning disk image '${dest_domain_img_paths[$i]}' (Qemu-img exit code: $qemu_img_exit_code)\n"
# Mark the process as partial (or totally failed):
copy_failed=true
# Attempt to delete the failed image:
[[ $chosen_endpoint == local ]] \
&& rm -f ${dest_domain_img_paths[$i]} \
|| rm -f $workdir_path/remote/$(basename ${dest_domain_img_paths[$i]})
;;
esac
# On remote replication, silently unmounts the remote folder after completion:
[[ $chosen_endpoint == remote ]] && umount -q $workdir_path/remote
done
[[ $copy_failed == true ]] && echo -e "WARNING: There were issues copying at least one disk image. Manual provision will be required for such failed ones\n"
# Starts src_domain if it was previously running before the copy:
[[ -n $shutdown_exit_code ]] && { domain_start $src_domain --nowait; echo ""; }
else
echo -e "WARNING: Domain '$src_domain' either failed, or is taking too much into shut down completely. Manual provision will be required for image disk(s)\n"
fi
fi
# -----------------------------------------------------------------------------
# Copy of NVRAM binaries:
# -----------------------------------------------------------------------------
# Tries to get an nvram file path of the VM that will be replicated:
src_domain_nvram_path=$(domain_nvram_path $src_domain)
if [[ -n $src_domain_nvram_path ]]; then
# Source VM requires nvram file to work, therefore it has to be provisioned:
echo -e "Provisioning detected Nvram binary..."
# Get nvram file path of the VM that will be replicated (from its xml definition file)
# On Unraid, file name will change with uuid replacement on new XML definitions file made in previous step
# (Other OSes might behave in similar way, but this has not been tested):
dest_domain_nvram_path=$(domain_nvram_path $dest_domain_definitions_xml_file)
if [[ $chosen_endpoint == local && $src_domain_nvram_path != $dest_domain_nvram_path ]]; then
# When chosen_endpoint is local and both nvram file paths differ, copy the source nvram file with the new destination name:
cp -f $src_domain_nvram_path $dest_domain_nvram_path
nvram_copy_exit_code=$?
elif [[ $chosen_endpoint == remote ]]; then
# Create directory (in case also doesn't exists) and if success, copy source nvram as dest nvram:
ssh $SSH_OPTIONS $remote_server_login "mkdir -p $(dirname $dest_domain_nvram_path)" && \
scp $SSH_OPTIONS $src_domain_nvram_path $remote_server_login:$dest_domain_nvram_path
nvram_copy_exit_code=$?
fi
case $nvram_copy_exit_code in
# As no exit_code is given, logic assumes files are identical and no copy was made:
'') echo -e "Source and destination Nvram file paths are identical in $chosen_endpoint endpoint ($src_domain_nvram_path) so nothing was copied\n"
;;
# Local or remote copy was successful:
0) echo -e "Persistent Nvram binary successfully provisioned to $chosen_endpoint endpoint as '$dest_domain_nvram_path'\n"
;;
# There were errors copying nvram file:
*) echo -e "WARNING: There were issues provisioning correspondent Nvram file. Manual provision (and possibly edition of domain '$dest_domain' config) will be required (latest exit code: $nvram_copy_exit_code)\n"
;;
esac
fi
# -----------------------------------------------------------------------------
# Define the VM at the chosen endpoint:
# -----------------------------------------------------------------------------
echo -e "Defining new domain '$dest_domain' onto $chosen_endpoint endpoint..."
case $chosen_endpoint in
local) # Defines VM directly:
virsh define $dest_domain_definitions_xml_file
domain_define_exit_code=$?
;;
remote) # Define where dest_domain_definitions_xml_file will be copied, depending if disk image paths are or not defined:
[[ -n ${dest_domain_img_paths[@]} ]] \
&& remote_dest_domain_definitions_xml_file="$(dirname ${dest_domain_img_paths[0]})/$(basename $dest_domain_definitions_xml_file)" \
|| remote_dest_domain_definitions_xml_file="/tmp/$(basename $dest_domain_definitions_xml_file)"
# Then copy, define the domain and get its exit code:
scp $SSH_OPTIONS $dest_domain_definitions_xml_file $remote_server_login:$remote_dest_domain_definitions_xml_file && \
ssh $SSH_OPTIONS $remote_server_login "virsh define $remote_dest_domain_definitions_xml_file"
domain_define_exit_code=$?
;;
esac
case $domain_define_exit_code in
0) # Success:
case $chosen_endpoint in
local) echo -e "Domain '$dest_domain' was defined successfully\n"
;;
remote) # Deletes the transferred file:
ssh $SSH_OPTIONS $remote_server_login "rm -f $remote_dest_domain_definitions_xml_file"
echo -e "Domain '$dest_domain' was defined at server '$remote_server_login' successfully\n"
;;
esac
# Delete temporal workdir:
rm -rf $workdir_path
;;
*) # Fail:
case $chosen_endpoint in
local) echo -e "\nERROR: Libvirt could not define domain '$dest_domain' (virsh define exit code: $domain_define_exit_code)\n"
;;
remote) echo -e "\nERROR: Libvirt could not define domain '$dest_domain' at server '$remote_server_name'. Failed definitions remain at remote path: '$remote_dest_domain_definitions_xml_file' (virsh define exit code: $domain_define_exit_code)\n"
;;
esac
;;
esac
# Notify about script's end, possibly after a long time operation:
echo -e "$(basename $0): Finished\n"
else
echo -e "ERROR: $(basename $0) could not dump configuration from '$src_domain', therefore cannot proceed with replication (virsh dumpxml exit code: $dumpxml_exit_code)\n"
fi
else
echo -e "No persistent Virtual Machines detected! (is libvirt service running?)\n"
fi
elif [[ $@ == -h || $@ == --help ]]; then
# Show help:
show_help
else
echo -e "Unknown argument(s) '$@' Usage: [SSH_OPTIONS=...] $(basename $0) [ -h | --help ]\n"
fi