brillo blueprint: create `brillo blueprint` tool.

Creates `brillo blueprint` as a tool to help users create blueprints
without manually creating the .json file.

Also modifies blueprint_lib to match brick_lib closer in behavior. In
particular:
 * Raise an error when trying to create a blueprint at a non-empty path.
 * Verify during creation that all specified BSP/bricks exist.

Adds some config file helper functions in workspace_lib for
both brick and blueprint use.

BUG=brillo:858
TEST=cbuildbot/run_tests
TEST=brillo blueprint create foo
TEST=brillo blueprint create //foo --brick bricks/my_brick

Change-Id: Idcc3ce05e6be36d46439b1653cb4f422aa5a775c
Reviewed-on: https://chromium-review.googlesource.com/267391
Reviewed-by: Bertrand Simonnet <bsimonnet@chromium.org>
Tested-by: David Pursell <dpursell@chromium.org>
Trybot-Ready: David Pursell <dpursell@chromium.org>
Commit-Queue: David Pursell <dpursell@chromium.org>
diff --git a/cli/brillo/brillo_blueprint.py b/cli/brillo/brillo_blueprint.py
new file mode 100644
index 0000000..4c64439
--- /dev/null
+++ b/cli/brillo/brillo_blueprint.py
@@ -0,0 +1,68 @@
+# Copyright 2015 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.
+
+"""brillo blueprint: Create and manage blueprints."""
+
+from __future__ import print_function
+
+from chromite.cli import command
+from chromite.lib import blueprint_lib
+from chromite.lib import cros_build_lib
+from chromite.lib import cros_logging as logging
+
+
+@command.CommandDecorator('blueprint')
+class BlueprintCommand(command.CliCommand):
+  """Create blueprints."""
+
+  EPILOG = """
+To create a blueprint "foo" in workspace/blueprints/:
+  brillo blueprint create foo
+
+Specify a path to use a directory other than workspace/blueprints/:
+  brillo blueprint create //my_blueprints/foo
+  brillo blueprint create ./foo
+
+You can configure the initial setup as well:
+  brillo blueprint create foo --bsp <bsp> --brick <brick>
+
+Multiple bricks can be specified by repeating -b/--brick:
+  brillo blueprint create foo --bsp <bsp> -b <brick> -b <brick>
+"""
+
+  @classmethod
+  def AddParser(cls, parser):
+    super(cls, BlueprintCommand).AddParser(parser)
+    subparser = parser.add_subparsers()
+    create_parser = subparser.add_parser('create', epilog=cls.EPILOG)
+    create_parser.add_argument('blueprint', help='Blueprint path/locator.')
+    create_parser.add_argument('--brick', '-b', action='append', dest='bricks',
+                               help='Add an existing brick to the blueprint.',
+                               default=[], metavar='BRICK')
+    create_parser.add_argument('--bsp', help='Set the blueprint BSP.',
+                               default='')
+    create_parser.set_defaults(handler_func='Create')
+
+  def Create(self):
+    """Create a new blueprint."""
+    path = blueprint_lib.ExpandBlueprintPath(self.options.blueprint)
+
+    if not self.options.bsp:
+      logging.warning('No BSP was specified; blueprint will not build until a '
+                      'valid BSP is set in %s.', path)
+
+    config = {blueprint_lib.BSP_FIELD: self.options.bsp,
+              blueprint_lib.BRICKS_FIELD: self.options.bricks}
+    blueprint_lib.Blueprint(path, initial_config=config)
+
+  def Run(self):
+    """Dispatch the call to the right handler."""
+    self.options.Freeze()
+    try:
+      getattr(self, self.options.handler_func)()
+    except Exception as e:
+      if self.options.debug:
+        raise
+      else:
+        cros_build_lib.Die(e)
diff --git a/cli/brillo/brillo_blueprint_unittest b/cli/brillo/brillo_blueprint_unittest
new file mode 120000
index 0000000..ef3e37b
--- /dev/null
+++ b/cli/brillo/brillo_blueprint_unittest
@@ -0,0 +1 @@
+../../scripts/wrapper.py
\ No newline at end of file
diff --git a/cli/brillo/brillo_blueprint_unittest.py b/cli/brillo/brillo_blueprint_unittest.py
new file mode 100644
index 0000000..1d16081
--- /dev/null
+++ b/cli/brillo/brillo_blueprint_unittest.py
@@ -0,0 +1,79 @@
+# Copyright 2015 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.
+
+"""This module tests the brillo blueprint command."""
+
+from __future__ import print_function
+
+from chromite.cli import command_unittest
+from chromite.cli.brillo import brillo_blueprint
+from chromite.lib import cros_test_lib
+
+
+class MockBlueprintCommand(command_unittest.MockCommand):
+  """Mock out the `brillo blueprint` command."""
+  TARGET = 'chromite.cli.brillo.brillo_blueprint.BlueprintCommand'
+  TARGET_CLASS = brillo_blueprint.BlueprintCommand
+  COMMAND = 'blueprint'
+
+
+class BlueprintCommandTest(cros_test_lib.OutputTestCase,
+                           cros_test_lib.WorkspaceTestCase):
+  """Test the flow of BlueprintCommand.Run()."""
+
+  FOO_BRICK = '//bricks/foo'
+  BAR_BRICK = '//bricks/bar'
+  BSP_BRICK = '//bsp/my_bsp'
+
+  def setUp(self):
+    """Patches objects."""
+    self.cmd_mock = None
+    self.CreateWorkspace()
+    # Create some fake bricks for testing.
+    for brick in (self.FOO_BRICK, self.BAR_BRICK, self.BSP_BRICK):
+      self.CreateBrick(brick)
+
+  def SetupCommandMock(self, cmd_args):
+    """Sets up the MockBlueprintCommand."""
+    self.cmd_mock = MockBlueprintCommand(cmd_args)
+    self.StartPatcher(self.cmd_mock)
+
+  def RunCommandMock(self, *args, **kwargs):
+    """Sets up and runs the MockBlueprintCommand."""
+    self.SetupCommandMock(*args, **kwargs)
+    self.cmd_mock.inst.Run()
+
+  def testCreateBlueprint(self):
+    """Tests basic blueprint creation."""
+    with self.OutputCapturer():
+      self.RunCommandMock(['create', 'foo'])
+    self.AssertBlueprintExists('//blueprints/foo.json')
+    # Also make sure we get a warning for the missing BSP.
+    self.AssertOutputContainsWarning('No BSP was specified', check_stderr=True)
+
+  def testCreateBlueprintBsp(self):
+    """Tests --bsp argument."""
+    self.RunCommandMock(['create', 'foo', '--bsp', self.BSP_BRICK])
+    self.AssertBlueprintExists('//blueprints/foo.json', bsp=self.BSP_BRICK)
+
+  def testCreateBlueprintBricks(self):
+    """Tests --brick and -b arguments."""
+    self.RunCommandMock(['create', 'foo', '--brick', self.FOO_BRICK,
+                         '-b%s' % self.BAR_BRICK])
+    self.AssertBlueprintExists('//blueprints/foo.json',
+                               bricks=[self.FOO_BRICK, self.BAR_BRICK])
+
+  def testCreateBlueprintAll(self):
+    """Test --brick and --bsp arguments together."""
+    self.RunCommandMock(['create', 'foo', '--brick', self.FOO_BRICK,
+                         '--bsp', self.BSP_BRICK])
+    self.AssertBlueprintExists('//blueprints/foo.json', bsp=self.BSP_BRICK,
+                               bricks=[self.FOO_BRICK])
+
+  def testBlueprintInvalidCommand(self):
+    """Tests that `brillo blueprint` without `create` prints usage."""
+    with self.OutputCapturer():
+      with self.assertRaises(SystemExit):
+        self.RunCommandMock([])
+    self.AssertOutputContainsLine('usage:', check_stderr=True)
diff --git a/lib/blueprint_lib.py b/lib/blueprint_lib.py
index 0c7f5fc..77d4186 100644
--- a/lib/blueprint_lib.py
+++ b/lib/blueprint_lib.py
@@ -6,18 +6,48 @@
 
 from __future__ import print_function
 
-import json
 import os
 
 from chromite.lib import brick_lib
-from chromite.lib import osutils
 from chromite.lib import workspace_lib
 
 
-class BlueprintNotFound(Exception):
+# Field names for specifying initial configuration.
+BRICKS_FIELD = 'bricks'
+BSP_FIELD = 'bsp'
+
+
+class BlueprintNotFoundError(Exception):
   """The blueprint does not exist."""
 
 
+class BlueprintCreationError(Exception):
+  """Blueprint creation failed."""
+
+
+# TODO(dpursell): Enable this shorthand blueprint specifications for all CLI
+# tools. http://brbug.com/931.
+def ExpandBlueprintPath(path):
+  """Expand a blueprint path using some common assumptions.
+
+  Makes the following changes to |path|:
+    1. Put non-paths in //blueprints (e.g. foo -> //blueprints/foo).
+    2. Add .json if no extension was given.
+
+  Args:
+    path: blueprint path.
+
+  Returns:
+    Modified blueprint path.
+  """
+  # Non-path arguments should be put in //blueprints by default.
+  if '/' not in path:
+    path = os.path.join('//blueprints', path)
+  if os.path.splitext(path)[1] != '.json':
+    path += '.json'
+  return path
+
+
 class Blueprint(object):
   """Encapsulates the interaction with a blueprint."""
 
@@ -30,32 +60,64 @@
         with '//'.
       initial_config: A dictionary of key-value pairs to seed a new blueprint
         with if the specified blueprint doesn't already exist.
+
+    Raises:
+      BlueprintNotFoundError: No blueprint exists at |blueprint_loc| and no
+        |initial_config| was given to create a new one.
+      BlueprintCreationError: |initial_config| was specified but a file
+        already exists at |blueprint_loc|.
     """
     self._path = (workspace_lib.LocatorToPath(blueprint_loc)
                   if workspace_lib.IsLocator(blueprint_loc) else blueprint_loc)
     self._locator = workspace_lib.PathToLocator(self._path)
-    if not os.path.exists(self._path):
-      if initial_config:
-        osutils.WriteFile(self._path,
-                          json.dumps(initial_config, sort_keys=True,
-                                     indent=4, separators=(',', ': ')),
-                          makedirs=True)
-      else:
-        raise BlueprintNotFound('blueprint %s not found.' % self._path)
 
-    self.config = json.loads(osutils.ReadFile(self._path))
+    if initial_config is not None:
+      self._CreateBlueprintConfig(initial_config)
+
+    try:
+      self.config = workspace_lib.ReadConfigFile(self._path)
+    except IOError:
+      raise BlueprintNotFoundError('Blueprint %s not found.' % self._path)
+
+  def _CreateBlueprintConfig(self, config):
+    """Create an initial blueprint config file.
+
+    Converts all brick paths in |config| into locators then saves the
+    configuration file to |self._path|.
+
+    Currently fails if |self._path| already exists, but could be
+    generalized to allow re-writing config files if needed.
+
+    Args:
+      config: configuration dictionary.
+
+    Raises:
+      BlueprintCreationError: A brick in |config| doesn't exist or an
+        error occurred while saving the config file.
+    """
+    if os.path.exists(self._path):
+      raise BlueprintCreationError('File already exists at %s.' % self._path)
+
+    try:
+      # Turn brick specifications into locators.
+      if config.get(BRICKS_FIELD):
+        config[BRICKS_FIELD] = [brick_lib.Brick(b).brick_locator
+                                for b in config[BRICKS_FIELD]]
+      if config.get(BSP_FIELD):
+        config[BSP_FIELD] = brick_lib.Brick(config[BSP_FIELD]).brick_locator
+
+      # Create the config file.
+      workspace_lib.WriteConfigFile(self._path, config)
+    except (brick_lib.BrickNotFound, workspace_lib.ConfigFileError) as e:
+      raise BlueprintCreationError('Blueprint creation failed. %s' % e)
 
   def GetBricks(self):
     """Returns the bricks field of a blueprint."""
-    return self.config.get('bricks', [])
+    return self.config.get(BRICKS_FIELD, [])
 
   def GetBSP(self):
     """Returns the BSP field of a blueprint."""
-    return self.config.get('bsp')
-
-  def GetMainPackage(self):
-    """Returns the main_package field of a blueprint."""
-    return self.config.get('main_package')
+    return self.config.get(BSP_FIELD)
 
   def FriendlyName(self):
     """Returns the friendly name for this blueprint."""
diff --git a/lib/blueprint_lib_unittest.py b/lib/blueprint_lib_unittest.py
index 1c145d9..6e36dee 100644
--- a/lib/blueprint_lib_unittest.py
+++ b/lib/blueprint_lib_unittest.py
@@ -6,8 +6,40 @@
 
 from __future__ import print_function
 
+from chromite.lib import blueprint_lib
 from chromite.lib import brick_lib
 from chromite.lib import cros_test_lib
+from chromite.lib import osutils
+from chromite.lib import workspace_lib
+
+
+class ExpandBlueprintPathTest(cros_test_lib.TestCase):
+  """Tests ExpandBlueprintPath()."""
+
+  def testBasicName(self):
+    """Tests a basic name."""
+    self.assertEqual('//blueprints/foo.json',
+                     blueprint_lib.ExpandBlueprintPath('foo'))
+
+  def testLocator(self):
+    """Tests a locator name."""
+    self.assertEqual('//foo/bar.json',
+                     blueprint_lib.ExpandBlueprintPath('//foo/bar'))
+
+  def testCurrentDirectory(self):
+    """Tests a path to the current directory."""
+    self.assertEqual('./foo.json',
+                     blueprint_lib.ExpandBlueprintPath('./foo'))
+
+  def testJson(self):
+    """Tests that .json isn't added twice."""
+    self.assertEqual('//blueprints/foo.json',
+                     blueprint_lib.ExpandBlueprintPath('foo.json'))
+
+  def testTxt(self):
+    """Tests that .txt extension still gets .json appended."""
+    self.assertEqual('//blueprints/foo.txt.json',
+                     blueprint_lib.ExpandBlueprintPath('foo.txt'))
 
 
 class BlueprintLibTest(cros_test_lib.WorkspaceTestCase):
@@ -19,19 +51,19 @@
   def testBlueprint(self):
     """Tests getting the basic blueprint getters."""
     bricks = ['//foo', '//bar', '//baz']
-    blueprint = self.CreateBlueprint(bricks=bricks, bsp='//bsp',
-                                     main_package='virtual/target-os')
+    for brick in bricks:
+      self.CreateBrick(brick)
+    self.CreateBrick('//bsp')
+    blueprint = self.CreateBlueprint(bricks=bricks, bsp='//bsp')
     self.assertEqual(blueprint.GetBricks(), bricks)
     self.assertEqual(blueprint.GetBSP(), '//bsp')
-    self.assertEqual(blueprint.GetMainPackage(), 'virtual/target-os')
 
   def testBlueprintNoBricks(self):
     """Tests that blueprints without bricks return reasonable defaults."""
-    blueprint = self.CreateBlueprint(bsp='//bsp2',
-                                     main_package='virtual/target-os-dev')
+    self.CreateBrick('//bsp2')
+    blueprint = self.CreateBlueprint(bsp='//bsp2')
     self.assertEqual(blueprint.GetBricks(), [])
     self.assertEqual(blueprint.GetBSP(), '//bsp2')
-    self.assertEqual(blueprint.GetMainPackage(), 'virtual/target-os-dev')
 
   def testGetUsedBricks(self):
     """Tests that we can list all the bricks used."""
@@ -41,11 +73,46 @@
                               initial_config={'name':'c',
                                               'dependencies': ['//b']})
 
-    blueprint = self.CreateBlueprint(bsp='//a', bricks=[brick_c.brick_locator])
+    blueprint = self.CreateBlueprint(blueprint_name='foo.json',
+                                     bsp='//a', bricks=[brick_c.brick_locator])
     self.assertEqual(3, len(blueprint.GetUsedBricks()))
 
     # We sort out duplicates: c depends on b and b is explicitly listed in
     # bricks too.
-    blueprint = self.CreateBlueprint(bsp='//a', bricks=[brick_c.brick_locator,
+    blueprint = self.CreateBlueprint(blueprint_name='bar.json',
+                                     bsp='//a', bricks=[brick_c.brick_locator,
                                                         brick_b.brick_locator])
     self.assertEqual(3, len(blueprint.GetUsedBricks()))
+
+  def testBlueprintAlreadyExists(self):
+    """Tests creating a blueprint where one already exists."""
+    self.CreateBrick('//foo')
+    self.CreateBrick('//bar')
+    self.CreateBlueprint(blueprint_name='//my_blueprint', bricks=['//foo'])
+    with self.assertRaises(blueprint_lib.BlueprintCreationError):
+      self.CreateBlueprint(blueprint_name='//my_blueprint', bricks=['//bar'])
+    # Make sure the original blueprint is untouched.
+    self.assertEqual(['//foo'],
+                     blueprint_lib.Blueprint('//my_blueprint').GetBricks())
+
+  def testBlueprintBrickNotFound(self):
+    """Tests creating a blueprint with a non-existent brick fails."""
+    with self.assertRaises(blueprint_lib.BlueprintCreationError):
+      self.CreateBlueprint(blueprint_name='//my_blueprint', bricks=['//none'])
+
+  def testBlueprintBSPNotFound(self):
+    """Tests creating a blueprint with a non-existent BSP fails."""
+    with self.assertRaises(blueprint_lib.BlueprintCreationError):
+      self.CreateBlueprint(blueprint_name='//my_blueprint', bsp='//none')
+
+  def testBlueprintNotFound(self):
+    """Tests loading a non-existent blueprint file."""
+    with self.assertRaises(blueprint_lib.BlueprintNotFoundError):
+      blueprint_lib.Blueprint('//not/a/blueprint')
+
+  def testInvalidBlueprint(self):
+    """Tests loading an invalid blueprint file."""
+    path = workspace_lib.LocatorToPath('//invalid_file')
+    osutils.WriteFile(path, 'invalid contents')
+    with self.assertRaises(workspace_lib.ConfigFileError):
+      blueprint_lib.Blueprint(path)
diff --git a/lib/brick_lib.py b/lib/brick_lib.py
index 569969c..0925688 100644
--- a/lib/brick_lib.py
+++ b/lib/brick_lib.py
@@ -6,7 +6,6 @@
 
 from __future__ import print_function
 
-import json
 import os
 
 from chromite.lib import osutils
@@ -17,7 +16,7 @@
                         'thin-manifests': 'true',
                         'use-manifests': 'true'}
 
-_CONFIG_JSON = 'config.json'
+_CONFIG_FILE = 'config.json'
 
 _IGNORED_OVERLAYS = ('portage-stable', 'chromiumos', 'eclass-overlay')
 
@@ -65,7 +64,7 @@
 
     self.config = None
     self.legacy = False
-    config_json = os.path.join(self.brick_dir, _CONFIG_JSON)
+    config_json = os.path.join(self.brick_dir, _CONFIG_FILE)
 
     if not os.path.exists(config_json):
       if initial_config:
@@ -106,7 +105,7 @@
       if self.config is None:
         raise BrickNotFound('Brick not found at %s' % self.brick_dir)
     elif initial_config is None:
-      self.config = json.loads(osutils.ReadFile(config_json))
+      self.config = workspace_lib.ReadConfigFile(config_json)
     else:
       raise BrickCreationFailed('brick %s already exists.' % self.brick_dir)
 
@@ -147,13 +146,13 @@
   def UpdateConfig(self, config, regenerate=True):
     """Updates the brick's configuration.
 
-    Write the json representation of |config| in config.json.
+    Writes |config| to the configuration file.
     If |regenerate| is true, regenerate the portage configuration files in
-    this brick to match the new config.json.
+    this brick to match the new configuration.
 
     Args:
-      config: brick configuration as a python dict
-      regenerate: if True, regenerate autogenerated brick files
+      config: brick configuration as a python dict.
+      regenerate: if True, regenerate autogenerated brick files.
     """
     if self.legacy:
       raise BrickFeatureNotSupported(
@@ -166,10 +165,8 @@
                                    else workspace_lib.PathToLocator(d)
                                    for d in self.config.get('dependencies', [])]
 
-    formatted_config = json.dumps(config, sort_keys=True, indent=4,
-                                  separators=(',', ': '))
-    osutils.WriteFile(os.path.join(self.brick_dir, _CONFIG_JSON),
-                      formatted_config, makedirs=True)
+    workspace_lib.WriteConfigFile(os.path.join(self.brick_dir, _CONFIG_FILE),
+                                  config)
 
     if regenerate:
       self.GeneratePortageConfig()
diff --git a/lib/brick_lib_unittest.py b/lib/brick_lib_unittest.py
index 02dd2d6..e08a4f7 100644
--- a/lib/brick_lib_unittest.py
+++ b/lib/brick_lib_unittest.py
@@ -50,7 +50,7 @@
       self.assertTrue(line in layout_conf)
 
   def testConfigurationGenerated(self):
-    """Test that portage's files are generated when brick.json changes."""
+    """Test that portage's files are generated when the config file changes."""
     (self.brick, self.brick_path) = self.CreateBrick()
     sample_config = {'name': 'hello',
                      'dependencies': []}
@@ -62,7 +62,7 @@
   def testFindBrickInPath(self):
     """Test that we can infer the current brick from the current directory."""
     (self.brick, self.brick_path) = self.CreateBrick()
-    os.remove(os.path.join(self.brick_path, 'config.json'))
+    os.remove(os.path.join(self.brick_path, brick_lib._CONFIG_FILE))
     brick_dir = os.path.join(self.workspace_path, 'foo', 'bar', 'project')
     expected_name = 'hello'
     brick_lib.Brick(brick_dir, initial_config={'name': 'hello'})
diff --git a/lib/cros_test_lib.py b/lib/cros_test_lib.py
index 7583d87..9694bbd 100644
--- a/lib/cros_test_lib.py
+++ b/lib/cros_test_lib.py
@@ -1582,6 +1582,21 @@
 
     return blueprint_lib.Blueprint(path, initial_config=config)
 
+  def AssertBlueprintExists(self, locator, bsp=None, bricks=None):
+    """Verifies a blueprint exists with the specified contents.
+
+    Args:
+      locator: blueprint locator to check.
+      bsp: Expected blueprint BSP or None.
+      bricks: Expected blueprint bricks or None.
+    """
+    actual = blueprint_lib.Blueprint(locator)
+
+    if bsp is not None:
+      self.assertEqual(bsp, actual.GetBSP())
+    if bricks is not None:
+      self.assertListEqual(bricks, actual.GetBricks())
+
 
 @contextlib.contextmanager
 def SetTimeZone(tz):
diff --git a/lib/workspace_lib.py b/lib/workspace_lib.py
index 2999c65..51a62cf 100644
--- a/lib/workspace_lib.py
+++ b/lib/workspace_lib.py
@@ -43,6 +43,10 @@
   """Given locator could not be resolved."""
 
 
+class ConfigFileError(Exception):
+  """Configuration file writing or reading failed."""
+
+
 def WorkspacePath(workspace_reference_dir=None):
   """Returns the path to the current workspace.
 
@@ -169,15 +173,12 @@
   Returns:
     Local workspace config as a Python dictionary.
   """
-  config_file = os.path.join(workspace_path, WORKSPACE_LOCAL_CONFIG)
-
-  # If the file doesn't exist, it's an empty dictionary.
-  if not os.path.exists(config_file):
+  try:
+    return ReadConfigFile(os.path.join(workspace_path, WORKSPACE_LOCAL_CONFIG))
+  except IOError:
+    # If the file doesn't exist, it's an empty dictionary.
     return {}
 
-  with open(config_file, 'r') as config_fp:
-    return json.load(config_fp)
-
 
 def _WriteLocalConfig(workspace_path, config):
   """Save out a new local config for a workspace.
@@ -186,11 +187,7 @@
     workspace_path: Root directory of the workspace (WorkspacePath()).
     config: New local workspace config contents as a Python dictionary.
   """
-  config_file = os.path.join(workspace_path, WORKSPACE_LOCAL_CONFIG)
-
-  # Overwrite the config file, with the new dictionary.
-  with open(config_file, 'w') as config_fp:
-    json.dump(config, config_fp)
+  WriteConfigFile(os.path.join(workspace_path, WORKSPACE_LOCAL_CONFIG), config)
 
 
 def IsLocator(name):
@@ -276,3 +273,47 @@
     return locator[len(_WORKSPACE_LOCATOR_PREFIX):].replace('/', '.')
 
   raise ValueError('Not a valid workspace locator: %s' % locator)
+
+
+def WriteConfigFile(path, config):
+  """Writes |config| to a file at |path|.
+
+  Configuration files in a workspace should all use the same format
+  whenever possible. Currently it's JSON, but centralizing config
+  read/write makes it easier to change when needed.
+
+  Args:
+    path: path to write.
+    config: configuration dictionary to write.
+
+  Raises:
+    ConfigFileError: |config| cannot be written as JSON.
+  """
+  # TODO(dpursell): Add support for comments in config files.
+  try:
+    osutils.WriteFile(
+        path,
+        json.dumps(config, sort_keys=True, indent=4, separators=(',', ': ')),
+        makedirs=True)
+  except TypeError as e:
+    raise ConfigFileError('Writing config file %s failed: %s', path, e)
+
+
+def ReadConfigFile(path):
+  """Reads a configuration file at |path|.
+
+  For use with WriteConfigFile().
+
+  Args:
+    path: file path.
+
+  Returns:
+    Result of parsing the JSON file.
+
+  Raises:
+    ConfigFileError: JSON parsing failed.
+  """
+  try:
+    return json.loads(osutils.ReadFile(path))
+  except ValueError as e:
+    raise ConfigFileError('%s is not in valid JSON format: %s' % (path, e))
diff --git a/lib/workspace_lib_unittest.py b/lib/workspace_lib_unittest.py
index 5aa4ef6..4e8426e 100644
--- a/lib/workspace_lib_unittest.py
+++ b/lib/workspace_lib_unittest.py
@@ -186,3 +186,31 @@
     assertReversible('//foo')
     assertReversible('//foo/bar/baz')
     assertReversible('board:gizmo')
+
+
+class ConfigurationTest(cros_test_lib.TempDirTestCase):
+  """Test WriteConfigFile() and ReadConfigFile()."""
+
+  def testWriteReadConfigFile(self):
+    """Tests WriteConfigFile() then ReadConfigFile()."""
+    path = os.path.join(self.tempdir, 'foo.json')
+    config = {'foo': 1, 'bar': 2}
+
+    workspace_lib.WriteConfigFile(path, config)
+    self.assertDictEqual(config, workspace_lib.ReadConfigFile(path))
+
+  def testWriteConfigFileInvalid(self):
+    """Tests writing an invalid configuration file."""
+    path = os.path.join(self.tempdir, 'foo.json')
+    config = Exception()
+
+    with self.assertRaises(workspace_lib.ConfigFileError):
+      workspace_lib.WriteConfigFile(path, config)
+
+  def testReadConfigFileInvalid(self):
+    """Tests reading an invalid configuration file."""
+    path = os.path.join(self.tempdir, 'foo.json')
+    osutils.WriteFile(path, 'invalid contents')
+
+    with self.assertRaises(workspace_lib.ConfigFileError):
+      workspace_lib.ReadConfigFile(path)