template_manager.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. #!/usr/bin/python3
  2. #
  3. # The Qubes OS Project, http://www.qubes-os.org
  4. #
  5. # Copyright (C) 2018 Marta Marczykowska-Górecka
  6. # <marmarta@invisiblethingslab.com>
  7. #
  8. # This program is free software; you can redistribute it and/or
  9. # modify it under the terms of the GNU General Public License
  10. # as published by the Free Software Foundation; either version 2
  11. # of the License, or (at your option) any later version.
  12. #
  13. # This program is distributed in the hope that it will be useful,
  14. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. # GNU General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU Lesser General Public License along
  19. # with this program; if not, see <http://www.gnu.org/licenses/>.
  20. #
  21. #
  22. import sys
  23. import os
  24. import os.path
  25. import traceback
  26. import quamash
  27. import asyncio
  28. from contextlib import suppress
  29. from qubesadmin import Qubes
  30. from qubesadmin import exc
  31. from qubesadmin import events
  32. from PyQt4 import QtGui # pylint: disable=import-error
  33. from PyQt4 import QtCore # pylint: disable=import-error
  34. from PyQt4 import Qt
  35. from . import ui_templatemanager
  36. column_names = ['Qube', 'State', 'Current template', 'New template']
  37. class TemplateManagerWindow(
  38. ui_templatemanager.Ui_MainWindow, QtGui.QMainWindow):
  39. def __init__(self, qt_app, qubes_app, dispatcher, parent=None):
  40. # pylint: disable=unused-argument
  41. super(TemplateManagerWindow, self).__init__()
  42. self.setupUi(self)
  43. self.qubes_app = qubes_app
  44. self.qt_app = qt_app
  45. self.dispatcher = dispatcher
  46. self.rows_in_table = {}
  47. self.templates = []
  48. self.timers = []
  49. self.prepare_vm_list()
  50. self.initialize_table_events()
  51. self.buttonBox.button(QtGui.QDialogButtonBox.Ok).clicked.connect(
  52. self.apply)
  53. self.buttonBox.button(QtGui.QDialogButtonBox.Cancel).clicked.connect(
  54. self.cancel)
  55. self.buttonBox.button(QtGui.QDialogButtonBox.Reset).clicked.connect(
  56. self.reset)
  57. self.vm_list.show()
  58. def prepare_vm_list(self):
  59. self.templates = [vm.name for vm in self.qubes_app.domains
  60. if vm.klass == 'TemplateVM']
  61. vms_with_templates = [vm for vm in self.qubes_app.domains
  62. if getattr(vm, 'template', None)]
  63. self.vm_list.setColumnCount(len(column_names))
  64. self.vm_list.setRowCount(len(vms_with_templates))
  65. row_count = 0
  66. for vm in vms_with_templates:
  67. row = VMRow(vm, row_count, self.vm_list, column_names,
  68. self.templates)
  69. self.rows_in_table[vm.name] = row
  70. row_count += 1
  71. self.vm_list.setHorizontalHeaderLabels(['Qube', '', 'Current', 'New'])
  72. self.vm_list.resizeColumnsToContents()
  73. def initialize_table_events(self):
  74. self.vm_list.cellDoubleClicked.connect(self.table_double_click)
  75. self.vm_list.horizontalHeader().sortIndicatorChanged.connect(
  76. self.sorting_changed)
  77. self.dispatcher.add_handler('domain-pre-start', self.vm_state_changed)
  78. self.dispatcher.add_handler('domain-start-failed',
  79. self.vm_state_changed)
  80. self.dispatcher.add_handler('domain-stopped', self.vm_state_changed)
  81. self.dispatcher.add_handler('domain-shutdown', self.vm_state_changed)
  82. self.dispatcher.add_handler('domain-add', self.vm_added)
  83. self.dispatcher.add_handler('domain-delete', self.vm_removed)
  84. def vm_added(self, _submitter, _event, vm, **_kwargs):
  85. # unfortunately, a VM just in the moment of creation may not have
  86. # a template it will have in a second - e.g., when cloning
  87. timer = Qt.QTimer()
  88. timer.setSingleShot(True)
  89. timer.timeout.connect(lambda: self._vm_added(vm, timer))
  90. self.timers.append(timer)
  91. timer.start(1000) # 1s
  92. def _vm_added(self, vm_name, timer):
  93. self.timers.remove(timer)
  94. try:
  95. vm = self.qubes_app.domains[vm_name]
  96. if not getattr(vm, 'template', None):
  97. return
  98. except (exc.QubesException, KeyError):
  99. return # it was a dispVM that crashed on start
  100. row_no = self.vm_list.rowCount()
  101. self.vm_list.setRowCount(self.vm_list.rowCount() + 1)
  102. row = VMRow(vm, row_no, self.vm_list, column_names,
  103. self.templates)
  104. self.rows_in_table[vm.name] = row
  105. self.vm_list.show()
  106. def vm_removed(self, _submitter, _event, **kwargs):
  107. if kwargs['vm'] not in self.rows_in_table:
  108. return
  109. self.vm_list.removeRow(self.rows_in_table[kwargs['vm']].name_item.row())
  110. def vm_state_changed(self, vm, event, **_kwargs):
  111. try:
  112. if vm.name not in self.rows_in_table:
  113. return
  114. except exc.QubesException:
  115. return # it was a crashing DispVM or closed DispVM
  116. if event == 'domain-pre-start':
  117. self.rows_in_table[vm.name].vm_state_change(is_running=True)
  118. elif event == 'domain-start-failed':
  119. self.rows_in_table[vm.name].vm_state_change(is_running=False)
  120. elif event == 'domain-stopped':
  121. self.rows_in_table[vm.name].vm_state_change(is_running=False)
  122. elif event == 'domain-shutdown':
  123. self.rows_in_table[vm.name].vm_state_change(is_running=False)
  124. def sorting_changed(self, index, _order):
  125. # this is very much not perfect, but QTableWidget does not
  126. # want to be sorted on custom widgets
  127. # possible fix - try to set data of dummy items.
  128. if index == column_names.index('New template'):
  129. self.vm_list.horizontalHeader().setSortIndicator(
  130. -1, QtCore.Qt.AscendingOrder)
  131. def table_double_click(self, row, column):
  132. template_column = column_names.index('Current template')
  133. if column != template_column:
  134. return
  135. template_name = self.vm_list.item(row, column).text()
  136. self.vm_list.clearSelection()
  137. for row_number in range(0, self.vm_list.rowCount()):
  138. if self.vm_list.item(
  139. row_number, template_column).text() == template_name:
  140. self.vm_list.selectRow(row_number)
  141. def reset(self):
  142. for row in self.rows_in_table.values():
  143. row.new_item.reset_choice()
  144. def cancel(self):
  145. self.close()
  146. def apply(self):
  147. errors = {}
  148. for vm, row in self.rows_in_table.items():
  149. if row.new_item.changed:
  150. try:
  151. setattr(self.qubes_app.domains[vm],
  152. 'template', row.new_item.currentText())
  153. except Exception as ex: # pylint: disable=broad-except
  154. errors[vm] = str(ex)
  155. if errors:
  156. error_messages = [vm + ": " + errors[vm] for vm in errors]
  157. QtGui.QMessageBox.warning(
  158. self,
  159. self.tr("Errors encountered!"),
  160. self.tr(
  161. "Errors encountered on template change in the following "
  162. "qubes: <br> {}.").format("<br> ".join(error_messages)))
  163. self.close()
  164. class VMNameItem(QtGui.QTableWidgetItem):
  165. def __init__(self, vm):
  166. super(VMNameItem, self).__init__()
  167. self.vm = vm
  168. self.setText(self.vm.name)
  169. self.setIcon(QtGui.QIcon.fromTheme(vm.label.icon))
  170. class StatusItem(QtGui.QTableWidgetItem):
  171. def __init__(self, vm):
  172. super(StatusItem, self).__init__()
  173. self.vm = vm
  174. self.state = None
  175. def set_state(self, is_running):
  176. self.state = is_running
  177. if self.state:
  178. self.setIcon(QtGui.QIcon.fromTheme('dialog-warning'))
  179. self.setToolTip("Cannot change template on a running VM.")
  180. else:
  181. self.setIcon(QtGui.QIcon())
  182. self.setToolTip("")
  183. def __lt__(self, other):
  184. if self.state == other.state:
  185. return self.vm.name < other.vm.name
  186. return self.state < other.state
  187. class CurrentTemplateItem(QtGui.QTableWidgetItem):
  188. def __init__(self, vm):
  189. super(CurrentTemplateItem, self).__init__()
  190. self.vm = vm
  191. self.setText(self.vm.template.name)
  192. def __lt__(self, other):
  193. if self.text() == other.text():
  194. return self.vm.name < other.vm.name
  195. return self.text() < other.text()
  196. class NewTemplateItem(QtGui.QComboBox):
  197. def __init__(self, vm, templates, table_widget):
  198. super(NewTemplateItem, self).__init__()
  199. self.vm = vm
  200. self.table_widget = table_widget
  201. self.changed = False
  202. for t in templates:
  203. self.addItem(t)
  204. self.setCurrentIndex(self.findText(vm.template.name))
  205. self.start_value = self.currentText()
  206. self.currentIndexChanged.connect(self.choice_changed)
  207. def choice_changed(self):
  208. if self.currentText() != self.start_value:
  209. self.changed = True
  210. self.setStyleSheet('font-weight: bold')
  211. else:
  212. self.changed = False
  213. self.setStyleSheet('font-weight: normal')
  214. for row_index in self.table_widget.selectionModel().selectedRows():
  215. widget = self.table_widget.cellWidget(
  216. row_index.row(), column_names.index('New template'))
  217. if widget.isEnabled() and widget.currentText() !=\
  218. self.currentText():
  219. widget.setCurrentIndex(widget.findText(self.currentText()))
  220. self.table_widget.clearSelection()
  221. def reset_choice(self):
  222. self.setCurrentIndex(self.findText(self.start_value))
  223. class VMRow:
  224. def __init__(self, vm, row_no, table_widget, columns, templates):
  225. self.vm = vm
  226. # icon and name
  227. self.name_item = VMNameItem(self.vm)
  228. table_widget.setItem(row_no, columns.index('Qube'), self.name_item)
  229. # state
  230. self.state_item = StatusItem(self.vm)
  231. table_widget.setItem(row_no, columns.index('State'), self.state_item)
  232. # current template
  233. self.current_item = CurrentTemplateItem(self.vm)
  234. table_widget.setItem(row_no, columns.index('Current template'),
  235. self.current_item)
  236. # new template
  237. # this is needed to make the cell correctly selectable/non-selectable
  238. self.dummy_new_item = QtGui.QTableWidgetItem()
  239. self.new_item = NewTemplateItem(self.vm, templates, table_widget)
  240. table_widget.setCellWidget(row_no, columns.index('New template'),
  241. self.new_item)
  242. table_widget.setItem(row_no, columns.index('New template'),
  243. self.dummy_new_item)
  244. self.vm_state_change(self.vm.is_running())
  245. def vm_state_change(self, is_running):
  246. self.new_item.setEnabled(not is_running)
  247. self.state_item.set_state(is_running)
  248. items = [self.name_item, self.state_item, self.current_item,
  249. self.dummy_new_item]
  250. for item in items:
  251. if is_running:
  252. item.setFlags(item.flags() & ~QtCore.Qt.ItemIsSelectable)
  253. else:
  254. item.setFlags(item.flags() | QtCore.Qt.ItemIsSelectable)
  255. # Bases on the original code by:
  256. # Copyright (c) 2002-2007 Pascal Varet <p.varet@gmail.com>
  257. def handle_exception(exc_type, exc_value, exc_traceback):
  258. filename, line, dummy, dummy = traceback.extract_tb(exc_traceback).pop()
  259. filename = os.path.basename(filename)
  260. error = "%s: %s" % (exc_type.__name__, exc_value)
  261. strace = ""
  262. stacktrace = traceback.extract_tb(exc_traceback)
  263. while stacktrace:
  264. (filename, line, func, txt) = stacktrace.pop()
  265. strace += "----\n"
  266. strace += "line: %s\n" % txt
  267. strace += "func: %s\n" % func
  268. strace += "line no.: %d\n" % line
  269. strace += "file: %s\n" % filename
  270. msg_box = QtGui.QMessageBox()
  271. msg_box.setDetailedText(strace)
  272. msg_box.setIcon(QtGui.QMessageBox.Critical)
  273. msg_box.setWindowTitle("Houston, we have a problem...")
  274. msg_box.setText("Whoops. A critical error has occured. "
  275. "This is most likely a bug in Qubes Manager.<br><br>"
  276. "<b><i>%s</i></b>" % error +
  277. "<br/>at line <b>%d</b><br/>of file %s.<br/><br/>"
  278. % (line, filename))
  279. msg_box.exec_()
  280. def loop_shutdown():
  281. pending = asyncio.Task.all_tasks()
  282. for task in pending:
  283. with suppress(asyncio.CancelledError):
  284. task.cancel()
  285. def main():
  286. qt_app = QtGui.QApplication(sys.argv)
  287. qt_app.setOrganizationName("The Qubes Project")
  288. qt_app.setOrganizationDomain("http://qubes-os.org")
  289. qt_app.setApplicationName("Qube Manager")
  290. qt_app.setWindowIcon(QtGui.QIcon.fromTheme("qubes-manager"))
  291. qt_app.lastWindowClosed.connect(loop_shutdown)
  292. qubes_app = Qubes()
  293. loop = quamash.QEventLoop(qt_app)
  294. asyncio.set_event_loop(loop)
  295. dispatcher = events.EventsDispatcher(qubes_app)
  296. manager_window = TemplateManagerWindow(qt_app, qubes_app, dispatcher)
  297. manager_window.show()
  298. try:
  299. loop.run_until_complete(
  300. asyncio.ensure_future(dispatcher.listen_for_events()))
  301. except asyncio.CancelledError:
  302. pass
  303. except Exception: # pylint: disable=broad-except
  304. loop_shutdown()
  305. exc_type, exc_value, exc_traceback = sys.exc_info()[:3]
  306. handle_exception(exc_type, exc_value, exc_traceback)
  307. if __name__ == "__main__":
  308. main()