#!/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