pkg/updater/gcs.go (449 lines of code) (raw):
/*
Copyright 2020 The TestGrid 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 updater
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/sirupsen/logrus"
"github.com/GoogleCloudPlatform/testgrid/internal/result"
"github.com/GoogleCloudPlatform/testgrid/metadata"
"github.com/GoogleCloudPlatform/testgrid/metadata/junit"
statepb "github.com/GoogleCloudPlatform/testgrid/pb/state"
statuspb "github.com/GoogleCloudPlatform/testgrid/pb/test_status"
"github.com/GoogleCloudPlatform/testgrid/util/gcs"
)
// gcsResult holds all the downloaded information for a build of a job.
//
// The suite results become rows and the job metadata is added to the column.
type gcsResult struct {
podInfo gcs.PodInfo
started gcs.Started
finished gcs.Finished
suites []gcs.SuitesMeta
job string
build string
malformed []string
}
// deadline to collect information (24 hours after the job starts or an hour after finishing).
func (r gcsResult) deadline() time.Time {
f := r.finished.Timestamp
if f == nil {
return time.Unix(r.started.Timestamp, 0).Add(24 * time.Hour)
}
return time.Unix(*f, 0).Add(time.Hour)
}
const maxDuplicates = 20
// EmailListKey is the expected metadata key for email addresses.
const EmailListKey = "EmailAddresses"
var overflowCell = Cell{
Result: statuspb.TestStatus_FAIL,
Icon: "...",
Message: "Too many duplicately named rows",
}
func propertyMap(r *junit.Result) map[string][]string {
out := map[string][]string{}
if r.Properties == nil {
return out
}
for _, p := range r.Properties.PropertyList {
out[p.Name] = append(out[p.Name], p.Value)
}
return out
}
// Means returns means for each given property's values.
func Means(properties map[string][]string) map[string]float64 {
out := make(map[string]float64, len(properties))
for name, values := range properties {
var sum float64
var n int
for _, str := range values {
v, err := strconv.ParseFloat(str, 64)
if err != nil {
continue
}
sum += v
n++
}
if n == 0 {
continue
}
out[name] = sum / float64(n)
}
return out
}
func first(properties map[string][]string) map[string]string {
out := make(map[string]string, len(properties))
for k, v := range properties {
if len(v) == 0 {
continue
}
out[k] = v[0]
}
return out
}
const (
overallRow = "Overall"
podInfoRow = "Pod"
)
// MergeCells will combine the cells into a single result.
//
// The flaky argument determines whether returned result
// is flaky (true) or failing when merging cells with both passing
// and failing results.
//
// Merging multiple results will set the icon to n/N passes
//
// Includes the message from the "most relevant" cell that includes a message.
// Where relevance is determined by result.GTE.
func MergeCells(flaky bool, cells ...Cell) Cell {
var out Cell
if len(cells) == 0 {
panic("empty cells")
}
out = cells[0]
if len(cells) == 1 {
return out
}
var pass int
var passMsg string
var fail int
var failMsg string
// determine the status and potential messages
// gather all metrics
means := map[string][]float64{}
issues := map[string]bool{}
current := out.Result
passMessageResult := current
failMessageResult := current
for _, c := range cells {
if result.GTE(c.Result, current) {
current = c.Result
}
switch {
case result.Passing(c.Result):
pass++
if c.Message != "" && result.GTE(c.Result, passMessageResult) {
passMsg = c.Message
passMessageResult = c.Result
}
case result.Failing(c.Result):
fail++
if c.Message != "" && result.GTE(c.Result, failMessageResult) {
failMsg = c.Message
failMessageResult = c.Result
}
}
for metric, mean := range c.Metrics {
means[metric] = append(means[metric], mean)
}
for _, i := range c.Issues {
issues[i] = true
}
}
if n := len(issues); n > 0 {
out.Issues = make([]string, 0, len(issues))
for key := range issues {
out.Issues = append(out.Issues, key)
}
sort.Strings(out.Issues)
}
if flaky && pass > 0 && fail > 0 {
out.Result = statuspb.TestStatus_FLAKY
} else {
out.Result = current
}
// determine the icon
total := len(cells)
out.Icon = strconv.Itoa(pass) + "/" + strconv.Itoa(total)
// compile the message
var msg string
if failMsg != "" {
msg = failMsg
} else if passMsg != "" {
msg = passMsg
}
if msg != "" {
msg = ": " + msg
}
out.Message = out.Icon + " runs passed" + msg
// merge metrics
if len(means) > 0 {
out.Metrics = make(map[string]float64, len(means))
for metric, means := range means {
var sum float64
for _, m := range means {
sum += m
}
out.Metrics[metric] = sum / float64(len(means))
}
}
return out
}
// SplitCells appends a unique suffix to each cell.
//
// When an excessive number of cells contain the same name
// the list gets truncated, replaced with a synthetic "... [overflow]" cell.
func SplitCells(originalName string, cells ...Cell) map[string]Cell {
n := len(cells)
if n == 0 {
return nil
}
if n > maxDuplicates {
n = maxDuplicates
}
out := make(map[string]Cell, n)
for idx, c := range cells {
// Ensure each name is unique
// If we have multiple results with the same name foo
// then append " [n]" to the name so we wind up with:
// foo
// foo [1]
// foo [2]
// etc
name := originalName
switch idx {
case 0:
// nothing
case maxDuplicates:
name = name + " [overflow]"
out[name] = overflowCell
return out
default:
name = name + " [" + strconv.Itoa(idx) + "]"
}
out[name] = c
}
return out
}
// ignoreStatus returns whether to ignore (equate to "NO_RESULT") a given status based on configuration.
func ignoreStatus(opt groupOptions, status statuspb.TestStatus) bool {
if status == statuspb.TestStatus_NO_RESULT {
return true
}
if opt.ignoreSkip && status == statuspb.TestStatus_PASS_WITH_SKIPS {
return true
}
// TODO(michelle192837): Implement `ignore_built`, e.g. ignore statuspb.TestStatus_BUILD_PASSED.
// TODO(michelle192837): Implement `ignore_pending`, e.g. ignore statuspb.TestStatus_RUNNING.
return false
}
// convertResult returns an InflatedColumn representation of the GCS result.
func convertResult(log logrus.FieldLogger, nameCfg nameConfig, id string, headers []string, result gcsResult, opt groupOptions) InflatedColumn {
cells := map[string][]Cell{}
var cellID string
if nameCfg.multiJob {
cellID = result.job + "/" + id
} else if opt.addCellID {
cellID = id
}
meta := result.finished.Metadata.Strings()
version := metadata.Version(result.started.Started, result.finished.Finished)
// Append each result into the column
for _, suite := range result.suites {
for _, r := range flattenResults(suite.Suites.Suites...) {
// "skipped" is the string that is always appended when the test is skipped without any reason in Ginkgo V2, e.g., "focus" is specified, and the test is skipped.
if r.Skipped != nil && r.Skipped.Value == "" && (r.Skipped.Message == "skipped" || r.Skipped.Message == "") {
continue
}
c := Cell{CellID: cellID}
if elapsed := r.Time; elapsed > 0 {
c.Metrics = setElapsed(c.Metrics, elapsed)
}
props := propertyMap(&r)
for metric, mean := range Means(props) {
if c.Metrics == nil {
c.Metrics = map[string]float64{}
}
c.Metrics[metric] = mean
}
const max = 140
if msg := r.Message(max); msg != "" {
c.Message = msg
}
switch {
case r.Errored != nil:
c.Result = statuspb.TestStatus_FAIL
if c.Message != "" {
c.Icon = "F"
}
case r.Failure != nil:
c.Result = statuspb.TestStatus_FAIL
if c.Message != "" {
c.Icon = "F"
}
case r.Skipped != nil:
c.Result = statuspb.TestStatus_PASS_WITH_SKIPS
c.Icon = "S"
default:
c.Result = statuspb.TestStatus_PASS
}
if override := CustomStatus(opt.rules, jUnitTestResult{&r}); override != nil {
c.Result = *override
}
if ignoreStatus(opt, c.Result) {
continue
}
for _, annotation := range opt.annotations {
_, ok := props[annotation.GetPropertyName()]
if !ok {
continue
}
c.Icon = annotation.ShortText
break
}
if f, ok := c.Metrics[opt.metricKey]; ok {
c.Icon = strconv.FormatFloat(f, 'g', 4, 64)
}
if values, ok := props[opt.userKey]; ok && len(values) > 0 {
c.UserProperty = values[0]
}
name := nameCfg.render(result.job, r.Name, first(props), suite.Metadata, meta)
cells[name] = append(cells[name], c)
}
}
overall := overallCell(result)
if overall.Result == statuspb.TestStatus_FAIL && overall.Message == "" { // Ensure failing build has a failing cell and/or overall message
var found bool
for _, namedCells := range cells {
for _, c := range namedCells {
if c.Result == statuspb.TestStatus_FAIL {
found = true // Failing test, huzzah!
break
}
}
if found {
break
}
}
if !found { // Nope, add the F icon and an explanatory Message
overall.Icon = "F"
overall.Message = "Build failed outside of test results"
}
}
injectedCells := map[string]Cell{
overallRow: overall,
}
if opt.analyzeProwJob {
if pic := podInfoCell(result); pic.Message != gcs.MissingPodInfo || overall.Result != statuspb.TestStatus_RUNNING {
injectedCells[podInfoRow] = pic
}
}
for name, c := range injectedCells {
c.CellID = cellID
jobName := result.job + "." + name
cells[jobName] = append([]Cell{c}, cells[jobName]...)
if nameCfg.multiJob {
cells[name] = append([]Cell{c}, cells[name]...)
}
}
buildID := id
if opt.buildKey != "" {
metadata := result.finished.Metadata.Strings()
if metadata != nil {
buildID = metadata[opt.buildKey]
}
if buildID == "" {
log.WithFields(logrus.Fields{
"metadata": result.finished.Metadata.Strings(),
"overrideBuildKey": opt.buildKey,
}).Warning("No override build ID found in metadata.")
}
}
out := InflatedColumn{
Column: &statepb.Column{
Build: buildID,
Started: float64(result.started.Timestamp * 1000),
Hint: id,
},
Cells: map[string]Cell{},
}
for name, cells := range cells {
switch {
case opt.merge:
out.Cells[name] = MergeCells(true, cells...)
default:
for n, c := range SplitCells(name, cells...) {
out.Cells[n] = c
}
}
}
for _, h := range headers {
val, ok := meta[h]
if !ok && h == "Commit" && version != metadata.Missing {
val = version
} else if !ok && overall.Result != statuspb.TestStatus_RUNNING {
val = "missing"
}
out.Column.Extra = append(out.Column.Extra, val)
}
emails, found := result.finished.Finished.Metadata.MultiString(EmailListKey)
if len(emails) == 0 && found {
log.Error("failed to extract dynamic email list, the list is empty or cannot convert to []string")
}
out.Column.EmailAddresses = emails
return out
}
func podInfoCell(result gcsResult) Cell {
podInfo := result.podInfo
pass, msg := podInfo.Summarize()
var status statuspb.TestStatus
var icon string
switch {
case msg == gcs.MissingPodInfo && time.Now().Before(result.deadline()):
status = statuspb.TestStatus_RUNNING // Try and reprocess it next time.
case msg == gcs.MissingPodInfo:
status = statuspb.TestStatus_PASS_WITH_SKIPS // Probably won't receive it.
case pass:
status = statuspb.TestStatus_PASS
default:
status = statuspb.TestStatus_FAIL
}
switch {
case msg == gcs.NoPodUtils:
icon = "E"
case msg == gcs.MissingPodInfo:
icon = "!"
case !pass:
icon = "F"
}
return Cell{
Message: msg,
Icon: icon,
Result: status,
}
}
// overallCell generates the overall cell for this GCS result.
func overallCell(result gcsResult) Cell {
var c Cell
var finished int64
if result.finished.Timestamp != nil {
finished = *result.finished.Timestamp
}
switch {
case len(result.malformed) > 0:
c.Result = statuspb.TestStatus_FAIL
c.Message = fmt.Sprintf("Malformed artifacts: %s", strings.Join(result.malformed, ", "))
c.Icon = "E"
case finished > 0: // completed result
var passed bool
res := result.finished.Result
switch {
case result.finished.Passed == nil:
if res != "" {
passed = res == "SUCCESS"
c.Icon = "E"
c.Message = fmt.Sprintf(`finished.json missing "passed": %t`, passed)
}
case result.finished.Passed != nil:
passed = *result.finished.Passed
}
if passed {
c.Result = statuspb.TestStatus_PASS
} else {
c.Result = statuspb.TestStatus_FAIL
}
c.Metrics = setElapsed(nil, float64(finished-result.started.Timestamp))
case time.Now().After(result.deadline()):
c.Result = statuspb.TestStatus_FAIL
c.Message = "Build did not complete within 24 hours"
c.Icon = "T"
default:
c.Result = statuspb.TestStatus_RUNNING
c.Message = "Build still running..."
c.Icon = "R"
}
return c
}
// ElapsedKey is the key for the target duration metric.
const ElapsedKey = "test-duration-minutes"
// TestMethodsElapsedKey is the key for the test results duration metric.
const TestMethodsElapsedKey = "test-methods-duration-minutes"
// setElapsed inserts the seconds-elapsed metric.
func setElapsed(metrics map[string]float64, seconds float64) map[string]float64 {
if metrics == nil {
metrics = map[string]float64{}
}
metrics[ElapsedKey] = seconds / 60
return metrics
}
// flattenResults returns the DFS of all junit results in all suites.
func flattenResults(suites ...junit.Suite) []junit.Result {
var results []junit.Result
for _, suite := range suites {
for _, innerSuite := range suite.Suites {
innerSuite.Name = dotName(suite.Name, innerSuite.Name)
results = append(results, flattenResults(innerSuite)...)
}
for _, r := range suite.Results {
r.Name = dotName(suite.Name, r.Name)
results = append(results, r)
}
}
return results
}
// dotName returns left.right or left or right
func dotName(left, right string) string {
if left != "" && right != "" {
return left + "." + right
}
if right == "" {
return left
}
return right
}