diff --git a/doc/manpages/qvm-features.rst b/doc/manpages/qvm-features.rst index 9f4c2a89..6153b2ff 100644 --- a/doc/manpages/qvm-features.rst +++ b/doc/manpages/qvm-features.rst @@ -429,10 +429,10 @@ on it. preload-dispvm-max ^^^^^^^^^^^^^^^^^^ -Number of disposables to preload. Upon setting, the number of running preloaded -disposables will be adjusted to match the maximum configured, if there is not -enough of them and there is enough available memory on the system, new ones will -be created, if there are more than enough, the excess will be removed. +Number of disposables to preload. Upon setting, the quantity of running +preloaded disposables will be adjusted to match the maximum configured, if there +is not enough of them and there is enough available memory on the system, new +ones will be created, if there are more than enough, the excess will be removed. | | **Valid on**: disposable template @@ -455,12 +455,6 @@ disposable in the list. As soon as the preloaded disposable is requested to be used, it is removed from the `preload-dispvm` list, GUI applications entries become visible, followed by a new disposable being preloaded. -.. warning:: - - Applications configured to autostart by the disposable template or the - template itself will be interactive before the preloaded disposable can be - paused. - | | **Managed by**: system | **Valid on**: disposable template diff --git a/qubesadmin/tests/mock_app.py b/qubesadmin/tests/mock_app.py index c9939e28..677ef727 100644 --- a/qubesadmin/tests/mock_app.py +++ b/qubesadmin/tests/mock_app.py @@ -986,12 +986,19 @@ def __init__(self): self._qubes["default-dvm"] = MockQube( name="default-dvm", qapp=self, - klass="DispVM", - template_for_dispvms="True", + klass="AppVM", + template_for_dispvms=True, template="fedora-36", features={"appmenus-dispvm": "1"}, ) + self._qubes["test-disp"] = MockQube( + name="test-disp", + qapp=self, + klass="DispVM", + template="default-dvm", + ) + self._qubes["test-vm"] = MockQube( name="test-vm", qapp=self, diff --git a/qubesadmin/tests/tools/qvm_start_daemon.py b/qubesadmin/tests/tools/qvm_start_daemon.py index d2a7af0f..26ec1e61 100644 --- a/qubesadmin/tests/tools/qvm_start_daemon.py +++ b/qubesadmin/tests/tools/qvm_start_daemon.py @@ -231,6 +231,10 @@ def test_020_start_gui_for_vm(self, proc_mock): self.app.expected_calls[ ('test-vm', 'admin.vm.property.Get', 'virt_mode', None)] = \ b'0\x00default=False type=str pv' + self.app.expected_calls[ + ('test-vm', 'admin.vm.property.Get', 'is_preload', None)] = \ + b'2\x00QubesNoSuchPropertyError\x00\x00Invalid property ' \ + b"'is_preload' of test-vm\x00" self.app.expected_calls[ ('test-vm', 'admin.vm.feature.CheckWithTemplate', 'no-monitor-layout', None)] = \ @@ -272,6 +276,10 @@ def test_021_start_gui_for_vm_hvm(self, proc_mock): self.app.expected_calls[ ('test-vm', 'admin.vm.property.Get', 'debug', None)] = \ b'0\x00default=False type=bool False' + self.app.expected_calls[ + ('test-vm', 'admin.vm.property.Get', 'is_preload', None)] = \ + b'2\x00QubesNoSuchPropertyError\x00\x00Invalid property ' \ + b"'is_preload' of test-vm\x00" self.app.expected_calls[ ('test-vm', 'admin.vm.feature.CheckWithTemplate', 'no-monitor-layout', None)] = \ @@ -312,6 +320,10 @@ def test_022_start_gui_for_vm_hvm_stubdom(self): self.app.expected_calls[ ('test-vm', 'admin.vm.property.Get', 'debug', None)] = \ b'0\x00default=False type=bool False' + self.app.expected_calls[ + ('test-vm', 'admin.vm.property.Get', 'is_preload', None)] = \ + b'2\x00QubesNoSuchPropertyError\x00\x00Invalid property ' \ + b"'is_preload' of test-vm\x00" self.app.expected_calls[ ('test-vm', 'admin.vm.feature.CheckWithTemplate', 'no-monitor-layout', None)] = \ @@ -360,6 +372,10 @@ def test_030_start_gui_for_stubdomain(self): self.app.expected_calls[ ('test-vm', 'admin.vm.property.Get', 'stubdom_xid', None)] = \ b'0\x00default=False type=int 3001' + self.app.expected_calls[ + ('test-vm', 'admin.vm.property.Get', 'is_preload', None)] = \ + b'2\x00QubesNoSuchPropertyError\x00\x00Invalid property ' \ + b"'is_preload' of test-vm\x00" self.app.expected_calls[ ('test-vm', 'admin.vm.feature.CheckWithTemplate', 'gui', None)] = \ b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00' @@ -391,6 +407,10 @@ def test_031_start_gui_for_stubdomain_forced(self): self.app.expected_calls[ ('test-vm', 'admin.vm.property.Get', 'stubdom_xid', None)] = \ b'0\x00default=False type=int 3001' + self.app.expected_calls[ + ('test-vm', 'admin.vm.property.Get', 'is_preload', None)] = \ + b'2\x00QubesNoSuchPropertyError\x00\x00Invalid property ' \ + b"'is_preload' of test-vm\x00" # self.app.expected_calls[ # ('test-vm', 'admin.vm.feature.CheckWithTemplate', 'gui', None)] = \ # b'0\x00' @@ -411,6 +431,128 @@ def test_031_start_gui_for_stubdomain_forced(self): async def mock_coroutine(self, mock, *args, **kwargs): mock(*args, **kwargs) + def test_038_start_gui_skip_preload(self): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self.addCleanup(loop.close) + + self.app.expected_calls[ + ('dom0', 'admin.vm.List', None, None)] = \ + b'0\x00test-disp class=DispVM state=Running\n' \ + b'gui-vm class=AppVM state=Running' + self.app.expected_calls[ + ('test-disp', 'admin.vm.CurrentState', None, None)] = \ + b'0\x00power_state=Running' + self.app.expected_calls[ + ('test-disp', 'admin.vm.feature.CheckWithTemplate', 'gui', None)] = \ + b'0\x00True' + self.app.expected_calls[ + ('test-disp', 'admin.vm.feature.CheckWithTemplate', + 'no-monitor-layout', None)] = \ + b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00' + self.app.expected_calls[ + ('test-disp', 'admin.vm.property.Get', 'virt_mode', None)] = \ + b'0\x00default=False type=str hvm' + self.app.expected_calls[ + ('test-disp', 'admin.vm.property.Get', 'xid', None)] = \ + b'0\x00default=False type=int 3000' + self.app.expected_calls[ + ('test-disp', 'admin.vm.property.Get', 'stubdom_xid', None)] = \ + b'0\x00default=False type=int 3001' + self.app.expected_calls[ + ('test-disp', 'admin.vm.property.Get', 'guivm', None)] = \ + b'0\x00default=False type=vm gui-vm' + self.app.expected_calls[ + ('test-disp', 'admin.vm.property.Set', 'is_preload', b'True')] = \ + b'0\x00default=False type=vm gui-vm' + self.app.expected_calls[ + ('test-disp', 'admin.vm.property.Get', 'is_preload', None)] = \ + b'0\x00default=False type=bool True' + + # pylint: disable=protected-access + self.app._local_name = 'gui-vm' + vm = self.app.domains['test-disp'] + vm.is_preload = True + mock_start_vm = unittest.mock.Mock() + mock_start_stubdomain = unittest.mock.Mock() + patch_start_vm = unittest.mock.patch.object( + self.launcher, 'start_gui_for_vm', functools.partial( + self.mock_coroutine, mock_start_vm)) + patch_start_stubdomain = unittest.mock.patch.object( + self.launcher, 'start_gui_for_stubdomain', lambda vm_, force: + self.mock_coroutine(mock_start_stubdomain, vm_)) + try: + patch_start_vm.start() + patch_start_stubdomain.start() + loop.run_until_complete(self.launcher.start_gui(vm)) + mock_start_vm.assert_not_called() + mock_start_stubdomain.assert_not_called() + finally: + unittest.mock.patch.stopall() + + def test_039_start_gui_for_preload(self): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self.addCleanup(loop.close) + + self.app.expected_calls[ + ('dom0', 'admin.vm.List', None, None)] = \ + b'0\x00test-disp class=DispVM state=Running\n' \ + b'gui-vm class=AppVM state=Running' + self.app.expected_calls[ + ('test-disp', 'admin.vm.CurrentState', None, None)] = \ + b'0\x00power_state=Running' + self.app.expected_calls[ + ('test-disp', 'admin.vm.feature.CheckWithTemplate', 'gui', None)] = \ + b'0\x00True' + self.app.expected_calls[ + ('test-disp', 'admin.vm.feature.CheckWithTemplate', + 'no-monitor-layout', None)] = \ + b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00' + self.app.expected_calls[ + ('test-disp', 'admin.vm.property.Get', 'virt_mode', None)] = \ + b'0\x00default=False type=str hvm' + self.app.expected_calls[ + ('test-disp', 'admin.vm.property.Get', 'xid', None)] = \ + b'0\x00default=False type=int 3000' + self.app.expected_calls[ + ('test-disp', 'admin.vm.property.Get', 'stubdom_xid', None)] = \ + b'0\x00default=False type=int 3001' + self.app.expected_calls[ + ('test-disp', 'admin.vm.property.Get', 'guivm', None)] = \ + b'0\x00default=False type=vm gui-vm' + self.app.expected_calls[ + ('test-disp', 'admin.vm.property.Get', 'is_preload', None)] = \ + b"2\x00QubesNoSuchPropertyError\x00\x00Invalid property " \ + b"'is_preload' of test-disp\x00" + + # pylint: disable=protected-access + self.app._local_name = 'gui-vm' + vm = self.app.domains['test-disp'] + + with \ + unittest.mock.patch('asyncio.ensure_future'), \ + unittest.mock.patch.object( + self.launcher, 'common_guid_args', lambda vm: [] + ), \ + unittest.mock.patch.object( + qubesadmin.tools.qvm_start_daemon, + 'get_monitor_layout', + unittest.mock.Mock(return_value=None) + ), \ + unittest.mock.patch.object( + self.launcher, 'start_gui', unittest.mock.Mock() + ) as mock_start, \ + unittest.mock.patch.object( + self.launcher, 'start_audio', unittest.mock.Mock() + ) as mock_start_audio: + # Execute and validate + self.launcher.on_property_preload_set( + vm, 'property-reset:is_preload' + ) + mock_start_audio.assert_called_once_with(vm) + mock_start.assert_called_once_with(vm, monitor_layout=None) + def test_040_start_gui(self): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -442,6 +584,10 @@ def test_040_start_gui(self): self.app.expected_calls[ ('test-vm', 'admin.vm.property.Get', 'guivm', None)] = \ b'0\x00default=False type=vm gui-vm' + self.app.expected_calls[ + ('test-vm', 'admin.vm.property.Get', 'is_preload', None)] = \ + b'2\x00QubesNoSuchPropertyError\x00\x00Invalid property ' \ + b"'is_preload' of test-vm\x00" # pylint: disable=protected-access self.app._local_name = 'gui-vm' @@ -562,6 +708,10 @@ def test_060_send_monitor_layout(self): ('test-vm', 'admin.vm.feature.CheckWithTemplate', 'no-monitor-layout', None)] = \ b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00' + self.app.expected_calls[ + ('test-vm', 'admin.vm.property.Get', 'is_preload', None)] = \ + b'2\x00QubesNoSuchPropertyError\x00\x00Invalid property ' \ + b"'is_preload' of test-vm\x00" vm = self.app.domains['test-vm'] mock_run_service = unittest.mock.Mock(spec={}) @@ -649,6 +799,10 @@ def test_063_send_monitor_layout_signal_existing(self): self.app.expected_calls[ ('test-vm', 'admin.vm.property.Get', 'stubdom_xid', None)] = \ b'0\x00default=False type=int 124' + self.app.expected_calls[ + ('test-vm', 'admin.vm.property.Get', 'is_preload', None)] = \ + b'2\x00QubesNoSuchPropertyError\x00\x00Invalid property ' \ + b"'is_preload' of test-vm\x00" self.app.expected_calls[ ('test-vm', 'admin.vm.feature.CheckWithTemplate', 'no-monitor-layout', None)] = \ @@ -691,6 +845,14 @@ def test_070_send_monitor_layout_all(self): b'test-vm3 class=AppVM state=Running\n' \ b'test-vm4 class=AppVM state=Halted\n' \ b'gui-vm class=AppVM state=Running' + self.app.expected_calls[ + ('test-vm', 'admin.vm.property.Get', 'is_preload', None)] = \ + b'2\x00QubesNoSuchPropertyError\x00\x00Invalid property ' \ + b"'is_preload' of test-vm\x00" + self.app.expected_calls[ + ('test-vm2', 'admin.vm.property.Get', 'is_preload', None)] = \ + b'2\x00QubesNoSuchPropertyError\x00\x00Invalid property ' \ + b"'is_preload' of test-vm2\x00" self.app.expected_calls[ ('test-vm', 'admin.vm.CurrentState', None, None)] = \ b'0\x00power_state=Running' diff --git a/qubesadmin/tools/qvm_start_daemon.py b/qubesadmin/tools/qvm_start_daemon.py index 8ace2608..bc6304f9 100644 --- a/qubesadmin/tools/qvm_start_daemon.py +++ b/qubesadmin/tools/qvm_start_daemon.py @@ -565,6 +565,7 @@ async def send_monitor_layout(self, vm, layout=None, startup=False): if ( vm.features.check_with_template("no-monitor-layout", False) or not vm.is_running() + or getattr(vm, "is_preload", False) ): return @@ -605,16 +606,17 @@ def send_monitor_layout_all(self): """Send monitor layout to all (running) VMs""" monitor_layout = get_monitor_layout() for vm in self.app.domains: - if getattr(vm, "guivm", None) != vm.app.local_name: - continue - if vm.klass == "AdminVM": + if ( + getattr(vm, "guivm", None) != vm.app.local_name + or vm.klass == "AdminVM" + or not vm.is_running() + or not vm.features.check_with_template("gui", True) + or getattr(vm, "is_preload", False) + ): continue - if vm.is_running(): - if not vm.features.check_with_template("gui", True): - continue - asyncio.ensure_future( - self.send_monitor_layout(vm, monitor_layout) - ) + asyncio.ensure_future( + self.send_monitor_layout(vm, monitor_layout) + ) @staticmethod def kde_guid_args(vm): @@ -722,6 +724,8 @@ async def start_gui_for_vm(self, vm, monitor_layout=None): :param monitor_layout: monitor layout to send; if None, fetch it from local X server. """ + if getattr(vm, "is_preload", False): + return guid_cmd = self.common_guid_args(vm) guid_cmd.extend(["-d", str(vm.xid)]) @@ -748,6 +752,8 @@ async def start_gui_for_stubdomain(self, vm, force=False): This function is a coroutine. """ + if getattr(vm, "is_preload", False): + return want_stubdom = force if not want_stubdom and vm.features.check_with_template( "gui-emulated", False @@ -779,6 +785,8 @@ async def start_audio_for_vm(self, vm): :param vm: VM for which start AUDIO daemon """ + if getattr(vm, "is_preload", False): + return pacat_cmd = [ PACAT_DAEMON_PATH, "-l", @@ -799,6 +807,8 @@ async def start_gui(self, vm, force_stubdom=False, monitor_layout=None): one for target AppVM is running. :param monitor_layout: monitor layout configuration """ + if getattr(vm, "is_preload", False): + return guivm = getattr(vm, "guivm", None) if guivm != vm.app.local_name: vm.log.info("GUI connected to {}. Skipping.".format(guivm)) @@ -820,6 +830,8 @@ async def start_audio(self, vm): :param vm: VM for which AUDIO daemon should be started """ + if getattr(vm, "is_preload", False): + return audiovm = getattr(vm, "audiovm", None) if audiovm != vm.app.local_name: vm.log.info("AUDIO connected to {}. Skipping.".format(audiovm)) @@ -837,7 +849,7 @@ async def start_audio(self, vm): def on_domain_spawn(self, vm, _event, **kwargs): """Handler of 'domain-spawn' event, starts GUI daemon for stubdomain""" - if not self.is_watched(vm): + if not self.is_watched(vm) or getattr(vm, "is_preload", False): return try: @@ -862,7 +874,7 @@ def on_domain_start(self, vm, _event, **kwargs): """Handler of 'domain-start' event, starts GUI/AUDIO daemon for actual VM""" - if not self.is_watched(vm): + if not self.is_watched(vm) or getattr(vm, "is_preload", False): return self.xid_cache[vm.name] = vm.xid, vm.stubdom_xid @@ -892,15 +904,15 @@ def on_domain_start(self, vm, _event, **kwargs): def on_connection_established(self, _subject, _event, **_kwargs): """Handler of 'connection-established' event, used to launch GUI/AUDIO daemon for domains started before this tool.""" - monitor_layout = get_monitor_layout() self.app.domains.clear_cache() for vm in self.app.domains: try: - if vm.klass == "AdminVM": - continue - - if not self.is_watched(vm): + if ( + vm.klass == "AdminVM" + or not self.is_watched(vm) + or getattr(vm, "is_preload", False) + ): continue power_state = vm.get_power_state() @@ -942,6 +954,28 @@ def on_domain_stopped(self, vm, _event, **_kwargs): self.cleanup_guid(stubdom_xid) self.cleanup_pacat_process(stubdom_xid) + def on_property_preload_set(self, vm, _event, **_kwargs): + """Handler of 'property-reset:is_preload' event, used to launch + GUI/AUDIO daemon after preload is marked as used.""" + if ( + not vm.klass == "DispVM" + or not self.is_watched(vm) + or getattr(vm, "is_preload", False) + or not vm.is_running() + ): + return + monitor_layout = get_monitor_layout() + try: + if "guivm" in self.enabled_services: + asyncio.ensure_future( + self.start_gui(vm, monitor_layout=monitor_layout) + ) + if "audiovm" in self.enabled_services: + asyncio.ensure_future(self.start_audio(vm)) + self.xid_cache[vm.name] = vm.xid, vm.stubdom_xid + except qubesadmin.exc.QubesDaemonCommunicationError as e: + vm.log.warning("Failed to handle %s: %s", vm.name, str(e)) + def on_property_audiovm_set(self, vm, event, **kwargs): """Handler for catching event related to dynamic AudioVM set/unset""" if vm.name not in self.xid_cache: @@ -1018,6 +1052,9 @@ def register_events(self, events): "connection-established", self.on_connection_established ) events.add_handler("domain-stopped", self.on_domain_stopped) + events.add_handler( + "property-reset:is_preload", self.on_property_preload_set + ) for event in [ "property-set:audiovm",