Skip to content

Commit

Permalink
Check for updates before running install playbook
Browse files Browse the repository at this point in the history
Can be skipped with --force argument.
  • Loading branch information
eloquence committed Feb 17, 2021
1 parent f3e51b4 commit e63f6e9
Show file tree
Hide file tree
Showing 3 changed files with 132 additions and 8 deletions.
60 changes: 59 additions & 1 deletion admin/securedrop_admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"""

import argparse
import functools
import ipaddress
import logging
import os
Expand All @@ -45,6 +46,9 @@
from pkg_resources import parse_version
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import x25519

from typing import cast

from typing import List

from typing import Set
Expand Down Expand Up @@ -80,6 +84,11 @@ class JournalistAlertEmailException(Exception):

# The type of each entry within SiteConfig.desc
_T = TypeVar('_T', bound=Union[int, str, bool])

# The function type used for the @update_check_required decorator; see
# https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators
_FuncT = TypeVar('_FuncT', bound=Callable[..., Any])

# (var, default, type, prompt, validator, transform, condition)
_DescEntryType = Tuple[str, _T, Type[_T], str, Optional[Validator], Optional[Callable], Callable]

Expand Down Expand Up @@ -516,7 +525,7 @@ def user_prompt_config(self) -> Dict[str, Any]:
self._config_in_progress[var] = ''
continue
self._config_in_progress[var] = self.user_prompt_config_one(desc,
self.config.get(var)) # noqa: E501
self.config.get(var)) # noqa: E501
return self._config_in_progress

def user_prompt_config_one(
Expand Down Expand Up @@ -690,6 +699,45 @@ def setup_logger(verbose: bool = False) -> None:
sdlog.addHandler(stdout)


def update_check_required(cmd_name: str) -> Callable[[_FuncT], _FuncT]:
"""
This decorator can be added to any subcommand that is part of securedrop-admin
via `@update_check_required("name_of_subcommand")`. It forces a check for
updates, and aborts if the locally installed code is out of date. It should
be generally added to all subcommands that make modifications on the
server or on the Admin Workstation.
The user can override this check by specifying the --force argument before
any subcommand.
"""
def decorator_update_check(func: _FuncT) -> _FuncT:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
cli_args = args[0]
if cli_args.force:
sdlog.info("Skipping update check because --force argument was provided.")
return func(*args, **kwargs)

update_status, latest_tag = check_for_updates(cli_args)
if update_status is True:
sdlog.error("You are not running the most recent signed SecureDrop release "
"on this workstation.")
sdlog.error("Latest available version: {}".format(latest_tag))
sdlog.error("Running outdated or mismatched code can cause significant "
"technical issues.")
sdlog.error("If you are certain you want to proceed, run:\n\n\t"
"./securedrop-admin --force {}\n".format(cmd_name))
sdlog.error("To apply the latest updates, run:\n\n\t"
"./securedrop-admin update\n")
sdlog.error("If this fails, see the latest upgrade guide on "
"https://docs.securedrop.org/ for instructions.")
sys.exit(1)
return func(*args, **kwargs)
return cast(_FuncT, wrapper)
return decorator_update_check


@update_check_required("sdconfig")
def sdconfig(args: argparse.Namespace) -> int:
"""Configure SD site settings"""
SiteConfig(args).load_and_update_config(validate=False)
Expand Down Expand Up @@ -752,8 +800,10 @@ def find_or_generate_new_torv3_keys(args: argparse.Namespace) -> int:
return 0


@update_check_required("install")
def install_securedrop(args: argparse.Namespace) -> int:
"""Install/Update SecureDrop"""

SiteConfig(args).load_and_update_config(prompt=False)

sdlog.info("Now installing SecureDrop on remote servers.")
Expand All @@ -767,6 +817,7 @@ def install_securedrop(args: argparse.Namespace) -> int:
)


@update_check_required("verify")
def verify_install(args: argparse.Namespace) -> int:
"""Run configuration tests against SecureDrop servers"""

Expand All @@ -776,6 +827,7 @@ def verify_install(args: argparse.Namespace) -> int:
cwd=os.getcwd())


@update_check_required("backup")
def backup_securedrop(args: argparse.Namespace) -> int:
"""Perform backup of the SecureDrop Application Server.
Creates a tarball of submissions and server config, and fetches
Expand All @@ -789,6 +841,7 @@ def backup_securedrop(args: argparse.Namespace) -> int:
return subprocess.check_call(ansible_cmd, cwd=args.ansible_path)


@update_check_required("restore")
def restore_securedrop(args: argparse.Namespace) -> int:
"""Perform restore of the SecureDrop Application Server.
Requires a tarball of submissions and server config, created via
Expand Down Expand Up @@ -825,6 +878,7 @@ def restore_securedrop(args: argparse.Namespace) -> int:
return subprocess.check_call(ansible_cmd, cwd=args.ansible_path)


@update_check_required("tailsconfig")
def run_tails_config(args: argparse.Namespace) -> int:
"""Configure Tails environment post SD install"""
sdlog.info("Configuring Tails workstation environment")
Expand Down Expand Up @@ -972,6 +1026,7 @@ def update(args: argparse.Namespace) -> int:
return 0


@update_check_required("logs")
def get_logs(args: argparse.Namespace) -> int:
"""Get logs for forensics and debugging purposes"""
sdlog.info("Gathering logs for forensics and debugging")
Expand All @@ -998,6 +1053,7 @@ def set_default_paths(args: argparse.Namespace) -> argparse.Namespace:
return args


@update_check_required("reset_admin_access")
def reset_admin_access(args: argparse.Namespace) -> int:
"""Resets SSH access to the SecureDrop servers, locking it to
this Admin Workstation."""
Expand All @@ -1021,6 +1077,8 @@ class ArgParseFormatterCombo(argparse.ArgumentDefaultsHelpFormatter,
help="Increase verbosity on output")
parser.add_argument('-d', action='store_true', default=False,
help="Developer mode. Not to be used in production.")
parser.add_argument('--force', action='store_true', required=False,
help="force command execution without update check")
parser.add_argument('--root', required=True,
help="path to the root of the SecureDrop repository")
parser.add_argument('--site-config',
Expand Down
14 changes: 7 additions & 7 deletions admin/tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ def verify_install_has_valid_config():
Checks that securedrop-admin install validates the configuration.
"""
cmd = os.path.join(os.path.dirname(CURRENT_DIR), 'securedrop_admin/__init__.py')
child = pexpect.spawn('python {0} --root {1} install'.format(cmd, SD_DIR))
child = pexpect.spawn('python {0} --force --root {1} install'.format(cmd, SD_DIR))
child.expect(b"SUDO password:", timeout=5)
child.close()

Expand All @@ -369,7 +369,7 @@ def test_install_with_no_config():
Checks that securedrop-admin install complains about a missing config file.
"""
cmd = os.path.join(os.path.dirname(CURRENT_DIR), 'securedrop_admin/__init__.py')
child = pexpect.spawn('python {0} --root {1} install'.format(cmd, SD_DIR))
child = pexpect.spawn('python {0} --force --root {1} install'.format(cmd, SD_DIR))
child.expect(b'ERROR: Please run "securedrop-admin sdconfig" first.', timeout=5)
child.expect(pexpect.EOF, timeout=5)
child.close()
Expand All @@ -380,7 +380,7 @@ def test_install_with_no_config():
def test_sdconfig_on_first_run():
cmd = os.path.join(os.path.dirname(CURRENT_DIR),
'securedrop_admin/__init__.py')
child = pexpect.spawn('python {0} --root {1} sdconfig'.format(cmd, SD_DIR))
child = pexpect.spawn('python {0} --force --root {1} sdconfig'.format(cmd, SD_DIR))
verify_username_prompt(child)
child.sendline('')
verify_reboot_prompt(child)
Expand Down Expand Up @@ -444,7 +444,7 @@ def test_sdconfig_on_first_run():
def test_sdconfig_both_v2_v3_true():
cmd = os.path.join(os.path.dirname(CURRENT_DIR),
'securedrop_admin/__init__.py')
child = pexpect.spawn('python {0} --root {1} sdconfig'.format(cmd, SD_DIR))
child = pexpect.spawn('python {0} --force --root {1} sdconfig'.format(cmd, SD_DIR))
verify_username_prompt(child)
child.sendline('')
verify_reboot_prompt(child)
Expand Down Expand Up @@ -508,7 +508,7 @@ def test_sdconfig_both_v2_v3_true():
def test_sdconfig_only_v2_true():
cmd = os.path.join(os.path.dirname(CURRENT_DIR),
'securedrop_admin/__init__.py')
child = pexpect.spawn('python {0} --root {1} sdconfig'.format(cmd, SD_DIR))
child = pexpect.spawn('python {0} --force --root {1} sdconfig'.format(cmd, SD_DIR))
verify_username_prompt(child)
child.sendline('')
verify_reboot_prompt(child)
Expand Down Expand Up @@ -572,7 +572,7 @@ def test_sdconfig_only_v2_true():
def test_sdconfig_enable_journalist_alerts():
cmd = os.path.join(os.path.dirname(CURRENT_DIR),
'securedrop_admin/__init__.py')
child = pexpect.spawn('python {0} --root {1} sdconfig'.format(cmd, SD_DIR))
child = pexpect.spawn('python {0} --force --root {1} sdconfig'.format(cmd, SD_DIR))
verify_username_prompt(child)
child.sendline('')
verify_reboot_prompt(child)
Expand Down Expand Up @@ -641,7 +641,7 @@ def test_sdconfig_enable_journalist_alerts():
def test_sdconfig_enable_https_on_source_interface():
cmd = os.path.join(os.path.dirname(CURRENT_DIR),
'securedrop_admin/__init__.py')
child = pexpect.spawn('python {0} --root {1} sdconfig'.format(cmd, SD_DIR))
child = pexpect.spawn('python {0} --force --root {1} sdconfig'.format(cmd, SD_DIR))
verify_username_prompt(child)
child.sendline('')
verify_reboot_prompt(child)
Expand Down
66 changes: 66 additions & 0 deletions admin/tests/test_securedrop-admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,72 @@ def test_not_verbose(self, capsys):
assert 'HIDDEN' not in out
assert 'VISIBLE' in out

def test_update_check_decorator_when_no_update_needed(self, caplog):
"""
When a function decorated with `@update_check_required` is run
And the `--force` argument was not given
And no update is required
Then the update check should run to completion
And no errors should be displayed
And the program should not exit
And the decorated function should be run
"""
with mock.patch(
"securedrop_admin.check_for_updates", side_effect=[[False, "1.5.0"]]
) as mocked_check, mock.patch("sys.exit") as mocked_exit:
# The decorator itself interprets --force
args = argparse.Namespace(force=False)
rv = securedrop_admin.update_check_required("update_check_test")(
lambda _: 100
)(args)
assert mocked_check.called
assert not mocked_exit.called
assert rv == 100
assert caplog.text == ''

def test_update_check_decorator_when_update_needed(self, caplog):
"""
When a function decorated with `@update_check_required` is run
And the `--force` argument was not given
And an update is required
Then the update check should run to completion
And an error referencing the command should be displayed
And the program should exit
"""
with mock.patch(
"securedrop_admin.check_for_updates", side_effect=[[True, "1.5.0"]]
) as mocked_check, mock.patch("sys.exit") as mocked_exit:
# The decorator itself interprets --force
args = argparse.Namespace(force=False)
securedrop_admin.update_check_required("update_check_test")(
lambda _: _
)(args)
assert mocked_check.called
assert mocked_exit.called
assert "update_check_test" in caplog.text

def test_update_check_decorator_when_skipped(self, caplog):
"""
When a function decorated with `@update_check_required` is run
And the `--force` argument was given
Then the update check should not run
And a message should be displayed acknowledging this
And the program should not exit
And the decorated function should be run
"""
with mock.patch(
"securedrop_admin.check_for_updates", side_effect=[[True, "1.5.0"]]
) as mocked_check, mock.patch("sys.exit") as mocked_exit:
# The decorator itself interprets --force
args = argparse.Namespace(force=True)
rv = securedrop_admin.update_check_required("update_check_test")(
lambda _: 100
)(args)
assert not mocked_check.called
assert not mocked_exit.called
assert "--force" in caplog.text
assert rv == 100

def test_check_for_updates_update_needed(self, tmpdir, caplog):
git_repo_path = str(tmpdir)
args = argparse.Namespace(root=git_repo_path)
Expand Down

0 comments on commit e63f6e9

Please sign in to comment.