go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/resultdb/internal/services/recorder/create_test_exoneration.go (about) 1 // Copyright 2019 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 recorder 16 17 import ( 18 "context" 19 "crypto/sha512" 20 "encoding/hex" 21 "fmt" 22 23 "cloud.google.com/go/spanner" 24 "github.com/google/uuid" 25 26 "go.chromium.org/luci/common/errors" 27 "go.chromium.org/luci/grpc/appstatus" 28 "go.chromium.org/luci/resultdb/internal/invocations" 29 "go.chromium.org/luci/resultdb/internal/spanutil" 30 "go.chromium.org/luci/resultdb/pbutil" 31 pb "go.chromium.org/luci/resultdb/proto/v1" 32 "go.chromium.org/luci/server/auth" 33 "go.chromium.org/luci/server/span" 34 ) 35 36 // validateCreateTestExonerationRequest returns a non-nil error if req is invalid. 37 func validateCreateTestExonerationRequest(req *pb.CreateTestExonerationRequest, requireInvocation bool) error { 38 if requireInvocation || req.Invocation != "" { 39 if err := pbutil.ValidateInvocationName(req.Invocation); err != nil { 40 return errors.Annotate(err, "invocation").Err() 41 } 42 } 43 44 ex := req.GetTestExoneration() 45 if err := pbutil.ValidateTestID(ex.GetTestId()); err != nil { 46 return errors.Annotate(err, "test_exoneration: test_id").Err() 47 } 48 if err := pbutil.ValidateVariant(ex.GetVariant()); err != nil { 49 return errors.Annotate(err, "test_exoneration: variant").Err() 50 } 51 52 hasVariant := len(ex.GetVariant().GetDef()) != 0 53 hasVariantHash := ex.VariantHash != "" 54 if hasVariant && hasVariantHash { 55 computedHash := pbutil.VariantHash(ex.GetVariant()) 56 if computedHash != ex.VariantHash { 57 return errors.Reason("computed and supplied variant hash don't match").Err() 58 } 59 } 60 61 if err := pbutil.ValidateRequestID(req.RequestId); err != nil { 62 return errors.Annotate(err, "request_id").Err() 63 } 64 65 if ex.ExplanationHtml == "" { 66 return errors.Reason("test_exoneration: explanation_html: unspecified").Err() 67 } 68 if ex.Reason == pb.ExonerationReason_EXONERATION_REASON_UNSPECIFIED { 69 return errors.Reason("test_exoneration: reason: unspecified").Err() 70 } 71 return nil 72 } 73 74 // CreateTestExoneration implements pb.RecorderServer. 75 func (s *recorderServer) CreateTestExoneration(ctx context.Context, in *pb.CreateTestExonerationRequest) (*pb.TestExoneration, error) { 76 if err := validateCreateTestExonerationRequest(in, true); err != nil { 77 return nil, appstatus.BadRequest(err) 78 } 79 invID := invocations.MustParseName(in.Invocation) 80 81 ret, mutation := insertTestExoneration(ctx, invID, in.RequestId, 0, in.TestExoneration) 82 err := mutateInvocation(ctx, invID, func(ctx context.Context) error { 83 span.BufferWrite(ctx, mutation) 84 return nil 85 }) 86 if err != nil { 87 return nil, err 88 } 89 return ret, nil 90 } 91 92 func insertTestExoneration(ctx context.Context, invID invocations.ID, requestID string, ordinal int, body *pb.TestExoneration) (ret *pb.TestExoneration, mutation *spanner.Mutation) { 93 // Compute exoneration ID and choose Insert vs InsertOrUpdate. 94 var exonerationIDSuffix string 95 mutFn := spanner.InsertMap 96 if requestID == "" { 97 // Use a random id. 98 exonerationIDSuffix = "r:" + uuid.New().String() 99 } else { 100 // Use a deterministic id. 101 exonerationIDSuffix = "d:" + deterministicExonerationIDSuffix(ctx, requestID, ordinal) 102 mutFn = spanner.InsertOrUpdateMap 103 } 104 105 // Use the given variant hash, or the hash of the given variant, whichever 106 // is present. If both are present then validation guarantees they'll 107 // match, so we can just use whichever. 108 variantHash := body.VariantHash 109 if variantHash == "" { 110 variantHash = pbutil.VariantHash(body.Variant) 111 } 112 113 exonerationID := fmt.Sprintf("%s:%s", variantHash, exonerationIDSuffix) 114 ret = &pb.TestExoneration{ 115 Name: pbutil.TestExonerationName(string(invID), body.TestId, exonerationID), 116 TestId: body.TestId, 117 Variant: body.Variant, 118 VariantHash: variantHash, 119 ExonerationId: exonerationID, 120 ExplanationHtml: body.ExplanationHtml, 121 Reason: body.Reason, 122 } 123 124 mutation = mutFn("TestExonerations", spanutil.ToSpannerMap(map[string]any{ 125 "InvocationId": invID, 126 "TestId": ret.TestId, 127 "ExonerationId": exonerationID, 128 "Variant": ret.Variant, 129 "VariantHash": ret.VariantHash, 130 "ExplanationHTML": spanutil.Compressed(ret.ExplanationHtml), 131 "Reason": ret.Reason, 132 })) 133 return 134 } 135 136 func deterministicExonerationIDSuffix(ctx context.Context, requestID string, ordinal int) string { 137 h := sha512.New() 138 // Include current identity, so that two separate clients 139 // do not override each other's test exonerations even if 140 // they happened to produce identical request ids. 141 // The alternative is to use remote IP address, but it is not 142 // implemented in pRPC. 143 fmt.Fprintln(h, auth.CurrentIdentity(ctx)) 144 fmt.Fprintln(h, requestID) 145 fmt.Fprintln(h, ordinal) 146 return hex.EncodeToString(h.Sum(nil)) 147 }