blob: 70fab2ef0af19561c8ab346dca33b1cd6c448f24 [file] [log] [blame]
// Copyright 2017 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 frontend
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"io/ioutil"
"net/http"
"net/url"
"strings"
"time"
"google.golang.org/grpc/codes"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/data/stringset"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/sync/parallel"
"go.chromium.org/luci/gae/service/memcache"
"go.chromium.org/luci/gae/service/urlfetch"
"go.chromium.org/luci/grpc/grpcutil"
"go.chromium.org/luci/server/router"
"go.chromium.org/luci/server/templates"
"go.chromium.org/luci/common/api/gitiles"
"go.chromium.org/luci/milo/api/config"
"go.chromium.org/luci/milo/buildsource"
"go.chromium.org/luci/milo/common"
"go.chromium.org/luci/milo/common/model"
"go.chromium.org/luci/milo/frontend/ui"
"go.chromium.org/luci/milo/git"
)
func logTimer(c context.Context, message string) func() {
tStart := clock.Now(c)
return func() {
logging.Debugf(c, "%s: took %s", message, clock.Since(c, tStart))
}
}
// validateFaviconURL checks to see if the URL is well-formed and from an allowed host.
func validateFaviconURL(faviconURL string) error {
parsedFaviconURL, err := url.Parse(faviconURL)
if err != nil {
return err
}
host := strings.SplitN(parsedFaviconURL.Host, ":", 2)[0]
if host != "storage.googleapis.com" {
return fmt.Errorf("%q is not a valid FaviconURL hostname", host)
}
return nil
}
func getFaviconURL(c context.Context, def *config.Console) string {
if def.FaviconUrl == "" {
return ""
}
if err := validateFaviconURL(def.FaviconUrl); err != nil {
logging.WithError(err).Warningf(c, "invalid favicon URL")
return ""
}
return def.FaviconUrl
}
// columnSummaryFn is called by buildTreeFromDef.
//
// columnIdx is the index of the current column we're processing. This
// corresponds to one of the Builder messages in the Console config proto
// message.
//
// This function should return a BuilderSummary and []BuildSummary for the
// specified console column.
type columnSummaryFn func(columnIdx int) (*model.BuilderSummary, []*model.BuildSummary)
func buildTreeFromDef(def *config.Console, getColumnSummaries columnSummaryFn) (*ui.Category, int) {
// Build console table tree from builders.
categoryTree := ui.NewCategory("")
depth := 0
for columnIdx, builderMsg := range def.Builders {
ref := &ui.BuilderRef{
ShortName: builderMsg.ShortName,
}
ref.Builder, ref.Build = getColumnSummaries(columnIdx)
if ref.Builder != nil {
// TODO(iannucci): This is redundant; use Builder.BuilderID directly.
ref.ID = ref.Builder.BuilderID
}
categories := builderMsg.ParseCategory()
if len(categories) > depth {
depth = len(categories)
}
categoryTree.AddBuilder(categories, ref)
}
return categoryTree, depth
}
// getConsoleGroups extracts the console summaries for all header summaries
// out of the summaries map into console groups for the header.
func getConsoleGroups(def *config.Header, summaries map[common.ConsoleID]*ui.BuilderSummaryGroup) []ui.ConsoleGroup {
if def == nil || len(def.GetConsoleGroups()) == 0 {
// No header, no console groups.
return nil
}
groups := def.GetConsoleGroups()
consoleGroups := make([]ui.ConsoleGroup, len(groups))
for i, group := range groups {
groupSummaries := make([]*ui.BuilderSummaryGroup, len(group.ConsoleIds))
for j, id := range group.ConsoleIds {
cid, err := common.ParseConsoleID(id)
if err != nil {
// This should never happen, the consoleID was already validated further
// upstream.
panic(err)
}
if consoleSummary, ok := summaries[cid]; ok {
groupSummaries[j] = consoleSummary
} else {
// This should never happen.
panic(fmt.Sprintf("could not find console summary %s", id))
}
}
consoleGroups[i].Consoles = groupSummaries
if group.Title != nil {
ariaLabel := "console group " + group.Title.Text
consoleGroups[i].Title = ui.NewLink(group.Title.Text, group.Title.Url, ariaLabel)
consoleGroups[i].Title.Alt = group.Title.Alt
}
}
return consoleGroups
}
func consoleRowCommits(c context.Context, project string, def *config.Console, limit int) (
[]*buildsource.ConsoleRow, []ui.Commit, error) {
tGitiles := logTimer(c, "Rows: loading commit from gitiles")
repoHost, repoProject, err := gitiles.ParseRepoURL(def.RepoUrl)
if err != nil {
return nil, nil, errors.Annotate(err, "invalid repo URL %q in the config", def.RepoUrl).Err()
}
rawCommits, err := git.Get(c).CombinedLogs(c, repoHost, repoProject, def.ExcludeRef, def.Refs, limit)
switch grpcutil.Code(err) {
case codes.OK:
// Do nothing, all is good.
case codes.NotFound:
return nil, nil, errors.Reason("incorrect repo URL %q in the config or no access", def.RepoUrl).
Tag(grpcutil.NotFoundTag).Err()
default:
return nil, nil, err
}
tGitiles()
commitIDs := make([]string, len(rawCommits))
for i, c := range rawCommits {
commitIDs[i] = c.Id
}
tBuilds := logTimer(c, "Rows: loading builds")
rows, err := buildsource.GetConsoleRows(c, project, def, commitIDs)
tBuilds()
if err != nil {
return nil, nil, err
}
// Build list of commits.
commits := make([]ui.Commit, len(rawCommits))
for row, commit := range rawCommits {
ct := commit.Committer.Time.AsTime()
commits[row] = ui.Commit{
AuthorName: commit.Author.Name,
AuthorEmail: commit.Author.Email,
CommitTime: ct,
Repo: def.RepoUrl,
Description: commit.Message,
Revision: ui.NewLink(
commit.Id,
def.RepoUrl+"/+/"+commit.Id,
fmt.Sprintf("commit by %s", commit.Author.Email)),
}
}
return rows, commits, nil
}
func console(c context.Context, project, id string, limit int, con *common.Console, headerCons []*common.Console, consoleGroupsErr error) (*ui.Console, error) {
def := &con.Def
consoleID := common.ConsoleID{Project: project, ID: id}
var header *ui.ConsoleHeader
var rows []*buildsource.ConsoleRow
var commits []ui.Commit
var builderSummaries map[common.ConsoleID]*ui.BuilderSummaryGroup
// Get 3 things in parallel:
// 1. The console header (except for summaries)
// 2. The console header summaries + this console's builders summaries.
// 3. The console body (rows + commits)
if err := parallel.FanOutIn(func(ch chan<- func() error) {
if def.Header != nil {
ch <- func() (err error) {
defer logTimer(c, "header")()
header, err = consoleHeader(c, project, def.Header)
return
}
}
ch <- func() (err error) {
defer logTimer(c, "summaries")()
builderSummaries, err = buildsource.GetConsoleSummariesFromDefs(c, append(headerCons, con), project)
return
}
ch <- func() (err error) {
defer logTimer(c, "rows")()
rows, commits, err = consoleRowCommits(c, project, def, limit)
return
}
}); err != nil {
return nil, err
}
// Reassemble builder summaries into both the current console
// and also the header.
if header != nil {
if consoleGroupsErr == nil {
header.ConsoleGroups = getConsoleGroups(def.Header, builderSummaries)
} else {
header.ConsoleGroupsErr = consoleGroupsErr
}
}
// Reassemble the builder summaries and rows into the categoryTree.
categoryTree, depth := buildTreeFromDef(def, func(columnIdx int) (*model.BuilderSummary, []*model.BuildSummary) {
builds := make([]*model.BuildSummary, len(commits))
for row := range commits {
if summaries := rows[row].Builds[columnIdx]; len(summaries) > 0 {
builds[row] = summaries[0]
}
}
return builderSummaries[consoleID].Builders[columnIdx], builds
})
return &ui.Console{
Name: def.Name,
Project: project,
Header: header,
Commit: commits,
Table: *categoryTree,
MaxDepth: depth + 1,
FaviconURL: getFaviconURL(c, def),
}, nil
}
func consolePreview(c context.Context, summaries *ui.BuilderSummaryGroup, def *config.Console) (*ui.Console, error) {
categoryTree, depth := buildTreeFromDef(def, func(columnIdx int) (*model.BuilderSummary, []*model.BuildSummary) {
return summaries.Builders[columnIdx], nil
})
return &ui.Console{
Name: def.Name,
Table: *categoryTree,
MaxDepth: depth + 1,
FaviconURL: getFaviconURL(c, def),
}, nil
}
// getJSONData fetches data from the given URL into the target, caching it
// for expiration seconds in memcache.
func getJSONData(c context.Context, url string, expiration time.Duration, target interface{}) error {
// Try memcache first.
item := memcache.NewItem(c, "url:"+url)
if err := memcache.Get(c, item); err == nil {
if err = json.Unmarshal(item.Value(), target); err == nil {
return nil
}
logging.WithError(err).Warningf(c, "couldn't load stored item from datastore")
} else if err != memcache.ErrCacheMiss {
logging.WithError(err).Errorf(c, "memcache is having issues")
}
// Fetch it from the app.
transport := urlfetch.Get(c)
response, err := (&http.Client{Transport: transport}).Get(url)
if err != nil {
return errors.Annotate(err, "failed to get data from %q", url).Err()
}
defer response.Body.Close()
if response.StatusCode >= 400 && response.StatusCode <= 599 {
return fmt.Errorf("Failed to fetch data: %q returned code %q", url, response.Status)
}
bytes, err := ioutil.ReadAll(response.Body)
if err != nil {
return errors.Annotate(err, "failed to read response body from %q", url).Err()
}
// Parse JSON into target.
if err := json.Unmarshal(bytes, target); err != nil {
return errors.Annotate(
err, "failed to decode JSON %q from %q", bytes, url).Err()
}
// Save data into memcache on a best-effort basis.
item.SetValue(bytes).SetExpiration(expiration)
if err = memcache.Set(c, item); err != nil {
logging.WithError(err).Warningf(c, "could not save url data %q into memcache", url)
}
return nil
}
// getTreeStatus returns the current tree status from the chromium-status app.
// This never errors, instead it constructs a fake purple TreeStatus
func getTreeStatus(c context.Context, host string) *ui.TreeStatus {
q := url.Values{}
q.Add("format", "json")
url := url.URL{
Scheme: "https",
Host: host,
Path: "current",
RawQuery: q.Encode(),
}
status := &ui.TreeStatus{}
if err := getJSONData(c, url.String(), 30*time.Second, status); err != nil {
// Generate a fake tree status.
logging.WithError(err).Errorf(c, "loading tree status")
status = &ui.TreeStatus{
GeneralState: "maintenance",
Message: "could not load tree status",
}
}
return status
}
// getOncallData fetches oncall data and caches it for 10 minutes.
func getOncallData(c context.Context, config *config.Oncall) (*ui.OncallSummary, error) {
result := ui.Oncall{}
err := getJSONData(c, config.Url, 10*time.Minute, &result)
var renderedHTML template.HTML
if err == nil {
renderedHTML = renderOncallers(config, &result)
} else {
renderedHTML = template.HTML("ERROR: Fetching oncall failed")
}
return &ui.OncallSummary{
Name: config.Name,
Oncallers: renderedHTML,
}, nil
}
// renderOncallers renders a summary string to be displayed in the UI, showing
// the current oncallers.
func renderOncallers(config *config.Oncall, jsonResult *ui.Oncall) template.HTML {
var oncallers string
if len(jsonResult.Emails) == 1 {
oncallers = jsonResult.Emails[0]
} else if len(jsonResult.Emails) > 1 {
if config.ShowPrimarySecondaryLabels {
var sb strings.Builder
fmt.Fprintf(&sb, "%v (primary)", jsonResult.Emails[0])
for _, oncaller := range jsonResult.Emails[1:] {
fmt.Fprintf(&sb, ", %v (secondary)", oncaller)
}
oncallers = sb.String()
} else {
oncallers = strings.Join(jsonResult.Emails, ", ")
}
} else if jsonResult.Primary != "" {
if len(jsonResult.Secondaries) > 0 {
var sb strings.Builder
fmt.Fprintf(&sb, "%v (primary)", jsonResult.Primary)
for _, oncaller := range jsonResult.Secondaries {
fmt.Fprintf(&sb, ", %v (secondary)", oncaller)
}
oncallers = sb.String()
} else {
oncallers = jsonResult.Primary
}
} else {
oncallers = "<none>"
}
return common.ObfuscateEmail(common.ShortenEmail(oncallers))
}
func consoleHeaderOncall(c context.Context, config []*config.Oncall) ([]*ui.OncallSummary, error) {
// Get oncall data from URLs.
oncalls := make([]*ui.OncallSummary, len(config))
err := parallel.WorkPool(8, func(ch chan<- func() error) {
for i, oc := range config {
i := i
oc := oc
ch <- func() (err error) {
oncalls[i], err = getOncallData(c, oc)
return
}
}
})
return oncalls, err
}
func consoleHeader(c context.Context, project string, header *config.Header) (*ui.ConsoleHeader, error) {
// Return nil if the header is empty.
switch {
case len(header.Oncalls) != 0:
// continue
case len(header.Links) != 0:
// continue
case header.TreeStatusHost != "":
// continue
default:
return nil, nil
}
var oncalls []*ui.OncallSummary
var treeStatus *ui.TreeStatus
// Get the oncall and tree status concurrently.
if err := parallel.FanOutIn(func(ch chan<- func() error) {
ch <- func() (err error) {
oncalls, err = consoleHeaderOncall(c, header.Oncalls)
if err != nil {
logging.WithError(err).Errorf(c, "getting oncalls")
}
return nil
}
if header.TreeStatusHost != "" {
ch <- func() error {
treeStatus = getTreeStatus(c, header.TreeStatusHost)
treeStatus.URL = &url.URL{Scheme: "https", Host: header.TreeStatusHost}
return nil
}
}
}); err != nil {
return nil, err
}
// Restructure links as resp data structures.
//
// This should be a one-to-one transformation.
links := make([]ui.LinkGroup, len(header.Links))
for i, linkGroup := range header.Links {
mlinks := make([]*ui.Link, len(linkGroup.Links))
for j, link := range linkGroup.Links {
ariaLabel := fmt.Sprintf("%s in %s", link.Text, linkGroup.Name)
mlinks[j] = ui.NewLink(link.Text, link.Url, ariaLabel)
}
links[i] = ui.LinkGroup{
Name: ui.NewLink(linkGroup.Name, "", ""),
Links: mlinks,
}
}
return &ui.ConsoleHeader{
Oncalls: oncalls,
Links: links,
TreeStatus: treeStatus,
}, nil
}
// consoleRenderer is a wrapper around Console to provide additional methods.
type consoleRenderer struct {
*ui.Console
}
// ConsoleTable generates the main console table html.
//
// This cannot be generated with templates due to the 'recursive' nature of
// this layout.
func (c consoleRenderer) ConsoleTable() template.HTML {
var buffer bytes.Buffer
// The first node is a dummy node
for _, column := range c.Table.Children() {
column.RenderHTML(&buffer, 1, c.MaxDepth)
}
return template.HTML(buffer.String())
}
// ConsoleSummary generates the html for the console's builders in the console list.
//
// It is similar to ConsoleTable, but flattens the structure.
func (c consoleRenderer) ConsoleSummary() template.HTML {
var buffer bytes.Buffer
for _, column := range c.Table.Children() {
column.RenderHTML(&buffer, 1, -1)
}
return template.HTML(buffer.String())
}
func (c consoleRenderer) BuilderLink(bs *model.BuildSummary) (*ui.Link, error) {
_, _, builderName, err := buildsource.BuilderID(bs.BuilderID).Split()
if err != nil {
return nil, err
}
return ui.NewLink(builderName, "/"+bs.BuilderID, fmt.Sprintf("builder %s", builderName)), nil
}
// consoleHeaderGroupIDs extracts the console group IDs out of the header config.
func consoleHeaderGroupIDs(project string, config []*config.ConsoleSummaryGroup) ([]common.ConsoleID, error) {
consoleIDSet := map[common.ConsoleID]struct{}{}
for _, group := range config {
for _, id := range group.ConsoleIds {
cid, err := common.ParseConsoleID(id)
if err != nil {
return nil, err
}
consoleIDSet[cid] = struct{}{}
}
}
consoleIDs := make([]common.ConsoleID, 0, len(consoleIDSet))
for cid := range consoleIDSet {
consoleIDs = append(consoleIDs, cid)
}
return consoleIDs, nil
}
// filterUnauthorizedBuildersFromConsoles filters out builders the user does not have access to.
func filterUnauthorizedBuildersFromConsoles(c context.Context, cons []*common.Console) error {
buckets := stringset.New(0)
for _, con := range cons {
buckets = buckets.Union(con.Buckets())
}
perms, err := common.BucketPermissions(c, buckets.ToSlice()...)
if err != nil {
return err
}
for _, con := range cons {
con.FilterBuilders(perms)
}
return nil
}
// ConsoleHandler renders the console page.
func ConsoleHandler(c *router.Context) error {
project := c.Params.ByName("project")
if project == "" {
return errors.New("missing project", grpcutil.InvalidArgumentTag)
}
group := c.Params.ByName("group")
// Get console from datastore and filter out builders from the definition.
con, err := common.GetConsole(c.Context, project, group)
switch {
case err != nil:
return err
case con.IsExternal():
// We don't allow navigating directly to external consoles.
return common.ErrConsoleNotFound
case con.Def.BuilderViewOnly:
redirect("/p/:project/g/:group/builders", http.StatusFound)(c)
return nil
}
defaultLimit := 50
if con.Def.DefaultCommitLimit > 0 {
defaultLimit = int(con.Def.DefaultCommitLimit)
}
const maxLimit = 1000
limit := defaultLimit
if tLimit := GetLimit(c.Request, -1); tLimit >= 0 {
limit = tLimit
}
if limit > maxLimit {
limit = maxLimit
}
var headerCons []*common.Console
var headerConsError error
if con.Def.Header != nil {
ids, err := consoleHeaderGroupIDs(project, con.Def.Header.GetConsoleGroups())
if err != nil {
return err
}
headerCons, err = common.GetConsoles(c.Context, ids)
if err != nil {
headerConsError = errors.Annotate(err, "error getting header consoles").Err()
headerCons = make([]*common.Console, 0)
}
}
if err := filterUnauthorizedBuildersFromConsoles(c.Context, append(headerCons, con)); err != nil {
return errors.Annotate(err, "error authorizing user").Err()
}
// Process the request and generate a renderable structure.
result, err := console(c.Context, project, group, limit, con, headerCons, headerConsError)
if err != nil {
return err
}
templates.MustRender(c.Context, c.Writer, "pages/console.html", templates.Args{
"Console": consoleRenderer{result},
"Expand": con.Def.DefaultExpand,
})
return nil
}
// ConsolesHandler is responsible for taking a project name and rendering the
// console list page (defined in ./appengine/templates/pages/builder_groups.html).
func ConsolesHandler(c *router.Context, projectID string) error {
// Get consoles related to this project and filter out all builders.
cons, err := common.GetProjectConsoles(c.Context, projectID)
if err != nil {
return err
}
if err := filterUnauthorizedBuildersFromConsoles(c.Context, cons); err != nil {
return errors.Annotate(err, "error authorizing user").Err()
}
type fullConsole struct {
ID string
ProjectID string
Def *config.Console
Render consoleRenderer
}
var consoles []fullConsole
summaryMap, err := buildsource.GetConsoleSummariesFromDefs(c.Context, cons, projectID)
if err != nil {
return err
}
for _, con := range cons {
summary, ok := summaryMap[con.ConsoleID()]
if !ok {
logging.Errorf(c.Context, "console summary for %s not found", con.ConsoleID())
continue
}
respConsole, err := consolePreview(c.Context, summary, &con.Def)
if err != nil {
logging.WithError(err).Errorf(c.Context, "failed to generate resp console")
continue
}
resolvedProjectID := con.ProjectID()
resolvedConsoleID := con.Def.Id
if con.Def.ExternalProject != "" {
resolvedProjectID = con.Def.ExternalProject
resolvedConsoleID = con.Def.ExternalId
}
full := fullConsole{
ID: resolvedConsoleID,
ProjectID: resolvedProjectID,
Def: &con.Def,
Render: consoleRenderer{respConsole},
}
consoles = append(consoles, full)
}
templates.MustRender(c.Context, c.Writer, "pages/builder_groups.html", templates.Args{
"ProjectID": projectID,
"Consoles": consoles,
})
return nil
}