Skip to content

Commit 32a14bb

Browse files
committed
Merge remote-tracking branch 'origin/pr/208'
* origin/pr/208: vmupdate: wait for full VM shutdown before next queued update vmupdate: extract reusable utilities to vmupdate/utils.py Pull request description: Fix update sequencing for VMs started by `qubes-vm-update`: wait for full shutdown in `QubeConnection.__exit__()` before returning. This prevents the next queued VM from being started while the previous one is still in the shutdown phase and still holding shared PCI devices. Closes QubesOS/qubes-issues#10617. - `vmupdate/qube_connection.py` - After calling `self.qube.shutdown()` for VMs started by updater, call `wait_for_domain_shutdown([self.qube])`. With `--max-concurrency=1`, the updater previously issued shutdown and immediately returned, allowing the next VM to start too early. For HVMs sharing PCI devices, this causes startup conflicts. - Added: `vmupdate/tests/test_qube_connection.py` - `test_wait_for_shutdown_when_vm_started_by_update` - `test_do_not_shutdown_if_vm_was_already_running`
2 parents 576fa4f + 060186e commit 32a14bb

4 files changed

Lines changed: 186 additions & 64 deletions

File tree

vmupdate/qube_connection.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@
2929
from typing import List
3030

3131
import qubesadmin
32+
import qubesadmin.exc
3233
from vmupdate.agent.source.args import AgentArgs
3334
from vmupdate.agent.source.log_config import LOGPATH, LOG_FILE
3435
from vmupdate.agent.source.status import StatusInfo, FinalStatus, FormatedLine
3536
from vmupdate.agent.source.common.process_result import ProcessResult
37+
from vmupdate.utils import shutdown_domains
3638

3739

3840
class QubeConnection:
@@ -91,11 +93,25 @@ def __exit__(self, exc_type, exc_val, exc_tb):
9193
)
9294

9395
if self.qube.is_running() and not self._initially_running:
94-
self.logger.info("Shutdown %s", self.qube.name)
95-
self.qube.shutdown()
96+
if self._has_assigned_pci_devices(self.qube):
97+
self.logger.info(
98+
'Waiting for full shutdown %s (PCI devices assigned)',
99+
self.qube.name)
100+
shutdown_domains([self.qube], self.logger)
101+
else:
102+
self.logger.info('Shutdown %s', self.qube.name)
103+
self.qube.shutdown()
96104

97105
self.__connected = False
98106

107+
@staticmethod
108+
def _has_assigned_pci_devices(vm) -> bool:
109+
"""Return True when VM has assigned PCI devices."""
110+
try:
111+
return any(vm.devices['pci'].get_assigned_devices())
112+
except qubesadmin.exc.QubesDaemonAccessError:
113+
return False
114+
99115
def transfer_agent(self, src_dir: str) -> ProcessResult:
100116
"""
101117
Copy a directory content to the workdir in the qube.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# coding=utf-8
2+
#
3+
# The Qubes OS Project, https://www.qubes-os.org
4+
#
5+
# Copyright (C) 2025 Jayant Saxena <jayantmcom@gmail.com>
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License
9+
# as published by the Free Software Foundation; either version 2
10+
# of the License, or (at your option) any later version.
11+
#
12+
# This program is distributed in the hope that it will be useful,
13+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
# GNU General Public License for more details.
16+
#
17+
# You should have received a copy of the GNU General Public License
18+
# along with this program; if not, write to the Free Software
19+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
20+
# USA.
21+
from unittest.mock import Mock, patch
22+
23+
from vmupdate.qube_connection import QubeConnection
24+
25+
26+
@patch("vmupdate.qube_connection.shutdown_domains")
27+
def test_wait_for_shutdown_when_vm_started_by_update(shutdown_domains):
28+
vm = Mock()
29+
vm.name = "hvm1"
30+
vm.is_running.side_effect = [False, True]
31+
vm.devices = {'pci': Mock()}
32+
vm.devices['pci'].get_assigned_devices.return_value = ["00_1f.2"]
33+
status_notifier = Mock()
34+
logger = Mock()
35+
36+
with QubeConnection(
37+
vm, "/tmp/qubes-update", cleanup=False, logger=logger,
38+
show_progress=False, status_notifier=status_notifier):
39+
pass
40+
41+
shutdown_domains.assert_called_once_with([vm], logger)
42+
vm.shutdown.assert_not_called()
43+
44+
45+
@patch("vmupdate.qube_connection.shutdown_domains")
46+
def test_do_not_wait_for_shutdown_without_assigned_pci(shutdown_domains):
47+
vm = Mock()
48+
vm.name = "hvm2"
49+
vm.is_running.side_effect = [False, True]
50+
vm.devices = {'pci': Mock()}
51+
vm.devices['pci'].get_assigned_devices.return_value = []
52+
status_notifier = Mock()
53+
logger = Mock()
54+
55+
with QubeConnection(
56+
vm, "/tmp/qubes-update", cleanup=False, logger=logger,
57+
show_progress=False, status_notifier=status_notifier):
58+
pass
59+
60+
vm.shutdown.assert_called_once_with()
61+
shutdown_domains.assert_not_called()
62+
63+
64+
@patch("vmupdate.qube_connection.shutdown_domains")
65+
def test_do_not_shutdown_if_vm_was_already_running(shutdown_domains):
66+
vm = Mock()
67+
vm.name = "hvm3"
68+
vm.is_running.return_value = True
69+
vm.devices = {'pci': Mock()}
70+
vm.devices['pci'].get_assigned_devices.return_value = ["00_1f.2"]
71+
status_notifier = Mock()
72+
logger = Mock()
73+
74+
with QubeConnection(
75+
vm, "/tmp/qubes-update", cleanup=False, logger=logger,
76+
show_progress=False, status_notifier=status_notifier):
77+
pass
78+
79+
vm.shutdown.assert_not_called()
80+
shutdown_domains.assert_not_called()

vmupdate/utils.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# coding=utf-8
2+
#
3+
# The Qubes OS Project, http://www.qubes-os.org
4+
#
5+
# Copyright (C) 2022 Piotr Bartman <prbartman@invisiblethingslab.com>
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License
9+
# as published by the Free Software Foundation; either version 2
10+
# of the License, or (at your option) any later version.
11+
#
12+
# This program is distributed in the hope that it will be useful,
13+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
# GNU General Public License for more details.
16+
#
17+
# You should have received a copy of the GNU General Public License
18+
# along with this program; if not, write to the Free Software
19+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
20+
# USA.
21+
import asyncio
22+
from datetime import datetime
23+
24+
import qubesadmin.exc
25+
from qubesadmin.events.utils import wait_for_domain_shutdown
26+
from vmupdate.agent.source.common.exit_codes import EXIT
27+
28+
29+
def shutdown_domains(to_shutdown, log):
30+
"""
31+
Try to shut down vms and wait to finish.
32+
"""
33+
ret_code = EXIT.OK
34+
wait_for = []
35+
for vm in to_shutdown:
36+
try:
37+
vm.shutdown(force=True)
38+
wait_for.append(vm)
39+
except qubesadmin.exc.QubesVMError as exc:
40+
log.error(str(exc))
41+
ret_code = EXIT.ERR_SHUTDOWN_APP
42+
43+
asyncio.run(wait_for_domain_shutdown(wait_for))
44+
45+
return ret_code, wait_for
46+
47+
48+
def get_feature(vm, feature_name, default_value=None):
49+
"""Get feature, with a working default_value."""
50+
try:
51+
return vm.features.get(feature_name, default_value)
52+
except qubesadmin.exc.QubesDaemonAccessError:
53+
return default_value
54+
55+
56+
def get_boolean_feature(vm, feature_name, default=False):
57+
"""Helper function to get a feature converted to bool if it exists.
58+
59+
Necessary because true/false in features are coded as 1/empty string.
60+
"""
61+
result = get_feature(vm, feature_name, None)
62+
if result is not None:
63+
result = bool(result)
64+
else:
65+
result = default
66+
return result
67+
68+
69+
def is_stale(vm, expiration_period):
70+
"""Return True if VM has not been checked for updates recently."""
71+
today = datetime.today()
72+
try:
73+
if not ('qrexec' in vm.features.keys()
74+
and vm.features.get('os', '') == 'Linux'):
75+
return False
76+
77+
last_update_str = vm.features.check_with_template(
78+
'last-updates-check',
79+
datetime.fromtimestamp(0).strftime('%Y-%m-%d %H:%M:%S')
80+
)
81+
last_update = datetime.fromisoformat(last_update_str)
82+
if (today - last_update).days > expiration_period:
83+
return True
84+
except qubesadmin.exc.QubesDaemonCommunicationError:
85+
pass
86+
return False

vmupdate/vmupdate.py

Lines changed: 2 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,18 @@
44
"""
55

66
import argparse
7-
import asyncio
87
import logging
98
import sys
109
import os
1110
import grp
12-
from datetime import datetime
1311
from typing import Set, Iterable, Dict, Tuple
1412

1513
import qubesadmin
1614
import qubesadmin.exc
17-
from qubesadmin.events.utils import wait_for_domain_shutdown
1815
from vmupdate.agent.source.status import FinalStatus
1916
from vmupdate.agent.source.common.exit_codes import EXIT
17+
from vmupdate.utils import shutdown_domains, get_feature, get_boolean_feature, \
18+
is_stale
2019
from . import update_manager
2120
from .agent.source.args import AgentArgs
2221

@@ -355,27 +354,6 @@ def select_targets(targets, args) -> Set[qubesadmin.vm.QubesVM]:
355354
return selected
356355

357356

358-
def is_stale(vm, expiration_period):
359-
today = datetime.today()
360-
try:
361-
if not (
362-
"qrexec" in vm.features.keys()
363-
and vm.features.get("os", "") == "Linux"
364-
):
365-
return False
366-
367-
last_update_str = vm.features.check_with_template(
368-
"last-updates-check",
369-
datetime.fromtimestamp(0).strftime("%Y-%m-%d %H:%M:%S"),
370-
)
371-
last_update = datetime.fromisoformat(last_update_str)
372-
if (today - last_update).days > expiration_period:
373-
return True
374-
except qubesadmin.exc.QubesDaemonCommunicationError:
375-
pass
376-
return False
377-
378-
379357
def run_update(
380358
targets, args, log, qube_klass="qubes", dom0=False
381359
) -> Tuple[int, Dict[str, FinalStatus]]:
@@ -408,26 +386,6 @@ def run_update(
408386
return ret_code, statuses
409387

410388

411-
def get_feature(vm, feature_name, default_value=None):
412-
"""Get feature, with a working default_value."""
413-
try:
414-
return vm.features.get(feature_name, default_value)
415-
except qubesadmin.exc.QubesDaemonAccessError:
416-
return default_value
417-
418-
419-
def get_boolean_feature(vm, feature_name, default=False):
420-
"""helper function to get a feature converted to a Bool if it does exist.
421-
Necessary because of the true/false in features being coded as 1/empty
422-
string."""
423-
result = get_feature(vm, feature_name, None)
424-
if result is not None:
425-
result = bool(result)
426-
else:
427-
result = default
428-
return result
429-
430-
431389
def apply_updates_to_appvm(
432390
args,
433391
vm_updated: Iterable,
@@ -528,24 +486,6 @@ def get_derived_vm_to_apply(templates, derived_statuses):
528486
return to_restart, to_shutdown
529487

530488

531-
def shutdown_domains(to_shutdown, log):
532-
"""
533-
Try to shut down vms and wait to finish.
534-
"""
535-
ret_code = EXIT.OK
536-
wait_for = []
537-
for vm in to_shutdown:
538-
try:
539-
vm.shutdown(force=True)
540-
wait_for.append(vm)
541-
except qubesadmin.exc.QubesVMError as exc:
542-
log.error(str(exc))
543-
ret_code = EXIT.ERR_SHUTDOWN_APP
544-
545-
asyncio.run(wait_for_domain_shutdown(wait_for))
546-
547-
return ret_code, wait_for
548-
549489

550490
def restart_vms(to_restart, log):
551491
"""

0 commit comments

Comments
 (0)