#!/bin/bash
#
# shellcheck disable=SC2181
#
# Copyright (c) 2022-2026 openmediavault plugin developers
#
# This file is licensed under the terms of the GNU General Public
# License version 2. This program is licensed "as is" without any
# warranty of any kind, whether express or implied.
#
# version: 0.4.1
#

if [[ $(id -u) -ne 0 ]]; then
  echo "This script must be executed as root or using sudo."
  exit 99
fi

export LC_ALL=C.UTF-8

# declare/initialize
declare -i keep=0
declare -i poweroff=0
declare -i poweredoff=0
declare -i samefmt=0
declare -i snap=0
declare -i used=0
declare -i vmId=0
declare -i incremental=0
declare -i fullInterval=0
declare -i backupActive=0
declare -i snapActive=0
declare -i incrPending=0

backupDir=""
backupVm=""
backupVmDir=""
compress=()
diskSpecs=()
email_comment=""
email_mode=""

# checkpoint prefix used to identify checkpoints owned by this script
chkPrefix="omvbak"
# seconds between polls while waiting on a push-mode backup job
pollInterval=2

# logging location
logDir="/var/log/"
logFile="${logDir}/omv-backup-vm.log"
listFile="/etc/omv-backup-vm.list"


# drop list rows for <path> <vm> <date>, matching the fields exactly (the
# path may be stored with or without a trailing slash)
_remove_list_rows() {
  local tmp
  [ -f "${listFile}" ] || return 0
  tmp="$(mktemp)"
  awk -F, -v p="${1%/}" -v v="${2}" -v d="${3}" \
    '!(($2 == p || $2 == p "/") && $3 == v && $4 == d)' "${listFile}" > "${tmp}" \
    && cat "${tmp}" > "${listFile}"
  rm -f "${tmp}"
}

_log()
{
  msg=${1}
  echo "[$(date +'%Y-%m-%d %H:%M:%S%z')] [backup] ${msg}" | tee -a ${logFile} >&2
}

# loop through options
while getopts "cC:d:e:F:hik:psv:" opt; do
  case "${opt}" in
    c)
      compress=(-c -o compression_type=zstd)
      _log "compression :: enabled"
      ;;
    C)
      email_comment="${OPTARG}"
      ;;
    d)
      backupDir="${OPTARG}"
      _log "Backup dir :: ${backupDir}"
      ;;
    e)
      email_mode="${OPTARG}"
      ;;
    F)
      fullInterval=${OPTARG}
      _log "Full interval :: ${fullInterval}"
      ;;
    h)
      echo "Use the following flags:"
      echo "  -c"
      echo "    compress backup (slower)"
      echo "  -C comment"
      echo "    comment for email subject"
      echo "  -d dir"
      echo "    set backup directory"
      echo "  -e onerror|always"
      echo "    send email on error only or always"
      echo "  -F #"
      echo "    incremental mode: start a new full backup every # backups"
      echo "    (chain length cap; 0 or unset means only one full, ever)"
      echo "  -h"
      echo "    show this help"
      echo "  -i"
      echo "    incremental backup mode (live, qcow2 disks only)"
      echo "    creates a full backup then differential backups of changed"
      echo "    blocks using libvirt checkpoints / qcow2 dirty bitmaps"
      echo "  -k #"
      echo "    keep last number of backups (full mode) or chains (incremental)"
      echo "  -p"
      echo "    power off VM before backup if powered on"
      echo "    VM will be powered back on if powered off by script"
      echo "  -s"
      echo "    use same format as source"
      echo "    will use cp command"
      echo "    compression flag will be ignored"
      echo "  -v vm-name"
      echo "    VM name to backup"
      echo ""
      echo "Examples:"
      echo "  omv-backup-vm -d /srv/backup/ -k 5 -v testvm1"
      echo "  omv-backup-vm -d /srv/backup/ -i -F 7 -k 3 -v testvm1"
      echo ""
      exit 100
      ;;
    i)
      incremental=1
      _log "incremental :: enabled"
      ;;
    k)
      keep=${OPTARG}
      _log "Keep :: ${keep}"
      ;;
    p)
      poweroff=1
      _log "poweroff :: ${poweroff}"
      ;;
    s)
      samefmt=1
      _log "Same format :: ${samefmt}"
      ;;
    v)
      backupVm=${OPTARG}
      _log "Backup VM :: ${backupVm}"
      ;;
    \?)
      echo "Invalid option: -${OPTARG}"
      ;;
  esac
done

# Disk lists below are newline separated "target,source" pairs; split only on
# newlines so paths containing spaces survive unquoted list expansion.
IFS=$'\n'

tmpfile=""
if [ -n "${email_mode}" ]; then
  tmpfile=$(mktemp)
  exec > "${tmpfile}" 2>&1
fi

_send_email() {
  local rc="$1"
  if [ -n "${tmpfile}" ] && [ -f "${tmpfile}" ]; then
    if [ "${email_mode}" = "always" ] || { [ "${email_mode}" = "onerror" ] && [ "${rc}" -ne 0 ]; }; then
      mail -E -s "Cron${email_comment:+ - ${email_comment}}" -a "From: Cron Daemon <root>" root < "${tmpfile}" > /dev/null 2>&1
    fi
    rm -f "${tmpfile}"
  fi
}
_on_exit() {
  _send_email "$1"
}

# INT/TERM: undo whatever is in flight, then actually stop. Without the exit
# the script would carry on after the handler returns.
_on_signal() {
  local sig="$1"
  trap '' INT TERM
  _log "Received SIG${sig}; cleaning up."

  if [ "${backupActive}" -eq 1 ]; then
    _log "Aborting active backup job."
    virsh domjobabort "${backupVm}" >/dev/null 2>&1 || true
    backupActive=0
  fi
  if [ "${incrPending}" -eq 1 ]; then
    _incr_cleanup
  fi
  if [ "${snapActive}" -eq 1 ]; then
    _log "Merging backup snapshot back into the original disks."
    _commit_snapshot || true
  fi
  if [ "${poweroff}" -eq 1 ] && [ "${poweredoff}" -eq 1 ]; then
    _log "Power on ${backupVm}..."
    virsh start "${backupVm}" || true
  fi

  if [ "${sig}" = "INT" ]; then
    exit 130
  fi
  exit 143
}

trap '_on_exit $?' EXIT
trap '_on_signal INT' INT
trap '_on_signal TERM' TERM

if [ ! -f "/usr/bin/virsh" ]; then
  _log "virsh command not found!"
  exit 2
fi

# check if directory exists
if [ ! -d "${backupDir}" ]; then
  _log "Backup directory '${backupDir}' does not exist!"
  exit 3
fi

# check if VM name was specified
if [ -z "${backupVm}" ]; then
  _log "No VM specified!"
  exit 4
fi

# check if VM is defined by libvirt
if ! virsh dominfo "${backupVm}" > /dev/null 2>&1; then
  _log "VM '${backupVm}' not defined!"
  exit 5
fi

# get date
date="$(date +'%Y-%m-%d_%H-%M-%S')"

start=$(date +%s)
_log "Starting backup..."

# get VM power state
powerState="$(virsh dominfo "${backupVm}" | awk '$1 == "State:" { print $2 }')"

# get VM ID
if [ "${powerState}" = "running" ]; then
  vmId=$(virsh domid "${backupVm}")
fi
_log "${backupVm} ID :: ${vmId}"

# list a domain's disks as "target,source" lines. args: <domain> <awk filter>
# the source is everything after the third column, so paths with spaces stay
# whole (domblklist pads columns with spaces)
_blk_disks() {
  virsh domblklist "${1}" --details | sed 1,2d | awk NF \
    | awk "${2}"' { src = $0; sub(/^[ \t]*[^ \t]+[ \t]+[^ \t]+[ \t]+[^ \t]+[ \t]+/, "", src); print $3 "," src }'
}

# get list of disks for VM as "target,source" pairs
# target = guest device name (vda), source = host path
disks=$(_blk_disks "${backupVm}" '$1 == "file" && $2 == "disk"')
excludeDisks=$(_blk_disks "${backupVm}" '$1 != "file" && $2 == "disk"')

#############################################################################
# helpers
#############################################################################

# return the qemu-img reported format of a disk image
# -U forces shared/read-only access so it works while the VM holds the
# image lock (a running guest keeps an exclusive lock otherwise).
# parse the plain-text "file format:" line rather than --output=json: newer
# qemu-img nests a protocol node ("format":"file") ahead of the real format
_disk_format() {
  qemu-img info -U "${1}" 2>/dev/null \
    | sed -n 's/^file format:[[:space:]]*\([[:alnum:]]*\).*/\1/p' | head -n1
}

# verify every backed-up disk is qcow2 (required for persistent bitmaps)
_all_qcow2() {
  local d fmt
  for d in ${disks}; do
    fmt="$(_disk_format "${d#*,}")"
    if [ "${fmt}" != "qcow2" ]; then
      _log "Disk ${d#*,} is '${fmt}', not qcow2 :: incremental not possible"
      return 1
    fi
  done
  return 0
}

# wait for an active push-mode backup job to finish, then report status
# returns 0 on success, non-zero on failure
_wait_backup() {
  local dom="${1}"
  local output jt
  local failures=0

  while true; do
    if ! output=$(virsh domjobinfo "${dom}" 2>&1); then
      failures=$((failures + 1))
      _log "Unable to query backup job (${failures}) :: ${output}"

      if [ "${failures}" -ge 5 ]; then
        _log "Unable to determine backup job state."
        return 1
      fi

      sleep "${pollInterval}"
      continue
    fi

    failures=0

    jt=$(printf '%s\n' "${output}" |
      awk -F: '/Job type/ {
        gsub(/^[ \t]+|[ \t]+$/, "", $2)
        print $2
      }')

    case "${jt}" in
      None)
        break
        ;;
      Unbounded|Bounded)
        sleep "${pollInterval}"
        ;;
      "")
        _log "Unable to parse backup job state :: ${output}"
        return 1
        ;;
      *)
        _log "Unexpected active backup job state '${jt}' :: ${output}"
        return 1
        ;;
    esac
  done

  if ! output=$(virsh domjobinfo "${dom}" --completed 2>&1); then
    _log "Unable to query completed backup result :: ${output}"
    return 1
  fi

  jt=$(printf '%s\n' "${output}" |
    awk -F: '/Job type/ {
      gsub(/^[ \t]+|[ \t]+$/, "", $2)
      print $2
    }')

  case "${jt}" in
    Completed)
      return 0
      ;;
    Failed|Cancelled|Canceled)
      _log "Backup completed with state '${jt}' :: ${output}"
      return 1
      ;;
    *)
      _log "Unexpected completed backup state '${jt}' :: ${output}"
      return 1
      ;;
  esac
}

# build the <disks> block for a backup XML, writing each changed disk to
# the chain dir. args: <type label> <output dir>
# echoes a space separated list of "target=outfile" written, sets globals
_backup_disks_xml=""
_backup_disks_map=""
_build_backup_xml() {
  local label="${1}" outdir="${2}" d target out
  _backup_disks_xml=""
  _backup_disks_map=""
  for d in ${disks}; do
    target="${d%%,*}"
    out="${outdir}/${label}_${date}_${target}.qcow2"
    _backup_disks_xml="${_backup_disks_xml}    <disk name='${target}' backup='yes' type='file'>
      <target file='${out}'/>
      <driver type='qcow2'/>
    </disk>
"
    _backup_disks_map="${_backup_disks_map}${target}=${out}"$'\n'
  done
  # explicitly exclude non-file / read-only disks (cdrom, passthrough)
  for d in ${excludeDisks}; do
    target="${d%%,*}"
    _backup_disks_xml="${_backup_disks_xml}    <disk name='${target}' backup='no'/>
"
  done
}

# build a checkpoint XML referencing every backed-up disk
_build_checkpoint_xml() {
  local chkName="${1}" d target body=""
  for d in ${disks}; do
    target="${d%%,*}"
    body="${body}    <disk name='${target}' checkpoint='bitmap'/>
"
  done
  for d in ${excludeDisks}; do
    target="${d%%,*}"
    body="${body}    <disk name='${target}' checkpoint='no'/>
"
  done
  cat <<EOF
<domaincheckpoint>
  <name>${chkName}</name>
  <description>openmediavault backup checkpoint</description>
  <disks>
${body}  </disks>
</domaincheckpoint>
EOF
}

# remove all checkpoints owned by this script for the domain (resets bitmaps)
# plain delete first so the dirty bitmap is actually dropped from the qcow2;
# only fall back to --metadata (libvirt record only) if that fails, otherwise
# orphaned bitmaps accumulate in the live disk across full cycles
_purge_checkpoints() {
  local c
  for c in $(virsh checkpoint-list "${backupVm}" --name 2>/dev/null \
      | awk -v p="${chkPrefix}" '$0 ~ "^" p {print}'); do
    _log "Removing stale checkpoint :: ${c}"
    virsh checkpoint-delete "${backupVm}" "${c}" > /dev/null 2>&1 \
      || virsh checkpoint-delete "${backupVm}" "${c}" --metadata > /dev/null 2>&1
  done
}

# copy the UEFI variable store (if any) next to the backup as nvram_<date>.fd
# args: <domain xml file> <output dir>
_backup_nvram() {
  local nvram
  nvram="$(xmlstarlet sel -t -v "/domain/os/nvram" "${1}" 2>/dev/null)"
  if [ -n "${nvram}" ] && [ -f "${nvram}" ]; then
    _log "Copying NVRAM :: ${nvram}"
    if ! cp "${nvram}" "${2}/nvram_${date}.fd"; then
      _log "Failed to copy NVRAM ${nvram}!"
      return 1
    fi
  fi
  return 0
}

# commit the external backup snapshot of a running VM back into its base
# images and remove the overlays. returns non-zero if a commit failed
_commit_snapshot() {
  local overlays disk
  # only this run's overlays (libvirt names them <base>.backup-snapshot_<date>);
  # disks already pivoted back to their base image are skipped
  overlays=$(_blk_disks "${vmId}" '$1 == "file" && $2 == "disk"' \
    | awk -v s=".backup-snapshot_${date}" 'substr($0, length($0) - length(s) + 1) == s')
  for disk in ${overlays}; do
    _log "Disk :: ${disk}"
    if ! virsh blockcommit ${vmId} "${disk#*,}" --active --pivot; then
      _log "Failed to commit changes.  VM '${backupVm}' may be in a bad state."
      return 1
    fi
    sync
  done
  snapActive=0

  # wait 5 secs before removing files
  sleep 5

  _log "Remove temp files..."
  for disk in ${overlays}; do
    _log "Disk :: ${disk}"
    rm -fv "${disk#*,}"
  done
  return 0
}

# undo a failed or interrupted incremental run: drop the files it wrote, the
# chain dir if this run started it, and the checkpoint it created
_incr_cleanup() {
  local m
  for m in ${_backup_disks_map}; do
    rm -f "${m#*=}"
  done
  rm -f "${incrChainDir}/nvram_${date}.fd"
  if [ "${incrNewChain}" -eq 1 ] && [ -n "${incrChainDir}" ]; then
    rm -rf "${incrChainDir}"
  fi
  virsh checkpoint-delete "${backupVm}" "${incrChk}" > /dev/null 2>&1 \
    || virsh checkpoint-delete "${backupVm}" "${incrChk}" --metadata > /dev/null 2>&1
  incrPending=0
}

#############################################################################
# incremental backup path
#############################################################################
do_incremental() {
  local chainDir manifest lastChk entryCount needFull=0
  local backupXml checkpointXml chkName d target out prevFile rc

  # locate the most recent existing chain for this VM
  chainDir=""
  manifest=""
  lastChk=""
  if [ -d "${backupDir}/${backupVm}" ]; then
    local cand
    cand=$(find "${backupDir}/${backupVm}" -mindepth 1 -maxdepth 1 -type d \
      | sort -r | head -n1)
    if [ -n "${cand}" ] && [ -f "${cand}/manifest" ]; then
      chainDir="${cand}"
      manifest="${chainDir}/manifest"
      lastChk=$(awk -F'|' 'END { print $3 }' "${manifest}")
      entryCount=$(wc -l < "${manifest}")
    fi
  fi

  # decide whether this run must be a full backup
  if [ -z "${chainDir}" ]; then
    _log "No existing chain found :: performing full backup"
    needFull=1
  elif [ -z "${lastChk}" ]; then
    _log "Chain has no checkpoint recorded :: performing full backup"
    needFull=1
  elif ! virsh checkpoint-info "${backupVm}" "${lastChk}" > /dev/null 2>&1; then
    _log "Checkpoint '${lastChk}' missing in libvirt :: performing full backup"
    needFull=1
  elif [ ${fullInterval} -gt 0 ] && [ ${entryCount:-0} -ge ${fullInterval} ]; then
    _log "Full interval reached (${entryCount}/${fullInterval}) :: performing full backup"
    needFull=1
  fi

  if [ ${needFull} -eq 1 ]; then
    # start a brand new chain rooted at this date; reset all bitmaps
    _purge_checkpoints
    chainDir="${backupDir}/${backupVm}/${date}"
    manifest="${chainDir}/manifest"
    mkdir -p "${chainDir}"
    # save VM definition for restore
    _log "Dump XML for backup :: ${backupVm}_${date}.xml"
    virsh dumpxml "${backupVm}" > "${chainDir}/${backupVm}_${date}.xml"
  fi

  chkName="${chkPrefix}-${date}"
  _build_backup_xml "$([ ${needFull} -eq 1 ] && echo full || echo incr)" "${chainDir}"

  # state used by _incr_cleanup if this run fails or is interrupted
  incrChainDir="${chainDir}"
  incrNewChain=${needFull}
  incrChk="${chkName}"
  incrPending=1

  backupXml="$(mktemp)"
  checkpointXml="$(mktemp)"
  _build_checkpoint_xml "${chkName}" > "${checkpointXml}"

  if [ ${needFull} -eq 1 ]; then
    _log "Creating full backup (push mode)..."
    cat > "${backupXml}" <<EOF
<domainbackup mode='push'>
  <disks>
${_backup_disks_xml}  </disks>
</domainbackup>
EOF
  else
    _log "Creating incremental backup from checkpoint '${lastChk}'..."
    cat > "${backupXml}" <<EOF
<domainbackup mode='push'>
  <incremental>${lastChk}</incremental>
  <disks>
${_backup_disks_xml}  </disks>
</domainbackup>
EOF
  fi

  # begin the backup and atomically create the new checkpoint/bitmap
  backupActive=1
  if ! virsh backup-begin "${backupVm}" "${backupXml}" "${checkpointXml}"; then
    backupActive=0
    _log "backup-begin failed!"
    rm -f "${backupXml}" "${checkpointXml}"
    _incr_cleanup
    exit 7
  fi
  rm -f "${backupXml}" "${checkpointXml}"

  _wait_backup "${backupVm}"
  rc=$?
  backupActive=0
  if [ ${rc} -ne 0 ]; then
    _log "Backup job reported failure!"
    _incr_cleanup
    exit 8
  fi
  _log "Backup job completed."

  if ! _backup_nvram "${chainDir}/${backupVm}_$(basename "${chainDir}").xml" "${chainDir}"; then
    _incr_cleanup
    exit 9
  fi

  # for incrementals, point each delta image at its predecessor in the
  # chain so the newest image is a complete, restorable qcow2 backing chain
  if [ ${needFull} -ne 1 ]; then
    for d in ${disks}; do
      target="${d%%,*}"
      out="${chainDir}/incr_${date}_${target}.qcow2"
      # previous image for this target = newest existing chain file != current
      prevFile=$(find "${chainDir}" -maxdepth 1 -type f \
        \( -name "full_*_${target}.qcow2" -o -name "incr_*_${target}.qcow2" \) \
        ! -name "incr_${date}_${target}.qcow2" | sort | tail -n1)
      if [ -n "${prevFile}" ]; then
        _log "Linking ${out##*/} -> backing $(basename "${prevFile}")"
        qemu-img rebase -u -f qcow2 -F qcow2 \
          -b "$(basename "${prevFile}")" "${out}"
      fi
    done
  fi

  # record this run in the chain manifest
  # format: TYPE|DATE|CHECKPOINT
  echo "$([ ${needFull} -eq 1 ] && echo full || echo incr)|${date}|${chkName}" \
    >> "${manifest}"
  incrPending=0

  # add entry to list file (one row per restore point)
  # measure only the files written by this run (this restore point's own
  # delta) rather than the whole chain, so incrementals report their true,
  # smaller size instead of the cumulative chain size. Use apparent size so
  # the value reflects the qcow2 content and is not skewed by filesystem
  # compression/sparseness (e.g. ZFS reporting far fewer allocated blocks)
  used=$(du -c --apparent-size --block-size=1 "${chainDir}/"*"_${date}_"*.qcow2 2>/dev/null \
    | tail -n1 | awk '{ print $1 }')
  used=${used:-0}
  _log "Adding entry to list file '${listFile}'"
  echo "$(uuid),${backupDir},${backupVm},${date},${used}" >> "${listFile}"

  # chain-aware retention: keep the most recent ${keep} chains
  if [ ${keep} -gt 0 ]; then
    _log "Keeping the most recent ${keep} chains..."
    local keepDir delDirs delDate delDir delDates dd c
    keepDir="${backupDir}/${backupVm}/"
    delDirs=$(find "${keepDir}" -mindepth 1 -maxdepth 1 -type d | sort -r | awk "NR>${keep}")
    for d in ${delDirs}; do
      delDate="$(basename "${d}")"
      delDir="$(dirname "${keepDir}")/"
      # a chain's manifest has one row per restore point (full + incrementals);
      # every one of those dates got its own list-file entry, so all of them
      # need to be dropped, not just the chain dir's own (full) date
      if [ -f "${d}/manifest" ]; then
        delDates=$(awk -F'|' '{print $2}' "${d}/manifest")
        # release any checkpoints belonging to this chain before deleting files
        for c in $(awk -F'|' '{print $3}' "${d}/manifest"); do
          virsh checkpoint-delete "${backupVm}" "${c}" --metadata > /dev/null 2>&1
        done
      else
        delDates="${delDate}"
      fi
      _log "Removing chain ${d}..."
      rm -rv "${d}"
      for dd in ${delDates}; do
        _remove_list_rows "${delDir}" "${backupVm}" "${dd}"
      done
    done
  fi

  end=$(date +%s)
  _log "Backup size :: ${used} bytes"
  _log "Backup time :: $((end-start)) secs"
  _log "Done."
  exit 0
}

# dispatch to incremental path when requested and supported
if [ ${incremental} -eq 1 ]; then
  if [ ${vmId} -le 0 ]; then
    _log "VM not running :: incremental requires a live VM, falling back to full backup"
  elif [ ${#compress[@]} -gt 0 ]; then
    _log "compression is ignored in incremental mode"
    compress=()
    if _all_qcow2; then
      do_incremental
    else
      _log "Falling back to full backup"
    fi
  elif _all_qcow2; then
    do_incremental
  else
    _log "Falling back to full backup"
  fi
fi

#############################################################################
# full (standalone) backup path - default behavior
#############################################################################

# create backup directory for VM
backupVmDir="${backupDir}/${backupVm}/${date}"
mkdir -p "${backupVmDir}"

# create backup xml
backupXml="${backupVm}_${date}.xml"
_log "Dump XML for backup :: ${backupXml}"
virsh dumpxml "${backupVm}" > "${backupVmDir}/${backupXml}"

grep -i 'loader.*pflash' "${backupVmDir}/${backupXml}"
if [ $? -eq 0 ]; then
  _log "VM is using pflash"
fi

# power off VM if poweroff flag passed
if [ ${poweroff} -eq 1 ] && [ ${vmId} -gt 0 ]; then
  _log "Shutting down ${backupVm}..."
  virsh shutdown --domain ${vmId}
  countDown=300 # seconds, 5 minutes
  while [ ${countDown} -gt 0 ]; do
    if virsh domstate "${backupVm}" | grep 'shut off' ; then
      poweredoff=1
      break
    fi
    sleepIntervalSeconds=5
    sleep ${sleepIntervalSeconds}
    countDown=$((countDown-sleepIntervalSeconds))
  done
  if [ ${poweredoff} -ne 1 ]; then
    _log "Unable to shut down ${backupVm}, will create a snapshot instead"
  fi
fi

# skip snapshot if the VM is not powered on
if [ ${poweredoff} -ne 1 ] && [ ${vmId} -gt 0 ]; then
  # create disk spec
  for disk in ${disks}; do
    _log "Adding disk to snapshot :: ${disk%%,*}"
    diskSpecs+=(--diskspec "${disk%%,*},snapshot=external")
  done
  for disk in ${excludeDisks}; do
    _log "Ignoring disk in snapshot :: ${disk%%,*}"
    diskSpecs+=(--diskspec "${disk%%,*},snapshot=no")
  done
  _log "Creating snapshot..."
  virsh snapshot-create-as \
    --domain ${vmId} \
    --atomic \
    --disk-only \
    --name "backup-snapshot_${date}" \
    --no-metadata \
    "${diskSpecs[@]}"
  if [ $? -ne 0 ]; then
    _log "Failed to create snapshot!"
    exit 7
  fi
  snap=1
  snapActive=1
else
  _log "Skip snapshot - VM is not powered on"
fi

# copy disks to VM backup directory
_log "Copy disks to backup directory..."
declare -A usedNames=()
for disk in ${disks}; do
  _log "Copying disk :: ${disk#*,}"
  filename="$(basename "${disk#*,}")"
  # disks with the same file name in different directories would overwrite
  # each other; add the target device to the name of any later duplicate
  bakName="${filename}_${date}.bak"
  if [ -n "${usedNames[${bakName}]:-}" ]; then
    bakName="${filename}_${disk%%,*}_${date}.bak"
  fi
  usedNames[${bakName}]=1
  if [ ${samefmt} -eq 1 ]; then
    cp --reflink=auto -v "${disk#*,}" "${backupVmDir}/${bakName}"
  else
    qemu-img convert -O qcow2 "${compress[@]}" "${disk#*,}" "${backupVmDir}/${bakName}"
  fi
done
_backup_nvram "${backupVmDir}/${backupXml}" "${backupVmDir}"

# skip commit and remove if the VM is not powered on
if [ ${vmId} -gt 0 ] && [ ${snap} -eq 1 ]; then
  _log "Commit changes back to original disks..."
  if ! _commit_snapshot; then
    exit 8
  fi
else
  _log "Skip commit and remove - VM is not powered on"
fi

# power on VM if poweroff flag passed and VM was actually powered off
if [ ${poweroff} -eq 1 ] && [ ${poweredoff} -eq 1 ]; then
  _log "Power on ${backupVm}..."
  virsh start "${backupVm}"
fi

# add entry to list file
# apparent size so the value reflects backup content and is not skewed by
# filesystem compression/sparseness (matches the incremental path)
used=$(du --apparent-size --block-size=1 "${backupVmDir}" | awk '{ print $1 }')
_log "Adding entry to list file '${listFile}'"
echo "$(uuid),${backupDir},${backupVm},${date},${used}" >> "${listFile}"

end=$(date +%s)
backuptime=$((end-start))

_log "Backup size :: ${used} bytes"
_log "Backup time :: ${backuptime} secs"

if [ ${keep} -gt 0 ]; then
  _log "Keeping the most recent ${keep} backups..."
  keepDir="${backupDir}/${backupVm}/"

  delDirs=$(find "${keepDir}" -mindepth 1 -maxdepth 1 -type d | sort -r | awk "NR>${keep}")
  for d in ${delDirs}; do
    delDate="$(basename "${d}")"
    _log "delete date :: ${delDate}"
    delDir="$(dirname "${keepDir}")/"
    _log "delete dir :: ${delDir}"
    _log "Removing ${d}..."
    rm -rv "${d}"
    _remove_list_rows "${delDir}" "${backupVm}" "${delDate}"
  done
fi

_log "Done."

exit 0
