github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/cmd/deck/job_history.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 main
    18  
    19  import (
    20  	"context"
    21  	"encoding/json"
    22  	"fmt"
    23  	"io/ioutil"
    24  	"net/url"
    25  	"path"
    26  	"regexp"
    27  	"sort"
    28  	"strconv"
    29  	"strings"
    30  	"time"
    31  
    32  	"cloud.google.com/go/storage"
    33  	"github.com/sirupsen/logrus"
    34  	"google.golang.org/api/iterator"
    35  	"k8s.io/test-infra/prow/config"
    36  	"k8s.io/test-infra/prow/deck/jobs"
    37  )
    38  
    39  const (
    40  	resultsPerPage  = 20
    41  	idParam         = "buildId"
    42  	latestBuildFile = "latest-build.txt"
    43  
    44  	// ** Job history assumes the GCS layout specified here:
    45  	// https://github.com/kubernetes/test-infra/tree/master/gubernator#gcs-bucket-layout
    46  	logsPrefix     = "logs"
    47  	symLinkPrefix  = "pr-logs/directory"
    48  	spyglassPrefix = "/view/gcs"
    49  	emptyID        = int64(-1) // indicates no build id was specified
    50  )
    51  
    52  var (
    53  	prefixRe = regexp.MustCompile("gs://.*?/")
    54  	linkRe   = regexp.MustCompile("/([0-9]+)\\.txt$")
    55  )
    56  
    57  type buildData struct {
    58  	index        int
    59  	jobName      string
    60  	prefix       string
    61  	SpyglassLink string
    62  	ID           string
    63  	Started      time.Time
    64  	Duration     time.Duration
    65  	Result       string
    66  	commitHash   string
    67  }
    68  
    69  // storageBucket is an abstraction for unit testing
    70  type storageBucket interface {
    71  	getName() string
    72  	listSubDirs(prefix string) ([]string, error)
    73  	listAll(prefix string) ([]string, error)
    74  	readObject(key string) ([]byte, error)
    75  }
    76  
    77  // gcsBucket is our real implementation of storageBucket
    78  type gcsBucket struct {
    79  	name string
    80  	*storage.BucketHandle
    81  }
    82  
    83  type jobHistoryTemplate struct {
    84  	OlderLink    string
    85  	NewerLink    string
    86  	LatestLink   string
    87  	Name         string
    88  	ResultsShown int
    89  	ResultsTotal int
    90  	Builds       []buildData
    91  }
    92  
    93  func (bucket gcsBucket) readObject(key string) ([]byte, error) {
    94  	obj := bucket.Object(key)
    95  	rc, err := obj.NewReader(context.Background())
    96  	if err != nil {
    97  		return []byte{}, fmt.Errorf("failed to get reader for GCS object: %v", err)
    98  	}
    99  	return ioutil.ReadAll(rc)
   100  }
   101  
   102  func (bucket gcsBucket) getName() string {
   103  	return bucket.name
   104  }
   105  
   106  func readLatestBuild(bucket storageBucket, root string) (int64, error) {
   107  	key := path.Join(root, latestBuildFile)
   108  	data, err := bucket.readObject(key)
   109  	if err != nil {
   110  		return -1, fmt.Errorf("failed to read %s: %v", key, err)
   111  	}
   112  	n, err := strconv.ParseInt(string(data), 10, 64)
   113  	if err != nil {
   114  		return -1, fmt.Errorf("failed to parse %s: %v", key, err)
   115  	}
   116  	return n, nil
   117  }
   118  
   119  // resolve sym links into the actual log directory for a particular test run
   120  func (bucket gcsBucket) resolveSymLink(symLink string) (string, error) {
   121  	data, err := bucket.readObject(symLink)
   122  	if err != nil {
   123  		return "", fmt.Errorf("failed to read %s: %v", symLink, err)
   124  	}
   125  	// strip gs://<bucket-name> from global address `u`
   126  	u := string(data)
   127  	return prefixRe.ReplaceAllString(u, ""), nil
   128  }
   129  
   130  func (bucket gcsBucket) spyglassLink(root, id string) (string, error) {
   131  	p, err := bucket.getPath(root, id, "")
   132  	if err != nil {
   133  		return "", fmt.Errorf("failed to get path: %v", err)
   134  	}
   135  	return path.Join(spyglassPrefix, bucket.getName(), p), nil
   136  }
   137  
   138  func (bucket gcsBucket) getPath(root, id, fname string) (string, error) {
   139  	if strings.HasPrefix(root, logsPrefix) {
   140  		return path.Join(root, id, fname), nil
   141  	}
   142  	symLink := path.Join(root, id+".txt")
   143  	dir, err := bucket.resolveSymLink(symLink)
   144  	if err != nil {
   145  		return "", fmt.Errorf("failed to resolve sym link: %v", err)
   146  	}
   147  	return path.Join(dir, fname), nil
   148  }
   149  
   150  // reads specified JSON file in to `data`
   151  func readJSON(bucket storageBucket, key string, data interface{}) error {
   152  	rawData, err := bucket.readObject(key)
   153  	if err != nil {
   154  		return fmt.Errorf("failed to read %s: %v", key, err)
   155  	}
   156  	err = json.Unmarshal(rawData, &data)
   157  	if err != nil {
   158  		return fmt.Errorf("failed to parse %s: %v", key, err)
   159  	}
   160  	return nil
   161  }
   162  
   163  // Lists the GCS "directory paths" immediately under prefix.
   164  func (bucket gcsBucket) listSubDirs(prefix string) ([]string, error) {
   165  	if !strings.HasSuffix(prefix, "/") {
   166  		prefix += "/"
   167  	}
   168  	dirs := []string{}
   169  	it := bucket.Objects(context.Background(), &storage.Query{
   170  		Prefix:    prefix,
   171  		Delimiter: "/",
   172  	})
   173  	for {
   174  		attrs, err := it.Next()
   175  		if err == iterator.Done {
   176  			break
   177  		}
   178  		if err != nil {
   179  			return dirs, err
   180  		}
   181  		if attrs.Prefix != "" {
   182  			dirs = append(dirs, attrs.Prefix)
   183  		}
   184  	}
   185  	return dirs, nil
   186  }
   187  
   188  // Lists all GCS keys with given prefix.
   189  func (bucket gcsBucket) listAll(prefix string) ([]string, error) {
   190  	keys := []string{}
   191  	it := bucket.Objects(context.Background(), &storage.Query{
   192  		Prefix: prefix,
   193  	})
   194  	for {
   195  		attrs, err := it.Next()
   196  		if err == iterator.Done {
   197  			break
   198  		}
   199  		if err != nil {
   200  			return keys, err
   201  		}
   202  		keys = append(keys, attrs.Name)
   203  	}
   204  	return keys, nil
   205  }
   206  
   207  // Gets all build ids for a job.
   208  func (bucket gcsBucket) listBuildIDs(root string) ([]int64, error) {
   209  	ids := []int64{}
   210  	if strings.HasPrefix(root, logsPrefix) {
   211  		dirs, err := bucket.listSubDirs(root)
   212  		if err != nil {
   213  			return ids, fmt.Errorf("failed to list GCS directories: %v", err)
   214  		}
   215  		for _, dir := range dirs {
   216  			i, err := strconv.ParseInt(path.Base(dir), 10, 64)
   217  			if err == nil {
   218  				ids = append(ids, i)
   219  			} else {
   220  				logrus.Warningf("unrecognized directory name (expected int64): %s", dir)
   221  			}
   222  		}
   223  	} else {
   224  		keys, err := bucket.listAll(root)
   225  		if err != nil {
   226  			return ids, fmt.Errorf("failed to list GCS keys: %v", err)
   227  		}
   228  		for _, key := range keys {
   229  			matches := linkRe.FindStringSubmatch(key)
   230  			if len(matches) == 2 {
   231  				i, err := strconv.ParseInt(matches[1], 10, 64)
   232  				if err == nil {
   233  					ids = append(ids, i)
   234  				} else {
   235  					logrus.Warningf("unrecognized file name (expected <int64>.txt): %s", key)
   236  				}
   237  			}
   238  		}
   239  	}
   240  	return ids, nil
   241  }
   242  
   243  func parseJobHistURL(url *url.URL) (bucketName, root string, buildID int64, err error) {
   244  	buildID = emptyID
   245  	p := strings.TrimPrefix(url.Path, "/job-history/")
   246  	s := strings.SplitN(p, "/", 2)
   247  	if len(s) < 2 {
   248  		err = fmt.Errorf("invalid path (expected /job-history/<gcs-path>): %v", url.Path)
   249  		return
   250  	}
   251  	bucketName = s[0]
   252  	root = s[1] // `root` is the root GCS "directory" prefix for this job's results
   253  	if bucketName == "" {
   254  		err = fmt.Errorf("missing GCS bucket name: %v", url.Path)
   255  		return
   256  	}
   257  	if root == "" {
   258  		err = fmt.Errorf("invalid GCS path for job: %v", url.Path)
   259  		return
   260  	}
   261  
   262  	if idVals := url.Query()[idParam]; len(idVals) >= 1 && idVals[0] != "" {
   263  		buildID, err = strconv.ParseInt(idVals[0], 10, 64)
   264  		if err != nil {
   265  			err = fmt.Errorf("invalid value for %s: %v", idParam, err)
   266  			return
   267  		}
   268  		if buildID < 0 {
   269  			err = fmt.Errorf("invalid value %s = %d", idParam, buildID)
   270  			return
   271  		}
   272  	}
   273  
   274  	return
   275  }
   276  
   277  func linkID(url *url.URL, id int64) string {
   278  	u := *url
   279  	q := u.Query()
   280  	var val string
   281  	if id != emptyID {
   282  		val = strconv.FormatInt(id, 10)
   283  	}
   284  	q.Set(idParam, val)
   285  	u.RawQuery = q.Encode()
   286  	return u.String()
   287  }
   288  
   289  func getBuildData(bucket storageBucket, dir string) (buildData, error) {
   290  	b := buildData{
   291  		Result:     "Unknown",
   292  		commitHash: "Unknown",
   293  	}
   294  	started := jobs.Started{}
   295  	err := readJSON(bucket, path.Join(dir, "started.json"), &started)
   296  	if err != nil {
   297  		return b, fmt.Errorf("failed to read started.json: %v", err)
   298  	}
   299  	b.Started = time.Unix(started.Timestamp, 0)
   300  	commitHash, err := getPullCommitHash(started.Pull)
   301  	if err == nil {
   302  		b.commitHash = commitHash
   303  	}
   304  	finished := jobs.Finished{}
   305  	err = readJSON(bucket, path.Join(dir, "finished.json"), &finished)
   306  	if err != nil {
   307  		logrus.Infof("failed to read finished.json (job might be unfinished): %v", err)
   308  	}
   309  	if finished.Timestamp != 0 {
   310  		b.Duration = time.Unix(finished.Timestamp, 0).Sub(b.Started)
   311  	}
   312  	if finished.Result != "" {
   313  		b.Result = finished.Result
   314  	}
   315  	return b, nil
   316  }
   317  
   318  // assumes a to be sorted in descending order
   319  // returns a subslice of a along with its indices (inclusive)
   320  func cropResults(a []int64, max int64) ([]int64, int, int) {
   321  	res := []int64{}
   322  	firstIndex := -1
   323  	lastIndex := 0
   324  	for i, v := range a {
   325  		if v <= max {
   326  			res = append(res, v)
   327  			if firstIndex == -1 {
   328  				firstIndex = i
   329  			}
   330  			lastIndex = i
   331  			if len(res) >= resultsPerPage {
   332  				break
   333  			}
   334  		}
   335  	}
   336  	return res, firstIndex, lastIndex
   337  }
   338  
   339  // golang <3
   340  type int64slice []int64
   341  
   342  func (a int64slice) Len() int           { return len(a) }
   343  func (a int64slice) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
   344  func (a int64slice) Less(i, j int) bool { return a[i] < a[j] }
   345  
   346  // Gets job history from the GCS bucket specified in config.
   347  func getJobHistory(url *url.URL, config *config.Config, gcsClient *storage.Client) (jobHistoryTemplate, error) {
   348  	start := time.Now()
   349  	tmpl := jobHistoryTemplate{}
   350  
   351  	bucketName, root, top, err := parseJobHistURL(url)
   352  	if err != nil {
   353  		return tmpl, fmt.Errorf("invalid url %s: %v", url.String(), err)
   354  	}
   355  	tmpl.Name = root
   356  	bucket := gcsBucket{bucketName, gcsClient.Bucket(bucketName)}
   357  
   358  	latest, err := readLatestBuild(bucket, root)
   359  	if err != nil {
   360  		return tmpl, fmt.Errorf("failed to locate build data: %v", err)
   361  	}
   362  	if top == emptyID || top > latest {
   363  		top = latest
   364  	}
   365  	if top != latest {
   366  		tmpl.LatestLink = linkID(url, emptyID)
   367  	}
   368  
   369  	buildIDs, err := bucket.listBuildIDs(root)
   370  	if err != nil {
   371  		return tmpl, fmt.Errorf("failed to get build ids: %v", err)
   372  	}
   373  	sort.Sort(sort.Reverse(int64slice(buildIDs)))
   374  
   375  	// determine which results to display on this page
   376  	shownIDs, firstIndex, lastIndex := cropResults(buildIDs, top)
   377  
   378  	// get links to the neighboring pages
   379  	if firstIndex > 0 {
   380  		nextIndex := firstIndex - resultsPerPage
   381  		// here emptyID indicates the most recent build, which will not necessarily be buildIDs[0]
   382  		next := emptyID
   383  		if nextIndex >= 0 {
   384  			next = buildIDs[nextIndex]
   385  		}
   386  		tmpl.NewerLink = linkID(url, next)
   387  	}
   388  	if lastIndex < len(buildIDs)-1 {
   389  		tmpl.OlderLink = linkID(url, buildIDs[lastIndex+1])
   390  	}
   391  
   392  	tmpl.Builds = make([]buildData, len(shownIDs))
   393  	tmpl.ResultsShown = len(shownIDs)
   394  	tmpl.ResultsTotal = len(buildIDs)
   395  
   396  	// concurrently fetch data for all of the builds to be shown
   397  	bch := make(chan buildData)
   398  	for i, buildID := range shownIDs {
   399  		go func(i int, buildID int64) {
   400  			id := strconv.FormatInt(buildID, 10)
   401  			dir, err := bucket.getPath(root, id, "")
   402  			if err != nil {
   403  				logrus.Errorf("failed to get path: %v", err)
   404  				bch <- buildData{}
   405  				return
   406  			}
   407  			b, err := getBuildData(bucket, dir)
   408  			if err != nil {
   409  				logrus.Warningf("build %d information incomplete: %v", buildID, err)
   410  			}
   411  			b.index = i
   412  			b.ID = id
   413  			b.SpyglassLink, err = bucket.spyglassLink(root, id)
   414  			if err != nil {
   415  				logrus.Errorf("failed to get spyglass link: %v", err)
   416  			}
   417  			bch <- b
   418  		}(i, buildID)
   419  	}
   420  	for i := 0; i < len(shownIDs); i++ {
   421  		b := <-bch
   422  		tmpl.Builds[b.index] = b
   423  	}
   424  
   425  	elapsed := time.Now().Sub(start)
   426  	logrus.Infof("loaded %s in %v", url.Path, elapsed)
   427  	return tmpl, nil
   428  }