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 "apb.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 "apb.OpResult{ 263 PreviousBalance: 0, 264 NewBalance: 1, 265 AccountStatus: quotapb.OpResult_ALREADY_EXISTS, 266 Status: quotapb.OpResult_SUCCESS, 267 }, nil, nil 268 }