diff --git a/qubes/api/internal.py b/qubes/api/internal.py index 4abfcc2e5..996d9a8d7 100644 --- a/qubes/api/internal.py +++ b/qubes/api/internal.py @@ -265,7 +265,7 @@ async def suspend_pre(self): preload_templates = qubes.vm.dispvm.get_preload_templates(self.app) for qube in preload_templates: - qube.remove_preload_excess(0) + qube.remove_preload_excess(0, reason="system wants to suspend") # first keep track of VMs which were paused before suspending previously_paused = [ diff --git a/qubes/app.py b/qubes/app.py index 6af98d532..0ff839f22 100644 --- a/qubes/app.py +++ b/qubes/app.py @@ -1643,6 +1643,7 @@ def on_domain_pre_deleted(self, event, vm): :param qubes.vm.QubesVM name: Qube name. """ # pylint: disable=unused-argument + dependencies = [] for obj in itertools.chain(self.domains, (self,)): if obj is vm: # allow removed VM to reference itself @@ -1653,18 +1654,31 @@ def on_domain_pre_deleted(self, event, vm): isinstance(prop, qubes.vm.VMProperty) and getattr(obj, prop.__name__) == vm ): - self.log.error( - "Cannot remove %s, used by %s.%s", - vm, - obj, - prop.__name__, - ) - raise qubes.exc.QubesVMInUseError( - vm, - "Domain is in use: {!r};" - "see 'journalctl -u qubesd -e' in dom0 for " - "details".format(vm.name), - ) + if getattr(obj, "is_preload", False) and ( + prop.__name__ == "template" + or ( + prop.__name__ == "default_dispvm" + and getattr(obj, "template", None) == vm + ) + ): + continue + if isinstance(obj, qubes.app.Qubes): + dependencies.insert(0, ("@GLOBAL", prop.__name__)) + elif not obj.property_is_default(prop): + dependencies.append((obj.name, prop.__name__)) + if dependencies: + self.log.error( + "Cannot remove %s as it is used by %s", + vm, + ", ".join( + ":".join(str(i) for i in tup) for tup in dependencies + ), + ) + raise qubes.exc.QubesVMInUseError( + vm, + "Domain is in use: {!r}; see 'journalctl -u qubesd -e' in dom0" + " for details".format(vm.name), + ) if isinstance(vm, qubes.vm.qubesvm.QubesVM): assignments = vm.get_provided_assignments() else: diff --git a/qubes/ext/audio.py b/qubes/ext/audio.py index 4a434b9bc..7f717e13d 100644 --- a/qubes/ext/audio.py +++ b/qubes/ext/audio.py @@ -69,10 +69,12 @@ def set_tag_and_qubesdb_entry(self, subject, event, newvalue=None): @qubes.ext.handler("domain-pre-shutdown") def on_domain_pre_shutdown(self, vm, event, **kwargs): attached_vms = [ - domain for domain in self.attached_vms(vm) if domain.is_running() + domain + for domain in self.attached_vms(vm) + if domain.is_running() and not getattr(domain, "is_preload", False) ] if attached_vms and not kwargs.get("force", False): - raise qubes.exc.QubesVMError( + raise qubes.exc.QubesVMInUseError( self, "There are running VMs using this VM as AudioVM: " "{}".format(", ".join(vm.name for vm in attached_vms)), diff --git a/qubes/tests/api_internal.py b/qubes/tests/api_internal.py index 5646c2523..86a512f48 100644 --- a/qubes/tests/api_internal.py +++ b/qubes/tests/api_internal.py @@ -44,6 +44,7 @@ def setUp(self): self.app = mock.NonCallableMock() self.dom0 = mock.NonCallableMock(spec=qubes.vm.adminvm.AdminVM) self.dom0.name = "dom0" + self.dom0.klass = "AdminVM" self.dom0.features = {} self.domains = { "dom0": self.dom0, @@ -88,17 +89,24 @@ def call_mgmt_func(self, method, arg=b"", payload=b""): def test_000_suspend_pre(self): running_vm = self.create_mockvm(features={"qrexec": True}) running_vm.is_running.return_value = True + running_vm.klass = "AppVM" - not_running_vm = self.create_mockvm(features={"qrexec": True}) + not_running_vm = self.create_mockvm( + features={"qrexec": True, "preload-dispvm-max": "1"} + ) not_running_vm.is_running.return_value = False + not_running_vm.template_for_dispvms = True + not_running_vm.klass = "AppVM" no_qrexec_vm = self.create_mockvm() no_qrexec_vm.is_running.return_value = True + no_qrexec_vm.klass = "AppVM" paused_vm = self.create_mockvm(features={"qrexec": True}) paused_vm.is_running.return_value = True paused_vm.get_power_state.return_value = "Paused" paused_vm.name = "SleepingBeauty" + paused_vm.klass = "AppVM" self.domains.update( { @@ -113,8 +121,11 @@ def test_000_suspend_pre(self): qubes.api.internal, "PREVIOUSLY_PAUSED", "/tmp/qubes-previously-paused.tmp", - ): + ), mock.patch.object( + not_running_vm, "remove_preload_excess" + ) as mock_remove: ret = self.call_mgmt_func(b"internal.SuspendPre") + mock_remove.assert_called_once_with(0, reason=mock.ANY) self.assertIsNone(ret) self.assertFalse(self.dom0.called) diff --git a/qubes/tests/app.py b/qubes/tests/app.py index 07b6c1900..bbea5920b 100644 --- a/qubes/tests/app.py +++ b/qubes/tests/app.py @@ -1096,6 +1096,40 @@ def test_205_remove_appvm_dispvm(self): with self.assertRaises(qubes.exc.QubesVMInUseError): del self.app.domains[appvm] + def test_205_remove_appvm_dispvm_preload(self): + appvm = self.app.add_new_vm( + "AppVM", + name="test-appvm", + template=self.template, + template_for_dispvms=True, + label="red", + ) + dispvm = self.app.add_new_vm( + "DispVM", name="test-dispvm", template=appvm, label="red" + ) + dispvm_alt = self.app.add_new_vm( + "DispVM", name="test-dispvm-alt", template=appvm, label="red" + ) + with mock.patch.object(self.app, "vmm"): + with self.assertRaises(qubes.exc.QubesVMInUseError): + del self.app.domains[appvm] + + with mock.patch.object(self.app, "vmm"): + with mock.patch.object(appvm, "fire_event_async"): + appvm.features["preload-dispvm-max"] = "1" + appvm.features["preload-dispvm"] = str(dispvm.name) + with self.assertRaises(qubes.exc.QubesVMInUseError): + del self.app.domains[appvm] + + with mock.patch.object(self.app, "vmm"): + with mock.patch.object(appvm, "fire_event_async"): + appvm.features["preload-dispvm-max"] = "2" + appvm.features["preload-dispvm"] = dispvm.name + appvm.features["preload-dispvm"] = ( + dispvm.name + " " + dispvm_alt.name + ) + del self.app.domains[appvm] + def test_206_remove_attached(self): # See also qubes.tests.api_admin. vm = self.app.add_new_vm( diff --git a/qubes/tests/ext.py b/qubes/tests/ext.py index 570be73d8..7187dc99e 100644 --- a/qubes/tests/ext.py +++ b/qubes/tests/ext.py @@ -22,6 +22,7 @@ import unittest.mock import qubes.ext.admin +import qubes.ext.audio import qubes.ext.core_features import qubes.ext.custom_persist import qubes.ext.services @@ -2610,3 +2611,58 @@ def test_000_tags_permission(self): "admin-permission:admin.vm.tag.Set", tag, ) + + +class TC_60_Audio(qubes.tests.QubesTestCase): + def setUp(self): + super().setUp() + self.ext = qubes.ext.audio.AUDIO() + self.audiovm = mock.MagicMock() + self.audiovm.name = "sys-audio" + self.client = mock.MagicMock() + self.client.name = "client" + self.client_alt = mock.MagicMock() + self.client_alt.name = "client" + + def test_000_shutdown(self): + self.ext.on_domain_pre_shutdown( + self.audiovm, + "domain-pre-shutdown", + ) + + def test_000_shutdown_used(self): + with unittest.mock.patch.object( + self.ext, "attached_vms", return_value=[self.client] + ), unittest.mock.patch.object( + self.client, "is_running", return_value=True + ): + self.client.is_preload = False + with self.assertRaises(qubes.exc.QubesVMInUseError): + self.ext.on_domain_pre_shutdown( + self.audiovm, + "domain-pre-shutdown", + ) + + self.client.is_preload = True + self.ext.on_domain_pre_shutdown( + self.audiovm, + "domain-pre-shutdown", + ) + + def test_000_shutdown_used_by_some(self): + with unittest.mock.patch.object( + self.ext, + "attached_vms", + return_value=[self.client, self.client_alt], + ), unittest.mock.patch.object( + self.client, "is_running", return_value=True + ), unittest.mock.patch.object( + self.client_alt, "is_running", return_value=True + ): + self.client.is_preload = False + self.client_alt.is_preload = True + with self.assertRaises(qubes.exc.QubesVMInUseError): + self.ext.on_domain_pre_shutdown( + self.audiovm, + "domain-pre-shutdown", + ) diff --git a/qubes/tests/integ/dispvm.py b/qubes/tests/integ/dispvm.py index fe3a138a7..80a2edc1b 100644 --- a/qubes/tests/integ/dispvm.py +++ b/qubes/tests/integ/dispvm.py @@ -305,9 +305,16 @@ def _on_domain_add(self, app, event, vm): # pylint: disable=unused-argument self._register_handlers(vm) async def cleanup_preload_run(self, qube): - old_preload = qube.get_feat_preload() + old_preload = qube.features.get("preload-dispvm", "") + old_preload = old_preload.split(" ") if old_preload else [] + if not old_preload: + return + logger.info( + "cleaning up preloaded disposables: %s:%s", qube.name, old_preload + ) tasks = [self.app.domains[x].cleanup() for x in old_preload] await asyncio.gather(*tasks) + self.wait_for_dispvm_destroy(old_preload) def cleanup_preload(self): logger.info("start") @@ -319,13 +326,24 @@ def cleanup_preload(self): logger.info("deleting global threshold feature") del self.app.domains["dom0"].features["preload-dispvm-threshold"] for qube in self.app.domains: - if "preload-dispvm-max" not in qube.features: + if "preload-dispvm-max" not in qube.features or qube not in [ + self.app.domains["dom0"], + default_dispvm, + self.disp_base, + self.disp_base_alt, + ]: continue logger.info("removing preloaded disposables: '%s'", qube.name) - if qube == default_dispvm: - self.loop.run_until_complete( - self.cleanup_preload_run(default_dispvm) + target = qube + if qube.klass == "AdminVM" and default_dispvm: + target = default_dispvm + old_preload_max = qube.features.get("preload-dispvm-max") or 0 + self.loop.run_until_complete( + self.wait_preload( + old_preload_max, fail_on_timeout=False, timeout=20 ) + ) + self.loop.run_until_complete(self.cleanup_preload_run(target)) logger.info("deleting max preload feature") del qube.features["preload-dispvm-max"] logger.info("end") diff --git a/qubes/tests/vm/mix/dvmtemplate.py b/qubes/tests/vm/mix/dvmtemplate.py index c230c1478..154473fdc 100755 --- a/qubes/tests/vm/mix/dvmtemplate.py +++ b/qubes/tests/vm/mix/dvmtemplate.py @@ -69,6 +69,11 @@ def setUp(self): self.template = self.app.add_new_vm( qubes.vm.templatevm.TemplateVM, name="test-template", label="red" ) + self.template_alt = self.app.add_new_vm( + qubes.vm.templatevm.TemplateVM, + name="test-template-alt", + label="red", + ) self.appvm = self.app.add_new_vm( qubes.vm.appvm.AppVM, name="test-vm", @@ -98,9 +103,14 @@ def cleanup_dispvm(self): if hasattr(self, "dispvm"): self.dispvm.close() del self.dispvm + if hasattr(self, "dispvm_alt"): + self.dispvm_alt.close() + del self.dispvm_alt self.template.close() + self.template_alt.close() self.appvm.close() del self.template + del self.template_alt del self.appvm self.app.domains.clear() self.app.pools.clear() @@ -241,7 +251,7 @@ def test_011_dvm_preload_del_max(self, mock_remove_preload_excess): del self.adminvm.features["preload-dispvm-max"] self.appvm.features["preload-dispvm-max"] = "" del self.appvm.features["preload-dispvm-max"] - mock_remove_preload_excess.assert_called_once_with(0) + mock_remove_preload_excess.assert_called_once_with(0, reason=mock.ANY) @mock.patch("qubes.events.Emitter.fire_event_async") def test_012_dvm_preload_set_max(self, mock_events): @@ -251,6 +261,11 @@ def test_012_dvm_preload_set_max(self, mock_events): "domain-preload-dispvm-start", reason=mock.ANY ) + mock_events.reset_mock() + self.appvm.template_for_dispvms = False + self.appvm.features["preload-dispvm-max"] = "2" + mock_events.assert_not_called() + def test_013_dvm_preload_get_treshold(self): cases = [None, False, "0", "2", "1000"] self.assertEqual(self.appvm.get_feat_preload_threshold(), 0) @@ -260,6 +275,163 @@ def test_013_dvm_preload_get_treshold(self): threshold = self.appvm.get_feat_preload_threshold() self.assertEqual(threshold, int(value or 0) * 1024**2) + @mock.patch("qubes.events.Emitter.fire_event_async") + @mock.patch( + "qubes.vm.mix.dvmtemplate.DVMTemplateMixin.remove_preload_excess" + ) + def test_030_dvm_preload_set_template(self, mock_remove, mock_events): + # Don't try to preload if max is not set. + mock_events.side_effect = self.mock_coro + self.appvm.template = self.template_alt + mock_events.assert_not_called() + mock_remove.assert_called_once_with(0, reason=mock.ANY) + + # Try to remove and preload if max is set and template has changed. + mock_remove.reset_mock() + self.appvm.features["preload-dispvm-max"] = "1" + mock_events.reset_mock() + self.appvm.template = self.template + mock_remove.assert_called_once_with(0, reason=mock.ANY) + mock_events.assert_called_once_with( + "domain-preload-dispvm-start", reason=mock.ANY + ) + + # Don't change anything if template hasn't changed. + mock_remove.reset_mock() + mock_events.reset_mock() + self.appvm.template = self.template + mock_remove.assert_not_called() + mock_events.assert_not_called() + + self.dispvm = self.app.add_new_vm( + qubes.vm.dispvm.DispVM, + name="test-dispvm", + template=self.appvm, + label="red", + dispid=42, + ) + self.dispvm_alt = self.app.add_new_vm( + qubes.vm.dispvm.DispVM, + name="test-dispvm-alt", + template=self.appvm, + label="red", + dispid=43, + ) + # Can't switch templates if disposable is running. + mock_remove.reset_mock() + mock_events.reset_mock() + with mock.patch.object(self.dispvm, "is_running", return_value=True): + with self.assertRaises(qubes.exc.QubesVMInUseError): + self.appvm.template = self.template_alt + mock_remove.assert_not_called() + mock_events.assert_not_called() + + # Can't switch templates if not all running disposable are preloads. + mock_remove.reset_mock() + self.appvm.features["preload-dispvm-max"] = "1" + self.appvm.features["preload-dispvm"] = self.dispvm.name + with mock.patch.object( + self.dispvm, "is_running", return_value=True + ), mock.patch.object(self.dispvm_alt, "is_running", return_value=True): + with self.assertRaises(qubes.exc.QubesVMInUseError): + self.appvm.template = self.template_alt + mock_remove.assert_not_called() + mock_events.assert_not_called() + + # Can switch templates if all running disposable are preloads. + self.appvm.features["preload-dispvm-max"] = "2" + self.appvm.features["preload-dispvm"] = ( + self.dispvm.name + " " + self.dispvm_alt.name + ) + with mock.patch.object( + self.dispvm, "is_running", return_value=True + ), mock.patch.object(self.dispvm_alt, "is_running", return_value=True): + self.appvm.template = self.template_alt + mock_remove.assert_called_once_with(0, reason=mock.ANY) + mock_events.assert_called_once_with( + "domain-preload-dispvm-start", reason=mock.ANY + ) + + @mock.patch("qubes.events.Emitter.fire_event_async") + @mock.patch( + "qubes.vm.mix.dvmtemplate.DVMTemplateMixin.remove_preload_excess" + ) + def test_040_dvm_preload_set_template_for_dispvms( + self, mock_remove, mock_events + ): + # Remove preloads when disabling property. + mock_events.side_effect = self.mock_coro + self.appvm.template_for_dispvms = False + mock_events.assert_not_called() + mock_remove.assert_called_once_with(0, reason=mock.ANY) + + # Disabling again does nothing. + mock_remove.reset_mock() + self.appvm.template_for_dispvms = False + mock_events.assert_not_called() + mock_remove.assert_not_called() + + # Preload when enabling property. + self.appvm.features["preload-dispvm-max"] = "1" + mock_events.reset_mock() + mock_remove.reset_mock() + self.appvm.template_for_dispvms = True + mock_remove.assert_not_called() + mock_events.assert_called_once_with( + "domain-preload-dispvm-start", reason=mock.ANY + ) + + # Enabling again does nothing. + mock_events.reset_mock() + mock_remove.reset_mock() + self.appvm.template_for_dispvms = True + mock_remove.assert_not_called() + mock_events.assert_not_called() + + # Try to disable property if it has dependents. + mock_events.reset_mock() + self.dispvm = self.app.add_new_vm( + qubes.vm.dispvm.DispVM, + name="test-dispvm", + template=self.appvm, + label="red", + dispid=42, + ) + self.dispvm_alt = self.app.add_new_vm( + qubes.vm.dispvm.DispVM, + name="test-dispvm-alt", + template=self.appvm, + label="red", + dispid=43, + ) + with self.assertRaises(qubes.exc.QubesVMInUseError): + self.appvm.template_for_dispvms = False + mock_remove.assert_not_called() + mock_events.assert_not_called() + + # Disabling property when not all dependents are preloads + self.appvm.features["preload-dispvm-max"] = 1 + self.appvm.features["preload-dispvm"] = self.dispvm.name + mock_events.reset_mock() + mock_remove.reset_mock() + with self.assertRaises(qubes.exc.QubesVMInUseError): + self.appvm.template_for_dispvms = False + mock_remove.assert_not_called() + mock_events.assert_not_called() + + # Disabling property when all dependents are preloads + self.appvm.features["preload-dispvm-max"] = 2 + mock_events.reset_mock() + mock_remove.reset_mock() + self.appvm.features["preload-dispvm"] = ( + self.dispvm.name + " " + self.dispvm_alt.name + ) + mock_events.reset_mock() + mock_remove.reset_mock() + del self.appvm.template_for_dispvms + mock_remove.assert_called_once_with(0, reason=mock.ANY) + mock_events.assert_not_called() + def test_100_get_preload_templates(self): print(qubes.vm.dispvm.get_preload_templates(self.app)) self.appvm.features["supported-rpc.qubes.WaitForRunningSystem"] = True diff --git a/qubes/tests/vm/mix/net.py b/qubes/tests/vm/mix/net.py index 0d61d1033..1d286262f 100644 --- a/qubes/tests/vm/mix/net.py +++ b/qubes/tests/vm/mix/net.py @@ -369,6 +369,19 @@ def test_170_provides_network_netvm(self): self.assertPropertyValue(vm2, "netvm", "", None, "") self.assertPropertyValue(vm, "provides_network", False, False, "False") + @patch("qubes.vm.qubesvm.QubesVM.libvirt_domain") + @patch("qubes.vm.qubesvm.QubesVM.is_halted", return_value=False) + def test_180_shutdown(self, mock_halted, mock_shutdown): + # pylint: disable=unused-argument + vm = self.get_vm() + self.setup_netvms(vm) + with patch.object(vm, "is_running", return_value=True): + vm.is_preload = False + with self.assertRaises(qubes.exc.QubesVMInUseError): + self.loop.run_until_complete(vm.netvm.shutdown()) + vm.is_preload = True + self.loop.run_until_complete(vm.netvm.shutdown()) + def test_200_vmid_to_ipv4(self): testcases = ( (1, "0.1"), diff --git a/qubes/vm/dispvm.py b/qubes/vm/dispvm.py index cb74011e2..11a73ffd8 100644 --- a/qubes/vm/dispvm.py +++ b/qubes/vm/dispvm.py @@ -540,7 +540,7 @@ async def on_domain_shutdown(self, _event, **_kwargs) -> None: await self._auto_cleanup() @qubes.events.handler("domain-remove-from-disk") - async def on_domain_delete(self, _event, **_kwargs) -> None: + async def on_domain_remove_from_disk(self, _event, **_kwargs) -> None: """ On volume removal, remove preloaded disposable from ``preload-dispvm`` feature in disposable template. If the feature is still here, it means @@ -665,7 +665,9 @@ async def from_appvm( # The gap is filled after the delay set by the # 'domain-shutdown' of its ancestors. Not refilling now to # deliver a disposable faster. - appvm.remove_preload_from_list([qube.name]) + appvm.remove_preload_from_list( + [qube.name], reason="of outdated volume(s)" + ) # Delay to not affect this run. asyncio.ensure_future( qube.delay(delay=2, coros=[qube.cleanup()]) @@ -676,7 +678,9 @@ async def from_appvm( if dispvm: dispvm.log.info("Requesting preloaded qube") dispvm.features["preload-dispvm-in-progress"] = True - appvm.remove_preload_from_list([dispvm.name]) + appvm.remove_preload_from_list( + [dispvm.name], reason="qube was requested" + ) dispvm.preload_requested = True app.save() timeout = int(dispvm.qrexec_timeout * 1.2) @@ -754,7 +758,9 @@ async def use_preload(self) -> None: if not appvm.features.get("internal", None): del self.features["internal"] await self.apply_deferred_netvm() - appvm.remove_preload_from_list([self.name]) + appvm.remove_preload_from_list( + [self.name], reason="qube was used without being requested" + ) self.features["preload-dispvm-in-progress"] = False self.app.save() asyncio.ensure_future( @@ -765,8 +771,15 @@ def _preload_cleanup(self) -> None: """ Cleanup preload from list. """ - if self.name in self.template.get_feat_preload(): - self.log.info("Automatic cleanup removes qube from preload list") + name = getattr(self, "name", None) + template = getattr(self, "template", None) + if not (name and template): + # Objects from self may be absent. + return + if name in template.get_feat_preload(): + self.template.remove_preload_from_list( + [self.name], reason="automatic cleanup was called" + ) self.template.remove_preload_from_list([self.name]) async def _bare_cleanup(self) -> None: @@ -804,7 +817,7 @@ async def cleanup(self) -> None: running = False # Full cleanup will be done automatically if event 'domain-shutdown' is # triggered and "auto_cleanup=True". - if not running or not self.auto_cleanup: + if not self.auto_cleanup or (not running and self.auto_cleanup): self._preload_cleanup() if self in self.app.domains: await self._bare_cleanup() diff --git a/qubes/vm/mix/dvmtemplate.py b/qubes/vm/mix/dvmtemplate.py index a14be4b8b..e6f2f9b32 100644 --- a/qubes/vm/mix/dvmtemplate.py +++ b/qubes/vm/mix/dvmtemplate.py @@ -97,12 +97,9 @@ def on_domain_loaded(self, event) -> None: ] if preload_in_progress: changes = True - self.log.info( - "Removing in progress preloaded qube(s): '%s'", - ", ".join(map(str, preload_in_progress)), - ) self.remove_preload_from_list( - [qube.name for qube in preload_in_progress] + [qube.name for qube in preload_in_progress], + reason="their progress was interrupted", ) for dispvm in preload_in_progress: asyncio.ensure_future(dispvm.cleanup()) @@ -131,6 +128,21 @@ def __on_domain_pre_start(self, event, **kwargs) -> None: if vm.is_running(): raise qubes.exc.QubesVMNotHaltedError(vm) + @qubes.events.handler("domain-remove-from-disk") + async def on_dvmtemplate_remove_from_disk(self, event, **kwargs): + # pylint: disable=unused-argument + preloads = [disp for disp in self.dispvms if disp.is_preload] + if not preloads: + return + names = [disp.name for disp in preloads] + self.remove_preload_from_list(names, reason="template being removed") + tasks = [disp.cleanup() for disp in preloads] + msg = ( + "Removing preloaded disposable(s) before removing itself from disk:" + ) + self.log.info("%s: %s", msg, ", ".join(names)) + await asyncio.gather(*tasks) + @qubes.events.handler("domain-shutdown") async def on_dvmtemplate_domain_shutdown(self, _event, **_kwargs) -> None: """ @@ -150,7 +162,7 @@ def on_feature_delete_preload_dispvm_max(self, event, feature) -> None: # pylint: disable=unused-argument if self.is_global_preload_set(): return - self.remove_preload_excess(0) + self.remove_preload_excess(0, reason="local feature was deleted") @qubes.events.handler("domain-feature-pre-set:preload-dispvm-max") def on_feature_pre_set_preload_dispvm_max( @@ -195,6 +207,8 @@ def on_feature_set_preload_dispvm_max( # pylint: disable=unused-argument if value == oldvalue: return + if not getattr(self, "template_for_dispvms"): + return if self.is_global_preload_set(): return reason = "local feature was set to " + repr(value) @@ -299,14 +313,24 @@ def __on_pre_set_dvmtemplate( :param bool oldvalue: Old value of the property. """ # pylint: disable=unused-argument + assert isinstance(self, qubes.vm.BaseVM) if newvalue: return - if any(self.dispvms): - raise qubes.exc.QubesVMInUseError( - self, + if not newvalue and not oldvalue: + return + dependencies = [ + disp.name for disp in self.dispvms if not disp.is_preload + ] + if dependencies: + msg = ( "Cannot change template_for_dispvms to False while there are " "some disposables based on this disposable template", ) + self.log.error("%s: %s", msg, ", ".join(dependencies)) + raise qubes.exc.QubesVMInUseError(self, msg) + self.remove_preload_excess( + 0, reason="template_for_dispvms was set to False" + ) @qubes.events.handler("property-pre-del:template_for_dispvms") def __on_pre_del_dvmtemplate(self, event, name, oldvalue=None) -> None: @@ -320,6 +344,22 @@ def __on_pre_del_dvmtemplate(self, event, name, oldvalue=None) -> None: """ self.__on_pre_set_dvmtemplate(event, name, False, oldvalue) + @qubes.events.handler("property-set:template_for_dispvms") + def __on_set_dvmtemplate(self, event, name, newvalue, oldvalue=None): + # pylint: disable=unused-argument + if not newvalue: + return + if newvalue == oldvalue: + return + if not self.can_preload(): + return + asyncio.ensure_future( + self.fire_event_async( + "domain-preload-dispvm-start", + reason="template_for_dispvms was set to True", + ) + ) + @qubes.events.handler("property-pre-set:template") def __on_pre_property_set_template( self, event, name, newvalue, oldvalue=None @@ -335,20 +375,39 @@ def __on_pre_property_set_template( property. """ # pylint: disable=unused-argument - for vm in self.dispvms: - if vm.is_running(): - raise qubes.exc.QubesVMNotHaltedError( - self, - "Cannot change template while there are running disposables" - " based on this disposable template", - ) + if newvalue == oldvalue: + return + dependencies = [ + disp.name + for disp in self.dispvms + if disp.is_running() and not disp.is_preload + ] + if dependencies: + msg = ( + "Cannot change template while there are running disposables" + " based on this disposable template", + ) + self.log.error("%s: %s", msg, ", ".join(dependencies)) + raise qubes.exc.QubesVMInUseError(self, msg) + self.remove_preload_excess(0, reason="template will change") @qubes.events.handler("property-set:template") def __on_property_set_template( self, event, name, newvalue, oldvalue=None ) -> None: # pylint: disable=unused-argument - pass + if newvalue == oldvalue: + return + if not getattr(self, "template_for_dispvms"): + return + if not self.can_preload(): + return + asyncio.ensure_future( + self.fire_event_async( + "domain-preload-dispvm-start", + reason="template has changed", + ) + ) @qubes.events.handler( "domain-preload-dispvm-used", @@ -396,9 +455,9 @@ async def on_domain_preload_dispvm_used( await asyncio.sleep(delay) if event == "autostart": - self.remove_preload_excess(0) + self.remove_preload_excess(0, reason="event autostart was called") elif not self.can_preload(): - self.remove_preload_excess() + self.remove_preload_excess(reason="there may be absent qubes") # Absent qubes might be removed above. if not self.can_preload(): return @@ -547,8 +606,10 @@ async def refresh_preload(self) -> None: ): continue outdated.append(qube) - self.remove_preload_from_list([qube.name]) if outdated: + self.remove_preload_from_list( + [qube.name for qube in outdated], reason="of outdated volume(s)" + ) tasks = [self.app.domains[qube].cleanup() for qube in outdated] asyncio.ensure_future(asyncio.gather(*tasks)) # Delay to not overload the system with cleanup+preload. @@ -560,11 +621,14 @@ async def refresh_preload(self) -> None: ) ) - def remove_preload_from_list(self, disposables: list[str]) -> None: + def remove_preload_from_list( + self, disposables: list[str], reason: Optional[str] = None + ) -> None: """ Removes list of preload qubes from the list. - :param list[str] disposables: disposable names to remove from list. + :param list[str] disposables: Disposable names to remove from list. + :param Optional[str] reason: Explanation of why it is being done. """ assert isinstance(self, qubes.vm.BaseVM) old_preload = self.get_feat_preload() @@ -572,18 +636,22 @@ def remove_preload_from_list(self, disposables: list[str]) -> None: qube for qube in old_preload if qube not in disposables ] if dispose := list(set(old_preload) - set(preload_dispvm)): - self.log.info( - "Removing qube(s) from preloaded list: '%s'", - ", ".join(dispose), - ) + event_log = "Removing qube(s) from preloaded list" + if reason: + event_log += " because %s" % str(reason) + event_log += ": '%s'" % ", ".join(dispose) + self.log.info(event_log) self.features["preload-dispvm"] = " ".join(preload_dispvm or []) - def remove_preload_excess(self, max_preload: Optional[int] = None) -> None: + def remove_preload_excess( + self, max_preload: Optional[int] = None, reason: Optional[str] = None + ) -> None: """ Removes preloaded qubes that exceeds the maximum specified. :param Optional[int] max_preload: Maximum number of preloaded that \ should exist. + :param Optional[str] reason: Explanation of why it is being done. """ assert isinstance(self, qubes.vm.BaseVM) if max_preload is None: @@ -593,10 +661,11 @@ def remove_preload_excess(self, max_preload: Optional[int] = None) -> None: return new_preload = old_preload[:max_preload] if excess := old_preload[max_preload:]: - self.log.info( - "Removing excess qube(s) from preloaded list: '%s'", - ", ".join(excess), - ) + event_log = "Removing excess qube(s) from preloaded list" + if reason: + event_log += " because %s" % str(reason) + event_log += ": '%s'" % ", ".join(excess) + self.log.info(event_log) self.features["preload-dispvm"] = " ".join(new_preload or []) for unwanted_disp in excess: if unwanted_disp in self.app.domains: diff --git a/qubes/vm/mix/net.py b/qubes/vm/mix/net.py index b2532a225..fa2f2426b 100644 --- a/qubes/vm/mix/net.py +++ b/qubes/vm/mix/net.py @@ -21,6 +21,7 @@ # """This module contains the NetVMMixin""" + import asyncio import ipaddress import os @@ -397,9 +398,13 @@ def on_domain_pre_shutdown(self, event, force=False): vms """ # pylint: disable=unused-argument - connected_vms = [vm for vm in self.connected_vms if vm.is_running()] + connected_vms = [ + vm + for vm in self.connected_vms + if vm.is_running() and not getattr(vm, "is_preload", False) + ] if connected_vms and not force: - raise qubes.exc.QubesVMError( + raise qubes.exc.QubesVMInUseError( self, "There are other VMs connected to this VM: {}".format( ", ".join(vm.name for vm in connected_vms)