blob: a8bf91eb740404b4ed94eec0de3fdcf54ee7e5f5 [file]
// Copyright 2016 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 buildbot
import (
"encoding/json"
"fmt"
"io/ioutil"
"math"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"golang.org/x/net/context"
"github.com/luci/gae/service/datastore"
"github.com/luci/luci-go/common/data/stringset"
"github.com/luci/luci-go/common/errors"
"github.com/luci/luci-go/common/logging"
"github.com/luci/luci-go/milo/api/resp"
"github.com/luci/luci-go/milo/common"
"github.com/luci/luci-go/milo/common/model"
)
// getBuild fetches a buildbot build from the datastore and checks ACLs.
// The return code matches the master responses.
func getBuild(c context.Context, master, builder string, buildNum int) (*buildbotBuild, error) {
if err := canAccessMaster(c, master); err != nil {
return nil, err
}
result := &buildbotBuild{
Master: master,
Buildername: builder,
Number: buildNum,
}
err := datastore.Get(c, result)
if err == datastore.ErrNoSuchEntity {
err = errors.New("build not found", common.CodeNotFound)
}
return result, err
}
// result2Status translates a buildbot result integer into a model.Status.
func result2Status(s *int) (status model.Status) {
if s == nil {
return model.Running
}
switch *s {
case 0:
status = model.Success
case 1:
status = model.Warning
case 2:
status = model.Failure
case 3:
status = model.NotRun // Skipped
case 4:
status = model.Exception
case 5:
status = model.WaitingDependency // Retry
default:
panic(fmt.Errorf("Unknown status %d", s))
}
return
}
// buildbotTimeToTime converts a buildbot time representation (pointer to float
// of seconds since epoch) to a native time.Time object.
func buildbotTimeToTime(t *float64) (result time.Time) {
if t != nil {
result = time.Unix(int64(*t), int64(*t*1e9)%1e9).UTC()
}
return
}
// parseTimes translates a buildbot time tuple (start, end) into a triplet
// of (Started time, Ending time, duration).
// If times[1] is nil and buildFinished is not, ended will be set to buildFinished
// time.
func parseTimes(buildFinished *float64, times []*float64) (started, ended time.Time, duration time.Duration) {
if len(times) != 2 {
panic(fmt.Errorf("Expected 2 floats for times, got %v", times))
}
if times[0] == nil {
// Some steps don't have timing info. In that case, just return nils.
return
}
started = buildbotTimeToTime(times[0])
switch {
case times[1] != nil:
ended = buildbotTimeToTime(times[1])
duration = ended.Sub(started)
case buildFinished != nil:
ended = buildbotTimeToTime(buildFinished)
duration = ended.Sub(started)
default:
duration = time.Since(started)
}
return
}
// getBanner parses the OS information from the build and maybe returns a banner.
func getBanner(c context.Context, b *buildbotBuild) *resp.LogoBanner {
logging.Infof(c, "OS: %s/%s", b.OSFamily, b.OSVersion)
osLogo := func() *resp.Logo {
result := &resp.Logo{}
switch b.OSFamily {
case "windows":
result.LogoBase = resp.Windows
case "Darwin":
result.LogoBase = resp.OSX
case "Debian":
result.LogoBase = resp.Ubuntu
default:
return nil
}
result.Subtitle = b.OSVersion
return result
}()
if osLogo != nil {
return &resp.LogoBanner{
OS: []resp.Logo{*osLogo},
}
}
logging.Warningf(c, "No OS info found.")
return nil
}
// summary extracts the top level summary from a buildbot build as a
// BuildComponent
func summary(c context.Context, b *buildbotBuild) resp.BuildComponent {
// TODO(hinoka): use b.toStatus()
// Status
var status model.Status
if b.Currentstep != nil {
status = model.Running
} else {
status = result2Status(b.Results)
}
// Timing info
started, ended, duration := parseTimes(nil, b.Times)
// Link to bot and original build.
host := "build.chromium.org/p"
if b.Internal {
host = "uberchromegw.corp.google.com/i"
}
bot := resp.NewLink(
b.Slave,
fmt.Sprintf("https://%s/%s/buildslaves/%s", host, b.Master, b.Slave),
)
source := resp.NewLink(
fmt.Sprintf("%s/%s/%d", b.Master, b.Buildername, b.Number),
fmt.Sprintf("https://%s/%s/builders/%s/builds/%d",
host, b.Master, b.Buildername, b.Number),
)
// The link to the builder page.
parent := resp.NewLink(b.Buildername, ".")
// Do a best effort lookup for the bot information to fill in OS/Platform info.
banner := getBanner(c, b)
sum := resp.BuildComponent{
ParentLabel: parent,
Label: fmt.Sprintf("#%d", b.Number),
Banner: banner,
Status: status,
Started: started,
Finished: ended,
Bot: bot,
Source: source,
Duration: duration,
Type: resp.Summary, // This is more or less ignored.
LevelsDeep: 1,
Text: []string{}, // Status messages. Eg "This build failed on..xyz"
}
return sum
}
var rLineBreak = regexp.MustCompile("<br */?>")
// components takes a full buildbot build struct and extract step info from all
// of the steps and returns it as a list of milo Build Components.
func components(b *buildbotBuild) (result []*resp.BuildComponent) {
endingTime := b.Times[1]
for _, step := range b.Steps {
if step.Hidden == true {
continue
}
bc := &resp.BuildComponent{
Label: step.Name,
}
// Step text sometimes contains <br>, which we want to parse into new lines.
for _, t := range step.Text {
for _, line := range rLineBreak.Split(t, -1) {
bc.Text = append(bc.Text, line)
}
}
// Figure out the status.
if !step.IsStarted {
bc.Status = model.NotRun
} else if !step.IsFinished {
bc.Status = model.Running
} else {
if len(step.Results) > 0 {
status := int(step.Results[0].(float64))
bc.Status = result2Status(&status)
} else {
bc.Status = model.Success
}
}
// Raise the interesting-ness if the step is not "Success".
if bc.Status != model.Success {
bc.Verbosity = resp.Interesting
}
remainingAliases := stringset.New(len(step.Aliases))
for linkAnchor := range step.Aliases {
remainingAliases.Add(linkAnchor)
}
getLinksWithAliases := func(logLink *resp.Link, isLog bool) resp.LinkSet {
// Generate alias links.
var aliases resp.LinkSet
if remainingAliases.Del(logLink.Label) {
stepAliases := step.Aliases[logLink.Label]
aliases = make(resp.LinkSet, len(stepAliases))
for i, alias := range stepAliases {
aliases[i] = alias.toLink()
}
}
// Step log link takes primary, with aliases as secondary.
links := make(resp.LinkSet, 1, 1+len(aliases))
links[0] = logLink
for _, a := range aliases {
a.Alias = true
}
return append(links, aliases...)
}
for _, l := range step.Logs {
logLink := resp.NewLink(l[0], l[1])
links := getLinksWithAliases(logLink, true)
if logLink.Label == "stdio" {
bc.MainLink = links
} else {
bc.SubLink = append(bc.SubLink, links)
}
}
// Step links are stored as maps of name: url
// Because Go doesn't believe in nice things, we now create another array
// just so that we can iterate through this map in order.
names := make([]string, 0, len(step.Urls))
for name := range step.Urls {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
logLink := resp.NewLink(name, step.Urls[name])
bc.SubLink = append(bc.SubLink, getLinksWithAliases(logLink, false))
}
// Add any unused aliases directly.
if remainingAliases.Len() > 0 {
unusedAliases := remainingAliases.ToSlice()
sort.Strings(unusedAliases)
for _, label := range unusedAliases {
var baseLink resp.LinkSet
for _, alias := range step.Aliases[label] {
aliasLink := alias.toLink()
if len(baseLink) == 0 {
aliasLink.Label = label
} else {
aliasLink.Alias = true
}
baseLink = append(baseLink, aliasLink)
}
if len(baseLink) > 0 {
bc.SubLink = append(bc.SubLink, baseLink)
}
}
}
// Figure out the times.
bc.Started, bc.Finished, bc.Duration = parseTimes(endingTime, step.Times)
result = append(result, bc)
}
return
}
// parseProp returns a string representation of v.
func parseProp(v interface{}) string {
// if v is a whole number, force it into an int. json.Marshal() would turn
// it into what looks like a float instead. We want this to remain and
// int instead of a number.
if vf, ok := v.(float64); ok {
if math.Floor(vf) == vf {
return fmt.Sprintf("%d", int64(vf))
}
}
// return the json representation of the value.
b, err := json.Marshal(v)
if err == nil {
return string(b)
}
return fmt.Sprintf("%v", v)
}
// Prop is a struct used to store a value and group so that we can make a map
// of key:Prop to pass into parseProp() for the purpose of cross referencing
// one prop while working on another.
type Prop struct {
Value interface{}
Group string
}
// properties extracts all properties from buildbot builds and groups them into
// property groups.
func properties(b *buildbotBuild) (result []*resp.PropertyGroup) {
groups := map[string]*resp.PropertyGroup{}
allProps := map[string]Prop{}
for _, prop := range b.Properties {
allProps[prop.Name] = Prop{
Value: prop.Value,
Group: prop.Source,
}
}
for key, prop := range allProps {
value := prop.Value
groupName := prop.Group
if _, ok := groups[groupName]; !ok {
groups[groupName] = &resp.PropertyGroup{GroupName: groupName}
}
vs := parseProp(value)
groups[groupName].Property = append(groups[groupName].Property, &resp.Property{
Key: key,
Value: vs,
})
}
// Insert the groups into a list in alphabetical order.
// You have to make a separate sorting data structure because Go doesn't like
// sorting things for you.
groupNames := []string{}
for n := range groups {
groupNames = append(groupNames, n)
}
sort.Strings(groupNames)
for _, k := range groupNames {
group := groups[k]
// Also take this oppertunity to sort the properties within the groups.
sort.Sort(group)
result = append(result, group)
}
return
}
// blame extracts the commit and blame information from a buildbot build and
// returns it as a list of Commits.
func blame(b *buildbotBuild) (result []*resp.Commit) {
if b.Sourcestamp != nil {
for _, c := range b.Sourcestamp.Changes {
files := c.GetFiles()
result = append(result, &resp.Commit{
AuthorEmail: c.Who,
Repo: c.Repository,
CommitTime: time.Unix(int64(c.When), 0).UTC(),
Revision: resp.NewLink(c.Revision, c.Revlink),
Description: c.Comments,
File: files,
})
}
}
return
}
// sourcestamp extracts the source stamp from various parts of a buildbot build,
// including the properties.
func sourcestamp(c context.Context, b *buildbotBuild) *resp.SourceStamp {
ss := &resp.SourceStamp{}
rietveld := ""
gerrit := ""
got_revision := ""
repository := ""
issue := int64(-1)
for _, prop := range b.Properties {
switch prop.Name {
case "rietveld":
if v, ok := prop.Value.(string); ok {
rietveld = v
} else {
logging.Warningf(c, "Field rietveld is not a string: %#v", prop.Value)
}
case "issue":
// Sometime this is a number (float), sometime it is a string.
if v, ok := prop.Value.(float64); ok {
issue = int64(v)
} else if v, ok := prop.Value.(string); ok {
if vi, err := strconv.ParseInt(v, 10, 64); err == nil {
issue = int64(vi)
} else {
logging.Warningf(c, "Could not decode field issue: %q - %s", prop.Value, err)
}
} else {
logging.Warningf(c, "Field issue is not a string or float: %#v", prop.Value)
}
case "got_revision":
if v, ok := prop.Value.(string); ok {
got_revision = v
} else {
logging.Warningf(c, "Field got_revision is not a string: %#v", prop.Value)
}
case "patch_issue":
if v, ok := prop.Value.(float64); ok {
issue = int64(v)
} else {
logging.Warningf(c, "Field patch_issue is not a float: %#v", prop.Value)
}
case "patch_gerrit_url":
if v, ok := prop.Value.(string); ok {
gerrit = v
} else {
logging.Warningf(c, "Field gerrit is not a string: %#v", prop.Value)
}
case "repository":
if v, ok := prop.Value.(string); ok {
repository = v
}
}
}
if issue != -1 {
switch {
case rietveld != "":
rietveld = strings.TrimRight(rietveld, "/")
ss.Changelist = resp.NewLink(
fmt.Sprintf("Rietveld CL %d", issue),
fmt.Sprintf("%s/%d", rietveld, issue))
case gerrit != "":
gerrit = strings.TrimRight(gerrit, "/")
ss.Changelist = resp.NewLink(
fmt.Sprintf("Gerrit CL %d", issue),
fmt.Sprintf("%s/c/%d", gerrit, issue))
}
}
if got_revision != "" {
ss.Revision = resp.NewLink(got_revision, "")
if repository != "" {
ss.Revision.URL = repository + "/+/" + got_revision
}
}
return ss
}
func renderBuild(c context.Context, b *buildbotBuild) *resp.MiloBuild {
// Modify the build for rendering.
updatePostProcessBuild(b)
// TODO(hinoka): Do all fields concurrently.
return &resp.MiloBuild{
SourceStamp: sourcestamp(c, b),
Summary: summary(c, b),
Components: components(b),
PropertyGroup: properties(b),
Blame: blame(b),
}
}
// DebugBuild fetches a debugging build for testing.
func DebugBuild(c context.Context, relBuildbotDir string, builder string, buildNum int) (*resp.MiloBuild, error) {
fname := fmt.Sprintf("%s.%d.json", builder, buildNum)
// ../buildbot below assumes that
// - this code is not executed by tests outside of this dir
// - this dir is a sibling of frontend dir
path := filepath.Join(relBuildbotDir, "testdata", fname)
raw, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
b := &buildbotBuild{}
if err := json.Unmarshal(raw, b); err != nil {
return nil, err
}
return renderBuild(c, b), nil
}
// Build fetches a buildbot build and translates it into a miloBuild.
func Build(c context.Context, master, builder string, buildNum int) (*resp.MiloBuild, error) {
b, err := getBuild(c, master, builder, buildNum)
if err != nil {
return nil, err
}
return renderBuild(c, b), nil
}
// updatePostProcessBuild transforms a build from its raw JSON format into the
// format that should be presented to users.
//
// Post-processing includes:
// - If the build is LogDog-only, promotes aliases (LogDog links) to
// first-class links in the build.
func updatePostProcessBuild(b *buildbotBuild) {
// If this is a LogDog-only build, we want to promote the LogDog links.
if loc, ok := b.getPropertyValue("log_location").(string); ok && strings.HasPrefix(loc, "logdog://") {
linkMap := map[string]string{}
for sidx := range b.Steps {
promoteLogDogLinks(&b.Steps[sidx], sidx == 0, linkMap)
}
// Update "Logs". This field is part of BuildBot, and is the amalgamation
// of all logs in the build's steps. Since each log is out of context of its
// original step, we can't apply the promotion logic; instead, we will use
// the link map to map any old URLs that were matched in "promoteLogDogLnks"
// to their new URLs.
for _, link := range b.Logs {
// "link" is in the form: [NAME, URL]
if len(link) != 2 {
continue
}
if newURL, ok := linkMap[link[1]]; ok {
link[1] = newURL
}
}
}
}
// promoteLogDogLinks updates the links in a BuildBot step to
// promote LogDog links.
//
// A build's links come in one of three forms:
// - Log Links, which link directly to BuildBot build logs.
// - URL Links, which are named links to arbitrary URLs.
// - Aliases, which attach to the label in one of the other types of links and
// augment it with additional named links.
//
// LogDog uses aliases exclusively to attach LogDog logs to other links. When
// the build is LogDog-only, though, the original links are actually junk. What
// we want to do is remove the original junk links and replace them with their
// alias counterparts, so that the "natural" BuildBot links are actually LogDog
// links.
//
// As URLs are re-mapped, the supplied "linkMap" will be updated to map the old
// URLs to the new ones.
func promoteLogDogLinks(s *buildbotStep, isInitialStep bool, linkMap map[string]string) {
type stepLog struct {
label string
url string
}
remainingAliases := stringset.New(len(s.Aliases))
for linkAnchor := range s.Aliases {
remainingAliases.Add(linkAnchor)
}
maybePromoteAliases := func(sl *stepLog, isLog bool) []*stepLog {
// As a special case, if this is the first step ("steps" in BuildBot), we
// will refrain from promoting aliases for "stdio", since "stdio" represents
// the raw BuildBot logs.
if isLog && isInitialStep && sl.label == "stdio" {
// No aliases, don't modify this log.
return []*stepLog{sl}
}
// If there are no aliases, we should obviously not promote them. This will
// be the case for pre-LogDog steps such as build setup.
aliases := s.Aliases[sl.label]
if len(aliases) == 0 {
return []*stepLog{sl}
}
// We have chosen to promote the aliases. Therefore, we will not include
// them as aliases in the modified step.
remainingAliases.Del(sl.label)
result := make([]*stepLog, len(aliases))
for i, alias := range aliases {
aliasStepLog := stepLog{alias.Text, alias.URL}
// Any link named "logdog" (Annotee cosmetic implementation detail) will
// inherit the name of the original log.
if isLog {
if aliasStepLog.label == "logdog" {
aliasStepLog.label = sl.label
}
}
result[i] = &aliasStepLog
}
// If we performed mapping, add the OLD -> NEW URL mapping to linkMap.
//
// Since multpiple aliases can apply to a single log, and we have to pick
// one, here, we'll arbitrarily pick the last one. This is maybe more
// consistent than the first one because linkMap, itself, will end up
// holding the last mapping for any given URL.
if len(result) > 0 {
linkMap[sl.url] = result[len(result)-1].url
}
return result
}
// Update step logs.
newLogs := make([][]string, 0, len(s.Logs))
for _, l := range s.Logs {
for _, res := range maybePromoteAliases(&stepLog{l[0], l[1]}, true) {
newLogs = append(newLogs, []string{res.label, res.url})
}
}
s.Logs = newLogs
// Update step URLs.
newURLs := make(map[string]string, len(s.Urls))
for label, link := range s.Urls {
urlLinks := maybePromoteAliases(&stepLog{label, link}, false)
if len(urlLinks) > 0 {
// Use the last URL link, since our URL map can only tolerate one link.
// The expected case here is that len(urlLinks) == 1, though, but it's
// possible that multiple aliases can be included for a single URL, so
// we need to handle that.
newValue := urlLinks[len(urlLinks)-1]
newURLs[newValue.label] = newValue.url
} else {
newURLs[label] = link
}
}
s.Urls = newURLs
// Preserve any aliases that haven't been promoted.
var newAliases map[string][]*buildbotLinkAlias
if l := remainingAliases.Len(); l > 0 {
newAliases = make(map[string][]*buildbotLinkAlias, l)
remainingAliases.Iter(func(v string) bool {
newAliases[v] = s.Aliases[v]
return true
})
}
s.Aliases = newAliases
}
// BuildID is buildbots's notion of a Build. See buildsource.ID.
type BuildID struct {
Master string
BuilderName string
BuildNumber string
}
// GetLog implements buildsource.ID.
func (b *BuildID) GetLog(context.Context, string) (string, bool, error) { panic("not implemented") }
// Get implements buildsource.ID.
func (b *BuildID) Get(c context.Context) (*resp.MiloBuild, error) {
num, err := strconv.ParseInt(b.BuildNumber, 10, 0)
if err != nil {
return nil, errors.Annotate(err, "BuildNumber is not a number").
Tag(common.CodeParameterError).
Err()
}
if num <= 0 {
return nil, errors.New("BuildNumber must be > 0", common.CodeParameterError)
}
if b.Master == "" {
return nil, errors.New("Master name is required", common.CodeParameterError)
}
if b.BuilderName == "" {
return nil, errors.New("BuilderName name is required", common.CodeParameterError)
}
return Build(c, b.Master, b.BuilderName, int(num))
}