xBuddy local vs remote path handling.

Explicitly check for local/remote in the xBuddy path and check for
and properly direct local requests.

Plus other bug fixes:
  - better defaults for paths
  - remove "latest-local" and use xbuddy/local/{b}/latest/{a} instead.

BUG=chromium:261667,256461
TEST=unittests, manual

Change-Id: I1baa7e6eab86249ab50d1e02a084ef1cdbf2fc98
Reviewed-on: https://gerrit.chromium.org/gerrit/62706
Reviewed-by: Ryan Cui <rcui@chromium.org>
Commit-Queue: Joy Chen <joychen@chromium.org>
Reviewed-by: Joy Chen <joychen@chromium.org>
Tested-by: Joy Chen <joychen@chromium.org>
diff --git a/xbuddy.py b/xbuddy.py
index dc5e6d4..d04d96e 100644
--- a/xbuddy.py
+++ b/xbuddy.py
@@ -26,7 +26,9 @@
 _XBUDDY_CAPACITY = 5
 
 # Local build constants
-LATEST_LOCAL = "latest-local"
+LATEST = "latest"
+LOCAL = "local"
+REMOTE = "remote"
 LOCAL_ALIASES = [
   'test',
   'base',
@@ -79,7 +81,6 @@
 
 LATEST_OFFICIAL = "latest-official"
 
-LATEST = "latest"
 RELEASE = "release"
 
 
@@ -149,6 +150,7 @@
     self._manage_builds = manage_builds
     self._timestamp_folder = os.path.join(self.static_dir,
                                           Timestamp.XBUDDY_TIMESTAMP_DIR)
+    common_util.MkDirP(self._timestamp_folder)
 
   @classmethod
   def ParseBoolean(cls, boolean_string):
@@ -175,7 +177,7 @@
   def _LookupChannel(self, board, channel='stable'):
     """Check the channel folder for the version number of interest."""
     # Get all names in channel dir. Get 10 highest directories by version
-    _Log("Checking channel %s for latest %s image", channel, board)
+    _Log("Checking channel '%s' for latest '%s' image", channel, board)
     channel_dir = devserver_constants.GS_CHANNEL_DIR % {'channel':channel,
                                                         'board':board}
     latest_version = gsutil_util.GetLatestVersionFromGSDir(channel_dir)
@@ -196,7 +198,7 @@
   def _LookupVersion(self, board, version):
     """Search GS image releases for the highest match to a version prefix."""
     # Build the pattern for GS to match
-    _Log("Checking gs for latest %s image with prefix %s", board, version)
+    _Log("Checking gs for latest '%s' image with prefix '%s'", board, version)
     image_url = devserver_constants.IMAGE_DIR % {'board':board,
                                                  'suffix':RELEASE,
                                                  'version':version + '*'}
@@ -277,7 +279,7 @@
       version - the discovered version of the image.
     """
     latest_local_dir = self.GetLatestImageDir(board)
-    if not (latest_local_dir and os.path.exists(latest_local_dir)):
+    if not latest_local_dir and os.path.exists(latest_local_dir):
       raise XBuddyException('No builds found for %s. Did you run build_image?' %
                             board)
 
@@ -288,12 +290,6 @@
     if not os.path.exists(path_to_image):
       raise XBuddyException('%s not found in %s. Did you run build_image?' %
                             (file_name, latest_local_dir))
-
-    # symlink the directories
-    common_util.MkDirP(os.path.join(self.static_dir, board))
-    link = os.path.join(self.static_dir, board, version)
-    XBuddy._Symlink(link, latest_local_dir)
-
     return version
 
   def _InterpretPath(self, path_list):
@@ -310,23 +306,47 @@
     Raises:
       XBuddyException: if the path can't be resolved into valid components
     """
-    if len(path_list) == 3:
-      # We have a full path, with b/v/a
-      board, version, image_type = path_list
-    elif len(path_list) == 2:
-      # We have only the board and the version, default to test image
-      board, version = path_list
-      image_type = GS_ALIASES[0]
-    elif len(path_list) == 1:
-      # We have only the board. default to latest test image.
-      board = path_list[0]
+    path_list = list(path_list)
+
+    # Required parts of path parsing.
+    try:
+      # Determine if image is explicitly local or remote.
+      is_local = False
+      if path_list[0] == LOCAL:
+        path_list.pop(0)
+        is_local = True
+      elif path_list[0] == REMOTE:
+        path_list.pop(0)
+      _Log(str(path_list))
+
+      # Set board
+      board = path_list.pop(0)
+      _Log(str(path_list))
+
+      # Set defaults
       version = LATEST
       image_type = GS_ALIASES[0]
-    else:
-      # Misshapen beyond recognition
-      raise XBuddyException('Invalid path, %s.' % '/'.join(path_list))
+    except IndexError:
+      msg = "Specify at least the board in your xBuddy call. Your path: %s"
+      raise XBuddyException(msg % os.path.join(path_list))
 
-    return image_type, board, version
+    # Read as much of the xBuddy path as possible
+    try:
+      # Override default if terminal is a valid artifact alias or a version
+      terminal = path_list[-1]
+      if terminal in GS_ALIASES + LOCAL_ALIASES:
+        image_type = terminal
+        version = path_list[-2]
+      else:
+        version = terminal
+    except IndexError:
+      # This path doesn't have an alias or a version. That's fine.
+      _Log("Some parts of the path not specified. Using defaults.")
+
+    _Log("Get artifact '%s' in {%s/%s}. Locally? %s",
+         image_type, board, version, is_local)
+
+    return image_type, board, version, is_local
 
   def _SyncRegistryWithBuildImages(self):
     """ Crawl images_dir for build_ids of images generated from build_image.
@@ -368,9 +388,7 @@
     # update currently cached builds
     build_dict = {}
 
-    common_util.MkDirP(self._timestamp_folder)
-    filenames = os.listdir(self._timestamp_folder)
-    for f in filenames:
+    for f in os.listdir(self._timestamp_folder):
       last_accessed = os.path.getmtime(os.path.join(self._timestamp_folder, f))
       build_id = Timestamp.TimestampToBuild(f)
       stale_time = datetime.timedelta(seconds = (time.time()-last_accessed))
@@ -383,7 +401,7 @@
     with XBuddy._staging_thread_count_lock:
       XBuddy._staging_thread_count += 1
     try:
-      _Log('Downloading %s from %s', artifact, gs_url)
+      _Log("Downloading '%s' from '%s'", artifact, gs_url)
       downloader.Downloader(self.static_dir, gs_url).Download(
           [artifact])
     finally:
@@ -392,13 +410,12 @@
 
   def _CleanCache(self):
     """Delete all builds besides the first _XBUDDY_CAPACITY builds"""
-    self._SyncRegistryWithBuildImages()
     cached_builds = [e[0] for e in self._ListBuildTimes()]
     _Log('In cache now: %s', cached_builds)
 
     for b in range(_XBUDDY_CAPACITY, len(cached_builds)):
       b_path = cached_builds[b]
-      _Log('Clearing %s from cache', b_path)
+      _Log("Clearing '%s' from cache", b_path)
 
       time_file = os.path.join(self._timestamp_folder,
                                Timestamp.BuildToTimestamp(b_path))
@@ -438,31 +455,36 @@
 
   def _GetArtifact(self, path):
     """Interpret an xBuddy path and return directory/file_name to resource."""
-    image_type, board, version = self._InterpretPath(path)
+    image_type, board, version, is_local = self._InterpretPath(path)
 
-    if version in [LATEST_LOCAL, '']:
+    if is_local:
       # Get a local image
       if image_type not in LOCAL_ALIASES:
-        raise XBuddyException('Bad image type: %s. Use one of: %s' %
+        raise XBuddyException('Bad local image type: %s. Use one of: %s' %
                               (image_type, LOCAL_ALIASES))
-
       file_name = LOCAL_ALIAS_TO_FILENAME[image_type]
-      version = self._GetLatestLocalVersion(board, file_name)
+
+      if version == LATEST:
+        # Get the latest local image for the given board
+        version = self._GetLatestLocalVersion(board, file_name)
+      else:
+        # An exact version path in build/images was specified for this board
+        local_file = os.path.join(self.images_dir, board, version, file_name)
+        if not os.path.exists(local_file):
+          raise XBuddyException('File not found in local dir: %s', local_file)
+
       image_url = os.path.join(board, version)
     else:
       # Get a remote image
       if image_type not in GS_ALIASES:
-        raise XBuddyException('Bad image type: %s. Use one of: %s' %
+        raise XBuddyException('Bad remote image type: %s. Use one of: %s' %
                               (image_type, GS_ALIASES))
       file_name = GS_ALIAS_TO_FILENAME[image_type]
 
       # Interpret the version (alias), and get gs address
       image_url = self._ResolveVersionToUrl(board, version)
-
       self._GetFromGS(image_url, image_type)
 
-    _Log("Get artifact %s from image %s", image_type, image_url)
-
     return image_url, file_name
 
   ############################ BEGIN PUBLIC METHODS
@@ -499,6 +521,7 @@
     Raises:
       XBuddyException if path is invalid or XBuddy's cache fails
     """
+    self._SyncRegistryWithBuildImages()
     build_id, file_name = self._GetArtifact(path_list)
 
     Timestamp.UpdateTimestamp(self._timestamp_folder, build_id)
@@ -506,7 +529,6 @@
     #TODO (joyc): run in sep thread
     self._CleanCache()
 
-    #TODO (joyc) static dir dependent on bug id: 214373
     return_url = os.path.join('static', build_id)
     if not return_dir:
       return_url =  os.path.join(return_url, file_name)
diff --git a/xbuddy_unittest.py b/xbuddy_unittest.py
index fabe411..9c37653 100644
--- a/xbuddy_unittest.py
+++ b/xbuddy_unittest.py
@@ -104,22 +104,36 @@
   def testBasicInterpretPath(self):
     """Basic checks for splitting a path"""
     path = ('parrot', 'R27-2455.0.0', 'test')
-    expected = ('test', 'parrot', 'R27-2455.0.0')
+    expected = ('test', 'parrot', 'R27-2455.0.0', False)
     self.assertEqual(self.mock_xb._InterpretPath(path_list=path), expected)
 
     path = ('parrot', 'R27-2455.0.0', 'full_payload')
-    expected = ('full_payload', 'parrot', 'R27-2455.0.0')
+    expected = ('full_payload', 'parrot', 'R27-2455.0.0', False)
     self.assertEqual(self.mock_xb._InterpretPath(path_list=path), expected)
 
     path = ('parrot', 'R27-2455.0.0')
-    expected = ('test', 'parrot', 'R27-2455.0.0')
+    expected = ('test', 'parrot', 'R27-2455.0.0', False)
     self.assertEqual(self.mock_xb._InterpretPath(path_list=path), expected)
 
-    path = ('parrot', 'R27-2455.0.0', 'too', 'many', 'pieces')
+    path = ('remote', 'parrot', 'R27-2455.0.0')
+    expected = ('test', 'parrot', 'R27-2455.0.0', False)
+    self.assertEqual(self.mock_xb._InterpretPath(path_list=path), expected)
+
+    path = ('local', 'parrot', 'R27-2455.0.0')
+    expected = ('test', 'parrot', 'R27-2455.0.0', True)
+    self.assertEqual(self.mock_xb._InterpretPath(path_list=path), expected)
+
+    path = ()
     self.assertRaises(xbuddy.XBuddyException,
                       self.mock_xb._InterpretPath,
                       path_list=path)
 
+    path = ('local',)
+    self.assertRaises(xbuddy.XBuddyException,
+                      self.mock_xb._InterpretPath,
+                      path_list=path)
+
+
   def testTimestampsAndList(self):
     """Creation and listing of builds according to their timestamps."""
     # make 3 different timestamp files