fix #6969: plugin: keep content directory when freeing its last file

After removing a volume, free_image tries to remove the parent
directory, so that no empty per-guest directories are left behind once
all images of a guest are gone. This was done unconditionally, so
deleting the last ISO, container template, snippet or import file also
removed the shared content directory like 'snippets' or 'template/iso'.

The directory gets re-created shortly after by pvestatd, but as
root:root with default permissions, losing any ownership or mode that
the admin set up, for example to allow a non-root user to upload
snippets.

Only clean up the parent directory for guest images, which are the only
volumes stored in per-guest directories.

Signed-off-by: Michal Fox <me@dualfroz.com>
This commit is contained in:
dualfroz committed 2026-09-25 11:30:43 +02:00
1 parent 40459bfd61
commit 43d4bbbdb2
3 files changed
+55 -3

No files matched your search

+3 -3
View File
@@ -1166,9 +1166,9 @@ sub free_image {
}
# try to cleanup directory to not clutter storage with empty $vmid dirs if
# all images from a guest got deleted
my $dir = dirname($path);
rmdir($dir);
# all images from a guest got deleted, but keep the shared content type dirs
my ($vtype) = $class->parse_volname($volname);
rmdir(dirname($path)) if $vtype eq 'images';
return undef;
}
+51
View File
@@ -0,0 +1,51 @@
package PVE::Storage::TestFreeImage;
use strict;
use warnings;
use lib qw(..);
use File::Path qw(make_path);
use File::Temp;
use PVE::Storage;
use PVE::Tools qw(file_set_contents);
use Test::More;
my $storage_dir = File::Temp->newdir();
my $scfg = { type => 'dir', path => "$storage_dir" };
# each test is comprised of the following array keys:
# [0] => volname of the only volume in its directory, freed by the test
# [1] => parent directory of the volume, relative to the storage path
# [2] => whether the now empty parent directory is expected to be kept
my $tests = [
['100/vm-100-disk-0.raw', 'images/100', 0],
['iso/some.iso', 'template/iso', 1],
['vztmpl/some.tar.zst', 'template/cache', 1],
['snippets/hook.pl', 'snippets', 1],
['import/some.ova', 'import', 1],
];
plan tests => 2 * scalar(@$tests);
for my $tt (@$tests) {
my ($volname, $subdir, $keep_dir) = @$tt;
my $path = PVE::Storage::DirPlugin->filesystem_path($scfg, $volname);
make_path("$storage_dir/$subdir");
file_set_contents($path, '');
my $format = (PVE::Storage::DirPlugin->parse_volname($volname))[6];
PVE::Storage::DirPlugin->free_image('local', $scfg, $volname, 0, $format);
ok(!-e $path, "$volname - volume removed");
is(
!!-d "$storage_dir/$subdir",
!!$keep_dir,
"$volname - empty directory '$subdir' " . ($keep_dir ? 'kept' : 'removed'),
);
}
done_testing();
1;
+1
View File
@@ -17,6 +17,7 @@ my $res = $harness->runtests(
"get_subdir_test.pm",
"filesystem_path_test.pm",
"prune_backups_test.pm",
"free_image_test.pm",
);
exit -1 if !$res || $res->{failed} || $res->{parse_errors};