diff --git a/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java b/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java index 5402b6b24760..ee97a6c63c67 100644 --- a/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java +++ b/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java @@ -23,7 +23,9 @@ import com.cloud.agent.api.LogLevel; import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class TakeBackupCommand extends Command { private String vmName; @@ -35,6 +37,7 @@ public class TakeBackupCommand extends Command { private Boolean quiesce; @LogLevel(LogLevel.Log4jLevel.Off) private String mountOptions; + private Map details = new HashMap<>(); public TakeBackupCommand(String vmName, String backupPath) { super(); @@ -106,6 +109,18 @@ public void setQuiesce(Boolean quiesce) { this.quiesce = quiesce; } + public Map getDetails() { + return details; + } + + public void setDetails(Map details) { + this.details = details; + } + + public void addDetail(String key, String value) { + this.details.put(key, value); + } + @Override public boolean executeInSequence() { return true; diff --git a/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java b/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java index f2ea8ac71c91..ae436225857e 100644 --- a/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java +++ b/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java @@ -84,6 +84,46 @@ public class NASBackupProvider extends AdapterBase implements BackupProvider, Co true, BackupFrameworkEnabled.key()); + ConfigKey NASBackupCompressionEnabled = new ConfigKey<>("Advanced", Boolean.class, + "nas.backup.compression.enabled", + "false", + "Enable qcow2 compression for NAS backup files.", + true, + ConfigKey.Scope.Zone, + BackupFrameworkEnabled.key()); + + ConfigKey NASBackupEncryptionEnabled = new ConfigKey<>("Advanced", Boolean.class, + "nas.backup.encryption.enabled", + "false", + "Enable LUKS encryption for NAS backup files.", + true, + ConfigKey.Scope.Zone, + BackupFrameworkEnabled.key()); + + ConfigKey NASBackupEncryptionPassphrase = new ConfigKey<>("Secure", String.class, + "nas.backup.encryption.passphrase", + "", + "Passphrase for LUKS encryption of NAS backup files. Required when encryption is enabled.", + true, + ConfigKey.Scope.Zone, + BackupFrameworkEnabled.key()); + + ConfigKey NASBackupBandwidthLimitMbps = new ConfigKey<>("Advanced", Integer.class, + "nas.backup.bandwidth.limit.mbps", + "0", + "Bandwidth limit in MiB/s for backup operations (0 = unlimited).", + true, + ConfigKey.Scope.Zone, + BackupFrameworkEnabled.key()); + + ConfigKey NASBackupIntegrityCheckEnabled = new ConfigKey<>("Advanced", Boolean.class, + "nas.backup.integrity.check", + "false", + "Run qemu-img check on backup files after creation to verify integrity.", + true, + ConfigKey.Scope.Zone, + BackupFrameworkEnabled.key()); + @Inject private BackupDao backupDao; @@ -205,6 +245,26 @@ public Pair takeBackup(final VirtualMachine vm, Boolean quiesce command.setMountOptions(backupRepository.getMountOptions()); command.setQuiesce(quiesceVM); + // Pass optional backup enhancement settings from zone-scoped configs + Long zoneId = vm.getDataCenterId(); + if (Boolean.TRUE.equals(NASBackupCompressionEnabled.valueIn(zoneId))) { + command.addDetail("compression", "true"); + } + if (Boolean.TRUE.equals(NASBackupEncryptionEnabled.valueIn(zoneId))) { + command.addDetail("encryption", "true"); + String passphrase = NASBackupEncryptionPassphrase.valueIn(zoneId); + if (passphrase != null && !passphrase.isEmpty()) { + command.addDetail("encryption_passphrase", passphrase); + } + } + Integer bandwidthLimit = NASBackupBandwidthLimitMbps.valueIn(zoneId); + if (bandwidthLimit != null && bandwidthLimit > 0) { + command.addDetail("bandwidth_limit", String.valueOf(bandwidthLimit)); + } + if (Boolean.TRUE.equals(NASBackupIntegrityCheckEnabled.valueIn(zoneId))) { + command.addDetail("integrity_check", "true"); + } + if (VirtualMachine.State.Stopped.equals(vm.getState())) { List vmVolumes = volumeDao.findByInstance(vm.getId()); vmVolumes.sort(Comparator.comparing(Volume::getDeviceId)); @@ -594,7 +654,12 @@ public Boolean crossZoneInstanceCreationEnabled(BackupOffering backupOffering) { @Override public ConfigKey[] getConfigKeys() { return new ConfigKey[]{ - NASBackupRestoreMountTimeout + NASBackupRestoreMountTimeout, + NASBackupCompressionEnabled, + NASBackupEncryptionEnabled, + NASBackupEncryptionPassphrase, + NASBackupBandwidthLimitMbps, + NASBackupIntegrityCheckEnabled }; } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java index 11fa605908a6..7ff5ff7a1843 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java @@ -34,9 +34,13 @@ import org.apache.cloudstack.backup.TakeBackupCommand; import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.Objects; @ResourceWrapper(handles = TakeBackupCommand.class) @@ -68,21 +72,59 @@ public Answer execute(TakeBackupCommand command, LibvirtComputingResource libvir } } + List cmdArgs = new ArrayList<>(); + cmdArgs.add(libvirtComputingResource.getNasBackupPath()); + cmdArgs.add("-o"); cmdArgs.add("backup"); + cmdArgs.add("-v"); cmdArgs.add(vmName); + cmdArgs.add("-t"); cmdArgs.add(backupRepoType); + cmdArgs.add("-s"); cmdArgs.add(backupRepoAddress); + cmdArgs.add("-m"); cmdArgs.add(Objects.nonNull(mountOptions) ? mountOptions : ""); + cmdArgs.add("-p"); cmdArgs.add(backupPath); + cmdArgs.add("-q"); cmdArgs.add(command.getQuiesce() != null && command.getQuiesce() ? "true" : "false"); + cmdArgs.add("-d"); cmdArgs.add(diskPaths.isEmpty() ? "" : String.join(",", diskPaths)); + + // Append optional enhancement flags from management server config + File passphraseFile = null; + Map details = command.getDetails(); + if (details != null) { + if ("true".equals(details.get("compression"))) { + cmdArgs.add("-c"); + } + if ("true".equals(details.get("encryption"))) { + String passphrase = details.get("encryption_passphrase"); + if (passphrase != null && !passphrase.isEmpty()) { + try { + passphraseFile = File.createTempFile("cs-backup-enc-", ".key"); + passphraseFile.deleteOnExit(); + try (FileWriter fw = new FileWriter(passphraseFile)) { + fw.write(passphrase); + } + cmdArgs.add("-e"); cmdArgs.add(passphraseFile.getAbsolutePath()); + } catch (IOException e) { + logger.error("Failed to create encryption passphrase file", e); + return new BackupAnswer(command, false, "Failed to create encryption passphrase file: " + e.getMessage()); + } + } + } + String bwLimit = details.get("bandwidth_limit"); + if (bwLimit != null && !"0".equals(bwLimit)) { + cmdArgs.add("-b"); cmdArgs.add(bwLimit); + } + if ("true".equals(details.get("integrity_check"))) { + cmdArgs.add("--verify"); + } + } + List commands = new ArrayList<>(); - commands.add(new String[]{ - libvirtComputingResource.getNasBackupPath(), - "-o", "backup", - "-v", vmName, - "-t", backupRepoType, - "-s", backupRepoAddress, - "-m", Objects.nonNull(mountOptions) ? mountOptions : "", - "-p", backupPath, - "-q", command.getQuiesce() != null && command.getQuiesce() ? "true" : "false", - "-d", diskPaths.isEmpty() ? "" : String.join(",", diskPaths) - }); + commands.add(cmdArgs.toArray(new String[0])); Pair result = Script.executePipedCommands(commands, libvirtComputingResource.getCmdsTimeout()); + // Clean up passphrase file after backup completes + if (passphraseFile != null && passphraseFile.exists()) { + passphraseFile.delete(); + } + if (result.first() != 0) { logger.debug("Failed to take VM backup: " + result.second()); BackupAnswer answer = new BackupAnswer(command, false, result.second().trim()); diff --git a/scripts/vm/hypervisor/kvm/nasbackup.sh b/scripts/vm/hypervisor/kvm/nasbackup.sh index e298006f7a8c..aa54a2ad7cff 100755 --- a/scripts/vm/hypervisor/kvm/nasbackup.sh +++ b/scripts/vm/hypervisor/kvm/nasbackup.sh @@ -32,6 +32,10 @@ MOUNT_OPTS="" BACKUP_DIR="" DISK_PATHS="" QUIESCE="" +COMPRESS="" +BANDWIDTH="" +ENCRYPT_PASSFILE="" +VERIFY="" logFile="/var/log/cloudstack/agent/agent.log" EXIT_CLEANUP_FAILED=20 @@ -87,6 +91,52 @@ sanity_checks() { log -ne "Environment Sanity Checks successfully passed" } +encrypt_backup() { + local backup_dir="$1" + if [[ -z "$ENCRYPT_PASSFILE" ]]; then + return + fi + if [[ ! -f "$ENCRYPT_PASSFILE" ]]; then + echo "Encryption passphrase file not found: $ENCRYPT_PASSFILE" + exit 1 + fi + log -ne "Encrypting backup files with LUKS" + for img in "$backup_dir"/*.qcow2; do + [[ -f "$img" ]] || continue + local tmp_img="${img}.luks" + if qemu-img convert -O qcow2 \ + --object "secret,id=sec0,file=$ENCRYPT_PASSFILE" \ + -o "encrypt.format=luks,encrypt.key-secret=sec0" \ + "$img" "$tmp_img" 2>&1 | tee -a "$logFile"; then + mv "$tmp_img" "$img" + log -ne "Encrypted: $img" + else + echo "Encryption failed for $img" + rm -f "$tmp_img" + exit 1 + fi + done +} + +verify_backup() { + local backup_dir="$1" + local failed=0 + for img in "$backup_dir"/*.qcow2; do + [[ -f "$img" ]] || continue + if ! qemu-img check "$img" > /dev/null 2>&1; then + echo "Backup verification failed for $img" + log -ne "Backup verification FAILED: $img" + failed=1 + else + log -ne "Backup verification passed: $img" + fi + done + if [[ $failed -ne 0 ]]; then + echo "One or more backup files failed verification" + exit 1 + fi +} + ### Operation methods ### backup_running_vm() { @@ -128,6 +178,14 @@ backup_running_vm() { exit 1 fi + # Throttle backup bandwidth if requested (MiB/s per disk) + if [[ -n "$BANDWIDTH" ]]; then + for disk in $(virsh -c qemu:///system domblklist $VM --details 2>/dev/null | awk '/disk/{print$3}'); do + virsh -c qemu:///system blockjob $VM $disk --bandwidth "${BANDWIDTH}" 2>/dev/null || true + done + log -ne "Backup bandwidth limited to ${BANDWIDTH} MiB/s per disk for $VM" + fi + # Backup domain information virsh -c qemu:///system dumpxml $VM > $dest/domain-config.xml 2>/dev/null virsh -c qemu:///system dominfo $VM > $dest/dominfo.xml 2>/dev/null @@ -147,8 +205,32 @@ backup_running_vm() { done rm -f $dest/backup.xml + + # Compress backup files if requested + if [[ "$COMPRESS" == "true" ]]; then + log -ne "Compressing backup files for $VM" + for img in "$dest"/*.qcow2; do + [[ -f "$img" ]] || continue + local tmp_img="${img}.tmp" + if qemu-img convert -c -O qcow2 "$img" "$tmp_img" 2>&1 | tee -a "$logFile"; then + mv "$tmp_img" "$img" + else + log -ne "Warning: compression failed for $img, keeping uncompressed" + rm -f "$tmp_img" + fi + done + fi + + # Encrypt backup files if requested + encrypt_backup "$dest" + sync + # Verify backup integrity if requested + if [[ "$VERIFY" == "true" ]]; then + verify_backup "$dest" + fi + # Print statistics virsh -c qemu:///system domjobinfo $VM --completed du -sb $dest | cut -f1 @@ -174,14 +256,23 @@ backup_stopped_vm() { volUuid="${disk##*/}" fi output="$dest/$name.$volUuid.qcow2" - if ! qemu-img convert -O qcow2 "$disk" "$output" > "$logFile" 2> >(cat >&2); then + if ! ionice -c 3 qemu-img convert $([[ "$COMPRESS" == "true" ]] && echo "-c") $([[ -n "$BANDWIDTH" ]] && echo "-r" "${BANDWIDTH}M") -O qcow2 "$disk" "$output" > "$logFile" 2> >(cat >&2); then echo "qemu-img convert failed for $disk $output" cleanup fi name="datadisk" done + + # Encrypt backup files if requested + encrypt_backup "$dest" + sync + # Verify backup integrity if requested + if [[ "$VERIFY" == "true" ]]; then + verify_backup "$dest" + fi + ls -l --numeric-uid-gid $dest | awk '{print $5}' } @@ -233,7 +324,7 @@ cleanup() { function usage { echo "" - echo "Usage: $0 -o -v|--vm -t -s -m -p -d -q|--quiesce " + echo "Usage: $0 -o -v|--vm -t -s -m -p -d -q|--quiesce [-c] [-b ] [-e ] [--verify]" echo "" exit 1 } @@ -280,6 +371,24 @@ while [[ $# -gt 0 ]]; do shift shift ;; + -c|--compress) + COMPRESS="true" + shift + ;; + -b|--bandwidth) + BANDWIDTH="$2" + shift + shift + ;; + -e|--encrypt) + ENCRYPT_PASSFILE="$2" + shift + shift + ;; + --verify) + VERIFY="true" + shift + ;; -h|--help) usage shift