- add sources.
[platform/framework/web/crosswalk.git] / src / native_client_sdk / src / build_tools / update_nacl_manifest.py
1 #!/usr/bin/env python
2 # Copyright (c) 2012 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 """Script that reads omahaproxy and gsutil to determine version of SDK to put
7 in manifest.
8 """
9
10 # pylint is convinced the email module is missing attributes
11 # pylint: disable=E1101
12
13 import buildbot_common
14 import csv
15 import cStringIO
16 import difflib
17 import email
18 import json
19 import logging
20 import logging.handlers
21 import manifest_util
22 import optparse
23 import os
24 import posixpath
25 import re
26 import smtplib
27 import subprocess
28 import sys
29 import time
30 import traceback
31 import urllib2
32
33 MANIFEST_BASENAME = 'naclsdk_manifest2.json'
34 SCRIPT_DIR = os.path.dirname(__file__)
35 REPO_MANIFEST = os.path.join(SCRIPT_DIR, 'json', MANIFEST_BASENAME)
36 GS_BUCKET_PATH = 'gs://nativeclient-mirror/nacl/nacl_sdk/'
37 GS_SDK_MANIFEST = GS_BUCKET_PATH + MANIFEST_BASENAME
38 GS_SDK_MANIFEST_LOG = GS_BUCKET_PATH + MANIFEST_BASENAME + '.log'
39 GS_MANIFEST_BACKUP_DIR = GS_BUCKET_PATH + 'manifest_backups/'
40
41 CANARY_BUNDLE_NAME = 'pepper_canary'
42 CANARY = 'canary'
43 NACLPORTS_ARCHIVE_NAME = 'naclports.tar.bz2'
44
45
46 logger = logging.getLogger(__name__)
47
48
49 def SplitVersion(version_string):
50   """Split a version string (e.g. "18.0.1025.163") into its components.
51
52   Note that this function doesn't handle versions in the form "trunk.###".
53   """
54   return tuple(map(int, version_string.split('.')))
55
56
57 def JoinVersion(version_tuple):
58   """Create a string from a version tuple.
59
60   The tuple should be of the form (18, 0, 1025, 163).
61   """
62   return '.'.join(map(str, version_tuple))
63
64
65 def GetTimestampManifestName():
66   """Create a manifest name with a timestamp.
67
68   Returns:
69     A manifest name with an embedded date. This should make it easier to roll
70     back if necessary.
71   """
72   return time.strftime('naclsdk_manifest2.%Y_%m_%d_%H_%M_%S.json',
73       time.gmtime())
74
75
76 def GetPlatformArchiveName(platform):
77   """Get the basename of an archive given a platform string.
78
79   Args:
80     platform: One of ('win', 'mac', 'linux').
81
82   Returns:
83     The basename of the sdk archive for that platform.
84   """
85   return 'naclsdk_%s.tar.bz2' % platform
86
87
88 def GetCanonicalArchiveName(url):
89   """Get the canonical name of an archive given its URL.
90
91   This will convert "naclsdk_linux.bz2" -> "naclsdk_linux.tar.bz2", and also
92   remove everything but the filename of the URL.
93
94   This is used below to determine if an expected bundle is found in an version
95   directory; the archives all have the same name, but may not exist for a given
96   version.
97
98   Args:
99     url: The url to parse.
100
101   Returns:
102     The canonical name as described above.
103   """
104   name = posixpath.basename(url)
105   match = re.match(r'naclsdk_(.*?)(?:\.tar)?\.bz2', name)
106   if match:
107     return 'naclsdk_%s.tar.bz2' % match.group(1)
108
109   return name
110
111
112 class Delegate(object):
113   """Delegate all external access; reading/writing to filesystem, gsutil etc."""
114
115   def GetRepoManifest(self):
116     """Read the manifest file from the NaCl SDK repository.
117
118     This manifest is used as a template for the auto updater; only pepper
119     bundles with no archives are considered for auto updating.
120
121     Returns:
122       A manifest_util.SDKManifest object read from the NaCl SDK repo."""
123     raise NotImplementedError()
124
125   def GetHistory(self):
126     """Read Chrome release history from omahaproxy.appspot.com
127
128     Here is an example of data from this URL:
129       cros,stable,18.0.1025.168,2012-05-01 17:04:05.962578\n
130       win,canary,20.0.1123.0,2012-05-01 13:59:31.703020\n
131       mac,canary,20.0.1123.0,2012-05-01 11:54:13.041875\n
132       win,stable,18.0.1025.168,2012-04-30 20:34:56.078490\n
133       mac,stable,18.0.1025.168,2012-04-30 20:34:55.231141\n
134       ...
135     Where each line has comma separated values in the following format:
136     platform, channel, version, date/time\n
137
138     Returns:
139       A list where each element is a line from the document, represented as a
140       tuple."""
141     raise NotImplementedError()
142
143   def GetTrunkRevision(self, version):
144     """Given a Chrome version, get its trunk revision.
145
146     Args:
147       version: A version string of the form '18.0.1025.64'
148     Returns:
149       The revision number for that version, as a string."""
150     raise NotImplementedError()
151
152   def GsUtil_ls(self, url):
153     """Runs gsutil ls |url|
154
155     Args:
156       url: The commondatastorage url to list.
157     Returns:
158       A list of URLs, all with the gs:// schema."""
159     raise NotImplementedError()
160
161   def GsUtil_cat(self, url):
162     """Runs gsutil cat |url|
163
164     Args:
165       url: The commondatastorage url to read from.
166     Returns:
167       A string with the contents of the file at |url|."""
168     raise NotImplementedError()
169
170   def GsUtil_cp(self, src, dest, stdin=None):
171     """Runs gsutil cp |src| |dest|
172
173     Args:
174       src: The file path or url to copy from.
175       dest: The file path or url to copy to.
176       stdin: If src is '-', this is used as the stdin to give to gsutil. The
177           effect is that text in stdin is copied to |dest|."""
178     raise NotImplementedError()
179
180   def SendMail(self, subject, text):
181     """Send an email.
182
183     Args:
184       subject: The subject of the email.
185       text: The text of the email.
186     """
187     raise NotImplementedError()
188
189
190 class RealDelegate(Delegate):
191   def __init__(self, dryrun=False, gsutil=None, mailfrom=None, mailto=None):
192     super(RealDelegate, self).__init__()
193     self.dryrun = dryrun
194     self.mailfrom = mailfrom
195     self.mailto = mailto
196     if gsutil:
197       self.gsutil = gsutil
198     else:
199       self.gsutil = buildbot_common.GetGsutil()
200
201   def GetRepoManifest(self):
202     """See Delegate.GetRepoManifest"""
203     with open(REPO_MANIFEST, 'r') as sdk_stream:
204       sdk_json_string = sdk_stream.read()
205
206     manifest = manifest_util.SDKManifest()
207     manifest.LoadDataFromString(sdk_json_string, add_missing_info=True)
208     return manifest
209
210   def GetHistory(self):
211     """See Delegate.GetHistory"""
212     url_stream = urllib2.urlopen('https://omahaproxy.appspot.com/history')
213     return [(platform, channel, version, date)
214         for platform, channel, version, date in csv.reader(url_stream)]
215
216   def GetTrunkRevision(self, version):
217     """See Delegate.GetTrunkRevision"""
218     url = 'http://omahaproxy.appspot.com/revision.json?version=%s' % (version,)
219     data = json.loads(urllib2.urlopen(url).read())
220     return 'trunk.%s' % int(data['chromium_revision'])
221
222   def GsUtil_ls(self, url):
223     """See Delegate.GsUtil_ls"""
224     try:
225       stdout = self._RunGsUtil(None, False, 'ls', url)
226     except subprocess.CalledProcessError:
227       return []
228
229     # filter out empty lines
230     return filter(None, stdout.split('\n'))
231
232   def GsUtil_cat(self, url):
233     """See Delegate.GsUtil_cat"""
234     return self._RunGsUtil(None, True, 'cat', url)
235
236   def GsUtil_cp(self, src, dest, stdin=None):
237     """See Delegate.GsUtil_cp"""
238     if self.dryrun:
239       logger.info("Skipping upload: %s -> %s" % (src, dest))
240       if src == '-':
241         logger.info('  contents = """%s"""' % stdin)
242       return
243
244     return self._RunGsUtil(stdin, True, 'cp', '-a', 'public-read', src, dest)
245
246   def SendMail(self, subject, text):
247     """See Delegate.SendMail"""
248     if self.mailfrom and self.mailto:
249       msg = email.MIMEMultipart.MIMEMultipart()
250       msg['From'] = self.mailfrom
251       msg['To'] = ', '.join(self.mailto)
252       msg['Date'] = email.Utils.formatdate(localtime=True)
253       msg['Subject'] = subject
254       msg.attach(email.MIMEText.MIMEText(text))
255       smtp_obj = smtplib.SMTP('localhost')
256       smtp_obj.sendmail(self.mailfrom, self.mailto, msg.as_string())
257       smtp_obj.close()
258
259   def _RunGsUtil(self, stdin, log_errors, *args):
260     """Run gsutil as a subprocess.
261
262     Args:
263       stdin: If non-None, used as input to the process.
264       log_errors: If True, write errors to stderr.
265       *args: Arguments to pass to gsutil. The first argument should be an
266           operation such as ls, cp or cat.
267     Returns:
268       The stdout from the process."""
269     cmd = [self.gsutil] + list(args)
270     logger.debug("Running: %s" % str(cmd))
271     if stdin:
272       stdin_pipe = subprocess.PIPE
273     else:
274       stdin_pipe = None
275
276     try:
277       process = subprocess.Popen(cmd, stdin=stdin_pipe, stdout=subprocess.PIPE,
278           stderr=subprocess.PIPE)
279       stdout, stderr = process.communicate(stdin)
280     except OSError as e:
281       raise manifest_util.Error("Unable to run '%s': %s" % (cmd[0], str(e)))
282
283     if process.returncode:
284       if log_errors:
285         logger.error(stderr)
286       raise subprocess.CalledProcessError(process.returncode, ' '.join(cmd))
287     return stdout
288
289
290 class GsutilLoggingHandler(logging.handlers.BufferingHandler):
291   def __init__(self, delegate):
292     logging.handlers.BufferingHandler.__init__(self, capacity=0)
293     self.delegate = delegate
294
295   def shouldFlush(self, record):
296     # BufferingHandler.shouldFlush automatically flushes if the length of the
297     # buffer is greater than self.capacity. We don't want that behavior, so
298     # return False here.
299     return False
300
301   def flush(self):
302     # Do nothing here. We want to be explicit about uploading the log.
303     pass
304
305   def upload(self):
306     output_list = []
307     for record in self.buffer:
308       output_list.append(self.format(record))
309     output = '\n'.join(output_list)
310     self.delegate.GsUtil_cp('-', GS_SDK_MANIFEST_LOG, stdin=output)
311
312     logging.handlers.BufferingHandler.flush(self)
313
314
315 class NoSharedVersionException(Exception):
316   pass
317
318
319 class VersionFinder(object):
320   """Finds a version of a pepper bundle that all desired platforms share.
321
322   Args:
323     delegate: See Delegate class above.
324     platforms: A sequence of platforms to consider, e.g.
325         ('mac', 'linux', 'win')
326     extra_archives: A sequence of tuples: (archive_basename, minimum_version),
327         e.g. [('foo.tar.bz2', '18.0.1000.0'), ('bar.tar.bz2', '19.0.1100.20')]
328         These archives must exist to consider a version for inclusion, as
329         long as that version is greater than the archive's minimum version.
330   """
331   def __init__(self, delegate, platforms, extra_archives=None):
332     self.delegate = delegate
333     self.history = delegate.GetHistory()
334     self.platforms = platforms
335     self.extra_archives = extra_archives
336
337   def GetMostRecentSharedVersion(self, major_version):
338     """Returns the most recent version of a pepper bundle that exists on all
339     given platforms.
340
341     Specifically, the resulting version should be the most recently released
342     (meaning closest to the top of the listing on
343     omahaproxy.appspot.com/history) version that has a Chrome release on all
344     given platforms, and has a pepper bundle archive for each platform as well.
345
346     Args:
347       major_version: The major version of the pepper bundle, e.g. 19.
348     Returns:
349       A tuple (version, channel, archives). The version is a string such as
350       "19.0.1084.41". The channel is one of ('stable', 'beta', or 'dev').
351       |archives| is a list of archive URLs."""
352     def GetPlatformHistory(platform):
353       return self._GetPlatformMajorVersionHistory(major_version, platform)
354
355     shared_version_generator = self._FindNextSharedVersion(self.platforms,
356                                                            GetPlatformHistory)
357     return self._DoGetMostRecentSharedVersion(shared_version_generator,
358                                               allow_trunk_revisions=False)
359
360   def GetMostRecentSharedCanary(self):
361     """Returns the most recent version of a canary pepper bundle that exists on
362     all given platforms.
363
364     Canary is special-cased because we don't care about its major version; we
365     always use the most recent canary, regardless of major version.
366
367     Returns:
368       A tuple (version, channel, archives). The version is a string such as
369       "19.0.1084.41". The channel is always 'canary'. |archives| is a list of
370       archive URLs."""
371     # Canary versions that differ in the last digit shouldn't be considered
372     # different; this number is typically used to represent an experiment, e.g.
373     # using ASAN or aura.
374     def CanaryKey(version):
375       return version[:-1]
376
377     # We don't ship canary on Linux, so it won't appear in self.history.
378     # Instead, we can use the matching Linux trunk build for that version.
379     shared_version_generator = self._FindNextSharedVersion(
380         set(self.platforms) - set(('linux',)),
381         self._GetPlatformCanaryHistory, CanaryKey)
382     return self._DoGetMostRecentSharedVersion(shared_version_generator,
383                                               allow_trunk_revisions=True)
384
385   def GetAvailablePlatformArchivesFor(self, version, allow_trunk_revisions):
386     """Returns a sequence of archives that exist for a given version, on the
387     given platforms.
388
389     The second element of the returned tuple is a list of all platforms that do
390     not have an archive for the given version.
391
392     Args:
393       version: The version to find archives for. (e.g. "18.0.1025.164")
394       allow_trunk_revisions: If True, will search for archives using the
395           trunk revision that matches the branch version.
396     Returns:
397       A tuple (archives, missing_archives). |archives| is a list of archive
398       URLs, |missing_archives| is a list of archive names.
399     """
400     archive_urls = self._GetAvailableArchivesFor(version)
401     platform_archives = set(GetPlatformArchiveName(p) for p in self.platforms)
402     expected_archives = platform_archives
403     if self.extra_archives:
404       for extra_archive, extra_archive_min_version in self.extra_archives:
405         if SplitVersion(version) >= SplitVersion(extra_archive_min_version):
406           expected_archives.add(extra_archive)
407     found_archives = set(GetCanonicalArchiveName(a) for a in archive_urls)
408     missing_archives = expected_archives - found_archives
409     if allow_trunk_revisions and missing_archives:
410       # Try to find trunk versions of any missing archives.
411       trunk_version = self.delegate.GetTrunkRevision(version)
412       trunk_archives = self._GetAvailableArchivesFor(trunk_version)
413       for trunk_archive_url in trunk_archives:
414         trunk_archive = GetCanonicalArchiveName(trunk_archive_url)
415         if trunk_archive in missing_archives:
416           archive_urls.append(trunk_archive_url)
417           missing_archives.discard(trunk_archive)
418
419     # Only return archives that are "expected".
420     def IsExpected(url):
421       return GetCanonicalArchiveName(url) in expected_archives
422
423     expected_archive_urls = [u for u in archive_urls if IsExpected(u)]
424     return expected_archive_urls, missing_archives
425
426   def _DoGetMostRecentSharedVersion(self, shared_version_generator,
427                                     allow_trunk_revisions):
428     """Returns the most recent version of a pepper bundle that exists on all
429     given platforms.
430
431     This function does the real work for the public GetMostRecentShared* above.
432
433     Args:
434       shared_version_generator: A generator that will yield (version, channel)
435           tuples in order of most recent to least recent.
436       allow_trunk_revisions: If True, will search for archives using the
437           trunk revision that matches the branch version.
438     Returns:
439       A tuple (version, channel, archives). The version is a string such as
440       "19.0.1084.41". The channel is one of ('stable', 'beta', 'dev',
441       'canary'). |archives| is a list of archive URLs."""
442     version = None
443     skipped_versions = []
444     channel = ''
445     while True:
446       try:
447         version, channel = shared_version_generator.next()
448       except StopIteration:
449         msg = 'No shared version for platforms: %s\n' % (
450             ', '.join(self.platforms))
451         msg += 'Last version checked = %s.\n' % (version,)
452         if skipped_versions:
453           msg += 'Versions skipped due to missing archives:\n'
454           for version, channel, missing_archives in skipped_versions:
455             archive_msg = '(missing %s)' % (', '.join(missing_archives))
456           msg += '  %s (%s) %s\n' % (version, channel, archive_msg)
457         raise NoSharedVersionException(msg)
458
459       logger.info('Found shared version: %s, channel: %s' % (
460           version, channel))
461
462       archives, missing_archives = self.GetAvailablePlatformArchivesFor(
463           version, allow_trunk_revisions)
464
465       if not missing_archives:
466         return version, channel, archives
467
468       logger.info('  skipping. Missing archives: %s' % (
469           ', '.join(missing_archives)))
470
471       skipped_versions.append((version, channel, missing_archives))
472
473   def _GetPlatformMajorVersionHistory(self, with_major_version, with_platform):
474     """Yields Chrome history for a given platform and major version.
475
476     Args:
477       with_major_version: The major version to filter for. If 0, match all
478           versions.
479       with_platform: The name of the platform to filter for.
480     Returns:
481       A generator that yields a tuple (channel, version) for each version that
482       matches the platform and major version. The version returned is a tuple as
483       returned from SplitVersion.
484     """
485     for platform, channel, version, _ in self.history:
486       version = SplitVersion(version)
487       if (with_platform == platform and
488           (with_major_version == 0 or with_major_version == version[0])):
489         yield channel, version
490
491   def _GetPlatformCanaryHistory(self, with_platform):
492     """Yields Chrome history for a given platform, but only for canary
493     versions.
494
495     Args:
496       with_platform: The name of the platform to filter for.
497     Returns:
498       A generator that yields a tuple (channel, version) for each version that
499       matches the platform and uses the canary channel. The version returned is
500       a tuple as returned from SplitVersion.
501     """
502     for platform, channel, version, _ in self.history:
503       version = SplitVersion(version)
504       if with_platform == platform and channel == CANARY:
505         yield channel, version
506
507
508   def _FindNextSharedVersion(self, platforms, generator_func, key_func=None):
509     """Yields versions of Chrome that exist on all given platforms, in order of
510        newest to oldest.
511
512     Versions are compared in reverse order of release. That is, the most
513     recently updated version will be tested first.
514
515     Args:
516       platforms: A sequence of platforms to consider, e.g.
517           ('mac', 'linux', 'win')
518       generator_func: A function which takes a platform and returns a
519           generator that yields (channel, version) tuples.
520       key_func: A function to convert the version into a value that should be
521           used for comparison. See python built-in sorted() or min(), for
522           an example.
523     Returns:
524       A generator that yields a tuple (version, channel) for each version that
525       matches all platforms and the major version. The version returned is a
526       string (e.g. "18.0.1025.164").
527     """
528     if not key_func:
529       key_func = lambda x: x
530
531     platform_generators = []
532     for platform in platforms:
533       platform_generators.append(generator_func(platform))
534
535     shared_version = None
536     platform_versions = []
537     for platform_gen in platform_generators:
538       platform_versions.append(platform_gen.next())
539
540     while True:
541       if logger.isEnabledFor(logging.INFO):
542         msg_info = []
543         for i, platform in enumerate(platforms):
544           msg_info.append('%s: %s' % (
545               platform, JoinVersion(platform_versions[i][1])))
546         logger.info('Checking versions: %s' % ', '.join(msg_info))
547
548       shared_version = min((v for c, v in platform_versions), key=key_func)
549
550       if all(key_func(v) == key_func(shared_version)
551              for c, v in platform_versions):
552         # The real shared_version should be the real minimum version. This will
553         # be different from shared_version above only if key_func compares two
554         # versions with different values as equal.
555         min_version = min((v for c, v in platform_versions))
556
557         # grab the channel from an arbitrary platform
558         first_platform = platform_versions[0]
559         channel = first_platform[0]
560         yield JoinVersion(min_version), channel
561
562         # force increment to next version for all platforms
563         shared_version = None
564
565       # Find the next version for any platform that isn't at the shared version.
566       try:
567         for i, platform_gen in enumerate(platform_generators):
568           if platform_versions[i][1] != shared_version:
569             platform_versions[i] = platform_gen.next()
570       except StopIteration:
571         return
572
573
574   def _GetAvailableArchivesFor(self, version_string):
575     """Downloads a list of all available archives for a given version.
576
577     Args:
578       version_string: The version to find archives for. (e.g. "18.0.1025.164")
579     Returns:
580       A list of strings, each of which is a platform-specific archive URL. (e.g.
581       "gs://nativeclient_mirror/nacl/nacl_sdk/18.0.1025.164/"
582       "naclsdk_linux.tar.bz2").
583
584       All returned URLs will use the gs:// schema."""
585     files = self.delegate.GsUtil_ls(GS_BUCKET_PATH + version_string)
586
587     assert all(file.startswith('gs://') for file in files)
588
589     archives = [f for f in files if not f.endswith('.json')]
590     manifests = [f for f in files if f.endswith('.json')]
591
592     # don't include any archives that don't have an associated manifest.
593     return filter(lambda a: a + '.json' in manifests, archives)
594
595
596 class UnknownLockedBundleException(Exception):
597   pass
598
599
600 class Updater(object):
601   def __init__(self, delegate):
602     self.delegate = delegate
603     self.versions_to_update = []
604     self.locked_bundles = []
605     self.online_manifest = manifest_util.SDKManifest()
606     self._FetchOnlineManifest()
607
608   def AddVersionToUpdate(self, bundle_name, version, channel, archives):
609     """Add a pepper version to update in the uploaded manifest.
610
611     Args:
612       bundle_name: The name of the pepper bundle, e.g. 'pepper_18'
613       version: The version of the pepper bundle, e.g. '18.0.1025.64'
614       channel: The stability of the pepper bundle, e.g. 'beta'
615       archives: A sequence of archive URLs for this bundle."""
616     self.versions_to_update.append((bundle_name, version, channel, archives))
617
618   def AddLockedBundle(self, bundle_name):
619     """Add a "locked" bundle to the updater.
620
621     A locked bundle is a bundle that wasn't found in the history. When this
622     happens, the bundle is now "locked" to whatever was last found. We want to
623     ensure that the online manifest has this bundle.
624
625     Args:
626       bundle_name: The name of the locked bundle.
627     """
628     self.locked_bundles.append(bundle_name)
629
630   def Update(self, manifest):
631     """Update a manifest and upload it.
632
633     Note that bundles will not be updated if the current version is newer.
634     That is, the updater will never automatically update to an older version of
635     a bundle.
636
637     Args:
638       manifest: The manifest used as a template for updating. Only pepper
639       bundles that contain no archives will be considered for auto-updating."""
640     # Make sure there is only one stable branch: the one with the max version.
641     # All others are post-stable.
642     stable_major_versions = [SplitVersion(version)[0] for _, version, channel, _
643                              in self.versions_to_update if channel == 'stable']
644     # Add 0 in case there are no stable versions.
645     max_stable_version = max([0] + stable_major_versions)
646
647     # Ensure that all locked bundles exist in the online manifest.
648     for bundle_name in self.locked_bundles:
649       online_bundle = self.online_manifest.GetBundle(bundle_name)
650       if online_bundle:
651         manifest.SetBundle(online_bundle)
652       else:
653         msg = ('Attempted to update bundle "%s", but no shared versions were '
654             'found, and there is no online bundle with that name.')
655         raise UnknownLockedBundleException(msg % bundle_name)
656
657     if self.locked_bundles:
658       # Send a nagging email that we shouldn't be wasting time looking for
659       # bundles that are no longer in the history.
660       scriptname = os.path.basename(sys.argv[0])
661       subject = '[%s] Reminder: remove bundles from %s' % (scriptname,
662                                                            MANIFEST_BASENAME)
663       text = 'These bundles are not in the omahaproxy history anymore: ' + \
664               ', '.join(self.locked_bundles)
665       self.delegate.SendMail(subject, text)
666
667
668     # Update all versions.
669     logger.info('>>> Updating bundles...')
670     for bundle_name, version, channel, archives in self.versions_to_update:
671       logger.info('Updating %s to %s...' % (bundle_name, version))
672       bundle = manifest.GetBundle(bundle_name)
673       for archive in archives:
674         platform_bundle = self._GetPlatformArchiveBundle(archive)
675         # Normally the manifest snippet's bundle name matches our bundle name.
676         # pepper_canary, however is called "pepper_###" in the manifest
677         # snippet.
678         platform_bundle.name = bundle_name
679         bundle.MergeWithBundle(platform_bundle)
680
681       # Fix the stability and recommended values
682       major_version = SplitVersion(version)[0]
683       if major_version < max_stable_version:
684         bundle.stability = 'post_stable'
685       else:
686         bundle.stability = channel
687       # We always recommend the stable version.
688       if bundle.stability == 'stable':
689         bundle.recommended = 'yes'
690       else:
691         bundle.recommended = 'no'
692
693       # Check to ensure this bundle is newer than the online bundle.
694       online_bundle = self.online_manifest.GetBundle(bundle_name)
695       if online_bundle:
696         # This test used to be online_bundle.revision >= bundle.revision.
697         # That doesn't do quite what we want: sometimes the metadata changes
698         # but the revision stays the same -- we still want to push those
699         # changes.
700         if online_bundle.revision > bundle.revision or online_bundle == bundle:
701           logger.info(
702               '  Revision %s is not newer than than online revision %s. '
703               'Skipping.' % (bundle.revision, online_bundle.revision))
704
705           manifest.SetBundle(online_bundle)
706           continue
707     self._UploadManifest(manifest)
708     logger.info('Done.')
709
710   def _GetPlatformArchiveBundle(self, archive):
711     """Downloads the manifest "snippet" for an archive, and reads it as a
712        Bundle.
713
714     Args:
715       archive: A full URL of a platform-specific archive, using the gs schema.
716     Returns:
717       An object of type manifest_util.Bundle, read from a JSON file storing
718       metadata for this archive.
719     """
720     stdout = self.delegate.GsUtil_cat(archive + '.json')
721     bundle = manifest_util.Bundle('')
722     bundle.LoadDataFromString(stdout)
723     # Some snippets were uploaded with revisions and versions as strings. Fix
724     # those here.
725     bundle.revision = int(bundle.revision)
726     bundle.version = int(bundle.version)
727
728     # HACK. The naclports archive specifies host_os as linux. Change it to all.
729     for archive in bundle.GetArchives():
730       if NACLPORTS_ARCHIVE_NAME in archive.url:
731         archive.host_os = 'all'
732     return bundle
733
734   def _UploadManifest(self, manifest):
735     """Upload a serialized manifest_util.SDKManifest object.
736
737     Upload one copy to gs://<BUCKET_PATH>/naclsdk_manifest2.json, and a copy to
738     gs://<BUCKET_PATH>/manifest_backups/naclsdk_manifest2.<TIMESTAMP>.json.
739
740     Args:
741       manifest: The new manifest to upload.
742     """
743     new_manifest_string = manifest.GetDataAsString()
744     online_manifest_string = self.online_manifest.GetDataAsString()
745
746     if self.delegate.dryrun:
747       logger.info(''.join(list(difflib.unified_diff(
748           online_manifest_string.splitlines(1),
749           new_manifest_string.splitlines(1)))))
750       return
751     else:
752       online_manifest = manifest_util.SDKManifest()
753       online_manifest.LoadDataFromString(online_manifest_string)
754
755       if online_manifest == manifest:
756         logger.info('New manifest doesn\'t differ from online manifest.'
757             'Skipping upload.')
758         return
759
760     timestamp_manifest_path = GS_MANIFEST_BACKUP_DIR + \
761         GetTimestampManifestName()
762     self.delegate.GsUtil_cp('-', timestamp_manifest_path,
763         stdin=manifest.GetDataAsString())
764
765     # copy from timestampped copy over the official manifest.
766     self.delegate.GsUtil_cp(timestamp_manifest_path, GS_SDK_MANIFEST)
767
768   def _FetchOnlineManifest(self):
769     try:
770       online_manifest_string = self.delegate.GsUtil_cat(GS_SDK_MANIFEST)
771     except subprocess.CalledProcessError:
772       # It is not a failure if the online manifest doesn't exist.
773       online_manifest_string = ''
774
775     if online_manifest_string:
776       self.online_manifest.LoadDataFromString(online_manifest_string)
777
778
779 def Run(delegate, platforms, extra_archives, fixed_bundle_versions=None):
780   """Entry point for the auto-updater.
781
782   Args:
783     delegate: The Delegate object to use for reading Urls, files, etc.
784     platforms: A sequence of platforms to consider, e.g.
785         ('mac', 'linux', 'win')
786       extra_archives: A sequence of tuples: (archive_basename, minimum_version),
787           e.g. [('foo.tar.bz2', '18.0.1000.0'), ('bar.tar.bz2', '19.0.1100.20')]
788           These archives must exist to consider a version for inclusion, as
789           long as that version is greater than the archive's minimum version.
790     fixed_bundle_versions: A sequence of tuples (bundle_name, version_string).
791         e.g. ('pepper_21', '21.0.1145.0')
792   """
793   if fixed_bundle_versions:
794     fixed_bundle_versions = dict(fixed_bundle_versions)
795   else:
796     fixed_bundle_versions = {}
797
798   manifest = delegate.GetRepoManifest()
799   auto_update_bundles = []
800   for bundle in manifest.GetBundles():
801     if not bundle.name.startswith('pepper_'):
802       continue
803     archives = bundle.GetArchives()
804     if not archives:
805       auto_update_bundles.append(bundle)
806
807   if not auto_update_bundles:
808     logger.info('No versions need auto-updating.')
809     return
810
811   version_finder = VersionFinder(delegate, platforms, extra_archives)
812   updater = Updater(delegate)
813
814   for bundle in auto_update_bundles:
815     try:
816       if bundle.name == CANARY_BUNDLE_NAME:
817         logger.info('>>> Looking for most recent pepper_canary...')
818         version, channel, archives = version_finder.GetMostRecentSharedCanary()
819       else:
820         logger.info('>>> Looking for most recent pepper_%s...' %
821             bundle.version)
822         version, channel, archives = version_finder.GetMostRecentSharedVersion(
823             bundle.version)
824     except NoSharedVersionException:
825       # If we can't find a shared version, make sure that there is an uploaded
826       # bundle with that name already.
827       updater.AddLockedBundle(bundle.name)
828       continue
829
830     if bundle.name in fixed_bundle_versions:
831       # Ensure this version is valid for all platforms.
832       # If it is, use the channel found above (because the channel for this
833       # version may not be in the history.)
834       version = fixed_bundle_versions[bundle.name]
835       logger.info('Fixed bundle version: %s, %s' % (bundle.name, version))
836       allow_trunk_revisions = bundle.name == CANARY_BUNDLE_NAME
837       archives, missing = version_finder.GetAvailablePlatformArchivesFor(
838           version, allow_trunk_revisions)
839       if missing:
840         logger.warn(
841             'Some archives for version %s of bundle %s don\'t exist: '
842             'Missing %s' % (version, bundle.name, ', '.join(missing)))
843         return
844
845     updater.AddVersionToUpdate(bundle.name, version, channel, archives)
846
847   updater.Update(manifest)
848
849
850 class CapturedFile(object):
851   """A file-like object that captures text written to it, but also passes it
852   through to an underlying file-like object."""
853   def __init__(self, passthrough):
854     self.passthrough = passthrough
855     self.written = cStringIO.StringIO()
856
857   def write(self, s):
858     self.written.write(s)
859     if self.passthrough:
860       self.passthrough.write(s)
861
862   def getvalue(self):
863     return self.written.getvalue()
864
865
866 def main(args):
867   parser = optparse.OptionParser()
868   parser.add_option('--gsutil', help='path to gsutil.')
869   parser.add_option('-d', '--debug', help='run in debug mode.',
870       action='store_true')
871   parser.add_option('--mailfrom', help='email address of sender.')
872   parser.add_option('--mailto', help='send error mails to...', action='append')
873   parser.add_option('-n', '--dryrun', help="don't upload the manifest.",
874       action='store_true')
875   parser.add_option('-v', '--verbose', help='print more diagnotic messages. '
876       'Use more than once for more info.',
877       action='count')
878   parser.add_option('--log-file', metavar='FILE', help='log to FILE')
879   parser.add_option('--upload-log', help='Upload log alongside the manifest.',
880                     action='store_true')
881   parser.add_option('--bundle-version',
882       help='Manually set a bundle version. This can be passed more than once. '
883       'format: --bundle-version pepper_24=24.0.1312.25', action='append')
884   options, args = parser.parse_args(args[1:])
885
886   if (options.mailfrom is None) != (not options.mailto):
887     options.mailfrom = None
888     options.mailto = None
889     logger.warning('Disabling email, one of --mailto or --mailfrom '
890         'was missing.\n')
891
892   if options.verbose >= 2:
893     logging.basicConfig(level=logging.DEBUG, filename=options.log_file)
894   elif options.verbose:
895     logging.basicConfig(level=logging.INFO, filename=options.log_file)
896   else:
897     logging.basicConfig(level=logging.WARNING, filename=options.log_file)
898
899   # Parse bundle versions.
900   fixed_bundle_versions = {}
901   if options.bundle_version:
902     for bundle_version_string in options.bundle_version:
903       bundle_name, version = bundle_version_string.split('=')
904       fixed_bundle_versions[bundle_name] = version
905
906   if options.mailfrom and options.mailto:
907     # Capture stderr so it can be emailed, if necessary.
908     sys.stderr = CapturedFile(sys.stderr)
909
910   try:
911     try:
912       delegate = RealDelegate(options.dryrun, options.gsutil,
913                               options.mailfrom, options.mailto)
914
915       if options.upload_log:
916         gsutil_logging_handler = GsutilLoggingHandler(delegate)
917         logger.addHandler(gsutil_logging_handler)
918
919       # Only look for naclports archives >= 27. The old ports bundles don't
920       # include license information.
921       extra_archives = [('naclports.tar.bz2', '27.0.0.0')]
922       Run(delegate, ('mac', 'win', 'linux'), extra_archives,
923           fixed_bundle_versions)
924       return 0
925     except Exception:
926       if options.mailfrom and options.mailto:
927         traceback.print_exc()
928         scriptname = os.path.basename(sys.argv[0])
929         subject = '[%s] Failed to update manifest' % (scriptname,)
930         text = '%s failed.\n\nSTDERR:\n%s\n' % (scriptname,
931                                                 sys.stderr.getvalue())
932         delegate.SendMail(subject, text)
933         return 1
934       else:
935         raise
936     finally:
937       if options.upload_log:
938         gsutil_logging_handler.upload()
939   except manifest_util.Error as e:
940     if options.debug:
941       raise
942     sys.stderr.write(str(e) + '\n')
943     return 1
944
945
946 if __name__ == '__main__':
947   sys.exit(main(sys.argv))