sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/spyglass/lenses/buildlog/lens.go (about) 1 /* 2 Copyright 2018 The Kubernetes 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 buildlog provides a build log viewer for Spyglass 18 package buildlog 19 20 import ( 21 "bytes" 22 "encoding/json" 23 "errors" 24 "fmt" 25 "html/template" 26 "io" 27 "net/http" 28 "net/url" 29 "path/filepath" 30 "regexp" 31 "strconv" 32 "strings" 33 34 "github.com/sirupsen/logrus" 35 36 prowconfig "sigs.k8s.io/prow/pkg/config" 37 pkgio "sigs.k8s.io/prow/pkg/io" 38 "sigs.k8s.io/prow/pkg/spyglass/api" 39 "sigs.k8s.io/prow/pkg/spyglass/lenses" 40 ) 41 42 const ( 43 name = "buildlog" 44 title = "Build Log" 45 priority = 10 46 neighborLines = 5 // number of "important" lines to be displayed in either direction 47 minLinesSkipped = 5 48 ) 49 50 var defaultHighlightLineLengthMax = 10000 // Default maximum length of a line worth highlighting 51 52 type config struct { 53 HighlightRegexes []string `json:"highlight_regexes"` 54 HideRawLog bool `json:"hide_raw_log,omitempty"` 55 Highlighter *highlightConfig `json:"highlighter,omitempty"` 56 HighlightLengthMax *int `json:"highlight_line_length_max,omitempty"` 57 } 58 59 type highlightConfig struct { 60 // Endpoint specifies the URL to send highlight requests 61 Endpoint string `json:"endpoint"` 62 // Pin should automatically save the highlight when set. 63 Pin bool `json:"pin"` 64 // Overwrite should replace any existing highlight when set. 65 Overwrite bool `json:"overwrite"` 66 // Auto should request highlights before loading the page, implies Pin. 67 Auto bool `json:"auto"` 68 } 69 70 type parsedConfig struct { 71 highlightRegex *regexp.Regexp 72 showRawLog bool 73 highlighter *highlightConfig 74 highlightLengthMax int 75 } 76 77 var _ api.Lens = Lens{} 78 79 // Lens implements the build lens. 80 type Lens struct{} 81 82 // Config returns the lens's configuration. 83 func (lens Lens) Config() lenses.LensConfig { 84 return lenses.LensConfig{ 85 Name: name, 86 Title: title, 87 Priority: priority, 88 } 89 } 90 91 // Header executes the "header" section of the template. 92 func (lens Lens) Header(artifacts []api.Artifact, resourceDir string, config json.RawMessage, spyglassConfig prowconfig.Spyglass) string { 93 return executeTemplate(resourceDir, "header", buildLogsView{}) 94 } 95 96 // defaultErrRE matches keywords and glog error messages. 97 // It is only used if higlight_regexes is not specified in the lens config. 98 var defaultErrRE = regexp.MustCompile(`timed out|ERROR:|(FAIL|Failure \[)\b|panic\b|^E\d{4} \d\d:\d\d:\d\d\.\d\d\d]`) 99 100 func init() { 101 lenses.RegisterLens(Lens{}) 102 } 103 104 // SubLine represents an substring within a LogLine. It it used so error terms can be highlighted. 105 type SubLine struct { 106 Highlighted bool 107 Text string 108 } 109 110 // LogLine represents a line displayed in the LogArtifactView. 111 type LogLine struct { 112 ArtifactName *string 113 Number int 114 Length int 115 Highlighted bool 116 Skip bool 117 SubLines []SubLine 118 Focused bool 119 Clip bool 120 } 121 122 // LineGroup holds multiple lines that can be collapsed/expanded as a block 123 type LineGroup struct { 124 Skip bool 125 Start, End int // closed, open 126 ByteOffset, ByteLength int64 127 LogLines []LogLine 128 ArtifactName *string 129 } 130 131 const moreLines = 20 132 133 func (g LineGroup) Expand() bool { 134 return len(g.LogLines) >= moreLines 135 } 136 137 // callbackRequest represents a request for output lines from an artifact. If Offset is 0 and Length 138 // is -1, all lines will be fetched. 139 type callbackRequest struct { 140 Artifact string `json:"artifact"` 141 Offset int64 `json:"offset"` 142 Length int64 `json:"length"` 143 StartLine int `json:"startLine"` 144 Top int `json:"top"` 145 Bottom int `json:"bottom"` 146 SaveEnd *int `json:"saveEnd"` 147 Analyze bool `json:"analyze"` 148 } 149 150 // LinesSkipped returns the number of lines skipped in a line group. 151 func (g LineGroup) LinesSkipped() int { 152 return g.End - g.Start 153 } 154 155 // LogArtifactView holds a single log file's view 156 type LogArtifactView struct { 157 ArtifactName string 158 ArtifactLink string 159 LineGroups []LineGroup 160 ViewAll bool 161 ShowRawLog bool 162 CanSave bool 163 CanAnalyze bool 164 } 165 166 // buildLogsView holds each log file view 167 type buildLogsView struct { 168 LogViews []LogArtifactView 169 } 170 171 func getConfig(rawConfig json.RawMessage) parsedConfig { 172 conf := parsedConfig{ 173 highlightRegex: defaultErrRE, 174 showRawLog: true, 175 } 176 177 // No config at all is fine. 178 if len(rawConfig) == 0 { 179 return conf 180 } 181 182 var c config 183 if err := json.Unmarshal(rawConfig, &c); err != nil { 184 logrus.WithError(err).Error("Failed to decode buildlog config") 185 return conf 186 } 187 conf.highlighter = c.Highlighter 188 if conf.highlighter != nil && conf.highlighter.Endpoint == "" { 189 conf.highlighter = nil 190 } 191 conf.showRawLog = !c.HideRawLog 192 if len(c.HighlightRegexes) == 0 { 193 return conf 194 } 195 if c.HighlightLengthMax == nil { 196 conf.highlightLengthMax = defaultHighlightLineLengthMax 197 } else { 198 conf.highlightLengthMax = *c.HighlightLengthMax 199 } 200 201 re, err := regexp.Compile(strings.Join(c.HighlightRegexes, "|")) 202 if err != nil { 203 logrus.WithError(err).Warnf("Couldn't compile %q", c.HighlightRegexes) 204 return conf 205 } 206 conf.highlightRegex = re 207 return conf 208 } 209 210 // Body returns the <body> content for a build log (or multiple build logs) 211 func (lens Lens) Body(artifacts []api.Artifact, resourceDir string, data string, rawConfig json.RawMessage, spyglassConfig prowconfig.Spyglass) string { 212 buildLogsView := buildLogsView{ 213 LogViews: []LogArtifactView{}, 214 } 215 216 conf := getConfig(rawConfig) 217 // Read log artifacts and construct template structs 218 for _, a := range artifacts { 219 av := LogArtifactView{ 220 ArtifactName: a.JobPath(), 221 ArtifactLink: a.CanonicalLink(), 222 ShowRawLog: conf.showRawLog, 223 } 224 lines, err := logLinesAll(a) 225 if err != nil { 226 logrus.WithError(err).Info("Error reading log.") 227 continue 228 } 229 artifact := av.ArtifactName 230 meta, _ := a.Metadata() 231 start, end := -1, -1 232 233 for key, val := range meta { 234 var targ *int 235 if key == focusStart { 236 targ = &start 237 } else if key == focusEnd { 238 targ = &end 239 } else { 240 continue 241 } 242 243 n, err := strconv.Atoi(val) 244 if err != nil { 245 continue 246 } 247 *targ = n 248 } 249 analyze := conf.highlighter != nil 250 if start == -1 && analyze && conf.highlighter.Auto { 251 resp, err := analyzeArtifact(a, &conf) 252 if err != nil { 253 logrus.WithError(err).Info("Failed to analyze artifact") 254 } else { 255 start, end = resp.Min, resp.Max 256 } 257 } 258 av.LineGroups = groupLines(&artifact, start, end, highlightLines(lines, 0, &artifact, conf.highlightRegex, conf.highlightLengthMax)...) 259 av.ViewAll = true 260 av.CanSave = canSave(a.CanonicalLink()) 261 av.CanAnalyze = analyze 262 buildLogsView.LogViews = append(buildLogsView.LogViews, av) 263 } 264 265 return executeTemplate(resourceDir, "body", buildLogsView) 266 } 267 268 func canSave(link string) bool { 269 return strings.Contains(link, pkgio.GSAnonHost) || strings.Contains(link, pkgio.GSCookieHost) 270 } 271 272 const failedUnmarshal = "Failed to unmarshal request" 273 const missingArtifact = "No artifact named %s" 274 const focusStart = "focus-start" 275 const focusEnd = "focus-end" 276 277 // Callback is used to retrieve new log segments 278 func (lens Lens) Callback(artifacts []api.Artifact, resourceDir string, data string, rawConfig json.RawMessage, spyglassConfig prowconfig.Spyglass) string { 279 var request callbackRequest 280 err := json.Unmarshal([]byte(data), &request) 281 if err != nil { 282 return failedUnmarshal 283 } 284 artifact, ok := artifactByName(artifacts, request.Artifact) 285 if !ok { 286 return fmt.Sprintf(missingArtifact, request.Artifact) 287 } 288 if request.Analyze { 289 conf := getConfig(rawConfig) 290 hr, err := analyzeArtifact(artifact, &conf) 291 if err != nil { 292 hr = &highlightResponse{Error: err.Error()} 293 } 294 buf, err := json.Marshal(hr) 295 if err != nil { 296 return err.Error() 297 } 298 return string(buf) 299 } 300 if request.SaveEnd != nil { 301 return storeHighlightedLines(&request, artifact) 302 } 303 return loadLines(&request, artifact, resourceDir, rawConfig) 304 } 305 306 type highlightRequest struct { 307 // URL to highlight 308 URL string `json:"url"` 309 // Pin if the highlight should be saved 310 Pin bool `json:"pin"` 311 // Overwrite if an existing highlight should be replaced 312 Overwrite bool `json:"overwrite"` 313 } 314 315 type highlightResponse struct { 316 // Min line number to highlight 317 Min int `json:"min"` 318 // Max line number to highlight (inclusive). 319 Max int `json:"max"` 320 // Link to the highlighted lines 321 Link string `json:"link,omitempty"` 322 // Pinned if the highlight changed 323 Pinned bool `json:"pinned,omitempty"` 324 // Error describing the problem. 325 Error string `json:"error,omitempty"` 326 } 327 328 var ( 329 errNoHighlighter = errors.New("buildlog.highlighter unconfigured") 330 ) 331 332 func analyzeArtifact(artifact api.Artifact, conf *parsedConfig) (*highlightResponse, error) { 333 if conf.highlighter == nil { 334 return nil, errNoHighlighter 335 } 336 link := artifact.CanonicalLink() 337 if !canSave(link) { 338 return nil, fmt.Errorf("Unsupported artifact: %q", link) 339 } 340 u, err := url.Parse(link) 341 if err != nil { 342 return nil, fmt.Errorf("parse artifact link %q: %v", link, err) 343 } 344 log := logrus.WithFields(logrus.Fields{ 345 "artifact": link, 346 }) 347 348 req := highlightRequest{ 349 URL: u.String(), 350 Pin: conf.highlighter.Pin || conf.highlighter.Auto, 351 Overwrite: conf.highlighter.Overwrite, 352 } 353 354 buf, err := json.Marshal(req) 355 if err != nil { 356 log.WithError(err).Error("Failed to marshal highlight request") 357 return nil, fmt.Errorf("bad request for %s", link) 358 } 359 360 resp, err := http.Post(conf.highlighter.Endpoint, "text/plain", bytes.NewBuffer(buf)) 361 if err != nil { 362 log.WithError(err).WithField("link", link).Error("POST to highlighter failed") 363 return nil, fmt.Errorf("POST %s failed", link) 364 } 365 defer resp.Body.Close() 366 if resp.StatusCode >= 400 { 367 log.WithField("status", resp.StatusCode).Error("Response failed") 368 return nil, fmt.Errorf("%s returned status code %d", link, resp.StatusCode) 369 } 370 371 dec := json.NewDecoder(resp.Body) 372 var hr highlightResponse 373 if err := dec.Decode(&hr); err != nil { 374 log.WithError(err).Error("Failed to decode response") 375 return nil, fmt.Errorf("bad response for %s", link) 376 } 377 return &hr, nil 378 } 379 380 func focusLines(artifact api.Artifact, start, end int) error { 381 return artifact.UpdateMetadata(map[string]string{ 382 focusStart: strconv.Itoa(start), 383 focusEnd: strconv.Itoa(end), 384 }) 385 } 386 387 func storeHighlightedLines(request *callbackRequest, artifact api.Artifact) string { 388 err := focusLines(artifact, request.StartLine, *request.SaveEnd) 389 if err != nil { 390 return err.Error() 391 } 392 logrus.WithFields(logrus.Fields{ 393 "artifact": artifact.CanonicalLink(), 394 "start": request.StartLine, 395 "end": request.SaveEnd, 396 }).Info("Saved selected lines") 397 return "" 398 } 399 400 func loadLines(request *callbackRequest, artifact api.Artifact, resourceDir string, rawConfig json.RawMessage) string { 401 402 var err error 403 var lines []string 404 if request.Offset == 0 && request.Length == -1 { 405 lines, err = logLinesAll(artifact) 406 } else { 407 lines, err = logLines(artifact, request.Offset, request.Length) 408 } 409 if err != nil { 410 return fmt.Sprintf("Failed to retrieve log lines: %v", err) 411 } 412 413 var skipFirst bool 414 var skipLines []string 415 skipRequest := *request 416 // Should we expand all the lines? Or just some from the top/bottom. 417 if t, n := request.Top, len(lines); t > 0 && t < n { 418 skipLines = lines[request.Top:] 419 lines = lines[:request.Top] 420 skipRequest.StartLine += t 421 for _, line := range lines { 422 b := int64(len(line) + 1) 423 skipRequest.Offset += b 424 skipRequest.Length -= b 425 } 426 } else if b := request.Bottom; b > 0 && b < n { 427 skipLines = lines[:n-b] 428 lines = lines[n-b:] 429 request.StartLine += (n - b) 430 for _, line := range lines { 431 skipRequest.Length -= int64(len(line) + 1) 432 } 433 skipFirst = true 434 } 435 var skipGroup *LineGroup 436 conf := getConfig(rawConfig) 437 if len(skipLines) > 0 { 438 logLines := highlightLines(skipLines, skipRequest.StartLine, &request.Artifact, conf.highlightRegex, conf.highlightLengthMax) 439 skipGroup = &LineGroup{ 440 Skip: true, 441 Start: skipRequest.StartLine, 442 End: skipRequest.StartLine + len(logLines), 443 ByteOffset: skipRequest.Offset, 444 ByteLength: skipRequest.Length, 445 ArtifactName: &request.Artifact, 446 LogLines: logLines, 447 } 448 } 449 groups := make([]*LineGroup, 0, 2) 450 451 if skipGroup != nil && skipFirst { 452 groups = append(groups, skipGroup) 453 skipGroup = nil 454 } 455 logLines := highlightLines(lines, request.StartLine, &request.Artifact, conf.highlightRegex, conf.highlightLengthMax) 456 groups = append(groups, &LineGroup{ 457 LogLines: logLines, 458 ArtifactName: &request.Artifact, 459 }) 460 if skipGroup != nil { 461 groups = append(groups, skipGroup) 462 } 463 return executeTemplate(resourceDir, "line groups", groups) 464 } 465 466 func artifactByName(artifacts []api.Artifact, name string) (api.Artifact, bool) { 467 for _, a := range artifacts { 468 if a.JobPath() == name { 469 return a, true 470 } 471 } 472 return nil, false 473 } 474 475 // logLinesAll reads all of an artifact and splits it into lines. 476 func logLinesAll(artifact api.Artifact) ([]string, error) { 477 read, err := artifact.ReadAll() 478 if err != nil { 479 return nil, fmt.Errorf("failed to read log %q: %w", artifact.JobPath(), err) 480 } 481 logLines := strings.Split(string(read), "\n") 482 483 return logLines, nil 484 } 485 486 func logLines(artifact api.Artifact, offset, length int64) ([]string, error) { 487 b := make([]byte, length) 488 _, err := artifact.ReadAt(b, offset) 489 if err != nil && err != io.EOF { 490 if err != lenses.ErrGzipOffsetRead { 491 return nil, fmt.Errorf("couldn't read requested bytes: %w", err) 492 } 493 moreBytes, err := artifact.ReadAtMost(offset + length) 494 if err != nil && err != io.EOF { 495 return nil, fmt.Errorf("couldn't handle reading gzipped file: %w", err) 496 } 497 b = moreBytes[offset:] 498 } 499 return strings.Split(string(b), "\n"), nil 500 } 501 502 func highlightLines(lines []string, startLine int, artifact *string, highlightRegex *regexp.Regexp, maxLen int) []LogLine { 503 // mark highlighted lines 504 logLines := make([]LogLine, 0, len(lines)) 505 for i, text := range lines { 506 length := len(text) 507 subLines := []SubLine{} 508 if length <= maxLen { 509 loc := highlightRegex.FindStringIndex(text) 510 for loc != nil { 511 subLines = append(subLines, SubLine{false, text[:loc[0]]}) 512 subLines = append(subLines, SubLine{true, text[loc[0]:loc[1]]}) 513 text = text[loc[1]:] 514 loc = highlightRegex.FindStringIndex(text) 515 } 516 } 517 subLines = append(subLines, SubLine{false, text}) 518 logLines = append(logLines, LogLine{ 519 Length: length + 1, // counting the "\n" 520 SubLines: subLines, 521 Number: startLine + i + 1, 522 Highlighted: len(subLines) > 1, 523 ArtifactName: artifact, 524 Skip: true, 525 }) 526 } 527 return logLines 528 } 529 530 // breaks lines into important/unimportant groups 531 func groupLines(artifact *string, start, end int, logLines ...LogLine) []LineGroup { 532 // show highlighted lines and their neighboring lines 533 for i, line := range logLines { 534 if start > 0 && end > 0 { 535 switch { 536 case line.Number >= start && line.Number <= end: 537 logLines[i].Skip = false 538 logLines[i].Focused = true 539 if line.Number == start { 540 logLines[i].Clip = true 541 } 542 case line.Number+neighborLines >= start && line.Number-neighborLines <= end: 543 logLines[i].Skip = false 544 } 545 continue 546 } 547 if line.Highlighted { 548 for d := -neighborLines; d <= neighborLines; d++ { 549 if i+d < 0 { 550 continue 551 } 552 if i+d >= len(logLines) { 553 break 554 } 555 logLines[i+d].Skip = false 556 } 557 } 558 } 559 // break into groups 560 var currentOffset int64 561 var previousOffset int64 562 var lineGroups []LineGroup 563 var curGroup LineGroup 564 for i, line := range logLines { 565 if line.Skip == curGroup.Skip { 566 curGroup.LogLines = append(curGroup.LogLines, line) 567 currentOffset += int64(line.Length) 568 } else { 569 curGroup.End = i 570 curGroup.ByteLength = currentOffset - previousOffset - 1 // -1 for trailing newline 571 previousOffset = currentOffset 572 if curGroup.Skip { 573 if curGroup.LinesSkipped() < minLinesSkipped { 574 curGroup.Skip = false 575 } 576 } 577 if len(curGroup.LogLines) > 0 { 578 lineGroups = append(lineGroups, curGroup) 579 } 580 curGroup = LineGroup{ 581 Skip: line.Skip, 582 Start: i, 583 LogLines: []LogLine{line}, 584 ByteOffset: currentOffset, 585 ArtifactName: artifact, 586 } 587 currentOffset += int64(line.Length) 588 } 589 } 590 curGroup.End = len(logLines) 591 curGroup.ByteLength = currentOffset - previousOffset - 1 592 if curGroup.Skip { 593 if curGroup.LinesSkipped() < minLinesSkipped { 594 curGroup.Skip = false 595 } 596 } 597 if len(curGroup.LogLines) > 0 { 598 lineGroups = append(lineGroups, curGroup) 599 } 600 return lineGroups 601 } 602 603 // LogViewTemplate executes the log viewer template ready for rendering 604 func executeTemplate(resourceDir, templateName string, data interface{}) string { 605 t := template.New("template.html") 606 _, err := t.ParseFiles(filepath.Join(resourceDir, "template.html")) 607 if err != nil { 608 return fmt.Sprintf("Failed to load template: %v", err) 609 } 610 var buf bytes.Buffer 611 if err := t.ExecuteTemplate(&buf, templateName, data); err != nil { 612 logrus.WithError(err).Error("Error executing template.") 613 } 614 return buf.String() 615 }