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 }