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 := &quotapb.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 := &quotapb.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(&quotapb.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 := &quotapb.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 := &quotapb.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, &quotapb.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 := &quotapb.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  }