| // Copyright 2022 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 bbfake |
| |
| import ( |
| "context" |
| "fmt" |
| "sort" |
| "strconv" |
| "strings" |
| |
| "google.golang.org/grpc" |
| "google.golang.org/grpc/codes" |
| "google.golang.org/grpc/status" |
| |
| "google.golang.org/protobuf/encoding/protojson" |
| "google.golang.org/protobuf/proto" |
| "google.golang.org/protobuf/reflect/protoreflect" |
| "google.golang.org/protobuf/types/known/structpb" |
| "google.golang.org/protobuf/types/known/timestamppb" |
| |
| "go.chromium.org/luci/auth/identity" |
| "go.chromium.org/luci/buildbucket/appengine/model" |
| bbpb "go.chromium.org/luci/buildbucket/proto" |
| bbutil "go.chromium.org/luci/buildbucket/protoutil" |
| "go.chromium.org/luci/common/clock" |
| "go.chromium.org/luci/common/data/stringset" |
| |
| "go.chromium.org/luci/cv/internal/buildbucket" |
| ) |
| |
| type clientFactory struct { |
| fake *Fake |
| } |
| |
| // MakeClient implements buildbucket.ClientFactory. |
| func (factory clientFactory) MakeClient(ctx context.Context, host, luciProject string) (buildbucket.Client, error) { |
| return &Client{ |
| fa: factory.fake.ensureApp(host), |
| luciProject: luciProject, |
| }, nil |
| } |
| |
| // Client connects a Buildbucket Fake and scope to a certain LUCI Project + |
| // Buildbucket host. |
| type Client struct { |
| fa *fakeApp |
| luciProject string |
| } |
| |
| // GetBuild implements buildbucket.Client. |
| func (c *Client) GetBuild(ctx context.Context, in *bbpb.GetBuildRequest, opts ...grpc.CallOption) (*bbpb.Build, error) { |
| switch { |
| case in.GetBuilder() != nil || in.GetBuildNumber() != 0: |
| return nil, status.Errorf(codes.Unimplemented, "GetBuild by builder+number is not supported") |
| case in.GetId() == 0: |
| return nil, status.Errorf(codes.InvalidArgument, "requested build id is 0") |
| } |
| |
| switch build := c.fa.getBuild(in.GetId()); { |
| case build == nil: |
| fallthrough |
| case !c.canAccessBuild(build): |
| projIdentity := identity.Identity(fmt.Sprintf("%s:%s", identity.Project, c.luciProject)) |
| return nil, status.Errorf(codes.NotFound, "requested resource not found or %q does not have permission to view it", projIdentity) |
| default: |
| if err := applyMask(build, in.GetMask()); err != nil { |
| return nil, err |
| } |
| return build, nil |
| } |
| } |
| |
| var supportedPredicates = stringset.NewFromSlice( |
| "gerrit_changes", |
| "include_experimental", |
| ) |
| |
| const defaultSearchPageSize = 5 |
| |
| // SearchBuilds implements buildbucket.Client. |
| // |
| // Support paging and the following predicates: |
| // * gerrit_changes |
| // * include_experimental |
| // |
| // Use `defaultSearchPageSize` if page size is not specified in the input. |
| func (c *Client) SearchBuilds(ctx context.Context, in *bbpb.SearchBuildsRequest, opts ...grpc.CallOption) (*bbpb.SearchBuildsResponse, error) { |
| if in.GetPredicate() != nil { |
| var notSupportedPredicates []string |
| in.GetPredicate().ProtoReflect().Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { |
| if v.IsValid() && !supportedPredicates.Has(string(fd.Name())) { |
| notSupportedPredicates = append(notSupportedPredicates, string(fd.Name())) |
| } |
| return true |
| }) |
| if len(notSupportedPredicates) > 0 { |
| return nil, status.Errorf(codes.InvalidArgument, "predicates [%s] are not supported", strings.Join(notSupportedPredicates, ", ")) |
| } |
| } |
| var lastReturnedBuildID int64 |
| if token := in.GetPageToken(); token != "" { |
| var err error |
| lastReturnedBuildID, err = strconv.ParseInt(token, 10, 64) |
| if err != nil { |
| return nil, status.Errorf(codes.InvalidArgument, "invalid token %q, expecting a build ID", token) |
| } |
| } |
| candidates := make([]*bbpb.Build, 0, len(c.fa.buildStore)) |
| c.fa.iterBuildStore(func(build *bbpb.Build) { |
| candidates = append(candidates, build) |
| }) |
| sort.Slice(candidates, func(i, j int) bool { |
| return candidates[i].Id < candidates[j].Id |
| }) |
| pageSize := in.GetPageSize() |
| if pageSize == 0 { |
| pageSize = defaultSearchPageSize |
| } |
| resBuilds := make([]*bbpb.Build, 0, pageSize) |
| for _, b := range candidates { |
| if c.shouldIncludeBuild(b, in.GetPredicate(), lastReturnedBuildID) { |
| if err := applyMask(b, in.GetMask()); err != nil { |
| return nil, err |
| } |
| resBuilds = append(resBuilds, b) |
| if len(resBuilds) == int(pageSize) { |
| return &bbpb.SearchBuildsResponse{ |
| Builds: resBuilds, |
| NextPageToken: strconv.FormatInt(b.Id, 10), |
| }, nil |
| } |
| } |
| } |
| return &bbpb.SearchBuildsResponse{Builds: resBuilds}, nil |
| } |
| |
| func (c *Client) shouldIncludeBuild(b *bbpb.Build, pred *bbpb.BuildPredicate, lastReturnedBuildID int64) bool { |
| switch { |
| case b.GetId() <= lastReturnedBuildID: |
| return false |
| case !c.canAccessBuild(b): |
| return false |
| case !pred.GetIncludeExperimental() && b.GetInput().GetExperimental(): |
| return false |
| case len(pred.GetGerritChanges()) > 0: |
| gcs := stringset.New(len(b.GetInput().GetGerritChanges())) |
| for _, gc := range b.GetInput().GetGerritChanges() { |
| gcs.Add(fmt.Sprintf("%s/%s/%d/%d", gc.GetHost(), gc.GetProject(), gc.GetChange(), gc.GetPatchset())) |
| } |
| for _, gc := range pred.GetGerritChanges() { |
| if !gcs.Has(fmt.Sprintf("%s/%s/%d/%d", gc.GetHost(), gc.GetProject(), gc.GetChange(), gc.GetPatchset())) { |
| return false |
| } |
| } |
| } |
| return true |
| } |
| |
| // CancelBuild implements buildbucket.Client. |
| func (c *Client) CancelBuild(ctx context.Context, in *bbpb.CancelBuildRequest, opts ...grpc.CallOption) (*bbpb.Build, error) { |
| if in.GetId() == 0 { |
| return nil, status.Errorf(codes.InvalidArgument, "requested build id is 0") |
| } |
| var noAccess bool |
| updatedBuild := c.fa.updateBuild(in.GetId(), func(build *bbpb.Build) { |
| switch { |
| case !c.canAccessBuild(build): |
| noAccess = true |
| case bbutil.IsEnded(build.GetStatus()): |
| // noop on ended build |
| default: |
| build.Status = bbpb.Status_CANCELED |
| now := timestamppb.New(clock.Now(ctx).UTC()) |
| if build.GetStartTime() == nil { |
| build.StartTime = now |
| } |
| build.EndTime = now |
| build.UpdateTime = now |
| build.SummaryMarkdown = in.GetSummaryMarkdown() |
| } |
| }) |
| |
| switch { |
| case updatedBuild == nil: // build not found |
| fallthrough |
| case noAccess: |
| projIdentity := identity.Identity(fmt.Sprintf("%s:%s", identity.Project, c.luciProject)) |
| return nil, status.Errorf(codes.NotFound, "requested resource not found or %q does not have permission to modify it", projIdentity) |
| default: |
| if err := applyMask(updatedBuild, in.GetMask()); err != nil { |
| return nil, err |
| } |
| return updatedBuild, nil |
| } |
| } |
| |
| var supportedScheduleArguments = stringset.NewFromSlice( |
| "request_id", |
| "builder", |
| "properties", |
| "gerrit_changes", |
| "tags", |
| "mask", |
| ) |
| |
| func (c *Client) scheduleBuild(ctx context.Context, in *bbpb.ScheduleBuildRequest) (*bbpb.Build, error) { |
| var notSupportedArguments []string |
| in.ProtoReflect().Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { |
| if v.IsValid() && !supportedScheduleArguments.Has(string(fd.Name())) { |
| notSupportedArguments = append(notSupportedArguments, string(fd.Name())) |
| } |
| return true |
| }) |
| if len(notSupportedArguments) > 0 { |
| return nil, status.Errorf(codes.InvalidArgument, "schedule arguments [%s] are not supported", strings.Join(notSupportedArguments, ", ")) |
| } |
| |
| if build := c.fa.findDupRequest(ctx, in.GetRequestId()); build != nil { |
| if err := applyMask(build, in.GetMask()); err != nil { |
| return nil, err |
| } |
| return build, nil |
| } |
| |
| builderID := in.GetBuilder() |
| if builderID == nil { |
| return nil, status.Errorf(codes.InvalidArgument, "requested builder is empty") |
| } |
| builderCfg := c.fa.loadBuilderCfg(builderID) |
| if builderCfg == nil { |
| return nil, status.Errorf(codes.NotFound, "builder %s not found", bbutil.FormatBuilderID(builderID)) |
| } |
| inputProps, err := mkInputProps(builderCfg, in.GetProperties()) |
| if err != nil { |
| return nil, err |
| } |
| now := timestamppb.New(clock.Now(ctx)) |
| build := &bbpb.Build{ |
| Builder: builderID, |
| Status: bbpb.Status_SCHEDULED, |
| CreateTime: now, |
| UpdateTime: now, |
| Input: &bbpb.Build_Input{ |
| Properties: inputProps, |
| GerritChanges: in.GetGerritChanges(), |
| }, |
| Infra: &bbpb.BuildInfra{ |
| Buildbucket: &bbpb.BuildInfra_Buildbucket{ |
| RequestedProperties: in.GetProperties(), |
| Hostname: c.fa.hostname, |
| }, |
| }, |
| Tags: in.GetTags(), |
| } |
| c.fa.insertBuild(ctx, build, in.GetRequestId()) |
| if err := applyMask(build, in.GetMask()); err != nil { |
| return nil, err |
| } |
| return build, nil |
| } |
| |
| func mkInputProps(builderCfg *bbpb.BuilderConfig, requestedProps *structpb.Struct) (*structpb.Struct, error) { |
| var ret *structpb.Struct |
| if builderProps := builderCfg.GetProperties(); builderProps != "" { |
| ret = &structpb.Struct{} |
| if err := protojson.Unmarshal([]byte(builderProps), ret); err != nil { |
| return nil, status.Errorf(codes.Internal, "failed to unmarshal properties: %s", builderProps) |
| } |
| } |
| if requestedProps != nil { |
| if ret == nil { |
| return requestedProps, nil |
| } |
| proto.Merge(ret, requestedProps) |
| } |
| return ret, nil |
| } |
| |
| // Batch implements buildbucket.Client. |
| // |
| // Supports: |
| // * CancelBuild |
| // * GetBuild |
| // * ScheduleBuild |
| func (c *Client) Batch(ctx context.Context, in *bbpb.BatchRequest, opts ...grpc.CallOption) (*bbpb.BatchResponse, error) { |
| responses := make([]*bbpb.BatchResponse_Response, len(in.GetRequests())) |
| for i, req := range in.GetRequests() { |
| res := &bbpb.BatchResponse_Response{} |
| switch req.GetRequest().(type) { |
| case *bbpb.BatchRequest_Request_CancelBuild: |
| if b, err := c.CancelBuild(ctx, req.GetCancelBuild()); err != nil { |
| res.Response = &bbpb.BatchResponse_Response_Error{ |
| Error: status.Convert(err).Proto(), |
| } |
| } else { |
| res.Response = &bbpb.BatchResponse_Response_CancelBuild{ |
| CancelBuild: b, |
| } |
| } |
| case *bbpb.BatchRequest_Request_GetBuild: |
| if b, err := c.GetBuild(ctx, req.GetGetBuild()); err != nil { |
| res.Response = &bbpb.BatchResponse_Response_Error{ |
| Error: status.Convert(err).Proto(), |
| } |
| } else { |
| res.Response = &bbpb.BatchResponse_Response_GetBuild{ |
| GetBuild: b, |
| } |
| } |
| case *bbpb.BatchRequest_Request_ScheduleBuild: |
| if b, err := c.scheduleBuild(ctx, req.GetScheduleBuild()); err != nil { |
| res.Response = &bbpb.BatchResponse_Response_Error{ |
| Error: status.Convert(err).Proto(), |
| } |
| } else { |
| res.Response = &bbpb.BatchResponse_Response_ScheduleBuild{ |
| ScheduleBuild: b, |
| } |
| } |
| default: |
| return nil, status.Errorf(codes.Unimplemented, "batch request type: %T is not supported", req.GetRequest()) |
| } |
| responses[i] = res |
| } |
| return &bbpb.BatchResponse{ |
| Responses: responses, |
| }, nil |
| } |
| |
| func (c *Client) canAccessBuild(build *bbpb.Build) bool { |
| // TODO(yiwzhang): implement proper ACL |
| return c.luciProject == build.GetBuilder().GetProject() |
| } |
| |
| func applyMask(build *bbpb.Build, bm *bbpb.BuildMask) error { |
| mask, err := model.NewBuildMask("", nil, bm) |
| if err != nil { |
| return status.Errorf(codes.Internal, "error while constructing BuildMask: %s", err) |
| } |
| if err := mask.Trim(build); err != nil { |
| return status.Errorf(codes.Internal, "error while applying field mask: %s", err) |
| } |
| return nil |
| } |