Create Zygote, a configurable container.

Create a new subclass of Container called Zygote.  Zygote adds support
for a host directory, which is a shared bind-mount that enables the host
system to bind/copy additional files/directories into a Container after
it has been started.

BUG=chromium:720219

TEST=sudo python lxc_functional_test.py -v
     2017-06-29 15:07:03,348 All tests passed.

TEST=sudo python zygote_unittest.py -v
     Ran 8 tests in 153.343s
     OK (skipped=1)

Change-Id: I5fbcd31edd1e6fc34b6531640cbf58a2f34b9c18
Reviewed-on: https://chromium-review.googlesource.com/557061
Commit-Ready: Ben Kwa <kenobi@chromium.org>
Tested-by: Ben Kwa <kenobi@chromium.org>
Reviewed-by: Dan Shi <dshi@google.com>
diff --git a/global_config.ini b/global_config.ini
index 32bc64c..426e4dd 100644
--- a/global_config.ini
+++ b/global_config.ini
@@ -74,6 +74,8 @@
 
 # Directory stores LXC containers
 container_path: /usr/local/autotest/containers
+# Shared mount point for host mounts for LXC containers.
+container_shared_host_path: /usr/local/autotest/containers/host
 
 # `container_base` is replaced by `container_base_folder_url` and `container_base_name`
 # The setting is kept for backwards compatibility reason.
diff --git a/site_utils/lxc/__init__.py b/site_utils/lxc/__init__.py
index 8ee0426..d260302 100644
--- a/site_utils/lxc/__init__.py
+++ b/site_utils/lxc/__init__.py
@@ -16,3 +16,4 @@
 from lxc import install_package
 from lxc import install_packages
 from lxc import install_python_package
+from zygote import Zygote
diff --git a/site_utils/lxc/constants.py b/site_utils/lxc/constants.py
index 910bf8e..9fbe485 100644
--- a/site_utils/lxc/constants.py
+++ b/site_utils/lxc/constants.py
@@ -38,6 +38,10 @@
 # Default directory used to store LXC containers.
 DEFAULT_CONTAINER_PATH = global_config.get_config_value('AUTOSERV',
                                                         'container_path')
+# Default directory for host mounts
+DEFAULT_SHARED_HOST_PATH = global_config.get_config_value(
+        'AUTOSERV',
+        'container_shared_host_path')
 
 # Path to drone_temp folder in the container, which stores the control file for
 # test job to run.
diff --git a/site_utils/lxc/container.py b/site_utils/lxc/container.py
index a4003e2..d1044d6 100644
--- a/site_utils/lxc/container.py
+++ b/site_utils/lxc/container.py
@@ -13,6 +13,7 @@
 from autotest_lib.site_utils.lxc import config as lxc_config
 from autotest_lib.site_utils.lxc import constants
 from autotest_lib.site_utils.lxc import lxc
+from autotest_lib.site_utils.lxc import utils as lxc_utils
 
 try:
     from chromite.lib import metrics
@@ -44,20 +45,73 @@
     The attributes available are defined in ATTRIBUTES constant.
     """
 
-    def __init__(self, container_path, attribute_values):
+    def __init__(self, container_path, name, attribute_values, src=None,
+                 snapshot=False):
         """Initialize an object of LXC container with given attribute values.
 
         @param container_path: Directory that stores the container.
+        @param name: Name of the container.
         @param attribute_values: A dictionary of attribute values for the
                                  container.
+        @param src: An optional source container.  If provided, the source
+                    continer is cloned, and the new container will point to the
+                    clone.
+        @param snapshot: If a source container was specified, this argument
+                         specifies whether or not to create a snapshot clone.
+                         The default is to attempt to create a snapshot.
+                         If a snapshot is requested and creating the snapshot
+                         fails, a full clone will be attempted.
         """
         self.container_path = os.path.realpath(container_path)
         # Path to the rootfs of the container. This will be initialized when
         # property rootfs is retrieved.
         self._rootfs = None
+        self.name = name
         for attribute, value in attribute_values.iteritems():
             setattr(self, attribute, value)
 
+        # Clone the container
+        if src is not None:
+            # Clone the source container to initialize this one.
+            lxc_utils.clone(src.container_path, src.name, self.container_path,
+                            self.name, snapshot)
+
+
+    @classmethod
+    def createFromExistingDir(cls, lxc_path, name, **kwargs):
+        """Creates a new container instance for an lxc container that already
+        exists on disk.
+
+        @param lxc_path: The LXC path for the container.
+        @param name: The container name.
+
+        @raise error.ContainerError: If the container doesn't already exist.
+
+        @return: The new container.
+        """
+        container = cls(lxc_path, name, kwargs)
+        container.refresh_status()
+        return container
+
+
+    @classmethod
+    def clone(cls, src, new_name, new_path=None, snapshot=False):
+        """Creates a clone of this container.
+
+        @param src: The original container.
+        @param new_name: Name for the cloned container.
+        @param new_path: LXC path for the cloned container (optional; if not
+                specified, the new container is created in the same directory as
+                this container).
+        @param snapshot: Whether to snapshot, or create a full clone.
+        @param cleanup: If a container with the given name and path already
+                exist, clean it up first.
+        """
+        if new_path is None:
+            new_path = src.container_path
+
+        return cls(new_path, new_name, {}, src, snapshot)
+
 
     def refresh_status(self):
         """Refresh the status information of the container.
@@ -66,7 +120,7 @@
         if not containers:
             raise error.ContainerError(
                     'No container found in directory %s with name of %s.' %
-                    self.container_path, self.name)
+                    (self.container_path, self.name))
         attribute_values = containers[0]
         for attribute, value in attribute_values.iteritems():
             setattr(self, attribute, value)
@@ -107,8 +161,8 @@
                         'in the container config file is %s' %
                         (self.name, lxc_rootfs_config))
             lxc_rootfs = match.group(1)
-            self.clone_from_snapshot = ':' in lxc_rootfs
-            if self.clone_from_snapshot:
+            cloned_from_snapshot = ':' in lxc_rootfs
+            if cloned_from_snapshot:
                 self._rootfs = lxc_rootfs.split(':')[-1]
             else:
                 self._rootfs = lxc_rootfs
@@ -160,8 +214,7 @@
         """
         cmd = 'sudo lxc-start -P %s -n %s -d' % (self.container_path, self.name)
         output = utils.run(cmd).stdout
-        self.refresh_status()
-        if self.state != 'RUNNING':
+        if not self.is_running():
             raise error.ContainerError(
                     'Container %s failed to start. lxc command output:\n%s' %
                     (os.path.join(self.container_path, self.name),
@@ -295,3 +348,9 @@
                         "\"local\/lib\",\\n/g' %s" % site_module)
         self.attach_run('sed -i "s/lib_placeholder/lib/g" %s' %
                         site_module)
+
+
+    def is_running(self):
+        """Returns whether or not this container is currently running."""
+        self.refresh_status()
+        return self.state == 'RUNNING'
diff --git a/site_utils/lxc/container_bucket.py b/site_utils/lxc/container_bucket.py
index 6f7d73f..4dc4d83 100644
--- a/site_utils/lxc/container_bucket.py
+++ b/site_utils/lxc/container_bucket.py
@@ -1,4 +1,4 @@
-# Copyright 2015 The Chromium Authors. All rights reserved.
+# Copyright 2017 The Chromium OS Authors. All rights reserved.
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 
@@ -28,7 +28,9 @@
     """A wrapper class to interact with containers in a specific container path.
     """
 
-    def __init__(self, container_path=constants.DEFAULT_CONTAINER_PATH):
+    def __init__(self,
+                 container_path=constants.DEFAULT_CONTAINER_PATH,
+                 shared_host_path = constants.DEFAULT_SHARED_HOST_PATH):
         """Initialize a ContainerBucket.
 
         @param container_path: Path to the directory used to store containers.
@@ -36,6 +38,13 @@
                                global config.
         """
         self.container_path = os.path.realpath(container_path)
+        self.shared_host_path = os.path.realpath(shared_host_path)
+        # Try to create the base container.
+        try:
+            self.base_container = Container.createFromExistingDir(
+                container_path, constants.BASE);
+        except error.ContainerError:
+            self.base_container = None
 
 
     def get_all(self):
@@ -47,7 +56,8 @@
         info_collection = lxc.get_container_info(self.container_path)
         containers = {}
         for info in info_collection:
-            container = Container(self.container_path, info)
+            container = Container.createFromExistingDir(self.container_path,
+                                                        **info)
             containers[container.name] = container
         return containers
 
@@ -82,6 +92,7 @@
             containers, key=lambda n: 1 if n.name == constants.BASE else 0):
             logging.info('Destroy container %s.', container.name)
             container.destroy()
+        self._cleanup_shared_host_path()
 
 
     @metrics.SecondsTimerDecorator(
@@ -158,7 +169,7 @@
             if not cleanup:
                 raise error.ContainerError('Container %s already exists.' %
                                            new_name)
-            container = Container(new_path, {'name': name})
+            container = Container.createFromExistingDir(new_path, new_name)
             try:
                 container.destroy()
             except error.CmdError as e:
@@ -168,15 +179,7 @@
                              name, e)
                 utils.run('sudo rm -rf "%s"' % container_folder)
 
-        snapshot_arg = '-s' if snapshot else ''
-        # overlayfs is the default clone backend storage. However it is not
-        # supported in Ganeti yet. Use aufs as the alternative.
-        aufs_arg = '-B aufs' if utils.is_vm() and snapshot else ''
-        cmd = (('sudo lxc-clone --lxcpath %s --newpath %s '
-                '--orig %s --new %s %s %s') %
-               (path, new_path, name, new_name, snapshot_arg, aufs_arg))
-
-        utils.run(cmd)
+        lxc_utils.clone(path, name, new_path, new_name, snapshot)
         return self.get(new_name)
 
 
@@ -238,6 +241,41 @@
         utils.run('sudo sed -i "s|container_dir|%s|g" "%s"' %
                   (self.container_path, config_path))
 
+        self.base_container = Container.createFromExistingDir(
+                self.container_path, constants.BASE)
+
+        self._setup_shared_host_path()
+
+
+    def _setup_shared_host_path(self):
+        """Sets up the shared host directory."""
+        # First, clear out the old shared host dir if it exists.
+        if lxc_utils.path_exists(self.shared_host_path):
+            self._cleanup_shared_host_path()
+        # Create the dir and set it up as a shared mount point.
+        utils.run(('sudo mkdir "{path}" && '
+                   'sudo mount --bind "{path}" "{path}" && '
+                   'sudo mount --make-unbindable "{path}" && '
+                   'sudo mount --make-shared "{path}"')
+                  .format(path=self.shared_host_path))
+
+
+    def _cleanup_shared_host_path(self):
+        """Removes the shared host directory.
+
+        This should only be called after all containers have been destroyed
+        (i.e. all host mounts have been disconnected and removed, so the shared
+        host directory should be empty).
+        """
+        if not os.path.exists(self.shared_host_path):
+            return
+
+        if len(os.listdir(self.shared_host_path)) > 0:
+            raise RuntimeError('Attempting to clean up host dir before all '
+                               'hosts have been disconnected')
+        utils.run('sudo umount "{path}" && sudo rmdir "{path}"'
+                  .format(path=self.shared_host_path))
+
 
     @metrics.SecondsTimerDecorator(
         '%s/setup_test_duration' % constants.STATS_KEY)
diff --git a/site_utils/lxc/container_bucket_unittest.py b/site_utils/lxc/container_bucket_unittest.py
new file mode 100644
index 0000000..4d6361e
--- /dev/null
+++ b/site_utils/lxc/container_bucket_unittest.py
@@ -0,0 +1,91 @@
+#!/usr/bin/python
+# Copyright 2017 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import argparse
+import logging
+import os
+import shutil
+import tempfile
+import unittest
+
+import common
+from autotest_lib.site_utils import lxc
+from autotest_lib.site_utils.lxc import unittest_logging
+
+
+options = None
+
+class ContainerBucketTests(unittest.TestCase):
+    """Unit tests for the ContainerBucket class."""
+
+    @classmethod
+    def setUpClass(cls):
+        cls.container_path = tempfile.mkdtemp(
+                dir=lxc.DEFAULT_CONTAINER_PATH,
+                prefix='container_bucket_unittest_')
+
+
+    @classmethod
+    def tearDownClass(cls):
+        shutil.rmtree(cls.container_path)
+
+
+    def setUp(self):
+        self.tmpdir = tempfile.mkdtemp()
+
+
+    def tearDown(self):
+        shutil.rmtree(self.tmpdir)
+
+
+    def testHostDirCreationAndCleanup(self):
+        """Verifies that the host dir is properly created and cleaned up when
+        the container bucket is set up and destroyed.
+        """
+        shared_host_path = os.path.realpath(os.path.join(self.tmpdir, 'host'))
+        bucket = lxc.ContainerBucket(self.container_path, shared_host_path)
+
+        # Verify the host path in the container bucket.
+        self.assertEqual(os.path.realpath(bucket.shared_host_path),
+                         shared_host_path)
+
+        # Set up, verify that the path is created.
+        bucket.setup_base()
+        self.assertTrue(os.path.isdir(shared_host_path))
+
+        # Clean up, verify that the path is removed.
+        bucket.destroy_all()
+        self.assertFalse(os.path.isdir(shared_host_path))
+
+
+    def testHostDirMissing(self):
+        """Verifies that a missing host dir does not cause container bucket
+        destruction to crash.
+        """
+        shared_host_path = os.path.realpath(os.path.join(self.tmpdir, 'host'))
+        bucket = lxc.ContainerBucket(self.container_path, shared_host_path)
+
+        # Verify that the host path does not exist.
+        self.assertFalse(os.path.exists(shared_host_path))
+        # Do not call startup, just call destroy.  This should not throw.
+        bucket.destroy_all()
+
+
+def parse_options():
+    """Parse command line inputs."""
+    parser = argparse.ArgumentParser()
+    parser.add_argument('-v', '--verbose', action='store_true',
+                        help='Print out ALL entries.')
+    args, _unused = parser.parse_known_args()
+    return args
+
+
+if __name__ == '__main__':
+    options = parse_options()
+
+    log_level=(logging.DEBUG if options.verbose else logging.INFO)
+    unittest_logging.setup(log_level)
+
+    unittest.main()
diff --git a/site_utils/lxc/lxc_functional_test.py b/site_utils/lxc/lxc_functional_test.py
index 9f5a623..080aebb 100644
--- a/site_utils/lxc/lxc_functional_test.py
+++ b/site_utils/lxc/lxc_functional_test.py
@@ -15,13 +15,13 @@
 import argparse
 import logging
 import os
-import sys
 import tempfile
 import time
 
 import common
 from autotest_lib.client.bin import utils
 from autotest_lib.site_utils import lxc
+from autotest_lib.site_utils.lxc import unittest_logging
 
 
 TEST_JOB_ID = 123
@@ -175,21 +175,6 @@
 """
 
 
-def setup_logging(log_level=logging.INFO):
-    """Direct logging to stdout.
-
-    @param log_level: Level of logging to redirect to stdout, default to INFO.
-    """
-    logger = logging.getLogger()
-    logger.setLevel(log_level)
-    handler = logging.StreamHandler(sys.stdout)
-    handler.setLevel(log_level)
-    formatter = logging.Formatter('%(asctime)s %(message)s')
-    handler.setFormatter(formatter)
-    logger.handlers = []
-    logger.addHandler(handler)
-
-
 def setup_base(bucket):
     """Test setup base container works.
 
@@ -352,8 +337,8 @@
                      'grant root access to this process.')
         utils.run('sudo true')
 
-    setup_logging(log_level=(logging.DEBUG if options.verbose
-                             else logging.INFO))
+    log_level=(logging.DEBUG if options.verbose else logging.INFO)
+    unittest_logging.setup(log_level)
 
     bucket = lxc.ContainerBucket(TEMP_DIR)
 
diff --git a/site_utils/lxc/unittest_cleanup.py b/site_utils/lxc/unittest_cleanup.py
new file mode 100644
index 0000000..fe5e5d4
--- /dev/null
+++ b/site_utils/lxc/unittest_cleanup.py
@@ -0,0 +1,36 @@
+#!/usr/bin/python
+# Copyright 2017 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import os
+
+import common
+from autotest_lib.client.bin import utils
+from autotest_lib.client.common_lib import error
+from autotest_lib.site_utils import lxc
+from autotest_lib.site_utils.lxc import utils as lxc_utils
+
+
+TEST_CONTAINER_PATH = os.path.join(lxc.DEFAULT_CONTAINER_PATH, 'test')
+TEST_HOST_PATH = os.path.join(TEST_CONTAINER_PATH, 'host')
+
+def main():
+    """Clean up the remnants from any old aborted unit tests."""
+    # Manually clean out the host dir.
+    if lxc_utils.path_exists(TEST_HOST_PATH):
+        for host_dir in os.listdir(TEST_HOST_PATH):
+            host_dir = os.path.realpath(os.path.join(TEST_HOST_PATH, host_dir))
+            try:
+                utils.run('sudo umount %s' % host_dir)
+            except error.CmdError:
+                pass
+            utils.run('sudo rm -r %s' % host_dir)
+
+    # Utilize the container_bucket to clear out old test containers.
+    bucket = lxc.ContainerBucket(TEST_CONTAINER_PATH, TEST_HOST_PATH)
+    bucket.destroy_all()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/site_utils/lxc/unittest_logging.py b/site_utils/lxc/unittest_logging.py
new file mode 100644
index 0000000..b19750b
--- /dev/null
+++ b/site_utils/lxc/unittest_logging.py
@@ -0,0 +1,21 @@
+# Copyright 2017 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import logging
+import sys
+
+
+def setup(log_level=logging.INFO):
+    """Direct logging to stdout.
+
+    @param log_level: Level of logging to redirect to stdout, default to INFO.
+    """
+    logger = logging.getLogger()
+    logger.setLevel(log_level)
+    handler = logging.StreamHandler(sys.stdout)
+    handler.setLevel(log_level)
+    formatter = logging.Formatter('%(asctime)s %(message)s')
+    handler.setFormatter(formatter)
+    logger.handlers = []
+    logger.addHandler(handler)
diff --git a/site_utils/lxc/utils.py b/site_utils/lxc/utils.py
index 29ad241..310b121 100644
--- a/site_utils/lxc/utils.py
+++ b/site_utils/lxc/utils.py
@@ -53,3 +53,42 @@
                                    interface_names)
     netif = interface.Interface(lxc_network)
     return netif.ipv4_address
+
+def clone(lxc_path, src_name, new_path, dst_name, snapshot):
+    """Clones a container.
+
+    @param lxc_path: The LXC path of the source container.
+    @param src_name: The name of the source container.
+    @param new_path: The LXC path of the destination container.
+    @param dst_name: The name of the destination container.
+    @param snapshot: Whether or not to create a snapshot clone.
+    """
+    snapshot_arg = '-s' if snapshot else ''
+    # overlayfs is the default clone backend storage. However it is not
+    # supported in Ganeti yet. Use aufs as the alternative.
+    aufs_arg = '-B aufs' if utils.is_vm() and snapshot else ''
+    cmd = (('sudo lxc-clone --lxcpath {lxcpath} --newpath {newpath} '
+            '--orig {orig} --new {new} {snapshot} {backing}')
+           .format(
+               lxcpath = lxc_path,
+               newpath = new_path,
+               orig = src_name,
+               new = dst_name,
+               snapshot = snapshot_arg,
+               backing = aufs_arg
+           ))
+    utils.run(cmd)
+
+
+def cleanup_host_mount(host_dir):
+    """Unmounts and removes the given host dir.
+
+    @param host_dir: The host dir to unmount and remove.
+    """
+    try:
+        utils.run('sudo umount "%s"' % host_dir)
+    except error.CmdError:
+        # Ignore errors.  Most likely this occurred because the host dir
+        # was already unmounted.
+        pass
+    utils.run('sudo rm -r "%s"' % host_dir)
diff --git a/site_utils/lxc/zygote.py b/site_utils/lxc/zygote.py
new file mode 100644
index 0000000..fbb9ee7
--- /dev/null
+++ b/site_utils/lxc/zygote.py
@@ -0,0 +1,84 @@
+# Copyright 2017 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import os
+
+import common
+from autotest_lib.client.bin import utils
+from autotest_lib.site_utils.lxc import Container
+from autotest_lib.site_utils.lxc import config as lxc_config
+from autotest_lib.site_utils.lxc import constants
+from autotest_lib.site_utils.lxc import utils as lxc_utils
+
+
+class Zygote(Container):
+    """A Container that implements post-bringup configuration.
+    """
+
+    def __init__(self, container_path, name, attribute_values, src=None,
+                 snapshot=False, host_path=None):
+        """Initialize an object of LXC container with given attribute values.
+
+        @param container_path: Directory that stores the container.
+        @param name: Name of the container.
+        @param attribute_values: A dictionary of attribute values for the
+                                 container.
+        @param src: An optional source container.  If provided, the source
+                    continer is cloned, and the new container will point to the
+                    clone.
+        @param snapshot: Whether or not to create a snapshot clone.  By default,
+                         this is false.  If a snapshot is requested and creating
+                         a snapshot clone fails, a full clone will be attempted.
+        @param host_path: If set to None (the default), a host path will be
+                          generated based on constants.DEFAULT_SHARED_HOST_PATH.
+                          Otherwise, this can be used to override the host path
+                          of the new container, for testing purposes.
+        """
+        super(Zygote, self).__init__(container_path, name, attribute_values,
+                                     src, snapshot)
+
+        # Initialize host dir and mount
+        if host_path is None:
+            self.host_path = os.path.join(
+                    os.path.realpath(constants.DEFAULT_SHARED_HOST_PATH),
+                    self.name)
+        else:
+            self.host_path = host_path
+
+        if src is not None:
+            # If creating a new zygote, initialize the host dir.
+            if not lxc_utils.path_exists(self.host_path):
+                utils.run('sudo mkdir %s' % self.host_path)
+            self.mount_dir(self.host_path, lxc_config.CONTAINER_AUTOTEST_DIR)
+
+
+    def destroy(self, force=True):
+        super(Zygote, self).destroy(force)
+        if lxc_utils.path_exists(self.host_path):
+            self._cleanup_host_mount()
+
+
+    def set_hostname(self, hostname):
+        """Sets the hostname within the container.
+
+        @param hostname The new container hostname.
+        """
+        if self.is_running():
+            self.attach_run('hostname %s' % (hostname))
+            self.attach_run(constants.APPEND_CMD_FMT % {
+                'content': '127.0.0.1 %s' % (hostname),
+                'file': '/etc/hosts'})
+        else:
+            config_file = os.path.join(self.container_path, self.name, 'config')
+            lxc_utsname_setting = (
+                'lxc.utsname = ' +
+                constants.CONTAINER_UTSNAME_FORMAT % hostname)
+            utils.run(
+                constants.APPEND_CMD_FMT % {'content': lxc_utsname_setting,
+                                            'file': config_file})
+
+
+    def _cleanup_host_mount(self):
+        """Unmount and remove the host dir for this container."""
+        lxc_utils.cleanup_host_mount(self.host_path);
diff --git a/site_utils/lxc/zygote_unittest.py b/site_utils/lxc/zygote_unittest.py
new file mode 100644
index 0000000..d4ffdc0
--- /dev/null
+++ b/site_utils/lxc/zygote_unittest.py
@@ -0,0 +1,250 @@
+#!/usr/bin/python
+# Copyright 2017 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import argparse
+import logging
+import os
+import tempfile
+import shutil
+import sys
+import unittest
+from contextlib import contextmanager
+
+import common
+from autotest_lib.client.bin import utils
+from autotest_lib.site_utils import lxc
+from autotest_lib.site_utils.lxc import config as lxc_config
+from autotest_lib.site_utils.lxc import unittest_logging
+from autotest_lib.site_utils.lxc import utils as lxc_utils
+
+
+options = None
+
+class ZygoteTests(unittest.TestCase):
+    """Unit tests for the Zygote class."""
+
+    @classmethod
+    def setUpClass(cls):
+        cls.test_dir = tempfile.mkdtemp(dir=lxc.DEFAULT_CONTAINER_PATH,
+                                        prefix='zygote_unittest_')
+        cls.shared_host_path = os.path.join(cls.test_dir, 'host')
+
+        # Use a container bucket just to download and set up the base image.
+        cls.bucket = lxc.ContainerBucket(cls.test_dir, cls.shared_host_path)
+
+        if cls.bucket.base_container is None:
+            logging.debug('Base container not found - reinitializing')
+            cls.bucket.setup_base()
+
+        cls.base_container = cls.bucket.base_container
+        assert(cls.base_container is not None)
+
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.base_container = None
+        if not options.skip_cleanup:
+            cls.bucket.destroy_all()
+            shutil.rmtree(cls.test_dir)
+
+    def tearDown(self):
+        # Ensure host dirs from each test are completely destroyed.
+        for host_dir in os.listdir(self.shared_host_path):
+            host_dir = os.path.realpath(os.path.join(self.shared_host_path,
+                                                     host_dir))
+            lxc_utils.cleanup_host_mount(host_dir);
+
+
+    def testCleanup(self):
+        """Verifies that the zygote cleans up after itself."""
+        with self.createZygote() as zygote:
+            host_path = zygote.host_path
+
+            self.assertTrue(os.path.isdir(host_path))
+
+            # Start/stop the zygote to exercise the host mounts.
+            zygote.start(wait_for_network=False)
+            zygote.stop()
+
+        # After the zygote is destroyed, verify that the host path is cleaned
+        # up.
+        self.assertFalse(os.path.isdir(host_path))
+
+
+    def testCleanupWithUnboundHostDir(self):
+        """Verifies that cleanup works when the host dir is unbound."""
+        with self.createZygote() as zygote:
+            host_path = zygote.host_path
+
+            self.assertTrue(os.path.isdir(host_path))
+            # Don't start the zygote, so the host mount is not bound.
+
+        # After the zygote is destroyed, verify that the host path is cleaned
+        # up.
+        self.assertFalse(os.path.isdir(host_path))
+
+
+    def testCleanupWithNoHostDir(self):
+        """Verifies that cleanup works when the host dir is missing."""
+        with self.createZygote() as zygote:
+            host_path = zygote.host_path
+
+            utils.run('sudo rmdir %s' % zygote.host_path)
+            self.assertFalse(os.path.isdir(host_path))
+        # Zygote destruction should yield no errors if the host path is
+        # missing.
+
+
+    def testHostname(self):
+        """Verifies that the zygote starts up with a default hostname that is
+        the lxc container name."""
+        test_name = 'testHostname'
+        with self.createZygote(name=test_name) as zygote:
+            zygote.start(wait_for_network=True)
+            hostname = zygote.attach_run('hostname -f').stdout.strip()
+            self.assertEqual(test_name, hostname)
+
+
+    @unittest.skip('Setting the container hostname using lxc.utsname does not'
+                   'work on goobuntu.')
+    def testSetHostnameNotRunning(self):
+        """Verifies that the hostname can be set on a stopped container."""
+        with self.createZygote() as zygote:
+            expected_hostname = 'my-new-hostname'
+            zygote.set_hostname(expected_hostname)
+            zygote.start(wait_for_network=True)
+            hostname = zygote.attach_run('hostname -f').stdout.strip()
+            self.assertEqual(expected_hostname, hostname)
+
+
+    def testSetHostnameRunning(self):
+        """Verifies that the hostname can be set on a running container."""
+        with self.createZygote() as zygote:
+            expected_hostname = 'my-new-hostname'
+            zygote.start(wait_for_network=True)
+            zygote.set_hostname(expected_hostname)
+            hostname = zygote.attach_run('hostname -f').stdout.strip()
+            self.assertEqual(expected_hostname, hostname)
+
+
+    def testHostDir(self):
+        """Verifies that the host dir on the container is created, and correctly
+        bind-mounted."""
+        with self.createZygote() as zygote:
+            self.assertIsNotNone(zygote.host_path)
+            self.assertTrue(os.path.isdir(zygote.host_path))
+
+            zygote.start(wait_for_network=False)
+
+            self.verifyBindMount(
+                zygote,
+                container_path=lxc_config.CONTAINER_AUTOTEST_DIR,
+                host_path=zygote.host_path)
+
+
+    def testHostDirExists(self):
+        """Verifies that the host dir is just mounted if it already exists."""
+        # Pre-create the host dir and put a file in it.
+        test_host_path = os.path.join(self.shared_host_path,
+                                      'testHostDirExists')
+        test_filename = 'test_file'
+        test_host_file = os.path.join(test_host_path, test_filename)
+        test_string = 'jackdaws love my big sphinx of quartz.'
+        os.mkdir(test_host_path)
+        with open(test_host_file, 'w+') as f:
+            f.write(test_string)
+
+        # Sanity check
+        self.assertTrue(lxc_utils.path_exists(test_host_file))
+
+        with self.createZygote(host_path=test_host_path) as zygote:
+            zygote.start(wait_for_network=False)
+
+            self.verifyBindMount(
+                zygote,
+                container_path=lxc_config.CONTAINER_AUTOTEST_DIR,
+                host_path=zygote.host_path)
+
+            # Verify that the old directory contents was preserved.
+            cmd = 'cat %s' % os.path.join(lxc_config.CONTAINER_AUTOTEST_DIR,
+                                          test_filename)
+            test_output = zygote.attach_run(cmd).stdout.strip()
+            self.assertEqual(test_string, test_output)
+
+
+    @contextmanager
+    def createZygote(self,
+                     name = None,
+                     attribute_values = None,
+                     snapshot = True,
+                     host_path = None):
+        """Clones a zygote from the test base container.
+        Use this to ensure that zygotes got properly cleaned up after each test.
+
+        @param container_path: The LXC path for the new container.
+        @param host_path: The host path for the new container.
+        @param name: The name of the new container.
+        @param attribute_values: Any attribute values for the new container.
+        @param snapshot: Whether to create a snapshot clone.
+        """
+        if name is None:
+            name = self.id().split('.')[-1]
+        if host_path is None:
+            host_path = os.path.join(self.shared_host_path, name)
+        if attribute_values is None:
+            attribute_values = {}
+        zygote = lxc.Zygote(self.test_dir,
+                            name,
+                            attribute_values,
+                            self.base_container,
+                            snapshot,
+                            host_path)
+        yield zygote
+        if not options.skip_cleanup:
+            zygote.destroy()
+
+
+    def verifyBindMount(self, container, container_path, host_path):
+        """Verifies that a given path in a container is bind-mounted to a given
+        path in the host system.
+
+        @param container: The Container instance to be tested.
+        @param container_path: The path in the container to compare.
+        @param host_path: The path in the host system to compare.
+        """
+        container_inode = (container.attach_run('ls -id %s' % container_path)
+                           .stdout.split()[0])
+        host_inode = utils.run('ls -id %s' % host_path).stdout.split()[0]
+        # Compare the container and host inodes - they should match.
+        self.assertEqual(container_inode, host_inode)
+
+
+def parse_options():
+    """Parse command line inputs.
+    """
+    parser = argparse.ArgumentParser()
+    parser.add_argument('-v', '--verbose', action='store_true',
+                        help='Print out ALL entries.')
+    parser.add_argument('--skip_cleanup', action='store_true',
+                        help='Skip deleting test containers.')
+    args, argv = parser.parse_known_args()
+
+    # Hack: python unittest also processes args.  Construct an argv to pass to
+    # it, that filters out the options it won't recognize.
+    if args.verbose:
+        argv.append('-v')
+    argv.insert(0, sys.argv[0])
+
+    return args, argv
+
+
+if __name__ == '__main__':
+    options, unittest_argv = parse_options()
+
+
+    log_level=(logging.DEBUG if options.verbose else logging.INFO)
+    unittest_logging.setup(log_level)
+
+    unittest.main(argv=unittest_argv)
diff --git a/utils/unittest_suite.py b/utils/unittest_suite.py
index cdeea33..76631b2 100755
--- a/utils/unittest_suite.py
+++ b/utils/unittest_suite.py
@@ -106,8 +106,10 @@
     # crbug.com/432621 These files are not tests, and will disappear soon.
     'des_01_test.py',
     'des_02_test.py',
-    # Rquire lxc to be installed
+    # Require lxc to be installed
+    'container_bucket_unittest.py',
     'lxc_functional_test.py',
+    'zygote_unittest.py',
     # Require sponge utils installed in site-packages
     'sponge_utils_functional_test.py',
     ))