go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/buildbucket/fake/client.go (about)

     1  // Copyright 2022 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 bbfake
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"sort"
    21  	"strconv"
    22  	"strings"
    23  
    24  	"google.golang.org/grpc"
    25  	"google.golang.org/grpc/codes"
    26  	"google.golang.org/grpc/status"
    27  
    28  	"google.golang.org/protobuf/encoding/protojson"
    29  	"google.golang.org/protobuf/proto"
    30  	"google.golang.org/protobuf/reflect/protoreflect"
    31  	"google.golang.org/protobuf/types/known/structpb"
    32  	"google.golang.org/protobuf/types/known/timestamppb"
    33  
    34  	"go.chromium.org/luci/auth/identity"
    35  	"go.chromium.org/luci/buildbucket/appengine/model"
    36  	bbpb "go.chromium.org/luci/buildbucket/proto"
    37  	bbutil "go.chromium.org/luci/buildbucket/protoutil"
    38  	"go.chromium.org/luci/common/clock"
    39  	"go.chromium.org/luci/common/data/stringset"
    40  
    41  	"go.chromium.org/luci/cv/internal/buildbucket"
    42  )
    43  
    44  type clientFactory struct {
    45  	fake *Fake
    46  }
    47  
    48  // MakeClient implements buildbucket.ClientFactory.
    49  func (factory clientFactory) MakeClient(ctx context.Context, host, luciProject string) (buildbucket.Client, error) {
    50  	return &Client{
    51  		fa:          factory.fake.ensureApp(host),
    52  		luciProject: luciProject,
    53  	}, nil
    54  }
    55  
    56  // Client connects a Buildbucket Fake and scope to a certain LUCI Project +
    57  // Buildbucket host.
    58  type Client struct {
    59  	fa          *fakeApp
    60  	luciProject string
    61  }
    62  
    63  // GetBuild implements buildbucket.Client.
    64  func (c *Client) GetBuild(ctx context.Context, in *bbpb.GetBuildRequest, opts ...grpc.CallOption) (*bbpb.Build, error) {
    65  	switch {
    66  	case in.GetBuilder() != nil || in.GetBuildNumber() != 0:
    67  		return nil, status.Errorf(codes.Unimplemented, "GetBuild by builder+number is not supported")
    68  	case in.GetId() == 0:
    69  		return nil, status.Errorf(codes.InvalidArgument, "requested build id is 0")
    70  	}
    71  
    72  	switch build := c.fa.getBuild(in.GetId()); {
    73  	case build == nil:
    74  		fallthrough
    75  	case !c.canAccessBuild(build):
    76  		projIdentity := identity.Identity(fmt.Sprintf("%s:%s", identity.Project, c.luciProject))
    77  		return nil, status.Errorf(codes.NotFound, "requested resource not found or %q does not have permission to view it", projIdentity)
    78  	default:
    79  		if err := applyMask(build, in.GetMask()); err != nil {
    80  			return nil, err
    81  		}
    82  		return build, nil
    83  	}
    84  }
    85  
    86  var supportedPredicates = stringset.NewFromSlice(
    87  	"gerrit_changes",
    88  	"include_experimental",
    89  )
    90  
    91  const defaultSearchPageSize = 5
    92  
    93  // SearchBuilds implements buildbucket.Client.
    94  //
    95  // Support paging and the following predicates:
    96  //   - gerrit_changes
    97  //   - include_experimental
    98  //
    99  // Use `defaultSearchPageSize` if page size is not specified in the input.
   100  func (c *Client) SearchBuilds(ctx context.Context, in *bbpb.SearchBuildsRequest, opts ...grpc.CallOption) (*bbpb.SearchBuildsResponse, error) {
   101  	if in.GetPredicate() != nil {
   102  		var notSupportedPredicates []string
   103  		in.GetPredicate().ProtoReflect().Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool {
   104  			if v.IsValid() && !supportedPredicates.Has(string(fd.Name())) {
   105  				notSupportedPredicates = append(notSupportedPredicates, string(fd.Name()))
   106  			}
   107  			return true
   108  		})
   109  		if len(notSupportedPredicates) > 0 {
   110  			return nil, status.Errorf(codes.InvalidArgument, "predicates [%s] are not supported", strings.Join(notSupportedPredicates, ", "))
   111  		}
   112  	}
   113  	var lastReturnedBuildID int64
   114  	if token := in.GetPageToken(); token != "" {
   115  		var err error
   116  		lastReturnedBuildID, err = strconv.ParseInt(token, 10, 64)
   117  		if err != nil {
   118  			return nil, status.Errorf(codes.InvalidArgument, "invalid token %q, expecting a build ID", token)
   119  		}
   120  	}
   121  	var candidates []*bbpb.Build
   122  	c.fa.iterBuildStore(func(build *bbpb.Build) {
   123  		candidates = append(candidates, build)
   124  	})
   125  	sort.Slice(candidates, func(i, j int) bool {
   126  		return candidates[i].Id < candidates[j].Id
   127  	})
   128  	pageSize := in.GetPageSize()
   129  	if pageSize == 0 {
   130  		pageSize = defaultSearchPageSize
   131  	}
   132  	resBuilds := make([]*bbpb.Build, 0, pageSize)
   133  	for _, b := range candidates {
   134  		if c.shouldIncludeBuild(b, in.GetPredicate(), lastReturnedBuildID) {
   135  			if err := applyMask(b, in.GetMask()); err != nil {
   136  				return nil, err
   137  			}
   138  			resBuilds = append(resBuilds, b)
   139  			if len(resBuilds) == int(pageSize) {
   140  				return &bbpb.SearchBuildsResponse{
   141  					Builds:        resBuilds,
   142  					NextPageToken: strconv.FormatInt(b.Id, 10),
   143  				}, nil
   144  			}
   145  		}
   146  	}
   147  	return &bbpb.SearchBuildsResponse{Builds: resBuilds}, nil
   148  }
   149  
   150  func (c *Client) shouldIncludeBuild(b *bbpb.Build, pred *bbpb.BuildPredicate, lastReturnedBuildID int64) bool {
   151  	switch {
   152  	case b.GetId() <= lastReturnedBuildID:
   153  		return false
   154  	case !c.canAccessBuild(b):
   155  		return false
   156  	case !pred.GetIncludeExperimental() && b.GetInput().GetExperimental():
   157  		return false
   158  	case len(pred.GetGerritChanges()) > 0:
   159  		gcs := stringset.New(len(b.GetInput().GetGerritChanges()))
   160  		for _, gc := range b.GetInput().GetGerritChanges() {
   161  			gcs.Add(fmt.Sprintf("%s/%s/%d/%d", gc.GetHost(), gc.GetProject(), gc.GetChange(), gc.GetPatchset()))
   162  		}
   163  		for _, gc := range pred.GetGerritChanges() {
   164  			if !gcs.Has(fmt.Sprintf("%s/%s/%d/%d", gc.GetHost(), gc.GetProject(), gc.GetChange(), gc.GetPatchset())) {
   165  				return false
   166  			}
   167  		}
   168  	}
   169  	return true
   170  }
   171  
   172  // CancelBuild implements buildbucket.Client.
   173  func (c *Client) CancelBuild(ctx context.Context, in *bbpb.CancelBuildRequest, opts ...grpc.CallOption) (*bbpb.Build, error) {
   174  	if in.GetId() == 0 {
   175  		return nil, status.Errorf(codes.InvalidArgument, "requested build id is 0")
   176  	}
   177  	var noAccess bool
   178  	var updatedBuild *bbpb.Build
   179  	if build := c.fa.getBuild(in.GetId()); build == nil {
   180  		noAccess = true
   181  	} else {
   182  		updatedBuild = c.fa.updateBuild(ctx, in.GetId(), func(build *bbpb.Build) {
   183  			switch {
   184  			case !c.canAccessBuild(build):
   185  				noAccess = true
   186  			case bbutil.IsEnded(build.GetStatus()):
   187  				// noop on ended build
   188  			default:
   189  				build.Status = bbpb.Status_CANCELED
   190  				now := timestamppb.New(clock.Now(ctx).UTC())
   191  				if build.GetStartTime() == nil {
   192  					build.StartTime = now
   193  				}
   194  				build.EndTime = now
   195  				build.UpdateTime = now
   196  				build.SummaryMarkdown = in.GetSummaryMarkdown()
   197  			}
   198  		})
   199  	}
   200  
   201  	if noAccess {
   202  		projIdentity := identity.Identity(fmt.Sprintf("%s:%s", identity.Project, c.luciProject))
   203  		return nil, status.Errorf(codes.NotFound, "requested resource not found or %q does not have permission to modify it", projIdentity)
   204  	}
   205  
   206  	if err := applyMask(updatedBuild, in.GetMask()); err != nil {
   207  		return nil, err
   208  	}
   209  	return updatedBuild, nil
   210  }
   211  
   212  var supportedScheduleArguments = stringset.NewFromSlice(
   213  	"request_id",
   214  	"builder",
   215  	"properties",
   216  	"gerrit_changes",
   217  	"tags",
   218  	"experiments",
   219  	"mask",
   220  )
   221  
   222  // ScheduleBuild schedules a new build for the provided builder.
   223  //
   224  // The builder should be present in buildbucket fake. It can be added via
   225  // AddBuilder function.
   226  func (c *Client) ScheduleBuild(ctx context.Context, in *bbpb.ScheduleBuildRequest) (*bbpb.Build, error) {
   227  	var notSupportedArguments []string
   228  	in.ProtoReflect().Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool {
   229  		if v.IsValid() && !supportedScheduleArguments.Has(string(fd.Name())) {
   230  			notSupportedArguments = append(notSupportedArguments, string(fd.Name()))
   231  		}
   232  		return true
   233  	})
   234  	if len(notSupportedArguments) > 0 {
   235  		return nil, status.Errorf(codes.InvalidArgument, "schedule arguments [%s] are not supported", strings.Join(notSupportedArguments, ", "))
   236  	}
   237  
   238  	if build := c.fa.findDupRequest(ctx, in.GetRequestId()); build != nil {
   239  		if err := applyMask(build, in.GetMask()); err != nil {
   240  			return nil, err
   241  		}
   242  		return build, nil
   243  	}
   244  
   245  	builderID := in.GetBuilder()
   246  	if builderID == nil {
   247  		return nil, status.Errorf(codes.InvalidArgument, "requested builder is empty")
   248  	}
   249  	builderCfg := c.fa.loadBuilderCfg(builderID)
   250  	if builderCfg == nil {
   251  		return nil, status.Errorf(codes.NotFound, "builder %s not found", bbutil.FormatBuilderID(builderID))
   252  	}
   253  	inputProps, err := mkInputProps(builderCfg, in.GetProperties())
   254  	if err != nil {
   255  		return nil, err
   256  	}
   257  	now := timestamppb.New(clock.Now(ctx))
   258  	build := &bbpb.Build{
   259  		Builder:    builderID,
   260  		Status:     bbpb.Status_SCHEDULED,
   261  		CreateTime: now,
   262  		UpdateTime: now,
   263  		Input: &bbpb.Build_Input{
   264  			Properties:    inputProps,
   265  			GerritChanges: in.GetGerritChanges(),
   266  		},
   267  		Infra: &bbpb.BuildInfra{
   268  			Buildbucket: &bbpb.BuildInfra_Buildbucket{
   269  				RequestedProperties: in.GetProperties(),
   270  				Hostname:            c.fa.hostname,
   271  			},
   272  		},
   273  		Tags: in.GetTags(),
   274  	}
   275  
   276  	if len(in.GetExperiments()) > 0 {
   277  		experiments := make(sort.StringSlice, 0, len(in.GetExperiments()))
   278  		for exp, enabled := range in.GetExperiments() {
   279  			if enabled {
   280  				experiments = append(experiments, exp)
   281  			}
   282  		}
   283  		experiments.Sort()
   284  		if len(experiments) > 0 {
   285  			build.Input.Experiments = experiments
   286  		}
   287  	}
   288  
   289  	c.fa.insertBuild(ctx, build, in.GetRequestId())
   290  	if err := applyMask(build, in.GetMask()); err != nil {
   291  		return nil, err
   292  	}
   293  	return build, nil
   294  }
   295  
   296  func mkInputProps(builderCfg *bbpb.BuilderConfig, requestedProps *structpb.Struct) (*structpb.Struct, error) {
   297  	var ret *structpb.Struct
   298  	if builderProps := builderCfg.GetProperties(); builderProps != "" {
   299  		ret = &structpb.Struct{}
   300  		if err := protojson.Unmarshal([]byte(builderProps), ret); err != nil {
   301  			return nil, status.Errorf(codes.Internal, "failed to unmarshal properties: %s", builderProps)
   302  		}
   303  	}
   304  	if requestedProps != nil {
   305  		if ret == nil {
   306  			return requestedProps, nil
   307  		}
   308  		proto.Merge(ret, requestedProps)
   309  	}
   310  	return ret, nil
   311  }
   312  
   313  // Batch implements buildbucket.Client.
   314  //
   315  // Supports:
   316  //   - CancelBuild
   317  //   - GetBuild
   318  //   - ScheduleBuild
   319  func (c *Client) Batch(ctx context.Context, in *bbpb.BatchRequest, opts ...grpc.CallOption) (*bbpb.BatchResponse, error) {
   320  	responses := make([]*bbpb.BatchResponse_Response, len(in.GetRequests()))
   321  	for i, req := range in.GetRequests() {
   322  		res := &bbpb.BatchResponse_Response{}
   323  		switch req.GetRequest().(type) {
   324  		case *bbpb.BatchRequest_Request_CancelBuild:
   325  			if b, err := c.CancelBuild(ctx, req.GetCancelBuild()); err != nil {
   326  				res.Response = &bbpb.BatchResponse_Response_Error{
   327  					Error: status.Convert(err).Proto(),
   328  				}
   329  			} else {
   330  				res.Response = &bbpb.BatchResponse_Response_CancelBuild{
   331  					CancelBuild: b,
   332  				}
   333  			}
   334  		case *bbpb.BatchRequest_Request_GetBuild:
   335  			if b, err := c.GetBuild(ctx, req.GetGetBuild()); err != nil {
   336  				res.Response = &bbpb.BatchResponse_Response_Error{
   337  					Error: status.Convert(err).Proto(),
   338  				}
   339  			} else {
   340  				res.Response = &bbpb.BatchResponse_Response_GetBuild{
   341  					GetBuild: b,
   342  				}
   343  			}
   344  		case *bbpb.BatchRequest_Request_ScheduleBuild:
   345  			if b, err := c.ScheduleBuild(ctx, req.GetScheduleBuild()); err != nil {
   346  				res.Response = &bbpb.BatchResponse_Response_Error{
   347  					Error: status.Convert(err).Proto(),
   348  				}
   349  			} else {
   350  				res.Response = &bbpb.BatchResponse_Response_ScheduleBuild{
   351  					ScheduleBuild: b,
   352  				}
   353  			}
   354  		default:
   355  			return nil, status.Errorf(codes.Unimplemented, "batch request type: %T is not supported", req.GetRequest())
   356  		}
   357  		responses[i] = res
   358  	}
   359  	return &bbpb.BatchResponse{
   360  		Responses: responses,
   361  	}, nil
   362  }
   363  
   364  func (c *Client) canAccessBuild(build *bbpb.Build) bool {
   365  	// TODO(yiwzhang): implement proper ACL
   366  	return c.luciProject == build.GetBuilder().GetProject()
   367  }
   368  
   369  func applyMask(build *bbpb.Build, bm *bbpb.BuildMask) error {
   370  	mask, err := model.NewBuildMask("", nil, bm)
   371  	if err != nil {
   372  		return status.Errorf(codes.Internal, "error while constructing BuildMask: %s", err)
   373  	}
   374  	if err := mask.Trim(build); err != nil {
   375  		return status.Errorf(codes.Internal, "error while applying field mask: %s", err)
   376  	}
   377  	return nil
   378  }