go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/quota/quota.go (about) 1 // Copyright 2022 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 quota 16 17 import ( 18 "context" 19 "crypto/sha256" 20 "encoding/hex" 21 "time" 22 23 "github.com/gomodule/redigo/redis" 24 "github.com/vmihailenco/msgpack/v5" 25 "google.golang.org/protobuf/types/known/durationpb" 26 27 "go.chromium.org/luci/common/data/stringset" 28 "go.chromium.org/luci/common/errors" 29 "go.chromium.org/luci/common/proto/msgpackpb" 30 31 "go.chromium.org/luci/server/auth" 32 "go.chromium.org/luci/server/quota/internal/lua" 33 "go.chromium.org/luci/server/quota/internal/quotakeys" 34 "go.chromium.org/luci/server/quota/quotapb" 35 ) 36 37 // ErrQuotaApply is returned by Apply when the updates were not 38 // applied. 39 // 40 // See the returned ApplyOpsResponse for details. 41 var ErrQuotaApply = errors.New("quota.Apply had errors") 42 43 // UpdateAccountsScript is a reference to the lua script used by the quota 44 // library. This is only a public symbol in order to patch it with 45 // the quotatestmonkeypatch library. 46 var UpdateAccountsScript = lua.UpdateAccountsScript 47 48 // For now, set the request timeout to 2 hours; can make this flexible later. 49 var updateAccountsRequestTTL = durationpb.New(time.Hour * 2) 50 51 // Holds a mask used to detect any invalid quotapb.Op_Options bits. 52 var invalidOptionsMask uint32 53 54 // currentHashScheme reflects the current UpdateAccountsInput.HashScheme 55 // value. Refer to the comment there for additional context. 56 const currentHashScheme = 1 57 58 const conflictOptions = quotapb.Op_IGNORE_POLICY_BOUNDS | quotapb.Op_DO_NOT_CAP_PROPOSED 59 60 func init() { 61 for val := range quotapb.Op_Options_name { 62 invalidOptionsMask = invalidOptionsMask | uint32(val) 63 } 64 invalidOptionsMask = ^invalidOptionsMask 65 } 66 67 func convertToInput(ctx context.Context, requestID string, requestTTL *durationpb.Duration, ops []*quotapb.Op) (*quotapb.UpdateAccountsInput, []string, error) { 68 ret := "apb.UpdateAccountsInput{Ops: make([]*quotapb.RawOp, len(ops))} 69 // KEYS will be fed directly as KEYS to the redis script. 70 KEYS := stringset.New(len(ops)) 71 for i, op := range ops { 72 if err := op.Validate(); err != nil { 73 return nil, nil, errors.Annotate(err, "ops[%d]", i).Err() 74 } 75 76 raw := "apb.RawOp{ 77 AccountRef: quotakeys.AccountKey(op.AccountId), 78 RelativeTo: op.RelativeTo, 79 Delta: op.Delta, 80 Options: op.Options, 81 } 82 KEYS.Add(raw.AccountRef) 83 84 if (op.Options & invalidOptionsMask) > 0 { 85 return nil, nil, errors.Reason("ops[%d]: unknown options", i).Err() 86 } 87 if op.Options&uint32(conflictOptions) == uint32(conflictOptions) { 88 return nil, nil, errors.Reason("ops[%d]: conflicting options", i).Err() 89 } 90 91 if op.PolicyId != nil { 92 if op.AccountId.AppId != op.PolicyId.Config.AppId { 93 return nil, nil, errors.Reason( 94 "ops[%d]: account and policy come from different apps: %s vs %s", 95 i, op.AccountId.AppId, op.PolicyId.Config.AppId).Err() 96 } 97 if op.AccountId.ResourceType != op.PolicyId.Key.ResourceType { 98 return nil, nil, errors.Reason( 99 "ops[%d]: account and policy are for different resource types: %s vs %s", 100 i, op.AccountId.ResourceType, op.PolicyId.Key.ResourceType).Err() 101 } 102 raw.PolicyRef = quotakeys.PolicyRef(op.PolicyId) 103 KEYS.Add(raw.PolicyRef.Config) 104 } 105 106 ret.Ops[i] = raw 107 } 108 109 h := sha256.New() 110 if err := msgpackpb.MarshalStream(h, ret, msgpackpb.Deterministic); err != nil { 111 return nil, nil, errors.Annotate(err, "calculating hash").Err() 112 } 113 114 if requestID != "" { 115 ret.RequestKey = quotakeys.RequestDedupKey("apb.RequestDedupKey{ 116 Ident: string(auth.CurrentIdentity(ctx)), 117 RequestId: requestID, 118 }) 119 120 ret.RequestKeyTtl = updateAccountsRequestTTL 121 if requestTTL != nil { 122 ret.RequestKeyTtl = requestTTL 123 } 124 125 KEYS.Add(ret.RequestKey) 126 } 127 ret.HashScheme = currentHashScheme 128 ret.Hash = hex.EncodeToString(h.Sum(nil)) 129 130 return ret, KEYS.ToSortedSlice(), nil 131 } 132 133 // ApplyOps combines several quota operations into one atomic action with a single 134 // requestID. 135 // 136 // The requestID won't be consumed until this returns success, and once it's 137 // successful, it will continue to return success without any quota changes for 138 // requestTTL. If requestTTL is not set, the TTL defaults to 2 hours. The 139 // requestID is tied to auth.CurrentIdentity. If requestID is empty, this 140 // operation is not idempotent. 141 // 142 // Policies must already be loaded with LoadPolicies. 143 func ApplyOps(ctx context.Context, requestID string, requestTTL *durationpb.Duration, ops []*quotapb.Op) (*quotapb.ApplyOpsResponse, error) { 144 inputMsg, keys, err := convertToInput(ctx, requestID, requestTTL, ops) 145 if err != nil { 146 return nil, err 147 } 148 input, err := msgpackpb.Marshal( 149 inputMsg, msgpackpb.Deterministic, 150 msgpackpb.WithStringInternTable(keys)) 151 if err != nil { 152 return nil, errors.Annotate(err, "failed to marshal UpdateAccountsInput").Err() 153 } 154 155 fullArgs := make(redis.Args, 0, len(keys)+2) 156 fullArgs = fullArgs.Add(len(keys)) 157 fullArgs = fullArgs.AddFlat(keys) 158 fullArgs = fullArgs.Add(string(input)) 159 160 resp := "apb.ApplyOpsResponse{} 161 err = withRedisConn(ctx, func(conn redis.Conn) error { 162 respRaw, err := redis.String(UpdateAccountsScript.DoContext(ctx, conn, fullArgs...)) 163 if err != nil { 164 return errors.Annotate(err, "running UpdateAccountsScript").Err() 165 } 166 if err := msgpackpb.Unmarshal(msgpack.RawMessage(respRaw), resp); err != nil { 167 return err 168 } 169 for _, result := range resp.Results { 170 if result.Status != quotapb.OpResult_SUCCESS { 171 err = ErrQuotaApply 172 break 173 } 174 } 175 return err 176 }) 177 return resp, err 178 } 179 180 // GetAccounts fetches the list of requested accounts. If the account does not 181 // exist, GetAccountsResponse.Account[i].Account is left unset. 182 // TODO(aravindvasudev): Implement logic to compute Account.ProjectedBalance. 183 func GetAccounts(ctx context.Context, accounts []*quotapb.AccountID) (*quotapb.GetAccountsResponse, error) { 184 resp := "apb.GetAccountsResponse{} 185 if len(accounts) == 0 { 186 return resp, nil 187 } 188 189 args := make(redis.Args, 0, len(accounts)) 190 for _, accountID := range accounts { 191 args = append(args, quotakeys.AccountKey(accountID)) 192 193 // rsp contains an entry for all the requested accounts, in spite of their existence. 194 resp.Accounts = append(resp.Accounts, "apb.GetAccountsResponse_AccountState{ 195 Id: accountID, 196 }) 197 } 198 199 err := withRedisConn(ctx, func(conn redis.Conn) error { 200 accountsRaw, err := redis.Strings(conn.Do("MGET", args...)) 201 if err != nil { 202 return errors.Annotate(err, "running MGET").Err() 203 } 204 205 for i, accountRaw := range accountsRaw { 206 if accountRaw == "" { 207 continue 208 } 209 210 account := "apb.Account{} 211 if err := msgpackpb.Unmarshal(msgpack.RawMessage(accountRaw), account); err != nil { 212 return err 213 } 214 215 resp.Accounts[i].Account = account 216 } 217 218 return nil 219 }) 220 221 if err != nil { 222 return nil, errors.Annotate(err, "failed to query accounts").Err() 223 } 224 225 return resp, nil 226 }