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 }