blob: 4b2ac52d6d31bc76e2d8be6eeb2b3e1e1866519e [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"
"math/rand"
"sort"
"strings"
"time"
"google.golang.org/protobuf/proto"
"go.chromium.org/luci/auth/identity"
"go.chromium.org/luci/common/clock"
"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"
// BuildStatusKind is a BuildStatus entity's kind in the datastore.
BuildStatusKind = "BuildStatus"
// BuildStorageDuration is the maximum lifetime of a Build.
//
// Lifetime is the time elapsed since the Build creation time.
// Cron runs periodically to scan and remove all the Builds of which
// lifetime exceeded this duration.
BuildStorageDuration = time.Hour * 24 * 30 * 18 // ~18 months
// BuildMaxCompletionTime defines the maximum duration that a Build must be
// completed within, from the build creation time.
BuildMaxCompletionTime = time.Hour * 24 * 5 // 5 days
// defaultBuildSyncInterval is the default interval between a build's latest
// update time and the next time to sync it with backend.
defaultBuildSyncInterval = 5 * time.Minute
syncTimeSep = "--"
)
// 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"`
// StartBuildToken is set when a backend task starts, and StartBuild requests are required
// to have it in the header.
StartBuildToken string `gae:"start_build_token,noindex"`
// PubSubCallback, if set, creates notifications for build status changes.
PubSubCallback PubSubCallback `gae:"pubsub_callback,noindex"`
// ParentID is the build's immediate parent build id.
// Stored separately from AncestorIds in order to index this special case.
ParentID int64 `gae:"parent_id"`
// Ids of the build’s ancestors. This includes all parents/grandparents/etc.
// This is ordered from top-to-bottom so `ancestor_ids[0]` is the root of
// the builds tree, and `ancestor_ids[-1]` is this build's immediate parent.
// This does not include any "siblings" at higher levels of the tree, just
// the direct chain of ancestors from root to this build.
AncestorIds []int64 `gae:"ancestor_ids"`
// Id of the first StartBuildTask call Buildbucket receives for the build.
// Buildbucket uses this to deduplicate the other StartBuildTask calls.
StartBuildTaskRequestID string `gae:"start_task_request_id,noindex"`
// Id of the first StartBuild call Buildbucket receives for the build.
// Buildbucket uses this to deduplicate the other StartBuild calls.
StartBuildRequestID string `gae:"start_build_request_id,noindex"`
// Computed field to be used by a cron job to get the builds that have not
// been updated for a while.
//
// It has a format like "<backend>--<project>--<shard>-=<next-sync-time>", where
// * backend is the backend target.
// * project is the luci project of the build.
// * shard is the added prefix to make sure the index on this property is
// sharded to avoid hot spotting.
// * next-sync-time is the unix time of the next time the build is supposed
// to be synced with its backend task, truncated in minute.
NextBackendSyncTime string `gae:"next_backend_sync_time"`
// Backend target for builds on TaskBackend.
BackendTarget string `gae:"backend_target"`
// How far into the future should NextBackendSyncTime be set after a build update.
BackendSyncInterval time.Duration `gae:"backend_sync_interval,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()
if b.BackendTarget != "" && b.BackendSyncInterval == 0 {
b.BackendSyncInterval = defaultBuildSyncInterval
}
if b.NextBackendSyncTime != "" && b.Proto.UpdateTime != nil {
backend, project, shardID, oldUnix := b.MustParseNextBackendSyncTime()
newUnix := fmt.Sprint(b.calculateNextSyncTime().Unix())
if newUnix > oldUnix {
b.NextBackendSyncTime = strings.Join([]string{backend, project, shardID, newUnix}, syncTimeSep)
}
}
// 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
}
b.AncestorIds = b.Proto.AncestorIds
if len(b.Proto.AncestorIds) > 0 {
b.ParentID = b.Proto.AncestorIds[len(b.Proto.AncestorIds)-1]
}
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 after applying the
// provided mask and redaction function.
func (b *Build) ToProto(ctx context.Context, m *BuildMask, redact func(*pb.Build) error) (*pb.Build, error) {
build := b.ToSimpleBuildProto(ctx)
if err := LoadBuildDetails(ctx, m, redact, build); err != nil {
return nil, err
}
return build, nil
}
// ToSimpleBuildProto returns the *pb.Build without loading steps, infra,
// input/output properties. Unlike ToProto, does not support redaction of fields.
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
}
func (b *Build) GetParentID() int64 {
if len(b.Proto.AncestorIds) > 0 {
return b.Proto.AncestorIds[len(b.Proto.AncestorIds)-1]
}
return 0
}
// ClearLease clears the lease by resetting the LeaseProperties.
//
// NeverLeased is kept unchanged.
func (b *Build) ClearLease() {
b.LeaseProperties = LeaseProperties{NeverLeased: b.LeaseProperties.NeverLeased}
}
// GenerateNextBackendSyncTime generates the build's NextBackendSyncTime if the build
// runs on a backend.
func (b *Build) GenerateNextBackendSyncTime(ctx context.Context, shards int32) {
if b.BackendTarget == "" {
return
}
shardID := 0
if shards > 1 {
seeded := rand.New(rand.NewSource(clock.Now(ctx).UnixNano()))
shardID = seeded.Intn(int(shards))
}
b.NextBackendSyncTime = ConstructNextSyncTime(b.BackendTarget, b.Project, shardID, b.calculateNextSyncTime())
}
func ConstructNextSyncTime(backend, project string, shardID int, syncTime time.Time) string {
return strings.Join([]string{backend, project, fmt.Sprint(shardID), fmt.Sprint(syncTime.Unix())}, syncTimeSep)
}
// calculateNextSyncTime calculates the next time the build should be synced.
// It rounds the build's update time to the closest minute them add BackendSyncInterval.
func (b *Build) calculateNextSyncTime() time.Time {
return b.Proto.UpdateTime.AsTime().Round(time.Minute).Add(b.BackendSyncInterval)
}
func (b *Build) MustParseNextBackendSyncTime() (backend, project, shardID, syncTime string) {
parts := strings.Split(b.NextBackendSyncTime, syncTimeSep)
if len(parts) != 4 {
panic(fmt.Sprintf("build.NextBackendSyncTime %s is in a wrong format", b.NextBackendSyncTime))
}
return parts[0], parts[1], parts[2], parts[3]
}
// LoadBuildDetails loads the details of the given builds, trimming them
// according to the specified mask and redaction function.
func LoadBuildDetails(ctx context.Context, m *BuildMask, redact func(*pb.Build) error, 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 []any
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 any) {
if included[path] {
dets = append(dets, det)
}
}
appendIfIncluded("infra", inf[i])
appendIfIncluded("input.properties", inp[i])
appendIfIncluded("steps", stp[i])
}
if err := GetIgnoreMissing(ctx, dets); err != nil {
return errors.Annotate(err, "error fetching build details").Err()
}
// For `output.properties`, should use *BuildOutputProperties.Get, instead of
// using datastore.Get directly.
if included["output.properties"] {
if err := errors.Filter(GetMultiOutputProperties(ctx, out...), datastore.ErrNoSuchEntity); err != nil {
return errors.Annotate(err, "error fetching build(s) output properties").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
if p.Output == nil {
p.Output = &pb.Build_Output{}
}
p.Output.Properties = out[i].Proto
p.Steps, err = stp[i].ToProto(ctx)
if err != nil {
return errors.Annotate(err, "error fetching steps for build %q", p.Id).Err()
}
if m.Includes("summary_markdown") {
p.SummaryMarkdown = protoutil.MergeSummary(p)
}
if redact != nil {
if err = redact(p); err != nil {
return errors.Annotate(err, "error redacting build %q", p.Id).Err()
}
}
if err = m.Trim(p); err != nil {
return errors.Annotate(err, "error trimming fields for build %q", p.Id).Err()
}
}
return nil
}
// BuildStatus stores build ids and their statuses.
type BuildStatus struct {
_kind string `gae:"$kind,BuildStatus"`
// ID is always 1 because only one such entity exists.
ID int `gae:"$id,1"`
// Build is the key for the build this entity belongs to.
Build *datastore.Key `gae:"$parent"`
// Address of a build.
// * If build number is enabled for the build, the address would be
// <project>/<bucket>/<builder>/<build_number>;
// * otherwise the address would be <project>/<bucket>/<builder>/b<build_id> (
// to easily differentiate build number and build id).
BuildAddress string `gae:"build_address"`
Status pb.Status `gae:"status,noindex"`
}