blob: b8c68e8a50ea424dc5f9cb5b3e34e0fc612b2520 [file] [log] [blame]
# Copyright 2019 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Recipe for rolling CIPD prebuilts into Fuchsia."""
import functools
import os
import re
from recipe_engine.config import List
from recipe_engine.recipe_api import Property
from RECIPE_MODULES.fuchsia.utils.api import memoize
DEPS = [
'fuchsia/auto_roller',
'fuchsia/build',
'fuchsia/buildbucket_util',
'fuchsia/jiri',
'fuchsia/status_check',
'fuchsia/upload_debug_symbols',
'recipe_engine/archive',
'recipe_engine/buildbucket',
'recipe_engine/cipd',
'recipe_engine/context',
'recipe_engine/file',
'recipe_engine/json',
'recipe_engine/path',
'recipe_engine/properties',
'recipe_engine/raw_io',
'recipe_engine/step',
]
PROPERTIES = {
'project':
Property(kind=str, help='Jiri remote manifest project', default=None),
'manifest':
Property(kind=str, help='Jiri manifest to use'),
'remote':
Property(kind=str, help='Remote manifest repository'),
'import_in':
Property(
kind=str, help='Path to the manifest to edit relative to $project'),
'packages':
Property(
kind=List(str),
help='The list of CIPD packages to update in $import_in'),
'debug_symbol_attribute':
Property(
kind=str,
default='debug-symbols',
help='Jiri attribute to match debug symbol packages'),
'debug_symbol_gcs_buckets':
Property(
kind=List(str),
default=(),
help='GCS buckets to upload debug symbols upon successful roll'),
'lockfiles':
Property(
kind=List(str),
default=(),
help='The list of lockfiles to update in "${manifest}=${lockfile}" format'
),
'dry_run':
Property(
kind=bool,
default=False,
help='Whether to dry-run the auto-roller (CQ+1 and abandon the change)'
),
'tag':
Property(
kind=str,
default='version',
help='A CIPD tag prefix common to all $packages where a common version can be extracted'
),
'ref':
Property(
kind=str,
default='latest',
help='A common CIPD ref to resolve when rolling a set of packages'),
'owners':
Property(
kind=List(str),
default=(),
help=('The owners responsible for watching this roller '
'(example: "username@google.com").')),
'test_multipliers':
Property(
kind=List(dict),
default=None,
help=('A list of test multipliers to pass into the roll CL '
'MULTIPLY footer.')),
}
COMMIT_MESSAGE = """[roll] Roll {roller} CIPD packages:
{packages}
From: {old_version}
To: {version}
{multiply}
Cq-Cl-Tag: roller-builder:{builder}
Cq-Cl-Tag: roller-bid:{build_id}
CQ-Do-Not-Cancel-Tryjobs: true"""
CIPD_URL = 'https://chrome-infra-packages.appspot.com/p/{package}/+/{version}'
@memoize
def get_tags_from_cipd(api, cipd_pkg, tag_prefix, ref):
"""Returns all tags for a given CIPD package instance.
Args:
api (RecipeApi): The recipe api to use.
cipd_pkg (str): The name of the CIPD package.
tag_prefix (str): The tag prefix to filter by.
ref (str): The ref of the desired instance.
Returns:
The package's tags that start with the given prefix.
"""
cipd_tags = api.cipd.describe(cipd_pkg, ref).tags
return set(t.tag for t in cipd_tags if t.tag.startswith(tag_prefix))
def find_common_tag(api, packages, tag, ref):
"""Returns a common tag shared by some instance of all the packages.
At least one (and generally most, if not all) of the chosen instances will
have the given `ref`. The only time that some of the instances won't
currently have that ref is when some of the packages have been updated to
a later version and some haven't (e.g. if another roller that does the
updating is running in parallel with this recipe). In that case, we'll use
instances of the updated packages whose versions correspond to the latest
instances of the non-updated packages.
Args:
api (RecipeApi): The recipe API to use.
packages (seq(str)): The names of the packages to search.
tag (str): The prefix to filter by when searching for a common tag. E.g.,
if `tag` is "version", then the common tag returned will start with
"version".
ref (str): The ref that should be shared by all (and at least one) of the
chosen packages instances, e.g. "latest".
Returns:
The name of a tag that's shared by some instance of all the specified
packages.
"""
# Fetch the instance of the first package that has the `ref` ref, and
# consider it as the "representative" instance. We'll try to find a subset of
# its tags that are shared by the `ref` instances of all the other packages.
common_tags = get_tags_from_cipd(api, packages[0], tag, ref)
found_outdated_package = False
i = 1
while i < len(packages):
pkg = packages[i]
i += 1
tags = get_tags_from_cipd(api, pkg, tag, ref)
intersection = common_tags.intersection(tags)
if intersection:
# This package's latest instance shares some tags with the previous
# packages' latest instances, so we still have some common tag candidates
# and can move on to the next package.
common_tags = intersection
continue
# Else, the instance of `pkg` at `ref` does not share any tags with all the
# previous packages' fetched instances. We'll see if `pkg` has any
# (probably older) instance that has a shared tag with all the previous
# packages' chosen instances.
for common_tag in common_tags:
older_instances = api.cipd.search(pkg, common_tag)
if older_instances:
break
if older_instances:
# The representative package instance has not yet been updated to this
# package's latest version (or at least the representative hadn't been
# updated at the time that we fetched its tags), but there are older
# instances of this package that *do* match the version of the
# representative instance, so we'll use one of those instead.
# NOTE: There is a chance that `pkg` was actually out of date relative to
# the representative package, but got updated to the same version as the
# previous packages in the time between "cipd describe" and "cipd
# search", in which case `older_instance` will actually be newer than the
# original instance and we will end up with the same result as if `pkg`
# had already been up-to-date when we first "cipd described" it.
older_instance = older_instances[0].instance_id
older_tags = get_tags_from_cipd(api, pkg, tag, older_instance)
common_tags.intersection_update(older_tags)
assert common_tags # should contain at least `common_tag`
elif not found_outdated_package:
# This package has not yet been updated to the version of the
# representative package instance. Go back and start over with *this*
# package as the new representative.
found_outdated_package = True
common_tags = tags
i = 0
else:
# We should only "backtrack" once (i.e., find a package whose latest
# version tag doesn't correspond to a version of all the other packages).
# If it happens twice, there's probably something weird going on and we
# won't be able to reconcile the tags by continuing.
raise api.step.StepFailure('unable to find common tag to roll')
# Choose one of the common tags (it doesn't matter which) as the version to
# pin all packages to.
return common_tags.pop()
def fetch_and_upload_debug_symbols(api, project, import_in, remote, project_dir,
packages, debug_symbol_attribute,
debug_symbol_gcs_buckets):
"""
Fetch debug symbol archives, unpack them, and upload debug symbols.
Args:
project (str): Jiri remote manifest project.
import_in (str): Path to the edited manifest relative to $project
containing debug symbol packages.
remote (str): Remote manifest repository.
project_dir (Path): Project root path of $import_in.
packages (seq(str)): The list of CIPD packages updated in $import_in.
debug_symbol_attribute (str): Jiri attribute to match debug symbol packages.
debug_symbol_gcs_buckets (seq(str)): GCS buckets to upload debug symbols to.
"""
with api.context(infra_steps=True):
api.jiri.init(
use_lock_file=True,
attributes=(debug_symbol_attribute,),
)
# Fetch debug symbol packages using locally edited manifest.
api.jiri.import_manifest(
manifest=import_in,
remote=remote,
name=project,
)
api.jiri.fetch_packages(local_manifest=True)
with api.step.nest('build'):
gn_results = api.build.gen(
checkout_root=api.path['start_dir'],
fuchsia_build_dir=api.path['start_dir'].join('out', 'default'),
target='x64',
build_type='debug',
product='products/bringup.gni',
# //bundles:infratools is necessary to build upload_debug_symbols.
packages=['//bundles:infratools'],
)
upload_debug_symbols_target = os.path.relpath(
str(gn_results.tool('upload_debug_symbols')),
str(gn_results.fuchsia_build_dir),
)
api.build.ninja(
gn_results=gn_results,
build_zircon=False,
targets=[upload_debug_symbols_target],
)
build_id_dirs = []
for package in packages:
# Find archives for each debug symbol package.
with api.context(cwd=project_dir):
package_def = api.jiri.read_manifest_element(
manifest=import_in,
element_type='package',
element_name=package,
)
# Skip non debug symbol packages.
if debug_symbol_attribute not in package_def.get('attributes', ''):
continue
package_path = api.path['start_dir'].join(package_def['path'])
archives = api.file.glob_paths(
name='find archives for %s' % package,
source=package_path,
pattern='**/*.tar.bz2',
test_data=(package_path.join('symbols.tar.bz2'),),
)
# Unpack archives into .build-id dirs.
for archive in archives:
# Extract API requires a unique, non-existent directory.
archive_basename = os.path.basename(api.path.abspath(archive))
output_dir = api.path['start_dir'].join(package, archive_basename)
api.archive.extract(
step_name='extract %s' % archive,
archive_file=archive,
output=output_dir,
)
build_id_dirs.append(output_dir)
for debug_symbol_gcs_bucket in debug_symbol_gcs_buckets:
api.upload_debug_symbols(
step_name='upload debug symbols',
upload_debug_symbols_path=gn_results.tool('upload_debug_symbols'),
bucket=debug_symbol_gcs_bucket,
build_id_dirs=build_id_dirs,
)
def _get_platform_specific_packages(package, output):
platform_regex = '(?<=' + package.replace('${platform}',
r'\${platform=).*(?=})')
pattern = re.compile(platform_regex)
match = pattern.search(output)
if match:
platforms = match.group(0).split(',')
return [package.replace('${platform}', platform) for platform in platforms]
return [package]
def _append_urls(packages, old_version, new_version):
package_line = '{package} old:{old} new:{new}'
packages_with_urls = []
for package in packages:
if '${platform}' in package:
packages_with_urls.append(package)
else:
packages_with_urls.append(
package_line.format(
old=CIPD_URL.format(package=package, version=old_version),
new=CIPD_URL.format(package=package, version=new_version),
package=package))
return packages_with_urls
def RunSteps(api, project, manifest, remote, import_in, packages,
debug_symbol_attribute, debug_symbol_gcs_buckets, lockfiles,
dry_run, tag, ref, owners, test_multipliers):
with api.context(infra_steps=True):
if owners:
owners_step = api.step('owners', None)
owners_step.presentation.step_summary_text = ', '.join(owners)
api.jiri.init(use_lock_file=True)
api.jiri.import_manifest(manifest, remote, project)
api.jiri.update(run_hooks=False)
api.jiri.run_hooks()
project_dir = api.path['start_dir'].join(*project.split('/'))
with api.step.nest('find common tag'):
version = find_common_tag(api, packages, tag, ref)
with api.context(cwd=project_dir):
changes = api.jiri.edit_manifest(
import_in, packages=[(package, version) for package in packages])
if not changes['packages']:
api.step('manifest up-to-date; nothing to roll', None)
return
old_version = changes['packages'][0]['old_version']
exact_packages = set()
# Test data for jiri.resolve
package_test_data = [
('@Subdir prebuilt/tools/buildbucket\n' +
package[:package.index('${platform}')] +
'${platform=linux-amd64,mac-amd64} git_revision:aa2dae..')
for package in packages
if '${platform}' in package
]
test_data = '\n'.join(package_test_data)
# Update the lockfiles.
for lock_entry in lockfiles:
fields = lock_entry.split('=')
manifest = fields[0]
lock = fields[1]
resolve_output = api.jiri.resolve(
local_manifest=True,
output=lock,
manifests=[manifest],
step_test_data=test_data).stdout
for p in packages:
if '${platform}' in p:
platform_pkgs = _get_platform_specific_packages(p, resolve_output)
exact_packages = exact_packages.union(platform_pkgs)
else:
exact_packages.add(p)
exact_packages = sorted(exact_packages)
packages_with_urls = _append_urls(exact_packages, old_version, version)
multiply = ''
if test_multipliers:
multiply = '\nMULTIPLY: `%s`\n' % api.json.dumps(
test_multipliers, indent=2)
message = COMMIT_MESSAGE.format(
roller=api.buildbucket.builder_name.replace('-roller', ''),
packages='\n'.join(packages_with_urls),
old_version=old_version,
version=version,
builder=api.buildbucket.builder_name,
build_id=api.buildbucket_util.id,
multiply=multiply,
)
# Land the changes.
rolled = api.auto_roller.attempt_roll(
gerrit_project=project,
repo_dir=project_dir,
commit_message=message,
dry_run=dry_run,
)
# If roll succeeded, upload any debug symbols that were rolled.
# TODO(fxb/37432): Upload debug symbols with artifactory.
if rolled and debug_symbol_gcs_buckets:
with api.step.nest('fetch and upload debug symbols'):
fetch_and_upload_debug_symbols(
api=api,
project=project,
import_in=import_in,
remote=remote,
project_dir=project_dir,
packages=packages,
debug_symbol_attribute=debug_symbol_attribute,
debug_symbol_gcs_buckets=debug_symbol_gcs_buckets,
)
def GenTests(api):
default_packages = ['pkgA', 'pkgB', 'pkgC']
debug_symbol_packages = ['pkgX/debug', 'pkgY/debug']
platform_packages = ['pkgM/${platform}', 'pkgN/${platform}']
default_lockfiles = ['integration/flower=integration/jiri.lock']
default_properties = api.properties(
project='integration',
manifest='minimal',
remote='https://fuchsia.googlesource.com',
import_in='chromium/chromium',
packages=default_packages,
lockfiles=default_lockfiles,
owners=['nobody@google.com', 'noreply@google.com'],
)
debug_symbols_properties = api.properties(
project='integration',
manifest='minimal',
remote='https://fuchsia.googlesource.com',
import_in='chromium/chromium',
packages=default_packages + debug_symbol_packages,
debug_symbol_gcs_buckets=('foo-bucket', 'bar-bucket'),
lockfiles=default_lockfiles,
)
platform_pkg_properties = api.properties(
project='integration',
manifest='minimal',
remote='https://fuchsia.googlesource.com',
import_in='fuchsia/prebuilts',
packages=platform_packages,
lockfiles=default_lockfiles,
tag='git_revision',
)
def cipd_describe(pkg, version, tags, second=False):
suffix = ' (2)' if second else ''
return api.step_data(
'find common tag.cipd describe %s%s' % (pkg, suffix),
api.cipd.example_describe(
package_name=pkg, version=version, test_data_tags=tags))
def cipd_search(pkg, tag, instances=None):
return api.step_data(
'find common tag.cipd search %s %s' % (pkg, tag),
api.cipd.example_search(package_name=pkg, instances=instances),
)
def cipd_search_and_describe(pkg, version, tag):
return (cipd_search(pkg, tag, instances=[version]) +
cipd_describe(pkg, version, [tag], second=True))
# yapf: disable
yield (
api.status_check.test('default_with_multipliers')
+ default_properties
+ api.properties(test_multipliers=[
{'name': 'test1', 'total_runs': 5},
])
+ cipd_describe('pkgA', version='A2', tags=['version:2'])
+ cipd_describe('pkgB', version='B2', tags=['version:2'])
+ cipd_describe('pkgC', version='C2', tags=['version:2', 'version:1'])
+ api.auto_roller.success_step_data()
+ api.buildbucket.ci_build(builder='chromium-roller')
)
yield (
api.status_check.test('last_package_out_of_date')
+ default_properties
# These two packages share a version tag; all good so far.
+ cipd_describe('pkgA', version='102', tags=['version:2'])
+ cipd_describe('pkgB', version='202', tags=['version:2'])
# But pkgC's latest instance doesn't share a tag with the other two
# packages' latest instances.
+ cipd_describe('pkgC', version='301', tags=['version:1'])
# So we look search for versions of the webrunner package that *do* share
# a tag with the other two package's latest instances (but there are
# none).
+ cipd_search('pkgC', tag='version:2', instances=[])
# So we'll go back and see if the previous packages have instances that
# correspond to the pkgC's package's latest version.
+ cipd_search_and_describe('pkgA', tag='version:1', version='101')
+ cipd_search_and_describe('pkgB', tag='version:1', version='201')
# We succeed in finding such instances, so there should be a roll.
+ api.auto_roller.success_step_data()
+ api.buildbucket.ci_build(builder='tools-roller')
)
yield (
api.status_check.test('first_package_out_of_date')
+ default_properties
+ cipd_describe('pkgA', version='A1', tags=['version:1'])
# No shared tag with previous package.
# So we look search for versions of this package that *do* share a tag
# with the first package's latest instance, and we find one.
+ cipd_describe('pkgB', version='B2', tags=['version:2'])
+ cipd_search_and_describe('pkgB', tag='version:1', version='B1')
# No shared tag with previous chosen packages.
# So we look search for versions of the webrunner package that *do* share
# a tag with the other two package's latest instances, and find one.
+ cipd_describe('pkgC', version='C2', tags=['version:2'])
+ cipd_search_and_describe('pkgC', tag='version:1', version='C1')
+ api.auto_roller.success_step_data()
+ api.buildbucket.ci_build(builder='tools-roller')
)
yield (
api.status_check.test('inconsistent_versions', status='failure')
+ default_properties
# These two packages share a version tag; all good so far.
+ cipd_describe('pkgA', version='A2', tags=['version:2'])
+ cipd_describe('pkgB', version='B2', tags=['version:2'])
# But pkgC's latest instance doesn't share a tag with the other two
# packages' latest instances.
+ cipd_describe('pkgC', version='C1', tags=['version:1'])
# So we look search for versions of the webrunner package that *do* share
# a tag with the other two package's latest instances (but there are
# none).
+ cipd_search('pkgC', tag='version:2', instances=[])
# So we'll go back and see if the previous packages have instances that
# correspond to the pkgC's latest version. But this package
# doesn't have an instance that matches that version, which should
# trigger a failure.
+ cipd_search('pkgA', tag='version:1', instances=[])
)
yield (
api.status_check.test('noop')
+ default_properties
+ cipd_describe('pkgA', version='A1', tags=['version:1'])
+ cipd_describe('pkgB', version='B1', tags=['version:1'])
+ cipd_describe('pkgC', version='C1', tags=['version:1'])
+ api.step_data('jiri edit', api.json.output({'packages': []}))
)
yield (
api.status_check.test('default_with_platform')
+ platform_pkg_properties
+ cipd_describe('pkgM/${platform}', version='M1', tags=['git_revision:a'])
+ cipd_describe('pkgN/${platform}', version='N1', tags=['git_revision:a'])
+ api.auto_roller.success_step_data()
+ api.buildbucket.ci_build(builder='tools-roller')
)
yield (
api.status_check.test('default_platform_not_resolved')
+ platform_pkg_properties
+ cipd_describe('pkgM/${platform}', version='M1', tags=['git_revision:a'])
+ cipd_describe('pkgN/${platform}', version='N1', tags=['git_revision:a'])
+ api.step_data('jiri resolve', api.raw_io.stream_output(''))
+ api.auto_roller.success_step_data()
+ api.buildbucket.ci_build(builder='tools-roller')
)
def fetch_debug_symbols(pkg, attributes=None):
test_output = {'path': pkg}
if attributes:
test_output['attributes'] = attributes
return api.jiri.read_manifest_element(
api,
'chromium/chromium',
'package',
pkg,
test_output=test_output,
nesting='fetch and upload debug symbols',
)
yield (
api.status_check.test('with_debug_symbols')
+ debug_symbols_properties
+ cipd_describe('pkgA', version='A2', tags=['version:2'])
+ cipd_describe('pkgB', version='B2', tags=['version:2'])
+ cipd_describe('pkgC', version='C2', tags=['version:2'])
+ cipd_describe('pkgX/debug', version='X2', tags=['version:2'])
+ cipd_describe('pkgY/debug', version='Y2', tags=['version:2'])
+ fetch_debug_symbols(
'pkgX/debug', attributes='debug-symbols,debug-symbols-amd64')
+ fetch_debug_symbols(
'pkgY/debug', attributes='debug-symbols,debug-symbols-amd64')
+ fetch_debug_symbols('pkgA')
+ fetch_debug_symbols('pkgB')
+ fetch_debug_symbols('pkgC')
+ api.auto_roller.success_step_data()
+ api.buildbucket.ci_build(builder='chromium-roller')
)
# yapf: enable