]> git.proxmox.com Git - pve-manager.git/blobdiff - PVE/VZDump.pm
api: backup/vzdump: add get_storage_param helper
[pve-manager.git] / PVE / VZDump.pm
index fe8a9b289aa8bab9c9b99e6ed19633b8c71c4fda..c58e5f78fcbd1cab77543410500d88e3fb84f896 100644 (file)
@@ -3,7 +3,9 @@ package PVE::VZDump;
 use strict;
 use warnings;
 
+use Clone;
 use Fcntl ':flock';
+use File::Basename;
 use File::Path;
 use IO::File;
 use IO::Select;
@@ -34,6 +36,9 @@ my @plugins = qw();
 
 my $confdesc = PVE::VZDump::Common::get_confdesc();
 
+my $confdesc_for_defaults = Clone::clone($confdesc);
+delete $confdesc_for_defaults->{$_}->{requires} for qw(notes-template protected);
+
 # Load available plugins
 my @pve_vzdump_classes = qw(PVE::VZDump::QemuServer PVE::VZDump::LXC);
 foreach my $plug (@pve_vzdump_classes) {
@@ -50,6 +55,13 @@ foreach my $plug (@pve_vzdump_classes) {
     }
 }
 
+sub get_storage_param {
+    my ($param) = @_;
+
+    return if $param->{dumpdir};
+    return $param->{storage} || 'local';
+}
+
 # helper functions
 
 sub debugmsg {
@@ -69,6 +81,63 @@ sub run_command {
     PVE::Tools::run_command($cmdstr, %param, logfunc => $logfunc);
 }
 
+my $verify_notes_template = sub {
+    my ($template) = @_;
+
+    die "contains a line feed\n" if $template =~ /\n/;
+
+    my @problematic = ();
+    while ($template =~ /\\(.)/g) {
+       my $char = $1;
+       push @problematic, "escape sequence '\\$char' at char " . (pos($template) - 2)
+           if $char !~ /^[n\\]$/;
+    }
+
+    while ($template =~ /\{\{([^\s{}]+)\}\}/g) {
+       my $var = $1;
+       push @problematic, "variable '$var' at char " . (pos($template) - length($var))
+           if $var !~ /^(cluster|guestname|node|vmid)$/;
+    }
+
+    die "found unknown: " . join(', ', @problematic) . "\n" if scalar(@problematic);
+};
+
+my $generate_notes = sub {
+    my ($notes_template, $task) = @_;
+
+    $verify_notes_template->($notes_template);
+
+    my $info = {
+       cluster => PVE::Cluster::get_clinfo()->{cluster}->{name} // 'standalone node',
+       guestname => $task->{hostname} // "VM $task->{vmid}", # is always set for CTs
+       node => PVE::INotify::nodename(),
+       vmid => $task->{vmid},
+    };
+
+    my $unescape = sub {
+       my ($char) = @_;
+       return '\\' if $char eq '\\';
+       return "\n" if $char eq 'n';
+       die "unexpected escape character '$char'\n";
+    };
+
+    $notes_template =~ s/\\(.)/$unescape->($1)/eg;
+
+    my $vars = join('|', keys $info->%*);
+    $notes_template =~ s/\{\{($vars)\}\}/$info->{$1}/g;
+
+    return $notes_template;
+};
+
+my sub parse_performance {
+    my ($param) = @_;
+
+    if (defined(my $perf = $param->{performance})) {
+       return if ref($perf) eq 'HASH'; # already parsed
+       $param->{performance} = PVE::JSONSchema::parse_property_string('backup-performance', $perf);
+    }
+}
+
 my $parse_prune_backups_maxfiles = sub {
     my ($param, $kind) = @_;
 
@@ -205,24 +274,36 @@ sub read_vzdump_defaults {
        map {
            my $default = $confdesc->{$_}->{default};
             defined($default) ? ($_ => $default) : ()
-       } keys %$confdesc
+       } keys %$confdesc_for_defaults
     };
     $parse_prune_backups_maxfiles->($defaults, "defaults in VZDump schema");
+    parse_performance($defaults);
 
     my $raw;
     eval { $raw = PVE::Tools::file_get_contents($fn); };
     return $defaults if $@;
 
-    my $conf_schema = { type => 'object', properties => $confdesc, };
+    my $conf_schema = { type => 'object', properties => $confdesc_for_defaults };
     my $res = PVE::JSONSchema::parse_config($conf_schema, $fn, $raw);
     if (my $excludes = $res->{'exclude-path'}) {
-       $res->{'exclude-path'} = PVE::Tools::split_args($excludes);
+       if (ref($excludes) eq 'ARRAY') {
+           my $list = [];
+           for my $path ($excludes->@*) {
+               # We still use `split_args` here to be compatible with old configs where one line
+               # still has multiple space separated entries.
+               push $list->@*, PVE::Tools::split_args($path)->@*;
+           }
+           $res->{'exclude-path'} = $list;
+       } else {
+           $res->{'exclude-path'} = PVE::Tools::split_args($excludes);
+       }
     }
     if (defined($res->{mailto})) {
        my @mailto = split_list($res->{mailto});
        $res->{mailto} = [ @mailto ];
     }
     $parse_prune_backups_maxfiles->($res, "options in '$fn'");
+    parse_performance($res);
 
     foreach my $key (keys %$defaults) {
        $res->{$key} = $defaults->{$key} if !defined($res->{$key});
@@ -493,31 +574,41 @@ sub new {
        die "cannot use options 'storage' and 'dumpdir' at the same time\n";
     }
 
-    if (!$opts->{dumpdir} && !$opts->{storage}) {
-       $opts->{storage} = 'local';
+    if (my $storage = get_storage_param($opts)) {
+       $opts->{storage} = $storage;
     }
 
-    $self->{job_init_log} = '';
-    open my $job_init_fd, '>', \$self->{job_init_log};
-    $self->run_hook_script('job-init', undef, $job_init_fd);
-    close $job_init_fd;
-
-    PVE::Cluster::cfs_update(); # Pick up possible changes made by the hook script.
+    # Enforced by the API too, but these options might come in via defaults. Drop them if necessary.
+    if (!$opts->{storage}) {
+       delete $opts->{$_} for qw(notes-template protected);
+    }
 
     my $errors = '';
+    my $add_error = sub {
+       my ($error) = @_;
+       $errors .= "\n" if $errors;
+       chomp($error);
+       $errors .= $error;
+    };
+
+    eval {
+       $self->{job_init_log} = '';
+       open my $job_init_fd, '>', \$self->{job_init_log};
+       $self->run_hook_script('job-init', undef, $job_init_fd);
+       close $job_init_fd;
+
+       PVE::Cluster::cfs_update(); # Pick up possible changes made by the hook script.
+    };
+    $add_error->($@) if $@;
 
     if ($opts->{storage}) {
        my $storage_cfg = PVE::Storage::config();
        eval { PVE::Storage::activate_storage($storage_cfg, $opts->{storage}) };
-       if (my $err = $@) {
-           chomp($err);
-           $errors .= "could not activate storage '$opts->{storage}': $err";
-       }
+       $add_error->("could not activate storage '$opts->{storage}': $@") if $@;
 
        my $info = eval { storage_info ($opts->{storage}) };
        if (my $err = $@) {
-           chomp($err);
-           $errors .= "could not get storage information for '$opts->{storage}': $err";
+           $add_error->("could not get storage information for '$opts->{storage}': $err");
        } else {
            $opts->{dumpdir} = $info->{dumpdir};
            $opts->{scfg} = $info->{scfg};
@@ -525,7 +616,7 @@ sub new {
            $opts->{'prune-backups'} //= $info->{'prune-backups'};
        }
     } elsif ($opts->{dumpdir}) {
-       $errors .= "dumpdir '$opts->{dumpdir}' does not exist"
+       $add_error->("dumpdir '$opts->{dumpdir}' does not exist")
            if ! -d $opts->{dumpdir};
     } else {
        die "internal error";
@@ -537,8 +628,7 @@ sub new {
     $opts->{remove} = 0 if $opts->{'prune-backups'}->{'keep-all'};
 
     if ($opts->{tmpdir} && ! -d $opts->{tmpdir}) {
-       $errors .= "\n" if $errors;
-       $errors .= "tmpdir '$opts->{tmpdir}' does not exist";
+       $add_error->("tmpdir '$opts->{tmpdir}' does not exist");
     }
 
     if ($errors) {
@@ -637,9 +727,8 @@ sub run_hook_script {
     my $script = $opts->{script};
     return if !$script;
 
-    if (!-x $script) {
-       die "The hook script '$script' is not executable.\n";
-    }
+    die "Error: The hook script '$script' does not exist.\n" if ! -f $script;
+    die "Error: The hook script '$script' is not executable.\n" if ! -x $script;
 
     my $cmd = [$script, $phase];
 
@@ -685,13 +774,13 @@ sub compressor_info {
            my $cpuinfo = PVE::ProcFSTools::read_cpuinfo();
            $zstd_threads = int(($cpuinfo->{cpus} + 1)/2);
        }
-       return ("zstd --rsyncable --threads=${zstd_threads}", 'zst');
+       return ("zstd --threads=${zstd_threads}", 'zst');
     } else {
        die "internal error - unknown compression option '$opt_compress'";
     }
 }
 
-sub get_unprotected_backup_file_list {
+sub get_backup_file_list {
     my ($dir, $bkname) = @_;
 
     my $bklist = [];
@@ -699,11 +788,12 @@ sub get_unprotected_backup_file_list {
        my $archive_info = eval { PVE::Storage::archive_info($fn) } // {};
        if ($archive_info->{is_std_name}) {
            my $path = "$dir/$archive_info->{filename}";
-           next if -e PVE::Storage::protection_file_path($path);
            my $backup = {
                'path' => $path,
                'ctime' => $archive_info->{ctime},
            };
+           $backup->{mark} = "protected"
+               if -e PVE::Storage::protection_file_path($path);
            push @{$bklist}, $backup;
        }
     }
@@ -779,21 +869,33 @@ sub exec_backup_task {
            }
        }
 
-       if ($backup_limit && !$opts->{remove}) {
+       if (($backup_limit && !$opts->{remove}) || $opts->{protected}) {
            my $count;
+           my $protected_count;
            if (my $storeid = $opts->{storage}) {
-               my $backups = PVE::Storage::volume_list($cfg, $storeid, $vmid, 'backup');
-               $count = grep {
-                   !$_->{protected} && (!$_->{subtype} || $_->{subtype} eq $vmtype)
-               } $backups->@*;
+               my @backups = grep {
+                   !$_->{subtype} || $_->{subtype} eq $vmtype
+               } PVE::Storage::volume_list($cfg, $storeid, $vmid, 'backup')->@*;
+
+               $count = grep { !$_->{protected} } @backups;
+               $protected_count = scalar(@backups) - $count;
            } else {
-               $count = scalar(get_unprotected_backup_file_list($opts->{dumpdir}, $bkname)->@*);
+               $count = grep { !$_->{mark} || $_->{mark} ne "protected" } get_backup_file_list($opts->{dumpdir}, $bkname)->@*;
            }
 
-           die "There is a max backup limit of $backup_limit enforced by the".
-               " target storage or the vzdump parameters.".
-               " Either increase the limit or delete old backup(s).\n"
-               if $count >= $backup_limit;
+           if ($opts->{protected}) {
+               my $max_protected = PVE::Storage::get_max_protected_backups(
+                   $opts->{scfg},
+                   $opts->{storage},
+               );
+               if ($max_protected > -1 && $protected_count >= $max_protected) {
+                   die "The number of protected backups per guest is limited to $max_protected ".
+                       "on storage '$opts->{storage}'\n";
+               }
+           } elsif ($count >= $backup_limit) {
+               die "There is a max backup limit of $backup_limit enforced by the target storage ".
+                   "or the vzdump parameters. Either increase the limit or delete old backups.\n";
+           }
        }
 
        if (!$self->{opts}->{pbs}) {
@@ -994,12 +1096,35 @@ sub exec_backup_task {
            debugmsg ('info', "archive file size: $cs", $logfd);
        }
 
+       # Mark as protected before pruning.
+       if (my $storeid = $opts->{storage}) {
+           my $volname = $opts->{pbs} ? $task->{target} : basename($task->{target});
+           my $volid = "${storeid}:backup/${volname}";
+
+           if ($opts->{'notes-template'} && $opts->{'notes-template'} ne '') {
+               debugmsg('info', "adding notes to backup", $logfd);
+               my $notes = eval { $generate_notes->($opts->{'notes-template'}, $task); };
+               if (my $err = $@) {
+                   debugmsg('warn', "unable to add notes - $err", $logfd);
+               } else {
+                   eval { PVE::Storage::update_volume_attribute($cfg, $volid, 'notes', $notes) };
+                   debugmsg('warn', "unable to add notes - $@", $logfd) if $@;
+               }
+           }
+
+           if ($opts->{protected}) {
+               debugmsg('info', "marking backup as protected", $logfd);
+               eval { PVE::Storage::update_volume_attribute($cfg, $volid, 'protected', 1) };
+               die "unable to set protected flag - $@\n" if $@;
+           }
+       }
+
        if ($opts->{remove}) {
            my $keepstr = join(', ', map { "$_=$prune_options->{$_}" } sort keys %$prune_options);
            debugmsg ('info', "prune older backups with retention: $keepstr", $logfd);
            my $pruned = 0;
            if (!defined($opts->{storage})) {
-               my $bklist = get_unprotected_backup_file_list($opts->{dumpdir}, $bkname);
+               my $bklist = get_backup_file_list($opts->{dumpdir}, $bkname);
 
                PVE::Storage::prune_mark_backup_group($bklist, $prune_options);
 
@@ -1179,9 +1304,9 @@ sub exec_backup {
     };
     my $err = $@;
 
-    $self->run_hook_script ('job-abort', undef, $job_end_fd) if $err;
-
     if ($err) {
+       eval { $self->run_hook_script ('job-abort', undef, $job_end_fd); };
+       $err .= $@ if $@;
        debugmsg ('err', "Backup job failed - $err", undef, 1);
     } else {
        if ($errcount) {
@@ -1197,10 +1322,14 @@ sub exec_backup {
     my $totaltime = time() - $starttime;
 
     eval {
+       # otherwise $self->sendmail() will interpret it as multiple problems
+       my $chomped_err = $err;
+       chomp($chomped_err) if $chomped_err;
+
        $self->sendmail(
            $tasklist,
            $totaltime,
-           undef,
+           $chomped_err,
            $self->{job_init_log} . $job_start_log,
            $job_end_log,
        );
@@ -1227,10 +1356,15 @@ sub option_exists {
 sub parse_mailto_exclude_path {
     my ($param) = @_;
 
-    # exclude-path list need to be 0 separated
+    # exclude-path list need to be 0 separated or be an array
     if (defined($param->{'exclude-path'})) {
-       my @expaths = split(/\0/, $param->{'exclude-path'} || '');
-       $param->{'exclude-path'} = [ @expaths ];
+       my $expaths;
+       if (ref($param->{'exclude-path'}) eq 'ARRAY') {
+           $expaths = $param->{'exclude-path'};
+       } else {
+           $expaths = [split(/\0/, $param->{'exclude-path'} || '')];
+       }
+       $param->{'exclude-path'} = $expaths;
     }
 
     if (defined($param->{mailto})) {
@@ -1257,6 +1391,12 @@ sub verify_vzdump_parameters {
        if defined($param->{'prune-backups'}) && defined($param->{maxfiles});
 
     $parse_prune_backups_maxfiles->($param, 'CLI parameters');
+    parse_performance($param);
+
+    if (my $template = $param->{'notes-template'}) {
+       eval { $verify_notes_template->($template); };
+       raise_param_exc({'notes-template' => $@}) if $@;
+    }
 
     $param->{all} = 1 if (defined($param->{exclude}) && !$param->{pool});