From b11619010e1c83488e14e2785569e515b2769062 Mon Sep 17 00:00:00 2001 From: tehut Date: Fri, 18 Apr 2025 13:24:52 -0700 Subject: [PATCH] Add priority flag to Dispatch CLI and API (#25622) * Add priority flag to Dispatch CLI and DispatchOpts() helper to HTTP API --- .changelog/25622.txt | 3 + api/jobs.go | 32 ++- command/agent/job_endpoint_test.go | 138 +++++++++-- command/job_dispatch.go | 12 +- command/job_dispatch_test.go | 100 ++++++++ nomad/job_endpoint.go | 18 +- nomad/job_endpoint_test.go | 214 +++++++++--------- nomad/structs/structs.go | 1 + .../content/docs/commands/job/dispatch.mdx | 6 + 9 files changed, 388 insertions(+), 136 deletions(-) create mode 100644 .changelog/25622.txt diff --git a/.changelog/25622.txt b/.changelog/25622.txt new file mode 100644 index 000000000..71eb4b3ed --- /dev/null +++ b/.changelog/25622.txt @@ -0,0 +1,3 @@ +```release-note:improvement +command: added priority flag to job dispatch command +``` diff --git a/api/jobs.go b/api/jobs.go index be3bfd21c..dc52270a3 100644 --- a/api/jobs.go +++ b/api/jobs.go @@ -529,16 +529,39 @@ func (j *Jobs) Summary(jobID string, q *QueryOptions) (*JobSummary, *QueryMeta, return &resp, qm, nil } +// DispatchOptions is used to pass through job dispatch parameters +type DispatchOptions struct { + JobID string + Meta map[string]string + Payload []byte + IdPrefixTemplate string + Priority int +} + func (j *Jobs) Dispatch(jobID string, meta map[string]string, payload []byte, idPrefixTemplate string, q *WriteOptions) (*JobDispatchResponse, *WriteMeta, error) { - var resp JobDispatchResponse - req := &JobDispatchRequest{ + + return j.DispatchOpts(&DispatchOptions{ JobID: jobID, Meta: meta, Payload: payload, - IdPrefixTemplate: idPrefixTemplate, + IdPrefixTemplate: idPrefixTemplate}, + q, + ) +} + +// DispatchOpts is used to dispatch a new job with the passed DispatchOpts. It +// returns the ID of the evaluation, along with any errors encountered. +func (j *Jobs) DispatchOpts(opts *DispatchOptions, q *WriteOptions) (*JobDispatchResponse, *WriteMeta, error) { + var resp JobDispatchResponse + req := &JobDispatchRequest{ + JobID: opts.JobID, + Meta: opts.Meta, + Payload: opts.Payload, + IdPrefixTemplate: opts.IdPrefixTemplate, + Priority: opts.Priority, } - wm, err := j.client.put("/v1/job/"+url.PathEscape(jobID)+"/dispatch", req, &resp, q) + wm, err := j.client.put("/v1/job/"+url.PathEscape(opts.JobID)+"/dispatch", req, &resp, q) if err != nil { return nil, nil, err } @@ -1571,6 +1594,7 @@ type JobDispatchRequest struct { Payload []byte Meta map[string]string IdPrefixTemplate string + Priority int } type JobDispatchResponse struct { diff --git a/command/agent/job_endpoint_test.go b/command/agent/job_endpoint_test.go index 83c89ca6c..611568abd 100644 --- a/command/agent/job_endpoint_test.go +++ b/command/agent/job_endpoint_test.go @@ -1964,9 +1964,8 @@ func TestHTTP_JobDispatch(t *testing.T) { }, } var resp structs.JobRegisterResponse - if err := s.Agent.RPC("Job.Register", &args, &resp); err != nil { - t.Fatalf("err: %v", err) - } + err := s.Agent.RPC("Job.Register", &args, &resp) + must.NoError(t, err) // Make the request respW := httptest.NewRecorder() @@ -1981,29 +1980,136 @@ func TestHTTP_JobDispatch(t *testing.T) { // Make the HTTP request req2, err := http.NewRequest(http.MethodPut, "/v1/job/"+job.ID+"/dispatch", buf) - if err != nil { - t.Fatalf("err: %v", err) - } + must.NoError(t, err) respW.Flush() // Make the request obj, err := s.Server.JobSpecificRequest(respW, req2) - if err != nil { - t.Fatalf("err: %v", err) - } + must.NoError(t, err) // Check the response dispatch := obj.(structs.JobDispatchResponse) - if dispatch.EvalID == "" { - t.Fatalf("bad: %v", dispatch) - } - - if dispatch.DispatchedJobID == "" { - t.Fatalf("bad: %v", dispatch) - } + must.NotEq(t, dispatch.EvalID, "") + must.NotEq(t, dispatch.DispatchedJobID, "") }) } +func TestHTTP_JobDispatchPriority(t *testing.T) { + ci.Parallel(t) + defaultPriority := 50 + validPriority := 90 + invalidPriority := -1 + + testCases := []struct { + name string + dispatchReq *structs.JobDispatchRequest + expectedPriority int + expectedErr bool + }{ + { + name: "no priority", + dispatchReq: &structs.JobDispatchRequest{ + WriteRequest: structs.WriteRequest{ + Region: "global", + Namespace: structs.DefaultNamespace, + IdempotencyToken: "foo", + }, + }, + expectedPriority: defaultPriority, + }, + { + name: "set invalid priority", + dispatchReq: &structs.JobDispatchRequest{ + WriteRequest: structs.WriteRequest{ + Region: "global", + Namespace: structs.DefaultNamespace, + IdempotencyToken: "foo", + }, + Priority: invalidPriority, + }, + expectedPriority: invalidPriority, + expectedErr: true, + }, + { + name: "set valid priority", + dispatchReq: &structs.JobDispatchRequest{ + WriteRequest: structs.WriteRequest{ + Region: "global", + Namespace: structs.DefaultNamespace, + IdempotencyToken: "foo", + }, + Priority: validPriority, + }, + expectedPriority: validPriority, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + httpTest(t, nil, func(s *TestAgent) { + + // Create the parameterized job + job := mock.BatchJob() + job.ParameterizedJob = &structs.ParameterizedJobConfig{} + + args := structs.JobRegisterRequest{ + Job: job, + WriteRequest: structs.WriteRequest{ + Region: "global", + Namespace: structs.DefaultNamespace, + }, + } + + var resp structs.JobRegisterResponse + err := s.Agent.RPC("Job.Register", &args, &resp) + must.NoError(t, err) + + // Make the request + respW := httptest.NewRecorder() + args2 := tc.dispatchReq + buf := encodeReq(args2) + + // Make the HTTP request + req2, err := http.NewRequest(http.MethodPut, "/v1/job/"+job.ID+"/dispatch", buf) + must.NoError(t, err) + respW.Flush() + + // Make the request + obj, err := s.Server.JobSpecificRequest(respW, req2) + if tc.expectedErr { + must.ErrorContains(t, err, "job priority must be between") + return + } else { + must.NoError(t, err) + } + + // Check the response + dispatch := obj.(structs.JobDispatchResponse) + must.NotEq(t, dispatch.EvalID, "") + must.NotEq(t, dispatch.DispatchedJobID, "") + must.NotEq(t, job.ID, dispatch.DispatchedJobID) + + //Check job priority + + // Make the HTTP request + bufInfo := encodeReq(structs.Job{ParentID: job.ID}) + reqInfo, err := http.NewRequest(http.MethodGet, "/v1/job/"+dispatch.DispatchedJobID, bufInfo) + must.NoError(t, err) + + // Make the request + respInfo := httptest.NewRecorder() + objInfo, err := s.Server.JobSpecificRequest(respInfo, reqInfo) + must.NoError(t, err) + respInfo.Flush() + + // Check the response + dispatchJob := objInfo.(*structs.Job) + must.Eq(t, tc.expectedPriority, dispatchJob.Priority) + }) + }) + } +} + func TestHTTP_JobDispatchPayload(t *testing.T) { ci.Parallel(t) httpTest(t, nil, func(s *TestAgent) { diff --git a/command/job_dispatch.go b/command/job_dispatch.go index 2b6f66210..8c42bbf96 100644 --- a/command/job_dispatch.go +++ b/command/job_dispatch.go @@ -122,6 +122,7 @@ func (c *JobDispatchCommand) Run(args []string) int { var idempotencyToken string var meta []string var idPrefixTemplate string + var priority int flags := c.Meta.FlagSet(c.Name(), FlagSetClient) flags.Usage = func() { c.Ui.Output(c.Help()) } @@ -131,7 +132,7 @@ func (c *JobDispatchCommand) Run(args []string) int { flags.Var((*flaghelper.StringFlag)(&meta), "meta", "") flags.StringVar(&idPrefixTemplate, "id-prefix-template", "", "") flags.BoolVar(&openURL, "ui", false, "") - + flags.IntVar(&priority, "priority", 0, "") if err := flags.Parse(args); err != nil { return 1 } @@ -201,7 +202,14 @@ func (c *JobDispatchCommand) Run(args []string) int { IdempotencyToken: idempotencyToken, Namespace: namespace, } - resp, _, err := client.Jobs().Dispatch(jobID, metaMap, payload, idPrefixTemplate, w) + opts := &api.DispatchOptions{ + JobID: jobID, + Meta: metaMap, + Payload: payload, + IdPrefixTemplate: idPrefixTemplate, + Priority: priority, + } + resp, _, err := client.Jobs().DispatchOpts(opts, w) if err != nil { c.Ui.Error(fmt.Sprintf("Failed to dispatch job: %s", err)) return 1 diff --git a/command/job_dispatch_test.go b/command/job_dispatch_test.go index 30e8c95f9..2a4050152 100644 --- a/command/job_dispatch_test.go +++ b/command/job_dispatch_test.go @@ -4,6 +4,7 @@ package command import ( + "strconv" "strings" "testing" @@ -104,6 +105,7 @@ func TestJobDispatchCommand_ACL(t *testing.T) { job := mock.MinJob() job.Type = "batch" job.ParameterizedJob = &structs.ParameterizedJobConfig{} + job.Priority = 20 //set priority on parent job state := srv.Agent.Server().State() err := state.UpsertJob(structs.MsgTypeTestSetup, 100, nil, job) must.NoError(t, err) @@ -210,3 +212,101 @@ namespace "default" { }) } } + +func TestJobDispatchCommand_Priority(t *testing.T) { + ci.Parallel(t) + defaultJobPriority := 50 + // Start server + srv, client, url := testServer(t, true, nil) + t.Cleanup(srv.Shutdown) + + waitForNodes(t, client) + + // Create a parameterized job. + job := mock.MinJob() + job.Type = "batch" + job.ParameterizedJob = &structs.ParameterizedJobConfig{} + job.Priority = defaultJobPriority // set default priority on parent job + state := srv.Agent.Server().State() + err := state.UpsertJob(structs.MsgTypeTestSetup, 100, nil, job) + must.NoError(t, err) + + testCases := []struct { + name string + priority string + expectedErr bool + additionalFlags []string + payload map[string]string + }{ + { + name: "no priority", + }, + { + name: "valid priority", + priority: "80", + }, + { + name: "invalid priority", + priority: "-1", + expectedErr: true, + }, + { + name: "priority + flag", + priority: "90", + additionalFlags: []string{"-verbose"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobDispatchCommand{Meta: Meta{Ui: ui}} + args := []string{ + "-address", url, + } + // Add priority, if present + if len(tc.priority) >= 1 { + args = append(args, []string{"-priority", tc.priority}...) + } + + // Add additional flags, if present + if len(tc.additionalFlags) >= 1 { + args = append(args, tc.additionalFlags...) + } + + // Add job ID to the command. + args = append(args, job.ID) + + // Run command. + code := cmd.Run(args) + if !tc.expectedErr { + must.Zero(t, code) + } else { + // Confirm expected error case + must.NonZero(t, code) + out := ui.ErrorWriter.String() + must.StrContains(t, out, "dispatch job priority must be between [1, 100]") + return + } + + // Confirm successful dispatch and parse job ID + out := ui.OutputWriter.String() + must.StrContains(t, out, "Dispatched Job ID =") + parts := strings.Fields(out) + id := strings.TrimSpace(parts[4]) + + // Confirm dispatched job priority set correctly + job, _, err := client.Jobs().Info(id, nil) + must.NoError(t, err) + must.NotNil(t, job) + + if len(tc.priority) >= 1 { + priority, err := strconv.Atoi(tc.priority) + must.NoError(t, err) + must.Eq(t, job.Priority, &priority) + } else { + must.Eq(t, defaultJobPriority, *job.Priority) + } + }) + } +} diff --git a/nomad/job_endpoint.go b/nomad/job_endpoint.go index b786b464c..41f920be0 100644 --- a/nomad/job_endpoint.go +++ b/nomad/job_endpoint.go @@ -2035,8 +2035,14 @@ func (j *Job) Dispatch(args *structs.JobDispatchRequest, reply *structs.JobDispa return fmt.Errorf("Specified job %q is stopped", args.JobID) } - // Validate the arguments - if err := validateDispatchRequest(args, parameterizedJob); err != nil { + // Set priority to match parent job if unset + if args.Priority == 0 { + args.Priority = parameterizedJob.Priority + } + + // Validate the arguments and parameterized job + agentConfig := j.srv.config + if err := validateDispatchRequest(args, parameterizedJob, agentConfig); err != nil { return err } @@ -2087,6 +2093,7 @@ func (j *Job) Dispatch(args *structs.JobDispatchRequest, reply *structs.JobDispa dispatchJob.Status = "" dispatchJob.StatusDescription = "" dispatchJob.DispatchIdempotencyToken = args.IdempotencyToken + dispatchJob.Priority = args.Priority // Merge in the meta data for k, v := range args.Meta { @@ -2154,7 +2161,7 @@ func (j *Job) Dispatch(args *structs.JobDispatchRequest, reply *structs.JobDispa // validateDispatchRequest returns whether the request is valid given the // parameterized job. -func validateDispatchRequest(req *structs.JobDispatchRequest, job *structs.Job) error { +func validateDispatchRequest(req *structs.JobDispatchRequest, job *structs.Job, config *Config) error { // Check the payload constraint is met hasInputData := len(req.Payload) != 0 if job.ParameterizedJob.Payload == structs.DispatchPayloadRequired && !hasInputData { @@ -2215,6 +2222,11 @@ func validateDispatchRequest(req *structs.JobDispatchRequest, job *structs.Job) return fmt.Errorf("Dispatch did not provide required meta keys: %v", flat) } + // Confirm that Priority is appropriately set on the JobDispatchRequest + if req.Priority < structs.JobMinPriority || req.Priority > config.JobMaxPriority { + return fmt.Errorf("dispatch job priority must be between [%d, %d]", structs.JobMinPriority, config.JobMaxPriority) + } + return nil } diff --git a/nomad/job_endpoint_test.go b/nomad/job_endpoint_test.go index 95aa2f83f..df370b9be 100644 --- a/nomad/job_endpoint_test.go +++ b/nomad/job_endpoint_test.go @@ -6665,7 +6665,8 @@ func TestJobEndpoint_Dispatch(t *testing.T) { reqInputDataTooLarge := &structs.JobDispatchRequest{ Payload: make([]byte, DispatchPayloadSizeLimit+100), } - + reqNoInputValidPriority := &structs.JobDispatchRequest{Priority: 55} + reqNoInputInvalidPriority := &structs.JobDispatchRequest{Priority: -1} type existingIdempotentChildJob struct { isTerminal bool } @@ -6675,131 +6676,164 @@ func TestJobEndpoint_Dispatch(t *testing.T) { parameterizedJob *structs.Job dispatchReq *structs.JobDispatchRequest noEval bool - err bool + expectError bool errStr string idempotencyToken string existingIdempotentJob *existingIdempotentChildJob + expectedPriority int } cases := []testCase{ { name: "optional input data w/ data", parameterizedJob: d1, dispatchReq: reqInputDataNoMeta, - err: false, + expectError: false, + expectedPriority: 50, }, { name: "optional input data w/o data", parameterizedJob: d1, dispatchReq: reqNoInputNoMeta, - err: false, + expectError: false, + expectedPriority: 50, }, { name: "require input data w/ data", parameterizedJob: d2, dispatchReq: reqInputDataNoMeta, - err: false, + expectError: false, + expectedPriority: 50, }, { name: "require input data w/o data", parameterizedJob: d2, dispatchReq: reqNoInputNoMeta, - err: true, + expectError: true, errStr: "not provided but required", + expectedPriority: 50, }, { name: "disallow input data w/o data", parameterizedJob: d3, dispatchReq: reqNoInputNoMeta, - err: false, + expectError: false, + expectedPriority: 50, }, { name: "disallow input data w/ data", parameterizedJob: d3, dispatchReq: reqInputDataNoMeta, - err: true, + expectError: true, errStr: "provided but forbidden", + expectedPriority: 50, }, { name: "require meta w/ meta", parameterizedJob: d4, dispatchReq: reqInputDataMeta, - err: false, + expectError: false, + expectedPriority: 50, }, { name: "require meta w/o meta", parameterizedJob: d4, dispatchReq: reqNoInputNoMeta, - err: true, + expectError: true, errStr: "did not provide required meta keys", + expectedPriority: 50, }, { name: "optional meta w/ meta", parameterizedJob: d5, dispatchReq: reqNoInputDataMeta, - err: false, + expectError: false, + expectedPriority: 50, }, { name: "optional meta w/o meta", parameterizedJob: d5, dispatchReq: reqNoInputNoMeta, - err: false, + expectError: false, + expectedPriority: 50, }, { name: "optional meta w/ bad meta", parameterizedJob: d5, dispatchReq: reqBadMeta, - err: true, + expectError: true, errStr: "unpermitted metadata keys", + expectedPriority: 50, }, { name: "optional input w/ too big of input", parameterizedJob: d1, dispatchReq: reqInputDataTooLarge, - err: true, + expectError: true, errStr: "Payload exceeds maximum size", + expectedPriority: 50, }, { name: "periodic job dispatched, ensure no eval", parameterizedJob: d6, dispatchReq: reqNoInputNoMeta, noEval: true, + expectedPriority: 50, }, { name: "periodic job stopped, ensure error", parameterizedJob: d7, dispatchReq: reqNoInputNoMeta, - err: true, + expectError: true, errStr: "stopped", + expectedPriority: 50, }, { name: "idempotency token, no existing child job", parameterizedJob: d1, dispatchReq: reqInputDataNoMeta, - err: false, + expectError: false, idempotencyToken: "foo", existingIdempotentJob: nil, + expectedPriority: 50, }, { name: "idempotency token, w/ existing non-terminal child job", parameterizedJob: d1, dispatchReq: reqInputDataNoMeta, - err: false, + expectError: false, idempotencyToken: "foo", existingIdempotentJob: &existingIdempotentChildJob{ isTerminal: false, }, - noEval: true, + noEval: true, + expectedPriority: 50, }, { name: "idempotency token, w/ existing terminal job", parameterizedJob: d1, dispatchReq: reqInputDataNoMeta, - err: false, + expectError: false, idempotencyToken: "foo", existingIdempotentJob: &existingIdempotentChildJob{ isTerminal: true, }, - noEval: true, + noEval: true, + expectedPriority: 50, + }, + { + name: "valid priority", + parameterizedJob: d1, + dispatchReq: reqNoInputValidPriority, + expectError: false, + expectedPriority: reqNoInputValidPriority.Priority, + }, + { + name: "invalid priority", + parameterizedJob: d1, + dispatchReq: reqNoInputInvalidPriority, + expectError: true, + errStr: "priority must be between", + expectedPriority: reqNoInputInvalidPriority.Priority, }, } @@ -6823,9 +6857,8 @@ func TestJobEndpoint_Dispatch(t *testing.T) { // Fetch the response var regResp structs.JobRegisterResponse - if err := msgpackrpc.CallWithCodec(codec, "Job.Register", regReq, ®Resp); err != nil { - t.Fatalf("err: %v", err) - } + err := msgpackrpc.CallWithCodec(codec, "Job.Register", regReq, ®Resp) + must.NoError(t, err) // Now try to dispatch tc.dispatchReq.JobID = tc.parameterizedJob.ID @@ -6838,107 +6871,66 @@ func TestJobEndpoint_Dispatch(t *testing.T) { // Dispatch with the same request so a child job w/ the idempotency key exists var initialIdempotentDispatchResp structs.JobDispatchResponse if tc.existingIdempotentJob != nil { - if err := msgpackrpc.CallWithCodec(codec, "Job.Dispatch", tc.dispatchReq, &initialIdempotentDispatchResp); err != nil { - t.Fatalf("Unexpected error dispatching initial idempotent job: %v", err) - } + err := msgpackrpc.CallWithCodec(codec, "Job.Dispatch", tc.dispatchReq, &initialIdempotentDispatchResp) + must.NoError(t, err) if tc.existingIdempotentJob.isTerminal { eval, err := s1.State().EvalByID(nil, initialIdempotentDispatchResp.EvalID) - if err != nil { - t.Fatalf("Unexpected error fetching eval %v", err) - } + must.NoError(t, err) + eval = eval.Copy() eval.Status = structs.EvalStatusComplete err = s1.State().UpsertEvals(structs.MsgTypeTestSetup, initialIdempotentDispatchResp.Index+1, []*structs.Evaluation{eval}) - if err != nil { - t.Fatalf("Unexpected error completing eval %v", err) - } + must.NoError(t, err) } } var dispatchResp structs.JobDispatchResponse dispatchErr := msgpackrpc.CallWithCodec(codec, "Job.Dispatch", tc.dispatchReq, &dispatchResp) - - if dispatchErr == nil { - if tc.err { - t.Fatalf("Expected error: %v", dispatchErr) - } - - // Check that we got an eval and job id back - switch dispatchResp.EvalID { - case "": - if !tc.noEval { - t.Fatalf("Bad response") - } - default: - if tc.noEval { - t.Fatalf("Got eval %q", dispatchResp.EvalID) - } - } - - if dispatchResp.DispatchedJobID == "" { - t.Fatalf("Bad response") - } - - state := s1.fsm.State() - ws := memdb.NewWatchSet() - out, err := state.JobByID(ws, tc.parameterizedJob.Namespace, dispatchResp.DispatchedJobID) - if err != nil { - t.Fatalf("err: %v", err) - } - if out == nil { - t.Fatalf("expected job") - } - if out.CreateIndex != dispatchResp.JobCreateIndex { - t.Fatalf("index mis-match") - } - if out.ParentID != tc.parameterizedJob.ID { - t.Fatalf("bad parent ID") - } - if !out.Dispatched { - t.Fatal("expected dispatched job") - } - if out.IsParameterized() { - t.Fatal("dispatched job should not be parameterized") - } - if out.ParameterizedJob == nil { - t.Fatal("parameter job config should exist") - } - - // Check that the existing job is returned in the case of a supplied idempotency token - if tc.idempotencyToken != "" && tc.existingIdempotentJob != nil { - if dispatchResp.DispatchedJobID != initialIdempotentDispatchResp.DispatchedJobID { - t.Fatal("dispatched job id should match initial dispatch") - } - - if dispatchResp.JobCreateIndex != initialIdempotentDispatchResp.JobCreateIndex { - t.Fatal("dispatched job create index should match initial dispatch") - } - } - - if tc.noEval { - return - } - - // Lookup the evaluation - eval, err := state.EvalByID(ws, dispatchResp.EvalID) - if err != nil { - t.Fatalf("err: %v", err) - } - - if eval == nil { - t.Fatalf("expected eval") - } - if eval.CreateIndex != dispatchResp.EvalCreateIndex { - t.Fatalf("index mis-match") - } + if tc.expectError { + must.ErrorContains(t, dispatchErr, tc.errStr) + return } else { - if !tc.err { - t.Fatalf("Got unexpected error: %v", dispatchErr) - } else if !strings.Contains(dispatchErr.Error(), tc.errStr) { - t.Fatalf("Expected err to include %q; got %v", tc.errStr, dispatchErr) - } + must.NoError(t, dispatchErr) } + + // Check that we got an eval and job id back + must.NotEq(t, dispatchResp.DispatchedJobID, "") + if tc.noEval { + must.Eq(t, dispatchResp.EvalID, "") + } else { + must.NotEq(t, dispatchResp.EvalID, "") + } + + state := s1.fsm.State() + ws := memdb.NewWatchSet() + out, err := state.JobByID(ws, tc.parameterizedJob.Namespace, dispatchResp.DispatchedJobID) + must.NoError(t, err) + must.NotNil(t, out) + + // Check job characteristics + must.Eq(t, out.CreateIndex, dispatchResp.JobCreateIndex) + must.Eq(t, out.ParentID, tc.parameterizedJob.ID) + must.Eq(t, out.Dispatched, true) + must.Eq(t, out.IsParameterized(), false) + must.NotNil(t, out.ParameterizedJob) + must.Eq(t, tc.expectedPriority, out.Priority) + + // Check that the existing job is returned in the case of a supplied idempotency token + if tc.idempotencyToken != "" && tc.existingIdempotentJob != nil { + must.Eq(t, dispatchResp.DispatchedJobID, initialIdempotentDispatchResp.DispatchedJobID) + must.Eq(t, dispatchResp.JobCreateIndex, initialIdempotentDispatchResp.JobCreateIndex) + } + + if tc.noEval { + return + } + + // Lookup the evaluation + eval, err := state.EvalByID(ws, dispatchResp.EvalID) + must.NoError(t, err) + must.NotNil(t, eval) + must.Eq(t, eval.CreateIndex, dispatchResp.EvalCreateIndex) }) } } diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index ae8e06942..39bb0b245 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -934,6 +934,7 @@ type JobDispatchRequest struct { Meta map[string]string WriteRequest IdPrefixTemplate string + Priority int } // JobValidateRequest is used to validate a job diff --git a/website/content/docs/commands/job/dispatch.mdx b/website/content/docs/commands/job/dispatch.mdx index 58436457b..3dc43ca11 100644 --- a/website/content/docs/commands/job/dispatch.mdx +++ b/website/content/docs/commands/job/dispatch.mdx @@ -78,6 +78,10 @@ dispatching parameterized jobs. - `-ui`: Open the dispatched job in the browser. +- `-priority`: Optional integer between 1 and [`job_max_priority`], + inclusively that overrides any priority value inherited from the parent job. + For general usage information on the priority flag see [job parameters]. + ## Examples Dispatch against a parameterized job with the ID "video-encode" and @@ -166,3 +170,5 @@ Evaluation ID = 31199841 [eval status]: /nomad/docs/commands/eval/status [parameterized job]: /nomad/docs/job-specification/parameterized 'Nomad parameterized Job Specification' [multiregion]: /nomad/docs/job-specification/multiregion#parameterized-dispatch +[`job_max_priority`]: /nomad/docs/configuration/server#job_max_priority +[job parameters]: /nomad/docs/job-specification/job#job-parameters