blob: 5266cb56f1c3e263db3453984ea6cf1c81aef559 [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 re
from PB.recipes.pigweed.cipd_roller import InputProperties
DEPS = [
'fuchsia/auto_roller',
'fuchsia/buildbucket_util',
'fuchsia/status_check',
'pigweed/checkout',
'recipe_engine/buildbucket',
'recipe_engine/cipd',
'recipe_engine/file',
'recipe_engine/json',
'recipe_engine/path',
'recipe_engine/properties',
'recipe_engine/step',
]
PROPERTIES = InputProperties
COMMIT_MESSAGE = """
roll: {package_name}
From: {old_version}
To: {new_version}
CQ-Do-Not-Cancel-Tryjobs: true
Build-Errors: continue
""".strip()
def _is_platform(part):
"""Return true for platform-style strings.
Example matches: "linux-amd64", "${platform}", "${os}-amd64", etc.
Example non-matches: "clang", "amd64", "linux".
"""
if '{' in 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()
def RunSteps(api, props):
cipd_json_path = props.cipd_json_path
package_name = props.package_name
package_spec = props.package_spec
ref = props.ref
tag = props.tag
allow_mismatched_refs = props.allow_mismatched_refs
props.checkout_options.use_trigger = False
checkout = api.checkout(props.checkout_options)
cipd_json_path = checkout.root.join(*re.split(r'[\\/]+', cipd_json_path))
if not package_name:
# Turn foo/bar/baz/${platform} and foo/bar/baz/${os=mac}-${arch} into
# 'baz'.
package_name = [
part for part in package_spec.split('/') if not _is_platform(part)
][-1]
basename = api.path.basename(cipd_json_path)
cipd_json = api.file.read_json('read {}'.format(basename), cipd_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'] == package_spec:
old_version = package['tags'][0]
break
else:
raise api.step.StepFailure(
"couldn't find package {} in {}".format(
package_spec, cipd_json_path
)
)
assert package.get('platforms'), 'platforms empty in json'
platforms = package.get('platforms')
base, name = package_spec.rstrip('/').rsplit('/', 1)
if _is_platform(name):
package_paths = ['{}/{}'.format(base, x) for x in platforms]
else:
package_paths = [package_spec]
package_tags = {}
tags = None
for package_path in package_paths:
try:
package_data = api.cipd.describe(package_path, 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(tag + ':')
)
if tags is None:
tags = set(package_tags[package_path])
else:
tags.intersection_update(package_tags[package_path])
if not tags and 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, tag)
with api.step.nest('common tags') as presentation:
presentation.step_summary_text = '\n'.join(sorted(tags))
if not tags:
err_lines = ['no common tags across "{}" refs of packages'.format(ref)]
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]
# 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
api.file.write_text(
'write {}'.format(basename),
cipd_json_path,
api.json.dumps(cipd_json, indent=2, separators=(',', ': ')) + '\n',
)
change = api.auto_roller.attempt_roll(
props.auto_roller_options,
repo_dir=api.path.dirname(cipd_json_path),
commit_message=COMMIT_MESSAGE.format(
package_name=package_name,
package_spec=package_spec,
old_version=old_version,
new_version=new_version,
builder=api.buildbucket.builder_name,
build_id=api.buildbucket_util.id,
),
)
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(package_path, *tags, **kwargs):
return api.step_data(
'cipd describe {}'.format(package_path),
api.cipd.example_describe(
package_path,
test_data_tags=[
'{}:{}'.format(name, value) for name, value in tags
],
),
**kwargs,
)
def no_ref(package_paths):
res = None
for package_path in package_paths:
step = describe(package_path, retcode=1)
res = res + step if res else step
return res
def no_common_tags(package_paths, tagname='git_revision'):
res = None
for i, package_path in enumerate(package_paths):
step = describe(package_path, (tagname, i))
res = res + step if res else step
return res
def multiple_common_tags(package_paths, tagname='git_revision'):
res = None
for package_path in package_paths:
step = describe(
package_path, (tagname, 1), (tagname, 2), (tagname, 3)
)
res = res + step if res else step
return res
def relaxing_works(package_paths, tagname='git_revision'):
return api.step_data(
'find shared tag.cipd describe {}'.format(package_paths[1]),
api.cipd.example_describe(
package_paths[1], test_data_tags=['{}:{}'.format(tagname, 0)],
),
)
def relaxing_does_not_work(package_paths, tagname='git_revision'):
# No version of package_paths[1] with hoped-for tag.
return api.step_data(
'find shared tag.cipd describe {}'.format(package_paths[0]),
api.cipd.example_describe(package_paths[0]),
retcode=1,
) + api.step_data(
'find shared tag.cipd describe {}'.format(package_paths[1]),
api.cipd.example_describe(package_paths[1]),
retcode=1,
)
def read_file_step_data(package_json_name, *packages):
return api.step_data(
'read {}'.format(package_json_name),
api.file.read_json({'packages': packages}),
)
spec = 'pigweed/host_tools/${platform}'
paths = (
'pigweed/host_tools/linux-amd64',
'pigweed/host_tools/windows-amd64',
)
def properties(package_spec=spec, dry_run=True, **kwargs):
new_kwargs = {
'package_spec': package_spec,
'ref': 'latest',
'tag': 'git_revision',
'cipd_json_path': 'pw_env_setup/py/pw_env_setup/cipd_setup/pigweed.json',
}
new_kwargs.update(api.checkout.git_properties())
new_kwargs.setdefault(
'auto_roller_options',
{'dry_run': dry_run, 'remote': api.checkout.pigweed_repo,},
)
new_kwargs.update(kwargs)
return api.properties(**new_kwargs)
yield (
api.status_check.test('success')
+ properties()
+ api.checkout.ci_test_data()
+ read_file_step_data(
'pigweed.json',
package(
spec,
'git_revision:123',
platforms=['linux-amd64', 'windows-amd64'],
),
)
+ api.auto_roller.dry_run_success()
)
yield (
api.status_check.test('bad_package_spec', status='failure')
+ properties(package_spec='bad-{}'.format(spec))
+ api.checkout.ci_test_data()
+ read_file_step_data('pigweed.json', package(spec, 'git_revision:123'))
)
yield (
api.status_check.test('no_common_tags', status='failure')
+ properties()
+ api.checkout.ci_test_data()
+ read_file_step_data(
'pigweed.json',
package(
spec,
'git_revision:123',
platforms=('linux-amd64', 'windows-amd64'),
),
)
+ no_common_tags(paths)
)
yield (
api.status_check.test('no_common_tags_but_relaxing_ref_mismatch_helps')
+ properties(allow_mismatched_refs=True)
+ api.checkout.ci_test_data()
+ read_file_step_data(
'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(paths)
# However, package 1 also exists at git_revision:0, so that revision is
# good to use.
+ relaxing_works(paths)
+ api.auto_roller.dry_run_success()
)
yield (
api.status_check.test(
'no_common_tags_and_relaxing_ref_mismatch_does_not_help',
status='failure',
)
+ properties(allow_mismatched_refs=True)
+ api.checkout.ci_test_data()
+ read_file_step_data(
'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(paths)
# However, package 1 does not exist at git_revision:0.
+ relaxing_does_not_work(paths)
)
yield (
api.status_check.test('multiple_common_tags')
+ properties()
+ api.checkout.ci_test_data()
+ multiple_common_tags(paths)
+ read_file_step_data(
'pigweed.json',
package(
spec,
'git_revision:2',
platforms=('linux-amd64', 'windows-amd64'),
),
)
)
yield (
api.status_check.test('missing_tag')
+ properties()
+ api.checkout.ci_test_data()
+ multiple_common_tags(paths)
+ no_ref(['pigweed/host_tools/fake-amd64'])
+ read_file_step_data(
'pigweed.json',
package(
spec,
'git_revision:2',
platforms=('linux-amd64', 'windows-amd64', 'fake-amd64'),
),
)
)
no_curly_spec = 'pigweed/host_tools/linux-amd64'
yield (
api.status_check.test('no_curlies_in_spec')
+ properties(package_spec=no_curly_spec)
+ api.checkout.ci_test_data()
+ read_file_step_data(
'pigweed.json',
package(
no_curly_spec,
'git_revision:123',
platforms=('linux-amd64', 'windows-amd64'),
),
)
+ api.auto_roller.dry_run_success()
)
yield (
api.status_check.test('platform-independent')
+ properties(package_spec='foo/bar/baz')
+ api.checkout.ci_test_data()
+ read_file_step_data(
'pigweed.json',
package(
'foo/bar/baz',
'git_revision:123',
platforms=['linux-amd64', 'mac-amd64'],
),
)
+ api.auto_roller.dry_run_success()
)