blob: 2712bbdd79db3e9ebc72f3baa41457d17ddad51a [file] [log] [blame]
// Copyright 2018 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package buildbucket
import (
"encoding/json"
"strconv"
"strings"
"github.com/golang/protobuf/jsonpb"
"github.com/golang/protobuf/ptypes"
structpb "github.com/golang/protobuf/ptypes/struct"
tspb "github.com/golang/protobuf/ptypes/timestamp"
"go.chromium.org/luci/buildbucket/proto"
v1 "go.chromium.org/luci/common/api/buildbucket/buildbucket/v1"
"go.chromium.org/luci/common/api/swarming/swarming/v1"
"go.chromium.org/luci/common/data/strpair"
"go.chromium.org/luci/common/errors"
)
// This file implements v1<->v2 interoperation.
// MalformedBuild tag is present in an error if the build was malformed.
var MalformedBuild = errors.BoolTag{Key: errors.NewTagKey("malformed buildbucket v1 build")}
// StatusToV2 converts v1 build's Status, Result, FailureReason and
// CancelationReason to v2 Status enum.
//
// If build.Status is "", returns (Status_STATUS_UNSPECIFIED, nil).
// Useful with partial buildbucket responses.
func StatusToV2(build *v1.ApiCommonBuildMessage) (buildbucketpb.Status, error) {
switch build.Status {
case "":
return buildbucketpb.Status_STATUS_UNSPECIFIED, nil
case "SCHEDULED":
return buildbucketpb.Status_SCHEDULED, nil
case "STARTED":
return buildbucketpb.Status_STARTED, nil
case "COMPLETED":
switch build.Result {
case "SUCCESS":
return buildbucketpb.Status_SUCCESS, nil
case "FAILURE":
switch build.FailureReason {
case "", "BUILD_FAILURE":
return buildbucketpb.Status_FAILURE, nil
case "INFRA_FAILURE", "BUILDBUCKET_FAILURE", "INVALID_BUILD_DEFINITION":
return buildbucketpb.Status_INFRA_FAILURE, nil
default:
return 0, errors.Reason("unexpected failure reason %q", build.FailureReason).Tag(MalformedBuild).Err()
}
case "CANCELED":
switch build.CancelationReason {
case "", "CANCELED_EXPLICITLY":
return buildbucketpb.Status_CANCELED, nil
case "TIMEOUT":
return buildbucketpb.Status_INFRA_FAILURE, nil
default:
return 0, errors.Reason("unexpected cancellation reason %q", build.CancelationReason).Tag(MalformedBuild).Err()
}
default:
return 0, errors.Reason("unexpected result %q", build.Result).Tag(MalformedBuild).Err()
}
default:
return 0, errors.Reason("unexpected status %q", build.Status).Tag(MalformedBuild).Err()
}
}
type v1Params struct {
Builder string `json:"builder_name"`
Properties json.RawMessage `json:"properties"`
}
// BuildToV2 converts a v1 build message to v2.
//
// The returned build may be incomplete if msg is incomplete.
// For example, if msg is a partial response and does not have builder name,
// the returned build won't have it either.
//
// The returned build does not include steps.
// Returns an error if msg is malformed.
func BuildToV2(msg *v1.ApiCommonBuildMessage) (b *buildbucketpb.Build, err error) {
// This implementation is a port of
// https://chromium.googlesource.com/infra/infra/+/d55f587c0f30b0297e4d134c698e7458baa39b7f/appengine/cr-buildbucket/v2/builds.py#21
params := &v1Params{}
if msg.ParametersJson != "" {
if err = json.NewDecoder(strings.NewReader(msg.ParametersJson)).Decode(params); err != nil {
return nil, errors.Annotate(err, "ParametersJson is invalid").Tag(MalformedBuild).Err()
}
}
resultDetails := &struct {
Properties json.RawMessage `json:"properties"`
TaskResult swarming.SwarmingRpcsTaskResult `json:"task_result"`
UI struct {
Info string `json:"info"`
} `json:"ui"`
}{}
if msg.ResultDetailsJson != "" {
if err = json.NewDecoder(strings.NewReader(msg.ResultDetailsJson)).Decode(resultDetails); err != nil {
return nil, errors.Annotate(err, "ResultDetailsJson is invalid").Tag(MalformedBuild).Err()
}
}
tags := strpair.ParseMap(msg.Tags)
address := tags.Get(v1.TagBuildAddress)
var number int
if address != "" {
_, _, _, _, number, err = v1.ParseBuildAddress(address)
if err != nil {
return nil, errors.Annotate(err, "invalid build address %q", address).Tag(MalformedBuild).Err()
}
}
status, err := StatusToV2(msg)
if err != nil {
return nil, err
}
builder, err := builderToV2(msg, tags, params)
if err != nil {
return nil, err
}
b = &buildbucketpb.Build{
Id: msg.Id,
Builder: builder,
Number: int32(number),
CreatedBy: msg.CreatedBy,
CreateTime: timestampToV2(msg.CreatedTs),
StartTime: timestampToV2(msg.StartedTs),
EndTime: timestampToV2(msg.CompletedTs),
UpdateTime: timestampToV2(msg.UpdatedTs),
Status: status,
Input: &buildbucketpb.Build_Input{
Experimental: msg.Experimental,
},
Output: &buildbucketpb.Build_Output{
SummaryMarkdown: resultDetails.UI.Info,
},
Infra: &buildbucketpb.BuildInfra{
Buildbucket: &buildbucketpb.BuildInfra_Buildbucket{
Canary: msg.Canary,
},
Swarming: &buildbucketpb.BuildInfra_Swarming{
Hostname: tags.Get("swarming_hostname"),
TaskId: tags.Get("swarming_task_id"),
TaskServiceAccount: msg.ServiceAccount,
},
},
}
if b.Input.Properties, err = propertiesToV2(params.Properties); err != nil {
return nil, errors.Annotate(err, "invalid input properties").Tag(MalformedBuild).Err()
}
if b.Output.Properties, err = propertiesToV2(resultDetails.Properties); err != nil {
return nil, errors.Annotate(err, "invalid output properties").Tag(MalformedBuild).Err()
}
b.Infra.Swarming.BotDimensions = make([]*buildbucketpb.StringPair, 0, len(resultDetails.TaskResult.BotDimensions))
for _, d := range resultDetails.TaskResult.BotDimensions {
for _, v := range d.Value {
b.Infra.Swarming.BotDimensions = append(b.Infra.Swarming.BotDimensions, &buildbucketpb.StringPair{
Key: d.Key,
Value: v,
})
}
}
if err := tagsToV2(b, msg.Tags); err != nil {
return nil, err
}
return b, nil
}
func tagsToV2(dest *buildbucketpb.Build, tags []string) error {
dest.Input.GitilesCommit = nil
for _, t := range toStringPairs(tags) {
switch t.Key {
case v1.TagBuilder, v1.TagBuildAddress:
// We've already parsed these tags.
case v1.TagBuildSet:
switch bs := buildbucketpb.ParseBuildSet(t.Value).(type) {
case *buildbucketpb.GerritChange:
dest.Input.GerritChanges = append(dest.Input.GerritChanges, bs)
case *buildbucketpb.GitilesCommit:
if dest.Input.GitilesCommit != nil {
return errors.Reason("more than one gitiles commit buildset").Tag(MalformedBuild).Err()
}
dest.Input.GitilesCommit = bs
default:
dest.Tags = append(dest.Tags, t)
}
case "swarming_dimension":
if d := toStringPair(t.Value); d != nil {
dest.Infra.Swarming.TaskDimensions = append(dest.Infra.Swarming.TaskDimensions, d)
}
case "swarming_tag":
if st := toStringPair(t.Value); st != nil {
switch st.Key {
case "priority":
pri, _ := strconv.ParseInt(st.Value, 10, 32)
dest.Infra.Swarming.Priority = int32(pri)
case "buildbucket_template_revision":
dest.Infra.Buildbucket.ServiceConfigRevision = st.Value
}
}
default:
dest.Tags = append(dest.Tags, t)
}
}
return nil
}
// BucketNameToV2 converts a v1 Bucket name to the v2 constituent parts.
// An error is returned if the bucketname does not match the expected format.
// The difference between the bucket name is that v2 uses short names, for example:
// v1: luci.chromium.try
// v2: try
// "luci" is dropped, "chromium" is recorded as the project, "try" is the name.
// If the bucket does not conform to this convention, or if it is not a luci bucket,
// then this return and empty string for both project and bucket.
func BucketNameToV2(v1Bucket string) (project string, bucket string) {
p := strings.SplitN(v1Bucket, ".", 3)
if len(p) != 3 || p[0] != "luci" {
return "", ""
}
return p[1], p[2]
}
// builderToV2 attempts to parse as many fields into bucket and project as possible,
// and do project name validation if the project is available.
func builderToV2(msg *v1.ApiCommonBuildMessage, tags strpair.Map, params *v1Params) (ret *buildbucketpb.BuilderID, err error) {
ret = &buildbucketpb.BuilderID{Builder: params.Builder}
if ret.Builder == "" {
ret.Builder = tags.Get(v1.TagBuilder) // Fallback: Grab builder name from tags.
}
ret.Project, ret.Bucket = BucketNameToV2(msg.Bucket)
if msg.Project != "" && ret.Project != "" && ret.Project != msg.Project {
err = errors.Reason(
"message project %q does not match bucket project %q", msg.Project, ret.Project).Tag(MalformedBuild).Err()
}
return
}
func timestampToV2(ts int64) *tspb.Timestamp {
if ts == 0 {
return nil
}
ret, _ := ptypes.TimestampProto(v1.ParseTimestamp(ts))
return ret
}
func propertiesToV2(v1 json.RawMessage) (*structpb.Struct, error) {
if len(v1) == 0 {
return nil, nil
}
ret := &structpb.Struct{}
return ret, jsonpb.UnmarshalString(string(v1), ret)
}
func toStringPair(s string) *buildbucketpb.StringPair {
parts := strings.SplitN(s, ":", 2)
if len(parts) != 2 {
return nil
}
return &buildbucketpb.StringPair{Key: parts[0], Value: parts[1]}
}
func toStringPairs(tags []string) []*buildbucketpb.StringPair {
ret := make([]*buildbucketpb.StringPair, 0, len(tags))
for _, t := range tags {
if p := toStringPair(t); p != nil {
ret = append(ret, p)
}
}
return ret
}