Integration test to test the basic functionality of dev-install and gmerge.

This module contains a test that runs some sanity integration tests against
a VM. First it starts a VM test image and turns it into a base image by wiping
all of the stateful partition. Once done, runs dev_install to restore the
stateful partition and then runs gmerge.

Right now I have the gmerge test not run as it's having some funky portage
issues (I could switch it to qemacs and it'll work -- see TODO).

BUG=chromium-os:12388 chromium-os:37901
TEST=Ran it --- many many times.

Change-Id: I32a230514d07d76b3d6cd9029181a374826c4104
Reviewed-on: https://gerrit.chromium.org/gerrit/44762
Commit-Queue: Chris Sosa <sosa@chromium.org>
Reviewed-by: Chris Sosa <sosa@chromium.org>
Tested-by: Chris Sosa <sosa@chromium.org>
diff --git a/devmode-test/README b/devmode-test/README
new file mode 100644
index 0000000..4cb44f3
--- /dev/null
+++ b/devmode-test/README
@@ -0,0 +1,8 @@
+# Copyright (c) 2013 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.
+
+Folder containing tests for dev-mode related programs like dev-install.
+
+These tests run from a VM and do not use autotest as they muck with the entire
+stateful partition which is necessary for autotest tests.
diff --git a/devmode-test/constants.py b/devmode-test/constants.py
new file mode 120000
index 0000000..8d73346
--- /dev/null
+++ b/devmode-test/constants.py
@@ -0,0 +1 @@
+../lib/constants.py
\ No newline at end of file
diff --git a/devmode-test/devinstall_test.py b/devmode-test/devinstall_test.py
new file mode 100755
index 0000000..b47f4cc
--- /dev/null
+++ b/devmode-test/devinstall_test.py
@@ -0,0 +1,217 @@
+#!/usr/bin/python
+#
+# Copyright (c) 2013 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.
+
+"""Integration test to test the basic functionality of dev-install and gmerge.
+
+This module contains a test that runs some sanity integration tests against
+a VM. First it starts a VM test image and turns it into a base image by wiping
+all of the stateful partition. Once done, runs dev_install to restore the
+stateful partition and then runs gmerge.
+"""
+
+import logging
+import optparse
+import os
+import shutil
+import socket
+import sys
+import tempfile
+
+import constants
+sys.path.append(constants.SOURCE_ROOT)
+sys.path.append(constants.CROS_PLATFORM_ROOT)
+
+from chromite.lib import cros_build_lib
+from crostestutils.lib import dev_server_wrapper
+from crostestutils.lib import mount_helper
+from crostestutils.lib import test_helper
+
+
+_LOCALHOST = 'localhost'
+_PRIVATE_KEY = os.path.join(constants.CROSUTILS_DIR, 'mod_for_test_scripts',
+                            'ssh_keys', 'testing_rsa')
+
+class TestError(Exception):
+  """Raised on any error during testing. It being raised is a test failure."""
+
+
+class DevModeTest(object):
+  """Wrapper for dev mode tests."""
+  def __init__(self, image_path, board, binhost):
+    """
+    Args:
+      image_path: Filesystem path to the image to test.
+      board: Board of the image under test.
+      binhost: Binhost override. Binhost as defined here is where dev-install
+               or gmerge go to search for binary packages. By default this will
+               be set to the devserver url of the host running this script.
+               If no override i.e. the default is ok, set to None.
+    """
+    self.image_path = image_path
+    self.board = board
+    self.binhost = binhost
+
+    self.tmpdir = tempfile.mkdtemp('DevModeTest')
+    self.tmpsshkey = os.path.join(self.tmpdir, 'testing_rsa')
+    self.tmpkvmpid = os.path.join(self.tmpdir, 'kvm_pid')
+
+    self.working_image_path = None
+    self.devserver = None
+    self.port = None
+
+  def Cleanup(self):
+    """Clean up any state at the end of the test."""
+    try:
+      if self.working_image_path:
+        os.remove(self.working_image_path)
+
+      if self.devserver:
+        self.devserver.Stop()
+
+      self.devserver = None
+
+      cmd = ['%s/bin/cros_stop_vm' % constants.CROSUTILS_DIR,
+             '--kvm_pid', self.tmpkvmpid]
+      cros_build_lib.RunCommand(cmd, debug_level=logging.DEBUG)
+
+      if self.tmpdir:
+        shutil.rmtree(self.tmpdir, ignore_errors=True)
+
+      self.tmpdir = None
+    except Exception:
+      logging.warning('Received error during cleanup', exc_info=True)
+
+  def _SetupSSH(self):
+    """Sets up the necessary items for running ssh."""
+    self.port = self._FindUnusedPort()
+    shutil.copyfile(_PRIVATE_KEY, self.tmpsshkey)
+    os.chmod(self.tmpsshkey, 0400)
+
+  def _RunSSHCommand(self, command, **kwargs):
+    """Runs ssh command (a list) using RunCommand (kwargs for RunCommand)."""
+    assert isinstance(command, list)
+    assert self.port and self.tmpsshkey, 'Tried to run ssh before ssh was setup'
+    kwargs.update(dict(debug_level=logging.DEBUG))
+    return cros_build_lib.RunCommand(
+        ['ssh', '-n', '-p', str(self.port), '-i', self.tmpsshkey,
+         'root@%s' % _LOCALHOST, '--'] + command, **kwargs)
+
+  def _WipeStatefulPartition(self):
+    """Deletes everything from the working image path's stateful partition."""
+    r_mount_point = os.path.join(self.tmpdir, 'm')
+    s_mount_point = os.path.join(self.tmpdir, 's')
+    mount_helper.MountImage(self.working_image_path,
+                            r_mount_point, s_mount_point, read_only=False,
+                            safe=True)
+    # Run in shell mode to interpret '*' as a glob.
+    cros_build_lib.SudoRunCommand('rm -rf %s/*' % s_mount_point, shell=True,
+                                  debug_level=logging.DEBUG)
+    mount_helper.UnmountImage(r_mount_point, s_mount_point)
+
+  def _FindUnusedPort(self):
+    """Returns a currently unused port."""
+    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+    s.bind((_LOCALHOST, 0))
+    port = s.getsockname()[1]
+    s.close()
+    return port
+
+  def PrepareTest(self):
+    """Pre-test modification to the image and env to setup test."""
+    logging.info('Setting up the image %s for vm testing.',
+                 self.image_path)
+    self._SetupSSH()
+    vm_path = test_helper.CreateVMImage(self.image_path, self.board,
+                                        full=False)
+
+    logging.info('Making copy of the vm image %s to manipulate.', vm_path)
+    self.working_image_path = vm_path + '.' + str(self.port)
+    shutil.copyfile(vm_path, self.working_image_path)
+    logging.debug('Copy of vm image stored at %s.', self.working_image_path)
+
+    logging.info('Wiping the stateful partition to prepare test.')
+    self._WipeStatefulPartition()
+
+    logging.info('Starting the vm on port %d.', self.port)
+    cmd = ['%s/bin/cros_start_vm' % constants.CROSUTILS_DIR,
+           '--ssh_port', str(self.port),
+           '--image_path', self.working_image_path,
+           '--no_graphics',
+           '--kvm_pid', self.tmpkvmpid]
+    cros_build_lib.RunCommand(cmd, debug_level=logging.DEBUG)
+
+    if not self.binhost:
+      logging.info('Starting the devserver.')
+      self.devserver = dev_server_wrapper.DevServerWrapper(self.tmpdir)
+      self.devserver.start()
+      self.devserver.WaitUntilStarted()
+      self.binhost = dev_server_wrapper.DevServerWrapper.GetDevServerURL(
+          None, 'static/pkgroot/%s/packages' % self.board)
+
+    logging.info('Using binhost %s', self.binhost)
+
+  def TestDevInstall(self):
+    """Tests that we can run dev-install and have python work afterwards."""
+    try:
+      logging.info('Running dev install in the vm.')
+      self._RunSSHCommand(
+          ['bash', '-l', '-c',
+           '"/usr/bin/dev_install --yes --binhost %s"' % self.binhost])
+
+      logging.info('Verifying that python works on the image.')
+      self._RunSSHCommand(
+          ['sudo', '-u', 'chronos', '--',
+           'python', '-c', '"print \'hello world\'"'])
+    except cros_build_lib.RunCommandError as e:
+      self.devserver.PrintLog()
+      logging.error('dev-install test failed. See devserver log above for more '
+                    'details.')
+      raise TestError('dev-install test failed with: %s' % str(e))
+
+  # TODO(sosa): Currently not run as emerge is not respecting package.provided
+  # after dev_install in a VM. Cannot repro on a device.
+  def TestGmerge(self):
+    """Evaluates whether the test passed or failed."""
+    logging.info('Verifying that python works on the image after dev install.')
+    try:
+      self._RunSSHCommand(['gmerge', 'gmerge', '--accept_stable', '--usepkg'])
+    except cros_build_lib.RunCommandError as e:
+      logging.error('gmerge test failed. See log for details')
+      raise TestError('gmerge test failed with: %s' % str(e))
+
+
+def main():
+  usage = ('%s <board> <path_to_[test|vm]_image>. '
+           'See --help for more options' % os.path.basename(sys.argv[0]))
+  parser = optparse.OptionParser(usage)
+  parser.add_option('--binhost', metavar='URL',
+                    help='binhost override. By default, starts up a devserver '
+                         'and uses it as the binhost.')
+  parser.add_option('-v', '--verbose', default=False, action='store_true',
+                    help='Print out added debugging information')
+
+  (options, args) = parser.parse_args()
+
+  if len(args) != 2:
+    parser.print_usage()
+    parser.error('Need board and path to test image.')
+
+  board = args[0]
+  image_path = os.path.realpath(args[1])
+
+  test_helper.SetupCommonLoggingFormat(verbose=options.verbose)
+
+  test = DevModeTest(image_path, board, options.binhost)
+  try:
+    test.PrepareTest()
+    test.TestDevInstall()
+    logging.info('All tests passed.')
+  finally:
+    test.Cleanup()
+
+
+if __name__ == '__main__':
+  main()
diff --git a/lib/dev_server_wrapper.py b/lib/dev_server_wrapper.py
index dd97977..684a5f3 100644
--- a/lib/dev_server_wrapper.py
+++ b/lib/dev_server_wrapper.py
@@ -76,7 +76,7 @@
     """Print devserver output."""
     print '--- Start output from %s ---' % self._log_filename
     # Open in update mode in case the child process hasn't opened the file yet.
-    with open(self._log_filename, 'w+') as log:
+    with open(self._log_filename) as log:
       sys.stdout.writelines(log)
     print '--- End output from %s ---' % self._log_filename
 
@@ -105,9 +105,10 @@
   def GetDevServerURL(cls, port, sub_dir):
     """Returns the dev server url for a given port and sub directory."""
     if not port: port = 8080
-    url = 'http://%(ip)s:%(port)s/%(dir)s' % {'ip': GetIPAddress(),
-                                              'port': str(port),
-                                              'dir': sub_dir}
+    url = 'http://%(ip)s:%(port)s' % {'ip': GetIPAddress(), 'port': str(port)}
+    if sub_dir:
+      url += '/' + sub_dir
+
     return url
 
   @classmethod
diff --git a/lib/mount_helper.py b/lib/mount_helper.py
new file mode 100644
index 0000000..16e9973
--- /dev/null
+++ b/lib/mount_helper.py
@@ -0,0 +1,31 @@
+# Copyright (c) 2013 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.
+
+# Module containing helper methods for mounting and unmounting an image.
+
+import os
+
+import constants
+from chromite.lib import cros_build_lib
+
+def MountImage(image_path, root_dir, stateful_dir, read_only, safe=False):
+  """Mounts a Chromium OS image onto mount dir points."""
+  from_dir, image = os.path.split(image_path)
+  cmd = ['./mount_gpt_image.sh',
+         '--from=%s' % from_dir,
+         '--image=%s' % image,
+         '--rootfs_mountpt=%s' % root_dir,
+         '--stateful_mountpt=%s' % stateful_dir]
+  if read_only: cmd.append('--read_only')
+  if safe: cmd.append('--safe')
+  cros_build_lib.RunCommandCaptureOutput(
+      cmd, print_cmd=False, cwd=constants.CROSUTILS_DIR)
+
+
+def UnmountImage(root_dir, stateful_dir):
+  """Unmounts a Chromium OS image specified by mount dir points."""
+  cmd = ['./mount_gpt_image.sh', '--unmount', '--rootfs_mountpt=%s' % root_dir,
+         '--stateful_mountpt=%s' % stateful_dir]
+  cros_build_lib.RunCommandCaptureOutput(
+      cmd, print_cmd=False, cwd=constants.CROSUTILS_DIR)
diff --git a/lib/public_key_manager.py b/lib/public_key_manager.py
index 723b2db..c3aca6f 100644
--- a/lib/public_key_manager.py
+++ b/lib/public_key_manager.py
@@ -11,27 +11,7 @@
 from chromite.lib import cros_build_lib
 from chromite.lib import git
 from chromite.lib import osutils
-
-
-def MountImage(image_path, root_dir, stateful_dir, read_only):
-  """Mounts a Chromium OS image onto mount dir points."""
-  from_dir, image = os.path.split(image_path)
-  cmd = ['./mount_gpt_image.sh',
-         '--from=%s' % from_dir,
-         '--image=%s' % image,
-         '--rootfs_mountpt=%s' % root_dir,
-         '--stateful_mountpt=%s' % stateful_dir]
-  if read_only: cmd.append('--read_only')
-  cros_build_lib.RunCommandCaptureOutput(
-      cmd, print_cmd=False, cwd=constants.CROSUTILS_DIR)
-
-
-def UnmountImage(root_dir, stateful_dir):
-  """Unmounts a Chromium OS image specified by mount dir points."""
-  cmd = ['./mount_gpt_image.sh', '--unmount', '--rootfs_mountpt=%s' % root_dir,
-         '--stateful_mountpt=%s' % stateful_dir]
-  cros_build_lib.RunCommandCaptureOutput(
-      cmd, print_cmd=False, cwd=constants.CROSUTILS_DIR)
+from crostestutils.lib import mount_helper
 
 
 class PublicKeyManager(object):
@@ -47,7 +27,7 @@
 
     # Gather some extra information about the image.
     try:
-      MountImage(image_path, self._rootfs_dir, self._stateful_dir,
+      mount_helper.MountImage(image_path, self._rootfs_dir, self._stateful_dir,
                  read_only=True)
       self._full_target_key_path = os.path.join(
           self._rootfs_dir, PublicKeyManager.TARGET_KEY_PATH)
@@ -59,7 +39,7 @@
         if not res.output: self._is_key_new = False
 
     finally:
-      UnmountImage(self._rootfs_dir, self._stateful_dir)
+      mount_helper.UnmountImage(self._rootfs_dir, self._stateful_dir)
 
   def __del__(self):
     """Remove our temporary directories we created in init."""
@@ -75,14 +55,14 @@
 
     cros_build_lib.Info('Copying %s into %s', self.key_path, self.image_path)
     try:
-      MountImage(self.image_path, self._rootfs_dir, self._stateful_dir,
-                 read_only=False)
+      mount_helper.MountImage(self.image_path, self._rootfs_dir,
+                              self._stateful_dir, read_only=False)
       dir_path = os.path.dirname(self._full_target_key_path)
       osutils.SafeMakedirs(dir_path, sudo=True)
       cmd = ['cp', '--force', '-p', self.key_path, self._full_target_key_path]
       cros_build_lib.SudoRunCommand(cmd)
     finally:
-      UnmountImage(self._rootfs_dir, self._stateful_dir)
+      mount_helper.UnmountImage(self._rootfs_dir, self._stateful_dir)
       self._MakeImageBootable()
 
   def _MakeImageBootable(self):
diff --git a/lib/test_helper.py b/lib/test_helper.py
index 96bff53..157a49a 100644
--- a/lib/test_helper.py
+++ b/lib/test_helper.py
@@ -39,7 +39,7 @@
   return max(1, min(cpu_count, mem_count, loop_count))
 
 
-def CreateVMImage(image, board):
+def CreateVMImage(image, board=None, full=True):
   """Returns the path of the image built to run in a VM.
 
   VM returned is a test image that can run full update testing on it.  This
@@ -47,26 +47,30 @@
 
   Args:
     image: Path to the image.
-    board: Board that the image was built with.
+    board: Board that the image was built with. If None, attempts to use the
+           configured default board.
+    full: If the vm image doesn't exist, create a "full" one which supports AU.
   """
   vm_image_path = '%s/chromiumos_qemu_image.bin' % os.path.dirname(image)
   if not os.path.exists(vm_image_path):
     logging.info('Creating %s', vm_image_path)
-    cros_build_lib.RunCommand(
-        ['./image_to_vm.sh',
-         '--full',
-         '--from=%s' % git.ReinterpretPathForChroot(os.path.dirname(image)),
-         '--board=%s' % board,
-         '--test_image'
-        ], enter_chroot=True, cwd=constants.SOURCE_ROOT)
+    cmd = ['./image_to_vm.sh',
+           '--from=%s' % git.ReinterpretPathForChroot(os.path.dirname(image)),
+           '--test_image']
+    if full:
+      cmd.extend(['--full'])
+    if board:
+      cmd.extend(['--board', board])
+
+    cros_build_lib.RunCommand(cmd, enter_chroot=True, cwd=constants.SOURCE_ROOT)
 
   assert os.path.exists(vm_image_path), 'Failed to create the VM image.'
   return vm_image_path
 
 
-def SetupCommonLoggingFormat():
+def SetupCommonLoggingFormat(verbose=True):
   """Sets up common logging format for the logging module."""
   logging_format = '%(asctime)s - %(filename)s - %(levelname)-8s: %(message)s'
   date_format = '%Y/%m/%d %H:%M:%S'
-  logging.basicConfig(level=logging.DEBUG, format=logging_format,
-                      datefmt=date_format)
+  logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO,
+                      format=logging_format, datefmt=date_format)