preCheckJobRequest() returned from inside its job-name branch, so every named or array job skipped the description, project and resreq checks, resreq validation and the shell-limit conversion that follow. The name now only sets the job count and array flag, and every submission runs the rest. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
305 lines
12 KiB
Go
305 lines
12 KiB
Go
package controllers
|
|
|
|
import (
|
|
"apiserver/backend"
|
|
"apiserver/models"
|
|
"apiserver/store"
|
|
"apiserver/utils"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func resetJobSubmitState() {
|
|
store.InitStores()
|
|
utils.Log = zap.NewNop()
|
|
}
|
|
|
|
// TestPreCheckJobRequest_LostAndFound verifies that submitting to the
|
|
// "lost_and_found" queue is rejected with a clear error message.
|
|
func TestPreCheckJobRequest_LostAndFound(t *testing.T) {
|
|
resetJobSubmitState()
|
|
|
|
req := &models.JobSubmitRequest{
|
|
QueueName: "lost_and_found",
|
|
}
|
|
|
|
_, _, err := preCheckJobRequest(req)
|
|
if err == nil {
|
|
t.Fatal("expected error when submitting to lost_and_found queue, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "lost_and_found") {
|
|
t.Errorf("error message should mention 'lost_and_found', got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "internal recovery queue") {
|
|
t.Errorf("error message should mention 'internal recovery queue', got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestJobsListFieldValidation_UsesJobInfoNotSubmitRequest is a regression
|
|
// guard for bug-1674 (gitea #41): GetJobsInfo (jobs.go:553) and GetJobByIDs
|
|
// (jobs.go:901) validate a caller's requested `-o`/`-l` field list against
|
|
// utils.GetSupportedFields(models.JobInfo{}) merged with
|
|
// models.JobAdditionalFields{} -- not models.JobSubmitRequest{}, the
|
|
// job-submission model. Response-only fields like pending_reason/start_time/
|
|
// end_time exist on JobInfo but not on JobSubmitRequest; validating against
|
|
// the wrong struct made `-l` (which requests these fields) fail outright with
|
|
// "unsupported query field" on every invocation, regardless of which such
|
|
// field Go's randomized map iteration hit first.
|
|
//
|
|
// allXcecliJobFields mirrors xcecli/models/jobs.go's JobFieldsMap value set
|
|
// verbatim (xcecli is a separate Go module -- see src/frontend/CLAUDE.md --
|
|
// so it cannot be imported directly here). Every value in that map is a
|
|
// field xcecli's `-o`/`-l` can actually send the apiserver; this test proves
|
|
// the combined JobInfo+JobAdditionalFields supported-fields list -- what
|
|
// both fixed call sites now validate against -- covers every one of them.
|
|
var allXcecliJobFields = []string{
|
|
"job_id", "submit_user", "job_status", "queue_name", "command",
|
|
"job_name", "submit_time", "from_host", "exec_hosts", "core_limit",
|
|
"mem_limit", "pids", "pgids", "project_name", "start_time", "end_time",
|
|
"cwd", "cpu_time", "resreq", "job_description", "launch_type",
|
|
"num_processors", "interactive_job_url", "pending_reason",
|
|
}
|
|
|
|
func TestJobsListFieldValidation_UsesJobInfoNotSubmitRequest(t *testing.T) {
|
|
supportedFields := utils.GetSupportedFields(models.JobInfo{})
|
|
utils.AddFieldsToSupportedList(&supportedFields, models.JobAdditionalFields{})
|
|
|
|
for _, field := range allXcecliJobFields {
|
|
if !utils.ContainsField(supportedFields, field) {
|
|
t.Errorf("GetSupportedFields(models.JobInfo{}) + JobAdditionalFields is missing %q -- xcecli's `-o`/`-l` can request this field (see xcecli/models/jobs.go's JobFieldsMap) and it must validate successfully", field)
|
|
}
|
|
}
|
|
|
|
// Documents exactly why validating against JobSubmitRequest broke:
|
|
// response-only fields like pending_reason don't exist there at all.
|
|
submitRequestFields := utils.GetSupportedFields(models.JobSubmitRequest{})
|
|
for _, field := range []string{"pending_reason", "start_time", "end_time"} {
|
|
if utils.ContainsField(submitRequestFields, field) {
|
|
t.Errorf("unexpected: models.JobSubmitRequest{} contains %q -- this test's premise (that JobSubmitRequest lacks response-only fields) no longer holds", field)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPreCheckJobRequest_NormalQueue verifies that a normal (non-reserved)
|
|
// queue name does not trigger the reserved-name rejection.
|
|
func TestPreCheckJobRequest_NormalQueue(t *testing.T) {
|
|
resetJobSubmitState()
|
|
|
|
// Seed a valid open queue in the store.
|
|
store.Queues.Put("normal", models.QueueInfo{
|
|
QueueName: "normal",
|
|
QueueStatus: 0, // open
|
|
})
|
|
|
|
req := &models.JobSubmitRequest{
|
|
QueueName: "normal",
|
|
}
|
|
|
|
_, _, err := preCheckJobRequest(req)
|
|
// Should not be rejected for the reserved-name check; may fail for other
|
|
// reasons (e.g. missing JobName validation), but not with our new error.
|
|
if err != nil && strings.Contains(err.Error(), "internal recovery queue") {
|
|
t.Errorf("normal queue should not be rejected with reserved-name error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestPreCheckJobRequest_EmptyQueue verifies that an empty queue name is
|
|
// allowed (defaults to "normal") and is not confused with reserved names.
|
|
func TestPreCheckJobRequest_EmptyQueue(t *testing.T) {
|
|
resetJobSubmitState()
|
|
|
|
req := &models.JobSubmitRequest{
|
|
QueueName: "",
|
|
}
|
|
|
|
// Empty queue name sets default — should not trigger reserved-name error.
|
|
_, _, err := preCheckJobRequest(req)
|
|
if err != nil && strings.Contains(err.Error(), "internal recovery queue") {
|
|
t.Errorf("empty queue should not trigger reserved-name error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateJobControl_HandlesRealJobInfoTypes is a regression guard for
|
|
// bug-1742 (gitea #45): models.JobInfo.JobStatus and .ArrayIdx are plain Go
|
|
// `int` (see the JobInfo struct in models/jobs.go), but validateJobControl
|
|
// asserted them as `int64` -- a mismatch invisible to any test that
|
|
// hand-built its utils.OrderMap with int64 literals instead of going through
|
|
// the same store.StructToOrderMap() reflection path production actually
|
|
// uses. That mismatch made every job-control call panic with "interface
|
|
// conversion: interface {} is int, not int64", surfaced to xcecli as an
|
|
// empty `failed: []` (Gin's Recovery() middleware swallows the panic).
|
|
func TestValidateJobControl_HandlesRealJobInfoTypes(t *testing.T) {
|
|
resetJobSubmitState()
|
|
|
|
jobInfo := models.JobInfo{
|
|
Unique_JobID: 73932643848159233,
|
|
JobID: 1788186846,
|
|
ArrayIdx: 1,
|
|
SubmitUser: "admin",
|
|
QueueName: "normal",
|
|
JobStatus: models.JOBSTAT["RUN"],
|
|
}
|
|
orderMap := store.StructToOrderMap(jobInfo)
|
|
|
|
successJobs, failedJobs := validateJobControl(orderMap, "kill", "")
|
|
|
|
if len(successJobs) != 1 || len(failedJobs) != 0 {
|
|
t.Fatalf("expected exactly one success job for a killable RUN job, got successJobs=%v failedJobs=%v", successJobs, failedJobs)
|
|
}
|
|
if got := successJobs[0]["array_idx"]; got != 1 {
|
|
t.Errorf("array_idx = %v (%T), want 1 (int)", got, got)
|
|
}
|
|
}
|
|
|
|
// TestValidateJobControl_RejectsNonKillableStatus verifies a job in a
|
|
// terminal state is reported as a failure (not silently dropped, and
|
|
// without panicking) -- this path also reads array_idx before returning.
|
|
func TestValidateJobControl_RejectsNonKillableStatus(t *testing.T) {
|
|
resetJobSubmitState()
|
|
|
|
jobInfo := models.JobInfo{
|
|
Unique_JobID: 1,
|
|
JobID: 1,
|
|
ArrayIdx: 0,
|
|
JobStatus: models.JOBSTAT["DONE"],
|
|
}
|
|
orderMap := store.StructToOrderMap(jobInfo)
|
|
|
|
successJobs, failedJobs := validateJobControl(orderMap, "kill", "")
|
|
|
|
if len(successJobs) != 0 || len(failedJobs) != 1 {
|
|
t.Fatalf("expected exactly one failed job for a DONE job, got successJobs=%v failedJobs=%v", successJobs, failedJobs)
|
|
}
|
|
}
|
|
|
|
// TestFoundJob_MatchesRealArrayIdxType is a regression guard for the same
|
|
// bug-1742 type mismatch inside foundJob's array-index lookup path.
|
|
func TestFoundJob_MatchesRealArrayIdxType(t *testing.T) {
|
|
resetJobSubmitState()
|
|
|
|
// an array element: its unique_job_id carries the array marking
|
|
const key = uint64(2)<<40 | uint64(1)<<20
|
|
elem := backend.MakeElementID(key, 1)
|
|
jobInfo := models.JobInfo{
|
|
Unique_JobID: int64(elem),
|
|
JobID: int64(key),
|
|
ArrayIdx: 1,
|
|
}
|
|
orderMap := store.StructToOrderMap(jobInfo)
|
|
|
|
found, err := foundJob(int64(key), 1, true, []utils.OrderMap{orderMap})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(found) != 1 {
|
|
t.Fatalf("expected to find 1 job, got %d", len(found))
|
|
}
|
|
}
|
|
|
|
// TestFoundJob_ExplicitIndex (5.4, 5.6, 8.2): an explicit element number
|
|
// matches only array elements with that number -- never an ordinary job with
|
|
// array_idx 0 or the whole array -- while no element part matches every row
|
|
// with that job_id.
|
|
func TestFoundJob_ExplicitIndex(t *testing.T) {
|
|
const key = uint64(2)<<40 | uint64(1)<<20
|
|
row := func(uid uint64, idx int) utils.OrderMap {
|
|
return store.StructToOrderMap(models.JobInfo{Unique_JobID: int64(uid), JobID: int64(key), ArrayIdx: idx})
|
|
}
|
|
ordinary := row(key, 0) // an ordinary job sharing the number
|
|
e1 := row(backend.MakeElementID(key, 1), 1)
|
|
e2 := row(backend.MakeElementID(key, 2), 2)
|
|
withZero := []utils.OrderMap{ordinary, e1, e2, row(backend.MakeElementID(key, 0), 0)}
|
|
noZero := []utils.OrderMap{ordinary, e1, e2}
|
|
|
|
uids := func(rows []utils.OrderMap) []int64 {
|
|
out := []int64{}
|
|
for _, r := range rows {
|
|
out = append(out, r.Data["unique_job_id"].(int64))
|
|
}
|
|
return out
|
|
}
|
|
|
|
if got, err := foundJob(int64(key), 0, true, noZero); err == nil {
|
|
t.Errorf("[0] without element 0 matched %v, want not found", uids(got))
|
|
}
|
|
got, err := foundJob(int64(key), 0, true, withZero)
|
|
if err != nil || len(got) != 1 || uint64(got[0].Data["unique_job_id"].(int64)) != backend.MakeElementID(key, 0) {
|
|
t.Errorf("[0] with a pre-upgrade element 0 = %v, %v; want that element alone", uids(got), err)
|
|
}
|
|
got, err = foundJob(int64(key), 2, true, noZero)
|
|
if err != nil || len(got) != 1 || uint64(got[0].Data["unique_job_id"].(int64)) != backend.MakeElementID(key, 2) {
|
|
t.Errorf("[2] = %v, %v; want element 2 alone", uids(got), err)
|
|
}
|
|
if got, err := foundJob(int64(key), 3, true, noZero); err == nil {
|
|
t.Errorf("[3] matched %v, want not found", uids(got))
|
|
}
|
|
got, err = foundJob(int64(key), 0, false, noZero)
|
|
if err != nil || len(got) != 3 {
|
|
t.Errorf("bare = %v, %v; want every row with that job_id", uids(got), err)
|
|
}
|
|
}
|
|
|
|
// TestPreCheckJobRequest_RejectsMalformedResreq (bug-1744): a resource request
|
|
// the syntax check rejects must fail the submission instead of being accepted
|
|
// and later silently dropped by the scheduler.
|
|
func TestPreCheckJobRequest_RejectsMalformedResreq(t *testing.T) {
|
|
resetJobSubmitState()
|
|
store.Queues.Put("normal", models.QueueInfo{QueueName: "normal"})
|
|
|
|
var checked string
|
|
orig := validateResreqSyntax
|
|
validateResreqSyntax = func(s string) error {
|
|
checked = s
|
|
if strings.Contains(s, "100000000a0") {
|
|
return errors.New("invalid rusage in resource request")
|
|
}
|
|
return nil
|
|
}
|
|
defer func() { validateResreqSyntax = orig }()
|
|
|
|
req := &models.JobSubmitRequest{QueueName: "normal",
|
|
Resreq: "rusage[mem=100000000a0]"}
|
|
_, _, err := preCheckJobRequest(req)
|
|
if checked != "rusage[mem=100000000a0]" {
|
|
t.Fatalf("resreq was not syntax-checked (checked %q)", checked)
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), "invalid rusage") {
|
|
t.Fatalf("malformed resreq must be rejected, got err=%v", err)
|
|
}
|
|
}
|
|
|
|
// bug-1953: a job name (plain or array) must not skip the checks and
|
|
// conversions every other submission gets.
|
|
func TestPreCheckJobRequest_NamedJobsGetFullValidation(t *testing.T) {
|
|
resetJobSubmitState()
|
|
origCfg := utils.JobYamlConfig
|
|
utils.JobYamlConfig = &utils.JobConfig{MaxArraySize: 1000}
|
|
t.Cleanup(func() { utils.JobYamlConfig = origCfg })
|
|
|
|
for _, name := range []string{"", "myjob", "arr[1-3]"} {
|
|
req := &models.JobSubmitRequest{JobName: name, Command: "sleep 1"}
|
|
req.ShellLimit.CPULimit = 60000
|
|
n, isArray, err := preCheckJobRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("name %q: unexpected error %v", name, err)
|
|
}
|
|
if req.CPULimit.Seconds != 60 {
|
|
t.Errorf("name %q: cpu limit not converted: got %d s, want 60", name, req.CPULimit.Seconds)
|
|
}
|
|
if name == "arr[1-3]" && (n != 3 || !isArray || req.JobName != "arr") {
|
|
t.Errorf("array name: got n=%d isArray=%v name=%q, want 3 true \"arr\"", n, isArray, req.JobName)
|
|
}
|
|
if name == "myjob" && (n != 1 || isArray) {
|
|
t.Errorf("plain name: got n=%d isArray=%v, want 1 false", n, isArray)
|
|
}
|
|
|
|
bad := &models.JobSubmitRequest{JobName: name, Command: "sleep 1",
|
|
ProjectName: strings.Repeat("p", 512)}
|
|
if _, _, err := preCheckJobRequest(bad); err == nil {
|
|
t.Errorf("name %q: an over-long project name was accepted", name)
|
|
}
|
|
}
|
|
}
|