blob: 9a4e1fa6393270643a6134cfc8360115c5de9d15 [file] [log] [blame]
// Copyright 2020 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 model
import (
"context"
"fmt"
"sort"
"strings"
"time"
"google.golang.org/protobuf/proto"
"go.chromium.org/luci/auth/identity"
"go.chromium.org/luci/common/data/strpair"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/gae/service/datastore"
bb "go.chromium.org/luci/buildbucket"
pb "go.chromium.org/luci/buildbucket/proto"
"go.chromium.org/luci/buildbucket/protoutil"
)
const (
// BuildKind is a Build entity's kind in the datastore.
BuildKind = "Build"
)
// isHiddenTag returns whether the given tag should be hidden by ToProto.
func isHiddenTag(key string) bool {
// build_address is reserved by the server so that the TagIndex infrastructure
// can be reused to fetch builds by builder + number (see tagindex.go and
// rpc/get_build.go).
// TODO(crbug/1042991): Unhide builder and gitiles_ref.
// builder and gitiles_ref are allowed to be specified, are not internal,
// and are only hidden here to match Python behavior.
return key == "build_address" || key == "builder" || key == "gitiles_ref"
}
// PubSubCallback encapsulates parameters for a Pub/Sub callback.
type PubSubCallback struct {
AuthToken string `gae:"auth_token,noindex"`
Topic string `gae:"topic,noindex"`
UserData []byte `gae:"user_data,noindex"`
}
// Build is a representation of a build in the datastore.
// Implements datastore.PropertyLoadSaver.
type Build struct {
_ datastore.PropertyMap `gae:"-,extra"`
_kind string `gae:"$kind,Build"`
ID int64 `gae:"$id"`
// LegacyProperties are properties set for v1 legacy builds.
LegacyProperties
// UnusedProperties are properties set previously but currently unused.
UnusedProperties
// Proto is the pb.Build proto representation of the build.
//
// infra, input.properties, output.properties, and steps
// are zeroed and stored in separate datastore entities
// due to their potentially large size (see details.go).
// tags are given their own field so they can be indexed.
//
// noindex is not respected here, it's set in pb.Build.ToProperty.
Proto *pb.Build `gae:"proto,legacy"`
Project string `gae:"project"`
// <project>/<bucket>. Bucket is in v2 format.
// e.g. chromium/try (never chromium/luci.chromium.try).
BucketID string `gae:"bucket_id"`
// <project>/<bucket>/<builder>. Bucket is in v2 format.
// e.g. chromium/try/linux-rel.
BuilderID string `gae:"builder_id"`
Canary bool `gae:"canary"`
CreatedBy identity.Identity `gae:"created_by"`
// TODO(nodir): Replace reliance on create_time indices with id.
CreateTime time.Time `gae:"create_time"`
// Experimental, if true, means to exclude from monitoring and search results
// (unless specifically requested in search results).
Experimental bool `gae:"experimental"`
// Experiments is a slice of experiments enabled or disabled on this build.
// Each element should look like "[-+]$experiment_name".
//
// Special case:
// "-luci.non_production" is not kept here as a storage/index
// optimization.
//
// Notably, all search/query implementations on the Build model
// apply this filter in post by checking that
// `b.ExperimentStatus("luci.non_production") == pb.Trinary_YES`.
//
// This is because directly including this value in the datastore query
// results in bad performance due to excessive zig-zag join overhead
// in the datastore, since 99%+ of the builds in Buildbucket are production
// builds.
Experiments []string `gae:"experiments"`
Incomplete bool `gae:"incomplete"`
// Deprecated; remove after v1 api turndown
IsLuci bool `gae:"is_luci"`
ResultDBUpdateToken string `gae:"resultdb_update_token,noindex"`
Status pb.Status `gae:"status_v2"`
StatusChangedTime time.Time `gae:"status_changed_time"`
// Tags is a slice of "<key>:<value>" strings taken from Proto.Tags.
// Stored separately in order to index.
Tags []string `gae:"tags"`
// UpdateToken is set at the build creation time, and UpdateBuild requests are required
// to have it in the header.
UpdateToken string `gae:"update_token,noindex"`
// PubSubCallback, if set, creates notifications for build status changes.
PubSubCallback PubSubCallback `gae:"pubsub_callback,noindex"`
}
// Realm returns this build's auth realm, or an empty string if not opted into the
// realms experiment.
func (b *Build) Realm() string {
return fmt.Sprintf("%s:%s", b.Proto.Builder.Project, b.Proto.Builder.Bucket)
}
// ExperimentStatus scans the experiments attached to this Build and returns:
// * YES - The experiment was known at schedule time and enabled.
// * NO - The experiment was known at schedule time and disabled.
// * UNSET - The experiment was unknown at schedule time.
//
// Malformed Experiment filters are treated as UNSET.
func (b *Build) ExperimentStatus(expname string) (ret pb.Trinary) {
b.IterExperiments(func(enabled bool, exp string) bool {
if exp == expname {
if enabled {
ret = pb.Trinary_YES
} else {
ret = pb.Trinary_NO
}
return false
}
return true
})
return
}
// IterExperiments parses all experiments and calls `cb` for each.
//
// This will always include a call with bb.ExperimentNonProduction, even
// if '-'+bb.ExperimentNonProduction isn't recorded in the underlying
// Experiments field.
func (b *Build) IterExperiments(cb func(enabled bool, exp string) bool) {
var hadNonProd bool
for _, expFilter := range b.Experiments {
if len(expFilter) == 0 {
continue
}
plusMinus, exp := expFilter[0], expFilter[1:]
hadNonProd = hadNonProd || exp == bb.ExperimentNonProduction
keepGoing := true
if plusMinus == '+' {
keepGoing = cb(true, exp)
} else if plusMinus == '-' {
keepGoing = cb(false, exp)
}
if !keepGoing {
return
}
}
if !hadNonProd {
cb(false, bb.ExperimentNonProduction)
}
}
// ExperimentsString sorts, joins, and returns the enabled experiments with "|".
//
// Returns "None" if no experiments were enabled in the build.
func (b *Build) ExperimentsString() string {
if len(b.Experiments) == 0 {
return "None"
}
enables := make([]string, 0, len(b.Experiments))
b.IterExperiments(func(isEnabled bool, name string) bool {
if isEnabled {
enables = append(enables, name)
}
return true
})
if len(enables) > 0 {
sort.Strings(enables)
return strings.Join(enables, "|")
}
return "None"
}
// Load overwrites this representation of a build by reading the given
// datastore.PropertyMap. Mutates this entity.
func (b *Build) Load(p datastore.PropertyMap) error {
return datastore.GetPLS(b).Load(p)
}
// Save returns the datastore.PropertyMap representation of this build. Mutates
// this entity to reflect computed datastore fields in the returned PropertyMap.
func (b *Build) Save(withMeta bool) (datastore.PropertyMap, error) {
b.BucketID = protoutil.FormatBucketID(b.Proto.Builder.Project, b.Proto.Builder.Bucket)
b.BuilderID = protoutil.FormatBuilderID(b.Proto.Builder)
b.Canary = b.Proto.Canary
b.Experimental = b.Proto.Input.GetExperimental()
b.Incomplete = !protoutil.IsEnded(b.Proto.Status)
b.Project = b.Proto.Builder.Project
oldStatus := b.Status
b.Status = b.Proto.Status
if b.Status != oldStatus {
b.StatusChangedTime = b.Proto.UpdateTime.AsTime()
}
b.CreateTime = b.Proto.CreateTime.AsTime()
// Set legacy values used by Python.
switch b.Status {
case pb.Status_SCHEDULED:
b.LegacyProperties.Result = 0
b.LegacyProperties.Status = Scheduled
case pb.Status_STARTED:
b.LegacyProperties.Result = 0
b.LegacyProperties.Status = Started
case pb.Status_SUCCESS:
b.LegacyProperties.Result = Success
b.LegacyProperties.Status = Completed
case pb.Status_FAILURE:
b.LegacyProperties.FailureReason = BuildFailure
b.LegacyProperties.Result = Failure
b.LegacyProperties.Status = Completed
case pb.Status_INFRA_FAILURE:
if b.Proto.StatusDetails.GetTimeout() != nil {
b.LegacyProperties.CancelationReason = TimeoutCanceled
b.LegacyProperties.Result = Canceled
} else {
b.LegacyProperties.FailureReason = InfraFailure
b.LegacyProperties.Result = Failure
}
b.LegacyProperties.Status = Completed
case pb.Status_CANCELED:
b.LegacyProperties.CancelationReason = ExplicitlyCanceled
b.LegacyProperties.Result = Canceled
b.LegacyProperties.Status = Completed
}
p, err := datastore.GetPLS(b).Save(withMeta)
if err != nil {
return nil, err
}
// Parameters and ResultDetails are only set via v1 API which is unsupported in
// Go. In order to preserve the value of these fields without having to interpret
// them, the type is set to []byte. But if no values for these fields are set,
// the []byte type causes an empty-type specific value (i.e. empty string) to be
// written to the datastore. Since Python interprets these fields as JSON, and
// and an empty string is not a valid JSON object, convert empty strings to nil.
// TODO(crbug/1042991): Remove Properties default once v1 API is removed.
if len(b.LegacyProperties.Parameters) == 0 {
p["parameters"] = datastore.MkProperty(nil)
}
// TODO(crbug/1042991): Remove ResultDetails default once v1 API is removed.
if len(b.LegacyProperties.ResultDetails) == 0 {
p["result_details"] = datastore.MkProperty(nil)
}
// Writing a value for PubSubCallback confuses the Python implementation which
// expects PubSubCallback to be a LocalStructuredProperty. See also unused.go.
delete(p, "pubsub_callback")
return p, nil
}
// ToProto returns the *pb.Build representation of this build.
func (b *Build) ToProto(ctx context.Context, m *BuildMask) (*pb.Build, error) {
build := b.ToSimpleBuildProto(ctx)
if err := LoadBuildDetails(ctx, m, build); err != nil {
return nil, err
}
return build, nil
}
// ToSimpleBuildProto returns the *pb.Build without loading steps, infra,
// input/output properties.
func (b *Build) ToSimpleBuildProto(ctx context.Context) *pb.Build {
p := proto.Clone(b.Proto).(*pb.Build)
p.Tags = make([]*pb.StringPair, 0, len(b.Tags))
for _, t := range b.Tags {
k, v := strpair.Parse(t)
if !isHiddenTag(k) {
p.Tags = append(p.Tags, &pb.StringPair{
Key: k,
Value: v,
})
}
}
return p
}
// LoadBuildDetails loads the details of the given builds, trimming them
// according to the mask.
func LoadBuildDetails(ctx context.Context, m *BuildMask, builds ...*pb.Build) error {
l := len(builds)
inf := make([]*BuildInfra, 0, l)
inp := make([]*BuildInputProperties, 0, l)
out := make([]*BuildOutputProperties, 0, l)
stp := make([]*BuildSteps, 0, l)
var dets []interface{}
included := map[string]bool{
"infra": m.Includes("infra"),
"input.properties": m.Includes("input.properties"),
"output.properties": m.Includes("output.properties"),
"steps": m.Includes("steps"),
}
for i, p := range builds {
if p.GetId() <= 0 {
return errors.Reason("invalid build for %q", p).Err()
}
key := datastore.KeyForObj(ctx, &Build{ID: p.Id})
inf = append(inf, &BuildInfra{Build: key})
inp = append(inp, &BuildInputProperties{Build: key})
out = append(out, &BuildOutputProperties{Build: key})
stp = append(stp, &BuildSteps{Build: key})
appendIfIncluded := func(path string, det interface{}) {
if included[path] {
dets = append(dets, det)
}
}
appendIfIncluded("infra", inf[i])
appendIfIncluded("input.properties", inp[i])
appendIfIncluded("output.properties", out[i])
appendIfIncluded("steps", stp[i])
}
if err := GetIgnoreMissing(ctx, dets); err != nil {
return errors.Annotate(err, "error fetching build details").Err()
}
var err error
for i, p := range builds {
p.Infra = inf[i].Proto
if p.Input == nil {
p.Input = &pb.Build_Input{}
}
p.Input.Properties = &inp[i].Proto.Struct
if p.Output == nil {
p.Output = &pb.Build_Output{}
}
p.Output.Properties = &out[i].Proto.Struct
p.Steps, err = stp[i].ToProto(ctx)
if err != nil {
return errors.Annotate(err, "error fetching steps for build %q", p).Err()
}
if err = m.Trim(p); err != nil {
return errors.Annotate(err, "error trimming fields for %q", p).Err()
}
}
return nil
}