Repository: conjure-up/conjure-up

Issue:
Add sudo support for spells
Some spell steps will require sudo.  They need a way to indicate that need, the GUI needs to detect if prompting for the password is necessary, and the TUI needs to document that passwordless sudo is required.

Discussion:

Patch:
diff --git a/README.md b/README.md
index 785176f..df56c32 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,10 @@ with all the sensible defaults in place.
 $ conjure-up canonical-kubernetes localhost
 ```
 
+Note that some spells require sudo for certain steps.  When running in headless
+mode, conjure-up should be run with a user with passwordless sudo enabled (or
+sudo should be pre-authorized before invoking conjure-up).
+
 ## Destroying deployments
 
 ```
diff --git a/conjureup/controllers/clouds/tui.py b/conjureup/controllers/clouds/tui.py
index 3b2cd5d..d7c9c9f 100644
--- a/conjureup/controllers/clouds/tui.py
+++ b/conjureup/controllers/clouds/tui.py
@@ -33,7 +33,8 @@ class CloudsController:
                        "please wait.".format(app.current_model))
             juju.add_model(app.current_model,
                            app.current_controller,
-                           app.current_cloud)
+                           app.current_cloud,
+                           allow_exists=True)
             return controllers.use('deploy').render()
 
         utils.error("Something happened with the controller or model, "
diff --git a/conjureup/controllers/steps/common.py b/conjureup/controllers/steps/common.py
index d990ade..d082321 100644
--- a/conjureup/controllers/steps/common.py
+++ b/conjureup/controllers/steps/common.py
@@ -1,10 +1,20 @@
 import json
 import os
+import os.path as path
 from glob import glob
 
+import yaml
+
 from conjureup import juju, utils
 from conjureup.api.models import model_info
 from conjureup.app_config import app
+from conjureup.models.step import StepModel
+
+
+class ValidationError(Exception):
+    def __init__(self, msg, *args, **kwargs):
+        self.msg = msg
+        super().__init__(msg, *args, **kwargs)
 
 
 def set_env(inputs):
@@ -38,6 +48,22 @@ def get_step_metadata_filenames(steps_dir):
     return sorted(glob(os.path.join(steps_dir, 'step-*.yaml')))
 
 
+def load_step(step_meta_path):
+    step_ex_path, ext = path.splitext(step_meta_path)
+    short_path = '/'.join(step_ex_path.split('/')[-3:])
+    if not path.isfile(step_ex_path):
+        raise ValidationError(
+            'Step {} has no implementation'.format(short_path))
+    elif not os.access(step_ex_path, os.X_OK):
+        raise ValidationError(
+            'Step {} is not executable, make sure it has '
+            'the executable bit set'.format(short_path))
+    with open(step_meta_path) as fp:
+        step_metadata = yaml.load(fp.read())
+        model = StepModel(step_metadata, step_ex_path)
+        return model
+
+
 def do_step(step_model, step_widget, message_cb, gui=False):
     """ Processes steps in the background
 
@@ -50,6 +76,13 @@ def do_step(step_model, step_widget, message_cb, gui=False):
     Step title and results message
     """
 
+    if step_model.needs_sudo:
+        password = None
+        if step_widget and step_widget.sudo_input:
+            password = step_widget.sudo_input.value
+        if not step_model.can_sudo(password):
+            raise Exception('Sudo failed')
+
     # merge the step_widget input data into our step model
     if gui:
         step_widget.clear_button()
diff --git a/conjureup/controllers/steps/gui.py b/conjureup/controllers/steps/gui.py
index cc23212..d583f0b 100644
--- a/conjureup/controllers/steps/gui.py
+++ b/conjureup/controllers/steps/gui.py
@@ -1,14 +1,10 @@
-import os
 import os.path as path
 from collections import OrderedDict, deque
 from functools import partial
 
-import yaml
-
 from conjureup import async, controllers
 from conjureup.app_config import app
 from conjureup.controllers.steps import common
-from conjureup.models.step import StepModel
 from conjureup.telemetry import track_exception, track_screen
 from conjureup.ui.views.steps import StepsView
 from conjureup.ui.widgets.step import StepWidget
@@ -35,6 +31,7 @@ class StepsController:
     def get_result(self, future):
         if future.exception():
             self.__handle_exception('E002', future.exception())
+            return
 
         step_model, step_widget = future.result()
 
@@ -104,38 +101,20 @@ class StepsController:
         step_widgets = deque()
         self.n_completed_steps = 0
         for step_meta_path in self.step_metas:
-            step_ex_path, ext = path.splitext(step_meta_path)
-            short_path = '/'.join(step_ex_path.split('/')[-3:])
-            err_msg = None
-            if not path.isfile(step_ex_path):
-                err_msg = (
-                    'Step {} has no implementation'.format(short_path))
-            elif not os.access(step_ex_path, os.X_OK):
-                err_msg = (
-                    'Step {} is not executable, make sure it has '
-                    'the executable bit set'.format(short_path))
-            if err_msg:
-                app.log.error(err_msg)
-                self.__handle_exception('E002', Exception(err_msg))
-                return
-            step_metadata = {}
-            with open(step_meta_path) as fp:
-                step_metadata = yaml.load(fp.read())
-
             try:
                 # Store step model and its widget
-                model = StepModel(step_metadata, step_meta_path)
+                model = common.load_step(step_meta_path)
+                if not model.viewable:
+                    app.log.debug("Skipping step: {}".format(model.title))
+                    continue
                 step_widget = StepWidget(
                     app,
                     model,
                     self.finish)
-                if not step_widget.model.viewable:
-                    app.log.debug("Skipping step: {}".format(step_widget))
-                    continue
-                model.path = step_ex_path
                 step_widgets.append(step_widget)
                 app.log.debug("Queueing step: {}".format(step_widget))
             except Exception as e:
+                app.log.exception(e)
                 self.__handle_exception('E002', e)
                 return
 
diff --git a/conjureup/controllers/steps/tui.py b/conjureup/controllers/steps/tui.py
index 18ce191..76fa355 100644
--- a/conjureup/controllers/steps/tui.py
+++ b/conjureup/controllers/steps/tui.py
@@ -1,14 +1,10 @@
-import os
 import os.path as path
 import sys
 from collections import OrderedDict
 
-import yaml
-
 from conjureup import controllers, utils
 from conjureup.app_config import app
 from conjureup.controllers.steps import common
-from conjureup.models.step import StepModel
 
 
 class StepsController:
@@ -26,25 +22,16 @@ class StepsController:
 
     def render(self):
         for step_meta_path in self.step_metas:
-            step_ex_path, ext = path.splitext(step_meta_path)
-            short_path = '/'.join(step_ex_path.split('/')[-3:])
-            err_msg = None
-            if not path.isfile(step_ex_path):
-                err_msg = (
-                    'Step {} has no implementation'.format(short_path))
-            elif not os.access(step_ex_path, os.X_OK):
-                err_msg = (
-                    'Step {} is not executable, make sure it has '
-                    'the executable bit set'.format(short_path))
-            if err_msg:
-                app.log.error(err_msg)
-                utils.error(err_msg)
+            try:
+                model = common.load_step(step_meta_path)
+            except common.ValidationError as e:
+                app.log.error(e.msg)
+                utils.error(e.msg)
+                sys.exit(1)
+            if model.needs_sudo and not model.can_sudo():
+                utils.error("Step requires passwordless sudo: {}".format(
+                    model.title))
                 sys.exit(1)
-            step_metadata = {}
-            with open(step_meta_path) as fp:
-                step_metadata = yaml.load(fp.read())
-            model = StepModel(step_metadata, step_meta_path)
-            model.path = step_ex_path
             app.log.debug("Running step: {}".format(model))
             try:
                 step_model, _ = common.do_step(model,
diff --git a/conjureup/juju.py b/conjureup/juju.py
index 6f16517..a1f129d 100644
--- a/conjureup/juju.py
+++ b/conjureup/juju.py
@@ -756,12 +756,16 @@ def get_model(controller, name):
         "Unable to find model: {}".format(name))
 
 
-def add_model(name, controller, cloud):
+def add_model(name, controller, cloud, allow_exists=False):
     """ Adds a model to current controller
 
     Arguments:
     controller: controller to add model in
+    allow_exists: re-use an existing model, if one exists.
     """
+    if allow_exists and model_available():
+        return
+
     sh = run('juju add-model {} -c {} {}'.format(name, controller, cloud),
              shell=True, stdout=DEVNULL, stderr=PIPE)
     if sh.returncode > 0:
diff --git a/conjureup/models/step.py b/conjureup/models/step.py
index a221100..1e20a27 100644
--- a/conjureup/models/step.py
+++ b/conjureup/models/step.py
@@ -1,5 +1,7 @@
 """ Step model
 """
+import subprocess
+
 from conjureup.app_config import app
 
 
@@ -10,8 +12,9 @@ class StepModel:
         self.description = step.get('description', '')
         self.result = ''
         self.viewable = step.get('viewable', False)
-
+        self.needs_sudo = step.get('sudo', False)
         self.additional_input = step.get('additional-input', [])
+        self.path = path
 
     def __getattr__(self, attr):
         """
@@ -34,3 +37,16 @@ class StepModel:
                                                self.description,
                                                self.viewable,
                                                self.path)
+
+    def can_sudo(self, password=None):
+        if not password:
+            result = subprocess.run(['sudo', '-nv'],
+                                    stdout=subprocess.DEVNULL,
+                                    stderr=subprocess.DEVNULL)
+        else:
+            password = '{}\n'.format(password).encode('utf8')
+            result = subprocess.run(['sudo', '-Sv'],
+                                    input=password,
+                                    stdout=subprocess.DEVNULL,
+                                    stderr=subprocess.DEVNULL)
+        return result.returncode == 0
diff --git a/conjureup/ui/widgets/step.py b/conjureup/ui/widgets/step.py
index e9c2568..c173152 100644
--- a/conjureup/ui/widgets/step.py
+++ b/conjureup/ui/widgets/step.py
@@ -37,6 +37,7 @@ class StepWidget(WidgetWrap):
         self.result = Text(step_model.result)
         self.output = Text(('info_minor', ''))
         self.icon = Text(("info_minor", "\N{BALLOT BOX}"))
+        self.sudo_input = None
 
         self.additional_input = []
         if len(step_model.additional_input) > 0:
@@ -156,6 +157,23 @@ class StepWidget(WidgetWrap):
             'pending_icon',
             self.icon.get_text()[0]
         ))
+        if self.model.needs_sudo:
+            self.step_pile.contents.append((Padding.line_break(""),
+                                            self.step_pile.options()))
+            can_sudo = self.model.can_sudo()
+            label = 'This step requires sudo.'
+            if not can_sudo:
+                label += '  Please enter sudo password:'
+            columns = [
+                ('weight', 0.5, Padding.left(Text(('body', label)), left=5)),
+            ]
+            if not can_sudo:
+                self.sudo_input = PasswordEditor()
+                columns.append(('weight', 1, Color.string_input(
+                    self.sudo_input, focus_map='string_input focus')))
+            self.step_pile.contents.append((Columns(columns, dividechars=3),
+                                            self.step_pile.options()))
+
         for i in self.additional_input:
             self.app.log.debug(i)
             self.step_pile.contents.append((Padding.line_break(""),


Test patch:
diff --git a/test/test_controllers_clouds_tui.py b/test/test_controllers_clouds_tui.py
index e6e7386..d5193b2 100644
--- a/test/test_controllers_clouds_tui.py
+++ b/test/test_controllers_clouds_tui.py
@@ -96,7 +96,8 @@ class CloudsTUIFinishTestCase(unittest.TestCase):
         self.mock_app.current_cloud = 'cloud'
         self.controller.finish()
         self.mock_juju.assert_has_calls([
-            call.add_model(ANY, 'testcontroller', 'cloud')])
+            call.add_model(ANY, 'testcontroller', 'cloud',
+                           allow_exists=True)])
 
     def test_finish_no_controller(self):
         "clouds.finish without existing controller"