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 }