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  }