template_manager.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  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 PyQt5 import QtWidgets, QtGui, QtCore # pylint: disable=import-error
  33. from . import ui_templatemanager # pylint: disable=no-name-in-module
  34. column_names = ['State', 'Qube', 'Current template', 'New template']
  35. class TemplateManagerWindow(
  36. ui_templatemanager.Ui_MainWindow, QtWidgets.QMainWindow):
  37. def __init__(self, qt_app, qubes_app, dispatcher, parent=None):
  38. # pylint: disable=unused-argument
  39. super(TemplateManagerWindow, self).__init__()
  40. self.setupUi(self)
  41. self.qubes_app = qubes_app
  42. self.qt_app = qt_app
  43. self.dispatcher = dispatcher
  44. self.rows_in_table = {}
  45. self.templates = []
  46. self.timers = []
  47. self.prepare_lists()
  48. self.initialize_table_events()
  49. self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).clicked.connect(
  50. self.apply)
  51. self.buttonBox.button(
  52. QtWidgets.QDialogButtonBox.Cancel).clicked.connect(self.cancel)
  53. self.buttonBox.button(QtWidgets.QDialogButtonBox.Reset).clicked.connect(
  54. self.reset)
  55. self.change_all_combobox.currentIndexChanged.connect(
  56. self.change_all_changed)
  57. self.clear_selection_button.clicked.connect(self.clear_selection)
  58. self.vm_list.show()
  59. def prepare_lists(self):
  60. self.templates = [vm.name for vm in self.qubes_app.domains
  61. if vm.klass == 'TemplateVM']
  62. self.change_all_combobox.addItem('(select template)')
  63. for template in self.templates:
  64. self.change_all_combobox.addItem(template)
  65. vms_with_templates = [vm for vm in self.qubes_app.domains
  66. if getattr(vm, 'template', None) and
  67. vm.klass != 'DispVM']
  68. self.vm_list.setColumnCount(len(column_names))
  69. self.vm_list.setRowCount(len(vms_with_templates))
  70. row_count = 0
  71. for vm in vms_with_templates:
  72. row = VMRow(vm, row_count, self.vm_list, column_names,
  73. self.templates)
  74. self.rows_in_table[vm.name] = row
  75. row_count += 1
  76. self.vm_list.setHorizontalHeaderLabels(['', 'Qube', 'Current', 'New'])
  77. self.vm_list.resizeColumnsToContents()
  78. def initialize_table_events(self):
  79. self.vm_list.cellDoubleClicked.connect(self.table_double_click)
  80. self.vm_list.cellClicked.connect(self.table_click)
  81. self.vm_list.horizontalHeader().sortIndicatorChanged.connect(
  82. self.sorting_changed)
  83. self.dispatcher.add_handler('domain-pre-start', self.vm_state_changed)
  84. self.dispatcher.add_handler('domain-start-failed',
  85. self.vm_state_changed)
  86. self.dispatcher.add_handler('domain-stopped', self.vm_state_changed)
  87. self.dispatcher.add_handler('domain-shutdown', self.vm_state_changed)
  88. self.dispatcher.add_handler('domain-add', self.vm_added)
  89. self.dispatcher.add_handler('domain-delete', self.vm_removed)
  90. def vm_added(self, _submitter, _event, vm, **_kwargs):
  91. # unfortunately, a VM just in the moment of creation may not have
  92. # a template it will have in a second - e.g., when cloning
  93. timer = QtCore.QTimer()
  94. timer.setSingleShot(True)
  95. timer.timeout.connect(lambda: self._vm_added(vm, timer))
  96. self.timers.append(timer)
  97. timer.start(1000) # 1s
  98. def _vm_added(self, vm_name, timer):
  99. self.timers.remove(timer)
  100. try:
  101. vm = self.qubes_app.domains[vm_name]
  102. if not getattr(vm, 'template', None) or vm.klass == 'DispVM':
  103. return
  104. except (exc.QubesException, KeyError):
  105. return # it was a dispVM that crashed on start
  106. row_no = self.vm_list.rowCount()
  107. self.vm_list.setRowCount(self.vm_list.rowCount() + 1)
  108. row = VMRow(vm, row_no, self.vm_list, column_names,
  109. self.templates)
  110. self.rows_in_table[vm.name] = row
  111. self.vm_list.show()
  112. def vm_removed(self, _submitter, _event, **kwargs):
  113. if kwargs['vm'] not in self.rows_in_table:
  114. return
  115. self.vm_list.removeRow(self.rows_in_table[kwargs['vm']].name_item.row())
  116. def vm_state_changed(self, vm, event, **_kwargs):
  117. try:
  118. if vm.name not in self.rows_in_table:
  119. return
  120. except exc.QubesException:
  121. return # it was a crashing DispVM or closed DispVM
  122. if event == 'domain-pre-start':
  123. self.rows_in_table[vm.name].vm_state_change(is_running=True)
  124. elif event == 'domain-start-failed':
  125. self.rows_in_table[vm.name].vm_state_change(is_running=False)
  126. elif event == 'domain-stopped':
  127. self.rows_in_table[vm.name].vm_state_change(is_running=False)
  128. elif event == 'domain-shutdown':
  129. self.rows_in_table[vm.name].vm_state_change(is_running=False)
  130. def sorting_changed(self, index, _order):
  131. # this is very much not perfect, but QTableWidget does not
  132. # want to be sorted on custom widgets
  133. if index == column_names.index('New template') or \
  134. index == column_names.index('State'):
  135. self.vm_list.horizontalHeader().setSortIndicator(
  136. -1, QtCore.Qt.AscendingOrder)
  137. def clear_selection(self):
  138. for row in self.rows_in_table.values():
  139. if row.checkbox:
  140. row.checkbox.setChecked(False)
  141. def change_all_changed(self):
  142. if self.change_all_combobox.currentIndex() == 0:
  143. return
  144. selected_template = self.change_all_combobox.currentText()
  145. for row in self.rows_in_table.values():
  146. if row.checkbox and row.checkbox.isChecked():
  147. row.new_item.setCurrentIndex(
  148. row.new_item.findText(selected_template))
  149. self.change_all_combobox.setCurrentIndex(0)
  150. def table_double_click(self, row, column):
  151. template_column = column_names.index('Current template')
  152. if column != template_column:
  153. return
  154. template_name = self.vm_list.item(row, column).text()
  155. for row_number in range(0, self.vm_list.rowCount()):
  156. if self.vm_list.item(
  157. row_number, template_column).text() == template_name:
  158. checkbox = self.vm_list.cellWidget(
  159. row_number, column_names.index('State'))
  160. if checkbox:
  161. if row_number == row:
  162. # this is because double click registers as a
  163. # single click and a double click
  164. checkbox.setChecked(False)
  165. else:
  166. checkbox.setChecked(True)
  167. def table_click(self, row, column):
  168. if column == column_names.index('New template'):
  169. return
  170. checkbox = self.vm_list.cellWidget(row, column_names.index('State'))
  171. if not checkbox:
  172. return
  173. checkbox.setChecked(not checkbox.isChecked())
  174. def reset(self):
  175. for row in self.rows_in_table.values():
  176. if row.new_item:
  177. row.new_item.reset_choice()
  178. if row.checkbox:
  179. row.checkbox.setChecked(False)
  180. def cancel(self):
  181. self.close()
  182. def apply(self):
  183. errors = {}
  184. for vm, row in self.rows_in_table.items():
  185. if row.new_item and row.new_item.changed:
  186. try:
  187. setattr(self.qubes_app.domains[vm],
  188. 'template', row.new_item.currentText())
  189. except Exception as ex: # pylint: disable=broad-except
  190. errors[vm] = str(ex)
  191. if errors:
  192. error_messages = [vm + ": " + errors[vm] for vm in errors]
  193. QtWidgets.QMessageBox.warning(
  194. self,
  195. self.tr("Errors encountered!"),
  196. self.tr(
  197. "Errors encountered on template change in the following "
  198. "qubes: <br> {}.").format("<br> ".join(error_messages)))
  199. self.close()
  200. class VMNameItem(QtWidgets.QTableWidgetItem):
  201. # pylint: disable=too-few-public-methods
  202. def __init__(self, vm):
  203. super(VMNameItem, self).__init__()
  204. self.vm = vm
  205. self.setText(self.vm.name)
  206. self.setIcon(QtGui.QIcon.fromTheme(vm.label.icon))
  207. class StatusItem(QtWidgets.QTableWidgetItem):
  208. def __init__(self, vm):
  209. super(StatusItem, self).__init__()
  210. self.vm = vm
  211. self.state = None
  212. def set_state(self, is_running):
  213. self.state = is_running
  214. if self.state:
  215. self.setIcon(QtGui.QIcon.fromTheme('dialog-warning'))
  216. self.setToolTip("Cannot change template on a running VM.")
  217. else:
  218. self.setIcon(QtGui.QIcon())
  219. self.setToolTip("")
  220. def __lt__(self, other):
  221. if self.state == other.state:
  222. return self.vm.name < other.vm.name
  223. return self.state < other.state
  224. class CurrentTemplateItem(QtWidgets.QTableWidgetItem):
  225. # pylint: disable=too-few-public-methods
  226. def __init__(self, vm):
  227. super(CurrentTemplateItem, self).__init__()
  228. self.vm = vm
  229. self.setText(self.vm.template.name)
  230. def __lt__(self, other):
  231. if self.text() == other.text():
  232. return self.vm.name < other.vm.name
  233. return self.text() < other.text()
  234. class NewTemplateItem(QtWidgets.QComboBox):
  235. def __init__(self, vm, templates, table_widget):
  236. super(NewTemplateItem, self).__init__()
  237. self.vm = vm
  238. self.table_widget = table_widget
  239. self.changed = False
  240. for template in templates:
  241. self.addItem(template)
  242. self.setCurrentIndex(self.findText(vm.template.name))
  243. self.start_value = self.currentText()
  244. self.currentIndexChanged.connect(self.choice_changed)
  245. def choice_changed(self):
  246. if self.currentText() != self.start_value:
  247. self.changed = True
  248. self.setStyleSheet('font-weight: bold')
  249. else:
  250. self.changed = False
  251. self.setStyleSheet('font-weight: normal')
  252. def reset_choice(self):
  253. self.setCurrentIndex(self.findText(self.start_value))
  254. class VMRow:
  255. # pylint: disable=too-few-public-methods
  256. def __init__(self, vm, row_no, table_widget, columns, templates):
  257. self.vm = vm
  258. self.table_widget = table_widget
  259. self.templates = templates
  260. # state
  261. self.state_item = StatusItem(self.vm)
  262. table_widget.setItem(row_no, columns.index('State'), self.state_item)
  263. self.checkbox = QtWidgets.QCheckBox()
  264. # icon and name
  265. self.name_item = VMNameItem(self.vm)
  266. table_widget.setItem(row_no, columns.index('Qube'), self.name_item)
  267. # current template
  268. self.current_item = CurrentTemplateItem(self.vm)
  269. table_widget.setItem(row_no, columns.index('Current template'),
  270. self.current_item)
  271. # new template
  272. self.dummy_new_item = QtWidgets.QTableWidgetItem("qube is running")
  273. self.new_item = NewTemplateItem(self.vm, templates, table_widget)
  274. table_widget.setItem(row_no, columns.index('New template'),
  275. self.dummy_new_item)
  276. self.vm_state_change(self.vm.is_running(), row_no)
  277. def vm_state_change(self, is_running, row=None):
  278. self.state_item.set_state(is_running)
  279. if not row:
  280. row = 0
  281. while row < self.table_widget.rowCount():
  282. if self.table_widget.item(
  283. row, column_names.index('Qube')).text() == \
  284. self.name_item.text():
  285. break
  286. row += 1
  287. # hiding cellWidgets does not work in a qTableWidget
  288. if not is_running:
  289. self.new_item = NewTemplateItem(self.vm, self.templates,
  290. self.table_widget)
  291. self.checkbox = QtWidgets.QCheckBox()
  292. self.table_widget.setCellWidget(
  293. row, column_names.index('New template'), self.new_item)
  294. self.table_widget.setCellWidget(
  295. row, column_names.index('State'), self.checkbox)
  296. else:
  297. new_template = self.table_widget.cellWidget(
  298. row, column_names.index('New template'))
  299. if new_template:
  300. self.table_widget.removeCellWidget(
  301. row, column_names.index('New template'))
  302. self.new_item = None
  303. checkbox = self.table_widget.cellWidget(
  304. row, column_names.index('State'))
  305. if checkbox:
  306. self.table_widget.removeCellWidget(
  307. row, column_names.index('State'))
  308. self.checkbox = None
  309. # Bases on the original code by:
  310. # Copyright (c) 2002-2007 Pascal Varet <p.varet@gmail.com>
  311. def handle_exception(exc_type, exc_value, exc_traceback):
  312. filename, line, dummy, dummy = traceback.extract_tb(exc_traceback).pop()
  313. filename = os.path.basename(filename)
  314. error = "%s: %s" % (exc_type.__name__, exc_value)
  315. strace = ""
  316. stacktrace = traceback.extract_tb(exc_traceback)
  317. while stacktrace:
  318. (filename, line, func, txt) = stacktrace.pop()
  319. strace += "----\n"
  320. strace += "line: %s\n" % txt
  321. strace += "func: %s\n" % func
  322. strace += "line no.: %d\n" % line
  323. strace += "file: %s\n" % filename
  324. msg_box = QtWidgets.QMessageBox()
  325. msg_box.setDetailedText(strace)
  326. msg_box.setIcon(QtWidgets.QMessageBox.Critical)
  327. msg_box.setWindowTitle("Houston, we have a problem...")
  328. msg_box.setText("Whoops. A critical error has occured. "
  329. "This is most likely a bug in Qubes Manager.<br><br>"
  330. "<b><i>%s</i></b>" % error +
  331. "<br/>at line <b>%d</b><br/>of file %s.<br/><br/>"
  332. % (line, filename))
  333. msg_box.exec_()
  334. def loop_shutdown():
  335. pending = asyncio.Task.all_tasks()
  336. for task in pending:
  337. with suppress(asyncio.CancelledError):
  338. task.cancel()
  339. def main():
  340. qt_app = QtWidgets.QApplication(sys.argv)
  341. qt_app.setOrganizationName("The Qubes Project")
  342. qt_app.setOrganizationDomain("http://qubes-os.org")
  343. qt_app.setApplicationName("Qube Manager")
  344. qt_app.setWindowIcon(QtGui.QIcon.fromTheme("qubes-manager"))
  345. qt_app.lastWindowClosed.connect(loop_shutdown)
  346. qubes_app = Qubes()
  347. loop = quamash.QEventLoop(qt_app)
  348. asyncio.set_event_loop(loop)
  349. dispatcher = events.EventsDispatcher(qubes_app)
  350. manager_window = TemplateManagerWindow(qt_app, qubes_app, dispatcher)
  351. manager_window.show()
  352. try:
  353. loop.run_until_complete(
  354. asyncio.ensure_future(dispatcher.listen_for_events()))
  355. except asyncio.CancelledError:
  356. pass
  357. except Exception: # pylint: disable=broad-except
  358. loop_shutdown()
  359. exc_type, exc_value, exc_traceback = sys.exc_info()[:3]
  360. handle_exception(exc_type, exc_value, exc_traceback)
  361. if __name__ == "__main__":
  362. main()