Skip to content

Commit bc7bbff

Browse files
committed
Refresh preloaded disposables on outdated volumes
Fixes: QubesOS/qubes-issues#10026 For: QubesOS/qubes-issues#1512
1 parent 36fee27 commit bc7bbff

5 files changed

Lines changed: 175 additions & 58 deletions

File tree

doc/qubes-vm/index.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ Helper classes and functions
6363
:members:
6464
:show-inheritance:
6565

66+
.. autoclass:: qubes.vm.mix.dvmtemplate.DVMTemplateMixin
67+
:members:
68+
:show-inheritance:
69+
6670
Particular VM classes
6771
^^^^^^^^^^^^^^^^^^^^^
6872

qubes/tests/integ/dispvm.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,32 @@ async def _test_018_preload_global(self):
692692
self.log_preload()
693693
logger.info("end")
694694

695+
def test_019_preload_refresh(self):
696+
"""Refresh preload on volume change."""
697+
self.loop.run_until_complete(self._test_019_preload_refresh())
698+
699+
async def _test_019_preload_refresh(self):
700+
logger.info("start")
701+
self.log_preload()
702+
preload_max = 1
703+
704+
self.disp_base.features["preload-dispvm-max"] = str(preload_max)
705+
for qube in [self.disp_base, self.disp_base.template]:
706+
await self.wait_preload(preload_max)
707+
old_preload = self.disp_base.get_feat_preload()
708+
await qube.start()
709+
logger.info("shutdown '%s'", qube.name)
710+
await qube.shutdown(wait=True)
711+
await self.wait_preload(preload_max)
712+
preload_dispvm = self.disp_base.get_feat_preload()
713+
self.assertTrue(
714+
set(old_preload).isdisjoint(preload_dispvm),
715+
f"old_preload={old_preload} preload_dispvm={preload_dispvm}",
716+
)
717+
718+
self.log_preload()
719+
logger.info("end")
720+
695721
@unittest.skipUnless(
696722
spawn.find_executable("xdotool"), "xdotool not installed"
697723
)

qubes/vm/dispvm.py

Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -472,45 +472,73 @@ async def from_appvm(cls, appvm, preload=False, **kwargs):
472472
if not preload and appvm.can_preload():
473473
# Not necessary to await for this event as its intent is to fill
474474
# gaps and not relevant for this run.
475+
# TODO: ben: preload with delay? Delay can help because the preload
476+
# is not relevant to this run.
475477
asyncio.ensure_future(
476478
appvm.fire_event_async(
477479
"domain-preload-dispvm-start", reason="there is a gap"
478480
)
479481
)
480482

481483
if not preload and (preload_dispvm := appvm.get_feat_preload()):
482-
dispvm = app.domains[preload_dispvm[0]]
483-
dispvm.log.info("Requesting preloaded qube")
484-
# The property "preload_requested" offloads "preload-dispvm" and
485-
# thus avoids various race condition:
486-
# - Decreasing maximum feature will not remove the qube;
487-
# - Another request to this function will not return the same qube.
488-
dispvm.features["preload-dispvm-in-progress"] = True
489-
appvm.remove_preload_from_list([dispvm.name])
490-
dispvm.preload_requested = True
491-
app.save()
492-
timeout = int(dispvm.qrexec_timeout * 1.2)
493-
try:
494-
if not dispvm.features.get("preload-dispvm-completed", False):
495-
dispvm.log.info(
496-
"Waiting preload completion with '%s' seconds timeout",
497-
timeout,
484+
dispvm = None
485+
for item in preload_dispvm:
486+
qube = app.domains[item]
487+
if any(vol.is_outdated() for vol in qube.volumes.values()):
488+
qube.log.warning(
489+
"Requested preloaded qube but it is outdated, trying "
490+
"another one if available"
498491
)
499-
async with asyncio.timeout(timeout):
500-
await dispvm.preload_complete.wait()
501-
if dispvm.is_paused():
502-
await dispvm.unpause()
503-
else:
504-
dispvm.use_preload()
492+
# The gap is filled after the delay set by the
493+
# 'domain-shutdown' of its ancestors. Not refilling now to
494+
# deliver a disposable faster.
495+
appvm.remove_preload_from_list([qube.name])
496+
# TODO: ben: cleanup with delay? Delay can help because the
497+
# cleanup is not relevant to this run.
498+
asyncio.ensure_future(qube.cleanup())
499+
dispvm = qube
500+
if dispvm:
501+
dispvm.log.info("Requesting preloaded qube")
502+
# The property "preload_requested" offloads "preload-dispvm"
503+
# and thus avoids various race condition:
504+
# - Decreasing maximum feature will not remove the qube;
505+
# - Another request to this function will not return the same
506+
# qube.
507+
dispvm.features["preload-dispvm-in-progress"] = True
508+
appvm.remove_preload_from_list([dispvm.name])
509+
dispvm.preload_requested = True
505510
app.save()
506-
return dispvm
507-
except asyncio.TimeoutError:
508-
dispvm.log.warning(
509-
"Requested preloaded qube but failed to finish preloading "
510-
"after '%d' seconds, falling back to normal disposable",
511-
int(timeout),
511+
timeout = int(dispvm.qrexec_timeout * 1.2)
512+
try:
513+
if not dispvm.features.get(
514+
"preload-dispvm-completed", False
515+
):
516+
dispvm.log.info(
517+
"Waiting preload completion with '%s' seconds "
518+
"timeout",
519+
timeout,
520+
)
521+
async with asyncio.timeout(timeout):
522+
await dispvm.preload_complete.wait()
523+
if dispvm.is_paused():
524+
await dispvm.unpause()
525+
else:
526+
dispvm.use_preload()
527+
app.save()
528+
return dispvm
529+
except asyncio.TimeoutError:
530+
dispvm.log.warning(
531+
"Requested preloaded qube but failed to finish "
532+
"preloading after '%d' seconds, falling back to normal "
533+
"disposable",
534+
int(timeout),
535+
)
536+
asyncio.ensure_future(dispvm.cleanup())
537+
else:
538+
appvm.log.warning(
539+
"Found only outdated preloaded qube(s), falling back to "
540+
"normal disposable"
512541
)
513-
asyncio.ensure_future(dispvm.cleanup())
514542

515543
dispvm = app.add_new_vm(
516544
cls, template=appvm, auto_cleanup=True, **kwargs
@@ -553,6 +581,9 @@ def use_preload(self):
553581
appvm.remove_preload_from_list([self.name])
554582
self.features["preload-dispvm-in-progress"] = False
555583
self.app.save()
584+
# TODO: ben: preload with delay? Adding a delay can help on concurrent
585+
# requests if the max is bigger than 1. No delay is better if we
586+
# expect multiple requests to be spread.
556587
asyncio.ensure_future(
557588
appvm.fire_event_async("domain-preload-dispvm-used", dispvm=self)
558589
)

qubes/vm/mix/dvmtemplate.py

Lines changed: 73 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
# with this program; if not, see <http://www.gnu.org/licenses/>.
2020

2121
import asyncio
22-
from typing import Optional
22+
from typing import Optional, Union
2323

2424
import qubes.config
2525
import qubes.events
@@ -87,6 +87,27 @@ def on_domain_loaded(self, event): # pylint: disable=unused-argument
8787
if changes:
8888
self.app.save()
8989

90+
@qubes.events.handler("domain-pre-start")
91+
def __on_domain_pre_start(self, event, **kwargs):
92+
"""Prevents startup for domain having a volume with disabled snapshots
93+
and a DispVM based on this volume started
94+
"""
95+
# pylint: disable=unused-argument
96+
volume_with_disabled_snapshots = False
97+
for vol in self.volumes.values():
98+
volume_with_disabled_snapshots |= vol.snapshots_disabled
99+
100+
if not volume_with_disabled_snapshots:
101+
return
102+
103+
for vm in self.dispvms:
104+
if vm.is_running():
105+
raise qubes.exc.QubesVMNotHaltedError(vm)
106+
107+
@qubes.events.handler("domain-shutdown")
108+
async def on_dvmtemplate_domain_shutdown(self, _event, **_kwargs):
109+
await self.refresh_preload()
110+
90111
@qubes.events.handler("domain-feature-delete:preload-dispvm-max")
91112
def on_feature_delete_preload_dispvm_max(
92113
self, event, feature
@@ -115,23 +136,6 @@ def on_feature_pre_set_preload_dispvm_max(
115136
"Invalid preload-dispvm-max value: not a digit"
116137
)
117138

118-
@qubes.events.handler("domain-pre-start")
119-
def __on_domain_pre_start(self, event, **kwargs):
120-
"""Prevents startup for domain having a volume with disabled snapshots
121-
and a DispVM based on this volume started
122-
"""
123-
# pylint: disable=unused-argument
124-
volume_with_disabled_snapshots = False
125-
for vol in self.volumes.values():
126-
volume_with_disabled_snapshots |= vol.snapshots_disabled
127-
128-
if not volume_with_disabled_snapshots:
129-
return
130-
131-
for vm in self.dispvms:
132-
if vm.is_running():
133-
raise qubes.exc.QubesVMNotHaltedError(vm)
134-
135139
@qubes.events.handler("domain-feature-set:preload-dispvm-max")
136140
def on_feature_set_preload_dispvm_max(
137141
self, event, feature, value, oldvalue=None
@@ -249,22 +253,40 @@ def __on_property_set_template(self, event, name, newvalue, oldvalue=None):
249253
"domain-preload-dispvm-start",
250254
)
251255
async def on_domain_preload_dispvm_used(
252-
self, event, **kwargs
253-
): # pylint: disable=unused-argument
256+
self,
257+
event: str,
258+
dispvm: Optional[qubes.vm.BaseVM] = None,
259+
reason: Optional[str] = None,
260+
delay: Union[int, float] = 0,
261+
**kwargs, # pylint: disable=unused-argument
262+
) -> None:
254263
"""
255-
Preloads on vacancy and offloads on excess. If the event suffix is
264+
Offloads on excess and preload on vacancy.
256265
``autostart``, the preloaded list is emptied before preloading.
257266
258-
:param event: event which was fired
267+
:param str event: Event which was fired. Events have the prefix \
268+
``domain-preload-dispvm-``. If the suffix is ``autostart``, the \
269+
preload list is emptied before attempting to preload. If the \
270+
suffix is ``used`` or ``start``, tries to preload until it fills \
271+
gaps.
272+
:param qubes.vm.dispvm.DispVM dispvm: Disposable that was used
273+
:param str reason: Why the event was fired
274+
:param float delay: Proceed only after sleeping that many seconds
259275
"""
276+
assert isinstance(self, qubes.vm.BaseVM)
260277
event = event.removeprefix("domain-preload-dispvm-")
261278
event_log = "Received preload event '%s'" % str(event)
262-
if event == "used":
263-
event_log += " for dispvm '%s'" % str(kwargs.get("dispvm"))
264-
if "reason" in kwargs:
265-
event_log += " because %s" % str(kwargs.get("reason"))
279+
if event == "used" and dispvm:
280+
event_log += " for dispvm '%s'" % str(dispvm)
281+
if reason:
282+
event_log += " because %s" % str(reason)
283+
if delay:
284+
event_log += " with a delay of %f second(s)" % float(delay)
266285
self.log.info(event_log)
267286

287+
if delay:
288+
await asyncio.sleep(delay)
289+
268290
if event == "autostart":
269291
self.remove_preload_excess(0)
270292
elif not self.can_preload():
@@ -329,7 +351,7 @@ def get_feat_global_preload_max(self) -> Optional[int]:
329351
def get_feat_preload_max(self, force_local=False) -> int:
330352
"""Get the ``preload-dispvm-max`` feature as an integer.
331353
332-
:param force_local: ignore global setting.
354+
:param bool force_local: ignore global setting.
333355
"""
334356
assert isinstance(self, qubes.vm.BaseVM)
335357
feature = "preload-dispvm-max"
@@ -370,10 +392,33 @@ def can_preload(self) -> bool:
370392
return True
371393
return False
372394

395+
async def refresh_preload(self) -> None:
396+
assert isinstance(self, qubes.vm.BaseVM)
397+
outdated = []
398+
for qube in self.dispvms:
399+
if not qube.is_preload or not any(
400+
vol.is_outdated() for vol in qube.volumes.values()
401+
):
402+
continue
403+
outdated.append(qube)
404+
self.remove_preload_from_list([qube.name])
405+
if outdated:
406+
tasks = [self.app.domains[qube].cleanup() for qube in outdated]
407+
# TODO: ben: cleanup and preload with delay? Delay can help user
408+
# expects to shutdown template and get a fresh disposable. Delay
409+
# won't help if max is on the low end, such as 1-2.
410+
asyncio.ensure_future(asyncio.gather(*tasks))
411+
asyncio.ensure_future(
412+
self.fire_event_async(
413+
"domain-preload-dispvm-start",
414+
reason="of outdated volume(s)",
415+
)
416+
)
417+
373418
def remove_preload_from_list(self, disposables: list[str]) -> None:
374419
"""Removes list of preload qubes from the list.
375420
376-
:param disposables: disposable names to remove from the preloaded list.
421+
:param list[str] disposables: disposable names to remove from list.
377422
"""
378423
assert isinstance(self, qubes.vm.BaseVM)
379424
old_preload = self.get_feat_preload()

qubes/vm/templatevm.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
# License along with this library; if not, see <https://www.gnu.org/licenses/>.
2020
#
2121

22-
""" This module contains the TemplateVM implementation """
22+
"""This module contains the TemplateVM implementation"""
2323

2424
import qubes
2525
import qubes.config
@@ -108,6 +108,17 @@ def __init__(self, *args, **kwargs):
108108
}
109109
super().__init__(*args, **kwargs)
110110

111+
@qubes.events.handler("domain-shutdown")
112+
async def on_template_domain_shutdown(self, _event, **_kwargs):
113+
appvms = [
114+
qube
115+
for qube in self.app.domains
116+
if getattr(qube, "template", None) == self
117+
and getattr(qube, "template_for_dispvms", False)
118+
]
119+
for qube in appvms:
120+
await qube.refresh_preload()
121+
111122
@qubes.events.handler("domain-feature-set:boot-mode.appvm-default")
112123
def on_feature_bootmode_appvm_set(
113124
self, event, feature, value, oldvalue=None

0 commit comments

Comments
 (0)