Repository: JonathonReinhart/scuba
Pull request #176 (2020-11-28 02:22:40)

Refactor scuba.__main__
- Move config locating code out of `ScubaDive` and into `run_scuba()`
- Move `ScubaDive` out of `scuba/__main__.py` and into new `scuba/scuba.py`
- Make `ScubaContext` responsible for processing user command given configuration data
- Eliminate `ScubaDive.prepare()` call in consumer


Patch:
diff --git a/scuba/__main__.py b/scuba/__main__.py
index fb4aa2a..2c5dcaf 100644
--- a/scuba/__main__.py
+++ b/scuba/__main__.py
@@ -3,9 +3,7 @@
 # https://github.com/JonathonReinhart/scuba
 # PYTHON_ARGCOMPLETE_OK
 
-import os.path
-from pwd import getpwuid
-from grp import getgrgid
+import os
 import sys
 import shlex
 import itertools
@@ -17,40 +15,18 @@ except ImportError:
         @staticmethod
         def autocomplete(*_, **__):
             pass
-import tempfile
-import shutil
-from collections.abc import Mapping
-from io import StringIO
 
-from .constants import *
-from .config import find_config, load_config, ScubaConfig, \
-        ConfigError, ConfigNotFoundError
-from .utils import *
-from .version import __version__
-from .dockerutil import get_image_command, get_image_entrypoint, make_vol_opt, \
-        DockerError, DockerExecuteError
 from . import dockerutil
+from .config import find_config, ScubaConfig, ConfigError, ConfigNotFoundError
+from .dockerutil import DockerError, DockerExecuteError
+from .scuba import ScubaDive, ScubaError
+from .utils import format_cmdline, parse_env_var
+from .version import __version__
 
-# This is the path where all scuba-related things will be bind-mounted into the
-# container.
-SCUBA_DIR = '/.scuba'
 
 def appmsg(fmt, *args):
     print('scuba: ' + fmt.format(*args), file=sys.stderr)
 
-def verbose_msg(fmt, *args):
-    if g_verbose:
-        appmsg(fmt, *args)
-
-def get_umask():
-    # Same logic as bash/builtins/umask.def
-    val = os.umask(0o22)
-    os.umask(val)
-    return val
-
-def writeln(f, line):
-    f.write(line + '\n')
-
 
 def parse_scuba_args(argv):
 
@@ -110,342 +86,26 @@ def parse_scuba_args(argv):
     return args
 
 
-class ScubaError(Exception):
-    pass
-
-class ScubaDive:
-    def __init__(self, user_command, docker_args=None, env=None, as_root=False, verbose=False,
-            image_override=None, entrypoint=None, shell_override=None):
-
-        env = env or {}
-        if not isinstance(env, Mapping):
-            raise ValueError('Argument env must be dict-like')
-
-        self.user_command = user_command
-        self.as_root = as_root
-        self.verbose = verbose
-        self.image_override = image_override
-        self.entrypoint_override = entrypoint
-        self.shell_override = shell_override
-
-        # These will be added to docker run cmdline
-        self.env_vars = env
-        self.volumes = []
-        self.options = []
-        self.docker_args = docker_args or []
-        self.workdir = None
-
-        self.__locate_scubainit()
-        self.__load_config()
-
-
-    def prepare(self):
-        '''Prepare to run the docker command'''
-        self.__make_scubadir()
-
-        if self.is_remote_docker:
-            '''
-            Docker is running remotely (e.g. boot2docker on OSX).
-            We don't need to do any user setup whatsoever.
-
-            TODO: For now, remote instances won't have any .scubainit
-
-            See:
-            https://github.com/JonathonReinhart/scuba/issues/17
-            '''
-            raise ScubaError('Remote docker not supported (DOCKER_HOST is set)')
-
-        # Docker is running natively
-        self.__setup_native_run()
-
-        # Apply environment vars from .scuba.yml
-        self.env_vars.update(self.context.environment)
-
-    def __str__(self):
-        s = StringIO()
-
-        indent = '  '
-        level = 0
-
-        def writelist(name, vals):
-            writeln(s, '{}{}:'.format(indent*level, name))
-            for val in vals or ():
-                writeln(s, '{}{}'.format(indent*(level+1), val))
-
-        def writescl(name, val=''):
-            writeln(s, '{}{:<14s}{}'.format(indent*level, name+':', val))
-
-        writeln(s, 'ScubaDive')
-        level += 1
-
-        writescl('verbose', self.verbose)
-        writescl('as_root', self.as_root)
-        writescl('workdir', self.workdir)
-
-        writelist('options', self.options)
-        writelist('docker_args', self.docker_args)
-        writelist('env_vars', ('{}={}'.format(*e) for e in self.env_vars.items()))
-        writelist('volumes', ('{} => {} {}'.format(hp, cp, opt)
-                              for hp, cp, opt in self.__get_vol_opts()))
-
-        writescl('user_command', self.user_command)
-        writescl('context')
-        level += 1
-        writescl('script', self.context.script)
-        writescl('image', self.context.image)
-        writelist('docker_args', self.context.docker_args)
-
-        return s.getvalue()
-
-
-    def cleanup_tempfiles(self):
-        shutil.rmtree(self.__scubadir_hostpath)
-
-
-    @property
-    def is_remote_docker(self):
-        return 'DOCKER_HOST' in os.environ
-
-    def add_env(self, name, val):
-        '''Add an environment variable to the docker run invocation
-        '''
-        if name in self.env_vars:
-            raise KeyError(name)
-        self.env_vars[name] = val
-
-    def add_volume(self, hostpath, contpath, options=None):
-        '''Add a volume (bind-mount) to the docker run invocation
-        '''
-        if options is None:
-            options = []
-        self.volumes.append((hostpath, contpath, options))
-
-    def add_option(self, option):
-        '''Add another option to the docker run invocation
-        '''
-        self.options.append(option)
-
-    def set_workdir(self, workdir):
-        self.workdir = workdir
-
-    def __locate_scubainit(self):
-        '''Determine path to scubainit binary
-        '''
-        pkg_path = os.path.dirname(__file__)
-
-        self.scubainit_path = os.path.join(pkg_path, 'scubainit')
-        if not os.path.isfile(self.scubainit_path):
-            raise ScubaError('scubainit not found at "{}"'.format(self.scubainit_path))
-
-    def __load_config(self):
-        '''Find and load .scuba.yml
-        '''
-
+def run_scuba(scuba_args):
+    # Locate .scuba.yml
+    try:
         # top_path is where .scuba.yml is found, and becomes the top of our bind mount.
         # top_rel is the relative path from top_path to the current working directory,
         # and is where we'll set the working directory in the container (relative to
         # the bind mount point).
-        try:
-            top_path, top_rel, self.config = find_config()
-        except ConfigNotFoundError as cfgerr:
-            # SCUBA_YML can be missing if --image was given.
-            # In this case, we assume a default config
-            if not self.image_override:
-                raise ScubaError(str(cfgerr))
-            top_path, top_rel = os.getcwd(), ''
-            self.config = ScubaConfig(image=None)
-        except ConfigError as cfgerr:
-            raise ScubaError(str(cfgerr))
-
-        # Mount scuba root directory at the same path in the container...
-        self.add_volume(top_path, top_path)
-
-        # ...and set the working dir relative to it
-        self.set_workdir(os.path.join(top_path, top_rel))
-
-        self.add_env('SCUBA_ROOT', top_path)
-
-    def __make_scubadir(self):
-        '''Make temp directory where all ancillary files are bind-mounted
-        '''
-        self.__scubadir_hostpath = tempfile.mkdtemp(prefix='scubadir')
-        self.__scubadir_contpath = '/.scuba'
-        self.add_volume(self.__scubadir_hostpath, self.__scubadir_contpath)
-
-    def __setup_native_run(self):
-        # These options are appended to mounted volume arguments
-        # NOTE: This tells Docker to re-label the directory for compatibility
-        # with SELinux. See `man docker-run` for more information.
-        self.vol_opts = ['z']
-
-        # Process any aliases
-        context = self.config.process_command(self.user_command,
-                image=self.image_override, shell=self.shell_override)
-
-        # Pass variables to scubainit
-        self.add_env('SCUBAINIT_UMASK', '{:04o}'.format(get_umask()))
-
-        # Check if the CLI args specify "run as root", or if the command (alias) does
-        if not self.as_root and not context.as_root:
-            uid = os.getuid()
-            gid = os.getgid()
-            self.add_env('SCUBAINIT_UID', uid)
-            self.add_env('SCUBAINIT_GID', gid)
-            self.add_env('SCUBAINIT_USER', getpwuid(uid).pw_name)
-            self.add_env('SCUBAINIT_GROUP', getgrgid(gid).gr_name)
-
-        if self.verbose:
-            self.add_env('SCUBAINIT_VERBOSE', 1)
-
-
-        # Copy scubainit into the container
-        # We make a copy because Docker 1.13 gets pissed if we try to re-label
-        # /usr, and Fedora 28 gives an AVC denial.
-        scubainit_cpath = self.copy_scubadir_file('scubainit', self.scubainit_path)
-
-        # Hooks
-        for name in ('root', 'user', ):
-            self.__generate_hook_script(name, context.shell)
-
-        # allocate TTY if scuba's output is going to a terminal
-        # and stdin is not redirected
-        if sys.stdout.isatty() and sys.stdin.isatty():
-            self.add_option('--tty')
-
-
-        '''
-        Normally, if the user provides no command to "docker run", the image's
-        default CMD is run. Because we set the entrypiont, scuba must emulate the
-        default behavior itself.
-        '''
-        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')
-            default_cmd = get_image_command(context.image)
-            if not default_cmd:
-                raise ScubaError('No command given and no image-specified command')
-            verbose_msg('{} Cmd: "{}"'.format(context.image, default_cmd))
-            context.script = [shell_quote_cmd(default_cmd)]
-
-        # Make scubainit the real entrypoint, and use the defined entrypoint as
-        # the docker command (if it exists)
-        self.add_option('--entrypoint={}'.format(scubainit_cpath))
-
-        self.docker_cmd = []
-        if self.entrypoint_override is not None:
-            # --entrypoint takes precedence
-            if self.entrypoint_override != '':
-                self.docker_cmd = [self.entrypoint_override]
-        elif context.entrypoint is not None:
-            # then .scuba.yml
-            if context.entrypoint != '':
-                self.docker_cmd = [context.entrypoint]
-        else:
-            ep = get_image_entrypoint(context.image)
-            if ep:
-                self.docker_cmd = ep
-
-        # The user command is executed via a generated shell script
-        with self.open_scubadir_file('command.sh', 'wt') as f:
-            self.docker_cmd += [context.shell, f.container_path]
-            writeln(f, '# Auto-generated from scuba')
-            writeln(f, 'set -e')
-            for cmd in context.script:
-                writeln(f, cmd)
-
-        self.context = context
-
-
-
-    def open_scubadir_file(self, name, mode):
-        '''Opens a file in the 'scubadir'
-
-        This file will automatically be bind-mounted into the container,
-        at a path given by the 'container_path' property on the returned file object.
-        '''
-        path = os.path.join(self.__scubadir_hostpath, name)
-        assert not os.path.exists(path)
-
-        # Make any directories required
-        os.makedirs(os.path.dirname(path), exist_ok=True)
-
-        f = open(path, mode)
-        f.container_path = os.path.join(self.__scubadir_contpath, name)
-        return f
-
-
-    def copy_scubadir_file(self, name, source):
-        '''Copies source into the scubadir
-
-        Returns the container-path of the copied file
-        '''
-        dest = os.path.join(self.__scubadir_hostpath, name)
-        assert not os.path.exists(dest)
-        shutil.copy2(source, dest)
-
-        return os.path.join(self.__scubadir_contpath, name)
-
-
-    def __generate_hook_script(self, name, shell):
-        script = self.config.hooks.get(name)
-        if not script:
-            return
-
-        # Generate the hook script, mount it into the container, and tell scubainit
-        with self.open_scubadir_file('hooks/{}.sh'.format(name), 'wt') as f:
-
-            self.add_env('SCUBAINIT_HOOK_{}'.format(name.upper()), f.container_path)
-
-            writeln(f, '#!{}'.format(shell))
-            writeln(f, '# Auto-generated from .scuba.yml')
-            writeln(f, 'set -e')
-            for cmd in script:
-                writeln(f, cmd)
-
-    def __get_vol_opts(self):
-        for hostpath, contpath, options in self.volumes:
-            yield hostpath, contpath, options + self.vol_opts
-
-    def get_docker_cmdline(self):
-        args = ['docker', 'run',
-            # interactive: keep STDIN open
-            '-i',
-
-            # remove container after exit
-            '--rm',
-        ]
-
-        for name,val in self.env_vars.items():
-            args.append('--env={}={}'.format(name, val))
-
-        for hostpath, contpath, options in self.__get_vol_opts():
-            args.append(make_vol_opt(hostpath, contpath, options))
-
-        if self.workdir:
-            args += ['-w', self.workdir]
-
-        args += self.options
-
-        # .scuba.yml (top-level or alias)
-        if self.context.docker_args is not None:
-            args += self.context.docker_args
-
-        # Command-line -d
-        if self.docker_args:
-            args += self.docker_args
-
-        # Docker image
-        args.append(self.context.image)
-
-        # Command to run in container
-        args += self.docker_cmd
-
-        return args
-
-
-def run_scuba(scuba_args):
+        top_path, top_rel, config = find_config()
+    except ConfigNotFoundError:
+        # .scuba.yml is allowed to be missing if --image was given.
+        if not scuba_args.image:
+            raise
+        top_path, top_rel, config = os.getcwd(), '', ScubaConfig()
+
+    # Set up scuba Docker invocation
     dive = ScubaDive(
-        scuba_args.command,
+        user_command = scuba_args.command,
+        config = config,
+        top_path = top_path,
+        top_rel = top_rel,
         docker_args = scuba_args.docker_args,
         env = scuba_args.env_vars,
         as_root = scuba_args.root,
@@ -453,10 +113,10 @@ def run_scuba(scuba_args):
         image_override = scuba_args.image,
         entrypoint = scuba_args.entrypoint,
         shell_override = scuba_args.shell,
+        keep_tempfiles = scuba_args.dry_run,
         )
 
-    try:
-        dive.prepare()
+    with dive:
         run_args = dive.get_docker_cmdline()
 
         if g_verbose or scuba_args.dry_run:
@@ -467,7 +127,8 @@ def run_scuba(scuba_args):
             print('$ ' + format_cmdline(run_args))
 
         if scuba_args.dry_run:
-            sys.exit(42)
+            appmsg("Temp files not cleaned up")
+            return 0
 
         # Explicitly pass sys.stdin/stdout/stderr so they apply to the
         # child process if overridden (by tests).
@@ -478,12 +139,6 @@ def run_scuba(scuba_args):
                 stderr = sys.stderr,
                 )
 
-    finally:
-        if scuba_args.dry_run:
-            appmsg("Temp files not cleaned up")
-        else:
-            dive.cleanup_tempfiles()
-
 
 def main(argv=None):
     scuba_args = parse_scuba_args(argv)
diff --git a/scuba/config.py b/scuba/config.py
index 1c85e3a..2212c1b 100644
--- a/scuba/config.py
+++ b/scuba/config.py
@@ -272,8 +272,6 @@ class ScubaAlias:
 
         return cls(name, script, image, entrypoint, environment, shell, as_root, docker_args)
 
-class ScubaContext:
-    pass
 
 class ScubaConfig:
     def __init__(self, **data):
@@ -349,85 +347,6 @@ class ScubaConfig:
         return self._docker_args
 
 
-    def process_command(self, command, image=None, shell=None):
-        '''Processes a user command using aliases
-
-        Arguments:
-            command     A user command list (e.g. argv)
-            image       Override the image from .scuba.yml
-            shell       Override the shell from .scuba.yml
-
-        Returns: A ScubaContext object with the following attributes:
-            script: a list of command line strings
-            image: the docker image name to use
-        '''
-        result = ScubaContext()
-        result.script = None
-        result.image = None
-        result.entrypoint = self.entrypoint
-        result.environment = self.environment.copy()
-        result.shell = self.shell
-        result.as_root = False
-        result.docker_args = self.docker_args
-
-        if command:
-            alias = self.aliases.get(command[0])
-            if not alias:
-                # Command is not an alias; use it as-is.
-                result.script = [shell_quote_cmd(command)]
-            else:
-                # Using an alias
-                # Does this alias override the image and/or entrypoint?
-                if alias.image:
-                    result.image = alias.image
-                if alias.entrypoint is not None:
-                    result.entrypoint = alias.entrypoint
-                if alias.shell is not None:
-                    result.shell = alias.shell
-                if alias.as_root:
-                    result.as_root = True
-
-                if isinstance(alias.docker_args, OverrideMixin) or result.docker_args is None:
-                    result.docker_args = alias.docker_args
-                elif alias.docker_args is not None:
-                    result.docker_args.extend(alias.docker_args)
-
-                # Merge/override the environment
-                if alias.environment:
-                    result.environment.update(alias.environment)
-
-                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] + ' ' + shell_quote_cmd(command)]
-
-            result.script = flatten_list(result.script)
-
-        # If a shell was given on the CLI, it should override the shell set by
-        # the alias or top-level config
-        if shell:
-            result.shell = shell
-
-        # 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
-
-
 def load_config(path):
     try:
         with open(path, 'r') as f:
diff --git a/scuba/scuba.py b/scuba/scuba.py
new file mode 100644
index 0000000..525bd88
--- /dev/null
+++ b/scuba/scuba.py
@@ -0,0 +1,440 @@
+import os
+import shutil
+import sys
+import tempfile
+from collections.abc import Mapping
+from grp import getgrgid
+from io import StringIO
+from pwd import getpwuid
+
+from .config import find_config, ScubaConfig, OverrideMixin
+from .config import ConfigError, ConfigNotFoundError
+from .dockerutil import get_image_command
+from .dockerutil import get_image_entrypoint
+from .dockerutil import make_vol_opt
+from .utils import shell_quote_cmd, flatten_list
+
+
+class ScubaError(Exception):
+    pass
+
+class ScubaDive:
+    def __init__(self, user_command, config, top_path, top_rel,
+            docker_args=None, env=None, as_root=False, verbose=False,
+            image_override=None, entrypoint=None, shell_override=None, keep_tempfiles=False):
+
+        env = env or {}
+        if not isinstance(env, Mapping):
+            raise ValueError('Argument env must be dict-like')
+
+        self.as_root = as_root
+        self.verbose = verbose
+        self.entrypoint_override = entrypoint
+        self.keep_tempfiles = keep_tempfiles
+
+        # These will be added to docker run cmdline
+        self.env_vars = env
+        self.volumes = []
+        self.options = []
+        self.docker_args = docker_args or []
+        self.workdir = None
+
+        self.__scubadir_hostpath = None
+        self.__scubadir_contpath = None
+        self.config = config
+
+
+        # Mount scuba root directory at the same path in the container...
+        self.add_volume(top_path, top_path)
+
+        # ...and set the working dir relative to it
+        self.set_workdir(os.path.join(top_path, top_rel))
+
+        self.add_env('SCUBA_ROOT', top_path)
+
+
+        try:
+
+            # Process any aliases
+            self.context = ScubaContext.process_command(
+                                      cfg = self.config,
+                                      command = user_command,
+                                      image = image_override,
+                                      shell = shell_override,
+                                      )
+
+            # Apply environment vars from .scuba.yml
+            self.env_vars.update(self.context.environment)
+
+            self.__make_scubadir()
+
+            if self.is_remote_docker:
+                '''
+                Docker is running remotely (e.g. boot2docker on OSX).
+                We don't need to do any user setup whatsoever.
+
+                TODO: For now, remote instances won't have any .scubainit
+
+                See:
+                https://github.com/JonathonReinhart/scuba/issues/17
+                '''
+                raise ScubaError('Remote docker not supported (DOCKER_HOST is set)')
+
+            # Docker is running natively
+            self.__setup_native_run()
+        except:
+            self._cleanup()
+            raise
+
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, *exc_info):
+        self._cleanup()
+
+    def _cleanup(self):
+        if self.__scubadir_hostpath and not self.keep_tempfiles:
+            shutil.rmtree(self.__scubadir_hostpath)
+
+    def __str__(self):
+        s = StringIO()
+
+        indent = '  '
+        level = 0
+
+        def writelist(name, vals):
+            writeln(s, '{}{}:'.format(indent*level, name))
+            for val in vals or ():
+                writeln(s, '{}{}'.format(indent*(level+1), val))
+
+        def writescl(name, val=''):
+            writeln(s, '{}{:<14s}{}'.format(indent*level, name+':', val))
+
+        writeln(s, 'ScubaDive')
+        level += 1
+
+        writescl('verbose', self.verbose)
+        writescl('as_root', self.as_root)
+        writescl('workdir', self.workdir)
+
+        writelist('options', self.options)
+        writelist('docker_args', self.docker_args)
+        writelist('env_vars', ('{}={}'.format(*e) for e in self.env_vars.items()))
+        writelist('volumes', ('{} => {} {}'.format(hp, cp, opt)
+                              for hp, cp, opt in self.__get_vol_opts()))
+
+        writescl('context')
+        level += 1
+        writescl('script', self.context.script)
+        writescl('image', self.context.image)
+        writelist('docker_args', self.context.docker_args)
+
+        return s.getvalue()
+
+
+
+
+    @property
+    def is_remote_docker(self):
+        return 'DOCKER_HOST' in os.environ
+
+    def add_env(self, name, val):
+        '''Add an environment variable to the docker run invocation
+        '''
+        if name in self.env_vars:
+            raise KeyError(name)
+        self.env_vars[name] = val
+
+    def add_volume(self, hostpath, contpath, options=None):
+        '''Add a volume (bind-mount) to the docker run invocation
+        '''
+        if options is None:
+            options = []
+        self.volumes.append((hostpath, contpath, options))
+
+    def add_option(self, option):
+        '''Add another option to the docker run invocation
+        '''
+        self.options.append(option)
+
+    def set_workdir(self, workdir):
+        self.workdir = workdir
+
+    def __locate_scubainit(self):
+        '''Determine path to scubainit binary
+        '''
+        pkg_path = os.path.dirname(__file__)
+
+        scubainit_path = os.path.join(pkg_path, 'scubainit')
+        if not os.path.isfile(scubainit_path):
+            raise ScubaError('scubainit not found at "{}"'.format(scubainit_path))
+        return scubainit_path
+
+    def __make_scubadir(self):
+        '''Make temp directory where all ancillary files are bind-mounted
+        '''
+        self.__scubadir_hostpath = tempfile.mkdtemp(prefix='scubadir')
+        self.__scubadir_contpath = '/.scuba'
+        self.add_volume(self.__scubadir_hostpath, self.__scubadir_contpath)
+
+    def __setup_native_run(self):
+        # These options are appended to mounted volume arguments
+        # NOTE: This tells Docker to re-label the directory for compatibility
+        # with SELinux. See `man docker-run` for more information.
+        self.vol_opts = ['z']
+
+        # Pass variables to scubainit
+        self.add_env('SCUBAINIT_UMASK', '{:04o}'.format(get_umask()))
+
+        # Check if the CLI args specify "run as root", or if the command (alias) does
+        if not self.as_root and not self.context.as_root:
+            uid = os.getuid()
+            gid = os.getgid()
+            self.add_env('SCUBAINIT_UID', uid)
+            self.add_env('SCUBAINIT_GID', gid)
+            self.add_env('SCUBAINIT_USER', getpwuid(uid).pw_name)
+            self.add_env('SCUBAINIT_GROUP', getgrgid(gid).gr_name)
+
+        if self.verbose:
+            self.add_env('SCUBAINIT_VERBOSE', 1)
+
+
+        # Copy scubainit into the container
+        # We make a copy because Docker 1.13 gets pissed if we try to re-label
+        # /usr, and Fedora 28 gives an AVC denial.
+        scubainit_cpath = self.copy_scubadir_file('scubainit', self.__locate_scubainit())
+
+        # Hooks
+        for name in ('root', 'user', ):
+            self.__generate_hook_script(name, self.context.shell)
+
+        # allocate TTY if scuba's output is going to a terminal
+        # and stdin is not redirected
+        if sys.stdout.isatty() and sys.stdin.isatty():
+            self.add_option('--tty')
+
+
+        '''
+        Normally, if the user provides no command to "docker run", the image's
+        default CMD is run. Because we set the entrypiont, scuba must emulate the
+        default behavior itself.
+        '''
+        if not self.context.script:
+            # No user-provided command; we want to run the image's default command
+            default_cmd = get_image_command(self.context.image)
+            if not default_cmd:
+                raise ScubaError('No command given and no image-specified command')
+            self.context.script = [shell_quote_cmd(default_cmd)]
+
+        # Make scubainit the real entrypoint, and use the defined entrypoint as
+        # the docker command (if it exists)
+        self.add_option('--entrypoint={}'.format(scubainit_cpath))
+
+        self.docker_cmd = []
+        if self.entrypoint_override is not None:
+            # --entrypoint takes precedence
+            if self.entrypoint_override != '':
+                self.docker_cmd = [self.entrypoint_override]
+        elif self.context.entrypoint is not None:
+            # then .scuba.yml
+            if self.context.entrypoint != '':
+                self.docker_cmd = [self.context.entrypoint]
+        else:
+            ep = get_image_entrypoint(self.context.image)
+            if ep:
+                self.docker_cmd = ep
+
+        # The user command is executed via a generated shell script
+        with self.open_scubadir_file('command.sh', 'wt') as f:
+            self.docker_cmd += [self.context.shell, f.container_path]
+            writeln(f, '# Auto-generated from scuba')
+            writeln(f, 'set -e')
+            for cmd in self.context.script:
+                writeln(f, cmd)
+
+
+    def open_scubadir_file(self, name, mode):
+        '''Opens a file in the 'scubadir'
+
+        This file will automatically be bind-mounted into the container,
+        at a path given by the 'container_path' property on the returned file object.
+        '''
+        path = os.path.join(self.__scubadir_hostpath, name)
+        assert not os.path.exists(path)
+
+        # Make any directories required
+        os.makedirs(os.path.dirname(path), exist_ok=True)
+
+        f = open(path, mode)
+        f.container_path = os.path.join(self.__scubadir_contpath, name)
+        return f
+
+
+    def copy_scubadir_file(self, name, source):
+        '''Copies source into the scubadir
+
+        Returns the container-path of the copied file
+        '''
+        dest = os.path.join(self.__scubadir_hostpath, name)
+        assert not os.path.exists(dest)
+        shutil.copy2(source, dest)
+
+        return os.path.join(self.__scubadir_contpath, name)
+
+
+    def __generate_hook_script(self, name, shell):
+        script = self.config.hooks.get(name)
+        if not script:
+            return
+
+        # Generate the hook script, mount it into the container, and tell scubainit
+        with self.open_scubadir_file('hooks/{}.sh'.format(name), 'wt') as f:
+
+            self.add_env('SCUBAINIT_HOOK_{}'.format(name.upper()), f.container_path)
+
+            writeln(f, '#!{}'.format(shell))
+            writeln(f, '# Auto-generated from .scuba.yml')
+            writeln(f, 'set -e')
+            for cmd in script:
+                writeln(f, cmd)
+
+    def __get_vol_opts(self):
+        for hostpath, contpath, options in self.volumes:
+            yield hostpath, contpath, options + self.vol_opts
+
+    def get_docker_cmdline(self):
+        args = ['docker', 'run',
+            # interactive: keep STDIN open
+            '-i',
+
+            # remove container after exit
+            '--rm',
+        ]
+
+        for name,val in self.env_vars.items():
+            args.append('--env={}={}'.format(name, val))
+
+        for hostpath, contpath, options in self.__get_vol_opts():
+            args.append(make_vol_opt(hostpath, contpath, options))
+
+        if self.workdir:
+            args += ['-w', self.workdir]
+
+        args += self.options
+
+        # .scuba.yml (top-level or alias)
+        if self.context.docker_args is not None:
+            args += self.context.docker_args
+
+        # Command-line -d
+        if self.docker_args:
+            args += self.docker_args
+
+        # Docker image
+        args.append(self.context.image)
+
+        # Command to run in container
+        args += self.docker_cmd
+
+        return args
+
+
+class ScubaContext:
+    def __init__(self, image=None, script=None, entrypoint=None, environment=None, shell=None, docker_args=None):
+        self.image = image
+        self.script = script
+        self.as_root = False
+        self.entrypoint = entrypoint
+        self.environment = environment
+        self.shell = shell
+        self.docker_args = docker_args
+
+    @classmethod
+    def process_command(cls, cfg, command, image=None, shell=None):
+        '''Processes a user command using aliases
+
+        Arguments:
+            cfg         ScubaConfig object
+            command     A user command list (e.g. argv)
+            image       Override the image from .scuba.yml
+            shell       Override the shell from .scuba.yml
+
+        Returns: A ScubaContext object with the following attributes:
+            script: a list of command line strings
+            image: the docker image name to use
+        '''
+        result = cls(
+                entrypoint = cfg.entrypoint,
+                environment = cfg.environment.copy(),
+                shell = cfg.shell,
+                docker_args = cfg.docker_args,
+                )
+
+        if command:
+            alias = cfg.aliases.get(command[0])
+            if not alias:
+                # Command is not an alias; use it as-is.
+                result.script = [shell_quote_cmd(command)]
+            else:
+                # Using an alias
+                # Does this alias override the image and/or entrypoint?
+                if alias.image:
+                    result.image = alias.image
+                if alias.entrypoint is not None:
+                    result.entrypoint = alias.entrypoint
+                if alias.shell is not None:
+                    result.shell = alias.shell
+                if alias.as_root:
+                    result.as_root = True
+
+                if isinstance(alias.docker_args, OverrideMixin) or result.docker_args is None:
+                    result.docker_args = alias.docker_args
+                elif alias.docker_args is not None:
+                    result.docker_args.extend(alias.docker_args)
+
+                # Merge/override the environment
+                if alias.environment:
+                    result.environment.update(alias.environment)
+
+                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] + ' ' + shell_quote_cmd(command)]
+
+            result.script = flatten_list(result.script)
+
+        # If a shell was given on the CLI, it should override the shell set by
+        # the alias or top-level config
+        if shell:
+            result.shell = shell
+
+        # 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 = cfg.image
+
+        return result
+
+
+def writeln(f, line):
+    f.write(line + '\n')
+
+
+def get_umask():
+    # Same logic as bash/builtins/umask.def
+    val = os.umask(0o22)
+    os.umask(val)
+    return val


Test patch:
diff --git a/tests/test_config.py b/tests/test_config.py
index fb24e28..b59b258 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -6,7 +6,6 @@ import logging
 import os
 from os.path import join
 from shutil import rmtree
-import shlex
 
 import scuba.config
 
@@ -284,276 +283,6 @@ class TestConfig:
 
         self.__test_load_config_safe('.external.yml')
 
-    ######################################################################
-    # process_command
-
-    def test_process_command_image(self):
-        '''process_command returns the image and entrypoint'''
-        image_name = 'test_image'
-        entrypoint = 'test_entrypoint'
-
-        cfg = scuba.config.ScubaConfig(
-                image = image_name,
-                entrypoint = entrypoint,
-                )
-        result = cfg.process_command([])
-        assert result.image == image_name
-        assert result.entrypoint == entrypoint
-
-    def test_process_command_empty(self):
-        '''process_command handles no aliases and an empty command'''
-        cfg = scuba.config.ScubaConfig(
-                image = 'na',
-                )
-        result = cfg.process_command([])
-        assert result.script == None
-
-
-    def test_process_command_no_aliases(self):
-        '''process_command handles no aliases'''
-        cfg = scuba.config.ScubaConfig(
-                image = 'na',
-                )
-        result = cfg.process_command(['cmd', 'arg1', 'arg2'])
-        assert [shlex.split(s) for s in result.script] == [
-            ['cmd', 'arg1', 'arg2'],
-        ]
-
-    def test_process_command_aliases_unused(self):
-        '''process_command handles unused aliases'''
-        cfg = scuba.config.ScubaConfig(
-                image = 'na',
-                aliases = dict(
-                    apple = 'banana',
-                    cat = 'dog',
-                    ),
-                )
-        result = cfg.process_command(['cmd', 'arg1', 'arg2'])
-        assert [shlex.split(s) for s in result.script] == [
-            ['cmd', 'arg1', 'arg2'],
-        ]
-
-    def test_process_command_aliases_used_noargs(self):
-        '''process_command handles aliases with no args'''
-        cfg = scuba.config.ScubaConfig(
-                image = 'na',
-                aliases = dict(
-                    apple = 'banana',
-                    cat = 'dog',
-                    ),
-                )
-        result = cfg.process_command(['apple', 'arg1', 'arg2'])
-        assert [shlex.split(s) for s in result.script] == [
-            ['banana', 'arg1', 'arg2'],
-        ]
-
-    def test_process_command_aliases_used_withargs(self):
-        '''process_command handles aliases with args'''
-        cfg = scuba.config.ScubaConfig(
-                image = 'na',
-                aliases = dict(
-                    apple = 'banana cherry "pie is good"',
-                    cat = 'dog',
-                    ),
-                )
-        result = cfg.process_command(['apple', 'arg1', 'arg2 with spaces'])
-        assert [shlex.split(s) for s in result.script] == [
-            ['banana', 'cherry', 'pie is good', 'arg1', 'arg2 with spaces'],
-        ]
-
-    def test_process_command_multiline_aliases_used(self):
-        '''process_command handles multiline aliases'''
-        cfg = scuba.config.ScubaConfig(
-                image = 'na',
-                aliases = dict(
-                    apple = dict(script=[
-                        'banana cherry "pie is good"',
-                        'so is peach',
-                    ]),
-                    cat = 'dog',
-                    ),
-                )
-        result = cfg.process_command(['apple'])
-        assert [shlex.split(s) for s in result.script] == [
-            ['banana', 'cherry', 'pie is good'],
-            ['so', 'is', 'peach'],
-        ]
-
-    def test_process_command_multiline_aliases_forbid_user_args(self):
-        '''process_command raises ConfigError when args are specified with multiline aliases'''
-        cfg = scuba.config.ScubaConfig(
-                image = 'na',
-                aliases = dict(
-                    apple = dict(script=[
-                        'banana cherry "pie is good"',
-                        'so is peach',
-                    ]),
-                    cat = 'dog',
-                    ),
-                )
-        with pytest.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 result.image == 'overridden'
-
-    def test_process_command_alias_overrides_image_and_entrypoint(self):
-        '''aliases can override the image and entrypoint'''
-        cfg = scuba.config.ScubaConfig(
-                image = 'default',
-                entrypoint = 'default_entrypoint',
-                aliases = dict(
-                    apple = dict(
-                        script = [
-                            'banana cherry "pie is good"',
-                            'so is peach',
-                        ],
-                        image = 'overridden',
-                        entrypoint = 'overridden_entrypoint',
-                    ),
-                ),
-            )
-        result = cfg.process_command(['apple'])
-        assert result.image == 'overridden'
-        assert result.entrypoint == 'overridden_entrypoint'
-
-    def test_process_command_alias_overrides_image_and_empty_entrypoint(self):
-        '''aliases can override the image and empty/null entrypoint'''
-        cfg = scuba.config.ScubaConfig(
-                image = 'default',
-                entrypoint = 'default_entrypoint',
-                aliases = dict(
-                    apple = dict(
-                        script = [
-                            'banana cherry "pie is good"',
-                            'so is peach',
-                        ],
-                        image = 'overridden',
-                        entrypoint = '',
-                    ),
-                ),
-            )
-        result = cfg.process_command(['apple'])
-        assert result.image == 'overridden'
-        assert 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 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 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 result.image == override_image_name
-
-    def test_process_command_alias_extends_docker_args(self):
-        '''aliases can extend the docker_args'''
-        cfg = scuba.config.ScubaConfig(
-                image = 'default',
-                docker_args = '--privileged',
-                aliases = dict(
-                    apple = dict(
-                        script = [
-                            'banana cherry "pie is good"',
-                        ],
-                        docker_args = '-v /tmp/:/tmp/',
-                    ),
-                ),
-            )
-        result = cfg.process_command(['apple'])
-        assert result.docker_args == ['--privileged', '-v', '/tmp/:/tmp/']
-
-    def test_process_command_alias_overrides_docker_args(self):
-        '''aliases can override the docker_args'''
-        cfg = scuba.config.ScubaConfig(
-                image = 'default',
-                docker_args = '--privileged',
-                aliases = dict(
-                    apple = dict(
-                        script = [
-                            'banana cherry "pie is good"',
-                        ],
-                        docker_args = scuba.config.OverrideStr('-v /tmp/:/tmp/'),
-                    ),
-                ),
-            )
-        result = cfg.process_command(['apple'])
-        assert result.docker_args == ['-v', '/tmp/:/tmp/']
-
-    def test_process_command_alias_overrides_docker_args_with_empty(self):
-        '''aliases can override the docker_args'''
-        cfg = scuba.config.ScubaConfig(
-                image = 'default',
-                docker_args = '--privileged',
-                aliases = dict(
-                    apple = dict(
-                        script = [
-                            'banana cherry "pie is good"',
-                        ],
-                        docker_args = scuba.config.OverrideStr(''),
-                    ),
-                ),
-            )
-        result = cfg.process_command(['apple'])
-        assert result.docker_args == []
-
-    def test_process_command_alias_inherits_top_docker_args(self):
-        '''aliases inherit the top-level docker_args if not specified'''
-        cfg = scuba.config.ScubaConfig(
-                image = 'default',
-                docker_args = '--privileged',
-                aliases = dict(
-                    apple = dict(
-                        script = [
-                            'banana cherry "pie is good"',
-                        ],
-                    ),
-                ),
-            )
-        result = cfg.process_command(['apple'])
-        assert result.docker_args == ['--privileged']
 
     ############################################################################
     # Hooks
@@ -685,13 +414,10 @@ class TestConfig:
 
 
     def test_env_alias(self):
-        '''Alias can have environment which overrides top-level'''
+        '''Alias can have environment'''
         with open('.scuba.yml', 'w') as f:
             f.write(r'''
                 image: na
-                environment:
-                  FOO: Top-level
-                  BAR: 42
                 aliases:
                   al:
                     script: Don't care
@@ -702,25 +428,11 @@ class TestConfig:
 
         config = scuba.config.load_config('.scuba.yml')
 
-        assert config.environment == dict(
-                FOO = "Top-level",
-                BAR = "42",
-            )
-
         assert config.aliases['al'].environment == dict(
                 FOO = "Overridden",
                 MORE = "Hello world",
             )
 
-        # Does the environment get overridden / merged?
-        ctx = config.process_command(['al'])
-
-        assert ctx.environment == dict(
-                FOO = "Overridden",
-                BAR = "42",
-                MORE = "Hello world",
-            )
-
 
     ############################################################################
     # Entrypoint
diff --git a/tests/test_main.py b/tests/test_main.py
index 83209b0..2c19f58 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -122,7 +122,7 @@ class TestMain:
 
         # http://alexmarandon.com/articles/python_mock_gotchas/#patching-in-the-wrong-place
         # http://www.voidspace.org.uk/python/mock/patch.html#where-to-patch
-        with mock.patch('scuba.__main__.get_image_command', side_effect=mocked_gic):
+        with mock.patch('scuba.scuba.get_image_command', side_effect=mocked_gic):
             # DockerError -> exit(128)
             self.run_scuba([], 128)
 
@@ -189,7 +189,7 @@ class TestMain:
 
         args = ['--dry-run', '--verbose', '/bin/false']
 
-        _, err = self.run_scuba(args, 42)
+        _, err = self.run_scuba(args)
 
         assert not subproc_call_mock.called
 
@@ -773,8 +773,8 @@ class TestMain:
                 BAZ = "From the command line",
             )
 
-    def test_builtin_env__SCUBA_DIR(self, in_tmp_path):
-        '''Verify SCUBA_DIR is set in container'''
+    def test_builtin_env__SCUBA_ROOT(self, in_tmp_path):
+        '''Verify SCUBA_ROOT is set in container'''
         with open('.scuba.yml', 'w') as f:
             f.write('image: {}\n'.format(DOCKER_IMAGE))
 
diff --git a/tests/test_scuba.py b/tests/test_scuba.py
new file mode 100644
index 0000000..9a9b748
--- /dev/null
+++ b/tests/test_scuba.py
@@ -0,0 +1,300 @@
+import pytest
+import shlex
+
+from scuba.config import ScubaConfig, ConfigError, OverrideStr
+from scuba.scuba import ScubaContext
+
+class TestScubaContext:
+    def test_process_command_image(self):
+        '''process_command returns the image and entrypoint'''
+        image_name = 'test_image'
+        entrypoint = 'test_entrypoint'
+
+        cfg = ScubaConfig(
+                image = image_name,
+                entrypoint = entrypoint,
+                )
+        result = ScubaContext.process_command(cfg, [])
+        assert result.image == image_name
+        assert result.entrypoint == entrypoint
+
+    def test_process_command_empty(self):
+        '''process_command handles no aliases and an empty command'''
+        cfg = ScubaConfig(
+                image = 'na',
+                )
+        result = ScubaContext.process_command(cfg, [])
+        assert result.script == None
+
+
+    def test_process_command_no_aliases(self):
+        '''process_command handles no aliases'''
+        cfg = ScubaConfig(
+                image = 'na',
+                )
+        result = ScubaContext.process_command(cfg, ['cmd', 'arg1', 'arg2'])
+        assert [shlex.split(s) for s in result.script] == [
+            ['cmd', 'arg1', 'arg2'],
+        ]
+
+    def test_process_command_aliases_unused(self):
+        '''process_command handles unused aliases'''
+        cfg = ScubaConfig(
+                image = 'na',
+                aliases = dict(
+                    apple = 'banana',
+                    cat = 'dog',
+                    ),
+                )
+        result = ScubaContext.process_command(cfg, ['cmd', 'arg1', 'arg2'])
+        assert [shlex.split(s) for s in result.script] == [
+            ['cmd', 'arg1', 'arg2'],
+        ]
+
+    def test_process_command_aliases_used_noargs(self):
+        '''process_command handles aliases with no args'''
+        cfg = ScubaConfig(
+                image = 'na',
+                aliases = dict(
+                    apple = 'banana',
+                    cat = 'dog',
+                    ),
+                )
+        result = ScubaContext.process_command(cfg, ['apple', 'arg1', 'arg2'])
+        assert [shlex.split(s) for s in result.script] == [
+            ['banana', 'arg1', 'arg2'],
+        ]
+
+    def test_process_command_aliases_used_withargs(self):
+        '''process_command handles aliases with args'''
+        cfg = ScubaConfig(
+                image = 'na',
+                aliases = dict(
+                    apple = 'banana cherry "pie is good"',
+                    cat = 'dog',
+                    ),
+                )
+        result = ScubaContext.process_command(cfg, ['apple', 'arg1', 'arg2 with spaces'])
+        assert [shlex.split(s) for s in result.script] == [
+            ['banana', 'cherry', 'pie is good', 'arg1', 'arg2 with spaces'],
+        ]
+
+    def test_process_command_multiline_aliases_used(self):
+        '''process_command handles multiline aliases'''
+        cfg = ScubaConfig(
+                image = 'na',
+                aliases = dict(
+                    apple = dict(script=[
+                        'banana cherry "pie is good"',
+                        'so is peach',
+                    ]),
+                    cat = 'dog',
+                    ),
+                )
+        result = ScubaContext.process_command(cfg, ['apple'])
+        assert [shlex.split(s) for s in result.script] == [
+            ['banana', 'cherry', 'pie is good'],
+            ['so', 'is', 'peach'],
+        ]
+
+    def test_process_command_multiline_aliases_forbid_user_args(self):
+        '''process_command raises ConfigError when args are specified with multiline aliases'''
+        cfg = ScubaConfig(
+                image = 'na',
+                aliases = dict(
+                    apple = dict(script=[
+                        'banana cherry "pie is good"',
+                        'so is peach',
+                    ]),
+                    cat = 'dog',
+                    ),
+                )
+        with pytest.raises(ConfigError):
+            ScubaContext.process_command(cfg, ['apple', 'ARGS', 'NOT ALLOWED'])
+
+    def test_process_command_alias_overrides_image(self):
+        '''aliases can override the image'''
+        cfg = ScubaConfig(
+                image = 'default',
+                aliases = dict(
+                    apple = dict(
+                        script = [
+                            'banana cherry "pie is good"',
+                            'so is peach',
+                        ],
+                        image = 'overridden',
+                    ),
+                ),
+            )
+        result = ScubaContext.process_command(cfg, ['apple'])
+        assert result.image == 'overridden'
+
+    def test_process_command_alias_overrides_image_and_entrypoint(self):
+        '''aliases can override the image and entrypoint'''
+        cfg = ScubaConfig(
+                image = 'default',
+                entrypoint = 'default_entrypoint',
+                aliases = dict(
+                    apple = dict(
+                        script = [
+                            'banana cherry "pie is good"',
+                            'so is peach',
+                        ],
+                        image = 'overridden',
+                        entrypoint = 'overridden_entrypoint',
+                    ),
+                ),
+            )
+        result = ScubaContext.process_command(cfg, ['apple'])
+        assert result.image == 'overridden'
+        assert result.entrypoint == 'overridden_entrypoint'
+
+    def test_process_command_alias_overrides_image_and_empty_entrypoint(self):
+        '''aliases can override the image and empty/null entrypoint'''
+        cfg = ScubaConfig(
+                image = 'default',
+                entrypoint = 'default_entrypoint',
+                aliases = dict(
+                    apple = dict(
+                        script = [
+                            'banana cherry "pie is good"',
+                            'so is peach',
+                        ],
+                        image = 'overridden',
+                        entrypoint = '',
+                    ),
+                ),
+            )
+        result = ScubaContext.process_command(cfg, ['apple'])
+        assert result.image == 'overridden'
+        assert result.entrypoint == ''
+
+
+    def test_process_command_image_override(self):
+        '''process_command allows image to be overridden when provided'''
+        override_image_name = 'override_image'
+
+        cfg = ScubaConfig(
+                image = 'test_image',
+                )
+        result = ScubaContext.process_command(cfg, [], image=override_image_name)
+        assert 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 = ScubaConfig()
+        result = ScubaContext.process_command(cfg, [], image=override_image_name)
+        assert 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 = ScubaConfig(
+                aliases = dict(
+                    apple = dict(
+                        script = [
+                            'banana cherry "pie is good"',
+                            'so is peach',
+                        ],
+                        image = 'apple_image',
+                    ),
+                )
+            )
+        result = ScubaContext.process_command(cfg, [], image=override_image_name)
+        assert result.image == override_image_name
+
+    def test_env_merge(self):
+        '''process_command merges/overrides the environment from the alias'''
+        cfg = ScubaConfig(
+                image = 'dontcare',
+                environment = dict(
+                    AAA = "aaa_base",
+                    BBB = "bbb_base",
+                ),
+                aliases = dict(
+                    test = dict(
+                        script = 'dontcare',
+                        environment = dict(
+                            BBB = "bbb_overridden",
+                            CCC = "ccc_overridden",
+                        ),
+                    ),
+                ),
+            )
+        result = ScubaContext.process_command(cfg, ['test'])
+        expected = dict(
+            AAA = "aaa_base",
+            BBB = "bbb_overridden",
+            CCC = "ccc_overridden",
+        )
+        assert result.environment == expected
+
+    def test_process_command_alias_extends_docker_args(self):
+        '''aliases can extend the docker_args'''
+        cfg = ScubaConfig(
+                image = 'default',
+                docker_args = '--privileged',
+                aliases = dict(
+                    apple = dict(
+                        script = [
+                            'banana cherry "pie is good"',
+                        ],
+                        docker_args = '-v /tmp/:/tmp/',
+                    ),
+                ),
+            )
+        result = ScubaContext.process_command(cfg, ['apple'])
+        assert result.docker_args == ['--privileged', '-v', '/tmp/:/tmp/']
+
+    def test_process_command_alias_overrides_docker_args(self):
+        '''aliases can override the docker_args'''
+        cfg = ScubaConfig(
+                image = 'default',
+                docker_args = '--privileged',
+                aliases = dict(
+                    apple = dict(
+                        script = [
+                            'banana cherry "pie is good"',
+                        ],
+                        docker_args = OverrideStr('-v /tmp/:/tmp/'),
+                    ),
+                ),
+            )
+        result = ScubaContext.process_command(cfg, ['apple'])
+        assert result.docker_args == ['-v', '/tmp/:/tmp/']
+
+    def test_process_command_alias_overrides_docker_args_with_empty(self):
+        '''aliases can override the docker_args'''
+        cfg = ScubaConfig(
+                image = 'default',
+                docker_args = '--privileged',
+                aliases = dict(
+                    apple = dict(
+                        script = [
+                            'banana cherry "pie is good"',
+                        ],
+                        docker_args = OverrideStr(''),
+                    ),
+                ),
+            )
+        result = ScubaContext.process_command(cfg, ['apple'])
+        assert result.docker_args == []
+
+    def test_process_command_alias_inherits_top_docker_args(self):
+        '''aliases inherit the top-level docker_args if not specified'''
+        cfg = ScubaConfig(
+                image = 'default',
+                docker_args = '--privileged',
+                aliases = dict(
+                    apple = dict(
+                        script = [
+                            'banana cherry "pie is good"',
+                        ],
+                    ),
+                ),
+            )
+        result = ScubaContext.process_command(cfg, ['apple'])
+        assert result.docker_args == ['--privileged']