blob: 60c91be0d792b9992d876b2fbbb3d0cb5e668809 [file] [log] [blame]
# Copyright 2019 The LUCI Authors. All rights reserved.
# Use of this source code is governed under the Apache License, Version 2.0
# that can be found in the LICENSE file.
"""Recipe API for LUCI CQ, the pre-commit testing system."""
from future.utils import iteritems
import re
from google.protobuf import json_format as json_pb
from PB.go.chromium.org.luci.cv.api.recipe.v1 import cq as cq_pb2
from recipe_engine import recipe_api
class CQApi(recipe_api.RecipeApi):
"""This module provides recipe API of LUCI CQ, aka pre-commit testing system.
The CQ service is being replaced with a service now named LUCI Change
Verifier (CV); for more information see:
https://chromium.googlesource.com/infra/luci/luci-go/+/HEAD/cv
TODO(qyearsley): Rename parts of this from CQ -> CV as appropriate.
"""
# Common Run modes.
DRY_RUN = 'DRY_RUN'
QUICK_DRY_RUN = 'QUICK_DRY_RUN'
FULL_RUN = 'FULL_RUN'
class CQInactive(Exception):
"""Incorrect usage of CQApi method requiring active CQ."""
def __init__(self, input_props, **kwargs):
super(CQApi, self).__init__(**kwargs)
self._input = input_props
self._active = False
self._output = cq_pb2.Output()
def initialize(self):
if self._input.active or (
# legacy style
'dry_run' in self.m.properties.get('$recipe_engine/cq', {})):
self._active = True
if self._active and not self._input.run_mode:
# backfill
self._input.run_mode = (
self.DRY_RUN if self._input.dry_run else self.FULL_RUN)
@property
def active(self):
"""Returns whether CQ is active for this build."""
return self._active
@property
def run_mode(self):
"""Returns the mode(str) of the CQ Run that triggers this build.
Raises:
CQInactive if CQ is not active for this build.
"""
self._enforce_active()
return self._input.run_mode
@property
def experimental(self):
"""Returns whether this build is triggered for a CQ experimental builder.
See `Builder.experiment_percentage` doc in [CQ
config](https://chromium.googlesource.com/infra/luci/luci-go/+/main/cv/api/config/v2/cq.proto)
Raises:
CQInactive if CQ is not active for this build.
"""
self._enforce_active()
return self._input.experimental
@property
def top_level(self):
"""Returns whether CQ triggered this build directly.
Can be spoofed. *DO NOT USE FOR SECURITY CHECKS.*
Raises:
CQInactive if CQ is not active for this build.
"""
self._enforce_active()
return self._input.top_level
@property
def ordered_gerrit_changes(self):
"""Returns list[bb_common_pb2.GerritChange] in order in which CLs should be
applied or submitted.
Raises:
CQInactive if CQ is not active for this build.
"""
self._enforce_active()
assert self.m.buildbucket.build.input.gerrit_changes, (
'you must simulate buildbucket.input.gerrit_changes in your test '
'in order to use api.cq.ordered_gerrit_changes')
return self.m.buildbucket.build.input.gerrit_changes
@property
def props_for_child_build(self):
"""Returns properties dict meant to be passed to child builds.
These will preserve the CQ context of the current build in the
about-to-be-triggered child build.
```python
properties = {'foo': bar, 'protolike': proto_message}
properties.update(api.cq.props_for_child_build)
req = api.buildbucket.schedule_request(
builder='child',
gerrit_changes=list(api.buildbucket.build.input.gerrit_changes),
properties=properties)
child_builds = api.buildbucket.schedule([req])
api.cq.record_triggered_builds(*child_builds)
```
The contents of returned dict should be treated as opaque blob,
it may be changed without notice.
"""
if not self._input.active:
return {}
msg = cq_pb2.Input()
msg.CopyFrom(self._input)
msg.top_level = False
return {'$recipe_engine/cq':
json_pb.MessageToDict(msg, preserving_proto_field_name=True)}
@property
def cl_group_key(self):
"""Returns a string that is unique for a current set of Gerrit change
patchsets (or, equivalently, buildsets).
The same `cl_group_key` will be used if another Attempt is made for the
same set of changes at a different time.
Raises:
CQInactive if CQ is not active for this build.
"""
return self._extract_unique_cq_tag('cl_group_key')
@property
def equivalent_cl_group_key(self):
"""Returns a string that is unique for a given set of Gerrit changes
disregarding trivial patchset differences.
For example, when a new "trivial" patchset is uploaded, then the
cl_group_key will change but the equivalent_cl_group_key will stay the same.
Raises:
CQInactive if CQ is not active for this build.
"""
return self._extract_unique_cq_tag('equivalent_cl_group_key')
@property
def triggered_build_ids(self):
"""Returns recorded Buildbucket build IDs as a list of integers."""
return [bid for bid in self._output.triggered_build_ids]
def record_triggered_builds(self, *builds):
"""Adds given Buildbucket builds to the list of triggered builds for CQ
to wait on corresponding build completion later.
Must be called after some step.
Expected usage:
```python
api.cq.record_triggered_builds(*api.buildbucket.schedule([req1, req2]))
```
Args:
* [`Build`](https://chromium.googlesource.com/infra/luci/luci-go/+/main/buildbucket/proto/build.proto)
objects, typically returned by `api.buildbucket.schedule`.
"""
return self.record_triggered_build_ids(*[b.id for b in builds])
def record_triggered_build_ids(self, *build_ids):
"""Adds given Buildbucket build ids to the list of triggered builds for CQ
to wait on corresponding build completion later.
Must be called after some step.
Args:
* build_id (int or string): Buildbucket build id.
"""
if not build_ids:
return
self._output.triggered_build_ids.extend(int(bid) for bid in build_ids)
self._write_output_props(
triggered_build_ids=[
str(bid) for bid in self._output.triggered_build_ids
],
)
@property
def do_not_retry_build(self):
return self._output.retry == cq_pb2.Output.OUTPUT_RETRY_DENIED
def set_do_not_retry_build(self):
"""Instruct CQ to not retry this build.
This mechanism is used to reduce duration of CQ attempt and save testing
capacity if retrying will likely return an identical result.
"""
if self._output.retry == cq_pb2.Output.OUTPUT_RETRY_DENIED:
return
self._output.retry = cq_pb2.Output.OUTPUT_RETRY_DENIED
self._write_output_props(
cur_step= self.m.step('TRYJOB DO NOT RETRY', cmd=None),
do_not_retry=True,
)
@property
def allowed_reuse_modes(self):
assert all(not r.deny for r in self._output.reuse), (
'deny reuse is not supported currently')
return [r.mode_regexp for r in self._output.reuse]
def allow_reuse_for(self, *modes):
"""Instructs CQ that this build can be reused in a future Run if
and only if its mode is in the provided modes.
Overwrites all previously set values.
"""
# TODO(yiwzhang): Expose low-level method to modify reuse if needed.
if not modes:
raise ValueError('expected at least 1 mode_regexp, got 0')
del self._output.reuse[:]
# TODO(yiwzhang): consider changing mode_regexp in output.reuse
# to mode_allowlist.
self._output.reuse.extend(
cq_pb2.Output.Reuse(mode_regexp=m) for m in modes)
self._write_output_props()
def _extract_unique_cq_tag(self, suffix):
key = 'cq_' + suffix
self._enforce_active()
for t in self.m.buildbucket.build.tags:
if t.key == key:
return t.value
raise ValueError('Can\'t find tag with key %r' % key) # pragma: nocover
def _write_output_props(self, cur_step=None, **addition_props):
# TODO(iannucci): add API to set properties regardless of the current step.
if not cur_step:
cur_step = self.m.step.active_result
assert cur_step, 'must be called after some step'
output = cq_pb2.Output()
output.CopyFrom(self._output)
cur_step.presentation.properties['$recipe_engine/cq/output'] = output
for k, v in iteritems(addition_props):
cur_step.presentation.properties[k] = v
def _enforce_active(self):
if not self._active:
raise self.CQInactive()