myxce/src/frontend/apiserver/controllers/jobs_handler_test.go
Rongsong Shen c73708b81f
fix(apiserver): restart copies the named job, not the first stored one (bug-1898)
RestartJobByID looked the job up with a job_id CompCondition, which the
store never evaluates, and restarted jqueryReply[0] -- the first stored
job -- for any ID, including unknown ones. It now parses the ID with
ParseIdentitySelection, fetches the rows by key and matches them with
foundJob, as ControlJobByID does: an ordinary job or <array-id>[k] is
restarted, an unknown job or element is not found, and a bare array
identity (several elements) is refused. Restart goes through the
generateJobID/submitJob seams so the handler fixture can test it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 03:44:34 +00:00

1509 lines
55 KiB
Go

//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("/:jobids/restart", jc.RestartJobByID)
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 <normal> (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 <normal>."
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 = <array-id>[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 <array-id>[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")
}
// ========== Control by identity (task 7.4, bug-1899) ==========
// controlItem is one accepted or failed item of a control reply.
type controlItem struct {
UniqueJobID uint64
HasUnique bool
JobID uint64
ArrayIdx int
Msg string
}
// controlReply is the data of a 202 control reply.
type controlReply struct {
Status string
Accepted []controlItem
Failed []controlItem
}
// decodeControlReply decodes a successful (202) control reply.
func decodeControlReply(t *testing.T, w *httptest.ResponseRecorder) controlReply {
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 {
Code int `json:"code"`
Data struct {
Status string `json:"status"`
Accepted []map[string]interface{} `json:"accepted"`
Failed []map[string]interface{} `json:"failed"`
} `json:"data"`
}
if err := dec.Decode(&raw); err != nil {
t.Fatalf("decode control reply: %v; body: %s", err, w.Body.String())
}
if raw.Code != 202 {
t.Fatalf("code = %d, want 202; body: %s", raw.Code, w.Body.String())
}
num := func(m map[string]interface{}, k string) (uint64, bool) {
n, ok := m[k].(json.Number)
if !ok {
return 0, false
}
v, err := strconv.ParseUint(n.String(), 10, 64)
if err != nil {
// job_id/unique_job_id are int64 on the wire; keep the bit pattern
s, err2 := strconv.ParseInt(n.String(), 10, 64)
if err2 != nil {
t.Fatalf("%s %q: %v", k, n, err)
}
v = uint64(s)
}
return v, true
}
conv := func(in []map[string]interface{}) []controlItem {
out := make([]controlItem, 0, len(in))
for _, m := range in {
var it controlItem
it.UniqueJobID, it.HasUnique = num(m, "unique_job_id")
it.JobID, _ = num(m, "job_id")
idx, _ := num(m, "array_idx")
it.ArrayIdx = int(idx)
it.Msg, _ = m["msg"].(string)
out = append(out, it)
}
return out
}
return controlReply{Status: raw.Data.Status, Accepted: conv(raw.Data.Accepted), Failed: conv(raw.Data.Failed)}
}
// itemIDs returns the unique_job_id of every item, in reply order.
func itemIDs(t *testing.T, items []controlItem) []uint64 {
t.Helper()
ids := make([]uint64, 0, len(items))
for _, it := range items {
if !it.HasUnique {
t.Errorf("result item without unique_job_id: %+v", it)
}
ids = append(ids, it.UniqueJobID)
}
return ids
}
// assertExactIDs checks got equals want element by element (order kept).
func assertExactIDs(t *testing.T, name string, got, want []uint64) {
t.Helper()
if len(got) != len(want) {
t.Errorf("%s: %v, want %v", name, got, want)
return
}
for i := range got {
if got[i] != want[i] {
t.Errorf("%s: %v, want %v", name, got, want)
return
}
}
}
// assertSeeded fails the test unless every id is in store.Jobs, so an
// act-on-no-job assertion cannot pass on an empty store.
func assertSeeded(t *testing.T, ids ...uint64) {
t.Helper()
for _, id := range ids {
if !store.Jobs.Has(id) {
t.Fatalf("fixture row %d missing from store.Jobs", id)
}
}
}
// controlByIDEscaped posts a control action for a selection list, escaping
// the path segment.
func (f *handlerFixture) controlByIDEscaped(jobids, action string) *httptest.ResponseRecorder {
f.t.Helper()
return f.controlByID(url.PathEscape(jobids), action)
}
// TestJobHandler_ControlTwoSelectionsDispatchOnce (bug-1899): a request with
// two selections dispatches each selected element exactly once and lists
// each once; the same holds for two ordinary jobs.
func TestJobHandler_ControlTwoSelectionsDispatchOnce(t *testing.T) {
f := newHandlerFixture(t)
X := handlerTestArrayBase + 30<<15
Y := handlerTestArrayBase + 31<<15
f.seedArrayElements(X, "foo", "RUN", 1, 2)
f.seedArrayElements(Y, "bar", "RUN", 1, 2)
A := handlerTestOrdinaryBase + 1
B := handlerTestOrdinaryBase + 2
f.seedOrdinaryJob(A, "a", "RUN")
f.seedOrdinaryJob(B, "b", "RUN")
x1, y2 := backend.MakeElementID(X, 1), backend.MakeElementID(Y, 2)
r := decodeControlReply(t, f.controlByIDEscaped(jobIDString(X)+"[1],"+jobIDString(Y)+"[2]", "kill"))
assertExactIDs(t, "X[1],Y[2] dispatched", f.dispatchedIDs(), []uint64{x1, y2})
assertExactIDs(t, "X[1],Y[2] accepted", itemIDs(t, r.Accepted), []uint64{x1, y2})
if len(r.Failed) != 0 {
t.Errorf("X[1],Y[2] failed items: %+v", r.Failed)
}
f.dispatched = nil
r = decodeControlReply(t, f.controlByIDEscaped(jobIDString(A)+","+jobIDString(B), "kill"))
assertExactIDs(t, "A,B dispatched", f.dispatchedIDs(), []uint64{A, B})
assertExactIDs(t, "A,B accepted", itemIDs(t, r.Accepted), []uint64{A, B})
// a job selected by two selections is acted on once (de-duplicated by
// unique_job_id)
f.dispatched = nil
x2 := backend.MakeElementID(X, 2)
r = decodeControlReply(t, f.controlByIDEscaped(jobIDString(X)+","+jobIDString(X)+"[2]", "kill"))
assertExactIDs(t, "X,X[2] dispatched", f.dispatchedIDs(), []uint64{x1, x2})
assertExactIDs(t, "X,X[2] accepted", itemIDs(t, r.Accepted), []uint64{x1, x2})
}
// TestJobHandler_ControlRangeReportsMissing (5.3): X[1-3] with element 2
// missing dispatches elements 1 and 3 and reports 2 as not found, carrying
// its would-be element ID.
func TestJobHandler_ControlRangeReportsMissing(t *testing.T) {
f := newHandlerFixture(t)
X := handlerTestArrayBase + 32<<15
f.seedArrayElements(X, "foo", "RUN", 1, 3, 4)
x1, x2, x3 := backend.MakeElementID(X, 1), backend.MakeElementID(X, 2), backend.MakeElementID(X, 3)
r := decodeControlReply(t, f.controlByIDEscaped(jobIDString(X)+"[1-3]", "kill"))
assertExactIDs(t, "dispatched", f.dispatchedIDs(), []uint64{x1, x3})
assertExactIDs(t, "accepted", itemIDs(t, r.Accepted), []uint64{x1, x3})
assertExactIDs(t, "failed", itemIDs(t, r.Failed), []uint64{x2})
if len(r.Failed) == 1 {
it := r.Failed[0]
if it.Msg != "job not found" || it.JobID != X || it.ArrayIdx != 2 {
t.Errorf("not-found item = %+v, want job_id %d, array_idx 2, msg \"job not found\"", it, X)
}
}
if r.Status != "partially_accepted" {
t.Errorf("status = %q, want partially_accepted", r.Status)
}
// a range on an identity no array has: every number not found, no job
f.dispatched = nil
Z := handlerTestArrayBase + 33<<15
r = decodeControlReply(t, f.controlByIDEscaped(jobIDString(Z)+"[1-2]", "kill"))
if got := f.dispatchedIDs(); len(got) != 0 {
t.Errorf("Z[1-2] dispatched %v, want none", got)
}
assertExactIDs(t, "Z[1-2] failed", itemIDs(t, r.Failed),
[]uint64{backend.MakeElementID(Z, 1), backend.MakeElementID(Z, 2)})
}
// TestJobHandler_ControlElementZeroMissing (5.4): X[0] with no element 0
// acts on no job -- not on the array, not on an ordinary job with identity
// X -- and reports element 0 as not found.
func TestJobHandler_ControlElementZeroMissing(t *testing.T) {
f := newHandlerFixture(t)
X := handlerTestArrayBase + 34<<15
elems := f.seedArrayElements(X, "foo", "RUN", 1, 2)
// an ordinary job sharing the number X (array_idx 0)
f.seedOrdinaryJob(X, "ordinary", "RUN")
assertSeeded(t, uint64(elems[0].Unique_JobID), uint64(elems[1].Unique_JobID), X)
for _, sel := range []string{"[0]", "[0-0]"} {
f.dispatched = nil
r := decodeControlReply(t, f.controlByIDEscaped(jobIDString(X)+sel, "kill"))
if got := f.dispatchedIDs(); len(got) != 0 {
t.Errorf("X%s dispatched %v, want none", sel, got)
}
if len(r.Accepted) != 0 {
t.Errorf("X%s accepted %+v, want none", sel, r.Accepted)
}
assertExactIDs(t, "X"+sel+" failed", itemIDs(t, r.Failed), []uint64{backend.MakeElementID(X, 0)})
if len(r.Failed) == 1 && r.Failed[0].Msg != "job not found" {
t.Errorf("X%s failed msg = %q, want \"job not found\"", sel, r.Failed[0].Msg)
}
}
}
// TestJobHandler_ControlPreUpgradeElementZero (8.2, 8.3): a pre-upgrade
// element 0 is dispatched alone, also when the same request selects another
// element of that array, and is reported with its own unique_job_id.
func TestJobHandler_ControlPreUpgradeElementZero(t *testing.T) {
f := newHandlerFixture(t)
X := handlerTestArrayBase + 35<<15
f.seedArrayElements(X, "old", "RUN", 0, 1, 2)
x0, x2 := backend.MakeElementID(X, 0), backend.MakeElementID(X, 2)
r := decodeControlReply(t, f.controlByIDEscaped(jobIDString(X)+"[0]", "kill"))
assertExactIDs(t, "X[0] dispatched", f.dispatchedIDs(), []uint64{x0})
assertExactIDs(t, "X[0] accepted", itemIDs(t, r.Accepted), []uint64{x0})
f.dispatched = nil
r = decodeControlReply(t, f.controlByIDEscaped(jobIDString(X)+"[0],"+jobIDString(X)+"[2]", "kill"))
assertExactIDs(t, "X[0],X[2] dispatched", f.dispatchedIDs(), []uint64{x0, x2})
assertExactIDs(t, "X[0],X[2] accepted", itemIDs(t, r.Accepted), []uint64{x0, x2})
}
// TestJobHandler_ControlValidThenInvalid (Terms): a valid selection followed
// by an invalid one rejects the whole command with 400 and dispatches
// nothing, without "not found" items.
func TestJobHandler_ControlValidThenInvalid(t *testing.T) {
f := newHandlerFixture(t)
X := handlerTestArrayBase + 36<<15
elems := f.seedArrayElements(X, "foo", "RUN", 1, 2)
assertSeeded(t, uint64(elems[0].Unique_JobID))
cases := []struct{ sel, want string }{
{jobIDString(X) + "[1]," + jobIDString(X) + "[40000]", "element numbers must be at most"},
{jobIDString(X) + "[1]," + jobIDString(X) + "[3-1]", "reversed"},
{jobIDString(X) + "[1]," + jobIDString(X) + "[a]", "invalid job identity or element selection"},
{jobIDString(X) + "[1]," + jobIDString(uint64(elems[1].Unique_JobID)), "must be named as"},
}
for _, c := range cases {
f.dispatched = nil
assert400(t, c.sel, f.controlByIDEscaped(c.sel, "kill"), c.want)
if got := f.dispatchedIDs(); len(got) != 0 {
t.Errorf("%s: dispatched %v, want none", c.sel, got)
}
}
}
// ========== Control by filter with element selections (task 7.5) ==========
// controlFilterKill posts a kill through the filter-based ControlJob with
// the job-name filter name (the -J value as the client sends it).
func (f *handlerFixture) controlFilterKill(name string) *httptest.ResponseRecorder {
f.t.Helper()
return f.controlByFilter(models.JobControlRequest{
Filter: models.JobControlFilter{JobName: name},
Action: "kill",
})
}
// TestJobHandler_ControlFilterRejectsBadSelection (5.7, 5.16, 5.5): a
// bracketed -J value the name parser rejects returns 400 with its specific
// message before any job is fetched, and dispatches nothing.
func TestJobHandler_ControlFilterRejectsBadSelection(t *testing.T) {
f := newHandlerFixture(t)
X := handlerTestArrayBase + 40<<15
elems := f.seedArrayElements(X, "foo", "RUN", 1, 2)
assertSeeded(t, uint64(elems[0].Unique_JobID))
cases := []struct{ name, want string }{
{"foo[40000]", "element numbers must be at most"},
{"foo[3-1]", "reversed"},
{"foo[a]", "invalid"},
{"foo [1]", "invalid"},
{"foo[1", "invalid"},
}
for _, c := range cases {
f.dispatched = nil
assert400(t, "-J "+c.name, f.controlFilterKill(c.name), c.want)
if got := f.dispatchedIDs(); len(got) != 0 {
t.Errorf("-J %s: dispatched %v, want none", c.name, got)
}
}
}
// TestJobHandler_ControlFilterEmptyNameSelectsNothing (design ApiMatch):
// -J '[1]' (control sends eq) matches no row, not the unnamed array.
func TestJobHandler_ControlFilterEmptyNameSelectsNothing(t *testing.T) {
f := newHandlerFixture(t)
Z := handlerTestArrayBase + 41<<15
elems := f.seedArrayElements(Z, "", "RUN", 1)
assertSeeded(t, uint64(elems[0].Unique_JobID))
w := f.controlFilterKill("[1]")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
if got := f.dispatchedIDs(); len(got) != 0 {
t.Errorf("-J [1] dispatched %v, want none", got)
}
}
// TestJobHandler_ControlFilterMostRecentOneElementArray (5.10): with no
// element number in the filter, when the newest matching job is the element
// of a one-element array, only that element is acted on.
func TestJobHandler_ControlFilterMostRecentOneElementArray(t *testing.T) {
f := newHandlerFixture(t)
O := handlerTestOrdinaryBase + 50
f.seedOrdinaryJob(O, "foo", "RUN")
A := handlerTestArrayBase + 42<<15
a1 := backend.MakeElementID(A, 1)
f.seedArrayElements(A, "foo", "RUN", 1)
assertSeeded(t, O, a1)
r := decodeControlReply(t, f.controlFilterKill("foo"))
assertExactIDs(t, "-J foo dispatched", f.dispatchedIDs(), []uint64{a1})
assertExactIDs(t, "-J foo accepted", itemIDs(t, r.Accepted), []uint64{a1})
// all-jobs: the rule does not apply, every matching job is acted on
f.dispatched = nil
w := f.controlByFilter(models.JobControlRequest{
Filter: models.JobControlFilter{JobName: "foo", AllJobs: true},
Action: "kill",
})
decodeControlReply(t, w)
assertExactIDs(t, "-J foo -a dispatched", f.dispatchedIDs(), []uint64{O, a1})
}
// TestJobHandler_ControlFilterElementSelection (5.2): -J 'foo[1]' acts on
// exactly element 1 of every array named foo -- both one-element arrays and
// element 1 of a larger one -- never on an ordinary job named foo, another
// element, or another name, whatever the newest row is.
func TestJobHandler_ControlFilterElementSelection(t *testing.T) {
f := newHandlerFixture(t)
O := handlerTestOrdinaryBase + 51
f.seedOrdinaryJob(O, "foo", "RUN")
A := handlerTestArrayBase + 43<<15
B := handlerTestArrayBase + 44<<15
C := handlerTestArrayBase + 45<<15
D := handlerTestArrayBase + 46<<15
f.seedArrayElements(A, "foo", "RUN", 1)
f.seedArrayElements(B, "foo", "RUN", 1)
f.seedArrayElements(C, "foo", "RUN", 1, 2)
f.seedArrayElements(D, "bar", "RUN", 1)
a1, b1, c1 := backend.MakeElementID(A, 1), backend.MakeElementID(B, 1), backend.MakeElementID(C, 1)
assertSeeded(t, O, a1, b1, c1, backend.MakeElementID(C, 2), backend.MakeElementID(D, 1))
r := decodeControlReply(t, f.controlFilterKill("foo[1]"))
assertExactIDs(t, "-J foo[1] dispatched", f.dispatchedIDs(), []uint64{a1, b1, c1})
assertExactIDs(t, "-J foo[1] accepted", itemIDs(t, r.Accepted), []uint64{a1, b1, c1})
// the range form 1-1 selects the same element 1 of every array named foo
f.dispatched = nil
r = decodeControlReply(t, f.controlFilterKill("foo[1-1]"))
assertExactIDs(t, "-J foo[1-1] dispatched", f.dispatchedIDs(), []uint64{a1, b1, c1})
}
// TestJobHandler_ControlFilterElementSelectionNewestSingleElement (5.2,
// 5.10): when the most recent job named foo is the element of a one-element
// array, -J 'foo[1]' still acts on element 1 of every array named foo (the
// most-recent rule applies only to filters without an element number),
// while -J 'foo' acts on that most recent element alone.
func TestJobHandler_ControlFilterElementSelectionNewestSingleElement(t *testing.T) {
f := newHandlerFixture(t)
O := handlerTestOrdinaryBase + 53
f.seedOrdinaryJob(O, "foo", "RUN")
A := handlerTestArrayBase + 47<<15
B := handlerTestArrayBase + 48<<15 // highest ID: the newest row
f.seedArrayElements(A, "foo", "RUN", 1)
f.seedArrayElements(B, "foo", "RUN", 1)
a1, b1 := backend.MakeElementID(A, 1), backend.MakeElementID(B, 1)
assertSeeded(t, O, a1, b1)
r := decodeControlReply(t, f.controlFilterKill("foo[1]"))
assertExactIDs(t, "-J foo[1] dispatched", f.dispatchedIDs(), []uint64{a1, b1})
assertExactIDs(t, "-J foo[1] accepted", itemIDs(t, r.Accepted), []uint64{a1, b1})
f.dispatched = nil
r = decodeControlReply(t, f.controlFilterKill("foo"))
assertExactIDs(t, "-J foo dispatched", f.dispatchedIDs(), []uint64{b1})
assertExactIDs(t, "-J foo accepted", itemIDs(t, r.Accepted), []uint64{b1})
}
// TestJobHandler_ControlFilterPlainNameWithSpaces: an unnamed job carries
// its command as its name; kill -J 'sleep 100' keeps working (no brackets,
// no selection parsing).
func TestJobHandler_ControlFilterPlainNameWithSpaces(t *testing.T) {
f := newHandlerFixture(t)
O := handlerTestOrdinaryBase + 52
f.seedOrdinaryJob(O, "sleep 100", "RUN")
f.seedOrdinaryJob(O+1, "sleep 200", "RUN")
r := decodeControlReply(t, f.controlFilterKill("sleep 100"))
assertExactIDs(t, "-J 'sleep 100' dispatched", f.dispatchedIDs(), []uint64{O})
assertExactIDs(t, "-J 'sleep 100' accepted", itemIDs(t, r.Accepted), []uint64{O})
}
// bug-1903: following GetJobsInfo's page_token yields the next page, every
// row exactly once, and the last page carries no token.
func TestJobHandler_QueryPagination(t *testing.T) {
f := newHandlerFixture(t)
s := seedQueryJobs(f)
filter := url.QueryEscape("{submit_user:eq:" + handlerTestUser + "}")
var got []uint64
token := ""
for page := 0; ; page++ {
if page > 10 {
t.Fatalf("paging did not terminate; rows so far %v", got)
}
path := "/xce/v1/jobs?limit=3&filter=" + filter
if token != "" {
path += "&page_token=" + url.QueryEscape(token)
}
w := f.do(http.MethodGet, path, nil)
got = append(got, rowIDs(t, w)...)
var reply struct {
Data struct {
PageToken string `json:"page_token"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &reply); err != nil {
t.Fatalf("decode page_token: %v", err)
}
if reply.Data.PageToken == "" {
break
}
token = reply.Data.PageToken
}
seen := map[uint64]int{}
for _, id := range got {
seen[id]++
}
for id, n := range seen {
if n != 1 {
t.Errorf("row %d returned %d times", id, n)
}
}
assertIDs(t, "all pages", sortedIDs(got...), s.all())
}
// ========== Restart by identity (bug-1898) ==========
// seedRestartJob seeds a DONE job with a distinctive command.
func (f *handlerFixture) seedRestartJob(unique uint64, command string) {
f.t.Helper()
f.seedJob(models.JobInfo{
Unique_JobID: int64(unique),
JobID: int64(backend.ExtractArrayBaseID(unique)),
ArrayIdx: backend.ExtractArrayIdx(unique),
JobName: "r",
Command: command,
JobStatus: models.JOBSTAT["DONE"],
})
}
func (f *handlerFixture) restart(jobids string) *httptest.ResponseRecorder {
f.t.Helper()
return f.do(http.MethodPost, "/xce/v1/jobs/"+url.PathEscape(jobids)+"/restart", map[string]interface{}{})
}
// bug-1898: RestartJobByID copied jqueryReply[0] of an unevaluated job_id
// CompCondition -- any job, not the one named. It must restart exactly the
// named job (ordinary job or <array-id>[k] element), report an unknown one as
// not found, and refuse a bare array identity (several jobs).
func TestJobHandler_RestartNamedJob(t *testing.T) {
f := newHandlerFixture(t)
arr := handlerTestArrayBase + 30<<15
ord := handlerTestOrdinaryBase + 31<<15
// decoys first, so "the first row" is never the target by accident
f.seedRestartJob(handlerTestOrdinaryBase+32<<15, "decoy-a")
f.seedRestartJob(backend.MakeElementID(arr, 1), "elem-1")
f.seedRestartJob(backend.MakeElementID(arr, 2), "elem-2")
f.seedRestartJob(ord, "ordinary")
cases := []struct {
name, jobids, wantCommand string
}{
{"ordinary job by decimal id", strconv.FormatUint(ord, 10), "ordinary"},
{"array element by <array-id>[k]", strconv.FormatUint(arr, 10) + "[2]", "elem-2"},
}
for _, c := range cases {
before := len(f.submitted)
w := f.restart(c.jobids)
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Errorf("%s: status %d; body %s", c.name, w.Code, w.Body.String())
continue
}
if len(f.submitted) != before+1 {
t.Errorf("%s: %d submits, want 1", c.name, len(f.submitted)-before)
continue
}
if got := f.submitted[before].Job.Command; got != c.wantCommand {
t.Errorf("%s: restarted a copy of %q, want %q", c.name, got, c.wantCommand)
}
}
// unknown job and missing element: not found, nothing submitted
for _, jobids := range []string{
strconv.FormatUint(handlerTestOrdinaryBase+40<<15, 10),
strconv.FormatUint(arr, 10) + "[9]",
} {
before := len(f.submitted)
w := f.restart(jobids)
if w.Code == http.StatusOK || w.Code == http.StatusCreated || len(f.submitted) != before {
t.Errorf("%s: status %d, %d submits; want not found and none", jobids, w.Code, len(f.submitted)-before)
}
}
// a bare array identity names several jobs: refused, nothing submitted
before := len(f.submitted)
w := f.restart(strconv.FormatUint(arr, 10))
if w.Code != http.StatusBadRequest || len(f.submitted) != before {
t.Errorf("bare array identity: status %d, %d submits; want 400 and none; body %s",
w.Code, len(f.submitted)-before, w.Body.String())
}
}