template_manager.py 16 KB

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