blob: 0f33324554cb4b1f832990c9b2225fa3bd905348 [file] [log] [blame]
// Copyright 2020 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package rpc
import (
"context"
"regexp"
"strings"
"time"
"github.com/golang/protobuf/proto"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc/metadata"
"go.chromium.org/luci/auth/identity"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/data/stringset"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/grpc/appstatus"
"go.chromium.org/luci/grpc/grpcutil"
"go.chromium.org/luci/server/auth"
"go.chromium.org/luci/server/bqlog"
"go.chromium.org/luci/buildbucket"
"go.chromium.org/luci/buildbucket/appengine/internal/buildtoken"
pb "go.chromium.org/luci/buildbucket/proto"
"go.chromium.org/luci/buildbucket/protoutil"
)
type tagValidationMode int
const (
TagNew tagValidationMode = iota
TagAppend
)
const (
buildSetMaxLength = 1024
)
const (
// BbagentUtilPkgDir is the directory containing packages that bbagent uses.
BbagentUtilPkgDir = "bbagent_utility_packages"
// CipdClientDir is the directory containing cipd itself
CipdClientDir = "cipd"
UserPackageDir = "cipd_bin_packages"
)
var (
sha1Regex = regexp.MustCompile(`^[a-f0-9]{40}$`)
reservedKeys = stringset.NewFromSlice("build_address")
gitilesCommitRegex = regexp.MustCompile(`^commit/gitiles/([^/]+)/(.+?)/\+/([a-f0-9]{40})$`)
gerritCLRegex = regexp.MustCompile(`^patch/gerrit/([^/]+)/(\d+)/(\d+)$`)
)
func init() {
bqlog.RegisterSink(bqlog.Sink{
Prototype: &pb.PRPCRequestLog{},
Table: "prpc_request_log",
})
}
// bundler is the key to a *bqlog.Bundler in the context.
var bundlerKey = "bundler"
// withBundler returns a new context with the given *bqlog.Bundler set.
func withBundler(ctx context.Context, b *bqlog.Bundler) context.Context {
return context.WithValue(ctx, &bundlerKey, b)
}
// getBundler returns the *bqlog.Bundler installed in the current context.
// Panics if there isn't one.
func getBundler(ctx context.Context) *bqlog.Bundler {
return ctx.Value(&bundlerKey).(*bqlog.Bundler)
}
// logToBQ logs a PRPC request log for this request to BigQuery (best-effort).
func logToBQ(ctx context.Context, id, parent, methodName string) {
user := auth.CurrentIdentity(ctx)
if user.Kind() == identity.User && !strings.HasSuffix(string(user), ".gserviceaccount.com") {
user = ""
}
cTime := getStartTime(ctx)
duration := int64(0)
if !cTime.IsZero() {
duration = clock.Now(ctx).Sub(cTime).Microseconds()
}
getBundler(ctx).Log(ctx, &pb.PRPCRequestLog{
Id: id,
Parent: parent,
CreationTime: cTime.UnixNano() / 1000,
Duration: duration,
Method: methodName,
User: string(user),
})
}
// commonPostlude converts an appstatus error to a gRPC error and logs it.
// Requires a *bqlog.Bundler in the context (see commonPrelude).
func commonPostlude(ctx context.Context, methodName string, rsp proto.Message, err error) error {
logToBQ(ctx, trace.SpanContextFromContext(ctx).TraceID().String(), "", methodName)
return appstatus.GRPCifyAndLog(ctx, err)
}
// teeErr saves `err` in `keep` and then returns `err`
func teeErr(err error, keep *error) error {
*keep = err
return err
}
// timeKey is the key to a time.Time in the context.
var timeKey = "start time"
// withStartTime returns a new context with the given time.Time set.
func withStartTime(ctx context.Context, t time.Time) context.Context {
return context.WithValue(ctx, &timeKey, t)
}
// getStartTime returns the time.Time installed in the current context.
func getStartTime(ctx context.Context) time.Time {
if t, ok := ctx.Value(&timeKey).(time.Time); ok {
return t
}
return time.Time{}
}
// commonPrelude logs debug information about the request and installs a
// start time and *bqlog.Bundler in the current context.
func commonPrelude(ctx context.Context, methodName string, req proto.Message) (context.Context, error) {
logging.Debugf(ctx, "%q called %q with request %s", auth.CurrentIdentity(ctx), methodName, proto.MarshalTextString(req))
return withBundler(withStartTime(ctx, clock.Now(ctx)), &bqlog.Default), nil
}
func validatePageSize(pageSize int32) error {
if pageSize < 0 {
return errors.Reason("page_size cannot be negative").Err()
}
return nil
}
// validateTags validates build tags.
//
// tagValidationMode should be one of the enum - TagNew, TagAppend
// Note: Duplicate tags can pass the validation, which will be eventually deduplicated when storing into DB.
func validateTags(tags []*pb.StringPair, m tagValidationMode) error {
if tags == nil {
return nil
}
var k, v string
var seenBuilderTagValue string
for _, tag := range tags {
k = tag.Key
v = tag.Value
if strings.Contains(k, ":") {
return errors.Reason(`tag key "%s" cannot have a colon`, k).Err()
}
if m == TagAppend && buildbucket.DisallowedAppendTagKeys.Has(k) {
return errors.Reason(`tag key "%s" cannot be added to an existing build`, k).Err()
}
if k == "buildset" {
if err := validateBuildSet(v); err != nil {
return err
}
}
if k == "builder" {
if seenBuilderTagValue == "" {
seenBuilderTagValue = v
} else if v != seenBuilderTagValue {
return errors.Reason(`tag "builder:%s" conflicts with tag "builder:%s"`, v, seenBuilderTagValue).Err()
}
}
if reservedKeys.Has(k) {
return errors.Reason(`tag "%s" is reserved`, k).Err()
}
}
return nil
}
func validateBuildSet(bs string) error {
if len("buildset:")+len(bs) > buildSetMaxLength {
return errors.Reason("buildset tag is too long").Err()
}
// Verify that a buildset with a known prefix is well formed.
if strings.HasPrefix(bs, "commit/gitiles/") {
matches := gitilesCommitRegex.FindStringSubmatch(bs)
if len(matches) == 0 {
return errors.Reason(`does not match regex "%s"`, gitilesCommitRegex).Err()
}
project := matches[2]
if strings.HasPrefix(project, "a/") {
return errors.Reason(`gitiles project must not start with "a/"`).Err()
}
if strings.HasSuffix(project, ".git") {
return errors.Reason(`gitiles project must not end with ".git"`).Err()
}
} else if strings.HasPrefix(bs, "patch/gerrit/") {
if !gerritCLRegex.MatchString(bs) {
return errors.Reason(`does not match regex "%s"`, gerritCLRegex).Err()
}
}
return nil
}
func validateSummaryMarkdown(md string) error {
if len(md) > protoutil.SummaryMarkdownMaxLength {
return errors.Reason("too big to accept (%d > %d bytes)", len(md), protoutil.SummaryMarkdownMaxLength).Err()
}
return nil
}
// TODO(ddoman): move proto validator functions to protoutil.
// validateCommitWithRef checks if `cm` is a valid commit with a ref.
func validateCommitWithRef(cm *pb.GitilesCommit) error {
if cm.GetRef() == "" {
return errors.Reason(`ref is required`).Err()
}
return validateCommit(cm)
}
// validateCommit validates the given Gitiles commit.
func validateCommit(cm *pb.GitilesCommit) error {
if cm.GetHost() == "" {
return errors.Reason("host is required").Err()
}
if cm.GetProject() == "" {
return errors.Reason("project is required").Err()
}
if cm.GetRef() != "" {
if !strings.HasPrefix(cm.Ref, "refs/") {
return errors.Reason("ref must match refs/.*").Err()
}
} else if cm.Position != 0 {
return errors.Reason("position requires ref").Err()
}
if cm.GetId() != "" && !sha1Regex.MatchString(cm.Id) {
return errors.Reason("id must match %q", sha1Regex).Err()
}
if cm.GetRef() == "" && cm.GetId() == "" {
return errors.Reason("one of id or ref is required").Err()
}
return nil
}
// Returned from validateToken if no token is found; Some uses of validateToken
// allow a missing token (such as establishing parent->child relationship during
// ScheduleBuild).
var errBadTokenAuth = errors.New("expected buildID and exactly one buildbucket token", grpcutil.UnauthenticatedTag)
// getBuildbucketToken extracts a singlar encoded build token from the current
// gRPC Metadata in `ctx`.
//
// Does not parse or validate the token in anyway (see validateToken for typical
// usage, or buildtoken.ParseToTokenBody for more specialized usage).
//
// `kitchenFallback` should only be supplied in cases where we need to fall back
// to kitchen's deprecated BuildTokenHeader.
//
// Returns errBadTokenAuth if the token is missing, or there is more than one.
func getBuildbucketToken(ctx context.Context, kitchenFallback bool) (string, error) {
md, _ := metadata.FromIncomingContext(ctx)
tokens := md.Get(buildbucket.BuildbucketTokenHeader)
if len(tokens) == 0 && kitchenFallback {
// TODO: Remove this when kitchen is removed.
tokens = md.Get(buildbucket.BuildTokenHeader)
}
if len(tokens) == 1 {
return tokens[0], nil
}
return "", errBadTokenAuth
}
// validateToken validates the build token from the header.
//
// The `purpose` and `bID` must match the purpose and build ID listed in the token.
//
// All errors that this would return are errBadTokenAuth.
// Details about token parsing are logged.
func validateToken(ctx context.Context, bID int64, purpose pb.TokenBody_Purpose) (*pb.TokenBody, error) {
if bID <= 0 {
return nil, errBadTokenAuth
}
buildTok, err := getBuildbucketToken(ctx, purpose == pb.TokenBody_BUILD)
if err != nil {
return nil, err
}
tok, err := buildtoken.ParseToTokenBody(ctx, buildTok, bID, purpose)
if err != nil {
return nil, err
}
return tok, nil
}