github.com/GoogleCloudPlatform/testgrid@v0.0.174/pkg/updater/gcs.go (about) 1 /* 2 Copyright 2020 The TestGrid Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package updater 18 19 import ( 20 "fmt" 21 "sort" 22 "strconv" 23 "strings" 24 "time" 25 26 "github.com/sirupsen/logrus" 27 28 "github.com/GoogleCloudPlatform/testgrid/internal/result" 29 "github.com/GoogleCloudPlatform/testgrid/metadata" 30 "github.com/GoogleCloudPlatform/testgrid/metadata/junit" 31 statepb "github.com/GoogleCloudPlatform/testgrid/pb/state" 32 statuspb "github.com/GoogleCloudPlatform/testgrid/pb/test_status" 33 "github.com/GoogleCloudPlatform/testgrid/util/gcs" 34 ) 35 36 // gcsResult holds all the downloaded information for a build of a job. 37 // 38 // The suite results become rows and the job metadata is added to the column. 39 type gcsResult struct { 40 podInfo gcs.PodInfo 41 started gcs.Started 42 finished gcs.Finished 43 suites []gcs.SuitesMeta 44 job string 45 build string 46 malformed []string 47 } 48 49 // deadline to collect information (24 hours after the job starts or an hour after finishing). 50 func (r gcsResult) deadline() time.Time { 51 f := r.finished.Timestamp 52 if f == nil { 53 return time.Unix(r.started.Timestamp, 0).Add(24 * time.Hour) 54 } 55 return time.Unix(*f, 0).Add(time.Hour) 56 } 57 58 const maxDuplicates = 20 59 60 // EmailListKey is the expected metadata key for email addresses. 61 const EmailListKey = "EmailAddresses" 62 63 var overflowCell = Cell{ 64 Result: statuspb.TestStatus_FAIL, 65 Icon: "...", 66 Message: "Too many duplicately named rows", 67 } 68 69 func propertyMap(r *junit.Result) map[string][]string { 70 out := map[string][]string{} 71 if r.Properties == nil { 72 return out 73 } 74 for _, p := range r.Properties.PropertyList { 75 out[p.Name] = append(out[p.Name], p.Value) 76 } 77 return out 78 } 79 80 // Means returns means for each given property's values. 81 func Means(properties map[string][]string) map[string]float64 { 82 out := make(map[string]float64, len(properties)) 83 for name, values := range properties { 84 var sum float64 85 var n int 86 for _, str := range values { 87 v, err := strconv.ParseFloat(str, 64) 88 if err != nil { 89 continue 90 } 91 sum += v 92 n++ 93 } 94 if n == 0 { 95 continue 96 } 97 out[name] = sum / float64(n) 98 } 99 return out 100 } 101 102 func first(properties map[string][]string) map[string]string { 103 out := make(map[string]string, len(properties)) 104 for k, v := range properties { 105 if len(v) == 0 { 106 continue 107 } 108 out[k] = v[0] 109 } 110 return out 111 } 112 113 const ( 114 overallRow = "Overall" 115 podInfoRow = "Pod" 116 ) 117 118 // MergeCells will combine the cells into a single result. 119 // 120 // The flaky argument determines whether returned result 121 // is flaky (true) or failing when merging cells with both passing 122 // and failing results. 123 // 124 // Merging multiple results will set the icon to n/N passes 125 // 126 // Includes the message from the "most relevant" cell that includes a message. 127 // Where relevance is determined by result.GTE. 128 func MergeCells(flaky bool, cells ...Cell) Cell { 129 var out Cell 130 if len(cells) == 0 { 131 panic("empty cells") 132 } 133 out = cells[0] 134 135 if len(cells) == 1 { 136 return out 137 } 138 139 var pass int 140 var passMsg string 141 var fail int 142 var failMsg string 143 144 // determine the status and potential messages 145 // gather all metrics 146 means := map[string][]float64{} 147 148 issues := map[string]bool{} 149 150 current := out.Result 151 passMessageResult := current 152 failMessageResult := current 153 154 for _, c := range cells { 155 if result.GTE(c.Result, current) { 156 current = c.Result 157 } 158 switch { 159 case result.Passing(c.Result): 160 pass++ 161 if c.Message != "" && result.GTE(c.Result, passMessageResult) { 162 passMsg = c.Message 163 passMessageResult = c.Result 164 } 165 case result.Failing(c.Result): 166 fail++ 167 if c.Message != "" && result.GTE(c.Result, failMessageResult) { 168 failMsg = c.Message 169 failMessageResult = c.Result 170 } 171 } 172 173 for metric, mean := range c.Metrics { 174 means[metric] = append(means[metric], mean) 175 } 176 177 for _, i := range c.Issues { 178 issues[i] = true 179 } 180 } 181 182 if n := len(issues); n > 0 { 183 out.Issues = make([]string, 0, len(issues)) 184 for key := range issues { 185 out.Issues = append(out.Issues, key) 186 } 187 sort.Strings(out.Issues) 188 } 189 190 if flaky && pass > 0 && fail > 0 { 191 out.Result = statuspb.TestStatus_FLAKY 192 } else { 193 out.Result = current 194 } 195 196 // determine the icon 197 total := len(cells) 198 out.Icon = strconv.Itoa(pass) + "/" + strconv.Itoa(total) 199 200 // compile the message 201 var msg string 202 if failMsg != "" { 203 msg = failMsg 204 } else if passMsg != "" { 205 msg = passMsg 206 } 207 208 if msg != "" { 209 msg = ": " + msg 210 } 211 out.Message = out.Icon + " runs passed" + msg 212 213 // merge metrics 214 if len(means) > 0 { 215 out.Metrics = make(map[string]float64, len(means)) 216 for metric, means := range means { 217 var sum float64 218 for _, m := range means { 219 sum += m 220 } 221 out.Metrics[metric] = sum / float64(len(means)) 222 } 223 } 224 return out 225 } 226 227 // SplitCells appends a unique suffix to each cell. 228 // 229 // When an excessive number of cells contain the same name 230 // the list gets truncated, replaced with a synthetic "... [overflow]" cell. 231 func SplitCells(originalName string, cells ...Cell) map[string]Cell { 232 n := len(cells) 233 if n == 0 { 234 return nil 235 } 236 if n > maxDuplicates { 237 n = maxDuplicates 238 } 239 out := make(map[string]Cell, n) 240 for idx, c := range cells { 241 // Ensure each name is unique 242 // If we have multiple results with the same name foo 243 // then append " [n]" to the name so we wind up with: 244 // foo 245 // foo [1] 246 // foo [2] 247 // etc 248 name := originalName 249 switch idx { 250 case 0: 251 // nothing 252 case maxDuplicates: 253 name = name + " [overflow]" 254 out[name] = overflowCell 255 return out 256 default: 257 name = name + " [" + strconv.Itoa(idx) + "]" 258 } 259 out[name] = c 260 } 261 return out 262 } 263 264 // ignoreStatus returns whether to ignore (equate to "NO_RESULT") a given status based on configuration. 265 func ignoreStatus(opt groupOptions, status statuspb.TestStatus) bool { 266 if status == statuspb.TestStatus_NO_RESULT { 267 return true 268 } 269 if opt.ignoreSkip && status == statuspb.TestStatus_PASS_WITH_SKIPS { 270 return true 271 } 272 // TODO(michelle192837): Implement `ignore_built`, e.g. ignore statuspb.TestStatus_BUILD_PASSED. 273 // TODO(michelle192837): Implement `ignore_pending`, e.g. ignore statuspb.TestStatus_RUNNING. 274 return false 275 } 276 277 // convertResult returns an InflatedColumn representation of the GCS result. 278 func convertResult(log logrus.FieldLogger, nameCfg nameConfig, id string, headers []string, result gcsResult, opt groupOptions) InflatedColumn { 279 cells := map[string][]Cell{} 280 var cellID string 281 if nameCfg.multiJob { 282 cellID = result.job + "/" + id 283 } else if opt.addCellID { 284 cellID = id 285 } 286 287 meta := result.finished.Metadata.Strings() 288 version := metadata.Version(result.started.Started, result.finished.Finished) 289 290 // Append each result into the column 291 for _, suite := range result.suites { 292 for _, r := range flattenResults(suite.Suites.Suites...) { 293 // "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. 294 if r.Skipped != nil && r.Skipped.Value == "" && (r.Skipped.Message == "skipped" || r.Skipped.Message == "") { 295 continue 296 } 297 c := Cell{CellID: cellID} 298 if elapsed := r.Time; elapsed > 0 { 299 c.Metrics = setElapsed(c.Metrics, elapsed) 300 } 301 302 props := propertyMap(&r) 303 for metric, mean := range Means(props) { 304 if c.Metrics == nil { 305 c.Metrics = map[string]float64{} 306 } 307 c.Metrics[metric] = mean 308 } 309 310 const max = 140 311 if msg := r.Message(max); msg != "" { 312 c.Message = msg 313 } 314 315 switch { 316 case r.Errored != nil: 317 c.Result = statuspb.TestStatus_FAIL 318 if c.Message != "" { 319 c.Icon = "F" 320 } 321 case r.Failure != nil: 322 c.Result = statuspb.TestStatus_FAIL 323 if c.Message != "" { 324 c.Icon = "F" 325 } 326 case r.Skipped != nil: 327 c.Result = statuspb.TestStatus_PASS_WITH_SKIPS 328 c.Icon = "S" 329 default: 330 c.Result = statuspb.TestStatus_PASS 331 } 332 333 if override := CustomStatus(opt.rules, jUnitTestResult{&r}); override != nil { 334 c.Result = *override 335 } 336 337 if ignoreStatus(opt, c.Result) { 338 continue 339 } 340 341 for _, annotation := range opt.annotations { 342 _, ok := props[annotation.GetPropertyName()] 343 if !ok { 344 continue 345 } 346 c.Icon = annotation.ShortText 347 break 348 } 349 350 if f, ok := c.Metrics[opt.metricKey]; ok { 351 c.Icon = strconv.FormatFloat(f, 'g', 4, 64) 352 } 353 354 if values, ok := props[opt.userKey]; ok && len(values) > 0 { 355 c.UserProperty = values[0] 356 } 357 358 name := nameCfg.render(result.job, r.Name, first(props), suite.Metadata, meta) 359 cells[name] = append(cells[name], c) 360 } 361 } 362 363 overall := overallCell(result) 364 if overall.Result == statuspb.TestStatus_FAIL && overall.Message == "" { // Ensure failing build has a failing cell and/or overall message 365 var found bool 366 for _, namedCells := range cells { 367 for _, c := range namedCells { 368 if c.Result == statuspb.TestStatus_FAIL { 369 found = true // Failing test, huzzah! 370 break 371 } 372 } 373 if found { 374 break 375 } 376 } 377 if !found { // Nope, add the F icon and an explanatory Message 378 overall.Icon = "F" 379 overall.Message = "Build failed outside of test results" 380 } 381 } 382 injectedCells := map[string]Cell{ 383 overallRow: overall, 384 } 385 386 if opt.analyzeProwJob { 387 if pic := podInfoCell(result); pic.Message != gcs.MissingPodInfo || overall.Result != statuspb.TestStatus_RUNNING { 388 injectedCells[podInfoRow] = pic 389 } 390 } 391 392 for name, c := range injectedCells { 393 c.CellID = cellID 394 jobName := result.job + "." + name 395 cells[jobName] = append([]Cell{c}, cells[jobName]...) 396 if nameCfg.multiJob { 397 cells[name] = append([]Cell{c}, cells[name]...) 398 } 399 } 400 401 buildID := id 402 if opt.buildKey != "" { 403 metadata := result.finished.Metadata.Strings() 404 if metadata != nil { 405 buildID = metadata[opt.buildKey] 406 } 407 if buildID == "" { 408 log.WithFields(logrus.Fields{ 409 "metadata": result.finished.Metadata.Strings(), 410 "overrideBuildKey": opt.buildKey, 411 }).Warning("No override build ID found in metadata.") 412 } 413 } 414 415 out := InflatedColumn{ 416 Column: &statepb.Column{ 417 Build: buildID, 418 Started: float64(result.started.Timestamp * 1000), 419 Hint: id, 420 }, 421 Cells: map[string]Cell{}, 422 } 423 424 for name, cells := range cells { 425 switch { 426 case opt.merge: 427 out.Cells[name] = MergeCells(true, cells...) 428 default: 429 for n, c := range SplitCells(name, cells...) { 430 out.Cells[n] = c 431 } 432 } 433 } 434 435 for _, h := range headers { 436 val, ok := meta[h] 437 if !ok && h == "Commit" && version != metadata.Missing { 438 val = version 439 } else if !ok && overall.Result != statuspb.TestStatus_RUNNING { 440 val = "missing" 441 } 442 out.Column.Extra = append(out.Column.Extra, val) 443 } 444 445 emails, found := result.finished.Finished.Metadata.MultiString(EmailListKey) 446 if len(emails) == 0 && found { 447 log.Error("failed to extract dynamic email list, the list is empty or cannot convert to []string") 448 } 449 out.Column.EmailAddresses = emails 450 return out 451 } 452 453 func podInfoCell(result gcsResult) Cell { 454 podInfo := result.podInfo 455 pass, msg := podInfo.Summarize() 456 var status statuspb.TestStatus 457 var icon string 458 switch { 459 case msg == gcs.MissingPodInfo && time.Now().Before(result.deadline()): 460 status = statuspb.TestStatus_RUNNING // Try and reprocess it next time. 461 case msg == gcs.MissingPodInfo: 462 status = statuspb.TestStatus_PASS_WITH_SKIPS // Probably won't receive it. 463 case pass: 464 status = statuspb.TestStatus_PASS 465 default: 466 status = statuspb.TestStatus_FAIL 467 } 468 469 switch { 470 case msg == gcs.NoPodUtils: 471 icon = "E" 472 case msg == gcs.MissingPodInfo: 473 icon = "!" 474 case !pass: 475 icon = "F" 476 } 477 478 return Cell{ 479 Message: msg, 480 Icon: icon, 481 Result: status, 482 } 483 } 484 485 // overallCell generates the overall cell for this GCS result. 486 func overallCell(result gcsResult) Cell { 487 var c Cell 488 var finished int64 489 if result.finished.Timestamp != nil { 490 finished = *result.finished.Timestamp 491 } 492 switch { 493 case len(result.malformed) > 0: 494 c.Result = statuspb.TestStatus_FAIL 495 c.Message = fmt.Sprintf("Malformed artifacts: %s", strings.Join(result.malformed, ", ")) 496 c.Icon = "E" 497 case finished > 0: // completed result 498 var passed bool 499 res := result.finished.Result 500 switch { 501 case result.finished.Passed == nil: 502 if res != "" { 503 passed = res == "SUCCESS" 504 c.Icon = "E" 505 c.Message = fmt.Sprintf(`finished.json missing "passed": %t`, passed) 506 } 507 case result.finished.Passed != nil: 508 passed = *result.finished.Passed 509 } 510 511 if passed { 512 c.Result = statuspb.TestStatus_PASS 513 } else { 514 c.Result = statuspb.TestStatus_FAIL 515 } 516 c.Metrics = setElapsed(nil, float64(finished-result.started.Timestamp)) 517 case time.Now().After(result.deadline()): 518 c.Result = statuspb.TestStatus_FAIL 519 c.Message = "Build did not complete within 24 hours" 520 c.Icon = "T" 521 default: 522 c.Result = statuspb.TestStatus_RUNNING 523 c.Message = "Build still running..." 524 c.Icon = "R" 525 } 526 return c 527 } 528 529 // ElapsedKey is the key for the target duration metric. 530 const ElapsedKey = "test-duration-minutes" 531 532 // TestMethodsElapsedKey is the key for the test results duration metric. 533 const TestMethodsElapsedKey = "test-methods-duration-minutes" 534 535 // setElapsed inserts the seconds-elapsed metric. 536 func setElapsed(metrics map[string]float64, seconds float64) map[string]float64 { 537 if metrics == nil { 538 metrics = map[string]float64{} 539 } 540 metrics[ElapsedKey] = seconds / 60 541 return metrics 542 } 543 544 // flattenResults returns the DFS of all junit results in all suites. 545 func flattenResults(suites ...junit.Suite) []junit.Result { 546 var results []junit.Result 547 for _, suite := range suites { 548 for _, innerSuite := range suite.Suites { 549 innerSuite.Name = dotName(suite.Name, innerSuite.Name) 550 results = append(results, flattenResults(innerSuite)...) 551 } 552 for _, r := range suite.Results { 553 r.Name = dotName(suite.Name, r.Name) 554 results = append(results, r) 555 } 556 } 557 return results 558 } 559 560 // dotName returns left.right or left or right 561 func dotName(left, right string) string { 562 if left != "" && right != "" { 563 return left + "." + right 564 } 565 if right == "" { 566 return left 567 } 568 return right 569 }