sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/spyglass/spyglass.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 spyglass creates views for Prow job artifacts.
    18  package spyglass
    19  
    20  import (
    21  	"context"
    22  	"encoding/json"
    23  	"fmt"
    24  	"io"
    25  	"net/url"
    26  	"path"
    27  	"sort"
    28  	"strconv"
    29  	"strings"
    30  
    31  	"github.com/sirupsen/logrus"
    32  
    33  	"github.com/GoogleCloudPlatform/testgrid/metadata"
    34  
    35  	prowapi "sigs.k8s.io/prow/pkg/apis/prowjobs/v1"
    36  	"sigs.k8s.io/prow/pkg/config"
    37  	"sigs.k8s.io/prow/pkg/deck/jobs"
    38  	pkgio "sigs.k8s.io/prow/pkg/io"
    39  	"sigs.k8s.io/prow/pkg/io/providers"
    40  	"sigs.k8s.io/prow/pkg/pod-utils/gcs"
    41  	"sigs.k8s.io/prow/pkg/spyglass/api"
    42  	"sigs.k8s.io/prow/pkg/spyglass/lenses"
    43  )
    44  
    45  // Key types specify the way Spyglass will fetch artifact handles
    46  const (
    47  	gcsKeyType  = api.GCSKeyType
    48  	prowKeyType = api.ProwKeyType
    49  )
    50  
    51  // Spyglass records which sets of artifacts need views for a Prow job. The metaphor
    52  // can be understood as follows: A spyglass receives light from a source through
    53  // an eyepiece, which has a lens that ultimately presents a view of the light source
    54  // to the observer. Spyglass receives light (artifacts) via a
    55  // source (src) through the eyepiece (Eyepiece) and presents the view (what you see
    56  // in your browser) via a lens (Lens).
    57  type Spyglass struct {
    58  	// JobAgent contains information about the current jobs in deck
    59  	JobAgent *jobs.JobAgent
    60  
    61  	config   config.Getter
    62  	testgrid *TestGrid
    63  
    64  	*StorageArtifactFetcher
    65  	*PodLogArtifactFetcher
    66  }
    67  
    68  // LensRequest holds data sent by a view
    69  type LensRequest struct {
    70  	Source    string   `json:"src"`
    71  	Index     int      `json:"index"`
    72  	Artifacts []string `json:"artifacts"`
    73  }
    74  
    75  // ExtraLink represents an extra link to be added to the Spyglass page.
    76  type ExtraLink struct {
    77  	Name        string
    78  	Description string
    79  	URL         string
    80  }
    81  
    82  // New constructs a Spyglass object from a JobAgent, a config.Agent, and a storage Client.
    83  func New(ctx context.Context, ja *jobs.JobAgent, cfg config.Getter, opener pkgio.Opener, useCookieAuth bool) *Spyglass {
    84  	return &Spyglass{
    85  		JobAgent:               ja,
    86  		config:                 cfg,
    87  		PodLogArtifactFetcher:  NewPodLogArtifactFetcher(ja),
    88  		StorageArtifactFetcher: NewStorageArtifactFetcher(opener, cfg, useCookieAuth),
    89  		testgrid: &TestGrid{
    90  			conf:   cfg,
    91  			opener: opener,
    92  			ctx:    ctx,
    93  		},
    94  	}
    95  }
    96  
    97  func (sg *Spyglass) Start() {
    98  	sg.testgrid.Start()
    99  }
   100  
   101  type LensConfig interface {
   102  	Config() lenses.LensConfig
   103  }
   104  
   105  // Lenses gets all views of all artifact files matching each regexp with a registered lens
   106  func (sg *Spyglass) Lenses(lensConfigIndexes []int) (orderedIndexes []int, lensMap map[int]LensConfig) {
   107  	type ld struct {
   108  		lens  LensConfig
   109  		index int
   110  	}
   111  	var ls []ld
   112  	for _, lensIndex := range lensConfigIndexes {
   113  		lfc := sg.config().Deck.Spyglass.Lenses[lensIndex]
   114  		lens, err := getLensConfig(lfc)
   115  		if err != nil {
   116  			logrus.WithField("lensName", lfc.Lens.Name).WithError(err).Error("Could not find artifact lens")
   117  		} else {
   118  			ls = append(ls, ld{lens, lensIndex})
   119  		}
   120  	}
   121  	// Make sure lenses are rendered in order by ascending priority
   122  	sort.Slice(ls, func(i, j int) bool {
   123  		iconf := ls[i].lens.Config()
   124  		jconf := ls[j].lens.Config()
   125  		iname := iconf.Name
   126  		jname := jconf.Name
   127  		pi := iconf.Priority
   128  		pj := jconf.Priority
   129  		if pi == pj {
   130  			return iname < jname
   131  		}
   132  		return pi < pj
   133  	})
   134  
   135  	lensMap = map[int]LensConfig{}
   136  	for _, l := range ls {
   137  		orderedIndexes = append(orderedIndexes, l.index)
   138  		lensMap[l.index] = l.lens
   139  	}
   140  
   141  	return orderedIndexes, lensMap
   142  }
   143  
   144  type lensConfigWrapper struct {
   145  	lensConfig lenses.LensConfig
   146  }
   147  
   148  func (l lensConfigWrapper) Config() lenses.LensConfig {
   149  	return l.lensConfig
   150  }
   151  
   152  func getLensConfig(lensFileConfig config.LensFileConfig) (LensConfig, error) {
   153  	lens, err := lenses.GetLens(lensFileConfig.Lens.Name)
   154  	if err != nil && err != lenses.ErrInvalidLensName {
   155  		return nil, err
   156  	}
   157  	if err == nil {
   158  		return lens, nil
   159  	}
   160  	// we couldn't find a local lens (err==lenses.ErrInvalidLensName) so let's search for a remote lens
   161  	if lensFileConfig.RemoteConfig != nil {
   162  		lc := lenses.LensConfig{
   163  			Name:  lensFileConfig.Lens.Name,
   164  			Title: lensFileConfig.RemoteConfig.Title,
   165  		}
   166  		if lensFileConfig.RemoteConfig.Priority != nil {
   167  			lc.Priority = *lensFileConfig.RemoteConfig.Priority
   168  		}
   169  		if lensFileConfig.RemoteConfig.HideTitle != nil {
   170  			lc.HideTitle = *lensFileConfig.RemoteConfig.HideTitle
   171  		}
   172  		return lensConfigWrapper{lc}, nil
   173  	}
   174  	return nil, fmt.Errorf("could not find lens")
   175  }
   176  
   177  func (sg *Spyglass) ResolveSymlink(src string) (string, error) {
   178  	src = strings.TrimSuffix(src, "/")
   179  	keyType, key, err := splitSrc(src)
   180  	if err != nil {
   181  		return "", fmt.Errorf("error parsing src: %w", err)
   182  	}
   183  	switch keyType {
   184  	case prowKeyType:
   185  		return src, nil // prowjob keys cannot be symlinks.
   186  	default:
   187  		if keyType == api.GCSKeyType {
   188  			keyType = providers.GS
   189  		}
   190  		potentialAlias := strings.Split(key, "/")[0]
   191  		if bucket, exists := sg.cfg().Deck.Spyglass.BucketAliases[potentialAlias]; exists {
   192  			key = strings.Replace(key, potentialAlias, bucket, 1)
   193  		}
   194  		reader, err := sg.opener.Reader(context.TODO(), fmt.Sprintf("%s://%s.txt", keyType, key))
   195  		if err != nil {
   196  			if pkgio.IsNotExist(err) {
   197  				return fmt.Sprintf("%s/%s", keyType, key), nil
   198  			}
   199  			return "", err
   200  		}
   201  		// Avoid using ReadAll here to prevent an attacker forcing us to read a giant file into memory.
   202  		bytes := make([]byte, 4096) // assume we won't get more than 4 kB of symlink to read
   203  		n, err := reader.Read(bytes)
   204  		if err != nil && err != io.EOF {
   205  			return "", fmt.Errorf("failed to read symlink file (which does seem to exist): %w", err)
   206  		}
   207  		if n == len(bytes) {
   208  			return "", fmt.Errorf("symlink destination exceeds length limit of %d bytes", len(bytes)-1)
   209  		}
   210  		u, err := url.Parse(string(bytes[:n]))
   211  		if err != nil {
   212  			return "", fmt.Errorf("failed to parse URL: %w", err)
   213  		}
   214  		return path.Join(keyType, u.Host, u.Path), nil
   215  	}
   216  }
   217  
   218  // JobPath returns a link to the directory for the job specified in src
   219  func (sg *Spyglass) JobPath(src string) (string, error) {
   220  	src = strings.TrimSuffix(src, "/")
   221  	keyType, key, err := splitSrc(src)
   222  	if err != nil {
   223  		return "", fmt.Errorf("error parsing src: %w", err)
   224  	}
   225  	split := strings.Split(key, "/")
   226  	switch keyType {
   227  	case prowKeyType:
   228  		if len(split) < 2 {
   229  			return "", fmt.Errorf("invalid key %s: expected <job-name>/<build-id>", key)
   230  		}
   231  		jobName := split[0]
   232  		buildID := split[1]
   233  		job, err := sg.jobAgent.GetProwJob(jobName, buildID)
   234  		if err != nil {
   235  			return "", fmt.Errorf("failed to get prow job from src %q: %w", key, err)
   236  		}
   237  		if job.Spec.DecorationConfig == nil {
   238  			return "", fmt.Errorf("failed to locate GCS upload bucket for %s: job is undecorated", jobName)
   239  		}
   240  		if job.Spec.DecorationConfig.GCSConfiguration == nil {
   241  			return "", fmt.Errorf("failed to locate GCS upload bucket for %s: missing GCS configuration", jobName)
   242  		}
   243  		bktName := job.Spec.DecorationConfig.GCSConfiguration.Bucket
   244  		if strings.Contains(bktName, "://") {
   245  			// handle :// in bucket name if necessary
   246  			bktName = strings.Replace(bktName, "://", "/", 1)
   247  		} else {
   248  			// fallback to gs/ if bucket name is given without storage type
   249  			bktName = fmt.Sprintf("%s/%s", providers.GS, bktName)
   250  		}
   251  		if job.Spec.Type == prowapi.PresubmitJob {
   252  			return path.Join(bktName, gcs.PRLogs, "directory", jobName), nil
   253  		}
   254  		return path.Join(bktName, gcs.NonPRLogs, jobName), nil
   255  	default:
   256  		if keyType == gcsKeyType {
   257  			keyType = providers.GS
   258  		}
   259  		if len(split) < 4 {
   260  			return "", fmt.Errorf("invalid key %s: expected <bucket-name>/<log-type>/.../<job-name>/<build-id>", key)
   261  		}
   262  		// see https://github.com/kubernetes/test-infra/tree/master/gubernator
   263  		bktName := split[0]
   264  		logType := split[1]
   265  		jobName := split[len(split)-2]
   266  		if logType == gcs.NonPRLogs {
   267  			return path.Join(keyType, path.Dir(key)), nil
   268  		} else if logType == gcs.PRLogs {
   269  			return path.Join(keyType, bktName, gcs.PRLogs, "directory", jobName), nil
   270  		}
   271  		return "", fmt.Errorf("unrecognized GCS key: %s", key)
   272  	}
   273  }
   274  
   275  // ProwJob returns a link and state to the YAML for the job specified in src.
   276  // If no job is found, it returns empty strings and nil error.
   277  func (sg *Spyglass) ProwJob(src string) (string, string, prowapi.ProwJobState, error) {
   278  	src = strings.TrimSuffix(src, "/")
   279  	keyType, key, err := splitSrc(src)
   280  	if err != nil {
   281  		return "", "", "", fmt.Errorf("error parsing src: %v", src)
   282  	}
   283  	split := strings.Split(key, "/")
   284  	var jobName string
   285  	var buildID string
   286  	switch keyType {
   287  	case prowKeyType:
   288  		if len(split) < 2 {
   289  			return "", "", "", fmt.Errorf("invalid key %s: expected <job-name>/<build-id>", key)
   290  		}
   291  		jobName = split[0]
   292  		buildID = split[1]
   293  	default:
   294  		if len(split) < 4 {
   295  			return "", "", "", fmt.Errorf("invalid key %s: expected <bucket-name>/<log-type>/.../<job-name>/<build-id>", key)
   296  		}
   297  		jobName = split[len(split)-2]
   298  		buildID = split[len(split)-1]
   299  	}
   300  	job, err := sg.jobAgent.GetProwJob(jobName, buildID)
   301  	if err != nil {
   302  		if jobs.IsErrProwJobNotFound(err) {
   303  			return "", "", "", nil
   304  		}
   305  		return "", "", "", err
   306  	}
   307  	return job.Spec.Job, job.Name, job.Status.State, nil
   308  }
   309  
   310  // RunPath returns the path to the directory for the job run specified in src.
   311  func (sg *Spyglass) RunPath(src string) (string, error) {
   312  	src = strings.TrimSuffix(src, "/")
   313  	keyType, key, err := splitSrc(src)
   314  	if err != nil {
   315  		return "", fmt.Errorf("error parsing src: %v", src)
   316  	}
   317  	switch keyType {
   318  	case prowKeyType:
   319  		_, gcsKey, err := sg.prowToGCS(key)
   320  		if err != nil {
   321  			return "", err
   322  		}
   323  		return gcsKey, nil
   324  	default:
   325  		return key, nil
   326  	}
   327  }
   328  
   329  // RunToPR returns the (org, repo, pr#) tuple referenced by the provided src.
   330  // Returns an error if src does not reference a job with an associated PR.
   331  func (sg *Spyglass) RunToPR(src string) (string, string, int, error) {
   332  	src = strings.TrimSuffix(src, "/")
   333  	keyType, key, err := splitSrc(src)
   334  	if err != nil {
   335  		return "", "", 0, fmt.Errorf("error parsing src: %w", err)
   336  	}
   337  	split := strings.Split(key, "/")
   338  	if len(split) < 2 {
   339  		return "", "", 0, fmt.Errorf("expected more URL components in %q", src)
   340  	}
   341  	switch keyType {
   342  	case prowKeyType:
   343  		if len(split) < 2 {
   344  			return "", "", 0, fmt.Errorf("invalid key %s: expected <job-name>/<build-id>", key)
   345  		}
   346  		jobName := split[0]
   347  		buildID := split[1]
   348  		job, err := sg.jobAgent.GetProwJob(jobName, buildID)
   349  		if err != nil {
   350  			return "", "", 0, fmt.Errorf("failed to get prow job from src %q: %w", key, err)
   351  		}
   352  		if job.Spec.Refs == nil || len(job.Spec.Refs.Pulls) == 0 {
   353  			return "", "", 0, fmt.Errorf("no PRs on job %q", job.Name)
   354  		}
   355  		return job.Spec.Refs.Org, job.Spec.Refs.Repo, job.Spec.Refs.Pulls[0].Number, nil
   356  	default:
   357  		// In theory, we could derive this information without trying to parse the URL by instead fetching the
   358  		// data from uploaded artifacts. In practice, that would not be a great solution: it would require us
   359  		// to try pulling two different metadata files (one for bootstrap and one for podutils), then parse them
   360  		// in unintended ways to infer the original PR. Aside from this being some work to do, it's also slow: we would
   361  		// like to be able to always answer this request without needing to call out to GCS.
   362  		logType := split[1]
   363  		if logType == gcs.NonPRLogs {
   364  			return "", "", 0, fmt.Errorf("not a PR URL: %q", key)
   365  		} else if logType == gcs.PRLogs {
   366  			if len(split) < 3 {
   367  				return "", "", 0, fmt.Errorf("malformed %s key %q should have at least three components", gcs.PRLogs, key)
   368  			}
   369  			prNumStr := split[len(split)-3]
   370  			prNum, err := strconv.Atoi(prNumStr)
   371  			if err != nil {
   372  				return "", "", 0, fmt.Errorf("couldn't parse PR number %q in %q: %w", prNumStr, key, err)
   373  			}
   374  			// We don't actually attempt to look up the job's own configuration.
   375  			// In practice, this shouldn't matter: we only want to read DefaultOrg and DefaultRepo, and overriding those
   376  			// per job would probably be a bad idea (indeed, not even the tests try to do this).
   377  			// This decision should probably be revisited if we ever want other information from it.
   378  			// TODO (droslean): we should get the default decoration config depending on the org/repo.
   379  			ddc := sg.config().Plank.GuessDefaultDecorationConfig("", "")
   380  			if ddc == nil || ddc.GCSConfiguration == nil {
   381  				return "", "", 0, fmt.Errorf("couldn't look up a GCS configuration")
   382  			}
   383  			c := ddc.GCSConfiguration
   384  			// Assumption: we can derive the type of URL from how many components it has, without worrying much about
   385  			// what the actual path configuration is.
   386  			switch len(split) {
   387  			case 7:
   388  				// In this case we suffer an ambiguity when using 'path_strategy: legacy', and the repo
   389  				// is in the default repo, and the repo name contains an underscore.
   390  				// Currently this affects no actual repo. Hopefully we will soon deprecate 'legacy' and
   391  				// ensure it never does.
   392  				parts := strings.SplitN(split[3], "_", 2)
   393  				if len(parts) == 1 {
   394  					return c.DefaultOrg, parts[0], prNum, nil
   395  				}
   396  				return parts[0], parts[1], prNum, nil
   397  			case 6:
   398  				return c.DefaultOrg, c.DefaultRepo, prNum, nil
   399  			default:
   400  				return "", "", 0, fmt.Errorf("didn't understand the GCS URL %q", key)
   401  			}
   402  		} else {
   403  			return "", "", 0, fmt.Errorf("unknown log type: %q", logType)
   404  		}
   405  	}
   406  }
   407  
   408  // ExtraLinks fetches started.json and extracts links from metadata.links.
   409  func (sg *Spyglass) ExtraLinks(ctx context.Context, src string) ([]ExtraLink, error) {
   410  	artifacts, err := sg.FetchArtifacts(ctx, src, "", 1000000, []string{prowapi.StartedStatusFile})
   411  	// Failing to parse src, that's an error.
   412  	if err != nil {
   413  		return nil, err
   414  	}
   415  
   416  	// Failing to find started.json is okay, just return nothing quietly.
   417  	if len(artifacts) == 0 {
   418  		logrus.Debug("Failed to find started.json while looking for extra links.")
   419  		return nil, nil
   420  	}
   421  	// Failing to read an artifact we already know to exist shouldn't happen, so that's an error.
   422  	content, err := artifacts[0].ReadAll()
   423  	if err != nil {
   424  		// Swallow the error if this file is empty
   425  		if size, sizeErr := artifacts[0].Size(); sizeErr != nil && size == 0 {
   426  			logrus.Debug("Started.json is empty.")
   427  			err = nil
   428  		}
   429  		return nil, err
   430  	}
   431  	// Being unable to parse a successfully fetched started.json correctly is also an error.
   432  	started := metadata.Started{}
   433  	if err := json.Unmarshal(content, &started); err != nil {
   434  		return nil, err
   435  	}
   436  	// Not having any links is fine.
   437  	links, ok := started.Metadata.Meta("links")
   438  	if !ok {
   439  		return nil, nil
   440  	}
   441  	extraLinks := make([]ExtraLink, 0, len(*links))
   442  	for _, name := range links.Keys() {
   443  		m, ok := links.Meta(name)
   444  		if !ok {
   445  			// This should never happen, because Keys() should only return valid Metas.
   446  			logrus.Debugf("Got bad link key %q from %s, but that should be impossible.", name, artifacts[0].CanonicalLink())
   447  			continue
   448  		}
   449  		s := m.Strings()
   450  		link := ExtraLink{
   451  			Name:        name,
   452  			URL:         s["url"],
   453  			Description: s["description"],
   454  		}
   455  		if link.URL == "" || link.Name == "" {
   456  			continue
   457  		}
   458  		extraLinks = append(extraLinks, link)
   459  	}
   460  	return extraLinks, nil
   461  }
   462  
   463  // TestGridLink returns a link to a relevant TestGrid tab for the given source string.
   464  // Because there is a one-to-many mapping from job names to TestGrid tabs, the returned tab
   465  // link may not be deterministic.
   466  func (sg *Spyglass) TestGridLink(src string) (string, error) {
   467  	if !sg.testgrid.Ready() || sg.config().Deck.Spyglass.TestGridRoot == "" {
   468  		return "", fmt.Errorf("testgrid is not configured")
   469  	}
   470  
   471  	src = strings.TrimSuffix(src, "/")
   472  	split := strings.Split(src, "/")
   473  	if len(split) < 2 {
   474  		return "", fmt.Errorf("couldn't parse src %q", src)
   475  	}
   476  	jobName := split[len(split)-2]
   477  	q, err := sg.testgrid.FindQuery(jobName)
   478  	if err != nil {
   479  		return "", fmt.Errorf("failed to find query: %w", err)
   480  	}
   481  	return sg.config().Deck.Spyglass.TestGridRoot + q, nil
   482  }