Repository: JonathonReinhart/scuba

Issue:
Allow missing top-level 'image' from .scuba.yml
For some projects it may not make sense to have a top-level (default) `image`. For these projects, the `aliases` are the primary use of Scuba.

Allow `.scuba.yml` to omit a top-level `image` key, and in this case require than an alias is being run.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3bafdde..f333da7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
 ### Changed
 - Use username/groupname of invoking user inside container (#153)
 - Ignore already existing UID/GIDs (#139)
+- Allow top-level `image` to be omitted in `.scuba.yml` (#158)
 
 ### Fixed
 - Fixed deprecation error with collections.Mapping (#156)
diff --git a/doc/yaml-reference.md b/doc/yaml-reference.md
index 920d210..548ebdb 100644
--- a/doc/yaml-reference.md
+++ b/doc/yaml-reference.md
@@ -10,7 +10,7 @@ other YAML file schemas, most options are controlled by top-level keys.
 
 ### `image`
 
-The `image` node *is required* and defines the Docker image from which Scuba
+The `image` node defines the Docker image from which Scuba
 containers are created.
 
 Example:
@@ -18,6 +18,10 @@ Example:
 image: debian:8.2
 ```
 
+The `image` node is usually necessary but, as of scuba 2.5, can be omitted for
+`.scuba.yml` files in which only the `aliases` are intended to be used.
+
+
 ### `environment`
 
 The optional `environment` node allows environment variables to be specified.
diff --git a/scuba/__main__.py b/scuba/__main__.py
index a18f585..8add106 100644
--- a/scuba/__main__.py
+++ b/scuba/__main__.py
@@ -279,13 +279,8 @@ class ScubaDive(object):
             self.add_option('--tty')
 
         # Process any aliases
-        try:
-            context = self.config.process_command(self.user_command)
-        except ConfigError as cfgerr:
-            raise ScubaError(str(cfgerr))
-
-        if self.image_override:
-            context.image = self.image_override
+        context = self.config.process_command(self.user_command,
+                image=self.image_override)
 
         '''
         Normally, if the user provides no command to "docker run", the image's
@@ -464,6 +459,9 @@ def main(argv=None):
     try:
         rc = run_scuba(scuba_args) or 0
         sys.exit(rc)
+    except ConfigError as e:
+        appmsg("Config error: " + str(e))
+        sys.exit(128)
     except DockerExecuteError as e:
         appmsg(str(e))
         sys.exit(2)
diff --git a/scuba/config.py b/scuba/config.py
index 660eb66..c1bba08 100644
--- a/scuba/config.py
+++ b/scuba/config.py
@@ -207,8 +207,8 @@ class ScubaContext(object):
 
 class ScubaConfig(object):
     def __init__(self, **data):
-        required_nodes = ('image',)
-        optional_nodes = ('aliases','hooks','entrypoint','environment')
+        required_nodes = ()
+        optional_nodes = ('image','aliases','hooks','entrypoint','environment')
 
         # Check for missing required nodes
         missing = [n for n in required_nodes if not n in data]
@@ -222,7 +222,7 @@ class ScubaConfig(object):
             raise ConfigError('{}: Unrecognized node{}: {}'.format(SCUBA_YML,
                     's' if len(extra) > 1 else '', ', '.join(extra)))
 
-        self._image = data['image']
+        self._image = data.get('image')
         self._entrypoint = _get_entrypoint(data)
         self._load_aliases(data)
         self._load_hooks(data)
@@ -255,6 +255,8 @@ class ScubaConfig(object):
 
     @property
     def image(self):
+        if not self._image:
+            raise ConfigError("Top-level 'image' not set")
         return self._image
 
     @property
@@ -274,11 +276,12 @@ class ScubaConfig(object):
         return self._environment
 
 
-    def process_command(self, command):
+    def process_command(self, command, image=None):
         '''Processes a user command using aliases
 
         Arguments:
             command     A user command list (e.g. argv)
+            image       Override the image from .scuba.yml
 
         Returns: A ScubaContext object with the following attributes:
             script: a list of command line strings
@@ -286,7 +289,7 @@ class ScubaConfig(object):
         '''
         result = ScubaContext()
         result.script = None
-        result.image = self.image
+        result.image = None
         result.entrypoint = self.entrypoint
         result.environment = self.environment.copy()
 
@@ -322,6 +325,15 @@ class ScubaConfig(object):
 
             result.script = flatten_list(result.script)
 
+        # If an image was given, it overrides what might have been set by an alias
+        if image:
+            result.image = image
+
+        # If the image was still not set, then try to get it from the confg,
+        # which will raise a ConfigError if it is not set
+        if not result.image:
+            result.image = self.image
+
         return result
 
 


Test patch:
diff --git a/tests/test_config.py b/tests/test_config.py
index 9242d29..167aadf 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -111,12 +111,14 @@ class TestConfig(TmpDirTestCase):
         with self.assertRaises(scuba.config.ConfigError):
             scuba.config.load_config('.scuba.yml')
 
-    def test_load_config_empty(self):
-        '''load_config raises ConfigError if the config is empty'''
+    def test_load_config_no_image(self):
+        '''load_config raises ConfigError if the config is empty and image is referenced'''
         with open('.scuba.yml', 'w') as f:
             pass
 
-        self._test_invalid_config()
+        config = scuba.config.load_config('.scuba.yml')
+        with self.assertRaises(scuba.config.ConfigError):
+            img = config.image
 
     def test_load_unexpected_node(self):
         '''load_config raises ConfigError on unexpected config node'''
@@ -441,6 +443,42 @@ class TestConfig(TmpDirTestCase):
         assert_equal(result.entrypoint, '')
 
 
+    def test_process_command_image_override(self):
+        '''process_command allows image to be overridden when provided'''
+        override_image_name = 'override_image'
+
+        cfg = scuba.config.ScubaConfig(
+                image = 'test_image',
+                )
+        result = cfg.process_command([], image=override_image_name)
+        assert_equal(result.image, override_image_name)
+
+    def test_process_command_image_override_missing(self):
+        '''process_command allows image to be overridden when not provided'''
+        override_image_name = 'override_image'
+
+        cfg = scuba.config.ScubaConfig()
+        result = cfg.process_command([], image=override_image_name)
+        assert_equal(result.image, override_image_name)
+
+    def test_process_command_image_override_alias(self):
+        '''process_command allows image to be overridden when provided by alias'''
+        override_image_name = 'override_image'
+
+        cfg = scuba.config.ScubaConfig(
+                aliases = dict(
+                    apple = dict(
+                        script = [
+                            'banana cherry "pie is good"',
+                            'so is peach',
+                        ],
+                        image = 'apple_image',
+                    ),
+                )
+            )
+        result = cfg.process_command([], image=override_image_name)
+        assert_equal(result.image, override_image_name)
+
     ############################################################################
     # Hooks