Repository: freedomofpress/securedrop

Issue:
[qt-journalist-updater] Resume failed updates
## Description

[See epic #3076 for primary feature, PRs for this ticket should go into `qt-journalist-updater`]

What happens if an update fails? For example, if there is an error in Tails config. Currently the GUI will throw up a dialog box, alerting the user of an error, but the update won't resume. Instead, we should prompt the user to try again on next boot. Here's a potential flow: 

0. On boot, we check if an update was in progress and failed. Recall that on this branch we already are running `./securedrop-admin check_for_updates` on boot to determine whether or not we need to run the GUI. Even if we are on the latest release tag, if the lock is there, start the updater as it means that tails config bailed out. 
1. Touch `~/Persistent/securedrop_update.lock`
2. Do the update.
3. If the update completes, delete `~/Persistent/securedrop_update.lock`
4. If it does not complete, then the user will be prompted to update again on next boot.

## User Research Evidence

Based on my knowledge interacting with SecureDrop journalists and admins, I know that two things are not uncommon (to be fair, they are not extremely common, but they happen enough that I think it's worthwhile to handle this case):

1. There is some kind of error in `securedrop-admin tailsconfig` that needs to be debugged, or 
2. The admin has made some local changes in their securedrop repo (this is common for admins but not for journalists).

## User Stories

As a SecureDrop user, I don't want to lose my one chance to autoupdate if I hit an unforeseen error. 


Discussion:
Possible implementation:

0. Add `*.lock` files to gitignore
1. In the GUI in `UpdateThread.run`, first touch: `~/Persistent/securedrop/securedrop_update.lock`
2. In `install_files/ansible-base/roles/tailsconfig/files/securedrop_init.py`, in addition to starting the GUI updater if `Update needed` is in the output, also check if `~/Persistent/securedrop/securedrop_update.lock` exists.
3. In the GUI, after the update completes successfully, i.e. in `tails_status`, we remove `~/Persistent/securedrop/securedrop_update.lock`

Resolving patch:
diff --git a/install_files/ansible-base/roles/tails-config/files/securedrop_init.py b/install_files/ansible-base/roles/tails-config/files/securedrop_init.py
--- a/install_files/ansible-base/roles/tails-config/files/securedrop_init.py
+++ b/install_files/ansible-base/roles/tails-config/files/securedrop_init.py
@@ -108,6 +108,7 @@
                                   '--root', path_securedrop_root,
                                   'check_for_updates'], env=env)
 
-if 'Update needed' in output:
+lock_location = "/home/amnesia/Persistent/securedrop/securedrop_update.lock"
+if 'Update needed' in output or os.path.exists(lock_location):
     # Start the SecureDrop updater GUI.
     subprocess.Popen(['python3', path_gui_updater], env=env)
diff --git a/journalist_gui/journalist_gui/SecureDropUpdater.py b/journalist_gui/journalist_gui/SecureDropUpdater.py
--- a/journalist_gui/journalist_gui/SecureDropUpdater.py
+++ b/journalist_gui/journalist_gui/SecureDropUpdater.py
@@ -3,11 +3,15 @@
 from PyQt5.QtCore import QThread, pyqtSignal
 import sys
 import subprocess
+import os
 import pexpect
 
 from journalist_gui import updaterUI, strings, resources_rc  # noqa
 
 
+LOCK_LOCATION = "/home/amnesia/Persistent/securedrop/securedrop_update.lock"  # noqa
+
+
 class SetupThread(QThread):
     signal = pyqtSignal('PyQt_PyObject')
 
@@ -20,6 +24,12 @@ def __init__(self):
     def run(self):
         sdadmin_path = '/home/amnesia/Persistent/securedrop/securedrop-admin'
         update_command = [sdadmin_path, 'setup']
+
+        # Create lock so we resume failed updates on reboot.
+        # Don't create the lock if it already exists.
+        if not os.path.exists(LOCK_LOCATION):
+            open(LOCK_LOCATION, 'a').close()
+
         try:
             self.output = subprocess.check_output(
                 update_command,
@@ -221,6 +231,8 @@ def tails_status(self, result):
         self.plainTextEdit.setPlainText(self.output)
         self.progressBar.setProperty("value", 80)
         if self.update_success:
+            # Remove lock
+            os.remove(LOCK_LOCATION)
             self.update_status_bar_and_output(strings.finished)
             self.progressBar.setProperty("value", 100)
             self.alert_success()


Test patch:
diff --git a/journalist_gui/test_gui.py b/journalist_gui/test_gui.py
--- a/journalist_gui/test_gui.py
+++ b/journalist_gui/test_gui.py
@@ -7,7 +7,7 @@
 from PyQt5.QtWidgets import (QApplication, QSizePolicy, QInputDialog)
 from PyQt5.QtTest import QTest
 
-from journalist_gui.SecureDropUpdater import UpdaterApp, strings
+from journalist_gui.SecureDropUpdater import UpdaterApp, strings, LOCK_LOCATION
 
 
 class AppTestCase(unittest.TestCase):
@@ -58,7 +58,10 @@ def test_output_tab(self):
     def test_setupThread(self, check_output):
         with mock.patch.object(self.window, "call_tailsconfig",
                                return_value=MagicMock()):
-            self.window.setup_thread.run()  # Call run directly
+            with mock.patch('builtins.open') as mock_open:
+                self.window.setup_thread.run()  # Call run directly
+
+            mock_open.assert_called_once_with(LOCK_LOCATION, 'a')
             self.assertEqual(self.window.update_success, True)
             self.assertEqual(self.window.progressBar.value(), 70)
 
@@ -67,7 +70,10 @@ def test_setupThread(self, check_output):
     def test_setupThread_failure(self, check_output):
         with mock.patch.object(self.window, "call_tailsconfig",
                                return_value=MagicMock()):
-            self.window.setup_thread.run()  # Call run directly
+            with mock.patch('builtins.open') as mock_open:
+                self.window.setup_thread.run()  # Call run directly
+
+            mock_open.assert_called_once_with(LOCK_LOCATION, 'a')
             self.assertEqual(self.window.update_success, False)
             self.assertEqual(self.window.progressBar.value(), 0)
             self.assertEqual(self.window.failure_reason,
@@ -128,7 +134,10 @@ def test_tailsconfigThread_no_failures(self, pt):
         before = MagicMock()
         before.decode.side_effect = ["SUDO: ", "Update successful. failed=0"]
         child.before = before
-        self.window.tails_thread.run()
+        with mock.patch('os.remove') as mock_remove:
+            self.window.tails_thread.run()
+
+        mock_remove.assert_called_once_with(LOCK_LOCATION)
         self.assertIn("failed=0", self.window.output)
         self.assertEqual(self.window.update_success, True)
 
@@ -173,13 +182,23 @@ def test_tailsconfigThread_some_other_subprocess_error(self, pt):
     def test_tails_status_success(self):
         result = {'status': True, "output": "successful.",
                   'failure_reason': ''}
-        self.window.tails_status(result)
+
+        with mock.patch('os.remove') as mock_remove:
+                    self.window.tails_status(result)
+
+        # We do remove the lock if the update does finish
+        mock_remove.assert_called_once_with(LOCK_LOCATION)
         self.assertEqual(self.window.progressBar.value(), 100)
 
     def test_tails_status_failure(self):
         result = {'status': False, "output": "successful.",
                   'failure_reason': '42'}
-        self.window.tails_status(result)
+
+        with mock.patch('os.remove') as mock_remove:
+                    self.window.tails_status(result)
+
+        # We do not remove the lock if the update does not finish
+        mock_remove.assert_not_called()
         self.assertEqual(self.window.progressBar.value(), 0)