Repository: freedomofpress/securedrop

Issue:
Add "securedrop-admin upgrade" command
## Description

Every release, we ask admins to do the same commands to update `securedrop-admin` in their admin workstation:

```
cd ~/Persistent/securedrop
git fetch --tags
git checkout <latest_release_tag>
gpg --recv-key "2224 5C81 E3BA EB41 38B3 6061 310F 5612 00F4 AD77”
git tag -v <latest_release_tag>  # Output should include “Good Signature”
```

Since there are several manual steps and they involve using `git`, they are confusing to admins. We could provide a `securedrop-admin upgrade` command that did these steps for them and would only bail out 1. there is no new release or 2. if the signature does not verify. 

## User Stories

As a SecureDrop administrator, I want an easier way to upgrade my `securedrop-admin` so that I don't make a mistake. 


Discussion:

Resolving patch:
diff --git a/admin/securedrop_admin/__init__.py b/admin/securedrop_admin/__init__.py
--- a/admin/securedrop_admin/__init__.py
+++ b/admin/securedrop_admin/__init__.py
@@ -461,6 +461,65 @@ def run_tails_config(args):
                           cwd=args.ansible_path)
 
 
+def check_for_updates(args):
+    """Check for SecureDrop updates"""
+    sdlog.info("Checking for SecureDrop updates...")
+
+    # Determine what branch we are on
+    current_tag = subprocess.check_output(['git', 'describe'], cwd=args.root)
+
+    # Fetch all branches
+    git_fetch_cmd = ['git', 'fetch', '--all']
+    subprocess.check_call(git_fetch_cmd, cwd=args.root)
+
+    # Get latest tag
+    git_all_tags = ["git", "tag"]
+    all_tags = subprocess.check_output(git_all_tags,
+                                       cwd=args.root).rstrip('\n').split('\n')
+
+    # Do not check out any release candidate tags
+    all_prod_tags = [x for x in all_tags if 'rc' not in x]
+
+    latest_tag = all_prod_tags[-1]
+
+    if current_tag != latest_tag:
+        sdlog.info("Update needed")
+        return True, latest_tag
+    sdlog.info("All updates applied")
+    return False, latest_tag
+
+
+def update(args):
+    """Verify, and apply latest SecureDrop workstation update"""
+    sdlog.info("Applying SecureDrop updates...")
+
+    update_status, latest_tag = check_for_updates(args)
+
+    if not update_status:
+        # Exit if we're up to date
+        return 0
+
+    git_checkout_cmd = ['git', 'checkout', latest_tag]
+    subprocess.check_call(git_checkout_cmd, cwd=args.root)
+
+    sdlog.info("Verifying signature on latest update...")
+    get_release_key = ['gpg', '--recv-key',
+                       '22245C81E3BAEB4138B36061310F561200F4AD77']
+    subprocess.check_call(get_release_key, cwd=args.root)
+
+    git_verify_tag_cmd = ['git', 'tag', '-v', latest_tag]
+    sig_result = subprocess.check_output(git_verify_tag_cmd,
+                                         stderr=subprocess.STDOUT,
+                                         cwd=args.root)
+
+    if 'Good signature' not in sig_result:
+        sdlog.info("Signature verification failed.")
+        return 1
+    sdlog.info("Signature verification successful.")
+
+    sdlog.info("Updated to SecureDrop {}.".format(latest_tag))
+
+
 def get_logs(args):
     """Get logs for forensics and debugging purposes"""
     sdlog.info("Gathering logs for forensics and debugging")
@@ -528,6 +587,13 @@ class ArgParseFormatterCombo(argparse.ArgumentDefaultsHelpFormatter,
     parse_restore.set_defaults(func=restore_securedrop)
     parse_restore.add_argument("restore_file")
 
+    parse_update = subparsers.add_parser('update', help=update.__doc__)
+    parse_update.set_defaults(func=update)
+
+    parse_check_updates = subparsers.add_parser('check_for_updates',
+                                                help=update.__doc__)
+    parse_check_updates.set_defaults(func=check_for_updates)
+
     parse_logs = subparsers.add_parser('logs',
                                        help=get_logs.__doc__)
     parse_logs.set_defaults(func=get_logs)


Test patch:
diff --git a/admin/tests/test_securedrop-admin.py b/admin/tests/test_securedrop-admin.py
--- a/admin/tests/test_securedrop-admin.py
+++ b/admin/tests/test_securedrop-admin.py
@@ -51,6 +51,92 @@ def test_not_verbose(self, capsys):
         assert 'HIDDEN' not in out
         assert 'VISIBLE' in out
 
+    def test_check_for_updates_update_needed(self, tmpdir, caplog):
+        git_repo_path = str(tmpdir)
+        args = argparse.Namespace(root=git_repo_path)
+        current_tag = "0.6"
+        tags_available = "0.6\n0.6-rc1\n0.6.1\n"
+
+        with mock.patch('subprocess.check_call'):
+            with mock.patch('subprocess.check_output',
+                            side_effect=[current_tag, tags_available]):
+                update_status, tag = securedrop_admin.check_for_updates(args)
+                assert "Update needed" in caplog.text
+                assert update_status is True
+                assert tag == '0.6.1'
+
+    def test_check_for_updates_update_not_needed(self, tmpdir, caplog):
+        git_repo_path = str(tmpdir)
+        args = argparse.Namespace(root=git_repo_path)
+        current_tag = "0.6.1"
+        tags_available = "0.6\n0.6-rc1\n0.6.1\n"
+
+        with mock.patch('subprocess.check_call'):
+            with mock.patch('subprocess.check_output',
+                            side_effect=[current_tag, tags_available]):
+                update_status, tag = securedrop_admin.check_for_updates(args)
+                assert "All updates applied" in caplog.text
+                assert update_status is False
+                assert tag == '0.6.1'
+
+    def test_check_for_updates_if_most_recent_tag_is_rc(self, tmpdir, caplog):
+        """During pre-release QA, the most recent tag ends in *-rc. Let's
+        verify that users will not accidentally check out this tag."""
+        git_repo_path = str(tmpdir)
+        args = argparse.Namespace(root=git_repo_path)
+        current_tag = "0.6.1"
+        tags_available = "0.6\n0.6-rc1\n0.6.1\n0.6.1-rc1\n"
+
+        with mock.patch('subprocess.check_call'):
+            with mock.patch('subprocess.check_output',
+                            side_effect=[current_tag, tags_available]):
+                update_status, tag = securedrop_admin.check_for_updates(args)
+                assert "All updates applied" in caplog.text
+                assert update_status is False
+                assert tag == '0.6.1'
+
+    def test_update_exits_if_not_needed(self, tmpdir, caplog):
+        git_repo_path = str(tmpdir)
+        args = argparse.Namespace(root=git_repo_path)
+
+        with mock.patch('securedrop_admin.check_for_updates',
+                        return_value=(False, "0.6.1")):
+            securedrop_admin.update(args)
+            assert "Applying SecureDrop updates..." in caplog.text
+            assert "Updated to SecureDrop" not in caplog.text
+
+    def test_update_signature_verifies(self, tmpdir, caplog):
+        git_repo_path = str(tmpdir)
+        args = argparse.Namespace(root=git_repo_path)
+
+        git_output = 'Good signature from "SecureDrop Release Signing Key"'
+
+        with mock.patch('securedrop_admin.check_for_updates',
+                        return_value=(True, "0.6.1")):
+            with mock.patch('subprocess.check_call'):
+                with mock.patch('subprocess.check_output',
+                                return_value=git_output):
+                    securedrop_admin.update(args)
+                    assert "Applying SecureDrop updates..." in caplog.text
+                    assert "Signature verification successful." in caplog.text
+                    assert "Updated to SecureDrop" in caplog.text
+
+    def test_update_signature_does_not_verify(self, tmpdir, caplog):
+        git_repo_path = str(tmpdir)
+        args = argparse.Namespace(root=git_repo_path)
+
+        git_output = 'Bad signature from "SecureDrop Release Signing Key"'
+
+        with mock.patch('securedrop_admin.check_for_updates',
+                        return_value=(True, "0.6.1")):
+            with mock.patch('subprocess.check_call'):
+                with mock.patch('subprocess.check_output',
+                                return_value=git_output):
+                    securedrop_admin.update(args)
+                    assert "Applying SecureDrop updates..." in caplog.text
+                    assert "Signature verification failed." in caplog.text
+                    assert "Updated to SecureDrop" not in caplog.text
+
 
 class TestSiteConfig(object):