| # 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. |
| """Utility functions for uploading a cipd package.""" |
| |
| from __future__ import annotations |
| |
| import collections |
| import dataclasses |
| import json |
| import re |
| from typing import Sequence, TYPE_CHECKING |
| |
| from PB.recipe_modules.fuchsia.cipd_util.upload_manifest import ( |
| CIPDUploadManifest, |
| ) |
| |
| from recipe_engine import recipe_api |
| |
| if TYPE_CHECKING: # pragma: no cover |
| from recipe_engine import config_types |
| from RECIPE_MODULES.pigweed.checkout import api as checkout_api |
| |
| |
| @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, name: str | None = None): |
| rolls = sorted(self.rolls) |
| |
| if not name: |
| name = ", ".join(x.package_name for x in rolls) |
| |
| result = [] |
| result.append(f'roll: {name}') |
| |
| 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()) |
| result.append('') |
| |
| return '\n'.join(result) |
| |
| def __bool__(self): |
| return bool(self.rolls) |
| |
| |
| class CipdRollApi(recipe_api.RecipeApi): |
| """Utility functions for uploading a cipd package.""" |
| |
| Roll = Roll |
| Commit = Commit |
| |
| def is_platform(self, part): |
| """Return true for platform-style strings. |
| |
| Example matches: "linux-amd64", "${platform}", "${os}-amd64", "cp38". |
| 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(self, 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 self.m.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 = self.m.cipd.describe( |
| package_path, tag_candidate |
| ) |
| except self.m.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() |
| |
| def process_package(self, checkout_root, pkg): |
| json_path = checkout_root.joinpath(*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 self.is_platform(part) |
| ][-1] |
| |
| basename = self.m.path.basename(json_path) |
| cipd_json = self.m.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 self.m.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 self.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 = self.m.cipd.describe(package_path, pkg.ref) |
| |
| except self.m.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 = self.find_shared_tags(package_tags, pkg.tag) |
| |
| with self.m.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 self.m.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 self.m.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 self.m.step.nest('check number of instances'): |
| for package_path in package_paths: |
| self.m.cipd.describe(package_path, new_version) |
| |
| with self.m.step.nest('new_version') as presentation: |
| presentation.step_summary_text = new_version |
| |
| if old_version in tags: |
| with self.m.step.nest('already up-to-date') as presentation: |
| presentation.step_summary_text = ( |
| 'current version {} in common tags' |
| ).format(old_version) |
| return None |
| |
| else: |
| self.m.file.write_text( |
| f'write {basename}', |
| json_path, |
| json.dumps(cipd_json, indent=2, separators=(',', ': ')) + '\n', |
| ) |
| return Roll( |
| package_name=pkg.name, |
| old_version=old_version, |
| new_version=new_version, |
| ) |