gs_cache: fall back to service account when default auth fails

BUG=b:179721419
TEST=manually tested on chromeos2-devservertest

Change-Id: I1528d4ee7e9e68400a5b0011174e6a9e3053d5f2
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/dev-util/+/2690206
Commit-Queue: Congbin Guo <guocb@chromium.org>
Tested-by: Congbin Guo <guocb@chromium.org>
Auto-Submit: Congbin Guo <guocb@chromium.org>
Reviewed-by: Allen Li <ayatane@chromium.org>
diff --git a/gs_cache/gs_archive_server.py b/gs_cache/gs_archive_server.py
index d0326ae..a7095b4 100644
--- a/gs_cache/gs_archive_server.py
+++ b/gs_cache/gs_archive_server.py
@@ -211,6 +211,49 @@
       return self._call('download', path, headers=headers)
 
 
+_SERVICE_ACCOUNT_BOTO_FILE = os.path.expanduser("~/.boto.service_account")
+
+
+class _GSContext(object):
+  """A wrapper class of gs.GSContext for service account migration purpose."""
+
+  def __init__(self):
+    self._ctx_default = gs.GSContext()
+    if os.path.isfile(_SERVICE_ACCOUNT_BOTO_FILE):
+      self._ctx_service_account = gs.GSContext(
+            boto_file=_SERVICE_ACCOUNT_BOTO_FILE)
+    else:
+      self._ctx_service_account = None
+
+  def fetch_file(self, path, want_content=True):
+    """Fetch the file stat and/or content from GS bucket.
+
+    Args:
+      path: The GS path of the file to fetch.
+      want_content: A boolean of whether fetch the content (as an iterator).
+    Returns:
+      A tuple of (stat, content) which is the GS file stat and content
+      iterator (or None).
+    """
+    try:
+      stat = self._ctx_default.Stat(path)
+      ctx = self._ctx_default
+    except gs.GSCommandError as err:
+      if not self._ctx_service_account:
+        raise
+      if not err.stderr.startswith("You aren't authorized to read "):
+        raise
+      _log("Not authorized by default. Trying service account.")
+      stat = self._ctx_service_account.Stat(path)
+      ctx = self._ctx_service_account
+
+    if want_content:
+        _log('Downloading %s', path, level=logging.INFO)
+        return stat, ctx.StreamingCat(path)
+
+    return stat, None
+
+
 class GsArchiveServerError(Exception):
   """Standard exception class for GsArchiveServer."""
 
@@ -219,7 +262,7 @@
   """The backend of Google Storage Cache server."""
 
   def __init__(self, caching_server):
-    self._gsutil = gs.GSContext()
+    self._gsutil = _GSContext()
     self._caching_server = caching_server
 
   @cherrypy.expose
@@ -253,13 +296,9 @@
       The stream of downloaded file.
     """
     path = 'gs://%s' % _check_file_extension('/'.join(args))
-    content = None
-
     try:
-      stat = self._gsutil.Stat(path)
-      if cherrypy.request.method == 'GET':
-        _log('Downloading %s', path, level=logging.INFO)
-        content = self._gsutil.StreamingCat(path)
+      want_content = cherrypy.request.method != 'HEAD'
+      stat, content = self._gsutil.fetch_file(path, want_content)
     except gs.GSNoSuchKey as err:
       raise cherrypy.HTTPError(httplib.NOT_FOUND, err.message)  # pylint: disable=exception-message-attribute
     except gs.GSCommandError as err: