blob: 0980352af58d71fe044679757731518cbede7af1 [file] [log] [blame]
// Copyright 2023 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 resultdb
import (
"context"
"cloud.google.com/go/spanner"
"go.chromium.org/luci/grpc/grpcutil"
"google.golang.org/grpc/codes"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/grpc/appstatus"
"go.chromium.org/luci/resultdb/internal/baselines"
"go.chromium.org/luci/resultdb/internal/invocations"
"go.chromium.org/luci/resultdb/internal/invocations/graph"
"go.chromium.org/luci/resultdb/internal/spanutil"
"go.chromium.org/luci/resultdb/pbutil"
pb "go.chromium.org/luci/resultdb/proto/v1"
"go.chromium.org/luci/resultdb/rdbperms"
"go.chromium.org/luci/server/auth"
"go.chromium.org/luci/server/auth/realms"
"go.chromium.org/luci/server/span"
)
const noPermissionsError = "Caller does not have permission to query new test variants"
// validateQueryNewTestVariantsRequest ensures that:
// 1. Arguments are valid
// 2. The caller has permission.
// 3. The baseline is ready for use. A baseline is not ready state if the
// baseline identifier is in the Baseline table and lastUpdated is within 72 hours.
func validateQueryNewTestVariantsRequest(ctx context.Context, req *pb.QueryNewTestVariantsRequest) error {
// Invocation and BaselineID provided must be formatted correctly.
invID, err := pbutil.ParseInvocationName(req.Invocation)
if err != nil {
return appstatus.Error(codes.InvalidArgument, errors.Annotate(err, "invocation").Err().Error())
}
project, _, err := pbutil.ParseBaselineName(req.Baseline)
if err != nil {
return appstatus.Error(codes.InvalidArgument, errors.Annotate(err, "baseline").Err().Error())
}
invRealm, err := invocations.ReadRealm(ctx, invocations.ID(invID))
if err != nil {
// If the invocation does not exist, we mask the error with permission
// denied to avoid leaking resource existence.
if grpcutil.Code(err) == codes.NotFound {
return appstatus.Errorf(codes.PermissionDenied, noPermissionsError)
} else {
return err
}
}
// Caller must have "resultdb.baselines.get" and "resultdb.testResults.list"
// Context is already set to spanner read context, and permissions.VerifyInvocationByName()
// will create a nested read context which is not permitted.
switch allowed, err := auth.HasPermission(ctx, rdbperms.PermListTestResults, invRealm, nil); {
case err != nil:
return err
case !allowed:
return errors.Annotate(appstatus.Error(codes.PermissionDenied, noPermissionsError), "error1").Err()
}
baselineRealm := realms.Join(project, realms.ProjectRealm)
switch allowed, err := auth.HasPermission(ctx, rdbperms.PermGetBaseline, baselineRealm, nil); {
case err != nil:
return err
case !allowed:
return errors.Annotate(appstatus.Error(codes.PermissionDenied, noPermissionsError), "error2").Err()
}
return nil
}
func checkBaselineStatus(ctx context.Context, project, baseline string) (isReady bool, err error) {
// If the baseline identifier is in the Baselines table, ensure that lastUpdated
// is older than 72 hours.
b, err := baselines.Read(ctx, project, baseline)
if err != nil {
// If the baseline is not found, it could've been ejected from the Baseline table
// meaning that it hasn't been marked for submission in a while and should not be
// used for new test calculation.
if err == baselines.NotFound {
return false, nil
} else {
return false, err
}
}
// If it's spinning up, it's not ready.
return !b.IsSpinningUp(clock.Now(ctx)), err
}
// findNewTests calculates the difference between test variants for the baseline and test variants from
// the invocation in the request.
func findNewTests(ctx context.Context, baselineProject, baselineID string, allInvs invocations.IDSet) ([]*pb.QueryNewTestVariantsResponse_NewTestVariant, error) {
if len(allInvs) == 0 {
return nil, nil
}
// Status 5 == SKIPPED.
st := spanner.NewStatement(`
SELECT DISTINCT
tr.TestId,
tr.VariantHash,
FROM (
SELECT
TestId,
VariantHash,
Status,
FROM TestResults
WHERE Status != 5 AND InvocationId IN UNNEST(@allInvs)
) tr
LEFT JOIN (
SELECT
TestId,
VariantHash,
FROM BaselineTestVariants b
WHERE BaselineId = @baselineID AND Project = @baselineProject
) b
ON b.TestId = tr.TestId AND b.VariantHash = tr.VariantHash
WHERE b.TestId IS NULL
ORDER BY tr.TestId, tr.VariantHash
LIMIT 10000
`)
st.Params = spanutil.ToSpannerMap(map[string]any{
"baselineID": baselineID,
"baselineProject": baselineProject,
"allInvs": allInvs,
})
it := span.Query(ctx, st)
res := []*pb.QueryNewTestVariantsResponse_NewTestVariant{}
err := it.Do(func(r *spanner.Row) error {
var testID, variantHash string
err := r.Columns(&testID, &variantHash)
if err != nil {
return errors.Annotate(err, "read new test variant row").Err()
}
tv := &pb.QueryNewTestVariantsResponse_NewTestVariant{
TestId: testID,
VariantHash: variantHash,
}
res = append(res, tv)
return nil
})
return res, err
}
// findAllInvocations searches for all included invocations.
func findAllInvocations(ctx context.Context, invIDs invocations.IDSet) (invocations.IDSet, error) {
invs, err := graph.Reachable(ctx, invIDs)
if err != nil {
return nil, err
}
rInvs := invocations.NewIDSet()
for invID, inv := range invs.Invocations {
if !inv.HasTestResults {
continue
}
rInvs.Add(invID)
}
return rInvs, nil
}
// QueryNewTestVariants implements pb.ResultDBServer.
func (s *resultDBServer) QueryNewTestVariants(ctx context.Context, req *pb.QueryNewTestVariantsRequest) (*pb.QueryNewTestVariantsResponse, error) {
ctx, cancel := span.ReadOnlyTransaction(ctx)
defer cancel()
err := validateQueryNewTestVariantsRequest(ctx, req)
if err != nil {
return nil, errors.Annotate(err, "new test variants").Err()
}
project, baselineID := baselines.MustParseBaselineName(req.Baseline)
invID := invocations.MustParseName(req.Invocation)
isReady, err := checkBaselineStatus(ctx, project, baselineID)
if err != nil {
return nil, errors.Annotate(err, "baseline status").Err()
}
if !isReady {
return &pb.QueryNewTestVariantsResponse{
IsBaselineReady: false,
}, nil
}
// The response from graph.Reachable() should also include the invocation being
// passed to it.
rInvs, err := findAllInvocations(ctx, invocations.NewIDSet(invID))
if err != nil {
return nil, errors.Annotate(err, "failed to read the reachable invocations").Err()
}
nt, err := findNewTests(ctx, project, baselineID, rInvs)
if err != nil {
return nil, errors.Annotate(err, "failed to query for new test variants").Err()
}
return &pb.QueryNewTestVariantsResponse{
NewTestVariants: nt,
IsBaselineReady: isReady,
}, nil
}