Browse Source

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
Marta Marczykowska-Górecka 3 years ago
parent
commit
1faf4f46b0
5 changed files with 420 additions and 2 deletions
  1. 189 0
      qubesmanager/clone_vm.py
  2. 7 2
      qubesmanager/common_threads.py
  3. 3 0
      rpm_spec/qmgr.spec.in
  4. 1 0
      setup.py
  5. 220 0
      ui/clonevmdlg.ui

+ 189 - 0
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
+# <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
+
+        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 <b>{}</b> 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 <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.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_()

+ 7 - 2
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

+ 3 - 0
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
 

+ 1 - 0
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 - 0
ui/clonevmdlg.ui

@@ -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>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;span style=&quot; font-weight:600;&quot;&gt;Caution&lt;/span&gt;: changing these settings can compromise your system or make the qube unable to boot. Use only if you know what you are doing.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</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>