go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config_service/internal/service/update_test.go (about) 1 // Copyright 2023 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 service 16 17 import ( 18 "context" 19 "fmt" 20 "net/http" 21 "net/http/httptest" 22 "testing" 23 "time" 24 25 "google.golang.org/protobuf/encoding/protojson" 26 "google.golang.org/protobuf/proto" 27 "google.golang.org/protobuf/types/known/emptypb" 28 29 "go.chromium.org/luci/common/clock" 30 "go.chromium.org/luci/common/clock/testclock" 31 cfgcommonpb "go.chromium.org/luci/common/proto/config" 32 "go.chromium.org/luci/common/testing/prpctest" 33 "go.chromium.org/luci/config" 34 "go.chromium.org/luci/config/validation" 35 "go.chromium.org/luci/gae/service/datastore" 36 "go.chromium.org/luci/server/auth/authtest" 37 38 "go.chromium.org/luci/config_service/internal/common" 39 "go.chromium.org/luci/config_service/internal/model" 40 "go.chromium.org/luci/config_service/testutil" 41 42 . "github.com/smartystreets/goconvey/convey" 43 . "go.chromium.org/luci/common/testing/assertions" 44 ) 45 46 type testConsumerServer struct { 47 cfgcommonpb.UnimplementedConsumerServer 48 sm *cfgcommonpb.ServiceMetadata 49 } 50 51 func (srv *testConsumerServer) GetMetadata(context.Context, *emptypb.Empty) (*cfgcommonpb.ServiceMetadata, error) { 52 return srv.sm, nil 53 } 54 55 func TestUpdateService(t *testing.T) { 56 t.Parallel() 57 58 Convey("Update Service", t, func() { 59 ctx := testutil.SetupContext() 60 ctx = authtest.MockAuthConfig(ctx) 61 62 ts := &prpctest.Server{} 63 srv := &testConsumerServer{} 64 cfgcommonpb.RegisterConsumerServer(ts, srv) 65 ts.Start(ctx) 66 defer ts.Close() 67 68 const serviceName = "my-service" 69 serviceMetadata := &cfgcommonpb.ServiceMetadata{ 70 ConfigPatterns: []*cfgcommonpb.ConfigPattern{ 71 {ConfigSet: string(config.MustProjectSet("foo")), Path: "exact:bar.cfg"}, 72 }, 73 } 74 srv.sm = serviceMetadata 75 serviceInfo := &cfgcommonpb.Service{ 76 Id: serviceName, 77 Hostname: ts.Host, 78 } 79 testutil.InjectSelfConfigs(ctx, map[string]proto.Message{ 80 common.ServiceRegistryFilePath: &cfgcommonpb.ServicesCfg{ 81 Services: []*cfgcommonpb.Service{ 82 serviceInfo, 83 }, 84 }, 85 }) 86 87 Convey("First time update", func() { 88 service := &model.Service{ 89 Name: serviceName, 90 } 91 So(datastore.Get(ctx, service), ShouldErrLike, datastore.ErrNoSuchEntity) 92 So(Update(ctx), ShouldBeNil) 93 So(datastore.Get(ctx, service), ShouldBeNil) 94 So(service.Info, ShouldResembleProto, serviceInfo) 95 So(service.Metadata, ShouldResembleProto, serviceMetadata) 96 So(service.UpdateTime, ShouldEqual, clock.Now(ctx).UTC()) 97 }) 98 99 Convey("Update existing", func() { 100 So(Update(ctx), ShouldBeNil) 101 Convey("Info changed", func() { 102 updatedInfo := proto.Clone(serviceInfo).(*cfgcommonpb.Service) 103 updatedInfo.Owners = append(updatedInfo.Owners, "new-owner@example.com") 104 testutil.InjectSelfConfigs(ctx, map[string]proto.Message{ 105 common.ServiceRegistryFilePath: &cfgcommonpb.ServicesCfg{ 106 Services: []*cfgcommonpb.Service{ 107 updatedInfo, 108 }, 109 }, 110 }) 111 So(Update(ctx), ShouldBeNil) 112 service := &model.Service{ 113 Name: serviceName, 114 } 115 So(datastore.Get(ctx, service), ShouldBeNil) 116 So(service.Info, ShouldResembleProto, updatedInfo) 117 So(service.Metadata, ShouldResembleProto, serviceMetadata) 118 So(service.UpdateTime, ShouldEqual, clock.Now(ctx).UTC()) 119 }) 120 Convey("Metadata changed", func() { 121 updated := &cfgcommonpb.ServiceMetadata{ 122 ConfigPatterns: []*cfgcommonpb.ConfigPattern{ 123 {ConfigSet: string(config.MustProjectSet("foo")), Path: "exact:bar.cfg"}, 124 {ConfigSet: string(config.MustProjectSet("foo")), Path: "exact:baz.cfg"}, 125 }, 126 } 127 srv.sm = updated 128 So(Update(ctx), ShouldBeNil) 129 service := &model.Service{ 130 Name: serviceName, 131 } 132 So(datastore.Get(ctx, service), ShouldBeNil) 133 So(service.Info, ShouldResembleProto, serviceInfo) 134 So(service.Metadata, ShouldResembleProto, updated) 135 So(service.UpdateTime, ShouldEqual, clock.Now(ctx).UTC()) 136 }) 137 }) 138 139 Convey("Error for invalid metadata", func() { 140 srv.sm = &cfgcommonpb.ServiceMetadata{ 141 ConfigPatterns: []*cfgcommonpb.ConfigPattern{ 142 {ConfigSet: string(config.MustProjectSet("foo")), Path: "regex:["}, 143 }, 144 } 145 So(Update(ctx), ShouldErrLike, "invalid metadata for service") 146 }) 147 148 Convey("Skip update if nothing changed", func() { 149 So(Update(ctx), ShouldBeNil) 150 service := &model.Service{ 151 Name: serviceName, 152 } 153 So(datastore.Get(ctx, service), ShouldBeNil) 154 prevUpdateTime := service.UpdateTime 155 tc := clock.Get(ctx).(testclock.TestClock) 156 tc.Add(1 * time.Hour) 157 So(Update(ctx), ShouldBeNil) 158 service = &model.Service{ 159 Name: serviceName, 160 } 161 So(datastore.Get(ctx, service), ShouldBeNil) 162 So(service.UpdateTime, ShouldEqual, prevUpdateTime) 163 }) 164 165 Convey("Update Service without metadata", func() { 166 testutil.InjectSelfConfigs(ctx, map[string]proto.Message{ 167 common.ServiceRegistryFilePath: &cfgcommonpb.ServicesCfg{ 168 Services: []*cfgcommonpb.Service{ 169 { 170 Id: serviceName, 171 }, 172 }, 173 }, 174 }) 175 So(Update(ctx), ShouldBeNil) 176 service := &model.Service{ 177 Name: serviceName, 178 } 179 So(datastore.Get(ctx, service), ShouldBeNil) 180 So(service.Info, ShouldResembleProto, &cfgcommonpb.Service{ 181 Id: serviceName, 182 }) 183 So(service.Metadata, ShouldBeNil) 184 So(service.UpdateTime, ShouldEqual, clock.Now(ctx).UTC()) 185 Convey("Update again", func() { 186 testutil.InjectSelfConfigs(ctx, map[string]proto.Message{ 187 common.ServiceRegistryFilePath: &cfgcommonpb.ServicesCfg{ 188 Services: []*cfgcommonpb.Service{ 189 { 190 Id: serviceName, 191 Owners: []string{"owner@example.com"}, 192 }, 193 }, 194 }, 195 }) 196 So(Update(ctx), ShouldBeNil) 197 service := &model.Service{ 198 Name: serviceName, 199 } 200 So(datastore.Get(ctx, service), ShouldBeNil) 201 So(service.Info, ShouldResembleProto, &cfgcommonpb.Service{ 202 Id: serviceName, 203 Owners: []string{"owner@example.com"}, 204 }) 205 }) 206 }) 207 208 Convey("Update self", func() { 209 serviceInfo := &cfgcommonpb.Service{ 210 Id: testutil.AppID, 211 Hostname: ts.Host, 212 // Add the service endpoint to ensure LUCI Config update its own 213 // Service entity without making rpc call to itself. 214 } 215 testutil.InjectSelfConfigs(ctx, map[string]proto.Message{ 216 common.ServiceRegistryFilePath: &cfgcommonpb.ServicesCfg{ 217 Services: []*cfgcommonpb.Service{ 218 serviceInfo, 219 }, 220 }, 221 }) 222 validation.Rules.Add("exact:services/"+testutil.AppID, "exact:foo.cfg", func(ctx *validation.Context, configSet, path string, content []byte) error { return nil }) 223 service := &model.Service{ 224 Name: testutil.AppID, 225 } 226 So(Update(ctx), ShouldBeNil) 227 So(datastore.Get(ctx, service), ShouldBeNil) 228 So(service.Info, ShouldResembleProto, serviceInfo) 229 So(service.Metadata, ShouldResembleProto, &cfgcommonpb.ServiceMetadata{ 230 ConfigPatterns: []*cfgcommonpb.ConfigPattern{ 231 { 232 ConfigSet: "exact:services/" + testutil.AppID, 233 Path: "exact:foo.cfg", 234 }, 235 }, 236 }) 237 So(service.UpdateTime, ShouldEqual, clock.Now(ctx).UTC()) 238 }) 239 240 Convey("Delete Service entity for deleted service", func() { 241 So(Update(ctx), ShouldBeNil) 242 er, err := datastore.Exists(ctx, datastore.MakeKey(ctx, model.ServiceKind, serviceName)) 243 So(err, ShouldBeNil) 244 So(er.All(), ShouldBeTrue) 245 testutil.InjectSelfConfigs(ctx, map[string]proto.Message{ 246 common.ServiceRegistryFilePath: &cfgcommonpb.ServicesCfg{ 247 Services: []*cfgcommonpb.Service{}, // delete the existing Service 248 }, 249 }) 250 So(Update(ctx), ShouldBeNil) 251 er, err = datastore.Exists(ctx, datastore.MakeKey(ctx, model.ServiceKind, serviceName)) 252 So(err, ShouldBeNil) 253 So(er.Any(), ShouldBeFalse) 254 }) 255 256 Convey("Legacy Metadata", func() { 257 legacyMetadata := &cfgcommonpb.ServiceDynamicMetadata{ 258 Version: "1.0", 259 Validation: &cfgcommonpb.Validator{ 260 Url: "https://example.com/validate", 261 Patterns: []*cfgcommonpb.ConfigPattern{ 262 {ConfigSet: string(config.MustProjectSet("foo")), Path: "exact:bar.cfg"}, 263 }, 264 }, 265 SupportsGzipCompression: true, 266 } 267 var legacySrvErrMsg string 268 269 legacyTestSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 270 if legacySrvErrMsg != "" { 271 w.WriteHeader(http.StatusInternalServerError) 272 fmt.Fprint(w, legacySrvErrMsg) 273 return 274 } 275 276 switch bytes, err := protojson.Marshal(legacyMetadata); { 277 case err != nil: 278 w.WriteHeader(http.StatusInternalServerError) 279 fmt.Fprintf(w, "%s", err) 280 default: 281 w.Write(bytes) 282 } 283 })) 284 defer legacyTestSrv.Close() 285 286 serviceInfo := &cfgcommonpb.Service{ 287 Id: serviceName, 288 MetadataUrl: legacyTestSrv.URL, 289 } 290 testutil.InjectSelfConfigs(ctx, map[string]proto.Message{ 291 common.ServiceRegistryFilePath: &cfgcommonpb.ServicesCfg{ 292 Services: []*cfgcommonpb.Service{ 293 serviceInfo, 294 }, 295 }, 296 }) 297 298 Convey("First time update", func() { 299 service := &model.Service{ 300 Name: serviceName, 301 } 302 So(datastore.Get(ctx, service), ShouldErrLike, datastore.ErrNoSuchEntity) 303 So(Update(ctx), ShouldBeNil) 304 So(datastore.Get(ctx, service), ShouldBeNil) 305 So(service.Info, ShouldResembleProto, serviceInfo) 306 So(service.LegacyMetadata, ShouldResembleProto, legacyMetadata) 307 So(service.UpdateTime, ShouldEqual, clock.Now(ctx).UTC()) 308 }) 309 310 Convey("Update existing", func() { 311 So(Update(ctx), ShouldBeNil) 312 Convey("Service info changed", func() { 313 updatedInfo := proto.Clone(serviceInfo).(*cfgcommonpb.Service) 314 updatedInfo.Owners = append(updatedInfo.Owners, "new-owner@example.com") 315 testutil.InjectSelfConfigs(ctx, map[string]proto.Message{ 316 common.ServiceRegistryFilePath: &cfgcommonpb.ServicesCfg{ 317 Services: []*cfgcommonpb.Service{ 318 updatedInfo, 319 }, 320 }, 321 }) 322 So(Update(ctx), ShouldBeNil) 323 service := &model.Service{ 324 Name: serviceName, 325 } 326 So(datastore.Get(ctx, service), ShouldBeNil) 327 So(service.Info, ShouldResembleProto, updatedInfo) 328 So(service.LegacyMetadata, ShouldResembleProto, legacyMetadata) 329 So(service.UpdateTime, ShouldEqual, clock.Now(ctx).UTC()) 330 }) 331 Convey("Legacy metadata changed", func() { 332 legacyMetadata = proto.Clone(legacyMetadata).(*cfgcommonpb.ServiceDynamicMetadata) 333 legacyMetadata.SupportsGzipCompression = false 334 So(Update(ctx), ShouldBeNil) 335 service := &model.Service{ 336 Name: serviceName, 337 } 338 So(datastore.Get(ctx, service), ShouldBeNil) 339 So(service.Info, ShouldResembleProto, serviceInfo) 340 So(service.LegacyMetadata, ShouldResembleProto, legacyMetadata) 341 So(service.UpdateTime, ShouldEqual, clock.Now(ctx).UTC()) 342 }) 343 }) 344 345 Convey("Error for invalid legacy metadata", func() { 346 Convey("Invalid regex", func() { 347 legacyMetadata = proto.Clone(legacyMetadata).(*cfgcommonpb.ServiceDynamicMetadata) 348 legacyMetadata.Validation.Patterns = []*cfgcommonpb.ConfigPattern{ 349 {ConfigSet: string(config.MustProjectSet("foo")), Path: "regex:["}, 350 } 351 }) 352 Convey("Empty url", func() { 353 legacyMetadata = proto.Clone(legacyMetadata).(*cfgcommonpb.ServiceDynamicMetadata) 354 legacyMetadata.Validation.Url = "" 355 }) 356 Convey("Invalid url", func() { 357 legacyMetadata = proto.Clone(legacyMetadata).(*cfgcommonpb.ServiceDynamicMetadata) 358 legacyMetadata.Validation.Url = "http://example.com\\validate" 359 }) 360 So(Update(ctx), ShouldErrLike, "invalid legacy metadata for service") 361 }) 362 363 Convey("Upgrade from legacy to new", func() { 364 So(Update(ctx), ShouldBeNil) 365 service := &model.Service{ 366 Name: serviceName, 367 } 368 So(datastore.Get(ctx, service), ShouldBeNil) 369 So(service.Info, ShouldResembleProto, serviceInfo) 370 So(service.Metadata, ShouldBeNil) 371 So(service.LegacyMetadata, ShouldNotBeNil) 372 373 newInfo := proto.Clone(serviceInfo).(*cfgcommonpb.Service) 374 newInfo.Hostname = ts.Host 375 newInfo.MetadataUrl = "" 376 testutil.InjectSelfConfigs(ctx, map[string]proto.Message{ 377 common.ServiceRegistryFilePath: &cfgcommonpb.ServicesCfg{ 378 Services: []*cfgcommonpb.Service{ 379 newInfo, 380 }, 381 }, 382 }) 383 So(Update(ctx), ShouldBeNil) 384 service = &model.Service{ 385 Name: serviceName, 386 } 387 So(datastore.Get(ctx, service), ShouldBeNil) 388 So(service.Info, ShouldResembleProto, newInfo) 389 So(service.Metadata, ShouldNotBeNil) 390 So(service.LegacyMetadata, ShouldBeNil) 391 }) 392 }) 393 }) 394 }