blob: c2f4ee10b9d131d483ead1a53d64a9f21474c9a6 [file] [log] [blame]
# Copyright 2018 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import base64
import datetime
import re
import traceback
import attr
from google.protobuf import json_format as jsonpb
from recipe_engine import recipe_api
from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2
from PB.recipe_engine.recipes_cfg import (AutorollRecipeOptions, DepRepoSpecs,
RepoSpec)
from PB.recipe_engine import result as result_pb2
class RepoData(object):
_TIME_FORMAT = '%Y-%m-%dT%H:%M:%S'
def __init__(self, issue, issue_url, trivial, last_roll_ts_utc):
assert isinstance(issue, str)
assert isinstance(issue_url, str)
assert isinstance(trivial, bool)
assert isinstance(last_roll_ts_utc, datetime.datetime)
self.issue = issue
self.issue_url = issue_url
self.trivial = trivial
self.last_roll_ts_utc = last_roll_ts_utc
@classmethod
def from_json(cls, obj):
return cls(
obj['issue'],
obj['issue_url'],
obj['trivial'],
datetime.datetime.strptime(obj['last_roll_ts_utc'], cls._TIME_FORMAT),
)
def to_json(self):
return {
'issue': self.issue,
'issue_url': self.issue_url,
'trivial': self.trivial,
'last_roll_ts_utc': self.last_roll_ts_utc.strftime(self._TIME_FORMAT),
}
COMMIT_MESSAGE_HEADER = ("""
This is an automated CL created by the recipe roller. This CL rolls
recipe changes from upstream projects (%(roll_projects)s) into this repository.
The build that created this CL was
https://ci.chromium.org/b/%(build_id)s
""")
NON_TRIVIAL_MESSAGE = ("""
Please review the expectation changes, and LGTM+CQ.
""")
COMMIT_MESSAGE_INFO = ("""
Please check the following references for more information:
- autoroller, https://chromium.googlesource.com/infra/luci/recipes-py/+/main/doc/workflow.md#autoroller
- rollback, https://chromium.googlesource.com/infra/luci/recipes-py/+/main/doc/workflow.md#rollback
- cross-repo dependencies, https://chromium.googlesource.com/infra/luci/recipes-py/+/main/doc/cross_repo.md
Use https://goo.gl/noib3a to file a bug.
""")
COMMIT_MESSAGE_FOOTER = ("""
Recipe-Tryjob-Bypass-Reason: Autoroller
Ignore-Freeze: Autoroller
Bugdroid-Send-Email: False
""")
# These are different results of a roll attempt:
# - success means we have a working non-empty roll
# - empty means the repo is using latest revision of its dependencies
# - failure means there are roll candidates but none of them are suitable
# for an automated roll
# - skip means that the roll was skipped (not processed). This can happen if
# the repo has a 'disable_message' in its autoroll_recipe_options.
ROLL_SUCCESS, ROLL_EMPTY, ROLL_FAILURE, ROLL_SKIP = range(4)
@attr.s
class _Status(object):
code = attr.ib(type=int)
url = attr.ib(type=str, default=None)
_ROLL_STALE_THRESHOLD = datetime.timedelta(hours=2)
def _gs_path(project_url):
return 'repo_metadata/%s' % base64.urlsafe_b64encode(
project_url.encode()).decode()
def get_commit_message(roll_result, build_id):
"""Construct a roll commit message from 'recipes.py autoroll' result.
"""
picked = roll_result['picked_roll_details']
commit_infos = picked['commit_infos']
deps = picked['spec']['deps']
roll_projects = sorted(commit_infos.keys())
trivial = roll_result['trivial']
message = 'Roll recipe dependencies (%s).\n' % (
'trivial' if trivial else 'nontrivial')
message += COMMIT_MESSAGE_HEADER % dict(
roll_projects=', '.join(roll_projects), build_id=build_id)
if not trivial:
message += NON_TRIVIAL_MESSAGE
blame = []
for project, commits in commit_infos.items():
blame.append('')
blame.append('%s:' % project)
remote = deps[project]['url']
if len(commits) == 1:
blame.append('%s/+/%s' % (remote, commits[0]['revision']))
else:
blame.append('%s/+log/%s~..%s' %
(remote, commits[0]['revision'], commits[-1]['revision']))
for commit in commits:
blame.append(' %s (%s)' %
(commit['revision'][:7], commit['author_email']))
message_lines = commit['message_lines']
summary_line = ' %s' % message_lines[0] if message_lines else 'n/a'
max_line_length = 72
if len(summary_line) > max_line_length:
summary_line = summary_line[:max_line_length - 3].rstrip() + '...'
blame.append(summary_line)
message += ''.join(l + '\n' for l in blame)
message += COMMIT_MESSAGE_INFO
message += COMMIT_MESSAGE_FOOTER
return message
def get_summary_markdown(roll_results):
links = []
for result in roll_results:
if not result.url:
continue
try:
host = re.search(r'/([\w-]+)-review\.', result.url).group(1)
number = re.search(r'/(\d+)$', result.url).group(1)
links.append('[{}:{}]({})'.format(host, number, result.url))
except AttributeError: # pragma: no cover
pass
return result_pb2.RawResult(
summary_markdown='\n'.join('* {}'.format(x) for x in links),
status=common_pb2.SUCCESS,
)
class RecipeAutorollerApi(recipe_api.RecipeApi):
def roll_projects(self, projects, db_gcs_bucket):
"""Attempts to roll each project from the provided list.
If rolling any of the projects leads to failures, other
projects are not affected.
Args:
projects: list of tuples of
project_id (string): id as found in recipes.cfg.
project_url (string): Git repository URL of the project.
db_gcs_bucket (string): The GCS bucket used as a database for previous
roll attempts.
"""
recipes_dir = self.m.path.cache_dir.joinpath('builder', 'recipe_engine')
self.m.file.rmtree('ensure recipe_dir gone', recipes_dir)
self.m.file.ensure_directory('ensure builder cache dir exists',
self.m.path.cache_dir / 'builder')
with self.m.context(cwd=self.m.path.cache_dir / 'builder'):
# Git clone really wants to have cwd set to something other than None.
self.m.git('clone', '--depth', '1',
'https://chromium.googlesource.com/infra/luci/recipes-py',
recipes_dir, name='clone recipe engine')
futures = []
for project_id, project_url in projects:
future = self.m.futures.spawn(self._roll_project, project_id, project_url,
recipes_dir, db_gcs_bucket)
futures.append((project_id, future))
failed_rolls = []
for project_id, future in futures:
if future.exception() is not None:
failed_rolls.append(project_id)
if failed_rolls:
raise self.m.step.StepFailure(
'Rolls failed for the following projects: {}'.format(
', '.join(failed_rolls)))
results = [f.result() for _, f in futures]
result_codes = [x.code for x in results]
# Failures to roll are OK as long as at least one of the repos is moving
# forward. For example, with repos with following dependencies:
#
# A <- B
# A, B <- C
#
# New commit in A repo will need to get rolled into B first. However,
# it'd also appear as a candidate for C roll, leading to a failure there.
if ROLL_FAILURE in result_codes and ROLL_SUCCESS not in result_codes:
self.m.step.empty(
'roll result',
status=self.m.step.FAILURE,
step_text='manual intervention needed: automated roll attempt failed')
return get_summary_markdown(results)
def _prepare_checkout(self, project_id, project_url):
# Keep persistent checkout. Speeds up the roller for large repos
# like chromium/src.
workdir = self.m.path.cache_dir.joinpath('builder', 'recipe_autoroller',
project_id)
self.m.git.checkout(
project_url, dir_path=workdir, submodules=False, ref='main')
with self.m.context(cwd=workdir):
# On LUCI user.email is already configured to match that of task service
# account with which we'll be authenticating to Git/Gerrit.
# Set a nicer name than service account's long email.
self.m.git('config', 'user.name', 'recipe-roller')
# Clean up possibly left over roll branch. Ignore errors.
self.m.git('branch', '-D', 'roll', ok_ret='any')
# git cl upload cannot work with detached HEAD, it requires a branch.
with self.m.depot_tools.on_path():
self.m.git('new-branch', 'roll', '--upstream', 'origin/main')
return workdir
def _check_previous_roll(self, project_url, workdir, db_gcs_bucket):
# Check status of last known CL for this repo. Ensure there's always
# at most one roll CL in flight.
repo_data, cl_status = self._get_pending_cl_status(project_url, workdir,
db_gcs_bucket)
if repo_data:
last_roll_elapsed = self.m.time.utcnow() - repo_data.last_roll_ts_utc
# Allow trivial rolls in CQ to finish.
if repo_data.trivial and cl_status == 'commit':
if (last_roll_elapsed and
last_roll_elapsed > _ROLL_STALE_THRESHOLD):
self.m.step.empty(
'stale roll',
status=self.m.step.FAILURE,
step_text='manual intervention needed: automated roll attempt is '
'stale')
return ROLL_SUCCESS
# Allow non-trivial rolls to wait for review comments.
if not repo_data.trivial and cl_status != 'closed':
if (last_roll_elapsed and
last_roll_elapsed > _ROLL_STALE_THRESHOLD):
self.m.step.empty(
'stale roll',
status=self.m.step.FAILURE,
step_text='manual intervention needed: automated roll attempt is '
'stale')
return ROLL_SUCCESS
# TODO(phajdan.jr): detect staleness by creating CLs in a loop.
# It's possible that the roller keeps creating new CLs (especially
# trivial rolls), but they e.g. fail to land, causing staleness.
# We're about to upload a new CL, so make sure the old one is closed.
if cl_status != 'closed':
with self.m.context(cwd=workdir):
self.m.git_cl('set-close', ['--issue', repo_data.issue],
name='git cl set-close')
return None
def _get_disable_reason(self, recipes_cfg_path):
current_cfg = self.m.json.read(
'read recipes.cfg',
recipes_cfg_path, step_test_data=lambda: self.m.json.test_api.output({}))
return current_cfg.json.output.get(
'autoroll_recipe_options', {}
).get('disable_reason')
def _roll_project(self, project_id, *args, **kwargs):
with self.m.step.nest(str(project_id)) as presentation:
try:
return self._roll_project_impl(project_id, *args, **kwargs)
except Exception:
# TODO(crbug.com/1256194): Print the stack trace unconditionally, even
# in testing mode, once Py2 support is no longer required. Stack trace
# formatting differs slightly between Python 2 and 3, making it
# difficult to maintain compatibility between the two versions for
# expectation files that contain stack traces.
if not self._test_data.enabled: # pragma: no cover
presentation.logs['exception'] = traceback.format_exc()
raise
def _roll_project_impl(self, project_id, project_url, recipes_dir,
db_gcs_bucket):
# Keep persistent checkout. Speeds up the roller for large repos
# like chromium/src.
workdir = self._prepare_checkout(project_id, project_url)
recipes_cfg_path = workdir.joinpath('infra', 'config', 'recipes.cfg')
disable_reason = self._get_disable_reason(recipes_cfg_path)
if disable_reason:
rslt = self.m.step.empty('disabled', step_text=disable_reason)
rslt.presentation.status = self.m.step.WARNING
return _Status(ROLL_SKIP)
status = self._check_previous_roll(project_url, workdir, db_gcs_bucket)
if status is not None:
# This means that the previous roll is still going, or similar. In this
# situation we're done with this repo, for now.
return _Status(status)
roll_step = self.m.step('roll', [
'vpython3', recipes_dir / 'recipes.py', '--package', recipes_cfg_path,
'-vv', 'autoroll', '--output-json',
self.m.json.output()
])
roll_result = roll_step.json.output
if roll_result['success'] and roll_result['picked_roll_details']:
issue_result = self._process_successful_roll(
project_url, roll_step, workdir, recipes_dir, recipes_cfg_path,
db_gcs_bucket)
return _Status(ROLL_SUCCESS, issue_result['issue_url'])
num_rejected = roll_result['rejected_candidates_count']
if not roll_result['roll_details'] and num_rejected == 0:
roll_step.presentation.step_text += ' (already at latest revisions)'
return _Status(ROLL_EMPTY)
for i, roll_candidate in enumerate(roll_result['roll_details']):
roll_step.presentation.logs['candidate #%d' % (i + 1)] = (
self.m.json.dumps(roll_candidate['spec'], indent=2))
return _Status(ROLL_FAILURE)
def _process_successful_roll(self, project_url, roll_step, workdir,
recipes_dir, recipes_cfg_path, db_gcs_bucket):
"""
Args:
roll_step - The StepResult of the actual roll command. This is used to
adjust presentation and obtain the json output.
"""
roll_result = roll_step.json.output
picked_details = roll_result['picked_roll_details']
spec = jsonpb.ParseDict(picked_details['spec'], RepoSpec())
upload_args = ['--send-mail']
if roll_result['trivial']:
s = spec.autoroll_recipe_options.trivial
opts = AutorollRecipeOptions.TrivialOptions
if s.self_approve_method == opts.CODE_REVIEW_1_APPROVE:
upload_args.extend(['-o', '-l=Code-Review+1'])
elif s.self_approve_method == opts.CODE_REVIEW_2_APPROVE:
upload_args.extend(['-o', '-l=Code-Review+2'])
elif s.self_approve_method == opts.NO_LABELS_APPROVE:
# No-op to ensure that we require code coverage for this branch.
pass
else:
upload_args.append('--set-bot-commit')
if s.tbr_emails:
upload_args.extend(['-r', self.m.random.choice(s.tbr_emails)])
if s.automatic_commit:
upload_args.append('--use-commit-queue')
else:
if not spec.autoroll_recipe_options.no_owners:
upload_args.append('--r-owners')
if s.dry_run:
upload_args.append('--cq-dry-run')
else:
s = spec.autoroll_recipe_options.nontrivial
if s.extra_reviewer_emails:
upload_args.append('--reviewers=%s' % ','.join(s.extra_reviewer_emails))
if not spec.autoroll_recipe_options.no_owners:
upload_args.append('--r-owners')
if s.automatic_commit_dry_run:
upload_args.append('--cq-dry-run')
if s.set_autosubmit:
upload_args.append('--enable-auto-submit')
upload_args.extend(['--bypass-hooks', '-f'])
commit_message = get_commit_message(roll_result,
self.m.buildbucket.build.id)
roll_step.presentation.logs['commit_message'] = commit_message.splitlines()
if roll_result['trivial']:
roll_step.presentation.step_text += ' (trivial)'
else:
roll_step.presentation.status = self.m.step.FAILURE
dep_specs = None
try:
dep_specs = self.m.step(
'get deps',
[
'vpython3',
recipes_dir / 'recipes.py',
'--package',
recipes_cfg_path,
'dump_specs',
],
stdout=self.m.proto.output(DepRepoSpecs, codec='JSONPB'),
step_test_data=lambda: self.m.proto.test_api.output_stream(
DepRepoSpecs(repo_specs={'recipe_engine': RepoSpec()})),
).stdout
except self.m.step.StepFailure:
# TODO(fxbug.dev/54380): delete this `except` after crrev.com/c/2252547
# has rolled into all downstream repos that are rolled by an autoroller.
pass
cc_list = set()
for dep, commits in picked_details['commit_infos'].items():
if dep_specs:
dep_spec = dep_specs.repo_specs[dep]
if dep_spec.autoroll_recipe_options.no_cc_authors:
continue
for commit in commits:
cc_list.add(commit['author_email'])
if cc_list:
upload_args.append('--cc=%s' % ','.join(sorted(cc_list)))
with self.m.context(cwd=workdir):
self.m.git('commit', '-a', '-m', 'roll recipes.cfg')
self.m.git_cl.upload(
commit_message, upload_args, name='git cl upload')
issue_step = self.m.git_cl(
'issue', ['--json', self.m.json.output()],
name='git cl issue',
step_test_data=lambda: self.m.json.test_api.output({
'issue': 123456789,
'issue_url': 'https://code-review.googlesource.com/123456789'}))
issue_result = issue_step.json.output
if not issue_result['issue'] or not issue_result['issue_url']:
self.m.step.empty(
'git cl upload failed',
status=self.m.step.FAILURE,
step_text='no issue metadata returned')
repo_data = RepoData(
str(issue_result['issue']),
issue_result['issue_url'],
roll_result['trivial'],
self.m.time.utcnow(),
)
issue_step.presentation.links['Issue %s' % repo_data.issue] = (
repo_data.issue_url)
self.m.gsutil.upload(
self.m.json.input(repo_data.to_json()), db_gcs_bucket,
_gs_path(project_url))
return issue_result
def _get_pending_cl_status(self, project_url, workdir, db_gcs_bucket):
"""Returns (current_repo_data, git_cl_status_string) of the last known
roll CL for given repo.
If no such CL has been recorded, returns (None, None).
"""
cat_result = self.m.gsutil.cat(
'gs://%s/%s' % (db_gcs_bucket, _gs_path(project_url)),
stdout=self.m.raw_io.output_text(),
stderr=self.m.raw_io.output_text(),
ok_ret=(0, 1),
name='repo_state',
step_test_data=lambda: self.m.raw_io.test_api.stream_output_text(
'No URLs matched', stream='stderr', retcode=1))
if cat_result.retcode:
cat_result.presentation.logs['stderr'] = [
self.m.step.active_result.stderr]
if not re.search('No URLs matched', cat_result.stderr): # pragma: no cover
raise Exception('gsutil failed in an unexpected way; see stderr log')
return None, None
repo_data = RepoData.from_json(self.m.json.loads(cat_result.stdout))
cat_result.presentation.links['Issue %s' % repo_data.issue] = (
repo_data.issue_url)
if repo_data.trivial:
cat_result.presentation.step_text += ' (trivial)'
with self.m.context(cwd=workdir):
status_result = self.m.git_cl(
'status', ['--issue', repo_data.issue, '--field', 'status'],
name='git cl status',
stdout=self.m.raw_io.output_text(),
step_test_data=lambda: self.m.raw_io.test_api.stream_output_text(
'foo')).stdout.strip()
self.m.step.active_result.presentation.step_text = status_result
return repo_data, status_result