From 1faf4f46b0206703984d256f47ee462869485a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marta=20Marczykowska-G=C3=B3recka?= Date: Tue, 2 Jun 2020 19:34:41 +0200 Subject: [PATCH 1/4] Added a Clone VM tool A simple GUI tool to enable cloning a VM to a different pool and changing the label. references QubesOS/qubes-issues#5127 --- qubesmanager/clone_vm.py | 189 ++++++++++++++++++++++++++++ qubesmanager/common_threads.py | 9 +- rpm_spec/qmgr.spec.in | 3 + setup.py | 1 + ui/clonevmdlg.ui | 220 +++++++++++++++++++++++++++++++++ 5 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 qubesmanager/clone_vm.py create mode 100644 ui/clonevmdlg.ui diff --git a/qubesmanager/clone_vm.py b/qubesmanager/clone_vm.py new file mode 100644 index 0000000..f7bcdb4 --- /dev/null +++ b/qubesmanager/clone_vm.py @@ -0,0 +1,189 @@ +#!/usr/bin/python3 +# +# The Qubes OS Project, http://www.qubes-os.org +# +# Copyright (C) 2020 Marta Marczykowska-Górecka +# +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program; if not, see . +# +# + +import os +import sys +import subprocess + +from PyQt5 import QtCore, QtWidgets, QtGui # pylint: disable=import-error + +import qubesadmin +import qubesadmin.tools +import qubesadmin.exc + +from . import common_threads +from . import utils + +from .ui_clonevmdlg import Ui_CloneVMDlg # pylint: disable=import-error + + +class CloneVMDlg(QtWidgets.QDialog, Ui_CloneVMDlg): + def __init__(self, qtapp, app, parent=None, src_vm=None): + super(CloneVMDlg, self).__init__(parent) + self.setupUi(self) + + self.qtapp = qtapp + self.app = app + + self.thread = None + self.progress = None + + self.vm_list, self.vm_idx = utils.prepare_vm_choice( + self.src_vm, + self.app, None, + None, + (lambda vm: vm.klass != 'AdminVM'), + allow_internal=False + ) + + if src_vm and self.src_vm.findText(src_vm.name) > -1: + self.src_vm.setCurrentIndex(self.src_vm.findText(src_vm.name)) + + self.label_list, self.label_idx = utils.prepare_label_choice( + self.label, + self.app, None, + None, + allow_default=False + ) + + self.update_label() + + self.pool_list, self.pool_idx = utils.prepare_choice( + widget=self.storage_pool, + holder=None, + propname=None, + choice=self.app.pools.values(), + default=self.app.default_pool, + allow_default=True, + allow_none=False + ) + + self.set_clone_name() + + self.name.setValidator(QtGui.QRegExpValidator( + QtCore.QRegExp("[a-zA-Z0-9_-]*", QtCore.Qt.CaseInsensitive), None)) + self.name.selectAll() + self.name.setFocus() + + if src_vm: + self.src_vm.setEnabled(False) + else: + self.src_vm.currentIndexChanged.connect(self.set_clone_name) + self.src_vm.currentIndexChanged.connect(self.update_label) + + def reject(self): + self.done(0) + + def accept(self): + name = self.name.text() + + if name in self.app.domains: + QtWidgets.QMessageBox.warning( + self, + self.tr('Incorrect qube name!'), + self.tr('A qube with the name {} already exists in the ' + 'system!').format(self.name.text())) + return + + label = self.label_list[self.label.currentIndex()] + + if self.pool_list[self.storage_pool.currentIndex()] is not \ + qubesadmin.DEFAULT: + pool = self.pool_list[self.storage_pool.currentIndex()] + else: + pool = None + + src_vm = self.vm_list[self.src_vm.currentIndex()] + + self.thread = common_threads.CloneVMThread( + src_vm, name, pool=pool, label=label) + self.thread.finished.connect(self.clone_finished) + self.thread.start() + + self.progress = QtWidgets.QProgressDialog( + self.tr("Cloning qube {0}...").format(name), "", 0, 0) + self.progress.setCancelButton(None) + self.progress.setModal(True) + self.progress.show() + + def set_clone_name(self): + vm_name = self.src_vm.currentText() + name_number = 1 + name_format = vm_name + '-clone-%d' + while name_format % name_number in self.app.domains.keys(): + name_number += 1 + self.name.setText(name_format % name_number) + + def update_label(self): + vm_label = self.vm_list[self.src_vm.currentIndex()].label + + label_idx = self.label.findText(str(vm_label)) + + if label_idx > -1: + self.label.setCurrentIndex(label_idx) + + + def clone_finished(self): + self.progress.hide() + + if not self.thread.msg_is_success: + QtWidgets.QMessageBox.warning( + self, + self.tr("Error cloning the qube!"), + self.tr("ERROR: {0}").format(self.thread.msg)) + + self.done(0) + + if self.thread.msg_is_success: + if self.launch_settings.isChecked(): + subprocess.check_call(['qubes-vm-settings', + str(self.name.text())]) + + +parser = qubesadmin.tools.QubesArgumentParser(vmname_nargs='?') + + +def main(args=None): + args = parser.parse_args(args) + if args.domains: + src_vm = args.domains.pop() + else: + src_vm = None + + qtapp = QtWidgets.QApplication(sys.argv) + + translator = QtCore.QTranslator(qtapp) + locale = QtCore.QLocale.system().name() + i18n_dir = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + 'i18n') + translator.load("qubesmanager_{!s}.qm".format(locale), i18n_dir) + qtapp.installTranslator(translator) + QtCore.QCoreApplication.installTranslator(translator) + + qtapp.setOrganizationName('Invisible Things Lab') + qtapp.setOrganizationDomain('https://www.qubes-os.org/') + qtapp.setApplicationName(QtCore.QCoreApplication.translate( + "appname", 'Clone qube')) + + dialog = CloneVMDlg(qtapp, args.app, src_vm=src_vm) + dialog.exec_() diff --git a/qubesmanager/common_threads.py b/qubesmanager/common_threads.py index 19484fd..0957f3a 100644 --- a/qubesmanager/common_threads.py +++ b/qubesmanager/common_threads.py @@ -55,13 +55,18 @@ class RemoveVMThread(QubesThread): # pylint: disable=too-few-public-methods class CloneVMThread(QubesThread): - def __init__(self, vm, dst_name): + def __init__(self, vm, dst_name, pool=None, label=None): super(CloneVMThread, self).__init__(vm) self.dst_name = dst_name + self.pool = pool + self.label = label def run(self): try: - self.vm.app.clone_vm(self.vm, self.dst_name) + self.vm.app.clone_vm(self.vm, self.dst_name, pool=self.pool) + if self.label: + result_vm = self.vm.app.domains[self.dst_name] + result_vm.label = self.label self.msg = (self.tr("Success"), self.tr("The qube was cloned successfully.")) self.msg_is_success = True diff --git a/rpm_spec/qmgr.spec.in b/rpm_spec/qmgr.spec.in index 5611a7b..d5d1a39 100644 --- a/rpm_spec/qmgr.spec.in +++ b/rpm_spec/qmgr.spec.in @@ -57,6 +57,7 @@ rm -rf $RPM_BUILD_ROOT /usr/bin/qubes-global-settings /usr/bin/qubes-vm-settings /usr/bin/qubes-vm-create +/usr/bin/qubes-vm-clone /usr/bin/qubes-vm-boot-from-device /usr/bin/qubes-backup /usr/bin/qubes-backup-restore @@ -84,6 +85,7 @@ rm -rf $RPM_BUILD_ROOT %{python3_sitelib}/qubesmanager/releasenotes.py %{python3_sitelib}/qubesmanager/informationnotes.py %{python3_sitelib}/qubesmanager/create_new_vm.py +%{python3_sitelib}/qubesmanager/clone_vm.py %{python3_sitelib}/qubesmanager/common_threads.py %{python3_sitelib}/qubesmanager/qube_manager.py %{python3_sitelib}/qubesmanager/utils.py @@ -108,6 +110,7 @@ rm -rf $RPM_BUILD_ROOT %{python3_sitelib}/qubesmanager/ui_qubemanager.py %{python3_sitelib}/qubesmanager/ui_devicelist.py %{python3_sitelib}/qubesmanager/ui_templatemanager.py +%{python3_sitelib}/qubesmanager/ui_clonevmdlg.py %{python3_sitelib}/qubesmanager/i18n/qubesmanager_*.qm %{python3_sitelib}/qubesmanager/i18n/qubesmanager_*.ts diff --git a/setup.py b/setup.py index c6d3728..6226047 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,7 @@ if __name__ == '__main__': 'qubes-global-settings = qubesmanager.global_settings:main', 'qubes-vm-settings = qubesmanager.settings:main', 'qubes-vm-create = qubesmanager.create_new_vm:main', + 'qubes-vm-clone = qubesmanager.clone_vm:main', 'qubes-vm-boot-from-device = qubesmanager.bootfromdevice:main', 'qubes-backup = qubesmanager.backup:main', 'qubes-backup-restore = qubesmanager.restore:main', diff --git a/ui/clonevmdlg.ui b/ui/clonevmdlg.ui new file mode 100644 index 0000000..f72c586 --- /dev/null +++ b/ui/clonevmdlg.ui @@ -0,0 +1,220 @@ + + + CloneVMDlg + + + + 0 + 0 + 616 + 238 + + + + Clone qube + + + + .. + + + + + + + 0 + 0 + + + + 0 + + + + Basic + + + + 12 + + + 12 + + + 12 + + + 12 + + + 12 + + + 9 + + + + + Source qube: + + + + + + + true + + + + + + + my-cloned-qube + + + + + + + + + + Clone name: + + + + + + + launch settings after cloning + + + + + + + + Advanced + + + + 12 + + + 12 + + + 12 + + + 12 + + + 12 + + + 9 + + + + + + + + + + + + + 0 + 0 + + + + <html><head/><body><p><span style=" font-weight:600;">Caution</span>: changing these settings can compromise your system or make the qube unable to boot. Use only if you know what you are doing.</p></body></html> + + + true + + + + + + + Storage pool + + + + + + + Qt::Vertical + + + + 20 + 10 + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + name + label + launch_settings + src_vm + tabWidget + storage_pool + + + + + buttonBox + accepted() + CloneVMDlg + accept() + + + 257 + 190 + + + 157 + 274 + + + + + buttonBox + rejected() + CloneVMDlg + reject() + + + 325 + 190 + + + 286 + 274 + + + + + From bdc11582f1d4b175c0a229de7d575726213d862e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marta=20Marczykowska-G=C3=B3recka?= Date: Tue, 2 Jun 2020 19:58:21 +0200 Subject: [PATCH 2/4] Changed Clone action in Qube Manager and VM settings to use the new Clone Qube dialog fixes QubesOS/qubes-issues#5127 --- qubesmanager/qube_manager.py | 38 +++++------------------------------- qubesmanager/settings.py | 24 +++++------------------ 2 files changed, 10 insertions(+), 52 deletions(-) diff --git a/qubesmanager/qube_manager.py b/qubesmanager/qube_manager.py index 40a5954..3d9762b 100644 --- a/qubesmanager/qube_manager.py +++ b/qubesmanager/qube_manager.py @@ -53,6 +53,7 @@ from . import create_new_vm from . import log_dialog from . import utils as manager_utils from . import common_threads +from . import clone_vm class SearchBox(QLineEdit): @@ -1045,39 +1046,10 @@ class VmManagerWindow(ui_qubemanager.Ui_VmManagerWindow, QMainWindow): def action_clonevm_triggered(self): for vm_info in self.get_selected_vms(): vm = vm_info.vm - name_number = 1 - name_format = vm.name + '-clone-%d' - while name_format % name_number in self.qubes_app.domains.keys(): - name_number += 1 - - (clone_name, ok) = QInputDialog.getText( - self, self.tr('Qubes clone Qube'), - self.tr('Enter name for Qube {} clone:').format(vm.name), - text=(name_format % name_number)) - if not ok or clone_name == "": - return - - name_in_use = clone_name in self.qubes_app.domains - - if name_in_use: - QMessageBox.warning( - self, self.tr("Name already in use!"), - self.tr("There already exists a qube called '{}'. " - "Cloning aborted.").format(clone_name)) - return - - self.progress = QProgressDialog( - self.tr( - "Cloning Qube..."), "", 0, 0) - self.progress.setCancelButton(None) - self.progress.setModal(True) - self.progress.setWindowTitle(self.tr("Cloning qube...")) - self.progress.show() - - thread = common_threads.CloneVMThread(vm, clone_name) - thread.finished.connect(self.clear_threads) - self.threads_list.append(thread) - thread.start() + with common_threads.busy_cursor(): + clone_window = clone_vm.CloneVMDlg( + self.qt_app, self.qubes_app, src_vm=vm) + clone_window.exec_() # noinspection PyArgumentList @pyqtSlot(name='on_action_resumevm_triggered') diff --git a/qubesmanager/settings.py b/qubesmanager/settings.py index ef366dc..3723d88 100644 --- a/qubesmanager/settings.py +++ b/qubesmanager/settings.py @@ -36,6 +36,7 @@ from . import utils from . import multiselectwidget from . import common_threads from . import device_list +from . import clone_vm from .appmenu_select import AppmenuSelectManager from . import firewall @@ -653,25 +654,10 @@ class VMSettingsWindow(ui_settingsdlg.Ui_SettingsDialog, QtWidgets.QDialog): self.tr("The qube will not be removed.")) def clone_vm(self): - - cloned_vm_name, ok = QtWidgets.QInputDialog.getText( - self, - self.tr('Clone qube'), - self.tr('Name for the cloned qube:')) - - if ok: - thread = common_threads.CloneVMThread(self.vm, cloned_vm_name) - thread.finished.connect(self.clear_threads) - self.threads_list.append(thread) - - self.progress = QtWidgets.QProgressDialog( - self.tr("Cloning Qube..."), "", 0, 0) - self.progress.setCancelButton(None) - self.progress.setModal(True) - self.thread_closes = True - self.progress.show() -# TODO: improvement: maybe this can be refactored into less repetition? - thread.start() + with common_threads.busy_cursor(): + clone_window = clone_vm.CloneVMDlg( + self.qapp, self.qubesapp, src_vm=self.vm) + clone_window.exec_() ######### advanced tab From 8461a307f2bc31fd721e2f2835e1b7f05f5c949f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marta=20Marczykowska-G=C3=B3recka?= Date: Thu, 30 Jul 2020 22:52:05 +0200 Subject: [PATCH 3/4] Updated Clone VM tool to use new better helper functions --- debian/install | 3 +++ qubesmanager/clone_vm.py | 49 ++++++++++++++++------------------------ 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/debian/install b/debian/install index f2b4000..787576e 100644 --- a/debian/install +++ b/debian/install @@ -7,6 +7,7 @@ /usr/bin/qubes-qube-manager /usr/bin/qubes-log-viewer /usr/bin/qubes-template-manager +/usr/bin/qubes-vm-clone /usr/libexec/qubes-manager/mount_for_backup.sh /usr/libexec/qubes-manager/qvm_about.sh /usr/libexec/qubes-manager/dsa-4371-update @@ -33,6 +34,7 @@ /usr/lib/*/dist-packages/qubesmanager/bootfromdevice.py /usr/lib/*/dist-packages/qubesmanager/device_list.py /usr/lib/*/dist-packages/qubesmanager/template_manager.py +/usr/lib/*/dist-packages/qubesmanager/clone_vm.py /usr/lib/*/dist-packages/qubesmanager/resources_rc.py @@ -51,6 +53,7 @@ /usr/lib/*/dist-packages/qubesmanager/ui_qubemanager.py /usr/lib/*/dist-packages/qubesmanager/ui_devicelist.py /usr/lib/*/dist-packages/qubesmanager/ui_templatemanager.py +/usr/lib/*/dist-packages/qubesmanager/ui_clonevmdlg.py /usr/lib/*/dist-packages/qubesmanager/i18n/qubesmanager_*.qm /usr/lib/*/dist-packages/qubesmanager/i18n/qubesmanager_*.ts diff --git a/qubesmanager/clone_vm.py b/qubesmanager/clone_vm.py index f7bcdb4..64e4de7 100644 --- a/qubesmanager/clone_vm.py +++ b/qubesmanager/clone_vm.py @@ -47,35 +47,25 @@ class CloneVMDlg(QtWidgets.QDialog, Ui_CloneVMDlg): self.thread = None self.progress = None - self.vm_list, self.vm_idx = utils.prepare_vm_choice( - self.src_vm, - self.app, None, - None, - (lambda vm: vm.klass != 'AdminVM'), - allow_internal=False - ) + utils.initialize_widget_with_vms( + widget=self.src_vm, + qubes_app=self.app, + filter_function=(lambda vm: vm.klass != 'AdminVM')) if src_vm and self.src_vm.findText(src_vm.name) > -1: self.src_vm.setCurrentIndex(self.src_vm.findText(src_vm.name)) - self.label_list, self.label_idx = utils.prepare_label_choice( - self.label, - self.app, None, - None, - allow_default=False - ) + utils.initialize_widget_with_labels(widget=self.label, + qubes_app=self.app) self.update_label() - self.pool_list, self.pool_idx = utils.prepare_choice( + utils.initialize_widget_with_default( widget=self.storage_pool, - holder=None, - propname=None, - choice=self.app.pools.values(), - default=self.app.default_pool, - allow_default=True, - allow_none=False - ) + choices=[(str(pool), pool) for pool in self.app.pools.values()], + add_qubes_default=True, + mark_existing_as_default=True, + default_value=self.app.default_pool) self.set_clone_name() @@ -104,15 +94,13 @@ class CloneVMDlg(QtWidgets.QDialog, Ui_CloneVMDlg): 'system!').format(self.name.text())) return - label = self.label_list[self.label.currentIndex()] + label = self.label.currentData() - if self.pool_list[self.storage_pool.currentIndex()] is not \ - qubesadmin.DEFAULT: - pool = self.pool_list[self.storage_pool.currentIndex()] - else: + pool = self.storage_pool.currentData() + if pool is qubesadmin.DEFAULT: pool = None - src_vm = self.vm_list[self.src_vm.currentIndex()] + src_vm = self.src_vm.currentData() self.thread = common_threads.CloneVMThread( src_vm, name, pool=pool, label=label) @@ -134,14 +122,13 @@ class CloneVMDlg(QtWidgets.QDialog, Ui_CloneVMDlg): self.name.setText(name_format % name_number) def update_label(self): - vm_label = self.vm_list[self.src_vm.currentIndex()].label + vm_label = self.src_vm.currentData().label label_idx = self.label.findText(str(vm_label)) if label_idx > -1: self.label.setCurrentIndex(label_idx) - def clone_finished(self): self.progress.hide() @@ -150,6 +137,10 @@ class CloneVMDlg(QtWidgets.QDialog, Ui_CloneVMDlg): self, self.tr("Error cloning the qube!"), self.tr("ERROR: {0}").format(self.thread.msg)) + else: + QtWidgets.QMessageBox.information( + self, + *self.thread.msg) self.done(0) From 354578dc37fa47355cd585ea9b6c3f72cd40a785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marta=20Marczykowska-G=C3=B3recka?= Date: Tue, 4 Aug 2020 17:01:34 +0200 Subject: [PATCH 4/4] Added and fixed tests for CloneVM tool fixes QubesOS/qubes-issues#5978 --- debian/install | 1 + qubesmanager/clone_vm.py | 6 +- qubesmanager/tests/test_clone_vm.py | 276 +++++++++++++++++++++++ qubesmanager/tests/test_create_new_vm.py | 1 - qubesmanager/tests/test_qube_manager.py | 18 +- qubesmanager/tests/test_vm_settings.py | 14 +- rpm_spec/qmgr.spec.in | 1 + 7 files changed, 291 insertions(+), 26 deletions(-) create mode 100644 qubesmanager/tests/test_clone_vm.py diff --git a/debian/install b/debian/install index 787576e..81795e7 100644 --- a/debian/install +++ b/debian/install @@ -65,6 +65,7 @@ /usr/lib/*/dist-packages/qubesmanager/tests/test_qube_manager.py /usr/lib/*/dist-packages/qubesmanager/tests/test_create_new_vm.py /usr/lib/*/dist-packages/qubesmanager/tests/test_vm_settings.py +/usr/lib/*/dist-packages/qubesmanager/tests/test_clone_vm.py /usr/lib/*/dist-packages/qubesmanager-*.egg-info/* diff --git a/qubesmanager/clone_vm.py b/qubesmanager/clone_vm.py index 64e4de7..f88216c 100644 --- a/qubesmanager/clone_vm.py +++ b/qubesmanager/clone_vm.py @@ -138,9 +138,11 @@ class CloneVMDlg(QtWidgets.QDialog, Ui_CloneVMDlg): self.tr("Error cloning the qube!"), self.tr("ERROR: {0}").format(self.thread.msg)) else: - QtWidgets.QMessageBox.information( + (title, msg) = self.thread.msg + QtWidgets.QMessageBox.warning( self, - *self.thread.msg) + title, + msg) self.done(0) diff --git a/qubesmanager/tests/test_clone_vm.py b/qubesmanager/tests/test_clone_vm.py new file mode 100644 index 0000000..63ab668 --- /dev/null +++ b/qubesmanager/tests/test_clone_vm.py @@ -0,0 +1,276 @@ +#!/usr/bin/python3 +# +# The Qubes OS Project, https://www.qubes-os.org/ +# +# Copyright (C) 2020 Marta Marczykowska-Górecka +# +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +import logging.handlers +import unittest +import unittest.mock + +from PyQt5 import QtTest, QtCore +from qubesadmin import Qubes +from qubesmanager.tests import init_qtapp +from qubesmanager import clone_vm + +# TODO: test when you do give a src vm + + +class CloneVMTest(unittest.TestCase): + def setUp(self): + super(CloneVMTest, self).setUp() + self.qtapp, self.loop = init_qtapp() + + self.qapp = Qubes() + + # mock up the Create VM Thread to avoid changing system state + self.patcher_thread = unittest.mock.patch( + 'qubesmanager.common_threads.CloneVMThread') + self.mock_thread = self.patcher_thread.start() + self.addCleanup(self.patcher_thread.stop) + + # mock the progress dialog to speed testing up + self.patcher_progress = unittest.mock.patch( + 'PyQt5.QtWidgets.QProgressDialog') + self.mock_progress = self.patcher_progress.start() + self.addCleanup(self.patcher_progress.stop) + + # mock the progress dialog to speed testing up + self.patcher_warning = unittest.mock.patch( + 'PyQt5.QtWidgets.QMessageBox.warning') + self.mock_warning = self.patcher_warning.start() + self.addCleanup(self.patcher_warning.stop) + + self.dialog = clone_vm.CloneVMDlg(self.qtapp, self.qapp) + + def test_00_window_loads(self): + self.assertGreater(self.dialog.src_vm.count(), 0, + "No source vms shown") + + self.assertGreater(self.dialog.label.count(), 0, "No labels listed") + + self.assertGreater(self.dialog.storage_pool.count(), 0, + "No pools listed") + + self.assertTrue(self.dialog.src_vm.isEnabled(), + "source vm dialog not active") + + def test_01_cancel_works(self): + self.__click_cancel() + self.assertEqual(self.mock_thread.call_count, 0, + "Attempted to create VM on cancel") + + def test_02_name_correctly_updates(self): + src_name = self.dialog.src_vm.currentText() + target_name = self.dialog.name.text() + + self.assertTrue(target_name.startswith(src_name), + "target name does not contain source name") + self.assertTrue('clone' in target_name, + "target name does not contain >clone<") + + self.dialog.src_vm.setCurrentIndex(self.dialog.src_vm.currentIndex()+1) + + src_name = self.dialog.src_vm.currentText() + target_name = self.dialog.name.text() + + self.assertTrue(target_name.startswith(src_name), + "target name does not contain source name") + self.assertTrue('clone' in target_name, + "target name does not contain >clone<") + + def test_03_label_correctly_updates(self): + src_label = self.dialog.src_vm.currentData().label.name + target_label = self.dialog.label.currentText() + + self.assertEqual(src_label, target_label, "incorrect start label") + + while self.dialog.src_vm.currentData().label.name == src_label: + self.dialog.src_vm.setCurrentIndex( + self.dialog.src_vm.currentIndex() + 1) + + src_label = self.dialog.src_vm.currentData().label.name + target_label = self.dialog.label.currentText() + + self.assertEqual(src_label, target_label, + "label did not change correctly") + + def test_04_clone_first_vm(self): + self.dialog.name.setText("clone-test") + src_vm = self.qapp.domains[self.dialog.src_vm.currentText()] + self.__click_ok() + + self.mock_thread.assert_called_once_with( + src_vm, "clone-test", pool=None, label=src_vm.label) + self.mock_thread().start.assert_called_once_with() + + def test_05_clone_other_vm(self): + self.dialog.src_vm.setCurrentIndex(self.dialog.src_vm.currentIndex()+1) + src_vm = self.qapp.domains[self.dialog.src_vm.currentText()] + + dst_name = self.dialog.name.text() + + self.__click_ok() + + self.mock_thread.assert_called_once_with( + src_vm, dst_name, pool=None, label=src_vm.label) + self.mock_thread().start.assert_called_once_with() + + def test_06_clone_label(self): + src_vm = self.qapp.domains[self.dialog.src_vm.currentText()] + + dst_name = self.dialog.name.text() + + while self.dialog.label.currentText() != 'blue': + self.dialog.label.setCurrentIndex( + self.dialog.label.currentIndex()+1) + + self.__click_ok() + + self.mock_thread.assert_called_once_with( + src_vm, dst_name, pool=None, label=self.qapp.labels['blue']) + self.mock_thread().start.assert_called_once_with() + + @unittest.mock.patch('subprocess.check_call') + def test_07_launch_settings(self, mock_call): + self.dialog.launch_settings.setChecked(True) + + self.dialog.name.setText("clone-test") + + self.__click_ok() + + self.mock_thread.assert_called_once_with( + unittest.mock.ANY, "clone-test", pool=None, + label=unittest.mock.ANY) + + self.mock_thread().msg = ("Success", "Success") + self.dialog.clone_finished() + + mock_call.assert_called_once_with(['qubes-vm-settings', "clone-test"]) + + def test_08_progress_hides(self): + self.dialog.name.setText("clone-test") + + self.__click_ok() + + self.mock_thread.assert_called_once_with( + unittest.mock.ANY, "clone-test", pool=None, + label=unittest.mock.ANY) + + # make sure the thread is not reporting an error + self.mock_thread().start.assert_called_once_with() + self.mock_thread().msg = ("Success", "Success") + + self.mock_progress().show.assert_called_once_with() + + self.dialog.clone_finished() + + self.mock_progress().hide.assert_called_once_with() + + def test_09_pool_nondefault(self): + while 'default' in self.dialog.storage_pool.currentText(): + self.dialog.storage_pool.setCurrentIndex( + self.dialog.storage_pool.currentIndex()+1) + + selected_pool = self.dialog.storage_pool.currentText() + + self.__click_ok() + + self.mock_thread.assert_called_once_with( + unittest.mock.ANY, unittest.mock.ANY, + pool=selected_pool, + label=unittest.mock.ANY) + self.mock_thread().start.assert_called_once_with() + + def __click_ok(self): + okwidget = self.dialog.buttonBox.button( + self.dialog.buttonBox.Ok) + + QtTest.QTest.mouseClick(okwidget, QtCore.Qt.LeftButton) + + def __click_cancel(self): + cancelwidget = self.dialog.buttonBox.button( + self.dialog.buttonBox.Cancel) + + QtTest.QTest.mouseClick(cancelwidget, QtCore.Qt.LeftButton) + + +class CloneVMTestSrcVM(unittest.TestCase): + def setUp(self): + super(CloneVMTestSrcVM, self).setUp() + self.qtapp, self.loop = init_qtapp() + + self.qapp = Qubes() + + # mock up the Create VM Thread to avoid changing system state + self.patcher_thread = unittest.mock.patch( + 'qubesmanager.common_threads.CloneVMThread') + self.mock_thread = self.patcher_thread.start() + self.addCleanup(self.patcher_thread.stop) + + # mock the progress dialog to speed testing up + self.patcher_progress = unittest.mock.patch( + 'PyQt5.QtWidgets.QProgressDialog') + self.mock_progress = self.patcher_progress.start() + self.addCleanup(self.patcher_progress.stop) + + # mock the progress dialog to speed testing up + self.patcher_warning = unittest.mock.patch( + 'PyQt5.QtWidgets.QMessageBox.warning') + self.mock_warning = self.patcher_warning.start() + self.addCleanup(self.patcher_warning.stop) + + self.src_vm = next( + domain for domain in self.qapp.domains + if domain.klass != 'AdminVM') + + self.dialog = clone_vm.CloneVMDlg(self.qtapp, self.qapp, + src_vm=self.src_vm) + + def test_00_window_loads(self): + self.assertEqual(self.dialog.src_vm.currentText(), self.src_vm.name) + self.assertEqual(self.dialog.src_vm.currentData(), self.src_vm) + + self.assertFalse(self.dialog.src_vm.isEnabled(), + "source vm dialog active") + + self.assertEqual(self.dialog.label.currentText(), + self.src_vm.label.name) + + def test_01_simple_clone(self): + self.dialog.name.setText("clone-test") + + self.__click_ok() + + self.mock_thread.assert_called_once_with( + self.src_vm, "clone-test", pool=None, label=self.src_vm.label) + self.mock_thread().start.assert_called_once_with() + + def __click_ok(self): + okwidget = self.dialog.buttonBox.button( + self.dialog.buttonBox.Ok) + + QtTest.QTest.mouseClick(okwidget, QtCore.Qt.LeftButton) + + +if __name__ == "__main__": + ha_syslog = logging.handlers.SysLogHandler('/dev/log') + ha_syslog.setFormatter( + logging.Formatter('%(name)s[%(process)d]: %(message)s')) + logging.root.addHandler(ha_syslog) + unittest.main() diff --git a/qubesmanager/tests/test_create_new_vm.py b/qubesmanager/tests/test_create_new_vm.py index 4bf0c0e..f56c357 100644 --- a/qubesmanager/tests/test_create_new_vm.py +++ b/qubesmanager/tests/test_create_new_vm.py @@ -22,7 +22,6 @@ import logging.handlers import unittest import unittest.mock -import qubesadmin from PyQt5 import QtTest, QtCore from qubesadmin import Qubes diff --git a/qubesmanager/tests/test_qube_manager.py b/qubesmanager/tests/test_qube_manager.py index 578ee50..7250c88 100644 --- a/qubesmanager/tests/test_qube_manager.py +++ b/qubesmanager/tests/test_qube_manager.py @@ -637,9 +637,8 @@ class QubeManagerTest(unittest.TestCase): self.dialog.action_manage_templates.trigger() mock_subprocess.assert_called_once_with('qubes-template-manager') - @unittest.mock.patch('qubesmanager.common_threads.CloneVMThread') - @unittest.mock.patch('PyQt5.QtWidgets.QInputDialog.getText') - def test_232_clonevm(self, mock_input, mock_thread): + @unittest.mock.patch('qubesmanager.clone_vm.CloneVMDlg') + def test_232_clonevm(self, mock_clone): action = self.dialog.action_clonevm self._select_admin_vm() @@ -648,18 +647,9 @@ class QubeManagerTest(unittest.TestCase): selected_vm = self._select_non_admin_vm() self.assertTrue(action.isEnabled()) - mock_input.return_value = (selected_vm.name + "clone1", False) action.trigger() - self.assertEqual(mock_thread.call_count, 0, - "Ignores cancelling clone VM") - - mock_input.return_value = (selected_vm.name + "clone1", True) - action.trigger() - mock_thread.assert_called_once_with(selected_vm, - selected_vm.name + "clone1") - mock_thread().finished.connect.assert_called_once_with( - self.dialog.clear_threads) - mock_thread().start.assert_called_once_with() + mock_clone.assert_called_once_with(self.qtapp, self.qapp, + src_vm=selected_vm) def test_233_search_action(self): self.qtapp.setActiveWindow(self.dialog.searchbox) diff --git a/qubesmanager/tests/test_vm_settings.py b/qubesmanager/tests/test_vm_settings.py index 1c8bd71..fa32c7f 100644 --- a/qubesmanager/tests/test_vm_settings.py +++ b/qubesmanager/tests/test_vm_settings.py @@ -311,23 +311,19 @@ class VMSettingsTest(unittest.TestCase): mock_thread.assert_called_with(self.vm, "test-vm2", unittest.mock.ANY) mock_thread().start.assert_called_with() -# TODO: thread tests for rename - - @unittest.mock.patch('PyQt5.QtWidgets.QProgressDialog') - @unittest.mock.patch('PyQt5.QtWidgets.QInputDialog.getText') - @unittest.mock.patch('qubesmanager.common_threads.CloneVMThread') - def test_12_clone_vm(self, mock_thread, mock_input, _): + @unittest.mock.patch('qubesmanager.clone_vm.CloneVMDlg') + def test_12_clone_vm(self, mock_clone): self.vm = self.qapp.add_new_vm("AppVM", "test-vm", "blue") self.dialog = vm_settings.VMSettingsWindow( self.vm, qapp=self.qtapp, qubesapp=self.qapp, init_page="basic") self.assertTrue(self.dialog.clone_vm_button.isEnabled()) - mock_input.return_value = ("test-vm2", True) self.dialog.clone_vm_button.click() - mock_thread.assert_called_with(self.vm, "test-vm2") - mock_thread().start.assert_called_with() + mock_clone.assert_called_once_with(self.qtapp, self.qapp, + src_vm=self.vm) + @unittest.mock.patch('PyQt5.QtWidgets.QMessageBox.warning') @unittest.mock.patch('PyQt5.QtWidgets.QProgressDialog') diff --git a/rpm_spec/qmgr.spec.in b/rpm_spec/qmgr.spec.in index d5d1a39..1d02d84 100644 --- a/rpm_spec/qmgr.spec.in +++ b/rpm_spec/qmgr.spec.in @@ -122,6 +122,7 @@ rm -rf $RPM_BUILD_ROOT %{python3_sitelib}/qubesmanager/tests/test_qube_manager.py %{python3_sitelib}/qubesmanager/tests/test_create_new_vm.py %{python3_sitelib}/qubesmanager/tests/test_vm_settings.py +%{python3_sitelib}/qubesmanager/tests/test_clone_vm.py %dir %{python3_sitelib}/qubesmanager-*.egg-info %{python3_sitelib}/qubesmanager-*.egg-info/*