From 5d830f6d9de9f24e68c53d7689c204c6910e40fc Mon Sep 17 00:00:00 2001 From: Rusty Bird Date: Fri, 3 Jul 2026 13:01:04 +0000 Subject: [PATCH 1/7] storage: add getter for volume's VM's name --- qubesadmin/storage.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/qubesadmin/storage.py b/qubesadmin/storage.py index 31b68cf5..902ffb61 100644 --- a/qubesadmin/storage.py +++ b/qubesadmin/storage.py @@ -116,6 +116,11 @@ def __lt__(self, other: object) -> bool: return (self._pool, self._vid) < (other._pool, other._vid) return NotImplemented + @property + def vm(self) -> str | None: + """Name of the VM the volume belongs to, if available""" + return self._vm + @property def name(self) -> str | None: """per-VM volume name, if available""" From 99c9c0767fbc5569a4a61102fbc0d45b50c02d2f Mon Sep 17 00:00:00 2001 From: Rusty Bird Date: Fri, 3 Jul 2026 13:05:31 +0000 Subject: [PATCH 2/7] tests/storage: remove phantom admin.pool.volume.* tests The only method in admin.pool.volume.* that actually exists is List; everything else related to volumes lives in admin.vm.volume.* --- qubesadmin/tests/storage.py | 78 +------------------------------------ 1 file changed, 1 insertion(+), 77 deletions(-) diff --git a/qubesadmin/tests/storage.py b/qubesadmin/tests/storage.py index 76d168ec..39bbf4e7 100644 --- a/qubesadmin/tests/storage.py +++ b/qubesadmin/tests/storage.py @@ -31,8 +31,6 @@ def setUp(self): super().setUp() self.vol = qubesadmin.storage.Volume(self.app, vm='test-vm', vm_name='volname') - self.pool_vol = qubesadmin.storage.Volume(self.app, pool='test-pool', - vid='some-id') def expect_info(self): self.app.expected_calls[ @@ -175,7 +173,7 @@ def test_050_clone(self): self.assertAllCalled() -class TestPoolVolume(TestVMVolume): +class TestPoolVolume(qubesadmin.tests.QubesTestCase): def setUp(self): super().setUp() self.vol = qubesadmin.storage.Volume(self.app, pool='test-pool', @@ -190,29 +188,6 @@ def test_000_qubesd_call(self): b'method_result') self.assertAllCalled() - def expect_info(self): - self.app.expected_calls[ - ('dom0', 'admin.pool.volume.Info', 'test-pool', b'some-id')] = \ - b'0\x00' \ - b'pool=test-pool\n' \ - b'vid=some-id\n' \ - b'size=1024\n' \ - b'usage=512\n' \ - b'rw=True\n' \ - b'snap_on_start=True\n' \ - b'save_on_stop=True\n' \ - b'source=\n' \ - b'revisions_to_keep=3\n' - - def test_001_fetch_info(self): - self.app.expected_calls[ - ('dom0', 'admin.pool.volume.Info', 'test-pool', - b'some-id')] = \ - b'0\x00prop1=val1\nprop2=val2\n' - self.vol._fetch_info() - self.assertEqual(self.vol._info, {'prop1': 'val1', 'prop2': 'val2'}) - self.assertAllCalled() - def test_010_pool(self): # this should _not_ produce any api call, as pool is already known self.assertEqual(self.vol.pool, 'test-pool') @@ -223,57 +198,6 @@ def test_011_vid(self): self.assertEqual(self.vol.vid, 'some-id') self.assertAllCalled() - def test_021_revisions(self): - self.app.expected_calls[ - ('dom0', 'admin.pool.volume.ListSnapshots', - 'test-pool', b'some-id')] = \ - b'0\x00' \ - b'snapid1\n' \ - b'snapid2\n' \ - b'snapid3\n' - self.assertEqual(self.vol.revisions, - ['snapid1', 'snapid2', 'snapid3']) - self.assertAllCalled() - - def test_022_revisions_empty(self): - self.app.expected_calls[ - ('dom0', 'admin.pool.volume.ListSnapshots', - 'test-pool', b'some-id')] = b'0\x00' - self.assertEqual(self.vol.revisions, []) - self.assertAllCalled() - - def test_030_resize(self): - self.app.expected_calls[ - ('dom0', 'admin.pool.volume.Resize', - 'test-pool', b'some-id 2048')] = b'0\x00' - self.vol.resize(2048) - self.assertAllCalled() - - def test_031_revert(self): - self.app.expected_calls[ - ('dom0', 'admin.pool.volume.Revert', 'test-pool', - b'some-id snapid1')] = b'0\x00' - self.vol.revert('snapid1') - self.assertAllCalled() - - def test_040_import_data(self): - self.skipTest('admin.pool.volume.Import not supported') - - def test_050_clone(self): - self.app.expected_calls[ - ('dom0', 'admin.pool.volume.CloneFrom', 'test-pool', - b'volid')] = b'0\x00abcdef' - self.app.expected_calls[ - ('dom0', 'admin.pool.volume.CloneTo', 'test-pool', - b'some-id abcdef')] = b'0\x00' - source_vol = qubesadmin.storage.Volume(self.app, pool='test-pool', - vid='volid') - self.vol.clone(source_vol) - self.assertAllCalled() - - def test_050_clone_wrong_volume(self): - self.skipTest('admin.pool.volume.Clone not supported') - class TestPool(qubesadmin.tests.QubesTestCase): def test_000_list(self): From 95e554de33ec5e73ed7f713769c6df2113d47d78 Mon Sep 17 00:00:00 2001 From: Rusty Bird Date: Fri, 3 Jul 2026 13:05:32 +0000 Subject: [PATCH 3/7] storage: remove outdated comment about shrinking Shrinking is already allowed by admin.vm.volume.Resize and implemented by file-reflink. --- qubesadmin/storage.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/qubesadmin/storage.py b/qubesadmin/storage.py index 902ffb61..cf7605a3 100644 --- a/qubesadmin/storage.py +++ b/qubesadmin/storage.py @@ -267,8 +267,6 @@ def is_outdated(self) -> bool: def resize(self, size: object) -> None: """Resize volume. - Currently only extending is supported. - :param int size: new size in bytes. """ self._qubesd_call('Resize', str(size).encode('ascii')) From 46b1851e913c11a88517566d4a54359570183b3b Mon Sep 17 00:00:00 2001 From: Rusty Bird Date: Fri, 3 Jul 2026 13:05:33 +0000 Subject: [PATCH 4/7] tools/qvm-volume: use 'resize' in code instead of old 'extend' 'extend' is already just a legacy alias for 'resize'. It looked odd to continue using the former name in the implementation, since it has been able to shrink volumes for a long time. --- qubesadmin/tools/qvm_volume.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/qubesadmin/tools/qvm_volume.py b/qubesadmin/tools/qvm_volume.py index df1f1abd..8b08fd96 100644 --- a/qubesadmin/tools/qvm_volume.py +++ b/qubesadmin/tools/qvm_volume.py @@ -242,8 +242,8 @@ def revert_volume(args): volume.revert(revision) -def extend_volumes(args): - """ Called by the parser to execute the :program:`qvm-volume extend` +def resize_volumes(args): + """ Called by the parser to execute the :program:`qvm-volume resize` subcommand """ volume = args.volume @@ -291,18 +291,18 @@ def init_revert_parser(sub_parsers): revert_parser.set_defaults(func=revert_volume) -def init_extend_parser(sub_parsers): - """ Add 'extend' action related options """ - extend_parser = sub_parsers.add_parser( +def init_resize_parser(sub_parsers): + """ Add 'resize' action related options """ + resize_parser = sub_parsers.add_parser( "resize", aliases=('extend', ), help="resize volume for domain") - extend_parser.add_argument(metavar='VM:VOLUME', dest='volume', + resize_parser.add_argument(metavar='VM:VOLUME', dest='volume', action=qubesadmin.tools.VMVolumeAction) - extend_parser.add_argument('size', help='New size in bytes') - extend_parser.add_argument( + resize_parser.add_argument('size', help='New size in bytes') + resize_parser.add_argument( '--force', '-f', action='store_true', help='Force operation, even if new size is smaller than the current ' 'one') - extend_parser.set_defaults(func=extend_volumes) + resize_parser.set_defaults(func=resize_volumes) def init_info_parser(sub_parsers): @@ -371,7 +371,7 @@ def get_parser(): dest='command') init_info_parser(sub_parsers) init_config_parser(sub_parsers) - init_extend_parser(sub_parsers) + init_resize_parser(sub_parsers) init_list_parser(sub_parsers) init_revert_parser(sub_parsers) init_import_parser(sub_parsers) From 888b27bf8512299220b7fea5c54bf5dfe46c2713 Mon Sep 17 00:00:00 2001 From: Rusty Bird Date: Fri, 3 Jul 2026 13:05:35 +0000 Subject: [PATCH 5/7] storage: centralize shrinking check from qvm-volume to module Ensure that other (e.g. GUI) callers of volume.resize() are also protected against shrinking, unless they opt into it by passing allow_shrink=True. If the safety check fails, the (slightly fleshed out) message still mentions 'qvm-volume resize --force' because this is the only tool featuring such an override. --- qubesadmin/storage.py | 15 ++++++++++++++- qubesadmin/tests/storage.py | 1 + qubesadmin/tests/tools/qvm_volume.py | 3 ++- qubesadmin/tools/qvm_volume.py | 9 +-------- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/qubesadmin/storage.py b/qubesadmin/storage.py index cf7605a3..d76afcfa 100644 --- a/qubesadmin/storage.py +++ b/qubesadmin/storage.py @@ -264,11 +264,24 @@ def is_outdated(self) -> bool: assert self._info is not None return self._info.get('is_outdated', False) == 'True' - def resize(self, size: object) -> None: + def resize(self, size: object, *, allow_shrink: bool=False) -> None: """Resize volume. :param int size: new size in bytes. """ + + if not allow_shrink: + current_size = self.size + if size < current_size: + vol_str = self.vm + ":" + self.name + raise qubesadmin.exc.StoragePoolException( + f"Refusing to shrink volume {vol_str}" + f" from {current_size} to {size} bytes.\n" + f"If you really know what you are doing:" + f" Manually shrink the filesystem on the volume first," + f" and repartition the volume if it is partitioned." + f" Do this in a VM, not in dom0." + f" Then use 'qvm-volume resize --force {vol_str} {size}'") self._qubesd_call('Resize', str(size).encode('ascii')) @property diff --git a/qubesadmin/tests/storage.py b/qubesadmin/tests/storage.py index 39bbf4e7..96e73cd8 100644 --- a/qubesadmin/tests/storage.py +++ b/qubesadmin/tests/storage.py @@ -134,6 +134,7 @@ def test_022_revisions_empty(self): self.assertAllCalled() def test_030_resize(self): + self.expect_info() self.app.expected_calls[ ('test-vm', 'admin.vm.volume.Resize', 'volname', b'2048')] = \ b'0\x00' diff --git a/qubesadmin/tests/tools/qvm_volume.py b/qubesadmin/tests/tools/qvm_volume.py index 762dd1d4..cc91da3f 100644 --- a/qubesadmin/tests/tools/qvm_volume.py +++ b/qubesadmin/tests/tools/qvm_volume.py @@ -232,7 +232,8 @@ def test_012_extend_deny_shrink(self): qubesadmin.tools.qvm_volume.main( ['resize', 'testvm:private', '1GiB'], app=self.app)) - self.assertIn('shrinking of private is disabled', stderr.getvalue()) + self.assertIn('Refusing to shrink volume testvm:private', + stderr.getvalue()) self.assertAllCalled() def test_013_resize_force_shrink(self): diff --git a/qubesadmin/tools/qvm_volume.py b/qubesadmin/tools/qvm_volume.py index 8b08fd96..27c6b931 100644 --- a/qubesadmin/tools/qvm_volume.py +++ b/qubesadmin/tools/qvm_volume.py @@ -248,14 +248,7 @@ def resize_volumes(args): """ volume = args.volume size = qubesadmin.utils.parse_size(args.size) - if not args.force and size < volume.size: - raise qubesadmin.exc.StoragePoolException( - 'For your own safety, shrinking of %s is' - ' disabled (%d < %d). If you really know what you' - ' are doing, resize filesystem manually first, then use `-f` ' - 'option.' % - (volume.name, size, volume.size)) - volume.resize(size) + volume.resize(size, allow_shrink=args.force) def init_list_parser(sub_parsers): From 4d47c1d0c211c013191eca3dfb79baccdfa18c5c Mon Sep 17 00:00:00 2001 From: Rusty Bird Date: Fri, 3 Jul 2026 17:54:30 +0000 Subject: [PATCH 6/7] tools/qvm-volume: resize_volumes() -> resize_volume() It only handles a single volume. --- qubesadmin/tools/qvm_volume.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qubesadmin/tools/qvm_volume.py b/qubesadmin/tools/qvm_volume.py index 27c6b931..cb8942a5 100644 --- a/qubesadmin/tools/qvm_volume.py +++ b/qubesadmin/tools/qvm_volume.py @@ -242,7 +242,7 @@ def revert_volume(args): volume.revert(revision) -def resize_volumes(args): +def resize_volume(args): """ Called by the parser to execute the :program:`qvm-volume resize` subcommand """ @@ -295,7 +295,7 @@ def init_resize_parser(sub_parsers): '--force', '-f', action='store_true', help='Force operation, even if new size is smaller than the current ' 'one') - resize_parser.set_defaults(func=resize_volumes) + resize_parser.set_defaults(func=resize_volume) def init_info_parser(sub_parsers): From 49d56e6d04215a423b5b840621d8b9a322e3e175 Mon Sep 17 00:00:00 2001 From: Rusty Bird Date: Fri, 10 Jul 2026 16:54:10 +0000 Subject: [PATCH 7/7] storage: volume.resize(): size is an int --- qubesadmin/storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qubesadmin/storage.py b/qubesadmin/storage.py index d76afcfa..4a35a077 100644 --- a/qubesadmin/storage.py +++ b/qubesadmin/storage.py @@ -264,7 +264,7 @@ def is_outdated(self) -> bool: assert self._info is not None return self._info.get('is_outdated', False) == 'True' - def resize(self, size: object, *, allow_shrink: bool=False) -> None: + def resize(self, size: int, *, allow_shrink: bool=False) -> None: """Resize volume. :param int size: new size in bytes.