blob: 7790dd28e4d5926d7be51107ac7e0e404014d6e0 [file] [log] [blame]
// Copyright 2021 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package userhtml
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/dustin/go-humanize"
"golang.org/x/sync/errgroup"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/server/router"
"go.chromium.org/luci/server/templates"
"go.chromium.org/luci/cv/internal/acls"
"go.chromium.org/luci/cv/internal/common"
adminpb "go.chromium.org/luci/cv/internal/rpc/admin/api"
"go.chromium.org/luci/cv/internal/run"
"go.chromium.org/luci/cv/internal/run/eventpb"
"go.chromium.org/luci/cv/internal/tryjob"
)
func runDetails(c *router.Context) {
ctx := c.Context
rID := fmt.Sprintf("%s/%s", c.Params.ByName("Project"), c.Params.ByName("Run"))
// Load the Run, checking its existence and ACLs.
r, err := run.LoadRun(ctx, common.RunID(rID), acls.NewRunReadChecker())
if err != nil {
errPage(c, err)
return
}
// Reverse entries to get the most recent at the top of the list.
logEntries, err := run.LoadRunLogEntries(ctx, r.ID)
if err != nil {
errPage(c, err)
return
}
nEntries := len(logEntries)
for i := 0; i < nEntries/2; i++ {
logEntries[i], logEntries[nEntries-1-i] = logEntries[nEntries-1-i], logEntries[i]
}
cls, err := run.LoadRunCLs(ctx, common.RunID(r.ID), r.CLs)
if err != nil {
errPage(c, err)
return
}
// Sort a stack of CLs by external ID.
sort.Slice(cls, func(i, j int) bool { return cls[i].ExternalID < cls[j].ExternalID })
// Compute next and previous runs for all cls in parallel.
clsAndLinks, err := computeCLsAndLinks(ctx, cls, r.ID)
if err != nil {
errPage(c, err)
return
}
clidToURL := makeURLMap(cls)
templates.MustRender(ctx, c.Writer, "pages/run_details.html", templates.Args{
"Run": r,
"Logs": logEntries,
"Cls": clsAndLinks,
"RelTime": func(ts time.Time) string {
return humanize.RelTime(ts, startTime(ctx), "ago", "from now")
},
"LogMessage": func(rle *run.LogEntry) string {
switch v := rle.GetKind().(type) {
case *run.LogEntry_Info_:
return v.Info.Message
case *run.LogEntry_ClSubmitted:
return StringifySubmissionSuccesses(clidToURL, v.ClSubmitted, int64(len(r.CLs)))
case *run.LogEntry_SubmissionFailure_:
return StringifySubmissionFailureReason(clidToURL, v.SubmissionFailure.Event)
case *run.LogEntry_RunEnded_:
// This assumes the status of a Run won't change after transitioning to
// one of the terminal statuses. If the assumption is no longer valid.
// End status and cancellation reason need to be stored explicitly in
// the log entry.
if r.Status == run.Status_CANCELLED {
switch len(r.CancellationReasons) {
case 0:
case 1:
return fmt.Sprintf("Run is cancelled. Reason: %s", r.CancellationReasons[0])
default:
var sb strings.Builder
sb.WriteString("Run is cancelled. Reasons:")
for _, reason := range r.CancellationReasons {
sb.WriteRune('\n')
sb.WriteString(" * ")
sb.WriteString(strings.TrimSpace(reason))
}
return sb.String()
}
}
return ""
default:
return ""
}
},
"AllTryjobs": r.Tryjobs.GetTryjobs(),
})
}
type clAndNeighborRuns struct {
Prev, Next common.RunID
CL *run.RunCL
}
func (cl *clAndNeighborRuns) URLWithPatchset() string {
return fmt.Sprintf("%s/%d", cl.CL.ExternalID.MustURL(), cl.CL.Detail.GetPatchset())
}
func (cl *clAndNeighborRuns) ShortWithPatchset() string {
return fmt.Sprintf("%s/%d", displayCLExternalID(cl.CL.ExternalID), cl.CL.Detail.GetPatchset())
}
func computeCLsAndLinks(ctx context.Context, cls []*run.RunCL, rid common.RunID) ([]*clAndNeighborRuns, error) {
ret := make([]*clAndNeighborRuns, len(cls))
eg, ctx := errgroup.WithContext(ctx)
for i, cl := range cls {
i, cl := i, cl
eg.Go(func() error {
prev, next, err := getNeighborsByCL(ctx, cl, rid)
if err != nil {
return errors.Annotate(err, "unable to get previous and next runs for cl %s", cl.ExternalID).Err()
}
ret[i] = &clAndNeighborRuns{
Prev: prev,
Next: next,
CL: cl,
}
return nil
})
}
return ret, eg.Wait()
}
func getNeighborsByCL(ctx context.Context, cl *run.RunCL, rID common.RunID) (common.RunID, common.RunID, error) {
eg, ctx := errgroup.WithContext(ctx)
prev := common.RunID("")
eg.Go(func() error {
qb := run.CLQueryBuilder{CLID: cl.ID, Limit: 1}.BeforeInProject(rID)
switch keys, err := qb.GetAllRunKeys(ctx); {
case err != nil:
return err
case len(keys) == 1:
prev = common.RunID(keys[0].StringID())
// It's OK to return the prev Run ID w/o checking ACLs because even if the
// user can't see the prev Run, user is already served this Run's ID, which
// contains the same LUCI project.
if prev.LUCIProject() != rID.LUCIProject() {
panic(fmt.Errorf("CLQueryBuilder.Before didn't limit project: %q vs %q", prev, rID))
}
}
return nil
})
next := common.RunID("")
eg.Go(func() error {
// CLQueryBuilder gives Runs of the same project ordered from newest to
// oldest. We need the oldest run which was created after the `rID`.
// So, fetch all the Run IDs, and choose the last of them.
//
// In practice, there should be << 100 Runs per CL, but since we are
// fetching just the keys and the query is very cheap, we go to 500.
const limit = 500
qb := run.CLQueryBuilder{CLID: cl.ID, Limit: limit}.AfterInProject(rID)
switch keys, err := qb.GetAllRunKeys(ctx); {
case err != nil:
return err
case len(keys) == limit:
return errors.Reason("too many Runs (>=%d) after %q", limit, rID).Err()
case len(keys) > 0:
next = common.RunID(keys[len(keys)-1].StringID())
// It's OK to return the next Run ID w/o checking ACLs because even if the
// user can't see the next Run, user is already served this Run's ID, which
// contains the same LUCI project.
if next.LUCIProject() != rID.LUCIProject() {
panic(fmt.Errorf("CLQueryBuilder.After didn't limit project: %q vs %q", next, rID))
}
}
return nil
})
err := eg.Wait()
return prev, next, err
}
// uiTryjob is fed to the template to draw tryjob chips.
type uiTryjob struct {
Link, Class, Display string
}
func makeUITryjob(t *run.Tryjob) *uiTryjob {
// TODO(crbug/1233963): Make sure we are not leaking any sensitive info
// based on Read Perms. E.g. internal builder name.
builder := t.Definition.GetBuildbucket().Builder
return &uiTryjob{
Link: tryjob.ExternalID(t.ExternalId).MustURL(),
Class: toCSSClass(t),
Display: fmt.Sprintf("%s/%s/%s", builder.Project, builder.Bucket, builder.Builder),
}
}
// toCSSClass returns a css class for styling a tryjob chip based on its
// status and its result's status.
func toCSSClass(t *run.Tryjob) string {
switch t.GetStatus() {
case tryjob.Status_PENDING:
return "not-started"
case tryjob.Status_CANCELLED:
return "cancelled"
case tryjob.Status_TRIGGERED, tryjob.Status_ENDED:
switch t.GetResult().GetStatus() {
case tryjob.Result_FAILED_PERMANENTLY, tryjob.Result_FAILED_TRANSIENTLY:
return "failed"
case tryjob.Result_SUCCEEDED:
return "passed"
default:
if t.GetStatus() == tryjob.Status_ENDED {
panic("Tryjob status is ENDED but result status is not set")
}
return "running"
}
default:
panic(fmt.Errorf("unknown tryjob status: %s", t.GetStatus()))
}
}
func logTypeString(rle *run.LogEntry) string {
switch v := rle.GetKind().(type) {
case *run.LogEntry_TryjobsUpdated_:
return "Tryjob Updated"
case *run.LogEntry_TryjobsRequirementUpdated_:
return "Tryjob Requirements Updated"
case *run.LogEntry_ConfigChanged_:
return "Config Changed"
case *run.LogEntry_Started_:
return "Started"
case *run.LogEntry_Created_:
return "Created"
case *run.LogEntry_TreeChecked_:
if v.TreeChecked.Open {
return "Tree Found Open"
}
return "Tree Found Closed"
case *run.LogEntry_Info_:
return v.Info.Label
case *run.LogEntry_AcquiredSubmitQueue_:
return "Acquired Submit Queue"
case *run.LogEntry_ReleasedSubmitQueue_:
return "Released Submit Queue"
case *run.LogEntry_Waitlisted_:
return "Waitlisted for Submit Queue"
case *run.LogEntry_SubmissionFailure_:
if v.SubmissionFailure.Event.GetResult() == eventpb.SubmissionResult_FAILED_TRANSIENT {
return "Transient Submission Failure"
}
return "Final Submission Failure"
case *run.LogEntry_ClSubmitted:
return "CL Submission"
case *run.LogEntry_RunEnded_:
return "CV Finished Work on this Run"
default:
return fmt.Sprintf("FIXME: Unknown Kind of LogEntry %T", v)
}
}
// groupTryjobsByStatus puts tryjobs in the list into separate lists by status.
func groupTryjobsByStatus(tjs []*run.Tryjob) map[string][]*run.Tryjob {
ret := map[string][]*run.Tryjob{}
for _, t := range tjs {
k := strings.Title(strings.ToLower(t.Status.String()))
ret[k] = append(ret[k], t)
}
return ret
}
func indexOf(runs []*adminpb.GetRunResponse, runID common.RunID) int {
for i := 0; i < len(runs); i++ {
if runs[i].Id == string(runID) {
return i
}
}
return -1
}
func makeURLMap(cls []*run.RunCL) map[common.CLID]string {
ret := make(map[common.CLID]string, len(cls))
for _, cl := range cls {
ret[cl.ID] = cl.ExternalID.MustURL()
}
return ret
}