blob: 8a703254babce191dd330944d337425097bc22c5 [file] [log] [blame]
Ben Murdoch097c5b22016-05-18 11:27:45 +01001#!/usr/bin/env python
2# Copyright 2015 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6'''
7Script to help uploading and downloading the Google Play services library to
8and from a Google Cloud storage.
9'''
10
11import argparse
12import logging
13import os
14import re
15import shutil
16import sys
17import tempfile
18import zipfile
19
20sys.path.append(os.path.join(os.path.dirname(__file__), os.pardir))
21import devil_chromium
22from devil.utils import cmd_helper
23from play_services import utils
24from pylib import constants
25from pylib.constants import host_paths
26from pylib.utils import logging_utils
27
28sys.path.append(os.path.join(host_paths.DIR_SOURCE_ROOT, 'build'))
29import find_depot_tools # pylint: disable=import-error,unused-import
30import breakpad
31import download_from_google_storage
32import upload_to_google_storage
33
34
35# Directory where the SHA1 files for the zip and the license are stored
36# It should be managed by git to provided information about new versions.
37SHA1_DIRECTORY = os.path.join(host_paths.DIR_SOURCE_ROOT, 'build', 'android',
38 'play_services')
39
40# Default bucket used for storing the files.
41GMS_CLOUD_STORAGE = 'chromium-android-tools/play-services'
42
43# Path to the default configuration file. It exposes the currently installed
44# version of the library in a human readable way.
45CONFIG_DEFAULT_PATH = os.path.join(host_paths.DIR_SOURCE_ROOT, 'build',
46 'android', 'play_services', 'config.json')
47
48LICENSE_FILE_NAME = 'LICENSE'
49ZIP_FILE_NAME = 'google_play_services_library.zip'
50GMS_PACKAGE_ID = 'extra-google-google_play_services' # used by sdk manager
51
52LICENSE_PATTERN = re.compile(r'^Pkg\.License=(?P<text>.*)$', re.MULTILINE)
53
54
55def main(raw_args):
56 parser = argparse.ArgumentParser(
57 description=__doc__ + 'Please see the subcommand help for more details.',
58 formatter_class=utils.DefaultsRawHelpFormatter)
59 subparsers = parser.add_subparsers(title='commands')
60
61 # Download arguments
62 parser_download = subparsers.add_parser(
63 'download',
64 help='download the library from the cloud storage',
65 description=Download.__doc__,
66 formatter_class=utils.DefaultsRawHelpFormatter)
67 parser_download.set_defaults(func=Download)
68 AddBasicArguments(parser_download)
69 AddBucketArguments(parser_download)
70
71 # SDK Update arguments
72 parser_sdk = subparsers.add_parser(
73 'sdk',
74 help='get the latest Google Play services SDK using Android SDK Manager',
75 description=UpdateSdk.__doc__,
76 formatter_class=utils.DefaultsRawHelpFormatter)
77 parser_sdk.set_defaults(func=UpdateSdk)
78 AddBasicArguments(parser_sdk)
79
80 # Upload arguments
81 parser_upload = subparsers.add_parser(
82 'upload',
83 help='upload the library to the cloud storage',
84 description=Upload.__doc__,
85 formatter_class=utils.DefaultsRawHelpFormatter)
86
87 parser_upload.add_argument('--skip-git',
88 action='store_true',
89 help="don't commit the changes at the end")
90 parser_upload.set_defaults(func=Upload)
91 AddBasicArguments(parser_upload)
92 AddBucketArguments(parser_upload)
93
94 args = parser.parse_args(raw_args)
95 if args.verbose:
96 logging.basicConfig(level=logging.DEBUG)
97 logging_utils.ColorStreamHandler.MakeDefault(not _IsBotEnvironment())
98 devil_chromium.Initialize()
99 return args.func(args)
100
101
102def AddBasicArguments(parser):
103 '''
104 Defines the common arguments on subparser rather than the main one. This
105 allows to put arguments after the command: `foo.py upload --debug --force`
106 instead of `foo.py --debug upload --force`
107 '''
108
109 parser.add_argument('--sdk-root',
110 help='base path to the Android SDK tools root',
111 default=constants.ANDROID_SDK_ROOT)
112
113 parser.add_argument('-v', '--verbose',
114 action='store_true',
115 help='print debug information')
116
117
118def AddBucketArguments(parser):
119 parser.add_argument('--bucket',
120 help='name of the bucket where the files are stored',
121 default=GMS_CLOUD_STORAGE)
122
123 parser.add_argument('--config',
124 help='JSON Configuration file',
125 default=CONFIG_DEFAULT_PATH)
126
127 parser.add_argument('--dry-run',
128 action='store_true',
129 help=('run the script in dry run mode. Files will be '
130 'copied to a local directory instead of the '
131 'cloud storage. The bucket name will be as path '
132 'to that directory relative to the repository '
133 'root.'))
134
135 parser.add_argument('-f', '--force',
136 action='store_true',
137 help='run even if the library is already up to date')
138
139
140def Download(args):
141 '''
142 Downloads the Google Play services library from a Google Cloud Storage bucket
143 and installs it to
144 //third_party/android_tools/sdk/extras/google/google_play_services.
145
146 A license check will be made, and the user might have to accept the license
147 if that has not been done before.
148 '''
149
150 if not os.path.isdir(args.sdk_root):
151 logging.debug('Did not find the Android SDK root directory at "%s".',
152 args.sdk_root)
153 if not args.force:
154 logging.info('Skipping, not on an android checkout.')
155 return 0
156
157 config = utils.ConfigParser(args.config)
158 paths = PlayServicesPaths(args.sdk_root, config.version_xml_path)
159
160 if os.path.isdir(paths.package) and not os.access(paths.package, os.W_OK):
161 logging.error('Failed updating the Google Play Services library. '
162 'The location is not writable. Please remove the '
163 'directory (%s) and try again.', paths.package)
164 return -2
165
166 new_lib_zip_sha1 = os.path.join(SHA1_DIRECTORY, ZIP_FILE_NAME + '.sha1')
167
168 logging.debug('Comparing zip hashes: %s and %s', new_lib_zip_sha1,
169 paths.lib_zip_sha1)
170 if utils.FileEquals(new_lib_zip_sha1, paths.lib_zip_sha1) and not args.force:
171 logging.info('Skipping, the Google Play services library is up to date.')
172 return 0
173
174 bucket_path = _VerifyBucketPathFormat(args.bucket,
175 config.version_number,
176 args.dry_run)
177
178 tmp_root = tempfile.mkdtemp()
179 try:
180 # setup the destination directory
181 if not os.path.isdir(paths.package):
182 os.makedirs(paths.package)
183
184 # download license file from bucket/{version_number}/license.sha1
185 new_license = os.path.join(tmp_root, LICENSE_FILE_NAME)
186
187 license_sha1 = os.path.join(SHA1_DIRECTORY, LICENSE_FILE_NAME + '.sha1')
188 _DownloadFromBucket(bucket_path, license_sha1, new_license,
189 args.verbose, args.dry_run)
190
191 if (not _IsBotEnvironment() and
192 not _CheckLicenseAgreement(new_license, paths.license,
193 config.version_number)):
194 logging.warning('Your version of the Google Play services library is '
195 'not up to date. You might run into issues building '
196 'or running the app. Please run `%s download` to '
197 'retry downloading it.', __file__)
198 return 0
199
200 new_lib_zip = os.path.join(tmp_root, ZIP_FILE_NAME)
201 _DownloadFromBucket(bucket_path, new_lib_zip_sha1, new_lib_zip,
202 args.verbose, args.dry_run)
203
204 try:
205 # We remove the current version of the Google Play services SDK.
206 if os.path.exists(paths.package):
207 shutil.rmtree(paths.package)
208 os.makedirs(paths.package)
209
210 logging.debug('Extracting the library to %s', paths.lib)
211 with zipfile.ZipFile(new_lib_zip, "r") as new_lib_zip_file:
212 new_lib_zip_file.extractall(paths.lib)
213
214 logging.debug('Copying %s to %s', new_license, paths.license)
215 shutil.copy(new_license, paths.license)
216
217 logging.debug('Copying %s to %s', new_lib_zip_sha1, paths.lib_zip_sha1)
218 shutil.copy(new_lib_zip_sha1, paths.lib_zip_sha1)
219
220 logging.info('Update complete.')
221
222 except Exception as e: # pylint: disable=broad-except
223 logging.error('Failed updating the Google Play Services library. '
224 'An error occurred while installing the new version in '
225 'the SDK directory: %s ', e)
226 return -3
227 finally:
228 shutil.rmtree(tmp_root)
229
230 return 0
231
232
233def UpdateSdk(args):
234 '''
235 Uses the Android SDK Manager to download the latest Google Play services SDK
236 locally. Its usual installation path is
237 //third_party/android_tools/sdk/extras/google/google_play_services
238 '''
239
240 # This should function should not run on bots and could fail for many user
241 # and setup related reasons. Also, exceptions here are not caught, so we
242 # disable breakpad to avoid spamming the logs.
243 breakpad.IS_ENABLED = False
244
245 sdk_manager = os.path.join(args.sdk_root, 'tools', 'android')
246 cmd = [sdk_manager, 'update', 'sdk', '--no-ui', '--filter', GMS_PACKAGE_ID]
247 cmd_helper.Call(cmd)
248 # If no update is needed, it still returns successfully so we just do nothing
249
250 return 0
251
252
253def Upload(args):
254 '''
255 Uploads the library from the local Google Play services SDK to a Google Cloud
256 storage bucket.
257
258 By default, a local commit will be made at the end of the operation.
259 '''
260
261 # This should function should not run on bots and could fail for many user
262 # and setup related reasons. Also, exceptions here are not caught, so we
263 # disable breakpad to avoid spamming the logs.
264 breakpad.IS_ENABLED = False
265
266 config = utils.ConfigParser(args.config)
267 paths = PlayServicesPaths(args.sdk_root, config.version_xml_path)
268
269 if not args.skip_git and utils.IsRepoDirty(host_paths.DIR_SOURCE_ROOT):
270 logging.error('The repo is dirty. Please commit or stash your changes.')
271 return -1
272
273 new_version_number = utils.GetVersionNumberFromLibraryResources(
274 paths.version_xml)
275 logging.debug('comparing versions: new=%d, old=%s',
276 new_version_number, config.version_number)
277 if new_version_number <= config.version_number and not args.force:
278 logging.info('The checked in version of the library is already the latest '
279 'one. No update is needed. Please rerun with --force to skip '
280 'this check.')
281 return 0
282
283 tmp_root = tempfile.mkdtemp()
284 try:
285 new_lib_zip = os.path.join(tmp_root, ZIP_FILE_NAME)
286 new_license = os.path.join(tmp_root, LICENSE_FILE_NAME)
287
288 # need to strip '.zip' from the file name here
289 shutil.make_archive(new_lib_zip[:-4], 'zip', paths.lib)
290 _ExtractLicenseFile(new_license, paths.source_prop)
291
292 bucket_path = _VerifyBucketPathFormat(args.bucket, new_version_number,
293 args.dry_run)
294 files_to_upload = [new_lib_zip, new_license]
295 logging.debug('Uploading %s to %s', files_to_upload, bucket_path)
296 _UploadToBucket(bucket_path, files_to_upload, args.dry_run)
297
298 new_lib_zip_sha1 = os.path.join(SHA1_DIRECTORY,
299 ZIP_FILE_NAME + '.sha1')
300 new_license_sha1 = os.path.join(SHA1_DIRECTORY,
301 LICENSE_FILE_NAME + '.sha1')
302 shutil.copy(new_lib_zip + '.sha1', new_lib_zip_sha1)
303 shutil.copy(new_license + '.sha1', new_license_sha1)
304 finally:
305 shutil.rmtree(tmp_root)
306
307 config.UpdateVersionNumber(new_version_number)
308
309 if not args.skip_git:
310 commit_message = ('Update the Google Play services dependency to %s\n'
311 '\n') % new_version_number
312 utils.MakeLocalCommit(host_paths.DIR_SOURCE_ROOT,
313 [new_lib_zip_sha1, new_license_sha1, config.path],
314 commit_message)
315
316 return 0
317
318
319def _DownloadFromBucket(bucket_path, sha1_file, destination, verbose,
320 is_dry_run):
321 '''Downloads the file designated by the provided sha1 from a cloud bucket.'''
322
323 download_from_google_storage.download_from_google_storage(
324 input_filename=sha1_file,
325 base_url=bucket_path,
326 gsutil=_InitGsutil(is_dry_run),
327 num_threads=1,
328 directory=None,
329 recursive=False,
330 force=False,
331 output=destination,
332 ignore_errors=False,
333 sha1_file=sha1_file,
334 verbose=verbose,
335 auto_platform=True,
336 extract=False)
337
338
339def _UploadToBucket(bucket_path, files_to_upload, is_dry_run):
340 '''Uploads the files designated by the provided paths to a cloud bucket. '''
341
342 upload_to_google_storage.upload_to_google_storage(
343 input_filenames=files_to_upload,
344 base_url=bucket_path,
345 gsutil=_InitGsutil(is_dry_run),
346 force=False,
347 use_md5=False,
348 num_threads=1,
349 skip_hashing=False,
350 gzip=None)
351
352
353def _InitGsutil(is_dry_run):
354 '''Initialize the Gsutil object as regular or dummy version for dry runs. '''
355
356 if is_dry_run:
357 return DummyGsutil()
358 else:
359 return download_from_google_storage.Gsutil(
360 download_from_google_storage.GSUTIL_DEFAULT_PATH)
361
362
363def _ExtractLicenseFile(license_path, prop_file_path):
364 with open(prop_file_path, 'r') as prop_file:
365 prop_file_content = prop_file.read()
366
367 match = LICENSE_PATTERN.search(prop_file_content)
368 if not match:
369 raise AttributeError('The license was not found in ' +
370 os.path.abspath(prop_file_path))
371
372 with open(license_path, 'w') as license_file:
373 license_file.write(match.group('text'))
374
375
376def _CheckLicenseAgreement(expected_license_path, actual_license_path,
377 version_number):
378 '''
379 Checks that the new license is the one already accepted by the user. If it
380 isn't, it prompts the user to accept it. Returns whether the expected license
381 has been accepted.
382 '''
383
384 if utils.FileEquals(expected_license_path, actual_license_path):
385 return True
386
387 with open(expected_license_path) as license_file:
388 # Uses plain print rather than logging to make sure this is not formatted
389 # by the logger.
390 print ('Updating the Google Play services SDK to '
391 'version %d.' % version_number)
392
393 # The output is buffered when running as part of gclient hooks. We split
394 # the text here and flush is explicitly to avoid having part of it dropped
395 # out.
396 # Note: text contains *escaped* new lines, so we split by '\\n', not '\n'.
397 for license_part in license_file.read().split('\\n'):
398 print license_part
399 sys.stdout.flush()
400
401 # Need to put the prompt on a separate line otherwise the gclient hook buffer
402 # only prints it after we received an input.
403 print 'Do you accept the license? [y/n]: '
404 sys.stdout.flush()
405 return raw_input('> ') in ('Y', 'y')
406
407
408def _IsBotEnvironment():
409 return bool(os.environ.get('CHROME_HEADLESS'))
410
411
412def _VerifyBucketPathFormat(bucket_name, version_number, is_dry_run):
413 '''
414 Formats and checks the download/upload path depending on whether we are
415 running in dry run mode or not. Returns a supposedly safe path to use with
416 Gsutil.
417 '''
418
419 if is_dry_run:
420 bucket_path = os.path.abspath(os.path.join(bucket_name,
421 str(version_number)))
422 if not os.path.isdir(bucket_path):
423 os.makedirs(bucket_path)
424 else:
425 if bucket_name.startswith('gs://'):
426 # We enforce the syntax without gs:// for consistency with the standalone
427 # download/upload scripts and to make dry run transition easier.
428 raise AttributeError('Please provide the bucket name without the gs:// '
429 'prefix (e.g. %s)' % GMS_CLOUD_STORAGE)
430 bucket_path = 'gs://%s/%d' % (bucket_name, version_number)
431
432 return bucket_path
433
434
435class PlayServicesPaths(object):
436 '''
437 Describes the different paths to be used in the update process.
438
439 Filesystem hierarchy | Exposed property / notes
440 ---------------------------------------------------|-------------------------
441 [sdk_root] | sdk_root / (1)
442 +- extras |
443 +- google |
444 +- google_play_services | package / (2)
445 +- source.properties | source_prop / (3)
446 +- LICENSE | license / (4)
447 +- google_play_services_library.zip.sha1 | lib_zip_sha1 / (5)
448 +- libproject |
449 +- google-play-services_lib | lib / (6)
450 +- res |
451 +- values |
452 +- version.xml | version_xml (7)
453
454 Notes:
455
456 1. sdk_root: Path provided as a parameter to the script (--sdk_root)
457 2. package: This directory contains the Google Play services SDK itself.
458 When downloaded via the Android SDK manager, it will contain,
459 documentation, samples and other files in addition to the library. When
460 the update script downloads the library from our cloud storage, it is
461 cleared.
462 3. source_prop: File created by the Android SDK manager that contains
463 the package information, such as the version info and the license.
464 4. license: File created by the update script. Contains the license accepted
465 by the user.
466 5. lib_zip_sha1: sha1 of the library zip that has been installed by the
467 update script. It is compared with the one required by the config file to
468 check if an update is necessary.
469 6. lib: Contains the library itself: jar and resources. This is what is
470 downloaded from the cloud storage.
471 7. version_xml: File that contains the exact Google Play services library
472 version, the one that we track. The version looks like 811500, is used in
473 the code and the on-device APK, as opposed to the SDK package version
474 which looks like 27.0.0 and is used only by the Android SDK manager.
475
476 '''
477
478 def __init__(self, sdk_root, version_xml_path):
479 relative_package = os.path.join('extras', 'google', 'google_play_services')
480 relative_lib = os.path.join(relative_package, 'libproject',
481 'google-play-services_lib')
482 self.sdk_root = sdk_root
483
484 self.package = os.path.join(sdk_root, relative_package)
485 self.lib_zip_sha1 = os.path.join(self.package, ZIP_FILE_NAME + '.sha1')
486 self.license = os.path.join(self.package, LICENSE_FILE_NAME)
487 self.source_prop = os.path.join(self.package, 'source.properties')
488
489 self.lib = os.path.join(sdk_root, relative_lib)
490 self.version_xml = os.path.join(self.lib, version_xml_path)
491
492
493class DummyGsutil(download_from_google_storage.Gsutil):
494 '''
495 Class that replaces Gsutil to use a local directory instead of an online
496 bucket. It relies on the fact that Gsutil commands are very similar to shell
497 ones, so for the ones used here (ls, cp), it works to just use them with a
498 local directory.
499 '''
500
501 def __init__(self):
502 super(DummyGsutil, self).__init__(
503 download_from_google_storage.GSUTIL_DEFAULT_PATH)
504
505 def call(self, *args):
506 logging.debug('Calling command "%s"', str(args))
507 return cmd_helper.GetCmdStatusOutputAndError(args)
508
509 def check_call(self, *args):
510 logging.debug('Calling command "%s"', str(args))
511 return cmd_helper.GetCmdStatusOutputAndError(args)
512
513
514if __name__ == '__main__':
515 sys.exit(main(sys.argv[1:]))