Skip to content

Commit 27fc544

Browse files
committed
Make qvm_shutdown reusable for other tools
It was necessary to make alternative main async functions, as Python is picky about running async code when mixing async and sync functions.
1 parent 9e6f3dd commit 27fc544

6 files changed

Lines changed: 108 additions & 119 deletions

File tree

qubesadmin/tests/tools/qvm_kill.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
# pylint: disable=missing-docstring
2222

23+
import asyncio
24+
2325
import qubesadmin.tests
2426
import qubesadmin.tests.tools
2527
import qubesadmin.tools.qvm_kill
@@ -35,6 +37,20 @@ def test_000_with_vm(self):
3537
qubesadmin.tools.qvm_kill.main(['some-vm'], app=self.app)
3638
self.assertAllCalled()
3739

40+
def test_000_with_vm_async(self):
41+
loop = asyncio.new_event_loop()
42+
asyncio.set_event_loop(loop)
43+
44+
self.app.expected_calls[
45+
('dom0', 'admin.vm.List', None, None)] = \
46+
b'0\x00some-vm class=AppVM state=Running\n'
47+
self.app.expected_calls[
48+
('some-vm', 'admin.vm.Kill', None, None)] = b'0\x00'
49+
loop.run_until_complete(
50+
qubesadmin.tools.qvm_kill.main_async(['some-vm'], app=self.app)
51+
)
52+
self.assertAllCalled()
53+
3854
def test_001_missing_vm(self):
3955
with self.assertRaises(SystemExit):
4056
with qubesadmin.tests.tools.StderrBuffer() as stderr:
@@ -74,9 +90,9 @@ def test_004_other_error(self):
7490
('dom0', 'admin.vm.List', None, None)] = \
7591
b'0\x00some-vm class=AppVM state=Running\n'
7692
with qubesadmin.tests.tools.StderrBuffer() as stderr:
77-
self.assertEqual(
78-
qubesadmin.tools.qvm_kill.main(['some-vm'], app=self.app),
79-
1)
93+
with self.assertRaises(SystemExit):
94+
self.assertEqual(
95+
qubesadmin.tools.qvm_kill.main(['some-vm'], app=self.app),
96+
1)
8097
self.assertAllCalled()
81-
self.assertIn("Failed to kill 'some-vm': Error message",
82-
stderr.getvalue())
98+
self.assertIn("Failed to kill: some-vm", stderr.getvalue())

qubesadmin/tests/tools/qvm_shutdown.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,24 @@ def test_010_wait(self):
9696
qubesadmin.tools.qvm_shutdown.main(['--wait', 'some-vm'], app=self.app)
9797
self.assertAllCalled()
9898

99+
def test_011_wait_async(self):
100+
'''test --wait option with async main'''
101+
loop = asyncio.new_event_loop()
102+
asyncio.set_event_loop(loop)
103+
104+
self.app.expected_calls[
105+
('some-vm', 'admin.vm.Shutdown', 'wait', None)] = \
106+
b'0\x00'
107+
self.app.expected_calls[
108+
('dom0', 'admin.vm.List', None, None)] = \
109+
b'0\x00some-vm class=AppVM state=Running\n'
110+
loop.run_until_complete(
111+
qubesadmin.tools.qvm_shutdown.main_async(
112+
['--wait', 'some-vm'], app=self.app
113+
)
114+
)
115+
self.assertAllCalled()
116+
99117
def test_012_wait_all(self):
100118
'''test --wait option, with multiple VMs'''
101119
loop = asyncio.new_event_loop()
@@ -199,6 +217,21 @@ def test_006_dry_run(self):
199217
['--dry-run', 'some-vm'], app=self.app)
200218
self.assertAllCalled()
201219

220+
def test_006_dry_run_async(self):
221+
'''test --dry-run skips shutdown calls'''
222+
loop = asyncio.new_event_loop()
223+
asyncio.set_event_loop(loop)
224+
225+
self.app.expected_calls[
226+
('dom0', 'admin.vm.List', None, None)] = \
227+
b'0\x00some-vm class=AppVM state=Running\n'
228+
loop.run_until_complete(
229+
qubesadmin.tools.qvm_shutdown.main_async(
230+
['--dry-run', 'some-vm'], app=self.app
231+
)
232+
)
233+
self.assertAllCalled()
234+
202235
def test_011_wait_retry(self):
203236
'''test --wait retries VMs whose shutdown request failed'''
204237
loop = asyncio.new_event_loop()

qubesadmin/tests/tools/qvm_template_postprocess.py

Lines changed: 2 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -400,18 +400,7 @@ def test_020_post_install(self, mock_import_root_img,
400400
self.app.expected_calls[
401401
('test-vm', 'admin.vm.Start', None, None)] = b'0\0'
402402
self.app.expected_calls[
403-
('test-vm', 'admin.vm.Shutdown', None, None)] = b'0\0'
404-
405-
if qubesadmin.tools.qvm_template_postprocess.have_events:
406-
patch_domain_shutdown = mock.patch(
407-
'qubesadmin.events.utils.wait_for_domain_shutdown')
408-
self.addCleanup(patch_domain_shutdown.stop)
409-
mock_domain_shutdown = patch_domain_shutdown.start()
410-
mock_domain_shutdown.side_effect = self.wait_for_shutdown
411-
else:
412-
self.app.expected_calls[
413-
('test-vm', 'admin.vm.List', None, None)] = \
414-
b'0\0test-vm class=TemplateVM state=Halted\n'
403+
('test-vm', 'admin.vm.Shutdown', 'wait', None)] = b'0\0'
415404

416405
asyncio.set_event_loop(asyncio.new_event_loop())
417406
ret = qubesadmin.tools.qvm_template_postprocess.main([
@@ -424,9 +413,6 @@ def test_020_post_install(self, mock_import_root_img,
424413
'test-vm'], self.source_dir.name)
425414
mock_import_appmenus.assert_called_once_with(self.app.domains[
426415
'test-vm'], self.source_dir.name, skip_generate=True)
427-
if qubesadmin.tools.qvm_template_postprocess.have_events:
428-
mock_domain_shutdown.assert_called_once_with([self.app.domains[
429-
'test-vm']])
430416
self.assertEqual(self.app.service_calls, [
431417
('test-vm', 'qubes.PostInstall', {
432418
'stdin': subprocess.PIPE,
@@ -458,18 +444,7 @@ def test_021_post_install_reinstall(self, mock_reset_private_img,
458444
self.app.expected_calls[
459445
('test-vm', 'admin.vm.Start', None, None)] = b'0\0'
460446
self.app.expected_calls[
461-
('test-vm', 'admin.vm.Shutdown', None, None)] = b'0\0'
462-
463-
if qubesadmin.tools.qvm_template_postprocess.have_events:
464-
patch_domain_shutdown = mock.patch(
465-
'qubesadmin.events.utils.wait_for_domain_shutdown')
466-
self.addCleanup(patch_domain_shutdown.stop)
467-
mock_domain_shutdown = patch_domain_shutdown.start()
468-
mock_domain_shutdown.side_effect = self.wait_for_shutdown
469-
else:
470-
self.app.expected_calls[
471-
('test-vm', 'admin.vm.List', None, None)] = \
472-
b'0\0test-vm class=TemplateVM state=Halted\n'
447+
('test-vm', 'admin.vm.Shutdown', 'wait', None)] = b'0\0'
473448

474449
asyncio.set_event_loop(asyncio.new_event_loop())
475450
ret = qubesadmin.tools.qvm_template_postprocess.main([
@@ -483,9 +458,6 @@ def test_021_post_install_reinstall(self, mock_reset_private_img,
483458
'test-vm'])
484459
mock_import_appmenus.assert_called_once_with(self.app.domains[
485460
'test-vm'], self.source_dir.name, skip_generate=True)
486-
if qubesadmin.tools.qvm_template_postprocess.have_events:
487-
mock_domain_shutdown.assert_called_once_with([self.app.domains[
488-
'test-vm']])
489461
self.assertEqual(self.app.service_calls, [
490462
('test-vm', 'qubes.PostInstall', {
491463
'stdin': subprocess.PIPE,
@@ -508,13 +480,6 @@ def test_022_post_install_skip_start(self, mock_reset_private_img,
508480
= b'0\0'
509481
self.app.add_new_vm = mock.Mock()
510482

511-
if qubesadmin.tools.qvm_template_postprocess.have_events:
512-
patch_domain_shutdown = mock.patch(
513-
'qubesadmin.events.utils.wait_for_domain_shutdown')
514-
self.addCleanup(patch_domain_shutdown.stop)
515-
mock_domain_shutdown = patch_domain_shutdown.start()
516-
mock_domain_shutdown.side_effect = self.wait_for_shutdown
517-
518483
asyncio.set_event_loop(asyncio.new_event_loop())
519484
ret = qubesadmin.tools.qvm_template_postprocess.main([
520485
'--really', '--skip-start', 'post-install', 'test-vm',
@@ -528,8 +493,6 @@ def test_022_post_install_skip_start(self, mock_reset_private_img,
528493
'test-vm'])
529494
mock_import_appmenus.assert_called_once_with(self.app.domains[
530495
'test-vm'], self.source_dir.name, skip_generate=False)
531-
if qubesadmin.tools.qvm_template_postprocess.have_events:
532-
self.assertFalse(mock_domain_shutdown.called)
533496
self.assertEqual(self.app.service_calls, [])
534497
self.assertAllCalled()
535498

qubesadmin/tools/qvm_kill.py

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,36 +21,41 @@
2121
'''Immediately terminate a qube without a graceful shutdown sequence.'''
2222

2323

24+
import asyncio
2425
import sys
26+
2527
import qubesadmin.exc
2628
import qubesadmin.tools
29+
import qubesadmin.tools.qvm_shutdown
2730

2831
parser = qubesadmin.tools.QubesArgumentParser(
2932
description='immediately terminate a qube without a graceful shutdown'
3033
' sequence',
3134
vmname_nargs='+')
3235

33-
def main(args=None, app=None):
34-
'''Main routine of :program:`qvm-kill`.
35-
36-
:param list args: Optional arguments to override those delivered from \
37-
command line.
38-
'''
3936

37+
async def run_async(args=None, app=None):
38+
# pylint: disable=missing-docstring
4039
args = parser.parse_args(args, app=app)
40+
remnants = await qubesadmin.tools.qvm_shutdown.kill(domains=args.domains)
41+
if not remnants:
42+
return 0
43+
parser.error_runtime(
44+
"Failed to kill: {}".format(
45+
", ".join(qube.name for qube in remnants)
46+
),
47+
len(remnants)
48+
)
49+
4150

42-
exit_code = 0
43-
for domain in args.domains:
44-
try:
45-
domain.kill()
46-
except qubesadmin.exc.QubesVMNotStartedError:
47-
pass
48-
except (IOError, OSError, qubesadmin.exc.QubesException) as e:
49-
exit_code = 1
50-
parser.print_error("Failed to kill '{}': {}".format(
51-
domain.name, e))
52-
53-
return exit_code
51+
async def main_async(args=None, app=None):
52+
# pylint: disable=missing-docstring
53+
return await run_async(args=args, app=app)
54+
55+
56+
def main(args=None, app=None):
57+
# pylint: disable=missing-docstring
58+
return asyncio.run(run_async(args=args, app=app))
5459

5560

5661
if __name__ == '__main__':

qubesadmin/tools/qvm_shutdown.py

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,7 @@
6969
)
7070

7171

72-
def shutdown(
73-
args,
74-
loop: asyncio.AbstractEventLoop,
75-
domains: list[qubesadmin.vm.QubesVM],
76-
force: bool,
77-
):
72+
async def shutdown(args, domains: list[qubesadmin.vm.QubesVM]):
7873
"""
7974
Asynchronously shutdown qubes and return qubes that failed to shutdown
8075
because and the client can't handle, as well as qubes that were in use
@@ -84,14 +79,12 @@ def shutdown(
8479
unhandled, used, timedout = [], [], []
8580
tasks = [
8681
asyncio.wait_for(
87-
asyncio.to_thread(qube.shutdown, force=force, wait=args.wait),
82+
asyncio.to_thread(qube.shutdown, force=args.force, wait=args.wait),
8883
timeout=args.timeout,
8984
)
9085
for qube in domains
9186
]
92-
results = loop.run_until_complete(
93-
asyncio.gather(*tasks, return_exceptions=True)
94-
)
87+
results = await asyncio.gather(*tasks, return_exceptions=True)
9588
for qube, res in zip(domains, results):
9689
if not isinstance(res, BaseException):
9790
qube.log.info("Shutdown succeeded")
@@ -124,16 +117,14 @@ def shutdown(
124117
return unhandled, used, timedout
125118

126119

127-
def kill(loop: asyncio.AbstractEventLoop, domains: list[qubesadmin.vm.QubesVM]):
120+
async def kill(domains: list[qubesadmin.vm.QubesVM]):
128121
"""
129122
Asynchronously kill qubes and return qubes that failed to shutdown.
130123
"""
131124
# pylint: disable=missing-docstring
132125
unhandled = domains.copy()
133126
tasks = [asyncio.to_thread(qube.kill) for qube in domains]
134-
results = loop.run_until_complete(
135-
asyncio.gather(*tasks, return_exceptions=True)
136-
)
127+
results = await asyncio.gather(*tasks, return_exceptions=True)
137128
for qube, res in zip(domains, results):
138129
if not isinstance(res, BaseException):
139130
qube.log.info("Killing succeeded")
@@ -148,18 +139,14 @@ def kill(loop: asyncio.AbstractEventLoop, domains: list[qubesadmin.vm.QubesVM]):
148139
return unhandled
149140

150141

151-
def main(args=None, app=None):
142+
async def run_async(args=None, app=None):
152143
# pylint: disable=missing-docstring
153144
args = parser.parse_args(args, app=app)
154-
force = args.force or (args.all_domains and not args.exclude)
155145
if args.dry_run:
156146
return
147+
args.force = args.force or (args.all_domains and not args.exclude)
157148

158-
loop = asyncio.new_event_loop()
159-
asyncio.set_event_loop(loop)
160-
unhandled, used, timedout = shutdown(
161-
args=args, force=force, loop=loop, domains=args.domains
162-
)
149+
unhandled, used, timedout = await shutdown(args=args, domains=args.domains)
163150
unhandled_retry = []
164151
timedout_retry = []
165152
if used:
@@ -174,8 +161,8 @@ def main(args=None, app=None):
174161
", ".join(qube.name for qube in used)
175162
)
176163
)
177-
unhandled_retry, used, timedout_retry = shutdown(
178-
args=args, force=force, loop=loop, domains=used
164+
unhandled_retry, used, timedout_retry = await shutdown(
165+
args=args, domains=used
179166
)
180167
unhandled.extend(qube for qube in unhandled_retry if qube not in unhandled)
181168
timedout.extend(qube for qube in timedout_retry if qube not in timedout)
@@ -187,17 +174,15 @@ def main(args=None, app=None):
187174
", ".join(qube.name for qube in timedout)
188175
)
189176
)
190-
unhandled, used, timedout = shutdown(
191-
args=args, force=force, loop=loop, domains=timedout
192-
)
177+
unhandled, used, timedout = await shutdown(args=args, domains=timedout)
193178

194179
if timedout:
195180
parser.print_error(
196181
"Killing timed out qubes: {}".format(
197182
", ".join(qube.name for qube in timedout)
198183
)
199184
)
200-
unhandled = kill(loop=loop, domains=timedout)
185+
unhandled = await kill(domains=timedout)
201186

202187
if not unhandled and not used and not timedout:
203188
return
@@ -218,5 +203,15 @@ def main(args=None, app=None):
218203
parser.error_runtime(msg, len(unhandled + used + timedout))
219204

220205

206+
async def main_async(args=None, app=None):
207+
# pylint: disable=missing-docstring
208+
await run_async(args=args, app=app)
209+
210+
211+
def main(args=None, app=None):
212+
# pylint: disable=missing-docstring
213+
asyncio.run(run_async(args=args, app=app))
214+
215+
221216
if __name__ == "__main__":
222217
sys.exit(main())

0 commit comments

Comments
 (0)