Repository: JonathonReinhart/scuba

Issue:
Allow command aliases to be associated with images
This builds upon #60, which allows for aliases to be multiline scripts, and also allows those aliases to run with a different Docker image.

Example `.scuba.yml`:
```yaml
image: default_image_if_not_overridden
aliases:
  foo: simple alias string
  bar:
    image: this overrides the top-level image
    script: a simple script
  snap:
    image: this overrides the top-level image
    script:
      - a complex script
      - with multiple commands
```

By using this schema, we can `!from_yaml` an entire alias definition directly from a [`.gitlab-ci.yml`] file. This allows scuba behave somewhat like a local CI runner.

[`.gitlab-ci.yml`]: http://doc.gitlab.com/ce/ci/yaml/README.html

Discussion:
codecov-io: ## [Current coverage][cc-pull] is 98.24% (diff: 100%)
> Merging [#68][cc-pull] into [master][cc-base-branch] will increase coverage by **0.13%**


```diff
@@             master        #68   diff @@
==========================================
  Files             7          7          
  Lines           371        399    +28   
  Methods           0          0          
  Messages          0          0          
  Branches          0          0          
==========================================
+ Hits            364        392    +28   
  Misses            7          7          
  Partials          0          0          
```

> Powered by [Codecov](https://codecov.io?src=pr). Last update [7e74ccd...17bfbc7][cc-compare]
[cc-base-branch]: https://codecov.io/gh/JonathonReinhart/scuba/branch/master?src=pr
[cc-compare]: https://codecov.io/gh/JonathonReinhart/scuba/compare/7e74ccde08d9943a71697b6c9210ae483fd0d2fe...17bfbc77adb33ab4ed30098f8d3b468b5c23062f?src=pr
[cc-pull]: https://codecov.io/gh/JonathonReinhart/scuba/pull/68?src=pr

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2785697..fad024c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
 ## [Unreleased]
 ### Added
 - Added support for enhanced aliases (#67)
+- Added support for per-alias image specification (#68)
 
 ### Changed
 - All ancillary files are bind-mounted via single temp dir
diff --git a/example/per_alias_image/.scuba.yml b/example/per_alias_image/.scuba.yml
new file mode 100644
index 0000000..9b9bae0
--- /dev/null
+++ b/example/per_alias_image/.scuba.yml
@@ -0,0 +1,14 @@
+image: !from_yaml ../common.yml image
+aliases:
+
+  # This one inherits the default, top-level 'image'
+  default:
+    script:
+      - cat /etc/os-release
+
+  # This one specifies a different image to use
+  different:
+    image: alpine
+    script:
+      - cat /etc/os-release
+
diff --git a/example/per_alias_image/run_example.sh b/example/per_alias_image/run_example.sh
new file mode 100755
index 0000000..767ba50
--- /dev/null
+++ b/example/per_alias_image/run_example.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+cd $(dirname $0)
+
+echo -e "\nRunning 'scuba default'"
+scuba default
+
+echo -e "\nRunning 'scuba different'"
+scuba different 
+
+echo ""
diff --git a/example/run_all.sh b/example/run_all.sh
index 9cabda1..e4d84f7 100755
--- a/example/run_all.sh
+++ b/example/run_all.sh
@@ -8,3 +8,4 @@ cd $(dirname $0)
 ./external_yaml_nested/run_example.sh
 ./scubainit_hooks/run_example.sh
 ./alias_multiline/run_example.sh
+./per_alias_image/run_example.sh
diff --git a/scuba/__main__.py b/scuba/__main__.py
index a0f4ef3..87c647b 100644
--- a/scuba/__main__.py
+++ b/scuba/__main__.py
@@ -125,6 +125,9 @@ class ScubaDive(object):
             writeln(s, '      {0} => {1} {2}'.format(hostpath, contpath, options))
 
         writeln(s, '   user_command: {0}'.format(self.user_command))
+        writeln(s, '   context:')
+        writeln(s, '     script: ' + str(self.context.script)) 
+        writeln(s, '     image:  ' + str(self.context.image)) 
 
         return s.getvalue()
 
@@ -231,7 +234,7 @@ class ScubaDive(object):
 
         # Process any aliases
         try:
-            script = self.config.process_command(self.user_command)
+            context = self.config.process_command(self.user_command)
         except ConfigError as cfgerr:
             raise ScubaError(str(cfgerr))
 
@@ -240,14 +243,14 @@ class ScubaDive(object):
         default CMD is run. Because we set the entrypiont, scuba must emulate the
         default behavior itself.
         '''
-        if len(script) == 0:
+        if not context.script:
             # No user-provided command; we want to run the image's default command
             verbose_msg('No user command; getting command from image')
             try:
-                script = [get_image_command(self.config.image)]
+                context.script = [get_image_command(context.image)]
             except DockerError as e:
                 raise ScubaError(str(e))
-            verbose_msg('{0} Cmd: "{1}"'.format(self.config.image, script[0]))
+            verbose_msg('{0} Cmd: "{1}"'.format(context.image, context.script[0]))
 
         # The user command is executed via a generated shell script
         with self.open_scubadir_file('command.sh', 'wt') as f:
@@ -255,9 +258,11 @@ class ScubaDive(object):
             writeln(f, '#!/bin/sh')
             writeln(f, '# Auto-generated from scuba')
             writeln(f, 'set -e')
-            for cmd in script:
+            for cmd in context.script:
                 writeln(f, shell_quote_cmd(cmd))
 
+        self.context = context
+
 
 
     def open_scubadir_file(self, name, mode):
@@ -329,7 +334,7 @@ class ScubaDive(object):
         args += self.options
 
         # Docker image
-        args.append(self.config.image)
+        args.append(self.context.image)
 
         # Command to run in container
         args += self.docker_cmd
diff --git a/scuba/config.py b/scuba/config.py
index 9a7fd41..07bed29 100644
--- a/scuba/config.py
+++ b/scuba/config.py
@@ -103,6 +103,46 @@ def find_config():
         rel = os.path.join(rest, rel)
 
 
+def _process_script_node(node, name):
+    '''Process a script-type node
+
+    This handles nodes that follow the *Common script schema*,
+    as outlined in doc/yaml-reference.md.
+    '''
+    if isinstance(node, basestring):
+        # The script is just the text itself
+        return [node]
+
+
+    if isinstance(node, dict):
+        # There must be a "script" key, which must be a list of strings
+        script = node.get('script')
+        if not script:
+            raise ConfigError("{0}: must have a 'script' subkey".format(name))
+
+        if not isinstance(script, list):
+            raise ConfigError("{0}.script: must be a list".format(name))
+
+        return script
+
+    raise ConfigError("{0}: must be string or dict".format(name))
+
+
+class ScubaAlias(object):
+    def __init__(self, name, script, image):
+        self.name = name
+        self.script = script
+        self.image = image
+
+    @classmethod
+    def from_dict(cls, name, node):
+        script = [shlex_split(cmd) for cmd in _process_script_node(node, name)]
+        image = node.get('image') if isinstance(node, dict) else None
+        return cls(name, script, image)
+
+class ScubaContext(object):
+    pass
+
 class ScubaConfig(object):
     def __init__(self, **data):
         required_nodes = ('image',)
@@ -126,51 +166,13 @@ class ScubaConfig(object):
         self._load_hooks(data)
 
 
-    def _process_script(self, node, name):
-        '''Process a script-type node
-
-        This can handle yaml of either a simple form:
-
-            node: this is my script
-
-        Or a more complex form (which allows for other sub-nodes):
-
-            node:
-              script:
-                - this is my script
-                - it has multiple parts
-
-        Other forms are disallowed:
-
-            node:
-              - this
-              - is
-              - forbidden
-        '''
-        if isinstance(node, basestring):
-            # The script is just the text itself
-            return [node]
-
-
-        if isinstance(node, dict):
-            # There must be a "script" key, which must be a list of strings
-            script = node.get('script')
-            if not script:
-                raise ConfigError("{0}: must have a 'script' subkey".format(name))
-
-            if not isinstance(script, list):
-                raise ConfigError("{0}.script: must be a list".format(name))
-
-            return script
-
-        raise ConfigError("{0}: must be string or dict".format(name))
 
 
     def _load_aliases(self, data):
         self._aliases = {}
 
         for name, node in data.get('aliases', {}).items():
-            self._aliases[name] = [shlex_split(cmd) for cmd in self._process_script(node, name)]
+            self._aliases[name] = ScubaAlias.from_dict(name, node)
 
 
     def _load_hooks(self, data):
@@ -179,7 +181,7 @@ class ScubaConfig(object):
         for name in ('user', 'root',):
             node = data.get('hooks', {}).get(name)
             if node:
-                hook = self._process_script(node, name)
+                hook = _process_script_node(node, name)
                 self._hooks[name] = hook
 
 
@@ -202,25 +204,39 @@ class ScubaConfig(object):
         Arguments:
             command     A user command list (e.g. argv)
 
-        Returns: A "script" - a list of command lists
+        Returns: A ScubaContext object with the following attributes:
+            script: a list of command lists
+            image: the docker image name to use
         '''
-        if not command:
-            return command
-
-        script = self.aliases.get(command[0])
-        if not script:
-            return [command]
-
-        if len(command) > 1:
-            # If an alias is a multiline script, then no additional
-            # arguments will be allowed in the scuba invocation.
-            if len(script) > 1:
-                raise ConfigError('Additional arguments not allowed with multi-line aliases')
-
-            command.pop(0)
-            return [script[0] + command]
-
-        return script
+        result = ScubaContext()
+        result.script = None
+        result.image = self.image
+
+        if command:
+            alias = self.aliases.get(command[0])
+            if not alias:
+                # Command is not an alias; use it as-is.
+                result.script = [command]
+            else:
+                # Using an alias
+                # Does this alias override the image?
+                if alias.image:
+                    result.image = alias.image
+
+                if len(alias.script) > 1:
+                    # Alias is a multiline script; no additional
+                    # arguments are allowed in the scuba invocation.
+                    if len(command) > 1:
+                        raise ConfigError('Additional arguments not allowed with multi-line aliases')
+                    result.script = alias.script
+
+                else:
+                    # Alias is a single-line script; perform substituion
+                    # and add user arguments.
+                    command.pop(0)
+                    result.script = [alias.script[0] + command]
+
+        return result
 
 
 def load_config(path):


Test patch:
diff --git a/tests/test_config.py b/tests/test_config.py
index 3843dad..6be5b4b 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -115,8 +115,8 @@ class TestConfig(TestCase):
         config = scuba.config.load_config('.scuba.yml')
         assert_equals(config.image, 'busybox')
         assert_equals(len(config.aliases), 2)
-        assert_seq_equal(config.aliases['foo'], [['bar']])
-        assert_seq_equal(config.aliases['snap'], [['crackle', 'pop']])
+        assert_seq_equal(config.aliases['foo'].script, [['bar']])
+        assert_seq_equal(config.aliases['snap'].script, [['crackle', 'pop']])
 
 
 
@@ -188,7 +188,7 @@ class TestConfig(TestCase):
                 image = 'na',
                 )
         result = cfg.process_command([])
-        assert_equal(result, [])
+        assert_equal(result.script, None)
 
 
     def test_process_command_no_aliases(self):
@@ -197,7 +197,7 @@ class TestConfig(TestCase):
                 image = 'na',
                 )
         result = cfg.process_command(['cmd', 'arg1', 'arg2'])
-        assert_equal(result, [['cmd', 'arg1', 'arg2']])
+        assert_equal(result.script, [['cmd', 'arg1', 'arg2']])
 
     def test_process_command_aliases_unused(self):
         '''process_command handles unused aliases'''
@@ -209,7 +209,7 @@ class TestConfig(TestCase):
                     ),
                 )
         result = cfg.process_command(['cmd', 'arg1', 'arg2'])
-        assert_equal(result, [['cmd', 'arg1', 'arg2']])
+        assert_equal(result.script, [['cmd', 'arg1', 'arg2']])
 
     def test_process_command_aliases_used_noargs(self):
         '''process_command handles aliases with no args'''
@@ -221,7 +221,7 @@ class TestConfig(TestCase):
                     ),
                 )
         result = cfg.process_command(['apple', 'arg1', 'arg2'])
-        assert_equal(result, [['banana', 'arg1', 'arg2']])
+        assert_equal(result.script, [['banana', 'arg1', 'arg2']])
 
     def test_process_command_aliases_used_withargs(self):
         '''process_command handles aliases with args'''
@@ -233,7 +233,7 @@ class TestConfig(TestCase):
                     ),
                 )
         result = cfg.process_command(['apple', 'arg1', 'arg2'])
-        assert_equal(result, [['banana', 'cherry', 'pie is good', 'arg1', 'arg2']])
+        assert_equal(result.script, [['banana', 'cherry', 'pie is good', 'arg1', 'arg2']])
 
     def test_process_command_multiline_aliases_used(self):
         '''process_command handles multiline aliases'''
@@ -248,7 +248,7 @@ class TestConfig(TestCase):
                     ),
                 )
         result = cfg.process_command(['apple'])
-        assert_equal(result, [
+        assert_equal(result.script, [
             ['banana', 'cherry', 'pie is good'],
             ['so', 'is', 'peach'],
         ])
@@ -267,6 +267,23 @@ class TestConfig(TestCase):
                 )
         assert_raises(scuba.config.ConfigError, cfg.process_command, ['apple', 'ARGS', 'NOT ALLOWED'])
 
+    def test_process_command_alias_overrides_image(self):
+        '''aliases can override the image'''
+        cfg = scuba.config.ScubaConfig(
+                image = 'default',
+                aliases = dict(
+                    apple = dict(
+                        script = [
+                            'banana cherry "pie is good"',
+                            'so is peach',
+                        ],
+                        image = 'overridden',
+                    ),
+                ),
+            )
+        result = cfg.process_command(['apple'])
+        assert_equal(result.image, 'overridden')
+
 
     ############################################################################
     # Hooks