go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/run/postaction/quota_test.go (about)

     1  // Copyright 2023 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 postaction
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"math/rand"
    21  	"testing"
    22  	"time"
    23  
    24  	"go.chromium.org/luci/auth/identity"
    25  	"go.chromium.org/luci/common/clock"
    26  	"go.chromium.org/luci/gae/service/datastore"
    27  	"go.chromium.org/luci/server/quota/quotapb"
    28  
    29  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    30  	"go.chromium.org/luci/cv/internal/common"
    31  	"go.chromium.org/luci/cv/internal/configs/prjcfg"
    32  	"go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest"
    33  	"go.chromium.org/luci/cv/internal/configs/validation"
    34  	"go.chromium.org/luci/cv/internal/cvtesting"
    35  	"go.chromium.org/luci/cv/internal/run"
    36  
    37  	. "github.com/smartystreets/goconvey/convey"
    38  )
    39  
    40  func TestCreditQuotaOp(t *testing.T) {
    41  	t.Parallel()
    42  
    43  	Convey("Do", t, func() {
    44  		ct := cvtesting.Test{}
    45  		ctx, cancel := ct.SetUp(t)
    46  		defer cancel()
    47  
    48  		const (
    49  			lProject  = "infra"
    50  			userEmail = "user@example.com"
    51  		)
    52  		userIdentity := identity.Identity(fmt.Sprintf("%s:%s", identity.User, userEmail))
    53  
    54  		cfg := cfgpb.Config{
    55  			CqStatusHost: validation.CQStatusHostPublic,
    56  			ConfigGroups: []*cfgpb.ConfigGroup{
    57  				{
    58  					Name: "test",
    59  					UserLimits: []*cfgpb.UserLimit{
    60  						{
    61  							Name: "test-limit",
    62  							Principals: []string{
    63  								"user:" + userEmail,
    64  							},
    65  							Run: &cfgpb.UserLimit_Run{
    66  								MaxActive: &cfgpb.UserLimit_Limit{
    67  									Limit: &cfgpb.UserLimit_Limit_Value{
    68  										Value: 1,
    69  									},
    70  								},
    71  							},
    72  						},
    73  					},
    74  				},
    75  			},
    76  		}
    77  		prjcfgtest.Create(ctx, lProject, &cfg)
    78  		configGroupID := prjcfgtest.MustExist(ctx, lProject).ConfigGroupIDs[0]
    79  
    80  		rm := &mockRM{}
    81  		qm := &mockQM{quotaSpecified: true}
    82  		executor := &Executor{
    83  			Run: &run.Run{
    84  				ID:            common.MakeRunID(lProject, clock.Now(ctx).Add(-1*time.Hour), 1, []byte("deadbeef")),
    85  				Status:        run.Status_SUCCEEDED,
    86  				BilledTo:      userIdentity,
    87  				Mode:          run.FullRun,
    88  				ConfigGroupID: configGroupID,
    89  			},
    90  			RM:                rm,
    91  			QM:                qm,
    92  			IsCancelRequested: func() bool { return false },
    93  			Payload: &run.OngoingLongOps_Op_ExecutePostActionPayload{
    94  				Name: CreditRunQuotaPostActionName,
    95  				Kind: &run.OngoingLongOps_Op_ExecutePostActionPayload_CreditRunQuota_{
    96  					CreditRunQuota: &run.OngoingLongOps_Op_ExecutePostActionPayload_CreditRunQuota{},
    97  				},
    98  			},
    99  		}
   100  		runCreateTime := clock.Now(ctx).UTC().Add(-1 * time.Minute)
   101  		Convey("credit quota and notify single pending run", func() {
   102  			r := &run.Run{
   103  				ID:            common.MakeRunID(lProject, runCreateTime, 1, []byte("deadbeef")),
   104  				Status:        run.Status_PENDING,
   105  				BilledTo:      userIdentity,
   106  				Mode:          run.FullRun,
   107  				CreateTime:    runCreateTime,
   108  				ConfigGroupID: configGroupID,
   109  			}
   110  			So(datastore.Put(ctx, r), ShouldBeNil)
   111  			summary, err := executor.Do(ctx)
   112  			So(err, ShouldBeNil)
   113  			So(summary, ShouldEqual, fmt.Sprintf("notified next Run %q to start", r.ID))
   114  			So(qm.creditQuotaCalledWith, ShouldResemble, common.RunIDs{executor.Run.ID})
   115  			So(rm.notifyStarted, ShouldResemble, common.RunIDs{r.ID})
   116  		})
   117  		Convey("do not notify if quota is not specified", func() {
   118  			r := &run.Run{
   119  				ID:            common.MakeRunID(lProject, runCreateTime, 1, []byte("deadbeef")),
   120  				Status:        run.Status_PENDING,
   121  				BilledTo:      userIdentity,
   122  				Mode:          run.FullRun,
   123  				CreateTime:    runCreateTime,
   124  				ConfigGroupID: configGroupID,
   125  			}
   126  			So(datastore.Put(ctx, r), ShouldBeNil)
   127  			qm.quotaSpecified = false
   128  			summary, err := executor.Do(ctx)
   129  			So(err, ShouldBeNil)
   130  			So(summary, ShouldEqual, fmt.Sprintf("run quota limit is not specified for user %q", r.BilledTo.Email()))
   131  			So(qm.creditQuotaCalledWith, ShouldResemble, common.RunIDs{executor.Run.ID})
   132  			So(rm.notifyStarted, ShouldBeEmpty)
   133  		})
   134  		Convey("do not notify pending run from different project", func() {
   135  			r := &run.Run{
   136  				ID:            common.MakeRunID("another-proj", runCreateTime, 1, []byte("deadbeef")),
   137  				Status:        run.Status_PENDING,
   138  				BilledTo:      userIdentity,
   139  				Mode:          run.FullRun,
   140  				CreateTime:    runCreateTime,
   141  				ConfigGroupID: configGroupID,
   142  			}
   143  			So(datastore.Put(ctx, r), ShouldBeNil)
   144  			summary, err := executor.Do(ctx)
   145  			So(err, ShouldBeNil)
   146  			So(summary, ShouldBeEmpty)
   147  			So(rm.notifyStarted, ShouldBeEmpty)
   148  		})
   149  		Convey("do not notify pending run from different config group", func() {
   150  			r := &run.Run{
   151  				ID:            common.MakeRunID(lProject, runCreateTime, 1, []byte("deadbeef")),
   152  				Status:        run.Status_PENDING,
   153  				BilledTo:      userIdentity,
   154  				Mode:          run.FullRun,
   155  				CreateTime:    runCreateTime,
   156  				ConfigGroupID: prjcfg.MakeConfigGroupID("another-config-group", "hash"),
   157  			}
   158  			So(datastore.Put(ctx, r), ShouldBeNil)
   159  			summary, err := executor.Do(ctx)
   160  			So(err, ShouldBeNil)
   161  			So(summary, ShouldBeEmpty)
   162  			So(rm.notifyStarted, ShouldBeEmpty)
   163  		})
   164  		Convey("do not notify pending run from different triggerer", func() {
   165  			r := &run.Run{
   166  				ID:            common.MakeRunID(lProject, runCreateTime, 1, []byte("deadbeef")),
   167  				Status:        run.Status_PENDING,
   168  				BilledTo:      identity.Identity(fmt.Sprintf("%s:%s", identity.User, "another-user@example.com")),
   169  				Mode:          run.FullRun,
   170  				CreateTime:    runCreateTime,
   171  				ConfigGroupID: configGroupID,
   172  			}
   173  			So(datastore.Put(ctx, r), ShouldBeNil)
   174  			summary, err := executor.Do(ctx)
   175  			So(err, ShouldBeNil)
   176  			So(summary, ShouldBeEmpty)
   177  			So(rm.notifyStarted, ShouldBeEmpty)
   178  		})
   179  		Convey("do not notify pending run that has pending Dep Run", func() {
   180  			depRun := &run.Run{
   181  				ID:            common.MakeRunID(lProject, runCreateTime.Add(-1*time.Minute), 1, []byte("deadbeef")),
   182  				Status:        run.Status_PENDING,
   183  				BilledTo:      identity.Identity(fmt.Sprintf("%s:%s", identity.User, "another-user@example.com")),
   184  				Mode:          run.FullRun,
   185  				CreateTime:    runCreateTime,
   186  				ConfigGroupID: configGroupID,
   187  			}
   188  			r := &run.Run{
   189  				ID:            common.MakeRunID(lProject, runCreateTime, 1, []byte("deadbeef")),
   190  				Status:        run.Status_PENDING,
   191  				BilledTo:      userIdentity,
   192  				Mode:          run.FullRun,
   193  				CreateTime:    runCreateTime,
   194  				ConfigGroupID: configGroupID,
   195  				DepRuns:       common.RunIDs{depRun.ID},
   196  			}
   197  			So(datastore.Put(ctx, depRun, r), ShouldBeNil)
   198  			summary, err := executor.Do(ctx)
   199  			So(err, ShouldBeNil)
   200  			So(summary, ShouldBeEmpty)
   201  			So(rm.notifyStarted, ShouldBeEmpty)
   202  		})
   203  		Convey("pick the earliest Run to notify", func() {
   204  			seededRand := rand.New(rand.NewSource(12345))
   205  			// Randomly create 100 Runs that are created in the past hour
   206  			runs := make([]*run.Run, 100)
   207  			for i := range runs {
   208  				runCreateTime = clock.Now(ctx).UTC().Add(time.Duration(seededRand.Float64() * float64(time.Hour)))
   209  				runs[i] = &run.Run{
   210  					ID:            common.MakeRunID(lProject, runCreateTime, 1, []byte("deadbeef")),
   211  					Status:        run.Status_PENDING,
   212  					BilledTo:      userIdentity,
   213  					Mode:          run.FullRun,
   214  					CreateTime:    runCreateTime,
   215  					ConfigGroupID: configGroupID,
   216  				}
   217  			}
   218  			So(datastore.Put(ctx, runs), ShouldBeNil)
   219  			var earliestRun *run.Run
   220  			for _, r := range runs {
   221  				if earliestRun == nil || r.CreateTime.Before(earliestRun.CreateTime) {
   222  					earliestRun = r
   223  				}
   224  			}
   225  			summary, err := executor.Do(ctx)
   226  			So(err, ShouldBeNil)
   227  			So(summary, ShouldEqual, fmt.Sprintf("notified next Run %q to start", earliestRun.ID))
   228  			So(rm.notifyStarted, ShouldResemble, common.RunIDs{earliestRun.ID})
   229  		})
   230  	})
   231  }
   232  
   233  type mockRM struct {
   234  	notifyStarted common.RunIDs
   235  }
   236  
   237  func (rm *mockRM) Start(ctx context.Context, runID common.RunID) error {
   238  	rm.notifyStarted = append(rm.notifyStarted, runID)
   239  	return nil
   240  }
   241  
   242  type mockQM struct {
   243  	quotaSpecified        bool
   244  	creditQuotaCalledWith common.RunIDs
   245  }
   246  
   247  func (qm *mockQM) RunQuotaAccountID(r *run.Run) *quotapb.AccountID {
   248  	return &quotapb.AccountID{
   249  		AppId:        "cv",
   250  		Realm:        r.ID.LUCIProject(),
   251  		Namespace:    r.ConfigGroupID.Name(),
   252  		Name:         r.BilledTo.Email(),
   253  		ResourceType: "mock-runs",
   254  	}
   255  }
   256  
   257  func (qm *mockQM) CreditRunQuota(ctx context.Context, r *run.Run) (*quotapb.OpResult, *cfgpb.UserLimit, error) {
   258  	qm.creditQuotaCalledWith = append(qm.creditQuotaCalledWith, r.ID)
   259  	if !qm.quotaSpecified {
   260  		return nil, nil, nil
   261  	}
   262  	return &quotapb.OpResult{
   263  		PreviousBalance: 0,
   264  		NewBalance:      1,
   265  		AccountStatus:   quotapb.OpResult_ALREADY_EXISTS,
   266  		Status:          quotapb.OpResult_SUCCESS,
   267  	}, nil, nil
   268  }