diff --git a/cli/provisionerjobs.go b/cli/provisionerjobs.go index b3e0a861ba4d3..17c5ad26fbaa7 100644 --- a/cli/provisionerjobs.go +++ b/cli/provisionerjobs.go @@ -4,9 +4,11 @@ import ( "fmt" "slices" + "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" @@ -21,6 +23,7 @@ func (r *RootCmd) provisionerJobs() *serpent.Command { }, Aliases: []string{"job"}, Children: []*serpent.Command{ + r.provisionerJobsCancel(), r.provisionerJobsList(), }, } @@ -124,3 +127,58 @@ func (r *RootCmd) provisionerJobsList() *serpent.Command { return cmd } + +func (r *RootCmd) provisionerJobsCancel() *serpent.Command { + var ( + client = new(codersdk.Client) + orgContext = NewOrganizationContext() + ) + cmd := &serpent.Command{ + Use: "cancel ", + Short: "Cancel a provisioner job", + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + r.InitClient(client), + ), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + org, err := orgContext.Selected(inv, client) + if err != nil { + return xerrors.Errorf("current organization: %w", err) + } + + jobID, err := uuid.Parse(inv.Args[0]) + if err != nil { + return xerrors.Errorf("invalid job ID: %w", err) + } + + job, err := client.OrganizationProvisionerJob(ctx, org.ID, jobID) + if err != nil { + return xerrors.Errorf("get provisioner job: %w", err) + } + + switch job.Type { + case codersdk.ProvisionerJobTypeTemplateVersionDryRun: + _, _ = fmt.Fprintf(inv.Stdout, "Canceling template version dry run job %s...\n", job.ID) + err = client.CancelTemplateVersionDryRun(ctx, ptr.NilToEmpty(job.Input.TemplateVersionID), job.ID) + case codersdk.ProvisionerJobTypeTemplateVersionImport: + _, _ = fmt.Fprintf(inv.Stdout, "Canceling template version import job %s...\n", job.ID) + err = client.CancelTemplateVersion(ctx, ptr.NilToEmpty(job.Input.TemplateVersionID)) + case codersdk.ProvisionerJobTypeWorkspaceBuild: + _, _ = fmt.Fprintf(inv.Stdout, "Canceling workspace build job %s...\n", job.ID) + err = client.CancelWorkspaceBuild(ctx, ptr.NilToEmpty(job.Input.WorkspaceBuildID)) + } + if err != nil { + return xerrors.Errorf("cancel provisioner job: %w", err) + } + + _, _ = fmt.Fprintln(inv.Stdout, "Job canceled") + + return nil + }, + } + + orgContext.AttachOptions(cmd) + + return cmd +} diff --git a/cli/provisionerjobs_test.go b/cli/provisionerjobs_test.go new file mode 100644 index 0000000000000..1566147c5311d --- /dev/null +++ b/cli/provisionerjobs_test.go @@ -0,0 +1,189 @@ +package cli_test + +import ( + "bytes" + "database/sql" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/aws/smithy-go/ptr" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestProvisionerJobs(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + client, _, coderdAPI := coderdtest.NewWithAPI(t, &coderdtest.Options{ + IncludeProvisionerDaemon: false, + Database: db, + Pubsub: ps, + }) + owner := coderdtest.CreateFirstUser(t, client) + templateAdminClient, templateAdmin := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(owner.OrganizationID)) + memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // Create initial resources with a running provisioner. + firstProvisioner := coderdtest.NewTaggedProvisionerDaemon(t, coderdAPI, "default-provisioner", map[string]string{"owner": "", "scope": "organization"}) + t.Cleanup(func() { _ = firstProvisioner.Close() }) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(req *codersdk.CreateTemplateRequest) { + req.AllowUserCancelWorkspaceJobs = ptr.Bool(true) + }) + + // Stop the provisioner so it doesn't grab any more jobs. + firstProvisioner.Close() + + t.Run("Cancel", func(t *testing.T) { + t.Parallel() + + // Set up test helpers. + type jobInput struct { + WorkspaceBuildID string `json:"workspace_build_id,omitempty"` + TemplateVersionID string `json:"template_version_id,omitempty"` + DryRun bool `json:"dry_run,omitempty"` + } + prepareJob := func(t *testing.T, input jobInput) database.ProvisionerJob { + t.Helper() + + inputBytes, err := json.Marshal(input) + require.NoError(t, err) + + var typ database.ProvisionerJobType + switch { + case input.WorkspaceBuildID != "": + typ = database.ProvisionerJobTypeWorkspaceBuild + case input.TemplateVersionID != "": + if input.DryRun { + typ = database.ProvisionerJobTypeTemplateVersionDryRun + } else { + typ = database.ProvisionerJobTypeTemplateVersionImport + } + default: + t.Fatal("invalid input") + } + + var ( + tags = database.StringMap{"owner": "", "scope": "organization", "foo": uuid.New().String()} + _ = dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{Tags: tags}) + job = dbgen.ProvisionerJob(t, db, coderdAPI.Pubsub, database.ProvisionerJob{ + InitiatorID: member.ID, + Input: json.RawMessage(inputBytes), + Type: typ, + Tags: tags, + StartedAt: sql.NullTime{Time: coderdAPI.Clock.Now().Add(-time.Minute), Valid: true}, + }) + ) + return job + } + + prepareWorkspaceBuildJob := func(t *testing.T) database.ProvisionerJob { + t.Helper() + var ( + wbID = uuid.New() + job = prepareJob(t, jobInput{WorkspaceBuildID: wbID.String()}) + w = dbgen.Workspace(t, db, database.WorkspaceTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + TemplateID: template.ID, + }) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + ID: wbID, + InitiatorID: member.ID, + WorkspaceID: w.ID, + TemplateVersionID: version.ID, + JobID: job.ID, + }) + ) + return job + } + + prepareTemplateVersionImportJobBuilder := func(t *testing.T, dryRun bool) database.ProvisionerJob { + t.Helper() + var ( + tvID = uuid.New() + job = prepareJob(t, jobInput{TemplateVersionID: tvID.String(), DryRun: dryRun}) + _ = dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: owner.OrganizationID, + CreatedBy: templateAdmin.ID, + ID: tvID, + TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, + JobID: job.ID, + }) + ) + return job + } + prepareTemplateVersionImportJob := func(t *testing.T) database.ProvisionerJob { + return prepareTemplateVersionImportJobBuilder(t, false) + } + prepareTemplateVersionImportJobDryRun := func(t *testing.T) database.ProvisionerJob { + return prepareTemplateVersionImportJobBuilder(t, true) + } + + // Run the cancellation test suite. + for _, tt := range []struct { + role string + client *codersdk.Client + name string + prepare func(*testing.T) database.ProvisionerJob + wantCancelled bool + }{ + {"Owner", client, "WorkspaceBuild", prepareWorkspaceBuildJob, true}, + {"Owner", client, "TemplateVersionImport", prepareTemplateVersionImportJob, true}, + {"Owner", client, "TemplateVersionImportDryRun", prepareTemplateVersionImportJobDryRun, true}, + {"TemplateAdmin", templateAdminClient, "WorkspaceBuild", prepareWorkspaceBuildJob, false}, + {"TemplateAdmin", templateAdminClient, "TemplateVersionImport", prepareTemplateVersionImportJob, true}, + {"TemplateAdmin", templateAdminClient, "TemplateVersionImportDryRun", prepareTemplateVersionImportJobDryRun, false}, + {"Member", memberClient, "WorkspaceBuild", prepareWorkspaceBuildJob, false}, + {"Member", memberClient, "TemplateVersionImport", prepareTemplateVersionImportJob, false}, + {"Member", memberClient, "TemplateVersionImportDryRun", prepareTemplateVersionImportJobDryRun, false}, + } { + tt := tt + wantMsg := "OK" + if !tt.wantCancelled { + wantMsg = "FAIL" + } + t.Run(fmt.Sprintf("%s/%s/%v", tt.role, tt.name, wantMsg), func(t *testing.T) { + t.Parallel() + + job := tt.prepare(t) + require.False(t, job.CanceledAt.Valid, "job.CanceledAt.Valid") + + inv, root := clitest.New(t, "provisioner", "jobs", "cancel", job.ID.String()) + clitest.SetupConfig(t, tt.client, root) + var buf bytes.Buffer + inv.Stdout = &buf + err := inv.Run() + if tt.wantCancelled { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + + job, err = db.GetProvisionerJobByID(testutil.Context(t, testutil.WaitShort), job.ID) + require.NoError(t, err) + assert.Equal(t, tt.wantCancelled, job.CanceledAt.Valid, "job.CanceledAt.Valid") + assert.Equal(t, tt.wantCancelled, job.CanceledAt.Time.After(job.StartedAt.Time), "job.CanceledAt.Time") + if tt.wantCancelled { + assert.Contains(t, buf.String(), "Job canceled") + } else { + assert.NotContains(t, buf.String(), "Job canceled") + } + }) + } + }) +} diff --git a/cli/testdata/coder_provisioner_jobs_--help.golden b/cli/testdata/coder_provisioner_jobs_--help.golden index 6442c78a03a8e..36600a06735a5 100644 --- a/cli/testdata/coder_provisioner_jobs_--help.golden +++ b/cli/testdata/coder_provisioner_jobs_--help.golden @@ -8,7 +8,8 @@ USAGE: Aliases: job SUBCOMMANDS: - list List provisioner jobs + cancel Cancel a provisioner job + list List provisioner jobs ——— Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_provisioner_jobs_cancel_--help.golden b/cli/testdata/coder_provisioner_jobs_cancel_--help.golden new file mode 100644 index 0000000000000..aed9cf20f9091 --- /dev/null +++ b/cli/testdata/coder_provisioner_jobs_cancel_--help.golden @@ -0,0 +1,13 @@ +coder v0.0.0-devel + +USAGE: + coder provisioner jobs cancel [flags] + + Cancel a provisioner job + +OPTIONS: + -O, --org string, $CODER_ORGANIZATION + Select which organization (uuid or name) to use. + +——— +Run `coder --help` for a list of global options. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3b53b81d2192a..f16653c1c834b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3090,6 +3090,49 @@ const docTemplate = `{ } } }, + "/organizations/{organization}/provisionerjobs/{job}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Organizations" + ], + "summary": "Get provisioner job", + "operationId": "get-provisioner-job", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Job ID", + "name": "job", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ProvisionerJob" + } + } + } + } + }, "/organizations/{organization}/provisionerkeys": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 743e7404c08ec..7859d7ffdc5e5 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2718,6 +2718,45 @@ } } }, + "/organizations/{organization}/provisionerjobs/{job}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Organizations"], + "summary": "Get provisioner job", + "operationId": "get-provisioner-job", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Job ID", + "name": "job", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ProvisionerJob" + } + } + } + } + }, "/organizations/{organization}/provisionerkeys": { "get": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index 9530f7cef10c9..e273b7afdb80f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1011,6 +1011,7 @@ func New(options *Options) *API { r.Get("/", api.provisionerDaemons) }) r.Route("/provisionerjobs", func(r chi.Router) { + r.Get("/{job}", api.provisionerJob) r.Get("/", api.provisionerJobs) }) }) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index b5d3280adde2a..6b518c7696369 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -4082,6 +4082,9 @@ func (q *FakeQuerier) GetProvisionerJobsByOrganizationAndStatusWithQueuePosition if len(arg.Status) > 0 && !slices.Contains(arg.Status, job.JobStatus) { continue } + if len(arg.IDs) > 0 && !slices.Contains(arg.IDs, job.ID) { + continue + } row := database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow{ ProvisionerJob: rowQP.ProvisionerJob, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 20800018a3a0e..1d18bfe3ad37c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6274,7 +6274,8 @@ LEFT JOIN queue_size qs ON TRUE WHERE ($1::uuid IS NULL OR pj.organization_id = $1) - AND (COALESCE(array_length($2::provisioner_job_status[], 1), 0) = 0 OR pj.job_status = ANY($2::provisioner_job_status[])) + AND (COALESCE(array_length($2::uuid[], 1), 0) = 0 OR pj.id = ANY($2::uuid[])) + AND (COALESCE(array_length($3::provisioner_job_status[], 1), 0) = 0 OR pj.job_status = ANY($3::provisioner_job_status[])) GROUP BY pj.id, qp.queue_position, @@ -6282,11 +6283,12 @@ GROUP BY ORDER BY pj.created_at DESC LIMIT - $3::int + $4::int ` type GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams struct { OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"` + IDs []uuid.UUID `db:"ids" json:"ids"` Status []ProvisionerJobStatus `db:"status" json:"status"` Limit sql.NullInt32 `db:"limit" json:"limit"` } @@ -6299,7 +6301,12 @@ type GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow } func (q *sqlQuerier) GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner(ctx context.Context, arg GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) ([]GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow, error) { - rows, err := q.db.QueryContext(ctx, getProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner, arg.OrganizationID, pq.Array(arg.Status), arg.Limit) + rows, err := q.db.QueryContext(ctx, getProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner, + arg.OrganizationID, + pq.Array(arg.IDs), + pq.Array(arg.Status), + arg.Limit, + ) if err != nil { return nil, err } diff --git a/coderd/database/queries/provisionerjobs.sql b/coderd/database/queries/provisionerjobs.sql index 371c1c4fc76e9..e7078dcfbff15 100644 --- a/coderd/database/queries/provisionerjobs.sql +++ b/coderd/database/queries/provisionerjobs.sql @@ -139,6 +139,7 @@ LEFT JOIN queue_size qs ON TRUE WHERE (sqlc.narg('organization_id')::uuid IS NULL OR pj.organization_id = @organization_id) + AND (COALESCE(array_length(@ids::uuid[], 1), 0) = 0 OR pj.id = ANY(@ids::uuid[])) AND (COALESCE(array_length(@status::provisioner_job_status[], 1), 0) = 0 OR pj.job_status = ANY(@status::provisioner_job_status[])) GROUP BY pj.id, diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index f61ecb1146743..591c60855a65e 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -29,6 +29,42 @@ import ( "github.com/coder/websocket" ) +// @Summary Get provisioner job +// @ID get-provisioner-job +// @Security CoderSessionToken +// @Produce json +// @Tags Organizations +// @Param organization path string true "Organization ID" format(uuid) +// @Param job path string true "Job ID" format(uuid) +// @Success 200 {object} codersdk.ProvisionerJob +// @Router /organizations/{organization}/provisionerjobs/{job} [get] +func (api *API) provisionerJob(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + jobID, ok := httpmw.ParseUUIDParam(rw, r, "job") + if !ok { + return + } + + jobs, ok := api.handleAuthAndFetchProvisionerJobs(rw, r, []uuid.UUID{jobID}) + if !ok { + return + } + if len(jobs) == 0 { + httpapi.ResourceNotFound(rw) + return + } + if len(jobs) > 1 || jobs[0].ProvisionerJob.ID != jobID { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching provisioner job.", + Detail: "Database returned an unexpected job.", + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, convertProvisionerJobWithQueuePosition(jobs[0])) +} + // @Summary Get provisioner jobs // @ID get-provisioner-jobs // @Security CoderSessionToken @@ -41,12 +77,26 @@ import ( // @Router /organizations/{organization}/provisionerjobs [get] func (api *API) provisionerJobs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + + jobs, ok := api.handleAuthAndFetchProvisionerJobs(rw, r, nil) + if !ok { + return + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(jobs, convertProvisionerJobWithQueuePosition)) +} + +// handleAuthAndFetchProvisionerJobs is an internal method shared by +// provisionerJob and provisionerJobs. If ok is false the caller should +// return immediately because the response has already been written. +func (api *API) handleAuthAndFetchProvisionerJobs(rw http.ResponseWriter, r *http.Request, ids []uuid.UUID) (_ []database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow, ok bool) { + ctx := r.Context() org := httpmw.OrganizationParam(r) // For now, only owners and template admins can access provisioner jobs. if !api.Authorize(r, policy.ActionRead, rbac.ResourceProvisionerJobs.InOrg(org.ID)) { httpapi.ResourceNotFound(rw) - return + return nil, false } qp := r.URL.Query() @@ -59,36 +109,29 @@ func (api *API) provisionerJobs(rw http.ResponseWriter, r *http.Request) { Message: "Invalid query parameters.", Validations: p.Errors, }) - return + return nil, false } jobs, err := api.Database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner(ctx, database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams{ OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true}, Status: slice.StringEnums[database.ProvisionerJobStatus](status), Limit: sql.NullInt32{Int32: limit, Valid: limit > 0}, + IDs: ids, }) if err != nil { if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) - return + return nil, false } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching provisioner jobs.", Detail: err.Error(), }) - return + return nil, false } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(jobs, func(dbJob database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow) codersdk.ProvisionerJob { - job := convertProvisionerJob(database.GetProvisionerJobsByIDsWithQueuePositionRow{ - ProvisionerJob: dbJob.ProvisionerJob, - QueuePosition: dbJob.QueuePosition, - QueueSize: dbJob.QueueSize, - }) - job.AvailableWorkers = dbJob.AvailableWorkers - return job - })) + return jobs, true } // Returns provisioner logs based on query parameters. @@ -338,6 +381,16 @@ func convertProvisionerJob(pj database.GetProvisionerJobsByIDsWithQueuePositionR return job } +func convertProvisionerJobWithQueuePosition(pj database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow) codersdk.ProvisionerJob { + job := convertProvisionerJob(database.GetProvisionerJobsByIDsWithQueuePositionRow{ + ProvisionerJob: pj.ProvisionerJob, + QueuePosition: pj.QueuePosition, + QueueSize: pj.QueueSize, + }) + job.AvailableWorkers = pj.AvailableWorkers + return job +} + func fetchAndWriteLogs(ctx context.Context, db database.Store, jobID uuid.UUID, after int64, rw http.ResponseWriter) { logs, err := db.GetProvisionerLogsAfterID(ctx, database.GetProvisionerLogsAfterIDParams{ JobID: jobID, diff --git a/coderd/provisionerjobs_test.go b/coderd/provisionerjobs_test.go index f7ce721f9d048..a8fd4f2a968f2 100644 --- a/coderd/provisionerjobs_test.go +++ b/coderd/provisionerjobs_test.go @@ -63,6 +63,25 @@ func TestProvisionerJobs(t *testing.T) { TemplateVersionID: version.ID, }) + t.Run("Single", func(t *testing.T) { + t.Parallel() + t.Run("OK", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + // Note this calls the single job endpoint. + job2, err := templateAdminClient.OrganizationProvisionerJob(ctx, owner.OrganizationID, job.ID) + require.NoError(t, err) + require.Equal(t, job.ID, job2.ID) + }) + t.Run("Missing", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + // Note this calls the single job endpoint. + _, err := templateAdminClient.OrganizationProvisionerJob(ctx, owner.OrganizationID, uuid.New()) + require.Error(t, err) + }) + }) + t.Run("All", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) diff --git a/codersdk/organizations.go b/codersdk/organizations.go index f25135598180c..a6bacd2798043 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -377,6 +377,22 @@ func (c *Client) OrganizationProvisionerJobs(ctx context.Context, organizationID return jobs, json.NewDecoder(res.Body).Decode(&jobs) } +func (c *Client) OrganizationProvisionerJob(ctx context.Context, organizationID, jobID uuid.UUID) (job ProvisionerJob, err error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/organizations/%s/provisionerjobs/%s", organizationID.String(), jobID.String()), + nil, + ) + if err != nil { + return job, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return job, ReadBodyAsError(res) + } + return job, json.NewDecoder(res.Body).Decode(&job) +} + func joinSlice[T ~string](s []T) string { var ss []string for _, v := range s { diff --git a/docs/manifest.json b/docs/manifest.json index a21d7583cc357..5dcba4a33fee8 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1158,6 +1158,11 @@ "description": "View and manage provisioner jobs", "path": "reference/cli/provisioner_jobs.md" }, + { + "title": "provisioner jobs cancel", + "description": "Cancel a provisioner job", + "path": "reference/cli/provisioner_jobs_cancel.md" + }, { "title": "provisioner jobs list", "description": "List provisioner jobs", diff --git a/docs/reference/api/organizations.md b/docs/reference/api/organizations.md index fc58bef11342d..32789743afc38 100644 --- a/docs/reference/api/organizations.md +++ b/docs/reference/api/organizations.md @@ -471,3 +471,66 @@ Status Code **200** | `type` | `template_version_dry_run` | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get provisioner job + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/provisionerjobs/{job} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /organizations/{organization}/provisionerjobs/{job}` + +### Parameters + +| Name | In | Type | Required | Description | +|----------------|------|--------------|----------|-----------------| +| `organization` | path | string(uuid) | true | Organization ID | +| `job` | path | string(uuid) | true | Job ID | + +### Example responses + +> 200 Response + +```json +{ + "available_workers": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "canceled_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "created_at": "2019-08-24T14:15:22Z", + "error": "string", + "error_code": "REQUIRED_TEMPLATE_VARIABLES", + "file_id": "8a0cfb4f-ddc9-436d-91bb-75133c583767", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "input": { + "error": "string", + "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", + "workspace_build_id": "badaf2eb-96c5-4050-9f1d-db2d39ca5478" + }, + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "queue_position": 0, + "queue_size": 0, + "started_at": "2019-08-24T14:15:22Z", + "status": "pending", + "tags": { + "property1": "string", + "property2": "string" + }, + "type": "template_version_import", + "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ProvisionerJob](schemas.md#codersdkprovisionerjob) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/cli/provisioner_jobs.md b/docs/reference/cli/provisioner_jobs.md index 359c5d8c0f7b5..1bd2226af0920 100644 --- a/docs/reference/cli/provisioner_jobs.md +++ b/docs/reference/cli/provisioner_jobs.md @@ -15,6 +15,7 @@ coder provisioner jobs ## Subcommands -| Name | Purpose | -|-------------------------------------------------|-----------------------| -| [list](./provisioner_jobs_list.md) | List provisioner jobs | +| Name | Purpose | +|-----------------------------------------------------|--------------------------| +| [cancel](./provisioner_jobs_cancel.md) | Cancel a provisioner job | +| [list](./provisioner_jobs_list.md) | List provisioner jobs | diff --git a/docs/reference/cli/provisioner_jobs_cancel.md b/docs/reference/cli/provisioner_jobs_cancel.md new file mode 100644 index 0000000000000..2040247b1199d --- /dev/null +++ b/docs/reference/cli/provisioner_jobs_cancel.md @@ -0,0 +1,21 @@ + +# provisioner jobs cancel + +Cancel a provisioner job + +## Usage + +```console +coder provisioner jobs cancel [flags] +``` + +## Options + +### -O, --org + +| | | +|-------------|----------------------------------| +| Type | string | +| Environment | $CODER_ORGANIZATION | + +Select which organization (uuid or name) to use. diff --git a/enterprise/cli/testdata/coder_provisioner_jobs_--help.golden b/enterprise/cli/testdata/coder_provisioner_jobs_--help.golden index 6442c78a03a8e..36600a06735a5 100644 --- a/enterprise/cli/testdata/coder_provisioner_jobs_--help.golden +++ b/enterprise/cli/testdata/coder_provisioner_jobs_--help.golden @@ -8,7 +8,8 @@ USAGE: Aliases: job SUBCOMMANDS: - list List provisioner jobs + cancel Cancel a provisioner job + list List provisioner jobs ——— Run `coder --help` for a list of global options. diff --git a/enterprise/cli/testdata/coder_provisioner_jobs_cancel_--help.golden b/enterprise/cli/testdata/coder_provisioner_jobs_cancel_--help.golden new file mode 100644 index 0000000000000..aed9cf20f9091 --- /dev/null +++ b/enterprise/cli/testdata/coder_provisioner_jobs_cancel_--help.golden @@ -0,0 +1,13 @@ +coder v0.0.0-devel + +USAGE: + coder provisioner jobs cancel [flags] + + Cancel a provisioner job + +OPTIONS: + -O, --org string, $CODER_ORGANIZATION + Select which organization (uuid or name) to use. + +——— +Run `coder --help` for a list of global options.