feat(coderd): add endpoint to list provisioner jobs (#16029)

Closes #15190
Updates #15084
This commit is contained in:
Mathias Fredriksson
2025-01-20 11:18:53 +02:00
committed by GitHub
parent 0e8ef09415
commit 3864c7e3b0
33 changed files with 1599 additions and 209 deletions
+113
View File
@@ -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",
+109
View File
@@ -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",
+3
View File
@@ -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) {
+12 -5
View File
@@ -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
+32
View File
@@ -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() {
+20 -11
View File
@@ -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
View File
@@ -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)
+15
View File
@@ -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()
+16
View File
@@ -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()
}
+1
View File
@@ -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)
+122
View File
@@ -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;
+83 -8
View File
@@ -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
}
+91
View File
@@ -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) {
+8
View File
@@ -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,
+5
View File
@@ -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"),
+1
View File
@@ -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{},
+9
View File
@@ -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,
+11
View File
@@ -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))