From 31cb2041c334f852b945b421fffb5ffcacc89e1e Mon Sep 17 00:00:00 2001
From: Peter Josef Scheibel <scheibel1@llnl.gov>
Date: Fri, 18 May 2018 17:53:58 -0700
Subject: [PATCH] env: add spack env command, along with env.yaml schema and
 tests

Co-authored-by: Elizabeth Fischer <rpf2116@columbia.edu>
---
 .gitignore                      |   1 +
 lib/spack/spack/cmd/env.py      | 795 ++++++++++++++++++++++++++++++++
 lib/spack/spack/schema/env.py   |  43 ++
 lib/spack/spack/test/cmd/env.py | 149 ++++++
 4 files changed, 988 insertions(+)
 create mode 100644 lib/spack/spack/cmd/env.py
 create mode 100644 lib/spack/spack/schema/env.py
 create mode 100644 lib/spack/spack/test/cmd/env.py

diff --git a/.gitignore b/.gitignore
index f42ebd911e..ee71398e44 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 /db
 /var/spack/stage
 /var/spack/cache
+/var/spack/environments
 /var/spack/repos/*/index.yaml
 /var/spack/repos/*/lock
 *.pyc
diff --git a/lib/spack/spack/cmd/env.py b/lib/spack/spack/cmd/env.py
new file mode 100644
index 0000000000..bbc98044ef
--- /dev/null
+++ b/lib/spack/spack/cmd/env.py
@@ -0,0 +1,795 @@
+# Copyright 2013-2018 Lawrence Livermore National Security, LLC and other
+# Spack Project Developers. See the top-level COPYRIGHT file for details.
+#
+# SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+import llnl.util.tty as tty
+import spack
+import llnl.util.filesystem as fs
+import spack.modules
+import spack.util.spack_json as sjson
+import spack.util.spack_yaml as syaml
+import spack.schema.env
+import spack.config
+import spack.cmd.spec
+import spack.cmd.install
+import spack.cmd.uninstall
+import spack.cmd.module
+import spack.cmd.common.arguments as arguments
+from spack.config import ConfigScope
+from spack.spec import Spec, CompilerSpec, FlagMap
+from spack.repo import Repo
+from spack.version import VersionList
+from contextlib import contextmanager
+
+import argparse
+try:
+    from itertools import izip_longest as zip_longest
+except ImportError:
+    from itertools import zip_longest
+import os
+import sys
+import shutil
+
+description = "group a subset of packages"
+section = "environment"
+level = "long"
+
+_db_dirname = fs.join_path(spack.paths.var_path, 'environments')
+
+
+def get_env_root(name):
+    """Given an environment name, determines its root directory"""
+    return fs.join_path(_db_dirname, name)
+
+
+def get_dotenv_dir(env_root):
+    """@return Directory in an environment that is owned by Spack"""
+    return fs.join_path(env_root, '.env')
+
+
+def get_write_paths(env_root):
+    """Determines the names of temporary and permanent directories to
+    write machine-generated environment info."""
+    tmp_new = fs.join_path(env_root, '.env.new')
+    dest = get_dotenv_dir(env_root)
+    tmp_old = fs.join_path(env_root, '.env.old')
+    return tmp_new, dest, tmp_old
+
+
+class Environment(object):
+    def clear(self):
+        self.user_specs = list()
+        self.concretized_order = list()
+        self.specs_by_hash = dict()
+
+    def __init__(self, name):
+        self.name = name
+        self.clear()
+
+        # Default config
+        self.yaml = {
+            'configs': ['<env>'],
+            'specs': []
+        }
+
+    @property
+    def path(self):
+        return get_env_root(self.name)
+
+    def repo_path(self):
+        return fs.join_path(get_dotenv_dir(self.path), 'repo')
+
+    def add(self, user_spec, report_existing=True):
+        """Add a single user_spec (non-concretized) to the Environment"""
+        query_spec = Spec(user_spec)
+        existing = set(x for x in self.user_specs
+                       if Spec(x).name == query_spec.name)
+        if existing:
+            if report_existing:
+                tty.die("Package {0} was already added to {1}"
+                        .format(query_spec.name, self.name))
+            else:
+                tty.msg("Package {0} was already added to {1}"
+                        .format(query_spec.name, self.name))
+        else:
+            tty.msg('Adding %s to environment %s' % (user_spec, self.name))
+            self.user_specs.append(user_spec)
+
+    def remove(self, query_spec):
+        """Remove specs from an environment that match a query_spec"""
+        query_spec = Spec(query_spec)
+        match_index = -1
+        for i, spec in enumerate(self.user_specs):
+            if Spec(spec).name == query_spec.name:
+                match_index = i
+                break
+
+        if match_index < 0:
+            tty.die("Not found: {0}".format(query_spec))
+
+        del self.user_specs[match_index]
+        if match_index < len(self.concretized_order):
+            spec_hash = self.concretized_order[match_index]
+            del self.concretized_order[match_index]
+            del self.specs_by_hash[spec_hash]
+
+    def concretize(self, force=False):
+        """Concretize user_specs in an Environment, creating (fully
+        concretized) specs.
+
+        force: bool
+           If set, re-concretize ALL specs, even those that were
+           already concretized.
+        """
+
+        if force:
+            # Clear previously concretized specs
+            self.specs_by_hash = dict()
+            self.concretized_order = list()
+
+        num_concretized = len(self.concretized_order)
+        new_specs = list()
+        for user_spec in self.user_specs[num_concretized:]:
+            tty.msg('Concretizing %s' % user_spec)
+
+            spec = spack.cmd.parse_specs(user_spec)[0]
+            spec.concretize()
+            new_specs.append(spec)
+            dag_hash = spec.dag_hash()
+            self.specs_by_hash[dag_hash] = spec
+            self.concretized_order.append(spec.dag_hash())
+
+            # Display concretized spec to the user
+            sys.stdout.write(spec.tree(
+                recurse_dependencies=True, install_status=True,
+                hashlen=7, hashes=True))
+
+        return new_specs
+
+    def install(self, install_args=None):
+        """Do a `spack install` on all the (concretized)
+        specs in an Environment."""
+
+        # Make sure log directory exists
+        logs = fs.join_path(self.path, 'logs')
+        try:
+            os.makedirs(logs)
+        except OSError:
+            if not os.path.isdir(logs):
+                raise
+
+        for concretized_hash in self.concretized_order:
+            spec = self.specs_by_hash[concretized_hash]
+
+            # Parse cli arguments and construct a dictionary
+            # that will be passed to Package.do_install API
+            kwargs = dict()
+            if install_args:
+                spack.cmd.install.update_kwargs_from_args(install_args, kwargs)
+            with pushd(self.path):
+                spec.package.do_install(**kwargs)
+
+                # Link the resulting log file into logs dir
+                logname = '%s-%s.log' % (spec.name, spec.dag_hash(7))
+                logpath = fs.join_path(logs, logname)
+                try:
+                    os.remove(logpath)
+                except OSError:
+                    pass
+                os.symlink(spec.package.build_log_path, logpath)
+
+    def uninstall(self, args):
+        """Uninstall all the specs in an Environment."""
+        specs = self._get_environment_specs(recurse_dependencies=True)
+        args.all = False
+        spack.cmd.uninstall.uninstall_specs(args, specs)
+
+    def list(self, stream, **kwargs):
+        """List the specs in an environment."""
+        for user_spec, concretized_hash in zip_longest(
+                self.user_specs, self.concretized_order):
+
+            stream.write('========= {0}\n'.format(user_spec))
+
+            if concretized_hash:
+                concretized_spec = self.specs_by_hash[concretized_hash]
+                stream.write(concretized_spec.tree(**kwargs))
+
+    def upgrade_dependency(self, dep_name, dry_run=False):
+        # TODO: if you have
+        # w -> x -> y
+        # and
+        # v -> x -> y
+        # then it would be desirable to ensure that w and v refer to the
+        # same x after upgrading y. This is not currently guaranteed.
+        new_order = list()
+        new_deps = list()
+        for i, spec_hash in enumerate(self.concretized_order):
+            spec = self.specs_by_hash[spec_hash]
+            if dep_name in spec:
+                if dry_run:
+                    tty.msg("Would upgrade {0} for {1}"
+                            .format(spec[dep_name].format(), spec.format()))
+                else:
+                    new_spec = upgrade_dependency_version(spec, dep_name)
+                    new_order.append(new_spec.dag_hash())
+                    self.specs_by_hash[new_spec.dag_hash()] = new_spec
+                    new_deps.append(new_spec[dep_name])
+            else:
+                new_order.append(spec_hash)
+
+        if not dry_run:
+            self.concretized_order = new_order
+            return new_deps[0] if new_deps else None
+
+    def reset_os_and_compiler(self, compiler=None):
+        new_order = list()
+        new_specs_by_hash = {}
+        for spec_hash in self.concretized_order:
+            spec = self.specs_by_hash[spec_hash]
+            new_spec = reset_os_and_compiler(spec, compiler)
+            new_order.append(new_spec.dag_hash())
+            new_specs_by_hash[new_spec.dag_hash()] = new_spec
+        self.concretized_order = new_order
+        self.specs_by_hash = new_specs_by_hash
+
+    def _get_environment_specs(self, recurse_dependencies=True):
+        """Returns the specs of all the packages in an environment.
+        If these specs appear under different user_specs, only one copy
+        is added to the list returned."""
+        package_to_spec = {}
+        spec_list = list()
+
+        for spec_hash in self.concretized_order:
+            spec = self.specs_by_hash[spec_hash]
+
+            specs = spec.traverse(deptype=('link', 'run')) \
+                if recurse_dependencies else (spec,)
+            for dep in specs:
+                if dep.name in package_to_spec:
+                    tty.warn("{0} takes priority over {1}"
+                             .format(package_to_spec[dep.name].format(),
+                                     dep.format()))
+                else:
+                    package_to_spec[dep.name] = dep
+                    spec_list.append(dep)
+
+        return spec_list
+
+    def to_dict(self):
+        """Used in serializing to JSON"""
+        concretized_order = list(self.concretized_order)
+        concrete_specs = dict()
+        for spec in self.specs_by_hash.values():
+            for s in spec.traverse():
+                if s.dag_hash() not in concrete_specs:
+                    concrete_specs[s.dag_hash()] = (
+                        s.to_node_dict(all_deps=True))
+        format = {
+            'user_specs': self.user_specs,
+            'concretized_order': concretized_order,
+            'concrete_specs': concrete_specs,
+        }
+        return format
+
+    @staticmethod
+    def from_dict(name, d):
+        """Used in deserializing from JSON"""
+        env = Environment(name)
+        env.user_specs = list(d['user_specs'])
+        env.concretized_order = list(d['concretized_order'])
+        specs_dict = d['concrete_specs']
+
+        hash_to_node_dict = specs_dict
+        root_hashes = set(env.concretized_order)
+
+        specs_by_hash = {}
+        for dag_hash, node_dict in hash_to_node_dict.items():
+            specs_by_hash[dag_hash] = Spec.from_node_dict(node_dict)
+
+        for dag_hash, node_dict in hash_to_node_dict.items():
+            for dep_name, dep_hash, deptypes in (
+                    Spec.dependencies_from_node_dict(node_dict)):
+                specs_by_hash[dag_hash]._add_dependency(
+                    specs_by_hash[dep_hash], deptypes)
+
+        env.specs_by_hash = dict(
+            (x, y) for x, y in specs_by_hash.items() if x in root_hashes)
+
+        return env
+
+
+def reset_os_and_compiler(spec, compiler=None):
+    spec = spec.copy()
+    for x in spec.traverse():
+        x.compiler = None
+        x.architecture = None
+        x.compiler_flags = FlagMap(x)
+        x._concrete = False
+        x._hash = None
+    if compiler:
+        spec.compiler = CompilerSpec(compiler)
+    spec.concretize()
+    return spec
+
+
+def upgrade_dependency_version(spec, dep_name):
+    spec = spec.copy()
+    for x in spec.traverse():
+        x._concrete = False
+        x._normal = False
+        x._hash = None
+    spec[dep_name].versions = VersionList(':')
+    spec.concretize()
+    return spec
+
+
+def check_consistent_env(env_root):
+    tmp_new, dest, tmp_old = get_write_paths(env_root)
+    if os.path.exists(tmp_new) or os.path.exists(tmp_old):
+        tty.die("Partial write state, run 'spack env repair'")
+
+
+def write(environment, new_repo=None):
+    """Writes an in-memory environment back to its location on disk,
+    in an atomic manner."""
+
+    tmp_new, dest, tmp_old = get_write_paths(get_env_root(environment.name))
+
+    # Write the machine-generated stuff
+    fs.mkdirp(tmp_new)
+    # create one file for the environment object
+    with open(fs.join_path(tmp_new, 'environment.json'), 'w') as f:
+        sjson.dump(environment.to_dict(), stream=f)
+
+    dest_repo_dir = fs.join_path(tmp_new, 'repo')
+    if new_repo:
+        shutil.copytree(new_repo.root, dest_repo_dir)
+    elif os.path.exists(environment.repo_path()):
+        shutil.copytree(environment.repo_path(), dest_repo_dir)
+
+    # Swap in new directory atomically
+    if os.path.exists(dest):
+        shutil.move(dest, tmp_old)
+    shutil.move(tmp_new, dest)
+    if os.path.exists(tmp_old):
+        shutil.rmtree(tmp_old)
+
+
+def repair(environment_name):
+    """Recovers from crash during critical section of write().
+    Possibilities:
+
+        tmp_new, dest
+        tmp_new, tmp_old
+        tmp_old, dest
+    """
+    tmp_new, dest, tmp_old = get_write_paths(get_env_root(environment_name))
+    if os.path.exists(tmp_old):
+        if not os.path.exists(dest):
+            shutil.move(tmp_new, dest)
+        else:
+            shutil.rmtree(tmp_old)
+        tty.info("Previous update completed")
+    elif os.path.exists(tmp_new):
+        tty.info("Previous update did not complete")
+    else:
+        tty.info("Previous update may have completed")
+
+    if os.path.exists(tmp_new):
+        shutil.rmtree(tmp_new)
+
+
+def read(environment_name):
+    # Check that env is in a consistent state on disk
+    env_root = get_env_root(environment_name)
+
+    # Read env.yaml file
+    env_yaml = spack.config._read_config_file(
+        fs.join_path(env_root, 'env.yaml'),
+        spack.schema.env.schema)
+
+    dotenv_dir = get_dotenv_dir(env_root)
+    with open(fs.join_path(dotenv_dir, 'environment.json'), 'r') as f:
+        environment_dict = sjson.load(f)
+    environment = Environment.from_dict(environment_name, environment_dict)
+    if env_yaml:
+        environment.yaml = env_yaml['env']
+
+    return environment
+
+
+# =============== Modifies Environment
+
+def environment_create(args):
+    if os.path.exists(get_env_root(args.environment)):
+        raise tty.die("Environment already exists: " + args.environment)
+
+    _environment_create(args.environment)
+
+
+def _environment_create(name, init_config=None):
+    environment = Environment(name)
+
+    user_specs = list()
+    config_sections = {}
+    if init_config:
+        for key, val in init_config.items():
+            if key == 'user_specs':
+                user_specs.extend(val)
+            else:
+                config_sections[key] = val
+
+    for user_spec in user_specs:
+        environment.add(user_spec)
+
+    write(environment)
+
+    # When creating the environment, the user may specify configuration
+    # to place in the environment initially. Spack does not interfere
+    # with this configuration after initialization so it is handled here
+    if len(config_sections) > 0:
+        config_basedir = fs.join_path(environment.path, 'config')
+        os.mkdir(config_basedir)
+        for key, val in config_sections.items():
+            yaml_section = syaml.dump({key: val}, default_flow_style=False)
+            yaml_file = '{0}.yaml'.format(key)
+            yaml_path = fs.join_path(config_basedir, yaml_file)
+            with open(yaml_path, 'w') as f:
+                f.write(yaml_section)
+
+
+def environment_add(args):
+    check_consistent_env(get_env_root(args.environment))
+    environment = read(args.environment)
+    parsed_specs = spack.cmd.parse_specs(args.package)
+
+    if args.all:
+        # Don't allow command-line specs with --all
+        if len(parsed_specs) > 0:
+            tty.die('Cannot specify --all and specs too on the command line')
+
+        yaml_specs = environment.yaml['specs']
+        if len(yaml_specs) == 0:
+            tty.msg('No specs to add from env.yaml')
+
+        # Add list of specs from env.yaml file
+        for user_spec, _ in yaml_specs.items():    # OrderedDict
+            environment.add(str(user_spec), report_existing=False)
+    else:
+        for spec in parsed_specs:
+            environment.add(str(spec))
+
+    write(environment)
+
+
+def environment_remove(args):
+    check_consistent_env(get_env_root(args.environment))
+    environment = read(args.environment)
+    if args.all:
+        environment.clear()
+    else:
+        for spec in spack.cmd.parse_specs(args.package):
+            environment.remove(spec.format())
+    write(environment)
+
+
+def environment_spec(args):
+    environment = read(args.environment)
+    prepare_repository(environment, use_repo=args.use_repo)
+    prepare_config_scope(environment)
+    spack.cmd.spec.spec(None, args)
+
+
+def environment_concretize(args):
+    check_consistent_env(get_env_root(args.environment))
+    environment = read(args.environment)
+    _environment_concretize(
+        environment, use_repo=args.use_repo, force=args.force)
+
+
+def _environment_concretize(environment, use_repo=False, force=False):
+    """Function body separated out to aid in testing."""
+
+    # Change global search paths
+    repo = prepare_repository(environment, use_repo=use_repo)
+    prepare_config_scope(environment)
+
+    new_specs = environment.concretize(force=force)
+
+    for spec in new_specs:
+        for dep in spec.traverse():
+            dump_to_environment_repo(dep, repo)
+
+    # Moves <env>/.env.new to <env>/.env
+    write(environment, repo)
+
+# =============== Does not Modify Environment
+
+
+def environment_install(args):
+    check_consistent_env(get_env_root(args.environment))
+    environment = read(args.environment)
+    prepare_repository(environment, use_repo=args.use_repo)
+    environment.install(args)
+
+
+def environment_uninstall(args):
+    check_consistent_env(get_env_root(args.environment))
+    environment = read(args.environment)
+    prepare_repository(environment)
+    environment.uninstall(args)
+
+# =======================================
+
+
+def dump_to_environment_repo(spec, repo):
+    dest_pkg_dir = repo.dirname_for_package_name(spec.name)
+    if not os.path.exists(dest_pkg_dir):
+        spack.repo.path.dump_provenance(spec, dest_pkg_dir)
+
+
+def prepare_repository(environment, remove=None, use_repo=False):
+    """Adds environment's repository to the global search path of repos"""
+    import tempfile
+    repo_stage = tempfile.mkdtemp()
+    new_repo_dir = fs.join_path(repo_stage, 'repo')
+    if os.path.exists(environment.repo_path()):
+        shutil.copytree(environment.repo_path(), new_repo_dir)
+    else:
+        spack.repo.create_repo(new_repo_dir, environment.name)
+    if remove:
+        remove_dirs = []
+        repo = Repo(new_repo_dir)
+        for pkg_name in remove:
+            remove_dirs.append(repo.dirname_for_package_name(pkg_name))
+        for d in remove_dirs:
+            shutil.rmtree(d)
+    repo = Repo(new_repo_dir)
+    if use_repo:
+        spack.repo.put_first(repo)
+    return repo
+
+
+def prepare_config_scope(environment):
+    """Adds environment's scope to the global search path
+    of configuration scopes"""
+
+    # Load up configs
+    for config_spec in environment.yaml['configs']:
+        config_name = os.path.split(config_spec)[1]
+        if config_name == '<env>':
+            # Use default config for the environment; doesn't have to exist
+            config_dir = fs.join_path(environment.path, 'config')
+            if not os.path.isdir(config_dir):
+                continue
+            config_name = environment.name
+        else:
+            # Use external user-provided config
+            config_dir = os.path.normpath(os.path.join(
+                environment.path, config_spec.format(**os.environ)))
+            if not os.path.isdir(config_dir):
+                tty.die('Spack config %s (%s) not found' %
+                        (config_name, config_dir))
+
+        tty.msg('Using Spack config %s scope at %s' %
+                (config_name, config_dir))
+        spack.config.config.push_scope(ConfigScope(config_name, config_dir))
+
+
+def environment_relocate(args):
+    environment = read(args.environment)
+    prepare_repository(environment, use_repo=args.use_repo)
+    environment.reset_os_and_compiler(compiler=args.compiler)
+    write(environment)
+
+
+def environment_list(args):
+    # TODO? option to list packages w/ multiple instances?
+    environment = read(args.environment)
+    import sys
+    environment.list(
+        sys.stdout, recurse_dependencies=args.recurse_dependencies,
+        hashes=args.long or args.very_long,
+        hashlen=None if args.very_long else 7,
+        install_status=args.install_status)
+
+
+def environment_stage(args):
+    environment = read(args.environment)
+    prepare_repository(environment, use_repo=args.use_repo)
+    for spec in environment.specs_by_hash.values():
+        for dep in spec.traverse():
+            dep.package.do_stage()
+
+
+def environment_location(args):
+    environment = read(args.environment)
+    print(environment.path)
+
+
+@contextmanager
+def redirect_stdout(ofname):
+    """Redirects STDOUT to (by default) a file within the environment;
+    or else a user-specified filename."""
+    with open(ofname, 'w') as f:
+        original = sys.stdout
+        sys.stdout = f
+        yield
+        sys.stdout = original
+
+
+@contextmanager
+def pushd(dir):
+    original = os.getcwd()
+    os.chdir(dir)
+    yield
+    os.chdir(original)
+
+
+def environment_loads(args):
+    # Set the module types that have been selected
+    module_types = args.module_type
+    if module_types is None:
+        # If no selection has been made select all of them
+        module_types = ['tcl']
+
+    module_types = list(set(module_types))
+
+    environment = read(args.environment)
+    recurse_dependencies = args.recurse_dependencies
+    args.recurse_dependencies = False
+    ofname = fs.join_path(environment.path, 'loads')
+    with redirect_stdout(ofname):
+        specs = environment._get_environment_specs(
+            recurse_dependencies=recurse_dependencies)
+        spack.cmd.module.loads(module_types, specs, args)
+
+    print('To load this environment, type:')
+    print('   source %s' % ofname)
+
+
+def environment_upgrade_dependency(args):
+    environment = read(args.environment)
+    repo = prepare_repository(
+        environment, use_repo=args.use_repo, remove=[args.dep_name])
+    new_dep = environment.upgrade_dependency(args.dep_name, args.dry_run)
+    if not args.dry_run and new_dep:
+        dump_to_environment_repo(new_dep, repo)
+        write(environment, repo)
+
+
+def add_use_repo_argument(cmd_parser):
+    cmd_parser.add_argument(
+        '--use-env-repo', action='store_true', dest='use_repo',
+        help='Use package definitions stored in the environment'
+    )
+
+
+def setup_parser(subparser):
+    subparser.add_argument(
+        'environment',
+        help="The environment you are working with"
+    )
+
+    sp = subparser.add_subparsers(
+        metavar='SUBCOMMAND', dest='environment_command')
+
+    create_parser = sp.add_parser('create', help='Make an environment')
+    create_parser.add_argument(
+        '--init-file', dest='init_file',
+        help='File with user specs to add and configuration yaml to use'
+    )
+
+    add_parser = sp.add_parser('add', help='Add a spec to an environment')
+    add_parser.add_argument(
+        '-a', '--all', action='store_true', dest='all',
+        help="Add all specs listed in env.yaml")
+    add_parser.add_argument(
+        'package',
+        nargs=argparse.REMAINDER,
+        help="Spec of the package to add"
+    )
+
+    remove_parser = sp.add_parser(
+        'remove', help='Remove a spec from this environment')
+    remove_parser.add_argument(
+        '-a', '--all', action='store_true', dest='all',
+        help="Remove all specs from (clear) the environment")
+    remove_parser.add_argument(
+        'package',
+        nargs=argparse.REMAINDER,
+        help="Spec of the package to remove"
+    )
+
+    spec_parser = sp.add_parser(
+        'spec', help='Concretize sample spec')
+    spack.cmd.spec.add_common_arguments(spec_parser)
+    add_use_repo_argument(spec_parser)
+
+    concretize_parser = sp.add_parser(
+        'concretize', help='Concretize user specs')
+    concretize_parser.add_argument(
+        '-f', '--force', action='store_true',
+        help="Re-concretize even if already concretized.")
+    add_use_repo_argument(concretize_parser)
+
+    relocate_parser = sp.add_parser(
+        'relocate',
+        help='Reconcretize environment with new OS and/or compiler')
+    relocate_parser.add_argument(
+        '--compiler',
+        help="Compiler spec to use"
+    )
+    add_use_repo_argument(relocate_parser)
+
+    list_parser = sp.add_parser('list', help='List specs in an environment')
+    arguments.add_common_arguments(
+        list_parser,
+        ['recurse_dependencies', 'long', 'very_long', 'install_status'])
+
+    loads_parser = sp.add_parser(
+        'loads',
+        help='List modules for an installed environment '
+        '(see spack module loads)')
+    spack.cmd.modules.add_loads_arguments(loads_parser)
+
+    sp.add_parser(
+        'location',
+        help='Print the root directory of the environment')
+
+    upgrade_parser = sp.add_parser(
+        'upgrade',
+        help='''Upgrade a dependency package in an environment to the latest
+version''')
+    upgrade_parser.add_argument(
+        'dep_name', help='Dependency package to upgrade')
+    upgrade_parser.add_argument(
+        '--dry-run', action='store_true', dest='dry_run',
+        help="Just show the updates that would take place")
+    add_use_repo_argument(upgrade_parser)
+
+    stage_parser = sp.add_parser(
+        'stage',
+        help='Download all source files for all packages in an environment')
+    add_use_repo_argument(stage_parser)
+
+    config_update_parser = sp.add_parser(
+        'update-config',
+        help='Add config yaml file to environment')
+    config_update_parser.add_argument(
+        'config_files',
+        nargs=argparse.REMAINDER,
+        help="Configuration files to add"
+    )
+
+    install_parser = sp.add_parser(
+        'install',
+        help='Install all concretized specs in an environment')
+    spack.cmd.install.add_common_arguments(install_parser)
+    add_use_repo_argument(install_parser)
+
+    uninstall_parser = sp.add_parser(
+        'uninstall',
+        help='Uninstall all concretized specs in an environment')
+    spack.cmd.uninstall.add_common_arguments(uninstall_parser)
+
+
+def env(parser, args, **kwargs):
+    action = {
+        'create': environment_create,
+        'add': environment_add,
+        'spec': environment_spec,
+        'concretize': environment_concretize,
+        'list': environment_list,
+        'loads': environment_loads,
+        'location': environment_location,
+        'remove': environment_remove,
+        'relocate': environment_relocate,
+        'upgrade': environment_upgrade_dependency,
+        'stage': environment_stage,
+        'install': environment_install,
+        'uninstall': environment_uninstall
+    }
+    action[args.environment_command](args)
diff --git a/lib/spack/spack/schema/env.py b/lib/spack/spack/schema/env.py
new file mode 100644
index 0000000000..dab4d6a5bd
--- /dev/null
+++ b/lib/spack/spack/schema/env.py
@@ -0,0 +1,43 @@
+# Copyright 2013-2018 Lawrence Livermore National Security, LLC and other
+# Spack Project Developers. See the top-level COPYRIGHT file for details.
+#
+# SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+"""Schema for env.yaml configuration file.
+
+.. literalinclude:: ../spack/schema/env.py
+   :lines: 32-
+"""
+
+
+schema = {
+    '$schema': 'http://json-schema.org/schema#',
+    'title': 'Spack Environments user configuration file schema',
+    'type': 'object',
+    'additionalProperties': False,
+    'properties': {
+        'env': {
+            'type': 'object',
+            'default': {},
+            'properties': {
+                'configs': {
+                    'type': 'array',
+                    'default': [],
+                    'items': {'type': 'string'}
+                },
+                'specs': {
+                    'type': 'object',
+                    'default': {},
+                    'additionalProperties': False,
+                    'patternProperties': {
+                        r'\w[\w-]*': {  # user spec
+                            'type': 'object',
+                            'default': {},
+                            'additionalProperties': False,
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py
new file mode 100644
index 0000000000..dbb3aa9f1f
--- /dev/null
+++ b/lib/spack/spack/test/cmd/env.py
@@ -0,0 +1,149 @@
+# Copyright 2013-2018 Lawrence Livermore National Security, LLC and other
+# Spack Project Developers. See the top-level COPYRIGHT file for details.
+#
+# SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+import unittest
+import tempfile
+import shutil
+import pytest
+try:
+    from StringIO import StringIO
+except ImportError:
+    from io import StringIO
+
+import spack.cmd.env
+import spack.modules
+import spack.util.spack_yaml as syaml
+from spack.cmd.env import (Environment, prepare_repository,
+                           _environment_concretize, prepare_config_scope,
+                           _environment_create)
+from spack.version import Version
+
+
+class TestEnvironment(unittest.TestCase):
+    def setUp(self):
+        self.env_dir = spack.cmd.env._db_dirname
+        spack.cmd.env._db_dirname = tempfile.mkdtemp()
+
+    def tearDown(self):
+        shutil.rmtree(spack.cmd.env._db_dirname)
+        spack.cmd.env._db_dirname = self.env_dir
+
+    def test_add(self):
+        c = Environment('test')
+        c.add('mpileaks')
+        assert 'mpileaks' in c.user_specs
+
+    @pytest.mark.usefixtures('config', 'mutable_mock_packages')
+    def test_concretize(self):
+        c = Environment('test')
+        c.add('mpileaks')
+        c.concretize()
+        env_specs = c._get_environment_specs()
+        assert any(x.name == 'mpileaks' for x in env_specs)
+
+    @pytest.mark.usefixtures('config', 'mutable_mock_packages',
+                             'install_mockery', 'mock_fetch')
+    def test_env_install(self):
+        c = Environment('test')
+        c.add('cmake-client')
+        c.concretize()
+        c.install()
+        env_specs = c._get_environment_specs()
+        spec = next(x for x in env_specs if x.name == 'cmake-client')
+        assert spec.package.installed
+
+    @pytest.mark.usefixtures('config', 'mutable_mock_packages')
+    def test_remove_after_concretize(self):
+        c = Environment('test')
+        c.add('mpileaks')
+        c.concretize()
+        c.add('python')
+        c.concretize()
+        c.remove('mpileaks')
+        env_specs = c._get_environment_specs()
+        assert not any(x.name == 'mpileaks' for x in env_specs)
+
+    @pytest.mark.usefixtures('config', 'mutable_mock_packages')
+    def test_reset_compiler(self):
+        c = Environment('test')
+        c.add('mpileaks')
+        c.concretize()
+
+        first_spec = c.specs_by_hash[c.concretized_order[0]]
+        available = set(['gcc', 'clang'])
+        available.remove(first_spec.compiler.name)
+        new_compiler = next(iter(available))
+        c.reset_os_and_compiler(compiler=new_compiler)
+
+        new_spec = c.specs_by_hash[c.concretized_order[0]]
+        assert new_spec.compiler != first_spec.compiler
+
+    @pytest.mark.usefixtures('config', 'mutable_mock_packages')
+    def test_environment_list(self):
+        c = Environment('test')
+        c.add('mpileaks')
+        c.concretize()
+        c.add('python')
+        mock_stream = StringIO()
+        c.list(mock_stream)
+        list_content = mock_stream.getvalue()
+        assert 'mpileaks' in list_content
+        assert 'python' in list_content
+        mpileaks_spec = c.specs_by_hash[c.concretized_order[0]]
+        assert mpileaks_spec.format() in list_content
+
+    @pytest.mark.usefixtures('config', 'mutable_mock_packages')
+    def test_upgrade_dependency(self):
+        c = Environment('test')
+        c.add('mpileaks ^callpath@0.9')
+        c.concretize()
+
+        c.upgrade_dependency('callpath')
+        env_specs = c._get_environment_specs()
+        callpath_dependents = list(x for x in env_specs if 'callpath' in x)
+        assert callpath_dependents
+        for spec in callpath_dependents:
+            assert spec['callpath'].version == Version('1.0')
+
+    @pytest.mark.usefixtures('config', 'mutable_mock_packages')
+    def test_init_config(self):
+        test_config = """user_specs:
+    - mpileaks
+packages:
+    mpileaks:
+        version: [2.2]
+"""
+        spack.package_prefs.PackagePrefs._packages_config_cache = None
+        spack.package_prefs.PackagePrefs._spec_cache = {}
+
+        _environment_create('test', syaml.load(StringIO(test_config)))
+        c = spack.cmd.env.read('test')
+        prepare_config_scope(c)
+        c.concretize()
+        assert any(x.satisfies('mpileaks@2.2')
+                   for x in c._get_environment_specs())
+
+    @pytest.mark.usefixtures('config', 'mutable_mock_packages')
+    def test_to_dict(self):
+        c = Environment('test')
+        c.add('mpileaks')
+        c.concretize()
+        context_dict = c.to_dict()
+        c_copy = Environment.from_dict('test_copy', context_dict)
+        assert c.specs_by_hash == c_copy.specs_by_hash
+
+    @pytest.mark.usefixtures('config', 'mutable_mock_packages')
+    def test_prepare_repo(self):
+        c = Environment('testx')
+        c.add('mpileaks')
+        _environment_concretize(c)
+        repo = None
+        try:
+            repo = prepare_repository(c)
+            package = repo.get(spack.spec.Spec('mpileaks'))
+            assert package.namespace.split('.')[-1] == 'testx'
+        finally:
+            if repo:
+                shutil.rmtree(repo.root)
-- 
GitLab