github.com/GoogleCloudPlatform/testgrid@v0.0.174/cmd/state_comparer/main.go (about)

     1  /*
     2  Copyright 2021 The TestGrid 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  // A script to quickly check two TestGrid state.protos do not wildly differ.
    18  // Assume that if the column and row names are approx. equivalent, the state
    19  // is probably reasonable.
    20  
    21  package main
    22  
    23  import (
    24  	"context"
    25  	"errors"
    26  	"flag"
    27  	"fmt"
    28  	"os"
    29  	"path/filepath"
    30  	"regexp"
    31  	"strings"
    32  
    33  	"github.com/GoogleCloudPlatform/testgrid/config"
    34  	"github.com/GoogleCloudPlatform/testgrid/util/gcs"
    35  	"github.com/google/go-cmp/cmp"
    36  	"github.com/sirupsen/logrus"
    37  	"google.golang.org/api/iterator"
    38  
    39  	statepb "github.com/GoogleCloudPlatform/testgrid/pb/state"
    40  )
    41  
    42  type options struct {
    43  	first, second gcs.Path
    44  	configPath    gcs.Path
    45  	creds         string
    46  	diffRatioOK   float64
    47  	debug, trace  bool
    48  	testGroupURL  string
    49  }
    50  
    51  // validate ensures reasonable options
    52  func (o *options) validate() error {
    53  	if o.first.String() == "" {
    54  		return errors.New("unset: --first")
    55  	}
    56  	if o.second.String() == "" {
    57  		return errors.New("unset: --second")
    58  	}
    59  	if o.diffRatioOK < 0.0 || o.diffRatioOK > 1.0 {
    60  		return fmt.Errorf("--diff-ratio-ok must be a ratio between 0.0 and 1.0: %f", o.diffRatioOK)
    61  	}
    62  	if o.debug && o.trace {
    63  		return fmt.Errorf("set only one of --debug or --trace log levels")
    64  	}
    65  	if !strings.HasSuffix(o.first.String(), "/") {
    66  		o.first.Set(o.first.String() + "/")
    67  	}
    68  	if !strings.HasSuffix(o.second.String(), "/") {
    69  		o.second.Set(o.second.String() + "/")
    70  	}
    71  	if o.testGroupURL != "" && !strings.HasSuffix(o.testGroupURL, "/") {
    72  		o.testGroupURL += "/"
    73  	}
    74  
    75  	return nil
    76  }
    77  
    78  // gatherOptions reads options from flags
    79  func gatherFlagOptions(fs *flag.FlagSet, args ...string) options {
    80  	var o options
    81  	fs.Var(&o.first, "first", "First directory of state files to compare.")
    82  	fs.Var(&o.second, "second", "Second directory of state files to compare.")
    83  	fs.Var(&o.configPath, "config", "Path to configuration file (e.g. gs://path/to/config)")
    84  	fs.StringVar(&o.creds, "gcp-service-account", "", "/path/to/gcp/creds (use local creds if empty)")
    85  	fs.Float64Var(&o.diffRatioOK, "diff-ratio-ok", 0.0, "Ratio between 0.0 and 1.0. Only count as different if ratio of differences / total is higher than this.")
    86  	fs.BoolVar(&o.debug, "debug", false, "If true, print detailed info like full diffs.")
    87  	fs.BoolVar(&o.trace, "trace", false, "If true, print extremely detailed info.")
    88  	fs.StringVar(&o.testGroupURL, "test-group-url", "", "Provide a TestGrid URL for viewing test group links (e.g. 'http://k8s.testgrid.io/q/testgroup/')")
    89  	fs.Parse(args)
    90  	return o
    91  }
    92  
    93  // gatherOptions reads options from flags
    94  func gatherOptions() options {
    95  	return gatherFlagOptions(flag.CommandLine, os.Args[1:]...)
    96  }
    97  
    98  var tgDupRegEx = regexp.MustCompile(` \<TESTGRID:\d+\>`)
    99  var dupRegEx = regexp.MustCompile(` \[\d+\]`)
   100  
   101  func rowsAndColumns(ctx context.Context, grid *statepb.Grid, numColumnsRecent int32) (map[string]int, map[string]int, map[string]int) {
   102  	rows := make(map[string]int)
   103  	issues := make(map[string]int)
   104  	for _, row := range grid.GetRows() {
   105  		// Ignore stale rows.
   106  		if numColumnsRecent != 0 && len(row.GetResults()) >= 2 {
   107  			if row.GetResults()[0] == 0 && row.GetResults()[1] >= numColumnsRecent {
   108  				continue
   109  			}
   110  		}
   111  		name := row.GetName()
   112  		// Ignore test methods.
   113  		if strings.Contains(name, "@TESTGRID@") {
   114  			continue
   115  		}
   116  		// Equate duplicate-named rows.
   117  		name = tgDupRegEx.ReplaceAllString(name, "")
   118  		name = dupRegEx.ReplaceAllString(name, "")
   119  		rows[name]++
   120  		for _, issueID := range row.GetIssues() {
   121  			issues[issueID]++
   122  		}
   123  	}
   124  
   125  	columns := make(map[string]int)
   126  	for _, column := range grid.GetColumns() {
   127  		// We know times and other data will differ, so ignore them for now.
   128  		key := fmt.Sprintf("%s|%s", column.GetBuild(), strings.Join(column.GetExtra(), "|"))
   129  		columns[key]++
   130  	}
   131  
   132  	return rows, columns, issues
   133  }
   134  
   135  func numDiff(diff string) int {
   136  	var plus, minus int
   137  	for _, line := range strings.Split(diff, "\n") {
   138  		if strings.HasPrefix(line, "+") {
   139  			plus++
   140  		}
   141  		if strings.HasPrefix(line, "-") {
   142  			minus++
   143  		}
   144  	}
   145  	if plus > minus {
   146  		return plus
   147  	}
   148  	return minus
   149  }
   150  
   151  type diffReasons struct {
   152  	firstHasDuplicates  bool
   153  	secondHasDuplicates bool
   154  	other               bool
   155  }
   156  
   157  func compareKeys(first, second map[string]int, diffRatioOK float64) (diffed bool) {
   158  	total := len(first)
   159  	if len(second) > len(first) {
   160  		total = len(second)
   161  	}
   162  	firstKeys := make(map[string]bool)
   163  	secondKeys := make(map[string]bool)
   164  	for k := range first {
   165  		firstKeys[k] = true
   166  	}
   167  	for k := range second {
   168  		secondKeys[k] = true
   169  	}
   170  	if diff := cmp.Diff(firstKeys, secondKeys); diff != "" {
   171  		n := numDiff(diff)
   172  		diffRatio := float64(n) / float64(total)
   173  		if diffRatio > diffRatioOK {
   174  			diffed = true
   175  		}
   176  	}
   177  	return
   178  }
   179  
   180  func reportDiff(first, second map[string]int, identifier string, diffRatioOK float64) (diffed bool, reasons diffReasons) {
   181  	total := len(second)
   182  	if len(first) > len(second) {
   183  		total = len(first)
   184  	}
   185  	if diff := cmp.Diff(first, second); diff != "" {
   186  		n := numDiff(diff)
   187  		diffRatio := float64(n) / float64(total)
   188  		if diffRatio > diffRatioOK {
   189  			diffed = true
   190  			keysDiffed := compareKeys(first, second, diffRatioOK)
   191  			logrus.Infof("\t❌ %d / %d %ss differ (%.2f) (keys diffed = %t)", n, total, identifier, diffRatio, keysDiffed)
   192  			if keysDiffed {
   193  				reasons.other = true
   194  				logrus.Debugf("\t(-first, +second): %s", diff)
   195  			} else {
   196  				// Guess where the duplicates are based on totals.
   197  				var firstTotal, secondTotal int
   198  				for _, v := range first {
   199  					firstTotal += v
   200  				}
   201  				for _, v := range second {
   202  					secondTotal += v
   203  				}
   204  				if firstTotal > secondTotal {
   205  					reasons.firstHasDuplicates = true
   206  				} else {
   207  					reasons.secondHasDuplicates = true
   208  				}
   209  			}
   210  		}
   211  	} else {
   212  		logrus.Tracef("\t✅ All %d %ss match!", len(first), identifier)
   213  	}
   214  	return
   215  }
   216  
   217  func compare(ctx context.Context, first, second *statepb.Grid, diffRatioOK float64, numColumnsRecent int32) (diffed bool, rowDiffReasons, colDiffReasons diffReasons) {
   218  	logrus.Tracef("*****************************")
   219  	firstRows, firstColumns, _ := rowsAndColumns(ctx, first, numColumnsRecent)
   220  	secondRows, secondColumns, _ := rowsAndColumns(ctx, second, numColumnsRecent)
   221  	// both grids have no results, ignore
   222  	if (len(firstRows) == 0 && len(secondRows) == 0) || (len(firstColumns) == 0 && len(secondColumns) == 0) {
   223  		return
   224  	}
   225  	// first has no results, second keeps one column
   226  	if len(firstColumns) == 0 && len(secondColumns) == 1 {
   227  		return
   228  	}
   229  
   230  	if rowsDiffed, reasons := reportDiff(firstRows, secondRows, "row", diffRatioOK); rowsDiffed {
   231  		diffed = true
   232  		rowDiffReasons = reasons
   233  	}
   234  	if colsDiffed, reasons := reportDiff(firstColumns, secondColumns, "column", diffRatioOK); colsDiffed {
   235  		diffed = true
   236  		colDiffReasons = reasons
   237  	}
   238  	if diffed {
   239  		logrus.Infof("Compared %q and %q...", first.GetConfig().GetName(), second.GetConfig().GetName())
   240  	}
   241  	return
   242  }
   243  
   244  func filenames(ctx context.Context, dir gcs.Path, client gcs.Client) ([]string, error) {
   245  	stats := client.Objects(ctx, dir, "/", "")
   246  	var filenames []string
   247  	for {
   248  		stat, err := stats.Next()
   249  		if errors.Is(err, iterator.Done) {
   250  			break
   251  		}
   252  		if err != nil {
   253  			return nil, err
   254  		}
   255  		filename := dir.String()
   256  		if !strings.HasSuffix(filename, "/") {
   257  			filename += "/"
   258  		}
   259  		filename += filepath.Base(stat.Name)
   260  		filenames = append(filenames, filename)
   261  	}
   262  	return filenames, nil
   263  }
   264  
   265  func main() {
   266  	opt := gatherOptions()
   267  	if err := opt.validate(); err != nil {
   268  		logrus.Fatalf("Invalid options %v: %v", opt, err)
   269  	}
   270  	ctx, cancel := context.WithCancel(context.Background())
   271  	defer cancel()
   272  
   273  	if opt.debug {
   274  		logrus.SetLevel(logrus.DebugLevel)
   275  	} else if opt.trace {
   276  		logrus.SetLevel(logrus.TraceLevel)
   277  	}
   278  
   279  	storageClient, err := gcs.ClientWithCreds(ctx, opt.creds)
   280  	if err != nil {
   281  		logrus.Fatalf("Failed to create storage client: %v", err)
   282  	}
   283  	defer storageClient.Close()
   284  
   285  	client := gcs.NewClient(storageClient)
   286  
   287  	cfg, err := config.Read(ctx, opt.configPath.String(), storageClient)
   288  	if err != nil {
   289  		logrus.WithError(err).WithField("path", opt.configPath.String()).Error("Failed to read configuration, proceeding without config info.")
   290  	}
   291  
   292  	firstFiles, err := filenames(ctx, opt.first, client)
   293  	if err != nil {
   294  		logrus.Fatalf("Failed to list files in %q: %v", opt.first.String(), err)
   295  	}
   296  	var diffedMsgs, errorMsgs []string
   297  	var total, notFound int
   298  	rowFirstDups := make(map[string]bool)  // Good; second deduplicates.
   299  	rowSecondDups := make(map[string]bool) // Bad; second adds duplicates.
   300  	colFirstDups := make(map[string]bool)  // Good; second deduplicates.
   301  	colSecondDups := make(map[string]bool) // Bad; second adds duplicates.
   302  	otherDiffed := make(map[string]bool)   // Bad; found unknown differences.
   303  	for _, firstP := range firstFiles {
   304  		tgName := filepath.Base(firstP)
   305  		secondP := opt.second.String()
   306  		if !strings.HasSuffix(secondP, "/") {
   307  			secondP += "/"
   308  		}
   309  		secondP += tgName
   310  		firstPath, err := gcs.NewPath(firstP)
   311  		if err != nil {
   312  			errorMsgs = append(errorMsgs, fmt.Sprintf("gcs.NewPath(%q): %v", firstP, err))
   313  			continue
   314  		}
   315  		// Optionally skip processing some groups.
   316  		tg := config.FindTestGroup(tgName, cfg)
   317  		if tg == nil {
   318  			logrus.Tracef("Did not find test group %q in config", tgName)
   319  			notFound++
   320  			continue
   321  		}
   322  
   323  		firstGrid, _, err := gcs.DownloadGrid(ctx, client, *firstPath)
   324  		if err != nil {
   325  			errorMsgs = append(errorMsgs, fmt.Sprintf("gcs.DownloadGrid(%q): %v", firstP, err))
   326  			continue
   327  		}
   328  		secondPath, err := gcs.NewPath(secondP)
   329  		if err != nil {
   330  			errorMsgs = append(errorMsgs, fmt.Sprintf("gcs.NewPath(%q): %v", secondP, err))
   331  			continue
   332  		}
   333  		secondGrid, _, err := gcs.DownloadGrid(ctx, client, *secondPath)
   334  		if err != nil {
   335  			errorMsgs = append(errorMsgs, fmt.Sprintf("gcs.DownloadGrid(%q): %v", secondP, err))
   336  			continue
   337  		}
   338  
   339  		if diffed, rowReasons, colReasons := compare(ctx, firstGrid, secondGrid, opt.diffRatioOK, tg.GetNumColumnsRecent()); diffed {
   340  			msg := fmt.Sprintf("%q vs. %q", firstP, secondP)
   341  			if opt.testGroupURL != "" {
   342  				parts := strings.Split(firstP, "/")
   343  				msg = opt.testGroupURL + parts[len(parts)-1]
   344  			}
   345  			if rowReasons.secondHasDuplicates {
   346  				rowSecondDups[msg] = true
   347  			} else if colReasons.secondHasDuplicates {
   348  				colSecondDups[msg] = true
   349  			} else if rowReasons.firstHasDuplicates {
   350  				rowFirstDups[msg] = true
   351  			} else if colReasons.firstHasDuplicates {
   352  				colFirstDups[msg] = true
   353  			} else {
   354  				otherDiffed[msg] = true
   355  			}
   356  			diffedMsgs = append(diffedMsgs, msg)
   357  		}
   358  		total++
   359  	}
   360  	logrus.Infof("Found diffs for %d of %d pairs (%d not found):", len(diffedMsgs), total, notFound)
   361  	report := func(diffs map[string]bool, name string) {
   362  		logrus.Infof("found %d %q:", len(diffs), name)
   363  		for msg := range diffs {
   364  			logrus.Infof("\t* %s", msg)
   365  		}
   366  	}
   367  	report(rowFirstDups, "✅ rows get deduplicated")
   368  	report(colFirstDups, "✅ columns get deduplicated")
   369  	report(rowSecondDups, "❌ rows get duplicated")
   370  	report(colSecondDups, "❌ columns get duplicated")
   371  	report(otherDiffed, "❌ other diffs")
   372  	if n := len(errorMsgs); n > 0 {
   373  		logrus.WithField("count", n).WithField("errors", errorMsgs).Fatal("Errors when diffing directories.")
   374  	}
   375  }