blob: ff663869dab10a7ceecdefa2aedd1b27c8ab3e4a [file] [log] [blame]
# 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()
)