go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/resultdb/internal/services/recorder/invocation.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  	"time"
    20  
    21  	"cloud.google.com/go/spanner"
    22  	"google.golang.org/grpc/codes"
    23  	"google.golang.org/grpc/metadata"
    24  
    25  	"go.chromium.org/luci/common/clock"
    26  	"go.chromium.org/luci/common/data/rand/mathrand"
    27  	"go.chromium.org/luci/grpc/appstatus"
    28  	"go.chromium.org/luci/server/span"
    29  	"go.chromium.org/luci/server/tokens"
    30  
    31  	"go.chromium.org/luci/resultdb/internal/invocations"
    32  	"go.chromium.org/luci/resultdb/internal/spanutil"
    33  	"go.chromium.org/luci/resultdb/pbutil"
    34  	pb "go.chromium.org/luci/resultdb/proto/v1"
    35  )
    36  
    37  const (
    38  	day = 24 * time.Hour
    39  
    40  	// Delete Invocations row after this duration since invocation creation.
    41  	invocationExpirationDuration = 2 * 365 * day // 2 y
    42  
    43  	// By default, finalize the invocation 2d after creation if it is still
    44  	// incomplete.
    45  	defaultInvocationDeadlineDuration = 2 * day
    46  
    47  	// The maximum amount of time for an invocation.
    48  	// This is the same as the BUILD_TIMEOUT
    49  	// https://source.chromium.org/chromium/infra/infra/+/main:appengine/cr-buildbucket/model.py;l=28;drc=e6d97dc362dd4a412fc7b07da0c4df53f2940a80
    50  	// https://source.chromium.org/chromium/infra/infra/+/main:go/src/go.chromium.org/luci/buildbucket/appengine/model/build.go;l=53
    51  	maxInvocationDeadlineDuration = 5 * day
    52  )
    53  
    54  // invocationTokenKind generates and validates tokens issued to authorize
    55  // updating a given invocation.
    56  var invocationTokenKind = tokens.TokenKind{
    57  	Algo:      tokens.TokenAlgoHmacSHA256,
    58  	SecretKey: "invocation_tokens_secret",
    59  	Version:   1,
    60  }
    61  
    62  // generateInvocationToken generates an update token for a given invocation.
    63  func generateInvocationToken(ctx context.Context, invID invocations.ID) (string, error) {
    64  	// The token should last as long as a build is allowed to run.
    65  	// Buildbucket has a max of 2 days, so one week should be enough even
    66  	// for other use cases.
    67  	return invocationTokenKind.Generate(ctx, []byte(invID), nil, 7*day) // One week.
    68  }
    69  
    70  // validateInvocationToken validates an update token for a given invocation,
    71  // returning an error if the token is invalid, nil otherwise.
    72  func validateInvocationToken(ctx context.Context, token string, invID invocations.ID) error {
    73  	_, err := invocationTokenKind.Validate(ctx, token, []byte(invID))
    74  	return err
    75  }
    76  
    77  // mutateInvocation checks if the invocation can be mutated and also
    78  // finalizes the invocation if it's deadline is exceeded.
    79  // If the invocation is active, continue with the other mutation(s) in f.
    80  func mutateInvocation(ctx context.Context, id invocations.ID, f func(context.Context) error) error {
    81  	var retErr error
    82  
    83  	token, err := extractUpdateToken(ctx)
    84  	if err != nil {
    85  		return err
    86  	}
    87  	if err := validateInvocationToken(ctx, token, id); err != nil {
    88  		return appstatus.Errorf(codes.PermissionDenied, "invalid update token")
    89  	}
    90  	_, err = span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
    91  		state, err := invocations.ReadState(ctx, id)
    92  		switch {
    93  		case err != nil:
    94  			return err
    95  
    96  		case state != pb.Invocation_ACTIVE:
    97  			return appstatus.Errorf(codes.FailedPrecondition, "%s is not active", id.Name())
    98  		}
    99  
   100  		return f(ctx)
   101  	})
   102  
   103  	if err != nil {
   104  		retErr = err
   105  	}
   106  	return retErr
   107  }
   108  
   109  func extractUpdateToken(ctx context.Context) (string, error) {
   110  	md, _ := metadata.FromIncomingContext(ctx)
   111  	token := md.Get(pb.UpdateTokenMetadataKey)
   112  	switch {
   113  	case len(token) == 0:
   114  		return "", appstatus.Errorf(codes.Unauthenticated, "missing %s metadata value in the request", pb.UpdateTokenMetadataKey)
   115  
   116  	case len(token) > 1:
   117  		return "", appstatus.Errorf(codes.InvalidArgument, "expected exactly one %s metadata value, got %d", pb.UpdateTokenMetadataKey, len(token))
   118  
   119  	default:
   120  		return token[0], nil
   121  	}
   122  }
   123  
   124  // rowOfInvocation returns Invocation row values to be inserted to create the
   125  // invocation.
   126  // inv.CreateTime is ignored in favor of spanner.CommitTime.
   127  func (s *recorderServer) rowOfInvocation(ctx context.Context, inv *pb.Invocation, createRequestID string) map[string]any {
   128  	now := clock.Now(ctx).UTC()
   129  	row := map[string]any{
   130  		"InvocationId": invocations.MustParseName(inv.Name),
   131  		"ShardId":      mathrand.Intn(ctx, invocations.Shards),
   132  		"State":        inv.State,
   133  		"Realm":        inv.Realm,
   134  		"CreatedBy":    inv.CreatedBy,
   135  
   136  		"InvocationExpirationTime":          now.Add(invocationExpirationDuration),
   137  		"ExpectedTestResultsExpirationTime": now.Add(s.ExpectedResultsExpiration),
   138  
   139  		"CreateTime": spanner.CommitTimestamp,
   140  		"Deadline":   inv.Deadline,
   141  
   142  		"Tags":             inv.Tags,
   143  		"ProducerResource": inv.ProducerResource,
   144  		"BigQueryExports":  inv.BigqueryExports,
   145  		"Properties":       spanutil.Compressed(pbutil.MustMarshal(inv.Properties)),
   146  		"InheritSources":   spanner.NullBool{Valid: inv.SourceSpec != nil, Bool: inv.SourceSpec.GetInherit()},
   147  		"Sources":          spanutil.Compressed(pbutil.MustMarshal(inv.SourceSpec.GetSources())),
   148  		"BaselineId":       inv.BaselineId,
   149  	}
   150  
   151  	if inv.State == pb.Invocation_FINALIZED {
   152  		// We are ignoring the provided inv.FinalizeTime because it would not
   153  		// make sense to have an invocation finalized before it was created,
   154  		// yet attempting to set this in the future would fail the sql schema
   155  		// restriction for columns that allow commit timestamp.
   156  		// Note this function is only used for setting FinalizeTime by derive
   157  		// invocation, which is planned to be superseded by other mechanisms.
   158  		row["FinalizeTime"] = spanner.CommitTimestamp
   159  	}
   160  
   161  	if createRequestID != "" {
   162  		row["CreateRequestId"] = createRequestID
   163  	}
   164  
   165  	return row
   166  }