go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/buildbucket/appengine/rpc/update_build_test.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 rpc
    16  
    17  import (
    18  	"context"
    19  	"sort"
    20  	"strconv"
    21  	"strings"
    22  	"testing"
    23  	"time"
    24  
    25  	"google.golang.org/genproto/protobuf/field_mask"
    26  	"google.golang.org/grpc/codes"
    27  	"google.golang.org/grpc/metadata"
    28  	"google.golang.org/protobuf/types/known/fieldmaskpb"
    29  	"google.golang.org/protobuf/types/known/structpb"
    30  	"google.golang.org/protobuf/types/known/timestamppb"
    31  
    32  	"go.chromium.org/luci/common/clock/testclock"
    33  	"go.chromium.org/luci/common/data/strpair"
    34  	"go.chromium.org/luci/common/errors"
    35  	"go.chromium.org/luci/common/proto/mask"
    36  	"go.chromium.org/luci/common/tsmon"
    37  	"go.chromium.org/luci/gae/filter/featureBreaker"
    38  	"go.chromium.org/luci/gae/filter/txndefer"
    39  	"go.chromium.org/luci/gae/impl/memory"
    40  	"go.chromium.org/luci/gae/service/datastore"
    41  	"go.chromium.org/luci/server/tq"
    42  	"go.chromium.org/luci/server/tq/tqtesting"
    43  
    44  	"go.chromium.org/luci/buildbucket"
    45  	"go.chromium.org/luci/buildbucket/appengine/common"
    46  	"go.chromium.org/luci/buildbucket/appengine/internal/buildtoken"
    47  	"go.chromium.org/luci/buildbucket/appengine/internal/metrics"
    48  	"go.chromium.org/luci/buildbucket/appengine/model"
    49  	taskdefs "go.chromium.org/luci/buildbucket/appengine/tasks/defs"
    50  	pb "go.chromium.org/luci/buildbucket/proto"
    51  	"go.chromium.org/luci/buildbucket/protoutil"
    52  
    53  	. "github.com/smartystreets/goconvey/convey"
    54  	. "go.chromium.org/luci/common/testing/assertions"
    55  )
    56  
    57  func TestValidateUpdate(t *testing.T) {
    58  	t.Parallel()
    59  	ctx := memory.Use(context.Background())
    60  	Convey("validate UpdateMask", t, func() {
    61  		req := &pb.UpdateBuildRequest{Build: &pb.Build{Id: 1}}
    62  
    63  		Convey("succeeds", func() {
    64  			Convey("with valid paths", func() {
    65  				req.UpdateMask = &field_mask.FieldMask{Paths: []string{
    66  					"build.tags",
    67  					"build.output",
    68  					"build.status_details",
    69  					"build.summary_markdown",
    70  				}}
    71  				req.Build.SummaryMarkdown = "this is a string"
    72  				So(validateUpdate(ctx, req, nil), ShouldBeNil)
    73  			})
    74  		})
    75  
    76  		Convey("fails", func() {
    77  			Convey("with nil request", func() {
    78  				So(validateUpdate(ctx, nil, nil), ShouldErrLike, "build.id: required")
    79  			})
    80  
    81  			Convey("with an invalid path", func() {
    82  				req.UpdateMask = &field_mask.FieldMask{Paths: []string{
    83  					"bucket.name",
    84  				}}
    85  				So(validateUpdate(ctx, req, nil), ShouldErrLike, `unsupported path "bucket.name"`)
    86  			})
    87  
    88  			Convey("with a mix of valid and invalid paths", func() {
    89  				req.UpdateMask = &field_mask.FieldMask{Paths: []string{
    90  					"build.tags",
    91  					"bucket.name",
    92  					"build.output",
    93  				}}
    94  				So(validateUpdate(ctx, req, nil), ShouldErrLike, `unsupported path "bucket.name"`)
    95  			})
    96  		})
    97  	})
    98  
    99  	Convey("validate status", t, func() {
   100  		req := &pb.UpdateBuildRequest{Build: &pb.Build{Id: 1}}
   101  		req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.status"}}
   102  
   103  		Convey("succeeds", func() {
   104  			req.Build.Status = pb.Status_SUCCESS
   105  			So(validateUpdate(ctx, req, nil), ShouldBeNil)
   106  		})
   107  
   108  		Convey("fails", func() {
   109  			req.Build.Status = pb.Status_SCHEDULED
   110  			So(validateUpdate(ctx, req, nil), ShouldErrLike, "build.status: invalid status SCHEDULED for UpdateBuild")
   111  		})
   112  	})
   113  
   114  	Convey("validate tags", t, func() {
   115  		req := &pb.UpdateBuildRequest{Build: &pb.Build{Id: 1}}
   116  		req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.tags"}}
   117  		req.Build.Tags = []*pb.StringPair{{Key: "ci:builder", Value: ""}}
   118  		So(validateUpdate(ctx, req, nil), ShouldErrLike, `tag key "ci:builder" cannot have a colon`)
   119  	})
   120  
   121  	Convey("validate summary_markdown", t, func() {
   122  		req := &pb.UpdateBuildRequest{Build: &pb.Build{Id: 1}}
   123  		req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.summary_markdown"}}
   124  		req.Build.SummaryMarkdown = strings.Repeat("☕", protoutil.SummaryMarkdownMaxLength)
   125  		So(validateUpdate(ctx, req, nil), ShouldErrLike, "too big to accept")
   126  	})
   127  
   128  	Convey("validate output.gitiles_ommit", t, func() {
   129  		req := &pb.UpdateBuildRequest{Build: &pb.Build{Id: 1}}
   130  		req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.output.gitiles_commit"}}
   131  		req.Build.Output = &pb.Build_Output{GitilesCommit: &pb.GitilesCommit{
   132  			Project: "project",
   133  			Host:    "host",
   134  			Id:      "id",
   135  		}}
   136  		So(validateUpdate(ctx, req, nil), ShouldErrLike, "ref is required")
   137  	})
   138  
   139  	Convey("validate output.properties", t, func() {
   140  		req := &pb.UpdateBuildRequest{Build: &pb.Build{Id: 1}}
   141  		req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.output.properties"}}
   142  
   143  		Convey("succeeds", func() {
   144  			props, _ := structpb.NewStruct(map[string]any{"key": "value"})
   145  			req.Build.Output = &pb.Build_Output{Properties: props}
   146  			So(validateUpdate(ctx, req, nil), ShouldBeNil)
   147  		})
   148  
   149  		Convey("fails", func() {
   150  			props, _ := structpb.NewStruct(map[string]any{"key": nil})
   151  			req.Build.Output = &pb.Build_Output{Properties: props}
   152  			So(validateUpdate(ctx, req, nil), ShouldErrLike, "value is not set")
   153  		})
   154  	})
   155  
   156  	Convey("validate output.status", t, func() {
   157  		req := &pb.UpdateBuildRequest{Build: &pb.Build{Id: 1}}
   158  		req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.output.status"}}
   159  
   160  		Convey("succeeds", func() {
   161  			req.Build.Output = &pb.Build_Output{Status: pb.Status_SUCCESS}
   162  			So(validateUpdate(ctx, req, nil), ShouldBeNil)
   163  		})
   164  
   165  		Convey("fails", func() {
   166  			req.Build.Output = &pb.Build_Output{Status: pb.Status_SCHEDULED}
   167  			So(validateUpdate(ctx, req, nil), ShouldErrLike, "build.output.status: invalid status SCHEDULED for UpdateBuild")
   168  		})
   169  	})
   170  
   171  	Convey("validate output.summary_markdown", t, func() {
   172  		req := &pb.UpdateBuildRequest{Build: &pb.Build{Id: 1}}
   173  		req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.output.summary_markdown"}}
   174  		req.Build.Output = &pb.Build_Output{SummaryMarkdown: strings.Repeat("☕", protoutil.SummaryMarkdownMaxLength)}
   175  		So(validateUpdate(ctx, req, nil), ShouldErrLike, "too big to accept")
   176  	})
   177  
   178  	Convey("validate output without sub masks", t, func() {
   179  		req := &pb.UpdateBuildRequest{Build: &pb.Build{Id: 1}}
   180  		req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.output"}}
   181  		Convey("ok", func() {
   182  			props, _ := structpb.NewStruct(map[string]any{"key": "value"})
   183  			req.Build.Output = &pb.Build_Output{
   184  				Properties: props,
   185  				GitilesCommit: &pb.GitilesCommit{
   186  					Host:     "host",
   187  					Project:  "project",
   188  					Ref:      "refs/",
   189  					Position: 1,
   190  				},
   191  				SummaryMarkdown: "summary",
   192  			}
   193  			So(validateUpdate(ctx, req, nil), ShouldBeNil)
   194  		})
   195  		Convey("properties is invalid", func() {
   196  			props, _ := structpb.NewStruct(map[string]any{"key": nil})
   197  			req.Build.Output = &pb.Build_Output{
   198  				Properties:      props,
   199  				SummaryMarkdown: "summary",
   200  			}
   201  			So(validateUpdate(ctx, req, nil), ShouldErrLike, "value is not set")
   202  		})
   203  		Convey("summary_markdown is invalid", func() {
   204  			req.Build.Output = &pb.Build_Output{
   205  				SummaryMarkdown: strings.Repeat("☕", protoutil.SummaryMarkdownMaxLength),
   206  			}
   207  			So(validateUpdate(ctx, req, nil), ShouldErrLike, "too big to accept")
   208  		})
   209  		Convey("gitiles_commit is invalid", func() {
   210  			req.Build.Output = &pb.Build_Output{
   211  				GitilesCommit: &pb.GitilesCommit{
   212  					Host:     "host",
   213  					Project:  "project",
   214  					Position: 1,
   215  				},
   216  			}
   217  			So(validateUpdate(ctx, req, nil), ShouldErrLike, "ref is required")
   218  		})
   219  	})
   220  
   221  	Convey("validate steps", t, func() {
   222  		ts := timestamppb.New(testclock.TestRecentTimeUTC)
   223  		bs := &model.BuildSteps{ID: 1}
   224  		req := &pb.UpdateBuildRequest{Build: &pb.Build{Id: 1}}
   225  		req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.steps"}}
   226  
   227  		Convey("succeeds", func() {
   228  			req.Build.Steps = []*pb.Step{
   229  				{Name: "step1", Status: pb.Status_SUCCESS, StartTime: ts, EndTime: ts},
   230  				{Name: "step2", Status: pb.Status_SUCCESS, StartTime: ts, EndTime: ts},
   231  			}
   232  			So(validateUpdate(ctx, req, bs), ShouldBeNil)
   233  		})
   234  
   235  		Convey("fails with duplicates", func() {
   236  			ts := timestamppb.New(testclock.TestRecentTimeUTC)
   237  			req.Build.Steps = []*pb.Step{
   238  				{Name: "step1", Status: pb.Status_SUCCESS, StartTime: ts, EndTime: ts},
   239  				{Name: "step1", Status: pb.Status_SUCCESS, StartTime: ts, EndTime: ts},
   240  			}
   241  			So(validateUpdate(ctx, req, bs), ShouldErrLike, `duplicate: "step1"`)
   242  		})
   243  
   244  		Convey("with a parent step", func() {
   245  			Convey("before child", func() {
   246  				req.Build.Steps = []*pb.Step{
   247  					{Name: "parent", Status: pb.Status_SUCCESS, StartTime: ts, EndTime: ts},
   248  					{Name: "parent|child", Status: pb.Status_SUCCESS, StartTime: ts, EndTime: ts},
   249  				}
   250  				So(validateUpdate(ctx, req, bs), ShouldBeNil)
   251  			})
   252  			Convey("after child", func() {
   253  				req.Build.Steps = []*pb.Step{
   254  					{Name: "parent|child", Status: pb.Status_SUCCESS, StartTime: ts, EndTime: ts},
   255  					{Name: "parent", Status: pb.Status_SUCCESS, StartTime: ts, EndTime: ts},
   256  				}
   257  				So(validateUpdate(ctx, req, bs), ShouldErrLike, `parent of "parent|child" must precede`)
   258  			})
   259  		})
   260  	})
   261  
   262  	Convey("validate agent output", t, func() {
   263  		req := &pb.UpdateBuildRequest{
   264  			Build: &pb.Build{
   265  				Id: 1,
   266  				Infra: &pb.BuildInfra{
   267  					Buildbucket: &pb.BuildInfra_Buildbucket{
   268  						Agent: &pb.BuildInfra_Buildbucket_Agent{},
   269  					},
   270  				},
   271  			},
   272  		}
   273  		req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.infra.buildbucket.agent.output"}}
   274  
   275  		Convey("empty", func() {
   276  			So(validateUpdate(ctx, req, nil), ShouldErrLike, "agent output is not set while its field path appears in update_mask")
   277  		})
   278  
   279  		Convey("invalid cipd", func() {
   280  			// wrong or unresolved version
   281  			req.Build.Infra.Buildbucket.Agent.Output = &pb.BuildInfra_Buildbucket_Agent_Output{
   282  				ResolvedData: map[string]*pb.ResolvedDataRef{
   283  					"cipd": {
   284  						DataType: &pb.ResolvedDataRef_Cipd{
   285  							Cipd: &pb.ResolvedDataRef_CIPD{
   286  								Specs: []*pb.ResolvedDataRef_CIPD_PkgSpec{{Package: "package", Version: "unresolved_v"}},
   287  							},
   288  						},
   289  					},
   290  				},
   291  			}
   292  			So(validateUpdate(ctx, req, nil), ShouldErrLike, `build.infra.buildbucket.agent.output: cipd.version: not a valid package instance ID "unresolved_v"`)
   293  
   294  			// wrong or unresolved package name
   295  			req.Build.Infra.Buildbucket.Agent.Output = &pb.BuildInfra_Buildbucket_Agent_Output{
   296  				ResolvedData: map[string]*pb.ResolvedDataRef{
   297  					"cipd": {
   298  						DataType: &pb.ResolvedDataRef_Cipd{
   299  							Cipd: &pb.ResolvedDataRef_CIPD{
   300  								Specs: []*pb.ResolvedDataRef_CIPD_PkgSpec{{Package: "infra/${platform}", Version: "GwXmwYBjad-WzXEyWWn8HkDsizOPFSH_gjJ35zQaA8IC"}},
   301  							},
   302  						},
   303  					},
   304  				},
   305  			}
   306  			So(validateUpdate(ctx, req, nil), ShouldErrLike, `cipd.package: invalid package name "infra/${platform}"`)
   307  
   308  			// build.status and agent.output.status conflicts
   309  			req.Build.Status = pb.Status_CANCELED
   310  			req.Build.Infra.Buildbucket.Agent.Output.Status = pb.Status_STARTED
   311  			So(validateUpdate(ctx, req, nil), ShouldErrLike, "build is in an ended status while agent output status is not ended")
   312  		})
   313  
   314  		Convey("valid", func() {
   315  			req.Build.Infra.Buildbucket.Agent.Output = &pb.BuildInfra_Buildbucket_Agent_Output{
   316  				Status:        pb.Status_SUCCESS,
   317  				AgentPlatform: "linux-amd64",
   318  				ResolvedData: map[string]*pb.ResolvedDataRef{
   319  					"cipd": {
   320  						DataType: &pb.ResolvedDataRef_Cipd{
   321  							Cipd: &pb.ResolvedDataRef_CIPD{
   322  								Specs: []*pb.ResolvedDataRef_CIPD_PkgSpec{
   323  									{Package: "infra/tools/git/linux-amd64", Version: "GwXmwYBjad-WzXEyWWn8HkDsizOPFSH_gjJ35zQaA8IC"}},
   324  							},
   325  						},
   326  					},
   327  				},
   328  			}
   329  			So(validateUpdate(ctx, req, nil), ShouldBeNil)
   330  		})
   331  
   332  	})
   333  
   334  	Convey("validate agent purpose", t, func() {
   335  		req := &pb.UpdateBuildRequest{
   336  			Build: &pb.Build{
   337  				Id: 1,
   338  				Infra: &pb.BuildInfra{
   339  					Buildbucket: &pb.BuildInfra_Buildbucket{
   340  						Agent: &pb.BuildInfra_Buildbucket_Agent{},
   341  					},
   342  				},
   343  			},
   344  		}
   345  		req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.infra.buildbucket.agent.purposes"}}
   346  
   347  		datastore.GetTestable(ctx).AutoIndex(true)
   348  		datastore.GetTestable(ctx).Consistent(true)
   349  		So(datastore.Put(ctx, &model.BuildInfra{
   350  			Build: datastore.KeyForObj(ctx, &model.Build{ID: req.Build.Id}),
   351  			Proto: &pb.BuildInfra{
   352  				Buildbucket: &pb.BuildInfra_Buildbucket{
   353  					Agent: &pb.BuildInfra_Buildbucket_Agent{
   354  						Input: &pb.BuildInfra_Buildbucket_Agent_Input{
   355  							Data: map[string]*pb.InputDataRef{"p1": {}},
   356  						},
   357  					},
   358  				},
   359  			},
   360  		}), ShouldBeNil)
   361  
   362  		Convey("nil", func() {
   363  			So(validateUpdate(ctx, req, nil), ShouldErrLike, "build.infra.buildbucket.agent.purposes: not set")
   364  		})
   365  
   366  		Convey("invalid agent purpose", func() {
   367  			req.Build.Infra.Buildbucket.Agent.Purposes = map[string]pb.BuildInfra_Buildbucket_Agent_Purpose{
   368  				"random_p": pb.BuildInfra_Buildbucket_Agent_PURPOSE_EXE_PAYLOAD,
   369  			}
   370  			So(validateUpdate(ctx, req, nil), ShouldErrLike, "build.infra.buildbucket.agent.purposes: Invalid path random_p - not in either input or output dataRef")
   371  		})
   372  
   373  		Convey("valid", func() {
   374  			// in input data.
   375  			req.Build.Infra.Buildbucket.Agent.Purposes = map[string]pb.BuildInfra_Buildbucket_Agent_Purpose{
   376  				"p1": pb.BuildInfra_Buildbucket_Agent_PURPOSE_EXE_PAYLOAD,
   377  			}
   378  			So(validateUpdate(ctx, req, nil), ShouldBeNil)
   379  
   380  			// in output data
   381  			req.UpdateMask.Paths = append(req.UpdateMask.Paths, "build.infra.buildbucket.agent.output")
   382  			req.Build.Infra.Buildbucket.Agent.Output = &pb.BuildInfra_Buildbucket_Agent_Output{
   383  				ResolvedData: map[string]*pb.ResolvedDataRef{
   384  					"output_p1": {}},
   385  			}
   386  			req.Build.Infra.Buildbucket.Agent.Purposes = map[string]pb.BuildInfra_Buildbucket_Agent_Purpose{
   387  				"output_p1": pb.BuildInfra_Buildbucket_Agent_PURPOSE_EXE_PAYLOAD,
   388  			}
   389  			So(validateUpdate(ctx, req, nil), ShouldBeNil)
   390  		})
   391  	})
   392  }
   393  
   394  func TestValidateStep(t *testing.T) {
   395  	t.Parallel()
   396  
   397  	Convey("validate", t, func() {
   398  		ts := timestamppb.New(testclock.TestRecentTimeUTC)
   399  		step := &pb.Step{Name: "step1"}
   400  		bStatus := pb.Status_STARTED
   401  
   402  		Convey("with status unspecified", func() {
   403  			step.Status = pb.Status_STATUS_UNSPECIFIED
   404  			So(validateStep(step, nil, bStatus), ShouldErrLike, "status: is unspecified or unknown")
   405  		})
   406  
   407  		Convey("with status ENDED_MASK", func() {
   408  			step.Status = pb.Status_ENDED_MASK
   409  			So(validateStep(step, nil, bStatus), ShouldErrLike, "status: must not be ENDED_MASK")
   410  		})
   411  
   412  		Convey("with non-terminal status", func() {
   413  			Convey("without start_time, when should have", func() {
   414  				step.Status = pb.Status_STARTED
   415  				So(validateStep(step, nil, bStatus), ShouldErrLike, `start_time: required by status "STARTED"`)
   416  			})
   417  
   418  			Convey("with start_time, when should not have", func() {
   419  				step.Status = pb.Status_SCHEDULED
   420  				step.StartTime = ts
   421  				So(validateStep(step, nil, bStatus), ShouldErrLike, `start_time: must not be specified for status "SCHEDULED"`)
   422  			})
   423  
   424  			Convey("with terminal build status", func() {
   425  				bStatus = pb.Status_SUCCESS
   426  				step.Status = pb.Status_STARTED
   427  				So(validateStep(step, nil, bStatus), ShouldErrLike, `status: cannot be "STARTED" because the build has a terminal status "SUCCESS"`)
   428  			})
   429  
   430  		})
   431  
   432  		Convey("with terminal status", func() {
   433  			step.Status = pb.Status_INFRA_FAILURE
   434  
   435  			Convey("missing start_time, but end_time", func() {
   436  				step.EndTime = ts
   437  				So(validateStep(step, nil, bStatus), ShouldErrLike, `start_time: required by status "INFRA_FAILURE"`)
   438  			})
   439  
   440  			Convey("missing end_time", func() {
   441  				step.StartTime = ts
   442  				So(validateStep(step, nil, bStatus), ShouldErrLike, "end_time: must have both or neither end_time and a terminal status")
   443  			})
   444  
   445  			Convey("end_time is before start_time", func() {
   446  				step.EndTime = ts
   447  				sts := timestamppb.New(testclock.TestRecentTimeUTC.AddDate(0, 0, 1))
   448  				step.StartTime = sts
   449  				So(validateStep(step, nil, bStatus), ShouldErrLike, "end_time: is before the start_time")
   450  			})
   451  		})
   452  
   453  		Convey("with logs", func() {
   454  			step.Status = pb.Status_STARTED
   455  			step.StartTime = ts
   456  
   457  			Convey("missing name", func() {
   458  				step.Logs = []*pb.Log{{Url: "url", ViewUrl: "view_url"}}
   459  				So(validateStep(step, nil, bStatus), ShouldErrLike, "logs[0].name: required")
   460  			})
   461  
   462  			Convey("missing url", func() {
   463  				step.Logs = []*pb.Log{{Name: "name", ViewUrl: "view_url"}}
   464  				So(validateStep(step, nil, bStatus), ShouldErrLike, "logs[0].url: required")
   465  			})
   466  
   467  			Convey("missing view_url", func() {
   468  				step.Logs = []*pb.Log{{Name: "name", Url: "url"}}
   469  				So(validateStep(step, nil, bStatus), ShouldErrLike, "logs[0].view_url: required")
   470  			})
   471  
   472  			Convey("duplicate name", func() {
   473  				step.Logs = []*pb.Log{
   474  					{Name: "name", Url: "url", ViewUrl: "view_url"},
   475  					{Name: "name", Url: "url", ViewUrl: "view_url"},
   476  				}
   477  				So(validateStep(step, nil, bStatus), ShouldErrLike, `logs[1].name: duplicate: "name"`)
   478  			})
   479  		})
   480  
   481  		Convey("with tags", func() {
   482  			step.Status = pb.Status_STARTED
   483  			step.StartTime = ts
   484  
   485  			Convey("missing key", func() {
   486  				step.Tags = []*pb.StringPair{{Value: "hi"}}
   487  				So(validateStep(step, nil, bStatus), ShouldErrLike, "tags[0].key: required")
   488  			})
   489  
   490  			Convey("reserved key", func() {
   491  				step.Tags = []*pb.StringPair{{Key: "luci.something", Value: "hi"}}
   492  				So(validateStep(step, nil, bStatus), ShouldErrLike, "tags[0].key: reserved prefix")
   493  			})
   494  
   495  			Convey("missing value", func() {
   496  				step.Tags = []*pb.StringPair{{Key: "my-service.tag"}}
   497  				So(validateStep(step, nil, bStatus), ShouldErrLike, "tags[0].value: required")
   498  			})
   499  
   500  			Convey("long key", func() {
   501  				step.Tags = []*pb.StringPair{{
   502  					// len=297
   503  					Key: ("my-service.my-service.my-service.my-service.my-service.my-service.my-service.my-service.my-service." +
   504  						"my-service.my-service.my-service.my-service.my-service.my-service.my-service.my-service.my-service." +
   505  						"my-service.my-service.my-service.my-service.my-service.my-service.my-service.my-service.my-service."),
   506  					Value: "yo",
   507  				}}
   508  				So(validateStep(step, nil, bStatus), ShouldErrLike, "tags[0].key: len > 256")
   509  			})
   510  
   511  			Convey("long value", func() {
   512  				step.Tags = []*pb.StringPair{{Key: "my-service.tag", Value: strings.Repeat("derp", 500)}}
   513  				So(validateStep(step, nil, bStatus), ShouldErrLike, "tags[0].value: len > 1024")
   514  			})
   515  		})
   516  	})
   517  }
   518  
   519  func TestCheckBuildForUpdate(t *testing.T) {
   520  	t.Parallel()
   521  	updateMask := func(req *pb.UpdateBuildRequest) *mask.Mask {
   522  		fm, err := mask.FromFieldMask(req.UpdateMask, req, false, true)
   523  		So(err, ShouldBeNil)
   524  		return fm.MustSubmask("build")
   525  	}
   526  
   527  	Convey("checkBuildForUpdate", t, func() {
   528  		ctx := metrics.WithServiceInfo(memory.Use(context.Background()), "sv", "job", "ins")
   529  		datastore.GetTestable(ctx).AutoIndex(true)
   530  		datastore.GetTestable(ctx).Consistent(true)
   531  
   532  		build := &model.Build{
   533  			ID: 1,
   534  			Proto: &pb.Build{
   535  				Id: 1,
   536  				Builder: &pb.BuilderID{
   537  					Project: "project",
   538  					Bucket:  "bucket",
   539  					Builder: "builder",
   540  				},
   541  				Status: pb.Status_SCHEDULED,
   542  			},
   543  			CreateTime: testclock.TestRecentTimeUTC,
   544  		}
   545  		So(datastore.Put(ctx, build), ShouldBeNil)
   546  		req := &pb.UpdateBuildRequest{Build: &pb.Build{Id: 1}}
   547  
   548  		Convey("works", func() {
   549  			b, err := common.GetBuild(ctx, 1)
   550  			So(err, ShouldBeNil)
   551  			err = checkBuildForUpdate(updateMask(req), req, b)
   552  			So(err, ShouldBeNil)
   553  
   554  			Convey("with build.steps", func() {
   555  				req.Build.Status = pb.Status_STARTED
   556  				req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.status", "build.steps"}}
   557  				b, err := common.GetBuild(ctx, 1)
   558  				So(err, ShouldBeNil)
   559  				err = checkBuildForUpdate(updateMask(req), req, b)
   560  				So(err, ShouldBeNil)
   561  			})
   562  			Convey("with build.output", func() {
   563  				req.Build.Status = pb.Status_STARTED
   564  				req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.status", "build.output"}}
   565  				b, err := common.GetBuild(ctx, 1)
   566  				So(err, ShouldBeNil)
   567  				err = checkBuildForUpdate(updateMask(req), req, b)
   568  				So(err, ShouldBeNil)
   569  			})
   570  		})
   571  
   572  		Convey("fails", func() {
   573  			Convey("if ended", func() {
   574  				build.Proto.Status = pb.Status_SUCCESS
   575  				So(datastore.Put(ctx, build), ShouldBeNil)
   576  				req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.status"}}
   577  				b, err := common.GetBuild(ctx, 1)
   578  				So(err, ShouldBeNil)
   579  				err = checkBuildForUpdate(updateMask(req), req, b)
   580  				So(err, ShouldBeRPCFailedPrecondition, "cannot update an ended build")
   581  			})
   582  
   583  			Convey("with build.steps", func() {
   584  				req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.steps"}}
   585  				b, err := common.GetBuild(ctx, 1)
   586  				So(err, ShouldBeNil)
   587  				err = checkBuildForUpdate(updateMask(req), req, b)
   588  				So(err, ShouldBeRPCInvalidArgument, "cannot update steps of a SCHEDULED build")
   589  			})
   590  			Convey("with build.output", func() {
   591  				req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.output.properties"}}
   592  				b, err := common.GetBuild(ctx, 1)
   593  				So(err, ShouldBeNil)
   594  				err = checkBuildForUpdate(updateMask(req), req, b)
   595  				So(err, ShouldBeRPCInvalidArgument, "cannot update build output fields of a SCHEDULED build")
   596  			})
   597  			Convey("with build.infra.buildbucket.agent.output", func() {
   598  				req.UpdateMask = &field_mask.FieldMask{Paths: []string{"build.infra.buildbucket.agent.output"}}
   599  				b, err := common.GetBuild(ctx, 1)
   600  				So(err, ShouldBeNil)
   601  				err = checkBuildForUpdate(updateMask(req), req, b)
   602  				So(err, ShouldBeRPCInvalidArgument, "cannot update agent output of a SCHEDULED build")
   603  			})
   604  		})
   605  	})
   606  }
   607  
   608  func TestUpdateBuild(t *testing.T) {
   609  
   610  	updateContextForNewBuildToken := func(ctx context.Context, buildID int64) (string, context.Context) {
   611  		newToken, _ := buildtoken.GenerateToken(ctx, buildID, pb.TokenBody_BUILD)
   612  		ctx = metadata.NewIncomingContext(ctx, metadata.Pairs(buildbucket.BuildbucketTokenHeader, newToken))
   613  		return newToken, ctx
   614  	}
   615  	sortTasksByClassName := func(tasks tqtesting.TaskList) {
   616  		sort.Slice(tasks, func(i, j int) bool {
   617  			return tasks[i].Class < tasks[j].Class
   618  		})
   619  	}
   620  
   621  	t.Parallel()
   622  
   623  	getBuildWithDetails := func(ctx context.Context, bid int64) *model.Build {
   624  		b, err := common.GetBuild(ctx, bid)
   625  		So(err, ShouldBeNil)
   626  		// ensure that the below fields were cleared when the build was saved.
   627  		So(b.Proto.Tags, ShouldBeNil)
   628  		So(b.Proto.Steps, ShouldBeNil)
   629  		if b.Proto.Output != nil {
   630  			So(b.Proto.Output.Properties, ShouldBeNil)
   631  		}
   632  		m := model.HardcodedBuildMask("output.properties", "steps", "tags", "infra")
   633  		So(model.LoadBuildDetails(ctx, m, nil, b.Proto), ShouldBeNil)
   634  		return b
   635  	}
   636  
   637  	Convey("UpdateBuild", t, func() {
   638  		srv := &Builds{}
   639  		ctx := memory.Use(context.Background())
   640  		ctx = metrics.WithServiceInfo(ctx, "svc", "job", "ins")
   641  		ctx = installTestSecret(ctx)
   642  
   643  		tk, ctx := updateContextForNewBuildToken(ctx, 1)
   644  		datastore.GetTestable(ctx).AutoIndex(true)
   645  		datastore.GetTestable(ctx).Consistent(true)
   646  		ctx, _ = tsmon.WithDummyInMemory(ctx)
   647  		store := tsmon.Store(ctx)
   648  		ctx = txndefer.FilterRDS(ctx)
   649  		ctx, sch := tq.TestingContext(ctx, nil)
   650  
   651  		t0 := testclock.TestRecentTimeUTC
   652  		ctx, tclock := testclock.UseTime(ctx, t0)
   653  
   654  		// helper function to call UpdateBuild.
   655  		updateBuild := func(ctx context.Context, req *pb.UpdateBuildRequest) error {
   656  			_, err := srv.UpdateBuild(ctx, req)
   657  			return err
   658  		}
   659  
   660  		// create and save a sample build in the datastore
   661  		build := &model.Build{
   662  			ID: 1,
   663  			Proto: &pb.Build{
   664  				Id: 1,
   665  				Builder: &pb.BuilderID{
   666  					Project: "project",
   667  					Bucket:  "bucket",
   668  					Builder: "builder",
   669  				},
   670  				Status: pb.Status_STARTED,
   671  				Output: &pb.Build_Output{
   672  					Status: pb.Status_STARTED,
   673  				},
   674  			},
   675  			CreateTime:  t0,
   676  			UpdateToken: tk,
   677  		}
   678  		bk := datastore.KeyForObj(ctx, build)
   679  		infra := &model.BuildInfra{
   680  			Build: bk,
   681  			Proto: &pb.BuildInfra{
   682  				Buildbucket: &pb.BuildInfra_Buildbucket{
   683  					Hostname: "bbhost",
   684  					Agent: &pb.BuildInfra_Buildbucket_Agent{
   685  						Input: &pb.BuildInfra_Buildbucket_Agent_Input{
   686  							Data: map[string]*pb.InputDataRef{},
   687  						},
   688  					},
   689  				},
   690  			},
   691  		}
   692  		bs := &model.BuildStatus{
   693  			Build:  bk,
   694  			Status: pb.Status_STARTED,
   695  		}
   696  		So(datastore.Put(ctx, build, infra, bs), ShouldBeNil)
   697  
   698  		req := &pb.UpdateBuildRequest{
   699  			Build: &pb.Build{Id: 1, SummaryMarkdown: "summary"},
   700  			UpdateMask: &field_mask.FieldMask{Paths: []string{
   701  				"build.summary_markdown",
   702  			}},
   703  		}
   704  
   705  		Convey("wrong purpose token", func() {
   706  			tk, _ = buildtoken.GenerateToken(ctx, 1, pb.TokenBody_START_BUILD)
   707  			ctx = metadata.NewIncomingContext(ctx, metadata.Pairs(buildbucket.BuildbucketTokenHeader, tk))
   708  			So(updateBuild(ctx, req), ShouldErrLike, "invalid token")
   709  		})
   710  
   711  		Convey("open mask, empty request", func() {
   712  			validMasks := []struct {
   713  				name string
   714  				err  string
   715  			}{
   716  				{"build.output", ""},
   717  				{"build.output.properties", ""},
   718  				{"build.output.status", "invalid status STATUS_UNSPECIFIED"},
   719  				{"build.status", "invalid status STATUS_UNSPECIFIED"},
   720  				{"build.output.status_details", ""},
   721  				{"build.status_details", ""},
   722  				{"build.steps", ""},
   723  				{"build.output.summary_markdown", ""},
   724  				{"build.summary_markdown", ""},
   725  				{"build.tags", ""},
   726  				{"build.output.gitiles_commit", "ref is required"},
   727  			}
   728  			for _, test := range validMasks {
   729  				Convey(test.name, func() {
   730  					req.UpdateMask.Paths[0] = test.name
   731  					err := updateBuild(ctx, req)
   732  					if test.err == "" {
   733  						So(err, ShouldBeNil)
   734  					} else {
   735  						So(err, ShouldErrLike, test.err)
   736  					}
   737  				})
   738  			}
   739  		})
   740  
   741  		Convey("build.update_time is always updated", func() {
   742  			req.UpdateMask = nil
   743  			So(updateBuild(ctx, req), ShouldBeNil)
   744  			b, err := common.GetBuild(ctx, req.Build.Id)
   745  			So(err, ShouldBeNil)
   746  			So(b.Proto.UpdateTime, ShouldResembleProto, timestamppb.New(t0))
   747  			So(b.Proto.Status, ShouldEqual, pb.Status_STARTED)
   748  
   749  			tclock.Add(time.Second)
   750  
   751  			So(updateBuild(ctx, req), ShouldBeNil)
   752  			b, err = common.GetBuild(ctx, req.Build.Id)
   753  			So(err, ShouldBeNil)
   754  			So(b.Proto.UpdateTime, ShouldResembleProto, timestamppb.New(t0.Add(time.Second)))
   755  		})
   756  
   757  		Convey("build.view_url", func() {
   758  			url := "https://redirect.com"
   759  			req.Build.ViewUrl = url
   760  			req.UpdateMask.Paths[0] = "build.view_url"
   761  			So(updateBuild(ctx, req), ShouldBeRPCOK)
   762  			b, err := common.GetBuild(ctx, req.Build.Id)
   763  			So(err, ShouldBeNil)
   764  			So(b.Proto.ViewUrl, ShouldEqual, url)
   765  		})
   766  
   767  		Convey("build.output.properties", func() {
   768  			props, err := structpb.NewStruct(map[string]any{"key": "value"})
   769  			So(err, ShouldBeNil)
   770  			req.Build.Output = &pb.Build_Output{Properties: props}
   771  
   772  			Convey("with mask", func() {
   773  				req.UpdateMask.Paths[0] = "build.output.properties"
   774  				So(updateBuild(ctx, req), ShouldBeRPCOK)
   775  				b := getBuildWithDetails(ctx, req.Build.Id)
   776  				m, err := structpb.NewStruct(map[string]any{"key": "value"})
   777  				So(err, ShouldBeNil)
   778  				So(b.Proto.Output.Properties, ShouldResembleProto, m)
   779  			})
   780  
   781  			Convey("without mask", func() {
   782  				So(updateBuild(ctx, req), ShouldBeRPCOK)
   783  				b := getBuildWithDetails(ctx, req.Build.Id)
   784  				So(b.Proto.Output.Properties, ShouldBeNil)
   785  			})
   786  
   787  		})
   788  
   789  		Convey("build.output.properties large", func() {
   790  			largeProps, err := structpb.NewStruct(map[string]any{})
   791  			So(err, ShouldBeNil)
   792  			k := "laaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaarge_key"
   793  			v := "laaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaarge_value"
   794  			for i := 0; i < 10000; i++ {
   795  				largeProps.Fields[k+strconv.Itoa(i)] = &structpb.Value{
   796  					Kind: &structpb.Value_StringValue{
   797  						StringValue: v,
   798  					},
   799  				}
   800  			}
   801  			So(err, ShouldBeNil)
   802  			req.Build.Output = &pb.Build_Output{Properties: largeProps}
   803  
   804  			Convey("with mask", func() {
   805  				req.UpdateMask.Paths[0] = "build.output"
   806  				So(updateBuild(ctx, req), ShouldBeRPCOK)
   807  				b := getBuildWithDetails(ctx, req.Build.Id)
   808  				So(b.Proto.Output.Properties, ShouldResembleProto, largeProps)
   809  				count, err := datastore.Count(ctx, datastore.NewQuery("PropertyChunk"))
   810  				So(err, ShouldBeNil)
   811  				So(count, ShouldEqual, 1)
   812  			})
   813  
   814  			Convey("without mask", func() {
   815  				So(updateBuild(ctx, req), ShouldBeRPCOK)
   816  				b := getBuildWithDetails(ctx, req.Build.Id)
   817  				So(b.Proto.Output.Properties, ShouldBeNil)
   818  			})
   819  		})
   820  
   821  		Convey("build.steps", func() {
   822  			step := &pb.Step{
   823  				Name:      "step",
   824  				StartTime: &timestamppb.Timestamp{Seconds: 1},
   825  				EndTime:   &timestamppb.Timestamp{Seconds: 12},
   826  				Status:    pb.Status_SUCCESS,
   827  			}
   828  			req.Build.Steps = []*pb.Step{step}
   829  
   830  			Convey("with mask", func() {
   831  				req.UpdateMask.Paths[0] = "build.steps"
   832  				So(updateBuild(ctx, req), ShouldBeRPCOK)
   833  				b := getBuildWithDetails(ctx, req.Build.Id)
   834  				So(b.Proto.Steps[0], ShouldResembleProto, step)
   835  			})
   836  
   837  			Convey("without mask", func() {
   838  				So(updateBuild(ctx, req), ShouldBeRPCOK)
   839  				b := getBuildWithDetails(ctx, req.Build.Id)
   840  				So(b.Proto.Steps, ShouldBeNil)
   841  			})
   842  
   843  			Convey("incomplete steps with non-terminal Build status", func() {
   844  				req.UpdateMask.Paths = []string{"build.status", "build.steps"}
   845  				req.Build.Status = pb.Status_STARTED
   846  				req.Build.Steps[0].Status = pb.Status_STARTED
   847  				req.Build.Steps[0].EndTime = nil
   848  				So(updateBuild(ctx, req), ShouldBeRPCOK)
   849  			})
   850  
   851  			Convey("incomplete steps with terminal Build status", func() {
   852  				req.UpdateMask.Paths = []string{"build.status", "build.steps"}
   853  				req.Build.Status = pb.Status_SUCCESS
   854  
   855  				Convey("with mask", func() {
   856  					req.Build.Steps[0].Status = pb.Status_STARTED
   857  					req.Build.Steps[0].EndTime = nil
   858  
   859  					// Should be rejected.
   860  					msg := `cannot be "STARTED" because the build has a terminal status "SUCCESS"`
   861  					So(updateBuild(ctx, req), ShouldHaveRPCCode, codes.InvalidArgument, msg)
   862  				})
   863  
   864  				Convey("w/o mask", func() {
   865  					// update the build with incomplete steps first.
   866  					req.Build.Status = pb.Status_STARTED
   867  					req.Build.Steps[0].Status = pb.Status_STARTED
   868  					req.Build.Steps[0].EndTime = nil
   869  					So(updateBuild(ctx, req), ShouldBeRPCOK)
   870  
   871  					// update the build again with a terminal status, but w/o step mask.
   872  					req.UpdateMask.Paths = []string{"build.status"}
   873  					req.Build.Status = pb.Status_SUCCESS
   874  					So(updateBuild(ctx, req), ShouldBeRPCOK)
   875  					nbs := &model.BuildStatus{Build: bk}
   876  					err := datastore.Get(ctx, nbs)
   877  					So(err, ShouldBeNil)
   878  					So(nbs.Status, ShouldEqual, pb.Status_SUCCESS)
   879  
   880  					// the step should have been cancelled.
   881  					b := getBuildWithDetails(ctx, req.Build.Id)
   882  					expected := &pb.Step{
   883  						Name:      step.Name,
   884  						Status:    pb.Status_CANCELED,
   885  						StartTime: step.StartTime,
   886  						EndTime:   timestamppb.New(t0),
   887  					}
   888  					So(b.Proto.Steps[0], ShouldResembleProto, expected)
   889  				})
   890  			})
   891  		})
   892  
   893  		Convey("build.tags", func() {
   894  			tag := &pb.StringPair{Key: "resultdb", Value: "disabled"}
   895  			req.Build.Tags = []*pb.StringPair{tag}
   896  
   897  			Convey("with mask", func() {
   898  				req.UpdateMask.Paths[0] = "build.tags"
   899  				So(updateBuild(ctx, req), ShouldBeRPCOK)
   900  
   901  				b := getBuildWithDetails(ctx, req.Build.Id)
   902  				expected := []string{strpair.Format("resultdb", "disabled")}
   903  				So(b.Tags, ShouldResemble, expected)
   904  
   905  				// change the value and update it again
   906  				tag.Value = "enabled"
   907  				So(updateBuild(ctx, req), ShouldBeRPCOK)
   908  
   909  				// both tags should exist
   910  				b = getBuildWithDetails(ctx, req.Build.Id)
   911  				expected = append(expected, strpair.Format("resultdb", "enabled"))
   912  				So(b.Tags, ShouldResemble, expected)
   913  			})
   914  
   915  			Convey("without mask", func() {
   916  				So(updateBuild(ctx, req), ShouldBeRPCOK)
   917  				b := getBuildWithDetails(ctx, req.Build.Id)
   918  				So(b.Tags, ShouldBeNil)
   919  			})
   920  		})
   921  
   922  		Convey("build.infra.buildbucket.agent.output", func() {
   923  			agentOutput := &pb.BuildInfra_Buildbucket_Agent_Output{
   924  				Status:        pb.Status_SUCCESS,
   925  				AgentPlatform: "linux-amd64",
   926  				ResolvedData: map[string]*pb.ResolvedDataRef{
   927  					"cipd": {
   928  						DataType: &pb.ResolvedDataRef_Cipd{
   929  							Cipd: &pb.ResolvedDataRef_CIPD{
   930  								Specs: []*pb.ResolvedDataRef_CIPD_PkgSpec{{Package: "infra/tools/git/linux-amd64", Version: "GwXmwYBjad-WzXEyWWn8HkDsizOPFSH_gjJ35zQaA8IC"}},
   931  							},
   932  						},
   933  					},
   934  				},
   935  			}
   936  			req.Build.Infra = &pb.BuildInfra{
   937  				Buildbucket: &pb.BuildInfra_Buildbucket{
   938  					Agent: &pb.BuildInfra_Buildbucket_Agent{
   939  						Output: agentOutput,
   940  					},
   941  				},
   942  			}
   943  			req.Build.Input = &pb.Build_Input{
   944  				Experiments: []string{"luci.buildbucket.agent.cipd_installation"},
   945  			}
   946  
   947  			Convey("with mask", func() {
   948  				req.UpdateMask.Paths[0] = "build.infra.buildbucket.agent.output"
   949  				So(updateBuild(ctx, req), ShouldBeRPCOK)
   950  				b := getBuildWithDetails(ctx, req.Build.Id)
   951  				So(b.Proto.Infra.Buildbucket.Agent.Output, ShouldResembleProto, agentOutput)
   952  			})
   953  
   954  			Convey("without mask", func() {
   955  				So(updateBuild(ctx, req), ShouldBeRPCOK)
   956  				b := getBuildWithDetails(ctx, req.Build.Id)
   957  				So(b.Proto.Infra.Buildbucket.Agent.Output, ShouldBeNil)
   958  			})
   959  
   960  		})
   961  
   962  		Convey("build.infra.buildbucket.agent.purposes", func() {
   963  			req.Build.Infra = &pb.BuildInfra{
   964  				Buildbucket: &pb.BuildInfra_Buildbucket{
   965  					Agent: &pb.BuildInfra_Buildbucket_Agent{
   966  						Purposes: map[string]pb.BuildInfra_Buildbucket_Agent_Purpose{
   967  							"p1": pb.BuildInfra_Buildbucket_Agent_PURPOSE_EXE_PAYLOAD,
   968  						},
   969  						Output: &pb.BuildInfra_Buildbucket_Agent_Output{
   970  							ResolvedData: map[string]*pb.ResolvedDataRef{
   971  								"p1": {},
   972  							},
   973  						},
   974  					},
   975  				},
   976  			}
   977  
   978  			Convey("with mask", func() {
   979  				req.UpdateMask = &field_mask.FieldMask{Paths: []string{
   980  					"build.infra.buildbucket.agent.output",
   981  					"build.infra.buildbucket.agent.purposes",
   982  				}}
   983  				So(updateBuild(ctx, req), ShouldBeRPCOK)
   984  				b := getBuildWithDetails(ctx, req.Build.Id)
   985  				So(b.Proto.Infra.Buildbucket.Agent.Purposes["p1"], ShouldEqual, pb.BuildInfra_Buildbucket_Agent_PURPOSE_EXE_PAYLOAD)
   986  			})
   987  
   988  			Convey("without mask", func() {
   989  				So(updateBuild(ctx, req), ShouldBeRPCOK)
   990  				b := getBuildWithDetails(ctx, req.Build.Id)
   991  				So(b.Proto.Infra.Buildbucket.Agent.Purposes, ShouldBeNil)
   992  			})
   993  		})
   994  
   995  		Convey("build-start event", func() {
   996  			Convey("Status_STARTED w/o status change", func() {
   997  				req.UpdateMask.Paths[0] = "build.status"
   998  				req.Build.Status = pb.Status_STARTED
   999  				So(updateBuild(ctx, req), ShouldBeRPCOK)
  1000  
  1001  				// no TQ tasks should be scheduled.
  1002  				So(sch.Tasks(), ShouldBeEmpty)
  1003  
  1004  				// no metric update, either.
  1005  				So(store.Get(ctx, metrics.V1.BuildCountStarted, time.Time{}, fv(false)), ShouldEqual, nil)
  1006  			})
  1007  
  1008  			Convey("Status_STARTED w/ status change", func() {
  1009  				// create a sample task with SCHEDULED.
  1010  				build.Proto.Id++
  1011  				build.ID++
  1012  				tk, ctx = updateContextForNewBuildToken(ctx, build.ID)
  1013  				build.UpdateToken = tk
  1014  				build.Proto.Status, build.Status = pb.Status_SCHEDULED, pb.Status_SCHEDULED
  1015  				buildStatus := &model.BuildStatus{
  1016  					Build:  datastore.KeyForObj(ctx, build),
  1017  					Status: pb.Status_SCHEDULED,
  1018  				}
  1019  				So(datastore.Put(ctx, build, buildStatus), ShouldBeNil)
  1020  
  1021  				// update it with STARTED
  1022  				req.Build.Id = build.ID
  1023  				req.UpdateMask.Paths[0] = "build.status"
  1024  				req.Build.Status = pb.Status_STARTED
  1025  				So(updateBuild(ctx, req), ShouldBeRPCOK)
  1026  
  1027  				// TQ tasks for pubsub-notification.
  1028  				tasks := sch.Tasks()
  1029  				sortTasksByClassName(tasks)
  1030  				So(tasks, ShouldHaveLength, 2)
  1031  				So(tasks[0].Payload.(*taskdefs.NotifyPubSub).GetBuildId(), ShouldEqual, build.ID)
  1032  				So(tasks[1].Payload.(*taskdefs.NotifyPubSubGoProxy).GetBuildId(), ShouldEqual, 2)
  1033  				So(tasks[1].Payload.(*taskdefs.NotifyPubSubGoProxy).GetProject(), ShouldEqual, "project")
  1034  
  1035  				// BuildStarted metric should be set 1.
  1036  				So(store.Get(ctx, metrics.V1.BuildCountStarted, time.Time{}, fv(false)), ShouldEqual, 1)
  1037  
  1038  				// BuildStatus should be updated.
  1039  				buildStatus = &model.BuildStatus{Build: datastore.KeyForObj(ctx, build)}
  1040  				So(datastore.Get(ctx, buildStatus), ShouldBeNil)
  1041  				So(buildStatus.Status, ShouldEqual, pb.Status_STARTED)
  1042  			})
  1043  
  1044  			Convey("output.status Status_STARTED w/o status change", func() {
  1045  				req.UpdateMask.Paths[0] = "build.output.status"
  1046  				req.Build.Output = &pb.Build_Output{Status: pb.Status_STARTED}
  1047  				So(updateBuild(ctx, req), ShouldBeRPCOK)
  1048  
  1049  				// no TQ tasks should be scheduled.
  1050  				So(sch.Tasks(), ShouldBeEmpty)
  1051  
  1052  				// no metric update, either.
  1053  				So(store.Get(ctx, metrics.V1.BuildCountStarted, time.Time{}, fv(false)), ShouldEqual, nil)
  1054  			})
  1055  
  1056  			Convey("output.status Status_STARTED w/ status change", func() {
  1057  				// create a sample task with SCHEDULED.
  1058  				build.Proto.Id++
  1059  				build.ID++
  1060  				tk, ctx = updateContextForNewBuildToken(ctx, build.ID)
  1061  				build.UpdateToken = tk
  1062  				build.Proto.Status, build.Status = pb.Status_SCHEDULED, pb.Status_SCHEDULED
  1063  				buildStatus := &model.BuildStatus{
  1064  					Build:  datastore.KeyForObj(ctx, build),
  1065  					Status: pb.Status_SCHEDULED,
  1066  				}
  1067  				So(datastore.Put(ctx, build, buildStatus), ShouldBeNil)
  1068  
  1069  				// update it with STARTED
  1070  				req.Build.Id = build.ID
  1071  				req.UpdateMask.Paths[0] = "build.output.status"
  1072  				req.Build.Output = &pb.Build_Output{Status: pb.Status_STARTED}
  1073  				So(updateBuild(ctx, req), ShouldBeRPCOK)
  1074  
  1075  				// TQ tasks for pubsub-notification.
  1076  				tasks := sch.Tasks()
  1077  				sortTasksByClassName(tasks)
  1078  				So(tasks, ShouldHaveLength, 2)
  1079  				So(tasks[0].Payload.(*taskdefs.NotifyPubSub).GetBuildId(), ShouldEqual, build.ID)
  1080  				So(tasks[1].Payload.(*taskdefs.NotifyPubSubGoProxy).GetBuildId(), ShouldEqual, 2)
  1081  				So(tasks[1].Payload.(*taskdefs.NotifyPubSubGoProxy).GetProject(), ShouldEqual, "project")
  1082  
  1083  				// BuildStarted metric should be set 1.
  1084  				So(store.Get(ctx, metrics.V1.BuildCountStarted, time.Time{}, fv(false)), ShouldEqual, 1)
  1085  
  1086  				// BuildStatus should be updated.
  1087  				buildStatus = &model.BuildStatus{Build: datastore.KeyForObj(ctx, build)}
  1088  				So(datastore.Get(ctx, build, buildStatus), ShouldBeNil)
  1089  				So(buildStatus.Status, ShouldEqual, pb.Status_STARTED)
  1090  				So(build.Proto.Status, ShouldEqual, pb.Status_STARTED)
  1091  			})
  1092  		})
  1093  
  1094  		Convey("build-completion event", func() {
  1095  			Convey("Status_SUCCESSS w/ status change", func() {
  1096  				req.UpdateMask.Paths[0] = "build.status"
  1097  				req.Build.Status = pb.Status_SUCCESS
  1098  				So(updateBuild(ctx, req), ShouldBeRPCOK)
  1099  
  1100  				// TQ tasks for pubsub-notification, bq-export, and invocation-finalization.
  1101  				tasks := sch.Tasks()
  1102  				So(tasks, ShouldHaveLength, 4)
  1103  				sum := 0
  1104  				for _, task := range tasks {
  1105  					switch v := task.Payload.(type) {
  1106  					case *taskdefs.NotifyPubSub:
  1107  						sum++
  1108  						So(v.GetBuildId(), ShouldEqual, req.Build.Id)
  1109  					case *taskdefs.ExportBigQuery:
  1110  						sum += 2
  1111  						So(v.GetBuildId(), ShouldEqual, req.Build.Id)
  1112  					case *taskdefs.FinalizeResultDBGo:
  1113  						sum += 4
  1114  						So(v.GetBuildId(), ShouldEqual, req.Build.Id)
  1115  					case *taskdefs.NotifyPubSubGoProxy:
  1116  						sum += 8
  1117  						So(v.GetBuildId(), ShouldEqual, req.Build.Id)
  1118  					default:
  1119  						panic("invalid task payload")
  1120  					}
  1121  				}
  1122  				So(sum, ShouldEqual, 15)
  1123  
  1124  				// BuildCompleted metric should be set to 1 with SUCCESS.
  1125  				fvs := fv(model.Success.String(), "", "", false)
  1126  				So(store.Get(ctx, metrics.V1.BuildCountCompleted, time.Time{}, fvs), ShouldEqual, 1)
  1127  			})
  1128  			Convey("output.status Status_SUCCESSS w/ status change", func() {
  1129  				buildStatus := &model.BuildStatus{
  1130  					Build:  datastore.KeyForObj(ctx, build),
  1131  					Status: pb.Status_STARTED,
  1132  				}
  1133  				So(datastore.Put(ctx, build, buildStatus), ShouldBeNil)
  1134  
  1135  				req.UpdateMask.Paths[0] = "build.output.status"
  1136  				req.Build.Output = &pb.Build_Output{Status: pb.Status_SUCCESS}
  1137  				So(updateBuild(ctx, req), ShouldBeRPCOK)
  1138  
  1139  				// TQ tasks for pubsub-notification, bq-export, and invocation-finalization.
  1140  				tasks := sch.Tasks()
  1141  				So(tasks, ShouldHaveLength, 0)
  1142  
  1143  				// BuildCompleted metric should not be set.
  1144  				fvs := fv(model.Success.String(), "", "", false)
  1145  				So(store.Get(ctx, metrics.V1.BuildCountCompleted, time.Time{}, fvs), ShouldBeNil)
  1146  
  1147  				// BuildStatus should not be updated.
  1148  				buildStatus = &model.BuildStatus{Build: datastore.KeyForObj(ctx, build)}
  1149  				So(datastore.Get(ctx, build, buildStatus), ShouldBeNil)
  1150  				So(buildStatus.Status, ShouldEqual, pb.Status_STARTED)
  1151  				So(build.Proto.Status, ShouldEqual, pb.Status_STARTED)
  1152  			})
  1153  			Convey("update output without output.status should not affect overall status", func() {
  1154  				buildStatus := &model.BuildStatus{
  1155  					Build:  datastore.KeyForObj(ctx, build),
  1156  					Status: pb.Status_STARTED,
  1157  				}
  1158  				So(datastore.Put(ctx, build, buildStatus), ShouldBeNil)
  1159  
  1160  				req.UpdateMask.Paths[0] = "build.output"
  1161  				req.Build.Output = &pb.Build_Output{
  1162  					Status: pb.Status_SUCCESS,
  1163  				}
  1164  				req.Build.Status = pb.Status_SUCCESS
  1165  				So(updateBuild(ctx, req), ShouldBeRPCOK)
  1166  
  1167  				// TQ tasks for pubsub-notification, bq-export, and invocation-finalization.
  1168  				tasks := sch.Tasks()
  1169  				So(tasks, ShouldHaveLength, 0)
  1170  
  1171  				// BuildCompleted metric should not be set.
  1172  				fvs := fv(model.Success.String(), "", "", false)
  1173  				So(store.Get(ctx, metrics.V1.BuildCountCompleted, time.Time{}, fvs), ShouldBeNil)
  1174  
  1175  				// BuildStatus should not be updated.
  1176  				buildStatus = &model.BuildStatus{Build: datastore.KeyForObj(ctx, build)}
  1177  				So(datastore.Get(ctx, build, buildStatus), ShouldBeNil)
  1178  				So(buildStatus.Status, ShouldEqual, pb.Status_STARTED)
  1179  				So(build.Proto.Status, ShouldEqual, pb.Status_STARTED)
  1180  				So(build.Proto.Output.Status, ShouldEqual, pb.Status_STARTED)
  1181  			})
  1182  		})
  1183  
  1184  		Convey("read mask", func() {
  1185  			Convey("w/ read mask", func() {
  1186  				req.UpdateMask.Paths[0] = "build.status"
  1187  				req.Build.Status = pb.Status_SUCCESS
  1188  				req.Mask = &pb.BuildMask{
  1189  					Fields: &fieldmaskpb.FieldMask{
  1190  						Paths: []string{
  1191  							"status",
  1192  						},
  1193  					},
  1194  				}
  1195  				b, err := srv.UpdateBuild(ctx, req)
  1196  				So(err, ShouldBeNil)
  1197  				So(b, ShouldResembleProto, &pb.Build{
  1198  					Status: pb.Status_SUCCESS,
  1199  				})
  1200  			})
  1201  		})
  1202  
  1203  		Convey("update build with parent", func() {
  1204  			parent := &model.Build{
  1205  				ID: 10,
  1206  				Proto: &pb.Build{
  1207  					Id: 10,
  1208  					Builder: &pb.BuilderID{
  1209  						Project: "project",
  1210  						Bucket:  "bucket",
  1211  						Builder: "builder",
  1212  					},
  1213  					Status: pb.Status_SUCCESS,
  1214  				},
  1215  				CreateTime:  t0,
  1216  				UpdateToken: tk,
  1217  			}
  1218  			ps := &model.BuildStatus{
  1219  				Build:  datastore.KeyForObj(ctx, parent),
  1220  				Status: pb.Status_STARTED,
  1221  			}
  1222  			So(datastore.Put(ctx, parent, ps), ShouldBeNil)
  1223  
  1224  			Convey("child can outlive parent", func() {
  1225  				child := &model.Build{
  1226  					ID: 11,
  1227  					Proto: &pb.Build{
  1228  						Id: 11,
  1229  						Builder: &pb.BuilderID{
  1230  							Project: "project",
  1231  							Bucket:  "bucket",
  1232  							Builder: "builder",
  1233  						},
  1234  						Status:           pb.Status_SCHEDULED,
  1235  						AncestorIds:      []int64{10},
  1236  						CanOutliveParent: true,
  1237  					},
  1238  					CreateTime:  t0,
  1239  					UpdateToken: tk,
  1240  				}
  1241  				So(datastore.Put(ctx, child), ShouldBeNil)
  1242  				req.UpdateMask.Paths[0] = "build.status"
  1243  				req.Build.Status = pb.Status_STARTED
  1244  				So(updateBuild(ctx, req), ShouldBeRPCOK)
  1245  			})
  1246  
  1247  			Convey("child cannot outlive parent", func() {
  1248  				child := &model.Build{
  1249  					ID: 11,
  1250  					Proto: &pb.Build{
  1251  						Id: 11,
  1252  						Builder: &pb.BuilderID{
  1253  							Project: "project",
  1254  							Bucket:  "bucket",
  1255  							Builder: "builder",
  1256  						},
  1257  						Status:           pb.Status_SCHEDULED,
  1258  						AncestorIds:      []int64{10},
  1259  						CanOutliveParent: false,
  1260  					},
  1261  					CreateTime:  t0,
  1262  					UpdateToken: tk,
  1263  				}
  1264  				tk, ctx = updateContextForNewBuildToken(ctx, 11)
  1265  				child.UpdateToken = tk
  1266  				cs := &model.BuildStatus{
  1267  					Build:  datastore.KeyForObj(ctx, child),
  1268  					Status: pb.Status_STARTED,
  1269  				}
  1270  				So(datastore.Put(ctx, child, cs), ShouldBeNil)
  1271  
  1272  				Convey("request is to terminate the child", func() {
  1273  					req.UpdateMask.Paths[0] = "build.status"
  1274  					req.Build.Id = 11
  1275  					req.Build.Status = pb.Status_SUCCESS
  1276  					req.Mask = &pb.BuildMask{
  1277  						Fields: &fieldmaskpb.FieldMask{
  1278  							Paths: []string{
  1279  								"status",
  1280  								"cancel_time",
  1281  							},
  1282  						},
  1283  					}
  1284  					build, err := srv.UpdateBuild(ctx, req)
  1285  					So(err, ShouldBeRPCOK)
  1286  					So(build.Status, ShouldEqual, pb.Status_SUCCESS)
  1287  					So(build.CancelTime, ShouldBeNil)
  1288  
  1289  					tasks := sch.Tasks()
  1290  					So(tasks, ShouldHaveLength, 4)
  1291  					sum := 0
  1292  					for _, task := range tasks {
  1293  						switch v := task.Payload.(type) {
  1294  						case *taskdefs.NotifyPubSub:
  1295  							sum++
  1296  							So(v.GetBuildId(), ShouldEqual, req.Build.Id)
  1297  						case *taskdefs.ExportBigQuery:
  1298  							sum += 2
  1299  							So(v.GetBuildId(), ShouldEqual, req.Build.Id)
  1300  						case *taskdefs.FinalizeResultDBGo:
  1301  							sum += 4
  1302  							So(v.GetBuildId(), ShouldEqual, req.Build.Id)
  1303  						case *taskdefs.NotifyPubSubGoProxy:
  1304  							sum += 8
  1305  							So(v.GetBuildId(), ShouldEqual, req.Build.Id)
  1306  						default:
  1307  							panic("invalid task payload")
  1308  						}
  1309  					}
  1310  					So(sum, ShouldEqual, 15)
  1311  
  1312  					// BuildCompleted metric should be set to 1 with SUCCESS.
  1313  					fvs := fv(model.Success.String(), "", "", false)
  1314  					So(store.Get(ctx, metrics.V1.BuildCountCompleted, time.Time{}, fvs), ShouldEqual, 1)
  1315  				})
  1316  
  1317  				Convey("start the cancel process if parent has ended", func() {
  1318  					// Child of the requested build.
  1319  					So(datastore.Put(ctx, &model.Build{
  1320  						ID: 12,
  1321  						Proto: &pb.Build{
  1322  							Id: 12,
  1323  							Builder: &pb.BuilderID{
  1324  								Project: "project",
  1325  								Bucket:  "bucket",
  1326  								Builder: "builder",
  1327  							},
  1328  							AncestorIds:      []int64{11},
  1329  							CanOutliveParent: false,
  1330  						},
  1331  						UpdateToken: tk,
  1332  					}), ShouldBeNil)
  1333  					req.Build.Id = 11
  1334  					req.Build.Status = pb.Status_STARTED
  1335  					req.UpdateMask.Paths[0] = "build.status"
  1336  					req.Mask = &pb.BuildMask{
  1337  						Fields: &fieldmaskpb.FieldMask{
  1338  							Paths: []string{
  1339  								"status",
  1340  								"cancel_time",
  1341  								"cancellation_markdown",
  1342  							},
  1343  						},
  1344  					}
  1345  					build, err := srv.UpdateBuild(ctx, req)
  1346  					So(err, ShouldBeRPCOK)
  1347  					So(build.Status, ShouldEqual, pb.Status_STARTED)
  1348  					So(build.CancelTime.AsTime(), ShouldEqual, t0)
  1349  					So(build.CancellationMarkdown, ShouldEqual, "canceled because its parent 10 has terminated")
  1350  					// One pubsub notification for the status update in the request,
  1351  					// one CancelBuildTask for the requested build,
  1352  					// one CancelBuildTask for the child build.
  1353  					So(sch.Tasks(), ShouldHaveLength, 4)
  1354  
  1355  					// BuildStatus is updated.
  1356  					updatedStatus := &model.BuildStatus{Build: datastore.MakeKey(ctx, "Build", 11)}
  1357  					So(datastore.Get(ctx, updatedStatus), ShouldBeNil)
  1358  					So(updatedStatus.Status, ShouldEqual, pb.Status_STARTED)
  1359  				})
  1360  
  1361  				Convey("start the cancel process if parent is missing", func() {
  1362  					tk, ctx = updateContextForNewBuildToken(ctx, 15)
  1363  					b := &model.Build{
  1364  						ID: 15,
  1365  						Proto: &pb.Build{
  1366  							Id: 15,
  1367  							Builder: &pb.BuilderID{
  1368  								Project: "project",
  1369  								Bucket:  "bucket",
  1370  								Builder: "builder",
  1371  							},
  1372  							AncestorIds:      []int64{3000000},
  1373  							CanOutliveParent: false,
  1374  							Status:           pb.Status_SCHEDULED,
  1375  						},
  1376  						UpdateToken: tk,
  1377  					}
  1378  					buildStatus := &model.BuildStatus{
  1379  						Build:  datastore.KeyForObj(ctx, b),
  1380  						Status: b.Proto.Status,
  1381  					}
  1382  					So(datastore.Put(ctx, b, buildStatus), ShouldBeNil)
  1383  					req.Build.Id = 15
  1384  					req.Build.Status = pb.Status_STARTED
  1385  					req.UpdateMask.Paths[0] = "build.status"
  1386  					req.Mask = &pb.BuildMask{
  1387  						Fields: &fieldmaskpb.FieldMask{
  1388  							Paths: []string{
  1389  								"status",
  1390  								"cancel_time",
  1391  								"cancellation_markdown",
  1392  							},
  1393  						},
  1394  					}
  1395  					build, err := srv.UpdateBuild(ctx, req)
  1396  					So(err, ShouldBeRPCOK)
  1397  					So(build.Status, ShouldEqual, pb.Status_STARTED)
  1398  					So(build.CancelTime.AsTime(), ShouldEqual, t0)
  1399  					So(build.CancellationMarkdown, ShouldEqual, "canceled because its parent 3000000 is missing")
  1400  					So(sch.Tasks(), ShouldHaveLength, 3)
  1401  
  1402  					// BuildStatus is updated.
  1403  					updatedStatus := &model.BuildStatus{Build: datastore.MakeKey(ctx, "Build", 15)}
  1404  					So(datastore.Get(ctx, updatedStatus), ShouldBeNil)
  1405  					So(updatedStatus.Status, ShouldEqual, pb.Status_STARTED)
  1406  				})
  1407  
  1408  				Convey("return err if failed to get parent", func() {
  1409  					So(datastore.Put(ctx, &model.Build{
  1410  						ID: 31,
  1411  						Proto: &pb.Build{
  1412  							Id: 31,
  1413  							Builder: &pb.BuilderID{
  1414  								Project: "project",
  1415  								Bucket:  "bucket",
  1416  								Builder: "builder",
  1417  							},
  1418  							AncestorIds:      []int64{30},
  1419  							CanOutliveParent: false,
  1420  						},
  1421  						UpdateToken: tk,
  1422  					}), ShouldBeNil)
  1423  
  1424  					// Mock datastore.Get failure.
  1425  					var fb featureBreaker.FeatureBreaker
  1426  					ctx, fb = featureBreaker.FilterRDS(ctx, nil)
  1427  					// Break GetMulti will ingest the error to datastore.Get,
  1428  					// directly breaking "Get" doesn't work.
  1429  					fb.BreakFeatures(errors.New("get error"), "GetMulti")
  1430  
  1431  					req.Build.Id = 31
  1432  					req.Build.Status = pb.Status_STARTED
  1433  					tk, ctx = updateContextForNewBuildToken(ctx, 31)
  1434  					req.UpdateMask.Paths[0] = "build.status"
  1435  					_, err := srv.UpdateBuild(ctx, req)
  1436  					So(err, ShouldErrLike, "get error")
  1437  
  1438  				})
  1439  
  1440  				Convey("build is being canceled", func() {
  1441  					tk, ctx = updateContextForNewBuildToken(ctx, 13)
  1442  					So(datastore.Put(ctx, &model.Build{
  1443  						ID: 13,
  1444  						Proto: &pb.Build{
  1445  							Id: 13,
  1446  							Builder: &pb.BuilderID{
  1447  								Project: "project",
  1448  								Bucket:  "bucket",
  1449  								Builder: "builder",
  1450  							},
  1451  							CancelTime:      timestamppb.New(t0.Add(-time.Minute)),
  1452  							SummaryMarkdown: "original summary",
  1453  						},
  1454  						UpdateToken: tk,
  1455  					}), ShouldBeNil)
  1456  					// Child of the requested build.
  1457  					So(datastore.Put(ctx, &model.Build{
  1458  						ID: 14,
  1459  						Proto: &pb.Build{
  1460  							Id: 14,
  1461  							Builder: &pb.BuilderID{
  1462  								Project: "project",
  1463  								Bucket:  "bucket",
  1464  								Builder: "builder",
  1465  							},
  1466  							AncestorIds:      []int64{13},
  1467  							CanOutliveParent: false,
  1468  						},
  1469  						UpdateToken: tk,
  1470  					}), ShouldBeNil)
  1471  					req.Build.Id = 13
  1472  					req.Build.SummaryMarkdown = "new summary"
  1473  					req.UpdateMask.Paths[0] = "build.summary_markdown"
  1474  					req.Mask = &pb.BuildMask{
  1475  						Fields: &fieldmaskpb.FieldMask{
  1476  							Paths: []string{
  1477  								"cancel_time",
  1478  								"summary_markdown",
  1479  							},
  1480  						},
  1481  					}
  1482  					build, err := srv.UpdateBuild(ctx, req)
  1483  					So(err, ShouldBeRPCOK)
  1484  					So(build.CancelTime.AsTime(), ShouldEqual, t0.Add(-time.Minute))
  1485  					So(build.SummaryMarkdown, ShouldEqual, "new summary")
  1486  					So(sch.Tasks(), ShouldBeEmpty)
  1487  				})
  1488  
  1489  				Convey("build is ended, should cancel children", func() {
  1490  					tk, ctx = updateContextForNewBuildToken(ctx, 20)
  1491  					p := &model.Build{
  1492  						ID: 20,
  1493  						Proto: &pb.Build{
  1494  							Id: 20,
  1495  							Builder: &pb.BuilderID{
  1496  								Project: "project",
  1497  								Bucket:  "bucket",
  1498  								Builder: "builder",
  1499  							},
  1500  						},
  1501  						UpdateToken: tk,
  1502  					}
  1503  					ps := &model.BuildStatus{
  1504  						Build:  datastore.KeyForObj(ctx, p),
  1505  						Status: pb.Status_STARTED,
  1506  					}
  1507  					So(datastore.Put(ctx, p, ps), ShouldBeNil)
  1508  					// Child of the requested build.
  1509  					c := &model.Build{
  1510  						ID: 21,
  1511  						Proto: &pb.Build{
  1512  							Id: 21,
  1513  							Builder: &pb.BuilderID{
  1514  								Project: "project",
  1515  								Bucket:  "bucket",
  1516  								Builder: "builder",
  1517  							},
  1518  							AncestorIds:      []int64{20},
  1519  							CanOutliveParent: false,
  1520  						},
  1521  						UpdateToken: tk,
  1522  					}
  1523  					So(datastore.Put(ctx, c), ShouldBeNil)
  1524  					req.Build.Id = 20
  1525  					req.Build.Status = pb.Status_INFRA_FAILURE
  1526  					req.UpdateMask.Paths[0] = "build.status"
  1527  					_, err := srv.UpdateBuild(ctx, req)
  1528  					So(err, ShouldBeRPCOK)
  1529  
  1530  					child, err := common.GetBuild(ctx, 21)
  1531  					So(err, ShouldBeNil)
  1532  					So(child.Proto.CancelTime, ShouldNotBeNil)
  1533  				})
  1534  
  1535  				Convey("build gets cancel signal from backend, should cancel children", func() {
  1536  					tk, ctx = updateContextForNewBuildToken(ctx, 20)
  1537  					So(datastore.Put(ctx, &model.Build{
  1538  						ID: 20,
  1539  						Proto: &pb.Build{
  1540  							Id: 20,
  1541  							Builder: &pb.BuilderID{
  1542  								Project: "project",
  1543  								Bucket:  "bucket",
  1544  								Builder: "builder",
  1545  							},
  1546  						},
  1547  						UpdateToken: tk,
  1548  					}), ShouldBeNil)
  1549  					// Child of the requested build.
  1550  					So(datastore.Put(ctx, &model.Build{
  1551  						ID: 21,
  1552  						Proto: &pb.Build{
  1553  							Id: 21,
  1554  							Builder: &pb.BuilderID{
  1555  								Project: "project",
  1556  								Bucket:  "bucket",
  1557  								Builder: "builder",
  1558  							},
  1559  							AncestorIds:      []int64{20},
  1560  							CanOutliveParent: false,
  1561  						},
  1562  						UpdateToken: tk,
  1563  					}), ShouldBeNil)
  1564  					req.Build.Id = 20
  1565  					req.UpdateMask.Paths = []string{"build.cancel_time", "build.cancellation_markdown"}
  1566  					req.Build.CancelTime = timestamppb.New(t0.Add(-time.Minute))
  1567  					req.Build.CancellationMarkdown = "swarming task is cancelled"
  1568  					_, err := srv.UpdateBuild(ctx, req)
  1569  					So(err, ShouldBeRPCOK)
  1570  
  1571  					child, err := common.GetBuild(ctx, 21)
  1572  					So(err, ShouldBeNil)
  1573  					So(child.Proto.CancelTime, ShouldNotBeNil)
  1574  
  1575  					// One CancelBuildTask for the requested build,
  1576  					// one CancelBuildTask for the child build.
  1577  					So(sch.Tasks(), ShouldHaveLength, 2)
  1578  				})
  1579  			})
  1580  		})
  1581  	})
  1582  }