go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/cvtesting/util.go (about) 1 // Copyright 2020 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package cvtesting 16 17 import ( 18 "context" 19 cryptorand "crypto/rand" 20 "encoding/hex" 21 "fmt" 22 "math/rand" 23 "net/mail" 24 "os" 25 "regexp" 26 "strconv" 27 "strings" 28 "testing" 29 "time" 30 31 nativeDatastore "cloud.google.com/go/datastore" 32 "github.com/golang/mock/gomock" 33 "google.golang.org/api/option" 34 35 "go.chromium.org/luci/auth" 36 "go.chromium.org/luci/auth/identity" 37 "go.chromium.org/luci/common/clock" 38 "go.chromium.org/luci/common/clock/testclock" 39 "go.chromium.org/luci/common/data/stringset" 40 "go.chromium.org/luci/common/errors" 41 "go.chromium.org/luci/common/logging" 42 "go.chromium.org/luci/common/logging/gologger" 43 "go.chromium.org/luci/common/tsmon" 44 "go.chromium.org/luci/common/tsmon/distribution" 45 "go.chromium.org/luci/common/tsmon/store" 46 "go.chromium.org/luci/common/tsmon/target" 47 "go.chromium.org/luci/common/tsmon/types" 48 "go.chromium.org/luci/gae/filter/txndefer" 49 "go.chromium.org/luci/gae/impl/cloud" 50 "go.chromium.org/luci/gae/impl/memory" 51 "go.chromium.org/luci/gae/service/datastore" 52 "go.chromium.org/luci/gae/service/info" 53 serverauth "go.chromium.org/luci/server/auth" 54 "go.chromium.org/luci/server/auth/authtest" 55 "go.chromium.org/luci/server/auth/realms" 56 "go.chromium.org/luci/server/caching" 57 "go.chromium.org/luci/server/secrets" 58 "go.chromium.org/luci/server/tq" 59 "go.chromium.org/luci/server/tq/tqtesting" 60 _ "go.chromium.org/luci/server/tq/txn/datastore" 61 62 bbfake "go.chromium.org/luci/cv/internal/buildbucket/fake" 63 "go.chromium.org/luci/cv/internal/common" 64 "go.chromium.org/luci/cv/internal/common/bq" 65 "go.chromium.org/luci/cv/internal/common/tree" 66 "go.chromium.org/luci/cv/internal/common/tree/treetest" 67 "go.chromium.org/luci/cv/internal/configs/srvcfg" 68 "go.chromium.org/luci/cv/internal/gerrit" 69 gf "go.chromium.org/luci/cv/internal/gerrit/gerritfake" 70 listenerpb "go.chromium.org/luci/cv/settings/listener" 71 72 . "github.com/smartystreets/goconvey/convey" 73 ) 74 75 const gaeTopLevelDomain = ".appspot.com" 76 77 // TODO(tandrii): add fake config generation facilities. 78 79 // Test encapsulates typical setup for CV test. 80 // 81 // Typical use: 82 // 83 // ct := cvtesting.Test{} 84 // ctx, cancel := ct.SetUp(t) 85 // defer cancel() 86 type Test struct { 87 // Env simulates CV environment. 88 Env *common.Env 89 // GFake is a Gerrit fake. Defaults to an empty one. 90 GFake *gf.Fake 91 // BuildbucketFake is a Buildbucket fake. Defaults to an empty one. 92 BuildbucketFake *bbfake.Fake 93 // TreeFake is a fake Tree. Defaults to an open Tree. 94 TreeFake *treetest.Fake 95 // BQFake is a fake BQ client. 96 BQFake *bq.Fake 97 // TQDispatcher is a dispatcher with which task classes must be registered. 98 // 99 // Must not be set. 100 TQDispatcher *tq.Dispatcher 101 // TQ allows to run TQ tasks. 102 TQ *tqtesting.Scheduler 103 // SucceededTQTasks is a list of the TQ tasks that were executed successfully. 104 SucceededTQTasks tqtesting.TaskList 105 FailedTQTasks tqtesting.TaskList 106 107 // Clock allows to move time forward. 108 // By default, the time is moved automatically is something waits on it. 109 Clock testclock.TestClock 110 // TSMonStore store keeps all metrics in memory and allows examination. 111 TSMonStore store.Store 112 113 // MaxDuration limits how long a test can run as a fail safe. 114 // 115 // Defaults to 10s to most likely finish in pre/post submit tests, 116 // with limited CPU resources. 117 // Set to ~10ms when debugging a hung test. 118 MaxDuration time.Duration 119 120 // GoMockCtl is the controller for gomock. 121 GoMockCtl *gomock.Controller 122 123 // cleanupFuncs are executed in reverse order in cleanup(). 124 cleanupFuncs []func() 125 126 // authDB is used to mock CrIA memberships. 127 authDB *authtest.FakeDB 128 } 129 130 type testingContextKeyType struct{} 131 132 // IsTestingContext checks if the given context was derived from one created by 133 // cvtesting.Test.SetUp(). 134 func IsTestingContext(ctx context.Context) bool { 135 return ctx.Value(testingContextKeyType{}) != nil 136 } 137 138 func (t *Test) SetUp(testingT *testing.T) (context.Context, func()) { 139 if t.Env == nil { 140 t.Env = &common.Env{ 141 LogicalHostname: "luci-change-verifier" + gaeTopLevelDomain, 142 HTTPAddressBase: "https://luci-change-verifier" + gaeTopLevelDomain, 143 GAEInfo: struct { 144 CloudProject string 145 ServiceName string 146 InstanceID string 147 }{ 148 CloudProject: "luci-change-verifier", 149 ServiceName: "test-service", 150 InstanceID: "test-instance", 151 }, 152 } 153 } 154 155 t.setMaxDuration() 156 ctxShared := context.WithValue(context.Background(), testingContextKeyType{}, struct{}{}) 157 // Don't set the deadline (timeout) into the context given to the test, 158 // as it may interfere with test clock. 159 ctx, cancel := context.WithCancel(ctxShared) 160 ctxTimed, cancelTimed := context.WithTimeout(ctxShared, t.MaxDuration) 161 go func(ctx context.Context) { 162 // Instead, watch for expiry of ctxTimed and cancel test `ctx`. 163 select { 164 case <-ctxTimed.Done(): 165 cancel() 166 case <-ctx.Done(): 167 // Normal test termination. 168 cancelTimed() 169 } 170 }(ctx) 171 t.cleanupFuncs = append(t.cleanupFuncs, func() { 172 // Fail the test if the test has timed out. 173 So(ctxTimed.Err(), ShouldBeNil) 174 cancel() 175 cancelTimed() 176 }) 177 178 // setup the test clock first so that logger can use test clock timestamp. 179 ctx = t.setUpTestClock(ctx) 180 if testing.Verbose() { 181 // TODO(crbug/1282023): make this logger emit testclock-based timestamps. 182 ctx = logging.SetLevel(gologger.StdConfig.Use(ctx), logging.Debug) 183 } 184 // setup timerCallback after setup logger so that the logging in the 185 // callback function can honor the verbose mode. 186 ctx = t.setTestClockTimerCB(ctx) 187 ctx = caching.WithEmptyProcessCache(ctx) 188 ctx = secrets.GeneratePrimaryTinkAEADForTest(ctx) 189 190 if t.TQDispatcher != nil { 191 panic("TQDispatcher must not be set") 192 } 193 t.TQDispatcher = &tq.Dispatcher{} 194 ctx, t.TQ = tq.TestingContext(ctx, t.TQDispatcher) 195 t.TQ.TaskSucceeded = tqtesting.TasksCollector(&t.SucceededTQTasks) 196 t.TQ.TaskFailed = tqtesting.TasksCollector(&t.FailedTQTasks) 197 198 if t.GFake == nil { 199 t.GFake = &gf.Fake{} 200 } 201 if t.BuildbucketFake == nil { 202 t.BuildbucketFake = &bbfake.Fake{} 203 } 204 if t.TreeFake == nil { 205 t.TreeFake = treetest.NewFake(ctx, tree.Open) 206 } 207 if t.BQFake == nil { 208 t.BQFake = &bq.Fake{} 209 } 210 211 ctx = t.installDS(ctx) 212 ctx = txndefer.FilterRDS(ctx) 213 t.authDB = authtest.NewFakeDB() 214 ctx = serverauth.WithState(ctx, &authtest.FakeState{FakeDB: t.authDB}) 215 216 ctx, _, _ = tsmon.WithFakes(ctx) 217 t.TSMonStore = store.NewInMemory(&target.Task{}) 218 tsmon.GetState(ctx).SetStore(t.TSMonStore) 219 220 t.GoMockCtl = gomock.NewController(testingT) 221 if err := srvcfg.SetTestListenerConfig(ctx, &listenerpb.Settings{}, nil); err != nil { 222 panic(err) 223 } 224 225 return ctx, t.cleanup 226 } 227 228 func (t *Test) cleanup() { 229 for i := len(t.cleanupFuncs) - 1; i >= 0; i-- { 230 t.cleanupFuncs[i]() 231 } 232 } 233 234 func (t *Test) RoundTestClock(multiple time.Duration) { 235 t.Clock.Set(t.Clock.Now().Add(multiple).Truncate(multiple)) 236 } 237 238 func (t *Test) GFactory() gerrit.Factory { 239 return gerrit.CachingFactory(16, gerrit.TimeLimitedFactory(gerrit.InstrumentedFactory(t.GFake))) 240 } 241 242 // TSMonSentValue returns the latest value of the given metric. 243 // 244 // If not set, returns nil. 245 func (t *Test) TSMonSentValue(ctx context.Context, m types.Metric, fieldVals ...any) any { 246 resetTime := time.Time{} 247 return t.TSMonStore.Get(ctx, m, resetTime, fieldVals) 248 } 249 250 // TSMonSentDistr returns the latest distr value of the given metric. 251 // 252 // If not set, returns nil. 253 // Panics if metric's value is not a distribution. 254 func (t *Test) TSMonSentDistr(ctx context.Context, m types.Metric, fieldVals ...any) *distribution.Distribution { 255 v := t.TSMonSentValue(ctx, m, fieldVals...) 256 if v == nil { 257 return nil 258 } 259 d, ok := v.(*distribution.Distribution) 260 if !ok { 261 panic(fmt.Errorf("metric %q value is not a %T, but %T", m.Info().Name, d, v)) 262 } 263 return d 264 } 265 266 func (t *Test) setMaxDuration() { 267 // Can't use Go's test timeout because it is per TestXYZ func, 268 // which typically instantiates & runs several `cvtesting.Test`s. 269 switch s := os.Getenv("CV_TEST_TIMEOUT_SEC"); { 270 case s != "": 271 v, err := strconv.ParseInt(s, 10, 31) 272 if err != nil { 273 panic(err) 274 } 275 t.MaxDuration = time.Duration(v) * time.Second 276 case t.MaxDuration != time.Duration(0): 277 // TODO(tandrii): remove the possibility to override this per test in favor 278 // of CV_TEST_TIMEOUT_SEC env var. 279 case raceDetectionEnabled: 280 t.MaxDuration = 90 * time.Second 281 default: 282 t.MaxDuration = 20 * time.Second 283 } 284 } 285 286 // DisableProjectInGerritListener updates the cached config to disable LUCI 287 // projects matching a given regexp in Listener. 288 func (t *Test) DisableProjectInGerritListener(ctx context.Context, projectRE string) { 289 cfg, err := srvcfg.GetListenerConfig(ctx, nil) 290 if err != nil { 291 panic(err) 292 } 293 existing := stringset.NewFromSlice(cfg.DisabledProjectRegexps...) 294 existing.Add(projectRE) 295 cfg.DisabledProjectRegexps = existing.ToSortedSlice() 296 if err := srvcfg.SetTestListenerConfig(ctx, cfg, nil); err != nil { 297 panic(err) 298 } 299 } 300 301 func (t *Test) installDS(ctx context.Context) context.Context { 302 if !strings.HasSuffix(t.Env.LogicalHostname, gaeTopLevelDomain) { 303 panic(fmt.Errorf("Env.LogicalHostname %q doesn't end with %q", t.Env.LogicalHostname, gaeTopLevelDomain)) 304 } 305 appID := t.Env.LogicalHostname[:len(t.Env.LogicalHostname)-len(gaeTopLevelDomain)] 306 307 if ctx, ok := t.installDSReal(ctx); ok { 308 return memory.UseInfo(ctx, appID) 309 } 310 if ctx, ok := t.installDSEmulator(ctx); ok { 311 return memory.UseInfo(ctx, appID) 312 } 313 314 ctx = memory.UseWithAppID(ctx, appID) 315 // CV runs against Firestore backend, which is consistent. 316 datastore.GetTestable(ctx).Consistent(true) 317 // Intentionally not enabling AutoIndex so that new code accidentally needing 318 // a new index adds it both here (for the rest of CV tests to work, notably 319 // e2e ones) and into appengine/index.yaml. 320 datastore.GetTestable(ctx).AutoIndex(false) 321 return ctx 322 } 323 324 // installDSProd configures CV tests to run with actual DS. 325 // 326 // If DATASTORE_PROJECT ENV var isn't set, returns false. 327 // 328 // To use, first 329 // 330 // $ luci-auth context -- bash 331 // $ export DATASTORE_PROJECT=my-cloud-project-with-datastore 332 // 333 // and then run go tests the usual way, e.g.: 334 // 335 // $ go test ./... 336 func (t *Test) installDSReal(ctx context.Context) (context.Context, bool) { 337 project := os.Getenv("DATASTORE_PROJECT") 338 if project == "" { 339 return ctx, false 340 } 341 if project == "luci-change-verifier" { 342 panic("Don't use production CV project. Using -dev is OK.") 343 } 344 345 at := auth.NewAuthenticator(ctx, auth.SilentLogin, auth.Options{ 346 Scopes: serverauth.CloudOAuthScopes, 347 }) 348 ts, err := at.TokenSource() 349 if err != nil { 350 err = errors.Annotate(err, "failed to initialize the token source (are you in `$ luci-auth context`?)").Err() 351 So(err, ShouldBeNil) 352 } 353 354 logging.Debugf(ctx, "Using DS of project %q", project) 355 client, err := nativeDatastore.NewClient(ctx, project, option.WithTokenSource(ts)) 356 So(err, ShouldBeNil) 357 return t.installDSshared(ctx, project, client), true 358 } 359 360 // installDSEmulator configures CV tests to run with DS emulator. 361 // 362 // If DATASTORE_EMULATOR_HOST ENV var isn't set, returns false. 363 // 364 // To use, run 365 // 366 // $ gcloud beta emulators datastore start --consistency=1.0 367 // 368 // and export DATASTORE_EMULATOR_HOST as printed by above command. 369 // 370 // NOTE: as of Feb 2021, emulator runs in legacy Datastore mode, 371 // not Firestore. 372 func (t *Test) installDSEmulator(ctx context.Context) (context.Context, bool) { 373 emulatorHost := os.Getenv("DATASTORE_EMULATOR_HOST") 374 if emulatorHost == "" { 375 return ctx, false 376 } 377 378 logging.Debugf(ctx, "Using DS emulator at %q", emulatorHost) 379 client, err := nativeDatastore.NewClient(ctx, "luci-gae-emulator-test") 380 So(err, ShouldBeNil) 381 return t.installDSshared(ctx, "luci-gae-emulator-test", client), true 382 } 383 384 func (t *Test) installDSshared(ctx context.Context, cloudProject string, client *nativeDatastore.Client) context.Context { 385 t.cleanupFuncs = append(t.cleanupFuncs, func() { 386 if err := client.Close(); err != nil { 387 logging.Errorf(ctx, "failed to close DS client: %s", err) 388 } 389 }) 390 ctx = (&cloud.ConfigLite{ProjectID: cloudProject, DS: client}).Use(ctx) 391 maybeCleanupOldDSNamespaces(ctx) 392 393 // Enter a namespace for this tests. 394 ns := genDSNamespaceName(time.Now()) 395 logging.Debugf(ctx, "Using %q DS namespace", ns) 396 ctx = info.MustNamespace(ctx, ns) 397 // Failure to clear is hard before the test, 398 // ignored after the test. 399 So(clearDS(ctx), ShouldBeNil) 400 t.cleanupFuncs = append(t.cleanupFuncs, func() { 401 if err := clearDS(ctx); err != nil { 402 logging.Errorf(ctx, "failed to clean DS namespace %s: %s", ns, err) 403 } 404 }) 405 return ctx 406 } 407 408 func genDSNamespaceName(t time.Time) string { 409 rnd := make([]byte, 8) 410 if _, err := cryptorand.Read(rnd); err != nil { 411 panic(err) 412 } 413 return fmt.Sprintf("testing-%s-%s", time.Now().Format("2006-01-02"), hex.EncodeToString(rnd)) 414 } 415 416 var dsNamespaceRegexp = regexp.MustCompile(`^testing-(\d{4}-\d\d-\d\d)-[0-9a-f]+$`) 417 418 func isOldTestDSNamespace(ns string, now time.Time) bool { 419 m := dsNamespaceRegexp.FindSubmatch([]byte(ns)) 420 if len(m) == 0 { 421 return false 422 } 423 // Anything up ~2 days old should be kept to avoid accidentally removing 424 // currently under test namespace in presence of timezones and out of sync 425 // clocks. 426 const maxAge = 2 * 24 * time.Hour 427 t, err := time.Parse("2006-01-02", string(m[1])) 428 if err != nil { 429 panic(err) 430 } 431 return now.Sub(t) > maxAge 432 } 433 434 func clearDS(ctx context.Context) error { 435 // Execute a kindless query to clear entire namespace. 436 q := datastore.NewQuery("").KeysOnly(true) 437 var allKeys []*datastore.Key 438 if err := datastore.GetAll(ctx, q, &allKeys); err != nil { 439 return errors.Annotate(err, "failed to get entities").Err() 440 } 441 if err := datastore.Delete(ctx, allKeys); err != nil { 442 return errors.Annotate(err, "failed to delete %d entities", len(allKeys)).Err() 443 } 444 return nil 445 } 446 447 func maybeCleanupOldDSNamespaces(ctx context.Context) { 448 if rand.Intn(1024) < 1020 { // ~99% of cases. 449 return 450 } 451 q := datastore.NewQuery("__namespace__").KeysOnly(true) 452 var allKeys []*datastore.Key 453 if err := datastore.GetAll(ctx, q, &allKeys); err != nil { 454 logging.Warningf(ctx, "failed to query all namespaces: %s", err) 455 return 456 } 457 now := time.Now() 458 var toDelete []string 459 for _, k := range allKeys { 460 ns := k.StringID() 461 if isOldTestDSNamespace(ns, now) { 462 toDelete = append(toDelete, ns) 463 } 464 } 465 logging.Debugf(ctx, "cleaning up %d old namespaces", len(toDelete)) 466 for _, ns := range toDelete { 467 logging.Debugf(ctx, "cleaning up %s", ns) 468 if err := clearDS(info.MustNamespace(ctx, ns)); err != nil { 469 logging.Errorf(ctx, "failed to clean old DS namespace %s: %s", ns, err) 470 } 471 } 472 } 473 474 // setUpTestClock simulates passage of time w/o idling CPU. 475 func (t *Test) setUpTestClock(ctx context.Context) context.Context { 476 if t.Clock != nil { 477 return clock.Set(ctx, t.Clock) 478 } 479 // Use a date-time that is easy to eyeball in logs. 480 utc := time.Date(2020, time.February, 2, 10, 30, 00, 0, time.UTC) 481 // But set it up in a clock as a local time to expose incorrect assumptions of UTC. 482 now := time.Date(2020, time.February, 2, 13, 30, 00, 0, time.FixedZone("Fake local", 3*60*60)) 483 So(now.Equal(utc), ShouldBeTrue) 484 ctx, t.Clock = testclock.UseTime(ctx, now) 485 return ctx 486 } 487 488 // setTestClockTimerCB moves test time forward if something we recognize waits 489 // for it. 490 func (t *Test) setTestClockTimerCB(ctx context.Context) context.Context { 491 // Testclock calls this callback every time something is waiting. 492 // To avoid getting stuck tests, we need to move testclock forward by the 493 // requested duration in most cases but not all. 494 moveIf := stringset.NewFromSlice( 495 // Used by tqtesting to wait until ETA of the next task. 496 tqtesting.ClockTag, 497 // Used to retry on outgoing requests for BB and Gerrit. 498 common.LaunchRetryClockTag, 499 ) 500 ignoreIf := stringset.NewFromSlice( 501 // Used in clock.WithTimeout(ctx) | clock.WithDeadline(ctx). 502 clock.ContextDeadlineTag, 503 ) 504 t.Clock.SetTimerCallback(func(dur time.Duration, timer clock.Timer) { 505 tags := testclock.GetTags(timer) 506 move, ignore := 0, 0 507 for _, tag := range tags { 508 switch { 509 case moveIf.Has(tag): 510 move++ 511 case ignoreIf.Has(tag): 512 ignore++ 513 default: 514 // Ignore by default, but log it to help fix the test if it gets stuck. 515 logging.Warningf(ctx, "ignoring unexpected timer tag: %q. If test is stuck, add tag to `moveIf` above this log line", tag) 516 } 517 } 518 // In ~all cases, there is exactly 1 tag, but be future proof. 519 switch { 520 case move > 0: 521 logging.Debugf(ctx, "moving test clock %s by %s forward for %s", t.Clock.Now(), dur, tags) 522 t.Clock.Add(dur) 523 case ignore == 0: 524 logging.Warningf(ctx, "ignoring timer without tags. If test is stuck, tag the waits via `clock` library") 525 } 526 }) 527 return ctx 528 } 529 530 // AddMember adds a given member into a given luci auth group. 531 // 532 // The email may omit domain. In that case, this method will add "@example.com" 533 // as the domain name. 534 func (t *Test) AddMember(email, group string) { 535 if _, err := mail.ParseAddress(email); err != nil { 536 email = fmt.Sprintf("%s@example.com", email) 537 } 538 id, err := identity.MakeIdentity(fmt.Sprintf("user:%s", email)) 539 if err != nil { 540 panic(err) 541 } 542 t.authDB.AddMocks(authtest.MockMembership(id, group)) 543 } 544 545 // AddPermission grants permission to the member in the given realm. 546 // 547 // The email may omit domain. In that case, this method will add "@example.com" 548 // as the domain name. 549 func (t *Test) AddPermission(email string, perm realms.Permission, realm string) { 550 if _, err := mail.ParseAddress(email); err != nil { 551 email = fmt.Sprintf("%s@example.com", email) 552 } 553 id, err := identity.MakeIdentity(fmt.Sprintf("user:%s", email)) 554 if err != nil { 555 panic(err) 556 } 557 t.authDB.AddMocks(authtest.MockPermission(id, realm, perm)) 558 } 559 560 func (t *Test) ResetMockedAuthDB(ctx context.Context) { 561 t.authDB = authtest.NewFakeDB() 562 serverauth.GetState(ctx).(*authtest.FakeState).FakeDB = t.authDB 563 }