Add a devserver call that can list staged contents of a build.

This will help with knowing why an update failed.

TEST=Executed the call on a local devserver. Added a unittest.
BUG=chromium:348097, chromium:349044, chromium:349047

Change-Id: I9c1a1a0ba9212852ad243eec728a010b5ed0b0fc
Reviewed-on: https://chromium-review.googlesource.com/189672
Tested-by: Prashanth B <beeps@chromium.org>
Reviewed-by: Dan Shi <dshi@chromium.org>
Reviewed-by: Chris Sosa <sosa@chromium.org>
Commit-Queue: Prashanth B <beeps@chromium.org>
diff --git a/devserver.py b/devserver.py
index 73fb39a..043e376 100755
--- a/devserver.py
+++ b/devserver.py
@@ -454,6 +454,32 @@
         artifacts, files))
 
   @cherrypy.expose
+  def list_image_dir(self, **kwargs):
+    """Take an archive url and list the contents in its staged directory.
+
+    Args:
+      kwargs:
+        archive_url: Google Storage URL for the build.
+
+    Example:
+      To list the contents of where this devserver should have staged
+      gs://image-archive/<board>-release/<build> call:
+      http://devserver_url:<port>/list_image_dir?archive_url=<gs://..>
+
+    Returns:
+      A string with information about the contents of the image directory.
+    """
+    archive_url = self._canonicalize_archive_url(kwargs.get('archive_url'))
+    download_helper = downloader.Downloader(updater.static_dir, archive_url)
+    try:
+      image_dir_contents = download_helper.ListBuildDir()
+    except build_artifact.ArtifactDownloadError as e:
+      return 'Cannot list the contents of staged artifacts. %s' % e
+    if not image_dir_contents:
+      return '%s has not been staged on this devserver.' % archive_url
+    return image_dir_contents
+
+  @cherrypy.expose
   def stage(self, **kwargs):
     """Downloads and caches the artifacts from Google Storage URL.
 
diff --git a/devserver_integration_test.py b/devserver_integration_test.py
index 755f2ce..49eea74 100755
--- a/devserver_integration_test.py
+++ b/devserver_integration_test.py
@@ -58,6 +58,7 @@
 CHECK_HEALTH = 'check_health'
 CONTROL_FILES = 'controlfiles'
 XBUDDY = 'xbuddy'
+LIST_IMAGE_DIR = 'list_image_dir'
 
 # API rpcs and constants.
 API_HOST_INFO = 'api/hostinfo'
@@ -512,6 +513,29 @@
                       self._MakeRPC,
                       '/'.join([XBUDDY, xbuddy_bad_path]))
 
+  def testListImageDir(self):
+    """Verifies that we can list the contents of the image directory."""
+    build_id = 'x86-mario-release/R32-4810.0.0'
+    archive_url = 'gs://chromeos-image-archive/%s' % build_id
+    build_dir = os.path.join(self.test_data_path, build_id)
+    shutil.rmtree(build_dir, ignore_errors=True)
+
+    logging.info('checking for %s on an unstaged build.', LIST_IMAGE_DIR)
+    response = self._MakeRPC(LIST_IMAGE_DIR, archive_url=archive_url)
+    self.assertTrue(archive_url in response and 'not been staged' in response)
+
+    logging.info('Checking for %s on a staged build.', LIST_IMAGE_DIR)
+    fake_file_name = 'fake_file'
+    try:
+      os.makedirs(build_dir)
+      open(os.path.join(build_dir, fake_file_name), 'w').close()
+    except OSError:
+      logging.error('Could not create files to imitate staged content. '
+                    'Build dir %s, file %s', build_dir, fake_file_name)
+      raise
+    response = self._MakeRPC(LIST_IMAGE_DIR, archive_url=archive_url)
+    self.assertTrue(fake_file_name in response)
+    shutil.rmtree(build_dir, ignore_errors=True)
 
 if __name__ == '__main__':
   logging_format = '%(levelname)-8s: %(message)s'
diff --git a/downloader.py b/downloader.py
index 9b3f5cb..c02ebe2 100755
--- a/downloader.py
+++ b/downloader.py
@@ -2,8 +2,10 @@
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 
+import collections
 import os
 import threading
+from datetime import datetime
 
 import build_artifact
 import common_util
@@ -115,6 +117,43 @@
       os.remove(file_name)
       os.rmdir(directory_path)
 
+  def ListBuildDir(self):
+    """List the files in the build directory.
+
+    Only lists files a single level into the build directory. Includes
+    timestamp information in the listing.
+
+    Returns:
+      A string with information about the files in the build directory.
+      None if the build directory doesn't exist.
+
+    Raises:
+      build_artifact.ArtifactDownloadError: If the build_dir path exists
+      but is not a directory.
+    """
+    if not os.path.exists(self._build_dir):
+      return None
+    if not os.path.isdir(self._build_dir):
+      raise build_artifact.ArtifactDownloadError(
+          'Artifacts %s improperly staged to build_dir path %s. The path is '
+          'not a directory.' % (self._archive_url, self._build_dir))
+
+    ls_format = collections.namedtuple(
+            'ls', ['name', 'accessed', 'modified', 'size'])
+    output_format = ('Name: %(name)s Accessed: %(accessed)s '
+            'Modified: %(modified)s Size: %(size)s bytes.\n')
+
+    build_dir_info = 'Listing contents of :%s \n' % self._build_dir
+    for file_name in os.listdir(self._build_dir):
+      file_path = os.path.join(self._build_dir, file_name)
+      file_info = os.stat(file_path)
+      ls_info = ls_format(file_path,
+                          datetime.fromtimestamp(file_info.st_atime),
+                          datetime.fromtimestamp(file_info.st_mtime),
+                          file_info.st_size)
+      build_dir_info += output_format % ls_info._asdict()
+    return build_dir_info
+
   def Download(self, artifacts, files, async=False):
     """Downloads and caches the |artifacts|.