go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/buildbucket/appengine/model/build.go (about)

     1  // Copyright 2020 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package model
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"math/rand"
    21  	"sort"
    22  	"strings"
    23  	"time"
    24  
    25  	"google.golang.org/protobuf/proto"
    26  
    27  	"go.chromium.org/luci/auth/identity"
    28  	"go.chromium.org/luci/common/clock"
    29  	"go.chromium.org/luci/common/data/strpair"
    30  	"go.chromium.org/luci/common/errors"
    31  	"go.chromium.org/luci/gae/service/datastore"
    32  
    33  	bb "go.chromium.org/luci/buildbucket"
    34  	pb "go.chromium.org/luci/buildbucket/proto"
    35  	"go.chromium.org/luci/buildbucket/protoutil"
    36  )
    37  
    38  const (
    39  	// BuildKind is a Build entity's kind in the datastore.
    40  	BuildKind = "Build"
    41  
    42  	// BuildStatusKind is a BuildStatus entity's kind in the datastore.
    43  	BuildStatusKind = "BuildStatus"
    44  
    45  	// BuildStorageDuration is the maximum lifetime of a Build.
    46  	//
    47  	// Lifetime is the time elapsed since the Build creation time.
    48  	// Cron runs periodically to scan and remove all the Builds of which
    49  	// lifetime exceeded this duration.
    50  	BuildStorageDuration = time.Hour * 24 * 30 * 18 // ~18 months
    51  	// BuildMaxCompletionTime defines the maximum duration that a Build must be
    52  	// completed within, from the build creation time.
    53  	BuildMaxCompletionTime = time.Hour * 24 * 5 // 5 days
    54  
    55  	// defaultBuildSyncInterval is the default interval between a build's latest
    56  	// update time and the next time to sync it with backend.
    57  	defaultBuildSyncInterval = 5 * time.Minute
    58  
    59  	syncTimeSep = "--"
    60  )
    61  
    62  // isHiddenTag returns whether the given tag should be hidden by ToProto.
    63  func isHiddenTag(key string) bool {
    64  	// build_address is reserved by the server so that the TagIndex infrastructure
    65  	// can be reused to fetch builds by builder + number (see tagindex.go and
    66  	// rpc/get_build.go).
    67  	// TODO(crbug/1042991): Unhide builder and gitiles_ref.
    68  	// builder and gitiles_ref are allowed to be specified, are not internal,
    69  	// and are only hidden here to match Python behavior.
    70  	return key == "build_address" || key == "builder" || key == "gitiles_ref"
    71  }
    72  
    73  // PubSubCallback encapsulates parameters for a Pub/Sub callback.
    74  type PubSubCallback struct {
    75  	AuthToken string `gae:"auth_token,noindex"`
    76  	Topic     string `gae:"topic,noindex"`
    77  	UserData  []byte `gae:"user_data,noindex"`
    78  }
    79  
    80  // Build is a representation of a build in the datastore.
    81  // Implements datastore.PropertyLoadSaver.
    82  type Build struct {
    83  	_     datastore.PropertyMap `gae:"-,extra"`
    84  	_kind string                `gae:"$kind,Build"`
    85  	ID    int64                 `gae:"$id"`
    86  
    87  	// LegacyProperties are properties set for v1 legacy builds.
    88  	LegacyProperties
    89  	// UnusedProperties are properties set previously but currently unused.
    90  	UnusedProperties
    91  
    92  	// Proto is the pb.Build proto representation of the build.
    93  	//
    94  	// infra, input.properties, output.properties, and steps
    95  	// are zeroed and stored in separate datastore entities
    96  	// due to their potentially large size (see details.go).
    97  	// tags are given their own field so they can be indexed.
    98  	//
    99  	// noindex is not respected here, it's set in pb.Build.ToProperty.
   100  	Proto *pb.Build `gae:"proto,legacy"`
   101  
   102  	Project string `gae:"project"`
   103  	// <project>/<bucket>. Bucket is in v2 format.
   104  	// e.g. chromium/try (never chromium/luci.chromium.try).
   105  	BucketID string `gae:"bucket_id"`
   106  	// <project>/<bucket>/<builder>. Bucket is in v2 format.
   107  	// e.g. chromium/try/linux-rel.
   108  	BuilderID string `gae:"builder_id"`
   109  
   110  	Canary bool `gae:"canary"`
   111  
   112  	CreatedBy identity.Identity `gae:"created_by"`
   113  	// TODO(nodir): Replace reliance on create_time indices with id.
   114  	CreateTime time.Time `gae:"create_time"`
   115  	// Experimental, if true, means to exclude from monitoring and search results
   116  	// (unless specifically requested in search results).
   117  	Experimental bool `gae:"experimental"`
   118  	// Experiments is a slice of experiments enabled or disabled on this build.
   119  	// Each element should look like "[-+]$experiment_name".
   120  	//
   121  	// Special case:
   122  	//   "-luci.non_production" is not kept here as a storage/index
   123  	//   optimization.
   124  	//
   125  	//   Notably, all search/query implementations on the Build model
   126  	//   apply this filter in post by checking that
   127  	//   `b.ExperimentStatus("luci.non_production") == pb.Trinary_YES`.
   128  	//
   129  	//   This is because directly including this value in the datastore query
   130  	//   results in bad performance due to excessive zig-zag join overhead
   131  	//   in the datastore, since 99%+ of the builds in Buildbucket are production
   132  	//   builds.
   133  	Experiments []string `gae:"experiments"`
   134  	Incomplete  bool     `gae:"incomplete"`
   135  
   136  	// Deprecated; remove after v1 api turndown
   137  	IsLuci bool `gae:"is_luci"`
   138  
   139  	ResultDBUpdateToken string    `gae:"resultdb_update_token,noindex"`
   140  	Status              pb.Status `gae:"status_v2"`
   141  	StatusChangedTime   time.Time `gae:"status_changed_time"`
   142  	// Tags is a slice of "<key>:<value>" strings taken from Proto.Tags.
   143  	// Stored separately in order to index.
   144  	Tags []string `gae:"tags"`
   145  
   146  	// UpdateToken is set at the build creation time, and UpdateBuild requests are required
   147  	// to have it in the header.
   148  	UpdateToken string `gae:"update_token,noindex"`
   149  
   150  	// StartBuildToken is set when a backend task starts, and StartBuild requests are required
   151  	// to have it in the header.
   152  	StartBuildToken string `gae:"start_build_token,noindex"`
   153  
   154  	// PubSubCallback, if set, creates notifications for build status changes.
   155  	PubSubCallback PubSubCallback `gae:"pubsub_callback,noindex"`
   156  
   157  	// ParentID is the build's immediate parent build id.
   158  	// Stored separately from AncestorIds in order to index this special case.
   159  	ParentID int64 `gae:"parent_id"`
   160  
   161  	// Ids of the build’s ancestors. This includes all parents/grandparents/etc.
   162  	// This is ordered from top-to-bottom so `ancestor_ids[0]` is the root of
   163  	// the builds tree, and `ancestor_ids[-1]` is this build's immediate parent.
   164  	// This does not include any "siblings" at higher levels of the tree, just
   165  	// the direct chain of ancestors from root to this build.
   166  	AncestorIds []int64 `gae:"ancestor_ids"`
   167  
   168  	// Id of the first StartBuildTask call Buildbucket receives for the build.
   169  	// Buildbucket uses this to deduplicate the other StartBuildTask calls.
   170  	StartBuildTaskRequestID string `gae:"start_task_request_id,noindex"`
   171  
   172  	// Id of the first StartBuild call Buildbucket receives for the build.
   173  	// Buildbucket uses this to deduplicate the other StartBuild calls.
   174  	StartBuildRequestID string `gae:"start_build_request_id,noindex"`
   175  
   176  	// Computed field to be used by a cron job to get the builds that have not
   177  	// been updated for a while.
   178  	//
   179  	// It has a format like "<backend>--<project>--<shard>-=<next-sync-time>", where
   180  	//   * backend is the backend target.
   181  	//	 * project is the luci project of the build.
   182  	//   * shard is the added prefix to make sure the index on this property is
   183  	//     sharded to avoid hot spotting.
   184  	//   * next-sync-time is the unix time of the next time the build is supposed
   185  	//     to be synced with its backend task, truncated in minute.
   186  	NextBackendSyncTime string `gae:"next_backend_sync_time"`
   187  
   188  	// Backend target for builds on TaskBackend.
   189  	BackendTarget string `gae:"backend_target"`
   190  
   191  	// How far into the future should NextBackendSyncTime be set after a build update.
   192  	BackendSyncInterval time.Duration `gae:"backend_sync_interval,noindex"`
   193  }
   194  
   195  // Realm returns this build's auth realm, or an empty string if not opted into the
   196  // realms experiment.
   197  func (b *Build) Realm() string {
   198  	return fmt.Sprintf("%s:%s", b.Proto.Builder.Project, b.Proto.Builder.Bucket)
   199  }
   200  
   201  // ExperimentStatus scans the experiments attached to this Build and returns:
   202  //   - YES - The experiment was known at schedule time and enabled.
   203  //   - NO - The experiment was known at schedule time and disabled.
   204  //   - UNSET - The experiment was unknown at schedule time.
   205  //
   206  // Malformed Experiment filters are treated as UNSET.
   207  func (b *Build) ExperimentStatus(expname string) (ret pb.Trinary) {
   208  	b.IterExperiments(func(enabled bool, exp string) bool {
   209  		if exp == expname {
   210  			if enabled {
   211  				ret = pb.Trinary_YES
   212  			} else {
   213  				ret = pb.Trinary_NO
   214  			}
   215  			return false
   216  		}
   217  		return true
   218  	})
   219  	return
   220  }
   221  
   222  // IterExperiments parses all experiments and calls `cb` for each.
   223  //
   224  // This will always include a call with bb.ExperimentNonProduction, even
   225  // if '-'+bb.ExperimentNonProduction isn't recorded in the underlying
   226  // Experiments field.
   227  func (b *Build) IterExperiments(cb func(enabled bool, exp string) bool) {
   228  	var hadNonProd bool
   229  
   230  	for _, expFilter := range b.Experiments {
   231  		if len(expFilter) == 0 {
   232  			continue
   233  		}
   234  		plusMinus, exp := expFilter[0], expFilter[1:]
   235  		hadNonProd = hadNonProd || exp == bb.ExperimentNonProduction
   236  
   237  		keepGoing := true
   238  		if plusMinus == '+' {
   239  			keepGoing = cb(true, exp)
   240  		} else if plusMinus == '-' {
   241  			keepGoing = cb(false, exp)
   242  		}
   243  		if !keepGoing {
   244  			return
   245  		}
   246  	}
   247  	if !hadNonProd {
   248  		cb(false, bb.ExperimentNonProduction)
   249  	}
   250  }
   251  
   252  // ExperimentsString sorts, joins, and returns the enabled experiments with "|".
   253  //
   254  // Returns "None" if no experiments were enabled in the build.
   255  func (b *Build) ExperimentsString() string {
   256  	if len(b.Experiments) == 0 {
   257  		return "None"
   258  	}
   259  
   260  	enables := make([]string, 0, len(b.Experiments))
   261  	b.IterExperiments(func(isEnabled bool, name string) bool {
   262  		if isEnabled {
   263  			enables = append(enables, name)
   264  		}
   265  		return true
   266  	})
   267  	if len(enables) > 0 {
   268  		sort.Strings(enables)
   269  		return strings.Join(enables, "|")
   270  	}
   271  	return "None"
   272  }
   273  
   274  // Load overwrites this representation of a build by reading the given
   275  // datastore.PropertyMap. Mutates this entity.
   276  func (b *Build) Load(p datastore.PropertyMap) error {
   277  	return datastore.GetPLS(b).Load(p)
   278  }
   279  
   280  // Save returns the datastore.PropertyMap representation of this build. Mutates
   281  // this entity to reflect computed datastore fields in the returned PropertyMap.
   282  func (b *Build) Save(withMeta bool) (datastore.PropertyMap, error) {
   283  	b.BucketID = protoutil.FormatBucketID(b.Proto.Builder.Project, b.Proto.Builder.Bucket)
   284  	b.BuilderID = protoutil.FormatBuilderID(b.Proto.Builder)
   285  	b.Canary = b.Proto.Canary
   286  	b.Experimental = b.Proto.Input.GetExperimental()
   287  	b.Incomplete = !protoutil.IsEnded(b.Proto.Status)
   288  	b.Project = b.Proto.Builder.Project
   289  
   290  	oldStatus := b.Status
   291  	b.Status = b.Proto.Status
   292  	if b.Status != oldStatus {
   293  		b.StatusChangedTime = b.Proto.UpdateTime.AsTime()
   294  	}
   295  	b.CreateTime = b.Proto.CreateTime.AsTime()
   296  
   297  	if b.BackendTarget != "" && b.BackendSyncInterval == 0 {
   298  		b.BackendSyncInterval = defaultBuildSyncInterval
   299  	}
   300  
   301  	if b.NextBackendSyncTime != "" && b.Proto.UpdateTime != nil {
   302  		backend, project, shardID, oldUnix := b.MustParseNextBackendSyncTime()
   303  		newUnix := fmt.Sprint(b.calculateNextSyncTime().Unix())
   304  		if newUnix > oldUnix {
   305  			b.NextBackendSyncTime = strings.Join([]string{backend, project, shardID, newUnix}, syncTimeSep)
   306  		}
   307  	}
   308  
   309  	// Set legacy values used by Python.
   310  	switch b.Status {
   311  	case pb.Status_SCHEDULED:
   312  		b.LegacyProperties.Result = 0
   313  		b.LegacyProperties.Status = Scheduled
   314  	case pb.Status_STARTED:
   315  		b.LegacyProperties.Result = 0
   316  		b.LegacyProperties.Status = Started
   317  	case pb.Status_SUCCESS:
   318  		b.LegacyProperties.Result = Success
   319  		b.LegacyProperties.Status = Completed
   320  	case pb.Status_FAILURE:
   321  		b.LegacyProperties.FailureReason = BuildFailure
   322  		b.LegacyProperties.Result = Failure
   323  		b.LegacyProperties.Status = Completed
   324  	case pb.Status_INFRA_FAILURE:
   325  		if b.Proto.StatusDetails.GetTimeout() != nil {
   326  			b.LegacyProperties.CancelationReason = TimeoutCanceled
   327  			b.LegacyProperties.Result = Canceled
   328  		} else {
   329  			b.LegacyProperties.FailureReason = InfraFailure
   330  			b.LegacyProperties.Result = Failure
   331  		}
   332  		b.LegacyProperties.Status = Completed
   333  	case pb.Status_CANCELED:
   334  		b.LegacyProperties.CancelationReason = ExplicitlyCanceled
   335  		b.LegacyProperties.Result = Canceled
   336  		b.LegacyProperties.Status = Completed
   337  	}
   338  
   339  	b.AncestorIds = b.Proto.AncestorIds
   340  	if len(b.Proto.AncestorIds) > 0 {
   341  		b.ParentID = b.Proto.AncestorIds[len(b.Proto.AncestorIds)-1]
   342  	}
   343  
   344  	p, err := datastore.GetPLS(b).Save(withMeta)
   345  	if err != nil {
   346  		return nil, err
   347  	}
   348  
   349  	// Parameters and ResultDetails are only set via v1 API which is unsupported in
   350  	// Go. In order to preserve the value of these fields without having to interpret
   351  	// them, the type is set to []byte. But if no values for these fields are set,
   352  	// the []byte type causes an empty-type specific value (i.e. empty string) to be
   353  	// written to the datastore. Since Python interprets these fields as JSON, and
   354  	// and an empty string is not a valid JSON object, convert empty strings to nil.
   355  	// TODO(crbug/1042991): Remove Properties default once v1 API is removed.
   356  	if len(b.LegacyProperties.Parameters) == 0 {
   357  		p["parameters"] = datastore.MkProperty(nil)
   358  	}
   359  	// TODO(crbug/1042991): Remove ResultDetails default once v1 API is removed.
   360  	if len(b.LegacyProperties.ResultDetails) == 0 {
   361  		p["result_details"] = datastore.MkProperty(nil)
   362  	}
   363  
   364  	// Writing a value for PubSubCallback confuses the Python implementation which
   365  	// expects PubSubCallback to be a LocalStructuredProperty. See also unused.go.
   366  	delete(p, "pubsub_callback")
   367  	return p, nil
   368  }
   369  
   370  // ToProto returns the *pb.Build representation of this build after applying the
   371  // provided mask and redaction function.
   372  func (b *Build) ToProto(ctx context.Context, m *BuildMask, redact func(*pb.Build) error) (*pb.Build, error) {
   373  	build := b.ToSimpleBuildProto(ctx)
   374  	if err := LoadBuildDetails(ctx, m, redact, build); err != nil {
   375  		return nil, err
   376  	}
   377  	return build, nil
   378  }
   379  
   380  // ToSimpleBuildProto returns the *pb.Build without loading steps, infra,
   381  // input/output properties. Unlike ToProto, does not support redaction of fields.
   382  func (b *Build) ToSimpleBuildProto(ctx context.Context) *pb.Build {
   383  	p := proto.Clone(b.Proto).(*pb.Build)
   384  	p.Tags = make([]*pb.StringPair, 0, len(b.Tags))
   385  	for _, t := range b.Tags {
   386  		k, v := strpair.Parse(t)
   387  		if !isHiddenTag(k) {
   388  			p.Tags = append(p.Tags, &pb.StringPair{
   389  				Key:   k,
   390  				Value: v,
   391  			})
   392  		}
   393  	}
   394  	return p
   395  }
   396  
   397  func (b *Build) GetParentID() int64 {
   398  	if len(b.Proto.AncestorIds) > 0 {
   399  		return b.Proto.AncestorIds[len(b.Proto.AncestorIds)-1]
   400  	}
   401  	return 0
   402  }
   403  
   404  // ClearLease clears the lease by resetting the LeaseProperties.
   405  //
   406  // NeverLeased is kept unchanged.
   407  func (b *Build) ClearLease() {
   408  	b.LeaseProperties = LeaseProperties{NeverLeased: b.LeaseProperties.NeverLeased}
   409  }
   410  
   411  // GenerateNextBackendSyncTime generates the build's NextBackendSyncTime if the build
   412  // runs on a backend.
   413  func (b *Build) GenerateNextBackendSyncTime(ctx context.Context, shards int32) {
   414  	if b.BackendTarget == "" {
   415  		return
   416  	}
   417  
   418  	shardID := 0
   419  	if shards > 1 {
   420  		seeded := rand.New(rand.NewSource(clock.Now(ctx).UnixNano()))
   421  		shardID = seeded.Intn(int(shards))
   422  	}
   423  	b.NextBackendSyncTime = ConstructNextSyncTime(b.BackendTarget, b.Project, shardID, b.calculateNextSyncTime())
   424  }
   425  
   426  func ConstructNextSyncTime(backend, project string, shardID int, syncTime time.Time) string {
   427  	return strings.Join([]string{backend, project, fmt.Sprint(shardID), fmt.Sprint(syncTime.Unix())}, syncTimeSep)
   428  }
   429  
   430  // calculateNextSyncTime calculates the next time the build should be synced.
   431  // It rounds the build's update time to the closest minute them add BackendSyncInterval.
   432  func (b *Build) calculateNextSyncTime() time.Time {
   433  	return b.Proto.UpdateTime.AsTime().Round(time.Minute).Add(b.BackendSyncInterval)
   434  }
   435  
   436  func (b *Build) MustParseNextBackendSyncTime() (backend, project, shardID, syncTime string) {
   437  	parts := strings.Split(b.NextBackendSyncTime, syncTimeSep)
   438  	if len(parts) != 4 {
   439  		panic(fmt.Sprintf("build.NextBackendSyncTime %s is in a wrong format", b.NextBackendSyncTime))
   440  	}
   441  	return parts[0], parts[1], parts[2], parts[3]
   442  }
   443  
   444  // LoadBuildDetails loads the details of the given builds, trimming them
   445  // according to the specified mask and redaction function.
   446  func LoadBuildDetails(ctx context.Context, m *BuildMask, redact func(*pb.Build) error, builds ...*pb.Build) error {
   447  	l := len(builds)
   448  	inf := make([]*BuildInfra, 0, l)
   449  	inp := make([]*BuildInputProperties, 0, l)
   450  	out := make([]*BuildOutputProperties, 0, l)
   451  	stp := make([]*BuildSteps, 0, l)
   452  	var dets []any
   453  
   454  	included := map[string]bool{
   455  		"infra":             m.Includes("infra"),
   456  		"input.properties":  m.Includes("input.properties"),
   457  		"output.properties": m.Includes("output.properties"),
   458  		"steps":             m.Includes("steps"),
   459  	}
   460  
   461  	for i, p := range builds {
   462  		if p.GetId() <= 0 {
   463  			return errors.Reason("invalid build for %q", p).Err()
   464  		}
   465  		key := datastore.KeyForObj(ctx, &Build{ID: p.Id})
   466  		inf = append(inf, &BuildInfra{Build: key})
   467  		inp = append(inp, &BuildInputProperties{Build: key})
   468  		out = append(out, &BuildOutputProperties{Build: key})
   469  		stp = append(stp, &BuildSteps{Build: key})
   470  		appendIfIncluded := func(path string, det any) {
   471  			if included[path] {
   472  				dets = append(dets, det)
   473  			}
   474  		}
   475  		appendIfIncluded("infra", inf[i])
   476  		appendIfIncluded("input.properties", inp[i])
   477  		appendIfIncluded("steps", stp[i])
   478  	}
   479  
   480  	if err := GetIgnoreMissing(ctx, dets); err != nil {
   481  		return errors.Annotate(err, "error fetching build details").Err()
   482  	}
   483  
   484  	// For `output.properties`, should use *BuildOutputProperties.Get, instead of
   485  	// using datastore.Get directly.
   486  	if included["output.properties"] {
   487  		if err := errors.Filter(GetMultiOutputProperties(ctx, out...), datastore.ErrNoSuchEntity); err != nil {
   488  			return errors.Annotate(err, "error fetching build(s) output properties").Err()
   489  		}
   490  	}
   491  
   492  	var err error
   493  	for i, p := range builds {
   494  		p.Infra = inf[i].Proto
   495  		if p.Input == nil {
   496  			p.Input = &pb.Build_Input{}
   497  		}
   498  		p.Input.Properties = inp[i].Proto
   499  		if p.Output == nil {
   500  			p.Output = &pb.Build_Output{}
   501  		}
   502  		p.Output.Properties = out[i].Proto
   503  		p.Steps, err = stp[i].ToProto(ctx)
   504  		if err != nil {
   505  			return errors.Annotate(err, "error fetching steps for build %q", p.Id).Err()
   506  		}
   507  		if m.Includes("summary_markdown") {
   508  			p.SummaryMarkdown = protoutil.MergeSummary(p)
   509  		}
   510  		if redact != nil {
   511  			if err = redact(p); err != nil {
   512  				return errors.Annotate(err, "error redacting build %q", p.Id).Err()
   513  			}
   514  		}
   515  		if err = m.Trim(p); err != nil {
   516  			return errors.Annotate(err, "error trimming fields for build %q", p.Id).Err()
   517  		}
   518  	}
   519  	return nil
   520  }
   521  
   522  // BuildStatus stores build ids and their statuses.
   523  type BuildStatus struct {
   524  	_kind string `gae:"$kind,BuildStatus"`
   525  
   526  	// ID is always 1 because only one such entity exists.
   527  	ID int `gae:"$id,1"`
   528  
   529  	// Build is the key for the build this entity belongs to.
   530  	Build *datastore.Key `gae:"$parent"`
   531  
   532  	// Address of a build.
   533  	// * If build number is enabled for the build, the address would be
   534  	// <project>/<bucket>/<builder>/<build_number>;
   535  	// * otherwise the address would be <project>/<bucket>/<builder>/b<build_id> (
   536  	// to easily differentiate build number and build id).
   537  	BuildAddress string `gae:"build_address"`
   538  
   539  	Status pb.Status `gae:"status,noindex"`
   540  }