Merge remote-tracking branch 'origin/pr/239'
* origin/pr/239: Added and fixed tests for CloneVM tool Updated Clone VM tool to use new better helper functions Changed Clone action in Qube Manager and VM settings to use the new Clone Qube dialog Added a Clone VM tool
This commit is contained in:
commit
7fc8c2e9e0
4
debian/install
vendored
4
debian/install
vendored
@ -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
|
||||
|
||||
@ -62,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/*
|
||||
|
||||
|
182
qubesmanager/clone_vm.py
Normal file
182
qubesmanager/clone_vm.py
Normal file
@ -0,0 +1,182 @@
|
||||
#!/usr/bin/python3
|
||||
#
|
||||
# The Qubes OS Project, http://www.qubes-os.org
|
||||
#
|
||||
# Copyright (C) 2020 Marta Marczykowska-Górecka
|
||||
# <marmarta@invisiblethingslab.com>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
#
|
||||
|
||||
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
|
||||
|
||||
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))
|
||||
|
||||
utils.initialize_widget_with_labels(widget=self.label,
|
||||
qubes_app=self.app)
|
||||
|
||||
self.update_label()
|
||||
|
||||
utils.initialize_widget_with_default(
|
||||
widget=self.storage_pool,
|
||||
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()
|
||||
|
||||
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 <b>{}</b> already exists in the '
|
||||
'system!').format(self.name.text()))
|
||||
return
|
||||
|
||||
label = self.label.currentData()
|
||||
|
||||
pool = self.storage_pool.currentData()
|
||||
if pool is qubesadmin.DEFAULT:
|
||||
pool = None
|
||||
|
||||
src_vm = self.src_vm.currentData()
|
||||
|
||||
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 <b>{0}</b>...").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.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()
|
||||
|
||||
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))
|
||||
else:
|
||||
(title, msg) = self.thread.msg
|
||||
QtWidgets.QMessageBox.warning(
|
||||
self,
|
||||
title,
|
||||
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_()
|
@ -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
|
||||
|
@ -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 <b>{}</b> 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')
|
||||
|
@ -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
|
||||
|
||||
|
276
qubesmanager/tests/test_clone_vm.py
Normal file
276
qubesmanager/tests/test_clone_vm.py
Normal file
@ -0,0 +1,276 @@
|
||||
#!/usr/bin/python3
|
||||
#
|
||||
# The Qubes OS Project, https://www.qubes-os.org/
|
||||
#
|
||||
# Copyright (C) 2020 Marta Marczykowska-Górecka
|
||||
# <marmarta@invisiblethingslab.com>
|
||||
#
|
||||
# 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()
|
@ -22,7 +22,6 @@
|
||||
import logging.handlers
|
||||
import unittest
|
||||
import unittest.mock
|
||||
import qubesadmin
|
||||
|
||||
from PyQt5 import QtTest, QtCore
|
||||
from qubesadmin import Qubes
|
||||
|
@ -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)
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
|
||||
@ -119,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/*
|
||||
|
1
setup.py
1
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',
|
||||
|
220
ui/clonevmdlg.ui
Normal file
220
ui/clonevmdlg.ui
Normal file
@ -0,0 +1,220 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>CloneVMDlg</class>
|
||||
<widget class="QDialog" name="CloneVMDlg">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>616</width>
|
||||
<height>238</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Clone qube</string>
|
||||
</property>
|
||||
<property name="windowIcon">
|
||||
<iconset theme="qubes-manager">
|
||||
<normaloff>.</normaloff>.</iconset>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Minimum">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="tabWidgetPage1">
|
||||
<attribute name="title">
|
||||
<string>Basic</string>
|
||||
</attribute>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<property name="leftMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="horizontalSpacing">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="verticalSpacing">
|
||||
<number>9</number>
|
||||
</property>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Source qube:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="3">
|
||||
<widget class="QComboBox" name="label">
|
||||
<property name="frame">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1" colspan="2">
|
||||
<widget class="QLineEdit" name="name">
|
||||
<property name="text">
|
||||
<string>my-cloned-qube</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1" colspan="2">
|
||||
<widget class="QComboBox" name="src_vm"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="name_label">
|
||||
<property name="text">
|
||||
<string>Clone name:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="1" colspan="3">
|
||||
<widget class="QCheckBox" name="launch_settings">
|
||||
<property name="text">
|
||||
<string>launch settings after cloning</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab">
|
||||
<attribute name="title">
|
||||
<string>Advanced</string>
|
||||
</attribute>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<property name="leftMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="horizontalSpacing">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="verticalSpacing">
|
||||
<number>9</number>
|
||||
</property>
|
||||
<item row="1" column="1">
|
||||
<widget class="QComboBox" name="storage_pool">
|
||||
<property name="currentText">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0" colspan="2">
|
||||
<widget class="QLabel" name="label_5">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string><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></string>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="text">
|
||||
<string>Storage pool</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>10</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QDialogButtonBox" name="buttonBox">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="standardButtons">
|
||||
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<tabstops>
|
||||
<tabstop>name</tabstop>
|
||||
<tabstop>label</tabstop>
|
||||
<tabstop>launch_settings</tabstop>
|
||||
<tabstop>src_vm</tabstop>
|
||||
<tabstop>tabWidget</tabstop>
|
||||
<tabstop>storage_pool</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>accepted()</signal>
|
||||
<receiver>CloneVMDlg</receiver>
|
||||
<slot>accept()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>257</x>
|
||||
<y>190</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>157</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>rejected()</signal>
|
||||
<receiver>CloneVMDlg</receiver>
|
||||
<slot>reject()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>325</x>
|
||||
<y>190</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>286</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
Loading…
Reference in New Issue
Block a user