go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/buildbucket/fake/fake.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 "encoding/json" 20 "fmt" 21 "math" 22 "sync" 23 "time" 24 25 "cloud.google.com/go/pubsub" 26 "google.golang.org/protobuf/proto" 27 "google.golang.org/protobuf/types/known/timestamppb" 28 29 bbpb "go.chromium.org/luci/buildbucket/proto" 30 bbutil "go.chromium.org/luci/buildbucket/protoutil" 31 "go.chromium.org/luci/common/clock" 32 "go.chromium.org/luci/common/data/stringset" 33 "go.chromium.org/luci/common/errors" 34 35 cfgpb "go.chromium.org/luci/cv/api/config/v2" 36 "go.chromium.org/luci/cv/internal/buildbucket" 37 ) 38 39 const requestDeduplicationWindow = 1 * time.Minute 40 41 type fakeApp struct { 42 hostname string 43 nextBuildID int64 // for generating monotonically decreasing build ID 44 requestCache timedMap 45 pubsubTopic *pubsub.Topic 46 buildStoreMu sync.RWMutex 47 buildStore map[int64]*bbpb.Build // build ID -> build 48 configStoreMu sync.RWMutex 49 configStore map[string]*bbpb.BuildbucketCfg // project name -> config 50 } 51 52 type Fake struct { 53 hostsMu sync.RWMutex 54 hosts map[string]*fakeApp // hostname -> fakeApp 55 } 56 57 // NewClientFactory returns a factory that creates a client for this buildbucket 58 // fake. 59 func (f *Fake) NewClientFactory() buildbucket.ClientFactory { 60 return clientFactory{ 61 fake: f, 62 } 63 } 64 65 // MustNewClient is a shorthand of `fake.NewClientFactory().MakeClient(...)`. 66 // 67 // Panics if fails to create new client. 68 func (f *Fake) MustNewClient(ctx context.Context, host, luciProject string) *Client { 69 factory := clientFactory{ 70 fake: f, 71 } 72 client, err := factory.MakeClient(ctx, host, luciProject) 73 if err != nil { 74 panic(errors.Annotate(err, "failed to create new buildbucket client").Err()) 75 } 76 return client.(*Client) 77 } 78 79 // RegisterPubsubTopic registers a pubsub topic for the given host. 80 // 81 // If a build is updated to the terminal status, a message will be sent to 82 // the topic for this build. 83 func (f *Fake) RegisterPubsubTopic(host string, topic *pubsub.Topic) { 84 fa := f.ensureApp(host) 85 fa.pubsubTopic = topic 86 } 87 88 // AddBuilder adds a new builder configuration to fake Buildbucket host. 89 // 90 // Overwrites the existing builder if the same builder already exists. 91 // `properties` should be marshallable by `encoding/json`. 92 func (f *Fake) AddBuilder(host string, builder *bbpb.BuilderID, properties any) *Fake { 93 fa := f.ensureApp(host) 94 fa.configStoreMu.Lock() 95 defer fa.configStoreMu.Unlock() 96 if _, ok := fa.configStore[builder.GetProject()]; !ok { 97 fa.configStore[builder.GetProject()] = &bbpb.BuildbucketCfg{} 98 } 99 cfg := fa.configStore[builder.GetProject()] 100 var bucket *bbpb.Bucket 101 for _, b := range cfg.GetBuckets() { 102 if b.Name == builder.GetBucket() { 103 bucket = b 104 break 105 } 106 } 107 if bucket == nil { 108 bucket = &bbpb.Bucket{ 109 Name: builder.GetBucket(), 110 Swarming: &bbpb.Swarming{}, 111 } 112 cfg.Buckets = append(cfg.GetBuckets(), bucket) 113 } 114 115 builderCfg := &bbpb.BuilderConfig{ 116 Name: builder.GetBuilder(), 117 } 118 if properties != nil { 119 bProperties, err := json.Marshal(properties) 120 if err != nil { 121 panic(err) 122 } 123 builderCfg.Properties = string(bProperties) 124 } 125 for i, b := range bucket.GetSwarming().GetBuilders() { 126 if b.Name == builder.GetBuilder() { 127 bucket.GetSwarming().GetBuilders()[i] = builderCfg 128 return f 129 } 130 } 131 bucket.GetSwarming().Builders = append(bucket.GetSwarming().GetBuilders(), builderCfg) 132 return f 133 } 134 135 // EnsureBuilders ensures all builders defined in the Project config are added 136 // to the Buildbucket fake. 137 func (f *Fake) EnsureBuilders(cfg *cfgpb.Config) { 138 added := stringset.New(1) 139 for _, cg := range cfg.GetConfigGroups() { 140 for _, b := range cg.GetVerifiers().GetTryjob().GetBuilders() { 141 if added.Has(fmt.Sprintf("%s/%s", b.GetHost(), b.GetName())) { 142 continue 143 } 144 builder, err := bbutil.ParseBuilderID(b.GetName()) 145 if err != nil { 146 panic(err) 147 } 148 f.AddBuilder(b.GetHost(), builder, nil) 149 added.Add(fmt.Sprintf("%s/%s", b.GetHost(), b.GetName())) 150 } 151 } 152 } 153 154 // MutateBuild mutates the provided build. 155 // 156 // Panics if the provided build is not found. 157 func (f *Fake) MutateBuild(ctx context.Context, host string, id int64, mutateFn func(*bbpb.Build)) *bbpb.Build { 158 f.hostsMu.RLock() 159 fakeApp, ok := f.hosts[host] 160 f.hostsMu.RUnlock() 161 if !ok { 162 panic(errors.Reason("unknown host %q", host)) 163 } 164 return fakeApp.updateBuild(ctx, id, mutateFn) 165 } 166 167 func (f *Fake) ensureApp(host string) *fakeApp { 168 f.hostsMu.Lock() 169 defer f.hostsMu.Unlock() 170 if _, ok := f.hosts[host]; !ok { 171 if f.hosts == nil { 172 f.hosts = make(map[string]*fakeApp) 173 } 174 f.hosts[host] = &fakeApp{ 175 hostname: host, 176 nextBuildID: math.MaxInt64 - 1, 177 buildStore: make(map[int64]*bbpb.Build), 178 configStore: make(map[string]*bbpb.BuildbucketCfg), 179 } 180 } 181 return f.hosts[host] 182 } 183 184 func (fa *fakeApp) getBuild(id int64) *bbpb.Build { 185 fa.buildStoreMu.RLock() 186 defer fa.buildStoreMu.RUnlock() 187 if build, ok := fa.buildStore[id]; ok { 188 return proto.Clone(build).(*bbpb.Build) 189 } 190 return nil 191 } 192 193 func (fa *fakeApp) iterBuildStore(cb func(*bbpb.Build)) { 194 fa.buildStoreMu.RLock() 195 defer fa.buildStoreMu.RUnlock() 196 for _, build := range fa.buildStore { 197 cb(proto.Clone(build).(*bbpb.Build)) 198 } 199 } 200 201 func (fa *fakeApp) updateBuild(ctx context.Context, id int64, cb func(*bbpb.Build)) *bbpb.Build { 202 fa.buildStoreMu.Lock() 203 defer fa.buildStoreMu.Unlock() 204 if build, ok := fa.buildStore[id]; ok { 205 cb(build) 206 build.UpdateTime = timestamppb.New(clock.Now(ctx).UTC()) 207 // store a copy to avoid cb keeps the reference to the build and mutate it 208 // later. 209 fa.buildStore[id] = proto.Clone(build).(*bbpb.Build) 210 fa.publishToTopicIfNecessary(ctx, build) 211 return build 212 } 213 panic(errors.Reason("unknown build %d", id).Err()) 214 } 215 216 // insertBuild also generates a monotonically decreasing build ID. 217 // 218 // Caches the build for `requestDeduplicationWindow` to deduplicate request 219 // with same request ID later. 220 func (fa *fakeApp) insertBuild(ctx context.Context, build *bbpb.Build, requestID string) *bbpb.Build { 221 fa.buildStoreMu.Lock() 222 defer fa.buildStoreMu.Unlock() 223 build.Id = fa.nextBuildID 224 fa.nextBuildID-- 225 if _, ok := fa.buildStore[build.Id]; ok { 226 panic(fmt.Sprintf("build %d already exists", build.Id)) 227 } 228 cloned := proto.Clone(build).(*bbpb.Build) 229 fa.buildStore[build.Id] = cloned 230 if requestID != "" { 231 fa.requestCache.set(ctx, requestID, cloned, requestDeduplicationWindow) 232 } 233 fa.publishToTopicIfNecessary(ctx, build) 234 return build 235 } 236 237 func (fa *fakeApp) publishToTopicIfNecessary(ctx context.Context, build *bbpb.Build) { 238 topic := fa.pubsubTopic 239 if topic == nil || !bbutil.IsEnded(build.GetStatus()) { 240 return 241 } 242 data, err := json.Marshal(buildbucket.PubsubMessage{ 243 Build: buildbucket.PubsubBuildMessage{ 244 ID: build.GetId(), 245 }, 246 Hostname: fa.hostname, 247 }) 248 if err != nil { 249 panic(errors.Annotate(err, "failed to marshal pubsub message").Err()) 250 } 251 res := topic.Publish(ctx, &pubsub.Message{ 252 Data: data, 253 }) 254 // Publish will batch messages. However, in the test, we want buildbucket 255 // fake to publish messages asap. Therefore, flush immediately after publish 256 // the messages 257 topic.Flush() 258 select { 259 case <-res.Ready(): 260 if _, err := res.Get(ctx); err != nil { 261 panic(errors.Annotate(err, "failed to publish the pubsub message").Err()) 262 } 263 case <-time.After(10 * time.Second): 264 panic(errors.Reason("took too long to publish the pubsub message").Err()) 265 } 266 } 267 268 func (fa *fakeApp) findDupRequest(ctx context.Context, requestID string) *bbpb.Build { 269 if requestID == "" { 270 return nil 271 } 272 if b, ok := fa.requestCache.get(ctx, requestID); ok { 273 return proto.Clone(b.(*bbpb.Build)).(*bbpb.Build) 274 } 275 return nil 276 } 277 278 func (fa *fakeApp) loadBuilderCfg(builderID *bbpb.BuilderID) *bbpb.BuilderConfig { 279 fa.configStoreMu.RLock() 280 defer fa.configStoreMu.RUnlock() 281 cfg, ok := fa.configStore[builderID.GetProject()] 282 if !ok { 283 return nil 284 } 285 for _, bucket := range cfg.GetBuckets() { 286 if bucket.GetName() != builderID.GetBucket() { 287 continue 288 } 289 for _, builder := range bucket.GetSwarming().GetBuilders() { 290 if builder.GetName() == builderID.GetBuilder() { 291 return proto.Clone(builder).(*bbpb.BuilderConfig) 292 } 293 } 294 } 295 return nil 296 }