sdw_updater/UpdaterApp.py (193 lines of code) (raw):

import subprocess import sys from PyQt5.QtCore import QThread, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import QDialog from sdw_updater import Updater, strings from sdw_updater.Updater import UpdateStatus, current_templates from sdw_updater.UpdaterAppUiQt5 import Ui_UpdaterDialog from sdw_util import Util logger = Util.get_logger(module=__name__) def launch_securedrop_client(): """ Helper function to launch the SecureDrop Client """ try: logger.info("Launching SecureDrop client") subprocess.Popen(["qvm-run", "sd-app", "gtk-launch press.freedom.SecureDropClient"]) except subprocess.CalledProcessError as e: logger.error("Error while launching SecureDrop client") logger.error(str(e)) sys.exit(0) class UpdaterApp(QDialog, Ui_UpdaterDialog): def __init__(self, should_skip_netcheck: bool = False, parent=None): super().__init__(parent) self.progress = 0 self._skip_netcheck = should_skip_netcheck self.setupUi(self) # We use a single dialog with button visibility toggled at different # stages. In the first stage, we only show the "Start Updates" and # "Cancel" buttons. self.applyUpdatesButton.setEnabled(True) self.applyUpdatesButton.show() self.applyUpdatesButton.clicked.connect(self._check_network_and_update) self.cancelButton.setEnabled(True) self.cancelButton.show() self.cancelButton.clicked.connect(self.exit_updater) self.clientOpenButton.setEnabled(False) self.clientOpenButton.hide() self.clientOpenButton.clicked.connect(launch_securedrop_client) self.rebootButton.setEnabled(False) self.rebootButton.hide() self.rebootButton.clicked.connect(self.reboot_workstation) self.show() self.headline.setText(strings.headline_introduction) self.proposedActionDescription.setText(strings.description_introduction) self.progress += 1 self.progressBar.setProperty("value", self.progress) self.progressBar.hide() @pyqtSlot(dict) def upgrade_status(self, result): """ This slot will receive update signals from UpgradeThread, thread which is used to check for TemplateVM upgrades """ logger.info(f"Signal: upgrade_status {str(result)}") self.progress = 100 self.progressBar.setProperty("value", self.progress) if result["recommended_action"] == UpdateStatus.REBOOT_REQUIRED: logger.info("Reboot required") self.rebootButton.setEnabled(True) self.rebootButton.show() self.cancelButton.setEnabled(True) self.cancelButton.show() self.headline.setText(strings.headline_status_reboot_required) self.proposedActionDescription.setText(strings.description_status_reboot_required) elif result["recommended_action"] == UpdateStatus.UPDATES_OK: logger.info("VMs have been succesfully updated, OK to start client") self.clientOpenButton.setEnabled(True) self.clientOpenButton.show() self.cancelButton.setEnabled(True) self.cancelButton.show() self.headline.setText(strings.headline_status_updates_complete) self.proposedActionDescription.setText(strings.description_status_updates_complete) else: logger.info("Error upgrading VMs") self.cancelButton.setEnabled(True) self.cancelButton.show() self.headline.setText(strings.headline_status_updates_failed) self.proposedActionDescription.setText(strings.description_status_updates_failed) @pyqtSlot(int) def update_progress_bar(self, value): """ This slot will receive updates from UpgradeThread which will provide a int representing the percentage of the progressBar. This slot will update the progressBar value once it receives the signal. """ current_progress = int(value) if current_progress <= 0: current_progress = 5 elif current_progress > 100: current_progress = 100 self.progress = current_progress self.progressBar.setProperty("value", self.progress) def _check_network_and_update(self): """ Wrapper for `apply_all_updates` that ensures network connectivity before updating, else stops the update and shows a connectivity error message to the user. Because this check happens before updates begin, an error at this stage simply stops the update attempt and does not affect the last UpdateStatus or affect the update timestamp. """ if self._skip_netcheck: logger.info("Network check skipped; launching updater") self.apply_all_updates() elif _is_netcheck_successful(): logger.info("Network check successful; checking for updates.") self.apply_all_updates() else: logger.error("Network connectivity check failed; cannot check for updates.") self._show_network_error() def _show_network_error(self): """ Show the network error dialog state. """ self.headline.setText(strings.headline_error_network) self.proposedActionDescription.setText(strings.description_error_network) self.cancelButton.setEnabled(True) self.applyUpdatesButton.hide() def apply_all_updates(self): """ Method used by the applyUpdatesButton that will create and start an UpgradeThread to apply updates to TemplateVMs """ logger.info("Starting UpgradeThread") self.progress = 5 self.progressBar.setProperty("value", self.progress) self.progressBar.show() self.headline.setText(strings.headline_applying_updates) self.proposedActionDescription.setText(strings.description_status_applying_updates) self.applyUpdatesButton.setEnabled(False) self.applyUpdatesButton.hide() self.cancelButton.setEnabled(False) self.upgrade_thread = UpgradeThread() self.upgrade_thread.start() self.upgrade_thread.upgrade_signal.connect(self.upgrade_status) self.upgrade_thread.progress_signal.connect(self.update_progress_bar) def reboot_workstation(self): """ Helper method to reboot the Workstation """ try: logger.info("Rebooting the workstation") subprocess.check_call(["sudo", "reboot"]) self.headline.setText(strings.headline_status_rebooting) self.proposedActionDescription.setText(strings.description_status_rebooting) except subprocess.CalledProcessError as e: self.headline.setText(strings.headline_error_reboot) self.proposedActionDescription.setText(strings.description_error_reboot) logger.error("Error while rebooting the workstation") logger.error(str(e)) def exit_updater(self): """ Exits the updater if the user clicks cancel """ sys.exit() def _is_netcheck_successful() -> bool: """ Helper function to assess network connectivity before launching updater. Assess network connectivity by checking connection status (via nmcli) in sys-net. """ command = b"nmcli networking connectivity check" if not Util.get_qubes_version(): logger.error("QubesOS not detected, cannot check network.") return False try: # Use of `--pass-io` is required to check on network status, since # nmcli returns 0 for all connection states we need to report back to dom0. result = subprocess.check_output(["qvm-run", "-p", "sys-net", command]) return result.decode("utf-8").strip() == "full" except subprocess.CalledProcessError as e: logger.error( "{} (connectivity check) failed; state reported as {}".format( command.decode("utf-8"), e.output ) ) return False class UpgradeThread(QThread): """ This thread will apply updates for TemplateVMs based on the VM list specified in the object's contructor """ upgrade_signal = pyqtSignal("PyQt_PyObject") progress_signal = pyqtSignal("int") def __init__(self): QThread.__init__(self) def run(self): results = self.run_full_update() # write flags to disk run_results = Updater.overall_update_status(results) Updater._write_updates_status_flag_to_disk(run_results) # Write the "last updated" date to disk if the system is up-to-date # after applying upgrades, regardless of whether a reboot is still pending. if run_results in {UpdateStatus.UPDATES_OK, UpdateStatus.REBOOT_REQUIRED}: Updater._write_last_updated_flags_to_disk() # populate signal results message = results # copy all information from updater call message["recommended_action"] = run_results self.upgrade_signal.emit(message) def run_full_update(self): # Pre-populate results with all available steps for early exits results = { "dom0": UpdateStatus.UPDATES_REQUIRED, "apply_dom0": UpdateStatus.UPDATES_REQUIRED, "apply_all": UpdateStatus.UPDATES_REQUIRED, "templates": UpdateStatus.UPDATES_REQUIRED, } # Update dom0 first, then apply dom0 state. If full state run # is required, the dom0 state will drop a flag. self.progress_signal.emit(5) results["dom0"] = Updater.apply_updates_dom0() if results["dom0"] == UpdateStatus.UPDATES_FAILED: return results # Fail early # apply dom0 state self.progress_signal.emit(10) # add to results dict, if it fails it will show error message results["apply_dom0"] = Updater.apply_dom0_state() if results["apply_dom0"] == UpdateStatus.UPDATES_FAILED: return results # Fail early self.progress_signal.emit(15) # rerun full config if dom0 checks determined it's required if Updater.migration_is_required(): # Progress bar will freeze for ~15m during full state run self.progress_signal.emit(35) # add to results dict, if it fails it will show error message results["apply_all"] = Updater.run_full_install() if results["apply_all"] == UpdateStatus.UPDATES_FAILED: return results # Fail early self.progress_signal.emit(75) templates_progress_callback = self.templates_progress_callback_factory( progress_start=75, progress_end=90, ) else: results["apply_all"] = UpdateStatus.UPDATES_OK # No updates templates_progress_callback = self.templates_progress_callback_factory( progress_start=15, progress_end=90, ) results["templates"] = Updater.apply_updates_templates( current_templates, templates_progress_callback, ) return results def templates_progress_callback_factory(self, progress_start, progress_end): def bump_progress(templates_total_progress): """ Figure out how much the progress bar should be bumped """ template_prog_percentage = (progress_end - progress_start) / 100 total_progress = int( progress_start + template_prog_percentage * templates_total_progress ) return self.progress_signal.emit(total_progress) return bump_progress