* [PATCH storage] fix #6969: plugin: keep content directory when freeing its last file
@ 2026-09-26 9:32 Michal Fox
2026-09-28 13:37 ` Max R. Carrara
0 siblings, 1 reply; 2+ messages in thread
From: Michal Fox @ 2026-09-26 9:32 UTC (permalink / raw)
To: pve-devel
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>
---
src/PVE/Storage/Plugin.pm | 6 ++---
src/test/free_image_test.pm | 51 ++++++++++++++++++++++++++++++++++++
src/test/run_plugin_tests.pl | 1 +
3 files changed, 55 insertions(+), 3 deletions(-)
create mode 100644 src/test/free_image_test.pm
diff --git a/src/PVE/Storage/Plugin.pm b/src/PVE/Storage/Plugin.pm
index a9e1751..d499fd9 100644
--- a/src/PVE/Storage/Plugin.pm
+++ b/src/PVE/Storage/Plugin.pm
@@ -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;
}
diff --git a/src/test/free_image_test.pm b/src/test/free_image_test.pm
new file mode 100644
index 0000000..6161866
--- /dev/null
+++ b/src/test/free_image_test.pm
@@ -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;
diff --git a/src/test/run_plugin_tests.pl b/src/test/run_plugin_tests.pl
index 8bce9d3..2e1c64f 100755
--- a/src/test/run_plugin_tests.pl
+++ b/src/test/run_plugin_tests.pl
@@ -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};
--
2.43.0
^ permalink raw reply related [flat|nested] 2+ messages in thread* Re: [PATCH storage] fix #6969: plugin: keep content directory when freeing its last file
2026-09-26 9:32 [PATCH storage] fix #6969: plugin: keep content directory when freeing its last file Michal Fox
@ 2026-09-28 13:37 ` Max R. Carrara
0 siblings, 0 replies; 2+ messages in thread
From: Max R. Carrara @ 2026-09-28 13:37 UTC (permalink / raw)
To: Michal Fox, pve-devel
On Sat Sep 26, 2026 at 11:32 AM CEST, Michal Fox wrote:
> 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>
> ---
Tested this on my workstation by creating an empty file in
`/var/lib/vz/snippets/` and changing the directory's permissions to see
whether it was re-created at a glance.
With the patch, the content dir is indeed left as-is. So, works as
advertised.
LGTM! Thanks for your contribution, it's very appreciated!
Consider:
Reviewed-by: Max R. Carrara <m.carrara@proxmox.com>
Tested-by: Max R. Carrara <m.carrara@proxmox.com>
> src/PVE/Storage/Plugin.pm | 6 ++---
> src/test/free_image_test.pm | 51 ++++++++++++++++++++++++++++++++++++
> src/test/run_plugin_tests.pl | 1 +
> 3 files changed, 55 insertions(+), 3 deletions(-)
> create mode 100644 src/test/free_image_test.pm
>
> diff --git a/src/PVE/Storage/Plugin.pm b/src/PVE/Storage/Plugin.pm
> index a9e1751..d499fd9 100644
> --- a/src/PVE/Storage/Plugin.pm
> +++ b/src/PVE/Storage/Plugin.pm
> @@ -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;
> }
> diff --git a/src/test/free_image_test.pm b/src/test/free_image_test.pm
> new file mode 100644
> index 0000000..6161866
> --- /dev/null
> +++ b/src/test/free_image_test.pm
> @@ -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;
> diff --git a/src/test/run_plugin_tests.pl b/src/test/run_plugin_tests.pl
> index 8bce9d3..2e1c64f 100755
> --- a/src/test/run_plugin_tests.pl
> +++ b/src/test/run_plugin_tests.pl
> @@ -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};
^ permalink raw reply [flat|nested] 2+ messages in thread
end of thread, other threads:[~2026-09-28 13:37 UTC | newest]
Thread overview: 2+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-09-26 9:32 [PATCH storage] fix #6969: plugin: keep content directory when freeing its last file Michal Fox
2026-09-28 13:37 ` Max R. Carrara
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox