Upstream version 9.38.198.0
[platform/framework/web/crosswalk.git] / src / third_party / chromite / lib / gs.py
1 # Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
2 # Use of this source code is governed by a BSD-style license that can be
3 # found in the LICENSE file.
4
5 """Library to make common google storage operations more reliable."""
6
7 import contextlib
8 import datetime
9 import getpass
10 import hashlib
11 import logging
12 import os
13 import re
14 import tempfile
15 import urlparse
16 import uuid
17
18 from chromite.cbuildbot import constants
19 from chromite.lib import cache
20 from chromite.lib import cros_build_lib
21 from chromite.lib import osutils
22 from chromite.lib import retry_util
23 from chromite.lib import timeout_util
24
25 PUBLIC_BASE_HTTPS_URL = 'https://commondatastorage.googleapis.com/'
26 PRIVATE_BASE_HTTPS_URL = 'https://storage.cloud.google.com/'
27 BASE_GS_URL = 'gs://'
28
29 # Format used by "gsutil ls -l" when reporting modified time.
30 DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
31
32 # Regexp for parsing each line of output from "gsutil ls -l".
33 # This regexp is prepared for the generation and meta_generation values,
34 # too, even though they are not expected until we use "-a".
35 LS_LA_RE = re.compile(
36     r'^\s*(\d*?)\s+(\S*?)\s+([^#$]+).*?(#(\d+)\s+meta_?generation=(\d+))?\s*$')
37
38
39 def CanonicalizeURL(url, strict=False):
40   """Convert provided URL to gs:// URL, if it follows a known format.
41
42   Args:
43     url: URL to canonicalize.
44     strict: Raises exception if URL cannot be canonicalized.
45   """
46   for prefix in (PUBLIC_BASE_HTTPS_URL, PRIVATE_BASE_HTTPS_URL):
47     if url.startswith(prefix):
48       return url.replace(prefix, BASE_GS_URL, 1)
49
50   if not url.startswith(BASE_GS_URL) and strict:
51     raise ValueError('Url %r cannot be canonicalized.' % url)
52
53   return url
54
55
56 def GetGsURL(bucket, for_gsutil=False, public=True, suburl=''):
57   """Construct a Google Storage URL
58
59   Args:
60     bucket: The Google Storage bucket to use
61     for_gsutil: Do you want a URL for passing to `gsutil`?
62     public: Do we want the public or private url
63     suburl: A url fragment to tack onto the end
64
65   Returns:
66     The fully constructed URL
67   """
68   if for_gsutil:
69     urlbase = BASE_GS_URL
70   else:
71     urlbase = PUBLIC_BASE_HTTPS_URL if public else PRIVATE_BASE_HTTPS_URL
72   return '%s%s/%s' % (urlbase, bucket, suburl)
73
74
75 class GSContextException(Exception):
76   """Base exception for all exceptions thrown by GSContext."""
77
78
79 # Since the underlying code uses RunCommand, some callers might be trying to
80 # catch cros_build_lib.RunCommandError themselves.  Extend that class so that
81 # code continues to work.
82 class GSCommandError(GSContextException, cros_build_lib.RunCommandError):
83   """Thrown when an error happened we couldn't decode."""
84
85
86 class GSContextPreconditionFailed(GSContextException):
87   """Thrown when google storage returns code=PreconditionFailed."""
88
89
90 class GSNoSuchKey(GSContextException):
91   """Thrown when google storage returns code=NoSuchKey."""
92
93
94 class GSCounter(object):
95   """A counter class for Google Storage."""
96
97   def __init__(self, ctx, path):
98     """Create a counter object.
99
100     Args:
101       ctx: A GSContext object.
102       path: The path to the counter in Google Storage.
103     """
104     self.ctx = ctx
105     self.path = path
106
107   def Get(self):
108     """Get the current value of a counter."""
109     try:
110       return int(self.ctx.Cat(self.path).output)
111     except GSNoSuchKey:
112       return 0
113
114   def AtomicCounterOperation(self, default_value, operation):
115     """Atomically set the counter value using |operation|.
116
117     Args:
118       default_value: Default value to use for counter, if counter
119                      does not exist.
120       operation: Function that takes the current counter value as a
121                  parameter, and returns the new desired value.
122
123     Returns:
124       The new counter value. None if value could not be set.
125     """
126     generation, _ = self.ctx.GetGeneration(self.path)
127     for _ in xrange(self.ctx.retries + 1):
128       try:
129         value = default_value if generation == 0 else operation(self.Get())
130         self.ctx.Copy('-', self.path, input=str(value), version=generation)
131         return value
132       except (GSContextPreconditionFailed, GSNoSuchKey):
133         # GSContextPreconditionFailed is thrown if another builder is also
134         # trying to update the counter and we lost the race. GSNoSuchKey is
135         # thrown if another builder deleted the counter. In either case, fetch
136         # the generation again, and, if it has changed, try the copy again.
137         new_generation, _ = self.ctx.GetGeneration(self.path)
138         if new_generation == generation:
139           raise
140         generation = new_generation
141
142   def Increment(self):
143     """Increment the counter.
144
145     Returns:
146       The new counter value. None if value could not be set.
147     """
148     return self.AtomicCounterOperation(1, lambda x: x + 1)
149
150   def Decrement(self):
151     """Decrement the counter.
152
153     Returns:
154       The new counter value. None if value could not be set.
155     """
156     return self.AtomicCounterOperation(-1, lambda x: x - 1)
157
158   def Reset(self):
159     """Reset the counter to zero.
160
161     Returns:
162       The new counter value. None if value could not be set.
163     """
164     return self.AtomicCounterOperation(0, lambda x: 0)
165
166   def StreakIncrement(self):
167     """Increment the counter if it is positive, otherwise set it to 1.
168
169     Returns:
170       The new counter value. None if value could not be set.
171     """
172     return self.AtomicCounterOperation(1, lambda x: x + 1 if x > 0 else 1)
173
174   def StreakDecrement(self):
175     """Decrement the counter if it is negative, otherwise set it to -1.
176
177     Returns:
178       The new counter value. None if value could not be set.
179     """
180     return self.AtomicCounterOperation(-1, lambda x: x - 1 if x < 0 else -1)
181
182
183 class GSContext(object):
184   """A class to wrap common google storage operations."""
185
186   # Error messages that indicate an invalid BOTO config.
187   AUTHORIZATION_ERRORS = ('no configured', 'detail=Authorization')
188
189   DEFAULT_BOTO_FILE = os.path.expanduser('~/.boto')
190   DEFAULT_GSUTIL_TRACKER_DIR = os.path.expanduser('~/.gsutil')
191   # This is set for ease of testing.
192   DEFAULT_GSUTIL_BIN = None
193   DEFAULT_GSUTIL_BUILDER_BIN = '/b/build/third_party/gsutil/gsutil'
194   # How many times to retry uploads.
195   DEFAULT_RETRIES = 3
196
197   # Multiplier for how long to sleep (in seconds) between retries; will delay
198   # (1*sleep) the first time, then (2*sleep), continuing via attempt * sleep.
199   DEFAULT_SLEEP_TIME = 60
200
201   GSUTIL_TAR = 'gsutil_3.42.tar.gz'
202   GSUTIL_URL = PUBLIC_BASE_HTTPS_URL + 'pub/%s' % GSUTIL_TAR
203
204   RESUMABLE_UPLOAD_ERROR = ('Too many resumable upload attempts failed without '
205                             'progress')
206   RESUMABLE_DOWNLOAD_ERROR = ('Too many resumable download attempts failed '
207                               'without progress')
208
209   @classmethod
210   def GetDefaultGSUtilBin(cls, cache_dir=None):
211     if cls.DEFAULT_GSUTIL_BIN is None:
212       if cache_dir is None:
213         # Import here to avoid circular imports (commandline imports gs).
214         from chromite.lib import commandline
215         cache_dir = commandline.GetCacheDir()
216       if cache_dir is not None:
217         common_path = os.path.join(cache_dir, constants.COMMON_CACHE)
218         tar_cache = cache.TarballCache(common_path)
219         key = (cls.GSUTIL_TAR,)
220         # The common cache will not be LRU, removing the need to hold a read
221         # lock on the cached gsutil.
222         ref = tar_cache.Lookup(key)
223         ref.SetDefault(cls.GSUTIL_URL)
224         cls.DEFAULT_GSUTIL_BIN = os.path.join(ref.path, 'gsutil', 'gsutil')
225       else:
226         # Check if the default gsutil path for builders exists. If
227         # not, try locating gsutil. If none exists, simply use 'gsutil'.
228         gsutil_bin = cls.DEFAULT_GSUTIL_BUILDER_BIN
229         if not os.path.exists(gsutil_bin):
230           gsutil_bin = osutils.Which('gsutil')
231         if gsutil_bin is None:
232           gsutil_bin = 'gsutil'
233         cls.DEFAULT_GSUTIL_BIN = gsutil_bin
234
235     return cls.DEFAULT_GSUTIL_BIN
236
237   def __init__(self, boto_file=None, cache_dir=None, acl=None,
238                dry_run=False, gsutil_bin=None, init_boto=False, retries=None,
239                sleep=None):
240     """Constructor.
241
242     Args:
243       boto_file: Fully qualified path to user's .boto credential file.
244       cache_dir: The absolute path to the cache directory. Use the default
245         fallback if not given.
246       acl: If given, a canned ACL. It is not valid to pass in an ACL file
247         here, because most gsutil commands do not accept ACL files. If you
248         would like to use an ACL file, use the SetACL command instead.
249       dry_run: Testing mode that prints commands that would be run.
250       gsutil_bin: If given, the absolute path to the gsutil binary.  Else
251         the default fallback will be used.
252       init_boto: If set to True, GSContext will check during __init__ if a
253         valid boto config is configured, and if not, will attempt to ask the
254         user to interactively set up the boto config.
255       retries: Number of times to retry a command before failing.
256       sleep: Amount of time to sleep between failures.
257     """
258     if gsutil_bin is None:
259       gsutil_bin = self.GetDefaultGSUtilBin(cache_dir)
260     else:
261       self._CheckFile('gsutil not found', gsutil_bin)
262     self.gsutil_bin = gsutil_bin
263
264     # The version of gsutil is retrieved on demand and cached here.
265     self._gsutil_version = None
266
267     # TODO (yjhong): disable parallel composite upload for now because
268     # it is not backward compatible (older gsutil versions cannot
269     # download files uploaded with this option enabled). Remove this
270     # after all users transition to newer versions (3.37 and above).
271     self.gsutil_flags = ['-o', 'GSUtil:parallel_composite_upload_threshold=0']
272
273     # Set HTTP proxy if environment variable http_proxy is set
274     # (crbug.com/325032).
275     if 'http_proxy' in os.environ:
276       url = urlparse.urlparse(os.environ['http_proxy'])
277       if not url.hostname or (not url.username and url.password):
278         logging.warning('GS_ERROR: Ignoring env variable http_proxy because it '
279                         'is not properly set: %s', os.environ['http_proxy'])
280       else:
281         self.gsutil_flags += ['-o', 'Boto:proxy=%s' % url.hostname]
282         if url.username:
283           self.gsutil_flags += ['-o', 'Boto:proxy_user=%s' % url.username]
284         if url.password:
285           self.gsutil_flags += ['-o', 'Boto:proxy_pass=%s' % url.password]
286         if url.port:
287           self.gsutil_flags += ['-o', 'Boto:proxy_port=%d' % url.port]
288
289     # Increase the number of retries. With 10 retries, Boto will try a total of
290     # 11 times and wait up to 2**11 seconds (~30 minutes) in total, not
291     # not including the time spent actually uploading or downloading.
292     self.gsutil_flags += ['-o', 'Boto:num_retries=10']
293
294     # Prefer boto_file if specified, else prefer the env then the default.
295     if boto_file is None:
296       boto_file = os.environ.get('BOTO_CONFIG')
297       if boto_file is None:
298         boto_file = self.DEFAULT_BOTO_FILE
299     self.boto_file = boto_file
300
301     self.acl = acl
302
303     self.dry_run = dry_run
304     self.retries = self.DEFAULT_RETRIES if retries is None else int(retries)
305     self._sleep_time = self.DEFAULT_SLEEP_TIME if sleep is None else int(sleep)
306
307     if init_boto:
308       self._InitBoto()
309
310   @property
311   def gsutil_version(self):
312     """Return the version of the gsutil in this context."""
313     if not self._gsutil_version:
314       cmd = ['-q', 'version']
315
316       # gsutil has been known to return version to stderr in the past, so
317       # use combine_stdout_stderr=True.
318       result = self.DoCommand(cmd, combine_stdout_stderr=True,
319                               redirect_stdout=True)
320
321       # Expect output like: gsutil version 3.35
322       match = re.search(r'^\s*gsutil\s+version\s+([\d.]+)', result.output,
323                         re.IGNORECASE)
324       if match:
325         self._gsutil_version = match.group(1)
326       else:
327         raise GSContextException('Unexpected output format from "%s":\n%s.' %
328                                  (result.cmdstr, result.output))
329
330     return self._gsutil_version
331
332   def _CheckFile(self, errmsg, afile):
333     """Pre-flight check for valid inputs.
334
335     Args:
336       errmsg: Error message to display.
337       afile: Fully qualified path to test file existance.
338     """
339     if not os.path.isfile(afile):
340       raise GSContextException('%s, %s is not a file' % (errmsg, afile))
341
342   def _TestGSLs(self):
343     """Quick test of gsutil functionality."""
344     result = self.DoCommand(['ls'], retries=0, debug_level=logging.DEBUG,
345                             redirect_stderr=True, error_code_ok=True)
346     return not (result.returncode == 1 and
347                 any(e in result.error for e in self.AUTHORIZATION_ERRORS))
348
349   def _ConfigureBotoConfig(self):
350     """Make sure we can access protected bits in GS."""
351     print 'Configuring gsutil. **Please use your @google.com account.**'
352     try:
353       self.DoCommand(['config'], retries=0, debug_level=logging.CRITICAL,
354                      print_cmd=False)
355     finally:
356       if (os.path.exists(self.boto_file) and not
357           os.path.getsize(self.boto_file)):
358         os.remove(self.boto_file)
359         raise GSContextException('GS config could not be set up.')
360
361   def _InitBoto(self):
362     if not self._TestGSLs():
363       self._ConfigureBotoConfig()
364
365   def Cat(self, path, **kwargs):
366     """Returns the contents of a GS object."""
367     kwargs.setdefault('redirect_stdout', True)
368     if not path.startswith(BASE_GS_URL):
369       # gsutil doesn't support cat-ting a local path, so just run 'cat' in that
370       # case.
371       kwargs.pop('retries', None)
372       kwargs.pop('headers', None)
373       if not os.path.exists(path):
374         raise GSNoSuchKey('%s: file does not exist' % path)
375       try:
376         return cros_build_lib.RunCommand(['cat', path], **kwargs)
377       except cros_build_lib.RunCommandError as e:
378         raise GSCommandError(e.msg, e.result, e.exception)
379     return self.DoCommand(['cat', path], **kwargs)
380
381   def CopyInto(self, local_path, remote_dir, filename=None, **kwargs):
382     """Upload a local file into a directory in google storage.
383
384     Args:
385       local_path: Local file path to copy.
386       remote_dir: Full gs:// url of the directory to transfer the file into.
387       filename: If given, the filename to place the content at; if not given,
388         it's discerned from basename(local_path).
389       **kwargs: See Copy() for documentation.
390     """
391     filename = filename if filename is not None else local_path
392     # Basename it even if an explicit filename was given; we don't want
393     # people using filename as a multi-directory path fragment.
394     return self.Copy(local_path,
395                       '%s/%s' % (remote_dir, os.path.basename(filename)),
396                       **kwargs)
397
398   @staticmethod
399   def _GetTrackerFilenames(dest_path):
400     """Returns a list of gsutil tracker filenames.
401
402     Tracker files are used by gsutil to resume downloads/uploads. This
403     function does not handle parallel uploads.
404
405     Args:
406       dest_path: Either a GS path or an absolute local path.
407
408     Returns:
409       The list of potential tracker filenames.
410     """
411     dest = urlparse.urlsplit(dest_path)
412     filenames = []
413     if dest.scheme == 'gs':
414       prefix = 'upload'
415       bucket_name = dest.netloc
416       object_name = dest.path.lstrip('/')
417       filenames.append(
418           re.sub(r'[/\\]', '_', 'resumable_upload__%s__%s.url' %
419                  (bucket_name, object_name)))
420     else:
421       prefix = 'download'
422       filenames.append(
423           re.sub(r'[/\\]', '_', 'resumable_download__%s.etag' % dest.path))
424
425     hashed_filenames = []
426     for filename in filenames:
427       if not isinstance(filename, unicode):
428         filename = unicode(filename, 'utf8').encode('utf-8')
429       m = hashlib.sha1(filename)
430       hashed_filenames.append('%s_TRACKER_%s.%s' %
431                               (prefix, m.hexdigest(), filename[-16:]))
432
433     return hashed_filenames
434
435   def _RetryFilter(self, e):
436     """Function to filter retry-able RunCommandError exceptions.
437
438     Args:
439       e: Exception object to filter. Exception may be re-raised as
440          as different type, if _RetryFilter determines a more appropriate
441          exception type based on the contents of e.
442
443     Returns:
444       True for exceptions thrown by a RunCommand gsutil that should be retried.
445     """
446     if not retry_util.ShouldRetryCommandCommon(e):
447       return False
448
449     # e is guaranteed by above filter to be a RunCommandError
450
451     if e.result.returncode < 0:
452       logging.info('Child process received signal %d; not retrying.',
453                    -e.result.returncode)
454       return False
455
456     error = e.result.error
457     if error:
458       if 'GSResponseError' in error:
459         if 'code=PreconditionFailed' in error:
460           raise GSContextPreconditionFailed(e)
461         if 'code=NoSuchKey' in error:
462           raise GSNoSuchKey(e)
463
464       # If the file does not exist, one of the following errors occurs.
465       if ('InvalidUriError:' in error or
466           'Attempt to get key for' in error or
467           'CommandException: No URIs matched' in error or
468           'CommandException: One or more URIs matched no objects' in error or
469           'CommandException: No such object' in error or
470           'Some files could not be removed' in error or
471           'does not exist' in error):
472         raise GSNoSuchKey(e)
473
474       logging.warning('GS_ERROR: %s', error)
475
476       # TODO: Below is a list of known flaky errors that we should
477       # retry. The list needs to be extended.
478
479       # Temporary fix: remove the gsutil tracker files so that our retry
480       # can hit a different backend. This should be removed after the
481       # bug is fixed by the Google Storage team (see crbug.com/308300).
482       if (self.RESUMABLE_DOWNLOAD_ERROR in error or
483           self.RESUMABLE_UPLOAD_ERROR in error or
484           'ResumableUploadException' in error or
485           'ResumableDownloadException' in error):
486
487         # Only remove the tracker files if we try to upload/download a file.
488         if 'cp' in e.result.cmd[:-2]:
489           # Assume a command: gsutil [options] cp [options] src_path dest_path
490           # dest_path needs to be a fully qualified local path, which is already
491           # required for GSContext.Copy().
492           tracker_filenames = self._GetTrackerFilenames(e.result.cmd[-1])
493           logging.info('Potential list of tracker files: %s',
494                        tracker_filenames)
495           for tracker_filename in tracker_filenames:
496             tracker_file_path = os.path.join(self.DEFAULT_GSUTIL_TRACKER_DIR,
497                                              tracker_filename)
498             if os.path.exists(tracker_file_path):
499               logging.info('Deleting gsutil tracker file %s before retrying.',
500                            tracker_file_path)
501               logging.info('The content of the tracker file: %s',
502                            osutils.ReadFile(tracker_file_path))
503               osutils.SafeUnlink(tracker_file_path)
504         return True
505
506       # We have seen flaky errors with 5xx return codes.
507       if 'GSResponseError: status=5' in error:
508         return True
509
510     return False
511
512   # TODO(mtennant): Make a private method.
513   def DoCommand(self, gsutil_cmd, headers=(), retries=None, version=None,
514                 parallel=False, **kwargs):
515     """Run a gsutil command, suppressing output, and setting retry/sleep.
516
517     Args:
518       gsutil_cmd: The (mostly) constructed gsutil subcommand to run.
519       headers: A list of raw headers to pass down.
520       parallel: Whether gsutil should enable parallel copy/update of multiple
521         files. NOTE: This option causes gsutil to use significantly more
522         memory, even if gsutil is only uploading one file.
523       retries: How many times to retry this command (defaults to setting given
524         at object creation).
525       version: If given, the generation; essentially the timestamp of the last
526         update.  Note this is not the same as sequence-number; it's
527         monotonically increasing bucket wide rather than reset per file.
528         The usage of this is if we intend to replace/update only if the version
529         is what we expect.  This is useful for distributed reasons- for example,
530         to ensure you don't overwrite someone else's creation, a version of
531         0 states "only update if no version exists".
532
533     Returns:
534       A RunCommandResult object.
535     """
536     kwargs = kwargs.copy()
537     kwargs.setdefault('redirect_stderr', True)
538
539     cmd = [self.gsutil_bin]
540     cmd += self.gsutil_flags
541     for header in headers:
542       cmd += ['-h', header]
543     if version is not None:
544       cmd += ['-h', 'x-goog-if-generation-match:%d' % int(version)]
545
546     # Enable parallel copy/update of multiple files if stdin is not to
547     # be piped to the command. This does not split a single file into
548     # smaller components for upload.
549     if parallel and kwargs.get('input') is None:
550       cmd += ['-m']
551
552     cmd.extend(gsutil_cmd)
553
554     if retries is None:
555       retries = self.retries
556
557     extra_env = kwargs.pop('extra_env', {})
558     extra_env.setdefault('BOTO_CONFIG', self.boto_file)
559
560     if self.dry_run:
561       logging.debug("%s: would've run: %s", self.__class__.__name__,
562                     cros_build_lib.CmdToStr(cmd))
563     else:
564       try:
565         return retry_util.GenericRetry(self._RetryFilter,
566                                        retries, cros_build_lib.RunCommand,
567                                        cmd, sleep=self._sleep_time,
568                                        extra_env=extra_env, **kwargs)
569       except cros_build_lib.RunCommandError as e:
570         raise GSCommandError(e.msg, e.result, e.exception)
571
572   def Copy(self, src_path, dest_path, acl=None, recursive=False,
573            skip_symlinks=True, **kwargs):
574     """Copy to/from GS bucket.
575
576     Canned ACL permissions can be specified on the gsutil cp command line.
577
578     More info:
579     https://developers.google.com/storage/docs/accesscontrol#applyacls
580
581     Args:
582       src_path: Fully qualified local path or full gs:// path of the src file.
583       dest_path: Fully qualified local path or full gs:// path of the dest
584                  file.
585       acl: One of the google storage canned_acls to apply.
586       recursive: Whether to copy recursively.
587       skip_symlinks: Skip symbolic links when copying recursively.
588
589     Returns:
590       Return the CommandResult from the run.
591
592     Raises:
593       RunCommandError if the command failed despite retries.
594     """
595     cmd = ['cp']
596     # Certain versions of gsutil (at least 4.3) assume the source of a copy is
597     # a directory if the -r option is used. If it's really a file, gsutil will
598     # look like it's uploading it but not actually do anything. We'll work
599     # around that problem by surpressing the -r flag if we detect the source
600     # is a local file.
601     if recursive and not os.path.isfile(src_path):
602       cmd.append('-r')
603       if skip_symlinks:
604         cmd.append('-e')
605
606     acl = self.acl if acl is None else acl
607     if acl is not None:
608       cmd += ['-a', acl]
609
610     with cros_build_lib.ContextManagerStack() as stack:
611       # Write the input into a tempfile if possible. This is needed so that
612       # gsutil can retry failed requests.
613       if src_path == '-' and kwargs.get('input') is not None:
614         f = stack.Add(tempfile.NamedTemporaryFile)
615         f.write(kwargs['input'])
616         f.flush()
617         del kwargs['input']
618         src_path = f.name
619
620       cmd += ['--', src_path, dest_path]
621
622       if not (src_path.startswith(BASE_GS_URL) or
623               dest_path.startswith(BASE_GS_URL)):
624         # Don't retry on local copies.
625         kwargs.setdefault('retries', 0)
626
627       return self.DoCommand(cmd, **kwargs)
628
629   # TODO(mtennant): Merge with LS() after it supports returning details.
630   def LSWithDetails(self, path, **kwargs):
631     """Does a detailed directory listing of the given gs path.
632
633     Args:
634       path: The path to get a listing of.
635
636     Returns:
637       List of tuples, where each tuple is (gs path, file size in bytes integer,
638         file modified time as datetime.datetime object).
639     """
640     kwargs['redirect_stdout'] = True
641     result = self.DoCommand(['ls', '-l', '--', path], **kwargs)
642
643     lines = result.output.splitlines()
644
645     # Output like the followig is expected:
646     #    99908  2014-03-01T05:50:08Z  gs://somebucket/foo/abc
647     #    99908  2014-03-04T01:16:55Z  gs://somebucket/foo/def
648     # TOTAL: 2 objects, 199816 bytes (495.36 KB)
649
650     # The last line is expected to be a summary line.  Ignore it.
651     url_tuples = []
652     for line in lines[:-1]:
653       match = LS_LA_RE.search(line)
654       size, timestamp, url = (match.group(1), match.group(2), match.group(3))
655       if timestamp:
656         timestamp = datetime.datetime.strptime(timestamp, DATETIME_FORMAT)
657       else:
658         timestamp = None
659       size = int(size) if size else None
660       url_tuples.append((url, size, timestamp))
661
662     return url_tuples
663
664   # TODO(mtennant): Enhance to add details to returned results, such as
665   # size, modified time, generation.
666   def LS(self, path, raw=False, **kwargs):
667     """Does a directory listing of the given gs path.
668
669     Args:
670       path: The path to get a listing of.
671       raw: Return the raw CommandResult object instead of parsing it.
672       kwargs: See options that DoCommand takes.
673
674     Returns:
675       If raw is False, a list of paths that matched |path|.  Might be more
676       than one if a directory or path include wildcards/etc...
677       If raw is True, then the CommandResult object.
678     """
679     kwargs['redirect_stdout'] = True
680     if not path.startswith(BASE_GS_URL):
681       # gsutil doesn't support listing a local path, so just run 'ls'.
682       kwargs.pop('retries', None)
683       kwargs.pop('headers', None)
684       result = cros_build_lib.RunCommand(['ls', path], **kwargs)
685     else:
686       result = self.DoCommand(['ls', '--', path], **kwargs)
687
688     if raw:
689       return result
690     else:
691       # TODO: Process resulting lines when given -l/-a.
692       # See http://crbug.com/342918 for more details.
693       return result.output.splitlines()
694
695   def DU(self, path, **kwargs):
696     """Returns size of an object."""
697     return self.DoCommand(['du', path], redirect_stdout=True, **kwargs)
698
699   def SetACL(self, upload_url, acl=None):
700     """Set access on a file already in google storage.
701
702     Args:
703       upload_url: gs:// url that will have acl applied to it.
704       acl: An ACL permissions file or canned ACL.
705     """
706     if acl is None:
707       if not self.acl:
708         raise GSContextException(
709             "SetAcl invoked w/out a specified acl, nor a default acl.")
710       acl = self.acl
711
712     self.DoCommand(['acl', 'set', acl, upload_url])
713
714   def ChangeACL(self, upload_url, acl_args_file=None, acl_args=None):
715     """Change access on a file already in google storage with "acl ch".
716
717     Args:
718       upload_url: gs:// url that will have acl applied to it.
719       acl_args_file: A file with arguments to the gsutil acl ch command. The
720                      arguments can be spread across multiple lines. Comments
721                      start with a # character and extend to the end of the
722                      line. Exactly one of this argument or acl_args must be
723                      set.
724       acl_args: A list of arguments for the gsutil acl ch command. Exactly
725                 one of this argument or acl_args must be set.
726     """
727     if acl_args_file and acl_args:
728       raise GSContextException(
729           'ChangeACL invoked with both acl_args and acl_args set.')
730     if not acl_args_file and not acl_args:
731       raise GSContextException(
732           'ChangeACL invoked with neither acl_args nor acl_args set.')
733
734     if acl_args_file:
735       lines = osutils.ReadFile(acl_args_file).splitlines()
736       # Strip out comments.
737       lines = [x.split('#', 1)[0].strip() for x in lines]
738       acl_args = ' '.join([x for x in lines if x]).split()
739
740     self.DoCommand(['acl', 'ch'] + acl_args + [upload_url])
741
742   def Exists(self, path, **kwargs):
743     """Checks whether the given object exists.
744
745     Args:
746       path: Full gs:// url of the path to check.
747
748     Returns:
749       True if the path exists; otherwise returns False.
750     """
751     try:
752       # Use 'gsutil stat' command to check for existence.  It is not
753       # subject to caching behavior of 'gsutil ls', and it only requires
754       # read access to the file, unlike 'gsutil acl get'.
755       self.DoCommand(['stat', path], redirect_stdout=True, **kwargs)
756     except GSNoSuchKey:
757       # A path that does not exist will result in error output like:
758       # InvalidUriError: Attempt to get key for "gs://foo/bar"
759       # That will result in GSNoSuchKey.
760       return False
761     return True
762
763   def Remove(self, path, ignore_missing=False):
764     """Remove the specified file.
765
766     Args:
767       path: Full gs:// url of the file to delete.
768       ignore_missing: Whether to suppress errors about missing files.
769     """
770     try:
771       self.DoCommand(['rm', path])
772     except GSNoSuchKey:
773       if not ignore_missing:
774         raise
775
776   def GetGeneration(self, path):
777     """Get the generation and metageneration of the given |path|.
778
779     Returns:
780       A tuple of the generation and metageneration.
781     """
782     def _Header(name):
783       if res and res.returncode == 0 and res.output is not None:
784         # Search for a header that looks like this:
785         # header: x-goog-generation: 1378856506589000
786         m = re.search(r'header: %s: (\d+)' % name, res.output)
787         if m:
788           return int(m.group(1))
789       return 0
790
791     try:
792       res = self.DoCommand(['-d', 'acl', 'get', path],
793                            error_code_ok=True, redirect_stdout=True)
794     except GSNoSuchKey:
795       # If a DoCommand throws an error, 'res' will be None, so _Header(...)
796       # will return 0 in both of the cases below.
797       pass
798
799     return (_Header('x-goog-generation'), _Header('x-goog-metageneration'))
800
801   def Counter(self, path):
802     """Return a GSCounter object pointing at a |path| in Google Storage.
803
804     Args:
805       path: The path to the counter in Google Storage.
806     """
807     return GSCounter(self, path)
808
809   def WaitForGsPaths(self, paths, timeout, period=10):
810     """Wait until a list of files exist in GS.
811
812     Args:
813       paths: The list of files to wait for.
814       timeout: Max seconds to wait for file to appear.
815       period: How often to check for files while waiting.
816
817     Raises:
818       timeout_util.TimeoutError if the timeout is reached.
819     """
820     # Copy the list of URIs to wait for, so we don't modify the callers context.
821     pending_paths = paths[:]
822
823     def _CheckForExistence():
824       pending_paths[:] = [x for x in pending_paths if not self.Exists(x)]
825
826     def _Retry(_return_value):
827       # Retry, if there are any pending paths left.
828       return pending_paths
829
830     timeout_util.WaitForSuccess(_Retry, _CheckForExistence,
831                                 timeout=timeout, period=period)
832
833
834 @contextlib.contextmanager
835 def TemporaryURL(prefix):
836   """Context manager to generate a random URL.
837
838   At the end, the URL will be deleted.
839   """
840   url = '%s/chromite-temp/%s/%s/%s' % (constants.TRASH_BUCKET, prefix,
841                                        getpass.getuser(), uuid.uuid1())
842   ctx = GSContext()
843   ctx.Remove(url, ignore_missing=True)
844   try:
845     yield url
846   finally:
847     ctx.Remove(url, ignore_missing=True)