go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/buildbucket/appengine/rpc/common.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 rpc
    16  
    17  import (
    18  	"context"
    19  	"regexp"
    20  	"strings"
    21  	"time"
    22  
    23  	"github.com/golang/protobuf/proto"
    24  	"go.opentelemetry.io/otel/trace"
    25  	"google.golang.org/grpc/metadata"
    26  
    27  	"go.chromium.org/luci/auth/identity"
    28  	"go.chromium.org/luci/common/clock"
    29  	"go.chromium.org/luci/common/data/stringset"
    30  	"go.chromium.org/luci/common/errors"
    31  	"go.chromium.org/luci/common/logging"
    32  	"go.chromium.org/luci/grpc/appstatus"
    33  	"go.chromium.org/luci/grpc/grpcutil"
    34  	"go.chromium.org/luci/server/auth"
    35  	"go.chromium.org/luci/server/bqlog"
    36  
    37  	"go.chromium.org/luci/buildbucket"
    38  	"go.chromium.org/luci/buildbucket/appengine/internal/buildtoken"
    39  	pb "go.chromium.org/luci/buildbucket/proto"
    40  	"go.chromium.org/luci/buildbucket/protoutil"
    41  )
    42  
    43  type tagValidationMode int
    44  
    45  const (
    46  	TagNew tagValidationMode = iota
    47  	TagAppend
    48  )
    49  
    50  const (
    51  	buildSetMaxLength = 1024
    52  )
    53  
    54  const (
    55  	// BbagentUtilPkgDir is the directory containing packages that bbagent uses.
    56  	BbagentUtilPkgDir = "bbagent_utility_packages"
    57  	// CipdClientDir is the directory containing cipd itself
    58  	CipdClientDir  = "cipd"
    59  	UserPackageDir = "cipd_bin_packages"
    60  )
    61  
    62  var (
    63  	sha1Regex          = regexp.MustCompile(`^[a-f0-9]{40}$`)
    64  	reservedKeys       = stringset.NewFromSlice("build_address")
    65  	gitilesCommitRegex = regexp.MustCompile(`^commit/gitiles/([^/]+)/(.+?)/\+/([a-f0-9]{40})$`)
    66  	gerritCLRegex      = regexp.MustCompile(`^patch/gerrit/([^/]+)/(\d+)/(\d+)$`)
    67  )
    68  
    69  func init() {
    70  	bqlog.RegisterSink(bqlog.Sink{
    71  		Prototype: &pb.PRPCRequestLog{},
    72  		Table:     "prpc_request_log",
    73  	})
    74  }
    75  
    76  // bundler is the key to a *bqlog.Bundler in the context.
    77  var bundlerKey = "bundler"
    78  
    79  // withBundler returns a new context with the given *bqlog.Bundler set.
    80  func withBundler(ctx context.Context, b *bqlog.Bundler) context.Context {
    81  	return context.WithValue(ctx, &bundlerKey, b)
    82  }
    83  
    84  // getBundler returns the *bqlog.Bundler installed in the current context.
    85  // Panics if there isn't one.
    86  func getBundler(ctx context.Context) *bqlog.Bundler {
    87  	return ctx.Value(&bundlerKey).(*bqlog.Bundler)
    88  }
    89  
    90  // logToBQ logs a PRPC request log for this request to BigQuery (best-effort).
    91  func logToBQ(ctx context.Context, id, parent, methodName string) {
    92  	user := auth.CurrentIdentity(ctx)
    93  	if user.Kind() == identity.User && !strings.HasSuffix(string(user), ".gserviceaccount.com") {
    94  		user = ""
    95  	}
    96  	cTime := getStartTime(ctx)
    97  	duration := int64(0)
    98  	if !cTime.IsZero() {
    99  		duration = clock.Now(ctx).Sub(cTime).Microseconds()
   100  	}
   101  	getBundler(ctx).Log(ctx, &pb.PRPCRequestLog{
   102  		Id:           id,
   103  		Parent:       parent,
   104  		CreationTime: cTime.UnixNano() / 1000,
   105  		Duration:     duration,
   106  		Method:       methodName,
   107  		User:         string(user),
   108  	})
   109  }
   110  
   111  // commonPostlude converts an appstatus error to a gRPC error and logs it.
   112  // Requires a *bqlog.Bundler in the context (see commonPrelude).
   113  func commonPostlude(ctx context.Context, methodName string, rsp proto.Message, err error) error {
   114  	logToBQ(ctx, trace.SpanContextFromContext(ctx).TraceID().String(), "", methodName)
   115  	return appstatus.GRPCifyAndLog(ctx, err)
   116  }
   117  
   118  // teeErr saves `err` in `keep` and then returns `err`
   119  func teeErr(err error, keep *error) error {
   120  	*keep = err
   121  	return err
   122  }
   123  
   124  // timeKey is the key to a time.Time in the context.
   125  var timeKey = "start time"
   126  
   127  // withStartTime returns a new context with the given time.Time set.
   128  func withStartTime(ctx context.Context, t time.Time) context.Context {
   129  	return context.WithValue(ctx, &timeKey, t)
   130  }
   131  
   132  // getStartTime returns the time.Time installed in the current context.
   133  func getStartTime(ctx context.Context) time.Time {
   134  	if t, ok := ctx.Value(&timeKey).(time.Time); ok {
   135  		return t
   136  	}
   137  	return time.Time{}
   138  }
   139  
   140  // commonPrelude logs debug information about the request and installs a
   141  // start time and *bqlog.Bundler in the current context.
   142  func commonPrelude(ctx context.Context, methodName string, req proto.Message) (context.Context, error) {
   143  	logging.Debugf(ctx, "%q called %q with request %s", auth.CurrentIdentity(ctx), methodName, proto.MarshalTextString(req))
   144  	return withBundler(withStartTime(ctx, clock.Now(ctx)), &bqlog.Default), nil
   145  }
   146  
   147  func validatePageSize(pageSize int32) error {
   148  	if pageSize < 0 {
   149  		return errors.Reason("page_size cannot be negative").Err()
   150  	}
   151  	return nil
   152  }
   153  
   154  // validateTags validates build tags.
   155  //
   156  // tagValidationMode should be one of the enum - TagNew, TagAppend
   157  // Note: Duplicate tags can pass the validation, which will be eventually deduplicated when storing into DB.
   158  func validateTags(tags []*pb.StringPair, m tagValidationMode) error {
   159  	if tags == nil {
   160  		return nil
   161  	}
   162  	var k, v string
   163  	var seenBuilderTagValue string
   164  	for _, tag := range tags {
   165  		k = tag.Key
   166  		v = tag.Value
   167  		if strings.Contains(k, ":") {
   168  			return errors.Reason(`tag key "%s" cannot have a colon`, k).Err()
   169  		}
   170  		if m == TagAppend && buildbucket.DisallowedAppendTagKeys.Has(k) {
   171  			return errors.Reason(`tag key "%s" cannot be added to an existing build`, k).Err()
   172  		}
   173  		if k == "buildset" {
   174  			if err := validateBuildSet(v); err != nil {
   175  				return err
   176  			}
   177  		}
   178  		if k == "builder" {
   179  			if seenBuilderTagValue == "" {
   180  				seenBuilderTagValue = v
   181  			} else if v != seenBuilderTagValue {
   182  				return errors.Reason(`tag "builder:%s" conflicts with tag "builder:%s"`, v, seenBuilderTagValue).Err()
   183  			}
   184  		}
   185  		if reservedKeys.Has(k) {
   186  			return errors.Reason(`tag "%s" is reserved`, k).Err()
   187  		}
   188  	}
   189  	return nil
   190  }
   191  
   192  func validateBuildSet(bs string) error {
   193  	if len("buildset:")+len(bs) > buildSetMaxLength {
   194  		return errors.Reason("buildset tag is too long").Err()
   195  	}
   196  
   197  	// Verify that a buildset with a known prefix is well formed.
   198  	if strings.HasPrefix(bs, "commit/gitiles/") {
   199  		matches := gitilesCommitRegex.FindStringSubmatch(bs)
   200  		if len(matches) == 0 {
   201  			return errors.Reason(`does not match regex "%s"`, gitilesCommitRegex).Err()
   202  		}
   203  		project := matches[2]
   204  		if strings.HasPrefix(project, "a/") {
   205  			return errors.Reason(`gitiles project must not start with "a/"`).Err()
   206  		}
   207  		if strings.HasSuffix(project, ".git") {
   208  			return errors.Reason(`gitiles project must not end with ".git"`).Err()
   209  		}
   210  	} else if strings.HasPrefix(bs, "patch/gerrit/") {
   211  		if !gerritCLRegex.MatchString(bs) {
   212  			return errors.Reason(`does not match regex "%s"`, gerritCLRegex).Err()
   213  		}
   214  	}
   215  	return nil
   216  }
   217  
   218  func validateSummaryMarkdown(md string) error {
   219  	if len(md) > protoutil.SummaryMarkdownMaxLength {
   220  		return errors.Reason("too big to accept (%d > %d bytes)", len(md), protoutil.SummaryMarkdownMaxLength).Err()
   221  	}
   222  	return nil
   223  }
   224  
   225  // TODO(ddoman): move proto validator functions to protoutil.
   226  
   227  // validateCommitWithRef checks if `cm` is a valid commit with a ref.
   228  func validateCommitWithRef(cm *pb.GitilesCommit) error {
   229  	if cm.GetRef() == "" {
   230  		return errors.Reason(`ref is required`).Err()
   231  	}
   232  	return validateCommit(cm)
   233  }
   234  
   235  // validateCommit validates the given Gitiles commit.
   236  func validateCommit(cm *pb.GitilesCommit) error {
   237  	if cm.GetHost() == "" {
   238  		return errors.Reason("host is required").Err()
   239  	}
   240  	if cm.GetProject() == "" {
   241  		return errors.Reason("project is required").Err()
   242  	}
   243  
   244  	if cm.GetRef() != "" {
   245  		if !strings.HasPrefix(cm.Ref, "refs/") {
   246  			return errors.Reason("ref must match refs/.*").Err()
   247  		}
   248  	} else if cm.Position != 0 {
   249  		return errors.Reason("position requires ref").Err()
   250  	}
   251  
   252  	if cm.GetId() != "" && !sha1Regex.MatchString(cm.Id) {
   253  		return errors.Reason("id must match %q", sha1Regex).Err()
   254  	}
   255  	if cm.GetRef() == "" && cm.GetId() == "" {
   256  		return errors.Reason("one of id or ref is required").Err()
   257  	}
   258  	return nil
   259  }
   260  
   261  // Returned from validateToken if no token is found; Some uses of validateToken
   262  // allow a missing token (such as establishing parent->child relationship during
   263  // ScheduleBuild).
   264  var errBadTokenAuth = errors.New("expected buildID and exactly one buildbucket token", grpcutil.UnauthenticatedTag)
   265  
   266  // getBuildbucketToken extracts a singlar encoded build token from the current
   267  // gRPC Metadata in `ctx`.
   268  //
   269  // Does not parse or validate the token in anyway (see validateToken for typical
   270  // usage, or buildtoken.ParseToTokenBody for more specialized usage).
   271  //
   272  // `kitchenFallback` should only be supplied in cases where we need to fall back
   273  // to kitchen's deprecated BuildTokenHeader.
   274  //
   275  // Returns errBadTokenAuth if the token is missing, or there is more than one.
   276  func getBuildbucketToken(ctx context.Context, kitchenFallback bool) (string, error) {
   277  	md, _ := metadata.FromIncomingContext(ctx)
   278  	tokens := md.Get(buildbucket.BuildbucketTokenHeader)
   279  	if len(tokens) == 0 && kitchenFallback {
   280  		// TODO: Remove this when kitchen is removed.
   281  		tokens = md.Get(buildbucket.BuildTokenHeader)
   282  	}
   283  	if len(tokens) == 1 {
   284  		return tokens[0], nil
   285  	}
   286  
   287  	return "", errBadTokenAuth
   288  }
   289  
   290  // validateToken validates the build token from the header.
   291  //
   292  // The `purpose` and `bID` must match the purpose and build ID listed in the token.
   293  //
   294  // All errors that this would return are errBadTokenAuth.
   295  // Details about token parsing are logged.
   296  func validateToken(ctx context.Context, bID int64, purpose pb.TokenBody_Purpose) (*pb.TokenBody, error) {
   297  	if bID <= 0 {
   298  		return nil, errBadTokenAuth
   299  	}
   300  	buildTok, err := getBuildbucketToken(ctx, purpose == pb.TokenBody_BUILD)
   301  	if err != nil {
   302  		return nil, err
   303  	}
   304  	tok, err := buildtoken.ParseToTokenBody(ctx, buildTok, bID, purpose)
   305  	if err != nil {
   306  		return nil, err
   307  	}
   308  	return tok, nil
   309  }