template_manager.py 16 KB

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