Skip to content

Commit a76ed3e

Browse files
committed
Preload disposables
For: QubesOS/qubes-issues#1512
1 parent 2069b1d commit a76ed3e

9 files changed

Lines changed: 285 additions & 3 deletions

File tree

linux/aux-tools/preload-dispvm

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/usr/bin/env python3
2+
3+
#import asyncio
4+
import qubesadmin
5+
6+
def get_apps():
7+
domains = qubesadmin.Qubes().domains
8+
return [qube for qube in domains
9+
if int(qube.features.get("dispvm-preload-max", 0)) > 0
10+
and qube.klass == "AppVM"
11+
and getattr(qube, "template_for_dispvms", False)
12+
]
13+
14+
#async def main():
15+
# appvms = get_apps()
16+
# ## TODO: How to fire events outside of qubesd? How to load DispVM by hand.
17+
# #o = MyClass()
18+
# #o.events_enabled = True
19+
# #effect = o.fire_event('event1')
20+
# event = "domain-preloaded-dispvm-autostart"
21+
# tasks = [qube.fire_event_async(event) for qube in appvms]
22+
# await asyncio.gather(*tasks)
23+
24+
def main():
25+
appvms = get_apps()
26+
method = "admin.vm.CreateDisposable"
27+
for qube in appvms:
28+
qube.qubesd_call("dom0", method, "preload-autostart")
29+
30+
if __name__ == "__main__":
31+
#asyncio.run(main())
32+
main()

linux/systemd/Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ install:
99
cp qubes-vm@.service $(DESTDIR)$(UNITDIR)
1010
cp qubes-qmemman.service $(DESTDIR)$(UNITDIR)
1111
cp qubesd.service $(DESTDIR)$(UNITDIR)
12+
cp qubes-preload-dispvm.service $(DESTDIR)$(UNITDIR)
1213
install -d $(DESTDIR)$(UNITDIR)/lvm2-pvscan@.service.d
1314
install -m 0644 lvm2-pvscan@.service.d_30_qubes.conf \
1415
$(DESTDIR)$(UNITDIR)/lvm2-pvscan@.service.d/30_qubes.conf
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[Unit]
2+
Description=Preload Qubes DispVMs
3+
After=qubes-vm@.service
4+
# TODO: or 'Requires='? Should a failure to autostart sys-(usb|net) make this
5+
# unit be skipped?
6+
Wants=qubes-vm@.service
7+
ConditionKernelCommandLine=!qubes.skip_autostart
8+
9+
[Service]
10+
Type=oneshot
11+
Environment=DISPLAY=:0
12+
ExecStart=/usr/lib/qubes/preload-dispvm
13+
Group=qubes
14+
RemainAfterExit=yes
15+
16+
[Install]
17+
WantedBy=multi-user.target

qubes/api/admin.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,7 +1272,13 @@ async def _vm_create(
12721272

12731273
@qubes.api.method("admin.vm.CreateDisposable", scope="global", write=True)
12741274
async def create_disposable(self, untrusted_payload):
1275-
self.enforce(not self.arg)
1275+
self.enforce(self.arg in [None, "preload", "preload-autostart"])
1276+
preload = False
1277+
preload_autostart = False
1278+
if self.arg == "preload":
1279+
preload = True
1280+
if self.arg == "preload-autostart":
1281+
preload_autostart = True
12761282
if untrusted_payload not in (b"", b"uuid"):
12771283
raise qubes.exc.QubesValueError(
12781284
"Invalid payload for admin.vm.CreateDisposable: "
@@ -1286,7 +1292,15 @@ async def create_disposable(self, untrusted_payload):
12861292

12871293
self.fire_event_for_permission(dispvm_template=dispvm_template)
12881294

1289-
dispvm = await qubes.vm.dispvm.DispVM.from_appvm(dispvm_template)
1295+
if preload_autostart:
1296+
await (
1297+
self.dest.fire_event_async("domain-preloaded-dispvm-autostart")
1298+
)
1299+
return
1300+
1301+
dispvm = await qubes.vm.dispvm.DispVM.from_appvm(
1302+
dispvm_template, preload=preload
1303+
)
12901304
# TODO: move this to extension (in race-free fashion, better than here)
12911305
dispvm.tags.add("created-by-" + str(self.src))
12921306
dispvm.tags.add("disp-created-by-" + str(self.src))

qubes/tests/api_admin.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3716,6 +3716,35 @@ def test_642_vm_create_disposable_not_allowed(self, storage_mock):
37163716
self.call_mgmt_func(b"admin.vm.CreateDisposable", b"test-vm1")
37173717
self.assertFalse(self.app.save.called)
37183718

3719+
@unittest.mock.patch("qubes.storage.Storage.create")
3720+
def test_643_vm_create_disposable_preload(self, mock_storage):
3721+
mock_storage.side_effect = self.dummy_coro
3722+
self.vm.template_for_dispvms = True
3723+
self.vm.features["preload-dispvm-max"] = 1
3724+
self.app.default_dispvm = self.vm
3725+
retval = self.call_mgmt_func(
3726+
b"admin.vm.CreateDisposable", b"dom0", arg="preload"
3727+
)
3728+
dispvm_preload = self.vm.features.get("dispvm-preload", "").split(" ")
3729+
self.assertIn(retval, dispvm_preload)
3730+
mock_storage.assert_called_once_with()
3731+
self.assertTrue(self.app.save.called)
3732+
3733+
@unittest.mock.patch("qubes.storage.Storage.create")
3734+
def test_643_vm_create_disposable_preload_autostart(self, mock_storage):
3735+
mock_storage.side_effect = self.dummy_coro
3736+
self.vm.template_for_dispvms = True
3737+
self.vm.features["preload-dispvm-max"] = 1
3738+
self.app.default_dispvm = self.vm
3739+
retval = self.call_mgmt_func(
3740+
b"admin.vm.CreateDisposable", b"dom0", arg="preload-autostart"
3741+
)
3742+
# TODO: doesn't return any value, so how to check if it was preloaded?
3743+
#dispvm_preload = self.vm.features.get("preload-dispvm", "").split(" ")
3744+
self.assertIsNone(retval)
3745+
mock_storage.assert_called_once_with()
3746+
self.assertTrue(self.app.save.called)
3747+
37193748
def test_650_vm_device_set_mode_required(self):
37203749
assignment = DeviceAssignment(
37213750
VirtualDevice(Port(self.vm, "1234", "testclass"), device_id="bee"),

qubes/tests/integ/dispvm.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ def tearDown(self):
206206
self.app.default_dispvm = None
207207
super(TC_20_DispVMMixin, self).tearDown()
208208

209+
# TODO: Test if run_service() marks the prelaoded DispVM as used.
209210
def test_010_simple_dvm_run(self):
210211
dispvm = self.loop.run_until_complete(
211212
qubes.vm.dispvm.DispVM.from_appvm(self.disp_base)

qubes/tests/vm/dispvm.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def cleanup_dispvm(self):
8484
async def mock_coro(self, *args, **kwargs):
8585
pass
8686

87+
# TODO: Test creating preloaded disposable and if features are correct.
8788
@mock.patch("os.symlink")
8889
@mock.patch("os.makedirs")
8990
@mock.patch("qubes.storage.Storage")

qubes/vm/dispvm.py

Lines changed: 186 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020

2121
""" A disposable vm implementation """
2222

23+
import asyncio
2324
import copy
25+
import psutil
26+
import subprocess
2427

2528
import qubes.vm.qubesvm
2629
import qubes.vm.appvm
@@ -187,11 +190,170 @@ def __init__(self, app, xml, *args, **kwargs):
187190
self.features.update(template.features)
188191
self.tags.update(template.tags)
189192

193+
def get_feat_preload(self, feature):
194+
if feature not in ["preload-dispvm", "preload-dispvm-max"]:
195+
raise qubes.exc.QubesException("Invalid feature provided")
196+
197+
if feature == "preload-dispvm":
198+
default = ""
199+
elif feature == "preload-dispvm-max":
200+
default = 0
201+
202+
value = self.features.check_with_template(feature, default)
203+
204+
if feature == "preload-dispvm":
205+
return value.split(" ")
206+
if feature == "preload-dispvm-max":
207+
return int(value)
208+
return None
209+
210+
def is_preloaded(self):
211+
preload_dispvm = self.get_feat_preload("preload-dispvm")
212+
if not preload_dispvm:
213+
return False
214+
if self.name not in preload_dispvm:
215+
return False
216+
return True
217+
218+
async def mark_preloaded(self):
219+
"""
220+
Create preloaded DispVM.
221+
222+
Template from which the VM should be created.
223+
224+
:return:
225+
"""
226+
preload_dispvm = self.get_feat_preload("preload-dispvm")
227+
if preload_dispvm:
228+
preload_dispvm.append(self.name)
229+
else:
230+
preload_dispvm = [self.name]
231+
232+
appvm = getattr(self, "template")
233+
appvm.features["preload-dispvm"] = " ".join(preload_dispvm)
234+
self.features["internal"] = True
235+
236+
async def use_preloaded(self):
237+
"""
238+
Mark preloaded DispVM as used.
239+
240+
:return:
241+
"""
242+
appvm = getattr(self, "template")
243+
244+
preload_dispvm = self.get_feat_preload("preload-dispvm")
245+
if self.name not in preload_dispvm:
246+
raise qubes.exc.QubesException("DispVM is not preloaded")
247+
248+
preload_dispvm = " ".join(preload_dispvm.remove(self.name))
249+
appvm.features["preload-dispvm"] = preload_dispvm
250+
self.features["internal"] = False
251+
await appvm.fire_event_async(
252+
"domain-preloaded-dispvm-used", dispvm=self
253+
)
254+
255+
@qubes.events.handler(
256+
"domain-preloaded-dispvm-used", "domain-preloaded-dispvm-autostart"
257+
)
258+
async def on_domain_preloaded_dispvm_used(self, event, delay=5, **kwargs): # pylint: disable=unused-argument
259+
"""When preloaded DispVM is used or after boot, preload another one.
260+
261+
:param event: event which was fired
262+
:param delay: delay between trials
263+
:returns:
264+
"""
265+
await asyncio.sleep(delay)
266+
while True:
267+
# TODO: Is there existing Qubes code that checks available memory
268+
# before starting a qube?
269+
memory = getattr(self, "memory", 0)
270+
available_memory = (
271+
psutil.virtual_memory().available / (1024 * 1024)
272+
)
273+
threshold = 1024 * 5
274+
if memory >= (available_memory - threshold):
275+
## TODO: how to pass arg?
276+
await qubes.vm.dispvm.DispVM.from_appvm(
277+
self, preload=True
278+
).start()
279+
#await qubes.api.admin.QubesAdminAPI.create_disposable(
280+
# self.app, b"dom0", "admin.vm.CreateDisposable", b"dom0", b"preload"
281+
#)
282+
# TODO: what to do if the maximum is never reached on autostart
283+
# as there is not enough memory, and then a preloaded DispVM is
284+
# used, calling for the creation of another one, while the
285+
# autostart will also try to create one. Is this a race
286+
# condition?
287+
# TODO: fire event after start of all qubes that are set to
288+
# autostart.
289+
if event == "domain-preloaded-dispvm-autostart":
290+
preload_dispvm_max = self.get_feat_preload(
291+
"preload-dispvm-max"
292+
)
293+
preload_dispvm = self.get_feat_preload("preload-dispvm")
294+
if (
295+
preload_dispvm
296+
and len(preload_dispvm) < preload_dispvm_max
297+
):
298+
continue
299+
break
300+
await asyncio.sleep(delay)
301+
190302
@qubes.events.handler("domain-load")
191303
def on_domain_loaded(self, event):
192304
"""When domain is loaded assert that this vm has a template.""" # pylint: disable=unused-argument
193305
assert self.template
194306

307+
@qubes.events.handler("domain-start")
308+
# W0236 (invalid-overridden-method) Method 'on_domain_started' was expected
309+
# to be 'non-async', found it instead as 'async'
310+
# TODO: Seems to conflict with qubes.vm.mix.net, which is pretty strange.
311+
# Larger bug? qubes.vm.qubesvm.QubesVM has NetVMMixin... which conflicts...
312+
async def on_domain_started(self, event, **kwargs):
313+
"""Pause preloaded domains as soon as they start."""
314+
# TODO:
315+
# Marek: Test if pause isn't too early. Some services (especially:
316+
# gui-agent) may still be starting. qubes.WaitForSession service may
317+
# help (ensure to use async handler to not block qubesd while waiting
318+
# on it).
319+
no_gui_sleep = 15
320+
gui_timeout = 30
321+
if self.is_preloaded():
322+
gui = self.features.get("gui", None)
323+
if not gui:
324+
asyncio.sleep(no_gui_sleep)
325+
self.pause()
326+
return
327+
328+
proc = None
329+
try:
330+
proc = await asyncio.wait_for(
331+
self.run_service(
332+
"qubes.WaitForSession",
333+
user=self.default_user,
334+
stdout=subprocess.DEVNULL,
335+
stderr=subprocess.DEVNULL,
336+
),
337+
timeout=gui_timeout,
338+
)
339+
except asyncio.TimeoutError:
340+
## TODO: should timeout be treated as an error/qubes.exc?
341+
return
342+
except (subprocess.CalledProcessError,qubes.exc.QubesException):
343+
raise qubes.exc.QubesException(
344+
"Failed to run QUBESRPC qubes.WaitForSession"
345+
)
346+
finally:
347+
if proc is not None:
348+
proc.terminate()
349+
self.pause()
350+
351+
@qubes.events.handler("domain-unpaused")
352+
async def on_domain_unpaused(self):
353+
"""Mark unpaused preloaded domains as used."""
354+
if self.is_preloaded():
355+
await self.use_preloaded()
356+
195357
@qubes.events.handler("property-pre-reset:template")
196358
def on_property_pre_reset_template(self, event, name, oldvalue=None):
197359
"""Forbid deleting template of VM""" # pylint: disable=unused-argument
@@ -228,11 +390,12 @@ async def _auto_cleanup(self):
228390
self.app.save()
229391

230392
@classmethod
231-
async def from_appvm(cls, appvm, **kwargs):
393+
async def from_appvm(cls, appvm, preload=False, **kwargs):
232394
"""Create a new instance from given AppVM
233395
234396
:param qubes.vm.appvm.AppVM appvm: template from which the VM should \
235397
be created
398+
:param bool preload: Whether to preload a disposable
236399
:returns: new disposable vm
237400
238401
*kwargs* are passed to the newly created VM
@@ -251,10 +414,32 @@ async def from_appvm(cls, appvm, **kwargs):
251414
"template_for_dispvms=False"
252415
)
253416
app = appvm.app
417+
418+
if preload:
419+
preload_dispvm_max = appvm.get_feat_preload("preload-dispvm-max")
420+
if preload_dispvm_max == 0:
421+
return
422+
preload_dispvm = appvm.get_feat_preload("preload-dispvm")
423+
if preload_dispvm and len(preload_dispvm) >= preload_dispvm_max:
424+
raise qubes.exc.QubesException(
425+
"Failed to create preloaded disposable, limit of "
426+
"preloaded DispVMs reached"
427+
)
428+
else:
429+
preload_dispvm = appvm.get_feat_preload("preload-dispvm")
430+
if preload_dispvm:
431+
dispvm = app.domains[preload_dispvm[0]]
432+
await dispvm.use_preloaded()
433+
return dispvm
434+
254435
dispvm = app.add_new_vm(
255436
cls, template=appvm, auto_cleanup=True, **kwargs
256437
)
257438
await dispvm.create_on_disk()
439+
440+
if preload:
441+
await dispvm.mark_preloaded()
442+
258443
app.save()
259444
return dispvm
260445

rpm_spec/core-dom0.spec.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,7 @@ done
546546
%{python3_sitelib}/qubes/qmemman/domainstate.py
547547
%{python3_sitelib}/qubes/qmemman/systemstate.py
548548

549+
/usr/lib/qubes/preload-dispvm
549550
/usr/lib/qubes/cleanup-dispvms
550551
/usr/lib/qubes/fix-dir-perms.sh
551552
/usr/lib/qubes/startup-misc.sh
@@ -556,6 +557,7 @@ done
556557
%{_unitdir}/qubes-qmemman.service
557558
%{_unitdir}/qubes-vm@.service
558559
%{_unitdir}/qubesd.service
560+
%{_unitdir}/qubes-preload-dispvm.service
559561
%attr(2770,root,qubes) %dir /var/lib/qubes
560562
%attr(2770,root,qubes) %dir /var/lib/qubes/vm-templates
561563
%attr(2770,root,qubes) %dir /var/lib/qubes/appvms

0 commit comments

Comments
 (0)