//go:build !cgo // Handler-level tests for the job endpoints (CreateJob, GetJobsInfo, // GetJobByIDs, ControlJobByID and the filter-based ControlJob), driven // through the test seams in jobs.go (generateJobID, generateArrayJobID, // submitJob, controlJob) so no C backend is needed. // // This file is !cgo because it reuses the JWT/RBAC fixtures (signTestJWT, // setupRBACTest) from integration_test.go, which is !cgo-only. package controllers import ( "apiserver/backend" "apiserver/models" "apiserver/store" "apiserver/utils" "bytes" "encoding/json" "errors" "net/http" "net/http/httptest" "net/url" "sort" "strconv" "strings" "sync" "testing" "time" "github.com/gin-gonic/gin" "go.uber.org/zap" "go.uber.org/zap/zapcore" "go.uber.org/zap/zaptest/observer" ) // handlerTestUser is the user every handler fixture request is signed as. const handlerTestUser = "handler_user" // handlerTestRBAC grants handlerTestUser every permission the job handlers // check, over the "all" domain. The role is deliberately not cluster_admin: // the handlers are registered without RequirePermission, and each handler // does its own job:control / queue:control / queue:submit checks. const handlerTestRBAC = ` permissions: - job:view - job:control - queue:submit - queue:control - queue:view roles: handler_tester: permissions: - action: job:view domain: - type: all - action: job:control domain: - type: all - action: queue:submit domain: - type: all - action: queue:control domain: - type: all - action: queue:view domain: - type: all users: handler_user: roles: - name: handler_tester domain: - type: all ` // handlerTestOrdinaryBase is the first ID the generateJobID replacement // hands out: tag 0, a non-zero timestamp field, index bits clear. const handlerTestOrdinaryBase = uint64(1)<<40 | uint64(1)<<20 // handlerTestArrayBase is the first array key the generateArrayJobID // replacement hands out (array-capable: tag 0, low 15 bits clear). const handlerTestArrayBase = uint64(2)<<40 | uint64(1)<<20 // submittedJob is one call the submitJob replacement saw. type submittedJob struct { Job models.JobInfo ArraySize int } // dispatchedControl is one call the controlJob replacement saw. type dispatchedControl struct { UniqueJobID uint64 Action int Content string } // handlerFixture is the shared fixture of the job handler tests: a gin // router with the job handlers, a signed token, seeded stores and recording // replacements of the backend seams. type handlerFixture struct { t *testing.T router *gin.Engine token string mu sync.Mutex // generateCalls records the arraySize argument of every generateJobID call. generateCalls []int // arrayCalls records the n argument of every generateArrayJobID call. arrayCalls []int submitted []submittedJob dispatched []dispatchedControl nextID uint64 nextArray uint64 // Errors the replacements return (nil = success). generateErr error submitErr error controlErr error } // newHandlerFixture builds the fixture. Order matters: setupRBACTest calls // resetIntegrationState -> store.InitStores, which replaces every store, so // the original store pointers are saved first, RBAC is set up next, and // seeding (seedJob/seedQueue) happens afterwards. Everything replaced here is // restored in t.Cleanup. func newHandlerFixture(t *testing.T) *handlerFixture { t.Helper() // Save every global this fixture (or setupRBACTest) replaces. origJobs, origArrayJobs, origQueues, origHosts := store.Jobs, store.ArrayJobs, store.Queues, store.Hosts origResources, origResLocs, origClusters := store.Resources, store.ResourceLocations, store.Clusters origExecHosts, origRoles, origUserRoles := store.JobExecHosts, store.Roles, store.UserRoles origGenerate, origGenerateArray := generateJobID, generateArrayJobID origSubmit, origControl := submitJob, controlJob origJobCfg, origLog, origAuthCfg := utils.JobYamlConfig, utils.Log, utils.AuthenticationYamlConfig origRBAC := rbacConfig t.Cleanup(func() { store.Jobs, store.ArrayJobs, store.Queues, store.Hosts = origJobs, origArrayJobs, origQueues, origHosts store.Resources, store.ResourceLocations, store.Clusters = origResources, origResLocs, origClusters store.JobExecHosts, store.Roles, store.UserRoles = origExecHosts, origRoles, origUserRoles generateJobID, generateArrayJobID = origGenerate, origGenerateArray submitJob, controlJob = origSubmit, origControl utils.JobYamlConfig, utils.Log, utils.AuthenticationYamlConfig = origJobCfg, origLog, origAuthCfg rbacConfig = origRBAC }) // Auth/RBAC (replaces the stores with empty ones), then config. setupRBACTest(t, handlerTestRBAC) utils.JobYamlConfig = &utils.JobConfig{MaxArraySize: 1000} f := &handlerFixture{ t: t, token: signTestJWT(handlerTestUser, "handler_tester", time.Now().Add(time.Hour)), nextID: handlerTestOrdinaryBase, nextArray: handlerTestArrayBase, } generateJobID = func(_, _ int, arraySize int) (uint64, error) { f.mu.Lock() defer f.mu.Unlock() f.generateCalls = append(f.generateCalls, arraySize) if f.generateErr != nil { return 0, f.generateErr } id := f.nextID f.nextID++ return id, nil } generateArrayJobID = func(_, _ int, n int) (uint64, error) { f.mu.Lock() defer f.mu.Unlock() f.arrayCalls = append(f.arrayCalls, n) if f.generateErr != nil { return 0, f.generateErr } key := f.nextArray f.nextArray += uint64(1) << 15 // next array-capable key return backend.MakeElementID(key, 1), nil } submitJob = func(job *models.JobInfo, arraySize int) error { f.mu.Lock() defer f.mu.Unlock() if f.submitErr != nil { return f.submitErr } f.submitted = append(f.submitted, submittedJob{Job: *job, ArraySize: arraySize}) return nil } controlJob = func(jobid uint64, action int, content string) error { f.mu.Lock() defer f.mu.Unlock() if f.controlErr != nil { return f.controlErr } f.dispatched = append(f.dispatched, dispatchedControl{UniqueJobID: jobid, Action: action, Content: content}) return nil } jc := JobController{} r := newTestRouter() g := r.Group("/xce/v1/jobs") g.GET("", jc.GetJobsInfo) g.GET("/:jobids", jc.GetJobByIDs) g.POST("/create", jc.CreateJob) g.POST("/:jobids/control", jc.ControlJobByID) g.POST("/control", jc.ControlJob) f.router = r return f } // seedQueue adds an open queue to store.Queues (idempotent). func (f *handlerFixture) seedQueue(name string) { f.t.Helper() if !store.Queues.Has(name) { store.Queues.Put(name, models.QueueInfo{QueueName: name}) } } // seedJob stores job under its Unique_JobID and seeds its queue, so the // handlers' queue:control check (store.GetAllQueueNames) admits it. An empty // QueueName defaults to "normal" and an empty SubmitUser to handlerTestUser. func (f *handlerFixture) seedJob(job models.JobInfo) models.JobInfo { f.t.Helper() if job.QueueName == "" { job.QueueName = "normal" } if job.SubmitUser == "" { job.SubmitUser = handlerTestUser } f.seedQueue(job.QueueName) store.Jobs.Put(uint64(job.Unique_JobID), job) return job } // seedOrdinaryJob seeds an ordinary (non-array) job with the given status // name (a models.JOBSTAT key, e.g. "RUN"). func (f *handlerFixture) seedOrdinaryJob(id uint64, name, status string) models.JobInfo { f.t.Helper() st, ok := models.JOBSTAT[status] if !ok { f.t.Fatalf("seedOrdinaryJob: unknown status %q", status) } return f.seedJob(models.JobInfo{ Unique_JobID: int64(id), JobID: int64(id), ArrayIdx: 0, JobName: name, JobStatus: st, }) } // seedArrayElements seeds elements idx... of the array identified by key // (an array-capable key), all with the given name and status. func (f *handlerFixture) seedArrayElements(key uint64, name, status string, idx ...int) []models.JobInfo { f.t.Helper() if !backend.IsArrayCapableKey(key) { f.t.Fatalf("seedArrayElements: %#x is not an array-capable key", key) } st, ok := models.JOBSTAT[status] if !ok { f.t.Fatalf("seedArrayElements: unknown status %q", status) } out := make([]models.JobInfo, 0, len(idx)) for _, k := range idx { elem := backend.MakeElementID(key, k) out = append(out, f.seedJob(models.JobInfo{ Unique_JobID: int64(elem), JobID: int64(backend.ExtractArrayBaseID(elem)), ArrayIdx: backend.ExtractArrayIdx(elem), JobName: name, JobStatus: st, })) } return out } // do sends a request with an optional JSON body, signed with the fixture // token, and returns the recorded response. func (f *handlerFixture) do(method, path string, body interface{}) *httptest.ResponseRecorder { f.t.Helper() var buf bytes.Buffer if body != nil { if err := json.NewEncoder(&buf).Encode(body); err != nil { f.t.Fatalf("encode request body: %v", err) } } req := httptest.NewRequest(method, path, &buf) req.RemoteAddr = "127.0.0.1:0" req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+f.token) w := httptest.NewRecorder() f.router.ServeHTTP(w, req) return w } // submit posts a submit body to CreateJob. The body is a JSON object as a // client sends it (models.JobSubmitRequest is not used directly: its zero // CustomTime fields do not marshal into a form the strict binder accepts). func (f *handlerFixture) submit(req map[string]interface{}) *httptest.ResponseRecorder { f.t.Helper() return f.do(http.MethodPost, "/xce/v1/jobs/create", req) } // controlByID posts a control action to ControlJobByID for jobids (the // path segment as written, e.g. "123" or "123[1-3]"). func (f *handlerFixture) controlByID(jobids, action string) *httptest.ResponseRecorder { f.t.Helper() return f.do(http.MethodPost, "/xce/v1/jobs/"+jobids+"/control", models.JobControlJobIDRequest{Action: action}) } // controlByFilter posts a control request to the filter-based ControlJob. func (f *handlerFixture) controlByFilter(req models.JobControlRequest) *httptest.ResponseRecorder { f.t.Helper() return f.do(http.MethodPost, "/xce/v1/jobs/control", req) } // dispatchedIDs returns the unique_job_id of every controlJob call, in order. func (f *handlerFixture) dispatchedIDs() []uint64 { f.mu.Lock() defer f.mu.Unlock() ids := make([]uint64, 0, len(f.dispatched)) for _, d := range f.dispatched { ids = append(ids, d.UniqueJobID) } return ids } // submissions returns a copy of every submitJob call. func (f *handlerFixture) submissions() []submittedJob { f.mu.Lock() defer f.mu.Unlock() return append([]submittedJob(nil), f.submitted...) } // jobIDString renders a job ID as the decimal path segment the REST API takes. func jobIDString(id uint64) string { return strconv.FormatUint(id, 10) } // handlerResponse is the envelope ReturnSuccess/ReturnError write. type handlerResponse struct { Code int `json:"code"` Msg interface{} `json:"msg"` Data json.RawMessage `json:"data"` Details json.RawMessage `json:"details"` } // decodeHandlerResponse decodes the response envelope. func decodeHandlerResponse(t *testing.T, w *httptest.ResponseRecorder) handlerResponse { t.Helper() var r handlerResponse if err := json.Unmarshal(w.Body.Bytes(), &r); err != nil { t.Fatalf("decode response: %v; body: %s", err, w.Body.String()) } return r } // ========== Smoke tests (task 5.4) ========== // TestJobHandler_SmokeSubmitOrdinaryJob submits an ordinary job through the // generateJobID/submitJob seams. func TestJobHandler_SmokeSubmitOrdinaryJob(t *testing.T) { f := newHandlerFixture(t) f.seedQueue("normal") w := f.submit(map[string]interface{}{"job_name": "smoke", "command": "sleep 1", "queue_name": "normal"}) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } resp := decodeHandlerResponse(t, w) if resp.Code != 201 { t.Fatalf("code = %d, want 201; body: %s", resp.Code, w.Body.String()) } var data models.JobSubmitResponse if err := json.Unmarshal(resp.Data, &data); err != nil { t.Fatalf("decode data: %v", err) } if uint64(data.JobID) != handlerTestOrdinaryBase { t.Errorf("jobid = %d, want %d", data.JobID, handlerTestOrdinaryBase) } if len(f.generateCalls) != 1 || f.generateCalls[0] != 1 { t.Errorf("generateJobID calls = %v, want [1]", f.generateCalls) } if len(f.arrayCalls) != 0 { t.Errorf("generateArrayJobID calls = %v, want none", f.arrayCalls) } subs := f.submissions() if len(subs) != 1 { t.Fatalf("submitJob calls = %d, want 1", len(subs)) } if got := uint64(subs[0].Job.Unique_JobID); got != handlerTestOrdinaryBase { t.Errorf("submitted unique_job_id = %d, want %d", got, handlerTestOrdinaryBase) } if subs[0].ArraySize != 1 || subs[0].Job.JobName != "smoke" || subs[0].Job.ArrayIdx != 0 { t.Errorf("submitted = {size %d, name %q, idx %d}, want {1, smoke, 0}", subs[0].ArraySize, subs[0].Job.JobName, subs[0].Job.ArrayIdx) } } // TestJobHandler_SmokeSubmitErrorIs500 checks a submitJob failure surfaces // as a 500 and nothing is recorded. func TestJobHandler_SmokeSubmitErrorIs500(t *testing.T) { f := newHandlerFixture(t) f.submitErr = errors.New("backend down") w := f.submit(map[string]interface{}{"job_name": "smoke", "command": "sleep 1"}) if w.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500; body: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "backend down") { t.Errorf("body does not carry the submit error: %s", w.Body.String()) } if len(f.submissions()) != 0 { t.Errorf("submissions recorded despite error: %v", f.submissions()) } } // TestJobHandler_SmokeControlByID controls one seeded ordinary job by ID // and checks the dispatch is recorded once with its unique_job_id. func TestJobHandler_SmokeControlByID(t *testing.T) { f := newHandlerFixture(t) const id = uint64(1)<<40 | 7 f.seedOrdinaryJob(id, "victim", "RUN") f.seedOrdinaryJob(id+1, "bystander", "RUN") w := f.controlByID(jobIDString(id), "kill") if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } if resp := decodeHandlerResponse(t, w); resp.Code != 202 { t.Fatalf("code = %d, want 202; body: %s", resp.Code, w.Body.String()) } got := f.dispatchedIDs() if len(got) != 1 || got[0] != id { t.Fatalf("dispatched = %v, want [%d]", got, id) } if f.dispatched[0].Action != models.JOB_KILL { t.Errorf("dispatched action = %d, want JOB_KILL (%d)", f.dispatched[0].Action, models.JOB_KILL) } } // TestJobHandler_SmokeControlByFilter controls one seeded job through the // filter-based ControlJob and checks the dispatch is recorded. func TestJobHandler_SmokeControlByFilter(t *testing.T) { f := newHandlerFixture(t) const id = uint64(1)<<40 | 9 f.seedOrdinaryJob(id, "filtered", "RUN") f.seedOrdinaryJob(id+1, "other", "RUN") w := f.controlByFilter(models.JobControlRequest{ Filter: models.JobControlFilter{JobName: "filtered"}, Action: "kill", }) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } got := f.dispatchedIDs() if len(got) != 1 || got[0] != id { t.Fatalf("dispatched = %v, want [%d]", got, id) } } // TestJobHandler_SmokeControlErrorIs500 checks a controlJob failure is a 500. func TestJobHandler_SmokeControlErrorIs500(t *testing.T) { f := newHandlerFixture(t) const id = uint64(1)<<40 | 11 f.seedOrdinaryJob(id, "victim", "RUN") f.controlErr = errors.New("backend down") w := f.controlByID(jobIDString(id), "kill") if w.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500; body: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "backend down") { t.Errorf("body does not carry the control error: %s", w.Body.String()) } } // ========== Array submission (task 6.2) ========== // submitData decodes the JobSubmitResponse of a successful submit. func submitData(t *testing.T, w *httptest.ResponseRecorder) models.JobSubmitResponse { t.Helper() if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } resp := decodeHandlerResponse(t, w) if resp.Code != 201 { t.Fatalf("code = %d, want 201; body: %s", resp.Code, w.Body.String()) } var data models.JobSubmitResponse if err := json.Unmarshal(resp.Data, &data); err != nil { t.Fatalf("decode data: %v", err) } return data } // assertNothingCreated checks no ID was allocated and nothing submitted (2.10). func (f *handlerFixture) assertNothingCreated() { f.t.Helper() if len(f.generateCalls) != 0 || len(f.arrayCalls) != 0 { f.t.Errorf("ID allocation called: generateJobID %v, generateArrayJobID %v; want none", f.generateCalls, f.arrayCalls) } if subs := f.submissions(); len(subs) != 0 { f.t.Errorf("submitJob called %d times, want 0", len(subs)) } } // TestJobHandler_SubmitArrayRange: arr[1-3] creates one 3-element array // through generateArrayJobID, stores the name "arr" and reports the array // identity and size (1.1, 1.3, 3.1, 3.2). func TestJobHandler_SubmitArrayRange(t *testing.T) { f := newHandlerFixture(t) f.seedQueue("normal") data := submitData(t, f.submit(map[string]interface{}{ "job_name": "arr[1-3]", "command": "sleep 1", "queue_name": "normal"})) first := backend.MakeElementID(handlerTestArrayBase, 1) if len(f.arrayCalls) != 1 || f.arrayCalls[0] != 3 { t.Fatalf("generateArrayJobID calls = %v, want [3]", f.arrayCalls) } if len(f.generateCalls) != 0 { t.Errorf("generateJobID calls = %v, want none", f.generateCalls) } subs := f.submissions() if len(subs) != 1 { t.Fatalf("submitJob calls = %d, want 1", len(subs)) } s := subs[0] if s.ArraySize != 3 { t.Errorf("submitted array size = %d, want 3", s.ArraySize) } if s.Job.JobName != "arr" { t.Errorf("stored job name = %q, want %q", s.Job.JobName, "arr") } if uint64(s.Job.Unique_JobID) != first || uint64(s.Job.JobID) != handlerTestArrayBase || s.Job.ArrayIdx != 1 { t.Errorf("submitted ids = {unique %#x, job %#x, idx %d}, want {%#x, %#x, 1}", s.Job.Unique_JobID, s.Job.JobID, s.Job.ArrayIdx, first, handlerTestArrayBase) } // Each element gets a distinct identity: first + i (1.3). for i := 0; i < s.ArraySize; i++ { if backend.ExtractArrayIdx(first+uint64(i)) != i+1 || backend.ExtractArrayBaseID(first+uint64(i)) != handlerTestArrayBase { t.Errorf("element %d identity %#x does not decode to [%d] of the array", i, first+uint64(i), i+1) } } if uint64(data.JobID) != handlerTestArrayBase { t.Errorf("response jobid = %d, want array id %d", data.JobID, handlerTestArrayBase) } if data.ArraySize != 3 { t.Errorf("response array_size = %d, want 3", data.ArraySize) } wantMsg := "Job <" + jobIDString(handlerTestArrayBase) + "> is submitted to queue (3 elements)." if data.Message != wantMsg { t.Errorf("response message = %q, want %q", data.Message, wantMsg) } } // TestJobHandler_SubmitOneElementArray: name[1] is a one-element array // (1.2, 7.8) through generateArrayJobID with n = 1. func TestJobHandler_SubmitOneElementArray(t *testing.T) { f := newHandlerFixture(t) f.seedQueue("normal") data := submitData(t, f.submit(map[string]interface{}{ "job_name": "name[1]", "command": "sleep 1", "queue_name": "normal"})) if len(f.arrayCalls) != 1 || f.arrayCalls[0] != 1 { t.Fatalf("generateArrayJobID calls = %v, want [1]", f.arrayCalls) } if len(f.generateCalls) != 0 { t.Errorf("generateJobID calls = %v, want none", f.generateCalls) } subs := f.submissions() if len(subs) != 1 || subs[0].ArraySize != 1 || subs[0].Job.JobName != "name" || subs[0].Job.ArrayIdx != 1 { t.Fatalf("submissions = %+v, want one {size 1, name \"name\", idx 1}", subs) } if data.ArraySize != 1 { t.Errorf("response array_size = %d, want 1", data.ArraySize) } if uint64(data.JobID) != handlerTestArrayBase { t.Errorf("response jobid = %d, want array id %d", data.JobID, handlerTestArrayBase) } } // TestJobHandler_SubmitArrayExprRejected: index-expression errors are 400 // with the parser's message unchanged, and nothing is created (2.x, 2.10). func TestJobHandler_SubmitArrayExprRejected(t *testing.T) { cases := []struct { name string jobName string interactive int wantMsg string }{ {"2.5 start 0", "arr[0-3]", 0, "job name 'arr[0-3]': element numbers start at 1"}, {"2.1 interactive", "arr[1-3]", 1, "job name 'arr[1-3]': interactive jobs cannot be arrays"}, {"2.3 list", "arr[1,2]", 0, ""}, {"2.7 start above 1", "arr[5]", 0, ""}, {"2.8 above limit", "arr[1-1001]", 0, ""}, {"2.2 malformed", "arr]", 0, ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f := newHandlerFixture(t) f.seedQueue("normal") want := tc.wantMsg if want == "" { _, _, _, ae := ParseSubmitIndexExpr(tc.jobName, tc.interactive != 0, 1000) if ae == nil { t.Fatalf("parser accepted %q", tc.jobName) } want = ae.Msg } w := f.submit(map[string]interface{}{"job_name": tc.jobName, "command": "sleep 1", "queue_name": "normal", "interactive": tc.interactive}) if w.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body: %s", w.Code, w.Body.String()) } resp := decodeHandlerResponse(t, w) if resp.Code != 400 || resp.Msg != want { t.Errorf("response = {%d, %v}, want {400, %q}", resp.Code, resp.Msg, want) } f.assertNothingCreated() }) } } // TestJobHandler_SubmitPlainNamesUnchanged: names without '[' or ']' keep // today's errors and 500 status and never create an array (1.4). func TestJobHandler_SubmitPlainNamesUnchanged(t *testing.T) { cases := []struct{ jobName, wantMsg string }{ {"my job", "job array list format is invalid: arrayName 'my job' contains invalid characters. Allowed characters are [a-zA-Z0-9._-]"}, {"foo%5", "job array list format is invalid: arrayName 'foo%5' contains invalid characters. Allowed characters are [a-zA-Z0-9._-]"}, {"foo%", "job array list format is invalid: invalid slot limit value '': must be a positive integer"}, } for _, tc := range cases { t.Run(tc.jobName, func(t *testing.T) { f := newHandlerFixture(t) f.seedQueue("normal") w := f.submit(map[string]interface{}{"job_name": tc.jobName, "command": "sleep 1", "queue_name": "normal"}) if w.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500; body: %s", w.Code, w.Body.String()) } if resp := decodeHandlerResponse(t, w); resp.Code != 500 || resp.Msg != tc.wantMsg { t.Errorf("response = {%d, %v}, want {500, %q}", resp.Code, resp.Msg, tc.wantMsg) } f.assertNothingCreated() }) } } // TestJobHandler_SubmitOrdinaryUnchanged: an ordinary job is accepted (no // typed-nil error leaks out of the pre-check), reported as before and its // response carries no array_size (3.3). func TestJobHandler_SubmitOrdinaryUnchanged(t *testing.T) { f := newHandlerFixture(t) f.seedQueue("normal") w := f.submit(map[string]interface{}{"job_name": "plain", "command": "sleep 1", "queue_name": "normal"}) data := submitData(t, w) if strings.Contains(w.Body.String(), "array_size") { t.Errorf("ordinary submit response carries array_size: %s", w.Body.String()) } wantMsg := "Job <" + jobIDString(handlerTestOrdinaryBase) + "> is submitted to default queue ." if data.Message != wantMsg || uint64(data.JobID) != handlerTestOrdinaryBase { t.Errorf("response = {%d, %q}, want {%d, %q}", data.JobID, data.Message, handlerTestOrdinaryBase, wantMsg) } if len(f.generateCalls) != 1 || f.generateCalls[0] != 1 || len(f.arrayCalls) != 0 { t.Errorf("allocation = generateJobID %v, generateArrayJobID %v; want [1], none", f.generateCalls, f.arrayCalls) } // Unnamed job: still ordinary. w = f.submit(map[string]interface{}{"command": "sleep 2", "queue_name": "normal"}) submitData(t, w) if len(f.arrayCalls) != 0 { t.Errorf("unnamed job allocated an array: %v", f.arrayCalls) } } // TestJobHandler_SubmitArrayFileNameSubstitution: at submission a new // array's file names get the first element's values, %I = 1 and // %J = [1] (9.4). func TestJobHandler_SubmitArrayFileNameSubstitution(t *testing.T) { f := newHandlerFixture(t) f.seedQueue("normal") submitData(t, f.submit(map[string]interface{}{ "job_name": "arr[1-3]", "command": "sleep 1", "queue_name": "normal", "out_file": "out.%J.%I", "err_file": "err.%J.%I"})) subs := f.submissions() if len(subs) != 1 { t.Fatalf("submitJob calls = %d, want 1", len(subs)) } // %J is the first element's identity, written [1]. elem := backend.FormatJobID(int64(handlerTestArrayBase)) + "[1]" if got := backend.FormatJobID(int64(backend.MakeElementID(handlerTestArrayBase, 1))); got != elem { t.Fatalf("first element formats as %q, want %q", got, elem) } wantOut, wantErr := "out."+elem+".1", "err."+elem+".1" if subs[0].Job.OutFile != wantOut || subs[0].Job.ErrFile != wantErr { t.Errorf("files = {%q, %q}, want {%q, %q}", subs[0].Job.OutFile, subs[0].Job.ErrFile, wantOut, wantErr) } } // TestJobHandler_SubmitArrayLogsArrayIdentity: CreateJob logs a new array // by its array identity and size, not by the raw element ID (5.9). func TestJobHandler_SubmitArrayLogsArrayIdentity(t *testing.T) { f := newHandlerFixture(t) f.seedQueue("normal") core, logs := observer.New(zapcore.InfoLevel) utils.Log = zap.New(core) submitData(t, f.submit(map[string]interface{}{ "job_name": "arr[1-3]", "command": "sleep 1", "queue_name": "normal"})) wantID := backend.FormatJobID(int64(handlerTestArrayBase)) for _, msg := range []string{"Generated job ID", "Job submission successfully from "} { entries := logs.FilterMessage(msg).All() if len(entries) != 1 { t.Fatalf("log %q: %d entries, want 1", msg, len(entries)) } fields := entries[0].ContextMap() if fields["array_jobid"] != wantID { t.Errorf("log %q: array_jobid = %v, want %q", msg, fields["array_jobid"], wantID) } if fields["array_size"] != int64(3) { t.Errorf("log %q: array_size = %v (%T), want 3", msg, fields["array_size"], fields["array_size"]) } if _, ok := fields["jobID"]; ok { t.Errorf("log %q: carries the raw jobID for an array: %v", msg, fields) } } } // ========== Query by selection (task 7.3, bug-1898) ========== // querySeed is the store content of the query tests: arrays X ("foo", // elements 1-4), Y ("foobar", elements 1-2), Z (unnamed, element 1) and W // ("baz", elements 1-2), and the ordinary job O ("bar"). type querySeed struct { X, Y, Z, W, O uint64 } func (s querySeed) elem(key uint64, k int) uint64 { return backend.MakeElementID(key, k) } func seedQueryJobs(f *handlerFixture) querySeed { f.t.Helper() s := querySeed{ X: handlerTestArrayBase + 10<<15, Y: handlerTestArrayBase + 11<<15, Z: handlerTestArrayBase + 12<<15, W: handlerTestArrayBase + 13<<15, // array-capable (low 15 bits clear) but ordinary: element-only // matching must not select it for O[0] (5.11) O: handlerTestOrdinaryBase + 20<<15, } f.seedArrayElements(s.X, "foo", "RUN", 1, 2, 3, 4) f.seedArrayElements(s.Y, "foobar", "RUN", 1, 2) f.seedArrayElements(s.Z, "", "RUN", 1) f.seedArrayElements(s.W, "baz", "RUN", 1, 2) f.seedOrdinaryJob(s.O, "bar", "RUN") return s } // allQueryIDs is every unique_job_id seedQueryJobs stores. func (s querySeed) all() []uint64 { return []uint64{ s.elem(s.X, 1), s.elem(s.X, 2), s.elem(s.X, 3), s.elem(s.X, 4), s.elem(s.Y, 1), s.elem(s.Y, 2), s.elem(s.Z, 1), s.elem(s.W, 1), s.elem(s.W, 2), s.O, } } // listJobs calls GetJobsInfo with filter (URL-escaped here). func (f *handlerFixture) listJobs(filter string) *httptest.ResponseRecorder { f.t.Helper() return f.do(http.MethodGet, "/xce/v1/jobs?filter="+url.QueryEscape(filter), nil) } // getJobs calls GetJobByIDs with the path segment jobids (URL-escaped here). func (f *handlerFixture) getJobs(jobids string) *httptest.ResponseRecorder { f.t.Helper() return f.do(http.MethodGet, "/xce/v1/jobs/"+url.PathEscape(jobids), nil) } // rowIDs returns the sorted unique_job_id of every row of a 200 list reply. func rowIDs(t *testing.T, w *httptest.ResponseRecorder) []uint64 { t.Helper() if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } dec := json.NewDecoder(bytes.NewReader(w.Body.Bytes())) dec.UseNumber() var raw struct { Data struct { Count int `json:"count"` Rows []struct { Data map[string]interface{} `json:"Data"` } `json:"rows"` } `json:"data"` } if err := dec.Decode(&raw); err != nil { t.Fatalf("decode list reply: %v; body: %s", err, w.Body.String()) } ids := make([]uint64, 0, len(raw.Data.Rows)) for _, r := range raw.Data.Rows { n, ok := r.Data["unique_job_id"].(json.Number) if !ok { t.Fatalf("row without unique_job_id: %v", r.Data) } id, err := strconv.ParseUint(n.String(), 10, 64) if err != nil { t.Fatalf("unique_job_id %q: %v", n, err) } ids = append(ids, id) } if raw.Data.Count != len(ids) { t.Errorf("count = %d, rows = %d", raw.Data.Count, len(ids)) } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) return ids } func sortedIDs(ids ...uint64) []uint64 { out := append([]uint64{}, ids...) sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) return out } func assertIDs(t *testing.T, name string, got, want []uint64) { t.Helper() want = sortedIDs(want...) if len(got) != len(want) { t.Errorf("%s: rows %v, want %v", name, got, want) return } for i := range got { if got[i] != want[i] { t.Errorf("%s: rows %v, want %v", name, got, want) return } } } // assert400 checks a 400 reply whose message contains want. func assert400(t *testing.T, name string, w *httptest.ResponseRecorder, want string) { t.Helper() if w.Code != http.StatusBadRequest { t.Errorf("%s: status = %d, want 400; body: %s", name, w.Code, w.Body.String()) return } r := decodeHandlerResponse(t, w) msg, _ := r.Msg.(string) if !strings.Contains(msg, want) { t.Errorf("%s: msg = %q, want it to contain %q", name, msg, want) } if len(r.Data) != 0 && string(r.Data) != "null" { t.Errorf("%s: a rejected query carries data: %s", name, r.Data) } } // TestJobHandler_QuerySelections covers the GetJobsInfo rows of the design's // Testing Strategy (Query). func TestJobHandler_QuerySelections(t *testing.T) { f := newHandlerFixture(t) s := seedQueryJobs(f) x, y, z, w := s.X, s.Y, s.Z, s.W X := jobIDString(x) Y := jobIDString(y) O := jobIDString(s.O) writtenX := backend.FormatJobID(int64(x)) cases := []struct { name string filter string want []uint64 }{ {"eq single element (bug-1898)", "{job_id:eq:" + X + "[2]}", []uint64{s.elem(x, 2)}}, {"eq range", "{job_id:eq:" + X + "[2-3]}", []uint64{s.elem(x, 2), s.elem(x, 3)}}, {"ordinary-id[0] lists nothing (5.11)", "{job_id:eq:" + O + "[0]}", nil}, {"array-id[0] without element 0 lists nothing (5.11)", "{job_id:eq:" + X + "[0]}", nil}, {"unnamed: eq [1] lists nothing", "{job_name:eq:[1]}", nil}, {"unnamed: contains [1] lists element 1 of every array", "{job_name:contains:[1]}", []uint64{s.elem(x, 1), s.elem(y, 1), s.elem(z, 1), s.elem(w, 1)}}, {"notcontains foo[1]", "{job_name:notcontains:foo[1]}", []uint64{s.elem(z, 1), s.elem(w, 1)}}, {"in + ne entries on the same field are AND'ed", "{job_id:in:(" + X + "[1]," + O + ")},{job_id:ne:" + O + "}", []uint64{s.elem(x, 1)}}, {"job_id and job_name selections intersect", "{job_id:in:(" + X + "[1]," + X + "[2])},{job_name:eq:foo[2]}", []uint64{s.elem(x, 2)}}, {"in with a bare and a bracketed value matches both", "{job_id:in:(" + O + "," + X + "[1])}", []uint64{s.O, s.elem(x, 1)}}, {"notin excludes the element", "{job_id:notin:(" + X + "[1])}", []uint64{s.elem(x, 2), s.elem(x, 3), s.elem(x, 4), s.elem(y, 1), s.elem(y, 2), s.elem(z, 1), s.elem(w, 1), s.elem(w, 2), s.O}}, {"startwith foo[1]", "{job_name:startwith:foo[1]}", []uint64{s.elem(x, 1), s.elem(y, 1)}}, {"written bare key alone (5.6)", "{job_id:eq:" + writtenX + "}", []uint64{s.elem(x, 1), s.elem(x, 2), s.elem(x, 3), s.elem(x, 4)}}, {"written bare key mixed with a bracketed value (5.6)", "{job_id:in:(" + writtenX + "," + Y + "[1])}", []uint64{s.elem(x, 1), s.elem(x, 2), s.elem(x, 3), s.elem(x, 4), s.elem(y, 1)}}, {"between on job_id evaluates like in", "{job_id:between:(" + X + "[1]," + Y + "[2])}", []uint64{s.elem(x, 1), s.elem(y, 2)}}, } for _, c := range cases { assertIDs(t, c.name, rowIDs(t, f.listJobs(c.filter)), c.want) } // sanity: an unfiltered (default submit_user) listing sees every seeded row assertIDs(t, "all rows", rowIDs(t, f.listJobs("{submit_user:eq:"+handlerTestUser+"}")), s.all()) } // TestJobHandler_QuerySelectionRejections: rejected selections reject the // whole query with 400 and list nothing. func TestJobHandler_QuerySelectionRejections(t *testing.T) { f := newHandlerFixture(t) s := seedQueryJobs(f) X := jobIDString(s.X) O := jobIDString(s.O) // a host exists (so GetHostList accepts it) but runs no job, so the // exec_hosts lookup alone would answer count 0 store.Hosts.Put("h1", models.HostInfo{HostName: "h1"}) cases := []struct { name, filter, want string }{ {"5.7 with an exec_hosts filter matching no job", "{exec_hosts:eq:h1},{job_id:eq:" + X + "[40000]}", "invalid"}, {"null operator on a bracketed name", "{job_name:notnull:foo[1]}", "operator doesn't support"}, {"ne on a bracketed identity", "{job_id:ne:" + X + "[1]}", "operator doesn't support"}, {"valid selection followed by an invalid one", "{job_id:eq:" + X + "[1]},{job_name:eq:foo[5-3]}", "reversed"}, {"5.8: element of a non-array identity", "{job_id:eq:" + jobIDString(s.O+1) + "[1]}", "not an array identity"}, {"5.16 syntax", "{job_id:eq:" + X + "[a]}", "invalid job identity or element selection"}, {"5.17 marked decimal without index", "{job_id:eq:" + jobIDString(s.elem(s.X, 1)) + "}", "must be named as"}, {"5.14 too-wide written key", "{job_id:in:(" + O + ",64-0-1-0[1])}", "wider than allowed"}, } for _, c := range cases { assert400(t, c.name, f.listJobs(c.filter), c.want) } // the exec_hosts row's message names the selection (5.7) and is not // replaced by "bad job array index" w := f.listJobs("{exec_hosts:eq:h1},{job_id:eq:" + X + "[40000]}") if strings.Contains(w.Body.String(), "bad job array index") { t.Errorf("message replaced by the old text: %s", w.Body.String()) } if !strings.Contains(w.Body.String(), X+"[40000]") { t.Errorf("message does not name the selection: %s", w.Body.String()) } } // TestJobHandler_GetJobByIDsSelections covers GET /jobs/{jobids}. func TestJobHandler_GetJobByIDsSelections(t *testing.T) { f := newHandlerFixture(t) s := seedQueryJobs(f) X := jobIDString(s.X) O := jobIDString(s.O) assertIDs(t, "X[1-3]", rowIDs(t, f.getJobs(X+"[1-3]")), []uint64{s.elem(s.X, 1), s.elem(s.X, 2), s.elem(s.X, 3)}) assertIDs(t, "X[2],Y[1]", rowIDs(t, f.getJobs(X+"[2],"+jobIDString(s.Y)+"[1]")), []uint64{s.elem(s.X, 2), s.elem(s.Y, 1)}) assertIDs(t, "bare X lists every element (5.6)", rowIDs(t, f.getJobs(X)), []uint64{s.elem(s.X, 1), s.elem(s.X, 2), s.elem(s.X, 3), s.elem(s.X, 4)}) assertIDs(t, "written bare O", rowIDs(t, f.getJobs(backend.FormatJobID(int64(s.O)))), []uint64{s.O}) assertIDs(t, "O[0] lists nothing (5.11)", rowIDs(t, f.getJobs(O+"[0]")), nil) assert400(t, "valid then invalid", f.getJobs(X+"[1],"+X+"[40000]"), X+"[40000]") assert400(t, "5.17", f.getJobs(jobIDString(s.elem(s.X, 1))), "must be named as") assert400(t, "5.14", f.getJobs("64-0-1-0"), "wider than allowed") assert400(t, "5.5", f.getJobs(X+"[3-1]"), "reversed") }