mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(coderd): add endpoint to list provisioner jobs (#16029)
Closes #15190 Updates #15084
This commit is contained in:
Generated
+113
@@ -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",
|
||||
|
||||
Generated
+109
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3258,6 +3258,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() {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -249,12 +250,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)),
|
||||
@@ -557,13 +564,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{
|
||||
@@ -577,7 +586,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")
|
||||
@@ -589,9 +598,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.
|
||||
@@ -599,7 +608,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,
|
||||
@@ -609,7 +618,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,
|
||||
@@ -618,7 +627,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
|
||||
}
|
||||
|
||||
+205
-83
@@ -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()
|
||||
|
||||
/*
|
||||
-- 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
|
||||
}
|
||||
|
||||
var rows []database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow
|
||||
for _, rowQP := range rowsWithQueuePosition {
|
||||
job := rowQP.ProvisionerJob
|
||||
|
||||
if arg.OrganizationID.Valid && job.OrganizationID != arg.OrganizationID.UUID {
|
||||
continue
|
||||
}
|
||||
if len(arg.Status) > 0 && !slices.Contains(arg.Status, job.JobStatus) {
|
||||
continue
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
slices.SortFunc(rows, func(a, b database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow) int {
|
||||
return b.ProvisionerJob.CreatedAt.Compare(a.ProvisionerJob.CreatedAt)
|
||||
})
|
||||
|
||||
queuePosition := make(map[uuid.UUID]int64)
|
||||
for idx, pj := range pendingJobs {
|
||||
queuePosition[pj.ID] = int64(idx + 1)
|
||||
if arg.Limit.Valid && arg.Limit.Int32 > 0 && len(rows) > int(arg.Limit.Int32) {
|
||||
rows = rows[:arg.Limit.Int32]
|
||||
}
|
||||
|
||||
// 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 !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
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetProvisionerJobsCreatedAfter(_ context.Context, after time.Time) ([]database.ProvisionerJob, error) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -6222,6 +6222,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
|
||||
`
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -338,6 +345,7 @@ func AllResources() []Objecter {
|
||||
ResourceOrganization,
|
||||
ResourceOrganizationMember,
|
||||
ResourceProvisionerDaemon,
|
||||
ResourceProvisionerJobs,
|
||||
ResourceProvisionerKeys,
|
||||
ResourceReplicas,
|
||||
ResourceSystem,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -564,6 +564,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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user