diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index d8a07dd20680e..0ef065dd86a81 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -43,7 +43,12 @@ "scope": "organization" }, "queue_position": 0, - "queue_size": 0 + "queue_size": 0, + "organization_id": "===========[first org ID]===========", + "input": { + "workspace_build_id": "========[workspace build ID]========" + }, + "type": "workspace_build" }, "reason": "initiator", "resources": [], diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3b319226e043f..e034492d7882f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3025,6 +3025,71 @@ const docTemplate = `{ } } }, + "/organizations/{organization}/provisionerjobs": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Organizations" + ], + "summary": "Get provisioner jobs", + "operationId": "get-provisioner-jobs", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "enum": [ + "pending", + "running", + "succeeded", + "canceling", + "canceled", + "failed", + "unknown", + "pending", + "running", + "succeeded", + "canceling", + "canceled", + "failed" + ], + "type": "string", + "description": "Filter results by status", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ProvisionerJob" + } + } + } + } + } + }, "/organizations/{organization}/provisionerkeys": { "get": { "security": [ @@ -12561,6 +12626,13 @@ const docTemplate = `{ "codersdk.ProvisionerJob": { "type": "object", "properties": { + "available_workers": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, "canceled_at": { "type": "string", "format": "date-time" @@ -12594,6 +12666,13 @@ const docTemplate = `{ "type": "string", "format": "uuid" }, + "input": { + "$ref": "#/definitions/codersdk.ProvisionerJobInput" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, "queue_position": { "type": "integer" }, @@ -12625,12 +12704,31 @@ const docTemplate = `{ "type": "string" } }, + "type": { + "$ref": "#/definitions/codersdk.ProvisionerJobType" + }, "worker_id": { "type": "string", "format": "uuid" } } }, + "codersdk.ProvisionerJobInput": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "template_version_id": { + "type": "string", + "format": "uuid" + }, + "workspace_build_id": { + "type": "string", + "format": "uuid" + } + } + }, "codersdk.ProvisionerJobLog": { "type": "object", "properties": { @@ -12687,6 +12785,19 @@ const docTemplate = `{ "ProvisionerJobUnknown" ] }, + "codersdk.ProvisionerJobType": { + "type": "string", + "enum": [ + "template_version_import", + "workspace_build", + "template_version_dry_run" + ], + "x-enum-varnames": [ + "ProvisionerJobTypeTemplateVersionImport", + "ProvisionerJobTypeWorkspaceBuild", + "ProvisionerJobTypeTemplateVersionDryRun" + ] + }, "codersdk.ProvisionerKey": { "type": "object", "properties": { @@ -12900,6 +13011,7 @@ const docTemplate = `{ "organization", "organization_member", "provisioner_daemon", + "provisioner_jobs", "provisioner_keys", "replicas", "system", @@ -12934,6 +13046,7 @@ const docTemplate = `{ "ResourceOrganization", "ResourceOrganizationMember", "ResourceProvisionerDaemon", + "ResourceProvisionerJobs", "ResourceProvisionerKeys", "ResourceReplicas", "ResourceSystem", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c4611109df79b..ca71f58dbe195 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2657,6 +2657,67 @@ } } }, + "/organizations/{organization}/provisionerjobs": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Organizations"], + "summary": "Get provisioner jobs", + "operationId": "get-provisioner-jobs", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "enum": [ + "pending", + "running", + "succeeded", + "canceling", + "canceled", + "failed", + "unknown", + "pending", + "running", + "succeeded", + "canceling", + "canceled", + "failed" + ], + "type": "string", + "description": "Filter results by status", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ProvisionerJob" + } + } + } + } + } + }, "/organizations/{organization}/provisionerkeys": { "get": { "security": [ @@ -11334,6 +11395,13 @@ "codersdk.ProvisionerJob": { "type": "object", "properties": { + "available_workers": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, "canceled_at": { "type": "string", "format": "date-time" @@ -11365,6 +11433,13 @@ "type": "string", "format": "uuid" }, + "input": { + "$ref": "#/definitions/codersdk.ProvisionerJobInput" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, "queue_position": { "type": "integer" }, @@ -11396,12 +11471,31 @@ "type": "string" } }, + "type": { + "$ref": "#/definitions/codersdk.ProvisionerJobType" + }, "worker_id": { "type": "string", "format": "uuid" } } }, + "codersdk.ProvisionerJobInput": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "template_version_id": { + "type": "string", + "format": "uuid" + }, + "workspace_build_id": { + "type": "string", + "format": "uuid" + } + } + }, "codersdk.ProvisionerJobLog": { "type": "object", "properties": { @@ -11452,6 +11546,19 @@ "ProvisionerJobUnknown" ] }, + "codersdk.ProvisionerJobType": { + "type": "string", + "enum": [ + "template_version_import", + "workspace_build", + "template_version_dry_run" + ], + "x-enum-varnames": [ + "ProvisionerJobTypeTemplateVersionImport", + "ProvisionerJobTypeWorkspaceBuild", + "ProvisionerJobTypeTemplateVersionDryRun" + ] + }, "codersdk.ProvisionerKey": { "type": "object", "properties": { @@ -11647,6 +11754,7 @@ "organization", "organization_member", "provisioner_daemon", + "provisioner_jobs", "provisioner_keys", "replicas", "system", @@ -11681,6 +11789,7 @@ "ResourceOrganization", "ResourceOrganizationMember", "ResourceProvisionerDaemon", + "ResourceProvisionerJobs", "ResourceProvisionerKeys", "ResourceReplicas", "ResourceSystem", diff --git a/coderd/coderd.go b/coderd/coderd.go index 4cd44f7cc64c0..9530f7cef10c9 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1010,6 +1010,9 @@ func New(options *Options) *API { r.Route("/provisionerdaemons", func(r chi.Router) { r.Get("/", api.provisionerDaemons) }) + r.Route("/provisionerjobs", func(r chi.Router) { + r.Get("/", api.provisionerJobs) + }) }) }) r.Route("/templates", func(r chi.Router) { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index b2f0491949238..b51b65e3261b8 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -5,7 +5,6 @@ import ( "database/sql" "encoding/json" "errors" - "fmt" "strings" "sync/atomic" "testing" @@ -48,7 +47,11 @@ type NotAuthorizedError struct { var _ httpapiconstraints.IsUnauthorizedError = (*NotAuthorizedError)(nil) func (e NotAuthorizedError) Error() string { - return fmt.Sprintf("unauthorized: %s", e.Err.Error()) + var detail string + if e.Err != nil { + detail = ": " + e.Err.Error() + } + return "unauthorized" + detail } // IsUnauthorized implements the IsUnauthorized interface. @@ -1975,7 +1978,7 @@ func (q *querier) GetProvisionerJobTimingsByJobID(ctx context.Context, jobID uui return q.db.GetProvisionerJobTimingsByJobID(ctx, jobID) } -// TODO: we need to add a provisioner job resource +// TODO: We have a ProvisionerJobs resource, but it hasn't been checked for this use-case. func (q *querier) GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUID) ([]database.ProvisionerJob, error) { // if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { // return nil, err @@ -1983,12 +1986,16 @@ func (q *querier) GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUID) return q.db.GetProvisionerJobsByIDs(ctx, ids) } -// TODO: we need to add a provisioner job resource +// TODO: We have a ProvisionerJobs resource, but it hasn't been checked for this use-case. func (q *querier) GetProvisionerJobsByIDsWithQueuePosition(ctx context.Context, ids []uuid.UUID) ([]database.GetProvisionerJobsByIDsWithQueuePositionRow, error) { return q.db.GetProvisionerJobsByIDsWithQueuePosition(ctx, ids) } -// TODO: We need to create a ProvisionerJob resource type +func (q *querier) GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner(ctx context.Context, arg database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) ([]database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner)(ctx, arg) +} + +// TODO: We have a ProvisionerJobs resource, but it hasn't been checked for this use-case. func (q *querier) GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]database.ProvisionerJob, error) { // if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { // return nil, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 5ad8dca748c3f..f451e3a05da28 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -3257,6 +3257,38 @@ func (s *MethodTestSuite) TestExtraMethods() { LastSeenAt: sql.NullTime{Time: dbtime.Now(), Valid: true}, }).Asserts(rbac.ResourceProvisionerDaemon, policy.ActionUpdate) })) + s.Run("GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner", s.Subtest(func(db database.Store, check *expects) { + org := dbgen.Organization(s.T(), db, database.Organization{}) + user := dbgen.User(s.T(), db, database.User{}) + tags := database.StringMap(map[string]string{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }) + t := dbgen.Template(s.T(), db, database.Template{OrganizationID: org.ID, CreatedBy: user.ID}) + tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{OrganizationID: org.ID, CreatedBy: user.ID, TemplateID: uuid.NullUUID{UUID: t.ID, Valid: true}}) + j1 := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ + OrganizationID: org.ID, + Type: database.ProvisionerJobTypeTemplateVersionImport, + Input: []byte(`{"template_version_id":"` + tv.ID.String() + `"}`), + Tags: tags, + }) + w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{OrganizationID: org.ID, OwnerID: user.ID, TemplateID: t.ID}) + wbID := uuid.New() + j2 := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ + OrganizationID: org.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Input: []byte(`{"workspace_build_id":"` + wbID.String() + `"}`), + Tags: tags, + }) + dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ID: wbID, WorkspaceID: w.ID, TemplateVersionID: tv.ID, JobID: j2.ID}) + + ds, err := db.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner(context.Background(), database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams{ + OrganizationID: uuid.NullUUID{Valid: true, UUID: org.ID}, + }) + s.NoError(err, "get provisioner jobs by org") + check.Args(database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams{ + OrganizationID: uuid.NullUUID{Valid: true, UUID: org.ID}, + }).Asserts(j1, policy.ActionRead, j2, policy.ActionRead).Returns(ds) + })) } func (s *MethodTestSuite) TestTailnetFunctions() { diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 1208cf60d573b..fe2ec83831b6e 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "net" "strings" "testing" @@ -248,12 +249,18 @@ func WorkspaceAgentScriptTiming(t testing.TB, db database.Store, orig database.W func Workspace(t testing.TB, db database.Store, orig database.WorkspaceTable) database.WorkspaceTable { t.Helper() + var defOrgID uuid.UUID + if orig.OrganizationID == uuid.Nil { + defOrg, _ := db.GetDefaultOrganization(genCtx) + defOrgID = defOrg.ID + } + workspace, err := db.InsertWorkspace(genCtx, database.InsertWorkspaceParams{ ID: takeFirst(orig.ID, uuid.New()), OwnerID: takeFirst(orig.OwnerID, uuid.New()), CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), UpdatedAt: takeFirst(orig.UpdatedAt, dbtime.Now()), - OrganizationID: takeFirst(orig.OrganizationID, uuid.New()), + OrganizationID: takeFirst(orig.OrganizationID, defOrgID, uuid.New()), TemplateID: takeFirst(orig.TemplateID, uuid.New()), LastUsedAt: takeFirst(orig.LastUsedAt, dbtime.Now()), Name: takeFirst(orig.Name, testutil.GetRandomName(t)), @@ -556,13 +563,15 @@ func ProvisionerJob(t testing.TB, db database.Store, ps pubsub.Pubsub, orig data } jobID := takeFirst(orig.ID, uuid.New()) + // Always set some tags to prevent Acquire from grabbing jobs it should not. + tags := maps.Clone(orig.Tags) if !orig.StartedAt.Time.IsZero() { - if orig.Tags == nil { - orig.Tags = make(database.StringMap) + if tags == nil { + tags = make(database.StringMap) } // Make sure when we acquire the job, we only get this one. - orig.Tags[jobID.String()] = "true" + tags[jobID.String()] = "true" } job, err := db.InsertProvisionerJob(genCtx, database.InsertProvisionerJobParams{ @@ -576,7 +585,7 @@ func ProvisionerJob(t testing.TB, db database.Store, ps pubsub.Pubsub, orig data FileID: takeFirst(orig.FileID, uuid.New()), Type: takeFirst(orig.Type, database.ProvisionerJobTypeWorkspaceBuild), Input: takeFirstSlice(orig.Input, []byte("{}")), - Tags: orig.Tags, + Tags: tags, TraceMetadata: pqtype.NullRawMessage{}, }) require.NoError(t, err, "insert job") @@ -588,9 +597,9 @@ func ProvisionerJob(t testing.TB, db database.Store, ps pubsub.Pubsub, orig data job, err = db.AcquireProvisionerJob(genCtx, database.AcquireProvisionerJobParams{ StartedAt: orig.StartedAt, OrganizationID: job.OrganizationID, - Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, - ProvisionerTags: must(json.Marshal(orig.Tags)), - WorkerID: uuid.NullUUID{}, + Types: []database.ProvisionerType{job.Provisioner}, + ProvisionerTags: must(json.Marshal(tags)), + WorkerID: takeFirst(orig.WorkerID, uuid.NullUUID{}), }) require.NoError(t, err) // There is no easy way to make sure we acquire the correct job. @@ -598,7 +607,7 @@ func ProvisionerJob(t testing.TB, db database.Store, ps pubsub.Pubsub, orig data } if !orig.CompletedAt.Time.IsZero() || orig.Error.String != "" { - err := db.UpdateProvisionerJobWithCompleteByID(genCtx, database.UpdateProvisionerJobWithCompleteByIDParams{ + err = db.UpdateProvisionerJobWithCompleteByID(genCtx, database.UpdateProvisionerJobWithCompleteByIDParams{ ID: jobID, UpdatedAt: job.UpdatedAt, CompletedAt: orig.CompletedAt, @@ -608,7 +617,7 @@ func ProvisionerJob(t testing.TB, db database.Store, ps pubsub.Pubsub, orig data require.NoError(t, err) } if !orig.CanceledAt.Time.IsZero() { - err := db.UpdateProvisionerJobWithCancelByID(genCtx, database.UpdateProvisionerJobWithCancelByIDParams{ + err = db.UpdateProvisionerJobWithCancelByID(genCtx, database.UpdateProvisionerJobWithCancelByIDParams{ ID: jobID, CanceledAt: orig.CanceledAt, CompletedAt: orig.CompletedAt, @@ -617,7 +626,7 @@ func ProvisionerJob(t testing.TB, db database.Store, ps pubsub.Pubsub, orig data } job, err = db.GetProvisionerJobByID(genCtx, jobID) - require.NoError(t, err) + require.NoError(t, err, "get job: %s", jobID.String()) return job } diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index fe15f8e505d3e..25997cafd736f 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1130,6 +1130,96 @@ func getOwnerFromTags(tags map[string]string) string { return "" } +func (q *FakeQuerier) getProvisionerJobsByIDsWithQueuePositionLocked(_ context.Context, ids []uuid.UUID) ([]database.GetProvisionerJobsByIDsWithQueuePositionRow, error) { + // WITH pending_jobs AS ( + // SELECT + // id, created_at + // FROM + // provisioner_jobs + // WHERE + // started_at IS NULL + // AND + // canceled_at IS NULL + // AND + // completed_at IS NULL + // AND + // error IS NULL + // ), + type pendingJobRow struct { + ID uuid.UUID + CreatedAt time.Time + } + pendingJobs := make([]pendingJobRow, 0) + for _, job := range q.provisionerJobs { + if job.StartedAt.Valid || + job.CanceledAt.Valid || + job.CompletedAt.Valid || + job.Error.Valid { + continue + } + pendingJobs = append(pendingJobs, pendingJobRow{ + ID: job.ID, + CreatedAt: job.CreatedAt, + }) + } + + // queue_position AS ( + // SELECT + // id, + // ROW_NUMBER() OVER (ORDER BY created_at ASC) AS queue_position + // FROM + // pending_jobs + // ), + slices.SortFunc(pendingJobs, func(a, b pendingJobRow) int { + c := a.CreatedAt.Compare(b.CreatedAt) + return c + }) + + queuePosition := make(map[uuid.UUID]int64) + for idx, pj := range pendingJobs { + queuePosition[pj.ID] = int64(idx + 1) + } + + // queue_size AS ( + // SELECT COUNT(*) AS count FROM pending_jobs + // ), + queueSize := len(pendingJobs) + + // SELECT + // sqlc.embed(pj), + // COALESCE(qp.queue_position, 0) AS queue_position, + // COALESCE(qs.count, 0) AS queue_size + // FROM + // provisioner_jobs pj + // LEFT JOIN + // queue_position qp ON pj.id = qp.id + // LEFT JOIN + // queue_size qs ON TRUE + // WHERE + // pj.id IN (...) + jobs := make([]database.GetProvisionerJobsByIDsWithQueuePositionRow, 0) + for _, job := range q.provisionerJobs { + if ids != nil && !slices.Contains(ids, job.ID) { + continue + } + // clone the Tags before appending, since maps are reference types and + // we don't want the caller to be able to mutate the map we have inside + // dbmem! + job.Tags = maps.Clone(job.Tags) + job := database.GetProvisionerJobsByIDsWithQueuePositionRow{ + // sqlc.embed(pj), + ProvisionerJob: job, + // COALESCE(qp.queue_position, 0) AS queue_position, + QueuePosition: queuePosition[job.ID], + // COALESCE(qs.count, 0) AS queue_size + QueueSize: int64(queueSize), + } + jobs = append(jobs, job) + } + + return jobs, nil +} + func (*FakeQuerier) AcquireLock(_ context.Context, _ int64) error { return xerrors.New("AcquireLock must only be called within a transaction") } @@ -3901,97 +3991,129 @@ func (q *FakeQuerier) GetProvisionerJobsByIDs(_ context.Context, ids []uuid.UUID return jobs, nil } -func (q *FakeQuerier) GetProvisionerJobsByIDsWithQueuePosition(_ context.Context, ids []uuid.UUID) ([]database.GetProvisionerJobsByIDsWithQueuePositionRow, error) { +func (q *FakeQuerier) GetProvisionerJobsByIDsWithQueuePosition(ctx context.Context, ids []uuid.UUID) ([]database.GetProvisionerJobsByIDsWithQueuePositionRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() - // WITH pending_jobs AS ( - // SELECT - // id, created_at - // FROM - // provisioner_jobs - // WHERE - // started_at IS NULL - // AND - // canceled_at IS NULL - // AND - // completed_at IS NULL - // AND - // error IS NULL - // ), - type pendingJobRow struct { - ID uuid.UUID - CreatedAt time.Time + if ids == nil { + ids = []uuid.UUID{} } - pendingJobs := make([]pendingJobRow, 0) - for _, job := range q.provisionerJobs { - if job.StartedAt.Valid || - job.CanceledAt.Valid || - job.CompletedAt.Valid || - job.Error.Valid { - continue - } - pendingJobs = append(pendingJobs, pendingJobRow{ - ID: job.ID, - CreatedAt: job.CreatedAt, - }) + return q.getProvisionerJobsByIDsWithQueuePositionLocked(ctx, ids) +} + +func (q *FakeQuerier) GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner(ctx context.Context, arg database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) ([]database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err } - // queue_position AS ( - // SELECT - // id, - // ROW_NUMBER() OVER (ORDER BY created_at ASC) AS queue_position - // FROM - // pending_jobs - // ), - slices.SortFunc(pendingJobs, func(a, b pendingJobRow) int { - c := a.CreatedAt.Compare(b.CreatedAt) - return c - }) + q.mutex.RLock() + defer q.mutex.RUnlock() - queuePosition := make(map[uuid.UUID]int64) - for idx, pj := range pendingJobs { - queuePosition[pj.ID] = int64(idx + 1) + /* + -- name: GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner :many + WITH pending_jobs AS ( + SELECT + id, created_at + FROM + provisioner_jobs + WHERE + started_at IS NULL + AND + canceled_at IS NULL + AND + completed_at IS NULL + AND + error IS NULL + ), + queue_position AS ( + SELECT + id, + ROW_NUMBER() OVER (ORDER BY created_at ASC) AS queue_position + FROM + pending_jobs + ), + queue_size AS ( + SELECT COUNT(*) AS count FROM pending_jobs + ) + SELECT + sqlc.embed(pj), + COALESCE(qp.queue_position, 0) AS queue_position, + COALESCE(qs.count, 0) AS queue_size, + array_agg(DISTINCT pd.id) FILTER (WHERE pd.id IS NOT NULL)::uuid[] AS available_workers + FROM + provisioner_jobs pj + LEFT JOIN + queue_position qp ON qp.id = pj.id + LEFT JOIN + queue_size qs ON TRUE + LEFT JOIN + provisioner_daemons pd ON ( + -- See AcquireProvisionerJob. + pj.started_at IS NULL + AND pj.organization_id = pd.organization_id + AND pj.provisioner = ANY(pd.provisioners) + AND provisioner_tagset_contains(pd.tags, pj.tags) + ) + WHERE + (sqlc.narg('organization_id')::uuid IS NULL OR pj.organization_id = @organization_id) + AND (COALESCE(array_length(@status::provisioner_job_status[], 1), 1) > 0 OR pj.job_status = ANY(@status::provisioner_job_status[])) + GROUP BY + pj.id, + qp.queue_position, + qs.count + ORDER BY + pj.created_at DESC + LIMIT + sqlc.narg('limit')::int; + */ + rowsWithQueuePosition, err := q.getProvisionerJobsByIDsWithQueuePositionLocked(ctx, nil) + if err != nil { + return nil, err } - // queue_size AS ( - // SELECT COUNT(*) AS count FROM pending_jobs - // ), - queueSize := len(pendingJobs) + var rows []database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow + for _, rowQP := range rowsWithQueuePosition { + job := rowQP.ProvisionerJob - // SELECT - // sqlc.embed(pj), - // COALESCE(qp.queue_position, 0) AS queue_position, - // COALESCE(qs.count, 0) AS queue_size - // FROM - // provisioner_jobs pj - // LEFT JOIN - // queue_position qp ON pj.id = qp.id - // LEFT JOIN - // queue_size qs ON TRUE - // WHERE - // pj.id IN (...) - jobs := make([]database.GetProvisionerJobsByIDsWithQueuePositionRow, 0) - for _, job := range q.provisionerJobs { - if !slices.Contains(ids, job.ID) { + if arg.OrganizationID.Valid && job.OrganizationID != arg.OrganizationID.UUID { continue } - // clone the Tags before appending, since maps are reference types and - // we don't want the caller to be able to mutate the map we have inside - // dbmem! - job.Tags = maps.Clone(job.Tags) - job := database.GetProvisionerJobsByIDsWithQueuePositionRow{ - // sqlc.embed(pj), - ProvisionerJob: job, - // COALESCE(qp.queue_position, 0) AS queue_position, - QueuePosition: queuePosition[job.ID], - // COALESCE(qs.count, 0) AS queue_size - QueueSize: int64(queueSize), + if len(arg.Status) > 0 && !slices.Contains(arg.Status, job.JobStatus) { + continue } - jobs = append(jobs, job) + + row := database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow{ + ProvisionerJob: rowQP.ProvisionerJob, + QueuePosition: rowQP.QueuePosition, + QueueSize: rowQP.QueueSize, + } + if row.QueuePosition > 0 { + var availableWorkers []database.ProvisionerDaemon + for _, daemon := range q.provisionerDaemons { + if daemon.OrganizationID == job.OrganizationID && + slices.Contains(daemon.Provisioners, job.Provisioner) && + tagsSubset(job.Tags, daemon.Tags) { + availableWorkers = append(availableWorkers, daemon) + } + } + slices.SortFunc(availableWorkers, func(a, b database.ProvisionerDaemon) int { + return a.CreatedAt.Compare(b.CreatedAt) + }) + for _, worker := range availableWorkers { + row.AvailableWorkers = append(row.AvailableWorkers, worker.ID) + } + } + rows = append(rows, row) } - return jobs, nil + slices.SortFunc(rows, func(a, b database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow) int { + return b.ProvisionerJob.CreatedAt.Compare(a.ProvisionerJob.CreatedAt) + }) + if arg.Limit.Valid && arg.Limit.Int32 > 0 && len(rows) > int(arg.Limit.Int32) { + rows = rows[:arg.Limit.Int32] + } + return rows, nil } func (q *FakeQuerier) GetProvisionerJobsCreatedAfter(_ context.Context, after time.Time) ([]database.ProvisionerJob, error) { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index f486224e41bb4..ba8a1f9cdc8a6 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1022,6 +1022,13 @@ func (m queryMetricsStore) GetProvisionerJobsByIDsWithQueuePosition(ctx context. return r0, r1 } +func (m queryMetricsStore) GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner(ctx context.Context, arg database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) ([]database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow, error) { + start := time.Now() + r0, r1 := m.s.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner(ctx, arg) + m.queryLatencies.WithLabelValues("GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]database.ProvisionerJob, error) { start := time.Now() jobs, err := m.s.GetProvisionerJobsCreatedAfter(ctx, createdAt) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 39e85f489ded1..b7460f1adc69c 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2105,6 +2105,21 @@ func (mr *MockStoreMockRecorder) GetProvisionerJobsByIDsWithQueuePosition(arg0, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProvisionerJobsByIDsWithQueuePosition", reflect.TypeOf((*MockStore)(nil).GetProvisionerJobsByIDsWithQueuePosition), arg0, arg1) } +// GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner mocks base method. +func (m *MockStore) GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner(arg0 context.Context, arg1 database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) ([]database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner", arg0, arg1) + ret0, _ := ret[0].([]database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner indicates an expected call of GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner. +func (mr *MockStoreMockRecorder) GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner", reflect.TypeOf((*MockStore)(nil).GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner), arg0, arg1) +} + // GetProvisionerJobsCreatedAfter mocks base method. func (m *MockStore) GetProvisionerJobsCreatedAfter(arg0 context.Context, arg1 time.Time) ([]database.ProvisionerJob, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 0836a948d7ad0..63e03ccb27f40 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -458,6 +458,18 @@ func (g Group) IsEveryone() bool { return g.ID == g.OrganizationID } +func (p ProvisionerJob) RBACObject() rbac.Object { + switch p.Type { + // Only acceptable for known job types at this time because template + // admins may not be allowed to view new types. + case ProvisionerJobTypeTemplateVersionImport, ProvisionerJobTypeTemplateVersionDryRun, ProvisionerJobTypeWorkspaceBuild: + return rbac.ResourceProvisionerJobs.InOrg(p.OrganizationID) + + default: + panic("developer error: unknown provisioner job type " + string(p.Type)) + } +} + func (p ProvisionerJob) Finished() bool { return p.CanceledAt.Valid || p.CompletedAt.Valid } @@ -511,3 +523,7 @@ func (k CryptoKey) CanVerify(now time.Time) bool { isBeforeDeletion := !k.DeletesAt.Valid || now.Before(k.DeletesAt.Time) return hasSecret && isBeforeDeletion } + +func (r GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow) RBACObject() rbac.Object { + return r.ProvisionerJob.RBACObject() +} diff --git a/coderd/database/querier.go b/coderd/database/querier.go index d306082127b18..1b7d299ba7975 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -208,6 +208,7 @@ type sqlcQuerier interface { GetProvisionerJobTimingsByJobID(ctx context.Context, jobID uuid.UUID) ([]ProvisionerJobTiming, error) GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUID) ([]ProvisionerJob, error) GetProvisionerJobsByIDsWithQueuePosition(ctx context.Context, ids []uuid.UUID) ([]GetProvisionerJobsByIDsWithQueuePositionRow, error) + GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner(ctx context.Context, arg GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) ([]GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow, error) GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]ProvisionerJob, error) GetProvisionerKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (ProvisionerKey, error) GetProvisionerKeyByID(ctx context.Context, id uuid.UUID) (ProvisionerKey, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 42dea8928e71e..0a80c6651463c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6234,6 +6234,128 @@ func (q *sqlQuerier) GetProvisionerJobsByIDsWithQueuePosition(ctx context.Contex return items, nil } +const getProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner = `-- name: GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner :many +WITH pending_jobs AS ( + SELECT + id, created_at + FROM + provisioner_jobs + WHERE + started_at IS NULL + AND + canceled_at IS NULL + AND + completed_at IS NULL + AND + error IS NULL +), +queue_position AS ( + SELECT + id, + ROW_NUMBER() OVER (ORDER BY created_at ASC) AS queue_position + FROM + pending_jobs +), +queue_size AS ( + SELECT COUNT(*) AS count FROM pending_jobs +) +SELECT + pj.id, pj.created_at, pj.updated_at, pj.started_at, pj.canceled_at, pj.completed_at, pj.error, pj.organization_id, pj.initiator_id, pj.provisioner, pj.storage_method, pj.type, pj.input, pj.worker_id, pj.file_id, pj.tags, pj.error_code, pj.trace_metadata, pj.job_status, + COALESCE(qp.queue_position, 0) AS queue_position, + COALESCE(qs.count, 0) AS queue_size, + -- Use subquery to utilize ORDER BY in array_agg since it cannot be + -- combined with FILTER. + ( + SELECT + -- Order for stable output. + array_agg(pd.id ORDER BY pd.created_at ASC)::uuid[] + FROM + provisioner_daemons pd + WHERE + -- See AcquireProvisionerJob. + pj.started_at IS NULL + AND pj.organization_id = pd.organization_id + AND pj.provisioner = ANY(pd.provisioners) + AND provisioner_tagset_contains(pd.tags, pj.tags) + ) AS available_workers +FROM + provisioner_jobs pj +LEFT JOIN + queue_position qp ON qp.id = pj.id +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[])) +GROUP BY + pj.id, + qp.queue_position, + qs.count +ORDER BY + pj.created_at DESC +LIMIT + $3::int +` + +type GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams struct { + OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"` + Status []ProvisionerJobStatus `db:"status" json:"status"` + Limit sql.NullInt32 `db:"limit" json:"limit"` +} + +type GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow struct { + ProvisionerJob ProvisionerJob `db:"provisioner_job" json:"provisioner_job"` + QueuePosition int64 `db:"queue_position" json:"queue_position"` + QueueSize int64 `db:"queue_size" json:"queue_size"` + AvailableWorkers []uuid.UUID `db:"available_workers" json:"available_workers"` +} + +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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow + for rows.Next() { + var i GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow + if err := rows.Scan( + &i.ProvisionerJob.ID, + &i.ProvisionerJob.CreatedAt, + &i.ProvisionerJob.UpdatedAt, + &i.ProvisionerJob.StartedAt, + &i.ProvisionerJob.CanceledAt, + &i.ProvisionerJob.CompletedAt, + &i.ProvisionerJob.Error, + &i.ProvisionerJob.OrganizationID, + &i.ProvisionerJob.InitiatorID, + &i.ProvisionerJob.Provisioner, + &i.ProvisionerJob.StorageMethod, + &i.ProvisionerJob.Type, + &i.ProvisionerJob.Input, + &i.ProvisionerJob.WorkerID, + &i.ProvisionerJob.FileID, + &i.ProvisionerJob.Tags, + &i.ProvisionerJob.ErrorCode, + &i.ProvisionerJob.TraceMetadata, + &i.ProvisionerJob.JobStatus, + &i.QueuePosition, + &i.QueueSize, + pq.Array(&i.AvailableWorkers), + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getProvisionerJobsCreatedAfter = `-- name: GetProvisionerJobsCreatedAfter :many SELECT id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, type, input, worker_id, file_id, tags, error_code, trace_metadata, job_status FROM provisioner_jobs WHERE created_at > $1 ` diff --git a/coderd/database/queries/provisionerjobs.sql b/coderd/database/queries/provisionerjobs.sql index ac246d4e2ef68..371c1c4fc76e9 100644 --- a/coderd/database/queries/provisionerjobs.sql +++ b/coderd/database/queries/provisionerjobs.sql @@ -87,6 +87,68 @@ LEFT JOIN WHERE pj.id = ANY(@ids :: uuid [ ]); +-- name: GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner :many +WITH pending_jobs AS ( + SELECT + id, created_at + FROM + provisioner_jobs + WHERE + started_at IS NULL + AND + canceled_at IS NULL + AND + completed_at IS NULL + AND + error IS NULL +), +queue_position AS ( + SELECT + id, + ROW_NUMBER() OVER (ORDER BY created_at ASC) AS queue_position + FROM + pending_jobs +), +queue_size AS ( + SELECT COUNT(*) AS count FROM pending_jobs +) +SELECT + sqlc.embed(pj), + COALESCE(qp.queue_position, 0) AS queue_position, + COALESCE(qs.count, 0) AS queue_size, + -- Use subquery to utilize ORDER BY in array_agg since it cannot be + -- combined with FILTER. + ( + SELECT + -- Order for stable output. + array_agg(pd.id ORDER BY pd.created_at ASC)::uuid[] + FROM + provisioner_daemons pd + WHERE + -- See AcquireProvisionerJob. + pj.started_at IS NULL + AND pj.organization_id = pd.organization_id + AND pj.provisioner = ANY(pd.provisioners) + AND provisioner_tagset_contains(pd.tags, pj.tags) + ) AS available_workers +FROM + provisioner_jobs pj +LEFT JOIN + queue_position qp ON qp.id = pj.id +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(@status::provisioner_job_status[], 1), 0) = 0 OR pj.job_status = ANY(@status::provisioner_job_status[])) +GROUP BY + pj.id, + qp.queue_position, + qs.count +ORDER BY + pj.created_at DESC +LIMIT + sqlc.narg('limit')::int; + -- name: GetProvisionerJobsCreatedAfter :many SELECT * FROM provisioner_jobs WHERE created_at > $1; diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index 4269f9a8dd57f..f61ecb1146743 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -19,12 +19,78 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/wsjson" "github.com/coder/coder/v2/provisionersdk" "github.com/coder/websocket" ) +// @Summary Get provisioner jobs +// @ID get-provisioner-jobs +// @Security CoderSessionToken +// @Produce json +// @Tags Organizations +// @Param organization path string true "Organization ID" format(uuid) +// @Param limit query int false "Page limit" +// @Param status query codersdk.ProvisionerJobStatus false "Filter results by status" enums(pending,running,succeeded,canceling,canceled,failed) +// @Success 200 {array} codersdk.ProvisionerJob +// @Router /organizations/{organization}/provisionerjobs [get] +func (api *API) provisionerJobs(rw http.ResponseWriter, r *http.Request) { + 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 + } + + qp := r.URL.Query() + p := httpapi.NewQueryParamParser() + limit := p.PositiveInt32(qp, 0, "limit") + status := p.Strings(qp, nil, "status") + p.ErrorExcessParams(qp) + if len(p.Errors) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid query parameters.", + Validations: p.Errors, + }) + return + } + + 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}, + }) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching provisioner jobs.", + Detail: err.Error(), + }) + return + } + + 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 + })) +} + // Returns provisioner logs based on query parameters. // The intended usage for a client to stream all logs (with JS API): // GET /logs @@ -236,14 +302,16 @@ func convertProvisionerJobLog(provisionerJobLog database.ProvisionerJobLog) code func convertProvisionerJob(pj database.GetProvisionerJobsByIDsWithQueuePositionRow) codersdk.ProvisionerJob { provisionerJob := pj.ProvisionerJob job := codersdk.ProvisionerJob{ - ID: provisionerJob.ID, - CreatedAt: provisionerJob.CreatedAt, - Error: provisionerJob.Error.String, - ErrorCode: codersdk.JobErrorCode(provisionerJob.ErrorCode.String), - FileID: provisionerJob.FileID, - Tags: provisionerJob.Tags, - QueuePosition: int(pj.QueuePosition), - QueueSize: int(pj.QueueSize), + ID: provisionerJob.ID, + OrganizationID: provisionerJob.OrganizationID, + CreatedAt: provisionerJob.CreatedAt, + Type: codersdk.ProvisionerJobType(provisionerJob.Type), + Error: provisionerJob.Error.String, + ErrorCode: codersdk.JobErrorCode(provisionerJob.ErrorCode.String), + FileID: provisionerJob.FileID, + Tags: provisionerJob.Tags, + QueuePosition: int(pj.QueuePosition), + QueueSize: int(pj.QueueSize), } // Applying values optional to the struct. if provisionerJob.StartedAt.Valid { @@ -260,6 +328,13 @@ func convertProvisionerJob(pj database.GetProvisionerJobsByIDsWithQueuePositionR } job.Status = codersdk.ProvisionerJobStatus(pj.ProvisionerJob.JobStatus) + // Only unmarshal input if it exists, this should only be zero in testing. + if len(provisionerJob.Input) > 0 { + if err := json.Unmarshal(provisionerJob.Input, &job.Input); err != nil { + job.Input.Error = xerrors.Errorf("decode input %s: %w", provisionerJob.Input, err).Error() + } + } + return job } diff --git a/coderd/provisionerjobs_test.go b/coderd/provisionerjobs_test.go index cf17d6495cfed..f7ce721f9d048 100644 --- a/coderd/provisionerjobs_test.go +++ b/coderd/provisionerjobs_test.go @@ -2,16 +2,107 @@ package coderd_test import ( "context" + "database/sql" + "encoding/json" "testing" + "time" + "github.com/google/uuid" "github.com/stretchr/testify/require" "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/database/dbtime" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" ) +func TestProvisionerJobs(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + Database: db, + Pubsub: ps, + }) + owner := coderdtest.CreateFirstUser(t, client) + templateAdminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(owner.OrganizationID)) + memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + time.Sleep(1500 * time.Millisecond) // Ensure the workspace build job has a different timestamp for sorting. + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Create a pending job. + w := dbgen.Workspace(t, db, database.WorkspaceTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + TemplateID: template.ID, + }) + wbID := uuid.New() + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + OrganizationID: w.OrganizationID, + StartedAt: sql.NullTime{Time: dbtime.Now(), Valid: true}, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Input: json.RawMessage(`{"workspace_build_id":"` + wbID.String() + `"}`), + }) + dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + ID: wbID, + JobID: job.ID, + WorkspaceID: w.ID, + TemplateVersionID: version.ID, + }) + + t.Run("All", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + jobs, err := templateAdminClient.OrganizationProvisionerJobs(ctx, owner.OrganizationID, nil) + require.NoError(t, err) + require.Len(t, jobs, 3) + }) + + t.Run("Status", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + jobs, err := templateAdminClient.OrganizationProvisionerJobs(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerJobsOptions{ + Status: []codersdk.ProvisionerJobStatus{codersdk.ProvisionerJobRunning}, + }) + require.NoError(t, err) + require.Len(t, jobs, 1) + }) + + t.Run("Limit", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + jobs, err := templateAdminClient.OrganizationProvisionerJobs(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerJobsOptions{ + Limit: 1, + }) + require.NoError(t, err) + require.Len(t, jobs, 1) + }) + + // For now, this is not allowed even though the member has created a + // workspace. Once member-level permissions for jobs are supported + // by RBAC, this test should be updated. + t.Run("MemberDenied", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + jobs, err := memberClient.OrganizationProvisionerJobs(ctx, owner.OrganizationID, nil) + require.Error(t, err) + require.Len(t, jobs, 0) + }) +} + func TestProvisionerJobLogs(t *testing.T) { t.Parallel() t.Run("StreamAfterComplete", func(t *testing.T) { diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index d1ebd1c8f56a1..e3678c3c5454a 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -214,6 +214,13 @@ var ( Type: "provisioner_daemon", } + // ResourceProvisionerJobs + // Valid Actions + // - "ActionRead" :: read provisioner jobs + ResourceProvisionerJobs = Object{ + Type: "provisioner_jobs", + } + // ResourceProvisionerKeys // Valid Actions // - "ActionCreate" :: create a provisioner key @@ -337,6 +344,7 @@ func AllResources() []Objecter { ResourceOrganization, ResourceOrganizationMember, ResourceProvisionerDaemon, + ResourceProvisionerJobs, ResourceProvisionerKeys, ResourceReplicas, ResourceSystem, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 2691eed9fe0a9..a9c53f0c2ad80 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -169,6 +169,11 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionDelete: actDef("delete a provisioner daemon"), }, }, + "provisioner_jobs": { + Actions: map[Action]ActionDefinition{ + ActionRead: actDef("read provisioner jobs"), + }, + }, "provisioner_keys": { Actions: map[Action]ActionDefinition{ ActionCreate: actDef("create a provisioner key"), diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index a57bd071a8052..f4c2d38ee93b1 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -483,6 +483,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceOrganizationMember.Type: {policy.ActionRead}, ResourceGroup.Type: {policy.ActionRead}, ResourceGroupMember.Type: {policy.ActionRead}, + ResourceProvisionerJobs.Type: {policy.ActionRead}, }), }, User: []Permission{}, diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 0172439829063..a213846be1fc0 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -553,6 +553,15 @@ func TestRolePermissions(t *testing.T) { false: {setOtherOrg, memberMe, orgMemberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, }, }, + { + Name: "ProvisionerJobs", + Actions: []policy.Action{policy.ActionRead}, + Resource: rbac.ResourceProvisionerJobs.InOrg(orgID), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, orgTemplateAdmin, orgAdmin}, + false: {setOtherOrg, memberMe, orgMemberMe, templateAdmin, userAdmin, orgUserAdmin, orgAuditor}, + }, + }, { Name: "System", Actions: crud, diff --git a/coderd/util/slice/slice.go b/coderd/util/slice/slice.go index 7317a801a089f..2a62e23592d84 100644 --- a/coderd/util/slice/slice.go +++ b/coderd/util/slice/slice.go @@ -13,6 +13,17 @@ func ToStrings[T ~string](a []T) []string { return tmp } +func StringEnums[E ~string](a []string) []E { + if a == nil { + return nil + } + tmp := make([]E, 0, len(a)) + for _, v := range a { + tmp = append(tmp, E(v)) + } + return tmp +} + // Omit creates a new slice with the arguments omitted from the list. func Omit[T comparable](a []T, omits ...T) []T { tmp := make([]T, 0, len(a)) diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 494f6c86887c4..f25135598180c 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "strconv" "strings" "time" @@ -343,6 +344,47 @@ func (c *Client) OrganizationProvisionerDaemons(ctx context.Context, organizatio return daemons, json.NewDecoder(res.Body).Decode(&daemons) } +type OrganizationProvisionerJobsOptions struct { + Limit int + Status []ProvisionerJobStatus +} + +func (c *Client) OrganizationProvisionerJobs(ctx context.Context, organizationID uuid.UUID, opts *OrganizationProvisionerJobsOptions) ([]ProvisionerJob, error) { + qp := url.Values{} + if opts != nil { + if opts.Limit > 0 { + qp.Add("limit", strconv.Itoa(opts.Limit)) + } + if len(opts.Status) > 0 { + qp.Add("status", joinSlice(opts.Status)) + } + } + + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/organizations/%s/provisionerjobs?%s", organizationID.String(), qp.Encode()), + nil, + ) + if err != nil { + return nil, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + var jobs []ProvisionerJob + return jobs, json.NewDecoder(res.Body).Decode(&jobs) +} + +func joinSlice[T ~string](s []T) string { + var ss []string + for _, v := range s { + ss = append(ss, string(v)) + } + return strings.Join(ss, ",") +} + // CreateTemplateVersion processes source-code and optionally associates the version with a template. // Executing without a template is useful for validating source-code. func (c *Client) CreateTemplateVersion(ctx context.Context, organizationID uuid.UUID, req CreateTemplateVersionRequest) (TemplateVersion, error) { diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index 675c31408243a..808cc14298cce 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -112,6 +112,34 @@ const ( ProvisionerJobUnknown ProvisionerJobStatus = "unknown" ) +func ProvisionerJobStatusEnums() []ProvisionerJobStatus { + return []ProvisionerJobStatus{ + ProvisionerJobPending, + ProvisionerJobRunning, + ProvisionerJobSucceeded, + ProvisionerJobCanceling, + ProvisionerJobCanceled, + ProvisionerJobFailed, + ProvisionerJobUnknown, + } +} + +// ProvisionerJobInput represents the input for the job. +type ProvisionerJobInput struct { + TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty" format:"uuid" table:"template version id"` + WorkspaceBuildID *uuid.UUID `json:"workspace_build_id,omitempty" format:"uuid" table:"workspace build id"` + Error string `json:"error,omitempty" table:"-"` +} + +// ProvisionerJobType represents the type of job. +type ProvisionerJobType string + +const ( + ProvisionerJobTypeTemplateVersionImport ProvisionerJobType = "template_version_import" + ProvisionerJobTypeWorkspaceBuild ProvisionerJobType = "workspace_build" + ProvisionerJobTypeTemplateVersionDryRun ProvisionerJobType = "template_version_dry_run" +) + // JobErrorCode defines the error code returned by job runner. type JobErrorCode string @@ -127,19 +155,23 @@ func JobIsMissingParameterErrorCode(code JobErrorCode) bool { // ProvisionerJob describes the job executed by the provisioning daemon. type ProvisionerJob struct { - ID uuid.UUID `json:"id" format:"uuid"` - CreatedAt time.Time `json:"created_at" format:"date-time"` - StartedAt *time.Time `json:"started_at,omitempty" format:"date-time"` - CompletedAt *time.Time `json:"completed_at,omitempty" format:"date-time"` - CanceledAt *time.Time `json:"canceled_at,omitempty" format:"date-time"` - Error string `json:"error,omitempty"` - ErrorCode JobErrorCode `json:"error_code,omitempty" enums:"REQUIRED_TEMPLATE_VARIABLES"` - Status ProvisionerJobStatus `json:"status" enums:"pending,running,succeeded,canceling,canceled,failed"` - WorkerID *uuid.UUID `json:"worker_id,omitempty" format:"uuid"` - FileID uuid.UUID `json:"file_id" format:"uuid"` - Tags map[string]string `json:"tags"` - QueuePosition int `json:"queue_position"` - QueueSize int `json:"queue_size"` + ID uuid.UUID `json:"id" format:"uuid" table:"id"` + CreatedAt time.Time `json:"created_at" format:"date-time" table:"created at,default_sort"` + StartedAt *time.Time `json:"started_at,omitempty" format:"date-time" table:"started at"` + CompletedAt *time.Time `json:"completed_at,omitempty" format:"date-time" table:"completed at"` + CanceledAt *time.Time `json:"canceled_at,omitempty" format:"date-time" table:"canceled at"` + Error string `json:"error,omitempty" table:"error"` + ErrorCode JobErrorCode `json:"error_code,omitempty" enums:"REQUIRED_TEMPLATE_VARIABLES" table:"error code"` + Status ProvisionerJobStatus `json:"status" enums:"pending,running,succeeded,canceling,canceled,failed" table:"status"` + WorkerID *uuid.UUID `json:"worker_id,omitempty" format:"uuid" table:"worker id"` + FileID uuid.UUID `json:"file_id" format:"uuid" table:"file id"` + Tags map[string]string `json:"tags" table:"tags"` + QueuePosition int `json:"queue_position" table:"queue position"` + QueueSize int `json:"queue_size" table:"queue size"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid" table:"organization id"` + Input ProvisionerJobInput `json:"input" table:"input,recursive_inline"` + Type ProvisionerJobType `json:"type" table:"type"` + AvailableWorkers []uuid.UUID `json:"available_workers,omitempty" format:"uuid" table:"available workers"` } // ProvisionerJobLog represents the provisioner log entry annotated with source and level. diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index ced2568719578..d42fd1c73ab70 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -27,6 +27,7 @@ const ( ResourceOrganization RBACResource = "organization" ResourceOrganizationMember RBACResource = "organization_member" ResourceProvisionerDaemon RBACResource = "provisioner_daemon" + ResourceProvisionerJobs RBACResource = "provisioner_jobs" ResourceProvisionerKeys RBACResource = "provisioner_keys" ResourceReplicas RBACResource = "replicas" ResourceSystem RBACResource = "system" @@ -82,6 +83,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceOrganization: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceOrganizationMember: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceProvisionerDaemon: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceProvisionerJobs: {ActionRead}, ResourceProvisionerKeys: {ActionCreate, ActionDelete, ActionRead}, ResourceReplicas: {ActionRead}, ResourceSystem: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, diff --git a/docs/reference/api/builds.md b/docs/reference/api/builds.md index d9bea14e060c1..e0cfbc372ee77 100644 --- a/docs/reference/api/builds.md +++ b/docs/reference/api/builds.md @@ -35,6 +35,9 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", "job": { + "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", @@ -42,6 +45,12 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "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", @@ -50,6 +59,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "property1": "string", "property2": "string" }, + "type": "template_version_import", "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, "matched_provisioners": { @@ -230,6 +240,9 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", "job": { + "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", @@ -237,6 +250,12 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ "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", @@ -245,6 +264,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ "property1": "string", "property2": "string" }, + "type": "template_version_import", "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, "matched_provisioners": { @@ -868,6 +888,9 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", "job": { + "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", @@ -875,6 +898,12 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta "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", @@ -883,6 +912,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta "property1": "string", "property2": "string" }, + "type": "template_version_import", "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, "matched_provisioners": { @@ -1136,6 +1166,9 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", "job": { + "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", @@ -1143,6 +1176,12 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "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", @@ -1151,6 +1190,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "property1": "string", "property2": "string" }, + "type": "template_version_import", "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, "matched_provisioners": { @@ -1312,6 +1352,7 @@ Status Code **200** | `» initiator_id` | string(uuid) | false | | | | `» initiator_name` | string | false | | | | `» job` | [codersdk.ProvisionerJob](schemas.md#codersdkprovisionerjob) | false | | | +| `»» available_workers` | array | false | | | | `»» canceled_at` | string(date-time) | false | | | | `»» completed_at` | string(date-time) | false | | | | `»» created_at` | string(date-time) | false | | | @@ -1319,12 +1360,18 @@ Status Code **200** | `»» error_code` | [codersdk.JobErrorCode](schemas.md#codersdkjoberrorcode) | false | | | | `»» file_id` | string(uuid) | false | | | | `»» id` | string(uuid) | false | | | +| `»» input` | [codersdk.ProvisionerJobInput](schemas.md#codersdkprovisionerjobinput) | false | | | +| `»»» error` | string | false | | | +| `»»» template_version_id` | string(uuid) | false | | | +| `»»» workspace_build_id` | string(uuid) | false | | | +| `»» organization_id` | string(uuid) | false | | | | `»» queue_position` | integer | false | | | | `»» queue_size` | integer | false | | | | `»» started_at` | string(date-time) | false | | | | `»» status` | [codersdk.ProvisionerJobStatus](schemas.md#codersdkprovisionerjobstatus) | false | | | | `»» tags` | object | false | | | | `»»» [any property]` | string | false | | | +| `»» type` | [codersdk.ProvisionerJobType](schemas.md#codersdkprovisionerjobtype) | false | | | | `»» worker_id` | string(uuid) | false | | | | `» matched_provisioners` | [codersdk.MatchedProvisioners](schemas.md#codersdkmatchedprovisioners) | false | | | | `»» available` | integer | false | | Available is the number of provisioner daemons that are available to take jobs. This may be less than the count if some provisioners are busy or have been stopped. | @@ -1439,6 +1486,9 @@ Status Code **200** | `status` | `canceling` | | `status` | `canceled` | | `status` | `failed` | +| `type` | `template_version_import` | +| `type` | `workspace_build` | +| `type` | `template_version_dry_run` | | `reason` | `initiator` | | `reason` | `autostart` | | `reason` | `autostop` | @@ -1542,6 +1592,9 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", "job": { + "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", @@ -1549,6 +1602,12 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "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", @@ -1557,6 +1616,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "property1": "string", "property2": "string" }, + "type": "template_version_import", "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, "matched_provisioners": { diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index 1d0c7a3c3450a..efe76a2eda58e 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -202,6 +202,7 @@ Status Code **200** | `resource_type` | `organization` | | `resource_type` | `organization_member` | | `resource_type` | `provisioner_daemon` | +| `resource_type` | `provisioner_jobs` | | `resource_type` | `provisioner_keys` | | `resource_type` | `replicas` | | `resource_type` | `system` | @@ -363,6 +364,7 @@ Status Code **200** | `resource_type` | `organization` | | `resource_type` | `organization_member` | | `resource_type` | `provisioner_daemon` | +| `resource_type` | `provisioner_jobs` | | `resource_type` | `provisioner_keys` | | `resource_type` | `replicas` | | `resource_type` | `system` | @@ -524,6 +526,7 @@ Status Code **200** | `resource_type` | `organization` | | `resource_type` | `organization_member` | | `resource_type` | `provisioner_daemon` | +| `resource_type` | `provisioner_jobs` | | `resource_type` | `provisioner_keys` | | `resource_type` | `replicas` | | `resource_type` | `system` | @@ -654,6 +657,7 @@ Status Code **200** | `resource_type` | `organization` | | `resource_type` | `organization_member` | | `resource_type` | `provisioner_daemon` | +| `resource_type` | `provisioner_jobs` | | `resource_type` | `provisioner_keys` | | `resource_type` | `replicas` | | `resource_type` | `system` | @@ -916,6 +920,7 @@ Status Code **200** | `resource_type` | `organization` | | `resource_type` | `organization_member` | | `resource_type` | `provisioner_daemon` | +| `resource_type` | `provisioner_jobs` | | `resource_type` | `provisioner_keys` | | `resource_type` | `replicas` | | `resource_type` | `system` | diff --git a/docs/reference/api/organizations.md b/docs/reference/api/organizations.md index 1c1e79dbca7af..fc58bef11342d 100644 --- a/docs/reference/api/organizations.md +++ b/docs/reference/api/organizations.md @@ -343,3 +343,131 @@ curl -X PATCH http://coder-server:8080/api/v2/organizations/{organization} \ | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Organization](schemas.md#codersdkorganization) | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get provisioner jobs + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/provisionerjobs \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /organizations/{organization}/provisionerjobs` + +### Parameters + +| Name | In | Type | Required | Description | +|----------------|-------|--------------|----------|--------------------------| +| `organization` | path | string(uuid) | true | Organization ID | +| `limit` | query | integer | false | Page limit | +| `status` | query | string | false | Filter results by status | + +#### Enumerated Values + +| Parameter | Value | +|-----------|-------------| +| `status` | `pending` | +| `status` | `running` | +| `status` | `succeeded` | +| `status` | `canceling` | +| `status` | `canceled` | +| `status` | `failed` | +| `status` | `unknown` | +| `status` | `pending` | +| `status` | `running` | +| `status` | `succeeded` | +| `status` | `canceling` | +| `status` | `canceled` | +| `status` | `failed` | + +### 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 | array of [codersdk.ProvisionerJob](schemas.md#codersdkprovisionerjob) | + +