Skip to content

Commit 33442ab

Browse files
committed
Merge remote-tracking branch 'origin/pr/448'
* origin/pr/448: add utests remove dead code around args.wait Harmonize behavior between has/hasnt_event, and remove deprecated hasnt_event branch Pull request description: *Mostly cleanup, with subtle inner API call changes that shouldn't affect users* When `have_events` is `False`, after reaching timeout, the previous code calls `Kill` on the filtered `current_vms = failed_domains(current_vms)current_vms = failed_domains(current_vms)`, whereas if it is `True` it calls `Kill` on all `this_round_domains`. The commit harmonises this behavior to always filter. This slightly changes the API calls, as seen in the modified utests. The current code does ```python3 for vm in this_round_domains: try: vm.shutdown(force=force) except: if not args.wait: vm.log.error(...) # THIS EXITS else: remaining_domains.add(vm) ``` From this point onward, if `not args.wait`, either we exited via `error`, or `remaining_domains` is empty (and then we `return` immediately after). This makes most following code depending on `args.wait` obsolete. EDIT: vm.log.error is not fatal, but a later section under `if not args.wait` terminates the script anyway. *Note: this particular commit is mostly GenAI. Tests were reviewed manually.*
2 parents 386de45 + 17c9380 commit 33442ab

2 files changed

Lines changed: 189 additions & 70 deletions

File tree

qubesadmin/tests/tools/qvm_shutdown.py

Lines changed: 159 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,6 @@ def test_004_multiple_vms(self):
8383
app=self.app)
8484
self.assertAllCalled()
8585

86-
@unittest.skipUnless(qubesadmin.tools.qvm_shutdown.have_events,
87-
'Events not present')
8886
def test_010_wait(self):
8987
'''test --wait option'''
9088
loop = asyncio.new_event_loop()
@@ -114,8 +112,6 @@ def test_010_wait(self):
114112
qubesadmin.tools.qvm_shutdown.main(['--wait', 'some-vm'], app=self.app)
115113
self.assertAllCalled()
116114

117-
@unittest.skipUnless(qubesadmin.tools.qvm_shutdown.have_events,
118-
'Events not present')
119115
def test_012_wait_all(self):
120116
'''test --wait option, with multiple VMs'''
121117
loop = asyncio.new_event_loop()
@@ -161,8 +157,6 @@ def test_012_wait_all(self):
161157
qubesadmin.tools.qvm_shutdown.main(['--wait', '--all'], app=self.app)
162158
self.assertAllCalled()
163159

164-
@unittest.skipUnless(qubesadmin.tools.qvm_shutdown.have_events,
165-
'Events not present')
166160
def test_015_wait_all_kill_timeout(self):
167161
'''test --wait option, with multiple VMs and killing on timeout'''
168162
loop = asyncio.new_event_loop()
@@ -194,9 +188,6 @@ def test_015_wait_all_kill_timeout(self):
194188
self.app.expected_calls[
195189
('sys-net', 'admin.vm.Shutdown', 'force', None)] = \
196190
b'0\x00'
197-
self.app.expected_calls[
198-
('sys-net', 'admin.vm.Kill', None, None)] = \
199-
b'2\x00QubesVMNotStartedError\x00\x00Domain is powered off\x00'
200191
self.app.expected_calls[
201192
('dom0', 'admin.vm.List', None, None)] = \
202193
b'0\x00' \
@@ -207,15 +198,20 @@ def test_015_wait_all_kill_timeout(self):
207198
('some-vm', 'admin.vm.CurrentState', None, None)] = [
208199
b'0\x00power_state=Running',
209200
b'0\x00power_state=Running',
201+
b'0\x00power_state=Running',
210202
]
211203
self.app.expected_calls[
212204
('other-vm', 'admin.vm.CurrentState', None, None)] = [
213205
b'0\x00power_state=Running',
214206
b'0\x00power_state=Running',
207+
b'0\x00power_state=Running',
215208
]
216209
self.app.expected_calls[
217-
('sys-net', 'admin.vm.CurrentState', None, None)] = \
218-
b'0\x00power_state=Halted'
210+
('sys-net', 'admin.vm.CurrentState', None, None)] = [
211+
b'0\x00power_state=Halted',
212+
b'0\x00power_state=Halted',
213+
b'0\x00power_state=Halted',
214+
]
219215
with self.assertRaisesRegex(SystemExit, '2'):
220216
qubesadmin.tools.qvm_shutdown.main(
221217
['--wait', '--all', '--timeout=1'], app=self.app)
@@ -249,3 +245,155 @@ def test_017_all_exclude_force_explicit(self):
249245
'--force'],
250246
app=self.app)
251247
self.assertAllCalled()
248+
249+
def test_005_force(self):
250+
'''test --force sends force flag to shutdown call'''
251+
self.app.expected_calls[
252+
('dom0', 'admin.vm.List', None, None)] = \
253+
b'0\x00some-vm class=AppVM state=Running\n'
254+
self.app.expected_calls[
255+
('some-vm', 'admin.vm.Shutdown', 'force', None)] = b'0\x00'
256+
qubesadmin.tools.qvm_shutdown.main(
257+
['--force', 'some-vm'], app=self.app)
258+
self.assertAllCalled()
259+
260+
def test_006_dry_run(self):
261+
'''test --dry-run skips shutdown calls'''
262+
self.app.expected_calls[
263+
('dom0', 'admin.vm.List', None, None)] = \
264+
b'0\x00some-vm class=AppVM state=Running\n'
265+
qubesadmin.tools.qvm_shutdown.main(
266+
['--dry-run', 'some-vm'], app=self.app)
267+
self.assertAllCalled()
268+
269+
def test_011_wait_retry(self):
270+
'''test --wait retries VMs whose shutdown request failed'''
271+
loop = asyncio.new_event_loop()
272+
asyncio.set_event_loop(loop)
273+
274+
mock_events = unittest.mock.AsyncMock()
275+
patch = unittest.mock.patch(
276+
'qubesadmin.events.EventsDispatcher._get_events_reader',
277+
mock_events)
278+
patch.start()
279+
self.addCleanup(patch.stop)
280+
mock_events.side_effect = qubesadmin.tests.tools.MockEventsReader([
281+
# round 1: wait for some-vm
282+
b'1\0\0connection-established\0\0',
283+
b'1\0some-vm\0domain-shutdown\0\0',
284+
# round 2: wait for other-vm
285+
b'1\0\0connection-established\0\0',
286+
b'1\0other-vm\0domain-shutdown\0\0',
287+
])
288+
289+
self.app.expected_calls[
290+
('dom0', 'admin.vm.List', None, None)] = \
291+
b'0\x00' \
292+
b'some-vm class=AppVM state=Running\n' \
293+
b'other-vm class=AppVM state=Running\n'
294+
self.app.expected_calls[
295+
('some-vm', 'admin.vm.Shutdown', None, None)] = \
296+
b'0\x00'
297+
# other-vm fails first attempt, succeeds on retry
298+
self.app.expected_calls[
299+
('other-vm', 'admin.vm.Shutdown', None, None)] = [
300+
b'2\x00QubesException\x00\x00Shutdown refused\x00',
301+
b'0\x00',
302+
]
303+
self.app.expected_calls[
304+
('some-vm', 'admin.vm.CurrentState', None, None)] = [
305+
b'0\x00power_state=Running',
306+
b'0\x00power_state=Halted',
307+
]
308+
self.app.expected_calls[
309+
('other-vm', 'admin.vm.CurrentState', None, None)] = [
310+
b'0\x00power_state=Running',
311+
b'0\x00power_state=Halted',
312+
]
313+
qubesadmin.tools.qvm_shutdown.main(
314+
['--wait', 'some-vm', 'other-vm'], app=self.app)
315+
self.assertAllCalled()
316+
317+
def test_013_wait_all_shutdown_fail(self):
318+
'''test --wait exits with error when all shutdown requests fail'''
319+
self.app.expected_calls[
320+
('dom0', 'admin.vm.List', None, None)] = \
321+
b'0\x00some-vm class=AppVM state=Running\n'
322+
self.app.expected_calls[
323+
('some-vm', 'admin.vm.Shutdown', None, None)] = \
324+
b'2\x00QubesException\x00\x00Shutdown refused\x00'
325+
self.app.expected_calls[
326+
('some-vm', 'admin.vm.CurrentState', None, None)] = \
327+
b'0\x00power_state=Running'
328+
with self.assertRaises(SystemExit):
329+
qubesadmin.tools.qvm_shutdown.main(
330+
['--wait', 'some-vm'], app=self.app)
331+
self.assertAllCalled()
332+
333+
def test_016_wait_kill_exception(self):
334+
'''test --wait timeout where kill raises QubesException'''
335+
loop = asyncio.new_event_loop()
336+
asyncio.set_event_loop(loop)
337+
338+
mock_events = unittest.mock.AsyncMock()
339+
patch = unittest.mock.patch(
340+
'qubesadmin.events.EventsDispatcher._get_events_reader',
341+
mock_events)
342+
patch.start()
343+
self.addCleanup(patch.stop)
344+
mock_events.side_effect = qubesadmin.tests.tools.MockEventsReader([
345+
b'1\0\0connection-established\0\0',
346+
])
347+
348+
self.app.expected_calls[
349+
('dom0', 'admin.vm.List', None, None)] = \
350+
b'0\x00some-vm class=AppVM state=Running\n'
351+
self.app.expected_calls[
352+
('some-vm', 'admin.vm.Shutdown', None, None)] = \
353+
b'0\x00'
354+
self.app.expected_calls[
355+
('some-vm', 'admin.vm.Kill', None, None)] = \
356+
b'2\x00QubesException\x00\x00Kill failed\x00'
357+
self.app.expected_calls[
358+
('some-vm', 'admin.vm.CurrentState', None, None)] = [
359+
b'0\x00power_state=Running',
360+
b'0\x00power_state=Running',
361+
]
362+
with self.assertRaises(SystemExit):
363+
qubesadmin.tools.qvm_shutdown.main(
364+
['--wait', '--timeout=1', 'some-vm'], app=self.app)
365+
self.assertAllCalled()
366+
367+
def test_017_wait_dispvm_na(self):
368+
'''test --wait treats DispVM with NA power state as shut down'''
369+
loop = asyncio.new_event_loop()
370+
asyncio.set_event_loop(loop)
371+
372+
mock_events = unittest.mock.AsyncMock()
373+
patch = unittest.mock.patch(
374+
'qubesadmin.events.EventsDispatcher._get_events_reader',
375+
mock_events)
376+
patch.start()
377+
self.addCleanup(patch.stop)
378+
mock_events.side_effect = qubesadmin.tests.tools.MockEventsReader([
379+
b'1\0\0connection-established\0\0',
380+
b'1\0disp123\0domain-shutdown\0\0',
381+
])
382+
383+
self.app.expected_calls[
384+
('dom0', 'admin.vm.List', None, None)] = \
385+
b'0\x00disp123 class=DispVM state=Running\n'
386+
self.app.expected_calls[
387+
('disp123', 'admin.vm.Shutdown', None, None)] = \
388+
b'0\x00'
389+
self.app.expected_calls[
390+
('disp123', 'admin.vm.CurrentState', None, None)] = [
391+
b'0\x00power_state=Running',
392+
# failed_domains: first get_power_state() != 'Halted',
393+
# then klass == 'DispVM' triggers second get_power_state()
394+
b'0\x00power_state=NA',
395+
b'0\x00power_state=NA',
396+
]
397+
qubesadmin.tools.qvm_shutdown.main(
398+
['--wait', 'disp123'], app=self.app)
399+
self.assertAllCalled()

qubesadmin/tools/qvm_shutdown.py

Lines changed: 30 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,10 @@
2525
from __future__ import print_function
2626

2727
import sys
28-
import time
2928

3029
import asyncio
3130

32-
try:
33-
import qubesadmin.events.utils
34-
have_events = True
35-
except ImportError:
36-
have_events = False
31+
import qubesadmin.events.utils
3732
import qubesadmin.tools
3833
import qubesadmin.exc
3934

@@ -74,66 +69,44 @@ def main(args=None, app=None): # pylint: disable=missing-docstring
7469

7570
force = args.force or (args.all_domains and not args.exclude)
7671

77-
if have_events:
78-
loop = asyncio.new_event_loop()
79-
asyncio.set_event_loop(loop)
80-
remaining_domains = args.domains
72+
loop = asyncio.new_event_loop()
73+
asyncio.set_event_loop(loop)
74+
remaining_domains = set(args.domains)
8175
for _ in range(len(args.domains)):
82-
this_round_domains = set(remaining_domains)
83-
if not this_round_domains:
76+
if not remaining_domains:
8477
break
85-
remaining_domains = set()
78+
shutdown_failed = set()
8679
if not args.dry_run:
87-
for vm in this_round_domains:
80+
for vm in remaining_domains:
8881
try:
8982
vm.shutdown(force=force)
9083
except qubesadmin.exc.QubesVMNotStartedError:
9184
pass
9285
except qubesadmin.exc.QubesException as e:
9386
if not args.wait:
9487
vm.log.error('Shutdown error: {}'.format(e))
95-
else:
96-
remaining_domains.add(vm)
88+
shutdown_failed.add(vm)
9789
if not args.wait:
98-
if remaining_domains:
90+
if shutdown_failed:
9991
parser.error_runtime(
10092
'Failed to shut down: ' +
101-
', '.join(vm.name for vm in remaining_domains),
102-
len(remaining_domains))
93+
', '.join(vm.name for vm in shutdown_failed),
94+
len(shutdown_failed))
10395
return
104-
this_round_domains.difference_update(remaining_domains)
105-
if not this_round_domains:
106-
# no VM shutdown request succeed, no sense to try again
96+
awaiting = remaining_domains - shutdown_failed
97+
remaining_domains = shutdown_failed
98+
if not awaiting:
99+
# no VM shutdown request succeeded, no sense to try again
107100
break
108-
if have_events:
109-
try:
110-
# pylint: disable=no-member
111-
loop.run_until_complete(asyncio.wait_for(
112-
qubesadmin.events.utils.wait_for_domain_shutdown(
113-
this_round_domains),
114-
args.timeout))
115-
except asyncio.TimeoutError:
116-
if not args.dry_run:
117-
for vm in this_round_domains:
118-
try:
119-
vm.kill()
120-
except qubesadmin.exc.QubesVMNotStartedError:
121-
# already shut down
122-
pass
123-
except qubesadmin.exc.QubesException as e:
124-
parser.error_runtime(e)
125-
else:
126-
timeout = args.timeout
127-
current_vms = list(sorted(this_round_domains))
128-
while timeout >= 0:
129-
current_vms = failed_domains(current_vms)
130-
if not current_vms:
131-
break
132-
args.app.log.info('Waiting for shutdown ({}): {}'.format(
133-
timeout, ', '.join([str(vm) for vm in current_vms])))
134-
time.sleep(1)
135-
timeout -= 1
101+
102+
try:
103+
# pylint: disable=no-member
104+
loop.run_until_complete(asyncio.wait_for(
105+
qubesadmin.events.utils.wait_for_domain_shutdown(
106+
awaiting), args.timeout))
107+
except (TimeoutError, asyncio.TimeoutError):
136108
if not args.dry_run:
109+
current_vms = failed_domains(awaiting)
137110
if current_vms:
138111
args.app.log.info(
139112
'Killing remaining qubes: {}'
@@ -147,15 +120,13 @@ def main(args=None, app=None): # pylint: disable=missing-docstring
147120
except qubesadmin.exc.QubesException as e:
148121
parser.error_runtime(e)
149122

150-
if args.wait:
151-
if have_events:
152-
loop.close()
153-
failed = failed_domains(args.domains)
154-
if failed:
155-
parser.error_runtime(
156-
'Failed to shut down: ' +
157-
', '.join(vm.name for vm in failed),
158-
len(failed))
123+
loop.close()
124+
failed = failed_domains(args.domains)
125+
if failed:
126+
parser.error_runtime(
127+
'Failed to shut down: ' +
128+
', '.join(vm.name for vm in failed),
129+
len(failed))
159130

160131

161132
if __name__ == '__main__':

0 commit comments

Comments
 (0)