brillo chroot: Add ability to move workspace chroots.

Add "brillo chroot --move" to specify a new location for a workspace's
chroot. Does not remove the old chroot (if it exists), or move it's
contents.

BUG=brillo:613
TEST=lint + unittests

Change-Id: I7f3b5f89c6fa7dcdb888bd3ee155bf82de2ac8b5
Reviewed-on: https://chromium-review.googlesource.com/263297
Reviewed-by: Don Garrett <dgarrett@chromium.org>
Commit-Queue: Don Garrett <dgarrett@chromium.org>
Tested-by: Don Garrett <dgarrett@chromium.org>
diff --git a/cli/cros/cros_chroot.py b/cli/cros/cros_chroot.py
index 6d17de4..e696d87 100644
--- a/cli/cros/cros_chroot.py
+++ b/cli/cros/cros_chroot.py
@@ -11,6 +11,7 @@
 from chromite.cli import command
 from chromite.lib import commandline
 from chromite.lib import cros_build_lib
+from chromite.lib import workspace_lib
 
 
 @command.CommandDecorator('chroot')
@@ -20,26 +21,32 @@
   # Override base class property to enable stats upload.
   upload_stats = True
 
-  @classmethod
-  def AddParser(cls, parser):
-    """Adds a parser."""
-    super(cls, ChrootCommand).AddParser(parser)
-    parser.add_argument(
-        'command', nargs=argparse.REMAINDER,
-        help='(optional) Command to execute inside the chroot.')
+  def _SpecifyNewChrootLocation(self, chroot_dir):
+    """Specify a new location for a workspace's chroot.
 
-  def Run(self):
-    """Runs `cros chroot`."""
-    self.options.Freeze()
+    Args:
+      chroot_dir: Directory in which to specify the new chroot.
+    """
+    workspace_path = workspace_lib.WorkspacePath()
 
+    if not workspace_path:
+      cros_build_lib.Die('You must be in a workspace, to move its chroot.')
+
+    # TODO(dgarrett): Validate chroot_dir, somehow.
+    workspace_lib.SetChrootDir(workspace_path, chroot_dir)
+
+  def _RunChrootCommand(self, cmd):
+    """Run the specified command inside the chroot.
+
+    Args:
+      cmd: A list or tuple of strings to use as a command and its arguments.
+           If empty, run 'bash'.
+
+    Returns:
+      The commands result code.
+    """
     commandline.RunInsideChroot(self, auto_detect_brick=False)
 
-    cmd = self.options.command
-
-    # If -- was used to separate out the command from arguments, ignore it.
-    if cmd and cmd[0] == '--':
-      cmd = cmd[1:]
-
     # If there is no command, run bash.
     if not cmd:
       cmd = ['bash']
@@ -47,3 +54,35 @@
     result = cros_build_lib.RunCommand(cmd, print_cmd=False, error_code_ok=True,
                                        mute_output=False)
     return result.returncode
+
+  @classmethod
+  def AddParser(cls, parser):
+    """Adds a parser."""
+    super(cls, ChrootCommand).AddParser(parser)
+    parser.add_argument(
+        '--move', help='Specify new directory for workspace chroot.')
+    parser.add_argument(
+        'command', nargs=argparse.REMAINDER,
+        help='(optional) Command to execute inside the chroot.')
+
+  def Run(self):
+    """Runs `cros chroot`."""
+    self.options.Freeze()
+
+    # Handle the special case of moving the chroot.
+    if self.options.move:
+      if self.options.command:
+        cros_build_lib.Die(
+            "You can't move a chroot, and use it at the same time.")
+
+      self._SpecifyNewChrootLocation(self.options.move)
+      return 0
+
+    # Handle the standard case.
+    cmd = self.options.command
+
+    # If -- was used to separate out the command from arguments, ignore it.
+    if cmd and cmd[0] == '--':
+      cmd = cmd[1:]
+
+    return self._RunChrootCommand(cmd)
diff --git a/cli/cros/cros_chroot_unittest.py b/cli/cros/cros_chroot_unittest.py
index bfd6dfa..e207a57 100644
--- a/cli/cros/cros_chroot_unittest.py
+++ b/cli/cros/cros_chroot_unittest.py
@@ -6,10 +6,14 @@
 
 from __future__ import print_function
 
+import os
+
 from chromite.cli import command_unittest
 from chromite.cli.cros import cros_chroot
 from chromite.lib import cros_build_lib
 from chromite.lib import cros_test_lib
+from chromite.lib import osutils
+from chromite.lib import workspace_lib
 
 
 class MockChrootCommand(command_unittest.MockCommand):
@@ -89,3 +93,32 @@
 
     # Ensure we pass along "--help" instead of processing it directly.
     self.cmd_mock.rc_mock.assertCommandContains(['--help'])
+
+
+class ChrootMoveTest(cros_test_lib.MockTempDirTestCase):
+  """Test the ChrootCommand move functionality."""
+
+  def SetupCommandMock(self, cmd_args):
+    """Sets up the `cros chroot` command mock."""
+    self.cmd_mock = MockChrootCommand(cmd_args)
+    self.StartPatcher(self.cmd_mock)
+
+  def setUp(self):
+    """Patches objects."""
+    self.cmd_mock = None
+
+    self.work_dir = os.path.join(self.tempdir, 'work')
+    osutils.SafeMakedirs(self.work_dir)
+
+    # Force us to be inside the workspace.
+    self.PatchObject(workspace_lib, 'WorkspacePath', return_value=self.work_dir)
+
+  def testMove(self):
+    """Tests a command name that matches a valid argument, after '--'."""
+    # Technically, this should try to run the command "--help".
+    self.SetupCommandMock(['--move', '/foo'])
+    self.cmd_mock.inst.Run()
+
+    # Verify that it took effect.
+    self.assertEqual('/foo', workspace_lib.ChrootPath(self.work_dir))
+
diff --git a/lib/workspace_lib.py b/lib/workspace_lib.py
index 4736628..0d62151 100644
--- a/lib/workspace_lib.py
+++ b/lib/workspace_lib.py
@@ -82,10 +82,50 @@
   Returns:
     Path to where the chroot is, or where it should be created.
   """
-  # TODO(dgarrett): Check local config for alternate locations.
+  config_value = GetChrootDir(workspace_path)
+
+  if config_value:
+    return config_value
+
+  # Return the default value.
   return os.path.join(workspace_path, WORKSPACE_CHROOT_DIR)
 
 
+def SetChrootDir(workspace_path, chroot_dir):
+  """Set which chroot directory a workspace uses.
+
+  This value will overwrite the default value, if set. This is normally only
+  used if the user overwrites the default value. This method is NOT atomic.
+
+  Args:
+    workspace_path: Root directory of the workspace (WorkspacePath()).
+    chroot_dir: Directory in which this workspaces chroot should be created.
+  """
+  # Read the config, update its chroot_dir, and write it.
+  config = _ReadLocalConfig(workspace_path)
+  config['chroot_dir'] = chroot_dir
+  _WriteLocalConfig(workspace_path, config)
+
+
+def GetChrootDir(workspace_path):
+  """Get override of chroot directory for a workspace.
+
+  You should normally call ChrootPath so that the default value will be
+  found if no explicit value has been set.
+
+  Args:
+    workspace_path: Root directory of the workspace (WorkspacePath()).
+
+  Returns:
+    version string or None.
+  """
+  # Config should always return a dictionary.
+  config = _ReadLocalConfig(workspace_path)
+
+  # If version is present, use it, else return None.
+  return config.get('chroot_dir')
+
+
 def GetActiveSdkVersion(workspace_path):
   """Find which SDK version a workspace is associated with.
 
diff --git a/lib/workspace_lib_unittest.py b/lib/workspace_lib_unittest.py
index d2b4d88..5aa4ef6 100644
--- a/lib/workspace_lib_unittest.py
+++ b/lib/workspace_lib_unittest.py
@@ -98,7 +98,16 @@
       constants.CHROOT_WORKSPACE_ROOT = orig_root
 
   def testChrootPath(self):
-    self.assertEqual('/work/.chroot', workspace_lib.ChrootPath('/work'))
+    # Check the default value.
+    self.assertEqual(os.path.join(self.workspace_dir, '.chroot'),
+                     workspace_lib.ChrootPath(self.workspace_dir))
+
+    # Set a new value.
+    workspace_lib.SetChrootDir(self.workspace_dir, self.bogus_dir)
+
+    # Check we get it back.
+    self.assertEqual(self.bogus_dir,
+                     workspace_lib.ChrootPath(self.workspace_dir))
 
   def testReadWriteLocalConfig(self):
     # Non-existent config should read as an empty dictionary.