use strict;
use warnings;
+use Clone;
use Fcntl ':flock';
+use File::Basename;
use File::Path;
use IO::File;
use IO::Select;
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) {
}
}
+sub get_storage_param {
+ my ($param) = @_;
+
+ return if $param->{dumpdir};
+ return $param->{storage} || 'local';
+}
+
# helper functions
sub debugmsg {
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) = @_;
my $scfg = PVE::Storage::storage_config($cfg, $storage);
my $type = $scfg->{type};
- die "can't use storage type '$type' for backup\n"
- if (!($type eq 'dir' || $type eq 'nfs' || $type eq 'glusterfs'
- || $type eq 'cifs' || $type eq 'cephfs' || $type eq 'pbs'));
die "can't use storage '$storage' for backups - wrong content type\n"
if (!$scfg->{content}->{backup});
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});
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;
+ }
+
+ # 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};
$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";
$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) {
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];
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_backup_file_list {
- my ($dir, $bkname, $exclude_fn) = @_;
+ my ($dir, $bkname) = @_;
my $bklist = [];
foreach my $fn (<$dir/${bkname}-*>) {
- next if $exclude_fn && $fn eq $exclude_fn;
-
my $archive_info = eval { PVE::Storage::archive_info($fn) } // {};
if ($archive_info->{is_std_name}) {
- my $filename = $archive_info->{filename};
+ my $path = "$dir/$archive_info->{filename}";
my $backup = {
- 'path' => "$dir/$filename",
+ 'path' => $path,
'ctime' => $archive_info->{ctime},
};
+ $backup->{mark} = "protected"
+ if -e PVE::Storage::protection_file_path($path);
push @{$bklist}, $backup;
}
}
}
}
- if ($backup_limit && !$opts->{remove}) {
+ if (($backup_limit && !$opts->{remove}) || $opts->{protected}) {
my $count;
- if ($self->{opts}->{pbs}) {
- my $res = PVE::Storage::PBSPlugin::run_client_cmd($opts->{scfg}, $opts->{storage}, 'snapshots', $pbs_group_name);
- $count = scalar(@$res);
+ my $protected_count;
+ if (my $storeid = $opts->{storage}) {
+ 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 {
- my $bklist = get_backup_file_list($opts->{dumpdir}, $bkname);
- $count = scalar(@$bklist);
+ $count = grep { !$_->{mark} || $_->{mark} ne "protected" } get_backup_file_list($opts->{dumpdir}, $bkname)->@*;
+ }
+
+ 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";
}
- 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 (!$self->{opts}->{pbs}) {
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_backup_file_list($opts->{dumpdir}, $bkname, $task->{target});
+ my $bklist = get_backup_file_list($opts->{dumpdir}, $bkname);
+
PVE::Storage::prune_mark_backup_group($bklist, $prune_options);
foreach my $prune_entry (@{$bklist}) {
'upload-log',
[ $pbs_snapshot_name, $task->{tmplog} ],
errmsg => "uploading backup task log failed",
+ outfunc => sub {},
);
};
debugmsg('warn', "$@") if $@; # $@ contains already error prefix
};
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) {
my $totaltime = time() - $starttime;
- eval { $self->sendmail ($tasklist, $totaltime, undef, $job_start_log, $job_end_log); };
+ eval {
+ # otherwise $self->sendmail() will interpret it as multiple problems
+ my $chomped_err = $err;
+ chomp($chomped_err) if $chomped_err;
+
+ $self->sendmail(
+ $tasklist,
+ $totaltime,
+ $chomped_err,
+ $self->{job_init_log} . $job_start_log,
+ $job_end_log,
+ );
+ };
debugmsg ('err', $@) if $@;
die $err if $err;
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})) {
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});