diff --git a/.changelog/24312.txt b/.changelog/24312.txt new file mode 100644 index 000000000..bce2802e6 --- /dev/null +++ b/.changelog/24312.txt @@ -0,0 +1,3 @@ +```release-note:improvement +api: new parameterized dispatch endpoint sends raw HTTP request body as Payload +``` diff --git a/command/agent/job_endpoint.go b/command/agent/job_endpoint.go index ea6c6bf9c..374422be6 100644 --- a/command/agent/job_endpoint.go +++ b/command/agent/job_endpoint.go @@ -5,6 +5,7 @@ package agent import ( "fmt" + "io" "maps" "net/http" "slices" @@ -88,6 +89,9 @@ func (s *HTTPServer) JobSpecificRequest(resp http.ResponseWriter, req *http.Requ case strings.HasSuffix(path, "/dispatch"): jobID := strings.TrimSuffix(path, "/dispatch") return s.jobDispatchRequest(resp, req, jobID) + case strings.HasSuffix(path, "/dispatch/payload"): + jobID := strings.TrimSuffix(path, "/dispatch/payload") + return s.jobDispatchPayloadRequest(resp, req, jobID) case strings.HasSuffix(path, "/versions"): jobID := strings.TrimSuffix(path, "/versions") return s.jobVersions(resp, req, jobID) @@ -896,6 +900,30 @@ func (s *HTTPServer) jobDispatchRequest(resp http.ResponseWriter, req *http.Requ return out, nil } +func (s *HTTPServer) jobDispatchPayloadRequest(resp http.ResponseWriter, req *http.Request, jobID string) (interface{}, error) { + if req.Method != http.MethodPut && req.Method != http.MethodPost { + return nil, CodedError(405, ErrInvalidMethod) + } + + args := structs.JobDispatchRequest{} + var err error + args.JobID = jobID + args.Payload, err = io.ReadAll(req.Body) + if err != nil { + return nil, CodedError(400, err.Error()) + } + + // this only parses query args and headers (not request body) + s.parseWriteRequest(req, &args.WriteRequest) + + var out structs.JobDispatchResponse + if err := s.agent.RPC("Job.Dispatch", &args, &out); err != nil { + return nil, err + } + setIndex(resp, out.Index) + return out, nil +} + // JobsParseRequest parses a hcl jobspec and returns a api.Job func (s *HTTPServer) JobsParseRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) { if req.Method != http.MethodPut && req.Method != http.MethodPost { diff --git a/command/agent/job_endpoint_test.go b/command/agent/job_endpoint_test.go index be15d0eca..808fcaf46 100644 --- a/command/agent/job_endpoint_test.go +++ b/command/agent/job_endpoint_test.go @@ -4,6 +4,7 @@ package agent import ( + "bytes" "fmt" "net/http" "net/http/httptest" @@ -2003,6 +2004,45 @@ func TestHTTP_JobDispatch(t *testing.T) { }) } +func TestHTTP_JobDispatchPayload(t *testing.T) { + ci.Parallel(t) + httpTest(t, nil, func(s *TestAgent) { + // Create the parameterized job + job := mock.BatchJob() + job.ParameterizedJob = &structs.ParameterizedJobConfig{ + Payload: "required", + } + + // Register the job + var resp structs.JobRegisterResponse + must.NoError(t, s.Agent.RPC("Job.Register", + &structs.JobRegisterRequest{ + Job: job, + WriteRequest: structs.WriteRequest{ + Region: "global", + Namespace: structs.DefaultNamespace, + }, + }, &resp)) + + // Build the request + url := "/v1/job/" + job.ID + "/dispatch/payload" + body := bytes.NewReader([]byte("any body at all")) + req, err := http.NewRequest(http.MethodPut, url, body) + must.NoError(t, err) + + // Make the request + respW := httptest.NewRecorder() + obj, err := s.Server.JobSpecificRequest(respW, req) + must.NoError(t, err) + must.Eq(t, http.StatusOK, respW.Result().StatusCode) + + // Check the response + dispatch := obj.(structs.JobDispatchResponse) + must.NotEq(t, "", dispatch.EvalID, must.Sprintf("expect EvalID in: %v", dispatch)) + must.NotEq(t, "", dispatch.DispatchedJobID, must.Sprintf("expect DispatchedJobID in: %v", dispatch)) + }) +} + func TestHTTP_JobRevert(t *testing.T) { ci.Parallel(t) httpTest(t, nil, func(s *TestAgent) { diff --git a/website/content/api-docs/jobs.mdx b/website/content/api-docs/jobs.mdx index 08d638876..1b948de60 100644 --- a/website/content/api-docs/jobs.mdx +++ b/website/content/api-docs/jobs.mdx @@ -1813,6 +1813,55 @@ $ curl \ } ``` +## Dispatch Job with raw Payload body + +This endpoint dispatches a new instance of a parameterized job using the full +request body as the `Payload` as described in [Dispatch Job](#dispatch-job). + +| Method | Path | Produces | +| ------ | ---------------------------------- | ------------------ | +| `POST` | `/v1/job/:job_id/dispatch/payload` | `application/json` | + +The table below shows this endpoint's support for +[blocking queries](/nomad/api-docs#blocking-queries) and +[required ACLs](/nomad/api-docs#acls). + +| Blocking Queries | ACL Required | +| ---------------- | ------------------------ | +| `NO` | `namespace:dispatch-job` | + +### Parameters + +- `:job_id` `(string: )` - Specifies the ID of the job. This is +specified as part of the path. + +### Sample Payload + +``` +any HTTP request body, JSON or otherwise, becomes the dispatch Payload +``` + +### Sample Request + +```shell-session +$ curl \ + --request POST \ + --data 'anything at all' \ + https://localhost:4646/v1/job/my-job/dispatch +``` + +### Sample Response + +```json +{ + "DispatchedJobID": "param/dispatch-1730920906-81821d1f", + "EvalCreateIndex": 179, + "EvalID": "5e973383-8d59-3f33-4496-72112a882605", + "Index": 179, + "JobCreateIndex": 178 +} +``` + ## Revert to older Job Version This endpoint reverts the job to an older version.