X-Git-Url: https://git.proxmox.com/?a=blobdiff_plain;f=PVE%2FStorage%2FPlugin.pm;h=34f962301224a1cf05a30673444298fe511d8492;hb=cd461a5012b63eeede63e7b99b4f59ce82fb6dbf;hp=5f3e4c1707e5809248e62b72423e78f40c8a2b36;hpb=277cafc0ff01dd7eb409ab0cc693ea779f357146;p=pve-storage.git diff --git a/PVE/Storage/Plugin.pm b/PVE/Storage/Plugin.pm index 5f3e4c1..34f9623 100644 --- a/PVE/Storage/Plugin.pm +++ b/PVE/Storage/Plugin.pm @@ -8,17 +8,18 @@ use File::chdir; use File::Path; use File::Basename; use File::stat qw(); -use Time::Local qw(timelocal); use PVE::Tools qw(run_command); -use PVE::JSONSchema qw(get_standard_option); +use PVE::JSONSchema qw(get_standard_option register_standard_option); use PVE::Cluster qw(cfs_register_file); use JSON; use base qw(PVE::SectionConfig); -use constant COMPRESSOR_RE => 'gz|lzo'; +use constant COMPRESSOR_RE => 'gz|lzo|zst'; + +use constant NOTES_EXT => ".notes"; our @COMMON_TAR_FLAGS = qw( --one-file-system @@ -36,7 +37,22 @@ our @SHARED_STORAGE = ( 'iscsidirect', 'glusterfs', 'zfs', - 'drbd'); + 'drbd', + 'pbs', +); + +our $QCOW2_PREALLOCATION = { + off => 1, + metadata => 1, + falloc => 1, + full => 1, +}; + +our $RAW_PREALLOCATION = { + off => 1, + falloc => 1, + full => 1, +}; our $MAX_VOLUMES_PER_GUEST = 1024; @@ -44,6 +60,74 @@ cfs_register_file ('storage.cfg', sub { __PACKAGE__->parse_config(@_); }, sub { __PACKAGE__->write_config(@_); }); +my %prune_option = ( + optional => 1, + type => 'integer', minimum => '0', + format_description => 'N', +); + +our $prune_backups_format = { + 'keep-all' => { + type => 'boolean', + description => 'Keep all backups. Conflicts with the other options when true.', + optional => 1, + }, + 'keep-last' => { + %prune_option, + description => 'Keep the last backups.', + }, + 'keep-hourly' => { + %prune_option, + description => 'Keep backups for the last different hours. If there is more' . + 'than one backup for a single hour, only the latest one is kept.' + }, + 'keep-daily' => { + %prune_option, + description => 'Keep backups for the last different days. If there is more' . + 'than one backup for a single day, only the latest one is kept.' + }, + 'keep-weekly' => { + %prune_option, + description => 'Keep backups for the last different weeks. If there is more' . + 'than one backup for a single week, only the latest one is kept.' + }, + 'keep-monthly' => { + %prune_option, + description => 'Keep backups for the last different months. If there is more' . + 'than one backup for a single month, only the latest one is kept.' + }, + 'keep-yearly' => { + %prune_option, + description => 'Keep backups for the last different years. If there is more' . + 'than one backup for a single year, only the latest one is kept.' + }, +}; +PVE::JSONSchema::register_format('prune-backups', $prune_backups_format, \&validate_prune_backups); +sub validate_prune_backups { + my ($prune_backups) = @_; + + my $res = { $prune_backups->%* }; + + my $keep_all = delete $res->{'keep-all'}; + + if (scalar(grep { $_ > 0 } values %{$res}) == 0) { + $res = { 'keep-all' => 1 }; + } elsif ($keep_all) { + die "keep-all cannot be set together with other options.\n"; + } + + return $res; +} +register_standard_option('prune-backups', { + description => "The retention options with shorter intervals are processed first " . + "with --keep-last being the very first one. Each option covers a " . + "specific period of time. We say that backups within this period " . + "are covered by this option. The next option does not take care " . + "of already covered backups and only considers older backups.", + optional => 1, + type => 'string', + format => 'prune-backups', +}); my $defaultData = { propertyList => { @@ -64,11 +148,13 @@ my $defaultData = { optional => 1, }, maxfiles => { - description => "Maximal number of backup files per VM. Use '0' for unlimted.", + description => "Deprecated: use 'prune-backups' instead. " . + "Maximal number of backup files per VM. Use '0' for unlimited.", type => 'integer', minimum => 0, optional => 1, }, + 'prune-backups' => get_standard_option('prune-backups'), shared => { description => "Mark storage as shared.", type => 'boolean', @@ -79,6 +165,13 @@ my $defaultData = { type => 'string', format => 'pve-storage-format', optional => 1, }, + preallocation => { + description => "Preallocation mode for raw and qcow2 images. " . + "Using 'metadata' on raw images results in preallocation=off.", + type => 'string', enum => ['off', 'metadata', 'falloc', 'full'], + default => 'metadata', + optional => 1, + }, }, }; @@ -274,6 +367,10 @@ sub decode_value { die "unable to combine 'none' with other content types\n"; } + if (scalar(keys $res->%*) == 0 && !$valid_content->{none}) { + die "storage does not support content type 'none'\n"; + } + return $res; } elsif ($key eq 'format') { my $valid_formats = $def->{format}->[0]; @@ -332,8 +429,15 @@ sub parse_config { type => 'dir', priority => 0, # force first entry path => '/var/lib/vz', - maxfiles => 0, - content => { images => 1, rootdir => 1, vztmpl => 1, iso => 1, snippets => 1}, + 'prune-backups' => 'keep-all=1', + content => { + backup => 1, + images => 1, + iso => 1, + rootdir => 1, + snippets => 1, + vztmpl => 1, + }, }; } @@ -360,15 +464,41 @@ sub parse_config { return $cfg; } +sub preallocation_cmd_option { + my ($scfg, $fmt) = @_; + + my $prealloc = $scfg->{preallocation}; + + if ($fmt eq 'qcow2') { + $prealloc = $prealloc // 'metadata'; + + die "preallocation mode '$prealloc' not supported by format '$fmt'\n" + if !$QCOW2_PREALLOCATION->{$prealloc}; + + return "preallocation=$prealloc"; + } elsif ($fmt eq 'raw') { + $prealloc = $prealloc // 'off'; + $prealloc = 'off' if $prealloc eq 'metadata'; + + die "preallocation mode '$prealloc' not supported by format '$fmt'\n" + if !$RAW_PREALLOCATION->{$prealloc}; + + return "preallocation=$prealloc"; + } + + return; +} + # Storage implementation # called during addition of storage (before the new storage config got written) -# die to abort additon if there are (grave) problems +# die to abort addition if there are (grave) problems # NOTE: runs in a storage config *locked* context sub on_add_hook { my ($class, $storeid, $scfg, %param) = @_; # do nothing by default + return undef; } # called during storage configuration update (before the updated storage config got written) @@ -378,6 +508,7 @@ sub on_update_hook { my ($class, $storeid, $scfg, %param) = @_; # do nothing by default + return undef; } # called during deletion of storage (before the new storage config got written) @@ -389,6 +520,7 @@ sub on_delete_hook { my ($class, $storeid, $scfg) = @_; # do nothing by default + return undef; } sub cluster_lock_storage { @@ -431,9 +563,9 @@ sub parse_volname { my ($vmid, $name) = ($1, $2); my (undef, $format, $isBase) = parse_name_dir($name); return ('images', $name, $vmid, undef, undef, $isBase, $format); - } elsif ($volname =~ m!^iso/([^/]+$PVE::Storage::iso_extension_re)$!) { + } elsif ($volname =~ m!^iso/([^/]+$PVE::Storage::ISO_EXT_RE_0)$!) { return ('iso', $1); - } elsif ($volname =~ m!^vztmpl/([^/]+\.tar\.[gx]z)$!) { + } elsif ($volname =~ m!^vztmpl/([^/]+$PVE::Storage::VZTMPL_EXT_RE_1)$!) { return ('vztmpl', $1); } elsif ($volname =~ m!^rootdir/(\d+)$!) { return ('rootdir', $1, $1); @@ -468,7 +600,7 @@ sub get_subdir { my $path = $scfg->{path}; - die "storage definintion has no path\n" if !$path; + die "storage definition has no path\n" if !$path; my $subdir = $vtype_subdirs->{$vtype}; @@ -604,7 +736,7 @@ sub clone_image { my ($class, $scfg, $storeid, $volname, $vmid, $snap) = @_; # this only works for file based storage types - die "storage definintion has no path\n" if !$scfg->{path}; + die "storage definition has no path\n" if !$scfg->{path}; my ($vtype, $basename, $basevmid, undef, undef, $isBase, $format) = $class->parse_volname($volname); @@ -622,7 +754,7 @@ sub clone_image { mkpath $imagedir; - my $name = $class->find_free_diskname($imagedir, $scfg, $vmid, "qcow2", 1); + my $name = $class->find_free_diskname($storeid, $scfg, $vmid, "qcow2", 1); warn "clone $volname: $vtype, $name, $vmid to $name (base=../$basevmid/$basename)\n"; @@ -635,7 +767,7 @@ sub clone_image { local $CWD = $imagedir; my $cmd = ['/usr/bin/qemu-img', 'create', '-b', "../$basevmid/$basename", - '-f', 'qcow2', $path]; + '-F', $format, '-f', 'qcow2', $path]; run_command($cmd); }; @@ -654,7 +786,7 @@ sub alloc_image { mkpath $imagedir; - $name = $class->find_free_diskname($imagedir, $scfg, $vmid, $fmt, 1) if !$name; + $name = $class->find_free_diskname($storeid, $scfg, $vmid, $fmt, 1) if !$name; my (undef, $tmpfmt) = parse_name_dir($name); @@ -677,7 +809,8 @@ sub alloc_image { } else { my $cmd = ['/usr/bin/qemu-img', 'create']; - push @$cmd, '-o', 'preallocation=metadata' if $fmt eq 'qcow2'; + my $prealloc_opt = preallocation_cmd_option($scfg, $fmt); + push @$cmd, '-o', $prealloc_opt if defined($prealloc_opt); push @$cmd, '-f', $fmt, $path, "${size}K"; @@ -695,6 +828,9 @@ sub alloc_image { sub free_image { my ($class, $storeid, $scfg, $volname, $isBase, $format) = @_; + die "cannot remove protected volume '$volname' on '$storeid'\n" + if $class->get_volume_attribute($scfg, $storeid, $volname, 'protected'); + my $path = $class->filesystem_path($scfg, $volname); if ($isBase) { @@ -727,6 +863,12 @@ sub file_size_info { my $st = File::stat::stat($filename); + if (!defined($st)) { + my $extramsg = -l $filename ? ' - dangling symlink?' : ''; + warn "failed to stat '$filename'$extramsg\n"; + return undef; + } + if (S_ISDIR($st->mode)) { return wantarray ? (0, 'subvol', 0, undef, $st->ctime) : 1; } @@ -742,13 +884,73 @@ sub file_size_info { warn $@ if $@; my $info = eval { decode_json($json) }; - warn "could not parse qemu-img info command output for '$filename'\n" if $@; + if (my $err = $@) { + warn "could not parse qemu-img info command output for '$filename' - $err\n"; + return wantarray ? (undef, undef, undef, undef, $st->ctime) : undef; + } my ($size, $format, $used, $parent) = $info->@{qw(virtual-size format actual-size backing-filename)}; + ($size) = ($size =~ /^(\d+)$/) or die "size '$size' not an integer\n"; # untaint + # coerce back from string + $size = int($size); + ($used) = ($used =~ /^(\d+)$/) or die "used '$used' not an integer\n"; # untaint + # coerce back from string + $used = int($used); + ($format) = ($format =~ /^(\S+)$/) or die "format '$format' includes whitespace\n"; # untaint + if (defined($parent)) { + ($parent) = ($parent =~ /^(\S+)$/) or die "parent '$parent' includes whitespace\n"; # untaint + } return wantarray ? ($size, $format, $used, $parent, $st->ctime) : $size; } +# FIXME remove on the next APIAGE reset. +# Deprecated, use get_volume_attribute instead. +sub get_volume_notes { + my ($class, $scfg, $storeid, $volname, $timeout) = @_; + + die "volume notes are not supported for $class"; +} + +# FIXME remove on the next APIAGE reset. +# Deprecated, use update_volume_attribute instead. +sub update_volume_notes { + my ($class, $scfg, $storeid, $volname, $notes, $timeout) = @_; + + die "volume notes are not supported for $class"; +} + +# Returns undef if the attribute is not supported for the volume. +# Should die if there is an error fetching the attribute. +# Possible attributes: +# notes - user-provided comments/notes. +# protected - not to be removed by free_image, and for backups, ignored when pruning. +sub get_volume_attribute { + my ($class, $scfg, $storeid, $volname, $attribute) = @_; + + if ($attribute eq 'notes') { + my $notes = eval { $class->get_volume_notes($scfg, $storeid, $volname); }; + if (my $err = $@) { + return if $err =~ m/^volume notes are not supported/; + die $err; + } + return $notes; + } + + return; +} + +# Dies if the attribute is not supported for the volume. +sub update_volume_attribute { + my ($class, $scfg, $storeid, $volname, $attribute, $value) = @_; + + if ($attribute eq 'notes') { + $class->update_volume_notes($scfg, $storeid, $volname, $value); + } + + die "attribute '$attribute' is not supported for storage type '$scfg->{type}'\n"; +} + sub volume_size_info { my ($class, $scfg, $storeid, $volname, $timeout) = @_; my $path = $class->filesystem_path($scfg, $volname); @@ -788,8 +990,11 @@ sub volume_snapshot { return undef; } +# Asserts that a rollback to $snap on $volname is possible. +# If certain snapshots are preventing the rollback and $blockers is an array +# reference, the snapshot names can be pushed onto $blockers prior to dying. sub volume_rollback_is_possible { - my ($class, $scfg, $storeid, $volname, $snap) = @_; + my ($class, $scfg, $storeid, $volname, $snap, $blockers) = @_; return 1; } @@ -826,6 +1031,10 @@ sub volume_snapshot_delete { return undef; } +sub volume_snapshot_needs_fsfreeze { + + return 0; +} sub storage_can_replicate { my ($class, $scfg, $storeid, $format) = @_; @@ -844,6 +1053,7 @@ sub volume_has_feature { snap => {qcow2 => 1} }, sparseinit => { base => {qcow2 => 1, raw => 1, vmdk => 1}, current => {qcow2 => 1, raw => 1, vmdk => 1} }, + rename => { current => {qcow2 => 1, raw => 1, vmdk => 1} }, }; # clone_image creates a qcow2 volume @@ -851,6 +1061,8 @@ sub volume_has_feature { defined($opts->{valid_target_formats}) && !(grep { $_ eq 'qcow2' } @{$opts->{valid_target_formats}}); + return 0 if $feature eq 'rename' && $class->can('api') && $class->api() < 10; + my ($vtype, $name, $vmid, $basename, $basevmid, $isBase, $format) = $class->parse_volname($volname); @@ -922,41 +1134,48 @@ my $get_subdir_files = sub { my $res = []; foreach my $fn (<$path/*>) { - my $st = File::stat::stat($fn); - next if S_ISDIR($st->mode); + next if (!$st || S_ISDIR($st->mode)); my $info; if ($tt eq 'iso') { - next if $fn !~ m!/([^/]+$PVE::Storage::iso_extension_re)$!i; + next if $fn !~ m!/([^/]+$PVE::Storage::ISO_EXT_RE_0)$!i; $info = { volid => "$sid:iso/$1", format => 'iso' }; } elsif ($tt eq 'vztmpl') { - next if $fn !~ m!/([^/]+\.tar\.([gx]z))$!; + next if $fn !~ m!/([^/]+$PVE::Storage::VZTMPL_EXT_RE_1)$!; $info = { volid => "$sid:vztmpl/$1", format => "t$2" }; } elsif ($tt eq 'backup') { - next if defined($vmid) && $fn !~ m/\S+-$vmid-\S+/; next if $fn !~ m!/([^/]+\.(tgz|(?:(?:tar|vma)(?:\.(${\COMPRESSOR_RE}))?)))$!; - + my $original = $fn; my $format = $2; $fn = $1; + + # only match for VMID now, to avoid false positives (VMID in parent directory name) + next if defined($vmid) && $fn !~ m/\S+-$vmid-\S+/; + $info = { volid => "$sid:backup/$fn", format => $format }; - if ($fn =~ m!^vzdump\-(?:lxc|qemu)\-(?:[1-9][0-9]{2,8})\-(\d{4})_(\d{2})_(\d{2})\-(\d{2})_(\d{2})_(\d{2})\.${format}$!) { - my $epoch = timelocal($6, $5, $4, $3, $2-1, $1 - 1900); - $info->{ctime} = $epoch; - } + my $archive_info = eval { PVE::Storage::archive_info($fn) } // {}; + + $info->{ctime} = $archive_info->{ctime} if defined($archive_info->{ctime}); if (defined($vmid) || $fn =~ m!\-([1-9][0-9]{2,8})\-[^/]+\.${format}$!) { $info->{vmid} = $vmid // $1; } + my $notes_fn = $original.NOTES_EXT; + if (-f $notes_fn) { + my $notes = PVE::Tools::file_read_firstline($notes_fn); + $info->{notes} = $notes if defined($notes); + } + $info->{protected} = 1 if -e PVE::Storage::protection_file_path($original); } elsif ($tt eq 'snippets') { $info = { @@ -974,6 +1193,8 @@ my $get_subdir_files = sub { return $res; }; +# If attributes are set on a volume, they should be included in the result. +# See get_volume_attribute for a list of possible attributes. sub list_volumes { my ($class, $storeid, $scfg, $vmid, $content_types) = @_; @@ -1029,7 +1250,7 @@ sub status { my $path = $scfg->{path}; - die "storage definintion has no path\n" if !$path; + die "storage definition has no path\n" if !$path; my $timeout = 2; my $res = PVE::Tools::df($path, $timeout); @@ -1039,13 +1260,14 @@ sub status { return ($res->{total}, $res->{avail}, $res->{used}, 1); } -sub volume_snapshot_list { +# Returns a hash with the snapshot names as keys and the following data: +# id - Unique id to distinguish different snapshots even if the have the same name. +# timestamp - Creation time of the snapshot (seconds since epoch). +# Returns an empty hash if the volume does not exist. +sub volume_snapshot_info { my ($class, $scfg, $storeid, $volname) = @_; - # implement in subclass - die "Volume_snapshot_list is not implemented for $class"; - - # return an empty array if dataset does not exist. + die "volume_snapshot_info is not implemented for $class"; } sub activate_storage { @@ -1053,7 +1275,7 @@ sub activate_storage { my $path = $scfg->{path}; - die "storage definintion has no path\n" if !$path; + die "storage definition has no path\n" if !$path; # this path test may hang indefinitely on unresponsive mounts my $timeout = 2; @@ -1121,6 +1343,77 @@ sub check_connection { return 1; } +sub prune_backups { + my ($class, $scfg, $storeid, $keep, $vmid, $type, $dryrun, $logfunc) = @_; + + $logfunc //= sub { print "$_[1]\n" }; + + my $backups = $class->list_volumes($storeid, $scfg, $vmid, ['backup']); + + my $backup_groups = {}; + my $prune_list = []; + + foreach my $backup (@{$backups}) { + my $volid = $backup->{volid}; + my $archive_info = eval { PVE::Storage::archive_info($volid) } // {}; + my $backup_type = $archive_info->{type} // 'unknown'; + my $backup_vmid = $archive_info->{vmid} // $backup->{vmid}; + + next if defined($type) && $type ne $backup_type; + + my $prune_entry = { + ctime => $backup->{ctime}, + type => $backup_type, + volid => $volid, + }; + + $prune_entry->{vmid} = $backup_vmid if defined($backup_vmid); + + if ($archive_info->{is_std_name}) { + die "internal error - got no VMID\n" if !defined($backup_vmid); + die "internal error - got wrong VMID '$backup_vmid' != '$vmid'\n" + if defined($vmid) && $backup_vmid ne $vmid; + + $prune_entry->{ctime} = $archive_info->{ctime}; + my $group = "$backup_type/$backup_vmid"; + push @{$backup_groups->{$group}}, $prune_entry; + } else { + # ignore backups that don't use the standard naming scheme + $prune_entry->{mark} = 'renamed'; + } + + $prune_entry->{mark} = 'protected' if $backup->{protected}; + + push @{$prune_list}, $prune_entry; + } + + foreach my $backup_group (values %{$backup_groups}) { + PVE::Storage::prune_mark_backup_group($backup_group, $keep); + } + + my $failed; + if (!$dryrun) { + foreach my $prune_entry (@{$prune_list}) { + next if $prune_entry->{mark} ne 'remove'; + + my $volid = $prune_entry->{volid}; + $logfunc->('info', "removing backup '$volid'"); + eval { + my (undef, $volname) = parse_volume_id($volid); + my $archive_path = $class->filesystem_path($scfg, $volname); + PVE::Storage::archive_remove($archive_path); + }; + if (my $err = $@) { + $logfunc->('err', "error when removing backup '$volid' - $err\n"); + $failed = 1; + } + } + } + die "error pruning backups - check log\n" if $failed; + + return $prune_list; +} + # Import/Export interface: # Any path based storage is assumed to support 'raw' and 'tar' streams, so # the default implementations will return this if $scfg->{path} is set, @@ -1160,7 +1453,6 @@ sub read_common_header($) { sysread($fh, my $size, 8); $size = unpack('Q<', $size); die "import: no size found in export header, aborting.\n" if !defined($size); - die "import: got a bad size (not a multiple of 1K), aborting.\n" if ($size&1023); # Size is in bytes! return $size; } @@ -1220,7 +1512,7 @@ sub volume_export_formats { # Import data from a stream, creating a new or replacing or adding to an existing volume. sub volume_import { - my ($class, $scfg, $storeid, $fh, $volname, $format, $base_snapshot, $with_snapshots, $allow_rename) = @_; + my ($class, $scfg, $storeid, $fh, $volname, $format, $snapshot, $base_snapshot, $with_snapshots, $allow_rename) = @_; die "volume import format '$format' not available for $class\n" if $format !~ /^(raw|tar|qcow2|vmdk)\+size$/; @@ -1280,7 +1572,7 @@ sub volume_import { } sub volume_import_formats { - my ($class, $scfg, $storeid, $volname, $base_snapshot, $with_snapshots) = @_; + my ($class, $scfg, $storeid, $volname, $snapshot, $base_snapshot, $with_snapshots) = @_; if ($scfg->{path} && !defined($base_snapshot)) { my $format = ($class->parse_volname($volname))[6]; if ($with_snapshots) { @@ -1293,4 +1585,39 @@ sub volume_import_formats { return (); } +sub rename_volume { + my ($class, $scfg, $storeid, $source_volname, $target_vmid, $target_volname) = @_; + die "not implemented in storage plugin '$class'\n" if $class->can('api') && $class->api() < 10; + die "no path found\n" if !$scfg->{path}; + + my ( + undef, + $source_image, + $source_vmid, + $base_name, + $base_vmid, + undef, + $format + ) = $class->parse_volname($source_volname); + + $target_volname = $class->find_free_diskname($storeid, $scfg, $target_vmid, $format, 1) + if !$target_volname; + + my $basedir = $class->get_subdir($scfg, 'images'); + + mkpath "${basedir}/${target_vmid}"; + + my $old_path = "${basedir}/${source_vmid}/${source_image}"; + my $new_path = "${basedir}/${target_vmid}/${target_volname}"; + + die "target volume '${target_volname}' already exists\n" if -e $new_path; + + my $base = $base_name ? "${base_vmid}/${base_name}/" : ''; + + rename($old_path, $new_path) || + die "rename '$old_path' to '$new_path' failed - $!\n"; + + return "${storeid}:${base}${target_vmid}/${target_volname}"; +} + 1;