blob: a76e63f07010922cca6246f55e6e355af38ca20a [file] [log] [blame]
# Copyright 2017 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.
"""API for interacting with the buildbucket service.
Depends on 'buildbucket' binary available in PATH:
https://godoc.org/go.chromium.org/luci/buildbucket/client/cmd/buildbucket
"""
import base64
import json
from google.protobuf import json_format
from recipe_engine import recipe_api
from .proto import build_pb2
from .proto import common_pb2
from . import util
class BuildbucketApi(recipe_api.RecipeApi):
"""A module for interacting with buildbucket."""
# Expose protobuf messages to the users of buildbucket module.
build_pb2 = build_pb2
common_pb2 = common_pb2
def __init__(
self, property, legacy_property, mastername, buildername, buildnumber,
revision, parent_got_revision, patch_storage, patch_gerrit_url,
patch_project, patch_issue, patch_set, issue, patchset, *args, **kwargs):
super(BuildbucketApi, self).__init__(*args, **kwargs)
self._service_account_key = None
self._host = 'cr-buildbucket.appspot.com'
legacy_property = legacy_property or {}
if isinstance(legacy_property, basestring):
legacy_property = json.loads(legacy_property)
self._legacy_property = legacy_property
self._build = build_pb2.Build()
if property.get('build'):
json_format.Parse(
json.dumps(property.get('build')),
self._build,
ignore_unknown_fields=True)
self._bucket_v1 = 'luci.%s.%s' % (
self._build.builder.project, self._build.builder.bucket)
else:
# Legacy mode.
build_dict = legacy_property.get('build', {})
self._bucket_v1 = build_dict.get('bucket', None)
self.build.number = int(buildnumber or 0)
self.build.created_by = build_dict.get('created_by', '')
created_ts = build_dict.get('created_ts')
if created_ts:
self.build.create_time.FromDatetime(
util.timestamp_to_datetime(float(created_ts)))
if 'id' in build_dict:
self._build.id = int(build_dict['id'])
build_sets = list(util._parse_buildset_tags(build_dict.get('tags', [])))
_legacy_builder_id(
build_dict, mastername, buildername, self._build.builder)
_legacy_input_gerrit_changes(
self._build.input.gerrit_changes, build_sets, patch_storage,
patch_gerrit_url, patch_project, patch_issue or issue,
patch_set or patchset)
_legacy_input_gitiles_commit(
self._build.input.gitiles_commit, build_dict, build_sets,
revision or parent_got_revision)
_legacy_tags(build_dict, self._build)
def set_buildbucket_host(self, host):
"""Changes the buildbucket backend hostname used by this module.
Args:
host (str): buildbucket server host (e.g. 'cr-buildbucket.appspot.com').
"""
self._host = host
def use_service_account_key(self, key_path):
"""Tells this module to start using given service account key for auth.
Otherwise the module is using the default account (when running on LUCI or
locally), or no auth at all (when running on Buildbot).
Exists mostly to support Buildbot environment. Recipe for LUCI environment
should not use this.
Args:
key_path (str): a path to JSON file with service account credentials.
"""
self._service_account_key = key_path
@property
def build(self):
"""Returns current build as a buildbucket.v2.Build protobuf message.
For value format, see Build message in
https://chromium.googlesource.com/infra/luci/luci-go/+/master/buildbucket/proto/build.proto.
DO NOT MODIFY the returned value.
Do not implement conditional logic on returned tags; they are for indexing.
Use returned build.input instead.
Pure Buildbot support: to simplify transition to buildbucket, returns a
message even if the current build is not a buildbucket build. Provides as
much information as possible. Some fields may be left empty, violating
the rules described in the .proto files.
If the current build is not a buildbucket build, returned build.id is 0.
"""
return self._build
@property
def builder_name(self):
"""Returns builder name. Shortcut for .build.builder.builder."""
return self.build.builder.builder
@property
def gitiles_commit(self):
"""Returns input gitiles commit. Shortcut for .build.input.gitiles_commit.
For value format, see GitilesCommit message in
https://chromium.googlesource.com/infra/luci/luci-go/+/master/buildbucket/proto/common.proto.
Never returns None, but sub-fields may be empty.
"""
return self.build.input.gitiles_commit
@property
def tags_for_child_build(self):
"""A dict of tags (key -> value) derived from current (parent) build for a
child build."""
original_tags = {t.key: t.value for t in self.build.tags}
new_tags = {'user_agent': 'recipe'}
# TODO(nodir): switch to ScheduleBuild API where we don't have to convert
# build input back to tags.
# This function returns a dict, so there can be only one buildset, although
# we can have multiple sources.
# Priority: CL buildset, commit buildset, custom buildset.
commit = self.build.input.gitiles_commit
if self.build.input.gerrit_changes:
cl = self.build.input.gerrit_changes[0]
new_tags['buildset'] = 'patch/gerrit/%s/%d/%d' % (
cl.host, cl.change, cl.patchset)
# Note: an input gitiles commit with ref without id is valid
# but such commit cannot be used to construct a valid commit buildset.
elif commit.host and commit.project and commit.id:
new_tags['buildset'] = (
'commit/gitiles/%s/%s/+/%s' % (
commit.host, commit.project, commit.id))
if commit.ref:
new_tags['gitiles_ref'] = commit.ref
else:
buildset = original_tags.get('buildset')
if buildset:
new_tags['buildset'] = buildset
if self.build.number:
new_tags['parent_buildnumber'] = str(self.build.number)
if self.build.builder.builder:
new_tags['parent_buildername'] = str(self.build.builder.builder)
return new_tags
# RPCs.
def put(self, builds, **kwargs):
"""Puts a batch of builds.
Args:
builds (list): A list of dicts, where keys are:
'bucket': (required) name of the bucket for the request.
'parameters' (dict): (required) arbitrary json-able parameters that a
build system would be able to interpret.
'tags': (optional) a dict(str->str) of tags for the build. These will
be added to those generated by this method and override them if
appropriate. If you need to remove a tag set by default, set its value
to None (for example, tags={'buildset': None} will ensure build is
triggered without 'buildset' tag).
Returns:
A step that as its .stdout property contains the response object as
returned by buildbucket.
"""
build_specs = []
for build in builds:
build_specs.append(self.m.json.dumps({
'bucket': build['bucket'],
'parameters_json': self.m.json.dumps(build['parameters']),
'tags': self._tags_for_build(build['bucket'], build['parameters'],
build.get('tags')),
'experimental': self.m.runtime.is_experimental,
}))
return self._call_service('put', build_specs, **kwargs)
def cancel_build(self, build_id, **kwargs):
return self._call_service('cancel', [build_id], **kwargs)
def get_build(self, build_id, **kwargs):
return self._call_service('get', [build_id], **kwargs)
# Internal.
def _call_service(self, command, args, **kwargs):
step_name = kwargs.pop('name', 'buildbucket.' + command)
if self._service_account_key:
args = ['-service-account-json', self._service_account_key] + args
args = ['buildbucket', command, '-host', self._host] + args
kwargs.setdefault('infra_step', True)
return self.m.step(step_name, args, stdout=self.m.json.output(), **kwargs)
def _tags_for_build(self, bucket, parameters, override_tags=None):
new_tags = self.tags_for_child_build
builder_name = parameters.get('builder_name')
if builder_name:
new_tags['builder'] = builder_name
# TODO(tandrii): remove this Buildbot-specific code.
if bucket.startswith('master.'):
new_tags['master'] = bucket[7:]
new_tags.update(override_tags or {})
return sorted(
'%s:%s' % (k, v)
for k, v in new_tags.iteritems()
if v is not None)
@property
def bucket_v1(self):
"""Returns bucket name in v1 format.
Mostly useful for scheduling new builds using V1 API.
"""
return self._bucket_v1
# DEPRECATED API.
@property
def properties(self): # pragma: no cover
"""DEPRECATED, use build attribute instead."""
return self._legacy_property
@property
def build_id(self): # pragma: no cover
"""DEPRECATED, use build.id instead."""
return self.build.id or None
@property
def build_input(self): # pragma: no cover
"""DEPRECATED, use build.input instead."""
return self.build.input
@property
def builder_id(self): # pragma: no cover
"""Deprecated. Use build.builder instead."""
return self.build.builder
# Legacy support.
def _legacy_tags(build_dict, build_msg):
for t in build_dict.get('tags', []):
k, v = t.split(':', 1)
if k =='buildset' and v.startswith(('patch/gerrit/', 'commit/gitiles')):
continue
if k in ('build_address', 'builder'):
continue
build_msg.tags.add(key=k, value=v)
def _legacy_input_gerrit_changes(
dest_repeated, build_sets,
patch_storage, patch_gerrit_url, patch_project, patch_issue, patch_set):
if patch_storage == 'gerrit' and patch_project:
host, path = util.parse_http_host_and_path(patch_gerrit_url)
if host and (not path or path == '/'):
try:
patch_issue = int(patch_issue or 0)
patch_set = int(patch_set or 0)
except ValueError:
pass
else:
if patch_issue and patch_set:
dest_repeated.add(
host=host,
project=patch_project,
change=patch_issue,
patchset=patch_set)
return
for bs in build_sets:
if isinstance(bs, common_pb2.GerritChange):
dest_repeated.add().CopyFrom(bs)
def _legacy_input_gitiles_commit(dest, build_dict, build_sets, revision):
commit = None
for bs in build_sets:
if isinstance(bs, common_pb2.GitilesCommit):
commit = bs
break
if commit:
dest.CopyFrom(commit)
ref_prefix = 'gitiles_ref:'
for t in build_dict.get('tags', []):
if t.startswith(ref_prefix):
dest.ref = t[len(ref_prefix):]
break
return
if util.is_sha1_hex(revision):
dest.id = revision
def _legacy_builder_id(build_dict, mastername, buildername, builder_id):
builder_id.project = build_dict.get('project') or ''
builder_id.bucket = build_dict.get('bucket') or ''
if builder_id.bucket:
luci_prefix = 'luci.%s.' % builder_id.project
if builder_id.bucket.startswith(luci_prefix):
builder_id.bucket = builder_id.bucket[len(luci_prefix):]
if not builder_id.bucket and mastername:
builder_id.bucket = 'master.%s' % mastername
tags_dict = dict(t.split(':', 1) for t in build_dict.get('tags', []))
builder_id.builder = tags_dict.get('builder') or buildername or ''