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  }