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 }