go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/scheduler/appengine/apiservers/scheduler_test.go (about)

     1  // Copyright 2017 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 apiservers
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"strings"
    21  	"testing"
    22  	"time"
    23  
    24  	"google.golang.org/grpc/codes"
    25  	"google.golang.org/grpc/status"
    26  	"google.golang.org/protobuf/types/known/emptypb"
    27  
    28  	"google.golang.org/protobuf/proto"
    29  
    30  	"go.chromium.org/luci/appengine/gaetesting"
    31  	"go.chromium.org/luci/auth/identity"
    32  
    33  	"go.chromium.org/luci/scheduler/api/scheduler/v1"
    34  	"go.chromium.org/luci/scheduler/appengine/catalog"
    35  	"go.chromium.org/luci/scheduler/appengine/engine"
    36  	"go.chromium.org/luci/scheduler/appengine/internal"
    37  	"go.chromium.org/luci/scheduler/appengine/messages"
    38  	"go.chromium.org/luci/scheduler/appengine/task"
    39  	"go.chromium.org/luci/scheduler/appengine/task/urlfetch"
    40  
    41  	. "github.com/smartystreets/goconvey/convey"
    42  )
    43  
    44  func TestGetJobsApi(t *testing.T) {
    45  	t.Parallel()
    46  
    47  	Convey("Scheduler GetJobs API works", t, func() {
    48  		ctx := gaetesting.TestingContext()
    49  		fakeEng, catalog := newTestEngine()
    50  		fakeTaskBlob, err := registerURLFetcher(catalog)
    51  		So(err, ShouldBeNil)
    52  		ss := SchedulerServer{Engine: fakeEng, Catalog: catalog}
    53  
    54  		Convey("Empty", func() {
    55  			fakeEng.getVisibleJobs = func() ([]*engine.Job, error) { return []*engine.Job{}, nil }
    56  			reply, err := ss.GetJobs(ctx, nil)
    57  			So(err, ShouldBeNil)
    58  			So(len(reply.GetJobs()), ShouldEqual, 0)
    59  		})
    60  
    61  		Convey("All Projects", func() {
    62  			fakeEng.getVisibleJobs = func() ([]*engine.Job, error) {
    63  				return []*engine.Job{
    64  					{
    65  						JobID:             "bar/foo",
    66  						ProjectID:         "bar",
    67  						Enabled:           true,
    68  						Schedule:          "0 * * * * * *",
    69  						ActiveInvocations: []int64{1},
    70  						Task:              fakeTaskBlob,
    71  					},
    72  					{
    73  						JobID:     "baz/faz",
    74  						ProjectID: "baz",
    75  						Enabled:   true,
    76  						Paused:    true,
    77  						Schedule:  "with 1m interval",
    78  						Task:      fakeTaskBlob,
    79  					},
    80  				}, nil
    81  			}
    82  			reply, err := ss.GetJobs(ctx, nil)
    83  			So(err, ShouldBeNil)
    84  			So(reply.GetJobs(), ShouldResemble, []*scheduler.Job{
    85  				{
    86  					JobRef:   &scheduler.JobRef{Job: "foo", Project: "bar"},
    87  					Schedule: "0 * * * * * *",
    88  					State:    &scheduler.JobState{UiStatus: "RUNNING"},
    89  					Paused:   false,
    90  				},
    91  				{
    92  					JobRef:   &scheduler.JobRef{Job: "faz", Project: "baz"},
    93  					Schedule: "with 1m interval",
    94  					State:    &scheduler.JobState{UiStatus: "PAUSED"},
    95  					Paused:   true,
    96  				},
    97  			})
    98  		})
    99  
   100  		Convey("One Project", func() {
   101  			fakeEng.getVisibleProjectJobs = func(projectID string) ([]*engine.Job, error) {
   102  				So(projectID, ShouldEqual, "bar")
   103  				return []*engine.Job{
   104  					{
   105  						JobID:             "bar/foo",
   106  						ProjectID:         "bar",
   107  						Enabled:           true,
   108  						Schedule:          "0 * * * * * *",
   109  						ActiveInvocations: []int64{1},
   110  						Task:              fakeTaskBlob,
   111  					},
   112  				}, nil
   113  			}
   114  			reply, err := ss.GetJobs(ctx, &scheduler.JobsRequest{Project: "bar"})
   115  			So(err, ShouldBeNil)
   116  			So(reply.GetJobs(), ShouldResemble, []*scheduler.Job{
   117  				{
   118  					JobRef:   &scheduler.JobRef{Job: "foo", Project: "bar"},
   119  					Schedule: "0 * * * * * *",
   120  					State:    &scheduler.JobState{UiStatus: "RUNNING"},
   121  					Paused:   false,
   122  				},
   123  			})
   124  		})
   125  
   126  		Convey("Paused but currently running job", func() {
   127  			fakeEng.getVisibleProjectJobs = func(projectID string) ([]*engine.Job, error) {
   128  				So(projectID, ShouldEqual, "bar")
   129  				return []*engine.Job{
   130  					{
   131  						// Job which is paused but its latest invocation still running.
   132  						JobID:             "bar/foo",
   133  						ProjectID:         "bar",
   134  						Enabled:           true,
   135  						Schedule:          "0 * * * * * *",
   136  						ActiveInvocations: []int64{1},
   137  						Paused:            true,
   138  						Task:              fakeTaskBlob,
   139  					},
   140  				}, nil
   141  			}
   142  			reply, err := ss.GetJobs(ctx, &scheduler.JobsRequest{Project: "bar"})
   143  			So(err, ShouldBeNil)
   144  			So(reply.GetJobs(), ShouldResemble, []*scheduler.Job{
   145  				{
   146  					JobRef:   &scheduler.JobRef{Job: "foo", Project: "bar"},
   147  					Schedule: "0 * * * * * *",
   148  					State:    &scheduler.JobState{UiStatus: "RUNNING"},
   149  					Paused:   true,
   150  				},
   151  			})
   152  		})
   153  	})
   154  }
   155  
   156  func TestGetInvocationsApi(t *testing.T) {
   157  	t.Parallel()
   158  
   159  	Convey("Scheduler GetInvocations API works", t, func() {
   160  		ctx := gaetesting.TestingContext()
   161  		fakeEng, catalog := newTestEngine()
   162  		_, err := registerURLFetcher(catalog)
   163  		So(err, ShouldBeNil)
   164  		ss := SchedulerServer{Engine: fakeEng, Catalog: catalog}
   165  
   166  		Convey("Job not found", func() {
   167  			fakeEng.mockNoJob()
   168  			_, err := ss.GetInvocations(ctx, &scheduler.InvocationsRequest{
   169  				JobRef: &scheduler.JobRef{Project: "not", Job: "exists"},
   170  			})
   171  			s, ok := status.FromError(err)
   172  			So(ok, ShouldBeTrue)
   173  			So(s.Code(), ShouldEqual, codes.NotFound)
   174  		})
   175  
   176  		Convey("DS error", func() {
   177  			fakeEng.mockJob("proj/job")
   178  			fakeEng.listInvocations = func(opts engine.ListInvocationsOpts) ([]*engine.Invocation, string, error) {
   179  				return nil, "", fmt.Errorf("ds error")
   180  			}
   181  			_, err := ss.GetInvocations(ctx, &scheduler.InvocationsRequest{
   182  				JobRef: &scheduler.JobRef{Project: "proj", Job: "job"},
   183  			})
   184  			s, ok := status.FromError(err)
   185  			So(ok, ShouldBeTrue)
   186  			So(s.Code(), ShouldEqual, codes.Internal)
   187  		})
   188  
   189  		Convey("Empty with huge pagesize", func() {
   190  			fakeEng.mockJob("proj/job")
   191  			fakeEng.listInvocations = func(opts engine.ListInvocationsOpts) ([]*engine.Invocation, string, error) {
   192  				So(opts, ShouldResemble, engine.ListInvocationsOpts{
   193  					PageSize: 50,
   194  				})
   195  				return nil, "", nil
   196  			}
   197  			r, err := ss.GetInvocations(ctx, &scheduler.InvocationsRequest{
   198  				JobRef:   &scheduler.JobRef{Project: "proj", Job: "job"},
   199  				PageSize: 1e9,
   200  			})
   201  			So(err, ShouldBeNil)
   202  			So(r.GetNextCursor(), ShouldEqual, "")
   203  			So(r.GetInvocations(), ShouldBeEmpty)
   204  		})
   205  
   206  		Convey("Some with custom pagesize and cursor", func() {
   207  			started := time.Unix(123123123, 0).UTC()
   208  			finished := time.Unix(321321321, 0).UTC()
   209  			fakeEng.mockJob("proj/job")
   210  			fakeEng.listInvocations = func(opts engine.ListInvocationsOpts) ([]*engine.Invocation, string, error) {
   211  				So(opts, ShouldResemble, engine.ListInvocationsOpts{
   212  					PageSize: 5,
   213  					Cursor:   "cursor",
   214  				})
   215  				return []*engine.Invocation{
   216  					{ID: 12, Revision: "deadbeef", Status: task.StatusRunning, Started: started,
   217  						TriggeredBy: identity.Identity("user:bot@example.com")},
   218  					{ID: 13, Revision: "deadbeef", Status: task.StatusAborted, Started: started, Finished: finished,
   219  						ViewURL: "https://example.com/13"},
   220  				}, "next", nil
   221  			}
   222  			r, err := ss.GetInvocations(ctx, &scheduler.InvocationsRequest{
   223  				JobRef:   &scheduler.JobRef{Project: "proj", Job: "job"},
   224  				PageSize: 5,
   225  				Cursor:   "cursor",
   226  			})
   227  			So(err, ShouldBeNil)
   228  			So(r.GetNextCursor(), ShouldEqual, "next")
   229  			So(r.GetInvocations(), ShouldResemble, []*scheduler.Invocation{
   230  				{
   231  					InvocationRef: &scheduler.InvocationRef{
   232  						JobRef:       &scheduler.JobRef{Project: "proj", Job: "job"},
   233  						InvocationId: 12,
   234  					},
   235  					ConfigRevision: "deadbeef",
   236  					Final:          false,
   237  					Status:         "RUNNING",
   238  					StartedTs:      started.UnixNano() / 1000,
   239  					TriggeredBy:    "user:bot@example.com",
   240  				},
   241  				{
   242  					InvocationRef: &scheduler.InvocationRef{
   243  						JobRef:       &scheduler.JobRef{Project: "proj", Job: "job"},
   244  						InvocationId: 13,
   245  					},
   246  					ConfigRevision: "deadbeef",
   247  					Final:          true,
   248  					Status:         "ABORTED",
   249  					StartedTs:      started.UnixNano() / 1000,
   250  					FinishedTs:     finished.UnixNano() / 1000,
   251  					ViewUrl:        "https://example.com/13",
   252  				},
   253  			})
   254  		})
   255  	})
   256  }
   257  
   258  func TestGetInvocationApi(t *testing.T) {
   259  	t.Parallel()
   260  
   261  	Convey("Works", t, func() {
   262  		ctx := gaetesting.TestingContext()
   263  		fakeEng, catalog := newTestEngine()
   264  		ss := SchedulerServer{Engine: fakeEng, Catalog: catalog}
   265  
   266  		Convey("OK", func() {
   267  			fakeEng.mockJob("proj/job")
   268  			fakeEng.getInvocation = func(jobID string, invID int64) (*engine.Invocation, error) {
   269  				So(jobID, ShouldEqual, "proj/job")
   270  				So(invID, ShouldEqual, 12)
   271  				return &engine.Invocation{
   272  					JobID:    jobID,
   273  					ID:       12,
   274  					Revision: "deadbeef",
   275  					Status:   task.StatusRunning,
   276  					Started:  time.Unix(123123123, 0).UTC(),
   277  				}, nil
   278  			}
   279  			inv, err := ss.GetInvocation(ctx, &scheduler.InvocationRef{
   280  				JobRef:       &scheduler.JobRef{Project: "proj", Job: "job"},
   281  				InvocationId: 12,
   282  			})
   283  			So(err, ShouldBeNil)
   284  			So(inv, ShouldResemble, &scheduler.Invocation{
   285  				InvocationRef: &scheduler.InvocationRef{
   286  					JobRef:       &scheduler.JobRef{Project: "proj", Job: "job"},
   287  					InvocationId: 12,
   288  				},
   289  				ConfigRevision: "deadbeef",
   290  				Status:         "RUNNING",
   291  				StartedTs:      123123123000000,
   292  			})
   293  		})
   294  
   295  		Convey("No job", func() {
   296  			fakeEng.mockNoJob()
   297  			_, err := ss.GetInvocation(ctx, &scheduler.InvocationRef{
   298  				JobRef:       &scheduler.JobRef{Project: "proj", Job: "job"},
   299  				InvocationId: 12,
   300  			})
   301  			s, ok := status.FromError(err)
   302  			So(ok, ShouldBeTrue)
   303  			So(s.Code(), ShouldEqual, codes.NotFound)
   304  		})
   305  
   306  		Convey("No invocation", func() {
   307  			fakeEng.mockJob("proj/job")
   308  			fakeEng.getInvocation = func(jobID string, invID int64) (*engine.Invocation, error) {
   309  				return nil, engine.ErrNoSuchInvocation
   310  			}
   311  			_, err := ss.GetInvocation(ctx, &scheduler.InvocationRef{
   312  				JobRef:       &scheduler.JobRef{Project: "proj", Job: "job"},
   313  				InvocationId: 12,
   314  			})
   315  			s, ok := status.FromError(err)
   316  			So(ok, ShouldBeTrue)
   317  			So(s.Code(), ShouldEqual, codes.NotFound)
   318  		})
   319  	})
   320  }
   321  
   322  func TestJobActionsApi(t *testing.T) {
   323  	t.Parallel()
   324  
   325  	// Note: PauseJob/ResumeJob/AbortJob are implemented identically, so test only
   326  	// PauseJob.
   327  
   328  	Convey("works", t, func() {
   329  		ctx := gaetesting.TestingContext()
   330  		fakeEng, catalog := newTestEngine()
   331  		ss := SchedulerServer{Engine: fakeEng, Catalog: catalog}
   332  
   333  		Convey("PermissionDenied", func() {
   334  			fakeEng.mockJob("proj/job")
   335  			fakeEng.pauseJob = func(jobID string) error {
   336  				return engine.ErrNoPermission
   337  			}
   338  			_, err := ss.PauseJob(ctx, &scheduler.JobRef{Project: "proj", Job: "job"})
   339  			s, ok := status.FromError(err)
   340  			So(ok, ShouldBeTrue)
   341  			So(s.Code(), ShouldEqual, codes.PermissionDenied)
   342  		})
   343  
   344  		Convey("OK", func() {
   345  			fakeEng.mockJob("proj/job")
   346  			fakeEng.pauseJob = func(jobID string) error {
   347  				So(jobID, ShouldEqual, "proj/job")
   348  				return nil
   349  			}
   350  			r, err := ss.PauseJob(ctx, &scheduler.JobRef{Project: "proj", Job: "job"})
   351  			So(err, ShouldBeNil)
   352  			So(r, ShouldResemble, &emptypb.Empty{})
   353  		})
   354  
   355  		Convey("NotFound", func() {
   356  			fakeEng.mockNoJob()
   357  			_, err := ss.PauseJob(ctx, &scheduler.JobRef{Project: "proj", Job: "job"})
   358  			s, ok := status.FromError(err)
   359  			So(ok, ShouldBeTrue)
   360  			So(s.Code(), ShouldEqual, codes.NotFound)
   361  		})
   362  	})
   363  }
   364  
   365  func TestAbortInvocationApi(t *testing.T) {
   366  	t.Parallel()
   367  
   368  	Convey("works", t, func() {
   369  		ctx := gaetesting.TestingContext()
   370  		fakeEng, catalog := newTestEngine()
   371  		ss := SchedulerServer{Engine: fakeEng, Catalog: catalog}
   372  
   373  		Convey("PermissionDenied", func() {
   374  			fakeEng.mockJob("proj/job")
   375  			fakeEng.abortInvocation = func(jobID string, invID int64) error {
   376  				return engine.ErrNoPermission
   377  			}
   378  			_, err := ss.AbortInvocation(ctx, &scheduler.InvocationRef{
   379  				JobRef:       &scheduler.JobRef{Project: "proj", Job: "job"},
   380  				InvocationId: 12,
   381  			})
   382  			s, ok := status.FromError(err)
   383  			So(ok, ShouldBeTrue)
   384  			So(s.Code(), ShouldEqual, codes.PermissionDenied)
   385  		})
   386  
   387  		Convey("OK", func() {
   388  			fakeEng.mockJob("proj/job")
   389  			fakeEng.abortInvocation = func(jobID string, invID int64) error {
   390  				So(jobID, ShouldEqual, "proj/job")
   391  				So(invID, ShouldEqual, 12)
   392  				return nil
   393  			}
   394  			r, err := ss.AbortInvocation(ctx, &scheduler.InvocationRef{
   395  				JobRef:       &scheduler.JobRef{Project: "proj", Job: "job"},
   396  				InvocationId: 12,
   397  			})
   398  			So(err, ShouldBeNil)
   399  			So(r, ShouldResemble, &emptypb.Empty{})
   400  		})
   401  
   402  		Convey("No job", func() {
   403  			fakeEng.mockNoJob()
   404  			_, err := ss.AbortInvocation(ctx, &scheduler.InvocationRef{
   405  				JobRef:       &scheduler.JobRef{Project: "proj", Job: "job"},
   406  				InvocationId: 12,
   407  			})
   408  			s, ok := status.FromError(err)
   409  			So(ok, ShouldBeTrue)
   410  			So(s.Code(), ShouldEqual, codes.NotFound)
   411  		})
   412  
   413  		Convey("No invocation", func() {
   414  			fakeEng.mockJob("proj/job")
   415  			fakeEng.abortInvocation = func(jobID string, invID int64) error {
   416  				return engine.ErrNoSuchInvocation
   417  			}
   418  			_, err := ss.AbortInvocation(ctx, &scheduler.InvocationRef{
   419  				JobRef:       &scheduler.JobRef{Project: "proj", Job: "job"},
   420  				InvocationId: 12,
   421  			})
   422  			s, ok := status.FromError(err)
   423  			So(ok, ShouldBeTrue)
   424  			So(s.Code(), ShouldEqual, codes.NotFound)
   425  		})
   426  	})
   427  }
   428  
   429  ////
   430  
   431  func registerURLFetcher(cat catalog.Catalog) ([]byte, error) {
   432  	if err := cat.RegisterTaskManager(&urlfetch.TaskManager{}); err != nil {
   433  		return nil, err
   434  	}
   435  	return proto.Marshal(&messages.TaskDefWrapper{
   436  		UrlFetch: &messages.UrlFetchTask{Url: "http://example.com/path"},
   437  	})
   438  }
   439  
   440  func newTestEngine() (*fakeEngine, catalog.Catalog) {
   441  	cat := catalog.New()
   442  	return &fakeEngine{}, cat
   443  }
   444  
   445  type fakeEngine struct {
   446  	getVisibleJobs        func() ([]*engine.Job, error)
   447  	getVisibleProjectJobs func(projectID string) ([]*engine.Job, error)
   448  	getVisibleJob         func(jobID string) (*engine.Job, error)
   449  	listInvocations       func(opts engine.ListInvocationsOpts) ([]*engine.Invocation, string, error)
   450  	getInvocation         func(jobID string, invID int64) (*engine.Invocation, error)
   451  
   452  	pauseJob        func(jobID string) error
   453  	resumeJob       func(jobID string) error
   454  	abortJob        func(jobID string) error
   455  	abortInvocation func(jobID string, invID int64) error
   456  }
   457  
   458  func (f *fakeEngine) mockJob(jobID string) *engine.Job {
   459  	j := &engine.Job{
   460  		JobID:     jobID,
   461  		ProjectID: strings.Split(jobID, "/")[0],
   462  		Enabled:   true,
   463  	}
   464  	f.getVisibleJob = func(jobID string) (*engine.Job, error) {
   465  		if jobID == j.JobID {
   466  			return j, nil
   467  		}
   468  		return nil, engine.ErrNoSuchJob
   469  	}
   470  	return j
   471  }
   472  
   473  func (f *fakeEngine) mockNoJob() {
   474  	f.getVisibleJob = func(string) (*engine.Job, error) {
   475  		return nil, engine.ErrNoSuchJob
   476  	}
   477  }
   478  
   479  func (f *fakeEngine) GetVisibleJobs(c context.Context) ([]*engine.Job, error) {
   480  	return f.getVisibleJobs()
   481  }
   482  
   483  func (f *fakeEngine) GetVisibleProjectJobs(c context.Context, projectID string) ([]*engine.Job, error) {
   484  	return f.getVisibleProjectJobs(projectID)
   485  }
   486  
   487  func (f *fakeEngine) GetVisibleJob(c context.Context, jobID string) (*engine.Job, error) {
   488  	return f.getVisibleJob(jobID)
   489  }
   490  
   491  func (f *fakeEngine) GetVisibleJobBatch(c context.Context, jobIDs []string) (map[string]*engine.Job, error) {
   492  	out := map[string]*engine.Job{}
   493  	for _, id := range jobIDs {
   494  		switch job, err := f.GetVisibleJob(c, id); {
   495  		case err == nil:
   496  			out[id] = job
   497  		case err != engine.ErrNoSuchJob:
   498  			return nil, err
   499  		}
   500  	}
   501  	return out, nil
   502  }
   503  
   504  func (f *fakeEngine) ListInvocations(c context.Context, job *engine.Job, opts engine.ListInvocationsOpts) ([]*engine.Invocation, string, error) {
   505  	return f.listInvocations(opts)
   506  }
   507  
   508  func (f *fakeEngine) PauseJob(c context.Context, job *engine.Job, reason string) error {
   509  	return f.pauseJob(job.JobID)
   510  }
   511  
   512  func (f *fakeEngine) ResumeJob(c context.Context, job *engine.Job, reason string) error {
   513  	return f.resumeJob(job.JobID)
   514  }
   515  
   516  func (f *fakeEngine) AbortInvocation(c context.Context, job *engine.Job, invID int64) error {
   517  	return f.abortInvocation(job.JobID, invID)
   518  }
   519  
   520  func (f *fakeEngine) AbortJob(c context.Context, job *engine.Job) error {
   521  	return f.abortJob(job.JobID)
   522  }
   523  
   524  func (f *fakeEngine) EmitTriggers(c context.Context, perJob map[*engine.Job][]*internal.Trigger) error {
   525  	return nil
   526  }
   527  
   528  func (f *fakeEngine) ListTriggers(c context.Context, job *engine.Job) ([]*internal.Trigger, error) {
   529  	panic("not implemented")
   530  }
   531  
   532  func (f *fakeEngine) GetInvocation(c context.Context, job *engine.Job, invID int64) (*engine.Invocation, error) {
   533  	return f.getInvocation(job.JobID, invID)
   534  }
   535  
   536  func (f *fakeEngine) InternalAPI() engine.EngineInternal {
   537  	panic("not implemented")
   538  }
   539  
   540  func (f *fakeEngine) GetJobTriageLog(c context.Context, job *engine.Job) (*engine.JobTriageLog, error) {
   541  	panic("not implemented")
   542  }