blob: a649cf4af5b79cb85bd72a9983cf1d46f21f7ec3 [file] [log] [blame]
# Copyright 2018 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
from recipe_engine import recipe_api
from urlparse import urlparse
import collections
# PatchInput describes the input to a `jiri patch` command. These are decoded from the
# list of JSON objects in a patchfile (see below).
#
# Properties:
# ref (str): The gerrit change ref (e.g refs/changes/aa/aabbcc/n)
# host (str): The code review host (e.g. fuchsia-review.googlesource.com)
# project (str): The patch project (e.g. garnet)
#
# Usage:
# These inputs are typically specified within a patchfile at the root of a project's
# directory. When checking out from a gerrit patchset (i.e. when running on CQ),
# CheckoutApi will patch any changes listed in this file.
PatchInput = collections.namedtuple('PatchInput', 'ref host project')
class PatchFile:
"""A file used to patch one or more changes from unrelated projects into a workspace.
The PatchFile should contain a list of JSON objects with the following structure:
[
{
"ref": "refs/changes/56/123456/3",
"host": "fuchsia-review.googlesource.com",
"project": "project",
}
]
"""
@staticmethod
def from_json(js):
patch_inputs = []
for js_object in js:
patch_inputs.append(PatchInput(**js_object))
return PatchFile(patch_inputs)
def __init__(self, patch_inputs):
self.patch_inputs = patch_inputs
@property
def inputs(self):
return self.patch_inputs
def validate(self, gerrit_change):
"""Verifies the following about this PatchFile:
1. No input overwrites the Gerrit change that is currently being tested.
2. No two inputs patch over one another.
Returns:
A ValueError that should be raised as a StepFailure, if validation fails. Else
None.
"""
def create_key(project, host):
"""Produces a unique ID for a project + host combination."""
return '%s/%s' % (project, host)
# Maps validated PatchInput keys to their PatchInputs.
validated = {}
# The key for the original Gerrit change that is being tested.
gerrit_patch_key = create_key(gerrit_change.project, gerrit_change.host)
for patch_input in self.patch_inputs:
patch_key = create_key(patch_input.project, patch_input.host)
# User cannot use patches.json to overwrite the original gerrit change.
if patch_key == gerrit_patch_key:
return ValueError((
"This patch overwrites the original gerrit change: %s\n"
"Inline this patch into the change instead of specifying in patches.json"
) % str(patch_input))
# User cannot patch multiple changes to the same project. Those changes should
# be tested locally.
if validated.get(patch_key, None):
return ValueError((
"Found patch that ovewrites a previous patch. These changes should be"
"tested together locally instead of through patches.json:\n"
"Original: %(original)s\nDuplicate: %(duplicate)s.") % dict(
original=str(validated[patch_key]),
duplicate=str(patch_input),
))
validated[patch_key] = patch_input
class CheckoutApi(recipe_api.RecipeApi):
"""An abstraction over how Jiri checkouts are created during Fuchsia CI/CQ builds."""
def __call__(self,
manifest,
remote,
project=None,
build_input=None,
timeout_secs=None,
run_hooks=True,
override=False):
"""Initializes and populates a jiri checkout from a remote manifest.
Emits a source manifest for the build.
Args:
manifest (str): Relative path to the manifest in the remote repository.
remote (str): URL to the remote repository.
project (str): The name that jiri should assign to the project.
build_input (buildbucket.build_pb2.Build.Input): The input to a buildbucket
build.
timeout_secs (int): A timeout for jiri update in seconds.
run_hooks (bool): Whether or not to run the hooks.
override (bool): Whether to override the imported manifest with a commit's
given revision.
"""
self.m.jiri.ensure_jiri()
self.m.jiri.init()
if build_input and build_input.gerrit_changes:
gerrit_change = build_input.gerrit_changes[0]
rest = build_input.gerrit_changes[1:]
assert rest == [], "build information contains more than one patchset"
self.from_patchset(
manifest=manifest,
remote=remote,
project=project,
run_hooks=run_hooks,
timeout_secs=timeout_secs,
gerrit_change=gerrit_change)
else:
commit = None
if build_input and build_input.gitiles_commit.id:
commit = build_input.gitiles_commit
self.from_commit(
manifest=manifest,
remote=remote,
commit=commit,
project=project,
run_hooks=run_hooks,
override=override,
timeout_secs=timeout_secs)
self.m.jiri.emit_source_manifest()
def from_patchset(self, manifest, remote, project, run_hooks, timeout_secs,
gerrit_change):
"""Initializes and populates a Jiri checkout from a remote manifest and Gerrit change.
Args:
manifest (str): Relative path to the manifest in the remote repository.
remote (str): URL to the remote repository.
project (str): The name that jiri should assign to the project.
build_input (buildbucket.build_pb2.Build.Input): The input to a buildbucket
build.
timeout_secs (int): A timeout for jiri update in seconds.
gerrit_change: An element from buildbucket.build_pb2.Build.Input.gerrit_changes.
"""
self.m.gerrit.ensure_gerrit()
# Fetch the project and update.
details = self._get_change_details(gerrit_change)
self.m.jiri.import_manifest(
manifest,
remote,
name=project,
revision='HEAD',
remote_branch=details['branch'])
self.m.jiri.update(run_hooks=False, timeout=timeout_secs)
# Patch the current Gerrit change.
current_revision = details['current_revision']
patch_ref = details['revisions'][current_revision]['ref']
self.m.jiri.patch(
patch_ref,
host='https://%s' % gerrit_change.host,
project=gerrit_change.project,
rebase=True,
)
# Handle patches.json, if present.
self._apply_patchfile(gerrit_change)
self.m.jiri.update(
gc=True,
rebase_tracked=True,
local_manifest=True,
run_hooks=False,
timeout=timeout_secs)
if run_hooks:
self.m.jiri.run_hooks(local_manifest=True)
def from_commit(self, manifest, remote, commit, project, run_hooks, override,
timeout_secs):
"""Initializes and populates a Jiri checkout from a remote manifest and Gitiles commmit.
Args:
manifest (str): Relative path to the manifest in the remote repository.
remote (str): URL to the remote repository.
project (str): The name that jiri should assign to the project.
remote (str): The remote git repository.
commit: Commit information derived from
buildbucket.build_pb2.Build.Input.gitiles_commit.
timeout_secs (int): A timeout for jiri update in seconds.
run_hooks (bool): Whether or not to run the hooks.
override (bool): Whether to override the imported manifest with a commit's
given revision.
"""
revision = commit.id if commit else 'HEAD'
if override and commit:
self.m.jiri.import_manifest(
manifest, remote, name=project, revision='HEAD')
# Note that in order to identify a project to override, jiri keys on
# both the project name and the remote source repository (not to be
# confused with `remote`, the manifest repository).
# We reconstruct the source repository in a scheme-agnostic manner.
manifest_remote_url = urlparse(remote)
project_remote = '%s://%s/%s' % (
manifest_remote_url.scheme,
manifest_remote_url.netloc,
commit.project,
)
self.m.jiri.override(
project=commit.project, remote=project_remote, new_revision=revision)
else:
self.m.jiri.import_manifest(
manifest, remote, name=project, revision=revision)
self.m.jiri.update(run_hooks=False, timeout=timeout_secs)
if run_hooks:
self.m.jiri.run_hooks()
def from_snapshot(self, snapshot, timeout_secs=None):
"""Initializes and populates a jiri checkout from a snapshot.
Additionally emits a source manifest for the build.
Args:
snapshot (Path): Path to the jiri snapshot.
timeout_secs (int): A timeout for jiri update in seconds.
"""
self.m.jiri.ensure_jiri()
self.m.jiri.init()
# Hooks must be run during update for a snapshot, otherwise it will be
# impossible to run them later. It's impossible because jiri doesn't record
# the hooks anywhere when it updates from a snapshot, so the only way to
# run hooks inside of the snapshot is to re-run update, which is redundant.
self.m.jiri.update(run_hooks=True, snapshot=snapshot, timeout=timeout_secs)
self.m.source_manifest.set_json_manifest('checkout',
self.m.jiri.source_manifest())
def _apply_patchfile(self, gerrit_change):
"""Parses and applies the PatchFile for the given gerrit change."""
# Verify patches.json exists.
patchfile_path = self.m.path['start_dir'].join(gerrit_change.project,
'patches.json')
if not self.m.path.exists(patchfile_path):
return
patch_file = self._parse_patchfile(patchfile_path)
# Ensure patchfile is valid.
validation_err = patch_file.validate(gerrit_change)
if validation_err is not None:
raise self.m.step.StepFailure(str(validation_err))
for patch_input in patch_file.inputs:
# If the patch pulls in a project that's not in the workspace already, the patch
# would not affect this build / test run. Skip this patch.
project_exists = len(
self.m.jiri.project([patch_input.project]).json.output) > 0
if not project_exists:
warning = 'Warning: skipping patch for %s which is not in the checkout' % patch_input.project
self.m.step(warning, [])
continue
# Strip protocol if present.
host = patch_input.host
host_url = urlparse(host)
if host_url.scheme:
host = host_url.hostname
# Patch in the change
self.m.jiri.patch(
ref=patch_input.ref,
host='https://%s' % host,
project=patch_input.project,
rebase=True)
def _get_change_details(self, gerrit_change):
"""Fetches the details of a Gerrit change"""
return self.m.gerrit.change_details(
name='get change details',
change_id='%s~%s' % (
gerrit_change.project,
gerrit_change.change,
),
gerrit_host='https://%s' % gerrit_change.host,
query_params=['CURRENT_REVISION'],
test_data=self.m.json.test_api.output({
'branch': 'master',
'current_revision': 'a1b2c3',
'revisions': {
'a1b2c3': {
'ref': 'refs/changes/00/100/5'
}
}
}),
)
def _parse_patchfile(self, patchfile_path):
"""Parses a PatchFile from the given path"""
js = self.m.json.read(
'read patches-json',
patchfile_path,
).json.output
return PatchFile.from_json(js)