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  }