| # 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) |