github.com/GoogleCloudPlatform/testgrid@v0.0.174/util/gcs/read.go (about)

     1  /*
     2  Copyright 2019 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 gcs
    18  
    19  import (
    20  	"context"
    21  	"encoding/json"
    22  	"errors"
    23  	"fmt"
    24  	"net/url"
    25  	"path"
    26  	"regexp"
    27  	"strconv"
    28  	"strings"
    29  
    30  	"cloud.google.com/go/storage"
    31  	"github.com/fvbommel/sortorder"
    32  	"google.golang.org/api/iterator"
    33  	core "k8s.io/api/core/v1"
    34  
    35  	"github.com/GoogleCloudPlatform/testgrid/metadata"
    36  	"github.com/GoogleCloudPlatform/testgrid/metadata/junit"
    37  )
    38  
    39  // PodInfo holds podinfo.json (data about the pod).
    40  type PodInfo struct {
    41  	Pod *core.Pod `json:"pod,omitempty"`
    42  	// ignore unused events
    43  }
    44  
    45  const (
    46  	// MissingPodInfo appears when builds complete without a podinfo.json report.
    47  	MissingPodInfo = "podinfo.json not found in job artifacts (has not uploaded, or Prow's GCS reporter is not enabled)."
    48  	// NoPodUtils appears when builds run without decoration.
    49  	NoPodUtils = "Decoration is not enabled; set `decorate: true` on Prowjob."
    50  )
    51  
    52  func truncate(s string, max int) string {
    53  	if max <= 0 {
    54  		return s
    55  	}
    56  	l := len(s)
    57  	if l < max {
    58  		return s
    59  	}
    60  	h := max / 2
    61  	return s[:h] + "..." + s[l-h:]
    62  }
    63  
    64  func checkContainerStatus(status core.ContainerStatus) (bool, string) {
    65  	name := status.Name
    66  	if status.State.Waiting != nil {
    67  		return false, fmt.Sprintf("%s still waiting: %s", name, status.State.Waiting.Message)
    68  	}
    69  	if status.State.Running != nil {
    70  		return false, fmt.Sprintf("%s still running", name)
    71  	}
    72  	if status.State.Terminated != nil && status.State.Terminated.ExitCode != 0 {
    73  		return false, fmt.Sprintf("%s exited %d: %s", name, status.State.Terminated.ExitCode, truncate(status.State.Terminated.Message, 140))
    74  	}
    75  	return true, ""
    76  }
    77  
    78  // Summarize returns if the pod completed successfully and a diagnostic message.
    79  func (pi PodInfo) Summarize() (bool, string) {
    80  	if pi.Pod == nil {
    81  		return false, MissingPodInfo
    82  	}
    83  
    84  	if pi.Pod.Status.Phase == core.PodSucceeded {
    85  		return true, ""
    86  	}
    87  
    88  	conditions := make(map[core.PodConditionType]core.PodCondition, len(pi.Pod.Status.Conditions))
    89  
    90  	for _, cond := range pi.Pod.Status.Conditions {
    91  		conditions[cond.Type] = cond
    92  	}
    93  
    94  	if cond, ok := conditions[core.PodScheduled]; ok && cond.Status != core.ConditionTrue {
    95  		return false, fmt.Sprintf("pod did not schedule: %s", cond.Message)
    96  	}
    97  
    98  	if cond, ok := conditions[core.PodInitialized]; ok && cond.Status != core.ConditionTrue {
    99  		return false, fmt.Sprintf("pod could not initialize: %s", cond.Message)
   100  	}
   101  
   102  	for _, status := range pi.Pod.Status.InitContainerStatuses {
   103  		if pass, msg := checkContainerStatus(status); !pass {
   104  			return pass, fmt.Sprintf("init container %s", msg)
   105  		}
   106  	}
   107  
   108  	var foundSidecar bool
   109  	for _, status := range pi.Pod.Status.ContainerStatuses {
   110  		if status.Name == "sidecar" {
   111  			foundSidecar = true
   112  		}
   113  		pass, msg := checkContainerStatus(status)
   114  		if pass {
   115  			continue
   116  		}
   117  		if status.Name == "sidecar" {
   118  			return pass, msg
   119  		}
   120  		if status.State.Terminated == nil {
   121  			return pass, msg
   122  		}
   123  	}
   124  
   125  	if !foundSidecar {
   126  		return true, NoPodUtils
   127  	}
   128  	return true, ""
   129  }
   130  
   131  // Started holds started.json data.
   132  type Started struct {
   133  	metadata.Started
   134  	// Pending when the job has not started yet
   135  	Pending bool
   136  }
   137  
   138  // Finished holds finished.json data.
   139  type Finished struct {
   140  	metadata.Finished
   141  	// Running when the job hasn't finished and finished.json doesn't exist
   142  	Running bool
   143  }
   144  
   145  // Build points to a build stored under a particular gcs prefix.
   146  type Build struct {
   147  	Path     Path
   148  	baseName string
   149  }
   150  
   151  func (build Build) object() string {
   152  	o := build.Path.Object()
   153  	if strings.HasSuffix(o, "/") {
   154  		return o[0 : len(o)-1]
   155  	}
   156  	return o
   157  }
   158  
   159  // Build is the unique invocation id of the job.
   160  func (build Build) Build() string {
   161  	return path.Base(build.object())
   162  }
   163  
   164  // Job is the name of the job for this build
   165  func (build Build) Job() string {
   166  	return path.Base(path.Dir(build.object()))
   167  }
   168  
   169  func (build Build) String() string {
   170  	return build.Path.String()
   171  }
   172  
   173  func readLink(objAttrs *storage.ObjectAttrs) string {
   174  	if link, ok := objAttrs.Metadata["x-goog-meta-link"]; ok {
   175  		return link
   176  	}
   177  	if link, ok := objAttrs.Metadata["link"]; ok {
   178  		return link
   179  	}
   180  	return ""
   181  }
   182  
   183  // hackOffset handles tot's sequential names, which GCS handles poorly
   184  // AKA asking GCS to return results after 6 will never find 10
   185  // So we always have to list everything for these types of numbers.
   186  func hackOffset(offset *string) string {
   187  	if *offset == "" {
   188  		return ""
   189  	}
   190  	if strings.HasSuffix(*offset, "/") {
   191  		*offset = (*offset)[:len(*offset)-1]
   192  	}
   193  	dir, offsetBaseName := path.Split(*offset)
   194  	const first = 1000000000000000000
   195  	if n, err := strconv.Atoi(offsetBaseName); err == nil && n < first {
   196  		*offset = path.Join(dir, "0")
   197  	}
   198  	return offsetBaseName
   199  }
   200  
   201  // ListBuilds returns the array of builds under path, sorted in monotonically decreasing order.
   202  func ListBuilds(parent context.Context, lister Lister, gcsPath Path, after *Path) ([]Build, error) {
   203  	ctx, cancel := context.WithCancel(parent)
   204  	defer cancel()
   205  	var offset string
   206  	if after != nil {
   207  		offset = after.Object()
   208  	}
   209  	offsetBaseName := hackOffset(&offset)
   210  	if !strings.HasSuffix(offset, "/") {
   211  		offset += "/"
   212  	}
   213  	it := lister.Objects(ctx, gcsPath, "/", offset)
   214  	var all []Build
   215  	for {
   216  		objAttrs, err := it.Next()
   217  		if errors.Is(err, iterator.Done) {
   218  			break
   219  		}
   220  		if err != nil {
   221  			return nil, fmt.Errorf("list objects: %w", err)
   222  		}
   223  
   224  		// if this is a link under directory/, resolve the build value
   225  		// This is used for PR type jobs which we store in a PR specific prefix.
   226  		// The directory prefix contains a link header to the result
   227  		// under the PR specific prefix.
   228  		if link := readLink(objAttrs); link != "" {
   229  			// links created by bootstrap.py have a space
   230  			link = strings.TrimSpace(link)
   231  			u, err := url.Parse(link)
   232  			if err != nil {
   233  				return nil, fmt.Errorf("parse %s link: %v", objAttrs.Name, err)
   234  			}
   235  			if !strings.HasSuffix(u.Path, "/") {
   236  				u.Path += "/"
   237  			}
   238  			var linkPath Path
   239  			if err := linkPath.SetURL(u); err != nil {
   240  				return nil, fmt.Errorf("bad %s link path %s: %w", objAttrs.Name, u, err)
   241  			}
   242  			all = append(all, Build{
   243  				Path:     linkPath,
   244  				baseName: path.Base(objAttrs.Name),
   245  			})
   246  			continue
   247  		}
   248  
   249  		if objAttrs.Prefix == "" {
   250  			continue // not a symlink to a directory
   251  		}
   252  
   253  		loc := "gs://" + gcsPath.Bucket() + "/" + objAttrs.Prefix
   254  		gcsPath, err := NewPath(loc)
   255  		if err != nil {
   256  			return nil, fmt.Errorf("bad path %q: %w", loc, err)
   257  		}
   258  
   259  		all = append(all, Build{
   260  			Path:     *gcsPath,
   261  			baseName: path.Base(objAttrs.Prefix),
   262  		})
   263  	}
   264  
   265  	Sort(all)
   266  
   267  	if offsetBaseName != "" {
   268  		// GCS will return 200 2000 30 for a prefix of 100
   269  		// testgrid expects this as 2000 200 (dropping 30)
   270  		for i, b := range all {
   271  			if sortorder.NaturalLess(b.baseName, offsetBaseName) || b.baseName == offsetBaseName {
   272  				return all[:i], nil // b <= offsetBaseName, so skip this one
   273  			}
   274  		}
   275  	}
   276  	return all, nil
   277  }
   278  
   279  // junit_CONTEXT_TIMESTAMP_THREAD.xml
   280  var re = regexp.MustCompile(`.+/(?:junit((_[^_]+)?(_\d+-\d+)?(_\d+)?|.+)?\.xml|test.xml)$`)
   281  
   282  // dropPrefix removes the _ in _CONTEXT to help keep the regexp simple
   283  func dropPrefix(name string) string {
   284  	if len(name) == 0 {
   285  		return name
   286  	}
   287  	return name[1:]
   288  }
   289  
   290  // parseSuitesMeta returns the metadata for this junit file (nil for a non-junit file).
   291  //
   292  // Expected format: junit_context_20180102-1256_07.xml
   293  // Results in {
   294  //   "Context": "context",
   295  //   "Timestamp": "20180102-1256",
   296  //   "Thread": "07",
   297  // }
   298  func parseSuitesMeta(name string) map[string]string {
   299  	mat := re.FindStringSubmatch(name)
   300  	if mat == nil {
   301  		return nil
   302  	}
   303  	c, ti, th := dropPrefix(mat[2]), dropPrefix(mat[3]), dropPrefix(mat[4])
   304  	if c == "" && ti == "" && th == "" {
   305  		c = mat[1]
   306  	}
   307  	return map[string]string{
   308  		"Context":   c,
   309  		"Timestamp": ti,
   310  		"Thread":    th,
   311  	}
   312  
   313  }
   314  
   315  // readJSON will decode the json object stored in GCS.
   316  func readJSON(ctx context.Context, opener Opener, p Path, i interface{}) error {
   317  	reader, _, err := opener.Open(ctx, p)
   318  	if errors.Is(err, storage.ErrObjectNotExist) {
   319  		return err
   320  	}
   321  	if err != nil {
   322  		return fmt.Errorf("open: %w", err)
   323  	}
   324  	defer reader.Close()
   325  	if err = json.NewDecoder(reader).Decode(i); err != nil {
   326  		return fmt.Errorf("decode: %w", err)
   327  	}
   328  	if err := reader.Close(); err != nil {
   329  		return fmt.Errorf("close: %w", err)
   330  	}
   331  	return nil
   332  }
   333  
   334  // PodInfo parses the build's pod state.
   335  func (build Build) PodInfo(ctx context.Context, opener Opener) (*PodInfo, error) {
   336  	path, err := build.Path.ResolveReference(&url.URL{Path: "podinfo.json"})
   337  	if err != nil {
   338  		return nil, fmt.Errorf("resolve: %w", err)
   339  	}
   340  	var podInfo PodInfo
   341  	err = readJSON(ctx, opener, *path, &podInfo)
   342  	if errors.Is(err, storage.ErrObjectNotExist) {
   343  		return nil, nil
   344  	}
   345  	if err != nil {
   346  		return nil, fmt.Errorf("read: %w", err)
   347  	}
   348  	return &podInfo, nil
   349  }
   350  
   351  // Started parses the build's started metadata.
   352  func (build Build) Started(ctx context.Context, opener Opener) (*Started, error) {
   353  	path, err := build.Path.ResolveReference(&url.URL{Path: "started.json"})
   354  	if err != nil {
   355  		return nil, fmt.Errorf("resolve: %w", err)
   356  	}
   357  	var started Started
   358  	err = readJSON(ctx, opener, *path, &started)
   359  	if errors.Is(err, storage.ErrObjectNotExist) {
   360  		started.Pending = true
   361  		return &started, nil
   362  	}
   363  	if err != nil {
   364  		return nil, fmt.Errorf("read: %w", err)
   365  	}
   366  	return &started, nil
   367  }
   368  
   369  // Finished parses the build's finished metadata.
   370  func (build Build) Finished(ctx context.Context, opener Opener) (*Finished, error) {
   371  	path, err := build.Path.ResolveReference(&url.URL{Path: "finished.json"})
   372  	if err != nil {
   373  		return nil, fmt.Errorf("resolve: %w", err)
   374  	}
   375  	var finished Finished
   376  	err = readJSON(ctx, opener, *path, &finished)
   377  	if errors.Is(err, storage.ErrObjectNotExist) {
   378  		finished.Running = true
   379  		return &finished, nil
   380  	}
   381  	if err != nil {
   382  		return nil, fmt.Errorf("read: %w", err)
   383  	}
   384  	return &finished, nil
   385  }
   386  
   387  // Artifacts writes the object name of all paths under the build's artifact dir to the output channel.
   388  func (build Build) Artifacts(ctx context.Context, lister Lister, artifacts chan<- string) error {
   389  	objs := lister.Objects(ctx, build.Path, "", "") // no delim or offset so we get all objects.
   390  	for {
   391  		obj, err := objs.Next()
   392  		if err == iterator.Done {
   393  			break
   394  		}
   395  		if err != nil {
   396  			return fmt.Errorf("list %s: %w", build.Path, err)
   397  		}
   398  		select {
   399  		case <-ctx.Done():
   400  			return ctx.Err()
   401  		case artifacts <- obj.Name:
   402  		}
   403  	}
   404  	return nil
   405  }
   406  
   407  // SuitesMeta holds testsuites xml and metadata from the filename
   408  type SuitesMeta struct {
   409  	Suites   *junit.Suites     // suites data extracted from file contents
   410  	Metadata map[string]string // metadata extracted from path name
   411  	Path     string
   412  	Err      error
   413  }
   414  
   415  const (
   416  	maxSize int64 = 100e6 // 100 million, coarce to int not float
   417  )
   418  
   419  func readSuites(ctx context.Context, opener Opener, p Path) (*junit.Suites, error) {
   420  	r, attrs, err := opener.Open(ctx, p)
   421  	if err != nil {
   422  		return nil, fmt.Errorf("open: %w", err)
   423  	}
   424  	defer r.Close()
   425  	if attrs != nil && attrs.Size > maxSize {
   426  		return nil, fmt.Errorf("too large: %d bytes > %d bytes max", attrs.Size, maxSize)
   427  	}
   428  	suitesMeta, err := junit.ParseStream(r)
   429  	if err != nil {
   430  		return nil, fmt.Errorf("parse: %w", err)
   431  	}
   432  	return suitesMeta, nil
   433  }
   434  
   435  // Suites takes a channel of artifact names, parses those representing junit suites, sending the result to the suites channel.
   436  //
   437  // Truncates xml results when set to a positive number of max bytes.
   438  func (build Build) Suites(ctx context.Context, opener Opener, artifacts <-chan string, suites chan<- SuitesMeta, max int) error {
   439  	for {
   440  		var art string
   441  		var more bool
   442  		select {
   443  		case <-ctx.Done():
   444  			return ctx.Err()
   445  		case art, more = <-artifacts:
   446  			if !more {
   447  				return nil
   448  			}
   449  		}
   450  		meta := parseSuitesMeta(art)
   451  		if meta == nil {
   452  			continue // not a junit file ignore it, ignore it
   453  		}
   454  		if art != "" && art[0] != '/' {
   455  			art = "/" + art
   456  		}
   457  		path, err := build.Path.ResolveReference(&url.URL{Path: art})
   458  		if err != nil {
   459  			return fmt.Errorf("resolve %q: %v", art, err)
   460  		}
   461  		out := SuitesMeta{
   462  			Metadata: meta,
   463  			Path:     path.String(),
   464  		}
   465  		out.Suites, err = readSuites(ctx, opener, *path)
   466  		if err != nil {
   467  			out.Err = err
   468  		} else {
   469  			out.Suites.Truncate(max)
   470  		}
   471  		select {
   472  		case <-ctx.Done():
   473  			return ctx.Err()
   474  		case suites <- out:
   475  		}
   476  	}
   477  }