go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/scheduler/appengine/catalog/catalog_test.go (about) 1 // Copyright 2015 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 catalog 16 17 import ( 18 "context" 19 "errors" 20 "testing" 21 22 "github.com/golang/protobuf/proto" 23 24 "google.golang.org/api/pubsub/v1" 25 26 "go.chromium.org/luci/appengine/gaetesting" 27 "go.chromium.org/luci/common/clock/testclock" 28 "go.chromium.org/luci/common/logging" 29 "go.chromium.org/luci/common/logging/gologger" 30 "go.chromium.org/luci/common/tsmon" 31 "go.chromium.org/luci/common/tsmon/store" 32 "go.chromium.org/luci/common/tsmon/target" 33 "go.chromium.org/luci/config" 34 "go.chromium.org/luci/config/cfgclient" 35 memcfg "go.chromium.org/luci/config/impl/memory" 36 "go.chromium.org/luci/config/impl/resolving" 37 "go.chromium.org/luci/config/validation" 38 "go.chromium.org/luci/config/vars" 39 40 "go.chromium.org/luci/scheduler/appengine/internal" 41 "go.chromium.org/luci/scheduler/appengine/messages" 42 "go.chromium.org/luci/scheduler/appengine/task" 43 44 . "github.com/smartystreets/goconvey/convey" 45 . "go.chromium.org/luci/common/testing/assertions" 46 ) 47 48 func TestRegisterTaskManagerAndFriends(t *testing.T) { 49 t.Parallel() 50 51 Convey("RegisterTaskManager works", t, func() { 52 c := New() 53 So(c.RegisterTaskManager(fakeTaskManager{}), ShouldBeNil) 54 So(c.GetTaskManager(&messages.NoopTask{}), ShouldNotBeNil) 55 So(c.GetTaskManager(&messages.UrlFetchTask{}), ShouldBeNil) 56 So(c.GetTaskManager(nil), ShouldBeNil) 57 }) 58 59 Convey("RegisterTaskManager bad proto type", t, func() { 60 c := New() 61 So(c.RegisterTaskManager(brokenTaskManager{}), ShouldErrLike, "expecting pointer to a struct") 62 }) 63 64 Convey("RegisterTaskManager twice", t, func() { 65 c := New() 66 So(c.RegisterTaskManager(fakeTaskManager{}), ShouldBeNil) 67 So(c.RegisterTaskManager(fakeTaskManager{}), ShouldNotBeNil) 68 }) 69 } 70 71 func TestProtoValidation(t *testing.T) { 72 t.Parallel() 73 74 ctx := context.Background() 75 76 Convey("validateJobProto works", t, func() { 77 c := New().(*catalog) 78 79 call := func(j *messages.Job) error { 80 valCtx := &validation.Context{Context: ctx} 81 c.validateJobProto(valCtx, j, "some-project:some-realm") 82 return valCtx.Finalize() 83 } 84 85 c.RegisterTaskManager(fakeTaskManager{}) 86 So(call(&messages.Job{}), ShouldErrLike, "missing 'id' field'") 87 So(call(&messages.Job{Id: "bad'id"}), ShouldErrLike, "not valid value for 'id' field") 88 So(call(&messages.Job{ 89 Id: "good id can have spaces and . and - and even ()", 90 Noop: &messages.NoopTask{}, 91 }), ShouldBeNil) 92 So(call(&messages.Job{ 93 Id: "good", 94 Schedule: "blah", 95 }), ShouldErrLike, "not valid value for 'schedule' field") 96 So(call(&messages.Job{ 97 Id: "good", 98 Schedule: "* * * * *", 99 }), ShouldErrLike, "can't find a recognized task definition") 100 So(call(&messages.Job{ 101 Id: "good", 102 Schedule: "* * * * *", 103 Noop: &messages.NoopTask{}, 104 TriggeringPolicy: &messages.TriggeringPolicy{Kind: 111111}, 105 }), ShouldErrLike, "unrecognized policy kind 111111") 106 }) 107 108 Convey("extractTaskProto works", t, func() { 109 c := New().(*catalog) 110 c.RegisterTaskManager(fakeTaskManager{ 111 name: "noop", 112 task: &messages.NoopTask{}, 113 }) 114 c.RegisterTaskManager(fakeTaskManager{ 115 name: "url fetch", 116 task: &messages.UrlFetchTask{}, 117 }) 118 119 Convey("with TaskDefWrapper", func() { 120 msg, err := c.extractTaskProto(ctx, &messages.TaskDefWrapper{ 121 Noop: &messages.NoopTask{}, 122 }, "some-project:some-realm") 123 So(err, ShouldBeNil) 124 So(msg.(*messages.NoopTask), ShouldNotBeNil) 125 126 msg, err = c.extractTaskProto(ctx, nil, "some-project:some-realm") 127 So(err, ShouldErrLike, "expecting a pointer to proto message") 128 So(msg, ShouldBeNil) 129 130 msg, err = c.extractTaskProto(ctx, &messages.TaskDefWrapper{}, "some-project:some-realm") 131 So(err, ShouldErrLike, "can't find a recognized task definition") 132 So(msg, ShouldBeNil) 133 134 msg, err = c.extractTaskProto(ctx, &messages.TaskDefWrapper{ 135 Noop: &messages.NoopTask{}, 136 UrlFetch: &messages.UrlFetchTask{}, 137 }, "some-project:some-realm") 138 So(err, ShouldErrLike, "only one field with task definition must be set") 139 So(msg, ShouldBeNil) 140 }) 141 142 Convey("with Job", func() { 143 msg, err := c.extractTaskProto(ctx, &messages.Job{ 144 Id: "blah", 145 Noop: &messages.NoopTask{}, 146 }, "some-project:some-realm") 147 So(err, ShouldBeNil) 148 So(msg.(*messages.NoopTask), ShouldNotBeNil) 149 150 msg, err = c.extractTaskProto(ctx, &messages.Job{ 151 Id: "blah", 152 }, "some-project:some-realm") 153 So(err, ShouldErrLike, "can't find a recognized task definition") 154 So(msg, ShouldBeNil) 155 156 msg, err = c.extractTaskProto(ctx, &messages.Job{ 157 Id: "blah", 158 Noop: &messages.NoopTask{}, 159 UrlFetch: &messages.UrlFetchTask{}, 160 }, "some-project:some-realm") 161 So(err, ShouldErrLike, "only one field with task definition must be set") 162 So(msg, ShouldBeNil) 163 }) 164 }) 165 166 Convey("extractTaskProto uses task manager validation", t, func() { 167 c := New().(*catalog) 168 c.RegisterTaskManager(fakeTaskManager{ 169 name: "broken noop", 170 validationErr: errors.New("boo"), 171 expectedRealmID: "some-project:some-realm", 172 }) 173 msg, err := c.extractTaskProto(ctx, &messages.TaskDefWrapper{ 174 Noop: &messages.NoopTask{}, 175 }, "some-project:some-realm") 176 So(err, ShouldErrLike, "boo") 177 So(msg, ShouldBeNil) 178 }) 179 } 180 181 func TestTaskMarshaling(t *testing.T) { 182 t.Parallel() 183 184 ctx := context.Background() 185 186 Convey("works", t, func() { 187 c := New().(*catalog) 188 c.RegisterTaskManager(fakeTaskManager{ 189 name: "url fetch", 190 task: &messages.UrlFetchTask{}, 191 expectedRealmID: "some-project:some-realm", 192 }) 193 194 // Round trip for a registered task. 195 blob, err := c.marshalTask(&messages.UrlFetchTask{ 196 Url: "123", 197 }) 198 So(err, ShouldBeNil) 199 task, err := c.UnmarshalTask(ctx, blob, "some-project:some-realm") 200 So(err, ShouldBeNil) 201 So(task, ShouldResembleProto, &messages.UrlFetchTask{ 202 Url: "123", 203 }) 204 205 // Unknown task type. 206 _, err = c.marshalTask(&messages.NoopTask{}) 207 So(err, ShouldErrLike, "unrecognized task definition type *messages.NoopTask") 208 209 // Once registered, but not anymore. 210 c = New().(*catalog) 211 _, err = c.UnmarshalTask(ctx, blob, "some-project:some-realm") 212 So(err, ShouldErrLike, "can't find a recognized task definition") 213 }) 214 } 215 216 func TestConfigReading(t *testing.T) { 217 t.Parallel() 218 219 Convey("with mocked config", t, func() { 220 ctx := testContext() 221 222 // Fetch configs from memory but resolve ${appid} into "app" to make 223 // RevisionURL more realistic. 224 vars := &vars.VarSet{} 225 vars.Register("appid", func(context.Context) (string, error) { 226 return "app", nil 227 }) 228 ctx = cfgclient.Use(ctx, resolving.New(vars, memcfg.New(mockedConfigs))) 229 230 cat := New() 231 cat.RegisterTaskManager(fakeTaskManager{ 232 name: "noop", 233 task: &messages.NoopTask{}, 234 }) 235 cat.RegisterTaskManager(fakeTaskManager{ 236 name: "url_fetch", 237 task: &messages.UrlFetchTask{}, 238 }) 239 240 Convey("GetAllProjects works", func() { 241 projects, err := cat.GetAllProjects(ctx) 242 So(err, ShouldBeNil) 243 So(projects, ShouldResemble, []string{"broken", "project1", "project2"}) 244 }) 245 246 Convey("GetProjectJobs works", func() { 247 const expectedRev = "06e505e46c49133cc928fbc244b27b232d7e8010" 248 249 defs, err := cat.GetProjectJobs(ctx, "project1") 250 So(err, ShouldBeNil) 251 So(defs, ShouldResemble, []Definition{ 252 { 253 JobID: "project1/noop-job-1", 254 RealmID: "project1:public", 255 Revision: expectedRev, 256 RevisionURL: "https://example.com/view/here/app.cfg", 257 Schedule: "*/10 * * * * * *", 258 Task: []uint8{10, 0}, 259 TriggeringPolicy: []uint8{16, 4}, 260 }, 261 { 262 JobID: "project1/noop-job-2", 263 RealmID: "project1:@legacy", 264 Revision: expectedRev, 265 RevisionURL: "https://example.com/view/here/app.cfg", 266 Schedule: "*/10 * * * * * *", 267 Task: []uint8{10, 0}, 268 }, 269 { 270 JobID: "project1/urlfetch-job-1", 271 RealmID: "project1:@legacy", 272 Revision: expectedRev, 273 RevisionURL: "https://example.com/view/here/app.cfg", 274 Schedule: "*/10 * * * * * *", 275 Task: []uint8{18, 21, 18, 19, 104, 116, 116, 112, 115, 58, 47, 47, 101, 120, 97, 109, 112, 108, 101, 46, 99, 111, 109}, 276 }, 277 { 278 JobID: "project1/trigger", 279 RealmID: "project1:@legacy", 280 Flavor: JobFlavorTrigger, 281 Revision: expectedRev, 282 RevisionURL: "https://example.com/view/here/app.cfg", 283 Schedule: "with 30s interval", 284 Task: []uint8{10, 0}, 285 TriggeringPolicy: []uint8{8, 1, 16, 2}, 286 TriggeredJobIDs: []string{ 287 "project1/noop-job-1", 288 "project1/noop-job-2", 289 "project1/noop-job-3", 290 }, 291 }, 292 }) 293 }) 294 295 Convey("GetProjectJobs filters unknown job IDs in triggers", func() { 296 const expectedRev = "3ef040fb696156a96c882837b05f31d2da0ba0f5" 297 298 defs, err := cat.GetProjectJobs(ctx, "project2") 299 So(err, ShouldBeNil) 300 So(defs, ShouldResemble, []Definition{ 301 { 302 JobID: "project2/noop-job-1", 303 RealmID: "project2:@legacy", 304 Revision: expectedRev, 305 RevisionURL: "https://example.com/view/here/app.cfg", 306 Schedule: "*/10 * * * * * *", 307 Task: []uint8{10, 0}, 308 }, 309 { 310 JobID: "project2/trigger", 311 RealmID: "project2:@legacy", 312 Flavor: 2, 313 Revision: expectedRev, 314 RevisionURL: "https://example.com/view/here/app.cfg", 315 Schedule: "with 30s interval", 316 Task: []uint8{10, 0}, 317 TriggeredJobIDs: []string{ 318 // No noop-job-2 here! 319 "project2/noop-job-1", 320 }, 321 }, 322 }) 323 }) 324 325 Convey("GetProjectJobs unknown project", func() { 326 defs, err := cat.GetProjectJobs(ctx, "unknown") 327 So(defs, ShouldBeNil) 328 So(err, ShouldBeNil) 329 }) 330 331 Convey("GetProjectJobs broken proto", func() { 332 defs, err := cat.GetProjectJobs(ctx, "broken") 333 So(defs, ShouldBeNil) 334 So(err, ShouldNotBeNil) 335 }) 336 337 Convey("UnmarshalTask works", func() { 338 defs, err := cat.GetProjectJobs(ctx, "project1") 339 So(err, ShouldBeNil) 340 341 task, err := cat.UnmarshalTask(ctx, defs[0].Task, defs[0].RealmID) 342 So(err, ShouldBeNil) 343 So(task, ShouldResembleProto, &messages.NoopTask{}) 344 345 task, err = cat.UnmarshalTask(ctx, []byte("blarg"), defs[0].RealmID) 346 So(err, ShouldNotBeNil) 347 So(task, ShouldBeNil) 348 }) 349 }) 350 } 351 352 func TestValidateConfig(t *testing.T) { 353 t.Parallel() 354 355 catalog := New() 356 catalog.RegisterTaskManager(fakeTaskManager{ 357 name: "noop", 358 task: &messages.NoopTask{}, 359 }) 360 catalog.RegisterTaskManager(fakeTaskManager{ 361 name: "url_fetch", 362 task: &messages.UrlFetchTask{}, 363 }) 364 365 rules := validation.NewRuleSet() 366 rules.Vars.Register("appid", func(context.Context) (string, error) { 367 return "luci-scheduler", nil 368 }) 369 catalog.RegisterConfigRules(rules) 370 371 Convey("Patterns are correct", t, func() { 372 patterns, err := rules.ConfigPatterns(context.Background()) 373 So(err, ShouldBeNil) 374 So(len(patterns), ShouldEqual, 1) 375 So(patterns[0].ConfigSet.Match("projects/xyz"), ShouldBeTrue) 376 So(patterns[0].Path.Match("luci-scheduler.cfg"), ShouldBeTrue) 377 }) 378 379 Convey("Config validation works", t, func() { 380 ctx := &validation.Context{Context: testContext()} 381 Convey("correct config file content", func() { 382 So(rules.ValidateConfig(ctx, "projects/good", "luci-scheduler.cfg", []byte(project3Cfg)), ShouldBeNil) 383 So(ctx.Finalize(), ShouldBeNil) 384 }) 385 386 Convey("Config that can't be deserialized", func() { 387 So(rules.ValidateConfig(ctx, "projects/bad", "luci-scheduler.cfg", []byte("deadbeef")), ShouldBeNil) 388 So(ctx.Finalize(), ShouldNotBeNil) 389 }) 390 391 Convey("rejects triggers with unknown references", func() { 392 So(rules.ValidateConfig(ctx, "projects/bad", "luci-scheduler.cfg", []byte(project2Cfg)), ShouldBeNil) 393 So(ctx.Finalize(), ShouldErrLike, `referencing unknown job "noop-job-2" in 'triggers' field`) 394 }) 395 396 Convey("rejects duplicate ids", func() { 397 // job + job 398 So(rules.ValidateConfig(ctx, "projects/bad", "luci-scheduler.cfg", []byte(` 399 job { 400 id: "dup" 401 noop: { } 402 } 403 job { 404 id: "dup" 405 noop: { } 406 } 407 `)), ShouldBeNil) 408 So(ctx.Finalize(), ShouldErrLike, `duplicate id "dup"`) 409 410 // job + trigger 411 So(rules.ValidateConfig(ctx, "projects/bad", "luci-scheduler.cfg", []byte(` 412 job { 413 id: "dup" 414 noop: { } 415 } 416 trigger { 417 id: "dup" 418 noop: { } 419 } 420 `)), ShouldBeNil) 421 So(ctx.Finalize(), ShouldErrLike, `duplicate id "dup"`) 422 }) 423 }) 424 } 425 426 //// 427 428 type fakeTaskManager struct { 429 name string 430 task proto.Message 431 432 validationErr error 433 expectedRealmID string 434 } 435 436 func (m fakeTaskManager) Name() string { 437 if m.name != "" { 438 return m.name 439 } 440 return "testing" 441 } 442 443 func (m fakeTaskManager) ProtoMessageType() proto.Message { 444 if m.task != nil { 445 return m.task 446 } 447 return &messages.NoopTask{} 448 } 449 450 func (m fakeTaskManager) Traits() task.Traits { 451 return task.Traits{} 452 } 453 454 func (m fakeTaskManager) ValidateProtoMessage(c *validation.Context, msg proto.Message, realmID string) { 455 So(msg, ShouldNotBeNil) 456 if m.expectedRealmID != "" { 457 So(realmID, ShouldEqual, m.expectedRealmID) 458 } 459 if m.validationErr != nil { 460 c.Error(m.validationErr) 461 } 462 } 463 464 func (m fakeTaskManager) LaunchTask(c context.Context, ctl task.Controller) error { 465 So(ctl.Task(), ShouldNotBeNil) 466 return nil 467 } 468 469 func (m fakeTaskManager) AbortTask(c context.Context, ctl task.Controller) error { 470 return nil 471 } 472 473 func (m fakeTaskManager) ExamineNotification(c context.Context, msg *pubsub.PubsubMessage) string { 474 return "" 475 } 476 477 func (m fakeTaskManager) HandleNotification(c context.Context, ctl task.Controller, msg *pubsub.PubsubMessage) error { 478 return errors.New("not implemented") 479 } 480 481 func (m fakeTaskManager) HandleTimer(c context.Context, ctl task.Controller, name string, payload []byte) error { 482 return errors.New("not implemented") 483 } 484 485 func (m fakeTaskManager) GetDebugState(c context.Context, ctl task.ControllerReadOnly) (*internal.DebugManagerState, error) { 486 return nil, errors.New("not implemented") 487 } 488 489 type brokenTaskManager struct { 490 fakeTaskManager 491 } 492 493 func (b brokenTaskManager) ProtoMessageType() proto.Message { 494 return nil 495 } 496 497 //// 498 499 func testContext() context.Context { 500 c := gaetesting.TestingContext() 501 c = gologger.StdConfig.Use(c) 502 c = logging.SetLevel(c, logging.Debug) 503 c, _ = testclock.UseTime(c, testclock.TestTimeUTC) 504 c, _, _ = tsmon.WithFakes(c) 505 tsmon.GetState(c).SetStore(store.NewInMemory(&target.Task{})) 506 return c 507 } 508 509 //// 510 511 const project1Cfg = ` 512 job { 513 id: "noop-job-1" 514 schedule: "*/10 * * * * * *" 515 realm: "public" 516 517 triggering_policy { 518 max_concurrent_invocations: 4 519 } 520 521 noop: {} 522 } 523 524 job { 525 id: "noop-job-2" 526 schedule: "*/10 * * * * * *" 527 528 noop: {} 529 } 530 531 job { 532 id: "noop-job-3" 533 schedule: "*/10 * * * * * *" 534 disabled: true 535 536 noop: {} 537 } 538 539 job { 540 id: "urlfetch-job-1" 541 schedule: "*/10 * * * * * *" 542 543 url_fetch: { 544 url: "https://example.com" 545 } 546 } 547 548 trigger { 549 id: "trigger" 550 551 triggering_policy { 552 kind: GREEDY_BATCHING 553 max_concurrent_invocations: 2 554 } 555 556 noop: {} 557 558 triggers: "noop-job-1" 559 triggers: "noop-job-2" 560 triggers: "noop-job-3" 561 } 562 563 # Will be skipped since BuildbucketTask Manager is not registered. 564 job { 565 id: "buildbucket-job" 566 schedule: "*/10 * * * * * *" 567 568 buildbucket: {} 569 } 570 ` 571 572 // project2Cfg has a trigger that references non-existing job. It will fail 573 // the config validation, but will still load by GetProjectJobs. We need this 574 // behavior since unfortunately some inconsistencies crept in into the configs. 575 const project2Cfg = ` 576 job { 577 id: "noop-job-1" 578 schedule: "*/10 * * * * * *" 579 noop: {} 580 } 581 582 trigger { 583 id: "trigger" 584 585 noop: {} 586 587 triggers: "noop-job-1" 588 triggers: "noop-job-2" # no such job 589 } 590 ` 591 592 const project3Cfg = ` 593 job { 594 id: "noop-job-v2" 595 noop: { 596 sleep_ms: 1000 597 } 598 } 599 600 trigger { 601 id: "noop-trigger-v2" 602 603 noop: { 604 sleep_ms: 1000 605 triggers_count: 2 606 } 607 608 triggers: "noop-job-v2" 609 } 610 ` 611 612 var mockedConfigs = map[config.Set]memcfg.Files{ 613 "projects/project1": { 614 "app.cfg": project1Cfg, 615 }, 616 "projects/project2": { 617 "app.cfg": project2Cfg, 618 }, 619 "projects/broken": { 620 "app.cfg": "broken!!!!111", 621 }, 622 }