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>
1509 lines
55 KiB
Go
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())
|
|
}
|
|
}
|