go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/frontend/view_build.go (about) 1 // Copyright 2018 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 frontend 16 17 import ( 18 "context" 19 "fmt" 20 "html/template" 21 "math/rand" 22 "net/http" 23 "strconv" 24 "strings" 25 "time" 26 27 "google.golang.org/grpc/codes" 28 "google.golang.org/grpc/status" 29 "google.golang.org/protobuf/types/known/timestamppb" 30 31 "go.chromium.org/luci/buildbucket/bbperms" 32 buildbucketpb "go.chromium.org/luci/buildbucket/proto" 33 "go.chromium.org/luci/buildbucket/protoutil" 34 bbv1 "go.chromium.org/luci/common/api/buildbucket/buildbucket/v1" 35 "go.chromium.org/luci/common/api/gitiles" 36 "go.chromium.org/luci/common/clock" 37 "go.chromium.org/luci/common/errors" 38 "go.chromium.org/luci/common/logging" 39 gitpb "go.chromium.org/luci/common/proto/git" 40 gitilespb "go.chromium.org/luci/common/proto/gitiles" 41 "go.chromium.org/luci/grpc/grpcutil" 42 "go.chromium.org/luci/milo/frontend/ui" 43 "go.chromium.org/luci/milo/internal/buildsource/buildbucket" 44 "go.chromium.org/luci/milo/internal/utils" 45 milopb "go.chromium.org/luci/milo/proto/v1" 46 "go.chromium.org/luci/milo/rpc" 47 "go.chromium.org/luci/server/auth" 48 "go.chromium.org/luci/server/auth/realms" 49 "go.chromium.org/luci/server/auth/xsrf" 50 "go.chromium.org/luci/server/router" 51 "go.chromium.org/luci/server/templates" 52 ) 53 54 // handleLUCIBuild renders a LUCI build. 55 func handleLUCIBuild(c *router.Context) error { 56 bid := &buildbucketpb.BuilderID{ 57 Project: c.Params.ByName("project"), 58 Bucket: c.Params.ByName("bucket"), 59 Builder: c.Params.ByName("builder"), 60 } 61 numberOrID := c.Params.ByName("numberOrId") 62 forceBlamelist := c.Request.FormValue("blamelist") != "" 63 blamelistOpt := buildbucket.GetBlamelist 64 if forceBlamelist { 65 blamelistOpt = buildbucket.ForceBlamelist 66 } 67 68 // Redirect to short bucket names. 69 if _, v2Bucket := bbv1.BucketNameToV2(bid.Bucket); v2Bucket != "" { 70 // Parameter "bucket" is v1, e.g. "luci.chromium.try". 71 u := *c.Request.URL 72 u.Path = fmt.Sprintf("/p/%s/builders/%s/%s/%s", bid.Project, v2Bucket, bid.Builder, numberOrID) 73 http.Redirect(c.Writer, c.Request, u.String(), http.StatusMovedPermanently) 74 } 75 76 br, err := prepareGetBuildRequest(bid, numberOrID) 77 if err != nil { 78 return err 79 } 80 81 bp, err := GetBuildPage(c, br, blamelistOpt) 82 return renderBuild(c, bp, true, err) 83 } 84 85 // GetBuildPage fetches the full set of information for a Milo build page from Buildbucket. 86 // Including the blamelist and other auxiliary information. 87 func GetBuildPage(ctx *router.Context, br *buildbucketpb.GetBuildRequest, blamelistOpt buildbucket.BlamelistOption) (*ui.BuildPage, error) { 88 now := timestamppb.New(clock.Now(ctx.Request.Context())) 89 90 c := ctx.Request.Context() 91 host, err := buildbucket.GetHost(c) 92 if err != nil { 93 return nil, err 94 } 95 client, err := buildbucket.BuildsClient(c, host, auth.AsUser) 96 if err != nil { 97 return nil, err 98 } 99 100 br.Fields = buildbucket.FullBuildMask 101 b, err := client.GetBuild(c, br) 102 if err != nil { 103 return nil, utils.TagGRPC(c, err) 104 } 105 106 var blame []*ui.Commit 107 var blameErr error 108 switch blamelistOpt { 109 case buildbucket.ForceBlamelist: 110 blame, blameErr = getBlame(c, host, b, 55*time.Second) 111 case buildbucket.GetBlamelist: 112 blame, blameErr = getBlame(c, host, b, 1*time.Second) 113 case buildbucket.NoBlamelist: 114 break 115 default: 116 blameErr = errors.Reason("invalid blamelist option").Err() 117 } 118 119 realm := realms.Join(b.Builder.Project, b.Builder.Bucket) 120 canCancel, err := auth.HasPermission(c, bbperms.BuildsCancel, realm, nil) 121 if err != nil { 122 return nil, err 123 } 124 canRetry, err := auth.HasPermission(c, bbperms.BuildsAdd, realm, nil) 125 if err != nil { 126 return nil, err 127 } 128 129 logging.Infof(c, "Got all the things") 130 return &ui.BuildPage{ 131 Build: ui.Build{ 132 Build: b, 133 Now: now, 134 }, 135 Blame: blame, 136 BuildbucketHost: host, 137 BlamelistError: blameErr, 138 ForcedBlamelist: blamelistOpt == buildbucket.ForceBlamelist, 139 CanCancel: canCancel, 140 CanRetry: canRetry, 141 }, nil 142 } 143 144 // simplisticBlamelist returns a slice of ui.Commit for a build, and/or an error. 145 // 146 // HACK(iannucci) - Getting the frontend to render a proper blamelist will 147 // require some significant refactoring. To do this properly, we'll need: 148 // - The frontend to get BuildSummary from the backend. 149 // - BuildSummary to have a .PreviousBuild() API. 150 // - The frontend to obtain the annotation streams itself (so it could see 151 // the SourceManifest objects inside of them). Currently getRespBuild defers 152 // to swarming's implementation of buildsource.ID.Get(), which only returns 153 // the resp object. 154 func simplisticBlamelist(c context.Context, build *buildbucketpb.Build) (result []*ui.Commit, err error) { 155 gc := build.GetInput().GetGitilesCommit() 156 if gc == nil { 157 return 158 } 159 160 svc := &rpc.MiloInternalService{ 161 GetGitilesClient: func(c context.Context, host string, as auth.RPCAuthorityKind) (gitilespb.GitilesClient, error) { 162 // Override to use `auth.AsSessionUser` because we know the function is 163 // called in the context of a cookie authenticated request not an actual 164 // RPC. 165 // This is a hack but the old build page should eventually go away once 166 // buildbucket can handle raw builds. 167 t, err := auth.GetRPCTransport(c, auth.AsSessionUser) 168 if err != nil { 169 return nil, err 170 } 171 client, err := gitiles.NewRESTClient(&http.Client{Transport: t}, host, false) 172 if err != nil { 173 return nil, err 174 } 175 176 return client, nil 177 }, 178 } 179 req := &milopb.QueryBlamelistRequest{ 180 Builder: build.Builder, 181 GitilesCommit: gc, 182 PageSize: 100, 183 } 184 res, err := svc.QueryBlamelist(c, req) 185 186 switch { 187 case err == nil: 188 // continue 189 case status.Code(err) == codes.PermissionDenied: 190 err = grpcutil.UnauthenticatedTag.Apply(err) 191 return 192 default: 193 return 194 } 195 196 result = make([]*ui.Commit, 0, len(res.Commits)+1) 197 for _, commit := range res.Commits { 198 result = append(result, uiCommit(commit, protoutil.GitilesRepoURL(gc))) 199 } 200 logging.Infof(c, "Fetched %d commit blamelist from Gitiles", len(result)) 201 202 if res.NextPageToken != "" { 203 result = append(result, &ui.Commit{ 204 Description: "<blame list capped at 100 commits>", 205 Revision: &ui.Link{}, 206 AuthorName: "<blame list capped at 100 commits>", 207 }) 208 } 209 210 return 211 } 212 213 func uiCommit(commit *gitpb.Commit, repoURL string) *ui.Commit { 214 res := &ui.Commit{ 215 AuthorName: commit.Author.Name, 216 AuthorEmail: commit.Author.Email, 217 Repo: repoURL, 218 Description: commit.Message, 219 220 // TODO(iannucci): this use of links is very sloppy; the frontend should 221 // know how to render a Commit without having Links embedded in it. 222 Revision: ui.NewLink( 223 commit.Id, 224 repoURL+"/+/"+commit.Id, fmt.Sprintf("commit by %s", commit.Author.Email)), 225 } 226 res.CommitTime = commit.Committer.Time.AsTime() 227 res.File = make([]string, 0, len(commit.TreeDiff)) 228 for _, td := range commit.TreeDiff { 229 // If a file was moved, there is both an old and a new path, from which we 230 // take only the new path. 231 // If a file was deleted, its new path is /dev/null. In that case, we're 232 // only interested in the old path. 233 switch { 234 case td.NewPath != "" && td.NewPath != "/dev/null": 235 res.File = append(res.File, td.NewPath) 236 case td.OldPath != "": 237 res.File = append(res.File, td.OldPath) 238 } 239 } 240 return res 241 } 242 243 // getBlame fetches blame information from Gitiles. 244 // This requires the BuildSummary to be indexed in Milo. 245 func getBlame(c context.Context, host string, b *buildbucketpb.Build, timeout time.Duration) ([]*ui.Commit, error) { 246 nc, cancel := context.WithTimeout(c, timeout) 247 defer cancel() 248 commit := b.GetInput().GetGitilesCommit() 249 // No commit? No blamelist. 250 if commit == nil { 251 return nil, nil 252 } 253 254 return simplisticBlamelist(nc, b) 255 } 256 257 // renderBuild is a shortcut for rendering build or returning err if it is not nil. 258 func renderBuild(c *router.Context, bp *ui.BuildPage, showOptInBanner bool, err error) error { 259 if err != nil { 260 return err 261 } 262 263 bp.StepDisplayPref = getStepDisplayPrefCookie(c) 264 bp.ShowDebugLogsPref = getShowDebugLogsPrefCookie(c) 265 266 var optInBannerHtml template.HTML 267 if showOptInBanner { 268 optInBannerHtml = bp.NewBuildPageOptInHTML() 269 } 270 271 templates.MustRender(c.Request.Context(), c.Writer, "pages/build.html", templates.Args{ 272 "BuildPage": bp, 273 "RetryRequestID": rand.Int31(), 274 "XsrfTokenField": xsrf.TokenField(c.Request.Context()), 275 "BannerHTML": optInBannerHtml, 276 }) 277 return nil 278 } 279 280 // redirectLUCIBuild redirects to a canonical build URL 281 // e.g. to /p/{project}/builders/{bucket}/{builder}/{number or id}. 282 func redirectLUCIBuild(c *router.Context) error { 283 id, err := parseBuildID(c.Params.ByName("id")) 284 if err != nil { 285 return err 286 } 287 builder, number, err := buildbucket.GetBuilderID(c.Request.Context(), id) 288 if err != nil { 289 return err 290 } 291 numberOrID := fmt.Sprintf("%d", number) 292 if number == 0 { 293 numberOrID = fmt.Sprintf("b%d", id) 294 } 295 296 u := fmt.Sprintf("/p/%s/builders/%s/%s/%s?%s", builder.Project, builder.Bucket, builder.Builder, numberOrID, c.Request.URL.RawQuery) 297 http.Redirect(c.Writer, c.Request, u, http.StatusMovedPermanently) 298 return nil 299 } 300 301 func handleGetRelatedBuildsTable(c *router.Context) error { 302 rbt, err := getRelatedBuilds(c) 303 if err != nil { 304 return err 305 } 306 templates.MustRender(c.Request.Context(), c.Writer, "widgets/related_builds_table.html", templates.Args{ 307 "RelatedBuildsTable": rbt, 308 }) 309 return nil 310 } 311 312 func getRelatedBuilds(c *router.Context) (*ui.RelatedBuildsTable, error) { 313 idInput := c.Params.ByName("id") 314 315 id, err := strconv.ParseInt(idInput, 10, 64) 316 if err != nil { 317 return nil, errors.Annotate(err, "bad build id").Tag(grpcutil.InvalidArgumentTag).Err() 318 } 319 rbt, err := buildbucket.GetRelatedBuildsTable(c.Request.Context(), id) 320 if err != nil { 321 return nil, errors.Annotate(err, "error when getting related builds table").Err() 322 } 323 return rbt, nil 324 } 325 326 func getStepDisplayPrefCookie(c *router.Context) ui.StepDisplayPref { 327 switch cookie, err := c.Request.Cookie("stepDisplayPref"); err { 328 case nil: 329 return ui.StepDisplayPref(cookie.Value) 330 case http.ErrNoCookie: 331 return ui.StepDisplayDefault 332 default: 333 logging.WithError(err).Errorf(c.Request.Context(), "failed to read stepDisplayPref cookie") 334 return ui.StepDisplayDefault 335 } 336 } 337 338 func getShowNewBuildPageCookie(c *router.Context) bool { 339 switch cookie, err := c.Request.Cookie("showNewBuildPage"); err { 340 case nil: 341 return cookie.Value == "true" 342 case http.ErrNoCookie: 343 return true 344 default: 345 logging.WithError(err).Errorf(c.Request.Context(), "failed to read showNewBuildPage cookie") 346 return true 347 } 348 } 349 350 func getShowDebugLogsPrefCookie(c *router.Context) bool { 351 switch cookie, err := c.Request.Cookie("showDebugLogsPref"); err { 352 case nil: 353 return cookie.Value == "true" 354 case http.ErrNoCookie: 355 return false 356 default: 357 logging.WithError(err).Errorf(c.Request.Context(), "failed to read showDebugLogsPref cookie") 358 return false 359 } 360 } 361 362 // parseBuildID parses build ID from string. 363 func parseBuildID(idStr string) (id int64, err error) { 364 // Verify it is an int64. 365 id, err = strconv.ParseInt(idStr, 10, 64) 366 if err != nil { 367 err = errors.Annotate(err, "invalid id").Tag(grpcutil.InvalidArgumentTag).Err() 368 } 369 return 370 } 371 372 func prepareGetBuildRequest(builderID *buildbucketpb.BuilderID, numberOrID string) (*buildbucketpb.GetBuildRequest, error) { 373 br := &buildbucketpb.GetBuildRequest{} 374 if strings.HasPrefix(numberOrID, "b") { 375 id, err := parseBuildID(numberOrID[1:]) 376 if err != nil { 377 return nil, err 378 } 379 br.Id = id 380 } else { 381 number, err := strconv.ParseInt(numberOrID, 10, 32) 382 if err != nil { 383 return nil, errors.Annotate(err, "bad build number").Tag(grpcutil.InvalidArgumentTag).Err() 384 } 385 br.Builder = builderID 386 br.BuildNumber = int32(number) 387 } 388 return br, nil 389 }