| # Copyright 2020 The Pigweed Authors |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| # use this file except in compliance with the License. You may obtain a copy of |
| # the License at |
| # |
| # https://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| # License for the specific language governing permissions and limitations under |
| # the License. |
| """Update a CIPD package to the latest version.""" |
| |
| import collections |
| import dataclasses |
| import re |
| from typing import List |
| |
| from PB.recipe_engine import result as result_pb2 |
| from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2 |
| from PB.recipes.pigweed.cipd_roller import InputProperties, Package |
| |
| DEPS = [ |
| 'fuchsia/auto_roller', |
| 'pigweed/checkout', |
| 'recipe_engine/cipd', |
| 'recipe_engine/file', |
| 'recipe_engine/json', |
| 'recipe_engine/path', |
| 'recipe_engine/properties', |
| 'recipe_engine/step', |
| ] |
| |
| PROPERTIES = InputProperties |
| |
| |
| def _is_platform(part): |
| """Return true for platform-style strings. |
| |
| Example matches: "linux-amd64", "${platform}", "${os}-amd64", "cp38", etc. |
| Example non-matches: "clang", "amd64", "linux". |
| """ |
| if '{' in part: |
| return True |
| |
| # Match Python version indicators. |
| if re.match(r'cp\d+', part): |
| return True |
| |
| try: |
| os, arch = part.split('-') |
| return os in ('linux', 'mac', 'windows') |
| except ValueError: |
| return False |
| |
| |
| def find_shared_tags(api, package_tags, tag): |
| """Attempts to find a tag shared by all packages. |
| |
| This function can be used if the intersection of the sets of tags |
| associated with different-platform packages with the same 'ref' is empty. |
| It finds a tag shared by all packages, with as many of them as possible |
| matching 'ref'. |
| """ |
| # Find the most common tags. We use the sorted dict keys for determinism. |
| package_paths = sorted(package_tags.keys()) |
| counter = collections.Counter() |
| for path in package_paths: |
| counter.update(package_tags[path]) |
| most_common_tags = counter.most_common() |
| |
| with api.step.nest("find shared tag"): |
| for tag_candidate, _ in most_common_tags: |
| # There is at least one package for which the version with the |
| # specified 'ref' does not have this tag. See if there exists a |
| # version of this package that *does* have this tag. If so, use |
| # that version. |
| updated_tags = dict() |
| for package_path in package_paths: |
| if tag_candidate in package_tags[package_path]: |
| # For this package we already have a version with this tag, |
| # nothing to do. |
| continue |
| try: |
| package_data = api.cipd.describe( |
| package_path, tag_candidate |
| ) |
| except api.step.StepFailure: |
| # No luck: there exists no version with this tag. |
| break |
| updated_tags[package_path] = set( |
| x.tag |
| for x in package_data.tags |
| if x.tag.startswith(tag + ':') |
| ) |
| else: |
| # We found a version of each package with the tag_candidate. |
| merged_tags = dict() |
| merged_tags.update(package_tags) |
| merged_tags.update(updated_tags) |
| tags = set.intersection(*merged_tags.values()) |
| # Should always succeed. |
| assert len(tags) > 0 |
| # Update package_tags to be consistent with the returned tags. |
| package_tags.update(updated_tags) |
| return tags |
| |
| # We failed to find any tag that meets our criteria. |
| return set() |
| |
| |
| _FOOTER = """ |
| CQ-Do-Not-Cancel-Tryjobs: true |
| Build-Errors: continue |
| """.strip() |
| |
| |
| @dataclasses.dataclass(order=True) |
| class Roll: |
| package_name: str |
| old_version: str |
| new_version: str |
| |
| def message(self): |
| return f'From: {self.old_version}\nTo: {self.new_version}' |
| |
| |
| @dataclasses.dataclass |
| class Commit: |
| rolls: List[Roll] = dataclasses.field(default_factory=list) |
| |
| def message(self): |
| rolls = sorted(self.rolls) |
| |
| result = [] |
| result.append(f'roll: {", ".join(x.package_name for x in rolls)}') |
| |
| if len(rolls) == 1: |
| result.append('') |
| result.append(rolls[0].message()) |
| |
| else: |
| for roll in rolls: |
| result.append('') |
| result.append(roll.package_name) |
| result.append(roll.message()) |
| |
| return '\n'.join(result) |
| |
| def __bool__(self): |
| return bool(self.rolls) |
| |
| |
| def process_package(api, checkout, pkg): |
| json_path = checkout.root.join(*re.split(r'[\\/]+', pkg.json_path)) |
| |
| if not pkg.name: |
| # Turn foo/bar/baz/${platform} and foo/bar/baz/${os=mac}-${arch} |
| # into 'baz'. |
| pkg.name = [ |
| part for part in pkg.spec.split('/') if not _is_platform(part) |
| ][-1] |
| |
| basename = api.path.basename(json_path) |
| cipd_json = api.file.read_json(f'read {basename}', json_path) |
| packages = cipd_json |
| if isinstance(cipd_json, dict): |
| packages = cipd_json['packages'] |
| old_version = None |
| package = None |
| for package in packages: |
| if package['path'] == pkg.spec: |
| old_version = package['tags'][0] |
| break |
| else: |
| raise api.step.StepFailure( |
| f"couldn't find package {pkg.spec} in {json_path}" |
| ) |
| |
| assert package.get('platforms'), 'platforms empty in json' |
| platforms = package.get('platforms') |
| base, name = pkg.spec.rstrip('/').rsplit('/', 1) |
| if _is_platform(name): |
| package_paths = [f'{base}/{x}' for x in platforms] |
| else: |
| package_paths = [pkg.spec] |
| |
| package_tags = {} |
| tags = None |
| for package_path in package_paths: |
| try: |
| package_data = api.cipd.describe(package_path, pkg.ref) |
| |
| except api.step.StepFailure: |
| # If we got here this package doesn't have the correct ref. This |
| # is likely because it's a new platform for an existing package. |
| # In that case ignore this platform when checking that refs |
| # agree on package versions. We still need at least one platform |
| # to have the ref or the checks below will fail. |
| pass |
| |
| else: |
| package_tags[package_path] = set( |
| x.tag |
| for x in package_data.tags |
| if x.tag.startswith(pkg.tag + ':') |
| ) |
| if tags is None: |
| tags = set(package_tags[package_path]) |
| else: |
| tags.intersection_update(package_tags[package_path]) |
| |
| if not tags and pkg.allow_mismatched_refs: |
| # The package with the requested ref has non-overlapping tag values |
| # for different platforms. Try relaxing the requirement that all |
| # packages come from the same ref, and see if this allows us to find |
| # a set with shared tag values. |
| tags = find_shared_tags(api, package_tags, pkg.tag) |
| |
| with api.step.nest('common tags') as presentation: |
| presentation.step_summary_text = '\n'.join(sorted(tags)) |
| |
| if not tags: |
| err_lines = [f'no common tags across "{pkg.ref}" refs of packages'] |
| for package_path, package_tags in sorted(package_tags.items()): |
| err_lines.append('') |
| err_lines.append(package_path) |
| for tag in package_tags: |
| err_lines.append(tag) |
| |
| raise api.step.StepFailure('<br>'.join(err_lines)) |
| |
| # Deterministically pick one of the common tags. |
| new_version = sorted(tags)[0] |
| package['tags'] = [new_version] |
| |
| version_part = new_version.split(':', 1)[1] |
| match = re.search(r'(?:\d|\b)(rc|pre|beta|alpha)(?:\d|\b)', version_part) |
| if match: |
| raise api.step.StepFailure( |
| f'found pre-release indicator {match.group(1)!r} in ' |
| f'{version_part!r}' |
| ) |
| |
| # Verify there's only one instance of each platform package with this |
| # tag. |
| with api.step.nest('check number of instances'): |
| for package_path in package_paths: |
| api.cipd.describe(package_path, new_version) |
| |
| with api.step.nest('new_version') as presentation: |
| presentation.step_summary_text = new_version |
| |
| if old_version in tags: |
| with api.step.nest('already up-to-date') as presentation: |
| presentation.step_summary_text = ( |
| 'current version {} in common tags' |
| ).format(old_version) |
| return None |
| |
| else: |
| api.file.write_text( |
| f'write {basename}', |
| json_path, |
| api.json.dumps(cipd_json, indent=2, separators=(',', ': ')) + '\n', |
| ) |
| return Roll( |
| package_name=pkg.name, |
| old_version=old_version, |
| new_version=new_version, |
| ) |
| |
| |
| def RunSteps(api, props): |
| props.checkout_options.use_trigger = False |
| checkout = api.checkout(props.checkout_options) |
| |
| commit = Commit() |
| |
| for pkg in props.packages: |
| with api.step.nest(pkg.spec): |
| roll = process_package(api, checkout, pkg) |
| if roll: |
| commit.rolls.append(roll) |
| |
| if not commit: |
| return result_pb2.RawResult( |
| summary_markdown='nothing to roll', status=common_pb2.SUCCESS, |
| ) |
| |
| change = api.auto_roller.attempt_roll( |
| props.auto_roller_options, |
| repo_dir=checkout.root, |
| commit_message=commit.message(), |
| ) |
| |
| return api.auto_roller.raw_result(change) |
| |
| |
| def GenTests(api): # pylint: disable=invalid-name |
| """Create tests.""" |
| |
| def package(cipd_path, old_version, platforms=None): |
| result = { |
| 'path': cipd_path, |
| 'tags': [old_version], |
| '_comment': 'comments should be preserved', |
| } |
| |
| if platforms is not None: |
| result['platforms'] = list(platforms) |
| |
| return result |
| |
| def describe(spec, package_path, tags, **kwargs): |
| return api.step_data( |
| f'{spec}.cipd describe {package_path}', |
| api.cipd.example_describe( |
| package_path, |
| test_data_tags=[f'{name}:{value}' for name, value in tags], |
| ), |
| **kwargs, |
| ) |
| |
| def no_ref(spec, package_paths): |
| res = None |
| for package_path in package_paths: |
| step = describe(spec, package_path, (), retcode=1) |
| res = res + step if res else step |
| return res |
| |
| def no_common_tags(spec, package_paths, tagname='git_revision'): |
| res = None |
| for i, package_path in enumerate(package_paths): |
| step = describe(spec, package_path, ((tagname, i),)) |
| res = res + step if res else step |
| return res |
| |
| def multiple_common_tags( |
| spec, package_paths, tagname='git_revision', versions=(1, 2, 3), |
| ): |
| res = None |
| for package_path in package_paths: |
| step = describe( |
| spec, package_path, tuple((tagname, x) for x in versions), |
| ) |
| res = res + step if res else step |
| return res |
| |
| def relaxing_works(spec, package_paths, tagname='git_revision'): |
| return api.step_data( |
| f'{spec}.find shared tag.cipd describe {package_paths[1]}', |
| api.cipd.example_describe( |
| package_paths[1], test_data_tags=[f'{tagname}:{0}'], |
| ), |
| ) |
| |
| def relaxing_does_not_work(spec, package_paths, tagname='git_revision'): |
| # No version of package_paths[1] with hoped-for tag. |
| return api.step_data( |
| f'{spec}.find shared tag.cipd describe {package_paths[0]}', |
| api.cipd.example_describe(package_paths[0]), |
| retcode=1, |
| ) + api.step_data( |
| f'{spec}.find shared tag.cipd describe {package_paths[1]}', |
| api.cipd.example_describe(package_paths[1]), |
| retcode=1, |
| ) |
| |
| def read_file_step_data(spec, package_json_name, *packages): |
| return api.step_data( |
| f'{spec}.read {package_json_name}', |
| api.file.read_json({'packages': packages}), |
| ) |
| |
| prefix = 'pigweed/host_tools/cp38' |
| spec = f'{prefix}/${{platform}}' |
| paths = ( |
| f'{prefix}/linux-amd64', |
| f'{prefix}/windows-amd64', |
| ) |
| |
| def package_props(**kwargs): |
| kwargs.setdefault('spec', spec) |
| kwargs.setdefault('ref', 'latest') |
| kwargs.setdefault('tag', 'git_revision') |
| kwargs.setdefault( |
| 'json_path', 'pw_env_setup/py/pw_env_setup/cipd_setup/pigweed.json', |
| ) |
| return Package(**kwargs) |
| |
| def properties(packages, dry_run=True, **kwargs): |
| props = InputProperties(**kwargs) |
| props.packages.extend(packages) |
| props.checkout_options.CopyFrom(api.checkout.git_options()) |
| props.auto_roller_options.CopyFrom( |
| api.auto_roller.Options( |
| dry_run=dry_run, remote=props.checkout_options.remote, |
| ) |
| ) |
| return api.properties(props) |
| |
| yield ( |
| api.test('success') |
| + properties([package_props()]) |
| + api.checkout.ci_test_data() |
| + read_file_step_data( |
| spec, |
| 'pigweed.json', |
| package( |
| spec, |
| 'git_revision:123', |
| platforms=['linux-amd64', 'windows-amd64'], |
| ), |
| ) |
| + api.auto_roller.dry_run_success() |
| ) |
| |
| yield ( |
| api.test('rc', status='FAILURE') |
| + properties([package_props(tag='version')]) |
| + api.checkout.ci_test_data() |
| + read_file_step_data( |
| spec, |
| 'pigweed.json', |
| package( |
| spec, 'version:123', platforms=['linux-amd64', 'windows-amd64'], |
| ), |
| ) |
| + describe(spec, paths[0], (('version', '234-rc3'),)) |
| + describe(spec, paths[1], (('version', '234-rc3'),)) |
| ) |
| |
| yield ( |
| api.test('multiple') |
| + properties( |
| [ |
| package_props(spec='foo/${platform}'), |
| package_props(spec='bar/${platform}'), |
| ] |
| ) |
| + api.checkout.ci_test_data() |
| + read_file_step_data( |
| 'foo/${platform}', |
| 'pigweed.json', |
| package( |
| 'foo/${platform}', |
| 'git_revision:foo123', |
| platforms=['linux-amd64', 'windows-amd64'], |
| ), |
| package( |
| 'bar/${platform}', |
| 'git_revision:bar123', |
| platforms=['linux-amd64', 'windows-amd64'], |
| ), |
| ) |
| + read_file_step_data( |
| 'bar/${platform}', |
| 'pigweed.json', |
| package( |
| 'foo/${platform}', |
| 'git_revision:397a2597cdc237f3026e6143b683be4b9ab60540', |
| platforms=['linux-amd64', 'windows-amd64'], |
| ), |
| package( |
| 'bar/${platform}', |
| 'git_revision:bar123', |
| platforms=['linux-amd64', 'windows-amd64'], |
| ), |
| ) |
| + api.auto_roller.dry_run_success() |
| ) |
| |
| bad_spec = f'bad-{spec}' |
| yield ( |
| api.test('bad_package_spec', status='FAILURE') |
| + properties([package_props(spec=bad_spec)]) |
| + api.checkout.ci_test_data() |
| + read_file_step_data( |
| bad_spec, 'pigweed.json', package(spec, 'git_revision:123') |
| ) |
| ) |
| |
| yield ( |
| api.test('no_common_tags', status='FAILURE') |
| + properties([package_props()]) |
| + api.checkout.ci_test_data() |
| + read_file_step_data( |
| spec, |
| 'pigweed.json', |
| package( |
| spec, |
| 'git_revision:123', |
| platforms=('linux-amd64', 'windows-amd64'), |
| ), |
| ) |
| + no_common_tags(spec, paths) |
| ) |
| |
| yield ( |
| api.test('no_common_tags_but_relaxing_ref_mismatch_helps') |
| + properties([package_props(allow_mismatched_refs=True)]) |
| + api.checkout.ci_test_data() |
| + read_file_step_data( |
| spec, |
| 'pigweed.json', |
| package( |
| spec, |
| 'git_revision:123', |
| platforms=('linux-amd64', 'windows-amd64'), |
| ), |
| ) |
| # Package 0 exists at git_revision:0, package 1 at git_revision:1 |
| + no_common_tags(spec, paths) |
| # However, package 1 also exists at git_revision:0, so that revision is |
| # good to use. |
| + relaxing_works(spec, paths) |
| + api.auto_roller.dry_run_success() |
| ) |
| |
| yield ( |
| api.test( |
| 'no_common_tags_and_relaxing_ref_mismatch_does_not_help', |
| status='FAILURE', |
| ) |
| + properties([package_props(allow_mismatched_refs=True)]) |
| + api.checkout.ci_test_data() |
| + read_file_step_data( |
| spec, |
| 'pigweed.json', |
| package( |
| spec, |
| 'git_revision:123', |
| platforms=('linux-amd64', 'windows-amd64'), |
| ), |
| ) |
| # Package 0 exists at git_revision:0, package 1 at git_revision:1 |
| + no_common_tags(spec, paths) |
| # However, package 1 does not exist at git_revision:0. |
| + relaxing_does_not_work(spec, paths) |
| ) |
| |
| yield ( |
| api.test('multiple_common_tags') |
| + properties([package_props()]) |
| + api.checkout.ci_test_data() |
| + multiple_common_tags(spec, paths) |
| + read_file_step_data( |
| spec, |
| 'pigweed.json', |
| package( |
| spec, |
| 'git_revision:2', |
| platforms=('linux-amd64', 'windows-amd64'), |
| ), |
| ) |
| ) |
| |
| yield ( |
| api.test('missing_tag') |
| + properties([package_props()]) |
| + api.checkout.ci_test_data() |
| + multiple_common_tags(spec, paths) |
| + no_ref(spec, [f'{prefix}/fake-amd64']) |
| + read_file_step_data( |
| spec, |
| 'pigweed.json', |
| package( |
| spec, |
| 'git_revision:2', |
| platforms=('linux-amd64', 'windows-amd64', 'fake-amd64'), |
| ), |
| ) |
| ) |
| |
| no_curly_spec = 'pigweed/host_tools/linux-amd64' |
| yield ( |
| api.test('no_curlies_in_spec') |
| + properties([package_props(spec=no_curly_spec)]) |
| + api.checkout.ci_test_data() |
| + read_file_step_data( |
| no_curly_spec, |
| 'pigweed.json', |
| package( |
| no_curly_spec, |
| 'git_revision:123', |
| platforms=('linux-amd64', 'windows-amd64'), |
| ), |
| ) |
| + api.auto_roller.dry_run_success() |
| ) |
| |
| yield ( |
| api.test('platform-independent') |
| + properties([package_props(spec='foo/bar/baz')]) |
| + api.checkout.ci_test_data() |
| + read_file_step_data( |
| 'foo/bar/baz', |
| 'pigweed.json', |
| package( |
| 'foo/bar/baz', |
| 'git_revision:123', |
| platforms=['linux-amd64', 'mac-amd64'], |
| ), |
| ) |
| + api.auto_roller.dry_run_success() |
| ) |