From efdcbba991149602b7f3fe38264e0fc97ac810d0 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 23 Jan 2025 11:34:05 +0000 Subject: [PATCH 1/6] feat(cli): add provisioner job cancel command --- cli/provisionerjobs.go | 58 ++++++ cli/provisionerjobs_test.go | 187 ++++++++++++++++++ .../coder_provisioner_jobs_--help.golden | 3 +- ...oder_provisioner_jobs_cancel_--help.golden | 13 ++ coderd/apidoc/docs.go | 43 ++++ coderd/apidoc/swagger.json | 39 ++++ coderd/coderd.go | 1 + coderd/database/dbmem/dbmem.go | 3 + coderd/database/queries.sql.go | 13 +- coderd/database/queries/provisionerjobs.sql | 1 + coderd/provisionerjobs.go | 76 +++++-- codersdk/organizations.go | 16 ++ docs/manifest.json | 5 + docs/reference/api/organizations.md | 63 ++++++ docs/reference/cli/provisioner_jobs.md | 7 +- docs/reference/cli/provisioner_jobs_cancel.md | 21 ++ .../coder_provisioner_jobs_--help.golden | 3 +- ...oder_provisioner_jobs_cancel_--help.golden | 13 ++ 18 files changed, 544 insertions(+), 21 deletions(-) create mode 100644 cli/provisionerjobs_test.go create mode 100644 cli/testdata/coder_provisioner_jobs_cancel_--help.golden create mode 100644 docs/reference/cli/provisioner_jobs_cancel.md create mode 100644 enterprise/cli/testdata/coder_provisioner_jobs_cancel_--help.golden 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..39762e0855af0 --- /dev/null +++ b/cli/provisionerjobs_test.go @@ -0,0 +1,187 @@ +package cli_test + +import ( + "bytes" + "context" + "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" +) + +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) + }) + + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // 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()} + pd = dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{Tags: tags}) + job = dbgen.ProvisionerJob(t, db, coderdAPI.Pubsub, database.ProvisionerJob{ + WorkerID: uuid.NullUUID{UUID: pd.ID, Valid: true}, + 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{OwnerID: member.ID, TemplateID: template.ID}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + ID: wbID, + 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.Error(t, err) + } else { + assert.NoError(t, err) + } + + job, err = db.GetProvisionerJobByID(context.Background(), 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..04c980198ab2b 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.getProvisionerJobs(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 expected job.", + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, convertProvisionerJobWithQueuePosition(jobs[0])) +} + // @Summary Get provisioner jobs // @ID get-provisioner-jobs // @Security CoderSessionToken @@ -41,12 +77,23 @@ import ( // @Router /organizations/{organization}/provisionerjobs [get] func (api *API) provisionerJobs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + + jobs, ok := api.getProvisionerJobs(rw, r, nil) + if !ok { + return + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(jobs, convertProvisionerJobWithQueuePosition)) +} + +func (api *API) getProvisionerJobs(rw http.ResponseWriter, r *http.Request, ids []uuid.UUID) ([]database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow, 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 +106,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 +378,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/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. From f217a5edbef1723a48c06413466a246fc96669bb Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 24 Jan 2025 13:18:58 +0000 Subject: [PATCH 2/6] improve getProvisionerJobs docs --- coderd/provisionerjobs.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index 04c980198ab2b..3206038929057 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -46,7 +46,7 @@ func (api *API) provisionerJob(rw http.ResponseWriter, r *http.Request) { return } - jobs, ok := api.getProvisionerJobs(rw, r, []uuid.UUID{jobID}) + jobs, ok := api.handleAuthAndFetchProvisionerJobs(rw, r, []uuid.UUID{jobID}) if !ok { return } @@ -78,7 +78,7 @@ func (api *API) provisionerJob(rw http.ResponseWriter, r *http.Request) { func (api *API) provisionerJobs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - jobs, ok := api.getProvisionerJobs(rw, r, nil) + jobs, ok := api.handleAuthAndFetchProvisionerJobs(rw, r, nil) if !ok { return } @@ -86,7 +86,10 @@ func (api *API) provisionerJobs(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(jobs, convertProvisionerJobWithQueuePosition)) } -func (api *API) getProvisionerJobs(rw http.ResponseWriter, r *http.Request, ids []uuid.UUID) ([]database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow, bool) { +// 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) From eb3f94ef603951d1c4dda39406ed080342509746 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 24 Jan 2025 13:20:09 +0000 Subject: [PATCH 3/6] use testutil ctx --- cli/provisionerjobs_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/provisionerjobs_test.go b/cli/provisionerjobs_test.go index 39762e0855af0..4736bd5106e7a 100644 --- a/cli/provisionerjobs_test.go +++ b/cli/provisionerjobs_test.go @@ -2,7 +2,6 @@ package cli_test import ( "bytes" - "context" "database/sql" "encoding/json" "fmt" @@ -21,6 +20,7 @@ import ( "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) { @@ -172,7 +172,7 @@ func TestProvisionerJobs(t *testing.T) { assert.NoError(t, err) } - job, err = db.GetProvisionerJobByID(context.Background(), job.ID) + 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") From 57f76d677da705163af5c46a41c4708a7bb8a198 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 24 Jan 2025 13:36:02 +0000 Subject: [PATCH 4/6] dbgen improvements --- cli/provisionerjobs_test.go | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/cli/provisionerjobs_test.go b/cli/provisionerjobs_test.go index 4736bd5106e7a..1566147c5311d 100644 --- a/cli/provisionerjobs_test.go +++ b/cli/provisionerjobs_test.go @@ -45,9 +45,6 @@ func TestProvisionerJobs(t *testing.T) { req.AllowUserCancelWorkspaceJobs = ptr.Bool(true) }) - workspace := coderdtest.CreateWorkspace(t, client, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - // Stop the provisioner so it doesn't grab any more jobs. firstProvisioner.Close() @@ -82,13 +79,13 @@ func TestProvisionerJobs(t *testing.T) { var ( tags = database.StringMap{"owner": "", "scope": "organization", "foo": uuid.New().String()} - pd = dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{Tags: tags}) + _ = dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{Tags: tags}) job = dbgen.ProvisionerJob(t, db, coderdAPI.Pubsub, database.ProvisionerJob{ - WorkerID: uuid.NullUUID{UUID: pd.ID, Valid: true}, - Input: json.RawMessage(inputBytes), - Type: typ, - Tags: tags, - StartedAt: sql.NullTime{Time: coderdAPI.Clock.Now().Add(-time.Minute), Valid: true}, + 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 @@ -99,9 +96,14 @@ func TestProvisionerJobs(t *testing.T) { var ( wbID = uuid.New() job = prepareJob(t, jobInput{WorkspaceBuildID: wbID.String()}) - w = dbgen.Workspace(t, db, database.WorkspaceTable{OwnerID: member.ID, TemplateID: template.ID}) - _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + 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, @@ -166,10 +168,10 @@ func TestProvisionerJobs(t *testing.T) { var buf bytes.Buffer inv.Stdout = &buf err := inv.Run() - if !tt.wantCancelled { - assert.Error(t, err) - } else { + if tt.wantCancelled { assert.NoError(t, err) + } else { + assert.Error(t, err) } job, err = db.GetProvisionerJobByID(testutil.Context(t, testutil.WaitShort), job.ID) From 8e8b4513d1bdcf5af221d1bc08b7cc75db14926f Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 24 Jan 2025 15:36:31 +0200 Subject: [PATCH 5/6] Update coderd/provisionerjobs.go --- coderd/provisionerjobs.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index 3206038929057..591c60855a65e 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -57,7 +57,7 @@ func (api *API) provisionerJob(rw http.ResponseWriter, r *http.Request) { 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 expected job.", + Detail: "Database returned an unexpected job.", }) return } From 31007a7f3c4d7aea4df40bd04faba8b38ca4730e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 27 Jan 2025 16:11:53 +0000 Subject: [PATCH 6/6] add tests for endpoint --- coderd/provisionerjobs_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy