blob: 11fc5e3c48f5e0995323de87b59dee2c293c98ea [file] [log] [blame]
// Copyright 2019 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 recorder
import (
"context"
"time"
"cloud.google.com/go/spanner"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/data/rand/mathrand"
"go.chromium.org/luci/grpc/appstatus"
"go.chromium.org/luci/server/span"
"go.chromium.org/luci/server/tokens"
"go.chromium.org/luci/resultdb/internal/invocations"
"go.chromium.org/luci/resultdb/internal/spanutil"
"go.chromium.org/luci/resultdb/pbutil"
pb "go.chromium.org/luci/resultdb/proto/v1"
)
const (
day = 24 * time.Hour
// Delete Invocations row after this duration since invocation creation.
invocationExpirationDuration = 2 * 365 * day // 2 y
// By default, finalize the invocation 2d after creation if it is still
// incomplete.
defaultInvocationDeadlineDuration = 2 * day
// The maximum amount of time for an invocation.
// This is the same as the BUILD_TIMEOUT
// https://source.chromium.org/chromium/infra/infra/+/main:appengine/cr-buildbucket/model.py;l=28;drc=e6d97dc362dd4a412fc7b07da0c4df53f2940a80
// https://source.chromium.org/chromium/infra/infra/+/main:go/src/go.chromium.org/luci/buildbucket/appengine/model/build.go;l=53
maxInvocationDeadlineDuration = 5 * day
)
// invocationTokenKind generates and validates tokens issued to authorize
// updating a given invocation.
var invocationTokenKind = tokens.TokenKind{
Algo: tokens.TokenAlgoHmacSHA256,
SecretKey: "invocation_tokens_secret",
Version: 1,
}
// generateInvocationToken generates an update token for a given invocation.
func generateInvocationToken(ctx context.Context, invID invocations.ID) (string, error) {
// The token should last as long as a build is allowed to run.
// Buildbucket has a max of 2 days, so one week should be enough even
// for other use cases.
return invocationTokenKind.Generate(ctx, []byte(invID), nil, 7*day) // One week.
}
// validateInvocationToken validates an update token for a given invocation,
// returning an error if the token is invalid, nil otherwise.
func validateInvocationToken(ctx context.Context, token string, invID invocations.ID) error {
_, err := invocationTokenKind.Validate(ctx, token, []byte(invID))
return err
}
// mutateInvocation checks if the invocation can be mutated and also
// finalizes the invocation if it's deadline is exceeded.
// If the invocation is active, continue with the other mutation(s) in f.
func mutateInvocation(ctx context.Context, id invocations.ID, f func(context.Context) error) error {
var retErr error
token, err := extractUpdateToken(ctx)
if err != nil {
return err
}
if err := validateInvocationToken(ctx, token, id); err != nil {
return appstatus.Errorf(codes.PermissionDenied, "invalid update token")
}
_, err = span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
state, err := invocations.ReadState(ctx, id)
switch {
case err != nil:
return err
case state != pb.Invocation_ACTIVE:
return appstatus.Errorf(codes.FailedPrecondition, "%s is not active", id.Name())
}
return f(ctx)
})
if err != nil {
retErr = err
}
return retErr
}
func extractUpdateToken(ctx context.Context) (string, error) {
md, _ := metadata.FromIncomingContext(ctx)
token := md.Get(pb.UpdateTokenMetadataKey)
switch {
case len(token) == 0:
return "", appstatus.Errorf(codes.Unauthenticated, "missing %s metadata value in the request", pb.UpdateTokenMetadataKey)
case len(token) > 1:
return "", appstatus.Errorf(codes.InvalidArgument, "expected exactly one %s metadata value, got %d", pb.UpdateTokenMetadataKey, len(token))
default:
return token[0], nil
}
}
// rowOfInvocation returns Invocation row values to be inserted to create the
// invocation.
// inv.CreateTime, inv.FinalizeStartTime and inv.FinalizeTime are ignored
// and set by the implementation to spanner.CommitTime as appropriate.
func (s *recorderServer) rowOfInvocation(ctx context.Context, inv *pb.Invocation, createRequestID string) map[string]any {
now := clock.Now(ctx).UTC()
row := map[string]any{
"InvocationId": invocations.MustParseName(inv.Name),
"ShardId": mathrand.Intn(ctx, invocations.Shards),
"State": inv.State,
"Realm": inv.Realm,
"CreatedBy": inv.CreatedBy,
"InvocationExpirationTime": now.Add(invocationExpirationDuration),
"ExpectedTestResultsExpirationTime": now.Add(s.ExpectedResultsExpiration),
"CreateTime": spanner.CommitTimestamp,
"Deadline": inv.Deadline,
"Tags": inv.Tags,
"ProducerResource": inv.ProducerResource,
"IsExportRoot": spanner.NullBool{Valid: inv.IsExportRoot, Bool: inv.IsExportRoot},
"BigQueryExports": inv.BigqueryExports,
"Properties": spanutil.Compressed(pbutil.MustMarshal(inv.Properties)),
"InheritSources": spanner.NullBool{Valid: inv.SourceSpec != nil, Bool: inv.SourceSpec.GetInherit()},
"Sources": spanutil.Compressed(pbutil.MustMarshal(inv.SourceSpec.GetSources())),
"IsSourceSpecFinal": spanner.NullBool{Valid: inv.IsSourceSpecFinal, Bool: inv.IsSourceSpecFinal},
"BaselineId": inv.BaselineId,
"TestInstruction": spanutil.Compressed(pbutil.MustMarshal(inv.TestInstruction)),
"StepInstructions": spanutil.Compressed(pbutil.MustMarshal(inv.StepInstructions)),
}
if inv.State == pb.Invocation_FINALIZING || inv.State == pb.Invocation_FINALIZED {
// Invocation immediately transitioning to finalizing/finalized.
row["FinalizeStartTime"] = spanner.CommitTimestamp
}
if inv.State == pb.Invocation_FINALIZED {
// Invocation immediately finalized.
row["FinalizeTime"] = spanner.CommitTimestamp
}
if createRequestID != "" {
row["CreateRequestId"] = createRequestID
}
return row
}