Repository: freedomofpress/securedrop

Issue:
Install command fails on admin workstation if v2 services enabled, v3 services disabled.
## Description

If the `site-specific` file on an Admin Workstation has `v2_onion_services: true` and `v3_onion_services: false

## Steps to Reproduce

using an admin workstation USB:
- ensure that the workstation code has been upgraded to 1.4.0
- run `./securedrop-admin sdconfig` and set v2 services on, v3 services off
- run `./securedrop-admin install`

## Expected Behavior

- Install playbook is run, prompting user for server-side admin password

## Actual Behavior

- command fails with error `ERROR: Since you disabled v2 onion services, you must enable v3 onion services`

## Comments

This issue is 1.4.0-specific, 1.3.0-tagged version works correctly. 


Discussion:
I can reproduce this. Investigating...

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
@@ -424,6 +424,9 @@ def __init__(self, args):
     def load_and_update_config(self, validate: bool = True, prompt: bool = True):
         if self.exists():
             self.config = self.load(validate)
+        elif not prompt:
+            sdlog.error('Please run "securedrop-admin sdconfig" first.')
+            sys.exit(1)
 
         return self.update_config(prompt)
 
@@ -624,6 +627,8 @@ def clean_config(self, config: dict) -> dict:
                         )
                         raise
                 clean_config[var] = transform(text) if transform else text
+                if var not in self._config_in_progress:
+                    self._config_in_progress[var] = clean_config[var]
         return clean_config
 
     def load(self, validate=True):


Test patch:
diff --git a/admin/tests/test_integration.py b/admin/tests/test_integration.py
--- a/admin/tests/test_integration.py
+++ b/admin/tests/test_integration.py
@@ -5,6 +5,7 @@
 import pytest
 import re
 import requests
+import shutil
 import subprocess
 import tempfile
 
@@ -191,6 +192,19 @@ def setup_function(function):
     global SD_DIR
     SD_DIR = tempfile.mkdtemp()
     ANSIBLE_BASE = '{0}/install_files/ansible-base'.format(SD_DIR)
+
+    for name in ["roles", "tasks"]:
+        shutil.copytree(
+            os.path.join(CURRENT_DIR, "../../install_files/ansible-base", name),
+            os.path.join(ANSIBLE_BASE, name)
+        )
+
+    for name in ["ansible.cfg", "securedrop-prod.yml"]:
+        shutil.copy(
+            os.path.join(CURRENT_DIR, '../../install_files/ansible-base', name),
+            ANSIBLE_BASE
+        )
+
     cmd = 'mkdir -p {0}/group_vars/all'.format(ANSIBLE_BASE).split()
     subprocess.check_call(cmd)
     for name in ['sd_admin_test.pub', 'ca.crt', 'sd.crt', 'key.asc']:
@@ -340,6 +354,29 @@ def verify_v3_onion_when_v2_is_enabled(child):
     assert ANSI_ESCAPE.sub('', child.buffer.decode("utf-8")).strip() == 'yes'  # noqa: E501
 
 
+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.expect(b"SUDO password:", timeout=5)
+    child.close()
+
+
+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.expect(b'ERROR: Please run "securedrop-admin sdconfig" first.', timeout=5)
+    child.expect(pexpect.EOF, timeout=5)
+    child.close()
+    assert child.exitstatus == 1
+    assert child.signalstatus is None
+
+
 def test_sdconfig_on_first_run():
     cmd = os.path.join(os.path.dirname(CURRENT_DIR),
                        'securedrop_admin/__init__.py')
@@ -401,6 +438,8 @@ def test_sdconfig_on_first_run():
         data = fobj.read()
     assert data == OUTPUT1
 
+    verify_install_has_valid_config()
+
 
 def test_sdconfig_both_v2_v3_true():
     cmd = os.path.join(os.path.dirname(CURRENT_DIR),
@@ -463,6 +502,8 @@ def test_sdconfig_both_v2_v3_true():
         data = fobj.read()
     assert data == WHEN_BOTH_TRUE
 
+    verify_install_has_valid_config()
+
 
 def test_sdconfig_only_v2_true():
     cmd = os.path.join(os.path.dirname(CURRENT_DIR),
@@ -525,6 +566,8 @@ def test_sdconfig_only_v2_true():
         data = fobj.read()
     assert data == WHEN_ONLY_V2
 
+    verify_install_has_valid_config()
+
 
 def test_sdconfig_enable_journalist_alerts():
     cmd = os.path.join(os.path.dirname(CURRENT_DIR),
@@ -592,6 +635,8 @@ def test_sdconfig_enable_journalist_alerts():
         data = fobj.read()
     assert JOURNALIST_ALERT_OUTPUT == data
 
+    verify_install_has_valid_config()
+
 
 def test_sdconfig_enable_https_on_source_interface():
     cmd = os.path.join(os.path.dirname(CURRENT_DIR),
@@ -666,6 +711,8 @@ def test_sdconfig_enable_https_on_source_interface():
         data = fobj.read()
     assert HTTPS_OUTPUT == data
 
+    verify_install_has_valid_config()
+
 
 # The following is the minimal git configuration which can be used to fetch
 # from the SecureDrop Github repository. We want to use this because the